mirror of
https://github.com/Terncode/pixel.horse.git
synced 2026-09-26 06:35:51 +02:00
Archive commit
This commit is contained in:
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}, []));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export const createReloadSettings =
|
||||
(reloadSettings: () => Promise<void>) =>
|
||||
async () =>
|
||||
await reloadSettings();
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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`,
|
||||
];
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 {};
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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 });
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
// }
|
||||
@@ -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 '';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ServerLiveSettings } from '../common/adminInterfaces';
|
||||
|
||||
export const liveSettings: ServerLiveSettings = {
|
||||
updating: false,
|
||||
shutdown: false,
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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(', ')})`);
|
||||
});
|
||||
});
|
||||
@@ -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}()`));
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 { }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) : [];
|
||||
}
|
||||
Reference in New Issue
Block a user