Archive commit

This commit is contained in:
Erik McClure
2019-08-28 21:15:02 -07:00
commit 735845746e
1435 changed files with 140724 additions and 0 deletions
+249
View File
@@ -0,0 +1,249 @@
import * as moment from 'moment';
import { Types } from 'mongoose';
import { uniq, truncate } from 'lodash';
import { AccountState, AccountFlags, AuthBase } from '../common/adminInterfaces';
import { Profile, ModInfo, AccountDataFlags } from '../common/interfaces';
import { ACCOUNT_NAME_MAX_LENGTH, DAY } from '../common/constants';
import { fromNow, includes, hasFlag } from '../common/utils';
import {
isAdmin, getCharacterLimit as getCharacterLimitInternal, getSupporterInviteLimit as getSupporterInviteLimitInternal
} from '../common/accountUtils';
import { cleanName } from '../client/clientUtils';
import {
IAccount, IAuth, Account, ID, characterCount as getCharacterCount, findAccount, queryAccount, updateAccount,
FriendRequest, IFriendRequest
} from './db';
import { assignAuth } from './authUtils';
import { UserError } from './userError';
import { system, logger } from './logger';
import { isActive, supporterLevel, isPastSupporter } from '../common/adminUtils';
import { IClient } from './serverInterfaces';
import { providers } from './oauth';
import { taskQueue } from './utils/taskQueue';
export interface SuspiciousCheckers {
isSuspiciousName(name: string): boolean;
isSuspiciousAuth(auth: AuthBase<any>): boolean;
}
export interface CreateAccountOptions extends SuspiciousCheckers {
userAgent: string | undefined;
browserId: string | undefined;
connectOnly: boolean;
creationLocked: boolean;
canCreateAccounts: boolean;
reportPotentialDuplicates: boolean;
ip: string;
warn: (accountId: string | Types.ObjectId, message: string, desc?: string) => void;
}
function getBanInfo(value: number | undefined): string | undefined {
return isActive(value) ? (value === -1 ? 'perma' : moment(value).fromNow(true)) : undefined;
}
export function getModInfo({ accountId, account, country }: IClient): ModInfo {
return {
shadow: getBanInfo(account.shadow),
mute: getBanInfo(account.mute),
note: account.note,
counters: account.counters || {},
country,
account: `${account.name} [${accountId.substr(-3)}]`,
};
}
function findAccountByEmail(emails?: string[]) {
return emails && emails.length ? queryAccount({ emails: { $in: emails } }) : Promise.resolve(undefined);
}
const availableProviders = providers.filter(a => !a.connectOnly).map(a => a.name).join(', ');
export const connectOnlySocialError =
`Cannot create new account using this social site, new accounts can only be created using: ${availableProviders}`;
function createNewAccount(profile: Profile, options: CreateAccountOptions) {
if (!options.canCreateAccounts) {
throw new UserError(
'Creating accounts is temporarily disabled, try again later');
} else if (options.connectOnly) {
throw new UserError(connectOnlySocialError);
} else if (options.creationLocked) {
throw new UserError(
'Could not create account, try again later', { log: `account creation blocked by ACL (${options.ip})` });
} else if (profile.suspended) {
throw new UserError(
'Cannot create new account using suspended social site account', { log: 'account creation blocked by suspended' });
} else {
return new Account();
}
}
async function hasDuplicatesAtOrigin(account: IAccount, ip: string) {
const now = Date.now();
const query = { origins: { $elemMatch: { ip } } };
const duplicates: IAccount[] = await Account.find(query, '_id ban mute shadow flags name').lean().exec();
return duplicates.some(({ _id, ban = 0, mute = 0, shadow = 0, flags = 0, name }) => {
if (_id.toString() === account._id.toString())
return false;
if (ban === -1 || ban > now || mute === -1 || mute > now || shadow === -1 || shadow > now)
return true;
if (hasFlag(flags, AccountFlags.CreatingDuplicates))
return true;
if (name === account.name)
return true;
return false;
});
}
const newAccountCheckQueue = taskQueue();
async function checkNewAccount(account: IAccount, options: CreateAccountOptions) {
newAccountCheckQueue.push(async () => {
try {
if (options.reportPotentialDuplicates) {
const duplicate = await hasDuplicatesAtOrigin(account, options.ip);
if (duplicate) {
options.warn(account._id, `Potential duplicate`);
}
}
} catch (e) {
options.warn(account._id, `Error when checking new account`, e.message);
}
});
}
export async function findOrCreateAccount(auth: IAuth, profile: Profile, options: CreateAccountOptions): Promise<IAccount> {
let account: IAccount | undefined = undefined;
let isNew = false;
if (auth.account) {
account = await findAccount(auth.account);
}
if (!account) {
account = await findAccountByEmail(profile.emails);
}
if (!account) {
account = createNewAccount(profile, options);
isNew = true;
}
const assigned = await assignAuth(auth, account);
if (assigned && options.isSuspiciousAuth(auth)) {
options.warn(account._id, 'Suspicious auth');
}
// fix accounts fields
account.name = account.name || truncate(cleanName(profile.name) || 'Anonymous', { length: ACCOUNT_NAME_MAX_LENGTH });
account.emails = account.emails || [];
if (profile.emails.some(e => !includes(account!.emails, e))) {
const suspiciousEmails = profile.emails.filter(options.isSuspiciousName);
if (suspiciousEmails.length) {
options.warn(account._id, 'Suspicious email', suspiciousEmails.join(', '));
}
account.emails = uniq([...account.emails, ...profile.emails]);
}
account.lastVisit = new Date();
account.lastUserAgent = options.userAgent || account.lastUserAgent;
account.lastBrowserId = options.browserId || account.lastBrowserId;
// save account
if (isNew) {
await account.save();
system(account._id, `created account "${account.name}"`);
checkNewAccount(account, options);
} else {
const { name, emails, lastVisit, lastUserAgent, lastBrowserId } = account;
await Account.updateOne({ _id: account._id }, { name, emails, lastVisit, lastUserAgent, lastBrowserId }).exec();
}
return account;
}
export function isNew(account: IAccount): boolean {
return !account.createdAt || account.createdAt.getTime() > fromNow(-DAY).getTime();
}
export function checkIfNotAdmin(account: IAccount, message: string) {
if (isAdmin(account)) {
logger.warn(`Cannot perform this action on admin user (${message})`);
throw new Error('Cannot perform this action on admin user');
} else {
return account;
}
}
export async function updateCharacterCount(account: ID) {
const characterCount = await getCharacterCount(account);
await updateAccount(account, { characterCount });
}
export function updateAccountState(account: IAccount, update: (state: AccountState) => void) {
const state = account.state || {};
update(state);
account.state = state;
updateAccount(account._id, { state: account.state })
.catch(e => logger.error(e));
}
export function getAccountAlertMessage(account: IAccount) {
return (account.alert && account.alert.expires.getTime() > Date.now()) ? account.alert.message : undefined;
}
async function findFriendRequest(accountId: string, friendId: string): Promise<IFriendRequest | undefined> {
const requests = await FriendRequest.find({
$or: [
{ source: accountId, target: friendId },
{ source: friendId, target: accountId },
]
}).exec();
return requests[0];
}
export async function addFriend(accountId: string, friendId: string) {
const existing = await findFriendRequest(accountId, friendId);
if (existing) {
throw new Error(`Friend request already exists`);
}
await FriendRequest.create({ source: accountId, target: friendId });
}
export async function removeFriend(accountId: string, friendId: string) {
const existing = await findFriendRequest(accountId, friendId);
if (existing) {
existing.remove();
}
}
export function getCharacterLimit(account: IAccount) {
return getCharacterLimitInternal({
flags: isPastSupporter(account) ? AccountDataFlags.PastSupporter : 0,
supporter: supporterLevel(account),
});
}
export function getSupporterInviteLimit(account: IAccount) {
return getSupporterInviteLimitInternal({
roles: account.roles,
flags: isPastSupporter(account) ? AccountDataFlags.PastSupporter : 0,
supporter: supporterLevel(account),
});
}
+45
View File
@@ -0,0 +1,45 @@
import { dropRightWhile, mapValues } from 'lodash';
import { BaseValues } from '../common/adminInterfaces';
import { IEvent } from './db';
export interface BaseTimes {
updatedAt: number;
createdAt: number;
lastVisit: number;
}
export function getBaseDate<T>(items: T[], get: (item: T) => Date): string {
return items.reduce((min, i) => {
const date = get(i);
return date && min.getTime() > date.getTime() ? date : min;
}, new Date(0)).toISOString();
}
export function getBaseTimes(base: BaseValues): BaseTimes {
return mapValues(base, (x: string) => (new Date(x)).getTime()) as any;
}
function trimValues(values: any[]): any[] {
return dropRightWhile(values, x => !x || (Array.isArray(x) && x.length === 0));
}
function encodeDate(date: Date | undefined, baseValue: number): number {
return date ? (date.getTime() - baseValue) : 0;
}
// NOTE: update eventFields
export function encodeEvent(event: IEvent, base: BaseTimes): any[] {
return trimValues([
event._id,
encodeDate(event.updatedAt, base.updatedAt),
encodeDate(event.createdAt, base.createdAt),
event.type,
event.server,
event.message,
event.desc,
event.count,
event.origin ? { ip: event.origin.ip, country: event.origin.country } : null,
event.account,
event.pony && event.pony.toString(),
]);
}
+598
View File
@@ -0,0 +1,598 @@
import * as moment from 'moment';
import { Socket, SocketServer, Method, ClientExtensions } from 'ag-sockets';
import { AccountCounters, Subscription } from '../common/interfaces';
import { HOUR } from '../common/constants';
import { fromNow, removeItem, formatDuration } from '../common/utils';
import { hasRole } from '../common/accountUtils';
import {
Settings, UpdateOrigin, AccountUpdate, OriginInfo, AccountOrigins, IAdminServerActions, FindPonyQuery,
AuthUpdate, PonyCreator, ServerConfig, GameServerSettings, AccountState, AuthDetails, MergeAccountData,
FindAccountQuery, AdminCache, ClearOrignsOptions, ModelTypes, Stats
} from '../common/adminInterfaces';
import { ClientAdminActions, ClientUpdate } from '../client/clientAdminActions';
import { TokenData } from './serverInterfaces';
import { toAccountData, toPonyObjectAdmin } from './serverUtils';
import {
IAccount, Account, ICharacter, Character, Auth, checkIfAdmin, ID, findCharacterById, updateAuth, queryAuths,
nullToUndefined, findAccount, findFriendIds
} from './db';
import {
updateAccountSafe, setRole, addEmail, removeEmail, removeIgnore, updateAccountCounter, timeoutAccount,
addIgnores, setAccountState, findAccounts, getAccountsByEmails, getAccountsByOrigin,
removeAccount, setAccountAlert
} from './api/admin-accounts';
import {
getAdminState, getChat, kickFromAllServers, notifyUpdate, clearSessions, actionForAllServers, updateOrigin,
getChatForAccounts, shutdownServers, resetUpdating, getUserCounts, getAccountDetails,
EndPoints, getOtherStats, updateGameServerSettings, updateServerSettings, forAllGameServers,
} from './api/admin';
import {
findPonies, removeCharactersAboveLimit, createCharacter, removeCharacter, assignCharacter, removeAllCharacters
} from './api/ponies';
import { accountStatus, accountAround, getServer, getLoginServer, RemovedDocument, accountHidden } from './internal';
import { create } from './reporter';
import { system } from './logger';
import { updatePatreonData, updatePastSupporters } from './polling';
import { AdminService } from './services/adminService';
import { getOriginStats, clearOrigins, removeAllOrigins, removeOrigins, addOrigin, clearOriginsForAccounts } from './api/origins';
import { getDuplicateEntries, getAllDuplicatesQuickInfo, getAllDuplicatesWithInfo } from './api/duplicates';
import { splitAccounts } from './api/merge';
import { removeAuth, assignAuth } from './api/admin-auths';
import { getLastPatreonData } from './patreon';
import { removeFriend, addFriend } from './accountUtils';
@Socket({
id: 'admin',
path: '/ws-admin',
connectionTokens: true,
tokenLifetime: 12 * HOUR,
perMessageDeflate: false,
})
export class AdminServerActions implements IAdminServerActions, SocketServer {
private account: IAccount;
private cache: AdminCache = {};
private subscriptions = new Map<string, Subscription>();
constructor(
private client: ClientAdminActions & ClientExtensions,
private server: ServerConfig,
private settings: Settings,
private adminService: AdminService,
private endPoints: EndPoints,
private removedDocument: RemovedDocument,
) {
this.account = (client.tokenData as TokenData).account;
this.subscriptions.set('account:deleted', this.adminService.accountDeleted.subscribe(account => {
if (this.cache.findAccounts) {
removeItem(this.cache.findAccounts.result, account);
}
}));
}
disconnected() {
this.subscriptions.forEach(subscription => subscription.unsubscribe());
clearTimeout(this.updatesTimeout);
}
// other
@Method({ promise: true })
async getSignedAccount() {
return toAccountData(this.account);
}
@Method({ promise: true })
async getCounts() {
const characters = await Promise.resolve(Character.estimatedDocumentCount() as any);
return {
characters,
accounts: this.adminService.accounts.items.length,
auths: this.adminService.auths.items.length,
origins: this.adminService.origins.items.length,
};
}
@Method({ promise: true })
async getOtherStats() {
return await getOtherStats(this.adminService);
}
// subscribing
private updates: ClientUpdate[] = [];
private updatesTimeout: any;
private pushUpdate(type: ModelTypes, id: string, update: any) {
const index = this.updates.findIndex(u => u.type === type && u.id === id);
if (index !== -1) {
this.updates[index].update = update;
} else {
this.updates.push({ type, id, update });
}
if (!this.updatesTimeout) {
this.updatesTimeout = setTimeout(() => {
this.client.updates(this.updates);
this.updates = [];
this.updatesTimeout = 0;
}, 50);
}
}
@Method()
subscribe(type: ModelTypes, id: string) {
const key = `${type}:${id}`;
if (this.subscriptions.has(key))
return;
if (type === 'ponies') {
if (!this.adminService.ponies.get(id)) {
this.adminService.ponies.fetch({ _id: id });
}
}
let subscription: Subscription | undefined;
if (type === 'accountAuths') {
subscription = this.adminService.subscribeToAccountAuths(id, update => this.pushUpdate(type, id, update));
} else if (type === 'accountOrigins') {
subscription = this.adminService.subscribeToAccountOrigins(id, update => this.pushUpdate(type, id, update));
} else if (type === 'accountPonies') {
subscription = this.adminService.subscribeToAccountPonies(id, update => this.pushUpdate(type, id, update));
} else if (type in this.adminService) {
subscription = this.adminService[type].subscribe(id, (id, update) => this.pushUpdate(type, id, update));
} else {
throw new Error(`Invalid model type (${type})`);
}
if (subscription) {
this.subscriptions.set(key, subscription);
}
}
@Method()
unsubscribe(type: ModelTypes, id: string) {
const key = `${type}:${id}`;
const subscription = this.subscriptions.get(key);
if (subscription) {
subscription.unsubscribe();
this.subscriptions.delete(key);
if (type === 'ponies') {
this.adminService.cleanupPony(id);
} else if (type === 'accountPonies') {
this.adminService.cleanupPoniesList(id);
}
}
}
// state
@Method({ promise: true })
async clearSessions(accountId: string) {
await clearSessions(accountId);
}
@Method({ promise: true })
async getState() {
return getAdminState();
}
@Method({ promise: true })
async updateSettings(update: Partial<Settings>) {
await updateServerSettings(this.settings, update);
}
@Method({ promise: true })
async updateGameServerSettings(serverId: string, update: Partial<GameServerSettings>) {
await updateGameServerSettings(this.settings, serverId, update);
}
@Method({ promise: true })
async fetchServerStats(serverId: string) {
const server = getServer(serverId);
return await server.api.stats();
}
@Method({ promise: true })
async fetchServerStatsTable(serverId: string, stats: Stats) {
const server = getServer(serverId);
return await server.api.statsTable(stats);
}
@Method({ promise: true })
async report(accountId: string) {
create(this.server, accountId).info(`Reported by ${this.account.name}`);
}
@Method({ promise: true })
async notifyUpdate(server: string) {
await notifyUpdate(server);
}
@Method({ promise: true })
async shutdownServers(server: string) {
await shutdownServers(server, true);
}
@Method({ promise: true })
async resetUpdating(server: string) {
await resetUpdating(server);
}
@Method({ promise: true })
async action(action: string, accountId: string) {
await actionForAllServers(action, accountId);
}
@Method({ promise: true })
async kick(accountId: string) {
await kickFromAllServers(accountId);
}
@Method({ promise: true })
async kickAll(serverId: string) {
const server = getServer(serverId);
await server.api.kickAll();
}
@Method({ promise: true })
async getChat(search: string, date: string, caseInsensitive: boolean) {
return await getChat(search, date, caseInsensitive);
}
@Method({ promise: true })
async getChatForAccounts(accountIds: string[], date: string) {
return await getChatForAccounts(accountIds, date);
}
@Method({ promise: true })
async getRequestStats() {
const loginServer = getLoginServer('login');
const requests = await loginServer.api.loginServerStats();
const userCounts = await getUserCounts();
return { requests, userCounts };
}
// live (remove)
@Method({ promise: true })
async get(endPoint: keyof EndPoints, id: string) {
// console.log('get', endPoint);
// return this.adminService[endPoint].get(id);
return await this.endPoints[endPoint].get(id) as any;
}
@Method({ promise: true })
async getAll(endPoint: keyof EndPoints, timestamp?: string) {
return await this.endPoints[endPoint].getAll(timestamp) as any;
}
@Method({ promise: true })
async assignAccount(endPoint: keyof EndPoints, id: string, account: string) {
return await this.endPoints[endPoint].assignAccount(id, account) as any;
}
@Method({ promise: true })
async removeItem(endPoint: keyof EndPoints, id: string) {
return await this.endPoints[endPoint].removeItem(id) as any;
}
// events
@Method({ promise: true })
async removeEvent(id: string) {
await this.adminService.events.remove(id);
await this.endPoints.events.removedItem(id);
}
// origins
@Method({ promise: true })
async updateOrigin(origin: UpdateOrigin) {
await updateOrigin(origin);
}
@Method({ promise: true })
async getOriginStats() {
return await getOriginStats(this.adminService.accounts.items);
}
@Method({ promise: true })
async clearOrigins(count: number, andHigher: boolean, options: ClearOrignsOptions) {
if (!this.adminService.loaded) {
throw new Error('Not loaded yet');
} else {
await clearOrigins(this.adminService, count, andHigher, options);
}
}
@Method({ promise: true })
async clearOriginsForAccounts(accounts: string[], options: ClearOrignsOptions) {
if (!this.adminService.loaded) {
throw new Error('Not loaded yet');
} else {
await clearOriginsForAccounts(this.adminService, accounts, options);
}
}
// ponies
@Method({ promise: true })
async getPony(id: string) {
return await Character.findById(id).exec().then(nullToUndefined) as any;
}
@Method({ promise: true })
async getPonyInfo(id: string) {
const character = await findCharacterById(id);
return toPonyObjectAdmin(character);
}
@Method({ promise: true })
async getPoniesCreators(account: string) {
const items: ICharacter[] = await Character.find({ account }, '_id name creator').lean().exec();
return items.map(({ _id, name, creator }) => <PonyCreator>{ _id, name, creator });
}
@Method({ promise: true })
async getPoniesForAccount(account: string) {
return await Character.find({ account }).lean().exec();
}
@Method({ promise: true })
async getDetailsForAccount(accountId: string) {
return await getAccountDetails(accountId);
}
@Method({ promise: true })
async findPonies(query: FindPonyQuery, page: number, _skipTotalCount: boolean) {
return await findPonies(query, page);
}
@Method({ promise: true })
async createPony(account: string, name: string, info: string) {
await createCharacter(account, name, info);
system(account, `Created character (${name}) ${this.by()}`);
}
@Method({ promise: true })
async assignPony(ponyId: string, accountId: string) {
await assignCharacter(ponyId, accountId);
}
@Method({ promise: true })
async removePony(id: string) {
await removeCharacter(this.adminService, id);
}
@Method({ promise: true })
async removePoniesAboveLimit(account: string) {
await removeCharactersAboveLimit(this.removedDocument, account);
system(account, `Removed ponies above limit ${this.by()}`);
}
@Method({ promise: true })
async removeAllPonies(account: string) {
await removeAllCharacters(this.removedDocument, account);
system(account, `Removed all ponies ${this.by()}`);
}
// auths
@Method({ promise: true })
async getAuth(id: string) {
return await Auth.findById(id).exec().then(nullToUndefined) as any;
}
@Method({ promise: true })
async getAuthsForAccount(accountId: string) {
return await Auth.find({ account: accountId }).exec() as any;
}
@Method({ promise: true })
async fetchAuthDetails(auths: string[]): Promise<AuthDetails[]> {
const items = await queryAuths({ _id: { $in: auths } }, '_id lastUsed');
return items.map(a => ({
id: a._id.toString(),
lastUsed: a.lastUsed && a.lastUsed.toISOString(),
}));
}
@Method({ promise: true })
async updateAuth(id: string, update: AuthUpdate) {
const auth = await Auth.findById(id).exec();
await throwOnAdmin(auth && auth.account);
await updateAuth(id, update);
}
@Method({ promise: true })
async assignAuth(authId: string, accountId: string) {
await assignAuth(authId, accountId);
}
@Method({ promise: true })
async removeAuth(id: string) {
await removeAuth(this.adminService, id);
}
// accounts
@Method({ promise: true })
async getAccount(id: string) {
return await findAccount(id) as any;
}
@Method({ promise: true })
async findAccounts(query: FindAccountQuery) {
return await findAccounts(this.cache, this.adminService, query);
}
@Method({ promise: true })
async createAccount(name: string): Promise<string> {
const account = await Account.create({ name });
system(account._id.toString(), `Created account ${this.by()}`);
return account._id.toString();
}
@Method({ promise: true })
async getAccountsByEmails(emails: string[]) {
return getAccountsByEmails(this.adminService, emails);
}
@Method({ promise: true })
async getAccountsByOrigin(ip: string) {
return getAccountsByOrigin(this.adminService, ip);
}
@Method({ promise: true })
async setName(accountId: string, name: string) {
await updateAccountSafe(accountId, { name });
system(accountId, `Updated name (${name}) ${this.by()}`);
}
@Method({ promise: true })
async setAge(accountId: string, age: number) {
if (age === -1) {
await Account.updateOne({ _id: accountId }, { $unset: { birthyear: 1 } }).exec();
} else {
const birthyear = (new Date()).getFullYear() - age;
await Account.updateOne({ _id: accountId }, { birthyear }).exec();
}
system(accountId, `Updated birth year (${age}) ${this.by()}`);
}
@Method({ promise: true })
async setRole(accountId: string, role: string, set: boolean) {
await setRole(accountId, role, set, hasRole(this.account, 'superadmin'));
system(accountId, `${set ? 'Added' : 'Removed'} role (${role}) ${this.by()}`);
}
@Method({ promise: true })
async updateAccount(accountId: string, update: AccountUpdate, message?: string) {
await updateAccountSafe(accountId, update);
if (message) {
system(accountId, `${message} ${this.by()}`);
}
}
@Method({ promise: true })
async timeoutAccount(accountId: string, timeout: number) {
const message = timeout ? `Timed out ${moment.duration(timeout).humanize()}` : 'Unmuted';
system(accountId, `${message} ${this.by()}`);
await timeoutAccount(accountId, fromNow(timeout | 0));
}
@Method({ promise: true })
async updateAccountCounter(accountId: string, name: keyof AccountCounters, value: number) {
await updateAccountCounter(accountId, name, value);
}
@Method({ promise: true })
async mergeAccounts(accountId: string, withId: string) {
const server = getLoginServer('login');
await server.api.mergeAccounts(accountId, withId, this.by(), hasRole(this.account, 'superadmin'), true);
}
@Method({ promise: true })
async unmergeAccounts(accountId: string, mergeId: string | undefined, split: MergeAccountData, keep: MergeAccountData) {
await splitAccounts(accountId, mergeId, split, keep, this.by());
}
@Method({ promise: true })
async getAccountStatus(accountId: string) {
return await accountStatus(accountId);
}
@Method({ promise: true })
async getAccountAround(accountId: string) {
return await accountAround(accountId);
}
@Method({ promise: true })
async getAccountHidden(accountId: string) {
return await accountHidden(accountId);
}
@Method({ promise: true })
async getAccountFriends(accountId: string) {
return findFriendIds(accountId);
}
@Method({ promise: true })
async removeAccount(accountId: string) {
await removeAccount(this.adminService, accountId);
}
@Method({ promise: true })
async setAlert(accountId: string, message: string, expiresIn: number) {
await setAccountAlert(accountId, message, fromNow(expiresIn));
system(accountId, `${expiresIn ? 'Set' : 'Unset'} alert for ${formatDuration(expiresIn)} "${message}" ${this.by()}`);
}
// accounts - origins
@Method({ promise: true })
async removeAllOrigins(accountId: string) {
await removeAllOrigins(this.adminService, accountId);
}
@Method({ promise: true })
async removeOriginsForAccount(accountId: string, ips: string[]) {
await removeOrigins(this.adminService, accountId, ips);
}
@Method({ promise: true })
async removeOriginsForAccounts(origins: AccountOrigins[]) {
await Promise.all(origins.map(o => removeOrigins(this.adminService, o.accountId, o.ips)));
}
@Method({ promise: true })
async addOriginToAccount(accountId: string, origin: OriginInfo) {
if (origin && origin.ip && origin.country) {
await addOrigin(accountId, origin);
system(accountId, `Added origin (${JSON.stringify(origin)}) ${this.by()}`);
} else {
throw new Error('Invalid origin');
}
}
// accounts - emails
@Method({ promise: true })
async addEmail(accountId: string, email: string) {
await addEmail(accountId, email);
system(accountId, `Added email (${email}) ${this.by()}`);
}
@Method({ promise: true })
async removeEmail(accountId: string, email: string) {
await removeEmail(accountId, email);
system(accountId, `Removed email (${email}) ${this.by()}`);
}
// accounts - ignores
@Method({ promise: true })
async removeIgnore(accountId: string, ignore: string) {
await removeIgnore(accountId, ignore);
}
@Method({ promise: true })
async addIgnores(accountId: string, ignores: string[]) {
await addIgnores(accountId, ignores);
}
@Method({ promise: true })
async setAccountState(accountId: string, state: AccountState) {
await setAccountState(accountId, state);
}
@Method({ promise: true })
async getIgnoresAndIgnoredBy(accountId: string): Promise<{ ignores: string[]; ignoredBy: string[]; }> {
const [ignores, ignoredBy] = await Promise.all([
Account
.find({ ignores: { $in: [accountId] } }, '_id')
.lean()
.exec()
.then((accounts: IAccount[]) => accounts.map(a => a._id.toString())),
Account
.findOne({ _id: accountId }, 'ignores')
.lean()
.exec()
.then((account: IAccount | null) => account && account.ignores || []),
]);
return { ignores, ignoredBy };
}
// accounts - friends
@Method({ promise: true })
async removeFriend(accountId: string, friendId: string) {
await removeFriend(accountId, friendId);
}
@Method({ promise: true })
async addFriend(accountId: string, friendId: string) {
await addFriend(accountId, friendId);
}
// accounts - duplicates
@Method({ promise: true })
async getAllDuplicatesQuickInfo(accountId: string) {
return await getAllDuplicatesQuickInfo(this.adminService, accountId);
}
@Method({ promise: true })
async getAllDuplicates(accountId: string) {
return await getAllDuplicatesWithInfo(this.adminService, accountId);
}
@Method({ promise: true })
async getDuplicateEntries(force: boolean) {
return await getDuplicateEntries(this.adminService.accounts.items, force);
}
// patreon
@Method({ promise: true })
async updatePatreon() {
await updatePatreonData(this.server, this.settings);
}
@Method({ promise: true })
async resetSupporter(accountId: string) {
await Account.updateOne(
{ _id: accountId },
{ $unset: { supporter: 1, patreon: 1, supporterDeclinedSince: 1 } }).exec();
}
@Method({ promise: true })
async getLastPatreonData() {
const data = await getLastPatreonData();
// if (data) {
// data.pledges.forEach(pledge => {
// const auth = this.adminService.auths.items.find(a => a.openId === pledge.user);
// pledge.account = auth && auth.account;
// });
// }
return data;
}
@Method({ promise: true })
async updatePastSupporters() {
await updatePastSupporters();
}
// other
@Method({ promise: true })
async getTimings(serverId: string) {
const server = getServer(serverId);
return server.api.getTimings();
}
@Method({ promise: true })
async teleportTo(accountId: string) {
const adminAccountId = this.account._id.toString();
await forAllGameServers(server => server.api.teleportTo(adminAccountId, accountId));
}
// utils
private by() {
return `by ${this.account.name} [${this.account._id}]`;
}
}
async function throwOnAdmin(account: ID | null | undefined) {
if (account) {
const isAdmin = await checkIfAdmin(account);
if (isAdmin) {
throw new Error('Cannot change for admin user');
}
}
}
+258
View File
@@ -0,0 +1,258 @@
import * as moment from 'moment';
import { ACCOUNT_NAME_MAX_LENGTH, ACCOUNT_NAME_MIN_LENGTH, MIN_CHATLOG_RANGE, MAX_CHATLOG_RANGE, HIDES_PER_PAGE } from '../../common/constants';
import {
UpdateAccountData, AccountSettings, AccountData, ModAction, EntitiesEditorInfo, EntityNameTypes
} from '../../common/interfaces';
import { isMod } from '../../common/accountUtils';
import { cleanName } from '../../client/clientUtils';
import { toAccountData, toPonyObject, toSocialSite, toPonyObjectFields, toSocialSiteFields } from '../serverUtils';
import {
IAccount, FindAccountSafe, FindAuth, FindAuths, FindCharacters, CountAuths, Auth,
ID, findFriends, Account, HideRequest
} from '../db';
import { UserError } from '../userError';
import * as entities from '../../common/entities';
import { includes, clamp, createValidBirthDate, parseISODate, formatISODate } from '../../common/utils';
import { getAccountAlertMessage } from '../accountUtils';
import { getAge } from '../../common/adminUtils';
export type GetAccountCharacters = ReturnType<typeof createGetAccountCharacters>;
export type UpdateAccount = ReturnType<typeof createUpdateAccount>;
export type UpdateSettings = ReturnType<typeof createUpdateSettings>;
export type RemoveSite = ReturnType<typeof createRemoveSite>;
export type GetAccountData = ReturnType<typeof createGetAccountData>;
type LogAccount = (accountId: ID, message: string) => void;
const exclude = [
'getEntityType', 'getEntityTypeName', 'createAnEntity', 'createEntity', 'pony',
'createBaseEntity', 'getEntityTypesAndNames',
];
export const allEntities = Object.keys(entities)
.filter(key => typeof (entities as any)[key] === 'function')
.filter(key => !includes(exclude, key));
function getEntityNamesToTypes() {
const result: EntityNameTypes[] = [];
for (const name of allEntities) {
const created = (entities as any)[name](0, 0);
const array = Array.isArray(created) ? created : [created];
const types = array.map(e => e.type);
result.push({ name, types });
}
return result;
}
const entitiesInfo: EntitiesEditorInfo = {
typeToName: entities.getEntityTypesAndNames(),
nameToTypes: getEntityNamesToTypes(),
names: allEntities,
};
const actions = [
{ name: 'kick', action: ModAction.Kick },
{ name: 'ban', action: ModAction.Ban },
];
export const modCheck = { xcz: { vdw: { qwe: { mnb: {} } } }, actions };
function fixUpdateAccountData(update: UpdateAccountData | undefined) {
const fixed: UpdateAccountData = {} as any;
if (update) {
if (update.name && typeof update.name === 'string') {
const name = cleanName(update.name);
if (name.length >= ACCOUNT_NAME_MIN_LENGTH && name.length <= ACCOUNT_NAME_MAX_LENGTH) {
fixed.name = name;
}
}
if (update.birthdate && typeof update.birthdate === 'string') {
fixed.birthdate = update.birthdate;
}
}
return fixed;
}
function fixAccountSettings(settings: AccountSettings | undefined) {
const fixed: Partial<AccountSettings> = {};
if (settings) {
if (settings.defaultServer !== undefined) {
fixed.defaultServer = `${settings.defaultServer}`;
}
if (settings.filterCyrillic !== undefined) {
fixed.filterCyrillic = !!settings.filterCyrillic;
}
if (settings.filterSwearWords !== undefined) {
fixed.filterSwearWords = !!settings.filterSwearWords;
}
if (settings.ignorePartyInvites !== undefined) {
fixed.ignorePartyInvites = !!settings.ignorePartyInvites;
}
if (settings.ignoreFriendInvites !== undefined) {
fixed.ignoreFriendInvites = !!settings.ignoreFriendInvites;
}
if (settings.ignorePublicChat !== undefined) {
fixed.ignorePublicChat = !!settings.ignorePublicChat;
}
if (settings.ignoreNonFriendWhispers !== undefined) {
fixed.ignoreNonFriendWhispers = !!settings.ignoreNonFriendWhispers;
}
if (settings.chatlogOpacity !== undefined) {
fixed.chatlogOpacity = clamp(settings.chatlogOpacity | 0, 0, 100);
}
if (settings.chatlogRange !== undefined) {
fixed.chatlogRange = clamp(settings.chatlogRange | 0, MIN_CHATLOG_RANGE, MAX_CHATLOG_RANGE);
}
if (settings.seeThroughObjects !== undefined) {
fixed.seeThroughObjects = !!settings.seeThroughObjects;
}
if (settings.filterWords !== undefined) {
fixed.filterWords = `${settings.filterWords}`;
}
if (settings.actions !== undefined) {
fixed.actions = `${settings.actions}`;
}
if (settings.hidden !== undefined) {
fixed.hidden = !!settings.hidden;
}
}
return fixed;
}
export const createGetAccountData =
(findCharacters: FindCharacters, findAuths: FindAuths) =>
async (account: IAccount): Promise<AccountData> => {
const [ponies, auths] = await Promise.all([
findCharacters(account._id, toPonyObjectFields),
findAuths(account._id, toSocialSiteFields),
]);
const data = toAccountData(account);
data.ponies = ponies.map(toPonyObject) as any;
data.sites = auths.map(toSocialSite);
data.alert = getAccountAlertMessage(account);
if (isMod(account)) {
data.check = modCheck;
}
if (BETA && isMod(account)) {
data.editor = entitiesInfo;
}
return data;
};
export async function getFriends(account: IAccount) {
return findFriends(account._id, true);
}
export async function getHides(account: IAccount, page: number) {
const hideRequests = await HideRequest
.find({ source: account._id }, '_id name date')
.sort({ date: -1 })
.skip(page * HIDES_PER_PAGE)
.limit(HIDES_PER_PAGE)
.lean()
.exec();
return hideRequests.map((f: any) => ({
id: f._id.toString(),
name: f.name,
date: moment(f.date).fromNow(),
}));
}
export const createGetAccountCharacters =
(findCharacters: FindCharacters) =>
async (account: IAccount) => {
const ponies = await findCharacters(account._id);
return ponies.map(toPonyObject);
};
export const createUpdateAccount =
(findAccount: FindAccountSafe, log: LogAccount) =>
async (account: IAccount, update: UpdateAccountData | undefined) => {
const a = await findAccount(account._id);
if (update) {
const fixed = fixUpdateAccountData(update);
const up: Partial<IAccount> = {};
if (fixed.name && fixed.name !== a.name) {
up.name = fixed.name;
log(a._id, `Renamed "${a.name}" => "${fixed.name}"`);
}
if (fixed.birthdate) {
const { day, month, year } = parseISODate(fixed.birthdate);
const date = createValidBirthDate(day, month, year);
if ((date && a.birthdate && date.getTime() !== a.birthdate.getTime()) || !a.birthdate) {
up.birthdate = date;
const from = a.birthdate ? `${formatISODate(a.birthdate)} (${getAge(a.birthdate)}yo)` : `undefined`;
const to = up.birthdate ? `${formatISODate(up.birthdate)} (${getAge(up.birthdate)}yo)` : `undefined`;
log(a._id, `Changed birthdate ${from} => ${to}`);
}
}
Object.assign(a, up);
await Account.updateOne({ _id: a._id }, up).exec();
}
return toAccountData(a);
};
export const createUpdateSettings =
(findAccount: FindAccountSafe) =>
async (account: IAccount, settings: AccountSettings | undefined) => {
const a = await findAccount(account._id);
account.settings = a.settings = { ...a.settings, ...fixAccountSettings(settings) };
await Account.updateOne({ _id: account._id }, { settings: account.settings }).exec();
return toAccountData(a);
};
export const createRemoveSite =
(findAuth: FindAuth, countAllVisibleAuths: CountAuths, log: LogAccount) =>
async (account: IAccount, siteId: unknown) => {
const [auth, auths] = await Promise.all([
siteId && typeof siteId === 'string' ? findAuth(siteId, account._id) : Promise.resolve(undefined),
countAllVisibleAuths(account._id),
]);
if (!auth || auth.disabled) {
throw new UserError('Social account not found');
} else if (auths === 1) {
throw new UserError('Cannot remove your only one social account');
} else {
log(account._id, `removed auth: ${auth.name} [${auth._id}]`);
await Auth.updateOne({ _id: auth._id }, { disabled: true }).exec();
}
return {};
};
export async function removeHide(account: IAccount, hideId: string) {
await HideRequest.deleteOne({ source: account._id, _id: hideId }).exec();
}
+195
View File
@@ -0,0 +1,195 @@
import { noop, uniq, fromPairs } from 'lodash';
import { AccountCounters, Dict } from '../../common/interfaces';
import {
AccountUpdate, AccountState, FindAccountQuery, FindAccountResult, AdminCache, AdminCacheEntry,
Account as AccountInterface
} from '../../common/adminInterfaces';
import { checkIfNotAdmin } from '../accountUtils';
import { updateAccount, findAccountSafe, IAccount, MongoUpdate, findAccount, Account } from '../db';
import { accountChanged } from '../internal';
import { MINUTE } from '../../common/constants';
import { fromNow, includes, arraysEqual } from '../../common/utils';
import { isMuted, isShadowed, filterAccounts, emailName } from '../../common/adminUtils';
import { AdminService } from '../services/adminService';
const banLogLimit = 10;
async function updateAccountAndNotify(accountId: string, update: MongoUpdate<IAccount>) {
await updateAccount(accountId, update);
await accountChanged(accountId);
}
export async function timeoutAccount(accountId: string, timeout: Date, message?: string) {
const account = await findAccountSafe(accountId, 'roles mute shadow');
checkIfNotAdmin(account, `timeout account: ${accountId}`);
const update: MongoUpdate<IAccount> = { mute: timeout.getTime() };
if (!isMuted(account) && !isShadowed(account)) {
update.$inc = { 'counters.timeouts': 1 };
if (message) {
update.$push = {
banLog: {
$each: [{ message, date: new Date() }],
$slice: -banLogLimit,
},
};
}
}
await updateAccountAndNotify(accountId, update);
}
function incrementAccountCounter(accountId: string, counter: keyof AccountCounters) {
return updateAccountAndNotify(accountId, { $inc: { [`counters.${counter}`]: 1 } });
}
export function updateAccountCounter(accountId: string, counter: keyof AccountCounters, value: number) {
return updateAccountAndNotify(accountId, { [`counters.${counter}`]: value });
}
let logSwearing: () => void = noop;
let logSpamming: () => void = noop;
export function initLogSwearingAndSpamming(swearing: typeof logSwearing, spamming: typeof logSpamming) {
logSwearing = swearing;
logSpamming = spamming;
}
export function reportSwearingAccount(accountId: string) {
logSwearing();
return incrementAccountCounter(accountId, 'swears');
}
export function reportSpammingAccount(accountId: string) {
logSpamming();
return incrementAccountCounter(accountId, 'spam');
}
export async function reportInviteLimitAccount(accountId: string) {
await incrementAccountCounter(accountId, 'inviteLimit');
const account = await findAccountSafe(accountId, 'counters');
return account.counters && account.counters.inviteLimit || 0;
}
export async function reportFriendLimitAccount(accountId: string) {
await incrementAccountCounter(accountId, 'friendLimit');
const account = await findAccountSafe(accountId, 'counters');
return account.counters && account.counters.friendLimit || 0;
}
export async function updateAccountSafe(accountId: string, update: AccountUpdate) {
const keys = Object.keys(update);
const allowAdmin = arraysEqual(keys, ['note']) || arraysEqual(keys, ['supporter']);
const account = await findAccountSafe(accountId);
if (!allowAdmin) {
checkIfNotAdmin(account, `update account: ${accountId}`);
}
const isNoteUpdate = 'note' in update && update.note !== account.note;
const accountUpdate = isNoteUpdate ? { ...update, noteUpdated: new Date() } : update;
await updateAccountAndNotify(accountId, accountUpdate);
}
export async function setRole(accountId: string, role: string, set: boolean, isSuperadmin: boolean) {
if (role === 'superadmin' || !isSuperadmin) {
throw new Error('Not allowed');
} else {
await updateAccountAndNotify(accountId, set ? { $addToSet: { roles: [role] } } : { $pull: { roles: role } });
}
}
export function addEmail(accountId: string, email: string) {
return updateAccount(accountId, { $addToSet: { emails: [email.trim().toLowerCase()] } });
}
export function removeEmail(accountId: string, email: string) {
return updateAccount(accountId, { $pull: { emails: email } });
}
export function removeIgnore(accountId: string, ignoredAccount: string) {
return updateAccountAndNotify(ignoredAccount, { $pull: { ignores: accountId } });
}
export function addIgnores(accountId: string, ignores: string[]) {
return updateAccountAndNotify(accountId, { $addToSet: { ignores } });
}
export function setAccountState(accountId: string, state: AccountState) {
return updateAccountAndNotify(accountId, { state });
}
function isValidCache<T>(entry: AdminCacheEntry<T>, query: string, duration: number): boolean {
return entry.query === query && entry.timestamp.getTime() > fromNow(-duration).getTime();
}
export async function findAccounts(
cache: AdminCache, service: AdminService, { search, showOnly, not, page, itemsPerPage, force }: FindAccountQuery
): Promise<FindAccountResult> {
const query = JSON.stringify({ search, showOnly, not });
let found: AccountInterface[];
if (force) {
cache.findAccounts = undefined;
}
if (cache.findAccounts && isValidCache(cache.findAccounts, query, 5 * MINUTE)) {
found = cache.findAccounts.result;
} else {
found = filterAccounts(service.accounts.items, search, showOnly, not);
cache.findAccounts = {
query,
result: found,
timestamp: new Date(),
};
}
const start = page * itemsPerPage;
return {
accounts: found.slice(start, start + itemsPerPage).map(a => a._id),
page,
totalItems: found.length,
};
}
export function getAccountsByEmail(service: AdminService, email: string) {
email = email.toLowerCase();
const name = emailName(email);
const accounts = service.getAccountsByEmailName(name) || [];
return accounts.filter(a => includes(a.emails, email)).map(a => a._id);
}
export function getAccountsByEmails(service: AdminService, emails: string[]): Dict<string[]> {
const pairs = uniq(emails)
.map(email => [email, getAccountsByEmail(service, email)] as [string, string[]])
.filter(([_, accounts]) => accounts.length > 0);
return fromPairs(pairs);
}
export function getAccountsByOrigin(service: AdminService, ip: string): string[] {
const origin = service.origins.get(ip);
return origin && origin.accounts && origin.accounts.map(a => a._id) || [];
}
export async function removeAccount(service: AdminService, accountId: string) {
const account = await findAccount(accountId);
if (account) {
checkIfNotAdmin(account, `remove account: ${accountId}`);
await account.remove();
service.removedItem('accounts', accountId);
}
}
export async function setAccountAlert(accountId: string, message: string, expires: Date) {
const update = message ? { alert: { message, expires } } : { $unset: { alert: 1 } };
await Account.updateOne({ _id: accountId }, update).exec();
}
+36
View File
@@ -0,0 +1,36 @@
import { AdminService } from '../services/adminService';
import { Auth, Account } from '../db';
import { checkIfNotAdmin } from '../accountUtils';
export async function assignAuth(authId: string, accountId: string) {
const auth = await Auth.findById(authId).exec();
if (!auth)
return;
const [src, dest] = await Promise.all([
Account.findById(auth.account).exec(),
Account.findById(accountId).exec(),
]);
src && checkIfNotAdmin(src, `assign auth from ${src._id}`);
dest && checkIfNotAdmin(dest, `assign auth to ${dest._id}`);
await Auth.updateOne({ _id: authId }, { account: accountId }).exec();
}
export async function removeAuth(service: AdminService, authId: string) {
const auth = await Auth.findById(authId).exec();
if (!auth)
return;
if (auth.account) {
const account = await Account.findById(auth.account).exec();
account && checkIfNotAdmin(account, `remove auth from ${account._id}`);
}
await Auth.deleteOne({ _id: authId }).exec();
service.auths.removed(authId);
}
+298
View File
@@ -0,0 +1,298 @@
import * as fs from 'fs';
import * as moment from 'moment';
import {
AdminState, eventFields, BaseValues, UpdateOrigin, UserCountStats, AccountDetails, SupporterInvite,
InternalGameServerState, InternalLoginServerState, OtherStats, Settings, GameServerSettings
} from '../../common/adminInterfaces';
import { execAsync } from '../serverUtils';
import {
IAccount, Account, Origin, Event, iterate, ISession, Session, SupporterInvite as DBSupporterInvite,
findAccount, ISupporterInvite, ID
} from '../db';
import { servers, serverStatus, loginServers } from '../internal';
import { encodeEvent, BaseTimes, getBaseDate, getBaseTimes } from '../adminEncoders';
import { createLiveEndPoint, LiveEndPoint } from '../liveEndPoint';
import * as paths from '../paths';
import { logger } from '../logger';
import { AdminService } from '../services/adminService';
import { loadSettings, saveSettings } from '../settings';
import { flatten } from '../../common/utils';
function encodeItems<T>(items: T[], base: BaseValues, encode: (items: T, base: BaseTimes) => any[]): any[][] {
const baseValues = getBaseTimes(base);
return items.map(i => encode(i, baseValues));
}
const events = createLiveEndPoint({
model: Event,
fields: eventFields,
encode(items, base) {
base.createdAt = getBaseDate(items, i => i.createdAt!);
base.updatedAt = getBaseDate(items, i => i.updatedAt);
return encodeItems(items, base, encodeEvent);
},
});
export interface EndPoints {
events: LiveEndPoint;
}
export function createEndPoints(): EndPoints {
return { events };
}
export function getAdminState(): AdminState {
return {
status: serverStatus,
loginServers: loginServers.map(s => s.state),
gameServers: servers.map(s => s.state),
};
}
async function forAllLoginServers(
action: (server: InternalLoginServerState) => any, filter = (_: InternalLoginServerState) => true
) {
await Promise.all(loginServers.filter(filter).map(action));
}
export async function forAllGameServers(
action: (server: InternalGameServerState) => any, filter = (_: InternalGameServerState) => true
) {
const liveServers = servers.filter(s => !s.state.dead);
await Promise.all(liveServers.filter(filter).map(action));
}
export function actionForAllServers(action: string, accountId: string) {
return forAllGameServers(s => s.api.action(action, accountId));
}
export function kickFromAllServers(accountId: string) {
return forAllGameServers(s => s.api.kick(accountId, undefined));
}
export function kickFromAllServersByCharacter(characterId: string) {
return forAllGameServers(s => s.api.kick(undefined, characterId));
}
function createFilter(id: string) {
return (server: { id: string; }) => id === '*' || server.id === id;
}
export async function notifyUpdate(server: string) {
await Promise.all([
forAllLoginServers(s => s.api.updateLiveSettings({ updating: true }), createFilter(server)),
forAllGameServers(s => s.api.notifyUpdate(), createFilter(server)),
]);
}
export function shutdownServers(server: string, value: boolean) {
return forAllGameServers(s => s.api.shutdownServer(value), createFilter(server));
}
export async function resetUpdating(server: string) {
await Promise.all([
forAllLoginServers(s => s.api.updateLiveSettings({ updating: false }), createFilter(server)),
forAllGameServers(s => s.api.cancelUpdate(), createFilter(server)),
shutdownServers(server, false),
]);
}
export async function reloadSettingsOnAllServers() {
await Promise.all([
forAllLoginServers(s => s.api.reloadSettings()),
forAllGameServers(s => s.api.reloadSettings()),
]);
}
export async function getChat(search: string, date: string, caseInsensitive: boolean) {
const query = search
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\./g, '\\.')
.replace(/\*/g, '\\*')
.replace(/\$/g, '\\*')
.replace(/\^/g, '\\*');
const flags = caseInsensitive ? '-i -E ' : '-E ';
const options = { maxBuffer: 1 * 1024 * 1024 }; // 1MB
async function fetchChatlog(lines: number) {
const logFile = paths.pathTo('logs', `info.${moment(date).format('YYYYMMDD')}.log`);
const { stdout } = await execAsync(`grep ${flags}"${query}" "${logFile}" | tail -n ${lines}`, options);
return stdout;
}
try {
if (!search) {
return '';
} else if (date === 'all') {
const { stdout } = await execAsync(`for f in ${paths.pathTo('logs')}/*.log; do `
+ `echo "$f" | grep -o '[0-9]*';`
+ `cat "$f" | grep ${flags}"${query}";`
+ `done`, options);
return stdout;
} else {
let lines = 8192;
let more = '';
do {
try {
const log = await fetchChatlog(lines);
return more + log;
} catch (e) {
if (e.message !== 'stdout maxBuffer exceeded') {
throw e;
}
}
lines /= 2;
more = '... more lines ...\n';
} while (lines > 1);
return '<error size exceeded>';
}
} catch (e) {
console.error('Failed to fetch chatlog: ', e);
return '<error>';
}
}
export async function getChatForAccounts(accountIds: string[], date: string) {
const accounts: Partial<IAccount>[] = await Account.find({ _id: { $in: accountIds } }, '_id merges').lean().exec();
const map = new Map<string, string>();
const ids = flatten(accounts.map(a => [a._id.toString(), ...(a.merges || []).map(a => a.id)]));
for (const a of accounts) {
const index = accountIds.indexOf(a._id.toString());
map.set(a._id.toString(), index ? `[${index}]` : ``);
(a.merges || []).forEach(({ id }) => {
map.set(id, index ? `[${index}:merged]` : `[merged]`);
});
}
const chat = await getChat(ids.join('|'), date, false);
const fixed = chat.replace(/^([0-9:]+) \[([a-f0-9]{24})\]/gmu, (_, date, id) =>
`${date} ${map.has(id) ? map.get(id) : `[${id}]`}`);
return fixed;
}
export async function clearSessions(accountId: string) {
const clearIds: string[] = [];
await iterate<ISession>(Session.find({ session: { $exists: true } }).lean() as any, session => {
try {
if (session.session) {
const data = JSON.parse(session.session);
const user = data && data.passport && data.passport.user;
if (user === accountId) {
clearIds.push(session._id);
}
}
} catch (e) {
logger.error('Error when claring session', e, session._id, session.session);
}
});
await Session.deleteOne({ _id: { $in: clearIds } }).exec();
}
export async function updateOrigin(update: UpdateOrigin) {
await Origin.updateOne({ ip: update.ip }, update, { upsert: true }).exec();
}
export async function getUserCounts(): Promise<UserCountStats[]> {
const statsFile = paths.pathTo('settings', `user-counts.log`);
try {
const content = await fs.readFileAsync(statsFile, 'utf8');
const lines = content.trim().split(/\n/);
return lines.map(line => JSON.parse(line));
} catch {
return [];
}
}
function convertInvite(invite: ISupporterInvite): SupporterInvite {
return {
_id: invite._id.toString(),
name: invite.name,
info: invite.info,
source: invite.source.toHexString(),
target: invite.target.toHexString(),
active: invite.active,
updatedAt: invite.updatedAt,
createdAt: invite.createdAt,
};
}
export async function getAccountDetails(accountId: ID): Promise<AccountDetails> {
const [account, invitesReceived, invitesSent] = await Promise.all([
findAccount(accountId, 'merges supporterLog banLog state'),
DBSupporterInvite.find({ target: accountId }).exec(),
DBSupporterInvite.find({ source: accountId }).exec(),
]);
return account ? {
merges: account.merges || [],
banLog: account.banLog || [],
supporterLog: account.supporterLog || [],
invitesReceived: invitesReceived.map(convertInvite),
invitesSent: invitesSent.map(convertInvite),
state: account.state || {},
} : {
merges: [],
banLog: [],
supporterLog: [],
invitesReceived: [],
invitesSent: [],
state: {},
};
}
export async function getOtherStats(service: AdminService): Promise<OtherStats> {
let totalIgnores = 0;
let authsWithEmptyAccount = 0;
let authsWithMissingAccount = 0;
for (const account of service.accounts.items) {
totalIgnores += account.ignoresCount!;
}
for (const auth of service.auths.items) {
if (!auth.account) {
authsWithEmptyAccount++;
} else if (!service.accounts.get(auth.account)) {
authsWithMissingAccount++;
}
}
return {
totalIgnores,
authsWithEmptyAccount,
authsWithMissingAccount,
};
}
export async function updateServerSettings(currentSettings: Settings, update: Partial<Settings>) {
const settings = await Promise.resolve(loadSettings());
Object.assign(currentSettings, settings, update);
await saveSettings(currentSettings);
await reloadSettingsOnAllServers();
}
export async function updateGameServerSettings(
currentSettings: Settings, serverId: string, update: Partial<GameServerSettings>
) {
const settings = await Promise.resolve(loadSettings());
Object.assign(currentSettings, settings);
const serverSettings = currentSettings.servers[serverId] = currentSettings.servers[serverId] || {};
Object.assign(serverSettings, update);
await saveSettings(currentSettings);
await reloadSettingsOnAllServers();
}
+187
View File
@@ -0,0 +1,187 @@
import { groupBy, uniq, compact } from 'lodash';
import {
duplicatesCollector, emailName, compareDuplicates, getIdsFromNote, createDuplicateResult
} from '../../common/adminUtils';
import { Account, Origin, DuplicatesInfo, DuplicateResult } from '../../common/adminInterfaces';
import { HOUR } from '../../common/constants';
import { DuplicateInfoEntry } from '../../common/adminInterfaces';
import { Account as DBAccount, Character, ICharacter, IAccount } from '../db';
import { removeItem, includes, flatten } from '../../common/utils';
import { AdminService } from '../services/adminService';
// get duplicate entries
const DUPLICATE_TIMEOUT = 1 * HOUR;
let duplicateEntries: string[] | undefined = undefined;
let duplicateTimestamp = 0;
export async function getDuplicateEntries(accounts: Account[], force: boolean) {
if (!duplicateEntries || force || (Date.now() - duplicateTimestamp) > DUPLICATE_TIMEOUT) {
duplicateTimestamp = Date.now();
duplicateEntries = [
...getDuplicateEmails(accounts),
// ...getDuplicateAuths(accounts),
];
}
return duplicateEntries;
}
export function getDuplicateEmails(accounts: Account[]) {
const duplicates: string[] = [];
const collect = duplicatesCollector(duplicates);
accounts.forEach(a => a.emails !== undefined && a.emails.forEach(collect));
return duplicates;
}
export function getDuplicateAuths(accounts: Account[]) {
const duplicates: string[] = [];
const collect = duplicatesCollector(duplicates);
accounts.forEach(a => a.auths !== undefined && a.auths.forEach(a => a.url && collect(a.url)));
return duplicates;
}
// get duplicate info
export async function getDuplicateInfo(accountId: string, otherAccounts: string[]): Promise<DuplicateInfoEntry[]> {
const ids = [accountId, ...otherAccounts];
const [chars, accounts] = await Promise.all([
Character.find({ account: ids }, 'account name').lean().exec() as Promise<ICharacter[]>,
DBAccount.find({ _id: ids }, '_id lastUserAgent').lean().exec() as Promise<IAccount[]>,
]);
chars.forEach(c => c.name = c.name.toLowerCase());
const groups = groupBy(chars, c => c.account);
const account = accounts.find(a => a._id.toString() === accountId);
const userAgent = account && account.lastUserAgent || '';
return otherAccounts.map(id => {
const account = accounts.find(a => a._id.toString() === id);
return {
account: id,
userAgent: (account && userAgent && account.lastUserAgent === userAgent) ? userAgent : '',
ponies: getDuplicateNames(groups[accountId], groups[id]),
};
});
}
function getDuplicateNames(mine: ICharacter[] = [], others: ICharacter[] = []): string[] {
return uniq(mine.filter(a => others.some(b => a.name === b.name)).map(c => c.name));
}
// get all duplicates
export async function getAllDuplicatesQuickInfo(service: AdminService, accountId: string): Promise<DuplicatesInfo> {
const duplicates = await getAllDuplicates(service, accountId);
return {
generatedAt: Date.now(),
count: duplicates.length,
name: duplicates.some(d => !!d.name),
emails: duplicates.some(d => !!d.emails),
browserId: duplicates.some(d => !!d.browserId),
perma: duplicates.some(d => !!d.perma),
};
}
export async function getAllDuplicatesWithInfo(service: AdminService, accountId: string) {
const duplicates = await getAllDuplicates(service, accountId);
const accountIds = duplicates.map(x => x.account);
const duplicatesInfo = await getDuplicateInfo(accountId, accountIds);
duplicatesInfo.forEach(({ account, ponies, userAgent }) => {
const duplicate = duplicates.find(d => d.account === account);
if (duplicate) {
duplicate.ponies = ponies;
duplicate.userAgent = userAgent;
}
});
duplicates.forEach(d => d.ponies = d.ponies || []);
return duplicates;
}
async function getAllDuplicates(service: AdminService, accountId: string): Promise<DuplicateResult[]> {
const account = service.accounts.get(accountId);
if (!account) {
return [];
} else {
return uniq([
...getDuplicatesByNote(service, account),
...getDuplicatesByEmail(service, account),
...getDuplicatesByBrowserId(service, account),
...getDuplicates(account),
])
.filter(a => a !== account)
.map(a => createDuplicateResult(a, account))
.sort(compareDuplicates)
.slice(0, 50);
}
}
function getDuplicates(account: Account) {
const accounts: Account[] = [];
const origins: Origin[] = [];
removeItem(accounts, account);
collectDuplicates(accounts, origins, account, 3);
return accounts;
}
function getDuplicatesByNote(service: AdminService, account: Account) {
const linkedTo = compact(getIdsFromNote(account.note).map(id => service.accounts.get(id)));
const linkedFrom = service.getAccountsByNoteRef(account._id);
return uniqueOtherAccounts([...linkedTo, ...linkedFrom], account);
}
function getDuplicatesByEmail(service: AdminService, account: Account) {
const accounts = (account.emails || [])
.map(emailName)
.map(name => service.getAccountsByEmailName(name));
return uniqueOtherAccounts(flatten(accounts), account);
}
function getDuplicatesByBrowserId(service: AdminService, account: Account) {
const browserId = account.lastBrowserId;
const accounts = browserId && service.getAccountsByBrowserId(browserId) || [];
return uniqueOtherAccounts(accounts, account);
}
function uniqueOtherAccounts(accounts: Account[], exclude: Account) {
return uniq(accounts.filter(a => a !== exclude));
}
function collectDuplicates(accounts: Account[], origins: Origin[], account: Account, level: number) {
if (level > 0 && !includes(accounts, account)) {
accounts.push(account);
account.originsRefs!.forEach(o => {
if (!includes(origins, o.origin)) {
origins.push(o.origin);
if (o.origin.accounts) {
o.origin.accounts.forEach(a => collectDuplicates(accounts, origins, a, level - 1));
}
}
});
}
}
// unused
export function getDuplicateEmailNames(accounts: Account[]) {
const set = new Set();
return uniq(accounts.reduce<string[]>((duplicates, a) => {
if (a.emails !== undefined && a.emails.length > 0) {
const names = a.emails.map(e => e.replace(/@.+$/, ''));
duplicates.push(...names.filter(name => set.has(name)));
names.forEach(name => set.add(name));
}
return duplicates;
}, []));
}
+94
View File
@@ -0,0 +1,94 @@
import { parse } from 'url';
import { VERSION_ERROR } from '../../common/errors';
import { IAccount, IOriginInfo, ICharacter, FindCharacter, HasActiveSupporterInvites } from '../db';
import { UserError } from '../userError';
import { InternalGameServerState } from '../../common/adminInterfaces';
import { JoinResponse } from '../../common/interfaces';
import { meetsRequirement } from '../../common/accountUtils';
import { supporterLevel } from '../../common/adminUtils';
import { isServerOffline } from '../serverUtils';
import { getAccountAlertMessage } from '../accountUtils';
export interface Config {
version: string;
host: string;
debug: boolean;
local: boolean;
}
export type FindServer = (id: string) => InternalGameServerState | undefined;
export type Join = (server: InternalGameServerState, account: IAccount, pony: ICharacter) => Promise<string>;
export type AddOrigin = (account: IAccount, origin: IOriginInfo) => Promise<void>;
export type JoinGame = (
account: IAccount, characterId: string, serverId: string, clientVersion: string, url: string, alert: unknown,
origin: IOriginInfo
) => Promise<JoinResponse>;
export const createJoinGame =
(
findServer: FindServer, { version, host, debug, local }: Config, findCharacter: FindCharacter, join: Join,
addOrigin: AddOrigin, hasInvites: HasActiveSupporterInvites
): JoinGame => {
const waiting = new Map<string, { time: Date; characterId: string; }>();
return async (account, characterId, serverId, clientVersion, url, hasAlert, origin) => {
const accountId = account._id.toString();
try {
const [server, supporterInvited] = await Promise.all([
findServer(serverId),
hasInvites(account._id),
]);
if (clientVersion !== version)
throw new UserError(VERSION_ERROR);
if (parse(url).host !== parse(host).host && !debug && !local)
throw new UserError('Invalid data', { message: 'Invalid host', desc: url });
if (!server)
throw new UserError('Invalid data');
if (isServerOffline(server))
throw new UserError('Server is offline');
if (server.state.settings.blockJoining)
throw new UserError('Cannot join to the server');
if (!meetsRequirement({ roles: account.roles, supporter: supporterLevel(account), supporterInvited }, server.state.require))
throw new UserError('Server is restricted');
if (!characterId || typeof characterId !== 'string')
throw new UserError('Invalid data', { message: 'Invalid pony ID', desc: `"${characterId}"` });
const req = waiting.get(accountId);
const time = new Date();
if (req) {
throw new UserError('Already waiting for join request');
}
const alert = getAccountAlertMessage(account);
if (alert && !hasAlert) {
return { alert };
}
waiting.set(accountId, { characterId, time });
const character = await findCharacter(characterId, account._id);
if (!character) {
throw new UserError('Character does not exist', {
desc: `(join) (account: ${accountId} pony: ${characterId})`
});
}
await addOrigin(account, origin);
const token = await join(server, account, character!);
return { token };
} finally {
waiting.delete(accountId);
}
};
};
+14
View File
@@ -0,0 +1,14 @@
import { EndPoints } from './admin';
import { AdminService } from '../services/adminService';
export class InternalAdminApi {
constructor(private adminService: AdminService, private endPoints: EndPoints) {
}
removedDocument(model: 'events' | 'ponies' | 'accounts' | 'auths' | 'origins', id: string) {
if (model in this.endPoints) {
(this.endPoints as any)[model].removedItem(id);
}
this.adminService.removedItem(model, id);
return Promise.resolve();
}
}
+4
View File
@@ -0,0 +1,4 @@
export const createReloadSettings =
(reloadSettings: () => Promise<void>) =>
async () =>
await reloadSettings();
+62
View File
@@ -0,0 +1,62 @@
import { Settings, LoginServerStatus, InternalLoginApi, ServerLiveSettings } from '../../common/adminInterfaces';
import { StatsTracker } from '../stats';
import { RemovedDocument } from '../internal';
import { createReloadSettings } from './internal-common';
import { mergeAccounts } from './merge';
export function createLoginServerStatus(settings: Settings, live: ServerLiveSettings): LoginServerStatus {
return {
canCreateAccounts: !!settings.canCreateAccounts,
isPageOffline: !!settings.isPageOffline,
blockWebView: !!settings.blockWebView,
reportPotentialDuplicates: !!settings.reportPotentialDuplicates,
autoMergeDuplicates: !!settings.autoMergeDuplicates,
suspiciousNames: settings.suspiciousNames || '',
suspiciousAuths: settings.suspiciousAuths || '',
suspiciousPonies: settings.suspiciousPonies || '',
suspiciousMessages: settings.suspiciousMessages || '',
suspiciousSafeMessages: settings.suspiciousSafeMessages || '',
suspiciousSafeWholeMessages: settings.suspiciousSafeWholeMessages || '',
suspiciousSafeInstantMessages: settings.suspiciousSafeInstantMessages || '',
suspiciousSafeInstantWholeMessages: settings.suspiciousSafeInstantWholeMessages || '',
updating: live.updating,
dead: false,
};
}
export const createLoginServerState =
(settings: Settings, live: ServerLiveSettings) =>
async () =>
createLoginServerStatus(settings, live);
export const createLoginServerStats =
(statsTracker: StatsTracker) =>
async () =>
statsTracker.getStats();
export const createUpdateLiveSettings =
(liveSettings: ServerLiveSettings) =>
async (update: Partial<ServerLiveSettings>) => {
Object.assign(liveSettings, update);
};
export const createInternalLoginApi =
(
settings: Settings, live: ServerLiveSettings, statsTracker: StatsTracker,
reloadSettings: () => Promise<void>, removedDocument: RemovedDocument,
): InternalLoginApi =>
({
reloadSettings: createReloadSettings(reloadSettings),
state: createLoginServerState(settings, live),
loginServerStats: createLoginServerStats(statsTracker),
updateLiveSettings: createUpdateLiveSettings(live),
mergeAccounts: async (id, withId, reason, allowAdmin, creatingDuplicates) => {
if (live.shutdown) {
throw new Error(`Cannot merge while server is shutdown`);
}
await mergeAccounts(id, withId, reason, removedDocument, allowAdmin, creatingDuplicates);
},
});
+333
View File
@@ -0,0 +1,333 @@
import {
GameServerState, AccountStatus, ServerConfig, InternalApi, ServerLiveSettings, Stats, StatsTable
} from '../../common/adminInterfaces';
import { isBanned, supporterLevel } from '../../common/adminUtils';
import { IClient, TokenService, GetSettings } from '../serverInterfaces';
import {
ICharacter, IAccount, FindAccountSafe, FindAuth, FindCharacterSafe, findAccountSafe, findCharacterSafe,
findAuth, HasActiveSupporterInvites, hasActiveSupporterInvites
} from '../db';
import { World, findClientsAroundAccountId, findClientByAccountId } from '../world';
import { HidingService, saveHidingData } from '../services/hiding';
import { meetsRequirement } from '../../common/accountUtils';
import { StatsTracker } from '../stats';
import { createReloadSettings } from './internal-common';
import { UserError } from '../userError';
import { liveSettings } from '../liveSettings';
import { formatDuration, invalidEnum } from '../../common/utils';
import { timingEntries } from '../timing';
import { toPairs, groupBy } from 'lodash';
import { getSizeOfMap } from '../serverMap';
import { teleportTo } from '../playerUtils';
export const createAccountChanged =
(world: World, tokens: TokenService, findAccount: FindAccountSafe) =>
async (accountId: string) => {
const account = await findAccount(accountId);
world.accountUpdated(account);
if (isBanned(account)) {
tokens.clearTokensForAccount(accountId);
}
};
export const createAccountMerged =
(hiding: HidingService) =>
async (accountId: string, mergedId: string) =>
await hiding.merged(accountId, mergedId);
function toAccountStatus(client: IClient | undefined, server: ServerConfig): AccountStatus {
return client ? {
online: true,
character: client.characterName,
server: server.id,
map: client.map.id || '-',
x: Math.round(client.pony.x),
y: Math.round(client.pony.y),
userAgent: client.userAgent,
incognito: client.incognito,
duration: formatDuration(Date.now() - client.connectedTime),
} : { online: false };
}
export const createAccountStatus =
(world: World, server: ServerConfig) =>
async (accountId: string) =>
toAccountStatus(findClientByAccountId(world, accountId), server);
export const createAccountAround =
(world: World) =>
async (accountId: string) =>
findClientsAroundAccountId(world, accountId);
export const createHiddenStats =
(hiding: HidingService) =>
async (accountId: string) =>
hiding.getStatsFor(accountId);
export const createTeleportTo =
(world: World) =>
async (adminAccountId: string, targetAccountId: string) => {
const admin = findClientByAccountId(world, adminAccountId);
const target = findClientByAccountId(world, targetAccountId);
if (admin && target && admin.map === target.map) {
teleportTo(admin, target.pony.x, target.pony.y);
}
};
async function setupPonyAuth(character: ICharacter, account: IAccount, findAuth: FindAuth) {
if (character.site) {
const auth = await findAuth(character.site, account._id);
if (auth && !auth.disabled && !auth.banned) {
character.auth = auth;
}
}
}
export const createJoin =
(
world: World, getSettings: GetSettings, server: ServerConfig,
{ clearTokensForAccount, createToken }: TokenService, findAccount: FindAccountSafe,
findCharacter: FindCharacterSafe, findAuth: FindAuth, live: ServerLiveSettings,
hasInvite: HasActiveSupporterInvites
) =>
async (accountId: string, characterId: string) => {
if (getSettings().isServerOffline || live.shutdown) {
throw new UserError('Server is offline');
}
const [account, character, supporterInvited] = await Promise.all([
findAccount(accountId),
findCharacter(characterId, accountId),
hasInvite(accountId),
]);
if (!meetsRequirement({ roles: account.roles, supporter: supporterLevel(account), supporterInvited }, server.require)) {
throw new UserError('Server is restricted');
}
await setupPonyAuth(character, account, findAuth);
character.lastUsed = new Date();
account.settings = { ...account.settings, defaultServer: server.id };
account.lastVisit = new Date();
if (!account.settings.hidden) {
account.lastOnline = new Date();
account.lastCharacter = character._id;
}
await Promise.all([character.save(), account.save()]);
world.kickByAccount(accountId);
clearTokensForAccount(accountId);
return createToken({ accountId, account, character });
};
function getClientCountOnMainMap(world: World) {
let count = 0;
const map = world.getMainMap();
for (const client of world.clients) {
if (client.map === map) {
count++;
}
}
return count;
}
export const createGetServerState =
(server: ServerConfig, getSettings: GetSettings, world: World, live: ServerLiveSettings) =>
async (): Promise<GameServerState> =>
({
id: server.id,
name: server.name,
path: server.path,
desc: server.desc,
flag: server.flag,
host: server.host,
alert: server.alert,
flags: server.flags,
require: server.require,
dead: false,
shutdown: live.shutdown,
maps: world.maps.length,
online: world.clients.length,
onMain: getClientCountOnMainMap(world),
queued: world.joinQueue.length,
settings: getSettings(),
});
export const createGetServerStats =
(statsTracker: StatsTracker) =>
async () =>
statsTracker.getSocketStats();
export const createGetStatsTable =
(world: World) =>
async (stats: Stats) => {
switch (stats) {
case Stats.Country:
return getCountryStats(world);
case Stats.Support:
return getSupportStats(world);
case Stats.Maps:
return getMapStats(world);
default:
invalidEnum(stats);
return [];
}
};
export const createAction =
(world: World) =>
async (action: string, accountId: string) => {
switch (action) {
case 'unstuck':
const client = findClientByAccountId(world, accountId);
if (client) {
world.resetToSpawn(client);
world.kick(client, 'unstuck');
}
break;
default:
throw new Error(`Invalid action (${action})`);
}
};
export const createKick =
(world: World, { clearTokensForAccount }: TokenService) =>
async (accountId: string | undefined, characterId: string | undefined) => {
if (accountId) {
clearTokensForAccount(accountId);
return world.kickByAccount(accountId);
} else if (characterId) {
return world.kickByCharacter(characterId);
} else {
return false;
}
};
export const createKickAll =
(world: World, { clearTokensAll }: TokenService) =>
async () => {
world.kickAll();
clearTokensAll();
};
export const createNotifyUpdate =
(world: World, live: ServerLiveSettings) =>
async () => {
live.updating = true;
world.notifyUpdate();
world.saveClientStates();
};
export const createCancelUpdate =
(live: ServerLiveSettings) =>
async () => {
live.updating = false;
};
export const createShutdownServer =
(world: World, live: ServerLiveSettings) =>
async (value: boolean) => {
live.shutdown = value;
if (live.shutdown) {
world.kickAll();
saveHidingData(world.hidingService, world.server.id);
}
};
/* istanbul ignore next */
export function createInternalApi(
world: World, server: ServerConfig, reloadSettings: () => Promise<void>, getSettings: GetSettings,
tokens: TokenService, hiding: HidingService, statsTracker: StatsTracker, live: ServerLiveSettings,
): InternalApi {
return {
reloadSettings: createReloadSettings(reloadSettings),
state: createGetServerState(server, getSettings, world, live),
stats: createGetServerStats(statsTracker),
statsTable: createGetStatsTable(world),
action: createAction(world),
join: createJoin(
world, getSettings, server, tokens, findAccountSafe, findCharacterSafe, findAuth, live, hasActiveSupporterInvites),
kick: createKick(world, tokens),
kickAll: createKickAll(world, tokens),
accountChanged: createAccountChanged(world, tokens, findAccountSafe),
accountMerged: createAccountMerged(hiding),
accountStatus: createAccountStatus(world, server),
accountAround: createAccountAround(world),
notifyUpdate: createNotifyUpdate(world, liveSettings),
cancelUpdate: createCancelUpdate(liveSettings),
shutdownServer: createShutdownServer(world, live),
accountHidden: createHiddenStats(hiding),
getTimings: async () => timingEntries(),
teleportTo: createTeleportTo(world),
};
}
function getCountryStats(world: World): StatsTable {
return [
['country', 'users'],
...toPairs(groupBy(world.clients, c => c.country))
.map(([key, value]) => ({ key, count: value.length }))
.sort((a, b) => b.count - a.count)
.map(({ key, count }) => [key, count.toString()]),
];
}
function getSupportStats(world: World): StatsTable {
let wasmYes = 0;
let wasmNo = 0;
let letAndConstYes = 0;
let letAndConstNo = 0;
for (const client of world.clients) {
if (client.supportsWasm) {
wasmYes++;
} else {
wasmNo++;
}
if (client.supportsLetAndConst) {
letAndConstYes++;
} else {
letAndConstNo++;
}
}
function percent(yes: number, no: number) {
return (yes * 100 / ((yes + no) || 1)).toFixed(0) + '%';
}
return [
['supports', 'yes', 'no', ''],
['wasm', wasmYes.toString(), wasmNo.toString(), percent(wasmYes, wasmNo)],
['let & const', letAndConstYes.toString(), letAndConstNo.toString(), percent(letAndConstYes, letAndConstNo)],
];
}
function getMapStats(world: World): StatsTable {
return [
['id', 'instance', 'entities', 'players', 'memory'],
...world.maps.map(map => {
const { entities, memory } = getSizeOfMap(map);
return [
map.id || 'main',
map.instance || '',
entities.toString(),
world.clients.reduce((sum, c) => sum + (c.map === map ? 1 : 0), 0).toString(),
`${(memory / 1024).toFixed()} kb`,
];
}),
];
}
+272
View File
@@ -0,0 +1,272 @@
import { assignWith, uniq, uniqBy, clone, mapValues, difference } from 'lodash';
import { toInt, maxDate, minDate, compareDates } from '../../common/utils';
import { updateCharacterCount, checkIfNotAdmin } from '../accountUtils';
import {
Account, Auth, Character, Event, IAccount, SupporterInvite, findAccountSafe, MongoUpdate, ID, FriendRequest,
findFriendIds, findHidesForMerge, HideRequest
} from '../db';
import { accountChanged, accountMerged, RemovedDocument } from '../internal';
import { system } from '../logger';
import { makeQueued } from '../utils/taskQueue';
import {
AccountBase, MergeData, MergeAccountData, AccountState, AccountFlags, MergeHideData
} from '../../common/adminInterfaces';
import { kickFromAllServers } from './admin';
function mergeBan(a: number | undefined, b: number | undefined): number {
return (a === -1 || b === -1) ? -1 : Math.max(a || 0, b || 0);
}
function mergeLists<T extends { date: Date; }>(a: T[] | undefined, b: T[] | undefined, limit: number) {
return [...(a || []), ...(b || [])].sort((a, b) => compareDates(a.date, b.date)).slice(-limit);
}
async function findAccounts(id: ID, withId: ID, allowAdmin = false) {
const accounts = await Account.find({ _id: { $in: [id, withId] } })
.populate('auths', 'name')
.populate('characters', 'name')
.exec();
if (!allowAdmin) {
accounts.forEach(a => checkIfNotAdmin(a, `merge: ${a._id}`));
}
const account = accounts.find(a => a._id.toString() === id);
const merge = accounts.find(a => a._id.toString() === withId);
if (accounts.length !== 2 || !account || !merge) {
throw new Error('Account does not exist');
}
return { account, merge };
}
function dumpData(account: IAccount, friends: string[], hides: MergeHideData[]): MergeAccountData {
const {
name, note, flags, counters = {}, auths = [], characters = [], ignores = [], emails = [], state = {},
birthdate,
} = account;
return {
name,
note,
flags,
state,
birthdate,
emails: emails.slice(),
ignores: ignores.slice(),
counters: clone(counters),
auths: auths.map(({ _id, name }) => ({ id: _id.toString(), name })),
characters: characters.map(({ _id, name }) => ({ id: _id.toString(), name })),
settings: account.settings,
friends: friends.slice(),
hides: hides.slice(),
};
}
function mergeStates(a: AccountState | undefined, b: AccountState | undefined) {
if (a && b) {
return {
...b,
...a,
gifts: toInt(a.gifts) + toInt(b.gifts),
candies: toInt(a.candies) + toInt(b.candies),
clovers: toInt(a.clovers) + toInt(b.clovers),
toys: toInt(a.toys) | toInt(b.toys),
};
} else {
return a || b;
}
}
async function merge(
id: string, withId: string, reason: string, removedDocument: RemovedDocument, allowAdmin = false,
creatingDuplicates = false
) {
const start = Date.now();
const [{ account, merge }, accountFriends, mergeFriends, accountHides, mergeHides] = await Promise.all([
findAccounts(id, withId, allowAdmin),
findFriendIds(id),
findFriendIds(withId),
findHidesForMerge(id),
findHidesForMerge(withId),
]);
const data: MergeData = {
account: dumpData(account, accountFriends, accountHides),
merge: dumpData(merge, mergeFriends, mergeHides),
};
const origins = uniqBy([...(account.origins || []), ...(merge.origins || [])], x => x.ip);
const ignores = uniq([...(account.ignores || []), ...(merge.ignores || [])]);
const emails = uniq([...(account.emails || []), ...(merge.emails || [])]);
const note = `${account.note || ''}\n${merge.note || ''}`.trim();
const createdAt = minDate(account.createdAt, merge.createdAt);
const lastVisit = maxDate(account.lastVisit, merge.lastVisit);
const ban = mergeBan(account.ban, merge.ban);
const shadow = mergeBan(account.shadow, merge.shadow);
const mute = mergeBan(account.mute, merge.mute);
const patreon = Math.max(toInt(account.patreon), toInt(merge.patreon));
const counters = assignWith(account.counters || {}, merge.counters || {}, (a, b) => (a | 0) + (b | 0));
const creatingDuplicatesFlag = creatingDuplicates ? AccountFlags.CreatingDuplicates : 0;
const flags = account.flags | merge.flags | creatingDuplicatesFlag;
const supporter = toInt(account.supporter) | toInt(merge.supporter);
const birthdate = account.birthdate || merge.birthdate;
const supporterLog = mergeLists(account.supporterLog, merge.supporterLog, 10);
const supporterTotal = toInt(account.supporterTotal) + toInt(merge.supporterTotal);
const banLog = mergeLists(account.banLog, merge.banLog, 10);
const merges = mergeLists(account.merges, merge.merges, 20);
const state = mergeStates(account.state, merge.state);
const alert = account.alert || merge.alert;
merges.push({ id: withId, name: merge.name, date: new Date(), reason, data });
const update: Partial<AccountBase<string>> = {
origins, ignores, emails, note, lastVisit, ban, shadow, mute, flags, counters, patreon, supporter, merges,
createdAt, supporterLog, supporterTotal, banLog, state, alert, birthdate,
};
await Promise.all([
Account.updateOne({ _id: account._id }, update).exec(),
Account.updateMany({ ignores: { $exists: true, $ne: [], $in: [withId] } }, { $addToSet: { ignores: id } }).exec()
.then(() => Account.updateMany({ ignores: { $exists: true, $ne: [], $in: [withId] } }, { $pull: { ignores: withId } }).exec()),
Auth.updateMany({ account: merge._id }, { account: account._id }).exec(),
Event.updateMany({ account: merge._id }, { account: account._id }).exec(),
Character.updateMany({ account: merge._id }, { account: account._id }).exec(),
Promise.all([
SupporterInvite.updateMany({ source: merge._id }, { source: account._id }).exec(),
SupporterInvite.updateMany({ target: merge._id }, { target: account._id }).exec(),
]).then(() => SupporterInvite.remove({ target: account._id, source: account._id }).exec()),
Promise.all([
FriendRequest.updateMany({ source: merge._id }, { source: account._id }).exec(),
FriendRequest.updateMany({ target: merge._id }, { target: account._id }).exec(),
]).then(() => FriendRequest.remove({ target: account._id, source: account._id }).exec()),
Promise.all([
HideRequest.updateMany({ source: merge._id }, { source: account._id }).exec(),
HideRequest.updateMany({ target: merge._id }, { target: account._id }).exec(),
]).then(() => HideRequest.remove({ target: account._id, source: account._id }).exec()),
]);
await removeDuplicateFriendRequests(id);
await merge.remove();
await kickFromAllServers(withId);
await removedDocument('accounts', withId);
await updateCharacterCount(id);
await accountMerged(id, withId);
await accountChanged(id);
system(account._id, `Merged ${account.name} with ${merge.name} [${merge._id}] (${reason}) (${Date.now() - start}ms)`);
}
async function removeDuplicateFriendRequests(id: string) {
const friendRequests = await FriendRequest.find({ $or: [{ source: id }, { target: id }] }).exec();
const checked = new Set<string>();
const removeRequests: ID[] = [];
for (const request of friendRequests) {
const friendId = request.source.toString() === id ? request.target.toString() : request.source.toString();
if (checked.has(friendId)) {
removeRequests.push(request._id);
} else {
checked.add(friendId);
}
}
if (removeRequests.length) {
await FriendRequest.remove({ _id: { $in: removeRequests } }).exec();
}
}
export async function split(
accountId: string, mergeId: string | undefined, split: MergeAccountData, keep: MergeAccountData, reason: string
) {
const start = Date.now();
const account = await findAccountSafe(accountId);
const unmerge = await Account.create({
name: split.name,
note: split.note,
flags: split.flags || 0,
emails: split.emails,
state: split.state,
ignores: split.ignores,
counters: split.counters,
birthdate: split.birthdate,
settings: split.settings,
});
const accountUpdate: MongoUpdate<IAccount> = {
note: `${account.note}\nsplit: [${unmerge._id}]`.trim(),
state: keep.state,
};
const removeIgnores = difference(split.ignores, account.ignores || []);
if (removeIgnores.length) {
accountUpdate.$pull = { ignores: removeIgnores };
}
const newCounters = split.counters || {};
if (Object.keys(newCounters).length > 0) {
const oldCounters = account.counters || {} as any;
const counters = mapValues(newCounters, (value, key) => Math.max(0, toInt(oldCounters[key]) - toInt(value)));
accountUpdate.counters = counters;
}
const authIds = split.auths.map(x => x.id);
await Promise.all([
Auth.updateMany({ _id: { $in: authIds } }, { account: unmerge._id, disabled: false }).exec(),
Character.updateMany({ _id: { $in: split.characters.map(x => x.id) } }, { account: unmerge._id }).exec(),
Account.updateOne({ _id: account._id }, accountUpdate).exec(),
]);
// friends
const friendsToRemove = [...(keep.friends || []), ...(split.friends || [])];
await FriendRequest.deleteMany({
$or: [
{ target: account._id, source: { $in: friendsToRemove } },
{ source: account._id, target: { $in: friendsToRemove } },
],
}).exec();
await FriendRequest.create([
...(keep.friends || []).map(id => ({ source: account._id, target: id })),
...(split.friends || []).map(id => ({ source: unmerge._id, target: id }))
]);
// hides
const hidesToRemove = [...(keep.hides || []), ...(split.hides || [])].map(hide => hide.id);
await HideRequest.deleteMany({
$or: [
{ source: account._id, target: { $in: hidesToRemove } },
],
}).exec();
await HideRequest.create([
...(keep.hides || []).map(hide => ({ source: account._id, target: hide.id, name: hide.name, date: new Date(hide.date) })),
...(split.hides || []).map(hide => ({ source: unmerge._id, target: hide.id, name: hide.name, date: new Date(hide.date) }))
]);
// other
if (mergeId) {
await Account.updateOne({ _id: account._id, 'merges._id': mergeId }, { 'merges.$.split': true }).exec();
}
await Promise.all([
updateCharacterCount(accountId),
updateCharacterCount(unmerge._id),
accountChanged(accountId),
]);
system(account._id, `Split off ${unmerge.name} [${unmerge._id}] (${reason}) (${Date.now() - start}ms)`);
}
export const mergeAccounts = makeQueued(merge);
export const splitAccounts = makeQueued(split);
+113
View File
@@ -0,0 +1,113 @@
import * as Bluebird from 'bluebird';
import { difference } from 'lodash';
import { DAY } from '../../common/constants';
import { fromNow } from '../../common/utils';
import { Account, AccountOrigins, OriginStats, OriginInfo, Origin, ClearOrignsOptions } from '../../common/adminInterfaces';
import { updateAccount } from '../db';
import { AdminService } from '../services/adminService';
export async function getOriginStats(accounts: Account[]): Promise<OriginStats> {
let totalOrigins = 0;
let totalOriginsIP4 = 0;
let totalOriginsIP6 = 0;
const distribution: number[] = [];
const uniques = new Set<string>();
const duplicates = new Set<string>();
for (const account of accounts) {
if (account.origins) {
for (const origin of account.origins) {
totalOrigins++;
if (uniques.has(origin.ip)) {
duplicates.add(origin.ip);
} else {
uniques.add(origin.ip);
}
if (origin.ip.indexOf(':') !== -1) {
totalOriginsIP6++;
} else {
totalOriginsIP4++;
}
}
}
const count = account.origins ? account.origins.length : 0;
while (distribution.length <= count) {
distribution.push(0);
}
distribution[count]++;
}
const uniqueOrigins = uniques.size;
const duplicateOrigins = duplicates.size;
const singleOrigins = uniqueOrigins - duplicateOrigins;
return {
uniqueOrigins, duplicateOrigins, singleOrigins, totalOrigins, totalOriginsIP4, totalOriginsIP6, distribution
};
}
export function removeAllOrigins(service: AdminService, accountId: string) {
service.removeOriginsFromAccount(accountId);
return updateAccount(accountId, { origins: [] });
}
export function removeOrigins(service: AdminService, accountId: string, ips: string[]) {
service.removeOriginsFromAccount(accountId, ips);
return updateAccount(accountId, { $pull: { origins: { ip: { $in: ips } } } });
}
export function addOrigin(accountId: string, { ip, country }: OriginInfo) {
return updateAccount(accountId, { $push: { origins: { ip, country, last: new Date() } } });
}
export async function clearOriginsForAccount(service: AdminService, accountId: string, options: ClearOrignsOptions) {
const account = service.accounts.get(accountId);
if (account) {
const { ips } = getOriginsToRemove(account, options);
await removeOrigins(service, accountId, ips);
}
}
export async function clearOriginsForAccounts(service: AdminService, accounts: string[], options: ClearOrignsOptions) {
await Bluebird.map(accounts, id => clearOriginsForAccount(service, id, options), { concurrency: 4 });
}
export async function clearOrigins(
service: AdminService, count: number, andHigher: boolean, options: ClearOrignsOptions
) {
const origins = service.accounts.items
.filter(a => a.originsRefs && (andHigher ? a.originsRefs.length >= count : a.originsRefs.length === count))
.map(a => getOriginsToRemove(a, options))
.filter(({ ips }) => !!ips.length);
await Bluebird.map(origins, o => removeOrigins(service, o.accountId, o.ips), { concurrency: 4 });
}
const isBanned = (origin: Origin) => origin.ban || origin.mute || origin.shadow;
function getOriginsToRemove(account: Account, { old, singles, trim, veryOld, country }: ClearOrignsOptions): AccountOrigins {
const date = fromNow((veryOld ? -90 : -14) * DAY).getTime();
const originsRefs = account.originsRefs || [];
const filtered = country ?
originsRefs.filter(({ origin }) => origin.country === country) :
originsRefs.filter(({ last, origin }) => {
return (!old || (!last || last.getTime() < date))
&& (!singles || origin.accounts!.length === 1)
&& !isBanned(origin);
});
const ips = filtered.map(({ origin }) => origin.ip);
if (trim) {
ips.push(...difference(originsRefs.map(({ origin }) => origin.ip), ips).slice(10));
}
return { accountId: account._id, ips };
}
+112
View File
@@ -0,0 +1,112 @@
import * as Bluebird from 'bluebird';
import { escapeRegExp } from 'lodash';
import { FindPonyQuery } from '../../common/adminInterfaces';
import { updateCharacterCount, getCharacterLimit } from '../accountUtils';
import { Character, findAccountSafe, ICharacter } from '../db';
import { RemovedDocument } from '../internal';
import { MINUTE } from '../../common/constants';
import { cached } from '../serverUtils';
import { logRemovedCharacter } from '../characterUtils';
import { kickFromAllServersByCharacter } from './admin';
import { AdminService } from '../services/adminService';
const ITEMS_PER_PAGE = 20;
const ITEMS_LIMIT = 1000;
const CACHE_TIMEOUT = 10 * MINUTE;
function createQuery({ search }: FindPonyQuery) {
const and: any[] = [];
if (search) {
if (search === 'orphan') {
and.push({ account: { $exists: false } });
} else if (/^exact:/.test(search)) {
and.push({ name: new RegExp(`^${escapeRegExp(search.substr(6))}$`, 'i') });
} else {
and.push({ name: new RegExp(escapeRegExp(search), 'i') });
}
}
return and.length === 0 ? {} : (and.length === 1 ? and[0] : { $and: and });
}
async function getPonyIds(query: FindPonyQuery) {
const items: ICharacter[] = await Character
.find(createQuery(query), '_id')
.sort(query.orderBy || 'createdAt')
.limit(ITEMS_LIMIT)
.lean()
.exec();
return items.map(i => i._id.toString());
}
const cachedGetPonyIds = cached(getPonyIds, CACHE_TIMEOUT);
export async function findPonies(query: FindPonyQuery, page: number) {
const from = page * ITEMS_PER_PAGE;
const ids = await cachedGetPonyIds(query);
const idsOnPage = ids.slice(from, from + ITEMS_PER_PAGE);
return {
items: idsOnPage,
totalCount: ids.length
};
}
export async function assignCharacter(characterId: string, accountId: string) {
const character = await Character.findById(characterId).exec();
if (!character)
return;
await kickFromAllServersByCharacter(characterId);
await Character.updateOne({ _id: characterId }, { account: accountId }).exec();
await Promise.all([
updateCharacterCount(character.account),
updateCharacterCount(accountId),
]);
}
export async function removeCharacter(service: AdminService, characterId: string) {
const character = await Character.findById(characterId).exec();
if (!character)
return;
await kickFromAllServersByCharacter(characterId);
await character.remove();
await updateCharacterCount(character.account);
logRemovedCharacter(character);
service.ponies.removed(characterId);
}
async function removeCharacters(character: ICharacter[], accountId: string, removedDocument: RemovedDocument) {
await Bluebird.map(character, async c => {
await c.remove();
await removedDocument('ponies', c._id.toString());
logRemovedCharacter(c);
}, { concurrency: 4 });
await updateCharacterCount(accountId);
}
export async function removeCharactersAboveLimit(removedDocument: RemovedDocument, accountId: string) {
const [account, items] = await Promise.all([
findAccountSafe(accountId),
Character.find({ account: accountId }).sort({ lastUsed: -1 }).exec(),
]);
const limited = items.slice(getCharacterLimit(account));
await removeCharacters(limited, accountId, removedDocument);
}
export async function removeAllCharacters(removedDocument: RemovedDocument, accountId: string) {
const items = await Character.find({ account: accountId }).sort({ lastUsed: -1 }).exec();
await removeCharacters(items, accountId, removedDocument);
}
export async function createCharacter(account: string, name: string, info: string) {
await Character.create({ account, name, info });
await updateCharacterCount(account);
}
+148
View File
@@ -0,0 +1,148 @@
import { PonyObject, PonyInfoNumber } from '../../common/interfaces';
import { CharacterFlags } from '../../common/adminInterfaces';
import { cleanName, validatePonyName } from '../../client/clientUtils';
import { Reporter, LogAccountMessage } from '../serverInterfaces';
import { toPonyObject } from '../serverUtils';
import { isForbiddenName } from '../../common/security';
import { colorToHexRGB } from '../../common/color';
import { IAccount, FindCharacter, FindAuth, CharacterCount, ID, CreateCharacter, ICharacter } from '../db';
import { isBadCM } from '../cmUtils';
import { UserError } from '../userError';
import { CHARACTER_SAVING_ERROR, CHARACTER_LIMIT_ERROR } from '../../common/errors';
import { decompressPony, compressPony } from '../../common/compressPony';
import { getCharacterLimit } from '../accountUtils';
import { PLAYER_DESC_MAX_LENGTH } from '../../common/constants';
function colorToText(c: number): string {
return c ? colorToHexRGB(c) : '';
}
export type UpdateCharacterCount = (accountId: ID) => Promise<void>;
export type RemoveCharacter = (ponyId: string, accountId: string) => Promise<ICharacter | undefined>;
export type SavePony = ReturnType<typeof createSavePony>;
export type RemovePony = ReturnType<typeof createRemovePony>;
export const createSavePony =
(
findCharacter: FindCharacter, findAuth: FindAuth, characterCount: CharacterCount,
updateCharacterCount: UpdateCharacterCount, createCharacter: CreateCharacter, log: LogAccountMessage,
isSuspiciousName: (name: string) => boolean, isSuspiciousPony: (info: PonyInfoNumber) => boolean,
) =>
async (account: IAccount, data: Partial<PonyObject> | undefined, reporter: Reporter): Promise<PonyObject | null> => {
if (!data || !data.info || typeof data.name !== 'string') {
throw new UserError('Invalid data', { data });
}
const originalName = data.name;
data.name = cleanName(data.name);
if (!validatePonyName(data.name)) {
throw new UserError('Invalid name', { desc: JSON.stringify(originalName), data });
}
let [character, auth] = await Promise.all([
data.id ? findCharacter(data.id, account._id) : undefined,
data.site ? findAuth(data.site, account._id, '_id') : undefined,
]).catch(error => {
throw new UserError('Invalid data', { error, data });
});
let suspicious: string[] = [];
let created = false;
let nameChanged = false;
let oldName: string | undefined;
try {
if (!character) {
character = createCharacter(account);
created = true;
}
const deco = decompressPony(data.info);
const info = compressPony(deco);
// if (data.info !== info) {
// reporter.danger(`Pony info does not match after re-compression`, `original: ${data.info}\nre-compressed: ${info}`);
// }
const badCM = isBadCM(deco.cm && deco.cm.map(colorToText) || [], colorToHexRGB(deco.coatFill!));
const forbiddenName = isForbiddenName(data.name);
const flags =
(badCM ? CharacterFlags.BadCM : 0) |
(data.hideSupport ? CharacterFlags.HideSupport : 0) |
(data.respawnAtSpawn ? CharacterFlags.RespawnAtSpawn : 0) |
(forbiddenName ? CharacterFlags.ForbiddenName : 0);
nameChanged = character.name !== data.name;
oldName = character.name;
if (nameChanged && isSuspiciousName(data.name)) {
suspicious.push('name');
}
if (character.info !== data.info && isSuspiciousPony(deco)) {
suspicious.push('look');
}
character.desc = typeof data.desc === 'string' ? data.desc.substr(0, PLAYER_DESC_MAX_LENGTH) : '';
character.name = data.name;
character.tag = data.tag;
character.site = auth ? auth._id : null;
character.info = info;
character.flags = flags;
character.lastUsed = new Date();
} catch (error) {
const message = DEVELOPMENT ? `${CHARACTER_SAVING_ERROR} (${error})` : CHARACTER_SAVING_ERROR;
throw new UserError(message, { error, data: { pony: data }, desc: `info: "${data.info}"` });
}
const count = created ? await characterCount(account._id) : 0;
if (count >= getCharacterLimit(account)) {
throw new UserError(CHARACTER_LIMIT_ERROR);
}
await character.save();
if (created) {
await updateCharacterCount(account._id);
}
if (suspicious.length) {
reporter.setPony(character._id.toString());
reporter.warn('Suspicious pony created', `"${character.name}" (${suspicious.join(', ')})`);
}
if (created) {
log(account._id, `created pony "${character.name}"`);
} else if (nameChanged) {
log(account._id, `renamed pony "${oldName}" => "${character.name}"`);
}
return toPonyObject(character);
};
export const createRemovePony =
(
kickFromAllServersByCharacter: (ponyId: string) => void,
removeCharacter: RemoveCharacter,
updateCharacterCount: UpdateCharacterCount,
removedCharacter: (ponyId: string) => void,
logRemovedCharacter: (character: ICharacter) => void,
) =>
async (ponyId: unknown, accountId: string) => {
if (!ponyId || typeof ponyId !== 'string') {
throw new Error(`Invalid ponyId (${ponyId})`);
}
await kickFromAllServersByCharacter(ponyId);
const character = await removeCharacter(ponyId, accountId);
await updateCharacterCount(accountId);
if (character) {
logRemovedCharacter(character);
removedCharacter(ponyId);
}
return {};
};
+106
View File
@@ -0,0 +1,106 @@
import { uniq } from 'lodash';
import { Types } from 'mongoose';
import { Profile } from '../common/interfaces';
import { arraysEqual } from '../common/utils';
import { IAccount, IAuth, Auth, findAuthByOpenId, updateAuth, UpdateAuth, Account } from './db';
import { system } from './logger';
import { UserError } from './userError';
import { CreateAccountOptions, connectOnlySocialError } from './accountUtils';
export async function assignAuth(auth: IAuth, account: IAccount) {
if (!auth.account || !auth.account.equals(account._id)) {
system(account._id, `connected auth ${auth.name} [${auth._id}]`);
await updateAuth(auth._id, { account: account._id });
return true;
} else {
return false;
}
}
export async function findOrCreateAuth(profile: Profile, accountId: string | undefined, options: CreateAccountOptions) {
let auth = await findAuthByOpenId(profile.id, profile.provider);
if (auth) {
await updateAuthInfo(updateAuth, auth, profile, accountId);
} else {
if (options.connectOnly && !accountId) {
if (profile.emails.length) {
const account = await Account.findOne({ emails: { $in: profile.emails } }).exec();
if (!account) {
throw new UserError(connectOnlySocialError);
}
} else {
throw new UserError(connectOnlySocialError);
}
}
auth = await createAuth(profile, accountId);
}
await verifyOrRestoreAuth(auth, accountId);
return auth;
}
export async function updateAuthInfo(
updateAuth: UpdateAuth, auth: IAuth | undefined, profile: Profile, accountId: string | undefined
) {
if (!auth)
return;
const changes: Partial<IAuth> = {};
if (profile.url && auth.url !== profile.url) {
changes.url = profile.url;
}
if (profile.username && auth.name !== profile.username) {
changes.name = profile.username;
}
if (profile.emails && profile.emails.length) {
if (!auth.emails || !arraysEqual(auth.emails.sort(), profile.emails.sort())) {
changes.emails = uniq([...(auth.emails || []), ...profile.emails]);
}
}
if (!auth.account && accountId) {
changes.account = Types.ObjectId(accountId);
}
if (Object.keys(changes).length > 0) {
Object.assign(auth, changes);
await updateAuth(auth._id, changes);
}
}
async function createAuth(profile: Profile, account: string | undefined) {
if (!profile.id) {
throw new Error('Missing profile ID');
}
return await Auth.create(<IAuth>{
account,
openId: profile.id,
provider: profile.provider,
name: profile.username,
url: profile.url,
emails: profile.emails || [],
lastUsed: new Date(),
});
}
async function verifyOrRestoreAuth(auth: IAuth, mergeAccount: string | undefined) {
const changes: Partial<IAuth> = { lastUsed: new Date() };
if (auth.disabled || auth.banned) {
if (!auth.banned && auth.account && !!mergeAccount) {
changes.disabled = false;
} else {
throw new UserError('Cannot sign-in using this social account');
}
}
Object.assign(auth, changes);
await updateAuth(auth._id, changes);
}
+19
View File
@@ -0,0 +1,19 @@
/// <reference path="../../typings/my.d.ts" />
require('source-map-support').install();
import 'core-js/stable/promise/finally';
import 'reflect-metadata';
import * as Promise from 'bluebird';
import * as fs from 'fs';
import { argv } from 'yargs';
(global as any).DEVELOPMENT = process.env.NODE_ENV !== 'production';
(global as any).BETA = !!argv.beta;
(global as any).TOOLS = !!argv.tools;
(global as any).SERVER = true;
(global as any).TIMING = false;
(global as any).TESTS = false;
(global as any).performance = Date;
Promise.promisifyAll(fs);
+22
View File
@@ -0,0 +1,22 @@
/// <reference path="../../typings/my.d.ts" />
import { readFileAsync, readFileSync } from 'fs';
import { createCanvas as createNodeCanvas, Image } from 'canvas';
import { setup } from '../client/canvasUtils';
export const createCanvas = createNodeCanvas;
export async function loadImage(src: string) {
const buffer = await readFileAsync(src);
const image = new Image();
image.src = buffer;
return image;
}
export function loadImageSync(src: string) {
const image = new Image();
image.src = readFileSync(src);
return image;
}
setup({ createCanvas: createNodeCanvas, loadImage });
+220
View File
@@ -0,0 +1,220 @@
import { repeat } from 'lodash';
import { toByteArray } from 'base64-js';
import { encodeString } from 'ag-sockets/dist/utf8';
import { PonyOptions, EntityState, UpdateFlags } from '../common/interfaces';
import { ICharacter, IAccount, Character, MongoQuery, queryCharacter } from './db';
import { isForbiddenName } from '../common/security';
import { supporterLevel } from '../common/adminUtils';
import { CharacterFlags, CharacterState, ServerConfig, CharacterStateFlags } from '../common/adminInterfaces';
import { hasFlag, randomPoint, bitmask, last, computeCRC } from '../common/utils';
import { log, systemMessage, logger } from './logger';
import { World } from './world';
import { ServerEntity, ServerMap, IClient } from './serverInterfaces';
import { pony as ponyEntity, getEntityType } from '../common/entities';
import { PONY_INFO_KEY, SWAP_TIMEOUT } from '../common/constants';
import { decompressPony, compressPony } from '../common/compressPony';
import { canFly, canMagic } from '../client/ponyUtils';
import { canUseTag } from '../common/tags';
import { CounterService } from './services/counter';
import { replaceEmojis } from '../client/emoji';
import { setEntityName, pushUpdateEntity } from './entityUtils';
import { saySystem } from './chat';
import { isPonyFlying } from '../common/entityUtils';
import { createCharacterState, updateClientCharacter } from './playerUtils';
import { encodeExpression } from '../common/encoders/expressionEncoder';
export const defaultCharacterState: CharacterState = { x: 0, y: 0 };
export function encryptInfo(info: string) {
return bitmask(toByteArray(info), PONY_INFO_KEY);
}
export function createPony(account: IAccount, character: ICharacter, state: CharacterState) {
const pony = ponyEntity(state.x, state.y) as ServerEntity;
pony.state = hasFlag(state.flags, CharacterStateFlags.Right) ? EntityState.FacingRight : 0;
updatePony(pony, account, character);
updatePonyFromState(pony, state);
cleanupPonyOptions(pony);
return pony;
}
function createDefaultCharacterState(map: ServerMap): CharacterState {
return {
...defaultCharacterState,
...randomPoint(map.spawnArea),
map: map.id,
};
}
export function getCharacterState(character: ICharacter, serverId: string, map: ServerMap): CharacterState {
return character.state && character.state[serverId] || createDefaultCharacterState(map);
}
export async function updateCharacterState(characterId: string, serverId: string, state: CharacterState) {
await Character.updateOne({ _id: characterId }, { [`state.${serverId}`]: state }).exec();
}
export function getAndFixCharacterState(
server: ServerConfig, character: ICharacter, world: World, states: CounterService<CharacterState>
): CharacterState {
const map = world.getMainMap();
const savedState = last(states.get(character._id.toString()).items) || getCharacterState(character, server.id, map);
const state = { ...defaultCharacterState, ...savedState };
if (hasFlag(character.flags, CharacterFlags.RespawnAtSpawn)) {
Object.assign(state, { map: map.id, ...randomPoint(map.spawnArea) });
}
return state;
}
export function updatePonyFromState(pony: ServerEntity, state: CharacterState) {
if (!pony.options) {
pony.options = {};
}
if (state.hold) {
const type = getEntityType(state.hold);
if (type) {
pony.options.hold = type;
}
} else if (pony.options.hold) {
pony.options.hold = 0;
}
if (state.toy) {
pony.options.toy = state.toy;
} else if (pony.options.toy) {
pony.options.toy = 0;
}
pony.options.extra = hasFlag(state.flags, CharacterStateFlags.Extra);
}
export function cleanupPonyOptions({ options }: ServerEntity) {
if (options) {
if (!options.hold) {
delete options.hold;
}
if (!options.extra) {
delete options.extra;
}
}
}
export function filterForbidden(name: string) {
const isForbidden = isForbiddenName(name);
return isForbidden ? repeat('?', name.length) : name;
}
export function updatePony(pony: ServerEntity, account: IAccount, character: ICharacter) {
const info = character.info || '';
const ponyInfo = decompressPony(info);
const originalName = replaceEmojis(character.name);
const allowedName = filterForbidden(originalName);
const options: PonyOptions = {};
const level = supporterLevel(account);
if (character.tag && canUseTag(account, character.tag)) {
options.tag = character.tag;
} else if (level && !hasFlag(character.flags, CharacterFlags.HideSupport)) {
options.tag = `sup${level}`;
}
pony.options = options;
pony.extraOptions = createExtraOptions(character);
pony.canFly = canFly(ponyInfo);
pony.canMagic = canMagic(ponyInfo);
// name
setEntityName(pony, allowedName);
// info
pony.info = info;
if (hasFlag(character.flags, CharacterFlags.BadCM) && ponyInfo.cm) {
ponyInfo.cm = undefined;
pony.infoSafe = compressPony(ponyInfo);
pony.encryptedInfoSafe = encryptInfo(pony.infoSafe);
} else {
pony.infoSafe = pony.info;
pony.encryptedInfoSafe = encryptInfo(info);
}
// crc
pony.crc = createCharacterCRC(account._id.toString(), originalName);
}
function createCharacterCRC(accountId: string, characterName: string) {
const characterNameBuffer = encodeString(characterName)!;
const accountIdBuffer = encodeString(accountId)!;
const buffer = new Uint32Array(Math.ceil((characterNameBuffer.byteLength + accountIdBuffer.byteLength) / 4));
const bufferUint8 = new Uint8Array(buffer.buffer);
bufferUint8.set(characterNameBuffer);
bufferUint8.set(accountIdBuffer, characterNameBuffer.byteLength);
return computeCRC(buffer) & 0xffff;
}
export function createExtraOptions(character: ICharacter) {
const options: any = {
ex: true,
};
if (character.auth && !isForbiddenName(character.auth.name)) {
options.site = {
provider: character.auth.provider,
name: character.auth.name,
url: character.auth.url,
};
}
return options;
}
export function logRemovedCharacter({ _id, account, name, info }: ICharacter) {
log(systemMessage(`${account}`, `removed pony [${_id}] "${name}" ${info}`));
}
export async function swapCharacter(client: IClient, { server }: World, query: MongoQuery<ICharacter>) {
if (client.isSwitchingMap)
return;
if ((Date.now() - client.lastSwap) < SWAP_TIMEOUT) {
return;
}
const character = await queryCharacter(query);
if (!character) {
return saySystem(client, `Can't find character`);
}
if (isPonyFlying(client.pony) && !canFly(decompressPony(character.info || ''))) {
return saySystem(client, `Can't swap to that character in-flight`);
}
const state = createCharacterState(client.pony, client.map);
updateCharacterState(client.characterId, server.id, state)
.catch(logger.error);
Character.updateOne({ _id: character._id }, { lastUsed: new Date() }).exec()
.catch(logger.error);
updateClientCharacter(client, character);
updatePony(client.pony, client.account, client.character);
updatePonyFromState(client.pony, getCharacterState(character, server.id, client.map));
const options = client.pony.options as PonyOptions;
options.expr = encodeExpression(undefined);
client.pony.state &= ~EntityState.Magic;
pushUpdateEntity({
entity: client.pony, options: { hold: 0, toy: 0, ...options },
flags: UpdateFlags.Info | UpdateFlags.Name | UpdateFlags.Options | UpdateFlags.State,
});
cleanupPonyOptions(client.pony);
client.myEntity(client.pony.id, client.characterName, client.character.info!, client.characterId, client.pony.crc || 0);
client.reporter.systemLog(`Swapped to "${client.characterName}"`);
client.lastSwap = Date.now();
}
+305
View File
@@ -0,0 +1,305 @@
import { repeat } from 'lodash';
import { isForbiddenMessage, createIsSuspiciousMessage } from '../common/security';
import {
ChatType, MessageType, Action, LeaveReason, isPublicChat, isPartyChat, isPublicMessage, isPartyMessage,
toMessageType, isWhisper, isWhisperTo
} from '../common/interfaces';
import { trimRepeatedLetters, urlRegexTexts, ipRegexText, urlExceptionRegex } from '../common/filterUtils';
import { parseExpression } from '../common/expressionUtils';
import { filterBadWords } from '../common/swears';
import { cleanMessage } from '../client/clientUtils';
import { parseCommand, getChatPrefix, RunCommand } from './commands';
import { IClient, OnSuspiciousMessage, ServerEntity, OnMessageSettings } from './serverInterfaces';
import { World } from './world';
import { setEntityExpression, isHiddenBy, isMutedOrShadowed, isIgnored, execAction } from './playerUtils';
import { Suspicious, GameServerSettings } from '../common/adminInterfaces';
import { isFriend } from './services/friends';
import { invalidEnumReturn } from '../common/utils';
import { isWorldPointWithPaddingVisible } from '../common/camera';
import { tileWidth } from '../common/constants';
function isLaugh(message: string): boolean {
return /(^| )(ha(ha)+|he(he)+|ja(ja)+|ха(ха)+|lol|rofl)$/i.test(message);
}
function isIP(match: string): boolean {
const parts = match.split(/\./g);
return !parts.some(p => /^0\d+$/.test(p)) && parts.map(x => parseInt(x, 10)).every(x => x >= 0 && x <= 255);
}
function replaceIP(match: string) {
return isIP(match) ? '[LINK]' : match;
}
const urlRegexes = urlRegexTexts.map(text => new RegExp(text, 'uig'));
const ipRegex = new RegExp(ipRegexText, 'uig');
function replaceLink(value: string) {
return urlExceptionRegex.test(value) ? value : '[LINK]';
}
export function filterUrls(message: string): string {
message = message.replace(ipRegex, replaceIP);
for (const regex of urlRegexes) {
message = message.replace(regex, replaceLink);
}
return message;
}
// non-party messages
function getMessageType(client: IClient, type: ChatType) {
switch (type) {
case ChatType.Say:
case ChatType.Party:
return MessageType.Chat;
case ChatType.Supporter:
switch (client.supporterLevel) {
case 1: return MessageType.Supporter1;
case 2: return MessageType.Supporter2;
case 3: return MessageType.Supporter3;
default: return MessageType.Chat;
}
case ChatType.Supporter1:
return client.supporterLevel >= 1 ? MessageType.Supporter1 : MessageType.Chat;
case ChatType.Supporter2:
return client.supporterLevel >= 2 ? MessageType.Supporter2 : MessageType.Chat;
case ChatType.Supporter3:
return client.supporterLevel >= 3 ? MessageType.Supporter3 : MessageType.Chat;
case ChatType.Think:
case ChatType.PartyThink:
return MessageType.Thinking;
case ChatType.Dismiss:
return MessageType.Dismiss;
case ChatType.Whisper:
return MessageType.Whisper;
default:
return invalidEnumReturn(type, MessageType.Chat);
}
}
export type LogChat = (client: IClient, text: string, type: ChatType, ignored: boolean, target: IClient | undefined) => void;
export type IsSuspiciousMessage = ReturnType<typeof createIsSuspiciousMessage>;
export type Say = ReturnType<typeof createSay>;
export const createSay =
(
world: World, runCommand: RunCommand, log: LogChat, checkSpam: OnMessageSettings, reportSwears: OnMessageSettings,
reportForbidden: OnMessageSettings, reportSuspicious: OnSuspiciousMessage, spamCommands: string[],
random: () => number, isSuspiciousMessage: IsSuspiciousMessage,
) =>
(client: IClient, text: string, chatType: ChatType, target: IClient | undefined, settings: GameServerSettings) => {
text = cleanMessage(text);
const { command, args, type } = parseCommand(text, chatType);
const whisper = type === ChatType.Whisper;
if (!command && !args)
return;
if (whisper && client === target)
return;
const forbidden = command == null && isPublicChat(type) && isForbiddenMessage(args);
log(client, text, type, forbidden, target);
const suspicious = isSuspiciousMessage(args, settings);
if (suspicious !== Suspicious.No) {
reportSuspicious(client, `${getChatPrefix(type)}${text}`, suspicious);
}
if (command != null) {
if (runCommand(client, command, args, type, target, settings)) {
if (type !== ChatType.Party && spamCommands.indexOf(command) !== -1) {
if (!client.map.instance) {
checkSpam(client, text, settings);
}
}
} else {
const expression = parseExpression(text.substr(1));
if (expression) {
setEntityExpression(client.pony, expression);
} else {
saySystem(client, 'Invalid command');
}
}
} else {
const message = args;
const think = type === ChatType.Think || type === ChatType.PartyThink;
const expression = (think || whisper) ? undefined : parseExpression(message);
if (expression) {
setEntityExpression(client.pony, expression);
} else if (!whisper && isLaugh(message)) {
execAction(client, Action.Laugh, settings);
}
if (isPartyChat(type)) {
sayToParty(client, message, think ? MessageType.PartyThinking : MessageType.Party);
} else {
const friendWhisper = whisper && target !== undefined && isFriend(client, target);
const messageNoLinks = filterUrls(message);
const messageCensored = forbidden ? repeat('*', messageNoLinks.length) : filterBadWords(messageNoLinks);
const trimmedMessage = trimRepeatedLetters(messageNoLinks);
const trimmedCensored = trimRepeatedLetters(messageCensored);
const messageType = getMessageType(client, type);
const swearing = messageNoLinks !== messageCensored;
if (!friendWhisper) {
if (!client.map.instance) {
checkSpam(client, message, settings);
}
if (settings.filterSwears && swearing) {
reportSwears(client, message, settings);
}
if (forbidden) {
reportForbidden(client, message, settings);
}
}
if (!friendWhisper && swearing && settings.kickSwearing && random() < 0.75) {
if (settings.kickSwearingToSpawn) {
world.resetToSpawn(client);
}
world.kick(client, 'swearing', LeaveReason.Swearing);
} else if (!friendWhisper && forbidden) {
sayTo(client, client.pony, trimmedMessage, messageType);
} else if (whisper) {
sayWhisper(client, trimmedMessage, trimmedCensored, messageType, target, settings);
} else {
sayToEveryone(client, trimmedMessage, trimmedCensored, messageType, settings);
}
}
}
};
export function sayTo(client: IClient, { id }: ServerEntity, message: string, type: MessageType) {
client.saysQueue.push([id, message, type]);
}
export function saySystem(client: IClient, message: string) {
sayTo(client, client.pony, message, MessageType.System);
}
function sayToClient(
client: IClient, entity: ServerEntity, message: string, censoredMessage: string, type: MessageType,
settings: GameServerSettings
): boolean {
if (client.pony !== entity && !isWhisperTo(type)) {
const swear = !!settings.hideSwearing && message !== censoredMessage;
if (isPublicMessage(type)) {
if (swear) {
return false;
}
if (!isWorldPointWithPaddingVisible(client.camera, entity, tileWidth * 2)) {
return false;
}
}
if (entity.client) {
if (isIgnored(client, entity.client)) {
return false;
}
if (!client.isMod && isHiddenBy(client, entity.client)) {
return false;
}
if (swear && !isFriend(client, entity.client)) {
return false;
}
}
if (client.accountSettings.filterSwearWords || settings.filterSwears) {
message = censoredMessage;
}
}
sayTo(client, entity, message, type);
return true;
}
function sayWhisper(
client: IClient, message: string, censoredMessage: string, type: MessageType,
target: IClient | undefined, settings: GameServerSettings
) {
if (target === undefined || target.shadowed || isHiddenBy(client, target)) {
saySystem(client, `Couldn't find this player`);
} else {
const friend = isFriend(client, target);
if (!friend && client.accountSettings.ignoreNonFriendWhispers) {
saySystem(client, `You can only whisper to friends`);
} else if (!friend && target.accountSettings.ignoreNonFriendWhispers) {
saySystem(client, `Can't whisper to this player`);
} else {
sayTo(client, target.pony, message, toMessageType(type));
if (!isMutedOrShadowed(client)) {
sayToClient(target, client.pony, message, censoredMessage, type, settings);
}
}
}
}
function sayToParty(client: IClient, message: string, type: MessageType) {
if (!client.party) {
saySystem(client, `you're not in a party`);
} else if (isMutedOrShadowed(client)) {
sayTo(client, client.pony, message, type);
} else {
for (const c of client.party.clients) {
sayTo(c, client.pony, message, type);
}
}
}
export function sayToAll(
entity: ServerEntity, message: string, censoredMessage: string, type: MessageType, settings: GameServerSettings
) {
if (entity.region) {
for (const client of entity.region.clients) {
sayToClient(client, entity, message, censoredMessage, type, settings);
}
}
}
export function sayToEveryone(
client: IClient, message: string, censoredMessage: string, type: MessageType, settings: GameServerSettings
) {
if (
isMutedOrShadowed(client) ||
client.accountSettings.ignorePublicChat
) {
sayTo(client, client.pony, message, type);
} else {
sayToAll(client.pony, message, censoredMessage, type, settings);
}
}
export function sayToOthers(
client: IClient, message: string, type: MessageType, target: IClient | undefined, settings: GameServerSettings
) {
if (isWhisper(type)) {
sayWhisper(client, message, message, type, target, settings);
} else if (isPartyMessage(type)) {
sayToParty(client, message, type);
} else {
sayToEveryone(client, message, message, type, settings);
}
}
export const sayToClientTest = sayToClient;
export const sayToPartyTest = sayToParty;
export const sayWhisperTest = sayWhisper;
+518
View File
@@ -0,0 +1,518 @@
import { uniq, compact } from 'lodash';
import { hex } from 'color-convert';
import { getDeltaE00, LAB } from 'delta-e';
import { repeat } from '../common/utils';
import { CM_SIZE } from '../common/constants';
const patterns = [
[ // 0
1, 0, 1, 1, 1,
1, 0, 1, 0, 0,
1, 1, 1, 1, 1,
0, 0, 1, 0, 1,
1, 1, 1, 0, 1,
],
[ // 1
1, 1, 1, 0, 1,
0, 0, 1, 0, 1,
1, 1, 1, 1, 1,
1, 0, 1, 0, 0,
1, 0, 1, 1, 1,
],
[ // 2
1, 0, 1, 1, 1,
1, 0, 1, 0, 0,
1, 1, 0, 1, 1,
0, 0, 1, 0, 1,
1, 1, 1, 0, 1,
],
[ // 3
1, 1, 1, 0, 1,
0, 0, 1, 0, 1,
1, 1, 0, 1, 1,
1, 0, 1, 0, 0,
1, 0, 1, 1, 1,
],
[ // 4
0, 0, 1, 1, 0,
1, 0, 1, 0, 0,
1, 1, 1, 1, 1,
0, 0, 1, 0, 1,
0, 1, 1, 0, 0,
],
[ // 5
0, 0, 1, 1, 0,
1, 0, 1, 0, 0,
1, 1, 0, 1, 1,
0, 0, 1, 0, 1,
0, 1, 1, 0, 0,
],
[ // 6
0, 1, 1, 0, 0,
0, 0, 1, 0, 1,
1, 1, 1, 1, 1,
1, 0, 1, 0, 0,
0, 0, 1, 1, 0,
],
[ // 7
1, 1, 1, 0, 0,
0, 0, 1, 0, 1,
1, 1, 1, 1, 1,
1, 0, 1, 0, 0,
0, 0, 1, 1, 1,
],
[ // 8
0, 1, 1, 0, 1,
0, 0, 1, 0, 1,
1, 1, 1, 1, 1,
1, 0, 1, 0, 0,
1, 0, 1, 1, 0,
],
[ // 9
0, 1, 1, 0, 0,
0, 0, 1, 0, 1,
1, 1, 0, 1, 1,
1, 0, 1, 0, 0,
0, 0, 1, 1, 0,
],
[ // 10
0, 1, 1, 0, 0,
0, 0, 1, 0, 1,
1, 1, 0, 1, 1,
1, 0, 1, 0, 0,
0, 0, 1, 1, 0,
],
[ // 11
0, 0, 1, 1, 1,
1, 0, 1, 0, 0,
1, 1, 1, 1, 1,
0, 0, 1, 0, 1,
1, 1, 1, 0, 0,
],
[ // 12
1, 0, 1, 1, 0,
1, 0, 1, 0, 0,
1, 1, 1, 1, 1,
0, 0, 1, 0, 1,
0, 1, 1, 0, 1,
],
// short arms
[ // 13
1, 0, 1, 1, 0,
1, 0, 1, 0, 0,
1, 1, 1, 1, 1,
0, 0, 1, 0, 1,
1, 1, 1, 0, 1,
],
[ // 14
0, 0, 1, 1, 1,
1, 0, 1, 0, 0,
1, 1, 1, 1, 1,
0, 0, 1, 0, 1,
1, 1, 1, 0, 1,
],
[ // 15
1, 0, 1, 1, 1,
1, 0, 1, 0, 0,
1, 1, 1, 1, 1,
0, 0, 1, 0, 1,
0, 1, 1, 0, 1,
],
[ // 16
1, 0, 1, 1, 1,
1, 0, 1, 0, 0,
1, 1, 1, 1, 1,
0, 0, 1, 0, 1,
1, 1, 1, 0, 0,
],
// short arms inverted
[
1, 1, 1, 0, 0,
0, 0, 1, 0, 1,
1, 1, 1, 1, 1,
1, 0, 1, 0, 0,
1, 0, 1, 1, 1,
],
[
1, 1, 1, 0, 1,
0, 0, 1, 0, 1,
1, 1, 1, 1, 1,
1, 0, 1, 0, 0,
1, 0, 1, 1, 0,
],
[
1, 1, 1, 0, 1,
0, 0, 1, 0, 1,
1, 1, 1, 1, 1,
1, 0, 1, 0, 0,
0, 0, 1, 1, 1,
],
[ // 20
0, 1, 1, 0, 1,
0, 0, 1, 0, 1,
1, 1, 1, 1, 1,
1, 0, 1, 0, 0,
1, 0, 1, 1, 1,
],
// long arms
[
0, 0, 1, 1, 0,
1, 0, 1, 0, 0,
1, 1, 1, 1, 1,
0, 0, 1, 0, 1,
0, 1, 1, 0, 1,
],
[
0, 0, 1, 1, 0,
1, 0, 1, 0, 0,
1, 1, 1, 1, 1,
0, 0, 1, 0, 1,
1, 1, 1, 0, 0,
],
[
1, 0, 1, 1, 0,
1, 0, 1, 0, 0,
1, 1, 1, 1, 1,
0, 0, 1, 0, 1,
0, 1, 1, 0, 0,
],
[
0, 0, 1, 1, 1,
1, 0, 1, 0, 0,
1, 1, 1, 1, 1,
0, 0, 1, 0, 1,
0, 1, 1, 0, 0,
],
// no corners
[ // 25
1, 0, 0, 1, 1,
1, 0, 1, 0, 0,
0, 1, 1, 1, 0,
0, 0, 1, 0, 1,
1, 1, 0, 0, 1,
],
[
1, 1, 0, 0, 1,
0, 0, 1, 0, 1,
0, 1, 1, 1, 0,
1, 0, 1, 0, 0,
1, 0, 0, 1, 1,
],
// small
[
0, 1, 1, 0, 1,
0, 0, 1, 1, 1,
0, 1, 1, 1, 0,
0, 1, 0, 1, 1,
0, 0, 0, 0, 0,
],
[
1, 1, 0, 1, 0,
0, 1, 1, 1, 0,
1, 1, 1, 0, 0,
1, 0, 1, 1, 0,
0, 0, 0, 0, 0,
],
[
0, 0, 0, 0, 0,
0, 1, 1, 0, 1,
0, 0, 1, 1, 1,
0, 1, 1, 1, 0,
0, 1, 0, 1, 1,
],
[ // 30
0, 0, 0, 0, 0,
1, 1, 0, 1, 0,
0, 1, 1, 1, 0,
1, 1, 1, 0, 0,
1, 0, 1, 1, 0,
],
[
0, 1, 0, 1, 1,
0, 1, 1, 1, 0,
0, 0, 1, 1, 1,
0, 1, 1, 0, 1,
0, 0, 0, 0, 0,
],
[
1, 0, 1, 1, 0,
1, 1, 1, 0, 0,
0, 1, 1, 1, 0,
1, 1, 0, 1, 0,
0, 0, 0, 0, 0,
],
[
0, 0, 0, 0, 0,
0, 1, 0, 1, 1,
0, 1, 1, 1, 0,
0, 0, 1, 1, 1,
0, 1, 1, 0, 1,
],
[
0, 0, 0, 0, 0,
1, 0, 1, 1, 0,
1, 1, 1, 0, 0,
0, 1, 1, 1, 0,
1, 1, 0, 1, 0,
],
// weird shapes
[ // 35
0, 1, 1, 0, 1,
0, 0, 1, 0, 1,
0, 1, 1, 1, 1,
0, 1, 0, 1, 0,
0, 1, 0, 1, 1,
],
[
1, 1, 0, 1, 0,
0, 1, 0, 1, 0,
1, 1, 1, 1, 0,
1, 0, 1, 0, 0,
1, 0, 1, 1, 0,
],
[
1, 0, 1, 1, 0,
1, 0, 1, 0, 0,
1, 1, 1, 1, 1,
0, 1, 0, 0, 1,
1, 1, 0, 0, 1,
],
[
1, 0, 0, 1, 1,
1, 0, 0, 1, 0,
1, 1, 1, 1, 1,
0, 0, 1, 0, 1,
0, 1, 1, 0, 1,
],
[
0, 0, 1, 1, 1,
1, 0, 1, 0, 0,
0, 1, 1, 1, 0,
0, 0, 1, 0, 1,
1, 1, 0, 0, 1,
],
[ // 40
1, 0, 1, 1, 1,
1, 0, 1, 0, 0,
0, 1, 1, 1, 0,
0, 0, 1, 0, 1,
1, 1, 0, 0, 0,
],
[
1, 0, 0, 1, 0,
1, 0, 1, 0, 0,
0, 1, 1, 1, 0,
0, 0, 1, 0, 1,
1, 1, 0, 0, 1,
],
[
1, 0, 0, 1, 1,
1, 0, 1, 0, 0,
0, 1, 1, 1, 0,
0, 0, 1, 0, 1,
0, 1, 0, 0, 1,
],
// additional pixels
[
1, 0, 1, 1, 1,
1, 0, 1, 0, 0,
1, 1, 1, 1, 1,
0, 0, 1, 1, 1,
1, 1, 1, 0, 1,
],
[
1, 0, 1, 1, 1,
1, 0, 1, 0, 0,
1, 1, 1, 1, 1,
0, 1, 1, 0, 1,
1, 1, 1, 0, 1,
],
[ // 45
1, 0, 1, 1, 1,
1, 1, 1, 0, 0,
1, 1, 1, 1, 1,
0, 0, 1, 0, 1,
1, 1, 1, 0, 1,
],
[
1, 0, 1, 1, 1,
1, 0, 1, 1, 0,
1, 1, 1, 1, 1,
0, 0, 1, 0, 1,
1, 1, 1, 0, 1,
],
// very wonky
[
1, 1, 1, 0, 1,
0, 0, 1, 0, 1,
1, 1, 1, 1, 0,
1, 0, 1, 0, 0,
0, 0, 1, 1, 1,
],
[
1, 1, 1, 0, 0,
0, 0, 1, 0, 1,
0, 1, 1, 1, 1,
1, 0, 1, 0, 0,
1, 0, 1, 1, 1,
],
// missing one corner
[
1, 0, 1, 1, 1,
1, 0, 1, 0, 0,
0, 1, 1, 1, 1,
0, 0, 1, 0, 1,
1, 1, 1, 0, 1,
],
[ // 50
1, 0, 0, 1, 1,
1, 0, 1, 0, 0,
1, 1, 1, 1, 1,
0, 0, 1, 0, 1,
1, 1, 1, 0, 1,
],
[
1, 0, 1, 1, 1,
1, 0, 1, 0, 0,
1, 1, 1, 1, 0,
0, 0, 1, 0, 1,
1, 1, 1, 0, 1,
],
[
1, 0, 1, 1, 1,
1, 0, 1, 0, 0,
1, 1, 1, 1, 1,
0, 0, 1, 0, 1,
1, 1, 0, 0, 1,
],
// missing arms
[
0, 0, 1, 1, 1,
0, 0, 1, 0, 0,
1, 1, 1, 1, 1,
0, 0, 1, 0, 1,
1, 1, 1, 0, 1,
],
[
1, 0, 1, 0, 0,
1, 0, 1, 0, 0,
1, 1, 1, 1, 1,
0, 0, 1, 0, 1,
1, 1, 1, 0, 1,
],
[ // 55
1, 0, 1, 1, 1,
1, 0, 1, 0, 0,
1, 1, 1, 1, 1,
0, 0, 1, 0, 0,
1, 1, 1, 0, 0,
],
[
1, 0, 1, 1, 1,
1, 0, 1, 0, 0,
1, 1, 1, 1, 1,
0, 0, 1, 0, 1,
0, 0, 1, 0, 1,
],
];
export function hexToLab(c: string): LAB {
const [L, A, B] = hex.lab(c);
return { L, A, B };
}
// export function colorToGrayscale(c: string) {
// const color = parseColorFast(c);
// const grayscale = toGrayscale(color);
// return getB(grayscale);
// }
export function theSameColor(a: LAB, b: LAB, delta = 27): boolean { // 27
return getDeltaE00(a, b) < delta;
}
export function isBadCM(cmString: string[], coatColor: string | undefined): string | undefined {
if (!cmString || !cmString.length)
return undefined;
const pad = CM_SIZE * CM_SIZE - cmString.length;
const coat = hexToLab(coatColor || '000000');
const padded = [...cmString, ...repeat(pad, '')];
const cmAlpha = padded.map(c => (!c || (coatColor && theSameColor(hexToLab(c), coat, 1))) ? 0 : 1);
const cmAlpha2 = padded.map(c => c ? 1 : 0);
const hasAlpha = cmString.some(c => !c);
const cm = [...cmString.map(c => c ? hexToLab(c) : coat), ...repeat(pad, coat)];
const colorsString = compact(uniq([coatColor, ...cmString]));
const colors = colorsString.map(hexToLab);
// const cmGrayscale = padded.map(colorToGrayscale);
// const grays = compact(uniq(cmGrayscale));
let patternIndex = 0;
for (const pattern of patterns) {
if (matchesAlpha(pattern, cmAlpha)) {
return `alpha(pattern:${patternIndex})`;
}
if (matchesAlpha(pattern, cmAlpha2)) {
return `alpha2(pattern:${patternIndex})`;
}
for (const color of colors) {
if (matchesColor(pattern, hasAlpha, color, cm)) {
return `color(pattern:${patternIndex}, color:${colorsString[colors.indexOf(color)]})`;
}
}
// for (const gray of grays) {
// if (matchesGrayscale(pattern, gray, cmGrayscale, patternIndex > 1 ? 50 : 120)) {
// return `grayscale(pattern:${patternIndex}, gray:${gray})`;
// }
// }
patternIndex++;
}
return undefined;
}
function matchesAlpha(pattern: number[], cm: number[]): boolean {
const length = Math.max(pattern.length, cm.length);
for (let i = 0; i < length; i++) {
if (pattern[i] !== cm[i]) {
return false;
}
}
return true;
}
function matchesColor(pattern: number[], hasAlpha: boolean, color: LAB, cm: LAB[]): boolean {
for (let i = 0; i < pattern.length; i++) {
const delta = hasAlpha ? 27 : 30;
const on = pattern[i] === 1;
const same = theSameColor(cm[i], color, delta);
if (on !== same) {
return false;
}
}
return true;
}
// function matchesGrayscale(pattern: number[], color: number, cm: number[], delta: number): boolean {
// for (let i = 0; i < pattern.length; i++) {
// const on = pattern[i] === 1;
// const same = Math.abs(cm[i] - color) < delta;
// if (on !== same) {
// return false;
// }
// }
// return true;
// }
+648
View File
@@ -0,0 +1,648 @@
import { range, compact, escapeRegExp } from 'lodash';
import {
MessageType, ChatType, Expression, Eye, Muzzle, Action, Season, Holiday, Weather, toAnnouncementMessageType,
} from '../common/interfaces';
import { hasRole } from '../common/accountUtils';
import { butterfly, bat, firefly, cloud, getEntityType, getEntityTypeName } from '../common/entities';
import { emojis } from '../client/emoji';
import { IClient, ServerMap } from './serverInterfaces';
import { World } from './world';
import { NotificationService } from './services/notification';
import { UserError, isUserError } from './userError';
import { parseExpression, expression } from '../common/expressionUtils';
import { filterBadWords } from '../common/swears';
import { randomString } from '../common/stringUtils';
import {
getCounter, holdToy, getCollectedToysCount, holdItem, playerSleep, playerBlush, playerLove, playerCry,
setEntityExpression, execAction, teleportTo
} from './playerUtils';
import { ServerLiveSettings, GameServerSettings } from '../common/adminInterfaces';
import { isCommand, processCommand, clamp, flatten, includes, randomPoint } from '../common/utils';
import { createNotifyUpdate, createShutdownServer } from './api/internal';
import { logger } from './logger';
import { pathTo } from './paths';
import { sayTo, sayToEveryone, sayToOthers, sayToAll, saySystem } from './chat';
import { resetTiles } from './serverRegion';
import {
findEntities, updateMapState, loadMapFromFile, saveMapToFile, saveEntitiesToFile, getSizeOfMap,
saveMapToFileBinaryAlt, saveRegionCollider, saveMap, loadMap
} from './serverMap';
import { PARTY_LIMIT, tileWidth, tileHeight, MAP_LOAD_SAVE_TIMEOUT } from '../common/constants';
import { PartyService } from './services/party';
import { getRegionGlobal } from '../common/worldMap';
import { swapCharacter } from './characterUtils';
import { writeFileAsync } from 'fs';
import { Account } from './db';
import { defaultHouseSave, removeToolbox, restoreToolbox } from './maps/houseMap';
export interface CommandContext {
world: World;
notifications: NotificationService;
liveSettings: ServerLiveSettings;
party: PartyService;
random: (min: number, max: number, floating?: boolean) => number;
}
export type CommandHandler = (
context: CommandContext, client: IClient, message: string, type: ChatType, target: IClient | undefined,
settings: GameServerSettings
) => any;
export interface Command {
names: string[];
help: string;
role: string;
spam?: boolean;
handler: CommandHandler;
}
function hasRoleNull(client: IClient, role: string) {
if (!role || hasRole(client.account, role))
return true;
return (role === 'sup1' && (client.supporterLevel >= 1 || client.isMod)) ||
(role === 'sup2' && (client.supporterLevel >= 2 || client.isMod)) ||
(role === 'sup3' && (client.supporterLevel >= 3 || client.isMod));
}
function command(names: string[], help: string, role: string, handler: CommandHandler, spam = false): Command {
return { names, help, role, handler, spam };
}
function emote(names: string[], expr: Expression, timeout?: number, cancellable?: boolean) {
return command(names, '', '', ({ }, { pony }) => setEntityExpression(pony, expr, timeout, cancellable));
}
function action(names: string[], action: Action) {
return command(names, '', '', ({ }, client, _, __, ___, settings) => execAction(client, action, settings));
}
function adminModChat(names: string[], help: string, role: string, type: MessageType) {
return command(names, help, role, ({ }, client, message, _, __, settings) => {
sayToEveryone(client, message, filterBadWords(message), type, settings);
});
}
function parseSeason(value: string): Season | undefined {
switch (value.toLowerCase()) {
case 'spring': return Season.Spring;
case 'summer': return Season.Summer;
case 'autumn': return Season.Autumn;
case 'winter': return Season.Winter;
default: return undefined;
}
}
function parseHoliday(value: string): Holiday | undefined {
switch (value.toLowerCase()) {
case 'none': return Holiday.None;
case 'halloween': return Holiday.Halloween;
case 'christmas': return Holiday.Christmas;
default: return undefined;
}
}
function parseWeather(value: string): Weather | undefined {
switch (value.toLowerCase()) {
case 'none': return Weather.None;
case 'rain': return Weather.Rain;
default: return undefined;
}
}
function getSpawnTarget(map: ServerMap, message: string) {
if (message === 'spawn') {
return randomPoint(map.spawnArea);
}
const spawn = map.spawns.get(message);
if (spawn) {
return randomPoint(spawn);
}
const match = /^(\d+) (\d+)$/.exec(message.trim());
if (!match) {
throw new UserError('invalid parameters');
}
const [, tx, ty] = match;
const x = clamp(+tx, 0, map.width - 0.5 / tileWidth);
const y = clamp(+ty, 0, map.height - 0.5 / tileHeight);
return { x, y };
}
function execWithFileName(client: IClient, message: string, action: (fileName: string) => Promise<any>) {
const fileName = message.replace(/[^a-zA-Z0-9_-]/g, '');
if (!fileName) {
throw new UserError('invalid file name');
}
action(fileName)
.catch(e => (logger.error(e), e.message))
.then(error => saySystem(client, error || 'saved'));
}
function shouldNotBeCalled() {
throw new Error('Should not be called');
}
function isValidMapForEditing(map: ServerMap, client: IClient, checkTimeout: boolean, onlyLeader: boolean) {
if (map.id !== 'house') {
saySystem(client, 'Can only be done inside the house');
return false;
}
if (checkTimeout && ((Date.now() - client.lastMapLoadOrSave) < MAP_LOAD_SAVE_TIMEOUT)) {
saySystem(client, `You need to wait ${Math.floor(MAP_LOAD_SAVE_TIMEOUT / 1000)} seconds before loading or saving again`);
return false;
}
if (onlyLeader && client.party && client.party.leader !== client) {
saySystem(client, 'Only party leader can do this');
return false;
}
return true;
}
let interval: any;
export function createCommands(world: World): Command[] {
const commands = compact([
// chat
command(['help', 'h', '?'], '/help - show help', '', ({ }, client) => {
const help = commands
.filter(c => c.help && hasRoleNull(client, c.role))
.map(c => c.help)
.join('\n');
saySystem(client, help);
}),
command(['roll', 'rand', 'random'], '/roll [[min-]max] - randomize a number', '',
({ random }, client, args, type, target, settings) => {
const ROLL_MAX = 1000000;
const [, min, max] = /^(?:(\d+)-)?(\d+)$/.exec(args) || ['', '', ''];
const minValue = clamp((min ? parseInt(min, 10) : 1) | 0, 0, ROLL_MAX);
const maxValue = clamp((max ? parseInt(max, 10) : 100) | 0, minValue, ROLL_MAX);
const result = args === '🍎' ? args : random(minValue, maxValue);
const message = `🎲 rolled ${result} of ${minValue !== 1 ? `${minValue}-` : ''}${maxValue}`;
sayToOthers(client, message, toAnnouncementMessageType(type), target, settings);
}, true),
command(['s', 'say'], '/s - say', '', shouldNotBeCalled),
command(['p', 'party'], '/p - party chat', '', shouldNotBeCalled),
command(['t', 'think'], '/t - thinking balloon', '', shouldNotBeCalled),
command(['w', 'whisper'], '/w <name> - whisper to player', '', shouldNotBeCalled),
command(['r', 'reply'], '/r - reply to whisper', '', shouldNotBeCalled),
command(['e'], '/e - set permanent expression', '', ({ }, { pony }, message) => {
pony.exprPermanent = parseExpression(message);
setEntityExpression(pony, undefined, 0);
}),
// actions
command(['turn'], '/turn - turn head', '', ({ }, client, _, __, ___, settings) => {
execAction(client, Action.TurnHead, settings);
}),
command(['boop', ')'], '/boop or /) - a boop', '', ({ }, client, message, _, __, settings) => {
const expression = parseExpression(message);
if (expression) {
setEntityExpression(client.pony, expression, 800);
}
execAction(client, Action.Boop, settings);
}),
command(['drop'], '/drop - drop held item', '', ({ }, client, _, __, ___, settings) => {
execAction(client, Action.Drop, settings);
}),
command(['droptoy'], '/droptoy - drop held toy', '', ({ }, client, _, __, ___, settings) => {
execAction(client, Action.DropToy, settings);
}),
// command(['open'], '/open - open gift', '', ({ }, client) => {
// openGift(client);
// }),
// counters
command(['gifts'], '/gifts - show gift score', '', ({ }, client, _, type, target, settings) => {
sayToOthers(client, `collected ${getCounter(client, 'gifts')} 🎁`, toAnnouncementMessageType(type), target, settings);
}, true),
command(['candies', 'candy'], '/candies - show candy score', '', ({ }, client, _, type, target, settings) => {
sayToOthers(client, `collected ${getCounter(client, 'candies')} 🍬`, toAnnouncementMessageType(type), target, settings);
}, true),
command(['eggs'], '/eggs - show egg score', '', ({ }, client, _, type, target, settings) => {
sayToOthers(client, `collected ${getCounter(client, 'eggs')} 🥚`, toAnnouncementMessageType(type), target, settings);
}, true),
command(['clovers', 'clover'], '/clovers - show clover score', '', ({ }, client, _, type, target, settings) => {
sayToOthers(client, `collected ${getCounter(client, 'clovers')} 🍀`, toAnnouncementMessageType(type), target, settings);
}, true),
command(['toys'], '/toys - show number of collected toys', '', ({ }, client, _, type, target, settings) => {
const { collected, total } = getCollectedToysCount(client);
sayToOthers(client, `collected ${collected}/${total} toys`, toAnnouncementMessageType(type), target, settings);
}),
// other
command(['unstuck'], '/unstuck - respawn at spawn point', '', ({ world }, client) => {
world.resetToSpawn(client);
world.kick(client, '/unstuck');
}),
command(['leave'], '/leave - leave the game', '', ({ world }, client) => {
world.kick(client, '/leave');
}),
// pony states
command(['sit'], '/sit - sit down or stand up', '', shouldNotBeCalled),
command(['lie', 'lay'], '/lie - lie down or sit up', '', shouldNotBeCalled),
command(['fly'], '/fly - fly up or fly down', '', shouldNotBeCalled),
command(['stand'], '/stand - stand up', '', shouldNotBeCalled),
// emotes
command(['blush'], '', '', ({ }, { pony }, message) => playerBlush(pony, message)),
command(['love', '<3'], '', '', ({ }, { pony }, message) => playerLove(pony, message)),
command(['sleep', 'zzz'], '', '', ({ }, { pony }, message) => playerSleep(pony, message)),
command(['cry'], '', '', ({ }, { pony }, message) => playerCry(pony, message)),
// expressions
emote(['smile', 'happy'], expression(Eye.Neutral, Eye.Neutral, Muzzle.Smile)),
emote(['frown'], expression(Eye.Neutral, Eye.Neutral, Muzzle.Frown)),
emote(['angry'], expression(Eye.Angry, Eye.Angry, Muzzle.Frown)),
emote(['sad'], expression(Eye.Sad, Eye.Sad, Muzzle.Frown)),
emote(['thinking'], expression(Eye.Neutral, Eye.Frown2, Muzzle.Concerned)),
// actions
action(['yawn'], Action.Yawn),
action(['laugh', 'lol', 'haha', 'хаха', 'jaja'], Action.Laugh),
action(['sneeze', 'achoo'], Action.Sneeze),
action(['magic'], Action.Magic),
// house
command(['savehouse'], '/savehouse - saves current house setup', '', async ({ }, client) => {
if (!isValidMapForEditing(client.map, client, true, false))
return;
client.lastMapLoadOrSave = Date.now();
const savedMap = JSON.stringify(saveMap(client.map,
{ saveTiles: true, saveEntities: true, saveWalls: true, saveOnlyEditableEntities: true }));
DEVELOPMENT && console.log(savedMap);
client.account.savedMap = savedMap;
await Account.updateOne({ _id: client.accountId }, { savedMap }).exec();
saySystem(client, 'Saved');
client.reporter.systemLog(`Saved house`);
}),
command(['loadhouse'], '/loadhouse - loads saved house setup', '', ({ world }, client) => {
if (!isValidMapForEditing(client.map, client, true, true))
return;
if (!client.account.savedMap)
return saySystem(client, 'No saved map state');
client.lastMapLoadOrSave = Date.now();
loadMap(world, client.map, JSON.parse(client.account.savedMap),
{ loadEntities: true, loadWalls: true, loadEntitiesAsEditable: true });
saySystem(client, 'Loaded');
client.reporter.systemLog(`Loaded house`);
}),
command(['resethouse'], '/resethouse - resets house setup to original state', '', ({ }, client) => {
if (!isValidMapForEditing(client.map, client, true, true))
return;
client.lastMapLoadOrSave = Date.now();
if (defaultHouseSave) {
loadMap(world, client.map, defaultHouseSave,
{ loadEntities: true, loadWalls: true, loadEntitiesAsEditable: true });
}
saySystem(client, 'Reset');
client.reporter.systemLog(`Reset house`);
}),
command(['lockhouse'], '/lockhouse - prevents other people from changing the house', '', ({ }, client) => {
if (!isValidMapForEditing(client.map, client, false, true))
return;
client.map.editingLocked = true;
saySystem(client, 'House locked');
client.reporter.systemLog(`House locked`);
}),
command(['unlockhouse'], '/unlockhouse - enables editing by other people', '', ({ }, client) => {
if (!isValidMapForEditing(client.map, client, false, true))
return;
client.map.editingLocked = false;
saySystem(client, 'House unlocked');
client.reporter.systemLog(`House unlocked`);
}),
command(['removetoolbox'], '/removetoolbox - removes toolbox from the house', '', ({ world }, client) => {
if (!isValidMapForEditing(client.map, client, false, true))
return;
removeToolbox(world, client.map);
saySystem(client, 'Toolbox removed');
client.reporter.systemLog(`Toolbox removed`);
}),
command(['restoretoolbox'], '/restoretoolbox - restores toolbox to the house', '', ({ }, client) => {
if (!isValidMapForEditing(client.map, client, false, true))
return;
restoreToolbox(world, client.map);
saySystem(client, 'Toolbox restored');
client.reporter.systemLog(`Toolbox restored`);
}),
// supporters
command(['swap'], '/swap <name> - swap character', '', async ({ world }, client, message) => {
if (!message) {
return saySystem(client, `You need to provide name of the character`);
}
const regex = new RegExp(`^${escapeRegExp(message)}$`, 'i');
const query = { account: client.account._id, name: { $regex: regex } };
await swapCharacter(client, world, query);
}),
command(['s1'], '', 'sup1', shouldNotBeCalled),
command(['s2'], '', 'sup2', shouldNotBeCalled),
command(['s3'], '', 'sup3', shouldNotBeCalled),
command(['ss'], '/ss - supporter text', 'sup1', shouldNotBeCalled),
// mod
adminModChat(['m'], '/m - mod text', 'mod', MessageType.Mod),
command(['emotetest'], '/emotetest - print all emotes', 'mod', (_context, client) => {
let text = '';
for (let i = 0; i < emojis.length;) {
if (text) {
text += '\n';
}
for (let j = 0; i < emojis.length && j < 20; j++ , i++) {
text += emojis[i].symbol;
}
}
sayTo(client, client.pony, text, MessageType.Chat);
}),
command(['goto'], '/goto <id> [<instance>]', 'mod', ({ world }, client, message) => {
const [id = '', instance] = message.split(' ');
const map = world.maps.find(map => map.id === id && map.instance === instance);
if (map) {
const { x, y } = randomPoint(map.spawnArea);
world.switchToMap(client, map, x, y);
}
}),
command(['tp'], '/tp <location> | <x> <y> - teleport to location', 'mod', (_context, client, message) => {
const { x, y } = getSpawnTarget(client.map, message);
teleportTo(client, x, y);
}),
// admin
adminModChat(['a'], '/a - admin text', 'admin', MessageType.Admin),
command(['announce'], '/announce - global announcement', 'admin', ({ }, client, message, _, __, settings) => {
findEntities(client.map, e => e.type === butterfly.type || e.type === bat.type || e.type === firefly.type)
.forEach(e => sayToAll(e, message, filterBadWords(message), MessageType.Admin, settings));
}),
command(['time'], '/time <hour> - change server time', DEVELOPMENT ? '' : 'admin', ({ world }, _client, message) => {
if (!/^\d+$/.test(message)) {
throw new UserError('invalid parameter');
}
world.setTime(parseInt(message, 10) % 24);
}),
command(['togglerestore'], '/togglerestore - toggle terrain restoration', 'admin', ({ world: { options } }, client) => {
options.restoreTerrain = !options.restoreTerrain;
saySystem(client, `restoration is ${options.restoreTerrain ? 'on' : 'off'}`);
}),
command(['resettiles'], '/resettiles - reset tiles to original state', 'admin', ({ }, client) => {
for (const region of client.map.regions) {
resetTiles(client.map, region);
}
}),
BETA && command(['season'], '/season <season> [<holiday>]', 'admin', ({ world }, _client, message) => {
const [s = '', h = ''] = message.split(' ');
const season = parseSeason(s);
const holiday = parseHoliday(h);
if (season === undefined) {
throw new UserError('invalid season');
} else {
world.setSeason(season, holiday === undefined ? world.holiday : holiday);
}
}),
BETA && command(['weather'], '/weather <none|rain>', 'admin', ({ }, client, message) => {
const weather = parseWeather(message);
if (weather === undefined) {
throw new UserError('invalid weather');
} else {
updateMapState(client.map, { weather });
}
}),
// superadmin
command(['update'], '/update - prepare server for update', 'superadmin', ({ world, liveSettings }) => {
createNotifyUpdate(world, liveSettings)();
}),
command(['shutdown'], '/shutdown - shutdown server for update', 'superadmin', ({ world, liveSettings }) => {
createShutdownServer(world, liveSettings)(true);
}),
// debug
DEVELOPMENT && command(['map'], '/map - show map info', '', ({ world }, client) => {
const map = client.map;
const { memory, entities } = getSizeOfMap(map);
const message = `[${map.id}:${map.instance || '-'}] ${world.maps.indexOf(map)}/${world.maps.length} ` +
`${(memory / 1024).toFixed(2)} kb ${entities} entities`;
saySystem(client, message);
}),
command(['loadmap'], '/loadmap <file name> - load map from file', 'superadmin', ({ world }, client, message) => {
execWithFileName(client, message, fileName =>
loadMapFromFile(world, client.map, pathTo('store', `${fileName}.json`), { loadOnlyTiles: true }));
}),
command(['savemap'], '/savemap <file name> - save map to file', 'superadmin', (_, client, message) => {
execWithFileName(client, message, async fileName => {
await saveMapToFile(client.map, pathTo('store', `${fileName}.json`), { saveTiles: true });
// await saveMapToFileBinary(client.map, pathTo('store', `${fileName}.bin`));
});
}),
command(['savemapbin'], '/savemapbin <file name> - save map to file', 'superadmin', (_, client, message) => {
execWithFileName(client, message, fileName => saveMapToFileBinaryAlt(client.map, pathTo('store', `${fileName}.json`)));
}),
command(['saveentities'], '/saveentities <file name> - save entities to file', 'superadmin', (_, client, message) => {
execWithFileName(client, message, fileName => saveEntitiesToFile(client.map, pathTo('store', `${fileName}.txt`)));
}),
command(['savehides'], '/savehides - save hides to file', 'superadmin', async ({ world }, client) => {
const json = world.hidingService.serialize();
await writeFileAsync(pathTo('store', 'hides.json'), json, 'utf8');
saySystem(client, 'saved');
}),
command(['throwerror'], '/throwerror <message> - throw test error', 'superadmin', (_, _client, message) => {
throw new Error(message || 'test');
}),
BETA && command(['test'], '', 'superadmin', ({ }, client) => {
client.map.regions.forEach(region => {
console.log(region.x, region.y, region.colliders.length);
});
}),
BETA && command(['spamchat'], '/spamchat - spam chat messages', 'superadmin',
({ world, random }, client, _, __, ___, settings) => {
if (interval) {
clearInterval(interval);
interval = undefined;
} else {
interval = setInterval(() => {
if (includes(world.clients, client)) {
const message = range(random(1, 10)).map(() => randomString(random(1, 10))).join(' ');
sayToEveryone(client, message, message, MessageType.Chat, settings);
} else {
clearInterval(interval);
}
}, 100);
}
}),
BETA && command(['noclouds'], '/noclouds - remove clouds', 'superadmin', ({ world }, client) => {
findEntities(client.map, e => e.type === cloud.type).forEach(e => world.removeEntity(e, client.map));
}),
BETA && command(['msg'], '/msg - say random stuff', 'superadmin', ({ }, client, _, __, ___, settings) => {
findEntities(client.map, e => !!e.options && e.name === 'debug 2')
.forEach(e => sayToAll(e, 'Hello there!', 'Hello there!', MessageType.Chat, settings));
}),
BETA && command(['hold'], '/hold <name> - hold item', 'superadmin', ({ }, client, message) => {
holdItem(client.pony, getEntityType(message));
}),
BETA && command(['toy'], '/toy <number> - hold toy', 'superadmin', ({ }, client, message) => {
holdToy(client.pony, parseInt(message, 10) | 0);
}),
BETA && command(['dc'], '/dc', 'superadmin', ({ }, client) => {
client.disconnect(true, false);
}),
BETA && command(['disconnect'], '/disconnect', 'superadmin', ({ }, client) => {
client.disconnect(true, true);
}),
BETA && command(['info'], '/info <id>', 'superadmin', ({ world }, client, message) => {
const id = parseInt(message, 10) | 0;
const entity = world.getEntityById(id);
if (entity) {
const { id, type, x, y, options } = entity;
const info = { id, type: getEntityTypeName(type), x, y, options };
saySystem(client, JSON.stringify(info, null, 2));
} else {
saySystem(client, 'undefined');
}
}),
BETA && command(['collider'], '/collider', 'superadmin', ({ }, client) => {
const region = getRegionGlobal(client.map, client.pony.x, client.pony.y);
if (region) {
saveRegionCollider(region);
saySystem(client, 'saved');
// console.log(region.tileIndices);
}
}),
DEVELOPMENT && command(['testparty'], '', 'superadmin', ({ party }, client) => {
const entities = findEntities(client.map, e => !!e.client && /^debug/.test(e.name || ''));
for (const e of entities.slice(0, PARTY_LIMIT - 1)) {
party.invite(client, e.client!);
}
}),
]);
return commands;
}
export function getSpamCommandNames(commands: Command[]): string[] {
return flatten(commands.filter(c => c.spam).map(c => c.names));
}
export type RunCommand = ReturnType<typeof createRunCommand>;
export const createRunCommand =
(context: CommandContext, commands: Command[]) =>
(client: IClient, command: string, args: string, type: ChatType, target: IClient | undefined, settings: GameServerSettings) => {
command = command.toLowerCase().trim();
const func = commands.find(c => c.names.indexOf(command) !== -1);
try {
if (func && hasRoleNull(client, func.role)) {
func.handler(context, client, args, type, target, settings);
} else {
return false;
}
} catch (e) {
if (isUserError(e)) {
saySystem(client, e.message);
} else {
throw e;
}
}
return true;
};
const chatTypes = new Map<string, ChatType>();
chatTypes.set('p', ChatType.Party);
chatTypes.set('party', ChatType.Party);
chatTypes.set('s', ChatType.Say);
chatTypes.set('say', ChatType.Say);
chatTypes.set('t', ChatType.Think);
chatTypes.set('think', ChatType.Think);
chatTypes.set('ss', ChatType.Supporter);
chatTypes.set('s1', ChatType.Supporter1);
chatTypes.set('s2', ChatType.Supporter2);
chatTypes.set('s3', ChatType.Supporter3);
chatTypes.set('r', ChatType.Whisper);
chatTypes.set('reply', ChatType.Whisper);
chatTypes.set('w', ChatType.Whisper);
chatTypes.set('whisper', ChatType.Whisper);
export function parseCommand(text: string, type: ChatType): { command?: string; args: string; type: ChatType; } {
if (!isCommand(text)) {
return { args: text, type };
}
const { command, args } = processCommand(text);
if (command) {
const chatType = chatTypes.get(command.toLowerCase());
if (chatType !== undefined) {
if (chatType === ChatType.Think) {
type = type === ChatType.Party ? ChatType.PartyThink : ChatType.Think;
} else {
type = chatType;
}
return { args, type };
}
}
return { command, args, type };
}
export function getChatPrefix(type: ChatType) {
switch (type) {
case ChatType.Party:
case ChatType.PartyThink:
return '/p ';
case ChatType.Supporter:
return '/ss ';
case ChatType.Dismiss:
return '/dismiss ';
case ChatType.Whisper:
return '/w ';
default:
return '';
}
}
+70
View File
@@ -0,0 +1,70 @@
import { argv } from 'yargs';
import { ServerConfig } from '../common/adminInterfaces';
export interface AppConfig {
title: string;
twitterLink?: string;
supporterLink?: string;
contactEmail?: string;
port: number;
adminPort?: number;
host: string;
proxy?: number;
noindex?: boolean;
secret: string;
token: string;
local: string;
adminLocal?: string;
sw?: boolean;
db: string;
pg: any;
rollbar?: {
environment: string;
clientToken: string;
serverToken: string;
gulpToken: string;
};
analytics?: {
trackingID: string;
};
assetsPath?: string;
oauth: { [key: string]: any };
servers: ServerConfig[];
facebookAppId?: string;
}
export interface AppPackage {
name: string;
version: string;
description: string;
}
export interface AppArgs {
port?: string;
login?: boolean;
admin?: boolean;
standaloneadmin?: boolean;
game?: string;
superadmin?: string;
users?: boolean;
tools?: boolean;
webpack?: boolean;
local?: boolean;
nocleanup?: boolean;
}
export const args = argv as AppArgs;
export const { version, description }: AppPackage = require('../../../package.json');
export const config: AppConfig = require('../../../config.json');
const loginServer: ServerConfig = { id: 'login', filter: false, port: config.port } as any;
const adminServer: ServerConfig = { id: 'admin', filter: false, port: config.adminPort || config.port } as any;
export const gameServers = config.servers.filter(s => !s.hidden);
const allServers = [...gameServers, loginServer, adminServer];
allServers.forEach(s => s.flags = s.flags || {});
const serverId = args.game || (args.login ? 'login' : (args.admin ? 'admin' : allServers[0].id));
export const server = allServers.find(s => s.id === serverId) || allServers[0];
export const port = (args.port && parseInt(args.port, 10)) || server.port || config.port;
+139
View File
@@ -0,0 +1,139 @@
import { sample } from 'lodash';
import { ServerEntity, IClient, ServerMap, Interact } from './serverInterfaces';
import { unholdItem, holdItem } from './playerUtils';
import { sayTo } from './chat';
import { MessageType, CreateEntityMethod, EntityState, setAnimationToEntityState } from '../common/interfaces';
import { World } from './world';
import * as entities from '../common/entities';
import { setEntityName, updateEntityState } from './entityUtils';
import { hasFlag, repeat } from '../common/utils';
export function give(type: number, message?: string) {
return (e: ServerEntity, client: IClient) => {
if (client.pony.options && client.pony.options.hold === type) {
unholdItem(client.pony);
} else {
if (message) {
sayTo(client, e, message, MessageType.Announcement);
}
holdItem(client.pony, type);
}
};
}
export function createBoxOfLanterns(x: number, y: number) {
const boxOfLanterns = entities.boxLanterns(x, y) as ServerEntity;
boxOfLanterns.interact = give(entities.lanternOn.type);
setEntityName(boxOfLanterns, 'Box of lanterns');
return boxOfLanterns;
}
export function createSign(x: number, y: number, name: string, interact: Interact, create = entities.sign) {
const entity = create(x, y) as ServerEntity;
setEntityName(entity, name);
entity.interact = interact;
return entity;
}
export function createSignWithText(x: number, y: number, name: string, text: string, create = entities.sign) {
return createSign(x, y, name, (entity, client) => sayTo(client, entity, text, MessageType.System), create);
}
export function boopLight(this: ServerEntity) {
setTimeout(() => {
if (hasFlag(this.state, EntityState.On)) {
turnOff(this);
this.lightDelay = Date.now() + 3000;
}
}, 300);
}
export function createAddLight(world: World, map: ServerMap, createEntity: CreateEntityMethod) {
return (x: number, y: number) => {
const entity = world.addEntity(createEntity(x, y), map);
entity.boop = boopLight;
return entity;
};
}
export function turnOff(entity: ServerEntity) {
updateEntityState(entity, EntityState.None);
}
export function turnOn(entity: ServerEntity) {
updateEntityState(entity, setAnimationToEntityState(EntityState.On, 1));
}
export function updateLights(entities: ServerEntity[], on: boolean) {
for (const entity of entities) {
if (hasFlag(entity.state, EntityState.On) !== on && Math.random() < 0.2) {
if (entity.lightDelay === undefined || entity.lightDelay < Date.now()) {
if (on) {
turnOn(entity);
} else {
turnOff(entity);
}
}
}
}
}
export function createFenceMaker(
world: World, map: ServerMap,
size: number, poles: CreateEntityMethod[], beamsH: CreateEntityMethod[], beamsV: CreateEntityMethod[]
) {
const add = (entity: ServerEntity) => world.addEntity(entity, map);
return (x: number, y: number, length: number, horizontal = true, skipStart = false, skipEnd = false) => {
const dx = horizontal ? size : 0;
const dy = horizontal ? 0 : size;
for (let i = 0; i < length; i++) {
if (i || !skipStart) {
add(sample(poles)!(x + dx * i, y + dy * i));
}
if (horizontal) {
add(sample(beamsH)!(x + dx * i + (size / 2), y));
} else {
add(sample(beamsV)!(x, y + dy * i));
}
}
if (!skipEnd) {
add(sample(poles)!(x + dx * length, y + dy * length));
}
};
}
export function createWoodenFenceMaker(world: World, map: ServerMap) {
return createFenceMaker(world, map, 1, [
...repeat(2, entities.woodenFencePole1),
...repeat(2, entities.woodenFencePole2),
...repeat(2, entities.woodenFencePole3),
...repeat(2, entities.woodenFencePole4),
entities.woodenFencePole5,
], [
...repeat(5, entities.woodenFenceBeamH1),
...repeat(5, entities.woodenFenceBeamH2),
...repeat(5, entities.woodenFenceBeamH3),
entities.woodenFenceBeamH4,
entities.woodenFenceBeamH5,
entities.woodenFenceBeamH6,
], [
entities.woodenFenceBeamV1,
entities.woodenFenceBeamV2,
entities.woodenFenceBeamV3,
]);
}
export function createStoneWallFenceMaker(world: World, map: ServerMap) {
return createFenceMaker(world, map, 2, [
entities.stoneWallPole1,
], [
entities.stoneWallBeamH1,
], [
entities.stoneWallBeamV1,
]);
}
+10
View File
@@ -0,0 +1,10 @@
export * from './controllers/testController';
export * from './controllers/cloudController';
export * from './controllers/flyingCritterController';
export * from './controllers/perfController';
export * from './controllers/collectableController';
export * from './controllers/wallController';
export * from './controllers/fakeClientController';
export * from './controllers/torchController';
export * from './controllers/updateController';
export * from './controllers/plantController';
@@ -0,0 +1,55 @@
import { tileWidth } from '../../common/constants';
import { Entity } from '../../common/interfaces';
import { entitiesIntersect } from '../../common/utils';
import { cloud } from '../../common/entities';
import * as sprites from '../../generated/sprites';
import { Controller, ServerMap, ServerEntity } from '../serverInterfaces';
import { World } from '../world';
import { updateEntityVelocity } from '../entityUtils';
import { timingEnd, timingStart } from '../timing';
const spriteWidth = sprites.cloud.shadow!.w / tileWidth;
const cloudVX = -0.5;
export class CloudController implements Controller {
private clouds: Entity[] = [];
private initialized = false;
constructor(private world: World, private map: ServerMap, private cloudCount: number) {
}
initialize() {
if (this.initialized)
return;
for (let i = 0; i < this.cloudCount; i++) {
this.addCloud(false, this.world.now / 1000);
}
this.initialized = true;
}
update(_: number, now: number) {
timingStart('CloudController.update()');
for (let i = this.clouds.length - 1; i >= 0; i--) {
const cloud = this.clouds[i];
if (cloud.x < -spriteWidth) {
this.clouds.splice(i, 1);
this.world.removeEntity(cloud, this.map);
}
}
if (this.clouds.length < this.cloudCount) {
this.addCloud(true, now);
}
timingEnd();
}
private addCloud(end: boolean, timestamp: number) {
const x = end ? this.map.width + spriteWidth : this.map.width * Math.random();
const y = this.map.height * Math.random();
const entity = cloud(x, y) as ServerEntity;
if (!this.clouds.some(c => entitiesIntersect(c, entity))) {
this.clouds.push(this.world.addEntity(entity, this.map));
updateEntityVelocity(entity, cloudVX, 0, timestamp);
}
}
}
@@ -0,0 +1,72 @@
import { remove, sample } from 'lodash';
import { Entity, CreateEntityMethod } from '../../common/interfaces';
import { IClient, Controller, ServerEntity, ServerMap } from '../serverInterfaces';
import { World } from '../world';
import { timingEnd, timingStart } from '../timing';
import { canPlaceItem, canBePickedByPlayer, pushRemoveEntityToClient } from '../entityUtils';
export function randomPosition(map: ServerMap) {
const x = Math.random() * map.width;
const y = Math.random() * map.height;
return { x, y };
}
export class CollectableController implements Controller {
private items: Entity[] = [];
constructor(
private world: World,
private map: ServerMap,
private ctors: CreateEntityMethod[],
public limit: number,
private pick: (client: IClient, entity: ServerEntity) => void,
private check: (client: IClient) => boolean = () => true,
private tries = 1,
private position = randomPosition,
private active = () => true
) {
}
initialize() {
}
update() {
timingStart('CollectableController.update()');
if (this.active()) {
for (let i = 0; i < this.tries; i++) {
if (this.items.length < this.limit) {
this.generateItem();
}
}
}
timingEnd();
}
private generateItem() {
const { world, map } = this;
const { x, y } = this.position(map);
const ctor = sample(this.ctors)!;
const entity = ctor(x, y) as ServerEntity;
if (!entity.interactRange) {
entity.interactRange = 1.5;
}
if (
x > 0 && y > 0 && x < map.width && y < map.height && canPlaceItem(map, entity) && !canBePickedByPlayer(map, entity)
) {
entity.interact = this.interact;
this.items.push(world.addEntity(entity, map));
}
}
private interact = (entity: Entity, client: IClient) => {
if (this.check(client)) {
if (client.shadowed) {
pushRemoveEntityToClient(client, entity);
} else {
remove(this.items, e => e === entity);
this.world.removeEntity(entity, this.map);
this.generateItem();
this.pick(client, entity);
}
}
}
}
@@ -0,0 +1,150 @@
import { sample } from 'lodash';
import {
createBinaryWriter, getWriterBuffer, resetWriter, resizeWriter, writeArrayHeader, writeUint8Array,
writeUint8
} from 'ag-sockets';
import { Controller, IClient } from '../serverInterfaces';
import { World } from '../world';
import { Character, Account } from '../db';
import { createClientAndPony } from '../playerUtils';
import { CounterService } from '../services/counter';
import { CharacterState, ServerConfig } from '../../common/adminInterfaces';
import { removeItem, times } from '../../common/utils';
import { timingStart, timingEnd } from '../timing';
interface Options {
count: number;
}
const mockCharacterStates = new CounterService<CharacterState>(0);
export class FakeClientsController implements Controller {
private clients: IClient[] = [];
private tokens: any[] = [];
private initialized = false;
constructor(private world: World, private server: ServerConfig, private options: Options) {
}
initialize() {
if (this.initialized)
return;
times(1000, async i => {
try {
const name = `perf-${i}`;
const account = await Account.findOne({ name }).exec();
if (!account)
throw new Error(`Missing debug account (${name})`);
const character = await Character.findOne({ account: account._id }).exec();
if (!character)
throw new Error(`Missing debug character (${name})`);
this.tokens.push({ id: name, account, character });
} catch (e) {
console.error(e);
}
});
this.initialized = true;
}
update() {
}
sparseUpdate() {
timingStart('FakeClientController.sparseUpdate()');
if (this.tokens.length) {
for (let i = this.clients.length - 1; i >= 0; i--) {
if (Math.random() < (10 / this.options.count)) {
this.leave(this.clients[i]);
}
}
if (this.clients.length < this.options.count) {
for (let i = 0; i < 10; i++) {
this.join();
}
}
}
timingEnd();
}
async join() {
try {
const token = sample(this.tokens)!;
if (!this.clients.some(c => c.tokenId === token.id)) {
const client = await joinFakeClient(token, this.server, this.world);
this.clients.push(client);
}
} catch (e) {
console.error(e);
}
}
async leave(client: IClient) {
this.world.leaveClient(client);
removeItem(this.clients, client);
}
}
const packetWriter = createBinaryWriter();
export let lastPacket: Uint8Array | undefined;
async function joinFakeClient(token: any, server: ServerConfig, world: World): Promise<IClient> {
const client: Partial<IClient> = {
tokenId: token.id,
tokenData: token,
disconnect() {
world.leaveClient(client as IClient);
},
queue() { },
left() { },
worldState() { },
mapState() { },
myEntity() { },
mapTest() { },
updateFriends() { },
actionParam() { },
update(_, subscribes, adds, datas) {
do {
try {
resetWriter(packetWriter);
writeUint8(packetWriter, 123);
if (writeArrayHeader(packetWriter, subscribes)) {
for (let i = 0; i < subscribes.length; i++) {
writeUint8Array(packetWriter, subscribes[i]);
}
}
writeUint8Array(packetWriter, adds);
if (writeArrayHeader(packetWriter, datas)) {
for (let i = 0; i < datas.length; i++) {
writeUint8Array(packetWriter, datas[i]);
}
}
break;
} catch (e) {
if (e instanceof RangeError || /DataView/.test(e.message)) {
resizeWriter(packetWriter);
} else {
throw e;
}
}
} while (true);
lastPacket = getWriterBuffer(packetWriter);
},
addNotification() { },
removeNotification() { },
};
createClientAndPony(client as IClient, [], [], server, world, mockCharacterStates);
world.joinClientToQueue(client as IClient);
return client as IClient;
}
@@ -0,0 +1,89 @@
import { sample } from 'lodash';
import { Entity, CreateEntityMethod, ServerFlags } from '../../common/interfaces';
import { Controller, ServerEntity, ServerMap } from '../serverInterfaces';
import { World } from '../world';
import { findClosestEntity, findEntities } from '../serverMap';
import { timingEnd, timingStart } from '../timing';
import { hasFlag, distanceXY } from '../../common/utils';
import { moveRandomly, findClosest, moveTowards } from '../entityUtils';
import { randomPosition } from './collectableController';
export class FlyingCritterController implements Controller {
private entities: Entity[] = [];
constructor(
private world: World, private map: ServerMap, private critter: CreateEntityMethod, private speed: number,
private limit: number, private isActive: () => boolean, private spawnOnStart = false
) {
}
initialize() {
if (this.spawnOnStart) {
for (let i = 0; i < this.limit; i++) {
const { x, y } = randomPosition(this.map);
this.entities.push(this.world.addEntity(this.critter(x, y), this.map));
}
}
}
update(_: number, now: number) {
timingStart('FlyingCritterController.update()');
updateTreehidingEntities(
this.entities, this.world, this.map, this.limit, this.speed, now, this.critter, this.isActive);
timingEnd();
}
}
function isTreeCrown(entity: ServerEntity) {
return hasFlag(entity.serverFlags || 0, ServerFlags.TreeCrown);
}
export function findClosestTree(map: ServerMap, x: number, y: number) {
return findClosestEntity(map, x, y, isTreeCrown);
}
export function findTrees(map: ServerMap) {
return findEntities(map, isTreeCrown);
}
interface TargetTree extends Entity {
targetTree?: Entity;
}
export function updateTreehidingEntities(
entities: TargetTree[], world: World, map: ServerMap, limit: number, speed: number, timestamp: number,
create: (x: number, y: number) => Entity, isActive: () => boolean
) {
const offsetY = -2;
if (isActive()) {
// release new critter
if (entities.length < limit && Math.random() < 0.1) {
const trees = findTrees(map);
const tree = sample(trees);
if (tree) {
const entity = create(tree.x, tree.y + offsetY);
entities.push(world.addEntity(entity, map));
moveRandomly(map, entity, speed, 1, timestamp);
}
}
for (const entity of entities) {
moveRandomly(map, entity, speed, 0.02, timestamp);
}
} else if (entities.length) {
// head to tree and disappear
const trees = findTrees(map);
for (let i = entities.length - 1; i >= 0; i--) {
const e = entities[i];
e.targetTree = e.targetTree || findClosest(e.x, e.y, trees);
if (distanceXY(e.x, e.y, e.targetTree.x, e.targetTree.y + offsetY) < 0.1) {
entities.splice(i, 1);
world.removeEntity(e, map);
} else {
moveTowards(e, e.targetTree.x, e.targetTree.y + offsetY, speed, timestamp);
}
}
}
}
+128
View File
@@ -0,0 +1,128 @@
import { range, compact } from 'lodash';
import { pony } from '../../common/entities';
import { Entity, EntityState, MessageType, EntityFlags } from '../../common/interfaces';
import { Controller, ServerEntity, IClient } from '../serverInterfaces';
import { World } from '../world';
import { Character } from '../db';
import { PONY_SPEED_TROT } from '../../common/constants';
import { setEntityName, updateEntityVelocity } from '../entityUtils';
import { encryptInfo } from '../characterUtils';
import { createCamera } from '../../common/camera';
import { shouldBeFacingRight } from '../../common/movementUtils';
import { timingEnd, timingStart } from '../timing';
import { sayToAll } from '../chat';
interface Options {
count: number;
moving: number;
saying?: boolean;
unique?: boolean;
spread?: boolean;
x?: number;
y?: number;
}
export class PerfController implements Controller {
private entities: Entity[] = [];
private limitLeft = 11;
private limitWidth = 30;
private limitTop = 9;
private limitHeight = 25;
private initialized = false;
constructor(private world: World, private options: Options) {
if (options.spread) {
this.limitWidth = 60;
this.limitHeight = 60;
}
if (options.x !== undefined) {
this.limitLeft = options.x;
}
if (options.y !== undefined) {
this.limitTop = options.y;
}
}
initialize() {
if (this.initialized)
return;
const world = this.world;
const map = world.getMainMap();
const names = [
'performance',
'performance 2',
];
const query = this.options.unique ?
Promise.resolve(Character.find({ account: '57ae2336a67f4dc52e123ed1' }).limit(this.options.count).exec()) :
Promise.all(names.map(name => Character.findOne({ name }).exec())).then(compact);
query
.then(characters => {
if (characters.length) {
this.entities = range(this.options.count).map(i => {
const character = characters[i % characters.length]!;
const name = character._id.toString();
const x = this.limitLeft + this.limitWidth * Math.random();
const y = this.limitTop + this.limitHeight * Math.random();
const p = pony(x, y) as ServerEntity;
setEntityName(p, name);
p.flags |= EntityFlags.CanCollide;
p.encryptedInfoSafe = encryptInfo(character.info || '');
p.client = {
pony: p,
accountId: 'foobar',
characterId: character._id.toString(),
ignores: new Set(),
hides: new Set(),
permaHides: new Set(),
account: {} as any,
regions: [],
camera: createCamera(),
updateRegion() { },
addEntity() { },
mapTest() { },
} as Partial<IClient> as any;
p.client!.camera.x = -10000;
p.vx = this.options.moving ? randomVelocity() : 0;
p.vy = this.options.moving ? randomVelocity() : 0;
p.state = shouldBeFacingRight(p) ? EntityState.FacingRight : EntityState.None;
return world.addEntity(p, map);
});
}
});
this.initialized = true;
}
update(_: number, now: number) {
timingStart('PerfController.update()');
const limitBottom = this.limitTop + this.limitHeight;
const limitRight = this.limitTop + this.limitHeight;
if (this.options.moving) {
for (const entity of this.entities) {
if ((entity.vy > 0 && entity.y > limitBottom) || (entity.vy < 0 && entity.y < this.limitTop)) {
updateEntityVelocity(entity, entity.vx, -entity.vy, now);
} else if ((entity.vx > 0 && entity.x > limitRight) || (entity.vx < 0 && entity.x < this.limitLeft)) {
updateEntityVelocity(entity, -entity.vx, entity.vy, now);
} else if (Math.random() < 0.1) {
updateEntityVelocity(entity, randomVelocity(), randomVelocity(), now);
}
if (this.options.saying && Math.random() < 0.01) {
sayToAll(entity, 'Hello World', 'Hello World', MessageType.Chat, {});
}
}
}
timingEnd();
}
}
function randomVelocity() {
const rand = Math.random();
return rand < 0.333 ? 0 : (rand < 0.666 ? -PONY_SPEED_TROT : +PONY_SPEED_TROT);
}
@@ -0,0 +1,89 @@
import { sample, random } from 'lodash';
import { Controller, ServerEntity, ServerMap, Interact } from '../serverInterfaces';
import { World } from '../world';
import { timingStart, timingEnd } from '../timing';
import { Rect, CreateEntityMethod, ServerFlags, TileType } from '../../common/interfaces';
import { removeItem, randomPoint } from '../../common/utils';
import { getTile } from '../../common/worldMap';
interface Plant extends ServerEntity {
plantStage: number;
plantStageNext: number;
}
export interface PlantConfig {
area: Rect;
count: number;
stages: CreateEntityMethod[][];
onPick?: Interact;
growOnlyOn?: TileType;
isActive?: () => boolean;
}
export class PlantController implements Controller {
private plants: Plant[] = [];
private interact: Interact = (entity, client) => {
this.world.removeEntity(entity, this.map);
removeItem(this.plants, entity);
this.config.onPick && this.config.onPick(entity, client);
}
private nextSpawn = 0;
constructor(private world: World, private map: ServerMap, private config: PlantConfig) {
}
initialize() {
}
update() {
}
sparseUpdate() {
timingStart('PlantController.sparseUpdate()');
const now = Date.now();
const maxStage = this.config.stages.length - 1;
if (
this.nextSpawn < now &&
(this.config.isActive === undefined || this.config.isActive()) &&
this.plants.length < this.config.count
) {
const { x, y } = randomPoint(this.config.area);
if (this.config.growOnlyOn === undefined || getTile(this.map, x, y) === this.config.growOnlyOn) {
this.addPlant(x, y, 0);
this.nextSpawn = now + random(10000, 20000);
}
}
const plantsToRemove: Plant[] = [];
for (const plant of this.plants) {
if (plant.plantStage < maxStage && plant.plantStageNext < now) {
plantsToRemove.push(plant);
this.addPlant(plant.x, plant.y, plant.plantStage + 1);
}
}
for (const plant of plantsToRemove) {
this.removePlant(plant);
}
timingEnd();
}
private removePlant(plant: Plant) {
removeItem(this.plants, plant);
this.world.removeEntity(plant, this.map);
}
private addPlant(x: number, y: number, stage: number) {
const create = sample(this.config.stages[stage])!;
const plant = create(x, y) as Plant;
plant.plantStage = stage;
plant.plantStageNext = Date.now() + random(15000, 40000);
plant.serverFlags = ServerFlags.DoNotSave;
if (stage === (this.config.stages.length - 1)) {
plant.interact = this.interact;
}
this.plants.push(plant);
this.world.addEntity(plant, this.map);
}
}
@@ -0,0 +1,87 @@
import { compact } from 'lodash';
import * as entities from '../../common/entities';
import { IClient, ServerEntity, Controller, ServerMap } from '../serverInterfaces';
import { World } from '../world';
import { Character } from '../db';
import { setEntityName } from '../entityUtils';
import { times } from '../../common/utils';
import { encryptInfo } from '../characterUtils';
import { createCamera } from '../../common/camera';
import { timingStart, timingEnd } from '../timing';
import { createBinaryWriter } from 'ag-sockets';
export class TestController implements Controller {
private clients: IClient[] = [];
private initialized = false;
constructor(private world: World, private map: ServerMap) {
}
initialize() {
if (this.initialized)
return;
const world = this.world;
const map = this.map;
if (DEVELOPMENT) {
Promise.all(times(10, i => `debug ${i + 1}`).map(name => Character.findOne({ name }).exec()))
.then(compact)
.then(items => items.forEach((item, i) => {
const name = item.name;
const tag = i === 0 ? 'mod' : (i === 2 ? 'sup2' : '');
const extraOptions = i === 0 ? {
site: {
provider: 'github',
name: 'Test name',
url: 'https://github.com/Microsoft/TypeScript',
}
} : undefined;
const p = entities.pony(57 + 1 * i, 47 + 1 * i) as ServerEntity;
p.options = { tag };
setEntityName(p, name);
p.encryptedInfoSafe = encryptInfo(item.info || '');
p.client = {
map,
accountSettings: {},
account: { id: 'foobar', name: 'Debug account' } as any,
country: 'XY',
regions: [],
saysQueue: { push() { }, length: 0 } as any,
notifications: [],
camera: createCamera(),
accountId: 'foobar',
characterId: '',
ignores: new Set(),
hides: new Set(),
permaHides: new Set(),
updateQueue: createBinaryWriter(1),
addEntity() { },
addNotification() { },
removeNotification() { },
updateParty() { },
mapUpdate() { },
} as Partial<IClient> as any;
p.client!.pony = p;
this.clients.push(p.client!);
p.extraOptions = extraOptions;
world.addEntity(p, map);
}));
}
this.initialized = true;
}
update() {
timingStart('TestController.update()');
timingEnd();
}
sparseUpdate() {
timingStart('TestController.sparseUpdate()');
for (const client of this.clients) {
for (const notification of client.notifications) {
notification.accept && notification.accept();
}
}
timingEnd();
}
}
@@ -0,0 +1,33 @@
import { isNight } from '../../common/timeUtils';
import { Controller, ServerEntity, ServerMap } from '../serverInterfaces';
import { World } from '../world';
import { timingStart, timingEnd } from '../timing';
import { updateLights } from '../controllerUtils';
import { hasFlag } from '../../common/utils';
import { EntityFlags } from '../../common/interfaces';
export class TorchController implements Controller {
private lights: ServerEntity[] = [];
constructor(private world: World, private map: ServerMap) {
}
initialize() {
this.lights = [];
for (const region of this.map.regions) {
for (const entity of region.entities) {
if (hasFlag(entity.flags, EntityFlags.OnOff)) {
this.lights.push(entity);
}
}
}
}
update() {
timingStart('TorchController.update()');
timingEnd();
}
sparseUpdate() {
timingStart('TorchController.sparseUpdate()');
updateLights(this.lights, isNight(this.world.time));
timingEnd();
}
}
@@ -0,0 +1,28 @@
import { Controller, ServerEntity, ServerMap } from '../serverInterfaces';
import { timingStart, timingEnd } from '../timing';
export class UpdateController implements Controller {
private updatables: ServerEntity[] = [];
constructor(private map: ServerMap) {
}
initialize() {
this.updatables = [];
for (const region of this.map.regions) {
for (const entity of region.entities) {
if (entity.serverUpdate) {
this.updatables.push(entity);
}
}
}
}
update(delta: number, now: number) {
timingStart('TorchController.update()');
for (const entity of this.updatables) {
entity.serverUpdate!(delta, now);
}
timingEnd();
}
}
+178
View File
@@ -0,0 +1,178 @@
import { fromByteArray, toByteArray } from 'base64-js';
import { Controller, ServerMap } from '../serverInterfaces';
import { World } from '../world';
import { Entity, TileType } from '../../common/interfaces';
import { tileHeight } from '../../common/constants';
import { array } from '../../common/utils';
import { Walls } from '../../common/entities';
const createGetAt = (width: number, height: number) => <T>(items: T[], x: number, y: number) => {
return (x < 0 || y < 0 || x >= width || y >= height) ? undefined : items[x + y * width];
};
const createSetAt = (width: number, height: number) => <T>(items: T[], x: number, y: number, value: T) => {
if (x >= 0 && y >= 0 && x < width && y < height) {
items[x + y * width] = value;
}
};
export class WallController implements Controller {
top = 0;
isTall = (_x: number, _y: number) => false;
lockOuterWalls = false;
private lockedTiles = new Set<string>();
private hWalls: (Entity | undefined)[];
private vWalls: (Entity | undefined)[];
constructor(world: World, map: ServerMap, walls: Walls) {
const width = map.width + 1;
const height = map.height + 1;
const getAt = createGetAt(width, height);
const setAt = createSetAt(width, height);
const hWalls = this.hWalls = array<Entity | undefined>(width * height, undefined);
const vWalls = this.vWalls = array<Entity | undefined>(width * height, undefined);
const cWalls = array<Entity | undefined>(width * height, undefined);
const yOffset = 3 / tileHeight;
const { wallHShort, wallVShort, wallH, wallV, wallCorners, wallCornersShort, wallCutR, wallCutL } = walls;
const calcCorner = (x: number, y: number) => {
// top right bottom left
return (getAt(vWalls, x, y - 1) ? 8 : 0)
+ (getAt(hWalls, x, y) ? 4 : 0)
+ (getAt(vWalls, x, y) ? 2 : 0)
+ (getAt(hWalls, x - 1, y) ? 1 : 0);
};
const updateCorner = (x: number, y: number) => {
if (x < 0 || y < 0 || x >= width || y >= height)
return;
const top = this.top;
const isOutside = x === 0 || y <= top || x === map.width || this.isTall(x, y);
const corners = isOutside ? wallCorners : wallCornersShort;
const current = getAt(cWalls, x, y);
const calc = calcCorner(x, y);
if (!current || current.type !== corners[calc].type) {
if (current) {
world.removeEntity(current, map);
}
setAt(cWalls, x, y, calc ? world.addEntity(corners[calc](x, y + yOffset), map) : undefined);
}
};
this.toggleWall = (x, y, type) => {
if (x < 0 || y < 0 || x >= width || y >= height)
return;
if (this.lockedTiles.has(`${x},${y}:${type}`))
return;
const walls = type === TileType.WallH ? hWalls : vWalls;
const entity = getAt(walls, x, y);
const top = this.top;
if (type === TileType.WallH && x === (width - 1))
return;
if (type === TileType.WallV && y === (height - 1))
return;
if (this.lockOuterWalls) {
if (type === TileType.WallH && (y <= top || y === (width - 1)))
return;
if (type === TileType.WallV && (x === 0 || x === (height - 1) || y < top))
return;
}
if (entity) {
world.removeEntity(entity, map);
setAt(walls, x, y, undefined);
} else {
if (type === TileType.WallH) {
const ctor = (y <= top || this.isTall(x, y)) ?
wallH : (x === 0 ? wallCutL : (x === (width - 2) ? wallCutR : wallHShort));
setAt(walls, x, y, world.addEntity(ctor(x + 0.5, y + yOffset), map));
} else {
const ctor = (x === 0 || x === (width - 1) || this.isTall(x, y)) ? wallV : wallVShort;
setAt(walls, x, y, world.addEntity(ctor(x, y + 0.5), map));
}
}
updateCorner(x, y);
updateCorner(x + 1, y);
updateCorner(x, y + 1);
};
}
initialize() {
}
update() {
}
toggleWall?: (x: number, y: number, type: TileType) => void;
lockWall(x: number, y: number, type: TileType.WallH | TileType.WallV) {
this.lockedTiles.add(`${x},${y}:${type}`);
}
serialize() {
const data = new Uint8Array(Math.ceil(this.vWalls.length / 8) + Math.ceil(this.hWalls.length / 8));
let offset = 0;
for (let i = 0; i < this.vWalls.length; i += 8, offset++) {
let value = 0;
for (let j = 0; j < 8; j++) {
if (this.vWalls[i + j]) {
value |= (1 << j);
}
}
data[offset] = value;
}
for (let i = 0; i < this.hWalls.length; i += 8, offset++) {
let value = 0;
for (let j = 0; j < 8; j++) {
if (this.hWalls[i + j]) {
value |= (1 << j);
}
}
data[offset] = value;
}
return fromByteArray(data);
}
deserialize(width: number, height: number, serialized: string) {
const data = toByteArray(serialized);
const size = (width + 1) * (height + 1);
let offset = 0;
for (let i = 0; i < size; i += 8, offset++) {
let value = data[offset];
for (let j = 0; j < 8; j++) {
if ((!!this.vWalls[i + j]) !== ((value & (1 << j)) !== 0)) {
const x = (i + j) % (width + 1);
const y = Math.floor((i + j) / (width + 1));
this.toggleWall!(x, y, TileType.WallV);
}
}
}
for (let i = 0; i < size; i += 8, offset++) {
let value = data[offset];
for (let j = 0; j < 8; j++) {
if ((!!this.hWalls[i + j]) !== ((value & (1 << j)) !== 0)) {
const x = (i + j) % (width + 1);
const y = Math.floor((i + j) / (width + 1));
this.toggleWall!(x, y, TileType.WallH);
}
}
}
}
}
+485
View File
@@ -0,0 +1,485 @@
import { model, Schema, Types, Document, Query } from 'mongoose';
import {
TimestampsBase, EventBase, CharacterBase, AccountBase, AuthBase, OriginBase, OriginInfoBase, CharacterState,
SupporterInviteBase, FriendRequestBase, HideRequestBase, MergeHideData
} from '../common/adminInterfaces';
import { logger } from './logger';
import { isAdmin } from '../common/accountUtils';
import { FriendData } from '../common/interfaces';
import { replaceEmojis } from '../client/emoji';
import { filterForbidden } from './characterUtils';
import { filterName } from '../common/swears';
//set('debug', true); // debug mongoose
export interface Doc extends Document {
updatedAt: Date;
}
export interface IOriginInfo extends OriginInfoBase { }
export interface ITimestamps extends TimestampsBase { }
export interface IAuth extends AuthBase<Types.ObjectId>, Doc { }
export interface IOrigin extends OriginBase, Doc { }
export interface IEvent extends EventBase<Types.ObjectId>, Doc { }
export interface ISupporterInvite extends SupporterInviteBase<Types.ObjectId>, Doc { }
export interface IFriendRequest extends FriendRequestBase<Types.ObjectId>, Doc { }
export interface IHideRequest extends HideRequestBase<Types.ObjectId>, Doc { }
export interface ICharacter extends CharacterBase<Types.ObjectId>, Doc {
auth?: IAuth;
}
export interface IAccount extends AccountBase<Types.ObjectId>, Doc {
auths?: IAuth[];
characters?: ICharacter[];
}
export interface ISession extends Doc {
session: string;
}
// schemas
const originInfo = {
ip: String,
country: String,
last: Date,
};
const mergeInfo = {
id: String,
name: String,
//code: Number,
date: Date,
reason: String,
data: Object,
split: Boolean,
};
const logEntry = {
message: String,
date: Date,
};
const authSchema = new Schema({
account: { type: Schema.Types.ObjectId, index: true, ref: 'Account' },
openId: String,
provider: String,
name: String,
url: String,
emails: [String],
disabled: Boolean,
banned: Boolean,
pledged: Number,
lastUsed: Date,
}, { timestamps: true });
authSchema.index({ updatedAt: 1 });
authSchema.index({ openId: 1, provider: 1 }, { unique: true });
const bannedMuted = {
mute: Number,
shadow: Number,
ban: Number,
};
const originSchema = new Schema({
ip: { type: String, index: true },
country: String,
...bannedMuted,
}, { timestamps: true });
originSchema.index({ updatedAt: 1 });
const accountSchema = new Schema({
name: String,
birthdate: Date,
birthyear: Number,
// code: Number,
emails: { type: [String], index: true },
lastVisit: Date,
lastUserAgent: String,
lastBrowserId: String,
lastOnline: Date,
lastCharacter: Schema.Types.ObjectId,
roles: [String],
origins: [originInfo],
note: String,
noteUpdated: Date,
ignores: [String],
// friends: [{ type: Schema.Types.ObjectId, unique: true, ref: 'Account' }],
flags: Number,
characterCount: { type: Number, default: 0 },
// NOTE: use account.markModified('settings') if changed nested field
settings: { type: Schema.Types.Mixed, default: () => ({}) },
counters: { type: Schema.Types.Mixed, default: () => ({}) },
patreon: Number,
supporter: Number,
supporterLog: [logEntry],
supporterTotal: Number,
supporterDeclinedSince: Date,
merges: [mergeInfo],
banLog: [logEntry],
mute: Number,
shadow: Number,
ban: Number,
// auths: [{ type: Schema.Types.ObjectId, ref: 'Auth' }],
state: Object,
alert: Object,
savedMap: String,
}, { timestamps: true });
accountSchema.virtual('auths', {
ref: 'Auth',
localField: '_id',
foreignField: 'account',
});
accountSchema.virtual('characters', {
ref: 'Character',
localField: '_id',
foreignField: 'account',
});
accountSchema.index({ updatedAt: 1 });
const characterSchema = new Schema({
account: { type: Schema.Types.ObjectId, index: true, ref: 'Account' },
site: { type: Schema.Types.ObjectId, ref: 'Auth' },
name: { type: String, index: true },
desc: String,
tag: String,
info: String,
flags: { type: Number, default: 0 },
lastUsed: { type: Date, index: true },
creator: String,
state: Object,
}, { timestamps: true });
characterSchema.index({ updatedAt: 1 });
characterSchema.index({ createdAt: 1 });
const eventSchema = new Schema({
account: { type: Schema.Types.ObjectId, index: true, ref: 'Account' },
pony: Schema.Types.ObjectId,
type: String,
server: String,
message: String,
desc: String,
origin: originInfo,
count: { type: Number, default: 1 },
}, { timestamps: true });
eventSchema.index({ updatedAt: 1 });
const supporterInviteSchema = new Schema({
source: { type: Schema.Types.ObjectId, index: true, ref: 'Account' },
target: { type: Schema.Types.ObjectId, index: true, ref: 'Account' },
name: String,
info: String,
active: Boolean,
}, { timestamps: true });
const friendRequestSchema = new Schema({
source: { type: Schema.Types.ObjectId, index: true, ref: 'Account' },
target: { type: Schema.Types.ObjectId, index: true, ref: 'Account' },
});
const hideRequestSchema = new Schema({
source: { type: Schema.Types.ObjectId, index: true, ref: 'Account' },
target: { type: Schema.Types.ObjectId, index: true, ref: 'Account' },
name: String,
date: Date,
});
const sessionSchema = new Schema({
_id: String,
session: String,
});
// models
export const Auth = model<IAuth>('Auth', authSchema);
export const Event = model<IEvent>('Event', eventSchema);
export const Origin = model<IOrigin>('Origin', originSchema);
export const Session = model<ISession>('session', sessionSchema);
export const Character = model<ICharacter>('Character', characterSchema);
accountSchema.post('remove', function (doc: Document) {
Promise.all([
Character.deleteMany({ account: doc._id }).exec(),
Event.deleteMany({ account: doc._id }).exec(),
Auth.deleteMany({ account: doc._id }).exec(),
FriendRequest.deleteMany({ $or: [{ target: doc._id }, { source: doc._id }] }).exec(),
HideRequest.deleteMany({ $or: [{ target: doc._id }, { source: doc._id }] }).exec(),
]).catch(logger.error);
});
export const Account = model<IAccount>('Account', accountSchema);
export const SupporterInvite = model<ISupporterInvite>('SupporterInvite', supporterInviteSchema);
export const FriendRequest = model<IFriendRequest>('FriendRequest', friendRequestSchema);
export const HideRequest = model<IHideRequest>('HideRequest', hideRequestSchema);
// helpers
export type ID = Types.ObjectId | string;
export interface MongoQueryExpr<T> {
$exists?: boolean;
$ne?: T;
$in?: T | T[];
$gt?: T;
$lt?: T;
$not?: MongoQueryExpr<T>;
$size?: number;
$regex?: RegExp;
}
export interface MongoUpdateExprField<T> {
$inc?: any;
$dec?: any;
$pull?: any;
$push?: any;
$unset?: {
[P in keyof T]?: any;
};
}
export interface MongoUpdateExpr<T> extends MongoUpdateExprField<T> {
$addToSet?: any;
}
export type MongoQuery<T> = {
[P in keyof T]?: T[P] | MongoQueryExpr<T[P]>;
};
export type MongoUpdate<T> = {
[P in keyof T]?: T[P] | MongoUpdateExprField<T[P]>;
} & MongoUpdateExpr<T>;
export function iterate<T>(query: Query<T>, onData: (doc: T) => void) {
return new Promise<void>(resolve => {
query.cursor()
.on('data', onData)
.on('end', resolve);
});
}
function throwOnEmpty<T>(message: string): (item: T | undefined) => T {
return item => {
if (item) {
return item;
} else {
throw new Error(message);
}
};
}
export function nullToUndefined<T>(item: T | null): T | undefined {
return item === null ? undefined : item;
}
export const checkCharacterExists = throwOnEmpty<ICharacter>('Character does not exist');
export const checkAccountExists = throwOnEmpty<IAccount>('Account does not exist');
// characters
export type CreateCharacter = (account: IAccount) => ICharacter;
export type CharacterCount = (accountId: ID) => Promise<number>;
export type FindCharacter = (characterId: ID, accountId: ID) => Promise<ICharacter | undefined>;
export type FindCharacterSafe = (characterId: ID, accountId: ID) => Promise<ICharacter>;
export type FindCharacters = (accountId: ID, fields?: string) => Promise<ICharacter[]>;
export type UpdateCharacterState = (characterId: ID, serverName: string, state: CharacterState) => Promise<void>;
export type QueryCharacter = (query: MongoQuery<ICharacter>, fields?: string) => Promise<ICharacter | undefined>;
export function createCharacter(account: IAccount) {
return new Character({ account: account._id, creator: `${account.name} [${account._id}]` });
}
export function characterCount(account: ID): Promise<number> {
return Character.countDocuments({ account }).exec();
}
export function findCharacter(pony: ID, account: ID): Promise<ICharacter | undefined> {
return Character.findOne({ _id: pony, account }).exec().then(nullToUndefined);
}
export function findCharacterSafe(pony: ID, accountId: ID): Promise<ICharacter> {
return findCharacter(pony, accountId)
.then(checkCharacterExists);
}
export function findCharacterById(id: string): Promise<ICharacter | undefined> {
return Character.findById(id).exec().then(nullToUndefined);
}
export const findAllCharacters: FindCharacters = (account, fields) =>
Character.find({ account }, fields).lean().exec();
export function findLatestCharacters(account: ID, count: number): Promise<ICharacter[]> {
return Character.find({ account })
.sort('-lastUsed')
.limit(count)
.exec();
}
export function removeCharacter(id: ID, account: ID): Promise<ICharacter | undefined> {
return Character.findOneAndRemove({ _id: id, account }).exec().then(nullToUndefined);
}
export const updateCharacterState: UpdateCharacterState = (characterId, serverName, state) =>
Character.updateOne({ _id: characterId }, { [`state.${serverName}`]: state }).exec().then(nullToUndefined);
export const queryCharacter: QueryCharacter = (query, fields) =>
Character.findOne(query, fields).exec() as any;
// auths
export type FindAuth = (authId: ID, accountId: ID, fields?: string) => Promise<IAuth | undefined>;
export type FindAuths = (accountId: ID, fields?: string) => Promise<IAuth[]>;
export type CountAuths = (accountId: ID) => Promise<number>;
export type QueryAuths = (query: MongoQuery<IAuth>, fields?: string) => Promise<IAuth[]>;
export type UpdateAuth = (authId: ID, update: MongoUpdate<IAuth>) => Promise<void>;
export const findAuthByOpenId = (openId: string, provider: string): Promise<IAuth | undefined> =>
Auth.findOne({ openId, provider }).exec().then(nullToUndefined);
export const findAuthByEmail = (emails: string[]): Promise<IAuth | undefined> =>
Auth.findOne({ emails: { $in: emails } }).exec().then(nullToUndefined);
export const findAuth: FindAuth = (auth, account, fields) =>
Auth.findOne({ _id: auth, account }, fields).exec().then(nullToUndefined);
export const findAllAuths: FindAuths = (account, fields) =>
Auth.find({ account, fields }).exec();
export const findAllVisibleAuths: FindAuths = (account, fields) =>
Auth.find({ account, disabled: { $ne: true }, banned: { $ne: true } }, fields).lean().exec();
export const countAllVisibleAuths: CountAuths = (account) =>
Auth.find({ account, disabled: { $ne: true }, banned: { $ne: true } }).countDocuments().exec();
export const queryAuths: QueryAuths = (query, fields) =>
Auth.find(query, fields).lean().exec();
export const updateAuth: UpdateAuth = (id, update) =>
Auth.updateOne({ _id: id }, update).exec();
// accounts
export type FindAccountSafe = (accountId: ID, projection?: string) => Promise<IAccount>;
export type UpdateAccount = (accountId: ID, update: MongoUpdate<IAccount>) => Promise<void>;
export type UpdateAccounts = (query: MongoQuery<IAccount>, update: MongoUpdate<IAccount>) => Promise<void>;
export type QueryAccounts = (query: MongoQuery<IAccount>, fields?: string) => Promise<IAccount[]>;
export type QueryAccount = (query: MongoQuery<IAccount>, fields?: string) => Promise<IAccount | undefined>;
export const findAccount = (account: ID, projection?: string): Promise<IAccount | undefined> =>
Account.findById(account, projection).exec().then(nullToUndefined);
export function checkIfAdmin(account: ID): Promise<boolean> {
return Account.findOne({ _id: account }, 'roles').lean().exec()
.then(a => a && isAdmin(a));
}
export function findAccountSafe(account: ID, projection?: string): Promise<IAccount> {
return findAccount(account, projection)
.then(checkAccountExists);
}
export const updateAccount: UpdateAccount = (accountId, update) =>
Account.updateOne({ _id: accountId }, update).exec();
export const updateAccounts: UpdateAccounts = (query, update) =>
Account.updateMany(query, update).exec();
export const queryAccounts: QueryAccounts = (query, fields) =>
Account.find(query, fields).lean().exec();
export const queryAccount: QueryAccount = (query, fields) =>
Account.findOne(query, fields).exec().then(nullToUndefined);
// supporter invites
export type HasActiveSupporterInvites = (accountId: ID) => Promise<boolean>;
export const hasActiveSupporterInvites: HasActiveSupporterInvites = (accountId) =>
SupporterInvite.countDocuments({ target: accountId, active: true }).exec()
.then(count => count > 0);
// friend requests
export async function findFriendIds(accountId: ID) {
const accountIdString = accountId.toString();
const friendRequests = await FriendRequest
.find({ $or: [{ source: accountId }, { target: accountId }] }, 'source target')
.lean()
.exec();
const friendIds = friendRequests
.map((f: any) => f.source.toString() === accountIdString ? f.target.toString() : f.source.toString());
return friendIds;
}
export async function findFriends(accountId: ID, withCharacters: boolean): Promise<FriendData[]> {
const friendIds = await findFriendIds(accountId);
const accounts: IAccount[] = await Account.find({ _id: { $in: friendIds } }, '_id name lastOnline lastCharacter').lean().exec();
let characters: ICharacter[] = [];
if (withCharacters) {
const characterIds = accounts.map(a => a.lastCharacter).filter(id => id);
characters = await Character.find({ _id: { $in: characterIds } }, '_id name info').lean().exec();
}
return accounts.map(a => {
const characterId = a.lastCharacter && a.lastCharacter.toString();
const character = characterId && characters.find(c => c._id.toString() === characterId);
const name = character && filterForbidden(replaceEmojis(character.name));
const nameFiltered = name && filterName(name);
return {
accountId: a._id.toString(),
accountName: a.name,
name,
pony: character && character.info,
nameBad: name !== nameFiltered,
};
});
}
// hide requests
export async function findHideIds(accountId: ID) {
const hideRequests: IHideRequest[] = await HideRequest.find({ source: accountId }, 'target').lean().exec();
return hideRequests.map(f => f.target.toString());
}
export async function findHideIdsRev(accountId: ID) {
const hideRequests: IHideRequest[] = await HideRequest.find({ target: accountId }, 'source').lean().exec();
return hideRequests.map(f => f.source.toString());
}
export async function findHidesForMerge(accountId: ID): Promise<MergeHideData[]> {
const hideRequests: IHideRequest[] = await HideRequest
.find({ source: accountId }, '_id name date')
.lean()
.exec();
return hideRequests.map(f => ({
id: f._id.toString(),
name: f.name,
date: f.date.toString(),
}));
}
export async function addHide(source: ID, target: ID, name: string) {
if (source.toString() === target.toString())
return;
const existing = await HideRequest.findOne({ source, target }, '_id').lean().exec();
if (!existing) {
await HideRequest.create({ source, target, name, date: new Date() });
}
}
+346
View File
@@ -0,0 +1,346 @@
import { resizeWriter, writeUint8, BinaryWriter, writeUint32, writeUint16 } from 'ag-sockets';
import { encodeString } from 'ag-sockets/dist/utf8';
import {
Entity, Rect, EntityState, UpdateFlags, Action, EntityOrPonyOptions, UpdateType, TileType, canWalk, setAnimationToEntityState
} from '../common/interfaces';
import { normalize, containsPoint, boundsIntersect, clamp, pointInXYWH, hasFlag, setFlag } from '../common/utils';
import { ServerEntity, ServerEntityWithClient, ServerMap, EntityUpdateBase, IClient } from './serverInterfaces';
import {
isCritter, isDecal, entityInRange, SIT_ON_BOUNDS_WIDTH, SIT_ON_BOUNDS_HEIGHT, SIT_ON_BOUNDS_OFFSET
} from '../common/entityUtils';
import { pushUpdateEntityToRegion } from './serverRegion';
import { getRegion, getRegionGlobal, getTile } from '../common/worldMap';
import { filterName } from '../common/swears';
import { shouldBeFacingRight } from '../common/movementUtils';
import { writeOneEntity, writeOneUpdate } from '../common/encoders/updateEncoder';
import { PONY_TYPE } from '../common/constants';
import { grapesPurple, grapesGreen } from '../common/entities';
export function isEntityShadowed(entity: ServerEntity): entity is ServerEntityWithClient {
return entity.client !== undefined && entity.client.shadowed;
}
export function setEntityName(entity: ServerEntity, name: string) {
entity.name = name;
entity.nameBad = name !== filterName(name);
entity.encodedName = encodeString(name)!;
}
export function getEntityName(entity: ServerEntity, client: IClient) {
if (entity.name && entity.nameBad && client.accountSettings.filterSwearWords) {
return filterName(entity.name);
} else {
return entity.name;
}
}
const grapeTypes = [...grapesPurple.map(x => x.type), ...grapesGreen.map(x => x.type)];
export function isHoldingGrapes(e: ServerEntity) {
const hold = e.options!.hold || 0;
return hold !== 0 && grapeTypes.indexOf(hold) !== -1;
}
export function canBoopEntity(e: ServerEntity, boopRect: Rect) {
if (e.type === PONY_TYPE) {
return isHoldingGrapes(e);
} else {
return e.boop !== undefined && containsPoint(0, 0, boopRect, e.x + (e.boopX || 0), e.y + (e.boopY || 0));
}
}
function distSq(ax: number, ay: number, bx: number, by: number) {
const dx = ax - bx;
const dy = ay - by;
return dx * dx + dy * dy;
}
export function findClosest(x: number, y: number, entities: Entity[]) {
let closest = entities[0];
let distance = closest ? distSq(x, y, closest.x, closest.y) : 0;
for (let i = 1; i < entities.length; i++) {
const entity = entities[i];
const dist = distSq(x, y, entity.x, entity.y);
if (dist < distance) {
closest = entity;
distance = dist;
}
}
return closest;
}
export function moveRandomly(
map: ServerMap, e: ServerEntity, speed: number, randomness: number, timestamp: number
) {
if (Math.random() < randomness) {
let vx = 0;
let vy = 0;
if (e.x < 0) {
vx = 1;
} else if (e.x > map.width) {
vx = -1;
} else if (e.y < 0) {
vy = 1;
} else if (e.y > map.height) {
vy = -1;
} else {
vx = Math.random() - 0.5;
vy = Math.random() - 0.5;
}
updateEntityVelocity(e, vx * speed, vy * speed, timestamp);
}
}
export function moveTowards(e: ServerEntity, x: number, y: number, speed: number, timestamp: number) {
const v = normalize(x - e.x, y - e.y);
updateEntityVelocity(e, v.x * speed, v.y * speed, timestamp);
}
// update entity functions
export function setEntityAnimation(entity: ServerEntity, animation: number, faceRight?: boolean) {
let state = entity.state;
if (faceRight !== undefined) {
state = setFlag(state, EntityState.FacingRight, faceRight);
}
state = setAnimationToEntityState(state, animation);
updateEntityState(entity, state);
}
export function updateEntityVelocity(entity: ServerEntity, vx: number, vy: number, timestamp: number) {
if (vx !== entity.vx || vy !== entity.vy) {
entity.vx = vx;
entity.vy = vy;
entity.timestamp = timestamp;
entity.state = setFlag(entity.state, EntityState.FacingRight, shouldBeFacingRight(entity));
updateEntity(entity, false);
}
}
export function updateEntity(entity: ServerEntity, switchRegion: boolean) {
const flags = UpdateFlags.Position | UpdateFlags.State | (switchRegion ? UpdateFlags.SwitchRegion : 0);
const { x, y, vx, vy } = entity;
pushUpdateEntity({ entity, flags, x, y, vx, vy });
}
export function updateEntityState(entity: ServerEntity, state: EntityState) {
entity.state = state;
pushUpdateEntity({ entity, flags: UpdateFlags.State });
}
export function updateEntityOptions(entity: ServerEntity, options: Partial<EntityOrPonyOptions>) {
entity.options = Object.assign(entity.options || {}, options) as any;
pushUpdateEntity({ entity, flags: UpdateFlags.Options, options });
}
export function updateEntityNameInfo(entity: ServerEntity) {
pushUpdateEntity({ entity, flags: UpdateFlags.Name | UpdateFlags.Info });
}
export function updateEntityExpression(entity: ServerEntity) {
pushUpdateEntity({ entity, flags: UpdateFlags.Expression });
}
export function sendAction(entity: ServerEntity, action: Action) {
pushUpdateEntity({ entity, flags: UpdateFlags.Action, action });
}
export function pushUpdateEntity(update: EntityUpdateBase) {
const entity = update.entity;
if (isEntityShadowed(entity)) {
pushUpdateEntityToClient(entity.client, update);
} else if (entity.region) {
pushUpdateEntityToRegion(entity.region, update);
}
}
export function isOverflowError(e: Error) {
return e instanceof RangeError || /DataView/.test(e.message);
}
function resizePreserveWriter(error: Error, writer: BinaryWriter, offset: number) {
if (isOverflowError(error)) {
const bytes = writer.bytes;
resizeWriter(writer);
writer.bytes.set(bytes);
writer.offset = offset;
// DEVELOPMENT && logger.debug(`resize writer to ${writer.bytes.byteLength} (${error.message})`);
} else {
throw error;
}
}
export function pushAddEntityToClient(client: IClient, entity: ServerEntity) {
const writer = client.updateQueue;
const offset = writer.offset;
while (true) {
try {
writeUint8(writer, UpdateType.AddEntity);
writeOneEntity(writer, entity, client);
break;
} catch (e) {
resizePreserveWriter(e, writer, offset);
}
}
}
export function pushUpdateEntityToClient(client: IClient, update: EntityUpdateBase) {
const writer = client.updateQueue;
const offset = writer.offset;
const { entity, flags, x = 0, y = 0, vx = 0, vy = 0, options, action = 0, playerState = 0 } = update;
while (true) {
try {
writeUint8(writer, UpdateType.UpdateEntity);
writeOneUpdate(writer, entity, flags, x, y, vx, vy, options, action, playerState);
break;
} catch (e) {
resizePreserveWriter(e, writer, offset);
}
}
}
export function pushRemoveEntityToClient(client: IClient, entity: ServerEntity) {
const writer = client.updateQueue;
const offset = writer.offset;
while (true) {
try {
writeUint8(writer, UpdateType.RemoveEntity);
writeUint32(writer, entity.id);
break;
} catch (e) {
resizePreserveWriter(e, writer, offset);
}
}
}
export function pushUpdateTileToClient(client: IClient, x: number, y: number, type: TileType) {
const writer = client.updateQueue;
const offset = writer.offset;
while (true) {
try {
writeUint8(writer, UpdateType.UpdateTile);
writeUint16(writer, x);
writeUint16(writer, y);
writeUint8(writer, type);
break;
} catch (e) {
resizePreserveWriter(e, writer, offset);
}
}
}
// other helpers
export function findIntersectingEntityByBounds(map: ServerMap, entity: ServerEntity) {
const { x, y } = getRegionGlobal(map, entity.x, entity.y);
const minX = Math.max(x - 1, 0);
const minY = Math.max(y - 1, 0);
const maxX = Math.min(x + 1, map.regionsX - 1);
const maxY = Math.min(y + 1, map.regionsY - 1);
for (let iy = minY; iy <= maxY; iy++) {
for (let ix = minX; ix <= maxX; ix++) {
const region = getRegion(map, ix, iy);
for (const e of region.entities) {
if (e !== entity && !isDecal(e) && !isCritter(e) && boundsIntersect(entity.x, entity.y, entity.bounds, e.x, e.y, e.bounds)) {
return e;
}
}
}
}
return undefined;
}
export function findPlayerThatCanPickEntity(map: ServerMap, entity: ServerEntity) {
const { x, y } = getRegionGlobal(map, entity.x, entity.y);
const minX = Math.max(x - 1, 0);
const minY = Math.max(y - 1, 0);
const maxX = Math.min(x + 1, map.regionsX - 1);
const maxY = Math.min(y + 1, map.regionsY - 1);
for (let iy = minY; iy <= maxY; iy++) {
for (let ix = minX; ix <= maxX; ix++) {
const region = getRegion(map, ix, iy);
for (const e of region.entities) {
if (e.client !== undefined && entityInRange(entity, e)) {
return e;
}
}
}
}
return undefined;
}
export function findPlayersThetCanBeSitOn(map: ServerMap, entity: ServerEntity) {
const { x, y } = getRegionGlobal(map, entity.x, entity.y);
const minX = Math.max(x - 1, 0);
const minY = Math.max(y - 1, 0);
const maxX = Math.min(x + 1, map.regionsX - 1);
const maxY = Math.min(y + 1, map.regionsY - 1);
for (let iy = minY; iy <= maxY; iy++) {
for (let ix = minX; ix <= maxX; ix++) {
const region = getRegion(map, ix, iy);
for (const e of region.entities) {
if (e !== entity && e.client !== undefined && canBeSitOn(e, entity)) {
return e;
}
}
}
}
return undefined;
}
function canBeSitOn(entity: ServerEntity, by: ServerEntity) {
const right = hasFlag(by.state, EntityState.FacingRight);
const entityRight = hasFlag(entity.state, EntityState.FacingRight);
if (right !== entityRight) {
return false;
}
const x = by.x + (right ? -SIT_ON_BOUNDS_OFFSET : (SIT_ON_BOUNDS_OFFSET - SIT_ON_BOUNDS_WIDTH));
const y = by.y - SIT_ON_BOUNDS_HEIGHT / 2;
const w = SIT_ON_BOUNDS_WIDTH;
const h = SIT_ON_BOUNDS_HEIGHT;
return pointInXYWH(entity.x, entity.y, x, y, w, h);
}
export function canPlaceItem(map: ServerMap, entity: ServerEntity) {
const tile = getTile(map, entity.x, entity.y);
return canWalk(tile) && tile !== TileType.Water && tile !== TileType.Boat &&
!findIntersectingEntityByBounds(map, entity);
}
export function canBePickedByPlayer(map: ServerMap, entity: ServerEntity) {
return !!findPlayerThatCanPickEntity(map, entity);
}
export function fixPosition(entity: ServerEntity, map: ServerMap, x: number, y: number, safe: boolean) {
entity.x = clamp(x, 0, map.width);
entity.y = clamp(y, 0, map.height);
updateEntity(entity, false);
if (entity.client) {
entity.client.fixPosition(entity.x, entity.y, safe);
entity.client.fixingPosition = true;
}
}
+182
View File
@@ -0,0 +1,182 @@
import * as request from 'request-promise';
import { noop, flatMap, uniq } from 'lodash';
import {
InternalGameServerState, ServerStatus, InternalLoginApi, InternalLoginServerState, InternalApi, HidingStats
} from '../common/adminInterfaces';
import { isMod } from '../common/accountUtils';
import { findById, flatten, delay } from '../common/utils';
import { config, args, gameServers } from './config';
import { logger } from './logger';
import { IAccount, ICharacter, findAccountSafe, findHideIds, findHideIdsRev } from './db';
import { createAccountChanged } from './api/internal';
import { TokenService } from './serverInterfaces';
import { World } from './world';
import { InternalAdminApi } from './api/internal-admin';
import { EndPoints } from './api/admin';
import { UserError } from './userError';
import { AdminService } from './services/adminService';
// import { taskQueue } from './utils/taskQueue';
export const serverStatus: ServerStatus = {
diskSpace: '',
memoryUsage: '',
certificateExpiration: '',
lastPatreonUpdate: '',
};
export const loginServers: InternalLoginServerState[] = [
{
id: 'login',
state: {
updating: false,
dead: true,
},
api: createApi<InternalLoginApi>(config.local, 'api-internal-login', config.token),
},
];
export const adminServer = config.adminLocal && !args.admin ? {
id: 'admin',
api: createApi<InternalAdminApi>(config.adminLocal, 'api-internal-admin', config.token),
} : undefined;
export const servers: InternalGameServerState[] = [];
if (args.login || args.admin) {
servers.push(...gameServers.map(s => ({
id: s.id,
state: {
...s,
offline: true,
dead: true,
maps: 0,
online: 0,
onMain: 0,
queued: 0,
shutdown: false,
filter: false,
settings: {},
},
api: createApi<InternalApi>(s.local, 'api-internal', config.token),
})));
}
export function findServer(id: string) {
return findById(servers, id);
}
export function getLoginServer(_id: string) {
return loginServers[0];
}
export function getServer(id: string) {
const server = findServer(id);
if (!server) {
throw new Error(`Invalid server ID (${id})`);
}
return server;
}
export function createApi<T extends {}>(host: string, url: string, apiToken: string): T {
return new Proxy<T>({} as any, {
get: (_, key) =>
(...args: any[]) =>
Promise.resolve<T>(request(`http://${host}/${url}/api`, {
json: true,
headers: { 'api-token': apiToken },
method: 'post',
body: { method: key, args },
})),
});
}
function mapGameServers<T>(action: (server: InternalGameServerState) => Promise<T> | T) {
return Promise.all(servers.filter(s => !s.state.dead).map(action));
}
export function createJoin(): typeof join {
return join;
}
async function join(joinServer: InternalGameServerState, account: IAccount, character: ICharacter): Promise<string> {
try {
const kicked = await mapGameServers(s => {
if (isMod(account) && s !== joinServer) {
return false;
} else {
return s.api.kick(account._id.toString(), undefined).catch(e => (logger.error(e), false));
}
});
if (kicked.some(x => x)) {
await delay(2000);
}
return await joinServer.api.join(account._id.toString(), character._id.toString());
} catch (error) {
if (error.error && error.error.userError) {
throw new UserError(error.error.error);
} else {
logger.error(error);
throw new Error('Internal error');
}
}
}
let accountChangedHandler = (_accountId: string) => Promise.resolve();
export function init(world: World, tokens: TokenService) {
accountChangedHandler = createAccountChanged(world, tokens, findAccountSafe);
}
export async function accountChanged(accountId: string) {
if (args.login || args.admin) {
await mapGameServers(s => {
s.api.accountChanged(accountId).catch(noop);
});
} else {
await accountChangedHandler(accountId);
}
}
export async function accountMerged(accountId: string, mergedId: string) {
await mapGameServers(s => { s.api.accountMerged(accountId, mergedId).catch(noop); });
}
export async function accountStatus(accountId: string) {
const statuses = await mapGameServers(s => s.api.accountStatus(accountId).catch(() => ({ online: false })));
return statuses.filter(s => !!s.online);
}
export async function accountAround(accountId: string) {
const users = await mapGameServers(s => s.api.accountAround(accountId).catch(() => []));
return flatten(users).sort((a, b) => a.distance - b.distance).slice(0, 10);
}
export async function accountHidden(accountId: string): Promise<HidingStats> {
const [users, permaHidden, permaHiddenBy] = await Promise.all([
mapGameServers(s => s.api.accountHidden(accountId).catch(() => ({ account: '', hidden: [], hiddenBy: [] }))),
findHideIds(accountId),
findHideIdsRev(accountId),
]);
return {
account: accountId,
hidden: uniq(flatMap(users, u => u.hidden)),
hiddenBy: uniq(flatMap(users, u => u.hiddenBy)),
permaHidden,
permaHiddenBy,
};
}
export type RemovedDocument = ReturnType<typeof createRemovedDocument>;
export const createRemovedDocument =
(endPoints: EndPoints | undefined, adminService: AdminService | undefined) =>
(model: 'events' | 'ponies' | 'accounts' | 'auths' | 'origins', id: string) => {
endPoints && model in endPoints && (endPoints as any)[model].removedItem(id);
adminService && adminService.removedItem(model, id);
return adminServer ? adminServer.api.removedDocument(model, id).catch(noop) : Promise.resolve();
};
+98
View File
@@ -0,0 +1,98 @@
import * as ipc from 'node-ipc';
export interface LoginServer {
hello(message: string): Promise<void>;
}
export interface GameServer {
something(): Promise<string>;
}
interface SocketState<TServer, TClient> {
server: TServer;
client: TClient;
}
export function startIPCServer<TServer, TClient extends Object>(
id: string, createServer: (client: TClient) => TServer
) {
ipc.config.id = id;
ipc.config.retry = 500;
ipc.config.silent = true;
ipc.serve(() => {
const sockets = new Map<any, SocketState<TServer, TClient>>();
ipc.server.on('connect', (socket) => {
console.log('server:connect');
const client: TClient = new Proxy<TClient>({} as any, {
get: (_, key) => (...args: any[]) => {
ipc.server.emit(socket, 'message', [key, args]);
},
});
const server = createServer(client);
sockets.set(socket, { server, client });
});
ipc.server.on('message', (data, socket) => {
console.log('server:message', data);
const socketState = sockets.get(socket);
if (socketState) {
(socketState.server as any)[data[0]](...data[1]);
} else {
console.error('missing server for socket');
}
});
ipc.server.on('error', (error) => {
console.log('server:error', error);
});
ipc.server.on('disconnect', (socket) => {
console.log('server:disconnect');
sockets.delete(socket);
});
ipc.server.on('socket.disconnected', (socket, _destroyedSocketID) => {
console.log('server:socket.disconnected');
sockets.delete(socket);
});
});
ipc.server.start();
}
export function startIPCClient<TServer extends Object, TClient>(
serverId: string, clientId: string, createClient: (server: TServer) => TClient
) {
ipc.config.id = clientId;
ipc.config.retry = 500;
ipc.config.silent = true;
ipc.connectTo(serverId, () => {
let connected = false;
const socket = ipc.of[serverId];
const server: TServer = new Proxy<TServer>({} as any, {
get: (_, key) => (...args: any[]) => {
socket.emit('message', [key, args]);
},
});
const client = createClient(server);
socket.on('connect', () => {
connected = true;
console.log('client:connect');
});
socket.on('disconnect', () => {
if (connected) {
connected = false;
console.log('client:disconnect');
}
});
socket.on('message', (data: any) => {
console.log('client:message', data);
(client as any)[data[0]](...data[1]);
});
});
}
+170
View File
@@ -0,0 +1,170 @@
import * as Promise from 'bluebird';
import { Model } from 'mongoose';
import { remove, noop } from 'lodash';
import { ITEM_LIMIT, LiveResponse, BaseValues } from '../common/adminInterfaces';
import { MINUTE } from '../common/constants';
import { fromNow } from '../common/utils';
import { Doc } from './db';
import { logger } from './logger';
export interface LiveEndPoint {
get(id: string): Promise<any>;
getAll(timestamp?: string): Promise<LiveResponse>;
assignAccount(id: string, account: string): Promise<void>;
removeItem(id: string): Promise<void>;
removedItem(id: string): void;
encodeItems(items: any[], timestamp: Date, more: boolean): LiveResponse;
destroy(): void;
}
interface DeletedId {
updatedAt: Date;
id: string;
}
interface LiveEndPointConfig<T extends Doc> {
model: Model<T>;
fields: string[];
fix?: boolean;
encode: (items: T[], base: BaseValues) => any[][];
beforeDelete?: (item: T) => any;
afterDelete?: (item: T) => any;
beforeAssign?: (item: T, accountId: string) => any;
afterAssign?: (from: string, to: string) => any;
}
export function createLiveEndPoint<T extends Doc>(
{ model, fields, encode, beforeDelete, afterDelete, beforeAssign, afterAssign, fix = false }: LiveEndPointConfig<T>
): LiveEndPoint {
const removedItems: DeletedId[] = [];
let fixing = false;
function removedItem(id: string) {
removedItems.push({ id, updatedAt: new Date() });
}
function removeItem(id: string) {
return Promise.resolve(model.findById(id).exec())
.tap(item => item && beforeDelete && beforeDelete(item))
.tap(item => {
if (item) {
removedItem(item._id.toString());
return item.remove() as any;
}
})
.tap(item => item && afterDelete && afterDelete(item))
.then(noop);
}
function assignAccount(id: string, account: string) {
return Promise.resolve()
.then(() => model.findById(id, 'account').lean().exec())
.tap((item: any) => item && beforeAssign && beforeAssign(item, account))
.tap(() => model.findByIdAndUpdate(id, { account }).exec())
.tap((item: any) => item && afterAssign && afterAssign(item.account, account))
.then(noop);
}
function encodeItems(items: T[], timestamp: Date, more: boolean): LiveResponse {
const base: BaseValues = {};
const updates = encode(items, base);
const deletes = removedItems
.filter(x => x.updatedAt.getTime() > timestamp.getTime())
.map(x => x.id);
return { updates, deletes, base, more };
}
function findItems(from: Date): Promise<T[]> {
return Promise.resolve(model.find({ updatedAt: { $gt: from } }, fields.join(' '))
// .sort([['updatedAt', 1], ['id', 1]])
.sort({ updatedAt: 1 })
.limit(ITEM_LIMIT + 1)
.lean()
.exec());
}
function findItemsExact(date: Date): Promise<T[]> {
return Promise.resolve(model.find({ updatedAt: date }, fields.join(' '))
.lean()
.exec());
}
function hasItem(items: T[], id: string) {
return items.some(i => i._id === id);
}
function addTailItems(items: T[]): Promise<{ items: T[]; more: boolean; }> {
if (items.length <= ITEM_LIMIT) {
return Promise.resolve({ items, more: false });
}
const a = items[items.length - 1];
const b = items[items.length - 2];
if (a.updatedAt.getTime() !== b.updatedAt.getTime()) {
items.pop();
return Promise.resolve({ items, more: true });
}
return findItemsExact(items[items.length - 1].updatedAt)
.then(other => other.filter(i => !hasItem(items, i._id)))
.then(other => [...items, ...other])
.then(items => ({ items, more: true }));
}
function getAll(timestamp?: string): Promise<LiveResponse> {
const from = timestamp ? new Date(timestamp) : new Date(0);
return findItems(from)
.then(addTailItems)
.tap(({ items }) => {
try {
if (items.length > ITEM_LIMIT * 2) {
fixItems(items);
logger.warn(`Fetching ${items.length} ${model.modelName}s [${items[ITEM_LIMIT + 1].updatedAt.toISOString()}]`);
}
} catch (e) {
logger.error(e);
}
})
.then(({ items, more }) => encodeItems(items, from, more));
}
function fixItems(items: T[]) {
if (fixing || !fix)
return;
fixing = true;
logger.info(`Fixing ${model.modelName}s`);
Promise.map(items, item => model.updateOne({ _id: item._id }, { unused: Date.now() % 1000 }).exec(), { concurrency: 1 })
.then(() => logger.info(`Fixed ${model.modelName}s`))
.catch(e => logger.error(e))
.finally(() => fixing = false)
.done();
}
function get(id: string) {
return Promise.resolve(model.findById(id).lean().exec());
}
const interval = setInterval(() => {
const date = fromNow(-10 * MINUTE);
remove(removedItems, x => x.updatedAt.getTime() < date.getTime());
}, 1 * MINUTE);
function destroy() {
clearInterval(interval);
}
return {
get,
getAll,
assignAccount,
removeItem,
removedItem,
encodeItems,
destroy,
};
}
+6
View File
@@ -0,0 +1,6 @@
import { ServerLiveSettings } from '../common/adminInterfaces';
export const liveSettings: ServerLiveSettings = {
updating: false,
shutdown: false,
};
+105
View File
@@ -0,0 +1,105 @@
import { console, dailyfile } from 'tracer';
import chalk from 'chalk';
import { ChatType, isPublicChat } from '../common/interfaces';
import { IClient } from './serverInterfaces';
import { getChatPrefix } from './commands';
import { isMutedOrShadowed } from './playerUtils';
import { ServerConfig } from '../common/adminInterfaces';
import { pathTo } from './paths';
import { ID } from './db';
const { reset, gray, magenta, cyan, green, yellow, red } = chalk;
function format(color: (text: string) => string) {
//'[{{timestamp}}] [{{title}}] {{message}} ({{file}}:{{line}})',
return [
reset('['),
gray('{{timestamp}}'),
reset('] ['),
color('{{title}}'),
reset('] {{message}} '),
gray('({{file}}:{{line}})'),
].join('');
}
export const logger = console({
level: 0,
dateformat: 'mmm dd HH:MM:ss',
format: [
format(reset),
{
trace: format(cyan),
debug: format(magenta),
info: format(green),
warn: format(yellow),
error: format(red),
}
],
} as any);
const daily = dailyfile({
root: pathTo('logs'),
maxLogFiles: 14,
dateformat: 'HH:MM:ss',
format: '{{timestamp}} {{message}}', // ({{file}}:{{line}})
} as any);
export function log(message: string) {
daily.info(message);
}
export function formatMessage(accountId: ID, type: string, message: string) {
return `[${accountId}]${type}\t${message}`;
}
export function systemMessage(accountId: ID, message: string) {
return formatMessage(accountId, '[system]', message);
}
function adminMessage(accountId: ID, message: string) {
return formatMessage(accountId, '[admin]', message);
}
export function system(accountId: ID, message: string) {
log(systemMessage(accountId, message));
}
export function admin(accountId: ID, message: string) {
log(adminMessage(accountId, message));
}
export function logPatreon(message: string) {
log(formatMessage('patreon', '', message));
}
export function logServer(message: string) {
log(formatMessage('server', '', message));
}
export function logPerformance(message: string) {
log(formatMessage('performance', '', message));
}
export function chat(
server: ServerConfig, client: IClient, text: string, type: ChatType, ignored: boolean, target: IClient | undefined
) {
let prefix = getChatPrefix(type);
let mod = '';
if (ignored) {
mod = '[ignored]';
} else if (isMutedOrShadowed(client)) {
mod = '[muted]';
} else if (client.accountSettings.ignorePublicChat && isPublicChat(type)) {
mod = '[ignorepub]';
}
if (type === ChatType.Whisper) {
prefix += `[${target ? `${target.accountId}${target.shadowed ? '][shadowed' : ''}` : 'undefined'}] `;
}
const message = formatMessage(
client.accountId, `[${server.id}][${client.map.id || 'main'}][${client.characterName}]${mod}`, `${prefix}${text}`);
log(message);
}
+294
View File
@@ -0,0 +1,294 @@
import { ServerEntity, ServerMap, IClient } from './serverInterfaces';
import { World } from './world';
import { Rect, SignEntityOptions, MessageType, CreateEntityMethod, PonyOptions, Point } from '../common/interfaces';
import { roundPosition } from '../common/positionUtils';
import { getRegionGlobal } from '../common/worldMap';
import { addEntityToRegion, getRegionTiles, removeEntityFromRegion } from './serverRegion';
import * as entities from '../common/entities';
import { updateTileIndices } from '../client/tileUtils';
import { generateRegionCollider } from '../common/region';
import { PONY_TYPE, tileWidth, tileHeight } from '../common/constants';
import { sayTo, saySystem } from './chat';
import { setEntityName, updateEntityVelocity, setEntityAnimation } from './entityUtils';
import { updateAccountState } from './accountUtils';
import { toInt, includes } from '../common/utils';
import { holdItem } from './playerUtils';
import { random, sample, clamp } from 'lodash';
import { findEntities } from './serverMap';
import { randomPosition } from './controllers/collectableController';
import { BunnyAnimation } from '../common/entities';
export const worldForTemplates: any = {
featureFlags: {},
addEntity(entity: ServerEntity, map: ServerMap) {
roundPosition(entity);
const region = getRegionGlobal(map, entity.x, entity.y);
entity.region = region;
addEntityToRegion(region, entity, map);
return entity;
},
removeEntity(entity: ServerEntity, map: ServerMap) {
let removed = false;
if (entity.region) {
removed = removeEntityFromRegion(entity.region, entity, map);
}
return removed;
},
};
export function addSpawnPointIndicators(world: World, map: ServerMap) {
const addSpawn = ({ x, y, w, h }: Rect) => {
world.addEntity(entities.spawnPole(x, y), map);
if (w && h) {
world.addEntity(entities.spawnPole(x + w, y), map);
world.addEntity(entities.spawnPole(x, y + h), map);
world.addEntity(entities.spawnPole(x + w, y + h), map);
}
};
addSpawn(map.spawnArea);
for (const spawn of Array.from(map.spawns.values())) {
addSpawn(spawn);
}
}
export function generateTileIndicesAndColliders(map: ServerMap) {
for (const region of map.regions) {
getRegionTiles(region); // initialize encodedTiles
if (region.tilesDirty) {
updateTileIndices(region, map);
}
}
for (const region of map.regions) {
if (region.colliderDirty) {
generateRegionCollider(region, map);
}
}
}
export function removePonies(entities: ServerEntity[]) {
for (let i = entities.length - 1; i >= 0; i--) {
if (entities[i].type === PONY_TYPE) {
entities.splice(i, 1);
}
}
}
export interface SignDirection {
icon: number;
name: string;
}
export interface SignConfig {
r?: number;
w?: (SignDirection | undefined)[];
e?: (SignDirection | undefined)[];
n?: (SignDirection | undefined)[];
s?: (SignDirection | undefined)[];
}
export function createDirectionSign(x: number, y: number, config: SignConfig) {
const result: ServerEntity[] = [];
const options: SignEntityOptions = { sign: {} };
const lines: string[] = [];
const { w = [], e = [], s = [], n = [] } = config;
const max = clamp(Math.max(w.length, e.length, s.length, n.length), 3, 5);
const skip = 5 - max;
function parse(entries: (SignDirection | undefined)[], arrow: string, plates: CreateEntityMethod[], ox: number) {
for (let i = 0; i < entries.length; i++) {
const e = entries[i];
if (e) {
lines.push(`${arrow} ${e.name}`);
const nameplate = plates[i](x + ox / tileWidth, y);
setEntityName(nameplate, e.name);
result.push(nameplate);
}
}
}
if (config.r) {
options.sign.r = config.r;
}
const ups = config.r ? entities.directionSignUpsRight : entities.directionSignUpsLeft;
const downs = config.r ? entities.directionSignDownsLeft : entities.directionSignDownsRight;
if (config.n) {
options.sign.n = config.n.map(x => x ? x.icon : -1);
parse(config.n, '↑', ups.slice(skip), 0);
}
if (config.w) {
options.sign.w = config.w.map(x => x ? x.icon : -1);
parse(config.w, '←', entities.directionSignLefts.slice(skip), -10);
}
if (config.e) {
options.sign.e = config.e.map(x => x ? x.icon : -1);
parse(config.e, '→', entities.directionSignRights.slice(skip), 10);
}
if (config.s) {
options.sign.s = config.s.map(x => x ? x.icon : -1);
parse(config.s, '↓', downs.slice(skip), 0);
}
const text = lines.join('\n');
const entity = entities.directionSign(x, y, options) as ServerEntity;
entity.interact = (entity, client) => sayTo(client, entity, text, MessageType.System);
result.push(entity);
return result;
}
const patchTypes = [
entities.cloverPatch3, entities.cloverPatch4, entities.cloverPatch5, entities.cloverPatch6, entities.cloverPatch7
].map(x => x.type);
const eggBasketTypes = entities.eggBaskets.map(b => b.type);
export function pickCandy(client: IClient) {
let count = 0;
updateAccountState(client.account, state => state.candies = count = toInt(state.candies) + 1);
saySystem(client, `${count} 🍬`);
}
export function pickGift(client: IClient) {
let count = 0;
updateAccountState(client.account, state => state.gifts = count = toInt(state.gifts) + 1);
saySystem(client, `${count} 🎁`);
holdItem(client.pony, entities.gift2.type);
}
export function pickClover(client: IClient) {
let count = 0;
updateAccountState(client.account, state => state.clovers = count = toInt(state.clovers) + 1);
saySystem(client, `${count} 🍀`);
holdItem(client.pony, entities.cloverPick.type);
}
export function pickEgg(client: IClient) {
let count = 0;
updateAccountState(client.account, state => state.eggs = count = toInt(state.eggs) + 1);
saySystem(client, `${count} 🥚`);
if (Math.random() < 0.05) {
const options = client.pony.options as PonyOptions;
const basketIndex = eggBasketTypes.indexOf(options.hold || 0);
if (basketIndex >= 0 && basketIndex < (eggBasketTypes.length - 1)) {
holdItem(client.pony, eggBasketTypes[basketIndex + 1]);
}
}
}
export function pickEntity(client: IClient, entity: ServerEntity) {
holdItem(client.pony, entity.type);
}
export function checkLantern(client: IClient) {
const options = client.pony.options as PonyOptions;
const canPick = options.hold === entities.jackoLanternOn.type || options.hold === entities.jackoLanternOff.type;
if (!canPick) {
saySystem(client, 'Get a lantern to collect candies');
}
return canPick;
}
export function checkBasket(client: IClient) {
const options = client.pony.options as PonyOptions;
const canPick = includes(eggBasketTypes, options.hold);
if (!canPick) {
saySystem(client, 'Get a basket to collect eggs');
}
return canPick;
}
export function checkNotCollecting(client: IClient) {
const options = client.pony.options as PonyOptions;
const canPick = includes(eggBasketTypes, options.hold) ||
options.hold === entities.jackoLanternOn.type ||
options.hold === entities.jackoLanternOff.type;
return !canPick;
}
export function positionClover(map: ServerMap) {
const patch = sample(findEntities(map, e => includes(patchTypes, e.type)));
if (patch && patch.bounds) {
const bounds = patch.bounds;
const position = {
x: patch.x + bounds.x / tileWidth + random(0, bounds.w / tileWidth, true),
y: patch.y + bounds.y / tileHeight + random(0, bounds.h / tileHeight, true),
};
return position;
} else {
return randomPosition(map);
}
}
export function createBunny(waypoints: Point[]) {
const { x, y } = waypoints[0];
const entity = entities.bunny(x, y) as ServerEntity;
const bunnySpeed = 2;
let waypoint = 0;
let sleepUntil = 0;
entity.serverUpdate = (_delta, now) => {
if (sleepUntil > now)
return;
const { x, y } = waypoints[waypoint];
const reachedX = Math.abs(entity.x - x) < 0.2;
const reachedY = Math.abs(entity.y - y) < 0.2;
if (reachedX && reachedY) {
const rand = Math.random();
updateEntityVelocity(entity, 0, 0, now);
if (rand < 0.1) {
setEntityAnimation(entity, BunnyAnimation.Clean);
sleepUntil = now + 2;
} else if (rand < 0.2) {
setEntityAnimation(entity, BunnyAnimation.Look);
sleepUntil = now + 2;
} else if (rand < 0.3) {
setEntityAnimation(entity, BunnyAnimation.Blink);
sleepUntil = now + 2;
} else if (rand < 0.6) {
setEntityAnimation(entity, BunnyAnimation.Sit);
sleepUntil = now + 2;
} else {
waypoint = (waypoint + 1) % waypoints.length;
setEntityAnimation(entity, BunnyAnimation.Sit);
sleepUntil = now + random(0.2, 2, true);
}
} else {
const vx = reachedX ? 0 : (x < entity.x ? -bunnySpeed : bunnySpeed);
const vy = reachedY ? 0 : (y < entity.y ? -bunnySpeed : bunnySpeed);
if (entity.vx !== vx || entity.vy !== vy) {
updateEntityVelocity(entity, vx, vy, now);
setEntityAnimation(entity, BunnyAnimation.Walk, vx === 0 ? undefined : vx > 0);
}
}
};
if (DEVELOPMENT && false) {
return [entity, ...waypoints.map(({ x, y }) => entities.routePole(x, y))];
} else {
return [entity];
}
}
+983
View File
@@ -0,0 +1,983 @@
import * as fs from 'fs';
import { pathTo } from '../paths';
import { ServerMap, MapUsage, ServerEntity } from '../serverInterfaces';
import { World, goToMap } from '../world';
import { addSpawnPointIndicators } from '../mapUtils';
import { createServerMap, deserializeMap } from '../serverMap';
import { TileType, MapType } from '../../common/interfaces';
import { rect } from '../../common/rect';
import { TorchController, FlyingCritterController } from '../controllers';
import * as entities from '../../common/entities';
import { WallController } from '../controllers/wallController';
import { createBoxOfLanterns, give } from '../controllerUtils';
import { holdItem } from '../playerUtils';
const mapData = JSON.parse(fs.readFileSync(pathTo('src', 'maps', 'cave.json'), 'utf8'));
export function createCaveMap(world: World): ServerMap {
const map = createServerMap('cave', MapType.Cave, 7, 7, TileType.None, MapUsage.Public);
map.spawnArea = rect(27, 52, 1, 2);
map.tilesLocked = true;
deserializeMap(map, mapData);
// for (let y = 0; y < map.height; y++) {
// for (let x = 0; x < map.width; x++) {
// const tile = getTile(map, x, y);
// if (tile === TileType.Dirt) {
// setTile(map, x, y, TileType.None);
// } else if (tile === TileType.Grass) {
// setTile(map, x, y, TileType.Dirt);
// }
// }
// }
const add = (entity: ServerEntity) => world.addEntity(entity, map);
const caveDecals = [entities.caveDecal1, entities.caveDecal3, entities.caveDecal2];
function cracksS(x: number, y: number) {
const code = (Math.random() * 1000) % 64;
const index1 = code & 0b11;
const index2 = (code >> 2) & 0b11;
const index3 = (code >> 4) & 0b11;
index1 && index1 !== 3 && add(caveDecals[index1 - 1](x + 0.5, y - 1)); // no decal 2 here
index2 && add(caveDecals[index2 - 1](x + 0.5, y));
index3 && add(caveDecals[index3 - 1](x + 0.5, y + 1));
}
function cracksSLeft(x: number, y: number) {
const code = (Math.random() * 1000) % 4;
(code & 0b01) && add(entities.caveDecalL(x + 0.5, y - 1));
(code & 0b10) && add(entities.caveDecalL(x + 0.5, y));
}
function cracksSRight(x: number, y: number) {
const code = (Math.random() * 1000) % 4;
(code & 0b01) && add(entities.caveDecalR(x + 0.5, y - 1));
(code & 0b10) && add(entities.caveDecalR(x + 0.5, y));
}
function caveSW(x: number, y: number) {
add(entities.caveSW(x + 0.5, y - 2));
cracksSLeft(x, y);
}
function caveSE(x: number, y: number) {
add(entities.caveSE(x + 0.5, y - 2));
cracksSRight(x, y);
}
function caveS(x: number, y: number) {
add(entities.caveS2(x + 0.5, y - 1));
cracksS(x, y);
}
function caveSStart(x: number, y: number) {
add(entities.caveS1(x + 0.5, y - 1));
cracksS(x, y);
}
function caveSEnd(x: number, y: number) {
add(entities.caveS3(x + 0.5, y - 1));
cracksS(x, y);
}
function caveS1(x: number, y: number) {
add(entities.caveSb(x + 0.5, y - 1));
cracksS(x, y);
}
function caveN(x: number, y: number) {
add(entities.caveTopN(x + 0.5, y));
}
function caveNE(x: number, y: number) {
add(entities.caveTopNE(x + 0.5, y));
}
function caveNW(x: number, y: number) {
add(entities.caveTopNW(x + 0.5, y));
}
function caveRightWithTrimNoEdge(x: number, y: number, h: number) {
caveRight(x, y, h);
caveTrimRight(x + 1, y, h, false);
}
function caveLeftWithTrimNoEdge(x: number, y: number, h: number) {
caveLeft(x, y, h);
caveTrimLeft(x, y, h, false);
}
function caveRightWithTrim(x: number, y: number, h: number) {
caveRight(x, y - 3, h - 3);
caveTrimRight(x + 1, y, h);
}
function caveLeftWithTrim(x: number, y: number, h: number) {
caveLeft(x, y - 3, h - 3);
caveTrimLeft(x, y, h);
}
function caveLeft(x: number, y: number, h: number) {
for (let i = 0; i < h; i++) {
add(entities.caveTopW(x + 0.5, y - i));
}
}
function caveRight(x: number, y: number, h: number) {
for (let i = 0; i < h; i++) {
add(entities.caveTopE(x + 0.5, y - i));
}
}
function caveTrimLeft(x: number, y: number, h: number, botTrim = true) {
if (botTrim) {
add(entities.caveBotTrimLeft(x - 0.5, y));
} else {
add(entities.caveMidTrimLeft(x - 0.5, y));
}
for (let i = 0; i < (h - 2); i++) {
add(entities.caveMidTrimLeft(x - 0.5, y - 1 - i));
}
if (h > 1) {
add(entities.caveTopTrimLeft(x - 0.5, y - h + 1));
}
}
function caveTrimRight(x: number, y: number, h: number, botTrim = true) {
if (botTrim) {
add(entities.caveBotTrimRight(x + 0.5, y));
} else {
add(entities.caveMidTrimRight(x + 0.5, y));
}
for (let i = 0; i < (h - 2); i++) {
add(entities.caveMidTrimRight(x + 0.5, y - 1 - i));
}
if (h > 1) {
add(entities.caveTopTrimRight(x + 0.5, y - h + 1));
}
}
function caveSSection(x: number, y: number, w: number) {
caveSStart(x, y);
for (let i = 1; i < (w - 1); i++) {
caveS(x + i, y);
}
caveSEnd(x + w - 1, y);
}
function caveSESection(x: number, y: number, w: number) {
for (let i = 0; i < w; i++) {
caveSE(x + i, y - i);
}
}
function caveSWSection(x: number, y: number, w: number) {
for (let i = 0; i < w; i++) {
caveSW(x + i, y + i);
}
}
// large crypt
caveS1(6, 1);
caveRightWithTrim(5, 3, 4);
caveSESection(4, 4, 2);
caveRightWithTrimNoEdge(3, 8, 6);
caveNE(4, 9);
caveNE(5, 10);
caveNE(6, 11);
caveN(7, 12);
caveNE(8, 12);
caveNE(9, 13);
caveRightWithTrim(9, 19, 6);
caveSE(9, 19);
caveSSection(7, 19, 2);
caveSE(6, 20);
caveRightWithTrim(5, 22, 4);
caveSESection(4, 23, 2);
caveRightWithTrimNoEdge(3, 27, 6);
caveNE(4, 28);
caveRightWithTrimNoEdge(4, 31, 3);
caveNE(5, 32);
caveN(6, 33);
caveNW(7, 32);
caveN(8, 32);
caveN(9, 32);
caveN(10, 32);
caveNW(11, 31);
caveNW(12, 30);
caveLeftWithTrimNoEdge(13, 29, 2);
caveNW(13, 27);
caveNW(14, 26);
caveLeftWithTrimNoEdge(15, 25, 1);
caveNW(15, 24);
caveN(16, 24);
caveN(17, 24);
caveNE(18, 24);
caveNE(19, 25);
caveN(20, 26);
caveNE(21, 26);
caveN(22, 27);
caveNE(23, 27);
caveNE(24, 28);
caveNE(25, 29);
caveNE(26, 30);
caveRightWithTrimNoEdge(26, 31, 1);
caveNE(27, 32);
caveNE(28, 33);
caveRightWithTrim(28, 38, 5);
caveSE(28, 38);
caveS1(27, 38);
caveSW(26, 38);
caveLeftWithTrim(26, 38, 4);
caveSW(25, 36);
caveSSection(23, 35, 2);
caveSE(22, 36);
caveRightWithTrimNoEdge(21, 37, 3);
caveNE(22, 38);
caveRightWithTrim(22, 41, 3);
caveSE(22, 41);
caveSW(21, 41);
caveLeftWithTrim(21, 41, 4);
caveSWSection(18, 37, 3);
caveS1(17, 36);
caveSE(16, 37);
caveRightWithTrim(15, 39, 4);
caveS1(15, 38);
caveSWSection(13, 37, 2);
caveS1(12, 36);
caveSESection(10, 38, 2);
caveRightWithTrimNoEdge(9, 39, 3);
caveNE(10, 40);
caveRightWithTrim(10, 44, 4);
caveSE(10, 44);
caveSSection(5, 44, 2);
caveSE(7, 44);
caveSW(8, 44);
caveS1(9, 44);
caveSE(4, 45);
caveRightWithTrimNoEdge(3, 46, 3);
caveNE(4, 47);
caveN(5, 48);
caveN(6, 48);
caveN(7, 48);
caveN(8, 48);
caveN(9, 48);
caveN(10, 48);
caveNE(11, 48);
caveN(12, 49);
caveNE(13, 49);
caveRightWithTrimNoEdge(13, 50, 1);
caveNE(14, 51);
caveN(15, 52);
caveNW(16, 51);
caveN(17, 51);
caveNW(18, 50);
caveNW(19, 49);
caveNW(20, 48);
caveLeftWithTrimNoEdge(21, 47, 2);
caveNW(21, 45);
caveN(22, 45);
caveNE(23, 45);
caveRightWithTrimNoEdge(23, 47, 2);
caveNE(24, 48);
caveRightWithTrimNoEdge(24, 49, 1);
caveNE(25, 50);
caveRightWithTrimNoEdge(25, 55, 5);
// entrance to caves
caveLeftWithTrimNoEdge(29, 55, 4);
caveNW(29, 51);
caveNW(30, 50);
caveN(31, 50);
caveN(32, 50);
caveNW(33, 49);
caveLeftWithTrimNoEdge(34, 48, 1);
caveNW(34, 47);
caveLeftWithTrimNoEdge(35, 46, 3);
caveNW(35, 43);
caveN(36, 43);
caveN(37, 43);
caveNW(38, 42);
caveN(39, 42);
caveN(40, 42);
caveN(41, 42);
caveN(42, 42);
caveNE(43, 42);
caveN(44, 43);
caveNE(45, 43);
caveN(46, 44);
caveNW(47, 43);
caveN(48, 43);
caveNW(49, 42);
caveN(50, 42);
caveNW(51, 41);
caveNW(52, 40);
caveLeftWithTrimNoEdge(53, 39, 1);
caveNW(53, 38);
caveLeftWithTrimNoEdge(54, 37, 4);
caveSW(53, 35);
caveLeftWithTrim(53, 35, 4);
caveSW(52, 33);
caveLeftWithTrim(52, 33, 5);
caveSWSection(50, 29, 2);
caveS1(49, 28);
caveSW(48, 28);
caveS1(47, 27);
caveSESection(45, 29, 2);
caveS1(44, 29);
caveRightWithTrim(43, 31, 4);
caveSESection(42, 32, 2);
caveRightWithTrim(41, 35, 5);
caveSE(41, 35);
caveRightWithTrim(40, 37, 4);
caveSE(40, 37);
caveSSection(37, 37, 3);
caveSE(36, 38);
caveSSection(33, 38, 3);
caveSW(32, 38);
caveLeftWithTrim(32, 38, 4);
caveNW(32, 34);
// small crypt
// large crypt
caveLeftWithTrim(13, 18, 4);
caveSW(13, 18);
caveLeftWithTrim(14, 20, 4);
caveSW(14, 20);
caveSESection(15, 20, 2);
caveS1(17, 18);
caveSW(18, 19);
caveS1(19, 19);
caveSW(20, 20);
caveSSection(21, 20, 2);
caveSESection(23, 20, 3);
caveRightWithTrim(25, 18, 4);
caveSE(26, 16);
caveRightWithTrim(26, 16, 4);
caveNE(26, 12);
caveRightWithTrimNoEdge(25, 11, 2);
caveNE(25, 9);
caveRightWithTrimNoEdge(24, 8, 4);
caveSE(25, 6);
caveRightWithTrim(25, 6, 4);
caveSESection(26, 4, 2);
caveS1(28, 2);
caveSE(29, 2);
caveSSection(30, 1, 4);
caveSW(34, 2);
caveS1(35, 2);
caveSW(36, 3);
caveLeftWithTrim(37, 5, 4);
caveSW(37, 5);
caveLeftWithTrim(38, 7, 4);
caveSWSection(38, 7, 3);
caveS1(41, 9);
caveSESection(42, 9, 2);
caveS1(44, 7);
caveSE(45, 7);
caveSSection(46, 6, 2);
caveSW(48, 7);
caveS1(49, 7);
caveSW(50, 8);
caveS1(51, 8);
caveSW(52, 9);
caveLeftWithTrim(53, 11, 4);
caveSW(53, 11);
caveLeftWithTrimNoEdge(54, 15, 6);
caveNW(53, 16);
caveLeftWithTrimNoEdge(53, 18, 2);
caveNW(52, 19);
caveNW(51, 20);
caveNW(50, 21);
caveN(49, 22);
caveNW(48, 22);
caveN(47, 23);
caveNE(46, 22);
caveN(45, 22);
caveNE(44, 21);
caveNE(43, 20);
caveRightWithTrimNoEdge(42, 19, 1);
caveNE(42, 18);
caveN(41, 18);
caveN(40, 18);
caveNE(39, 17);
caveRightWithTrimNoEdge(38, 16, 1);
caveNE(38, 15);
caveN(37, 15);
caveNE(36, 14);
caveN(35, 14);
caveNW(34, 14);
caveNW(33, 15);
caveN(32, 16);
caveNW(31, 16);
caveN(30, 17);
caveNW(29, 17);
caveLeftWithTrimNoEdge(29, 19, 2);
caveNW(28, 20);
caveLeftWithTrimNoEdge(28, 21, 1);
caveNW(27, 22);
caveLeftWithTrim(27, 26, 4);
caveSW(27, 26);
caveS1(28, 26);
caveLeftWithTrim(29, 28, 4);
caveSWSection(29, 28, 2);
caveSSection(31, 29, 2);
// small crypt
add(entities.caveFill(3, 9));
add(entities.caveFill(4, 10));
add(entities.caveFill(5, 11));
add(entities.caveFill(6, 12));
add(entities.caveFill(8, 13));
add(entities.caveFill(3, 28));
add(entities.caveFill(4, 32));
add(entities.caveFill(5, 33));
add(entities.caveFill(7, 33));
add(entities.caveFill(11, 32));
add(entities.caveFill(12, 31));
add(entities.caveFill(13, 30));
add(entities.caveFill(14, 27));
add(entities.caveFill(15, 26));
add(entities.caveFill(18, 25));
add(entities.caveFill(19, 26));
add(entities.caveFill(23, 28));
add(entities.caveFill(24, 29));
add(entities.caveFill(25, 30));
add(entities.caveFill(26, 32));
add(entities.caveFill(27, 33));
add(entities.caveFill(9, 40));
add(entities.caveFill(11, 49));
add(entities.caveFill(13, 51));
add(entities.caveFill(14, 52));
add(entities.caveFill(16, 52));
add(entities.caveFill(18, 51));
add(entities.caveFill(19, 50));
add(entities.caveFill(20, 49));
add(entities.caveFill(21, 48));
add(entities.caveFill(23, 48));
add(entities.caveFill(24, 50));
add(entities.caveFill(30, 51));
add(entities.caveFill(33, 50));
add(entities.caveFill(34, 49));
add(entities.caveFill(35, 47));
add(entities.caveFill(38, 43));
add(entities.caveFill(43, 43));
add(entities.caveFill(45, 44));
add(entities.caveFill(47, 44));
add(entities.caveFill(49, 43));
add(entities.caveFill(51, 42));
add(entities.caveFill(52, 41));
add(entities.caveFill(53, 40));
add(entities.caveFill(54, 38));
add(entities.caveFill(33, 34));
add(entities.caveFill(28, 22));
add(entities.caveFill(31, 17));
add(entities.caveFill(33, 16));
add(entities.caveFill(36, 15));
add(entities.caveFill(38, 17));
add(entities.caveFill(39, 18));
add(entities.caveFill(43, 21));
add(entities.caveFill(44, 22));
add(entities.caveFill(46, 23));
add(entities.caveFill(48, 23));
add(entities.caveFill(50, 22));
add(entities.caveFill(51, 21));
add(entities.caveFill(52, 20));
add(entities.caveFill(53, 19));
add(entities.caveFill(54, 16));
add(entities.caveFill(24, 9));
add(entities.caveFill(25, 12));
add(entities.caveFill(21, 27));
add(entities.trigger3x1(27.5, 55)).trigger = (_, client) => goToMap(world, client, '', 'cave');
add(entities.lanternOn(10.38, 18.38));
add(entities.lanternOn(12.66, 18.38));
add(entities.lanternOn(18.41, 12.00));
add(entities.lanternOn(21.41, 12.13));
add(entities.lanternOn(18.47, 14.21));
add(entities.lanternOn(21.44, 14.33));
add(entities.lanternOn(10.41, 11.54));
add(entities.lanternOn(12.66, 11.50));
add(entities.lanternOn(13.94, 4.67));
add(entities.lanternOn(9.44, 4.67));
add(entities.lanternOn(27.44, 31.54));
add(entities.lanternOn(21.97, 26.50));
add(entities.lanternOn(19.31, 21.79));
add(entities.lanternOn(15.53, 24.17));
add(entities.lanternOn(9.28, 25.63));
add(entities.lanternOn(29.34, 17.29));
add(entities.lanternOn(15.81, 40.42));
add(entities.lanternOn(11.28, 43.50));
add(entities.lanternOn(14.66, 48.75));
add(entities.lanternOn(18.44, 48.33));
add(entities.lanternOn(16.18, 45.04));
add(entities.lanternOn(39.53, 41.91));
add(entities.lanternOn(41.37, 37.41));
add(entities.lanternOn(45.84, 34.13));
add(entities.lanternOn(50.50, 38.17));
add(entities.lanternOn(44.78, 41.12));
add(entities.lanternOn(37.46, 27.12));
add(entities.lanternOn(26.22, 51.79));
add(entities.lanternOn(28.75, 51.75));
add(entities.lanternOn(23.34, 42.08));
add(entities.lanternOn(23.88, 45.33));
add(entities.lanternOn(33.66, 40.20));
add(entities.lanternOn(34.44, 45.17));
add(entities.lanternOn(28.81, 44.25));
add(entities.lanternOn(25.31, 20.50));
add(entities.lanternOn(28.63, 28.38));
add(entities.lanternOn(29.28, 36.92));
add(entities.lanternOn(32.38, 31.38));
add(entities.lanternOn(33.22, 22.83));
add(entities.lanternOn(31.84, 49.83));
add(entities.lanternOn(30.28, 48.91));
add(entities.lanternOn(14.09, 8.63));
add(entities.lanternOn(9.41, 8.54));
add(entities.waterRock1(8.63, 27.92));
add(entities.waterRock3(8.75, 28.08));
add(entities.waterRock6(29.59, 8.79));
add(entities.waterRock8(29.66, 9.00));
add(entities.waterRock5(31.81, 6.83));
add(entities.waterRock4(31.41, 6.58));
add(entities.waterRock1(32.34, 9.79));
add(entities.waterRock1(46.56, 13.50));
add(entities.waterRock10(46.25, 13.71));
add(entities.waterRock11(49.63, 16.63));
add(entities.waterRock9(46.50, 11.46));
add(entities.waterRock4(46.38, 11.63));
add(entities.box(32.03, 49.13));
add(createBoxOfLanterns(30.97, 49.63)).interact = (_, client) => {
if (client.pony.options!.hold === entities.crystalHeld.type) {
holdItem(client.pony, entities.crystalLantern.type);
} else {
holdItem(client.pony, entities.lanternOn.type);
}
};
// top rooms
add(createBoxOfLanterns(9.34, 11.46));
add(entities.crate1A(17.78, 11.21));
add(entities.crate1A(18.84, 11.29));
add(entities.crate1A(7.69, 4.13));
add(entities.crate1A(8.72, 4.13));
add(entities.crate1A(10.00, 4.17));
add(entities.crate1A(8.06, 5.33));
add(entities.crate1BHigh(8.31, 4.17));
add(entities.crate1BHigh(18.25, 11.30));
add(entities.barrel(15.31, 3.79));
add(entities.barrel(14.66, 4.54));
add(entities.barrel(15.28, 5.08));
add(entities.barrel(14.50, 3.75));
add(entities.barrel(15.34, 6.08));
add(entities.barrel(19.78, 10.75));
add(entities.barrel(20.56, 11.25));
function railsH(x: number, y: number, length: number) {
for (let i = 0; i < length; i++) {
add(entities.mineRailsH(x + i + 0.5, y));
}
}
function railsV(x: number, y: number, length: number) {
for (let i = 0; i < length; i++) {
add(entities.mineRailsV(x + 0.5, y - i));
}
}
railsV(30, 40, 8);
add(entities.mineRailsSE(30.5, 32));
railsH(31, 32, 5);
add(entities.mineRailsEndRight(36.5, 32.5));
railsH(5, 46, 13);
add(entities.mineRailsNW(18.5, 46));
railsV(18, 45, 1);
add(entities.mineRailsNSE(18.5, 44));
railsH(19, 44, 7);
railsV(18, 43, 1);
add(entities.mineRailsSW(18.5, 42));
add(entities.mineCart(49, 40));
add(entities.crystalsCartPile(49, 40)).interact = give(entities.crystalHeld.type);
add(entities.mineCart(31.5, 46));
add(entities.crystalsCartPile(31.5, 46)).interact = give(entities.crystalHeld.type);
add(entities.mineRailsEndRight(50.5, 40.5));
railsH(38, 40, 9);
add(entities.mineRailsNWE(47.5, 40));
railsH(48, 40, 2);
add(entities.mineRailsSE(37.5, 40));
add(entities.mineRailsNW(37.5, 41));
railsH(27, 41, 3);
add(entities.mineRailsNWE(30.5, 41));
railsH(31, 41, 6);
add(entities.mineRailsSE(26.5, 41));
railsV(26, 43, 2);
add(entities.mineRailsNSW(26.5, 44));
railsV(26, 45, 1);
add(entities.mineRailsNE(26.5, 46));
railsH(28, 46, 5);
add(entities.mineRailsEndRight(33.5, 46.5));
railsH(14, 42, 4);
add(entities.mineRailsEndLeft(13.5, 42.5));
railsV(47, 39, 8);
add(entities.mineRailsEndTop(47.5, 32));
add(entities.mineRailsSWE(27.5, 46));
railsV(27, 55, 9);
add(entities.wallMap(23.97, 36.96));
add(entities.table3(24.00, 37.2916));
add(entities.lanternOnTable(24.375, 37.30));
add(entities.lanternOn(22.50, 38.33));
add(entities.sandPileSmall(25.50, 40.33));
add(entities.table3(37.81, 39.41));
add(entities.sandPileMedium(31.44, 42.67));
add(entities.sandPileSmall(32.03, 43.54));
add(entities.sandPileTiny(28.75, 45.33));
add(entities.sandPileSmall(32.63, 47.50));
add(entities.sandPileTiny(33.03, 47.96));
add(entities.sandPileSmall(27.94, 45.25));
add(entities.sandPileSmall(29.81, 48.00));
add(entities.sandPileTiny(29.13, 48.38));
add(entities.sandPileSmall(35.72, 42.46));
add(entities.sandPileTiny(32.53, 42.42));
add(entities.sandPileTinier(36.47, 42.71));
add(entities.sandPileBig(50.00, 34.96));
add(entities.sandPileMedium(49.16, 36.00));
add(entities.sandPileSmall(44.13, 34.42));
add(entities.sandPileMedium(17.88, 40.58));
add(entities.sandPileSmall(18.69, 41.29));
add(entities.sandPileSmall(15.63, 49.88));
add(entities.sandPileTiny(16.31, 50.33));
add(entities.sandPileTinier(17.63, 41.58));
add(entities.sandPileSmall(7.19, 24.29));
add(entities.sandPileTiny(6.69, 24.79));
add(entities.sandPileMedium(6.19, 46.92));
add(entities.sandPileTiny(5.03, 47.25));
add(entities.rockB(5.13, 46.46));
add(entities.rockB(7.31, 47.33));
add(entities.rock2B(5.00, 47.13));
add(entities.rock3B(5.72, 46.54));
add(entities.rock3B(7.91, 47.04));
add(entities.rock2B(8.03, 45.75));
add(entities.rock2B(6.72, 47.54));
add(entities.rock2B(32.03, 49.38));
add(entities.rock2B(50.63, 39.71));
add(entities.rock3B(41.59, 37.13));
add(entities.rock3B(32.13, 31.25));
add(entities.rock3B(22.34, 22.08));
add(entities.rock2B(22.91, 22.29));
add(entities.rock3B(35.63, 4.17));
add(entities.rock2B(44.66, 9.29));
add(entities.rockB(7.47, 21.33));
add(entities.rock2B(7.81, 21.71));
add(entities.caveCover(13.125, 11.625));
add(entities.caveCover(14.125, 11.625));
add(entities.caveCover(15.125, 11.625));
add(entities.caveCover(13.125, 15.625));
add(entities.caveCover(14.125, 15.625));
add(entities.caveCover(15.125, 15.625));
add(entities.caveCover(15.875, 15.625));
add(entities.caveCover(16.875, 16.625));
add(entities.caveCover(17.875, 16.625));
add(entities.caveCover(18.875, 16.625));
add(entities.caveCover(19.875, 16.625));
add(entities.caveCover(20.875, 16.625));
add(entities.caveCover(21.875, 16.625));
add(entities.caveCover(22.875, 16.625));
add(entities.caveCover(30.88, 27.625));
add(entities.caveCover(31.88, 27.625));
add(entities.caveCover(32.88, 27.625));
add(entities.caveCover(38.125, 30.625));
add(entities.caveCover(33.125, 34.625));
add(entities.caveCover(34.03, 34.625));
add(entities.caveCover(35.03, 34.625));
add(entities.caveCover(36.03, 34.625));
add(entities.caveCover(37.03, 34.625));
add(entities.caveCover(38.03, 34.625));
add(entities.caveCover(32.88, 35.17));
add(entities.crystals1(48.50, 10.42));
add(entities.crystals1(42.59, 17.17));
add(entities.crystals2(47.44, 14.42));
add(entities.crystals3(52.06, 16.25));
add(entities.crystals4(48.69, 18.96));
add(entities.crystals5(45.56, 10.21));
add(entities.crystals6(51.53, 11.58));
add(entities.crystals7(45.53, 16.50));
add(entities.crystals9(51.56, 18.71));
add(entities.crystals10(45.59, 20.67));
add(entities.crystals10(43.59, 13.33));
add(entities.crystals3(40.63, 13.46));
add(entities.crystals9(38.80, 10.04));
add(entities.crystals8(37.34, 13.58));
add(entities.crystals8(49.91, 15.21));
add(entities.crystals1(33.41, 10.46));
add(entities.crystals1(32.66, 4.33));
add(entities.crystals2(34.38, 8.71));
add(entities.crystals3(33.44, 14.67));
add(entities.crystals3(27.47, 6.38));
add(entities.crystals4(28.63, 5.79));
add(entities.crystals5(28.69, 9.46));
add(entities.crystals5(37.38, 9.79));
add(entities.crystals6(28.22, 10.96));
add(entities.crystals7(31.56, 11.75));
add(entities.crystals8(34.81, 6.21));
add(entities.crystals9(26.22, 8.04));
add(entities.crystals10(36.91, 8.21));
add(entities.crystals2(28.68, 14.54));
add(entities.crystals8(28.63, 18.79));
add(entities.crystals7(26.34, 23.71));
add(entities.crystals6(23.22, 23.29));
add(entities.crystals5(16.43, 22.29));
add(entities.crystals3(8.59, 30.33));
add(entities.crystals5(6.59, 27.58));
add(entities.crystals2(11.625, 28.42));
add(entities.crystals8(7.31, 22.54));
add(entities.crystals4(8.44, 23.17));
add(entities.crystals6(7.31, 29.83));
add(entities.crystals1(5.44, 25.38));
add(entities.crystals3(5.31, 6.50));
add(entities.crystals7(6.41, 10.42));
add(entities.crystals5(49.56, 31.46));
add(entities.crystals8(50.63, 33.33));
add(entities.crystals1(47.19, 30.21));
add(entities.crystals9(46.06, 32.21));
add(entities.crystals8(17.28, 39.38));
add(entities.crystals3(12.41, 39.33));
add(entities.crystals6(11.31, 40.75));
add(entities.waterCrystal1(31.34, 7.13));
add(entities.waterCrystal2(31.03, 6.92));
add(entities.waterCrysta3(31.00, 7.29));
add(entities.waterCrysta3(32.34, 10.00));
add(entities.waterCrystal2(46.06, 13.29));
add(entities.waterCrysta3(49.44, 16.29));
add(entities.waterCrysta3(46.72, 11.67));
add(entities.waterCrysta3(41.75, 12.54));
add(entities.waterCrystal1(8.47, 27.46));
add(entities.waterCrysta3(10.47, 27.54));
add(entities.stalactite3(17.06, 20.79));
add(entities.stalactite3(22.25, 22.83));
add(entities.stalactite2(21.81, 22.46));
add(entities.stalactite1(17.41, 21.08));
add(entities.stalactite1(28.31, 28.25));
add(entities.stalactite2(13.69, 20.63));
add(entities.stalactite3(6.56, 22.04));
add(entities.stalactite3(7.38, 28.46));
add(entities.stalactite1(7.66, 28.54));
add(entities.stalactite2(6.28, 22.42));
add(entities.stalactite2(9.69, 20.79));
add(entities.stalactite1(10.06, 20.58));
add(entities.stalactite2(12.25, 25.79));
add(entities.stalactite1(11.91, 26.08));
add(entities.stalactite3(11.88, 25.71));
add(entities.stalactite2(31.25, 31.25));
add(entities.stalactite3(22.72, 42.88));
add(entities.stalactite1(23.06, 42.71));
add(entities.stalactite3(10.59, 40.21));
add(entities.stalactite2(12.19, 38.42));
add(entities.stalactite1(12.63, 38.42));
add(entities.stalactite3(47.25, 29.46));
add(entities.stalactite2(44.34, 31.33));
add(entities.stalactite1(47.69, 29.58));
add(entities.stalactite1(51.75, 33.50));
add(entities.stalactite2(26.31, 18.21));
add(entities.stalactite3(28.22, 4.33));
add(entities.stalactite3(37.69, 7.29));
add(entities.stalactite2(39.44, 10.00));
add(entities.stalactite2(28.75, 4.13));
add(entities.stalactite1(27.47, 10.88));
add(entities.stalactite1(37.28, 7.04));
add(entities.stalactite3(49.88, 9.42));
add(entities.stalactite3(50.63, 13.92));
add(entities.stalactite3(42.84, 14.63));
add(entities.stalactite3(50.78, 20.96));
add(entities.stalactite3(44.09, 9.46));
add(entities.stalactite2(43.16, 15.04));
add(entities.stalactite2(49.44, 9.21));
add(entities.stalactite2(46.00, 8.42));
add(entities.stalactite2(51.47, 14.17));
add(entities.stalactite1(51.06, 14.29));
add(entities.stalactite1(42.59, 15.13));
add(entities.stalactite1(44.31, 9.67));
add(entities.stalactite1(51.78, 10.33));
add(entities.stalactite1(50.44, 21.29));
add(entities.stalactite1(47.41, 17.71));
add(entities.stalactite2(47.06, 17.71));
add(entities.stalactite1(28.66, 19.67));
add(entities.stalactite3(4.34, 6.33));
add(entities.stalactite2(4.56, 7.00));
add(entities.stalactite1(5.56, 10.29));
add(entities.stalactite3(35.38, 11.83));
add(entities.stalactite2(35.28, 12.21));
add(entities.stalactite1(34.94, 11.79));
add(entities.stalactite3(15.03, 22.29));
add(entities.stalactite1(14.63, 22.17));
// storage room
add(entities.table3(37.03, 23.71));
add(entities.lanternOnTable(37.25, 23.75));
add(entities.barrel(31.69, 24.71));
add(entities.barrel(32.03, 25.50));
add(entities.barrel(31.63, 26.17));
add(entities.barrel(38.34, 25.75));
add(entities.barrel(38.41, 27.04));
add(entities.barrel(37.78, 26.38));
add(entities.barrel(31.94, 26.96));
add(entities.toolboxFull(34.28, 22.96)).interact = give(entities.pickaxe.type);
add(entities.box(38.22, 29.96));
add(entities.ropeRack(34.31, 22.20)).interact = give(entities.rope.type);
add(entities.boxLanterns(35.63, 24.04)).interact = give(entities.lanternOn.type);
add(entities.crate1A(38.19, 28.79));
add(entities.crate1A(34.81, 28.58));
add(entities.crate1A(34.72, 29.88));
add(entities.crate2A(34.81, 30.67));
add(entities.ropeRack(34.31, 39.91)).interact = give(entities.rope.type);
add(entities.crate1A(34.22, 43.38));
add(entities.crate1A(33.91, 44.50));
function placeMineCart(x: number, y: number) {
add(entities.mineCartBack(x + 0.22, y - 0.04));
add(entities.mineCartFront(x + 0.22, y + 0.83));
}
placeMineCart(41, 40);
placeMineCart(45, 40);
placeMineCart(16, 42);
placeMineCart(8, 46);
placeMineCart(12, 46);
placeMineCart(15, 46);
placeMineCart(28, 41);
map.controllers.push(new TorchController(world, map));
map.controllers.push(new FlyingCritterController(world, map, entities.bat, 2, 10, () => true, true));
const wallController = new WallController(world, map, entities.stoneWalls);
map.controllers.push(wallController);
wallController.top = 3;
wallController.isTall = (x, y) => {
if (y === 10 && x >= 17 && x <= 23)
return true;
if (x >= 31 && x <= 39 && y >= 22 && y <= 25)
return true;
if (y === 31 && x >= 33 && x <= 34)
return true;
return false;
};
if (wallController.toggleWall) {
// large crypt
for (let x = 7; x <= 15; x++) {
wallController.toggleWall(x, 3, TileType.WallH);
}
for (let y = 3; y <= 10; y++) {
wallController.toggleWall(16, y, TileType.WallV);
}
for (let x = 13; x <= 15; x++) {
wallController.toggleWall(x, 11, TileType.WallH);
}
wallController.toggleWall(13, 11, TileType.WallV);
for (let x = 13; x <= 16; x++) {
wallController.toggleWall(x, 12, TileType.WallH);
}
wallController.toggleWall(17, 10, TileType.WallV);
wallController.toggleWall(17, 11, TileType.WallV);
for (let x = 17; x <= 22; x++) {
wallController.toggleWall(x, 10, TileType.WallH);
}
for (let y = 10; y <= 15; y++) {
wallController.toggleWall(23, y, TileType.WallV);
}
for (let x = 17; x <= 22; x++) {
wallController.toggleWall(x, 16, TileType.WallH);
}
wallController.toggleWall(17, 15, TileType.WallV);
for (let x = 13; x <= 16; x++) {
wallController.toggleWall(x, 15, TileType.WallH);
}
// small crypt
wallController.toggleWall(33, 31, TileType.WallH);
wallController.toggleWall(34, 30, TileType.WallV);
wallController.toggleWall(34, 29, TileType.WallV);
wallController.toggleWall(34, 28, TileType.WallV);
wallController.toggleWall(34, 27, TileType.WallV);
wallController.toggleWall(33, 27, TileType.WallH);
wallController.toggleWall(32, 27, TileType.WallH);
wallController.toggleWall(31, 27, TileType.WallH);
wallController.toggleWall(31, 26, TileType.WallV);
wallController.toggleWall(31, 25, TileType.WallV);
wallController.toggleWall(31, 24, TileType.WallV);
wallController.toggleWall(31, 24, TileType.WallH);
wallController.toggleWall(32, 23, TileType.WallV);
wallController.toggleWall(32, 22, TileType.WallV);
wallController.toggleWall(32, 22, TileType.WallH);
wallController.toggleWall(33, 22, TileType.WallH);
wallController.toggleWall(34, 22, TileType.WallH);
wallController.toggleWall(35, 22, TileType.WallV);
wallController.toggleWall(35, 23, TileType.WallH);
wallController.toggleWall(36, 23, TileType.WallH);
wallController.toggleWall(37, 23, TileType.WallH);
wallController.toggleWall(38, 23, TileType.WallV);
wallController.toggleWall(38, 24, TileType.WallV);
wallController.toggleWall(38, 25, TileType.WallH);
wallController.toggleWall(39, 25, TileType.WallV);
wallController.toggleWall(39, 26, TileType.WallV);
wallController.toggleWall(39, 27, TileType.WallV);
wallController.toggleWall(39, 28, TileType.WallV);
wallController.toggleWall(39, 29, TileType.WallV);
wallController.toggleWall(38, 30, TileType.WallH);
wallController.toggleWall(38, 30, TileType.WallV);
wallController.toggleWall(38, 31, TileType.WallV);
wallController.toggleWall(38, 32, TileType.WallV);
wallController.toggleWall(38, 33, TileType.WallV);
wallController.toggleWall(37, 34, TileType.WallH);
wallController.toggleWall(36, 34, TileType.WallH);
wallController.toggleWall(35, 34, TileType.WallH);
wallController.toggleWall(34, 34, TileType.WallH);
wallController.toggleWall(33, 34, TileType.WallH);
wallController.toggleWall(33, 34, TileType.WallV);
}
if (DEVELOPMENT) {
addSpawnPointIndicators(world, map);
}
return map;
}
+42
View File
@@ -0,0 +1,42 @@
import * as fs from 'fs';
import * as entities from '../../common/entities';
import { rect } from '../../common/rect';
import { TileType, MapType } from '../../common/interfaces';
import { createServerMap, deserializeMap } from '../serverMap';
import { World, goToMap } from '../world';
import { createSign } from '../controllerUtils';
import { ServerEntity } from '../serverInterfaces';
import { pathTo } from '../paths';
// load tile data
// To customize the map use in-game editor tools to change tiles, then use `/savemap custom` command,
// your map will be saved to `/store/custom.json` file, move the file to `/src/maps/custom.json`
// and restart the server.
const mapData = JSON.parse(fs.readFileSync(pathTo('src', 'maps', 'custom.json'), 'utf8'));
export function createCustomMap(world: World) {
// size: 4 by 4 regions -> 32 by 32 tiles
// default tiles: grass
const map = createServerMap('custom', MapType.None, 4, 4, TileType.Grass);
// initialize tiles
deserializeMap(map, mapData);
// place default spawn point at the center of the map
map.spawnArea = rect(map.width / 2, map.height / 2, 0, 0);
// shorthand for adding entities
function add(entity: ServerEntity) {
world.addEntity(entity, map);
}
// place return sign 2 tiles north of center of the map
add(createSign(map.width / 2, map.height / 2 - 2, 'Go back', (_, client) => goToMap(world, client, '', 'center')));
// place barrel at 5, 5 location
add(entities.barrel(5, 5));
// place more entities here ...
return map;
}
+189
View File
@@ -0,0 +1,189 @@
import * as entities from '../../common/entities';
import { rect } from '../../common/rect';
import { ServerMap, MapUsage, ServerEntity } from '../serverInterfaces';
import { World, goToMap } from '../world';
import { addSpawnPointIndicators } from '../mapUtils';
import { TileType, MapType, MapFlags, EntityState } from '../../common/interfaces';
import { createServerMap, setTile, MapData, saveMap } from '../serverMap';
import { WallController } from '../controllers';
import { resetRegionUpdates } from '../serverRegion';
import { getTile } from '../../common/worldMap';
import { tileHeight, HOUSE_ENTITY_LIMIT } from '../../common/constants';
export let defaultHouseSave: MapData | undefined = undefined;
const toolboxX = 2.125;
const toolboxY = 15.41;
export function createHouseMap(world: World, instanced: boolean, _template = false): ServerMap {
const map = createServerMap('house', MapType.House, 2, 2, TileType.Wood, instanced ? MapUsage.Party : MapUsage.Public);
if (getTile(map, 0, 0) !== TileType.None) {
for (let x = 0; x < map.width; x++) {
setTile(map, x, 0, TileType.None);
setTile(map, x, 1, TileType.None);
setTile(map, x, 2, TileType.None);
}
}
setTile(map, 4, map.height - 1, TileType.Stone);
setTile(map, 5, map.height - 1, TileType.Stone);
map.usage = instanced ? MapUsage.Party : MapUsage.Public;
map.spawnArea = rect(4, 8 + 6, 2, 1);
map.defaultTile = TileType.None;
map.flags |= MapFlags.EditableWalls | MapFlags.EditableEntities | MapFlags.EditableTiles;
map.editableEntityLimit = HOUSE_ENTITY_LIMIT;
map.editableArea = rect(0, 76 / tileHeight, map.width, map.height);
const topWall = 3;
const windowY = 76 / tileHeight;
const add = (entity: ServerEntity) => world.addEntity(entity, map);
const addEditable = (entity: ServerEntity) => (entity.state |= EntityState.Editable, add(entity));
add(entities.triggerDoor(5, map.height))
.trigger = (_, client) => goToMap(world, client, instanced ? 'island' : 'public-island', 'house');
addEditable(entities.window1(2, windowY));
addEditable(entities.window1(5, windowY));
addEditable(entities.window1(8, windowY));
addEditable(entities.window1(12, windowY));
addEditable(entities.window1(14, windowY));
addEditable(entities.picture1(3.53, windowY));
addEditable(entities.picture2(10.09, windowY));
addEditable(entities.table1(13.50, 13.20));
addEditable(entities.table1(2.69, 5.63));
addEditable(entities.table2(8.69, 11.60));
addEditable(entities.lanternOn(3.53, 15.13));
addEditable(entities.lanternOn(6.56, 15.13));
addEditable(entities.lanternOn(1.43, 14.04));
addEditable(entities.lanternOn(9.97, 6.67));
addEditable(entities.lanternOn(13.50, 6.54));
addEditable(entities.lanternOn(10.09, 15.13));
addEditable(entities.lanternOn(14.09, 15.04));
addEditable(entities.lanternOn(9.44, 4.67));
addEditable(entities.lanternOn(14.43, 8.54));
addEditable(entities.lanternOnWall(8.375, 12.70));
addEditable(entities.lanternOnWall(9.09, 12.08));
addEditable(entities.lanternOnWall(13.50, 13.33));
addEditable(entities.lanternOnWall(2.69, 5.71));
addEditable(entities.cushion1(3.94, 4.83));
addEditable(entities.cushion1(1.56, 4.63));
addEditable(entities.cushion1(6.91, 10.54));
addEditable(entities.cushion1(10.38, 11.96));
addEditable(entities.cushion1(10.56, 10.54));
addEditable(entities.cushion1(6.91, 12.17));
addEditable(entities.cushion1(8.88, 13.08));
addEditable(entities.cushion1(8.84, 9.67));
addEditable(entities.cushion1(13.25, 4.75));
addEditable(entities.cushion1(14.50, 3.67));
addEditable(entities.cushion1(15.38, 5.00));
addEditable(entities.cushion1(14.13, 6.17));
addEditable(entities.cushion1(14.69, 12.25));
addEditable(entities.cushion1(12.31, 12.29));
addEditable(entities.cushion1(9.94, 5.00));
addEditable(entities.cushion1(8.03, 5.00));
addEditable(entities.boxLanterns(0.72, 15.58));
addEditable(entities.toolboxFull(toolboxX, toolboxY));
const wallController = new WallController(world, map, entities.woodenWalls);
map.controllers.push(wallController);
wallController.top = 3;
if (wallController.toggleWall) {
for (let x = 0; x < map.width; x++) {
wallController.toggleWall(x, topWall, TileType.WallH);
if (x !== 4 && x !== 5) {
wallController.toggleWall(x, map.height, TileType.WallH);
}
if (x !== 5 && x !== 8 && x !== 12) {
wallController.toggleWall(x, 8, TileType.WallH);
}
}
for (let x = 0; x < 3; x++) {
wallController.toggleWall(x, 13, TileType.WallH);
}
for (let y = topWall; y < 8; y++) {
wallController.toggleWall(7, y, TileType.WallV);
wallController.toggleWall(11, y, TileType.WallV);
}
for (let y = 8; y < map.height; y++) {
if (y !== 11 && y !== 14) {
wallController.toggleWall(3, y, TileType.WallV);
}
}
for (let y = topWall; y < map.height; y++) {
wallController.toggleWall(0, y, TileType.WallV);
wallController.toggleWall(map.width, y, TileType.WallV);
}
}
wallController.lockOuterWalls = true;
if (DEVELOPMENT) {
addSpawnPointIndicators(world, map);
}
for (const region of map.regions) {
resetRegionUpdates(region);
}
if (!defaultHouseSave) {
defaultHouseSave = saveMap(map, {
saveTiles: true, saveEntities: true, saveOnlyEditableEntities: true, saveWalls: true
});
}
return map;
}
export function resetHouseMap(map: ServerMap) {
for (const { tiles } of map.regions) {
for (let i = 0; i < tiles.length; i++) {
tiles[i] = TileType.Wood;
}
}
}
function findEntityByType(map: ServerMap, type: number) {
for (const region of map.regions) {
for (const entity of region.entities) {
if (entity.type === type) {
return entity;
}
}
}
return undefined;
}
export function removeToolbox(world: World, map: ServerMap) {
const toolbox = findEntityByType(map, entities.toolboxFull.type);
if (toolbox) {
world.removeEntity(toolbox, map);
}
}
export function restoreToolbox(world: World, map: ServerMap) {
const toolbox = findEntityByType(map, entities.toolboxFull.type);
if (!toolbox) {
const entity = entities.toolboxFull(toolboxX, toolboxY);
entity.state |= EntityState.Editable;
world.addEntity(entity, map);
}
}
+386
View File
@@ -0,0 +1,386 @@
import * as fs from 'fs';
import { sample } from 'lodash';
import * as entities from '../../common/entities';
import { pathTo } from '../paths';
import { ServerMap, MapUsage, ServerEntity } from '../serverInterfaces';
import { World, goToMap } from '../world';
import {
addSpawnPointIndicators, generateTileIndicesAndColliders, removePonies, worldForTemplates, createBunny
} from '../mapUtils';
import { serverMapInstanceFromTemplate, createServerMap, copyMapTiles, deserializeMap } from '../serverMap';
import { TileType, MapType, Season } from '../../common/interfaces';
import { rect } from '../../common/rect';
import { createAddLight, createSignWithText, createWoodenFenceMaker, createBoxOfLanterns } from '../controllerUtils';
import { setEntityName, updateEntityOptions } from '../entityUtils';
import { getNextToyOrExtra, holdItem } from '../playerUtils';
import { tileWidth, tileHeight } from '../../common/constants';
import { TorchController, UpdateController } from '../controllers';
import { resetRegionUpdates } from '../serverRegion';
import { point } from '../../common/utils';
const islandMapData = JSON.parse(fs.readFileSync(pathTo('src', 'maps', 'island.json'), 'utf8'));
let islandMapTemplate: ServerMap | undefined;
export function createIslandMap(world: World, instanced: boolean, template = false): ServerMap {
if (!template && !islandMapTemplate) {
islandMapTemplate = createIslandMap(worldForTemplates, false, true);
}
const map = (instanced && islandMapTemplate) ?
serverMapInstanceFromTemplate(islandMapTemplate) :
createServerMap('island', MapType.Island, 7, 7, TileType.Water, instanced ? MapUsage.Party : MapUsage.Public);
map.usage = instanced ? MapUsage.Party : MapUsage.Public;
map.spawnArea = rect(43.4, 20, 3.3, 2.5);
map.spawns.set('house', rect(27, 24, 2, 2));
if (islandMapTemplate) {
copyMapTiles(map, islandMapTemplate);
} else {
deserializeMap(map, islandMapData);
}
const goto = instanced ? 'house' : 'public-house';
const add = (entity: ServerEntity) => world.addEntity(entity, map);
const addEntities = (entities: ServerEntity[]) => entities.map(add);
add(entities.house(28, 23)).interact = (_, client) => goToMap(world, client, goto);
add(entities.triggerHouseDoor(27.40, 22.87)).trigger = (_, client) => goToMap(world, client, goto);
add(createBoxOfLanterns(25.5, 25.5));
const boxOfFruits = add(entities.boxFruits(20.72, 20.88));
setEntityName(boxOfFruits, 'Box of fruits');
const giftPile = add(entities.giftPileInteractive(37.66, 18.21));
giftPile.interact = (_, client) => updateEntityOptions(client.pony, getNextToyOrExtra(client));
setEntityName(giftPile, 'Toy stash');
const types = entities.stashEntities.map(e => e.type);
const itemSign = add(entities.signQuest(24.41, 25.00));
itemSign.interact = (_, client) => {
const index = types.indexOf(client.pony.options!.hold || 0);
holdItem(client.pony, types[(index + 1) % types.length]);
};
setEntityName(itemSign, 'Item stash');
const addTorch = createAddLight(world, map, entities.torch);
addTorch(25.00, 24.00);
addTorch(39.69, 18.38);
addTorch(39.66, 21.67);
addTorch(23.34, 38.46);
addTorch(28.16, 31.79);
addTorch(29.13, 38.17);
addTorch(30.84, 29.42);
addTorch(29.69, 25.00);
addTorch(20.00, 27.88);
addTorch(22.63, 30.54);
addTorch(19.22, 32.08);
addTorch(25.50, 26.79);
addTorch(15.94, 26.42);
addTorch(15.88, 29.75);
// pier
const px = 1 / tileWidth;
const plankWidth = 78 / tileWidth;
const plankHeight = 12 / tileHeight;
const plankOffsets = [0, -1, 0, -2, -1, -1, 0, -2, -1, 0].map(x => x / tileWidth);
addEntities(entities.fullBoat(45.06, 24));
add(entities.pierLeg(41.31, 20.54));
add(entities.pierLeg(43.00, 22.58));
add(entities.pierLeg(44.84, 22.58));
add(entities.pierLeg(46.65, 22.58));
add(entities.barrel(46.83, 23.35));
add(entities.lanternOn(46.19, 23.38));
add(entities.lanternOn(42.63, 21.42));
add(entities.lanternOn(47.00, 18.96));
add(entities.triggerBoat(45.5, 24.8)).interact = (_, client) => goToMap(world, client, '', 'harbor');
add(createSignWithText(43, 23.5, 'Return to land', `Hop on the boat to return to the mainland`));
for (let y = 0; y < 10; y++) {
const minX = y < 5 ? 0 : 1;
const maxX = (y % 2) ? 4 : 3;
const baseX = 40 + ((y % 2) ? 0 : (plankWidth / 2)) + plankOffsets[y];
const baseY = 19 - (9 / tileHeight);
for (let x = minX; x < maxX; x++) {
if ((x === minX && (y % 2)) || (x === (maxX - 1) && (y % 2))) {
const ox = x === minX ? (18 / tileWidth) : (-18 / tileWidth);
const plank = sample(entities.planksShort)!;
add(plank(baseX + ox + x * plankWidth, baseY + y * plankHeight));
} else {
const plank = sample(entities.planks)!;
add(plank(baseX + x * plankWidth, baseY + y * plankHeight));
}
}
}
add(entities.plankShadow(41.31, 20.58));
add(entities.plankShadowShort(43.03, 21.08));
add(entities.plankShadowShort(43, 21.08 + plankHeight * 2));
const baseY = 18.58;
const baseX = 46.71;
add(entities.plankShadowShort(baseX, baseY));
add(entities.plankShadowShort(baseX + 2 * px, baseY + plankHeight));
add(entities.plankShadowShort(baseX, baseY + plankHeight * 2));
add(entities.plankShadowShort(baseX + 1 * px, baseY + plankHeight * 3));
add(entities.plankShadowShort(baseX - 1 * px, baseY + plankHeight * 4));
add(entities.plankShadowShort(baseX + 2 * px, baseY + plankHeight * 5));
add(entities.plankShadowShort(baseX, baseY + plankHeight * 6));
add(entities.plankShadowShort(baseX + 1 * px, baseY + plankHeight * 7));
add(entities.plankShadowShort(baseX - 1 * px, baseY + plankHeight * 8));
add(entities.plankShadowShort(baseX + 3 * px, baseY + plankHeight * 9));
add(entities.collider3x1(40, 18));
add(entities.collider3x1(43, 18));
add(entities.collider2x1(46, 18));
add(entities.collider1x3(47, 19));
add(entities.collider1x3(47, 22));
add(entities.collider3x1(40, 21));
add(entities.collider1x3(42, 22));
add(entities.collider1x1(43, 24));
add(entities.collider3x1(42, 25));
add(entities.collider3x1(45, 25));
add(entities.collider1x3(47.6, 23));
add(entities.collider1x3(41.5, 22));
// lone boat
addEntities(entities.fullBoat(27.12, 42, false));
add(entities.plankShort3(27.43, 39.67));
add(entities.plankShort3(27.46, 40.21));
add(entities.plankShort3(27.43, 40.75));
add(entities.plankShort3(27.46, 41.21));
add(entities.pierLeg(27.96, 40.88));
add(entities.pierLeg(27.02, 40.79));
add(entities.plankShadowShort(27.5, 39.67));
add(entities.plankShadowShort(27.5 + 1 * px, 39.67 + plankHeight));
add(entities.plankShadowShort(27.5, 39.67 + plankHeight * 2));
add(entities.plankShadowShort(27.5 + 1 * px, 39.67 + plankHeight * 3));
add(entities.lanternOn(28.77, 42.27));
add(entities.collider3x1(24, 41));
add(entities.collider2x1(24, 42));
add(entities.collider1x2(26, 40));
add(entities.collider1x2(28, 40));
add(entities.collider2x1(28, 41));
add(entities.collider3x1(24, 43));
add(entities.collider3x1(27, 43));
add(entities.collider1x1(29, 42));
add(entities.collider1x3(29.7, 41));
add(entities.collider1x3(23.5, 41));
const addWoodenFence = createWoodenFenceMaker(world, map);
addWoodenFence(20, 20, 4);
addWoodenFence(20, 20, 4, false, true);
addWoodenFence(24, 20, 4, false, true);
addWoodenFence(20, 24, 1, true, true);
addWoodenFence(23, 24, 1, true, false, true);
addEntities(entities.tree(22.41, 18.21, 0));
addEntities(entities.tree(30.44, 23.46, 1 + 4));
addEntities(entities.tree5(34.53, 27.04, 0));
addEntities(entities.tree5(19.28, 21.75, 1));
add(entities.pumpkin(23.22, 20.58));
add(entities.pumpkin(20.53, 22.29));
add(entities.pumpkin(20.91, 23.08));
add(entities.largeLeafedBush2(35.03, 26.71));
add(entities.largeLeafedBush4(34.13, 27.54));
add(entities.largeLeafedBush2(20.34, 24.42));
add(entities.largeLeafedBush3(19.72, 24.04));
add(entities.largeLeafedBush3(23.03, 38.67));
add(entities.largeLeafedBush4(23.72, 38.04));
add(entities.barrel(39.53, 18.96));
add(entities.barrel(38.78, 18.38));
add(entities.barrel(38.47, 19.21));
add(entities.barrel(24.63, 23.50));
add(entities.treeStump1(20.25, 36.46));
add(entities.lanternOn(20.75, 37.00));
add(entities.lanternOn(36.06, 34.25));
add(entities.lanternOn(37.63, 36.83));
add(entities.lanternOn(37.84, 26.94));
add(entities.waterRock1(22.63, 41.83));
add(entities.waterRock1(28.56, 13.75));
add(entities.waterRock1(16.25, 20.88));
add(entities.waterRock1(40.62, 29.62));
add(entities.waterRock1(40.63, 17.96));
add(entities.waterRock2(40.22, 17.50));
add(entities.waterRock2(20.34, 15.75));
add(entities.waterRock2(15.41, 34.13));
add(entities.waterRock2(36.50, 40.63));
add(entities.waterRock3(36.13, 40.96));
add(entities.waterRock3(41.63, 34.38));
add(entities.waterRock3(15.16, 34.67));
add(entities.waterRock3(20.88, 15.50));
add(entities.waterRock4(36.56, 41.13));
add(entities.waterRock4(40.72, 17.29));
add(entities.waterRock4(28.22, 13.25));
add(entities.waterRock5(28.66, 13.17));
add(entities.waterRock5(16.84, 20.42));
add(entities.waterRock5(14.44, 30.50));
add(entities.waterRock5(23.13, 41.88));
add(entities.waterRock5(30.44, 39.71));
add(entities.waterRock5(41.84, 34.83));
add(entities.waterRock5(36.63, 14.75));
add(entities.waterRock6(36.25, 14.54));
add(entities.waterRock6(14.84, 34.29));
add(entities.waterRock6(40.44, 38.71));
add(entities.waterRock7(42.13, 34.42));
add(entities.waterRock7(20.34, 15.25));
add(entities.waterRock7(28.78, 40.79));
add(entities.waterRock7(22.66, 42.08));
add(entities.waterRock8(22.25, 41.92));
add(entities.waterRock8(14.13, 30.79));
add(entities.waterRock8(16.28, 20.21));
add(entities.waterRock8(42.19, 34.79));
add(entities.waterRock9(36.78, 14.29));
add(entities.waterRock9(13.97, 30.33));
add(entities.waterRock10(14.50, 25.83));
add(entities.waterRock10(18.88, 40.75));
add(entities.waterRock11(25.37, 13.75));
add(entities.waterRock11(18.44, 40.58));
add(entities.waterRock11(15.41, 24.33));
add(entities.waterRock4(15.13, 23.96));
add(entities.waterRock11(35.38, 31.08));
add(entities.waterRock8(35.09, 30.71));
add(entities.waterRock3(34.03, 29.79));
add(entities.waterRock4(34.31, 29.67));
add(entities.waterRock4(24.50, 35.92));
add(entities.waterRock3(33.44, 38.21));
add(entities.waterRock4(33.16, 37.92));
add(entities.waterRock1(25.50, 17.79));
add(entities.waterRock9(25.66, 18.13));
add(entities.waterRock4(25.97, 17.79));
add(entities.flower3Pickable(30.25, 24.92)).interact = (_, { pony }) => holdItem(pony, entities.flowerPick.type);
add(entities.bench1(37.78, 24.96));
add(entities.benchSeat(37.75, 28.29));
add(entities.benchBack(37.75, 29.17));
// small island bit
addEntities(entities.tree(9.16, 16.50, 2 + 8));
add(entities.waterRock1(5.34, 23.71));
add(entities.waterRock1(13.28, 14.33));
add(entities.waterRock1(11.19, 24.83));
add(entities.waterRock3(10.94, 25.21));
add(entities.waterRock3(6.31, 18.92));
add(entities.waterRock3(13.59, 14.71));
add(entities.waterRock5(13.84, 14.17));
add(entities.waterRock4(11.38, 25.13));
add(entities.waterRock6(6.41, 15.92));
add(entities.waterRock8(5.16, 23.13));
add(entities.waterRock10(6.06, 16.33));
add(entities.waterRock11(14.66, 20.75));
add(entities.torch(9.31, 17.83));
add(entities.torch(9.44, 21.67));
add(entities.torch(12.75, 18.29));
if (world.season === Season.Summer || world.season === Season.Spring) {
add(entities.flowerPatch1(21.16, 26.04));
add(entities.flowerPatch3(35.81, 34.75));
add(entities.flowerPatch3(23.09, 32.29));
add(entities.flowerPatch5(19.63, 33.04));
add(entities.flowerPatch5(37.38, 26.42));
add(entities.flowerPatch5(18.75, 26.38));
add(entities.flowerPatch5(35.38, 33.88));
add(entities.flowerPatch6(25.78, 31.04));
add(entities.flowerPatch6(33.00, 33.79));
add(entities.flowerPatch6(38.72, 26.00));
add(entities.flowerPatch3(11.34, 19.33));
add(entities.flowerPatch6(11.09, 17.79));
add(entities.flowerPatch7(8.63, 21.67));
}
if (world.season === Season.Autumn) {
add(entities.leafpileStickRed(31.00, 22.75));
add(entities.leaves5(18.41, 21.46));
add(entities.leaves2(21.59, 18.17));
add(entities.leaves2(23.56, 18.00));
add(entities.leaves1(22.00, 17.21));
add(entities.leaves1(22.47, 20.00));
add(entities.leaves2(33.69, 25.42));
add(entities.leaves1(33.47, 26.75));
add(entities.leaves1(35.66, 27.08));
add(entities.leaves3(30.31, 24.04));
add(entities.leaves1(31.56, 23.00));
add(entities.leaves1(29.47, 23.21));
add(entities.leaves3(8.84, 17.04));
add(entities.leaves2(8.91, 15.25));
add(entities.leaves1(10.44, 16.46));
}
addEntities(createBunny([
point(22.19, 27.25),
point(19.75, 26.13),
point(18.44, 29.04),
point(20.41, 31.42),
point(19.94, 33.04),
point(22.88, 33.67),
point(24.91, 31.25),
point(26.09, 32.50),
point(26.34, 34.83),
point(32.50, 35.46),
point(34.03, 34.67),
point(36.06, 36.50),
point(36.47, 35.33),
point(36.22, 34.63),
point(33.63, 34.83),
point(31.88, 33.25),
point(32.00, 28.46),
point(33.22, 25.75),
point(35.09, 24.75),
point(36.13, 25.67),
point(36.72, 27.38),
point(38.31, 27.42),
point(38.63, 26.54),
point(37.31, 26.63),
point(36.06, 26.42),
point(35.34, 24.29),
point(33.13, 25.83),
point(30.06, 25.13),
point(26.50, 26.38),
point(24.66, 28.25),
point(22.25, 28.50),
point(20.97, 27.00),
point(19.13, 27.13),
point(20.69, 29.25),
point(22.34, 29.42),
point(21.53, 31.46),
point(23.50, 31.54),
point(23.81, 29.71),
]));
map.controllers.push(new TorchController(world, map));
map.controllers.push(new UpdateController(map));
if (DEVELOPMENT) {
addSpawnPointIndicators(world, map);
}
if (!islandMapTemplate) {
generateTileIndicesAndColliders(map);
}
return map;
}
export function resetIslandMap(map: ServerMap) {
copyMapTiles(map, islandMapTemplate!);
for (const region of map.regions) {
region.clients = [];
removePonies(region.entities);
removePonies(region.movables);
resetRegionUpdates(region);
}
}
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
import * as entities from '../../common/entities';
import { rect } from '../../common/rect';
import { TileType, MapType } from '../../common/interfaces';
import { createServerMap } from '../serverMap';
import { World, goToMap } from '../world';
import { allEntities } from '../api/account';
import { createSign } from '../controllerUtils';
import { ServerEntity } from '../serverInterfaces';
import { setEntityName } from '../entityUtils';
export function createPaletteMap(world: World) {
const map = createServerMap('palette', MapType.None, 10, 10, TileType.Grass);
map.spawnArea = rect(map.width / 2, map.height / 2, 0, 0);
function add(entity: ServerEntity) {
world.addEntity(entity, map);
}
add(createSign(map.width / 2, map.height / 2, 'Go back', (_, client) => goToMap(world, client, '', 'center')));
const pad = 5;
let x = pad;
let y = pad;
for (const name of allEntities) {
const entityOrEntities = (entities as any)[name](x, y);
const ents = Array.isArray(entityOrEntities) ? entityOrEntities : [entityOrEntities];
for (const entity of ents) {
add(entity);
setEntityName(entity, name);
}
x += 3;
if (x > (map.width - pad)) {
x = pad;
y += 3;
}
}
return map;
}
+219
View File
@@ -0,0 +1,219 @@
import * as moment from 'moment';
import chalk from 'chalk';
import { IClient } from './serverInterfaces';
import { decodeMovement, dirToVector, flagsToSpeed, isMovingRight } from '../common/movementUtils';
import { setPonyState, isSittingState, isLyingState } from '../common/entityUtils';
import { setupCamera } from '../common/camera';
import { logger } from './logger';
import { hasFlag, setFlag } from '../common/utils';
import { EntityState } from '../common/interfaces';
import { kickClient, setEntityExpression } from './playerUtils';
import { CounterService } from './services/counter';
import { roundPositionX, roundPositionY } from '../common/positionUtils';
import { tileWidth, tileHeight, SECOND } from '../common/constants';
import { GameServerSettings } from '../common/adminInterfaces';
import { updateEntity, fixPosition } from './entityUtils';
import { isStaticCollision, isOutsideMap } from '../common/collision';
const teleportReportLimit = 10;
const maxLagLimitSeconds = 15;
const maxLagLimit = maxLagLimitSeconds * SECOND;
export type Move = ReturnType<typeof createMove>;
export const createMove =
(teleportCounter: CounterService<void>) =>
(client: IClient, now: number, a: number, b: number, c: number, d: number, e: number, settings: GameServerSettings) => {
if (client.loading || client.fixingPosition || client.isSwitchingMap)
return;
const connectionDuration = (now - client.connectedTime) >>> 0;
const pony = client.pony;
const { x, y, dir, flags, time, camera } = decodeMovement(a, b, c, d, e);
const v = dirToVector(dir);
const speed = flagsToSpeed(flags);
if (checkOutsideMap(client, x, y))
return;
setupCamera(client.camera, camera.x, camera.y, camera.w, camera.h, client.map);
if (checkLagging(client, time, connectionDuration, settings))
return;
if (checkTeleporting(client, x, y, time, settings, teleportCounter))
return;
if (!isStaticCollision(pony, client.map, true)) {
client.safeX = pony.x;
client.safeY = pony.y;
}
pony.x = x;
pony.y = y;
if (isStaticCollision(pony, client.map)) {
pony.x = client.safeX;
pony.y = client.safeY;
if (!isStaticCollision(pony, client.map)) {
if (settings.logFixingPosition) {
client.reporter.systemLog(`Fixed colliding (${x} ${y}) -> (${pony.x} ${pony.y})`);
}
DEVELOPMENT && !TESTS && logger.warn(`Fixing position due to collision`);
fixPosition(pony, client.map, client.safeX, client.safeY, false);
} else {
pony.x = x;
pony.y = y;
}
}
pony.vx = v.x * speed;
pony.vy = v.y * speed;
let ponyState = pony.state || 0;
const facingRight = hasFlag(ponyState, EntityState.FacingRight);
const right = isMovingRight(pony.vx, facingRight);
if (facingRight !== right) {
ponyState = setFlag(ponyState, EntityState.FacingRight, right);
ponyState = setFlag(ponyState, EntityState.HeadTurned, false);
}
if ((pony.vx || pony.vy) && (isSittingState(ponyState) || isLyingState(ponyState))) {
ponyState = setPonyState(ponyState, EntityState.PonyStanding);
}
pony.state = ponyState;
updateEntity(pony, false);
if (pony.exprCancellable) {
setEntityExpression(pony, undefined);
}
pony.timestamp = now / 1000;
client.lastX = pony.x;
client.lastY = pony.y;
client.lastTime = time;
client.lastVX = pony.vx;
client.lastVY = pony.vy;
};
function checkOutsideMap(client: IClient, x: number, y: number): boolean {
if (isOutsideMap(x, y, client.map)) {
const message = `map: [${client.map.id || 'main'}] coords: [${x.toFixed(2)}, ${y.toFixed(2)}]`;
if (!client.shadowed) {
client.reporter.warn(`Outside map`, message);
}
kickClient(client, `outside ${message}`);
return true;
}
return false;
}
function checkLagging(client: IClient, time: number, connectionTime: number, settings: GameServerSettings): boolean {
const dt = time - connectionTime;
const lagging = (dt > maxLagLimit) || (dt < -maxLagLimit);
if (lagging) {
if (settings.logLagging) {
// logger.warn(`Time delta > ${maxLagLimitSeconds}s (${dt}) account: ${client.account.name} [${client.accountId}]`);
client.reporter.systemLog(`Time delta > ${maxLagLimitSeconds}s (${dt})`);
client.logDisconnect = true;
}
if (settings.kickLagging) {
client.reporter.systemLog(`Lagging (dt: ${dt} time: ${time} connectionTime: ${connectionTime})`);
kickClient(client, 'lagging');
return true;
}
}
return false;
}
function checkTeleporting(
client: IClient, x: number, y: number, time: number, settings: GameServerSettings, counter: CounterService<void>
): boolean {
if (!client.lastTime)
return false;
const pony = client.pony;
const borderX = 0.5;
const borderY = 0.5;
const delta = ((time - client.lastTime) / 1000) * 1;
const afterX = roundPositionX(client.lastX + client.lastVX * delta);
const afterY = roundPositionY(client.lastY + client.lastVY * delta);
const afterMinX = client.lastVX === 0 ? afterX - Math.abs(client.lastVY) : afterX;
const afterMaxX = client.lastVX === 0 ? afterX + Math.abs(client.lastVY) : afterX;
const afterMinY = client.lastVY === 0 ? afterY - Math.abs(client.lastVX) : afterY;
const afterMaxY = client.lastVY === 0 ? afterY + Math.abs(client.lastVX) : afterY;
const minX = Math.floor((Math.min(client.lastX, afterMinX) - borderX) * tileWidth) / tileWidth;
const maxX = Math.ceil((Math.max(client.lastX, afterMaxX) + borderX) * tileWidth) / tileWidth;
const minY = Math.floor((Math.min(client.lastY, afterMinY) - borderY) * tileHeight) / tileHeight;
const maxY = Math.ceil((Math.max(client.lastY, afterMaxY) + borderY) * tileHeight) / tileHeight;
const outX = x < minX || x > maxX;
const outY = y < minY || y > maxY;
if (outX || outY) {
if (settings.logTeleporting) {
const colX = outX ? chalk.red : chalk.reset;
const colY = outY ? chalk.red : chalk.reset;
logger.log(
`[${chalk.gray(moment().format('MMM DD HH:mm:ss'))}] [${chalk.yellow('teleport')}] ` +
`[${chalk.gray(client.accountId)}] (${client.account.name})\n` +
`\tdx: ${client.lastX.toFixed(5)} -> ${colX(x.toFixed(5))} [${minX.toFixed(5)}-${maxX.toFixed(5)}]\n` +
`\tdy: ${client.lastY.toFixed(5)} -> ${colY(y.toFixed(5))} [${minY.toFixed(5)}-${maxY.toFixed(5)}]\n` +
`\tdt: ${delta.toFixed(5)}`);
}
if (settings.reportTeleporting) {
const { count } = counter.add(client.accountId);
if (count > teleportReportLimit) {
counter.remove(client.accountId);
client.reporter.warn(`Teleporting (x${teleportReportLimit})`);
}
}
if (settings.kickTeleporting) {
kickClient(client, 'teleporting');
return true;
}
if (settings.fixTeleporting) {
pony.vx = 0;
pony.vy = 0;
client.reporter.systemLog(`Fixed teleporting (${x} ${y}) -> (${pony.x} ${pony.y})`);
fixPosition(client.pony, client.map, pony.x, pony.y, false);
return true;
}
}
const dx = Math.abs(x - pony.x);
const dy = Math.abs(y - pony.y);
if (dx > 8 || dy > 8) {
if (settings.fixTeleporting) {
pony.vx = 0;
pony.vy = 0;
client.reporter.systemLog(`Fixed teleporting (too far) (${x} ${y}) -> (${pony.x} ${pony.y})`);
fixPosition(client.pony, client.map, pony.x, pony.y, false);
return true;
}
}
return false;
}
+150
View File
@@ -0,0 +1,150 @@
/// <reference path="../../typings/my.d.ts" />
import { Request } from 'express';
import { isString } from 'lodash';
import { Strategy as GoogleStrategy } from '@passport-next/passport-google-oauth2';
import { Strategy as TwitterStrategy } from 'passport-twitter';
import { Strategy as FacebookStrategy } from '@passport-next/passport-facebook';
import { Strategy as GithubStrategy } from 'passport-github2';
import { Strategy as VKontakteStrategy } from 'passport-vkontakte';
import { Strategy as PatreonStrategy } from 'passport-patreon';
import { Profile } from '../common/interfaces';
import { PATREON_COLOR } from '../common/colors';
import { colorToCSS } from '../common/color';
import { config } from './config';
import { IAccount } from './db';
export type OAuthProfileName = string | { familyName: string; givenName: string; };
export interface OAuthProfile {
id?: string;
name?: OAuthProfileName;
username?: string;
displayName?: string;
emails?: { value: string; }[];
provider: string;
gender?: string;
profileUrl?: string;
_raw: string;
_json: any;
}
export interface Strategy {
new(
options: any,
callback: (
req: Request,
accessToken: string,
refreshToken: string,
profile: OAuthProfile,
callback: (err: Error | null, user: IAccount | null) => void) => void): any;
}
export interface OAuthProviderInfo {
id: string;
name: string;
color: string;
strategy: Strategy;
auth?: any;
connectOnly?: boolean;
additionalOptions?: any;
}
const providerList: OAuthProviderInfo[] = [
{
id: 'google',
name: 'Google',
color: '#DC4A3D',
strategy: GoogleStrategy,
},
{
id: 'twitter',
name: 'Twitter',
color: '#55ACEE',
strategy: TwitterStrategy,
},
{
id: 'facebook',
name: 'Facebook',
color: '#3765A3',
strategy: FacebookStrategy,
},
{
id: 'github',
name: 'GitHub',
color: '#800080',
strategy: GithubStrategy,
},
{
id: 'vkontakte',
name: 'VKontakte',
color: '#4C75A3',
strategy: VKontakteStrategy,
},
{
id: 'patreon',
name: 'Patreon',
color: colorToCSS(PATREON_COLOR),
strategy: PatreonStrategy,
},
];
providerList.forEach(p => p.auth = config.oauth[p.id]);
providerList.filter(p => p.auth && p.auth.connectOnly).forEach(p => p.connectOnly = true);
export const providers = providerList.filter(p => !!p.auth);
export function getProfileUrl(profile: OAuthProfile): string | undefined {
if (profile.provider === 'twitter') {
return `https://twitter.com/${profile.username}`;
} else if (profile.provider === 'tumblr') {
return `http://${profile.username}.tumblr.com/`;
} else if (profile.provider === 'facebook') {
return `http://www.facebook.com/${profile.id}`;
} else if (profile._json.attributes && profile._json.attributes.url) { // patreon
return profile._json.attributes.url;
} else {
return profile.profileUrl || profile._json.url;
}
}
export function getProfileEmails(profile: OAuthProfile): string[] {
if (profile.emails && profile.emails.length) {
return profile.emails.map(e => e.value);
} else if (profile._json && profile._json.attributes && profile._json.attributes.email) { // patreon
return [profile._json.attributes.email];
} else {
return [];
}
}
export function getProfileUsername(profile: OAuthProfile): string | undefined {
return profile.username || profile.displayName || getProfileNameInternal(profile.name);
}
export function getProfileName(profile: OAuthProfile): string | undefined {
return profile.displayName || profile.username || getProfileNameInternal(profile.name);
}
function getProfileNameInternal(name: OAuthProfileName | undefined): string | undefined {
if (!name || isString(name)) {
return name;
} else {
return `${name.givenName} ${name.familyName}`.trim();
}
}
export function getProfile(provider: string, profile: OAuthProfile): Profile {
const emails = getProfileEmails(profile).map(e => e.toLowerCase());
return {
id: profile.id || profile.username || '',
provider: profile.provider || provider,
username: getProfileUsername(profile) || emails[0],
name: getProfileName(profile) || emails[0],
emails,
url: getProfileUrl(profile),
createdAt: profile._json && profile._json.created_at && new Date(profile._json.created_at),
suspended: profile._json && profile._json.suspended,
};
}
+45
View File
@@ -0,0 +1,45 @@
import { Request } from 'express';
import { IOriginInfo, IAccount, Account } from './db';
import { config } from './config';
import { logger } from './logger';
import { OriginInfoBase } from '../common/adminInterfaces';
const get_ip = require('ipware')().get_ip;
export function getIP(req: { headers: any; }) {
return req.headers['cf-connecting-ip'] || (get_ip(req) ? get_ip(req).clientIp : null);
}
export function getOriginFromHTTP(req: { headers: any; }): IOriginInfo {
const ip = getIP(req) || '0.0.0.0';
const ipcountry = (ip === '127.0.0.1' || ip === '::ffff:127.0.0.1' || ip === '::1') ? 'LOCAL' : '';
const country = ipcountry || req.headers['cf-ipcountry'] || '??';
return { ip, country, last: new Date() };
}
export function getOrigin(req: Request): IOriginInfo {
const origin = getOriginFromHTTP(req);
if (origin.country === '??' && config.proxy) {
logger.warn('Invalid IP', JSON.stringify(req.ips));
//create(null, null, null).danger('Invalid IP', JSON.stringify(req.ips));
}
return origin;
}
export async function addOrigin(account: IAccount, origin: IOriginInfo) {
try {
const _id = account._id;
const existingOrigin = account.origins && account.origins
.find(o => o.ip === origin.ip) as (OriginInfoBase & { _id: any }) | undefined;
if (existingOrigin) {
await Account.updateOne({ _id, 'origins._id': existingOrigin._id }, { $set: { 'origins.$.last': new Date() } }).exec();
} else {
await Account.updateOne({ _id }, { $push: { origins: origin } }).exec();
}
} catch (e) {
logger.error('Failed to add origin', e);
}
}
+8
View File
@@ -0,0 +1,8 @@
import * as path from 'path';
export const root = path.join(__dirname, '..', '..', '..');
export const store = path.join(root, 'store');
export function pathTo(...parts: string[]) {
return path.join(root, ...parts);
}
+237
View File
@@ -0,0 +1,237 @@
import * as Bluebird from 'bluebird';
import { toPairs, groupBy, max } from 'lodash';
import { patreon, PatronData } from 'patreon';
import { IAuth, IAccount, UpdateAccounts, UpdateAccount, QueryAuths, QueryAccounts, UpdateAuth } from './db';
import { PatreonFlags, PatreonPledge, PatreonData, PatreonReward } from '../common/adminInterfaces';
import { Dict } from '../common/interfaces';
import { DAY, SECOND, MONTH, rewardLevel1, rewardLevel2, rewardLevel3 } from '../common/constants';
import { LogMessage, LogAccountMessage } from './serverInterfaces';
import { delay, fromNow } from '../common/utils';
import { logPatreon } from './logger';
export const declinedDayLimit = 14;
export const declinedTimeLimit = declinedDayLimit * DAY;
export const supporterLogLimit = 10;
export const SUPPORTER_REWARD_IDS: Dict<PatreonFlags> = {
[rewardLevel1]: PatreonFlags.Supporter1,
[rewardLevel2]: PatreonFlags.Supporter2,
[rewardLevel3]: PatreonFlags.Supporter3,
};
export type RemoveOldSupporters = ReturnType<typeof createRemoveOldSupporters>;
export type UpdateSupporters = ReturnType<typeof createUpdateSupporters>;
export type AddTotalPledged = ReturnType<typeof createAddTotalPledged>;
let lastPatreonData: PatreonData | undefined = undefined;
export function getLastPatreonData() {
return lastPatreonData;
}
/* istanbul ignore next */
export function createPatreonClient(accessToken: string): (path: string) => Promise<PatronData> {
const timeoutLimit = 10 * SECOND;
const client = patreon(accessToken);
client.setStore({ sync() { } });
return (path: string) => Promise.race([
delay(timeoutLimit).then(() => { throw new Error('Patreon request timed out'); }),
client(path),
]);
}
export async function fetchPatreonData(client: (path: string) => Promise<PatronData>, log: LogMessage): Promise<PatreonData> {
const campaignData = await client('/current_user/campaigns');
const rewards = campaignData.rawJson.included
.filter(x => x.type === 'reward')
.map<PatreonReward>(x => ({
id: x.id,
title: x.attributes.title || '',
description: x.attributes.description || '',
}));
const campaignId = campaignData.rawJson.data[0].id;
const pledges: PatreonPledge[] = [];
const queryParams = '&include=patron.null,reward.null&fields%5Bpledge%5D=total_historical_amount_cents,declined_since';
const query = 'page%5Bcount%5D=100&sort=created';
let url = `/campaigns/${campaignId}/pledges?${query}`;
let pages = 0;
do {
const pledgeData = await client(`${url}${queryParams}`);
const pledgeItems = pledgeData.rawJson.data
.filter(x => x.relationships.patron.data && x.relationships.reward.data)
.map<PatreonPledge>(x => ({
user: x.relationships.patron.data.id,
reward: x.relationships.reward.data.id,
total: x.attributes.total_historical_amount_cents || 0,
declinedSince: x.attributes.declined_since || undefined,
}));
pledges.push(...pledgeItems);
url = (pledgeData.rawJson.links.next || '').replace('https://www.patreon.com/api/oauth2/api', '');
pages++;
if (pages > 100) {
throw new Error('Exceeded 100 pages of patreon data');
}
} while (url);
log(`fetched patreon data (pages: ${pages}, pledges: ${pledges.length}, rewards: ${rewards.length})`);
return lastPatreonData = { pledges, rewards };
}
export const createUpdatePatreonInfo =
(
queryAuths: QueryAuths, queryAccounts: QueryAccounts, removeOldSupporters: RemoveOldSupporters,
updateSupporters: UpdateSupporters, updateTotalPledged: AddTotalPledged
) =>
async ({ pledges }: PatreonData, now: Date) => {
const ids = pledges.map(p => p.user);
const query = {
provider: 'patreon',
openId: { $in: ids },
account: { $exists: true },
banned: { $ne: true },
disabled: { $ne: true },
};
const patreonAuths = await queryAuths(query, '_id account openId pledged');
const accountsWithPatreon = await queryAccounts({ patreon: { $exists: true, $ne: 0 } }, '_id patreon supporterDeclinedSince');
// removes support from accounts without any non-banned patreon auth
await removeOldSupporters(patreonAuths, accountsWithPatreon);
await updateSupporters(patreonAuths, accountsWithPatreon, pledges, now);
await updateTotalPledged(patreonAuths, pledges);
};
export const createRemoveOldSupporters =
(updateAccounts: UpdateAccounts, log: LogAccountMessage) =>
async (auths: IAuth[], accounts: IAccount[]) => {
const clear = accounts
.filter(account => auths.every(auth => !auth.account || !account._id.equals(auth.account)))
.map(account => account._id);
clear.forEach(id => log(`${id}`, `removed supporter`));
await updateAccounts({ _id: { $in: clear } }, {
$unset: { patreon: 1, supporterDeclinedSince: 1 },
$push: {
supporterLog: {
$each: [{ date: new Date(), message: 'removed supporter' }],
$slice: -supporterLogLimit,
},
},
});
await updateAccounts(
{ supporterDeclinedSince: { $exists: true, $lt: fromNow(-2 * MONTH) } },
{ $unset: { supporterDeclinedSince: 1 } });
};
export const createUpdateSupporters =
(updateAccount: UpdateAccount, log: LogAccountMessage) =>
async (auths: IAuth[], accountsWithPatreon: IAccount[], pledges: PatreonPledge[], now: Date) => {
const start = Date.now();
const pledgesMap = new Map<string, PatreonPledge>();
const accountsWithPatreonMap = new Map<string, IAccount>();
for (const pledge of pledges) {
pledgesMap.set(pledge.user, pledge);
}
for (const account of accountsWithPatreon) {
accountsWithPatreonMap.set(account._id.toString(), account);
}
const setup = auths
.filter(auth => auth.account)
.map(auth => {
const accountId = auth.account!.toString();
const pledge = auth.openId && pledgesMap.get(auth.openId);
const pledgeFlags = pledge && SUPPORTER_REWARD_IDS[pledge.reward] || PatreonFlags.None;
const declinedSince = (pledge && pledge.declinedSince) ? new Date(pledge.declinedSince) : undefined;
const account = accountsWithPatreonMap.get(accountId!);
const declined = isDeclined(declinedSince, now);
const patreon = declined ? PatreonFlags.None : pledgeFlags;
const current = account && account.patreon || 0;
const declinedChanged = !!account && !datesEqual(account.supporterDeclinedSince, declinedSince);
const hadPatreon = !!account;
return {
account: accountId, patreon, declinedSince, declinedChanged, declined, current, hadPatreon
};
});
const grouped = toPairs(groupBy(setup, x => x.account))
.map(([account, items]) => {
const current = max(items.map(i => i.current))!;
const patreon = max(items.map(i => i.patreon))!;
return {
account,
changed: items.some(i => !i.hadPatreon) || current !== patreon,
declinedChanged: items.some(i => i.declinedChanged),
patreon,
declinedSince: items.map(i => i.declinedSince).find(x => !!x),
declined: items.some(i => i.declined),
hadPatreon: items.some(i => i.hadPatreon),
};
})
.filter(({ changed, declinedChanged }) => changed || declinedChanged);
grouped
.filter(g => g.changed)
.map(g => ({ account: g.account, message: supporterMessage(g.patreon, g.declined, g.hadPatreon) }))
.filter(({ message }) => !!message)
.forEach(({ account, message }) => log(`${account}`, message!));
logPatreon(`update supporters (${Date.now() - start}ms) ` +
`[auths: ${auths.length}, grouped: ${grouped.length}, pledges: ${pledges.length}, ` +
`accountsWithPatreon: ${accountsWithPatreon.length}]`);
await Bluebird.map(grouped, ({ account, patreon, declinedSince, changed, declined, hadPatreon }) => {
const message = changed ? supporterMessage(patreon, declined, hadPatreon) : undefined;
return updateAccount(account, {
supporterDeclinedSince: declinedSince,
...(changed ? { patreon } : {}),
...(message ? {
$push: {
supporterLog: {
$each: [{ date: new Date(), message }],
$slice: -supporterLogLimit,
},
}
} : {}),
});
}, { concurrency: 4 });
};
function isDeclined(declinedSince: Date | undefined, now: Date): boolean {
return !!declinedSince && (
now.getDate() > declinedDayLimit ||
(now.getTime() - declinedSince.getTime()) > declinedTimeLimit);
}
function supporterMessage(patreon: PatreonFlags, declined: boolean, hadPatreon: boolean) {
return patreon ?
`added supporter (${patreon})` :
(hadPatreon ? `removed supporter${declined ? ' (declined)' : ''}` : undefined);
}
function datesEqual(a: Date | undefined, b: Date | undefined) {
return (!a && !b) || (a && b && a.getTime() === b.getTime());
}
export const createAddTotalPledged =
(updateAuth: UpdateAuth) =>
async (auths: IAuth[], pledges: PatreonPledge[]) => {
const setup = auths
.map(auth => ({ auth, pledge: pledges.find(p => p.user === auth.openId) }))
.filter(({ auth, pledge }) => pledge && pledge.total !== auth.pledged);
await Bluebird.map(setup, ({ auth, pledge }) =>
updateAuth(auth._id, { pledged: pledge!.total }), { concurrency: 4 });
};
+811
View File
@@ -0,0 +1,811 @@
import { sample } from 'lodash';
import { createBinaryWriter, resetWriter } from 'ag-sockets';
import { IClient, ServerEntity, Reporter, TokenData, ServerMap } from './serverInterfaces';
import { IAccount, ICharacter, UpdateAccount, IOriginInfo, findFriendIds } from './db';
import * as entities from '../common/entities';
import { isShadowed, isMuted, supporterLevel } from '../common/adminUtils';
import { handlePromiseDefault } from './serverUtils';
import {
removeItem, hasFlag, distance, toInt, includes, array, flatten, containsPointWitBorder, distanceXY, setFlag, invalidEnum
} from '../common/utils';
import { CharacterState, ServerConfig, AccountState, CharacterStateFlags, GameServerSettings } from '../common/adminInterfaces';
import {
EntityState, Expression, PonyOptions, Action, ExpressionExtra, Eye, Muzzle, CLOSED_MUZZLES,
isExpressionAction, EntityPlayerState, UpdateFlags, InteractAction
} from '../common/interfaces';
import { encodeExpression, EMPTY_EXPRESSION, decodeExpression } from '../common/encoders/expressionEncoder';
import { EXPRESSION_TIMEOUT, DAY, FLY_DELAY, SECOND, PONY_TYPE } from '../common/constants';
import { World } from './world';
import { centerCameraOn, createCamera } from '../common/camera';
import { findEntitiesInBounds } from './serverMap';
import { CounterService } from './services/counter';
import { create } from './reporter';
import { updateAccountState } from './accountUtils';
import { isMod } from '../common/accountUtils';
import { getOriginFromHTTP } from './originUtils';
import { createPony, getAndFixCharacterState, updateCharacterState } from './characterUtils';
import {
updateEntityOptions, canBoopEntity, findPlayersThetCanBeSitOn, updateEntityState, updateEntityExpression,
sendAction, pushUpdateEntityToClient, fixPosition, isHoldingGrapes
} from './entityUtils';
import { replaceEmojis } from '../client/emoji';
import { expression, parseExpression } from '../common/expressionUtils';
import {
canBoop2, isPonySitting, isPonyStanding, getBoopRect, canStand, isPonyFlying, setPonyState, canSit, canLie
} from '../common/entityUtils';
import { withBorder } from '../common/rect';
import { isOnlineFriend } from './services/friends';
import { grapePurple, grapeGreen, tools } from '../common/entities';
import { saySystem } from './chat';
export function isMutedOrShadowed(client: IClient) {
return client.shadowed || isMuted(client.account);
}
export function isIgnored(ignoring: IClient, target: IClient): boolean {
return target.ignores.has(ignoring.accountId);
}
export function kickClient(client: IClient, reason = 'kicked') {
client.leaveReason = reason;
client.disconnect(true, true);
}
export function getCounter(client: IClient, key: keyof AccountState) {
return toInt(client.account.state && client.account.state[key]);
}
export function createClientAndPony(
client: IClient, friends: string[], hides: string[], server: ServerConfig, world: World, states: CounterService<CharacterState>
) {
const { account, character } = client.tokenData as TokenData;
const origin = client.originalRequest && getOriginFromHTTP(client.originalRequest);
const reporter = create(server, account._id, character._id, origin);
const state = getAndFixCharacterState(server, character, world, states);
client.characterState = state;
const pony = createPony(account, character, state);
pony.client = createClient(client, account, friends, hides, character, pony, world.getMainMap(), reporter, origin);
centerCameraOn(client.camera, pony);
}
export function updateClientCharacter(client: IClient, character: ICharacter) {
client.character = character;
client.characterId = client.character._id.toString();
client.characterName = replaceEmojis(client.character.name);
}
export function createClient(
client: IClient, account: IAccount, friends: string[], hides: string[], character: ICharacter, pony: ServerEntity,
defaultMap: ServerMap, reporter: Reporter, origin: IOriginInfo | undefined
): IClient {
updateClientCharacter(client, character);
client.ip = origin && origin.ip || '';
client.country = origin && origin.country || '??';
client.userAgent = client.originalRequest && client.originalRequest.headers['user-agent'];
client.accountId = account._id.toString();
client.accountName = account.name;
client.ignores = new Set(account.ignores);
client.hides = new Set();
client.permaHides = new Set(hides);
client.friends = new Set(friends);
client.friendsCRC = undefined;
client.accountSettings = { ...account.settings };
client.supporterLevel = supporterLevel(account);
client.isMod = isMod(account);
client.reporter = reporter;
client.account = account;
client.character = character;
client.pony = pony;
client.map = defaultMap;
client.isSwitchingMap = false;
client.notifications = [];
client.regions = [];
client.shadowed = isShadowed(account);
client.country = origin && origin.country || '??';
client.camera = createCamera();
client.camera.w = 800;
client.camera.h = 600;
client.safeX = pony.x;
client.safeY = pony.y;
client.lastPacket = Date.now();
client.lastAction = 0;
client.lastBoopAction = 0;
client.lastExpressionAction = 0;
client.lastSays = [];
client.lastX = pony.x;
client.lastY = pony.y;
client.lastTime = 0;
client.lastVX = 0;
client.lastVY = 0;
client.lastMapSwitch = 0;
client.lastSitX = 0;
client.lastSitY = 0;
client.lastSitTime = 0;
client.sitCount = 0;
client.lastSwap = 0;
client.lastMapLoadOrSave = 0;
client.lastCameraX = 0;
client.lastCameraY = 0;
client.lastCameraW = 0;
client.lastCameraH = 0;
client.updateQueue = createBinaryWriter(128);
client.regionUpdates = [];
client.saysQueue = [];
client.unsubscribes = [];
client.subscribes = [];
client.positions = [];
return client;
}
export function resetClientUpdates(client: IClient) {
resetWriter(client.updateQueue);
client.regionUpdates.length = 0;
client.saysQueue.length = 0;
client.unsubscribes.length = 0;
client.subscribes.length = 0;
}
export function createCharacterState(entity: ServerEntity, map: ServerMap): CharacterState {
const options = entity.options as PonyOptions;
const flags: CharacterStateFlags =
(hasFlag(entity.state, EntityState.FacingRight) ? CharacterStateFlags.Right : 0) |
(options.extra ? CharacterStateFlags.Extra : 0);
const state: CharacterState = { x: entity.x, y: entity.y };
if (flags) {
state.flags = flags;
}
if (map.id) {
state.map = map.id;
}
if (options.hold) {
state.hold = entities.getEntityTypeName(options.hold);
}
if (options.toy) {
state.toy = options.toy;
}
return state;
}
export async function createAndUpdateCharacterState(client: IClient, server: ServerConfig) {
const state = createCharacterState(client.pony, client.map);
await updateCharacterState(client.characterId, server.id, state);
}
// utils
export function addIgnore(target: IClient, accountId: string) {
target.account.ignores = target.account.ignores || [];
target.account.ignores.push(accountId);
target.ignores.add(accountId);
}
export function removeIgnore(target: IClient, accountId: string) {
if (target.account.ignores) {
removeItem(target.account.ignores, accountId);
}
target.ignores.delete(accountId);
}
export const createIgnorePlayer =
(updateAccount: UpdateAccount, handlePromise = handlePromiseDefault) =>
(client: IClient, target: IClient, ignored: boolean) => {
if (target.accountId === client.accountId)
return;
const id = client.accountId;
const is = isIgnored(client, target);
if (ignored === is)
return;
if (ignored) {
addIgnore(target, id);
} else {
removeIgnore(target, id);
}
handlePromise(updateAccount(target.accountId, { [ignored ? '$push' : '$pull']: { ignores: id } })
.then(() => updateEntityPlayerState(client, target.pony))
.then(() => {
const { accountId, account, character } = target;
const message = `${ignored ? 'ignored' : 'unignored'} ${character.name} (${account.name}) [${accountId}]`;
client.reporter.systemLog(message);
}), client.reporter.error);
};
export function findClientByEntityId(self: IClient, entityId: number): IClient | undefined {
const selected = self.selected;
if (selected && selected.id === entityId && selected.client) {
return selected.client;
}
if (self.party) { // TODO: remove ?
const client = self.party.clients.find(c => c.pony.id === entityId);
if (client) {
//this.logger.log('client from party');
return client;
}
const pending = self.party.pending.find(c => c.client.pony.id === entityId);
if (pending) {
//this.logger.log('pending from party');
return pending.client;
}
}
const notification = self.notifications.find(c => c.entityId === entityId);
if (notification) {
//this.logger.log('sender from notification');
return notification.sender;
}
return undefined;
}
export function cancelEntityExpression(entity: ServerEntity) {
if (entity.exprCancellable) {
setEntityExpression(entity, undefined);
}
}
export function setEntityExpression(
entity: ServerEntity, expression: Expression | undefined, timeout = EXPRESSION_TIMEOUT, cancellable = false
) {
expression = expression || entity.exprPermanent;
const expr = encodeExpression(expression);
(entity.options as PonyOptions).expr = expr;
if (expression && timeout) {
entity.exprTimeout = Date.now() + timeout;
} else {
entity.exprTimeout = undefined;
}
const sleeping = expression !== undefined && hasFlag(expression.extra, ExpressionExtra.Zzz);
entity.exprCancellable = cancellable || sleeping;
updateEntityExpression(entity);
}
export function playerBlush(pony: ServerEntity, args = '') {
const expr = parseOrCurrentExpression(pony, args) || expression(Eye.Neutral, Eye.Neutral, Muzzle.Neutral);
expr.extra |= ExpressionExtra.Blush;
setEntityExpression(pony, expr, DAY, !!pony.exprCancellable);
}
export function parseOrCurrentExpression(pony: ServerEntity, message: string) {
return parseExpression(message)
|| decodeExpression((!pony.options || pony.options.expr == null) ? EMPTY_EXPRESSION : pony.options.expr);
}
export function playerSleep(pony: ServerEntity, args = '') {
if (pony.vx === 0 && pony.vy === 0) {
const base = parseOrCurrentExpression(pony, args) || expression(Eye.Closed, Eye.Closed, Muzzle.Neutral);
const muzzle = CLOSED_MUZZLES.indexOf(base.muzzle) !== -1 ? base.muzzle : Muzzle.Neutral;
const expr = { ...base, muzzle, left: Eye.Closed, right: Eye.Closed, extra: ExpressionExtra.Zzz };
setEntityExpression(pony, expr, 0, true);
}
}
export function playerLove(pony: ServerEntity, args = '') {
const expr = parseOrCurrentExpression(pony, args) || expression(Eye.Neutral, Eye.Neutral, Muzzle.Smile);
expr.extra |= ExpressionExtra.Hearts;
setEntityExpression(pony, expr, DAY, !!pony.exprCancellable);
}
export function playerCry(pony: ServerEntity, args = '') {
const expr = parseExpression(args) || expression(Eye.Sad, Eye.Sad, Muzzle.Frown);
expr.extra = expr.extra | ExpressionExtra.Cry;
setEntityExpression(pony, expr, 0);
}
const fruitTypes = entities.fruits.map(f => f.type);
export function interactWith(client: IClient, target: ServerEntity | undefined) {
if (target) {
const pony = client.pony;
if (target.interact && (!target.interactRange || distance(pony, target) < target.interactRange)) {
target.interact(target, client);
} else if (target.triggerBounds && target.trigger) {
if (containsPointWitBorder(target.x, target.y, target.triggerBounds, pony.x, pony.y, 3)) {
target.trigger(target, client);
} else {
DEVELOPMENT && console.warn(`outside trigger bounds ` +
`(bounds: ${target.x} ${target.y} ${JSON.stringify(target.triggerBounds)} point: ${pony.x} ${pony.y})`);
}
} else if (target.interactAction) {
switch (target.interactAction) {
case InteractAction.Toolbox: {
switchTool(client, false);
break;
}
case InteractAction.GiveLantern: {
if (client.pony.options!.hold === entities.lanternOn.type) {
unholdItem(pony);
} else {
holdItem(pony, entities.lanternOn.type);
}
break;
}
case InteractAction.GiveFruits: {
const index = fruitTypes.indexOf(client.pony.options!.hold || 0) + 1;
holdItem(client.pony, fruitTypes[index % fruitTypes.length]);
break;
}
case InteractAction.GiveCookie1: {
const hold = client.pony.options!.hold;
let cookie = hold;
while (hold === cookie) {
cookie = sample(entities.candies1Types)!;
}
holdItem(client.pony, cookie!);
break;
}
case InteractAction.GiveCookie2: {
const hold = client.pony.options!.hold;
let cookie = hold;
while (hold === cookie) {
cookie = sample(entities.candies2Types)!;
}
holdItem(client.pony, cookie!);
break;
}
default:
invalidEnum(target.interactAction);
}
}
}
}
export function useHeldItem(client: IClient) {
const hold = client.pony.options!.hold || 0;
if (isGift(hold)) {
openGift(client);
}
}
export function canPerformAction(client: IClient) {
return client.lastAction < Date.now();
}
export function updateEntityPlayerState(client: IClient, entity: ServerEntity) {
const playerState = getPlayerState(client, entity);
pushUpdateEntityToClient(client, { entity, flags: UpdateFlags.PlayerState, playerState });
}
// actions
export function turnHead(client: IClient) {
if (canPerformAction(client)) {
updateEntityState(client.pony, client.pony.state ^ EntityState.HeadTurned);
}
}
const purpleGrapeTypes = entities.grapesPurple.map(x => x.type);
const greenGrapeTypes = entities.grapesGreen.map(x => x.type);
export function boop(client: IClient, now: number) {
if (canPerformAction(client) && canBoop2(client.pony) && client.lastBoopAction < now) {
cancelEntityExpression(client.pony);
sendAction(client.pony, Action.Boop);
if (!client.shadowed && (isPonySitting(client.pony) || isPonyStanding(client.pony))) {
const boopRect = getBoopRect(client.pony);
const boopBounds = withBorder(boopRect, 1);
const entities = findEntitiesInBounds(client.map, boopBounds);
const entity = entities.find(e => canBoopEntity(e, boopRect));
if (entity) {
if (entity.boop) {
entity.boop(client);
} else if (entity.type === PONY_TYPE) {
const clientHold = client.pony.options!.hold || 0;
if (isHoldingGrapes(entity) && clientHold !== grapeGreen.type && clientHold !== grapePurple.type) {
let index = purpleGrapeTypes.indexOf(entity.options!.hold || 0);
if (index !== -1) {
holdItem(client.pony, grapePurple.type);
if (index === (purpleGrapeTypes.length - 1)) {
unholdItem(entity);
} else {
holdItem(entity, purpleGrapeTypes[index + 1]);
}
} else {
let index = greenGrapeTypes.indexOf(entity.options!.hold || 0);
if (index !== -1) {
holdItem(client.pony, grapeGreen.type);
if (index === (greenGrapeTypes.length - 1)) {
unholdItem(entity);
} else {
holdItem(entity, greenGrapeTypes[index + 1]);
}
}
}
}
}
}
}
client.lastBoopAction = now + 500;
}
}
export function stand(client: IClient) {
if (canPerformAction(client) && canStand(client.pony, client.map)) {
if (!isPonyFlying(client.pony)) {
cancelEntityExpression(client.pony);
}
updateEntityState(client.pony, setPonyState(client.pony.state, EntityState.PonyStanding));
}
}
const SIT_MAX_TIME = 2 * SECOND;
const SIT_MAX_DIST = 1;
const SIT_MAX_COUNT = 5;
function checkSuspiciousSitting(client: IClient) {
const now = Date.now();
const { x, y } = client.pony;
const dist = distanceXY(x, y, client.lastSitX, client.lastSitY);
if ((now - client.lastSitTime) < SIT_MAX_TIME && dist < SIT_MAX_DIST && findPlayersThetCanBeSitOn(client.map, client.pony)) {
client.sitCount++;
if (client.sitCount > SIT_MAX_COUNT) {
client.reporter.warn(`Suspicious sitting`);
client.sitCount = 0;
}
} else {
client.sitCount = 1;
}
client.lastSitX = x;
client.lastSitY = y;
client.lastSitTime = now;
}
export function sit(client: IClient, settings: GameServerSettings) {
if (canPerformAction(client) && canSit(client.pony, client.map)) {
updateEntityState(client.pony, setPonyState(client.pony.state, EntityState.PonySitting));
if (settings.reportSitting) {
checkSuspiciousSitting(client);
}
}
}
export function lie(client: IClient) {
if (canPerformAction(client) && canLie(client.pony, client.map)) {
updateEntityState(client.pony, setPonyState(client.pony.state, EntityState.PonyLying));
}
}
export function fly(client: IClient) {
if (canPerformAction(client) && client.pony.canFly && !isPonyFlying(client.pony)) {
cancelEntityExpression(client.pony);
updateEntityState(client.pony, setPonyState(client.pony.state, EntityState.PonyFlying));
client.pony.inTheAirDelay = FLY_DELAY;
}
}
export function expressionAction(client: IClient, action: Action) {
if (canPerformAction(client) && isExpressionAction(action) && client.lastExpressionAction < Date.now()) {
cancelEntityExpression(client.pony);
sendAction(client.pony, action);
client.lastExpressionAction = Date.now() + 500;
}
}
// hold
export function holdItem(entity: ServerEntity, hold: number) {
if (entity.options && entity.options.hold !== hold) {
updateEntityOptions(entity, { hold });
}
}
export function unholdItem(entity: ServerEntity) {
if (entity.options && entity.options.hold) {
updateEntityOptions(entity, { hold: 0 });
delete entity.options.hold;
}
}
// toy
export function holdToy(entity: ServerEntity, toy: number) {
if (entity.options && entity.options.toy !== toy) {
updateEntityOptions(entity, { toy });
}
}
export function unholdToy(entity: ServerEntity) {
if (entity.options && entity.options.toy) {
updateEntityOptions(entity, { toy: 0 });
delete entity.options.toy;
}
}
// gifts and toys
const giftTypes = [entities.gift2.type];
const toys = [
// hat
{ type: 0, multiplier: 20 },
{ type: 0, multiplier: 10 },
{ type: 0, multiplier: 5 },
{ type: 0, multiplier: 1 }, // pink
// snowpony
{ type: 0, multiplier: 20 },
{ type: 0, multiplier: 10 }, // clothes
{ type: 0, multiplier: 1 }, // evil
// gift
{ type: 0, multiplier: 20 },
{ type: 0, multiplier: 10 },
{ type: 0, multiplier: 10 },
{ type: 0, multiplier: 5 },
{ type: 0, multiplier: 1 },
// hanging thing
{ type: 0, multiplier: 20 }, // bell
{ type: 0, multiplier: 10 }, // mistletoe
{ type: 0, multiplier: 5 }, // cookie
{ type: 0, multiplier: 1 }, // spider
// teddy
{ type: 0, multiplier: 20 }, // brown
{ type: 0, multiplier: 10 }, // brown angel
{ type: 0, multiplier: 20 }, // black
{ type: 0, multiplier: 10 }, // black angel
{ type: 0, multiplier: 5 }, // brown clothes
{ type: 0, multiplier: 5 }, // black clothes
{ type: 0, multiplier: 1 }, // white santa
// xmas tree
{ type: 0, multiplier: 10 },
{ type: 0, multiplier: 5 },
// deer
{ type: 0, multiplier: 5 },
{ type: 0, multiplier: 1 }, // with clothes
// candy horns
{ type: 0, multiplier: 10 }, // one
{ type: 0, multiplier: 2 }, // two
{ type: 0, multiplier: 1 }, // two (alt)
// star
{ type: 0, multiplier: 5 },
// halo
{ type: 0, multiplier: 5 },
];
toys.forEach((toy, i) => toy.type = i + 1);
const toyTypes = flatten(toys.map(x => array(x.multiplier, x.type)));
function hasToyUnlocked(type: number, collectedToys: number) {
const index = toys.findIndex(t => t.type === type);
return hasFlag(collectedToys, 1 << index);
}
function unlockToy(type: number, collectedToys: number) {
const index = toys.findIndex(t => t.type === type);
return collectedToys | (1 << index);
}
export function getCollectedToysCount(client: IClient) {
const stateToys = toInt((client.account.state || {}).toys);
const total = toys.length;
let collected = 0;
for (let i = 0, bit = 1; i < total; i++ , bit <<= 1) {
if (stateToys & bit) {
collected++;
}
}
return { collected, total };
}
export function getNextToyOrExtra(client: IClient) {
const collectedToys = toInt((client.account.state || {}).toys);
const options = client.pony.options || {};
const extra = !!options.extra;
const toy = toInt(options.toy);
if (extra) {
return { extra: false, toy: 0 };
} else {
for (let i = toys.findIndex(t => t.type === toy) + 1; i < toys.length; i++) {
const type = toys[i].type;
if (hasToyUnlocked(type, collectedToys)) {
return { extra: false, toy: type };
}
}
return { extra: true, toy: 0 };
}
}
export function openGift(client: IClient) {
const options = client.pony.options || {};
if (isGift(options.hold)) {
let toyType = 0;
do {
toyType = sample(toyTypes)!;
} while (toyType === options.toy);
sendAction(client.pony, Action.HoldPoof);
unholdItem(client.pony);
setTimeout(() => holdToy(client.pony, toyType), 200);
const state = client.account.state || {};
if (!hasToyUnlocked(toyType, toInt(state.toys))) {
updateAccountState(client.account, state => {
state.toys = unlockToy(toyType, toInt(state.toys));
});
}
}
}
export function isGift(type: number | undefined) {
return type !== undefined && includes(giftTypes, type);
}
export function isHiddenBy(a: IClient, b: IClient) {
return a.hides.has(b.accountId) || b.hides.has(a.accountId) ||
a.permaHides.has(b.accountId) || b.permaHides.has(a.accountId);
}
export function getPlayerState(client: IClient, entity: ServerEntity): EntityPlayerState {
let state = EntityPlayerState.None;
if (entity.client !== undefined) {
if (isIgnored(client, entity.client)) {
state |= EntityPlayerState.Ignored;
}
if (isHiddenBy(client, entity.client)) {
state |= EntityPlayerState.Hidden;
}
if (isOnlineFriend(client, entity.client)) {
state |= EntityPlayerState.Friend;
}
}
return state;
}
export async function reloadFriends(client: IClient) {
const friends = await findFriendIds(client.accountId);
client.friends = new Set(friends);
client.friendsCRC = undefined;
client.actionParam(0, Action.FriendsCRC, undefined);
}
export function execAction(client: IClient, action: Action, settings: GameServerSettings) {
switch (action) {
case Action.Boop:
boop(client, Date.now());
break;
case Action.TurnHead:
turnHead(client);
break;
case Action.Stand:
stand(client);
break;
case Action.Sit:
sit(client, settings);
break;
case Action.Lie:
lie(client);
break;
case Action.Fly:
fly(client);
break;
case Action.Drop:
unholdItem(client.pony);
break;
case Action.Sleep:
playerSleep(client.pony);
break;
case Action.Blush:
playerBlush(client.pony);
break;
case Action.Cry:
playerCry(client.pony);
break;
case Action.Love:
playerLove(client.pony);
break;
case Action.DropToy:
unholdToy(client.pony);
updateEntityOptions(client.pony, { extra: false });
break;
case Action.Magic:
if (client.pony.canMagic) {
const has = hasFlag(client.pony.state, EntityState.Magic);
updateEntityState(client.pony, setFlag(client.pony.state, EntityState.Magic, !has));
}
break;
case Action.SwitchTool:
switchTool(client, false);
break;
case Action.SwitchToolRev:
switchTool(client, true);
break;
case Action.SwitchToPlaceTool:
holdItem(client.pony, entities.hammer.type);
break;
case Action.SwitchToTileTool:
holdItem(client.pony, entities.shovel.type);
break;
default:
if (isExpressionAction(action)) {
expressionAction(client, action);
} else {
throw new Error(`Invalid action (${action})`);
}
break;
}
}
export function switchTool(client: IClient, reverse: boolean) {
const hold = client.pony.options!.hold || 0;
const index = tools.findIndex(t => t.type === hold);
const unholdIndex = reverse ? 0 : tools.length - 1;
if (index === unholdIndex) {
unholdItem(client.pony);
} else {
const newIndex = reverse ? (index === -1 ? tools.length - 1 : index - 1) : ((index + 1) % tools.length);
const tool = tools[newIndex];
holdItem(client.pony, tool.type);
saySystem(client, tool.text);
}
}
export function teleportTo(client: IClient, x: number, y: number) {
fixPosition(client.pony, client.map, x, y, true);
client.safeX = client.pony.x;
client.safeY = client.pony.y;
client.lastTime = 0;
}
+244
View File
@@ -0,0 +1,244 @@
import * as fs from 'fs';
import * as Bluebird from 'bluebird';
import {
InternalGameServerState, BannedMuted, Settings, ServerConfig, InternalLoginServerState, SupporterFlags
} from '../common/adminInterfaces';
import { fromNow, delay } from '../common/utils';
import { logger, logPatreon, system, logPerformance } from './logger';
import { Auth, updateAccounts, updateAccount, queryAuths, queryAccounts, updateAuth, Account, SupporterInvite } from './db';
import { getDiskSpace, getCertificateExpirationDate, getMemoryUsage } from './serverUtils';
import { HOUR, MINUTE, DAY, SECOND, YEAR } from '../common/constants';
import {
fetchPatreonData, createPatreonClient, createUpdatePatreonInfo, createRemoveOldSupporters,
createUpdateSupporters, createAddTotalPledged
} from './patreon';
import { create } from './reporter';
import { servers, serverStatus, loginServers, RemovedDocument } from './internal';
import * as paths from './paths';
import { updateSupporterInvites } from './services/supporterInvites';
import { AdminService } from './services/adminService';
import { clearOrigins } from './api/origins';
import { config } from './config';
let updatingPatreonPromise: Promise<void> | undefined;
async function updatePatreonDataInternal(server: ServerConfig, accessToken: string) {
try {
const removeOldSupporters = createRemoveOldSupporters(updateAccounts, system);
const updateSupporters = createUpdateSupporters(updateAccount, system);
const addTotalPledged = createAddTotalPledged(updateAuth);
const updatePatreonInfo = createUpdatePatreonInfo(
queryAuths, queryAccounts, removeOldSupporters, updateSupporters, addTotalPledged);
const client = createPatreonClient(accessToken);
const data = await fetchPatreonData(client, logPatreon);
await updatePatreonInfo(data, new Date());
serverStatus.lastPatreonUpdate = (new Date()).toISOString();
} catch (e) {
const message = e.error ? (e.error.message || e.error.statusText || `${e}`) : e.message;
const stack = (e.error ? e.error.stack : e.stack) || '';
create(server).danger('Patreon update failed', `${message}\n${stack}`.trim());
logger.error(e);
} finally {
updatingPatreonPromise = undefined;
}
}
export async function updatePatreonData(server: ServerConfig, { patreonToken }: Settings) {
if (patreonToken && config.supporterLink) {
return updatingPatreonPromise = updatingPatreonPromise || updatePatreonDataInternal(server, patreonToken);
}
}
async function clearOldIgnores() {
const start = Date.now();
await updateAccounts({
ignores: { $exists: true, $not: { $size: 0 } },
lastVisit: { $lt: fromNow(-YEAR) },
}, { ignores: [] });
logPerformance(`[async] clearOldIgnores (${Date.now() - start}ms)`);
}
async function cleanupBanField(field: keyof BannedMuted) {
const start = Date.now();
await updateAccounts({ [field]: { $exists: true, $gt: 0, $lt: Date.now() } }, { $unset: { [field]: 1 } });
logPerformance(`[async] cleanupBanField (${field}) (${Date.now() - start}ms)`);
}
async function cleanupBans() {
const start = Date.now();
await cleanupBanField('ban');
await cleanupBanField('shadow');
await cleanupBanField('mute');
logPerformance(`[async] cleanupBans (${Date.now() - start}ms)`);
}
async function cleanupMerges() {
const start = Date.now();
const date = fromNow(-30 * DAY);
await updateAccounts({ merges: { $exists: true, $not: { $size: 0 } } }, { $pull: { merges: { date: { $lt: date } } } });
await updateAccounts({ merges: { $exists: true, $size: 0 } }, { $unset: { merges: 1 } });
logPerformance(`[async] cleanupMerges (${Date.now() - start}ms)`);
}
async function cleanupAccountAlerts() {
const start = Date.now();
await updateAccounts(
{ alert: { $exists: true }, 'alert.expires': { $lt: new Date() } } as any,
{ $unset: { alert: 1 } });
logPerformance(`[async] cleanupAccountAlerts (${Date.now() - start}ms)`);
}
export async function updatePastSupporters() {
const start = Date.now();
const auths = await Auth.find({
pledged: { $exists: true, $gt: 0 },
disabled: { $ne: true },
banned: { $ne: true }
}, 'account').exec();
const accounts = await Account.find({
supporter: { $exists: true, $bitsAllSet: SupporterFlags.PastSupporter }
}, '_id').exec();
const shouldBeFlagged = new Set<string>();
const areFlagged = new Set<string>();
for (const auth of auths) {
if (auth.account) {
shouldBeFlagged.add(auth.account.toString());
}
}
for (const account of accounts) {
areFlagged.add(account._id.toString());
}
for (const auth of auths) {
if (auth.account) {
if (!areFlagged.has(auth.account.toString())) {
await Account.updateOne({ _id: auth.account }, { $bit: { supporter: { or: SupporterFlags.PastSupporter } } }).exec();
}
}
}
for (const account of accounts) {
if (!shouldBeFlagged.has(account._id.toString())) {
await Account.updateOne({ _id: account._id }, { $bit: { supporter: { and: ~SupporterFlags.PastSupporter } } }).exec();
}
}
logPerformance(`[async] cleanupAccountAlerts (${Date.now() - start}ms)`);
}
const cleanupStrayAuths = (removedDocument: RemovedDocument) =>
async () => {
const start = Date.now();
const date = fromNow(-1 * DAY);
const query = { account: { $exists: false }, updatedAt: { $lt: date }, createdAt: { $lt: date } };
const items = await queryAuths(query, '_id');
await Auth.deleteMany(query).exec();
await Bluebird.map(items, item => removedDocument('auths', item._id.toString()), { concurrency: 4 });
logPerformance(`[async] cleanupAccountAlerts (${Date.now() - start}ms)`);
};
async function updateServerState(server: InternalGameServerState | InternalLoginServerState) {
try {
const state = await server.api.state();
Object.assign(server.state, state);
} catch {
server.state.dead = true;
}
}
let lastVisitedTodayCheck = (new Date()).getDate();
async function countUsersVisitedToday() {
const start = Date.now();
const day = (new Date()).getDate();
if (lastVisitedTodayCheck !== day) {
lastVisitedTodayCheck = day;
const statsFile = paths.pathTo('settings', `user-counts.log`);
const count = await Account.countDocuments({ lastVisit: { $gt: fromNow(-1 * DAY) } }).exec();
const json = JSON.stringify({ count, date: (new Date()).toISOString() });
await fs.appendFileAsync(statsFile, `${json}\n`, 'utf8');
logPerformance(`[async] countUsersVisitedToday (${Date.now() - start}ms)`);
}
}
async function mergePotentialDuplicates(service: AdminService) {
if (loginServers[0].state.autoMergeDuplicates) {
await service.mergePotentialDuplicates();
}
}
export async function poll(action: () => any, delayTime: number) {
try {
await delay(delayTime);
await action();
} catch (e) {
console.error(e);
} finally {
poll(action, delayTime);
}
}
export async function pollImmediate(action: () => any, delayTime: number) {
try {
await action();
} catch (e) {
console.error(e);
} finally {
await delay(delayTime);
poll(action, delayTime);
}
}
export function pollServers() {
return poll(() => Promise.all([...loginServers, ...servers].map(updateServerState)), 1 * SECOND);
}
export function pollPatreon(server: ServerConfig, settings: Settings) {
return poll(() => updatePatreonData(server, settings), 10 * MINUTE);
}
export const pollDiskSpace = () => pollImmediate(() =>
getDiskSpace().then(value => serverStatus.diskSpace = value), HOUR);
export const pollMemoryUsage = () => pollImmediate(() =>
getMemoryUsage().then(value => serverStatus.memoryUsage = value), 10 * MINUTE);
export const pollCertificateExpirationDate = () => pollImmediate(() =>
getCertificateExpirationDate().then(value => serverStatus.certificateExpiration = value), HOUR);
export const startBansCleanup = () => poll(cleanupBans, DAY + 10 * MINUTE);
export const startMergesCleanup = () => poll(cleanupMerges, DAY + 15 * MINUTE);
export const startStrayAuthsCleanup = (removedDocument: RemovedDocument) =>
poll(cleanupStrayAuths(removedDocument), DAY + 35 * MINUTE);
export const startClearOldIgnores = () => poll(clearOldIgnores, DAY + 20 * MINUTE);
export const startCollectingUsersVisitedCount = () => poll(countUsersVisitedToday, 10 * MINUTE);
export const startSupporterInvitesCleanup = () => poll(() => updateSupporterInvites(SupporterInvite), HOUR);
export const startPotentialDuplicatesCleanup = (service: AdminService) =>
poll(() => mergePotentialDuplicates(service), 10 * MINUTE);
export const startAccountAlertsCleanup = () => poll(cleanupAccountAlerts, DAY + 25 * MINUTE);
export const startUpdatePastSupporters = () => poll(updatePastSupporters, DAY + 30 * MINUTE);
export function startClearTo10Origns(adminService: AdminService) {
return poll(async () => {
if (adminService.loaded) {
const start = Date.now();
await clearOrigins(adminService, 10, true, { old: false, singles: true, trim: true });
logPerformance(`[async] startClearTo10Origns (${Date.now() - start}ms)`);
}
}, DAY + 35 * MINUTE);
}
export function startClearVeryOldOrigns(adminService: AdminService) {
return poll(async () => {
if (adminService.loaded) {
const start = Date.now();
await clearOrigins(adminService, 1, true, { old: true, singles: false, trim: false });
logPerformance(`[async] startClearVeryOldOrigns (${Date.now() - start}ms)`);
}
}, DAY + 50 * MINUTE);
}
+30
View File
@@ -0,0 +1,30 @@
export interface Pool<T> {
create(): T;
dispose(value: T): boolean;
}
export function createPool<T>(count: number, createNew: () => T, reset: (value: T) => void): Pool<T> {
const pool: T[] = [];
const create = () => {
const existing = pool.pop();
if (existing) {
reset(existing);
return existing;
} else {
return createNew();
}
};
const dispose = (value: T) => {
if (pool.length < count) {
pool.push(value);
return true;
} else {
return false;
}
};
return { create, dispose };
}
+301
View File
@@ -0,0 +1,301 @@
import { createBinaryWriter, getWriterBuffer, BinaryWriter } from 'ag-sockets';
import { removeItem, pointInRect, clamp, includes } from '../common/utils';
import { ServerEntity, IClient, ServerRegion, ServerMap } from './serverInterfaces';
import {
tickTilesRestoration, resetRegionUpdates, pushRemoveEntityToRegion, removeEntityFromRegion, addEntityToRegion
} from './serverRegion';
import { updateEntity, isEntityShadowed, isOverflowError, pushAddEntityToClient } from './entityUtils';
import { writeRegion, writeUpdate } from '../common/encoders/updateEncoder';
import { toWorldX, toWorldY } from '../common/positionUtils';
import { isRectVisible } from '../common/camera';
import { timingStart, timingEnd } from './timing';
import { getRegion } from '../common/worldMap';
import { logger } from './logger';
import { EntityFlags } from '../common/interfaces';
import { REGION_SIZE } from '../common/constants';
let updatesBuffer = new ArrayBuffer(4096);
let updatesBufferOffset = 0;
export function resetEncodeUpdate() {
updatesBufferOffset = 0;
}
function resizeUpdatesBuffer(e: Error) {
if (isOverflowError(e)) {
updatesBuffer = new ArrayBuffer(updatesBuffer.byteLength * 2);
updatesBufferOffset = 0;
DEVELOPMENT && logger.debug(`resize buffer to ${updatesBuffer.byteLength} (${e.message})`);
} else {
throw e;
}
}
function createUpdatesWriter() {
const buffer = new Uint8Array(updatesBuffer, updatesBufferOffset, updatesBuffer.byteLength - updatesBufferOffset);
return createBinaryWriter(buffer);
}
function commitUpdatesWriter(writer: BinaryWriter) {
const result = getWriterBuffer(writer);
updatesBufferOffset += result.byteLength;
return result;
}
function encodeUpdate(region: ServerRegion): Uint8Array {
timingStart('encodeUpdate()');
let result: Uint8Array;
while (true) {
try {
const writer = createUpdatesWriter();
writeUpdate(writer, region);
result = commitUpdatesWriter(writer);
break;
} catch (e) {
resizeUpdatesBuffer(e);
}
}
timingEnd();
return result;
}
function encodeRegion(region: ServerRegion, client: IClient): Uint8Array {
timingStart('encodeRegion()');
let result: Uint8Array;
while (true) {
try {
const writer = createUpdatesWriter();
writeRegion(writer, region, client);
result = commitUpdatesWriter(writer);
break;
} catch (e) {
resizeUpdatesBuffer(e);
}
}
timingEnd();
return result;
}
export function subscribeToRegionsInRange(client: IClient) {
timingStart('subscribeToRegionsInRange()');
const { map, camera } = client;
const maxX = clamp(Math.floor(toWorldX(camera.x + camera.w) / REGION_SIZE) + 1, 0, map.regionsX - 1);
const maxY = clamp(Math.floor(toWorldY(camera.y + camera.h) / REGION_SIZE) + 1, 0, map.regionsY - 1);
const minX = clamp(Math.floor(toWorldX(camera.x) / REGION_SIZE) - 1, 0, maxX);
const minY = clamp(Math.floor(toWorldY(camera.y) / REGION_SIZE) - 1, 0, maxY);
for (let y = minY; y <= maxY; y++) {
for (let x = minX; x <= maxX; x++) {
const region = getRegion(map, x, y);
if (isRectVisible(camera, region.subscribeBounds)) {
if (!isSubscribedToRegion(client, region)) {
timingStart('subscribeToRegion()');
region.clients.push(client);
client.regions.push(region);
client.subscribes.push(encodeRegion(region, client));
timingEnd();
}
}
}
}
timingEnd();
}
export function unsubscribeFromOutOfRangeRegions(client: IClient) {
timingStart('unsubscribeFromOutOfRangeRegions()');
const regions = client.regions;
for (let i = regions.length - 1; i >= 0; i--) {
const region = regions[i];
if (!isRectVisible(client.camera, region.unsubscribeBounds)) {
if (includes(region.entities, client.pony)) {
DEVELOPMENT && logger.warn(`Trying to unsubscribe client from region they are in`);
} else {
removeItem(region.clients, client);
regions.splice(i, 1);
client.unsubscribes.push(region.x, region.y);
}
}
}
timingEnd();
}
export function unsubscribeFromAllRegions(client: IClient, silent: boolean) {
for (const region of client.regions) {
removeItem(region.clients, client);
if (!silent) {
client.unsubscribes.push(region.x, region.y);
}
}
client.regions = [];
}
export function getExpectedRegion({ x, y, flags, region }: ServerEntity, map: ServerMap) {
if (region !== undefined && (flags & EntityFlags.Movable) !== 0 && pointInRect(x, y, region.boundsWithBorder)) {
return region;
} else {
const rx = clamp(Math.floor(x / REGION_SIZE), 0, map.regionsX - 1) | 0;
const ry = clamp(Math.floor(y / REGION_SIZE), 0, map.regionsY - 1) | 0;
return map.regions[(rx + ((ry * map.regionsX) | 0)) | 0];
}
}
export function updateRegion(entity: ServerEntity, map: ServerMap) {
const expectedRegion = getExpectedRegion(entity, map);
if (expectedRegion !== entity.region) {
transferToRegion(entity, expectedRegion, map);
}
}
const moves: { entity: ServerEntity, region: ServerRegion; map: ServerMap; }[] = [];
export function updateRegions(maps: ServerMap[]) {
timingStart('updateRegions()');
moves.length = 0;
// TODO: only update changed entities
timingStart('getExpectedRegion');
for (const map of maps) {
for (const region of map.regions) {
for (const entity of region.movables) {
const expectedRegion = getExpectedRegion(entity, map);
if (expectedRegion !== entity.region) {
moves.push({ entity, region: expectedRegion, map });
}
}
}
}
timingEnd();
timingStart('transferToRegion');
for (const { entity, region, map } of moves) {
transferToRegion(entity, region, map);
}
timingEnd();
moves.length = 0;
timingEnd();
}
export function commitRegionUpdates(regions: ServerRegion[]) {
timingStart('commitRegionUpdates()');
for (const region of regions) {
if (region.entityUpdates.length || region.entityRemoves.length || region.tileUpdates.length) {
if (region.clients.length) {
const data = encodeUpdate(region);
for (const client of region.clients) {
client.regionUpdates.push(data);
}
}
resetRegionUpdates(region);
}
}
timingEnd();
}
export function transferToRegion(entity: ServerEntity, region: ServerRegion, map: ServerMap) {
const oldRegion = entity.region;
if (oldRegion) {
removeEntityFromRegion(oldRegion, entity, map);
updateEntity(entity, true);
}
entity.region = region;
addEntityToRegion(region, entity, map);
if (!isEntityShadowed(entity)) {
for (const client of region.clients) {
if (!oldRegion || !isSubscribedToRegion(client, oldRegion)) {
pushAddEntityToClient(client, entity);
}
}
}
}
export function addToRegion(entity: ServerEntity, region: ServerRegion, map: ServerMap) {
entity.region = region;
addEntityToRegion(region, entity, map);
if (isEntityShadowed(entity)) {
pushAddEntityToClient(entity.client, entity);
} else {
for (const client of region.clients) {
pushAddEntityToClient(client, entity);
}
}
}
export function removeFromRegion(entity: ServerEntity, region: ServerRegion, map: ServerMap) {
const removed = removeEntityFromRegion(region, entity, map);
pushRemoveEntityToRegion(region, entity);
return removed;
}
export function isSubscribedToRegion(client: IClient, region: ServerRegion) {
return includes(client.regions, region);
}
export function sparseRegionUpdate(map: ServerMap, region: ServerRegion, options: { restoreTerrain: boolean; }) {
if (options.restoreTerrain) {
tickTilesRestoration(map, region);
}
}
// timing helpers
function writingTiming() {
timingStart('write');
}
function sendingTiming() {
timingEnd();
timingStart('send');
}
function doneTiming() {
timingEnd();
}
function noop() {
}
export function setupTiming(client: any) {
if (client.__internalHooks) {
client.__internalHooks.writing = writingTiming;
client.__internalHooks.sending = sendingTiming;
client.__internalHooks.done = doneTiming;
}
}
export function clearTiming(client: any) {
if (client.__internalHooks) {
client.__internalHooks.writing = noop;
client.__internalHooks.sending = noop;
client.__internalHooks.done = noop;
}
}
+103
View File
@@ -0,0 +1,103 @@
import { Request } from 'express';
import { truncate } from 'lodash';
import { Reporter } from './serverInterfaces';
import { Event, IEvent, IOriginInfo, ID, IAccount } from './db';
import { logger, system } from './logger';
import { ServerConfig } from '../common/adminInterfaces';
import { getOrigin } from './originUtils';
const maxDescLength = 300;
/* istanbul ignore next */
const createLogEvent =
(config: ServerConfig) =>
(
account: ID | undefined, pony: ID | undefined, originInfo: IOriginInfo | undefined, type: string,
message: string, desc?: string
) => {
const server = config.id;
if (desc) {
desc = truncate(desc, { length: maxDescLength });
}
const origin = originInfo && { ip: originInfo.ip, country: originInfo.country };
Event.findOne({ server, account, pony, type, message, origin }).exec()
.then(event => {
if (event) {
if (!event.desc || (event.desc.length < maxDescLength && desc && event.desc.indexOf(desc) === -1)) {
event.desc = `${event.desc || ''}\n${desc || ''}`.trim();
}
return Event.updateOne({ _id: event._id }, { desc: event.desc, count: event.count + 1 }).exec();
} else {
return Event.create(<IEvent>{ server, account, pony, type, message, origin, desc });
}
})
.catch(logger.error);
return null;
};
const ignoreWarnings = ['Suspicious message', 'Spam'];
/* istanbul ignore next */
export function create(server: ServerConfig, account?: ID, pony?: ID, originInfo?: IOriginInfo): Reporter {
const logEvent = createLogEvent(server);
const accountId = `${account}`;
function log(type: string, message: string, desc?: string) {
logEvent(account, pony, originInfo, type, message, desc);
if (DEVELOPMENT) {
logger.debug('[event]', `[${type}]`, message);
}
}
return {
info(message: string, desc?: string) {
log('info', message, desc);
},
warn(message: string, desc?: string) {
log('warning', message, desc);
if (ignoreWarnings.indexOf(message) === -1) {
system(accountId, message);
}
},
warnLog(message: string) {
logger.warn(message);
},
danger(message: string, desc?: string) {
log('danger', message, desc);
logger.error(message, desc || '');
},
error(error: Error, desc?: string) {
log('danger', error.message, desc);
logger.error(error, desc || '');
},
system(message: string, desc?: string, logEvent = true) {
if (logEvent) {
log('info', message, desc);
}
system(accountId, message);
},
systemLog(message: string) {
system(accountId, message);
DEVELOPMENT && logger.log(message);
},
setPony(newPony: any) {
pony = newPony;
},
};
}
/* istanbul ignore next */
export function createFromRequest(server: ServerConfig, req: Request, pony?: any) {
const user = req && req.user as IAccount | undefined;
const account = user ? user.id : undefined;
const origin = req ? getOrigin(req) : undefined;
return create(server, account, pony, origin);
}
+100
View File
@@ -0,0 +1,100 @@
import { HOUR } from '../common/constants';
import { isMuted } from '../common/adminUtils';
import { fromNow } from '../common/utils';
import { isNew } from './accountUtils';
import { ReportAccount, TimeoutAccount, ReportInviteLimit, OnSuspiciousMessage, OnMessageSettings } from './serverInterfaces';
import { CounterService } from './services/counter';
import { handlePromiseDefault } from './serverUtils';
import { Suspicious } from '../common/adminInterfaces';
type Counter = CounterService<string>;
export const SPAM_TIMEOUT = 1 * HOUR;
export const SWEAR_TIMEOUT = 10 * HOUR;
export const FORBIDDEN_TIMEOUT = 1 * HOUR;
export const createReportSuspicious =
(counter: Counter): OnSuspiciousMessage =>
(client, message, suspicious) => {
const { accountId, account, reporter, shadowed } = client;
const limit = 5;
const { count, items } = counter.add(accountId, message);
if (count > limit || suspicious === Suspicious.Very) {
const msg = items.join('\n');
counter.remove(accountId);
if (!(isMuted(account) || shadowed)) {
reporter.warn('Suspicious messages', msg);
}
}
};
export const createReportSwears =
(
counter: Counter, reportSwearing: ReportAccount, timeoutAccount: TimeoutAccount, handlePromise = handlePromiseDefault,
): OnMessageSettings =>
(client, message, settings) => {
const { accountId, account, reporter, shadowed } = client;
const limit = 5; // isNew ? 3 : 6;
const { count, items } = counter.add(accountId, message);
if (count > limit) {
const msg = items.join('\n');
const timeout = settings.autoBanSwearing && !(isMuted(account) || shadowed);
const duration = SWEAR_TIMEOUT * (settings.doubleTimeouts ? 2 : 1);
counter.remove(accountId);
handlePromise(Promise.resolve()
.then(() => reportSwearing(accountId))
.then(() => timeout ? timeoutAccount(accountId, fromNow(duration), 'Timed out for swearing') : undefined)
.then(() => {
if (timeout) {
reporter.system('Timed out for swearing', msg, !!settings.reportSwears);
} else if (!(isMuted(account) || shadowed)) {
reporter.warn('Swearing', msg);
}
}), reporter.error);
}
};
export const createReportForbidden =
(
counter: Counter, timeoutAccount: TimeoutAccount, handlePromise = handlePromiseDefault
): OnMessageSettings =>
(client, message, settings) => {
const { accountId, account, reporter, shadowed } = client;
const newAccount = isNew(account);
const limit = newAccount ? 5 : 10;
const { count, items } = counter.add(accountId, message);
const mutedOrShadowed = isMuted(account) || shadowed;
if (!mutedOrShadowed) {
if (count >= limit) {
const msg = items.join('\n');
const duration = FORBIDDEN_TIMEOUT * (settings.doubleTimeouts ? 2 : 1);
counter.remove(accountId);
if (newAccount || settings.autoBanSwearing) {
handlePromise(timeoutAccount(accountId, fromNow(duration))
.then(() => reporter.system('Timed out for forbidden messages', msg)), reporter.error);
} else {
reporter.warn('Forbidden messages', msg);
}
}
}
};
export const reportInviteLimit =
(
reportInviteLimitAccount: (account: string) => Promise<number>, message: string, handlePromise = handlePromiseDefault
): ReportInviteLimit =>
({ accountId, reporter }) =>
handlePromise(reportInviteLimitAccount(accountId)
.then(count => {
reporter.systemLog(message);
if (count % 10 === 0) {
reporter.warn(`${message} (${count})`);
}
}), reporter.error);
+217
View File
@@ -0,0 +1,217 @@
import { Request, Response, NextFunction, RequestHandler } from 'express';
import * as fs from 'fs';
import * as path from 'path';
import * as moment from 'moment';
import * as ExpressBrute from 'express-brute';
import { noop } from 'lodash';
import { HASH } from '../generated/hash';
import { isAdmin } from '../common/accountUtils';
import { ACCOUNT_ERROR, VERSION_ERROR, OFFLINE_ERROR } from '../common/errors';
import { logger } from './logger';
import { createFromRequest } from './reporter';
import { AppConfig } from './config';
import { isUserError, reportUserError } from './userError';
import { Settings, ServerConfig } from '../common/adminInterfaces';
import { getIP } from './originUtils';
import { IAccount } from './db';
const ROLLBAR_IP = '35.184.69.251';
export const notFound: RequestHandler = (_, res) => {
res.setHeader('Cache-Control', 'public, max-age=0');
res.sendStatus(404);
};
export const validAccount = (server: ServerConfig): RequestHandler => (req, res, next) => {
const account = req.user as IAccount | undefined;
const accountId = req.body.accountId as string;
const accountName = req.body.accountName as string;
if (!account || account.id !== accountId) {
if (!/#$/.test(accountId)) {
createFromRequest(server, req).warn(ACCOUNT_ERROR, `${accountName} [${accountId}] (${req.path})`);
}
//logger.warn(ACCOUNT_ERROR, `${accountName} [${accountId}] (${req.path})`);
res.status(403).json({ error: ACCOUNT_ERROR });
} else {
next(null);
}
};
export const blockMaps = (debug: boolean, local: boolean): RequestHandler => (req, res, next) => {
if (!debug && !local && /\.map$/.test(req.path) && getIP(req) !== ROLLBAR_IP) {
res.sendStatus(404);
} else {
next(null);
}
};
export const hash: RequestHandler = (req, res, next) => {
const apiVersion = req.get('api-version');
if (apiVersion !== HASH) {
res.status(400).json({ error: VERSION_ERROR });
} else {
next(null);
}
};
export const offline = (settings: Settings): RequestHandler => (_req, res, next) => {
if (settings.isPageOffline) {
res.status(503).send(OFFLINE_ERROR);
} else {
next(null);
}
};
export const internal = (config: AppConfig, server: ServerConfig): RequestHandler => (req, res, next) => {
if (req.get('api-token') === config.token) {
next(null);
} else {
createFromRequest(server, req).warn('Unauthorized internal api call', req.originalUrl);
res.sendStatus(403);
}
};
export const auth: RequestHandler = (req, res, next) => {
if (req.isAuthenticated()) {
next(null);
} else {
//createFromRequest(req).warn('Unauthorized access', req.originalUrl);
res.setHeader('X-Robots-Tag', 'noindex');
res.sendStatus(403);
}
};
export const admin = (server: ServerConfig): RequestHandler => (req, res, next) => {
if (req.isAuthenticated() && req.user && isAdmin(req.user)) {
next(null);
} else {
if (!/Googlebot/.test(req.get('User-Agent')!)) {
createFromRequest(server, req).warn(`Unauthorized access (admin)`, req.originalUrl);
}
res.setHeader('X-Robots-Tag', 'noindex, nofollow');
res.sendStatus(403);
}
};
const store = new ExpressBrute.MemoryStore();
export function limit(freeRetries: number, lifetime: number) {
const options: any = {
freeRetries,
lifetime,
failCallback(req: Request, res: Response, _next: NextFunction, nextValidRequestDate: any) {
logger.warn(`rate limit ${req.url} ${req.ip}`);
res.status(429).send(`Too many requests, please try again ${moment(nextValidRequestDate).fromNow()}`);
}
};
return (new (ExpressBrute as any)(store, options)).prevent;
}
function reportError(e: Error, server: ServerConfig, req: Request) {
createFromRequest(server, req).danger(`Req error: ${e.message}`, `${req.method.toUpperCase()} ${req.originalUrl}`);
logger.error(e);
}
export function handleError(server: ServerConfig, req: Request, res: Response) {
return (e: Error) => {
if (isUserError(e)) {
reportUserError(e, server, req);
res.status(422).json({ error: e.message, userError: true });
} else {
reportError(e, server, req);
res.status(500).json({ error: 'Error occurred' });
}
};
}
let logRequest: (req: Request, result: any, url?: string) => void = noop;
export function initLogRequest(func: typeof logRequest) {
logRequest = func;
}
export function handleJSON(server: ServerConfig, req: Request, res: Response, result: any): any {
Promise.resolve(result)
.then(result => {
res.header('Cache-Control', 'private, no-cache, no-store, must-revalidate, max-age=0');
res.json(result);
return result;
})
.then(result => logRequest(req, result))
.catch(handleError(server, req, res));
}
export function wrap(server: ServerConfig, handle: (req: Request) => any): RequestHandler {
return (req, res) => handleJSON(server, req, res, handle(req));
}
export function wrapApi(server: ServerConfig, api: any) {
return wrap(server, ({ body: { method, args = [] } }) => {
if (api[method]) {
return api[method](...args);
} else {
return Promise.reject(new Error(`Invalid method (${method})`));
}
});
}
interface StaticFile {
buffer: Buffer;
mimeType: string;
}
function readFiles(files: Map<string, StaticFile>, dir: string, url: string) {
const mimeTypes: any = {
'.js': 'application/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.png': 'image/png',
'.jpg': 'image/jpeg',
};
for (const file of fs.readdirSync(dir)) {
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory()) {
readFiles(files, filePath, `${url}/${file}`);
} else {
const ext = path.extname(file);
const mimeType = mimeTypes[ext];
if (mimeType) {
const buffer = fs.readFileSync(filePath);
files.set(`${url}/${file}`, { mimeType, buffer });
}
}
}
}
export function inMemoryStaticFiles(assetsPath: string, assetsUrl: string, maxAge: number): RequestHandler {
const staticFiles = new Map<string, StaticFile>();
const cacheControl = `public, max-age=${Math.floor(maxAge / 1000)}`;
readFiles(staticFiles, assetsPath, assetsUrl);
return (req, res, next) => {
if (req.method !== 'GET' && req.method !== 'HEAD') {
return next();
}
const staticFile = staticFiles.get(req.path);
if (!staticFile) {
return next();
}
try {
res.setHeader('Content-Type', staticFile.mimeType);
res.setHeader('Cache-Control', cacheControl);
res.status(200).end(staticFile.buffer);
} catch (e) {
next(e);
}
};
}
+36
View File
@@ -0,0 +1,36 @@
import { Router } from 'express';
import { offline as createOffline, validAccount as createValidAccount, hash, wrap, limit } from '../requestUtils';
import {
createUpdateAccount, createRemoveSite, createUpdateSettings, createGetAccountCharacters, removeHide, getHides, getFriends
} from '../api/account';
import { findAccountSafe, findAuth, findAllCharacters, countAllVisibleAuths, IAccount } from '../db';
import { system } from '../logger';
import { Settings, ServerConfig } from '../../common/adminInterfaces';
export default function (server: ServerConfig, settings: Settings) {
const validAccount = createValidAccount(server);
const offline = createOffline(settings);
const app = Router();
const getAccountCharacters = createGetAccountCharacters(findAllCharacters);
const updateAccount = createUpdateAccount(findAccountSafe, system);
const updateSettings = createUpdateSettings(findAccountSafe);
const removeSite = createRemoveSite(findAuth, countAllVisibleAuths, system);
app.post('/account-characters', offline, hash, validAccount, limit(60, 60), wrap(server, req =>
getAccountCharacters(req.user as IAccount)));
app.post('/account-update', offline, hash, validAccount, limit(60, 60), wrap(server, req =>
updateAccount(req.user as IAccount, req.body.account)));
app.post('/account-settings', offline, hash, validAccount, limit(60, 60), wrap(server, req =>
updateSettings(req.user as IAccount, req.body.settings)));
app.post('/remove-site', offline, hash, validAccount, limit(60, 60), wrap(server, req =>
removeSite(req.user as IAccount, req.body.siteId)));
app.post('/remove-hide', offline, hash, validAccount, limit(60, 60), wrap(server, req =>
removeHide(req.user as IAccount, req.body.hideId)));
app.post('/get-hides', offline, hash, validAccount, limit(60, 60), wrap(server, req =>
getHides(req.user as IAccount, req.body.page || 0)));
app.post('/get-friends', offline, hash, validAccount, limit(120, 60), wrap(server, req =>
getFriends(req.user as IAccount)));
return app;
}
+35
View File
@@ -0,0 +1,35 @@
import { Router } from 'express';
import { limit, offline as createOffline, wrap, validAccount as createValidAccount, hash } from '../requestUtils';
import { createJoinGame, Config } from '../api/game';
import { findServer } from '../internal';
import { findCharacter, hasActiveSupporterInvites, IAccount } from '../db';
import { createJoin } from '../internal';
import { Settings, ServerConfig } from '../../common/adminInterfaces';
import { getOrigin, addOrigin } from '../originUtils';
export default function (server: ServerConfig, settings: Settings, config: Config) {
const offline = createOffline(settings);
const validAccount = createValidAccount(server);
const join = createJoin();
const app = Router();
let inQueue = 0;
const joinGame = createJoinGame(findServer, config, findCharacter, join, addOrigin, hasActiveSupporterInvites);
app.post('/game/join', offline, limit(60, 5 * 60), hash, validAccount, wrap(server, async req => {
if (inQueue > 100) {
return {};
} else {
try {
inQueue++;
const { ponyId, serverId, version, url, alert } = req.body;
return await joinGame(req.user as IAccount, ponyId, serverId, version, url, alert, getOrigin(req));
} finally {
inQueue--;
}
}
}));
return app;
}
+37
View File
@@ -0,0 +1,37 @@
import { Router } from 'express';
import { offline as createOffline, validAccount as createValidAccount, hash, wrap } from '../requestUtils';
import { createFromRequest } from '../reporter';
import { createSavePony, createRemovePony } from '../api/pony';
import { findAuth, findCharacter, characterCount, createCharacter, removeCharacter, IAccount } from '../db';
import { updateCharacterCount } from '../accountUtils';
import { system } from '../logger';
import { kickFromAllServersByCharacter } from '../api/admin';
import { createIsSuspiciousName, createIsSuspiciousPony } from '../../common/security';
import { Settings, ServerConfig } from '../../common/adminInterfaces';
import { RemovedDocument } from '../internal';
import { logRemovedCharacter } from '../characterUtils';
export default function (server: ServerConfig, settings: Settings, removedDocument: RemovedDocument) {
const offline = createOffline(settings);
const validAccount = createValidAccount(server);
const app = Router();
const isSuspiciousName = createIsSuspiciousName(settings);
const isSuspiciousPony = createIsSuspiciousPony(settings);
const savePonyHandler = createSavePony(
findCharacter, findAuth, characterCount, updateCharacterCount, createCharacter, system,
isSuspiciousName, isSuspiciousPony);
const removePonyHandler = createRemovePony(
kickFromAllServersByCharacter, removeCharacter, updateCharacterCount,
id => removedDocument('ponies', id), logRemovedCharacter);
app.post('/pony/save', offline, hash, validAccount, wrap(server, req =>
savePonyHandler(req.user as IAccount, req.body.pony, createFromRequest(server, req))));
app.post('/pony/remove', offline, hash, validAccount, wrap(server, req =>
removePonyHandler(req.body.id, (req.user as IAccount).id)));
return app;
}
+95
View File
@@ -0,0 +1,95 @@
import { Router } from 'express';
import * as fs from 'fs';
import * as path from 'path';
import { repeat } from 'lodash';
import { randomString } from '../../common/stringUtils';
import { execAsync } from '../serverUtils';
import { offline as createOffline, handleJSON, auth } from '../requestUtils';
import * as paths from '../paths';
import { findAllCharacters, IAccount } from '../db';
import { createGetAccountCharacters } from '../api/account';
import { Settings, ServerConfig } from '../../common/adminInterfaces';
import { World } from '../world';
import { ToolsMapInfo } from '../../components/tools/tools-map/tools-map';
import { flatten } from '../../common/utils';
import { serializeMap } from '../serverMap';
export default function (server: ServerConfig, settings: Settings, world: World | undefined) {
const offline = createOffline(settings);
const app = Router();
app.use(auth);
app.get('/ponies', offline, (req, res) => {
handleJSON(server, req, res, createGetAccountCharacters(findAllCharacters)(req.user as IAccount));
});
app.get('/animation/:id', offline, (req, res) => {
const filePath = path.join(paths.store, req.params.id);
res.sendFile(filePath);
});
app.post('/animation', offline, (req, res) => {
const name = randomString(10);
const filePath = path.join(paths.store, name);
fs.writeFileAsync(filePath, req.body.animation, 'utf8')
.then(() => res.send({ name }));
});
app.post('/animation-gif', offline, (req, res) => {
const image: string = req.body.image;
const width: number = req.body.width || 80;
const height: number = req.body.height || 80;
const fps: number = req.body.fps || 24;
const remove: number = req.body.remove || 0;
const name = randomString(10);
const filePath = path.join(paths.store, name + '.png');
const header = 'data:image/gif;base64,';
const buffer = Buffer.from(image.substr(header.length), 'base64');
const magick = /^win/.test(process.platform) ? 'magick' : 'convert';
const command = `${magick} -dispose 3 -delay ${100 / fps} -loop 0 "${filePath}" -crop ${width}x${height} `
+ `+repage${repeat(' +delete', remove)} "${filePath.replace(/png$/, 'gif')}"`;
fs.writeFileAsync(filePath, buffer)
.then(() => execAsync(command))
.then(() => res.send({ name }));
});
app.get('/maps', offline, (_, res) => {
if (world) {
res.json(world.maps.map(m => m.id));
} else {
res.sendStatus(400);
}
});
app.get('/map', offline, (req, res) => {
if (world) {
const id = req.query.map || '';
const map = world.maps.find(m => m.id === id);
if (map) {
const mapInfo: ToolsMapInfo = {
...serializeMap(map),
defaultTile: map.defaultTile,
type: map.type,
info: {
season: world.season,
entities: flatten(map.regions.map(r => r.entities))
.map(({ type, x, y, order, id }) => ({ type, x, y, order, id })),
},
};
res.json(mapInfo);
return;
}
}
res.sendStatus(400);
});
return app;
}
+20
View File
@@ -0,0 +1,20 @@
import { Router } from 'express';
import { auth } from '../requestUtils';
import { Settings, ServerConfig } from '../../common/adminInterfaces';
import { RemovedDocument } from '../internal';
import { Config } from '../api/game';
import apiAccount from './api-account';
import apiPony from './api-pony';
import apiGame from './api-game';
export default function (server: ServerConfig, settings: Settings, config: Config, removedDocument: RemovedDocument) {
const app = Router();
app.use(auth);
app.use(apiAccount(server, settings));
app.use(apiPony(server, settings, removedDocument));
app.use(apiGame(server, settings, config));
return app;
}
+60
View File
@@ -0,0 +1,60 @@
import { Router } from 'express';
import { noop } from 'lodash';
import { findAllCharacters, findAllVisibleAuths, IAccount, Account } from '../db';
import { offline, hash, handleJSON } from '../requestUtils';
import { createGetAccountData } from '../api/account';
import { Settings, ServerConfig } from '../../common/adminInterfaces';
import { includes } from '../../common/utils';
const blockApps: string[] = [];
const MAX_CONCURRENT_REQUESTS = 100;
let requests = 0;
export default function (server: ServerConfig, settings: Settings) {
const app = Router();
const getAccountData = createGetAccountData(findAllCharacters, findAllVisibleAuths);
async function handleAccountRequest(account: IAccount, userAgent?: string, browserId?: string) {
if (requests < MAX_CONCURRENT_REQUESTS) {
requests++;
try {
const lastUserAgent = userAgent || account.lastUserAgent;
const lastBrowserId = browserId || account.lastBrowserId;
if ((lastUserAgent && account.lastUserAgent !== lastUserAgent) ||
(lastBrowserId && account.lastBrowserId !== lastBrowserId)) {
account.lastUserAgent = lastUserAgent;
account.lastBrowserId = lastBrowserId;
Account.updateOne({ _id: account._id }, { lastUserAgent, lastBrowserId }, noop);
}
return await getAccountData(account);
} finally {
requests--;
}
} else {
return { limit: true };
}
}
app.post('/account', offline(settings), hash, (req, res) => {
req.session!.touch();
let account = req.user as IAccount | undefined;
const browserId = req.get('Api-Bid');
const userAgent = req.get('User-Agent') || '';
const requestedWith = req.get('X-Requested-With');
const isWebViewUserAgent = /Chrome\/\d+\.0\.0\.0 Mobile|; wv\)/.test(userAgent);
const isWebView = requestedWith || isWebViewUserAgent;
if (!account || (settings.blockWebView && isWebView && includes(blockApps, requestedWith))) {
handleJSON(server, req, res, null);
} else {
handleJSON(server, req, res, handleAccountRequest(account, userAgent, browserId));
}
});
return app;
}
+71
View File
@@ -0,0 +1,71 @@
import { Router } from 'express';
import { GameStatus, ServerInfo, ServerInfoShort } from '../../common/interfaces';
import { InternalGameServerState, Settings, ServerLiveSettings } from '../../common/adminInterfaces';
import { offline } from '../requestUtils';
import { servers } from '../internal';
import { version } from '../config';
import { isServerOffline } from '../serverUtils';
import { StatsTracker } from '../stats';
import { MIN_ADULT_AGE } from '../../common/constants';
function isServerSafe(server: InternalGameServerState) {
return server.state.alert !== '18+';
}
function toServerState(server: InternalGameServerState): ServerInfo {
const { name, path, desc, flag, alert, online, settings, require, host } = server.state;
return {
id: server.id,
name,
path,
desc,
host,
flag,
alert,
dead: false,
online,
offline: isServerOffline(server),
filter: !!settings.filterSwears,
require,
};
}
function toServerStateShort(server: InternalGameServerState): ServerInfoShort {
return {
id: server.id,
online: server.state.online,
offline: isServerOffline(server),
};
}
function getGameStatus(
servers: InternalGameServerState[], live: ServerLiveSettings, short: boolean, age: number
): GameStatus {
const adult = age >= MIN_ADULT_AGE;
return {
version,
update: live.updating ? true : undefined,
servers: servers
.filter(s => isServerSafe(s) || adult)
.map(short ? toServerStateShort : toServerState),
};
}
export default function (settings: Settings, live: ServerLiveSettings, statsTracker: StatsTracker) {
const app = Router();
app.get('/game/status', offline(settings), (req, res) => {
const status = getGameStatus(servers, live, req.query.short === 'true', req.query.d | 0);
res.json(status);
statsTracker.logRequest(req, status);
});
app.post('/csp', offline(settings), (_, res) => {
//logger.warn('CSP report', getIPFromRequest(req), req.body['csp-report']);
res.sendStatus(200);
});
return app;
}
+317
View File
@@ -0,0 +1,317 @@
import { Router, Request, Response, RequestHandler } from 'express';
import { use, authenticate, AuthenticateOptions } from 'passport';
import { Strategy as LocalStrategy } from 'passport-local';
import { remove } from 'lodash';
import { MINUTE } from '../../common/constants';
import { fromNow, hasFlag, includes } from '../../common/utils';
import { BannedMuted, Settings, ServerConfig, AccountFlags, ServerLiveSettings } from '../../common/adminInterfaces';
import { Account, IAccount, Origin, IOrigin } from '../db';
import { limit, auth as authRequest, wrap } from '../requestUtils';
import { CreateAccountOptions, findOrCreateAccount, SuspiciousCheckers, getAccountAlertMessage } from '../accountUtils';
import { create, createFromRequest } from '../reporter';
import { logger, logServer, system } from '../logger';
import { providers, getProfile } from '../oauth';
import { accountChanged, RemovedDocument } from '../internal';
import { UserError, isUserError, reportUserError } from '../userError';
import { kickFromAllServers } from '../api/admin';
import { createIsSuspiciousName, createIsSuspiciousAuth } from '../../common/security';
import { isBanned, isActive } from '../../common/adminUtils';
import { mergeAccounts } from '../api/merge';
import { findOrCreateAuth } from '../authUtils';
import { getOriginFromHTTP, getOrigin, addOrigin } from '../originUtils';
import { Profile } from '../../common/interfaces';
interface MergeRequest {
accountId: string;
time: number;
}
const FRESH_ACCOUNT_TIME = 1 * MINUTE;
const mergeRequests: MergeRequest[] = [];
/* tslint:disable */
const ignoreErrors = [
'Service unavailable', // replacement for twitter HTTP error
'Internal error',
'User denied your request',
'Code was already redeemed.',
'Code is invalid or expired.',
'This authorization code has expired.',
'This authorization code has been used.',
'Failed to fetch user profile',
'Failed to obtain access token',
'Failed to find request token in session',
'User authorization failed: user is deactivated.',
'User authorization failed: user revoke access for this token.',
'Backend Error',
'TokenError',
'Bad Request',
'Rate limit exceeded',
`Sorry, this feature isn't available right now: An error occurred while processing this request. Please try again later.`,
'Przepraszamy, ta funkcja nie jest obecnie dostępna: Podczas przetwarzania żądania wystąpił błąd. Spróbuj ponownie później.',
'К сожалению, эта функция сейчас не доступна: Во время обработки запроса возникла ошибка. Пожалуйста, попробуйте еще раз позже',
'К сожалению, эта функция сейчас не доступна: Во время обработки запроса возникла ошибка. Пожалуйста, попробуйте еще раз позже.',
'Desculpe, esse recurso não está disponível no momento: Ocorreu um erro ao processar essa solicitação. Tente novamente mais tarde.',
'Esta aplicación no está disponible: La aplicación que intentas usar ya no está disponible o tiene el acceso restringido.',
'Xin lỗi, tính năng này không khả dụng ngay bây giờ: Đã xảy ra lỗi khi xử lý yêu cầu này. Vui lòng thử lại sau.',
'Lo sentimos, esta función no está disponible ahora: Ocurrió un error mientras se procesaba la solicitud. Vuelve a intentarlo más tarde.',
`The access token is invalid since the user hasn't engaged the app in longer than 90 days.`,
`Application Unavailable: The application you're trying to use is either no longer available or access is restricted.`,
'An unexpected error has occurred. Please retry your request later.',
'Code was invalid or expired. ',
'Internal server error: could not check access_token now, check later.',
'failed to fetch user profile',
'Failed to obtain request token',
'User canceled the Dialog flow',
'Internal Error',
'Bad Authentication data.',
'Diese Function ist vorübergehend nicht verfügbar',
'Diese Funktion ist vorübergehend nicht verfügbar',
'User authorization failed: no access_token passed.',
'Ungültiges Anfrage-Token.',
'Invalid Credentials',
'Invalid code.',
'Internal server error: Database problems, try later',
'An invalid Platform session was found.: An invalid Platform session was found.',
`Cannot read property 'id' of undefined`, // patreon error
'User Rate Limit Exceeded. Rate of requests for user exceed configured project quota. You may consider re-evaluating expected per-user traffic to the API and adjust project quota limits accordingly. You may monitor aggregate quota usage and adjust limits in the API Console: https://console.developers.google.com/apis/api/plus.googleapis.com/quotas?project=200390553857',
];
function kickCurrentUser(req: Request) {
const user = req.user as IAccount | undefined;
if (user) {
kickFromAllServers(user.id)
.catch(e => logger.error(e));
}
}
function logIn(req: Request, account: IAccount) {
return new Promise<void>((resolve, reject) => {
kickCurrentUser(req);
req.logIn(account, e => e ? reject(e) : resolve());
});
}
function isMerge(accountId: string) {
const minTime = fromNow(-10 * MINUTE).getTime();
remove(mergeRequests, r => r.time < minTime);
return mergeRequests.some(r => r.accountId === accountId);
}
function getIP(req: Request) {
return req.ip || req.ips[0];
}
function reportError(server: ServerConfig, message: string, e: Error, req: Request) {
createFromRequest(server, req).danger(message, e.toString());
logger.error(message, e);
}
function fixTwitterErrorMessage(message: string) {
return /^<!DOCTYPE html>/.test(message) ? 'Service unavailable' : message;
}
async function checkBanField(
server: ServerConfig, account: IAccount, field: keyof BannedMuted, message: string, origin: IOrigin
) {
if (isActive(origin[field]) && !isActive(account[field])) {
create(server, account._id, undefined, origin).warn(message);
account[field] = origin[field];
await account.save();
}
}
async function loginUser(server: ServerConfig, req: Request, res: Response, account: IAccount) {
const origin = await Origin.findOne({ ip: getIP(req) }).exec();
await addOrigin(account, getOrigin(req));
if (origin) {
await checkBanField(server, account, 'mute', 'Muted account by origin', origin);
await checkBanField(server, account, 'shadow', 'Shadowed account by origin', origin);
await checkBanField(server, account, 'ban', 'Banned account by origin', origin);
}
if (isBanned(account)) {
// const message = isTemporarilyBanned(account) ? `Account locked()` : 'Account locked';
throw new UserError('Account locked', undefined, getAccountAlertMessage(account));
}
await logIn(req, account);
await accountChanged(account._id.toString());
const isFresh = account.createdAt && account.createdAt.getTime() > fromNow(-FRESH_ACCOUNT_TIME).getTime();
res.redirect(isFresh ? '/account' : '/');
}
async function mergeUser(req: Request, res: Response, account: IAccount, removedDocument: RemovedDocument) {
const user = req.user as IAccount;
const userId = user._id.toString();
const accountId = account._id.toString();
remove(mergeRequests, r => r.accountId === userId);
if (userId !== accountId) {
await mergeAccounts(userId, accountId, 'by user', removedDocument, false);
}
res.redirect('/account?merged=true');
}
function handleErrorAndRedirect(
server: ServerConfig, url: string, message: string, e: Error, req: Request, res: Response
) {
if (isUserError(e)) {
reportUserError(e, server, req);
url += `?error=${encodeURIComponent(e.message)}`;
if (e.userInfo) {
url += `&alert=${encodeURIComponent(e.userInfo)}`;
}
} else {
reportError(server, `Auth error: ${message}`, e, req);
url += `?error=${encodeURIComponent(message)}`;
}
res.redirect(url);
}
async function handleAuth(
server: ServerConfig, live: ServerLiveSettings, removedDocument: RemovedDocument,
req: Request, res: Response, error: Error | null, account: IAccount | null,
) {
const user = req.user as IAccount | undefined;
const merge = isMerge(user && user.id);
try {
if (error) {
if (isUserError(error)) {
throw error;
}
const message = fixTwitterErrorMessage(error.message);
const ignore = includes(ignoreErrors, message);
throw new UserError(message, ignore ? undefined : { error, desc: `url: ${req.path}` });
}
if (!account) {
throw new UserError('No account');
}
if (merge && !hasFlag(account.flags, AccountFlags.BlockMerging)) {
if (live.shutdown) {
throw new Error(`Cannot merge while server is shutdown`);
}
await mergeUser(req, res, account, removedDocument);
} else {
await loginUser(server, req, res, account);
}
} catch (e) {
const message = merge ? 'Account merge error' : 'Authentication error';
handleErrorAndRedirect(server, merge ? '/account' : '/', message, e, req, res);
}
}
function createHandler(
server: ServerConfig, live: ServerLiveSettings, id: string, options: AuthenticateOptions,
removedDocument: RemovedDocument
): RequestHandler {
return (req, res, next) => {
const handler = authenticate(id, options, (error: Error | null, account: IAccount | null) =>
handleAuth(server, live, removedDocument, req, res, error, account));
return handler(req, res, next);
};
}
export function authRoutes(
host: string, server: ServerConfig, settings: Settings, live: ServerLiveSettings, mockLogin: boolean,
removedDocument: RemovedDocument
) {
const failureRedirect = `/?error=${encodeURIComponent('Authentication failed')}`;
const app = Router();
const checkers: SuspiciousCheckers = {
isSuspiciousName: createIsSuspiciousName(settings),
isSuspiciousAuth: createIsSuspiciousAuth(settings),
};
providers.filter(p => !!p.auth).forEach(({ id, strategy, auth, connectOnly, additionalOptions = {} }) => {
const callbackURL = `${host}auth/${id}/callback`;
const scope = id === 'patreon' ? ['users'] : ['email'];
const options = {
...additionalOptions,
...auth,
callbackURL,
includeEmail: true,
profileFields: ['id', 'displayName', 'name', 'emails'],
passReqToCallback: true,
};
async function signInOrSignUp(req: Request, profile: Profile) {
const user = req.user as IAccount | undefined;
const userId = user && user._id.toString();
const mergeAccount = (userId && isMerge(userId)) ? userId : undefined;
const createAccountOptions = createOptions(req, !!connectOnly, server, settings, checkers);
const auth = await findOrCreateAuth(profile, mergeAccount, createAccountOptions);
const account = await findOrCreateAccount(auth, profile, createAccountOptions);
const { ip, userAgent } = createAccountOptions;
system(account._id, `signed-in with "${auth.name}" [${auth._id}] [${ip}] [${userAgent}]`);
return account;
}
use(id, new strategy(options, (req, _accessToken, _refreshToken, oauthProfile, callback) => {
const profile = getProfile(id, oauthProfile);
signInOrSignUp(req, profile)
.then(account => {
callback(null, account);
})
.catch((error: Error) => {
logServer(`failed to sign-in ${JSON.stringify(profile)}`);
callback(error, null);
});
}));
app.get(`/${id}`, limit(120, 3600), createHandler(server, live, id, { scope, failureRedirect }, removedDocument));
app.get(`/${id}/callback`, limit(120, 3600), createHandler(server, live, id, { failureRedirect }, removedDocument));
app.get(`/${id}/merge`, limit(120, 3600), authRequest, (req, res) => {
const accountId = (req.user as IAccount)._id.toString();
mergeRequests.push({ accountId, time: Date.now() });
res.redirect(`/auth/${id}`);
});
});
app.post('/sign-out', wrap(server, req => {
kickCurrentUser(req);
req.logout();
return { success: true };
}));
if (mockLogin) {
use(new LocalStrategy((login, _pass, done) => Account.findById(login, done)));
app.get('/local', authenticate('local', { successRedirect: '/', failureRedirect: '/failed-login' }));
}
return app;
}
function createOptions(
req: Request, connectOnly: boolean, server: ServerConfig, settings: Settings, checkers: SuspiciousCheckers
): CreateAccountOptions {
const acl = req.cookies && req.cookies.acl;
const origin = getOriginFromHTTP(req);
return {
ip: getIP(req),
userAgent: req.get('User-Agent'),
browserId: req.get('Api-Bid'),
connectOnly: !!connectOnly,
creationLocked: acl && acl > (new Date()).toISOString(),
canCreateAccounts: !!settings.canCreateAccounts,
reportPotentialDuplicates: !!settings.reportPotentialDuplicates,
warn: (accountId, message, desc) => create(server, accountId, undefined, origin).warn(message, desc),
...checkers,
};
}
+167
View File
@@ -0,0 +1,167 @@
import * as fs from 'fs';
import * as path from 'path';
import { compileFile } from 'pug';
import { RequestHandler } from 'express';
import { ClientOptions, Server, writeObject } from 'ag-sockets';
import { OAuthProvider } from '../../common/interfaces';
import { providers, OAuthProviderInfo } from '../oauth';
import { config, version, description } from '../config';
import { TokenData } from '../serverInterfaces';
import { logger } from '../logger';
import { pathTo } from '../paths';
import { writeBinary } from '../../common/binaryUtils';
interface RevFile {
name: string;
path: string;
url: string;
}
interface PageOptions {
isPublic?: boolean;
production: boolean;
base: string;
assets?: string;
style: string;
script: string;
scriptES: string;
token?: string;
noindex?: boolean;
socketOptions?: ClientOptions;
webpack?: boolean;
local?: boolean;
}
function getFiles(urlBase: string, dir: string, sub: string): RevFile[] {
try {
return fs.readdirSync(path.join(dir, sub))
.filter(file => /\.(js|css|png)$/.test(file))
.map(file => ({
name: file.replace(/-[a-f0-9]{10}\.(js|css|png)$/, '.$1'),
path: path.join(dir, sub, file),
url: `${urlBase}/${sub}/${file}`,
}));
} catch {
return [];
}
}
export function createIndex(assetsPath: string, adminAssetsPath: string) {
function toOAuthProvider({ id, name, color, auth, connectOnly }: OAuthProviderInfo): OAuthProvider {
return { id, name, color, disabled: auth ? undefined : true, connectOnly };
}
const revServer = new Map<string, RevFile>();
[
...getFiles('assets', assetsPath, 'styles'),
...getFiles('assets', assetsPath, 'scripts'),
...getFiles('assets', assetsPath, 'images'),
...getFiles('assets-admin', adminAssetsPath, 'styles'),
...getFiles('assets-admin', adminAssetsPath, 'scripts'),
].forEach(file => revServer.set(file.name, file));
function revUrlGetter(dir: string) {
return (name: string) => {
const file = revServer.get(name);
return file && file.url || `assets/${dir}/${name}`;
};
}
function getRevPath(name: string) {
return (revServer.get(name) && revServer.get(name)!.path) || path.join(assetsPath, name);
}
const getRevScriptURL = revUrlGetter('scripts');
const getRevStyleURL = revUrlGetter('styles');
const getRevImageURL = revUrlGetter('images');
const template = compileFile(pathTo('views', 'index.pug'));
const inlineStyle = fs.readFileSync(getRevPath('style-inline.css'), 'utf8');
const loadingImage = fs.readFileSync(getRevPath('logo-gray.png'));
const oauthProviders = providers.map(toOAuthProvider);
function encodeSocketOptions(options: ClientOptions | undefined): string {
if (options) {
const data = writeBinary(writer => writeObject(writer, options));
const buffer = Buffer.from(data);
return buffer.toString('base64');
} else {
return '';
}
}
function renderPage(
{ isPublic, style, script, scriptES, production, noindex, base, socketOptions, token, local }: PageOptions
) {
return template({
doctype: 'html',
host: config.host,
title: config.title,
twitterLink: config.twitterLink,
supporterLink: config.supporterLink,
email: config.contactEmail,
logo: `${config.host}${getRevImageURL('logo-120.png')}`,
loadingImage: `data:image/png;base64,${loadingImage.toString('base64')}`,
version,
description,
base,
token,
sw: config.sw ? 'true' : undefined,
noindex: noindex || config.noindex,
production,
local: local ? 'true' : undefined,
socketOptions: encodeSocketOptions(socketOptions),
inlineStyle,
style,
script,
scriptES,
oauthProviders,
facebookAppId: config.facebookAppId,
isPublic: isPublic ? 'true' : undefined,
});
}
function admin(
production: boolean, base: string, assetsBase: string, scriptName: string, socket: Server
): RequestHandler {
const socketOptions = socket.options();
const style = `${assetsBase}/${getRevStyleURL('style-admin.css')}`;
const script = `${assetsBase}/${getRevScriptURL(scriptName)}`;
const scriptES = script;
return (req, res) => {
try {
const token = socket.token({ account: req.user } as TokenData);
res.send(renderPage({ production, base, style, script, scriptES, noindex: true, socketOptions, token }));
} catch (e) {
logger.error(e);
res.sendStatus(500);
}
};
}
function user(
production: boolean, base: string, styleName: string, scriptName: string, scriptESName: string,
socketOptions: ClientOptions | undefined, noindex: boolean, local: boolean, isPublic: boolean,
) {
const style = `/${getRevStyleURL(styleName)}`;
const script = `/${getRevScriptURL(scriptName)}`;
const scriptES = `/${getRevScriptURL(scriptESName)}`;
const sprites1 = DEVELOPMENT ? `/assets/images/pony.png` : `/${getRevImageURL('pony.png')}`;
const sprites2 = DEVELOPMENT ? `/assets/images/pony2.png` : `/${getRevImageURL('pony2.png')}`;
const page = renderPage({ isPublic, production, base, style, script, scriptES, socketOptions, noindex, local });
const preload = [
`<${script}>; rel=preload; as=script`,
`<${style}>; rel=preload; as=style`,
`<${sprites1}>; rel=preload; as=fetch; crossorigin`,
`<${sprites2}>; rel=preload; as=fetch; crossorigin`,
];
return { page, preload };
}
return { admin, user, getRevScript: getRevScriptURL, getRevStyle: getRevStyleURL };
}
+420
View File
@@ -0,0 +1,420 @@
import './boot';
import * as fs from 'fs';
import * as Bluebird from 'bluebird';
import * as mongoose from 'mongoose';
import * as http from 'http';
import * as morgan from 'morgan';
import * as bodyParser from 'body-parser';
import * as expressSession from 'express-session';
import * as serveFavicon from 'serve-favicon';
import * as Rollbar from 'rollbar';
import * as passport from 'passport';
import * as connectMongo from 'connect-mongo';
import * as express from 'express';
// import { WebSocketServer } from '@clusterws/cws';
import { WebSocketServer } from 'clusterws-uws';
import { compact, once } from 'lodash';
import { copySync, removeSync, ensureDirSync } from 'fs-extra';
import { createServerHost, createClientOptions, ServerOptions, ClientExtensions, Packet } from 'ag-sockets';
import { config, port, server, args, version } from './config';
import { YEAR, WEEK } from '../common/constants';
import { rollbarCheckIgnore } from '../common/rollbar';
import { isBanned } from '../common/adminUtils';
import { includes } from '../common/utils';
import { STAMP } from '../generated/hash';
import { ClientActions } from '../client/clientActions';
import { ClientAdminActions } from '../client/clientAdminActions';
import { ServerActions } from './serverActions';
import { AdminServerActions } from './adminServerActions';
import { IAccount, Account } from './db';
import { logger } from './logger';
import { settings, reloadSettings } from './settings';
import { SocketErrorHandler } from './utils/socketErrorHandler';
import { tokenService } from './serverUtils';
import {
admin as isAdmin, auth, blockMaps, wrapApi, internal, initLogRequest, notFound, inMemoryStaticFiles
} from './requestUtils';
import { StatsTracker } from './stats';
import { start } from './start';
import { createServerActionsFactory } from './serverActionsManager';
import { init, createRemovedDocument } from './internal';
import {
pollServers, pollDiskSpace, pollCertificateExpirationDate, pollPatreon, startBansCleanup, pollMemoryUsage,
startMergesCleanup, startStrayAuthsCleanup, startClearOldIgnores, startCollectingUsersVisitedCount,
startSupporterInvitesCleanup, startPotentialDuplicatesCleanup, startAccountAlertsCleanup, startUpdatePastSupporters,
startClearTo10Origns, startClearVeryOldOrigns
} from './polling';
import { pathTo } from './paths';
import { liveSettings } from './liveSettings';
import { createIndex } from './routes/index';
import { authRoutes } from './routes/auth';
import api from './routes/api';
import api1 from './routes/api1';
import api2 from './routes/api2';
import apiTools from './routes/api-tools';
import { createInternalApi } from './api/internal';
import { createInternalLoginApi } from './api/internal-login';
import { initLogSwearingAndSpamming } from './api/admin-accounts';
import { InternalAdminApi } from './api/internal-admin';
import { AdminService } from './services/adminService';
import { createEndPoints } from './api/admin';
import { World } from './world';
function getServiceWorker() {
try {
return fs.readFileSync(pathTo('build', 'sw.min.js'));
} catch {
return '';
}
}
mongoose.connect(config.db, {
reconnectTries: Number.MAX_VALUE,
useNewUrlParser: true,
useCreateIndex: true,
useFindAndModify: false,
});
const MongoStore = connectMongo(expressSession);
const app = express();
const production = app.get('env') === 'production';
const maxAge = production ? YEAR : 0;
const etag = false;
const limit = !production || args.tools ? '100mb' : '100kb';
Bluebird.config({ warnings: false, longStackTraces: !production });
const rollbar = config.rollbar && Rollbar.init({
accessToken: config.rollbar.serverToken,
environment: config.rollbar.environment,
handleUncaughtExceptions: true,
handleUnhandledRejections: true,
captureUncaught: true,
checkIgnore: rollbarCheckIgnore,
} as any);
let assetsPath = pathTo('build', 'assets');
let adminAssetsPath = pathTo('build', 'assets-admin');
ensureDirSync(pathTo('build-copy'));
if (production && args.login) {
const newAssetsPath = pathTo('build-copy', 'assets');
removeSync(newAssetsPath);
copySync(assetsPath, newAssetsPath);
assetsPath = newAssetsPath;
}
if (production && args.admin) {
const newAssetsPath = pathTo('build-copy', 'assets-admin');
removeSync(newAssetsPath);
copySync(adminAssetsPath, newAssetsPath);
adminAssetsPath = newAssetsPath;
}
app.set('port', port);
app.set('views', pathTo('views'));
app.set('view engine', 'pug');
app.set('view options', { doctype: 'html' });
app.set('x-powered-by', false);
app.set('etag', false);
if (config.proxy) {
app.set('trust proxy', config.proxy);
}
if (production) {
app.use(require('hsts')({ maxAge }));
app.use(require('frameguard')({ action: 'sameorigin' }));
// app.use(require('shrink-ray-current')());
}
if (args.login || args.admin) {
app.use(serveFavicon(pathTo('favicons', 'favicon.ico')));
}
app.use(morgan('dev', { skip: (_, res) => res.statusCode < 500 || res.statusCode === 503 }));
const serviceWorker = getServiceWorker();
if (serviceWorker) {
app.get('/sw.js', (_, res) => {
res.setHeader('Content-Type', 'application/javascript');
res.setHeader('Cache-Control', 'public, max-age=0');
res.send(serviceWorker);
});
} else {
app.get('/sw.js', notFound);
}
if (args.login || args.admin) {
if (production) {
app.use(inMemoryStaticFiles(assetsPath, '/assets', maxAge));
}
app.use('/assets', blockMaps(DEVELOPMENT, !!args.local), express.static(assetsPath, { maxAge, etag }));
app.use(express.static(pathTo('public'), { maxAge, etag }));
app.use(express.static(pathTo('favicons'), { maxAge, etag }));
}
app.use(bodyParser.json({ type: ['json', 'application/csp-report'], limit }));
app.use(bodyParser.urlencoded({ extended: true, limit }));
app.use(require('cookie-parser')());
if (args.login || args.admin) {
passport.serializeUser<IAccount, string>((account, done) => done(null, account._id.toString()));
passport.deserializeUser<IAccount | false, string>((id, done) =>
Account.findById(id, (err, a) => done(err, a && !isBanned(a) ? a : false)));
}
const ignore = [
'RangeNotSatisfiableError',
'PreconditionFailedError',
];
app.use((err: any, req: any, res: express.Response, next: any) => {
const ignored = err instanceof Error && includes(ignore, err.name);
return next(ignored ? null : err, req, res);
});
if (rollbar) {
app.use(rollbar.errorHandler());
}
if (!production) {
app.use('/assets-admin', express.static(pathTo('assets')));
app.use('/assets-admin', express.static(pathTo('src')));
app.use('/assets', express.static(pathTo('assets')));
app.use('/assets', express.static(pathTo('src')));
app.use(require('errorhandler')());
}
const httpServer = http.createServer(app);
const errorHandler = new SocketErrorHandler(rollbar, server);
const createSession = () => expressSession({
secret: config.secret,
resave: false,
saveUninitialized: false,
cookie: {
maxAge: WEEK * 2,
},
store: new MongoStore({ mongooseConnection: mongoose.connection }),
});
const statsPath = pathTo('logs', `stats-${server.id}.csv`);
const stats = new StatsTracker(statsPath);
const sessionMiddlewares = once(() => [createSession(), passport.initialize(), passport.session()] as express.RequestHandler[]);
const adminMiddlewares = once(() => [...sessionMiddlewares(), isAdmin(server)]);
const socketOptionsBase: ServerOptions = {
ws: { Server: WebSocketServer },
hash: STAMP,
};
initLogRequest(stats.logRequest);
initLogSwearingAndSpamming(stats.logSwearing, stats.logSpamming);
const host = createServerHost(httpServer, {
path: args.standaloneadmin && !args.game ? '/admin/ws-admin' : server.path,
ws: { Server: WebSocketServer },
perMessageDeflate: false,
errorHandler,
});
let theWorld: World | undefined = undefined;
let sent = 0, received = 0;
let sentPackets = 0, receivedPackets = 0;
if (args.game) {
const getSettings = () => settings.servers[server.id] || {};
const { world, createServerActions, hiding } = createServerActionsFactory(
server, settings, getSettings, {
stats: () => {
const result = { sent, received, sentPackets, receivedPackets };
sent = 0;
received = 0;
sentPackets = 0;
receivedPackets = 0;
return result;
}
});
const options = {
...socketOptionsBase,
verifyClient: () => !getSettings().isServerOffline && !liveSettings.shutdown,
forceBinary: true,
onSend: (packet: Packet) => {
sent += packet.binary ? packet.binary.byteLength : (packet.json ? packet.json.length : 0);
sentPackets++;
stats.logSendStats(packet);
},
onRecv: (packet: Packet) => {
received += packet.binary ? packet.binary.byteLength : (packet.json ? packet.json.length : 0);
receivedPackets++;
stats.logRecvStats(packet);
},
};
const gameSocket = host.socket(ServerActions, ClientActions, createServerActions as any, options);
const tokens = tokenService(gameSocket);
start(world, server);
init(world, tokens);
theWorld = world;
const apiInternal = createInternalApi(
world, server, reloadSettings, getSettings, tokens, hiding, stats, liveSettings);
app.use('/api-internal', internal(config, server), wrapApi(server, apiInternal));
}
const endPoints = args.admin ? createEndPoints() : undefined;
const adminService = args.admin ? new AdminService() : undefined;
const removedDocument = createRemovedDocument(endPoints, adminService);
const index = createIndex(assetsPath, adminAssetsPath);
if (args.admin) {
if (args.standaloneadmin) {
app.use('/admin/assets-admin', ...adminMiddlewares(), express.static(adminAssetsPath, { maxAge, etag }));
app.get('/admin/assets-admin/*', (_, res) => res.sendStatus(404));
const adminApi = new InternalAdminApi(adminService!, endPoints!);
app.use('/api-internal-admin', internal(config, server), wrapApi(server, adminApi));
}
const createClient = (client: ClientAdminActions & ClientExtensions) =>
new AdminServerActions(client, server, settings, adminService!, endPoints!, removedDocument);
const base = '/admin';
const assetsBase = args.standaloneadmin ? '/admin' : '';
const adminSocket = host.socket(AdminServerActions, ClientAdminActions, createClient, socketOptionsBase);
const sendAdminPage = index.admin(production, `${base}/`, assetsBase, 'bootstrap-admin.js', adminSocket);
app.get(`${base}`, ...adminMiddlewares(), sendAdminPage);
app.get(`${base}/*`, ...adminMiddlewares(), sendAdminPage);
}
if (args.tools) {
const toolsPage = index.user(
production, '/tools/', 'style-tools.css', 'bootstrap-tools.js', 'bootstrap-tools.js', undefined, true, !!args.local, false);
app.get('/tools', ...sessionMiddlewares(), auth, (_, res) => res.send(toolsPage.page));
app.get('/tools/*', ...sessionMiddlewares(), auth, (_, res) => res.send(toolsPage.page));
app.use('/api-tools', ...sessionMiddlewares(), apiTools(server, settings, theWorld));
app.get('/api-tools/*', (_, res) => res.sendStatus(404));
}
if (args.login) {
const socketOptions = createClientOptions(ServerActions, ClientActions, socketOptionsBase);
const userPage = index.user(
production, '/', 'style.css', 'bootstrap.js', 'bootstrap-es.js', socketOptions, false, !!args.local, !production);
const offlinePage = fs.readFileSync(pathTo('public', 'offline.html'), 'utf8');
const script = `${config.host}${index.getRevScript('bootstrap.js')}`;
const scriptES = `${config.host}${index.getRevScript('bootstrap-es.js')}`;
const analytics = config.analytics ? 'https://www.google-analytics.com' : '';
// const workbox = 'https://storage.googleapis.com/workbox-cdn';
// const rollbarScripts =
// rollbar ? 'https://d37gvrvc0wt4s1.cloudfront.net https://cdnjs.cloudflare.com/ajax/libs/rollbar.js/' : '';
const csp = `object-src 'none';`
+ `frame-src 'self';`
+ `frame-ancestors 'self';`
+ `worker-src ${config.host}sw.js;`
+ `script-src 'unsafe-eval' ${script} ${scriptES} ${analytics};`
// + `report-uri /api2/csp`
;
const linkPreloads: string[] = [
...userPage.preload,
];
app.use('/assets-admin', ...adminMiddlewares(), express.static(adminAssetsPath, { maxAge, etag }));
app.use('/auth', ...sessionMiddlewares(), authRoutes(
config.host, server, settings, liveSettings, args.local || DEVELOPMENT, removedDocument));
app.use('/api', ...sessionMiddlewares(), api(
server, settings, { version, host: config.host, debug: DEVELOPMENT, local: !!args.local }, removedDocument));
app.use('/api1', ...sessionMiddlewares(), api1(server, settings));
app.use('/api2', api2(settings, liveSettings, stats));
const loginApi = createInternalLoginApi(settings, liveSettings, stats, reloadSettings, removedDocument);
app.use('/api-internal-login', internal(config, server), wrapApi(server, loginApi));
app.get('/assets-admin/*', notFound);
app.get('/assets/*', notFound);
app.get('/auth/*', notFound);
app.get('/api/*', notFound);
app.get('/api1/*', notFound);
app.get('/api2/*', notFound);
app.get('/*', (req, res) => {
if (settings.isPageOffline) {
res.send(offlinePage);
} else {
if (production && !args.local) {
res.setHeader('Content-Security-Policy', csp);
res.setHeader('Link', linkPreloads);
}
res.setHeader('Referrer-Policy', 'no-referrer');
// res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-XSS-Protection', '1; mode=block');
res.send(userPage.page);
stats.logRequest(req, userPage.page, '/');
}
});
}
app.use((err: any, req: any, res: express.Response, next: any) => {
if (err instanceof URIError) {
res.redirect(config.host);
} else {
return next(err, req, res);
}
});
reloadSettings().then(() => {
if (args.login || args.game) {
stats.startStatTracking();
}
if (args.login || args.admin) {
pollServers();
}
if (args.admin && !args.nocleanup) {
startStrayAuthsCleanup(removedDocument);
startClearOldIgnores();
startMergesCleanup();
startBansCleanup();
startCollectingUsersVisitedCount();
startSupporterInvitesCleanup();
startPotentialDuplicatesCleanup(adminService!);
startAccountAlertsCleanup();
startUpdatePastSupporters();
startClearTo10Origns(adminService!);
startClearVeryOldOrigns(adminService!);
pollPatreon(server, settings);
}
if (args.admin) {
pollDiskSpace();
pollMemoryUsage();
pollCertificateExpirationDate();
}
httpServer.listen(port, () => {
const options = compact([
app.get('env'),
args.login && 'login',
args.admin && 'admin',
args.standaloneadmin && '(standaloneadmin)',
args.tools && 'tools',
args.game && `game:${server.id}`,
]);
logger.info(`Listening on port ${port} (${options.join(', ')})`);
});
});
+762
View File
@@ -0,0 +1,762 @@
import { Socket, Method, SocketServer, Bin, getMethods } from 'ag-sockets';
import {
PlayerAction, ModAction, ChatType, PonyData, IServerActions, TileType, Action, EditorAction, Entity,
EntityOrPonyOptions, LeaveReason, SupporterInvite, InfoFlags, AccountSettings, FriendStatusFlags,
SelectFlags, UpdateFlags, isValidModTile, isValidTile, MapFlags, EntityState, houseTiles
} from '../common/interfaces';
import { CharacterState, ServerConfig } from '../common/adminInterfaces';
import { PARTY_LIMIT, OFFLINE_PONY, TILE_CHANGE_RANGE, MIN_HIDE_TIME, MAX_HIDE_TIME, PONY_TYPE } from '../common/constants';
import { toInt, formatDuration, includes, clamp, hasFlag, distanceXY, computeFriendsCRC } from '../common/utils';
import { banMessage } from '../common/adminUtils';
import * as entities from '../common/entities';
import { IClient, IgnorePlayer, FindClientByEntityId, AccountService, GetSettings } from './serverInterfaces';
import { getModInfo } from './accountUtils';
import { NotificationService } from './services/notification';
import { PartyService } from './services/party';
import { World, findClientByAccountId, findAllOnlineFriends } from './world';
import { HidingService } from './services/hiding';
import {
createCharacterState, interactWith, useHeldItem, setEntityExpression, getPlayerState, execAction,
updateEntityPlayerState
} from './playerUtils';
import { allEntities } from './api/account';
import { CounterService } from './services/counter';
import { decodeExpression, isCancellableExpression } from '../common/encoders/expressionEncoder';
import { updateEntity, pushUpdateEntityToClient, pushUpdateTileToClient } from './entityUtils';
import { SupporterInvitesService } from './services/supporterInvites';
import { Move } from './move';
import { logger } from './logger';
import { findFriends } from './db';
import { Say, saySystem } from './chat';
import { getTile } from '../common/worldMap';
import { updateRegion, getExpectedRegion } from './regionUtils';
import { findEntities } from './serverMap';
import { FriendsService, toFriendOnline } from './services/friends';
import { setupCamera } from '../common/camera';
import { swapCharacter } from './characterUtils';
import { isOutsideMap } from '../common/collision';
import { createAnEntity } from '../common/entities';
import { mockPaletteManager } from '../common/ponyInfo';
interface AddedEntity {
name: string;
entities: Entity[];
}
const modActionNames = ['None', 'Report', 'Mute', 'Shadow', 'Kick', 'Ban'];
const playerActionNames = [
'None',
'Ignore',
'Unignore',
'InviteToParty',
'RemoveFromParty',
'PromotePartyLeader',
'HidePlayer',
'InviteToSupporterServers',
'AddFriend',
'RemoveFriend',
];
const editorAdded = new Map<string, AddedEntity[]>();
const debugRate = DEVELOPMENT ? '1000/s' : '';
@Socket({
id: 'game',
debug: false,
connectionTokens: true,
pingInterval: 3000,
connectionTimeout: 10000,
reconnectTimeout: 500,
transferLimit: 4000,
perMessageDeflate: false,
keepOriginalRequest: true,
})
export class ServerActions implements IServerActions, SocketServer {
constructor(
private readonly client: IClient,
private readonly world: World,
private readonly notificationService: NotificationService,
private readonly partyService: PartyService,
private readonly supporterInvites: SupporterInvitesService,
private readonly getSettings: GetSettings,
private readonly server: ServerConfig,
private readonly chatSay: Say,
private readonly moveFunc: Move,
private readonly hiding: HidingService,
private readonly states: CounterService<CharacterState>,
private readonly accountService: AccountService,
private readonly ignorePlayer: IgnorePlayer,
private readonly findClientByEntityId: FindClientByEntityId,
private readonly friends: FriendsService,
) {
}
private get account() {
return this.client.account;
}
private get pony() {
return this.client.pony;
}
private get map() {
return this.client.map;
}
connected() {
this.client.connectedTime = Date.now();
this.client.lastPacket = Date.now();
this.client.loading = true;
this.client.reporter.systemLog(`joined [${this.server.id}] as "${this.client.characterName}" [${this.client.ip}]`);
if (DEVELOPMENT && /slow/.test(this.client.characterName)) {
setTimeout(() => this.world.joinClientToQueue(this.client), 5000);
} else {
this.world.joinClientToQueue(this.client);
}
}
async disconnected() {
const state = createCharacterState(this.pony, this.client.map);
const duration = Date.now() - this.client.connectedTime;
const leaveReason = this.client.leaveReason || 'disconnected';
if (this.client.logDisconnect) {
logger.warn(`disconnected (${leaveReason}) account: ${this.client.account.name} [${this.client.accountId}]`);
}
this.client.offline = true;
this.client.offlineAt = new Date();
this.client.reporter.systemLog(`left [${this.server.id}] (${leaveReason}) (${formatDuration(duration)})`);
this.world.leaveClient(this.client);
this.partyService.clientDisconnected(this.client);
this.friends.clientDisconnected(this.client);
this.states.add(this.client.characterId, state);
await Promise.all([
this.accountService.updateAccount(this.client.accountId, { lastVisit: new Date(), state: this.account.state }),
this.accountService.updateCharacterState(this.client.characterId, state),
]);
}
@Method({ rateLimit: '2/s', binary: [Bin.U32, Bin.Str, Bin.U8] })
say(entityId: number, text: string, chatType: ChatType) {
validateNumber(entityId, 'entityId');
validateString(text, 'text');
validateNumber(chatType, 'chatType');
this.updateLastAction();
if (this.client.isSwitchingMap)
return;
const target = entityId ? this.world.getEntityById(entityId) : undefined;
this.chatSay(this.client, text, chatType, target && target.client, this.getSettings());
}
@Method({ rateLimit: '3/s', binary: [Bin.U32, Bin.U8] })
select(entityId: number, flags: SelectFlags) {
validateNumber(entityId, 'entityId');
this.updateLastAction();
if (this.client.isSwitchingMap)
return;
const entity = entityId === 0 ? undefined : (this.world.getEntityById(entityId) || this.getEntityFromClients(entityId));
const mod = this.client.isMod;
this.client.selected = entity;
if (entity && entity.client && entity !== this.client.pony) {
if (flags) {
const baseOptions: Partial<EntityOrPonyOptions> = mod ? { modInfo: getModInfo(entity.client) } : {};
const options = { ...baseOptions, ...entity.extraOptions };
if (hasFlag(flags, SelectFlags.FetchInfo)) {
const playerState = getPlayerState(this.client, entity);
const flags = UpdateFlags.Options | UpdateFlags.Name | UpdateFlags.Info | UpdateFlags.PlayerState;
pushUpdateEntityToClient(this.client, { entity, flags, options, playerState });
} else if (hasFlag(flags, SelectFlags.FetchEx) || mod) {
pushUpdateEntityToClient(this.client, { entity, flags: UpdateFlags.Options, options });
}
}
} else if (entityId) {
this.client.updateSelection(entityId, 0);
}
}
@Method({ rateLimit: '2/s', serverRateLimit: '4/s', binary: [Bin.U32] })
interact(entityId: number) {
validateNumber(entityId, 'entityId');
this.updateLastAction();
if (this.client.isSwitchingMap)
return;
interactWith(this.client, this.world.getEntityById(entityId));
}
@Method({ rateLimit: '2/s', binary: [] })
use() {
this.updateLastAction();
if (this.client.isSwitchingMap)
return;
useHeldItem(this.client);
}
@Method({ rateLimit: '3/s', serverRateLimit: '8/s', binary: [Bin.U8] })
action(action: Action) {
validateNumber(action, 'action');
this.updateLastAction();
switch (action) {
case Action.KeepAlive:
break;
case Action.UnhideAllHiddenPlayers:
this.hiding.requestUnhideAll(this.client);
break;
default:
if (this.client.isSwitchingMap)
return;
execAction(this.client, action, this.getSettings());
break;
}
}
@Method({ rateLimit: '2/s', serverRateLimit: '4/s', binary: [Bin.U8, Bin.Obj] })
actionParam(action: Action, param: any) {
validateNumber(action, 'action');
switch (action) {
case Action.CancelSupporterInvite:
validateString(param, 'param');
// TODO: ...
break;
case Action.SwapCharacter: {
validateString(param, 'param');
this.updateLastAction();
if (param !== this.client.characterId) {
swapCharacter(this.client, this.world, { account: this.client.account._id, _id: param })
.catch(e => this.client.reporter.error(e));
}
break;
}
case Action.RemoveFriend: {
validateString(param, 'param');
this.updateLastAction();
const target = findClientByAccountId(this.world, param);
if (target) {
this.friends.remove(this.client, target);
} else {
this.friends.removeByAccountId(this.client, param);
}
break;
}
case Action.FriendsCRC: {
validateNumber(param, 'param');
const crc = param >>> 0;
if (this.client.friendsCRC === undefined) {
this.client.friendsCRC = computeFriendsCRC(Array.from(this.client.friends.values()));
}
if (this.client.friendsCRC !== crc) {
findFriends(this.client.accountId, true)
.then(friends => {
this.client.updateFriends(friends.map(f => {
const client = findClientByAccountId(this.world, f.accountId);
return {
accountId: f.accountId,
accountName: f.accountName,
entityId: client && client.pony.id,
status: client ? FriendStatusFlags.Online : FriendStatusFlags.None,
name: f.name,
nameBad: f.nameBad,
info: f.pony,
};
}), true);
})
.catch(e => logger.error(e));
}
break;
}
case Action.RemoveEntity: {
validateNumber(param, 'param');
this.updateLastAction();
const entity = this.world.getEntityById(param | 0);
if (
entity && hasFlag(entity.state, EntityState.Editable) && this.pony.options!.hold === entities.broom.type &&
this.map.regions.some(r => includes(r.entities, entity))
) {
if (this.isHouseLocked()) {
saySystem(this.client, `House is locked`);
} else {
this.world.removeEntity(entity, this.map);
}
}
break;
}
case Action.PlaceEntity: {
if (
!param || typeof param !== 'object' || typeof param.x !== 'number' || typeof param.y !== 'number' ||
typeof param.type !== 'number'
) {
return;
}
this.updateLastAction();
const { x, y, type } = param as { x: number; y: number; type: number; };
if (
isOutsideMap(x, y, this.map) ||
!entities.placeableEntities.some(x => x.type === type) ||
!hasFlag(this.map.flags, MapFlags.EditableEntities)
) {
return saySystem(this.client, `Cannot place object`);
}
if (this.isHouseLocked()) {
return saySystem(this.client, `House is locked`);
}
let totalEditableEntities = 0;
for (const region of this.map.regions) {
for (const entity of region.entities) {
if (hasFlag(entity.state, EntityState.Editable)) {
totalEditableEntities++;
}
}
}
if (totalEditableEntities >= this.map.editableEntityLimit) {
return saySystem(this.client, `Object limit reached`);
}
const entity = createAnEntity(type, 0, x, y, {}, mockPaletteManager, this.world);
entity.state |= EntityState.Editable;
this.world.addEntity(entity, this.map);
break;
}
default:
throw new Error(`Invalid Action (${action})`);
}
}
@Method({ rateLimit: '10/s', serverRateLimit: '20/s', binary: [Bin.U8, Bin.Obj] })
actionParam2(action: Action, param: any) {
validateNumber(action, 'action');
switch (action) {
case Action.Info:
validateNumber(param, 'param');
this.client.incognito = hasFlag(param, InfoFlags.Incognito);
this.client.supportsWasm = hasFlag(param, InfoFlags.SupportsWASM);
this.client.supportsLetAndConst = hasFlag(param, InfoFlags.SupportsLetAndConst);
break;
case Action.RequestEntityInfo: {
validateNumber(param, 'param');
const entity = this.world.getEntityById(param | 0);
if (entity && entity.client) {
this.client.entityInfo(entity.id, entity.name || '', entity.crc || 0, !!entity.nameBad);
}
break;
}
default:
throw new Error(`Invalid Action (${action})`);
}
}
@Method({ promise: true, rateLimit: '1/s', binary: [] })
async getInvites(): Promise<SupporterInvite[]> {
return [
{ id: 'a', info: OFFLINE_PONY, name: 'Offline Pony', active: true },
{ id: 'b', info: OFFLINE_PONY, name: 'Fuzzy', active: true },
{ id: 'c', info: OFFLINE_PONY, name: 'Molly', active: false },
];
}
@Method({ rateLimit: '2/s', serverRateLimit: '4/s', binary: [Bin.U32] })
expression(expression: number) {
validateNumber(expression, 'expression');
this.updateLastAction();
const expr = decodeExpression(expression);
const cancellable = !!expr && isCancellableExpression(expr);
if (cancellable) {
setEntityExpression(this.pony, expr, 0, true);
} else {
this.pony.exprPermanent = expr;
setEntityExpression(this.pony, undefined, 0);
}
}
@Method({ rateLimit: '3/s', binary: [Bin.U32, Bin.U8, Bin.Obj] })
playerAction(entityId: number, action: PlayerAction, param: unknown) {
validateNumber(entityId, 'entityId');
validateNumber(action, 'action');
this.updateLastAction();
const target = this.getClientByEntityId(entityId);
if (!target) {
this.client.reporter.warnLog(`No client for: ${playerActionNames[action]} [${action}], id: ${entityId}`);
return;
}
switch (action) {
case PlayerAction.Ignore:
case PlayerAction.Unignore:
this.ignorePlayer(this.client, target, action === PlayerAction.Ignore);
break;
case PlayerAction.InviteToParty:
this.partyService.invite(this.client, target);
break;
case PlayerAction.RemoveFromParty:
this.partyService.remove(this.client, target);
break;
case PlayerAction.PromotePartyLeader:
this.partyService.promoteLeader(this.client, target);
break;
case PlayerAction.HidePlayer:
const hideFor = toInt(param);
if (hideFor === 0) {
this.hiding.requestHide(this.client, target, 0);
} else {
this.hiding.requestHide(this.client, target, clamp(hideFor, MIN_HIDE_TIME, MAX_HIDE_TIME));
}
break;
case PlayerAction.InviteToSupporterServers:
this.supporterInvites.requestInvite(this.client, target);
break;
case PlayerAction.AddFriend:
this.friends.add(this.client, target);
break;
case PlayerAction.RemoveFriend:
this.friends.remove(this.client, target);
break;
default:
throw new Error(`Invalid player action (${playerActionNames[action]}) [${action}]`);
}
}
@Method({ rateLimit: '1/s', binary: [] })
leaveParty() {
this.updateLastAction();
if (this.client.isSwitchingMap)
return;
if (this.client.party) {
this.partyService.remove(this.client.party.leader, this.client);
}
}
@Method({ promise: true, rateLimit: '10/s', binary: [Bin.U32, Bin.U8, Bin.I32] })
async otherAction(entityId: number, action: ModAction, param: number) {
validateNumber(entityId, 'entityId');
validateNumber(action, 'action');
validateNumber(param, 'param');
this.updateLastAction();
const target = this.getClientForModAction(entityId, modActionNames[action]);
switch (action) {
case ModAction.Report:
await this.logModAction(target, 'Reported');
break;
case ModAction.Mute:
await this.setBan(target, 'mute', param);
break;
case ModAction.Shadow:
await this.setBan(target, 'shadow', param);
break;
case ModAction.Ban:
await this.setBan(target, 'ban', -1);
break;
case ModAction.Kick:
await this.world.kick(target, 'mod kick');
break;
default:
throw new Error(`Invalid mod action (${action})`);
}
}
private setBan(target: IClient, field: 'mute' | 'shadow' | 'ban', value: number) {
const timeout = value > 0 ? Date.now() + value : value;
this.logModAction(target, banMessage(field, timeout));
return this.accountService.update(target.accountId, { [field]: timeout });
}
@Method({ promise: true, binary: [Bin.U32, Bin.Str] })
async setNote(entityId: number, text: string) {
validateNumber(entityId, 'entityId');
validateString(text, 'text', true);
this.updateLastAction();
const client = this.getClientForModAction(entityId, 'setNote');
await this.accountService.update(client.accountId, { note: text });
}
@Method({ rateLimit: '5/s', binary: [Bin.Obj] })
async saveSettings(settings: AccountSettings) {
this.updateLastAction();
const wasHidden = !!this.client.accountSettings.hidden;
await this.accountService.updateSettings(this.account, settings);
this.client.accountSettings = { ...this.account.settings };
this.client.reporter.systemLog(`Saved settings`);
const isHidden = !!this.client.accountSettings.hidden;
if (wasHidden !== isHidden) {
for (const friend of findAllOnlineFriends(this.world, this.client)) {
if (isHidden) {
friend.updateFriends([{ accountId: this.client.accountId, status: FriendStatusFlags.None }], false);
} else {
friend.updateFriends([toFriendOnline(this.client)], false);
}
updateEntityPlayerState(friend, this.client.pony);
}
}
}
@Method({ binary: [Bin.U16] })
acceptNotification(id: number) {
validateNumber(id, 'id');
this.updateLastAction();
this.notificationService.acceptNotification(this.client, id);
}
@Method({ binary: [Bin.U16] })
rejectNotification(id: number) {
validateNumber(id, 'id');
this.updateLastAction();
this.notificationService.rejectNotification(this.client, id);
}
@Method({ binary: [[Bin.U32]] })
getPonies(ids: number[]) {
const party = this.client.party;
const createPonyData = ({ pony }: IClient): PonyData => {
return [
pony.id,
pony.options,
pony.encodedName,
pony.encryptedInfoSafe,
getPlayerState(this.client, pony),
!!pony.nameBad,
];
};
if (party && ids && ids.length && ids.length <= PARTY_LIMIT) {
const ponies = party.clients
.filter(c => includes(ids, c.pony.id))
.map(createPonyData);
this.client.updatePonies(ponies);
}
}
@Method({ binary: [] })
loaded() {
this.client.loading = false;
}
@Method({ binary: [] })
fixedPosition() {
this.client.fixingPosition = false;
}
@Method({ binary: [Bin.U32, Bin.U32, Bin.U16, Bin.U16] })
updateCamera(x: number, y: number, width: number, height: number) {
validateNumber(x, 'x');
validateNumber(y, 'y');
validateNumber(width, 'width');
validateNumber(height, 'height');
this.updateLastAction();
setupCamera(this.client.camera, x, y, width, height, this.client.map);
}
@Method({ binary: [Bin.U32, Bin.U32, Bin.U32, Bin.U32, Bin.U16] })
move(a: number, b: number, c: number, d: number, e: number) {
validateNumber(a, 'a');
validateNumber(b, 'b');
validateNumber(c, 'c');
validateNumber(d, 'd');
validateNumber(e, 'e');
this.updateLastAction();
this.moveFunc(this.client, Date.now(), a, b, c, d, e, this.getSettings());
}
private isHouseLocked() {
return this.map.editingLocked && this.client.party && this.client.party.leader !== this.client;
}
@Method({ rateLimit: debugRate || '3/s', serverRateLimit: debugRate || '7/s', binary: [Bin.U16, Bin.U16, Bin.U8] })
changeTile(x: number, y: number, type: TileType) {
validateNumber(x, 'x');
validateNumber(y, 'y');
validateNumber(type, 'type');
this.updateLastAction();
if (this.client.isSwitchingMap)
return;
const wallTile = type === TileType.WallH || type === TileType.WallV;
if (hasFlag(this.map.flags, MapFlags.EditableWalls) && wallTile) {
if (this.isHouseLocked()) {
saySystem(this.client, `House is locked`);
} else {
this.world.toggleWall(this.map, x, y, type);
}
} else if (BETA && this.client.isMod && wallTile) {
this.world.toggleWall(this.map, x, y, type);
} else if (hasFlag(this.map.flags, MapFlags.EditableTiles) && this.pony.options!.hold === entities.shovel.type) {
if (!houseTiles.some(t => t.type === type))
return;
if (this.isHouseLocked())
return saySystem(this.client, `House is locked`);
this.world.setTile(this.map, x, y, type);
} else if (BETA && this.client.isMod && isValidModTile(type)) {
this.world.setTile(this.map, x, y, type);
} else if (isValidTile(type)) {
if ((BETA || distanceXY(x, y, this.pony.x, this.pony.y) < TILE_CHANGE_RANGE)) {
const tile = getTile(this.map, x, y);
if (tile === TileType.Dirt || tile === TileType.Grass) {
if (this.client.shadowed) {
pushUpdateTileToClient(this.client, x, y, type);
} else {
this.world.setTile(this.map, x, y, type);
}
}
}
}
}
@Method({ rateLimit: '1/s', binary: [] })
leave() {
this.client.leaveReason = 'leave';
this.client.left(LeaveReason.None);
}
@Method({ binary: [Bin.Obj] })
editorAction(action: EditorAction) {
if (this.server.flags.objects && this.client.isMod) {
const added = editorAdded.get(this.client.accountId) || [];
editorAdded.set(this.client.accountId, added);
switch (action.type) {
case 'place':
if (includes(allEntities, action.entity)) {
const name = action.entity;
const entity = (entities as any)[name](action.x, action.y);
const toAdd = Array.isArray(entity) ? entity : [entity];
toAdd.forEach(e => this.world.addEntity(e, this.map));
added.push({ name, entities: toAdd });
} else {
saySystem(this.client, 'Invalid entity');
}
break;
case 'move':
for (const { id, x, y } of action.entities) {
const entity = this.world.getEntityById(id);
if (entity && entity.type !== PONY_TYPE) {
entity.x = x;
entity.y = y;
updateEntity(entity, false);
updateRegion(entity, this.client.map);
getExpectedRegion(entity, this.client.map).colliderDirty = true;
}
}
break;
case 'undo':
const remove = added.pop();
remove && remove.entities.forEach(e => this.world.removeEntityFromSomeMap(e));
break;
case 'clear':
added.forEach(x => x.entities.forEach(e => this.world.removeEntityFromSomeMap(e)));
added.length = 0;
break;
case 'list':
const existingEntities = added
.filter(({ entities }) => entities.some(e => !!this.world.getEntityById(e.id)))
.map(({ name, entities: [{ x, y }] }) => ({ name, x, y }));
this.client.entityList(existingEntities);
break;
case 'remove':
for (const id of action.entities) {
const entity = this.world.getEntityById(id);
if (entity && entity.type !== PONY_TYPE) {
this.world.removeEntityFromSomeMap(entity);
}
}
break;
case 'tile': {
const { x, y, tile, size } = action;
if (isValidModTile(tile)) {
for (let iy = 0; iy < size; iy++) {
for (let ix = 0; ix < size; ix++) {
this.world.setTile(this.map, x + ix, y + iy, tile);
}
}
}
break;
}
case 'party': {
const entities = findEntities(this.map, e => !!e.client && /^debug/.test(e.name || ''));
for (const e of entities.slice(0, PARTY_LIMIT - 1)) {
this.partyService.invite(this.client, e.client!);
}
break;
}
default:
throw new Error(`Invalid editor action (${action})`);
}
}
}
private logModAction(client: IClient, title: string) {
client.reporter.system(`${title} by ${this.account.name}`);
}
private getClientForModAction(entityId: number, action: string) {
if (!this.client.isMod) {
this.client.disconnect(true, true);
throw new Error(`Action not allowed (${action})`);
}
const client = this.world.getClientByEntityId(entityId);
if (!client) {
throw new Error(`Client does not exist (${action})`);
}
if (client.accountId === this.client.accountId) {
throw new Error(`Cannot perform action on self (${action})`);
}
return client;
}
private getEntityFromClients(entityId: number) {
const client = this.getClientByEntityId(entityId);
return client && client.pony;
}
private getClientByEntityId(entityId: number) {
return this.world.getClientByEntityId(entityId) || this.findClientByEntityId(this.client, entityId);
}
private updateLastAction() {
this.client.lastPacket = Date.now();
}
}
function validateNumber(value: number, fieldName: string) {
if (typeof value !== 'number' || isNaN(value) || !isFinite(value)) {
throw new Error(`Not a number (${fieldName})`);
}
}
function validateString(value: string, fieldName: string, allowNull = false) {
if (typeof value !== 'string' && !(allowNull && value === null)) {
throw new Error(`Not a string (${fieldName})`);
}
}
if (DEVELOPMENT) {
/* istanbul ignore next */
getMethods(ServerActions)
.forEach(m => m.options.binary || console.error(`Missing binary encoding for ServerActions.${m.name}()`));
}
+118
View File
@@ -0,0 +1,118 @@
import { SocketClient, ClientExtensions } from 'ag-sockets';
import * as fs from 'fs';
import { noop, random } from 'lodash';
import { HOUR, SECOND, SEASON, HOLIDAY, UNHIDE_TIMEOUT, MINUTE } from '../common/constants';
import { CharacterState, ServerConfig, Settings } from '../common/adminInterfaces';
import { ClientActions } from '../client/clientActions';
import {
updateAccountSafe, timeoutAccount, reportInviteLimitAccount, reportSwearingAccount, reportSpammingAccount,
reportFriendLimitAccount
} from './api/admin-accounts';
import { ServerActions } from './serverActions';
import { IClient, AccountService, GetSettings, SocketStats, TokenData } from './serverInterfaces';
import { createReportSwears, createReportForbidden, reportInviteLimit, createReportSuspicious } from './reporting';
import { createSpamChecker } from './spamChecker';
import { NotificationService } from './services/notification';
import { CounterService } from './services/counter';
import { HidingService, pollHidingDataSave, hidingDataPath } from './services/hiding';
import { PartyService } from './services/party';
import { World, findClientByAccountId } from './world';
import { log, chat } from './logger';
import { createSay, LogChat } from './chat';
import { createRunCommand, createCommands, getSpamCommandNames } from './commands';
import { createIgnorePlayer, findClientByEntityId, createClientAndPony } from './playerUtils';
import { createUpdateSettings } from './api/account';
import { findAccountSafe, updateAccount, SupporterInvite, Account, IAccount, findFriendIds, findHideIds } from './db';
import { SupporterInvitesService } from './services/supporterInvites';
import { createMove } from './move';
import { liveSettings } from './liveSettings';
import { createIsSuspiciousMessage } from '../common/security';
import { updateCharacterState } from './characterUtils';
import { FriendsService } from './services/friends';
async function refreshSettings(account: IAccount) {
const a = await Account.findOne({ _id: account._id }, 'settings').exec();
if (a) {
account.settings = a.settings;
}
}
export function createServerActionsFactory(
server: ServerConfig, settings: Settings, getSettings: GetSettings, socketStats: SocketStats
) {
const reportInviteLimitFunc = reportInviteLimit(reportInviteLimitAccount, `Party invite limit`);
const reportFriendLimitFunc = reportInviteLimit(reportFriendLimitAccount, `Friend request limit`);
const notifications = new NotificationService();
const party = new PartyService(notifications, reportInviteLimitFunc);
const supporterInvites = new SupporterInvitesService(SupporterInvite, notifications, log);
const friends = new FriendsService(notifications, reportFriendLimitFunc);
let world: World;
const hiding = new HidingService(UNHIDE_TIMEOUT, notifications, accountId => findClientByAccountId(world, accountId), log);
world = new World(server, party, friends, hiding, notifications, getSettings, liveSettings, socketStats);
const spamCounter = new CounterService<string>(2 * HOUR);
const rapidCounter = new CounterService<number>(1 * MINUTE);
const swearsCounter = new CounterService<string>(2 * HOUR);
const forbiddenCounter = new CounterService<string>(4 * HOUR);
const suspiciousCounter = new CounterService<string>(4 * HOUR);
const teleportCounter = new CounterService<void>(1 * HOUR);
const statesCounter = new CounterService<CharacterState>(10 * SECOND);
const logChatMessage: LogChat = (client, text, type, ignored, target) => chat(server, client, text, type, ignored, target);
world.season = SEASON;
world.holiday = HOLIDAY;
try {
const hidingData = fs.readFileSync(hidingDataPath(server.id), 'utf8');
if (hidingData) {
hiding.deserialize(hidingData);
}
} catch { }
pollHidingDataSave(hiding, server.id);
hiding.changes.subscribe(({ by, who }) => world.notifyHidden(by, who));
hiding.unhidesAll.subscribe(by => world.kickByAccount(by));
hiding.start();
spamCounter.start();
swearsCounter.start();
forbiddenCounter.start();
suspiciousCounter.start();
const commands = createCommands(world);
const spamCommands = getSpamCommandNames(commands);
const runCommand = createRunCommand({ world, notifications, random, liveSettings, party }, commands);
const updateSettings = createUpdateSettings(findAccountSafe);
const accountService: AccountService = {
update: updateAccountSafe,
updateSettings: (account, settings) => updateSettings(account, settings).then(noop),
refreshSettings,
updateAccount,
updateCharacterState: (characterId, state) => updateCharacterState(characterId, server.id, state),
};
const reportSwears = createReportSwears(swearsCounter, reportSwearingAccount, timeoutAccount);
const reportForbidden = createReportForbidden(forbiddenCounter, timeoutAccount);
const reportSuspicious = createReportSuspicious(suspiciousCounter);
const checkSpam = createSpamChecker(spamCounter, rapidCounter, reportSpammingAccount, timeoutAccount);
const isSuspiciousMessage = createIsSuspiciousMessage(settings);
const say = createSay(
world, runCommand, logChatMessage, checkSpam, reportSwears, reportForbidden, reportSuspicious, spamCommands,
Math.random, isSuspiciousMessage);
const move = createMove(teleportCounter);
const ignorePlayer = createIgnorePlayer(updateAccount);
async function createServerActions(client: ClientActions & SocketClient & ClientExtensions & IClient) {
const { account } = client.tokenData as TokenData;
const [friendIds, hideIds] = await Promise.all([findFriendIds(account._id), findHideIds(account._id)]);
createClientAndPony(client, friendIds, hideIds, server, world, statesCounter);
return new ServerActions(
client, world, notifications, party, supporterInvites, getSettings, server, say, move, hiding, statesCounter,
accountService, ignorePlayer, findClientByEntityId, friends
);
}
return { world, hiding, createServerActions };
}
+308
View File
@@ -0,0 +1,308 @@
import { ClientExtensions, BinaryWriter } from 'ag-sockets';
import { ClientActions } from '../client/clientActions';
import {
Entity, ServerFlags, AccountSettings, NotificationFlags, Expression, Camera, SayData, Region, TileUpdate,
Rect, IMap, MapType, TileType, MapState, UpdateFlags, Action, EntityOrPonyOptions, EntityPlayerState, MapFlags
} from '../common/interfaces';
import { IAccount, ICharacter, UpdateAccount } from './db';
import { AccountUpdate, CharacterState, GameServerSettings, Suspicious } from '../common/adminInterfaces';
export interface EntityUpdate {
entity: Entity;
flags: UpdateFlags;
// NOTE: need to use different position than current entity.x/y
// otherwise we get jump at the start of movement due to
// updated position after the frame
x: number;
y: number;
vx: number;
vy: number;
action: Action;
playerState: EntityPlayerState;
options: EntityOrPonyOptions | undefined;
}
export type EntityUpdateBase = Partial<EntityUpdate> & { entity: ServerEntity; flags: UpdateFlags; };
export interface Reporter {
info(message: string, desc?: string): void;
warn(message: string, desc?: string): void;
warnLog(message: string): void;
danger(message: string, desc?: string): void;
error(error: Error, desc?: string): void;
system(message: string, desc?: string, logEvent?: boolean): void;
systemLog(message: string): void;
setPony(pony: any): void;
}
export interface ServerNotification {
id: number;
name: string;
message: string;
note?: string;
flags?: NotificationFlags;
entityId?: number;
accept?(): void;
reject?(): void;
sender?: IClient;
}
export interface ServerParty {
id: string;
leader: IClient;
leaderTimeout?: any;
clients: IClient[];
pending: { client: IClient; notificationId: number; }[];
cleanup?: number;
}
export interface TokenData {
accountId: string;
account: IAccount;
character: ICharacter;
}
export interface TokenService {
clearTokensForAccount(accountId: string): void;
clearTokensAll(): void;
createToken(token: TokenData): string;
}
export interface LastSay {
message: string;
count: number;
age: number;
}
export interface QueuedSay {
id: number;
type: number;
message: string;
}
export interface ServerRegion extends Region {
tiles: Uint8Array;
entities: ServerEntity[];
movables: ServerEntity[];
colliders: ServerEntity[];
// entityAdds: any[];
reusedUpdates: number;
entityUpdates: EntityUpdate[];
entityRemoves: number[];
tileUpdates: TileUpdate[];
clients: IClient[]; // subscribed clients
bounds: Readonly<Rect>;
boundsWithBorder: Readonly<Rect>;
subscribeBounds: Readonly<Rect>; // screen space
unsubscribeBounds: Readonly<Rect>; // screen space
tilesSnapshot: Uint8Array | undefined;
tilesTimeouts: Uint8Array | undefined;
encodedTiles: Uint8Array | undefined;
}
export const enum MapUsage {
Public,
Party,
}
export interface ServerMap extends IMap<ServerRegion> {
id: string;
usage: MapUsage;
flags: MapFlags;
readonly type: MapType;
readonly width: number;
readonly height: number;
readonly regionsX: number;
readonly regionsY: number;
instance: string | undefined;
defaultTile: TileType;
state: MapState;
regions: ServerRegion[];
spawnArea: Rect;
spawns: Map<string, Rect>;
lockedTiles: Set<number>;
lastUsed: number;
controllers: Controller[];
dontUpdateTilesAndColliders: boolean;
tilesLocked: boolean;
editableEntityLimit: number;
editableArea?: Rect;
editingLocked: boolean;
}
export interface IClient extends ClientActions, ClientExtensions {
// origin info
ip: string;
country: string;
userAgent?: string;
// browser info
incognito?: boolean;
supportsWasm?: boolean;
supportsLetAndConst?: boolean;
// quick access account fields
accountId: string;
accountName: string;
accountSettings: AccountSettings;
account: IAccount;
friends: Set<string>;
friendsCRC: number | undefined;
// quick access character fields
characterId: string;
characterName: string;
character: ICharacter;
isMod: boolean;
shadowed: boolean;
supporterLevel: number;
pony: ServerEntity;
regions: ServerRegion[];
map: ServerMap;
isSwitchingMap: boolean;
camera: Camera;
characterState: CharacterState;
offline?: boolean;
offlineAt?: Date;
selected?: ServerEntity;
reporter: Reporter;
notifications: ServerNotification[];
party?: ServerParty;
ignores: Set<string>;
hides: Set<string>;
permaHides: Set<string>;
lastSwap: number;
lastMapLoadOrSave: number;
// last state
safeX: number;
safeY: number;
lastPacket: number;
lastAction: number;
lastBoopAction: number;
lastExpressionAction: number;
lastSays: LastSay[];
lastX: number;
lastY: number;
lastTime: number;
lastVX: number;
lastVY: number;
// sitting reporting
lastSitX: number;
lastSitY: number;
lastSitTime: number;
sitCount: number;
// subscription checking
lastCameraX: number;
lastCameraY: number;
lastCameraW: number;
lastCameraH: number;
// flags
lastMapSwitch: number;
// queuedMapSwitch?: { map: ServerMap; x: number; y: number; };
logDisconnect?: boolean;
loading?: boolean;
fixingPosition?: boolean;
connectedTime: number;
leaveReason?: string;
// pending data
updateQueue: BinaryWriter;
regionUpdates: Uint8Array[];
saysQueue: SayData[];
unsubscribes: number[];
subscribes: Uint8Array[];
// error reporting
rateLimitMessage?: string;
rateLimitCount?: number;
// debug
positions: { frame: number; x: number; y: number; moved: boolean; }[];
}
export type Interact = (target: ServerEntity, client: IClient) => void;
export interface ServerEntity extends Entity {
// flags
serverFlags?: ServerFlags;
canFly?: boolean;
canMagic?: boolean;
// state
region?: ServerRegion;
client?: IClient;
// interaction
interact?: Interact;
// trigger
trigger?: Interact;
// boop
boop?(client: IClient): void;
boopX?: number;
boopY?: number;
// expression
exprTimeout?: number;
exprCancellable?: boolean;
exprPermanent?: Expression;
// other
lightDelay?: number;
// cached info & name
nameBad?: boolean;
encodedName?: Uint8Array;
info?: string;
infoSafe?: string;
encryptedInfoSafe?: Uint8Array;
// update
serverUpdate?: (delte: number, now: number) => void;
}
export interface ServerEntityWithClient extends ServerEntity {
client: IClient;
}
export interface Controller {
initialize(now: number): void;
update(delta: number, now: number): void;
sparseUpdate?(): void;
toggleWall?(x: number, y: number, type: TileType): void;
}
export interface AccountService {
update(accountId: string, update: AccountUpdate): Promise<void>;
updateSettings(account: IAccount, settings: AccountSettings): Promise<void>;
refreshSettings(account: IAccount): Promise<void>;
updateAccount: UpdateAccount;
updateCharacterState(characterId: string, state: CharacterState): Promise<void>;
}
export interface SocketStats {
stats(): { sent: number; sentPackets: number; received: number; receivedPackets: number; };
}
export type OnMessage = (client: IClient, message: string) => void;
export type OnMessageSettings = (client: IClient, message: string, settings: GameServerSettings) => void;
export type OnSuspiciousMessage = (client: IClient, message: string, suspicious: Suspicious) => void;
export type IgnorePlayer = (client: IClient, target: IClient, ignored: boolean) => void;
export type FindClientByEntityId = (client: IClient, entityId: number) => IClient | undefined;
export type ReportAccount = (accountId: string) => Promise<void>;
export type TimeoutAccount = (accountId: string, timeout: Date, message?: string) => Promise<void>;
export type ReportInviteLimit = (client: IClient) => void;
export type ReportError = (message: string, data: any) => void;
export type LogAccountMessage = (accountId: string, message: string) => void;
export type LogMessage = (message: string) => void;
export type GetSettings = () => GameServerSettings;
+529
View File
@@ -0,0 +1,529 @@
import { writeFileAsync, readFileAsync, writeFileSync } from 'fs';
import { fromByteArray } from 'base64-js';
import {
TileType, MapInfo, MapState, defaultMapState, Rect, MapType, ServerFlags, EntityFlags, MapFlags, EntityState
} from '../common/interfaces';
import { getRegionGlobal, getTile, getRegion } from '../common/worldMap';
import { distanceSquaredXY, containsPoint, hasFlag } from '../common/utils';
import { POSITION_MAX } from '../common/movementUtils';
import { getEntityTypeName, getEntityType, createAnEntity } from '../common/entities';
import { deserializeTiles } from '../common/compress';
import { rect } from '../common/rect';
import { snapshotRegionTiles, setRegionTile, createServerRegion, getSizeOfRegion, cloneServerRegion } from './serverRegion';
import { ServerEntity, ServerRegion, ServerMap, MapUsage } from './serverInterfaces';
import { getTileColor } from '../common/colors';
import { World } from './world';
import { REGION_SIZE, REGION_HEIGHT, REGION_WIDTH, tileWidth, tileHeight } from '../common/constants';
import { pathTo } from './paths';
import { createCanvas } from './canvasUtilsNode';
import { mockPaletteManager } from '../common/ponyInfo';
import { setEntityName } from './entityUtils';
import { WallController } from './controllers/wallController';
export interface EntityData {
type: string;
x: number;
y: number;
options?: any;
name?: string;
}
export interface MapData {
width: number;
height: number;
tiles?: string;
entities?: EntityData[];
walls?: string;
}
export interface MapLoadOptions {
offsetX?: number;
offsetY?: number;
loadOnlyTiles?: boolean;
loadEntities?: boolean;
loadEntitiesAsEditable?: boolean;
loadWalls?: boolean;
}
export interface MapSaveOptions {
saveTiles?: boolean;
saveEntities?: boolean;
saveOnlyEditableEntities?: boolean;
saveWalls?: boolean;
}
export function createServerMap(
id: string, type: MapType, regionsX: number, regionsY: number, defaultTile = TileType.None, usage = MapUsage.Public,
initRegions = true
): ServerMap {
const width = regionsX * REGION_SIZE;
const height = regionsY * REGION_SIZE;
const regions: ServerRegion[] = [];
const state: MapState = { ...defaultMapState };
const spawnArea = rect(0, 0, 1, 1);
const lockedTiles = new Set<number>();
if (regionsX <= 0 || regionsY <= 0 || width > POSITION_MAX || height > POSITION_MAX) {
throw new Error('Invalid map parameters');
}
if (initRegions) {
for (let ry = 0; ry < regionsY; ry++) {
for (let rx = 0; rx < regionsX; rx++) {
regions.push(createServerRegion(rx, ry, defaultTile));
}
}
}
return {
id, usage, type, flags: MapFlags.None, width, height, state, regions, regionsX, regionsY, defaultTile, spawnArea,
lockedTiles, spawns: new Map(), instance: undefined, lastUsed: Date.now(), controllers: [],
dontUpdateTilesAndColliders: false, tilesLocked: false, editableEntityLimit: 0, editingLocked: false,
};
}
export function serverMapInstanceFromTemplate(map: ServerMap): ServerMap {
const {
id, usage, type, flags, width, height, state, regionsX, regionsY, defaultTile, spawnArea, lockedTiles,
editableEntityLimit
} = map;
return {
id, usage, type, flags, width, height, state: { ...state },
regions: map.regions.map(cloneServerRegion),
regionsX, regionsY, defaultTile, spawnArea,
lockedTiles, spawns: new Map(), instance: undefined, lastUsed: Date.now(), controllers: [],
dontUpdateTilesAndColliders: true, tilesLocked: map.tilesLocked,
editableEntityLimit, editingLocked: false,
};
}
export function copyMapTiles(target: ServerMap, source: ServerMap) {
for (let i = 0; i < target.regions.length; i++) {
const srcRegion = source.regions[i];
const tgtRegion = target.regions[i];
tgtRegion.tiles.set(srcRegion.tiles);
tgtRegion.tileIndices.set(srcRegion.tileIndices);
tgtRegion.encodedTiles = srcRegion.encodedTiles;
tgtRegion.colliderDirty = true;
}
}
export function getMapInfo(map: ServerMap): MapInfo {
return {
type: map.type,
flags: map.flags,
regionsX: map.regionsX,
regionsY: map.regionsY,
defaultTile: map.defaultTile,
editableArea: map.editableArea,
};
}
export function getSizeOfMap(map: ServerMap) {
const memory = map.regions.reduce((sum, r) => sum + getSizeOfRegion(r), 0);
const entities = map.regions.reduce((sum, r) => sum + r.entities.length, 0);
return { memory, entities };
}
export function setTile(map: ServerMap, x: number, y: number, type: TileType) {
const region = getRegionGlobal(map, x, y);
if (region) {
const regionX = Math.floor(x) - region.x * REGION_SIZE;
const regionY = Math.floor(y) - region.y * REGION_SIZE;
setRegionTile(map, region, regionX, regionY, type);
}
}
export function snapshotTiles(map: ServerMap) {
for (const region of map.regions) {
snapshotRegionTiles(region);
}
}
export function lockTile(map: ServerMap, x: number, y: number) {
const index = ((x | 0) + (y | 0) * map.width) | 0;
map.lockedTiles.add(index);
}
export function lockTiles(map: ServerMap, x: number, y: number, w: number, h: number) {
for (let iy = 0; iy < h; iy++) {
for (let ix = 0; ix < w; ix++) {
lockTile(map, x + ix, y + iy);
}
}
}
export function isTileLocked(map: ServerMap, x: number, y: number) {
const index = ((x | 0) + (y | 0) * map.width) | 0;
return map.lockedTiles.has(index);
}
export function serializeTiles(map: ServerMap) {
const tilesData: number[] = [];
const data: number[] = [];
const { width, height } = map;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
tilesData.push(getTile(map, x, y));
}
}
for (let i = 0; i < tilesData.length; i++) {
const tile = tilesData[i];
let count = 1;
while (tilesData.length > (i + 1) && tilesData[i + 1] === tile && count < 255) {
count++;
i++;
}
data.push(count, tile);
}
return new Uint8Array(data);
}
export function serializeMap(map: ServerMap): MapData {
const { width, height } = map;
const tiles = fromByteArray(serializeTiles(map));
return { width, height, tiles };
}
export function deserializeMap(map: ServerMap, { tiles, width }: MapData, { offsetX = 0, offsetY = 0 }: MapLoadOptions = {}) {
const decodedTiles = deserializeTiles(tiles!);
for (let i = 0; i < decodedTiles.length; i++) {
const x = i % width;
const y = Math.floor(i / width);
setTile(map, x + offsetX, y + offsetY, decodedTiles[i]);
}
}
export function saveMap(map: ServerMap, saveOptions: MapSaveOptions): MapData {
const data: MapData = { width: map.width, height: map.height };
if (saveOptions.saveTiles) {
data.tiles = serializeMap(map).tiles;
}
if (saveOptions.saveEntities) {
data.entities = [];
for (const region of map.regions) {
for (const entity of region.entities) {
if (!hasFlag(entity.serverFlags, ServerFlags.DoNotSave) && !hasFlag(entity.flags, EntityFlags.Debug)) {
if (saveOptions.saveOnlyEditableEntities && !hasFlag(entity.state, EntityState.Editable))
continue;
const options = entity.options && Object.keys(entity.options).length > 0 ? entity.options : undefined;
const name = entity.name;
data.entities.push({ type: getEntityTypeName(entity.type), x: entity.x, y: entity.y, options, name });
}
}
}
}
if (saveOptions.saveWalls) {
const controller = map.controllers.find(c => c.toggleWall) as WallController | undefined;
if (controller) {
data.walls = controller.serialize();
}
}
return data;
}
export async function saveMapToFile(map: ServerMap, fileName: string, options: MapSaveOptions) {
const data = saveMap(map, options);
const json = JSON.stringify(data, null, 2);
await writeFileAsync(fileName, json, 'utf8');
}
export async function saveMapToFileBinary(map: ServerMap, fileName: string) {
const tiles = serializeTiles(map);
const buffer = new Uint8Array(4 + 4 + tiles.byteLength);
const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
view.setInt32(0, map.width, true);
view.setInt32(4, map.height, true);
buffer.set(tiles, 8);
await writeFileAsync(fileName, Buffer.from(buffer.buffer, buffer.byteOffset, buffer.byteLength));
}
export async function saveMapToFileBinaryAlt(map: ServerMap, fileName: string) {
const buffer = Buffer.alloc(4 + 4 + map.width * map.height);
buffer.writeUInt32LE(map.width, 0);
buffer.writeUInt32LE(map.height, 4);
for (let y = 0, i = 8; y < map.height; y++) {
for (let x = 0; x < map.width; x++ , i++) {
buffer.writeUInt8(getTile(map, x, y), i);
}
}
await writeFileAsync(fileName, buffer);
}
export async function saveEntitiesToFile(map: ServerMap, fileName: string) {
const lines: string[] = [];
for (const region of map.regions) {
for (const entity of region.entities) {
lines.push(`${getEntityTypeName(entity.type)} ${entity.x} ${entity.y}`);
}
}
await writeFileAsync(fileName, lines.join('\n'), 'utf8');
}
export function loadMap(world: World, map: ServerMap, data: MapData, loadOptions: MapLoadOptions) {
if (data.tiles) {
deserializeMap(map, data, loadOptions);
}
if (loadOptions.loadOnlyTiles)
return;
if (loadOptions.loadEntitiesAsEditable) {
const entitiesToRemove: ServerEntity[] = [];
for (const region of map.regions) {
for (const entity of region.entities) {
if (hasFlag(entity.state, EntityState.Editable)) {
entitiesToRemove.push(entity);
}
}
}
for (const entity of entitiesToRemove) {
world.removeEntity(entity, map);
}
}
if (loadOptions.loadEntities && data.entities) {
for (const { x, y, type, name, options } of data.entities) {
const typeNumber = getEntityType(type);
const entity = createAnEntity(typeNumber, 0, x, y, options, mockPaletteManager, world);
if (name) {
setEntityName(entity, name);
}
if (loadOptions.loadEntitiesAsEditable) {
entity.state |= EntityState.Editable;
}
world.addEntity(entity, map);
}
}
if (loadOptions.loadWalls && data.walls) {
const controller = map.controllers.find(c => c.toggleWall) as WallController | undefined;
if (controller) {
controller.deserialize(data.width, data.height, data.walls);
}
}
}
export async function loadMapFromFile(world: World, map: ServerMap, fileName: string, options: MapLoadOptions) {
const json = await readFileAsync(fileName, 'utf8');
const data = JSON.parse(json);
loadMap(world, map, data, options);
}
export function saveRegionCollider(region: ServerRegion) {
const canvas = createCanvas(REGION_WIDTH, REGION_HEIGHT);
const context = canvas.getContext('2d')!;
context.fillStyle = 'white';
context.fillRect(0, 0, canvas.width, canvas.height);
context.fillStyle = '#eee';
for (let y = 0, i = 0; y < REGION_SIZE; y++ , i++) {
for (let x = 0; x < REGION_SIZE; x++ , i++) {
if ((i % 2) === 0) {
context.fillRect(x * tileWidth, y * tileHeight, tileWidth, tileHeight);
}
}
}
context.globalAlpha = 0.8;
context.fillStyle = 'red';
for (let y = 0; y < REGION_HEIGHT; y++) {
for (let x = 0; x < REGION_WIDTH; x++) {
if (region.collider[x + y * REGION_WIDTH]) {
context.fillRect(x, y, 1, 1);
}
}
}
writeFileSync(pathTo('store', 'collider.png'), canvas.toBuffer());
}
function distanceSquaredToRegion(x: number, y: number, region: ServerRegion) {
const left = region.x * REGION_SIZE;
const top = region.y * REGION_SIZE;
const right = left + REGION_SIZE;
const bottom = top + REGION_SIZE;
const dx = x < left ? (left - x) : (x > right ? (x - right) : 0);
const dy = y < left ? (top - y) : (y > bottom ? (y - bottom) : 0);
return dx * dx + dy * dy;
}
export function findClosestEntity(
map: ServerMap, originX: number, originY: number, predicate: (entity: ServerEntity) => boolean
): ServerEntity | undefined {
let minX = Math.floor(originX / REGION_SIZE);
let minY = Math.floor(originY / REGION_SIZE);
let maxX = minX;
let maxY = minY;
let closest: ServerEntity | undefined = undefined;
let closestDist = Number.MAX_VALUE;
while (minX >= 0 || minY >= 0 || maxX < map.regionsX || maxY < map.regionsY) {
let regionsChecked = 0;
for (let y = minY; y <= maxY; y++) {
for (let x = minX; x <= maxX; x = (x === maxX || y === minY || y === maxY) ? x + 1 : maxX) {
if (x >= 0 && y >= 0 && x < map.regionsX && y < map.regionsY) {
const region = getRegion(map, x, y);
if (distanceSquaredToRegion(originX, originY, region) < closestDist) {
regionsChecked++;
for (const entity of region.entities) {
if (predicate(entity)) {
const dist = distanceSquaredXY(originX, originY, entity.x, entity.y);
if (dist < closestDist) {
closest = entity;
closestDist = dist;
}
}
}
}
}
}
}
if (!regionsChecked) {
break;
}
minX -= 1;
minY -= 1;
maxX += 1;
maxY += 1;
}
return closest;
}
export function findEntities(map: ServerMap, predicate: (entity: ServerEntity) => boolean): ServerEntity[] {
const entities: ServerEntity[] = [];
for (const region of map.regions) {
for (const entity of region.entities) {
if (predicate(entity)) {
entities.push(entity);
}
}
}
return entities;
}
// TODO: maybe only regions in bounds, instead of adding 1 region border ?
function forEachRegionInBounds(map: ServerMap, bounds: Rect, callback: (region: ServerRegion) => void) {
const minX = Math.max(0, Math.floor(bounds.x / REGION_SIZE) - 1) | 0;
const minY = Math.max(0, Math.floor(bounds.y / REGION_SIZE) - 1) | 0;
const maxX = Math.min(Math.floor((bounds.x + bounds.w) / REGION_SIZE) + 1, map.regionsX - 1) | 0;
const maxY = Math.min(Math.floor((bounds.y + bounds.h) / REGION_SIZE) + 1, map.regionsY - 1) | 0;
for (let ry = minY; ry <= maxY; ry++) {
for (let rx = minX; rx <= maxX; rx++) {
const region = getRegion(map, rx, ry);
callback(region);
}
}
}
export function findEntitiesInBounds(map: ServerMap, bounds: Rect) {
const result: ServerEntity[] = [];
forEachRegionInBounds(map, bounds, region => {
for (const entity of region.entities) {
if (containsPoint(0, 0, bounds, entity.x, entity.y)) {
result.push(entity);
}
}
});
return result;
}
export function updateMapState(map: ServerMap, update: Partial<MapState>) {
Object.assign(map.state, update);
for (const region of map.regions) {
for (const client of region.clients) {
client.mapUpdate(map.state);
}
}
}
export function hasAnyClients(map: ServerMap) {
for (const region of map.regions) {
if (region.clients.length > 0) {
return true;
}
}
return false;
}
export function createMinimap(world: World, map: ServerMap) {
const { width, height } = map;
const buffer = new Uint32Array(width * height);
for (let y = 0; y < map.height; y++) {
for (let x = 0; x < map.width; x++) {
const tile = getTile(map, x, y);
buffer[x + y * width] = getTileColor(tile, world.season);
}
}
// map.entities = info.entities
// .map(({ type, id, x, y }) => createAnEntity(type, id, x, y, {}, mockPaletteManager));
// for (let i = 1; i <= 2; i++) {
// for (const e of map.entities) {
// if (e.minimap && e.minimap.order === i) {
// const { color, rect } = e.minimap;
// mapContext.fillStyle = colorToCSS(color);
// mapContext.fillRect(Math.round(e.x + rect.x), Math.round(e.y + rect.y), rect.w, rect.h);
// }
// }
// }
// canvas.width = mapCanvas.width * scale;
// canvas.height = mapCanvas.height * scale;
// const context = canvas.getContext('2d')!;
// context.save();
// if (scale >= 1) {
// disableImageSmoothing(context);
// }
// context.scale(scale, scale);
// context.drawImage(mapCanvas, 0, 0);
// context.restore();
return new Uint8Array(buffer.buffer);
}
+242
View File
@@ -0,0 +1,242 @@
import { random } from 'lodash';
import { ServerEntity, ServerRegion, EntityUpdateBase, ServerMap } from './serverInterfaces';
import { TileType, EntityFlags, UpdateFlags, canWalk } from '../common/interfaces';
import { compressTiles } from '../common/compress';
import { rect, withBorder, withPadding } from '../common/rect';
import {
REGION_BORDER, tileHeight, tileWidth, REGION_SIZE, TILES_RESTORE_MIN_SEC, TILES_RESTORE_MAX_SEC
} from '../common/constants';
import { rectToScreen } from '../common/positionUtils';
import { removeItem, hasFlag } from '../common/utils';
import { canCollideWith } from '../common/collision';
import { invalidateRegionsCollider, getRegionTile } from '../common/region';
import { setColliderDirty, setTilesDirty } from '../common/worldMap';
const subscribeBoundsBottomPad = 3;
const randoms = new Uint8Array(REGION_SIZE * REGION_SIZE);
export function createServerRegion(x: number, y: number, defaultTile = TileType.Dirt): ServerRegion {
const bounds = rect(x * REGION_SIZE, y * REGION_SIZE, REGION_SIZE, REGION_SIZE);
const tiles = new Uint8Array(REGION_SIZE * REGION_SIZE);
const tileIndices = new Int16Array(REGION_SIZE * REGION_SIZE);
const collider = new Uint8Array(REGION_SIZE * REGION_SIZE * tileWidth * tileHeight);
tileIndices.fill(-1);
if (defaultTile !== 0) {
for (let i = 0; i < tiles.length; i++) {
tiles[i] = defaultTile;
}
}
return {
x, y,
entityUpdates: [],
entityRemoves: [],
tileUpdates: [],
clients: [],
entities: [],
movables: [],
colliders: [],
collider,
colliderDirty: true,
randoms,
tiles,
tileIndices,
tilesDirty: true,
tilesSnapshot: undefined,
tilesTimeouts: undefined,
encodedTiles: undefined,
reusedUpdates: 0,
bounds,
boundsWithBorder: withBorder(bounds, REGION_BORDER),
subscribeBounds: rectToScreen(withPadding(
bounds, REGION_SIZE, REGION_SIZE, REGION_SIZE + subscribeBoundsBottomPad, REGION_SIZE)),
unsubscribeBounds: rectToScreen(withPadding(
bounds, REGION_SIZE + 1, REGION_SIZE + 1, REGION_SIZE + subscribeBoundsBottomPad + 1, REGION_SIZE + 1)),
};
}
export function cloneServerRegion(region: ServerRegion): ServerRegion {
const tiles = new Uint8Array(REGION_SIZE * REGION_SIZE);
tiles.set(region.tiles);
return {
x: region.x,
y: region.y,
entityUpdates: [],
entityRemoves: [],
tileUpdates: [],
clients: [],
entities: [],
movables: [],
colliders: [],
collider: region.collider,
colliderDirty: false,
randoms,
tiles,
tileIndices: region.tileIndices,
tilesDirty: false,
tilesSnapshot: undefined,
tilesTimeouts: undefined,
encodedTiles: region.encodedTiles,
reusedUpdates: 0,
bounds: region.bounds,
boundsWithBorder: region.boundsWithBorder,
subscribeBounds: region.subscribeBounds,
unsubscribeBounds: region.unsubscribeBounds,
};
}
export function getSizeOfRegion(region: ServerRegion) {
let size = region.tiles.byteLength;
size += region.tileIndices.byteLength;
size += region.tilesSnapshot ? region.tilesSnapshot.byteLength : 0;
size += region.tilesTimeouts ? region.tilesTimeouts.byteLength : 0;
size += region.encodedTiles ? region.encodedTiles.byteLength : 0;
size += region.collider ? region.collider.byteLength : 0;
return size;
}
export function addEntityToRegion(region: ServerRegion, entity: ServerEntity, map: ServerMap) {
region.entities.push(entity);
if (canCollideWith(entity)) {
region.colliders.push(entity);
invalidateRegionsCollider(region, map);
}
if (hasFlag(entity.flags, EntityFlags.Movable)) {
region.movables.push(entity);
}
}
export function removeEntityFromRegion(region: ServerRegion, entity: ServerEntity, map: ServerMap) {
const removed = removeItem(region.entities, entity);
if (canCollideWith(entity)) {
removeItem(region.colliders, entity);
invalidateRegionsCollider(region, map);
}
removeItem(region.movables, entity);
return removed;
}
export function pushUpdateEntityToRegion(region: ServerRegion, update: EntityUpdateBase) {
const index = findUpdate(region, update.entity);
if (index === -1) {
region.entityUpdates.push({ x: 0, y: 0, vx: 0, vy: 0, action: 0, playerState: 0, options: undefined, ...update });
} else {
region.reusedUpdates++;
const existing = region.entityUpdates[index];
existing.flags |= update.flags;
if (hasFlag(update.flags, UpdateFlags.Position)) {
const { x = 0, y = 0, vx = 0, vy = 0 } = update;
existing.x = x;
existing.y = y;
existing.vx = vx;
existing.vy = vy;
}
if (hasFlag(update.flags, UpdateFlags.Options)) {
existing.options = { ...existing.options, ...update.options };
}
if (hasFlag(update.flags, UpdateFlags.PlayerState)) {
existing.playerState = update.playerState!;
}
if (hasFlag(update.flags, UpdateFlags.Action)) {
existing.action = update.action!;
}
}
}
export function pushRemoveEntityToRegion(region: ServerRegion, entity: ServerEntity) {
region.entityRemoves.push(entity.id);
}
export function setRegionTile(map: ServerMap, region: ServerRegion, x: number, y: number, type: TileType, skipRestore = false) {
const old = getRegionTile(region, x, y);
if (type === old)
return;
const index = x | (y << 3);
region.tiles[index] = type;
region.tileUpdates.push({ x, y, type: type });
region.encodedTiles = undefined;
if (region.tilesTimeouts && !skipRestore) {
region.tilesTimeouts[index] = random(TILES_RESTORE_MIN_SEC, TILES_RESTORE_MAX_SEC);
}
if (canWalk(old) !== canWalk(type)) {
setTilesDirty(map, region.x * REGION_SIZE + x - 1, region.y * REGION_SIZE + y - 1, 3, 3);
setColliderDirty(map, region, x, y);
}
}
export function resetRegionUpdates(region: ServerRegion) {
region.entityUpdates.length = 0;
region.entityRemoves.length = 0;
region.tileUpdates.length = 0;
region.reusedUpdates = 0;
}
export function snapshotRegionTiles(region: ServerRegion) {
region.tilesSnapshot = region.tiles.slice();
region.tilesTimeouts = new Uint8Array(region.tiles.length);
}
export function getRegionTiles(region: ServerRegion) {
if (region.encodedTiles === undefined) {
region.encodedTiles = compressTiles(region.tiles);
}
return region.encodedTiles;
}
export function resetTiles(map: ServerMap, region: ServerRegion) {
if (region.tilesSnapshot && region.tilesTimeouts) {
for (let i = 0; i < region.tilesTimeouts.length; i++) {
region.tilesTimeouts[i] = 0;
if (region.tiles[i] !== region.tilesSnapshot[i]) {
const x = i % REGION_SIZE;
const y = Math.floor(i / REGION_SIZE);
setRegionTile(map, region, x, y, region.tilesSnapshot[i], true);
}
}
}
}
export function tickTilesRestoration(map: ServerMap, region: ServerRegion) {
if (region.tilesSnapshot && region.tilesTimeouts) {
for (let i = 0; i < region.tilesTimeouts.length; i++) {
if (region.tilesTimeouts[i] > 0) {
region.tilesTimeouts[i]--;
if (region.tilesTimeouts[i] === 0 && region.tiles[i] !== region.tilesSnapshot[i]) {
const x = i % REGION_SIZE;
const y = Math.floor(i / REGION_SIZE);
setRegionTile(map, region, x, y, region.tilesSnapshot[i], true);
}
}
}
}
}
function findUpdate({ entityUpdates }: ServerRegion, entity: ServerEntity) {
for (let i = 0; i < entityUpdates.length; i++) {
if (entityUpdates[i].entity === entity) {
return i;
}
}
return -1;
}
+145
View File
@@ -0,0 +1,145 @@
import * as fs from 'fs';
import { exec, ExecOptions } from 'child_process';
import { noop } from 'lodash';
import { Server } from 'ag-sockets';
import { AccountData, SocialSite, PonyObject, AccountDataFlags } from '../common/interfaces';
import { IAccount, ICharacter, IAuth } from './db';
import { TokenService, TokenData } from './serverInterfaces';
import { AccountFlags, CharacterFlags, InternalGameServerState } from '../common/adminInterfaces';
import { supporterLevel, isPastSupporter } from '../common/adminUtils';
import { hasFlag, cloneDeep, formatISODate } from '../common/utils';
import * as paths from './paths';
export function tokenService(socket: Server): TokenService {
return {
clearTokensForAccount(accountId: string) {
socket.clearTokens((_, data: TokenData) => data.accountId === accountId);
},
clearTokensAll() {
socket.clearTokens(() => true);
},
createToken(token: TokenData) {
return socket.token(token);
}
};
}
export function isServerOffline(server: InternalGameServerState) {
return server.state.dead || !!server.state.settings.isServerOffline || !!server.state.shutdown;
}
export function toAccountData(account: IAccount): AccountData {
const { _id, name, birthdate, birthyear, characterCount, roles, settings, flags } = account;
return {
id: _id.toString(),
name, characterCount,
birthdate: birthdate && formatISODate(birthdate) || '',
birthyear,
settings: cloneDeep(settings || {}),
supporter: supporterLevel(account) || undefined,
roles: (roles && roles.length) ? [...roles] : undefined,
flags: (hasFlag(flags, AccountFlags.DuplicatesNotification) ? AccountDataFlags.Duplicates : 0) |
(isPastSupporter(account) ? AccountDataFlags.PastSupporter : 0),
};
}
export const toPonyObjectFields = '_id name info desc site tag lastUsed flags';
export function toPonyObject(character: ICharacter): PonyObject;
export function toPonyObject(character: ICharacter | undefined): PonyObject | null;
export function toPonyObject(character: ICharacter | undefined): PonyObject | null {
return character ? {
id: character._id.toString(),
name: character.name,
desc: character.desc || '',
info: character.info || '',
site: character.site ? character.site.toString() : undefined,
tag: character.tag || undefined,
lastUsed: character.lastUsed && character.lastUsed.toISOString(),
hideSupport: hasFlag(character.flags, CharacterFlags.HideSupport) ? true : undefined,
respawnAtSpawn: hasFlag(character.flags, CharacterFlags.RespawnAtSpawn) ? true : undefined,
} : null;
}
export function toPonyObjectAdmin(character: ICharacter): PonyObject;
export function toPonyObjectAdmin(character: ICharacter | undefined): PonyObject | null;
export function toPonyObjectAdmin(character: ICharacter | undefined): PonyObject | null {
return character ? { ...toPonyObject(character)!, creator: character.creator } : null;
}
export const toSocialSiteFields = '_id name provider url';
export function toSocialSite({ _id, name, provider, url }: IAuth): SocialSite {
return { id: _id.toString(), name, provider, url };
}
/* istanbul ignore next */
export function execAsync(command: string, options?: ExecOptions) {
return new Promise<{ stdout: string; stderr: string; }>((resolve, reject) => {
exec(command, options || {}, (error, stdout, stderr) => {
if (error) {
reject(error);
} else {
resolve({ stdout, stderr });
}
});
});
}
/* istanbul ignore next */
export async function logErrorToFile(message: string, data: any) {
const fileName = `error-${Date.now()}.json`;
const filePath = paths.pathTo('store', fileName);
await fs.writeFileAsync(filePath, JSON.stringify({ message, data }, null, 2), 'utf8');
return fileName;
}
/* istanbul ignore next */
export async function getDiskSpace() {
// NOTE: add your own code here
return '';
}
/* istanbul ignore next */
export async function getCertificateExpirationDate() {
// NOTE: add your own code here
return '';
}
/* istanbul ignore next */
export async function getMemoryUsage() {
// NOTE: add your own code here
return `0%`;
}
/* istanbul ignore next */
export function handlePromiseDefault(promise: Promise<any>, errorHandler: any = noop) {
Promise.resolve(promise).catch(errorHandler);
}
export function cached<TResult, T extends Function>(func: T, cacheTimeout = 1000): T & { clear(...args: any[]): void; } {
const cacheMap = new Map<string, { timeout: any; result: TResult; }>();
const cachedFunc: any = (...args: any[]) => {
const cacheKey = JSON.stringify(args);
const cache = cacheMap.get(cacheKey);
if (cache) {
clearTimeout(cache.timeout);
cache.timeout = setTimeout(() => cacheMap.delete(cacheKey), cacheTimeout);
return cache.result;
} else {
const result = func(...args);
const timeout = setTimeout(() => cacheMap.delete(cacheKey), cacheTimeout);
cacheMap.set(cacheKey, { result, timeout });
return result;
}
};
cachedFunc.clear = (...args: any[]) => {
cacheMap.delete(JSON.stringify(args));
};
return cachedFunc;
}
+44
View File
@@ -0,0 +1,44 @@
import { CounterService } from './counter';
import { IClient } from '../serverInterfaces';
import { isMutedOrShadowed, isIgnored } from '../playerUtils';
export const enum LimiterResult {
Yes = 0,
SameAccount = 1,
MutedOrShadowed = 2,
Ignored = 3,
LimitReached = 4,
TargetOffline = 5,
}
export class ActionLimiter {
private counters: CounterService<void>;
constructor(clearTimeout: number, private countLimit: number) {
this.counters = new CounterService<void>(clearTimeout);
this.counters.start();
}
canExecute(requester: IClient, target: IClient): LimiterResult {
if (requester === target || requester.accountId === target.accountId)
return LimiterResult.SameAccount;
if (target.offline)
return LimiterResult.TargetOffline;
if (isMutedOrShadowed(requester))
return LimiterResult.MutedOrShadowed;
if (isIgnored(requester, target) || isIgnored(target, requester))
return LimiterResult.Ignored;
if (this.counters.get(requester.accountId).count >= this.countLimit)
return LimiterResult.LimitReached;
return LimiterResult.Yes;
}
count(requester: IClient) {
return this.counters.add(requester.accountId).count;
}
dispose() {
this.counters.stop();
}
}
+542
View File
@@ -0,0 +1,542 @@
import { sort } from 'timsort';
import { remove } from 'lodash';
import { Subject } from 'rxjs';
import * as db from '../db';
import { LiveList } from './liveList';
import { removeItem, includes, toInt, fromNow } from '../../common/utils';
import {
Account, Auth, Origin, OriginRef, OriginInfo, Character, ListListener, OriginInfoBase, Event, eventFields, PonyIdDateName
} from '../../common/adminInterfaces';
import {
addToMap, removeFromMap, emailName, getIdsFromNote, compareAccounts, compareOriginRefs, compareByName,
compareAuths, createIdStore, getPotentialDuplicates, createPotentialDuplicatesFilter
} from '../../common/adminUtils';
import { logger, logPerformance } from '../logger';
import { ObservableList } from './observableList';
import { HOUR } from '../../common/constants';
import { getLoginServer } from '../internal';
function addAuthToAccount(account: Account, auth: Auth, log: string) {
const existingAuth = account.auths!.find(a => a._id === auth._id);
if (existingAuth) { // TODO: remove
console.log('duplicate auth', auth._id, 'to', account._id, log);
} else {
account.authsList!.pushOrdered(auth, compareAuths);
}
}
function pushUnique<T>(list: T[], item: T) {
if (list.indexOf(item) === -1) {
list.push(item);
}
}
function removeAuthFromAccount(account: Account, auth: Auth) {
return account.authsList!.remove(auth);
}
function addPonyToAccount(account: Account, pony: Character) {
if (account.poniesList) {
account.poniesList.pushOrdered(pony, compareByName);
}
}
function removePonyFromAccount(account: Account, pony: Character) {
if (account.poniesList) {
return account.poniesList.remove(pony);
} else {
return false;
}
}
function getTotalPledged(auths: Auth[] | undefined) {
return Math.floor((auths || []).reduce((sum, a) => sum + toInt(a.pledged), 0) / 100);
}
export class AdminService {
readonly accounts: LiveList<Account>;
readonly origins: LiveList<Origin>;
readonly auths: LiveList<Auth>;
readonly ponies: LiveList<Character>;
readonly events: LiveList<Event>;
readonly accountDeleted = new Subject<Account>();
private emailMap = new Map<string, Account[]>();
private noteRefMap = new Map<string, Account[]>();
private browserIdMap = new Map<string, Account[]>();
private unassignedAuths: Auth[] = [];
private unassignedPonies: Character[] = [];
constructor() {
const accountId = createIdStore();
this.accounts = new LiveList<Account>(db.Account, {
fields: [
'_id', 'updatedAt', 'createdAt', 'lastVisit', 'name', 'birthdate', 'origins', 'ignores', 'emails', 'note',
'counters', 'mute', 'shadow', 'ban', 'flags', 'roles', 'characterCount', 'patreon', 'supporter',
'supporterDeclinedSince', 'lastBrowserId', 'noteUpdated', 'alert', 'birthyear'
],
clean: ({
_id, createdAt, updatedAt, lastVisit, name, birthdate, origins, ignoresCount, emails, note, counters, mute,
shadow, ban, flags, roles, characterCount, patreon, supporter, supporterDeclinedSince, auths, noteUpdated,
alert, birthyear,
}) =>
({
_id, createdAt, updatedAt, lastVisit, name, birthdate, origins, ignoresCount: toInt(ignoresCount),
emails, note, counters, mute, shadow, ban, flags: toInt(flags), roles, birthyear,
characterCount: toInt(characterCount), patreon: toInt(patreon), supporter: toInt(supporter),
supporterDeclinedSince, totalPledged: getTotalPledged(auths), noteUpdated, alert,
}),
fix: account => {
account._id = accountId(account._id);
account.nameLower = account.name.toLowerCase();
account.ignoresCount = account.ignores ? account.ignores.length : 0;
account.ignores = undefined;
account.origins = (account.origins || []).map(o => ({ ip: o.ip, country: o.country, last: o.last }));
},
onAdd: account => {
account.auths = [];
// account.ponies = [];
account.originsRefs = [];
account.authsList = new ObservableList(account.auths!, a => a._id);
if (account.lastBrowserId) {
this.addBrowserIdToMap(account.lastBrowserId, account);
}
if (account.emails) {
for (const e of account.emails) {
this.addEmailToMap(e, account);
}
}
this.addNoteRefsToMap(account.note, account);
this.updateOriginRefs(account);
this.accountsForPotentialDuplicatesCheck.push(account);
},
onUpdate: (oldAccount, newAccount) => {
if (oldAccount.emails) {
for (const e of oldAccount.emails) {
if (!includes(newAccount.emails, e)) {
this.removeEmailFromMap(e, oldAccount);
}
}
}
if (newAccount.emails) {
for (const e of newAccount.emails) {
if (!includes(oldAccount.emails, e)) {
this.addEmailToMap(e, oldAccount);
}
}
}
if (oldAccount.note !== newAccount.note) {
this.removeNoteRefsFromMap(oldAccount.note, oldAccount);
this.addNoteRefsToMap(newAccount.note, oldAccount);
}
if (oldAccount.lastBrowserId !== newAccount.lastBrowserId) {
oldAccount.lastBrowserId && this.removeBrowserIdFromMap(oldAccount.lastBrowserId, oldAccount);
newAccount.lastBrowserId && this.addBrowserIdToMap(newAccount.lastBrowserId, oldAccount);
}
Object.assign(oldAccount, newAccount);
if (newAccount.birthyear === undefined) {
oldAccount.birthyear = undefined;
}
if (newAccount.alert === undefined) {
oldAccount.alert = undefined;
}
if (newAccount.patreon === undefined) {
oldAccount.patreon = undefined;
}
if (newAccount.supporter === undefined) {
oldAccount.supporter = undefined;
}
this.updateOriginRefs(oldAccount);
this.accountsForPotentialDuplicatesCheck.push(oldAccount);
},
onAddedOrUpdated: () => {
this.assignItems(this.unassignedAuths, (account, auth) => addAuthToAccount(account, auth, 'onAddedOrUpdated'));
this.assignItems(this.unassignedPonies, addPonyToAccount);
},
onDelete: account => {
account.origins = [];
this.updateOriginRefs(account);
if (account.emails) {
for (const email of account.emails) {
this.removeEmailFromMap(email, account);
}
}
account.lastBrowserId && this.removeBrowserIdFromMap(account.lastBrowserId, account);
this.removeNoteRefsFromMap(account.note, account);
this.accountDeleted.next(account);
},
onFinished: () => {
sort(this.accounts.items, compareAccounts);
this.auths.start();
},
});
this.origins = new LiveList<Origin>(db.Origin, {
fields: ['_id', 'updatedAt', 'ip', 'country', 'mute', 'shadow', 'ban'],
clean: ({ _id, updatedAt, ip, country, mute, shadow, ban, accounts }) =>
({ _id, updatedAt, ip, country, mute, shadow, ban, accountsCount: accounts ? accounts.length : 0 }),
onAdd: origin => {
origin.accounts = [];
},
onSubscribeToMissing: ip => ({ ip, country: '??' }) as any,
}, origin => origin.ip);
this.auths = new LiveList<Auth>(db.Auth, {
fields: ['_id', 'updatedAt', 'account', 'provider', 'name', 'url', 'disabled', 'banned', 'pledged', 'lastUsed'],
clean: ({ _id, updatedAt, account, provider, name, url, disabled, banned, pledged, lastUsed }) =>
({ _id, updatedAt, account, provider, name, url, disabled, banned, pledged, lastUsed }),
fix: auth => {
if (auth.account) {
auth.account = accountId(auth.account.toString());
}
},
onAdd: auth => {
this.assignAccount(auth, this.unassignedAuths, account => addAuthToAccount(account, auth, 'onAdd'));
},
onUpdate: this.createUpdater<Auth>({
remove: (account, auth) => removeAuthFromAccount(account, auth) || removeItem(this.unassignedAuths, auth),
add: (account, auth) =>
account ? addAuthToAccount(account, auth, 'onUpdate') : pushUnique(this.unassignedAuths, auth),
}),
onDelete: auth => {
removeItem(this.unassignedAuths, auth);
this.accounts.for(auth.account, account => removeAuthFromAccount(account, auth));
},
onFinished: () => {
logger.info('Admin service loaded');
},
});
this.ponies = new LiveList<Character>(db.Character, {
fields: ['_id', 'createdAt', 'updatedAt', 'lastUsed', 'account', 'name', 'flags'],
noStore: true,
clean: ({ _id, createdAt, updatedAt, account, name, flags, lastUsed }) =>
({ _id, createdAt, updatedAt, account, name, flags, lastUsed }),
fix: pony => {
if (pony.account) {
pony.account = accountId(pony.account.toString());
}
},
ignore: pony => {
const account = this.accounts.get(pony.account);
return account === undefined || account.ponies === undefined;
},
onAdd: pony => {
this.assignAccount(pony, this.unassignedPonies, account => addPonyToAccount(account, pony));
},
onUpdate: this.createUpdater<Character>({
remove: (account, pony) => removePonyFromAccount(account, pony) || removeItem(this.unassignedPonies, pony),
add: (account, pony) => account ? addPonyToAccount(account, pony) : pushUnique(this.unassignedPonies, pony),
}),
onDelete: pony => {
removeItem(this.unassignedPonies, pony);
this.accounts.for(pony.account, account => removePonyFromAccount(account, pony));
},
// afterAssign: (from, to) => Promise.all([updateCharacterCount(from), updateCharacterCount(to)]),
});
this.events = new LiveList<Event>(db.Event, {
fields: eventFields,
clean: ({ _id, createdAt, updatedAt, message, desc, account, pony, origin }) =>
({ _id, createdAt, updatedAt, message, desc, account, pony, origin }),
});
setTimeout(() => this.events.start(), 100);
setTimeout(() => this.ponies.start(), 200);
setTimeout(() => this.origins.start(), 300);
setTimeout(() => this.accounts.start(), 400);
}
get loaded() {
return this.accounts.loaded && this.origins.loaded && this.auths.loaded;
}
removedItem(type: 'events' | 'ponies' | 'accounts' | 'auths' | 'origins', id: string) {
if (type === 'accounts') {
this.accounts.removed(id);
} else if (type === 'origins') {
this.origins.removed(id);
} else if (type === 'auths') {
this.auths.removed(id);
} else if (type === 'ponies') {
this.ponies.removed(id);
} else {
console.warn(`Unhandled removedItem for type: ${type}`);
}
}
getAccountsByNoteRef(accountId: string) {
return this.noteRefMap.get(accountId) || [];
}
getAccountsByEmailName(emailName: string) {
return this.emailMap.get(emailName) || [];
}
getAccountsByBrowserId(browserId: string) {
return this.browserIdMap.get(browserId);
}
removeOriginsFromAccount(accountId: string, ips?: string[]) {
const account = this.accounts.get(accountId);
if (account) {
if (ips) {
if (remove(account.origins, o => includes(ips, o.ip)).length) {
this.updateOriginRefs(account);
}
} else if (account.origins.length) {
account.origins = [];
this.updateOriginRefs(account);
}
}
}
subscribeToAccountAuths(accountId: string, listener: ListListener<string>) {
const account = this.accounts.get(accountId);
if (account) {
return account.authsList!.subscribe(listener);
} else {
return undefined;
}
}
subscribeToAccountOrigins(accountId: string, listener: ListListener<OriginInfoBase>) {
const account = this.accounts.get(accountId);
if (account) {
if (!account.originsList) {
account.originsList = new ObservableList(
account.originsRefs!, ({ origin, last }) => ({ ip: origin.ip, country: origin.country, last }));
}
return account.originsList.subscribe(listener);
}
return undefined;
}
subscribeToAccountPonies(accountId: string, listener: ListListener<PonyIdDateName>) {
const account = this.accounts.get(accountId);
if (account) {
if (!account.ponies) {
account.ponies = this.ponies.items.filter(p => p.account === account._id);
this.ponies.fetch({ account: account._id });
}
if (!account.poniesList) {
account.poniesList = new ObservableList(
account.ponies!, p => ({ id: p._id, name: p.name, date: p.lastUsed ? p.lastUsed.getTime() : 0 }));
}
return account.poniesList.subscribe(listener);
}
return undefined;
}
cleanupOriginsList(accountId: string) {
const account = this.accounts.get(accountId);
if (account && account.originsList && !account.originsList.hasSubscribers()) {
account.originsList = undefined;
}
}
cleanupPoniesList(accountId: string) {
const account = this.accounts.get(accountId);
if (account && account.ponies && account.poniesList && !account.poniesList.hasSubscribers()) {
const ponies = account.ponies;
account.ponies = undefined;
account.poniesList = undefined;
for (const pony of ponies) {
this.cleanupPony(pony._id);
}
}
}
cleanupPony(ponyId: string) {
const pony = this.ponies.get(ponyId);
if (pony && !this.ponies.hasSubscriptions(ponyId)) {
const account = this.accounts.get(pony.account);
if (!account || !account.ponies) {
this.ponies.discard(ponyId);
}
}
}
private duplicateFilter = createPotentialDuplicatesFilter(id => this.browserIdMap.get(id));
private accountsForPotentialDuplicatesCheck: Account[] = [];
async mergePotentialDuplicates() {
const start = Date.now();
const duplicateFilter = this.duplicateFilter;
while (this.accountsForPotentialDuplicatesCheck.length) {
const popedAccount = this.accountsForPotentialDuplicatesCheck.pop()!;
const account = this.getAccount(popedAccount._id);
if (account && duplicateFilter(account)) {
const threshold = fromNow(-1 * HOUR).getTime();
const duplicates = getPotentialDuplicates(account, id => this.getAccountsByBrowserId(id))
.filter(a => a.createdAt && a.createdAt.getTime() < threshold);
if (duplicates.length) {
const server = getLoginServer('login');
const duplicate = duplicates[0];
const accountIsOlder = account.lastVisit && duplicate.lastVisit
&& account.lastVisit.getTime() < duplicate.lastVisit.getTime();
const accountId = accountIsOlder ? duplicate._id : account._id;
const withId = accountIsOlder ? account._id : duplicate._id;
logPerformance(`mergePotentialDuplicates (${Date.now() - start}ms) [yes]`);
await server.api.mergeAccounts(accountId, withId, `by server`, false, true);
return accountId;
}
}
}
this.accountsForPotentialDuplicatesCheck = [];
logPerformance(`mergePotentialDuplicates (${Date.now() - start}ms) [no]`);
return undefined;
}
// helpers
private addEmailToMap(email: string, account: Account) {
addToMap(this.emailMap, emailName(email), account);
}
private removeEmailFromMap(email: string, account: Account) {
removeFromMap(this.emailMap, emailName(email), account);
}
private addNoteRefsToMap(note: string, account: Account) {
for (let id of getIdsFromNote(note)) {
if (id !== account._id) {
addToMap(this.noteRefMap, id, account);
}
}
}
private removeNoteRefsFromMap(note: string, account: Account) {
for (let id of getIdsFromNote(note)) {
if (id !== account._id) {
removeFromMap(this.noteRefMap, id, account);
}
}
}
private addBrowserIdToMap(browserId: string, account: Account) {
addToMap(this.browserIdMap, browserId, account);
}
private removeBrowserIdFromMap(browserId: string, account: Account) {
removeFromMap(this.browserIdMap, browserId, account);
}
private getAccount(id: string | undefined) {
return id ? this.accounts.get(id) : undefined;
}
private getOrCreateOrigin({ ip, country }: OriginInfo): Origin {
return this.origins.get(ip)
|| this.origins.add({ _id: '', ip, country, accounts: [], updatedAt: new Date(0), createdAt: new Date(0) });
}
private assignAccount<T extends { account?: string; }>(item: T, unassigned: T[], action: (account: Account) => void) {
const account = this.getAccount(item.account);
if (account) {
action(account);
} else {
pushUnique(unassigned, item);
}
}
private updateOriginRefs(a: Account) {
if (a.originsRefs) {
for (const o of a.originsRefs) {
removeById(o.origin.accounts!, a._id);
}
}
const oldOriginRefs = a.originsRefs;
a.originsRefs = a.origins.map(o => <OriginRef>{ origin: this.getOrCreateOrigin(o), last: o.last });
sort(a.originsRefs, compareOriginRefs);
for (const o of a.originsRefs) {
if (o.origin.accounts && !includes(o.origin.accounts, a)) {
o.origin.accounts.push(a);
}
}
if (oldOriginRefs) {
for (const o of oldOriginRefs) {
if (!o.origin._id && o.origin.accounts!.length === 0) {
this.origins.removed(o.origin.ip);
} else {
this.origins.trigger(o.origin.ip, o.origin);
}
}
}
if (a.originsList) {
a.originsList.replace(a.originsRefs);
}
}
private assignItems<T extends { account?: string; }>(unassigned: T[], push: (account: Account, item: T) => void) {
remove(unassigned, item => {
const account = item.account && this.getAccount(item.account);
if (account) {
push(account, item);
return true;
} else {
return false;
}
});
}
private createUpdater<T extends { account?: string; }>(
{ add, remove }: {
remove: (account: Account, item: T) => void;
add: (account: Account | undefined, item: T) => void;
}
) {
return (oldItem: T, newItem: T) => {
const oldAccountId = oldItem.account;
const newAccountId = newItem.account;
Object.assign(oldItem, newItem);
if (oldAccountId !== newAccountId) {
const oldAccount = this.getAccount(oldAccountId);
const newAccount = this.getAccount(newAccountId);
if (oldAccount) {
remove(oldAccount, oldItem);
}
add(newAccount, oldItem);
}
};
}
}
function findIndexById<U, T extends { _id: U }>(items: T[], id: U): number {
for (let i = 0; i < items.length; i++) {
if (items[i]._id === id) {
return i;
}
}
return -1;
}
function removeById<U, T extends { _id: U }>(items: T[], id: U): T | undefined {
const index = findIndexById(items, id);
if (index !== -1) {
const item = items[index];
items.splice(index, 1);
return item;
} else {
return undefined;
}
}
+51
View File
@@ -0,0 +1,51 @@
interface Counter<T> {
date: number;
count: number;
items: T[];
}
const zeroCounter: Counter<any> = { date: 0, count: 0, items: [] };
export class CounterService<T> {
private counters = new Map<string, Counter<T>>();
private interval: any;
constructor(private clearTimeout: number) {
}
get(id: string): Counter<T> {
return this.counters.get(id) || zeroCounter;
}
add(id: string, item?: T, count = 1) {
const counter = this.counters.get(id) || { date: 0, count: 0, items: [] };
counter.date = Date.now();
counter.count += count;
if (item) {
counter.items.push(item);
}
this.counters.set(id, counter);
return counter;
}
remove(id: string) {
this.counters.delete(id);
}
cleanup() {
const threshold = Date.now() - this.clearTimeout;
const remove: string[] = [];
this.counters.forEach((value, key) => {
if (value.date < threshold) {
remove.push(key);
}
});
remove.forEach(id => this.remove(id));
}
start() {
this.interval = this.interval || setInterval(() => this.cleanup(), this.clearTimeout / 10);
}
stop() {
clearInterval(this.interval);
this.interval = undefined;
}
}
+207
View File
@@ -0,0 +1,207 @@
import { NotificationFlags, FriendStatusFlags, FriendStatusData } from '../../common/interfaces';
import { HOUR, FRIENDS_LIMIT } from '../../common/constants';
import { AccountFlags } from '../../common/adminInterfaces';
import { hasFlag } from '../../common/utils';
import { IClient } from '../serverInterfaces';
import { addFriend, removeFriend } from '../accountUtils';
import { saySystem } from '../chat';
import { logger } from '../logger';
import { NotificationService } from './notification';
import { ActionLimiter, LimiterResult } from './actionLimiter';
import { updateEntityPlayerState } from '../playerUtils';
import { getEntityName } from '../entityUtils';
export const PENDING_LIMIT = 2;
export const REJECTED_LIMIT = 5;
export const REJECTED_TIMEOUT = 2 * HOUR;
export function isFriend(client: IClient, friend: IClient) {
return client.friends.has(friend.accountId);
}
export function isOnlineFriend(client: IClient, friend: IClient) {
return client.friends.has(friend.accountId) && !friend.accountSettings.hidden;
}
export function toFriendOnline(client: IClient): FriendStatusData {
return {
accountId: client.accountId,
accountName: client.accountName,
status: FriendStatusFlags.Online,
entityId: client.pony.id,
crc: client.pony.crc,
name: client.pony.name,
nameBad: client.pony.nameBad,
info: client.pony.infoSafe,
};
}
export function toFriendOffline(client: IClient): FriendStatusData {
return {
accountId: client.accountId,
accountName: client.accountName,
status: FriendStatusFlags.None,
entityId: 0,
};
}
export function toFriendRemove(client: IClient): FriendStatusData {
return {
accountId: client.accountId,
status: FriendStatusFlags.Remove,
};
}
export function toFriend(client: IClient): FriendStatusData {
if (client.isConnected) {
return toFriendOnline(client);
} else {
return toFriendOffline(client);
}
}
export class FriendsService {
private limiter = new ActionLimiter(REJECTED_TIMEOUT, REJECTED_LIMIT);
private pending = new Map<string, Set<string>>();
constructor(
private notificationService: NotificationService,
private reportInviteLimit: (client: IClient) => void
) {
}
dispose() {
this.limiter.dispose();
}
clientDisconnected(client: IClient) {
for (const key of Array.from(this.pending.keys())) {
const pending = this.pending.get(key)!;
pending.delete(client.accountId);
if (!pending.size) {
this.pending.delete(key);
}
}
}
remove(client: IClient, friend: IClient) {
removeFriend(client.accountId, friend.accountId).catch(e => logger.error(e));
client.friends.delete(friend.accountId);
client.friendsCRC = undefined;
friend.friends.delete(client.accountId);
friend.friendsCRC = undefined;
client.reporter.systemLog(`Removed friend [${friend.accountId}]`);
client.updateFriends([{ accountId: friend.accountId, status: FriendStatusFlags.Remove }], false);
friend.updateFriends([{ accountId: client.accountId, status: FriendStatusFlags.Remove }], false);
updateEntityPlayerState(client, friend.pony);
updateEntityPlayerState(friend, client.pony);
}
removeByAccountId(client: IClient, friendAccountId: string) {
removeFriend(client.accountId, friendAccountId).catch(e => logger.error(e));
client.friends.delete(friendAccountId);
client.friendsCRC = undefined;
client.reporter.systemLog(`Removed friend [${friendAccountId}]`);
client.updateFriends([{ accountId: friendAccountId, status: FriendStatusFlags.Remove }], false);
}
add(client: IClient, target: IClient) {
const can = this.limiter.canExecute(client, target);
if (can === LimiterResult.LimitReached) {
return saySystem(client, 'Reached request rejection limit');
} else if (can !== LimiterResult.Yes) {
return saySystem(client, 'Cannot send request');
}
const pending = this.pending.get(client.accountId) || new Set();
if (pending.has(target.accountId))
return saySystem(client, 'Already sent request');
if (isFriend(client, target))
return saySystem(client, 'Already on friends list');
if (client.friends.size >= FRIENDS_LIMIT)
return saySystem(client, 'Your friend list is full');
if (target.friends.size >= FRIENDS_LIMIT)
return saySystem(client, 'Target player friend list is full');
if (hasFlag(client.account.flags, AccountFlags.BlockFriendRequests))
return saySystem(client, 'Cannot send request');
if (target.accountSettings.ignoreFriendInvites)
return saySystem(client, 'Cannot send request');
if (pending.size >= PENDING_LIMIT)
return saySystem(client, 'Too many pending requests');
const notificationId = this.addInviteNotification(client, target);
if (!notificationId) {
return saySystem(client, 'Cannot send request');
}
pending.add(target.accountId);
this.pending.set(client.accountId, pending);
client.reporter.systemLog(`Friend request [${target.accountId}]`);
}
private acceptInvitation(client: IClient, friend: IClient, notificationId: number) {
client.reporter.systemLog(`Friend request accepted by [${friend.accountId}]`);
saySystem(client, `Friend request accepted by ${getEntityName(friend.pony, client)}`);
addFriend(client.accountId, friend.accountId)
.catch(e => {
if (e.message !== `Friend request already exists`) {
logger.error(e);
}
});
client.friends.add(friend.accountId);
client.friendsCRC = undefined;
friend.friends.add(client.accountId);
friend.friendsCRC = undefined;
this.removePending(client, friend);
this.notificationService.removeNotification(friend, notificationId);
client.updateFriends([toFriend(friend)], false);
friend.updateFriends([toFriend(client)], false);
updateEntityPlayerState(client, friend.pony);
updateEntityPlayerState(friend, client.pony);
}
private rejectInvitation(client: IClient, friend: IClient, notificationId: number) {
client.reporter.systemLog(`Friend request rejected by [${friend.accountId}]`);
saySystem(client, `Friend request rejected by ${getEntityName(friend.pony, client)}`);
this.removePending(client, friend);
this.notificationService.removeNotification(friend, notificationId);
this.countReject(client);
}
private removePending(client: IClient, friend: IClient) {
const pending = this.pending.get(client.accountId);
if (pending) {
pending.delete(friend.accountId);
if (pending.size === 0) {
this.pending.delete(client.accountId);
}
}
}
private countReject(invitedBy: IClient) {
const count = this.limiter.count(invitedBy);
if (count >= REJECTED_LIMIT) {
this.reportInviteLimit(invitedBy);
}
}
private addInviteNotification(client: IClient, friend: IClient) {
const notificationId = this.notificationService.addNotification(friend, {
id: 0,
sender: client,
name: client.pony.name || '',
entityId: client.pony.id,
message: `<div class="text-friends"><b>Friend request</b></div><b>#NAME#</b> wants to add you to their friends`,
flags: NotificationFlags.Accept | NotificationFlags.Reject | NotificationFlags.Ignore |
(client.pony.nameBad ? NotificationFlags.NameBad : 0),
accept: () => this.acceptInvitation(client, friend, notificationId),
reject: () => this.rejectInvitation(client, friend, notificationId),
});
return notificationId;
}
}
+364
View File
@@ -0,0 +1,364 @@
import * as fs from 'fs';
import { Subject } from 'rxjs';
import { HIDE_LIMIT, MINUTE } from '../../common/constants';
import { NotificationFlags } from '../../common/interfaces';
import { IClient, ServerNotification } from '../serverInterfaces';
import { systemMessage, logger } from '../logger';
import { NotificationService } from './notification';
import { includes } from '../../common/utils';
import { isFriend } from './friends';
import { pathTo } from '../paths';
import { addHide } from '../db';
import { HidingStats } from '../../common/adminInterfaces';
import { getEntityName } from '../entityUtils';
import { saySystem } from '../chat';
interface Hide {
by: string;
who: string;
}
const hidePlayerLimit = 'Cannot hide any more players.';
const cannotHidePlayerInParty = 'Cannot hide players from your party.';
const cannotHideFriends = 'Cannot hide friends.';
const unhideAllLimit = 'Cannot unhide hidden players, try again later.';
const unhideAllLimitNote = 'You can only do this once per hour.';
function clientInfo({ accountId, account, characterName }: IClient) {
return `${characterName} (${account.name}) [${accountId}]`;
}
function simpleNotification(message: string, note?: string): ServerNotification {
return { id: 0, name: '', message, note, flags: NotificationFlags.Ok };
}
export function hidingDataPath(serverId: string) {
return pathTo('settings', `hiding-${serverId}.json`);
}
export async function saveHidingData(hiding: HidingService, serverId: string) {
if (!TESTS) {
try {
const data = hiding.serialize();
await fs.writeFileAsync(hidingDataPath(serverId), data, 'utf8');
} catch (e) {
logger.error(e);
}
}
}
export function pollHidingDataSave(hiding: HidingService, serverId: string) {
setInterval(() => saveHidingData(hiding, serverId), 10 * MINUTE);
}
export class HidingService {
changes = new Subject<Hide>();
unhidesAll = new Subject<string>();
private hides = new Map<string, Map<string, number>>();
private unhides = new Map<string, number>();
private interval: any;
constructor(
private clearUnhides: number,
private notifications: NotificationService,
private findClient: (accountId: string) => IClient | undefined,
private log: (message: string) => void,
) {
}
serialize() {
const hides: any = {};
const unhides: any = {};
this.hides.forEach((hidesMap, by) => {
const list: any = {};
hidesMap.forEach((value, key) => list[key] = value);
hides[by] = list;
});
this.unhides.forEach((value, key) => unhides[key] = value);
return JSON.stringify({ hides, unhides });
}
deserialize(data: string) {
try {
const { hides, unhides } = JSON.parse(data);
for (const by of Object.keys(hides)) {
const hidesMap = new Map<string, number>();
for (const key of Object.keys(hides[by])) {
hidesMap.set(key, hides[by][key]);
}
this.hides.set(by, hidesMap);
}
for (const key of Object.keys(unhides)) {
this.unhides.set(key, unhides[key]);
}
this.cleanup();
} catch (e) {
logger.error(e);
}
}
getStatsFor(account: string): HidingStats {
const hides = this.hides.get(account);
const hidden = hides ? Array.from(hides.keys()) : [];
const hiddenBy: string[] = [];
this.hides.forEach((hides, by) => {
if (hides.has(account)) {
hiddenBy.push(by);
}
});
return { account, hidden, hiddenBy, permaHidden: [], permaHiddenBy: [] };
}
connected(client: IClient) {
const hides = this.hides.get(client.accountId);
if (hides) {
for (const id of Array.from(hides.keys())) {
client.hides.add(id);
}
}
}
requestHide(requester: IClient, target: IClient, timeout: number) {
const hides = this.hides.get(requester.accountId);
const count = hides && hides.size || 0;
if (requester.accountId === target.accountId) {
saySystem(requester, `Cannot hide yourself`);
} else if (requester.party && includes(requester.party.clients, target)) {
this.notifications.addNotification(requester, simpleNotification(cannotHidePlayerInParty));
} else if (isFriend(requester, target)) {
this.notifications.addNotification(requester, simpleNotification(cannotHideFriends));
} else if (count >= HIDE_LIMIT) {
this.notifications.addNotification(requester, simpleNotification(hidePlayerLimit));
} else {
this.notifications.addNotification(requester, {
id: 0,
name: target.pony.name || '',
entityId: target.pony.id,
message: `Are you sure you want to hide <b>#NAME#</b> ?`,
flags: NotificationFlags.Yes | NotificationFlags.No | (target.pony.nameBad ? NotificationFlags.NameBad : 0),
accept: () => this.confirmHide(requester, target, timeout),
});
}
}
requestUnhideAll(requester: IClient) {
const unhideTimestamp = this.unhides.get(requester.accountId) || 0;
if (unhideTimestamp > Date.now()) {
this.notifications.addNotification(requester, simpleNotification(unhideAllLimit, unhideAllLimitNote));
} else {
this.notifications.addNotification(requester, {
id: 0,
name: '',
message: 'Are you sure you want to unhide all temporarily hidden players ?',
note: 'You can only do this once per hour. This action will require re-joining the game.',
flags: NotificationFlags.Yes | NotificationFlags.No,
accept: () => this.unhideAll(requester),
});
}
}
confirmHide(requester: IClient, target: IClient, timeout: number) {
if (this.hide(requester, target, timeout)) {
let message = `${requester.characterName} (${requester.account.name}) hides ${clientInfo(target)}`;
if (timeout === 0) {
message += ' (permanent)';
}
this.log(systemMessage(requester.accountId, message));
}
}
private isHiddenInner(who: string, from: string): boolean {
const hides = this.hides.get(who);
return hides !== undefined && hides.has(from);
}
isHidden(who: string, from: string): boolean {
return this.isHiddenInner(who, from) || this.isHiddenInner(from, who);
}
isHiddenClient(who: IClient, from: IClient) {
return this.isHidden(who.accountId, from.accountId);
}
hide(byClient: IClient, whoClient: IClient, timeout: number) {
const by = byClient.accountId;
const who = whoClient.accountId;
if (timeout === 0) { // permanent
addHide(by, who, getEntityName(whoClient.pony, byClient) || '[none]')
.then(() => {
byClient.permaHides.add(who);
this.notify([{ by, who }]);
})
.catch(e => logger.error(e));
return true;
} else {
if (by === who)
return false;
if (this.isHiddenInner(by, who))
return false;
const hides = this.hides.get(by) || new Map<string, number>();
hides.set(who, Date.now() + timeout);
this.hides.set(by, hides);
byClient.hides.add(who);
this.notify([{ by, who }]);
return true;
}
}
// TODO: remove ?
unhide(byClient: IClient, whoClient: IClient) {
const by = byClient.accountId;
const who = whoClient.accountId;
const hides = this.hides.get(by);
if (hides) {
if (hides.has(who)) {
hides.delete(who);
if (hides.size === 0) {
this.hides.delete(by);
}
byClient.hides.delete(who);
this.notify([{ by, who }]);
}
}
}
unhideAll(byClient: IClient) {
const by = byClient.accountId;
if (this.unhides.has(by))
return;
const hides = this.hides.get(by);
if (hides) {
const notify: Hide[] = [];
hides.forEach((_, who) => notify.push({ by, who }));
this.hides.delete(by);
this.unhides.set(by, Date.now() + this.clearUnhides);
byClient.hides.clear();
this.notify(notify);
this.unhidesAll.next(by);
}
this.log(systemMessage(by, 'unhide all'));
}
merged(target: string, merge: string) {
const targetHides = this.hides.get(target);
const mergeHides = this.hides.get(merge);
const notify: Hide[] = [];
if (targetHides) {
targetHides.delete(merge);
}
if (mergeHides) {
const targetClient = this.findClient(target);
mergeHides.delete(target);
if (targetHides) {
for (const id of Array.from(mergeHides.keys())) {
const who = targetHides.get(id);
targetHides.set(id, Math.max(who || 0, mergeHides.get(id)!));
targetClient && targetClient.hides.add(id);
if (!who) {
notify.push({ by: target, who: id });
notify.push({ by: merge, who: id });
}
}
} else {
this.hides.set(target, mergeHides);
for (const id of Array.from(mergeHides.keys())) {
targetClient && targetClient.hides.add(id);
notify.push({ by: target, who: id });
notify.push({ by: merge, who: id });
}
}
this.hides.delete(merge);
}
const targetUnhides = this.unhides.get(target);
const mergeUnhides = this.unhides.get(merge);
if (mergeUnhides) {
this.unhides.set(target, Math.max(targetUnhides || 0, mergeUnhides));
this.unhides.delete(merge);
}
this.hides.forEach((_, by) => {
const hides = this.hides.get(by)!;
const mergeHide = hides.get(merge);
if (mergeHide) {
hides.set(target, Math.max(mergeHide, hides.get(target) || 0));
hides.delete(merge);
const client = this.findClient(by);
if (client) {
client.hides.delete(merge);
if (target !== by) {
client && client.hides.add(target);
}
}
notify.push({ by, who: target });
notify.push({ by, who: merge });
}
});
this.notify(notify);
}
cleanup() {
const now = Date.now();
const notify: Hide[] = [];
for (const by of Array.from(this.hides.keys())) {
const hides = this.hides.get(by)!;
for (const who of Array.from(hides.keys())) {
if (hides.get(who)! < now) {
hides.delete(who);
const client = this.findClient(by);
client && client.hides.delete(who);
notify.push({ by, who });
}
}
if (hides.size === 0) {
this.hides.delete(by);
}
}
this.notify(notify);
for (const key of Array.from(this.unhides.keys())) {
if (this.unhides.get(key)! < now) {
this.unhides.delete(key);
}
}
}
start() {
this.interval = this.interval || setInterval(() => this.cleanup(), 10 * MINUTE);
}
stop() {
clearInterval(this.interval);
this.interval = undefined;
}
private notify(hides: Hide[]) {
for (const hide of hides) {
this.changes.next(hide);
}
}
}
+200
View File
@@ -0,0 +1,200 @@
import { Model } from 'mongoose';
import { removeItem, maxDate } from '../../common/utils';
import { logger as defaultLogger } from '../logger';
import { Document } from '../../common/adminInterfaces';
import { iterate } from '../db';
const tickInterval = 1000;
type Listener = (id: string, item: any) => void;
export interface LiveListConfig<T> {
fields: (keyof T)[];
noStore?: boolean;
ignore?: (item: T) => boolean;
clean: (item: T) => Partial<T>; // clean before sending to client
fix?: (item: T) => void; // fix after getting from DB
onSubscribeToMissing?: (id: string) => T;
// events
onAdd?: (item: T) => void;
onUpdate?: (oldItem: T, newItem: T) => void;
onDelete?: (item: T) => void;
onFinished?: () => void;
onAddedOrUpdated?: () => void;
}
function fixDocumentId<T extends Document>(item: T) {
item._id = item._id.toString();
return item;
}
export class LiveList<T extends Document> {
items: T[] = [];
private itemsMap = new Map<string, T>();
private listeners = new Map<string, Listener[]>();
private timestamp = new Date(0);
private finished = false;
private running = false;
private timeout: any;
private fieldsString: string;
constructor(
private model: Model<any>,
private config: LiveListConfig<T>,
private getId = (item: T) => item._id,
private logger = defaultLogger,
) {
this.fieldsString = config.fields.join(' ');
}
get loaded() {
return this.finished;
}
start() {
if (this.config.noStore) {
this.timestamp = new Date();
}
this.running = true;
this.tick();
}
stop() {
this.running = false;
clearTimeout(this.timeout);
}
get(id: string) {
return this.itemsMap.get(id);
}
for(id: string | undefined, callback: (item: T) => void) {
const item = id ? this.get(id) : undefined;
item && callback(item);
}
add(item: T) {
const id = this.getId(item);
this.items.push(item);
this.itemsMap.set(id, item);
if (this.config.onAdd) {
this.config.onAdd(item);
}
this.trigger(id, item);
return item;
}
// NOTE: only _id
async remove(id: string) {
await this.model.deleteOne({ _id: id }).exec();
this.removed(id);
}
removed(id: string) {
const item = this.get(id);
if (item) {
this.trigger(id, undefined);
this.itemsMap.delete(id);
removeItem(this.items, item);
this.config.onDelete && this.config.onDelete(item);
}
}
discard(id: string) {
const item = this.get(id);
if (item) {
this.itemsMap.delete(id);
removeItem(this.items, item);
}
}
trigger(id: string, item: T | undefined) {
const listeners = this.listeners.get(id);
if (listeners) {
const cleaned = item ? this.config.clean(item) : item;
listeners.forEach(listener => listener(id, cleaned as any));
}
}
subscribe(id: string, listener: Listener) {
const listeners = this.listeners.get(id) || [];
listeners.push(listener);
this.listeners.set(id, listeners);
const item = this.get(id);
if (item) {
listener(id, this.config.clean(item));
} else if (this.config.onSubscribeToMissing) {
this.add(this.config.onSubscribeToMissing(id));
}
return {
unsubscribe: () => {
const listeners = this.listeners.get(id) || [];
removeItem(listeners, listener);
if (listeners.length === 0) {
this.listeners.delete(id);
}
}
};
}
hasSubscriptions(id: string) {
return !!this.listeners.get(id);
}
async tick() {
if (this.running) {
clearTimeout(this.timeout);
try {
await this.update();
} catch (e) {
this.logger.error(e);
} finally {
this.timeout = setTimeout(() => this.tick(), tickInterval);
}
}
}
async fetch(search: any) {
await this.internalUpdate(search, true);
}
async update() {
await this.internalUpdate({ updatedAt: { $gt: this.timestamp } }, false);
}
private async internalUpdate(search: any, fetching: boolean) {
const query = this.model.find(search, this.fieldsString);
const applyUpdate = this.config.onUpdate || Object.assign;
let addedOrUpdated = false;
await iterate(query.lean(), update => {
try {
fixDocumentId(update);
if (this.config.fix) {
this.config.fix(update);
}
if (!fetching) {
this.timestamp = maxDate(this.timestamp, update.updatedAt)!;
}
const doc = this.get(this.getId(update));
if (doc !== undefined) {
applyUpdate(doc, update);
this.trigger(this.getId(doc), doc);
} else if (fetching || !(this.config.ignore && this.config.ignore(update))) {
this.add(update);
}
addedOrUpdated = true;
} catch (e) {
console.error(e);
}
});
if (addedOrUpdated && this.config.onAddedOrUpdated) {
this.config.onAddedOrUpdated();
}
if (!this.finished) {
this.finished = true;
this.config.onFinished && this.config.onFinished();
}
}
}
+71
View File
@@ -0,0 +1,71 @@
import { findById, removeById } from '../../common/utils';
import { IClient, ServerNotification } from '../serverInterfaces';
const NOTIFICATION_LIMIT = 10;
function getId(notifications: ServerNotification[]) {
for (let id = 1; id <= 0xffff; id++) {
if (!findById(notifications, id)) {
return id;
}
}
/* istanbul ignore next */
throw new Error('Unable to get unique id for notification');
}
function hasNotification(client: IClient, notification: ServerNotification) {
return client.notifications.some(n =>
n.message === notification.message &&
n.flags === notification.flags &&
n.note === notification.note &&
n.sender === notification.sender &&
n.entityId === notification.entityId);
}
export class NotificationService {
addNotification(client: IClient, notification: ServerNotification) {
if (client.notifications.length >= NOTIFICATION_LIMIT || hasNotification(client, notification)) {
return 0;
} else {
notification.id = getId(client.notifications);
client.notifications.push(notification);
const { id, entityId = 0, name, message, note = '', flags = 0 } = notification;
client.addNotification(id, entityId, name, message, note, flags);
return notification.id;
}
}
removeNotification(client: IClient, id: number) {
if (removeById(client.notifications, id)) {
client.removeNotification(id);
return true;
} else {
return false;
}
}
acceptNotification(client: IClient, id: number) {
const notification = findById(client.notifications, id);
this.removeNotification(client, id);
if (notification && notification.accept) {
notification.accept();
}
}
rejectNotification(client: IClient, id: number) {
const notification = findById(client.notifications, id);
this.removeNotification(client, id);
if (notification && notification.reject) {
notification.reject();
}
}
rejectAll(client: IClient) {
client.notifications.slice()
.forEach(n => this.rejectNotification(client, n.id));
}
dismissAll(client: IClient) {
while (client.notifications.length) {
this.removeNotification(client, client.notifications[0].id);
}
}
}
+48
View File
@@ -0,0 +1,48 @@
import { removeItem } from '../../common/utils';
import { ListListener, IObservableList } from '../../common/adminInterfaces';
import { pushOrdered } from '../../common/adminUtils';
export class ObservableList<T, V> implements IObservableList<T, V> {
private listeners: ListListener<V>[] = [];
constructor(private list: T[], private map: (item: T) => V) {
}
hasSubscribers() {
return this.listeners.length > 0;
}
trigger() {
if (this.listeners.length) {
const items = this.list.map(this.map);
for (const listener of this.listeners) {
listener(items);
}
}
}
push(item: T) {
this.list.push(item);
this.trigger();
}
pushOrdered(item: T, compare: (a: T, b: T) => number) {
pushOrdered(this.list, item, compare);
this.trigger();
}
remove(item: T) {
const removed = removeItem(this.list, item);
this.trigger();
return removed;
}
replace(list: T[]) {
this.list = list;
this.trigger();
}
subscribe(listener: ListListener<V>) {
this.listeners.push(listener);
this.trigger();
return {
unsubscribe: () => {
removeItem(this.listeners, listener);
}
};
}
}
+317
View File
@@ -0,0 +1,317 @@
import { Subject } from 'rxjs';
import { remove } from 'lodash';
import { PartyFlags, NotificationFlags } from '../../common/interfaces';
import { AccountFlags } from '../../common/adminInterfaces';
import { removeItem, hasFlag, includes } from '../../common/utils';
import { PARTY_LIMIT, HOUR, SECOND } from '../../common/constants';
import { ServerParty, IClient } from '../serverInterfaces';
import { NotificationService } from './notification';
import { ActionLimiter, LimiterResult } from './actionLimiter';
import { saySystem } from '../chat';
import { isFriend } from './friends';
export const LEADER_TIMEOUT = 5 * SECOND;
export const INVITE_LIMIT = 5;
export const INVITE_REJECTED_LIMIT = 5;
export const INVITE_REJECTED_TIMEOUT = 1 * HOUR;
function toPartyMember(client: IClient, pending: boolean, leader: boolean): [number, PartyFlags] {
const flags = (pending ? PartyFlags.Pending : 0)
| (leader ? PartyFlags.Leader : 0)
| (client.offline ? PartyFlags.Offline : 0);
return [client.pony.id, flags];
}
function findClientInParties(parties: ServerParty[], accountId: string) {
for (const party of parties) {
for (let index = 0; index < party.clients.length; index++) {
if (party.clients[index].accountId === accountId) {
return { party, index };
}
}
}
return { party: undefined, index: 0 };
}
export class PartyService {
parties: ServerParty[] = [];
partyChanged = new Subject<IClient>();
private id = 0;
private limiter = new ActionLimiter(INVITE_REJECTED_TIMEOUT, INVITE_REJECTED_LIMIT);
constructor(
private notificationService: NotificationService,
private reportInviteLimit: (client: IClient) => void,
) {
}
dispose() {
this.limiter.dispose();
}
clientConnected(client: IClient) {
const { party, index } = findClientInParties(this.parties, client.accountId);
if (party) {
const existing = party.clients[index];
party.clients[index] = client;
client.party = party;
if (party.leader === existing) {
party.leader = client;
clearTimeout(party.leaderTimeout);
}
existing.party = undefined;
existing.offlineAt = new Date();
this.sendPartyUpdateToAll(party);
}
}
clientDisconnected(client: IClient) {
const party = client.party;
if (party) {
this.sendPartyUpdateToAll(party);
party.leaderTimeout = setTimeout(() => {
const newLeader = party.clients.find(c => c !== client && !c.offline);
if (newLeader) {
this.promoteLeader(client, newLeader);
} else {
this.destroyParty(party);
}
}, LEADER_TIMEOUT);
} else {
const pendingParty = this.parties.find(p => p.pending.some(x => x.client === client));
if (pendingParty) {
remove(pendingParty.pending, x => x.client === client);
this.sendPartyUpdateToAll(pendingParty);
}
}
}
remove(leader: IClient, client: IClient) {
const party = leader.party;
if (!party || party.leader !== leader)
return;
if (includes(party.clients, client)) {
removeItem(party.clients, client);
client.party = undefined;
if (party.leader === client && party.clients[0]) {
party.leader = party.clients[0];
}
client.updateParty(undefined);
this.sendPartyUpdateToAll(party);
this.partyChanged.next(client);
} else {
const pending = party.pending.find(p => p.client === client);
if (pending) {
leader.reporter.systemLog(`Invite cancelled for [${client.accountId}]`);
removeItem(party.pending, pending);
this.notificationService.removeNotification(pending.client, pending.notificationId);
this.sendPartyUpdateToAll(party);
this.countReject(leader);
}
}
this.cleanupParty(party);
}
invite(leader: IClient, client: IClient) {
let party = leader.party;
const can = this.limiter.canExecute(leader, client);
if (can === LimiterResult.LimitReached) {
return saySystem(leader, 'Reached invite rejection limit');
} else if (can !== LimiterResult.Yes) {
return saySystem(leader, 'Cannot invite');
}
if (client.shadowed)
return saySystem(leader, 'Cannot invite');
if (hasFlag(leader.account.flags, AccountFlags.BlockPartyInvites))
return saySystem(leader, 'Cannot invite');
if (party && party.leader !== leader)
return saySystem(leader, 'You need to be party leader');
if (party && (party.clients.length + party.pending.length) >= PARTY_LIMIT)
return saySystem(leader, 'Party is full');
if (client.party)
return saySystem(leader, 'Already in a party');
if (party && party.pending.some(p => p.client === client))
return saySystem(leader, 'Already invited');
if (client.accountSettings.ignorePartyInvites && !isFriend(client, leader))
return saySystem(leader, 'Cannot invite');
if (this.parties.reduce((sum, p) => sum + p.pending.filter(x => x.client === client).length, 0) >= INVITE_LIMIT)
return saySystem(leader, 'Too many pending invites');
const partyExisted = !!leader.party;
if (!partyExisted) {
party = this.createParty(leader);
}
/* istanbul ignore next */
if (!party)
throw new Error(`Party not created`);
const notificationId = this.addInviteNotification(client, leader, party);
if (!notificationId) {
if (!partyExisted) {
leader.party = undefined;
removeItem(this.parties, party);
}
return saySystem(leader, 'Cannot invite');
}
party.pending.push({ client, notificationId });
this.sendPartyUpdateToAll(party);
leader.reporter.systemLog(`Invite to party [${client.accountId}]`);
if (!partyExisted) {
this.partyChanged.next(leader);
}
}
leave(client: IClient) {
if (client.party) {
this.remove(client.party.leader, client);
}
}
promoteLeader(leader: IClient, client: IClient) {
const party = leader.party;
if (!party)
return;
if (leader === client)
return;
if (client.offline)
return saySystem(leader, 'Player is offline');
if (party.leader !== leader)
return saySystem(leader, 'You need to be party leader');
if (!includes(party.clients, client))
return saySystem(leader, 'Not in the party');
party.leader = client;
this.sendPartyUpdateToAll(party);
}
cleanupParties() {
const now = Date.now();
for (let i = this.parties.length - 1; i >= 0; i--) {
const party = this.parties[i];
if (party.clients.every(c => c.offline)) {
party.cleanup = party.cleanup || now;
if ((now - party.cleanup) > (10 * SECOND)) {
this.destroyParty(party);
}
} else if (party.cleanup !== undefined) {
party.cleanup = undefined;
}
}
}
private createParty(leader: IClient) {
const party: ServerParty = {
id: `party-${this.id++}`,
leader,
clients: [leader],
pending: [],
};
leader.party = party;
this.parties.push(party);
return party;
}
private destroyParty(party: ServerParty) {
const clients = party.clients;
clients.forEach(c => c.party = undefined);
clients.forEach(c => c.updateParty(undefined));
party.pending.forEach(p => this.notificationService.removeNotification(p.client, p.notificationId));
party.clients = [];
party.pending = [];
removeItem(this.parties, party);
clients.forEach(c => this.partyChanged.next(c));
}
private sendPartyUpdate(client: IClient, party: ServerParty) {
const clients = party.clients.map(c => toPartyMember(c, false, c === party.leader));
const pending = party.pending.map(c => toPartyMember(c.client, true, false));
client.updateParty([...clients, ...pending]);
}
private sendPartyUpdateToAll(party: ServerParty) {
party.clients
.filter(c => !c.offline)
.forEach(c => this.sendPartyUpdate(c, party));
}
private cleanupParty(party: ServerParty) {
if (party.clients.length === 0 || (party.clients.length + party.pending.length) <= 1) {
this.destroyParty(party);
}
}
private acceptInvitation(party: ServerParty, client: IClient, invitedBy: IClient) {
const removed = remove(party.pending, p => p.client === client)[0];
if (!client.party && removed) {
party.leader.reporter.systemLog(`Invite accepted by [${client.accountId}]`);
party.clients.push(client);
client.party = party;
this.notificationService.removeNotification(client, removed.notificationId);
this.sendPartyUpdateToAll(party);
this.parties
.filter(p => p.pending.some(x => x.client === client))
.forEach(p => this.rejectInvitation(p, client, invitedBy));
this.partyChanged.next(client);
}
}
private rejectInvitation(party: ServerParty, client: IClient, invitedBy: IClient) {
const removed = remove(party.pending, p => p.client === client)[0];
if (removed) {
party.leader.reporter.systemLog(`Invite rejected by [${client.accountId}]`);
this.notificationService.removeNotification(client, removed.notificationId);
this.sendPartyUpdateToAll(party);
this.cleanupParty(party);
this.countReject(invitedBy);
}
}
private countReject(invitedBy: IClient) {
const count = this.limiter.count(invitedBy);
if (count >= INVITE_REJECTED_LIMIT) {
this.reportInviteLimit(invitedBy);
}
}
private addInviteNotification(client: IClient, leader: IClient, party: ServerParty) {
return this.notificationService.addNotification(client, {
id: 0,
sender: leader,
name: leader.pony.name || '',
entityId: leader.pony.id,
message: `<div class="text-party"><b>Party invite</b></div><b>#NAME#</b> invited you to a party`,
flags: NotificationFlags.Accept | NotificationFlags.Reject | NotificationFlags.Ignore |
(client.pony.nameBad ? NotificationFlags.NameBad : 0),
accept: () => this.acceptInvitation(party, client, leader),
reject: () => this.rejectInvitation(party, client, leader),
});
}
}
+127
View File
@@ -0,0 +1,127 @@
import { groupBy, toPairs, compact } from 'lodash';
import { IClient } from '../serverInterfaces';
import { NotificationService } from './notification';
import { NotificationFlags, SupporterInvite } from '../../common/interfaces';
import { systemMessage } from '../logger';
import { UserError } from '../userError';
import { compareDates, fromNow, flatten } from '../../common/utils';
import { DAY, HOUR } from '../../common/constants';
import { Model } from 'mongoose';
import { ISupporterInvite, IAccount } from '../db';
import { ActionLimiter, LimiterResult } from './actionLimiter';
import { saySystem } from '../chat';
import { getSupporterInviteLimit } from '../accountUtils';
export const INVITE_REJECTED_TIMEOUT = 1 * HOUR;
export const INVITE_REJECTED_LIMIT = 5;
function formatMessage(requester: IClient, target: IClient, message: string) {
const requesterInfo = `${requester.characterName} (${requester.account.name})`;
const targetInfo = `${target.characterName} (${target.account.name}) [${target.accountId}]`;
return systemMessage(requester.accountId, `${requesterInfo} ${message} ${targetInfo}`);
}
export class SupporterInvitesService {
private limiter = new ActionLimiter(INVITE_REJECTED_TIMEOUT, INVITE_REJECTED_LIMIT);
constructor(
private model: Model<ISupporterInvite>,
private notifications: NotificationService,
private log: (message: string) => void,
) {
}
dispose() {
this.limiter.dispose();
}
async getInvites(source: IClient): Promise<SupporterInvite[]> {
const items = await this.model.find({ source: source.account._id }).exec();
return items.map(({ _id, name, info, active }) => ({ id: _id.toString(), name, info, active }));
}
async isInvited(target: IClient): Promise<boolean> {
const count = await this.model.countDocuments({ target: target.account._id, active: true }).exec();
return count > 0;
}
async requestInvite(requester: IClient, target: IClient) {
const items = await this.getInvites(requester);
const limit = getSupporterInviteLimit(requester.account);
if (items.length >= limit)
return saySystem(requester, 'Invite limit reached');
if (this.limiter.canExecute(requester, target) !== LimiterResult.Yes)
return saySystem(requester, 'Cannot invite');
this.log(formatMessage(requester, target, 'invited to supporter server'));
this.notifications.addNotification(target, {
id: 0,
sender: requester,
name: requester.pony.name || '',
entityId: requester.pony.id,
message: `<b>#NAME#</b> invited you to supporter servers`,
flags: NotificationFlags.Accept | NotificationFlags.Reject | NotificationFlags.Ignore |
(requester.pony.nameBad ? NotificationFlags.NameBad : 0),
accept: () => this.acceptInvite(requester, target),
reject: () => this.rejectInvite(requester, target),
});
}
acceptInvite(requester: IClient, target: IClient) {
this.log(formatMessage(requester, target, 'supporter invite accepted by'));
this.invite(requester, target);
}
rejectInvite(requester: IClient, target: IClient) {
this.log(formatMessage(requester, target, 'supporter invite rejected by'));
this.limiter.count(requester);
}
async invite(requester: IClient, target: IClient) {
const limit = getSupporterInviteLimit(requester.account);
const items = await this.getInvites(requester);
if (items.length >= limit) {
throw new UserError('Invite limit reached');
}
await this.model.create({
source: requester.account._id,
target: target.account._id,
name: target.characterName,
info: target.character.info,
active: true,
});
}
uninvite(requester: IClient, inviteId: string) {
return Promise.resolve(this.model.deleteOne({ _id: inviteId, source: requester.account._id }).exec());
}
}
type LeanInvite = ISupporterInvite & { source: IAccount };
export async function updateSupporterInvites(model: Model<ISupporterInvite>) {
const invites: LeanInvite[] = await model.find({}, '_id active')
.populate('source', '_id supporter patreon roles')
.lean()
.exec();
const itemsBySource = toPairs(groupBy(invites, i => i.source._id as string));
const itemsToUpdate = itemsBySource
.map(([_, items]) => {
const source = items[0].source;
const limit = getSupporterInviteLimit(source);
return compact(items
.sort((a, b) => compareDates(a.createdAt, b.createdAt))
.map((item, i) => {
const active = i < limit;
return item.active === active ? undefined : { id: item._id, active };
}));
});
const groups = toPairs(groupBy(flatten(itemsToUpdate), i => i.active));
await Promise.all(groups.map(([_, items]) => {
const active = items[0].active;
const ids = items.map(i => i.id);
return model.updateMany({ _id: { $in: ids } }, { active }).exec();
}));
await model.deleteMany({ active: false, updatedAt: { $lt: fromNow(-100 * DAY) } }).exec();
}
+45
View File
@@ -0,0 +1,45 @@
import { readFileAsync, writeFileAsync } from 'fs';
import { Settings } from '../common/adminInterfaces';
import { cloneDeep } from '../common/utils';
import * as paths from './paths';
const defaultSettings: Settings = {
canCreateAccounts: true,
servers: {},
};
export const settings: Settings = cloneDeep(defaultSettings);
const settingsPath = paths.pathTo('settings', `settings.json`);
/* istanbul ignore next */
export async function loadSettings() {
const json = await readFileAsync(settingsPath, 'utf8');
return JSON.parse(json) as Settings;
}
/* istanbul ignore next */
export async function saveSettings(settings: Settings) {
const json = JSON.stringify(settings, undefined, 2);
await writeFileAsync(settingsPath, json, 'utf8');
}
/* istanbul ignore next */
export async function updateSettings(update: Partial<Settings>) {
let settings = { ...defaultSettings };
try {
settings = await loadSettings();
} catch { }
Object.assign(settings, update);
await saveSettings(settings);
}
/* istanbul ignore next */
export async function reloadSettings() {
try {
const current = await loadSettings();
Object.assign(settings, current);
} catch { }
}
+148
View File
@@ -0,0 +1,148 @@
import { ReportAccount, TimeoutAccount, IClient, LastSay, OnMessageSettings } from './serverInterfaces';
import { CounterService } from './services/counter';
import { fromNow } from '../common/utils';
import { SPAM_TIMEOUT } from './reporting';
import { isMutedOrShadowed } from './playerUtils';
import { handlePromiseDefault } from './serverUtils';
import { SECOND } from '../common/constants';
import { GameServerSettings } from '../common/adminInterfaces';
const TINY_MESSAGE_LENGTH = 3;
const SHORT_MESSAGE_LENGTH = 8;
const LONG_MESSAGE_LENGTH = 50;
export const MULTIPLE_MATCH_COUNT = 5;
export const REPORT_AFTER_LIMIT = 5;
export const MUTE_AFTER_LIMIT = 7;
export const TINY_MESSAGE_MUL = 1.5; // was 4
export const SHORT_MESSAGE_MUL = 1.5; // was 2
export const LONG_MESSAGE_MUL = 0.75;
export const RAPID_MESSAGE_COUNT = 35;
export const RAPID_MESSAGE_TIMEOUT = 30 * SECOND;
export const createSpamChecker =
(
spamCounter: CounterService<string>, rapidCounter: CounterService<number>, countSpamming: ReportAccount,
timeoutAccount: TimeoutAccount, handlePromise = handlePromiseDefault,
): OnMessageSettings => {
async function countAndTimeout(
client: IClient, timeout: boolean, message: string, items: string[], settings: GameServerSettings
) {
const timeoutTime = fromNow(SPAM_TIMEOUT * (settings.doubleTimeouts ? 2 : 1));
await countSpamming(client.accountId);
if (!isMutedOrShadowed(client)) {
if (timeout && settings.autoBanSpamming) {
await timeoutAccount(client.accountId, timeoutTime, 'Timed out for spamming');
if (settings.reportSpam) {
client.reporter.system('Timed out for spamming', items.join('\n'));
} else {
client.reporter.systemLog('Timed out for spamming');
}
} else if (settings.reportSpam && !ignoreReporting(message)) {
client.reporter.warn('Spam', message);
}
}
}
async function countAndTimeoutForSpam(client: IClient, message: string, settings: GameServerSettings) {
const increment = message.length >= LONG_MESSAGE_LENGTH ? 2 : 1;
const { count, items } = spamCounter.add(client.accountId, message, increment);
const timeout = count >= MUTE_AFTER_LIMIT;
await countAndTimeout(client, timeout, message, items, settings);
}
return (client, message, settings) => {
if (client.isMod)
return;
if (message === '.')
return;
const lastSays = client.lastSays;
const lastMatch = findLastSayByPartialString(lastSays, message);
if (lastMatch) {
lastMatch.count++;
lastMatch.age = 0;
const spamLimit = REPORT_AFTER_LIMIT * getLengthMultiplier(message);
if (lastMatch.count >= spamLimit) {
lastMatch.count = 0;
handlePromise(countAndTimeoutForSpam(client, message, settings), client.reporter.error);
}
} else {
if (lastSays.length < MULTIPLE_MATCH_COUNT) {
lastSays.push({ message, count: 1, age: 0 });
} else {
lastSays.sort(byAge);
const lastSay = lastSays[lastSays.length - 1];
lastSay.message = message;
lastSay.count = 1;
lastSay.age = 0;
}
}
for (const say of lastSays) {
say.age++;
}
const now = Date.now();
const threshold = now - RAPID_MESSAGE_TIMEOUT;
const counter = rapidCounter.add(client.accountId, now);
while (counter.items.length && counter.items[0] < threshold) {
counter.items.shift();
counter.count--;
}
if (counter.count > RAPID_MESSAGE_COUNT) {
countAndTimeout(client, true, 'rapid messages', ['rapid messages'], settings);
rapidCounter.remove(client.accountId);
}
};
};
function findLastSayByPartialString(lastSays: LastSay[], message: string) {
for (const say of lastSays) {
if (partialString(say.message, message)) {
return say;
}
}
return undefined;
}
function byAge(a: LastSay, b: LastSay) {
return b.age - a.age;
}
function ignoreReporting(message: string) {
return message.length <= 3 || /^[aаz]+$|^\/roll/i.test(message);
}
function partialString(a: string, b: string): boolean {
if (a === b) {
return true;
} else {
const length = Math.floor(Math.min(a.length, b.length) * 0.75);
return length > 8 && a.substr(0, length) === b.substr(0, length);
}
}
function getLengthMultiplier(message: string) {
if (message.length >= LONG_MESSAGE_LENGTH) {
return LONG_MESSAGE_MUL;
} else if (message.length <= TINY_MESSAGE_LENGTH) {
return TINY_MESSAGE_MUL;
} else if (message.length <= SHORT_MESSAGE_LENGTH) {
return SHORT_MESSAGE_MUL;
} else {
return 1;
}
}
+105
View File
@@ -0,0 +1,105 @@
import { readFileSync } from 'fs';
import * as ctrl from './controllers';
import { World, goToMap } from './world';
import { create } from './reporter';
import { logger } from './logger';
import { SERVER_FPS } from '../common/constants';
import { ServerConfig } from '../common/adminInterfaces';
import { timingReset, timingStart, timingEnd } from './timing';
import { initializeTileHeightmaps } from '../client/tileUtils';
import { normalSpriteSheet } from '../generated/sprites';
import { pathTo } from './paths';
import { createMainMap } from './maps/mainMap';
import { createIslandMap } from './maps/islandMap';
import { createHouseMap } from './maps/houseMap';
import { createPaletteMap } from './maps/paletteMap';
import { createCaveMap } from './maps/caveMap';
import { createCustomMap } from './maps/customMap';
import { createSign } from './controllerUtils';
import { signQuestion } from '../common/entities';
export function start(world: World, server: ServerConfig) {
const data = readFileSync(pathTo('src', 'ts', 'generated', 'pony.bin'));
normalSpriteSheet.data = {
width: 512,
height: 512,
data: new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength),
};
initializeTileHeightmaps();
world.maps.push(createMainMap(world));
world.maps.push(createCaveMap(world));
// custom map
if (DEVELOPMENT) { // remove `if` when you're ready to publish your map
// place sign that will teleport the player to your custom map
world.addEntity(createSign(
75, 69, 'Go to custom map', (_, client) => goToMap(world, client, 'custom'), signQuestion), world.getMainMap());
// add map to the world, go to `/src/ts/server/maps/customMap.ts` to customize your map
world.maps.push(createCustomMap(world));
}
if (world.featureFlags.test) {
const island = createIslandMap(world, false);
island.id = 'public-island';
world.maps.push(island);
const house = createHouseMap(world, false);
house.id = 'public-house';
world.maps.push(house);
}
if (BETA) {
world.maps.push(createPaletteMap(world));
}
if (DEVELOPMENT) {
world.controllers.push(new ctrl.TestController(world, world.getMainMap()));
// world.controllers.push(new ctrl.PerfController(world, {
// count: 2000, moving: 1000, unique: true, spread: false, saying: false, x: 20, y: 20
// }));
// world.controllers.push(new ctrl.FakeClientsController(world, server, {
// count: 1000,
// }));
world.setTime(12);
}
let last = Date.now();
let frames = 0;
world.initialize(last);
if (!DEVELOPMENT) {
create(server).info(`Server started`);
}
setInterval(() => {
timingReset();
timingStart('frame');
try {
const now = Date.now();
world.update(now - last, now);
last = now;
frames++;
if (frames >= SERVER_FPS) {
frames = 0;
world.sparseUpdate(now);
}
} catch (e) {
create(server).danger(e.message);
logger.error(e);
}
timingEnd();
}, 1000 / SERVER_FPS);
return world;
}
+224
View File
@@ -0,0 +1,224 @@
import * as fs from 'fs';
import * as moment from 'moment';
import { compact } from 'lodash';
import { Request } from 'express';
import { Packet } from 'ag-sockets';
import { RequestStats, ServerStats } from '../common/adminInterfaces';
import { HOUR } from '../common/constants';
import { ByteSize } from './utils/byteSize';
interface Stats {
count: number;
size: ByteSize;
totalCount: number;
lastHourCount: number;
lastHourTotal: string;
lastHourAverage: string;
lastHourOrder: string;
lastHourSize: ByteSize;
}
interface SocketStats {
id: number;
name: string;
countStr: number;
countBin: number;
size: ByteSize;
lastHourCountStr: number;
lastHourCountBin: number;
lastHourTotal: string;
lastHourAverage: string;
lastHourOrder: string;
lastHourSize: ByteSize;
}
const MB = 1024 * 1024;
const SEPARATOR = ';';
const statsHeaders = ['date', 'requests count', 'requests size', 'swearing', 'spamming'];
function encodeCSV(values: any[]) {
return `${values.join(SEPARATOR)}\n`;
}
function getDate() {
return (new Date()).getDate();
}
function getAverage({ bytes, mbytes }: ByteSize, count: number): string {
if (!count) {
return '0';
} else if (mbytes >= 1) {
return `${Math.floor((mbytes / count) * MB)} b`;
} else {
return `${Math.floor(bytes / count)} b`;
}
}
function updateHourlySocketStats(stat: SocketStats | undefined) {
if (stat) {
stat.lastHourCountBin = stat.countBin;
stat.lastHourCountStr = stat.countStr;
stat.lastHourSize = stat.size;
stat.lastHourTotal = stat.size.toHumanReadable();
stat.lastHourAverage = getAverage(stat.size, stat.countBin + stat.countStr);
stat.lastHourOrder = stat.size.toSortableString();
stat.countBin = 0;
stat.countStr = 0;
stat.size = new ByteSize();
}
}
export class StatsTracker {
private stats = new Map<string, Stats>();
private recvStats: (SocketStats | undefined)[] = [];
private sendStats: (SocketStats | undefined)[] = [];
private dailyDate = getDate();
private dailyRequestCount = 0;
private dailyRequestSize = new ByteSize();
private dailySwearing = 0;
private dailySpamming = 0;
constructor(private statsPath: string) {
}
logRequest = (req: Request, result: any, url?: string) => {
if (result && !/^\/api-internal/.test(req.baseUrl)) {
this.logStat(
url || (req.baseUrl + req.path), typeof result === 'string' ? result.length : JSON.stringify(result).length);
}
}
logSwearing = () => {
this.dailySwearing++;
}
logSpamming = () => {
this.dailySpamming++;
}
logRecvStats = (packet: Packet) => {
this.logSocketStats(this.recvStats, packet);
}
logSendStats = (packet: Packet) => {
this.logSocketStats(this.sendStats, packet);
}
private logSocketStats(stats: (SocketStats | undefined)[], { id, name, binary, json }: Packet) {
const entry = stats[id] || (stats[id] = {
id,
name,
countStr: 0,
countBin: 0,
size: new ByteSize(),
lastHourCountStr: 0,
lastHourCountBin: 0,
lastHourTotal: '0',
lastHourAverage: '0',
lastHourOrder: '0',
lastHourSize: new ByteSize(),
});
if (!!binary) {
entry.countBin++;
} else {
entry.countStr++;
}
entry.size.addBytes(binary ? (binary.length || binary.byteLength) : (json ? json.length : 0));
}
getStats(): RequestStats[] {
const result: RequestStats[] = [];
this.stats.forEach(({ lastHourCount, lastHourTotal, lastHourAverage, lastHourOrder, totalCount }, path) => {
result.push({
path,
totalCount,
count: lastHourCount,
average: lastHourAverage,
total: lastHourTotal,
order: lastHourOrder,
});
});
return result.sort((a, b) => b.order.localeCompare(a.order));
}
private createActionsStats(type: string, stats: (SocketStats | undefined)[]) {
return compact(stats).map(s => ({
id: s.id,
name: s.name,
type,
countBin: s.lastHourCountBin,
countStr: s.lastHourCountStr,
average: s.lastHourAverage,
total: s.lastHourTotal,
order: s.lastHourOrder,
}));
}
getSocketStats(): ServerStats {
return {
actions: [
...this.createActionsStats('recv', this.recvStats),
...this.createActionsStats('send', this.sendStats),
].sort((a, b) => b.order.localeCompare(a.order)),
};
}
private logStat(path: string, bytes: number) {
const entry = this.stats.get(path);
if (entry) {
entry.count++;
entry.size.addBytes(bytes);
} else {
this.stats.set(path, {
count: 1,
size: new ByteSize(bytes),
totalCount: 0,
lastHourCount: 0,
lastHourTotal: '-',
lastHourAverage: '-',
lastHourOrder: '',
lastHourSize: new ByteSize(),
});
}
}
private submitDailyStats(statsPath: string) {
const statsEntry = [
moment().format('MMM DD'), // DD-MM-YY HH:mm:ss
this.dailyRequestCount.toString(),
this.dailyRequestSize.toString(),
this.dailySwearing.toString(),
this.dailySpamming.toString(),
];
fs.appendFileAsync(statsPath, encodeCSV(statsEntry), { encoding: 'utf8' })
.catch(console.error)
.done();
}
startStatTracking() {
if (!fs.existsSync(this.statsPath)) {
fs.writeFileSync(this.statsPath, encodeCSV(statsHeaders), { encoding: 'utf8' });
}
setInterval(() => {
const date = getDate();
if (date !== this.dailyDate) {
this.submitDailyStats(this.statsPath);
this.dailyDate = date;
this.dailyRequestCount = 0;
this.dailySwearing = 0;
this.dailySpamming = 0;
this.dailyRequestSize = new ByteSize();
}
this.stats.forEach(entry => {
entry.lastHourCount = entry.count;
entry.lastHourSize = entry.size;
entry.lastHourTotal = entry.size.toHumanReadable();
entry.lastHourAverage = getAverage(entry.size, entry.count);
entry.lastHourOrder = entry.size.toSortableString();
entry.totalCount += entry.count;
entry.count = 0;
entry.size = new ByteSize();
this.dailyRequestCount += entry.lastHourCount;
this.dailyRequestSize.add(entry.lastHourSize);
});
this.sendStats.forEach(updateHourlySocketStats);
this.recvStats.forEach(updateHourlySocketStats);
}, 1 * HOUR);
}
}
+65
View File
@@ -0,0 +1,65 @@
import { TimingEntry, TimingEntryType } from '../common/adminInterfaces';
const ENABLED = true;
const ENTRIES_LIMIT = 50000;
const entries: TimingEntry[] = [];
let entriesCount = 0;
let now: () => number;
if (typeof window !== 'undefined') {
now = performance.now;
} else {
const hrtime = process.hrtime;
const getNanoSeconds = () => {
const hr = hrtime();
return hr[0] * 1e9 + hr[1];
};
const nodeLoadTime = getNanoSeconds() - process.uptime() * 1e9;
now = () => (getNanoSeconds() - nodeLoadTime) / 1e6;
}
if (ENABLED) {
for (let i = 0; i < ENTRIES_LIMIT; i++) {
entries.push({ type: 0, time: 0, name: undefined });
}
}
export function timingStart(name: string) {
if (ENABLED) {
if (entriesCount < ENTRIES_LIMIT) {
const entry = entries[entriesCount];
entry.type = TimingEntryType.Start;
entry.time = now();
entry.name = name;
entriesCount++;
} else {
console.warn(`exceeded timing entry limit`);
}
}
}
export function timingEnd() {
if (ENABLED) {
if (entriesCount < ENTRIES_LIMIT) {
const entry = entries[entriesCount];
entry.type = TimingEntryType.End;
entry.time = now();
entry.name = undefined;
entriesCount++;
} else {
console.warn(`exceeded timing entry limit`);
}
}
}
export function timingReset() {
if (ENABLED) {
entriesCount = 0;
}
}
export function timingEntries() {
return entries.slice(0, entriesCount);
}
+52
View File
@@ -0,0 +1,52 @@
import { Request } from 'express';
import { logger } from './logger';
import { createFromRequest } from './reporter';
import { IClient, Reporter } from './serverInterfaces';
import { ServerConfig } from '../common/adminInterfaces';
export interface UserErrorInfo {
error?: Error;
message?: string;
desc?: string;
data?: any;
log?: string;
}
export class UserError extends Error {
//static name: string;
constructor(public message: string, public info?: UserErrorInfo, public userInfo?: string) {
super(message);
Object.defineProperty(this, 'name', { value: 'UserError' });
Error.captureStackTrace(this, UserError);
}
}
export function isUserError(e: Error): e is UserError {
return e.name === 'UserError';
}
function report(message: string, info: UserErrorInfo, reporter: Reporter | undefined, extra = '') {
const keys = Object.keys(info);
if (keys.length === 1 && keys[0] === 'log') {
logger.log(info.log);
} else {
if (reporter) {
reporter.warn((info.error && info.error.message) || info.message || message || '<no message>', info.desc);
}
logger.warn(info.error || info.message || message || '<no message>', info.desc || '', info.data || '', extra);
}
}
export function reportUserError(e: UserError, server: ServerConfig, req: Request) {
if (e.info) {
report(e.message, e.info, createFromRequest(server, req), `${req.url} ${req.ip}`);
}
}
export function reportUserError2(e: UserError, client: IClient | undefined) {
if (e.info) {
report(e.message, e.info, client && client.reporter);
}
}
+31
View File
@@ -0,0 +1,31 @@
const MB = 1024 * 1024;
export class ByteSize {
constructor(public bytes = 0, public mbytes = 0) {
this.reduce();
}
add({ bytes, mbytes }: ByteSize) {
this.addBytes(bytes, mbytes);
}
addBytes(bytes: number, mbytes = 0) {
this.mbytes += mbytes;
this.bytes += bytes;
this.reduce();
return this;
}
toString() {
return this.mbytes ? `${this.mbytes.toString()}${this.bytes.toString().padStart(6, '0')}` : this.bytes.toString();
}
toSortableString() {
return `${this.mbytes.toString().padStart(9, '0')}-${this.bytes.toString().padStart(6, '0')}`;
}
toHumanReadable() {
return this.mbytes >= 1 ?
`${this.mbytes} mb` :
(this.bytes >= 2048 ? `${Math.floor(this.bytes / 1024)} kb` : `${this.bytes} b`);
}
private reduce() {
this.mbytes += Math.floor(this.bytes / MB);
this.bytes = this.bytes % MB;
}
}
+156
View File
@@ -0,0 +1,156 @@
import { escapeRegExp } from 'lodash';
import { ErrorHandler, getMethods } from 'ag-sockets';
import * as Rollbar from 'rollbar';
import { IClient } from '../serverInterfaces';
import { logger } from '../logger';
import { isUserError, reportUserError2 } from '../userError';
import { includes } from '../../common/utils';
import { create } from '../reporter';
import { ServerConfig } from '../../common/adminInterfaces';
import { getOriginFromHTTP } from '../originUtils';
import { ServerActions } from '../serverActions';
const reporterIgnore = /^rate limit exceeded/i;
const ignoreErrors = [
'String message while forced binary',
];
const rollbarIgnore = new RegExp('^(' + [
'reserved fields must be empty',
'rate limit exceeded',
'transfer limit exceeded',
'some error',
'Invalid token',
'Action not allowed',
'Client does not exist',
'Cannot perform this action on admin user',
'Account creation is temporarily disabled',
'Not a number',
'Not a string',
...ignoreErrors,
].map(escapeRegExp).join('|') + ')', 'i');
let lastError = '';
let lastErrorTime = 0;
function formatMessage(message: string | Uint8Array | null | undefined) {
if (message === null) {
return '<null>';
} else if (message === undefined) {
return '<undefined>';
} else if (typeof message === 'string') {
return message;
} else if (message instanceof Uint8Array) {
return `<${Array.from(message).toString()}>`;
} else {
return `<${JSON.stringify(message)}>`;
}
}
function getPerson(client: IClient | undefined) {
return client && client.account ? {
id: client.accountId,
username: client.account.name,
} : {};
}
function reportError(rollbar: Rollbar | undefined, e: Error, client: IClient | undefined, config: ServerConfig) {
if (isUserError(e)) {
reportUserError2(e, client);
return e;
} else {
if (client && client.reporter && !reporterIgnore.test(e.message)) {
client.reporter.error(e);
} else if (client && client.originalRequest) {
const origin = client.originalRequest && getOriginFromHTTP(client.originalRequest);
create(config, undefined, undefined, origin).error(e);
} else {
create(config).error(e);
}
if (!rollbarIgnore.test(e.message)) {
rollbar && rollbar.error(e, null as any, { person: getPerson(client) });
}
return new Error('Error occurred');
}
}
const serverMethods = getMethods(ServerActions);
function getMethodNameFromPacket(packet: string | Uint8Array) {
try {
if (typeof packet === 'string') {
const values = JSON.parse(packet);
return serverMethods[values[0]].name;
} else {
return serverMethods[packet[0]].name;
}
} catch {
return '???';
}
}
function reportRateLimit(client: IClient, e: Error, message: string) {
let reported = false;
if (client.rateLimitMessage === e.message && client.rateLimitCount) {
if (++client.rateLimitCount > 5) {
reported = true;
client.reporter.warn(`${e.message} (x5)`, message);
client.rateLimitCount = 1;
client.disconnect(true, true);
}
} else {
client.rateLimitMessage = e.message;
client.rateLimitCount = 1;
}
return reported;
}
export class SocketErrorHandler implements ErrorHandler {
constructor(private rollbar: Rollbar | undefined, private config: ServerConfig) {
}
handleError(client: IClient | null, e: Error) {
if (!/no server for given id/i.test(e.message)) {
reportError(this.rollbar, e, client || undefined, this.config);
}
}
handleRejection(client: IClient, e: Error) {
if (/^rate limit exceeded/i.test(e.message)) {
reportRateLimit(client, e, 'rejection');
return new Error('Error occurred');
} else {
return reportError(this.rollbar, e, client, this.config);
}
}
handleRecvError(client: IClient, e: Error, socketMessage: string | Uint8Array) {
if (lastError === e.message && Date.now() < (lastErrorTime + 5000))
return;
const message = formatMessage(socketMessage);
const method = getMethodNameFromPacket(socketMessage);
let reported = false;
if (client.reporter) {
if (/^rate limit exceeded/i.test(e.message)) {
reported = reportRateLimit(client, e, message);
} else if (/^transfer limit exceeded/i.test(e.message)) {
reported = true;
const desc = e.message.replace(/transfer limit exceeded /i, '');
client.reporter.warn('Transfer limit exceeded', `${desc} - (${method}) ${message}`);
} else if (!includes(ignoreErrors, e.message)) {
reported = true;
client.reporter.error(e, `(${method}) ${message}`);
}
}
lastError = e.message;
lastErrorTime = Date.now();
if (!reported && !rollbarIgnore.test(e.message || '')) {
logger.error(`recv error: ${e.stack || e}\n\n message: ${message}`);
this.rollbar && this.rollbar.error(e, null as any, { custom: { message }, person: getPerson(client) });
}
}
}
+60
View File
@@ -0,0 +1,60 @@
import { noop } from 'lodash';
export interface TaskQueue {
push<T>(action: () => Promise<T> | T): Promise<T>;
wait(): Promise<void>;
}
interface Task {
resolve: (value: any) => void;
reject: (error: any) => void;
action: () => any;
}
export function taskQueue(): TaskQueue {
const queue: Task[] = [];
let working = false;
function next() {
const task = queue.shift();
if (task) {
exec(task);
} else {
working = false;
}
}
function exec({ action, resolve, reject }: Task) {
working = true;
Promise.resolve()
.then(action)
.then(resolve, reject)
.catch(console.error)
.finally(next);
}
function push(action: () => any): Promise<any> {
return new Promise<any>((resolve, reject) => {
const task: Task = { action, resolve, reject };
if (working) {
queue.push(task);
} else {
exec(task);
}
});
}
function wait() {
return push(noop);
}
return { push, wait };
}
export function makeQueued<T extends (...args: any[]) => any>(action: T): T {
const queue = taskQueue();
return ((...args: any[]) => queue.push(() => action(...args))) as T;
}
+999
View File
@@ -0,0 +1,999 @@
import { getWriterBuffer } from 'ag-sockets';
import { remove, compact } from 'lodash';
import {
TileType, WorldState, Season, Holiday, WorldStateFlags, LeaveReason, NotificationFlags, UpdateFlags, Action,
} from '../common/interfaces';
import { removeItem, distance, clamp, randomPoint, includes, fromNow } from '../common/utils';
import { HOUR_LENGTH, DAY_LENGTH } from '../common/timeUtils';
import {
AFK_TIMEOUT, MAP_DISCARD_TIMEOUT, JOINS_PER_UPDATE, REMOVE_INTERVAL, REMOVE_TIMEOUT,
MAP_SWITCHES_PER_UPDATE, MINUTE, SERVER_FPS, HOUR
} from '../common/constants';
import {
IClient, ServerEntity, Controller, GetSettings, ServerNotification, SocketStats, ServerMap, MapUsage
} from './serverInterfaces';
import { isTileLocked, getMapInfo, setTile, hasAnyClients, createMinimap } from './serverMap';
import { getModInfo } from './accountUtils';
import { IAccount } from './db';
import { PartyService } from './services/party';
import { NotificationService } from './services/notification';
import { AroundEntry, ServerLiveSettings, ServerConfig } from '../common/adminInterfaces';
import { fixPosition, updateEntity, pushRemoveEntityToClient, pushUpdateEntityToClient } from './entityUtils';
import { isBanned, isShadowed } from '../common/adminUtils';
import {
createAndUpdateCharacterState, setEntityExpression, reloadFriends, updateEntityPlayerState, resetClientUpdates, isMutedOrShadowed
} from './playerUtils';
import {
sparseRegionUpdate, addToRegion, removeFromRegion, unsubscribeFromOutOfRangeRegions,
subscribeToRegionsInRange, unsubscribeFromAllRegions, commitRegionUpdates, updateRegions, setupTiming,
clearTiming, resetEncodeUpdate
} from './regionUtils';
import { roundPosition, roundPositionXMidPixel, roundPositionYMidPixel } from '../common/positionUtils';
import { logger } from './logger';
import { updateCamera, centerCameraOn } from '../common/camera';
import { timingStart, timingEnd } from './timing';
import { getRegionGlobal, getTile } from '../common/worldMap';
import { getEntityTypeName } from '../common/entities';
import { toFriendOnline, toFriendOffline, FriendsService } from './services/friends';
// import { Pool, createPool } from './pool';
import { isStaticCollision, getCollisionStats, fixCollision, updatePosition } from '../common/collision';
import { HidingService } from './services/hiding';
import { generateRegionCollider } from '../common/region';
import { updateTileIndices } from '../client/tileUtils';
import { removeEntityFromRegion } from './serverRegion';
import { createIslandMap } from './maps/islandMap';
import { createHouseMap } from './maps/houseMap';
import { updateMainMapSeason } from './maps/mainMap';
interface MapSwitch {
map: ServerMap;
x: number;
y: number;
client: IClient;
}
interface ReservedID {
id: number;
time: number;
}
export class World {
season = Season.Summer;
holiday = Holiday.None;
maps: ServerMap[] = [];
controllers: Controller[] = [];
options = {
restoreTerrain: !DEVELOPMENT,
};
clients: IClient[] = [];
clientsByAccount = new Map<string, IClient>();
joinQueue: IClient[] = [];
mapSwitchQueue: MapSwitch[] = [];
now = 0;
start = 0;
// mapPools = new Map<string, Pool<ServerMap>>();
private maxId = 0 >>> 0;
private offlineClients: IClient[] = [];
private baseTime = 0;
private entityById = new Map<number, ServerEntity>();
private reservedIds = new Map<number, string>();
private reservedIdsByKey = new Map<string, ReservedID>();
constructor(
public readonly server: ServerConfig,
private readonly partyService: PartyService,
private readonly friendsService: FriendsService,
public readonly hidingService: HidingService,
private readonly notifications: NotificationService,
private readonly getSettings: GetSettings,
private readonly liveSettings: ServerLiveSettings,
private readonly socketStats: SocketStats,
) {
// this.mapPools.set('island', createPool(10, () => createIslandMap(this, true), resetIslandMap));
// this.mapPools.set('house', createPool(10, () => createHouseMap(this, true), resetHouseMap));
partyService.partyChanged.subscribe(client => {
if (client.isConnected && client.map.usage === MapUsage.Party) {
if (
client.party && client.party.leader === client && client.map.instance === client.accountId &&
!this.maps.some(m => m.id === client.map.id && m.instance === client.party!.id)
) {
client.map.instance = client.party.id;
} else {
refreshMap(this, client);
}
}
});
}
get featureFlags() {
return this.server.flags;
}
// entities
get time() {
return this.baseTime + Date.now();
}
setTime(hour: number) {
let newBaseTime = hour * HOUR_LENGTH - (Date.now() % DAY_LENGTH);
while (newBaseTime < 0) {
newBaseTime += DAY_LENGTH;
}
this.baseTime = newBaseTime;
this.updateWorldState();
}
setTile(map: ServerMap, x: number, y: number, type: TileType) {
if (!BETA && map.tilesLocked)
return;
if (x >= 0 && y >= 0 && x < map.width && y < map.height && !isTileLocked(map, x, y) && type !== getTile(map, x, y)) {
setTile(map, x, y, type);
}
}
toggleWall(map: ServerMap, x: number, y: number, type: TileType) {
for (const controller of map.controllers) {
if (controller.toggleWall) {
controller.toggleWall(x, y, type);
}
}
}
getState(): WorldState {
return {
time: this.time,
season: this.season,
holiday: this.holiday,
flags: this.getSettings().filterSwears ? WorldStateFlags.Safe : WorldStateFlags.None,
featureFlags: this.featureFlags,
};
}
setSeason(season: Season, holiday: Holiday) {
this.season = season;
this.holiday = holiday;
this.updateWorldState();
updateMainMapSeason(this, this.getMainMap(), season, holiday);
}
private updateWorldState() {
const state = this.getState();
for (const client of this.clients) {
client.worldState(state, false);
}
}
getEntityById(id: number) {
return this.entityById.get(id);
}
getNewEntityId() {
do {
this.maxId = (this.maxId + 1) >>> 0;
} while (this.maxId === 0 || this.entityById.has(this.maxId) || this.reservedIds.has(this.maxId));
return this.maxId;
}
addEntity(entity: ServerEntity, map: ServerMap) {
if (DEVELOPMENT) {
if (entity.update) {
console.error('Entity update() method is only for client-side use');
}
if (entity.id && this.entityById.has(entity.id)) {
console.error(`Entity already added to the world ${getEntityTypeName(entity.type)} [${entity.id}]`);
}
}
entity.id = entity.id || this.getNewEntityId();
entity.timestamp = this.now / 1000;
this.entityById.set(entity.id, entity);
roundPosition(entity);
const region = getRegionGlobal(map, entity.x, entity.y);
addToRegion(entity, region, map);
return entity;
}
removeEntity(entity: ServerEntity, map: ServerMap) {
let removed = false;
if (entity.region) {
removed = removeFromRegion(entity, entity.region, map);
}
this.entityById.delete(entity.id);
return removed;
}
removeEntityFromSomeMap(entity: ServerEntity) {
const map = this.maps.find(m => m.regions.some(r => includes(r.entities, entity)));
if (map) {
this.removeEntity(entity, map);
} else {
DEVELOPMENT && logger.error(`Missing map for entity`);
}
}
// map
getMainMap() {
return this.maps[0];
}
switchToMap(client: IClient, map: ServerMap, x: number, y: number) {
if (client.map === map) {
DEVELOPMENT && logger.error(`Switching to the same map`);
return;
}
if (this.mapSwitchQueue.some(x => x.client === client)) {
DEVELOPMENT && logger.error(`Already in map switch queue`);
return;
}
this.mapSwitchQueue.push({ client, map, x, y });
client.isSwitchingMap = true;
client.pony.vx = 0;
client.pony.vy = 0;
updateEntity(client.pony, false);
client.mapSwitching();
}
actualSwitchToMap(client: IClient, map: ServerMap, x: number, y: number) {
unsubscribeFromAllRegions(client, false);
if (client.pony.region) {
removeFromRegion(client.pony, client.pony.region, client.map);
}
x = clamp(x, 0, map.width);
y = clamp(y, 0, map.height);
resetClientUpdates(client);
client.mapState(getMapInfo(map), map.state);
client.map = map;
client.pony.x = x;
client.pony.y = y;
client.safeX = x;
client.safeY = y;
client.lastTime = 0;
client.lastMapSwitch = Date.now();
client.loading = true;
client.lastCameraX = 0;
client.lastCameraY = 0;
client.lastCameraW = 0;
client.lastCameraH = 0;
client.isSwitchingMap = false;
addToRegion(client.pony, getRegionGlobal(map, x, y), map);
fixPosition(client.pony, map, x, y, true);
client.reporter.systemLog(`Switched map to [${client.map.id || 'main'}]`);
}
// main
initialize(now: number) {
this.start = now;
this.now = now;
const nowSeconds = now / 1000;
for (const controller of this.controllers) {
controller.initialize(nowSeconds);
}
for (const map of this.maps) {
for (const controller of map.controllers) {
controller.initialize(nowSeconds);
}
}
}
update(delta: number, now: number) {
const started = Date.now();
timingStart('world.update()');
resetEncodeUpdate();
this.now = now;
const nowSeconds = now / 1000;
const deltaSeconds = delta / 1000;
timingStart('update tiles');
for (const map of this.maps) {
if (!map.dontUpdateTilesAndColliders) {
for (const region of map.regions) {
if (region.tilesDirty) {
updateTileIndices(region, map);
}
}
}
}
timingEnd();
timingStart('update colliders');
for (const map of this.maps) {
if (!map.dontUpdateTilesAndColliders) {
for (const region of map.regions) {
if (region.colliderDirty) {
generateRegionCollider(region, map);
}
}
}
}
timingEnd();
timingStart('update positions');
for (const map of this.maps) {
for (const region of map.regions) {
// TODO: update only moving entities, separate list of movingEntities
for (const entity of region.movables) {
// TODO: make sure timestamp is initialized if entity is moving
const delta = nowSeconds - entity.timestamp;
if (delta > 0) {
if (entity.vx !== 0 || entity.vy !== 0) {
timingStart('updatePosition()');
updatePosition(entity, delta, map);
timingEnd();
}
entity.timestamp = nowSeconds;
}
}
}
}
timingEnd();
timingStart('updateCamera + updateSubscriptions');
for (const client of this.clients) {
if (updateClientCamera(client)) {
unsubscribeFromOutOfRangeRegions(client);
subscribeToRegionsInRange(client);
}
}
timingEnd();
timingStart('update controllers');
for (const controller of this.controllers) {
controller.update(deltaSeconds, nowSeconds);
}
for (const map of this.maps) {
for (const controller of map.controllers) {
controller.update(deltaSeconds, nowSeconds);
}
}
timingEnd();
timingStart('actualSwitchToMap');
for (let i = 0; i < MAP_SWITCHES_PER_UPDATE && this.mapSwitchQueue.length; i++) {
const { client, map, x, y } = this.mapSwitchQueue.shift()!;
this.actualSwitchToMap(client, map, x, y);
}
timingEnd();
timingStart('updateRegions');
updateRegions(this.maps); // NOTE: creates transfers
timingEnd();
timingStart('timeoutEntityExpression + inTheAirDelay');
for (const { pony } of this.clients) {
// timeout expressions
if (pony.exprTimeout && pony.exprTimeout < now) {
setEntityExpression(pony, undefined); // NOTE: creates updates
}
// count down in-the-air delay
if (pony.inTheAirDelay !== undefined && pony.inTheAirDelay > 0) {
pony.inTheAirDelay -= deltaSeconds;
}
}
timingEnd();
// const { totalUpdates, reusedUpdates } = this.updatesStats();
timingStart(`commitRegionUpdates`); // [${totalUpdates} / ${reusedUpdates}]`);
for (const map of this.maps) {
commitRegionUpdates(map.regions);
}
timingEnd();
let clientsWithAdds = 0;
let clientsWithUpdates = 0;
let clientsWithSays = 0;
let totalSays = 0;
timingStart(`send updates`);
for (const client of this.clients) {
const { updateQueue, regionUpdates, saysQueue, unsubscribes, subscribes } = client;
const updateBuffer = updateQueue.offset ? getWriterBuffer(updateQueue) : null;
const total = updateQueue.offset + regionUpdates.length + saysQueue.length + unsubscribes.length + subscribes.length;
if (total !== 0) {
if (updateQueue.offset > 0)
clientsWithAdds++;
if (regionUpdates.length > 0)
clientsWithUpdates++;
if (saysQueue.length > 0)
clientsWithSays++;
totalSays += saysQueue.length;
setupTiming(client);
timingStart('client.update()');
client.update(unsubscribes, subscribes, updateBuffer, regionUpdates, saysQueue);
timingEnd();
clearTiming(client);
resetClientUpdates(client);
}
}
timingEnd();
timingStart('joinQueuedClients');
if (Date.now() < (started + (1000 / SERVER_FPS))) {
for (let i = 0; i < JOINS_PER_UPDATE && this.joinQueue.length > 0; i++) {
this.joinClientToWorld(this.joinQueue.shift()!); // NOTE: creates adds
}
}
timingEnd();
const { isCollidingCount, isCollidingObjectCount } = getCollisionStats();
timingStart(`adds [${clientsWithAdds}]\n` +
`updates [${clientsWithUpdates}]\n` +
`says [${totalSays} / ${clientsWithSays}]\n` +
`sockets [${this.socketStatsText()}]\n` +
`collisions [${isCollidingObjectCount} / ${isCollidingCount}]`);
this.cleanupOfflineClients();
timingEnd();
timingEnd();
}
sparseUpdate(now: number) {
timingStart('world.sparseUpdate()');
timingStart('sparse update controllers');
for (const controller of this.controllers) {
if (controller.sparseUpdate !== undefined) {
controller.sparseUpdate();
}
}
for (const map of this.maps) {
for (const controller of map.controllers) {
if (controller.sparseUpdate !== undefined) {
controller.sparseUpdate();
}
}
}
timingEnd();
timingStart('sparseRegionUpdate');
for (const map of this.maps) {
for (const region of map.regions) {
sparseRegionUpdate(map, region, this.options);
}
}
timingEnd();
timingStart('kick afk clients');
for (const client of this.clients) {
if ((now - client.lastPacket) > AFK_TIMEOUT) {
this.kick(client, 'afk');
}
}
timingEnd();
timingStart('send queue status (join)');
for (let i = 0; i < this.joinQueue.length; i++) {
this.joinQueue[i].queue(i + 1);
}
timingEnd();
timingStart('send queue status (map)');
for (let i = 0; i < this.mapSwitchQueue.length; i++) {
this.mapSwitchQueue[i].client.queue(i + 1);
}
timingEnd();
timingStart('cleanup unused maps');
const mapDiscardThreshold = now - MAP_DISCARD_TIMEOUT;
for (const map of this.maps) {
if (map.instance && (hasAnyClients(map) || this.mapSwitchQueue.some(q => q.map === map))) {
map.lastUsed = now;
}
}
for (let i = this.maps.length - 1; i > 0; i--) {
const map = this.maps[i];
if (map.instance && map.lastUsed < mapDiscardThreshold) {
this.maps.splice(i, 1);
// const pool = this.mapPools.get(map.id);
// if (pool && pool.dispose(map)) {
// for (const region of map.regions) {
// resetRegionUpdates(region);
// }
// } else {
for (const region of map.regions) {
for (const entity of region.entities) {
this.entityById.delete(entity.id);
}
}
// }
}
}
timingEnd();
timingStart('cleanup reserved ids');
const threshold = Date.now() - 5 * MINUTE;
for (const key of Array.from(this.reservedIdsByKey.keys())) { // TODO: avoid doing this to save gc
const { id, time } = this.reservedIdsByKey.get(key)!;
if (time < threshold) {
this.reservedIdsByKey.delete(key);
this.reservedIds.delete(id);
}
}
timingEnd();
timingStart('cleanup parties');
this.partyService.cleanupParties();
timingEnd();
timingEnd();
}
private socketStatsText() {
const { sent, received, sentPackets, receivedPackets } = this.socketStats.stats();
return `sent: ${(sent / 1024).toFixed(2)} kb (${sentPackets}), ` +
`recv: ${(received / 1024).toFixed(2)} kb (${receivedPackets})`;
}
updatesStats() {
timingStart('updatesStats()');
let totalUpdates = 0, reusedUpdates = 0;
for (const map of this.maps) {
for (const region of map.regions) {
totalUpdates += region.entityUpdates.length;
reusedUpdates += region.reusedUpdates;
}
}
timingEnd();
return { totalUpdates, reusedUpdates };
}
// clients
private lastCleanup = 0;
private cleanupOfflineClients() {
const now = Date.now();
if ((now - this.lastCleanup) > REMOVE_INTERVAL) {
this.lastCleanup = now;
const removeFrom = now - REMOVE_TIMEOUT;
remove(this.offlineClients, c => c.offline && !c.party && c.offlineAt && c.offlineAt.getTime() < removeFrom);
}
}
joinClientToQueue(client: IClient) {
if (this.liveSettings.shutdown) {
client.leaveReason = 'shutdown';
client.disconnect(false, true);
return;
}
const { tokenId } = client;
function findClientsToKick(clients: IClient[]) {
return clients.filter(c => c.tokenId === tokenId);
}
const clientsToKick = [
...findClientsToKick(this.clients),
...findClientsToKick(this.joinQueue),
];
for (const client of clientsToKick) {
const reason = client.tokenId === tokenId ? 'kicked [joining again]' : 'kicked [alone on ip]';
this.kick(client, reason, LeaveReason.None, true);
}
// TODO: wait for all clients to be kicked before adding to the queue
// another queue before joinQueue
this.joinQueue.push(client);
}
joinClientToWorld(client: IClient) {
timingStart('joinClientToWorld()');
const key = `${client.accountId}:${client.characterId}`;
const reserved = this.reservedIdsByKey.get(key);
if (reserved) {
client.pony.id = reserved.id;
this.reservedIdsByKey.delete(client.accountId);
this.reservedIds.delete(reserved.id);
} else {
client.pony.id = this.getNewEntityId();
}
client.myEntity(client.pony.id, client.characterName, client.character.info!, client.characterId, client.pony.crc || 0);
this.clients.push(client);
this.clientsByAccount.set(client.accountId, client);
this.partyService.clientConnected(client);
this.hidingService.connected(client);
let map = findOrCreateMapForClient(this, client.characterState.map || '', client);
if (!map) {
map = this.getMainMap();
const { x, y } = randomPoint(map.spawnArea);
client.pony.x = x;
client.pony.y = y;
}
if (isStaticCollision(client.pony, map)) {
if (!fixCollision(client.pony, map)) {
const { x, y } = randomPoint(map.spawnArea);
client.pony.x = x;
client.pony.y = y;
}
}
client.pony.x = roundPositionXMidPixel(client.pony.x);
client.pony.y = roundPositionYMidPixel(client.pony.y);
client.map = map;
centerCameraOn(client.camera, client.pony);
client.worldState(this.getState(), true);
client.mapState(getMapInfo(client.map), client.map.state);
if (BETA) {
timingStart('minimap');
client.mapTest(client.map.width, client.map.height, createMinimap(this, client.map));
timingEnd();
}
updateCamera(client.camera, client.pony, map);
this.addEntity(client.pony, client.map);
const visibleOnlineFriends: IClient[] = [];
for (const c of this.clients) {
if (c.selected && c.selected.client && c.selected.client.accountId === client.accountId) {
c.updateSelection(c.selected.id, client.pony.id);
}
if (client.friends.has(c.accountId)) {
if (!c.accountSettings.hidden && !c.shadowed) {
visibleOnlineFriends.push(c);
}
if (!client.accountSettings.hidden) {
c.updateFriends([toFriendOnline(client)], false);
}
if (!c.friends.has(client.accountId)) {
reloadFriends(c).catch(e => logger.error(e));
}
} else if (c.friends.has(client.accountId)) {
reloadFriends(c).catch(e => logger.error(e));
}
}
if (visibleOnlineFriends.length) {
client.updateFriends(visibleOnlineFriends.map(toFriendOnline), false);
}
if (this.liveSettings.updating) {
this.notifications.addNotification(client, updateNotification());
}
// TEMP: duplicate pony bug
if (client.map.instance) {
for (const region of client.map.regions) {
for (const entity of region.entities) {
if (entity.client !== undefined && entity !== client.pony && entity.client.accountId === client.accountId) {
const sameClients = this.clients.filter(c => c.accountId === client.accountId).length;
client.reporter.systemLog(`Client pony already on map ` +
`(old: ${entity.id}, new: ${client.pony.id}, sameClients: ${sameClients})`);
}
}
}
}
timingEnd();
}
getClientByEntityId(entityId: number) {
if (entityId === 0) {
return undefined;
} else {
const byPonyId = (c: IClient) => c.pony.id === entityId;
return this.clients.find(byPonyId) || this.offlineClients.find(byPonyId);
}
}
private removeEntityFromAnyMap(entity: ServerEntity) {
for (const map of this.maps) {
for (const region of map.regions) {
const index = region.entities.indexOf(entity);
if (index !== - 1) {
removeEntityFromRegion(region, entity, map);
return map;
}
}
}
return undefined;
}
leaveClient(client: IClient) {
const friends = findAllOnlineFriends(this, client);
if (!client.accountSettings.hidden) {
for (const friend of friends) {
friend.updateFriends([toFriendOffline(client)], false);
}
}
this.reservedIds.set(client.pony.id, client.accountId);
this.reservedIdsByKey.set(`${client.accountId}:${client.characterId}`, { id: client.pony.id, time: Date.now() });
if (!this.removeEntity(client.pony, client.map)) {
const map = this.removeEntityFromAnyMap(client.pony);
client.reporter.systemLog(`Removing from any map (` +
`expected: ${client.map && client.map.id} [${client.map && client.map.instance}], ` +
`actual: ${map && map.id} [${map && map.instance}])`);
}
unsubscribeFromAllRegions(client, true);
removeItem(this.joinQueue, client);
removeItem(this.clients, client);
this.clientsByAccount.delete(client.accountId);
this.offlineClients.push(client);
const index = this.mapSwitchQueue.findIndex(x => x.client === client);
if (index !== -1) {
this.mapSwitchQueue.splice(index, 1);
}
}
notifyHidden(by: string, who: string) {
const byClient = findClientByAccountId(this, by);
const whoClient = findClientByAccountId(this, who);
if (byClient && whoClient) {
updateEntityPlayerState(byClient, whoClient.pony);
updateEntityPlayerState(whoClient, byClient.pony);
}
}
resetToSpawn(client: IClient) {
Object.assign(client.pony, randomPoint(client.map.spawnArea));
}
kick(client: IClient | undefined, leaveReason = 'kicked', reason = LeaveReason.None, force = false) {
if (client) {
removeItem(this.joinQueue, client);
this.notifications.rejectAll(client);
this.leaveClient(client);
client.leaveReason = leaveReason;
client.left(reason);
if (force) {
client.disconnect(true);
} else {
setTimeout(() => {
if (client.isConnected) {
client.disconnect(true);
}
}, 200);
}
}
return !!client;
}
kickAll() {
this.clients.slice().forEach(c => this.kick(c, 'kickAll'));
this.joinQueue.slice().forEach(c => c.disconnect());
this.joinQueue = [];
}
kickByAccount(accountId: string) {
return this.kick(findClientByAccountId(this, accountId), 'kickByAccount');
}
kickByCharacter(characterId: string) {
return this.kick(findClientByCharacterId(this, characterId), 'kickByCharacter');
}
accountUpdated(account: IAccount) {
const accountId = account._id.toString();
const client = findClientByAccountId(this, accountId);
if (client) {
this.updateClientAccount(client, account);
for (const c of this.clients) {
if (c.isMod && c.selected === client.pony) {
pushUpdateEntityToClient(c, { entity: client.pony, flags: UpdateFlags.Options, options: { modInfo: getModInfo(client) } });
}
}
}
}
private updateClientAccount(client: IClient, newAccount: IAccount) {
const oldAccount = client.account;
client.account = newAccount;
if (oldAccount.ban !== newAccount.ban && isBanned(newAccount)) {
sendAcl(client);
this.kick(client, 'kick (ban)');
return;
}
if (oldAccount.shadow !== newAccount.shadow) {
if (isShadowed(newAccount)) {
this.shadow(client);
} else if (isShadowed(oldAccount)) {
sendAcl(client);
this.kick(client, 'kick (unshadow)');
return;
}
}
const shouldSendAcl = oldAccount.mute !== newAccount.mute
|| oldAccount.ban !== newAccount.ban
|| oldAccount.shadow !== newAccount.shadow;
if (shouldSendAcl) {
sendAcl(client);
}
}
private shadow(client: IClient) {
client.shadowed = true;
this.partyService.clientDisconnected(client);
this.friendsService.clientDisconnected(client);
this.notifications.dismissAll(client);
if (client.pony.region) {
for (const c of client.pony.region.clients) {
if (c !== client) {
pushRemoveEntityToClient(c, client.pony);
}
}
}
}
// update notification
notifyUpdate() {
for (const client of this.clients) {
this.notifications.addNotification(client, updateNotification());
}
}
saveClientStates() {
for (const client of this.clients) {
createAndUpdateCharacterState(client, this.server);
}
}
}
// account creation lock
function sendAcl(client: IClient) {
const acl = isMutedOrShadowed(client) ? fromNow(12 * HOUR) : new Date(0);
client.actionParam(client.pony.id, Action.ACL, acl.toISOString());
}
function updateNotification(): ServerNotification {
return {
id: 0,
name: '',
message: 'Server will restart shortly for updates and maintenance',
flags: NotificationFlags.Ok,
};
}
export function refreshMap(world: World, client: IClient) {
const map = findOrCreateMapForClient(world, client.map.id, client);
if (map) {
world.switchToMap(client, map, client.pony.x, client.pony.y);
} else {
logger.warn(`Missing map: ${client.map.id}`);
}
}
export function goToMap(world: World, client: IClient, id: string, spawn?: string) {
const map = findOrCreateMapForClient(world, id, client);
if (map) {
const area = spawn && map.spawns.get(spawn) || map.spawnArea;
const { x, y } = randomPoint(area);
world.switchToMap(client, map, x, y);
} else {
logger.warn(`Missing map: ${id}`);
}
}
function findOrCreateMapInstance(world: World, id: string, instance: string) {
let map = world.maps.find(m => m.id === id && m.instance === instance);
if (!map) {
// const pool = world.mapPools.get(id);
// if (!pool) {
// throw new Error(`Invalid map id: ${id}`);
// }
switch (id) {
case 'house':
map = createHouseMap(world, true);
break;
case 'island':
map = createIslandMap(world, true);
break;
default:
throw new Error(`Invalid map id: ${id}`);
}
// map = pool.create();
map.instance = instance;
map.lastUsed = Date.now();
map.controllers.forEach(c => c.initialize(world.now / 1000));
world.maps.push(map);
}
return map;
}
function findOrCreateMapForClient(world: World, id: string, client: IClient) {
const map = world.maps.find(m => !m.instance && m.id === id);
if (map) {
return map;
} else {
if (client.party) {
return findOrCreateMapInstance(world, id, client.party.id);
} else {
return findOrCreateMapInstance(world, id, client.accountId);
}
}
}
function updateClientCamera(client: IClient) {
const camera = client.camera;
updateCamera(camera, client.pony, client.map);
if (
client.lastCameraX !== camera.x || client.lastCameraY !== camera.y ||
client.lastCameraW !== camera.w || client.lastCameraH !== camera.h
) {
client.lastCameraX = camera.x;
client.lastCameraY = camera.y;
client.lastCameraW = camera.w;
client.lastCameraH = camera.h;
return true;
} else {
return false;
}
}
export function findAllOnlineFriends(world: World, client: IClient) {
return compact(Array.from(client.friends.keys())
.map(account => findClientByAccountId(world, account)));
}
export function findClientByAccountId(world: World, accountId: string) {
return world.clientsByAccount.get(accountId);
}
export function findClientByCharacterId(world: World, characterId: string) {
return world.clients.find(c => c.characterId === characterId);
}
export function findClientsAroundAccountId(world: World, accountId: string): AroundEntry[] {
const client = findClientByAccountId(world, accountId);
return client ? world.clients
.filter(c => c !== client && c.map === client.map)
.map(c => ({
account: c.accountId,
distance: distance(client.pony, c.pony),
party: !!(c.party && c.party === client.party),
}))
.filter(x => x.distance < 5 || x.party)
.sort((a, b) => a.distance - b.distance)
.slice(0, 12) : [];
}