mirror of
https://github.com/Terncode/pixel.horse.git
synced 2026-09-24 13:55:04 +02:00
Archive commit
This commit is contained in:
@@ -0,0 +1,617 @@
|
||||
import { NgZone, Injectable } from '@angular/core';
|
||||
import { DomSanitizer } from '@angular/platform-browser';
|
||||
import { merge, remove, repeat, noop } from 'lodash';
|
||||
import { SocketService, createClientSocket } from 'ag-sockets/dist/browser';
|
||||
import { fromNow } from '../../common/utils';
|
||||
import { DAY, MINUTE } from '../../common/constants';
|
||||
import { AccountData, AccountCounters } from '../../common/interfaces';
|
||||
import {
|
||||
Account, Auth, Character, Origin, OriginInfo, Event, AdminState, UpdateOrigin, AccountFlags, IAdminServerActions,
|
||||
FindPonyQuery, AuthUpdate, ItemCounts, SupporterFlags, GameServerSettings, Settings,
|
||||
AccountState, MergeAccountData, FindAccountQuery, ClearOrignsOptions, OriginInfoBase, PonyIdDateName, Stats, BaseValues,
|
||||
} from '../../common/adminInterfaces';
|
||||
import { ClientAdminActions } from '../../client/clientAdminActions';
|
||||
import { LiveCollection } from './liveCollection';
|
||||
import { socketOptions, token } from '../../client/data';
|
||||
import { getUrl } from '../../client/rev';
|
||||
import {
|
||||
formatChat, formatEventDesc, getId, banMessage, parsePonies
|
||||
} from '../../common/adminUtils';
|
||||
import { StorageService } from './storageService';
|
||||
import { decompressPonyString } from '../../common/compressPony';
|
||||
import { ModelSubscriber } from './modelSubscriber';
|
||||
|
||||
interface FindPoniesResult {
|
||||
items: string[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
const notification = window.Notification;
|
||||
|
||||
function shouldNotify(e: Event) {
|
||||
return !/^(Spam|Suspicious message|Invalid account|Invite limit reached|Timed out for (swearing|spamming))$/i
|
||||
.test(e.message);
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AdminModel {
|
||||
account?: AccountData;
|
||||
counts: ItemCounts = {
|
||||
accounts: 0,
|
||||
characters: 0,
|
||||
auths: 0,
|
||||
origins: 0,
|
||||
};
|
||||
initialized = false;
|
||||
state: AdminState = {
|
||||
status: {
|
||||
diskSpace: '',
|
||||
memoryUsage: '',
|
||||
certificateExpiration: '',
|
||||
lastPatreonUpdate: '',
|
||||
},
|
||||
loginServers: [
|
||||
{
|
||||
updating: false,
|
||||
dead: false,
|
||||
},
|
||||
],
|
||||
gameServers: [],
|
||||
};
|
||||
updated?: (list: string, added: boolean) => void; // TODO: remove this
|
||||
duplicateEntries?: string[];
|
||||
error?: string;
|
||||
log = (..._: any[]) => { };
|
||||
get loading(): boolean {
|
||||
return !this.account;
|
||||
}
|
||||
accountPromise!: Promise<AccountData>;
|
||||
accounts: ModelSubscriber<Account>;
|
||||
origins: ModelSubscriber<Origin>;
|
||||
auths: ModelSubscriber<Auth>;
|
||||
ponies: ModelSubscriber<Character>;
|
||||
accountAuths: ModelSubscriber<string[]>;
|
||||
accountPonies: ModelSubscriber<PonyIdDateName[]>;
|
||||
accountOrigins: ModelSubscriber<OriginInfoBase[]>;
|
||||
private liveEvents: LiveCollection<Event>;
|
||||
private handleError = (error: Error) => {
|
||||
console.error(error);
|
||||
this.error = error.message;
|
||||
return undefined;
|
||||
}
|
||||
private checkError = <T>(promise: Promise<T>) => promise.catch(this.handleError) as Promise<T | undefined>;
|
||||
private running = true;
|
||||
private initializedLive = false;
|
||||
private socket: SocketService<ClientAdminActions, IAdminServerActions>;
|
||||
private resolveAccount!: (account: AccountData) => void;
|
||||
private initAccountPromise() {
|
||||
this.accountPromise = new Promise(resolve => {
|
||||
this.resolveAccount = resolve;
|
||||
});
|
||||
}
|
||||
constructor(private sanitizer: DomSanitizer, private storage: StorageService, zone: NgZone) {
|
||||
this.initAccountPromise();
|
||||
this.socket = createClientSocket<ClientAdminActions, IAdminServerActions>(
|
||||
{ ...socketOptions() }, token, undefined, zone.run.bind(zone));
|
||||
|
||||
(window as any).model = this;
|
||||
|
||||
if (this.socket) {
|
||||
this.socket.client = new ClientAdminActions(this);
|
||||
this.socket.connect();
|
||||
}
|
||||
|
||||
this.accounts = new ModelSubscriber<Account>('accounts', this.socket, {
|
||||
fix: account => {
|
||||
account.createdAt = new Date(account.createdAt!);
|
||||
account.updatedAt = new Date(account.updatedAt!);
|
||||
account.lastVisit = account.lastVisit && new Date(account.lastVisit);
|
||||
|
||||
if (account.alert) {
|
||||
account.alert.expires = new Date(account.alert.expires);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
this.auths = new ModelSubscriber<Auth>('auths', this.socket, {
|
||||
fix: account => {
|
||||
account.updatedAt = new Date(account.updatedAt!);
|
||||
account.lastUsed = account.lastUsed && new Date(account.lastUsed);
|
||||
},
|
||||
});
|
||||
|
||||
this.ponies = new ModelSubscriber<Character>('ponies', this.socket, {
|
||||
fix: character => {
|
||||
character.createdAt = new Date(character.createdAt!);
|
||||
character.updatedAt = new Date(character.updatedAt!);
|
||||
character.lastUsed = character.lastUsed && new Date(character.lastUsed);
|
||||
},
|
||||
});
|
||||
|
||||
this.origins = new ModelSubscriber<Origin>('origins', this.socket, {});
|
||||
|
||||
this.accountAuths = new ModelSubscriber<string[]>('accountAuths', this.socket, {}, []);
|
||||
this.accountPonies = new ModelSubscriber<PonyIdDateName[]>('accountPonies', this.socket, {}, []);
|
||||
this.accountOrigins = new ModelSubscriber<OriginInfoBase[]>('accountOrigins', this.socket, {}, []);
|
||||
|
||||
this.liveEvents = new LiveCollection<Event>('events', 1000, getId, {
|
||||
decode: decodeEvent,
|
||||
onUpdated: (added, all) => {
|
||||
if (all.length) {
|
||||
this.log(`events ${all.length}`);
|
||||
}
|
||||
|
||||
all.forEach(e => {
|
||||
e.descHTML = this.sanitizer.bypassSecurityTrustHtml(formatEventDesc(e.desc));
|
||||
});
|
||||
|
||||
this.callUpdated('events', !!added);
|
||||
this.updateTitle();
|
||||
|
||||
if (this.notifications) {
|
||||
added.filter(shouldNotify).forEach(e => this.notify(e.message, e.desc));
|
||||
all.filter(e => e.count === 10).forEach(e => this.notify(e.message, e.desc));
|
||||
}
|
||||
},
|
||||
onDelete: () => this.updateTitle(),
|
||||
}, this.socket);
|
||||
|
||||
if (!this.socket) {
|
||||
this.initialize(true);
|
||||
}
|
||||
}
|
||||
get server() {
|
||||
return this.socket.server;
|
||||
}
|
||||
get notifications() {
|
||||
return this.storage.getItem('admin-notifications') === 'true';
|
||||
}
|
||||
get connected() {
|
||||
return this.socket.isConnected;
|
||||
}
|
||||
get events() {
|
||||
return this.liveEvents.items;
|
||||
}
|
||||
get loaded() {
|
||||
return this.liveEvents.finished;
|
||||
}
|
||||
initialize(live: boolean) {
|
||||
if (this.initializedLive)
|
||||
return;
|
||||
|
||||
notification.requestPermission();
|
||||
|
||||
this.initializedLive = true;
|
||||
this.server.getSignedAccount()
|
||||
.then(account => {
|
||||
this.account = account;
|
||||
this.updateState();
|
||||
this.resolveAccount(account);
|
||||
|
||||
if (live) {
|
||||
setTimeout(() => this.liveEvents.live(), 100);
|
||||
setInterval(() => this.checkDuplicateEntries(), 60 * MINUTE);
|
||||
setInterval(() => {
|
||||
if (this.connected) {
|
||||
this.getCounts().then(counts => this.counts = counts || this.counts);
|
||||
}
|
||||
}, 5 * 1000);
|
||||
}
|
||||
});
|
||||
}
|
||||
connectedToSocket() {
|
||||
this.accounts.connected();
|
||||
this.origins.connected();
|
||||
this.auths.connected();
|
||||
this.ponies.connected();
|
||||
this.accountAuths.connected();
|
||||
this.accountPonies.connected();
|
||||
this.accountOrigins.connected();
|
||||
this.updateTitle();
|
||||
}
|
||||
checkDuplicateEntries(force = false) {
|
||||
return this.server.getDuplicateEntries(force)
|
||||
.then(entries => this.duplicateEntries = entries || [])
|
||||
.catch(noop);
|
||||
}
|
||||
stop() {
|
||||
this.running = false;
|
||||
this.liveEvents.stop();
|
||||
}
|
||||
toggleNotifications() {
|
||||
this.storage.setItem('admin-notifications', this.notifications ? 'false' : 'true');
|
||||
}
|
||||
// other
|
||||
getCounts() {
|
||||
return this.checkError(this.server.getCounts());
|
||||
}
|
||||
getRequestStats() {
|
||||
return this.checkError(this.server.getRequestStats());
|
||||
}
|
||||
getOtherStats() {
|
||||
return this.checkError(this.server.getOtherStats());
|
||||
}
|
||||
// auths
|
||||
getAuth(id: string) {
|
||||
return this.checkError(this.server.getAuth(id));
|
||||
}
|
||||
getAuthsForAccount(accountId: string) {
|
||||
return this.checkError(this.server.getAuthsForAccount(accountId));
|
||||
}
|
||||
removeAuth(id: string) {
|
||||
return this.checkError(this.server.removeAuth(id));
|
||||
}
|
||||
assignAuth(authId: string, accountId: string) {
|
||||
return this.checkError(this.server.assignAuth(authId, accountId));
|
||||
}
|
||||
updateAuth(id: string, update: AuthUpdate) {
|
||||
return this.checkError(this.server.updateAuth(id, update));
|
||||
}
|
||||
setAuthPledged(id: string, pledged: number) {
|
||||
return this.checkError(this.server.updateAuth(id, { pledged }));
|
||||
}
|
||||
// ponies
|
||||
getPoniesCreators(accountId: string) {
|
||||
return this.checkError(this.server.getPoniesCreators(accountId));
|
||||
}
|
||||
getPoniesForAccount(accountId: string) {
|
||||
return this.checkError(this.server.getPoniesForAccount(accountId));
|
||||
}
|
||||
getPonyInfo(pony: Character) {
|
||||
return this.server.getPonyInfo(pony._id)
|
||||
.then(data => {
|
||||
if (data) {
|
||||
pony.info = data.info;
|
||||
pony.ponyInfo = decompressPonyString(data.info, false);
|
||||
pony.lastUsed = data.lastUsed ? new Date(data.lastUsed) : undefined;
|
||||
pony.creator = data.creator;
|
||||
}
|
||||
});
|
||||
}
|
||||
removePony(id: string) {
|
||||
return this.checkError(this.server.removePony(id));
|
||||
}
|
||||
assignPony(ponyId: string, accountId: string) {
|
||||
return this.checkError(this.server.assignPony(ponyId, accountId));
|
||||
}
|
||||
findPonies(query: FindPonyQuery, page: number, skipTotalCount: boolean): Promise<FindPoniesResult | undefined> {
|
||||
return this.checkError(this.server.findPonies(query, page, skipTotalCount));
|
||||
}
|
||||
removePoniesAboveLimit(account: string) {
|
||||
return this.checkError(this.server.removePoniesAboveLimit(account));
|
||||
}
|
||||
removeAllPonies(account: string) {
|
||||
return this.checkError(this.server.removeAllPonies(account));
|
||||
}
|
||||
createPony(accountId: string, name: string, info: string) {
|
||||
return this.checkError(this.server.createPony(accountId, name, info));
|
||||
}
|
||||
restorePonies(accountId: string, chatlog: string, onlyIds?: string[]) {
|
||||
const ponies = parsePonies(chatlog, onlyIds);
|
||||
return Promise.all(ponies.map(({ name, info }) => this.createPony(accountId, name, info)));
|
||||
}
|
||||
// origins
|
||||
updateOrigin(origin: UpdateOrigin) {
|
||||
return this.checkError(this.server.updateOrigin(origin));
|
||||
}
|
||||
removeOriginsForAccount(accountId: string, ips: string[]) {
|
||||
return this.checkError(this.server.removeOriginsForAccount(accountId, ips));
|
||||
}
|
||||
clearOriginsForAccount(accountId: string, options: ClearOrignsOptions) {
|
||||
return this.clearOriginsForAccounts([accountId], options);
|
||||
}
|
||||
clearOriginsForAccounts(accounts: string[], options: ClearOrignsOptions) {
|
||||
return this.checkError(this.server.clearOriginsForAccounts(accounts, options));
|
||||
}
|
||||
clearOrigins(count: number, andHigher: boolean, options: ClearOrignsOptions) {
|
||||
return this.checkError(this.server.clearOrigins(count, andHigher, options));
|
||||
}
|
||||
addOriginToAccount(accountId: string, origin: OriginInfo) {
|
||||
return this.checkError(this.server.addOriginToAccount(accountId, origin));
|
||||
}
|
||||
getOriginStats() {
|
||||
return this.checkError(this.server.getOriginStats());
|
||||
}
|
||||
// accounts
|
||||
getAccount(id: string) {
|
||||
return this.checkError(this.server.getAccount(id));
|
||||
}
|
||||
getDetailsForAccount(account: Account) {
|
||||
return this.checkError(this.server.getDetailsForAccount(account._id));
|
||||
}
|
||||
findAccounts(query: FindAccountQuery) {
|
||||
return this.checkError(this.server.findAccounts(query));
|
||||
}
|
||||
createAccount(name = '') {
|
||||
return this.checkError(this.server.createAccount(name)
|
||||
.then(id => (console.log(`created account: [${id}]`), id)));
|
||||
}
|
||||
getAccountStatus(accountId: string) {
|
||||
return this.checkError(this.server.getAccountStatus(accountId));
|
||||
}
|
||||
getAccountAround(accountId: string) {
|
||||
return this.checkError(this.server.getAccountAround(accountId));
|
||||
}
|
||||
getAccountHidden(accountId: string) {
|
||||
return this.checkError(this.server.getAccountHidden(accountId));
|
||||
}
|
||||
getAccountFriends(accountId: string) {
|
||||
return this.checkError(this.server.getAccountFriends(accountId));
|
||||
}
|
||||
getAllDuplicatesQuickInfo(accountId: string) {
|
||||
return this.checkError(this.server.getAllDuplicatesQuickInfo(accountId));
|
||||
}
|
||||
getAllDuplicates(accountId: string) {
|
||||
return this.checkError(this.server.getAllDuplicates(accountId));
|
||||
}
|
||||
getAccountsByEmails(emails: string[]) {
|
||||
return this.checkError(this.server.getAccountsByEmails(emails));
|
||||
}
|
||||
getAccountsByOrigin(ip: string) {
|
||||
return this.checkError(this.server.getAccountsByOrigin(ip));
|
||||
}
|
||||
removeAccount(accountId: string) {
|
||||
return this.checkError(this.server.removeAccount(accountId));
|
||||
}
|
||||
setAlert(accountId: string, message: string, expiresIn: number) {
|
||||
return this.checkError(this.server.setAlert(accountId, message, expiresIn));
|
||||
}
|
||||
setName(accountId: string, name: string) {
|
||||
return this.checkError(this.server.setName(accountId, name));
|
||||
}
|
||||
setAge(accountId: string, age: number) {
|
||||
return this.checkError(this.server.setAge(accountId, age));
|
||||
}
|
||||
setRole(accountId: string, role: string, set: boolean) {
|
||||
return this.checkError(this.server.setRole(accountId, role, set));
|
||||
}
|
||||
setNote(accountId: string, note: string) {
|
||||
const account = this.accounts.get(accountId);
|
||||
|
||||
if (account && account.note !== note) {
|
||||
account.note = note;
|
||||
account.noteUpdated = new Date();
|
||||
}
|
||||
|
||||
return this.checkError(this.server.updateAccount(accountId, { note }));
|
||||
}
|
||||
setAccountFlags(accountId: string, flags: AccountFlags) {
|
||||
return this.checkError(this.server.updateAccount(accountId, { flags }));
|
||||
}
|
||||
setSupporterFlags(accountId: string, supporter: SupporterFlags) {
|
||||
return this.checkError(this.server.updateAccount(accountId, { supporter }));
|
||||
}
|
||||
setAccountBanField(accountId: string, field: string, value: number) {
|
||||
return this.checkError(this.server.updateAccount(accountId, { [field]: value }, banMessage(field, value)));
|
||||
}
|
||||
setAccountTimeout(accountId: string, timeout: number) {
|
||||
return this.checkError(this.server.timeoutAccount(accountId, timeout));
|
||||
}
|
||||
setAccountCounter(accountId: string, name: keyof AccountCounters, value: number) {
|
||||
return this.checkError(this.server.updateAccountCounter(accountId, name, value));
|
||||
}
|
||||
updateAccount(accountId: string, update: Partial<Account>) {
|
||||
return this.checkError(this.server.updateAccount(accountId, update));
|
||||
}
|
||||
mergeAccounts(accountId: string, withId: string) {
|
||||
return this.checkError(this.server.mergeAccounts(accountId, withId));
|
||||
}
|
||||
unmergeAccounts(accountId: string, mergeId: string | undefined, split: MergeAccountData, keep: MergeAccountData) {
|
||||
return this.checkError(this.server.unmergeAccounts(accountId, mergeId, split, keep));
|
||||
}
|
||||
addEmail(accountId: string, email: string) {
|
||||
return this.checkError(this.server.addEmail(accountId, email));
|
||||
}
|
||||
removeEmail(accountId: string, email: string) {
|
||||
return this.checkError(this.server.removeEmail(accountId, email));
|
||||
}
|
||||
removeIgnore(accountId: string, ignore: string) {
|
||||
return this.checkError(this.server.removeIgnore(accountId, ignore));
|
||||
}
|
||||
addIgnores(accountId: string, ignores: string[]) {
|
||||
return this.checkError(this.server.addIgnores(accountId, ignores));
|
||||
}
|
||||
removeFriend(accountId: string, friendId: string) {
|
||||
return this.checkError(this.server.removeFriend(accountId, friendId));
|
||||
}
|
||||
addFriend(accountId: string, friendId: string) {
|
||||
return this.checkError(this.server.addFriend(accountId, friendId));
|
||||
}
|
||||
setAccountState(accountId: string, state: AccountState) {
|
||||
return this.checkError(this.server.setAccountState(accountId, state));
|
||||
}
|
||||
getIgnoresAndIgnoredBy(accountId: string) {
|
||||
return this.checkError(this.server.getIgnoresAndIgnoredBy(accountId));
|
||||
}
|
||||
clearSessions(accountId: string) {
|
||||
return this.checkError(this.server.clearSessions(accountId));
|
||||
}
|
||||
// events
|
||||
removeEvent(eventId: string) {
|
||||
// return this.checkError(this.server.removeEvent(eventId))
|
||||
return this.liveEvents.remove(eventId)
|
||||
.then(() => this.callUpdated('events', false))
|
||||
.then(() => this.updateTitle())
|
||||
.catch(this.handleError);
|
||||
}
|
||||
cleanupDeletedEvents() {
|
||||
if (remove(this.events, e => e.deleted).length) {
|
||||
this.callUpdated('events', false);
|
||||
this.updateTitle();
|
||||
}
|
||||
}
|
||||
// state
|
||||
updateSettings(settings: Partial<Settings>) {
|
||||
return this.server.updateSettings(settings)
|
||||
.then(() => this.updateState());
|
||||
}
|
||||
updateGameServerSettings(serverId: string, settings: Partial<GameServerSettings>) {
|
||||
return this.server.updateGameServerSettings(serverId, settings)
|
||||
.then(() => this.updateState());
|
||||
}
|
||||
report(accountId: string) {
|
||||
return this.checkError(this.server.report(accountId));
|
||||
}
|
||||
action(action: string, accountId: string) {
|
||||
return this.checkError(this.server.action(action, accountId));
|
||||
}
|
||||
kick(accountId: string) {
|
||||
return this.checkError(this.server.kick(accountId));
|
||||
}
|
||||
kickAll(serverId: string) {
|
||||
return this.server.kickAll(serverId)
|
||||
.then(() => this.updateState());
|
||||
}
|
||||
getChat(search: string, date?: string, caseInsensitive = false) {
|
||||
date = date || (new Date()).toISOString();
|
||||
return search ? this.server.getChat(search, date, caseInsensitive) : Promise.resolve('');
|
||||
}
|
||||
getChatForAccounts(accountIds: string[], date?: string) {
|
||||
date = date || (new Date()).toISOString();
|
||||
return accountIds.length ? this.server.getChatForAccounts(accountIds, date) : Promise.resolve('');
|
||||
}
|
||||
searchFormattedChat(search: string, date?: string) {
|
||||
return this.formatChat(this.getChat(search, date, true));
|
||||
}
|
||||
accountsFormattedChat(accountIds: string[], date?: string) {
|
||||
return this.formatChat(this.getChatForAccounts(accountIds, date));
|
||||
}
|
||||
private formatChat(promise: Promise<string>) {
|
||||
return this.checkError(promise)
|
||||
.then(chat => chat === undefined ? 'ERROR' : chat)
|
||||
.then(raw => ({ raw, html: formatChat(raw) }));
|
||||
}
|
||||
fetchServerStats(serverId: string) {
|
||||
return this.checkError(this.server.fetchServerStats(serverId));
|
||||
}
|
||||
fetchServerStatsTable(serverId: string, stats: Stats) {
|
||||
return this.checkError(this.server.fetchServerStatsTable(serverId, stats));
|
||||
}
|
||||
notifyUpdate(server: string) {
|
||||
return this.server.notifyUpdate(server)
|
||||
.then(() => this.updateState())
|
||||
.catch(this.handleError);
|
||||
}
|
||||
shutdownServers(server: string) {
|
||||
return this.server.shutdownServers(server)
|
||||
.then(() => this.updateState())
|
||||
.catch(this.handleError);
|
||||
}
|
||||
resetUpdating(server: string) {
|
||||
return this.server.resetUpdating(server)
|
||||
.then(() => this.updateState())
|
||||
.catch(this.handleError);
|
||||
}
|
||||
resetSupporter(accountId: string) {
|
||||
return this.server.resetSupporter(accountId)
|
||||
.catch(this.handleError);
|
||||
}
|
||||
getLastPatreonData() {
|
||||
return this.server.getLastPatreonData()
|
||||
.catch(this.handleError);
|
||||
}
|
||||
updatePastSupporters() {
|
||||
return this.server.updatePastSupporters()
|
||||
.catch(this.handleError);
|
||||
}
|
||||
// other
|
||||
getTimings(server: string) {
|
||||
return this.server.getTimings(server)
|
||||
.catch(this.handleError);
|
||||
}
|
||||
teleportTo(accountId: string) {
|
||||
return this.server.teleportTo(accountId)
|
||||
.catch(this.handleError);
|
||||
}
|
||||
// helpers
|
||||
get isLowDiskSpace() {
|
||||
return parseInt(this.state.status.diskSpace || '0', 10) > 95;
|
||||
}
|
||||
get isLowMemory() {
|
||||
return parseInt(this.state.status.memoryUsage || '0', 10) > 90;
|
||||
}
|
||||
get isOldCertificate() {
|
||||
const date = this.state.status.certificateExpiration;
|
||||
return date && (new Date(date)).getTime() < fromNow(7 * DAY).getTime();
|
||||
}
|
||||
get isOldPatreon() {
|
||||
const date = this.state.status.lastPatreonUpdate;
|
||||
return date && (new Date(date)).getTime() < fromNow(-21 * MINUTE).getTime();
|
||||
}
|
||||
private requestState() {
|
||||
return this.socket.isConnected ? this.server.getState().then(s => this.readState(s)) : Promise.resolve();
|
||||
}
|
||||
private readState(state: AdminState) {
|
||||
merge(this.state, state);
|
||||
this.initialized = true;
|
||||
this.updateTitle();
|
||||
}
|
||||
private updateStateTimeout: any;
|
||||
private updateState(): void {
|
||||
if (!this.running)
|
||||
return;
|
||||
|
||||
clearTimeout(this.updateStateTimeout);
|
||||
|
||||
this.requestState()
|
||||
.catch((e: Error) => console.error(e.stack))
|
||||
.then(() => {
|
||||
this.updateStateTimeout = setTimeout(() => this.updateState(), 1000);
|
||||
});
|
||||
}
|
||||
private callUpdated(list: string, added: boolean) {
|
||||
if (this.updated) {
|
||||
this.updated(list, added);
|
||||
}
|
||||
}
|
||||
updateTitle() {
|
||||
const ponies = this.state.gameServers.reduce((sum, s) => sum + s.online, 0);
|
||||
const count = this.events.reduce((sum, e) => sum + (e.deleted ? 0 : 1), 0);
|
||||
const inred = this.events.reduce((sum, e) => sum + ((!e.deleted && e.count > 9) ? 1 : 0), 0);
|
||||
const flag = this.isLowDiskSpace || this.isLowMemory || this.isOldCertificate || this.isOldPatreon;
|
||||
document.title = `${ponies} | ${count}${repeat('!', inred)}${flag ? ' 🚩' : ''}${!this.connected ? ' ⚠' : ''} | Pony Town`;
|
||||
}
|
||||
private notify(title: string, body: string) {
|
||||
if (this.notifications && notification.permission === 'granted') {
|
||||
const n = new notification(title, {
|
||||
body: body || '',
|
||||
icon: getUrl('images/logo-120.png'),
|
||||
});
|
||||
|
||||
n.onclick = () => {
|
||||
window.focus();
|
||||
n.close();
|
||||
};
|
||||
|
||||
n.onshow = () => {
|
||||
setTimeout(() => n.close(), 4000);
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function decodeDate(value: number | undefined, base: string | undefined): Date {
|
||||
if (value == null || base == null) {
|
||||
return new Date(0);
|
||||
} else {
|
||||
const d = new Date(base);
|
||||
d.setTime(d.getTime() + value);
|
||||
return d;
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeEvent(values: any[], base: BaseValues): Event {
|
||||
return {
|
||||
_id: values[0],
|
||||
updatedAt: decodeDate(values[1], base.updatedAt!),
|
||||
createdAt: decodeDate(values[2], base.createdAt!),
|
||||
type: values[3],
|
||||
server: values[4],
|
||||
message: values[5],
|
||||
desc: values[6],
|
||||
count: values[7] | 0,
|
||||
origin: values[8],
|
||||
account: values[9],
|
||||
pony: values[10],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Howl } from 'howler';
|
||||
import { random, sample } from 'lodash';
|
||||
import { Season, Holiday, MapType } from '../../common/interfaces';
|
||||
import { getUrl } from '../../client/rev';
|
||||
|
||||
interface Track {
|
||||
name: string;
|
||||
src: string[];
|
||||
howl?: Howl;
|
||||
}
|
||||
|
||||
function getTracks(season: Season, holiday: Holiday, map: MapType) {
|
||||
switch (map) {
|
||||
case MapType.Island:
|
||||
return [
|
||||
'island',
|
||||
'sunny-island',
|
||||
];
|
||||
case MapType.House:
|
||||
return [
|
||||
'happy-house',
|
||||
'sweet-home',
|
||||
];
|
||||
case MapType.Cave:
|
||||
return [
|
||||
'cave-crystals',
|
||||
'cave-secrets',
|
||||
];
|
||||
default:
|
||||
return [
|
||||
//'largo',
|
||||
//'musicbox',
|
||||
//'unrest',
|
||||
'bossanova',
|
||||
'clop',
|
||||
'fivefour',
|
||||
'hypnosis',
|
||||
'scherzo',
|
||||
'trills',
|
||||
'waltzalt',
|
||||
...(season === Season.Winter ? [
|
||||
'trees-winter',
|
||||
'reindeer-winter',
|
||||
] : [
|
||||
'trees',
|
||||
'reindeer',
|
||||
]),
|
||||
'season',
|
||||
'ambient',
|
||||
'building',
|
||||
'school',
|
||||
'falling',
|
||||
'tio',
|
||||
'orchid',
|
||||
...(season === Season.Winter ? [
|
||||
'xmas-air',
|
||||
'xmas-horns',
|
||||
'xmas-presents',
|
||||
] : []),
|
||||
...(holiday === Holiday.Halloween ? [
|
||||
'ghost',
|
||||
'pumpkin',
|
||||
] : []),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
const FADE_TRACKS = true;
|
||||
|
||||
function fadeOut(track: Track, id: number, volume: number) {
|
||||
const howl = track && track.howl;
|
||||
|
||||
if (howl) {
|
||||
if (FADE_TRACKS) {
|
||||
howl
|
||||
.fade(volume, 0, 1000, id)
|
||||
.once('fade', () => howl.pause(id).stop(id), id);
|
||||
} else {
|
||||
howl
|
||||
.volume(0, id)
|
||||
.pause(id)
|
||||
.stop(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fadeIn(track: Track, id: number, volume: number) {
|
||||
if (track && track.howl) {
|
||||
track.howl.fade(0, volume, 1000, id);
|
||||
}
|
||||
}
|
||||
|
||||
interface Instance {
|
||||
id: number;
|
||||
track: Track;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class Audio {
|
||||
private tracks: Track[] = [];
|
||||
private volume = 0;
|
||||
private loops = 0;
|
||||
private playing = false;
|
||||
private stopped: Instance[] = [];
|
||||
private instance?: Instance;
|
||||
get trackName() {
|
||||
return this.instance && this.volume ? this.instance.track.name : '';
|
||||
}
|
||||
initTracks(season: Season, holiday: Holiday, map: MapType) {
|
||||
const tracks = getTracks(season, holiday, map);
|
||||
|
||||
// Make new tracks more frequent
|
||||
// const duplicateTracks = tracks.filter(t => t === 'ghost' || t === 'pumpkin');
|
||||
// tracks.push(...duplicateTracks);
|
||||
// tracks.push(...duplicateTracks);
|
||||
|
||||
this.tracks = tracks.map(name => ({ name, src: [getUrl(`music/${name}.webm`), getUrl(`music/${name}.mp3`)] }));
|
||||
this.loops = 0;
|
||||
}
|
||||
setVolume(volume: number) {
|
||||
this.volume = volume / 100;
|
||||
|
||||
if (this.playing) {
|
||||
if (this.instance) {
|
||||
this.setInstanceVolume(this.instance, this.volume);
|
||||
} else if (this.volume) {
|
||||
this.playRandomTrack();
|
||||
}
|
||||
}
|
||||
}
|
||||
play() {
|
||||
try {
|
||||
if (!this.playing) {
|
||||
this.playing = true;
|
||||
|
||||
if (this.volume) {
|
||||
if (this.instance) {
|
||||
this.resumeInstance(this.instance);
|
||||
} else {
|
||||
this.playRandomTrack();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
playOrSwitchToRandomTrack() {
|
||||
if (FADE_TRACKS) {
|
||||
if (this.playing && this.volume) {
|
||||
this.playRandomTrack();
|
||||
} else {
|
||||
this.play();
|
||||
}
|
||||
} else {
|
||||
this.play();
|
||||
}
|
||||
}
|
||||
stop() {
|
||||
if (this.playing) {
|
||||
this.playing = false;
|
||||
this.stopInstance(this.instance);
|
||||
}
|
||||
}
|
||||
forcePlay() {
|
||||
if (!this.instance || !this.instance.track.howl!.playing(this.instance.id)) {
|
||||
this.playRandomTrack();
|
||||
}
|
||||
}
|
||||
touch() {
|
||||
this.stopInstances();
|
||||
this.setInstanceVolume(this.instance, this.volume);
|
||||
}
|
||||
private switchToTrack(track: Track) {
|
||||
if (this.instance && this.instance.track === track) {
|
||||
return false;
|
||||
} else {
|
||||
this.stopInstance(this.instance);
|
||||
this.instance = this.playTrack(track);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
playRandomTrack() {
|
||||
while (!this.switchToTrack(sample(this.tracks)!))
|
||||
;
|
||||
|
||||
this.loops = random(4, 7);
|
||||
}
|
||||
private playTrack(track: Track): Instance {
|
||||
this.prepareTrack(track);
|
||||
const id = track.howl!.play();
|
||||
fadeIn(track, id, this.volume);
|
||||
return { id, track };
|
||||
}
|
||||
private resumeInstance({ track, id }: Instance) {
|
||||
track.howl!.play(id);
|
||||
fadeIn(track, id, this.volume);
|
||||
}
|
||||
private stopInstance(instance: Instance | undefined) {
|
||||
if (instance) {
|
||||
this.stopped.push(instance);
|
||||
}
|
||||
|
||||
this.stopInstances();
|
||||
}
|
||||
private stopInstances() {
|
||||
this.stopped.forEach(({ track, id }) => fadeOut(track, id, this.volume));
|
||||
this.stopped = this.stopped.filter(({ track, id }) => track.howl!.playing(id));
|
||||
}
|
||||
private setInstanceVolume(instance: Instance | undefined, volume: number) {
|
||||
if (instance) {
|
||||
const howl = instance.track.howl!;
|
||||
howl.volume(volume, instance.id);
|
||||
|
||||
if (volume && !howl.playing(instance.id)) {
|
||||
howl.play(instance.id);
|
||||
} else if (!volume && howl.playing(instance.id)) {
|
||||
howl.pause(instance.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
private prepareTrack(track: Track) {
|
||||
if (!track.howl) {
|
||||
track.howl = new Howl({
|
||||
src: track.src,
|
||||
loop: true,
|
||||
html5: true,
|
||||
});
|
||||
|
||||
track.howl.on('end', id => this.onEnd(id));
|
||||
}
|
||||
}
|
||||
private handlingOnEnd = 0;
|
||||
private handlingOnEndAt = 0;
|
||||
private onEnd(id: number) {
|
||||
if (
|
||||
this.instance && this.instance.id === id && --this.loops < 0 &&
|
||||
(this.handlingOnEnd !== id || this.handlingOnEndAt < performance.now())
|
||||
) {
|
||||
this.handlingOnEnd = id;
|
||||
this.handlingOnEndAt = performance.now() + 500;
|
||||
|
||||
if (this.volume && this.playing) {
|
||||
this.playRandomTrack();
|
||||
} else {
|
||||
this.stopInstance(this.instance);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Router, CanActivate } from '@angular/router';
|
||||
import { Model } from './model';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AuthGuard implements CanActivate {
|
||||
constructor(private router: Router, private model: Model) {
|
||||
}
|
||||
canActivate() {
|
||||
return this.model.accountPromise
|
||||
.then(account => {
|
||||
if (account) {
|
||||
return true;
|
||||
} else {
|
||||
this.router.navigate(['/']);
|
||||
return false;
|
||||
}
|
||||
}) as any;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { ClientErrorHandler, ClientOptions } from 'ag-sockets/dist/browser';
|
||||
import { Person } from '../../common/rollbar';
|
||||
|
||||
@Injectable()
|
||||
export class ErrorReporter {
|
||||
disable() {
|
||||
}
|
||||
configureUser(_person: Person) {
|
||||
}
|
||||
configureData(_data: any) {
|
||||
}
|
||||
captureEvent(_data: any) {
|
||||
}
|
||||
reportError(error: any, data?: any) {
|
||||
console.error(error, data);
|
||||
}
|
||||
createClientErrorHandler(socketOptions: ClientOptions): ClientErrorHandler {
|
||||
const handleRecvError = (error: Error, data: string | Uint8Array) => {
|
||||
if (error.message) {
|
||||
let method: string | undefined;
|
||||
|
||||
if (data instanceof Uint8Array) {
|
||||
const bytes: number[] = [];
|
||||
const length = Math.min(data.length, 200);
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
bytes.push(data[i]);
|
||||
}
|
||||
|
||||
const trail = length < data.length ? '...' : '';
|
||||
|
||||
if (data.length > 0) {
|
||||
const item = socketOptions.client[data[0]] as string | [string, any];
|
||||
method = typeof item === 'string' ? item : item[0];
|
||||
}
|
||||
|
||||
data = `<${bytes.toString()}${trail}>`;
|
||||
}
|
||||
|
||||
this.reportError(error, { data, method });
|
||||
}
|
||||
};
|
||||
|
||||
return { handleRecvError };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Injectable, NgZone } from '@angular/core';
|
||||
|
||||
export interface FrameLoop {
|
||||
init(): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class FrameService {
|
||||
constructor(private zone: NgZone) {
|
||||
}
|
||||
create(frame: (delta: number) => void) {
|
||||
const zone = this.zone;
|
||||
let ref = 0;
|
||||
let last = 0;
|
||||
|
||||
function tick(now: number) {
|
||||
ref = requestAnimationFrame(tick);
|
||||
frame((now - last) / 1000);
|
||||
last = now;
|
||||
}
|
||||
|
||||
return {
|
||||
init() {
|
||||
if (!ref) {
|
||||
last = performance.now();
|
||||
zone.runOutsideAngular(() => ref = requestAnimationFrame(tick));
|
||||
}
|
||||
},
|
||||
destroy() {
|
||||
cancelAnimationFrame(ref);
|
||||
ref = 0;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import { Injectable, NgZone, ErrorHandler } from '@angular/core';
|
||||
import { merge } from 'lodash';
|
||||
import { createClientSocket, SocketService, ClientOptions, ClientErrorHandler } from 'ag-sockets/dist/browser';
|
||||
import { ServerInfo, IServerActions, GameStatus, AccountData, LeaveReason } from '../../common/interfaces';
|
||||
import { RequestError, findById, includes } from '../../common/utils';
|
||||
import { OFFLINE_ERROR, PROTECTION_ERROR, BROWSER_NOT_SUPPORTED_ERROR, VERSION_ERROR } from '../../common/errors';
|
||||
import { startGameLoop, GameLoop } from '../../client/gameLoop';
|
||||
import { ClientActions } from '../../client/clientActions';
|
||||
import { socketOptions } from '../../client/data';
|
||||
import { PonyTownGame } from '../../client/game';
|
||||
import { Model } from './model';
|
||||
import { ErrorReporter } from './errorReporter';
|
||||
import { meetsRequirement } from '../../common/accountUtils';
|
||||
import { isLanguage, isFocused, sortServersForRussian } from '../../client/clientUtils';
|
||||
import { StorageService } from './storageService';
|
||||
|
||||
export interface ClientSocketService extends SocketService<ClientActions, IServerActions> { }
|
||||
|
||||
function createSocket(
|
||||
gameService: GameService, game: PonyTownGame, model: Model, zone: NgZone, options: ClientOptions,
|
||||
token: string, errorHandler: ClientErrorHandler
|
||||
): ClientSocketService {
|
||||
const socket = createClientSocket<ClientActions, IServerActions>(options, token, errorHandler);
|
||||
socket.client = new ClientActions(gameService, game, model, zone);
|
||||
|
||||
if (!socket.supportsBinary) {
|
||||
throw new Error(BROWSER_NOT_SUPPORTED_ERROR);
|
||||
}
|
||||
|
||||
return socket;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class GameService {
|
||||
playing = false;
|
||||
joining = false;
|
||||
offline = false;
|
||||
protectionError = false;
|
||||
rateLimitError = false;
|
||||
versionError = false;
|
||||
version?: string;
|
||||
server?: ServerInfo;
|
||||
servers: ServerInfo[] = [];
|
||||
error?: string;
|
||||
leftMessage?: string;
|
||||
private safelyLeft = false;
|
||||
private gameLoop?: GameLoop;
|
||||
private disconnectedTimeout?: any;
|
||||
private initialized = false;
|
||||
private update?: boolean;
|
||||
private locked = false;
|
||||
constructor(
|
||||
private model: Model,
|
||||
private game: PonyTownGame,
|
||||
private zone: NgZone,
|
||||
private errorHandler: ErrorHandler,
|
||||
private errorReporter: ErrorReporter,
|
||||
private storage: StorageService,
|
||||
) {
|
||||
this.pollStatus();
|
||||
}
|
||||
get selected() {
|
||||
return this.game.selected;
|
||||
}
|
||||
get account() {
|
||||
return this.model.account;
|
||||
}
|
||||
get canPlay(): boolean {
|
||||
return !!this.model.pony &&
|
||||
!!this.model.pony.name &&
|
||||
!this.model.pending &&
|
||||
!this.joining &&
|
||||
!!this.server &&
|
||||
!this.server.offline &&
|
||||
!this.rateLimitError &&
|
||||
!this.versionError &&
|
||||
!this.locked;
|
||||
}
|
||||
get updateWarning(): boolean {
|
||||
return !!this.update;
|
||||
}
|
||||
get filterSwearWords(): boolean {
|
||||
return !!(this.server && this.server.filter)
|
||||
|| !!(this.account && this.account.settings && this.account.settings.filterSwearWords);
|
||||
}
|
||||
get wasPlaying() {
|
||||
return this.storage.getBoolean('playing');
|
||||
}
|
||||
join(ponyId: string) {
|
||||
this.errorReporter.captureEvent({ name: 'Join' });
|
||||
const server = this.server;
|
||||
|
||||
if (this.playing || this.joining || !server) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (typeof WebSocket === 'undefined' || typeof Float32Array === 'undefined') {
|
||||
return Promise.reject(new Error(BROWSER_NOT_SUPPORTED_ERROR));
|
||||
}
|
||||
|
||||
this.joining = true;
|
||||
this.leftMessage = undefined;
|
||||
this.safelyLeft = false;
|
||||
|
||||
return this.model.join(server.id, ponyId)
|
||||
.then(({ token, alert }) => {
|
||||
if (!this.joining) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
this.model.accountAlert = alert;
|
||||
this.joining = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.zone.runOutsideAngular(() => {
|
||||
const options = { ...socketOptions(), path: server.path, host: server.host };
|
||||
const errorHandler = this.errorReporter.createClientErrorHandler(options);
|
||||
const socket = createSocket(this, this.game, this.model, this.zone, options, token, errorHandler);
|
||||
|
||||
if (this.gameLoop) {
|
||||
this.gameLoop.cancel();
|
||||
}
|
||||
|
||||
this.game.startup(socket, this.model.isMod);
|
||||
this.gameLoop = startGameLoop(this.game, e => this.handleGameError(e));
|
||||
|
||||
return this.gameLoop.started
|
||||
.then(() => {
|
||||
this.errorReporter.captureEvent({ name: 'gameLoop.started' });
|
||||
const socketConnected = this.pollUntilConnected(socket);
|
||||
socket.connect();
|
||||
return socketConnected;
|
||||
})
|
||||
.then(() => {
|
||||
this.errorReporter.captureEvent({ name: 'socketConnected' });
|
||||
return true;
|
||||
})
|
||||
.catch(e => {
|
||||
this.errorReporter.captureEvent({ name: 'socket.disconnect()', error: e.message });
|
||||
socket.disconnect();
|
||||
throw e;
|
||||
});
|
||||
});
|
||||
})
|
||||
.then(joined => {
|
||||
this.errorReporter.captureEvent({ name: joined ? 'Joined game' : 'Not joined game' });
|
||||
})
|
||||
.catch((e: RequestError) => {
|
||||
this.errorReporter.captureEvent({ name: 'Failed to join game', error: e.message });
|
||||
|
||||
// if (e.status && e.status > 500 && e.status < 500) {
|
||||
// this.rateLimitError = true;
|
||||
// setTimeout(() => this.rateLimitError = false, 5000);
|
||||
// }
|
||||
|
||||
this.zone.run(() => this.left('join.catch'));
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
leave(reason: string) {
|
||||
this.errorReporter.captureEvent({ name: 'Leave', reason });
|
||||
this.game.leave();
|
||||
this.left('leave');
|
||||
}
|
||||
joined() {
|
||||
this.errorReporter.captureEvent({ name: 'Joined' });
|
||||
this.storage.setBoolean('playing', true);
|
||||
clearTimeout(this.disconnectedTimeout);
|
||||
setTimeout(() => {
|
||||
this.joining = false;
|
||||
this.playing = true;
|
||||
});
|
||||
}
|
||||
left(from: string, reason = LeaveReason.None) {
|
||||
this.errorReporter.captureEvent({ name: 'Left', from, reason });
|
||||
this.storage.setBoolean('playing', false);
|
||||
this.safelyLeft = true;
|
||||
|
||||
if (reason === LeaveReason.Swearing) {
|
||||
this.leftMessage = 'Kicked for swearing or inappropriate language';
|
||||
this.locked = true;
|
||||
} else {
|
||||
this.leftMessage = undefined;
|
||||
}
|
||||
|
||||
if (this.gameLoop) {
|
||||
this.errorReporter.captureEvent({ name: 'gameLoop.cancel()' });
|
||||
this.gameLoop.cancel();
|
||||
this.gameLoop = undefined;
|
||||
}
|
||||
|
||||
clearTimeout(this.disconnectedTimeout);
|
||||
|
||||
setTimeout(() => {
|
||||
this.joining = false;
|
||||
this.playing = false;
|
||||
});
|
||||
|
||||
if (this.locked) {
|
||||
setTimeout(() => {
|
||||
this.locked = false;
|
||||
}, 7000);
|
||||
}
|
||||
|
||||
if (this.model.friends) {
|
||||
for (const friend of this.model.friends) {
|
||||
friend.online = false;
|
||||
friend.entityId = 0;
|
||||
}
|
||||
}
|
||||
|
||||
this.game.release();
|
||||
this.game.onLeft.next();
|
||||
}
|
||||
disconnected() {
|
||||
this.errorReporter.captureEvent({ name: 'Disconnected' });
|
||||
clearTimeout(this.disconnectedTimeout);
|
||||
|
||||
if (!this.safelyLeft) {
|
||||
this.disconnectedTimeout = setTimeout(() => this.left('disconnected.timeout'), 10000);
|
||||
}
|
||||
}
|
||||
private pollStatus() {
|
||||
return this.getAndUpdateStatus(this.account)
|
||||
.finally(() => {
|
||||
setTimeout(() => this.pollStatus(), this.initialized ? 10000 : 500);
|
||||
});
|
||||
}
|
||||
private getAndUpdateStatus(account: AccountData | undefined) {
|
||||
if (this.joining || this.playing || !account || !isFocused()) {
|
||||
return Promise.resolve();
|
||||
} else {
|
||||
return this.model.status(this.initialized)
|
||||
.then(status => this.updateStatus(account, status))
|
||||
.catch((e: RequestError) => {
|
||||
DEVELOPMENT && console.error(e);
|
||||
this.offline = e.message === OFFLINE_ERROR;
|
||||
this.versionError = e.message === VERSION_ERROR;
|
||||
this.protectionError = e.message === PROTECTION_ERROR;
|
||||
});
|
||||
}
|
||||
}
|
||||
private updateStatus(account: AccountData, status: GameStatus) {
|
||||
this.initialized = true;
|
||||
this.offline = false;
|
||||
this.version = status.version;
|
||||
this.update = status.update;
|
||||
|
||||
for (const server of status.servers) {
|
||||
const existing = findById(this.servers, server.id);
|
||||
|
||||
if (existing) {
|
||||
merge(existing, server);
|
||||
} else if ('name' in server) {
|
||||
const info = server as ServerInfo;
|
||||
info.countryFlags = info.flag && /^[a-z]{2}( [a-z]{2})*$/.test(info.flag) ? info.flag.split(/ /g) : [];
|
||||
|
||||
if (info.name && account && meetsRequirement(account, info.require)) {
|
||||
this.servers.push(info);
|
||||
}
|
||||
} else {
|
||||
// got new server on the list
|
||||
this.initialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = this.servers.length - 1; i >= 0; i--) {
|
||||
if (!findById(status.servers, this.servers[i].id)) {
|
||||
this.servers.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (isLanguage('ru')) {
|
||||
this.servers.sort(sortServersForRussian);
|
||||
}
|
||||
|
||||
if (!this.server && account.settings.defaultServer) {
|
||||
this.server = findById(this.servers, account.settings.defaultServer);
|
||||
|
||||
if (DEVELOPMENT && /join/.test(this.model.pony.name)) {
|
||||
setTimeout(() => this.join(this.model.pony.id));
|
||||
}
|
||||
}
|
||||
|
||||
if (!includes(this.servers, this.server)) {
|
||||
this.server = undefined;
|
||||
}
|
||||
}
|
||||
private handleGameError(error: Error) {
|
||||
this.errorReporter.captureEvent({ name: 'handleGameError', error: error.message });
|
||||
this.error = error.message;
|
||||
this.errorHandler.handleError(error);
|
||||
this.leave('handleGameError');
|
||||
}
|
||||
private pollUntilConnected(socket: ClientSocketService) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const interval = setInterval(() => {
|
||||
if (socket.isConnected) {
|
||||
clearInterval(interval);
|
||||
this.zone.run(resolve);
|
||||
} else if (!this.joining) {
|
||||
clearInterval(interval);
|
||||
this.zone.run(() => reject(new Error('Cancelled (poll)')));
|
||||
}
|
||||
}, 10);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { StorageService } from './storageService';
|
||||
|
||||
interface InstallEvent extends Event {
|
||||
prompt(): void;
|
||||
userChoice: Promise<'accepted' | 'dismissed'>;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class InstallService {
|
||||
private installEvent?: InstallEvent;
|
||||
constructor(private storage: StorageService) {
|
||||
if (!this.storage.getBoolean('install-dismissed')) {
|
||||
window.addEventListener('beforeinstallprompt', event => {
|
||||
event.preventDefault();
|
||||
this.installEvent = event as any;
|
||||
});
|
||||
}
|
||||
}
|
||||
get canInstall() {
|
||||
return !!this.installEvent || (DEVELOPMENT && localStorage.getItem('install'));
|
||||
}
|
||||
install() {
|
||||
if (!this.installEvent) {
|
||||
return Promise.reject(new Error('Cannot install'));
|
||||
}
|
||||
|
||||
this.installEvent.prompt();
|
||||
|
||||
return this.installEvent.userChoice
|
||||
.finally(() => {
|
||||
this.installEvent = undefined;
|
||||
});
|
||||
}
|
||||
dismiss() {
|
||||
this.installEvent = undefined;
|
||||
this.storage.setBoolean('install-dismissed', true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Injectable, NgZone } from '@angular/core';
|
||||
import { removeItem } from '../../common/utils';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class IntervalUpdateService {
|
||||
private interval: any;
|
||||
private actions: (() => void)[] = [];
|
||||
constructor(private zone: NgZone) {
|
||||
}
|
||||
subscribe(action: () => void) {
|
||||
this.actions.push(action);
|
||||
|
||||
if (!this.interval) {
|
||||
this.zone.runOutsideAngular(() => {
|
||||
this.interval = setInterval(() => {
|
||||
this.actions.forEach(a => a());
|
||||
}, 1000 * 10);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
removeItem(this.actions, action);
|
||||
|
||||
if (this.actions.length === 0) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
toggle(action: () => void) {
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
|
||||
return (on: boolean) => {
|
||||
if (on && !unsubscribe) {
|
||||
unsubscribe = this.subscribe(action);
|
||||
} else if (!on && unsubscribe) {
|
||||
unsubscribe();
|
||||
unsubscribe = undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { SocketService } from 'ag-sockets/dist/browser';
|
||||
import { removeItem } from '../../common/utils';
|
||||
import { Document, LiveResponse, IAdminServerActions, BaseValues } from '../../common/adminInterfaces';
|
||||
import { ClientAdminActions } from '../../client/clientAdminActions';
|
||||
|
||||
export interface Options<T> {
|
||||
// collection
|
||||
beforeUpdate?: (updates: T[]) => void;
|
||||
onUpdated?: (added: T[], all: T[]) => void;
|
||||
onFinished?: () => void;
|
||||
// item
|
||||
decode: (fields: any[], base: BaseValues) => T;
|
||||
onUpdate?: (oldItem: T, newItem: T) => void;
|
||||
onDelete?: (item: T) => void;
|
||||
deleteItems?: boolean;
|
||||
ignore?: (item: T) => boolean;
|
||||
}
|
||||
|
||||
export class LiveCollection<T extends Document> {
|
||||
items: T[] = [];
|
||||
finished = false;
|
||||
private running = true;
|
||||
private itemsMap = new Map<string, T>();
|
||||
private liveTimeout: any;
|
||||
constructor(
|
||||
private name: 'events',
|
||||
private rate: number,
|
||||
private getKey: (item: T) => string,
|
||||
private options: Options<T>,
|
||||
private socket: SocketService<ClientAdminActions, IAdminServerActions>,
|
||||
private timestamp = (new Date(0)).toISOString(),
|
||||
private logError = (e: Error) => console.error(e.stack),
|
||||
) {
|
||||
}
|
||||
get(key: string) {
|
||||
return this.itemsMap.get(key);
|
||||
}
|
||||
push(item: T) {
|
||||
this.items.push(item);
|
||||
this.itemsMap.set(this.getKey(item), item);
|
||||
return item;
|
||||
}
|
||||
remove(key: string) {
|
||||
return this.server.removeItem(this.name, key)
|
||||
.then(() => this.removeItem(key, true, true));
|
||||
}
|
||||
removeItem(key: string, deleted = false, removeFromList = false) {
|
||||
const item = this.itemsMap.get(key);
|
||||
|
||||
if (item) {
|
||||
if (removeFromList || this.options.deleteItems) {
|
||||
removeItem(this.items, item);
|
||||
this.itemsMap.delete(key);
|
||||
} else if (deleted) {
|
||||
item.deleted = true;
|
||||
}
|
||||
|
||||
if (deleted && this.options.onDelete) {
|
||||
this.options.onDelete(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
assignAccount(id: string, account: string) {
|
||||
return this.server.assignAccount(this.name, id, account);
|
||||
}
|
||||
live(): Promise<void> {
|
||||
if (!this.running)
|
||||
return Promise.resolve();
|
||||
|
||||
clearTimeout(this.liveTimeout);
|
||||
|
||||
return this.update()
|
||||
.catch(this.logError)
|
||||
.then(more => {
|
||||
this.liveTimeout = setTimeout(() => this.live(), more ? 100 : this.rate);
|
||||
});
|
||||
}
|
||||
stop() {
|
||||
this.running = false;
|
||||
}
|
||||
read({ updates, deletes, base, more }: LiveResponse, liveFetch = true) {
|
||||
const items = updates.map(i => this.options.decode(i, base));
|
||||
|
||||
if (liveFetch) {
|
||||
const timestamp = items
|
||||
.reduce((max, i) => max.getTime() < i.updatedAt.getTime() ? i.updatedAt : max, new Date(this.timestamp));
|
||||
this.timestamp = timestamp.toISOString();
|
||||
}
|
||||
|
||||
if (this.options.beforeUpdate) {
|
||||
this.options.beforeUpdate(items);
|
||||
}
|
||||
|
||||
const { added, all } = this.applyUpdates(items, liveFetch);
|
||||
|
||||
if (this.options.onUpdated && items.length) {
|
||||
this.options.onUpdated(added, all);
|
||||
}
|
||||
|
||||
deletes.forEach(key => this.removeItem(key, true));
|
||||
|
||||
if (liveFetch) {
|
||||
const finished = this.finished || !more;
|
||||
|
||||
if (!this.finished && finished) {
|
||||
this.finished = true;
|
||||
|
||||
if (this.options.onFinished) {
|
||||
this.options.onFinished();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return more;
|
||||
}
|
||||
private get server() {
|
||||
return this.socket.server;
|
||||
}
|
||||
private update() {
|
||||
return this.socket.isConnected ? this.server.getAll(this.name, this.timestamp).then(r => this.read(r)) : Promise.resolve(false);
|
||||
}
|
||||
private applyUpdates(updates: T[], liveFetch: boolean) {
|
||||
const added: T[] = [];
|
||||
const all: T[] = [];
|
||||
|
||||
updates.forEach(update => {
|
||||
const doc = this.get(this.getKey(update));
|
||||
|
||||
if (doc) {
|
||||
if (this.options.onUpdate) {
|
||||
this.options.onUpdate(doc, update);
|
||||
} else {
|
||||
Object.assign(doc, update);
|
||||
}
|
||||
all.push(doc);
|
||||
} else if (!liveFetch || !this.options.ignore || !this.options.ignore(update)) {
|
||||
this.push(update);
|
||||
added.push(update);
|
||||
all.push(update);
|
||||
}
|
||||
});
|
||||
|
||||
return { added, all };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
|
||||
import { merge } from 'lodash';
|
||||
import { Subject } from 'rxjs';
|
||||
import { HASH } from '../../generated/hash';
|
||||
import {
|
||||
AccountData, UpdateAccountData, AccountSettings, GameStatus, SocialSiteInfo, PonyObject, JoinResponse,
|
||||
OAuthProvider, EntitiesEditorInfo, FriendData, PalettePonyInfo, HiddenPlayer
|
||||
} from '../../common/interfaces';
|
||||
import { createDefaultPony, syncLockedPonyInfo, mockPaletteManager } from '../../common/ponyInfo';
|
||||
import { removeById, observableToPromise, delay, computeFriendsCRC } from '../../common/utils';
|
||||
import { isMod, getSupporterInviteLimit, getCharacterLimit } from '../../common/accountUtils';
|
||||
import {
|
||||
NAME_ERROR, ACCESS_ERROR, CHARACTER_SAVING_ERROR, NOT_AUTHENTICATED_ERROR, OFFLINE_ERROR, PROTECTION_ERROR
|
||||
} from '../../common/errors';
|
||||
import { version, host } from '../../client/data';
|
||||
import {
|
||||
toSocialSiteInfo, cleanName, validatePonyName, isStandalone, attachDebugMethod
|
||||
} from '../../client/clientUtils';
|
||||
import { ErrorReporter } from './errorReporter';
|
||||
import { randomString } from '../../common/stringUtils';
|
||||
import { StorageService } from './storageService';
|
||||
import { decompressPonyString, compressPonyString, decodePonyInfo } from '../../common/compressPony';
|
||||
import { SECOND, PLAYER_DESC_MAX_LENGTH } from '../../common/constants';
|
||||
import { canUseTag } from '../../common/tags';
|
||||
|
||||
export interface Friend extends FriendData {
|
||||
entityId: number;
|
||||
crc: number;
|
||||
online: boolean;
|
||||
ponyInfo: PalettePonyInfo | undefined;
|
||||
actualName: string;
|
||||
}
|
||||
|
||||
const LIMIT_ERROR = 'Request limit reached, please wait';
|
||||
const noneSite: SocialSiteInfo = { id: '', name: 'none', url: '', icon: '', color: '#222' };
|
||||
const modStatus = {
|
||||
mod: false,
|
||||
check: {} as any,
|
||||
editor: {
|
||||
names: [],
|
||||
typeToName: [],
|
||||
nameToTypes: [],
|
||||
} as EntitiesEditorInfo,
|
||||
};
|
||||
|
||||
function compareStrings(a: string | undefined, b: string | undefined) {
|
||||
return (a || '').localeCompare(b || '');
|
||||
}
|
||||
|
||||
function comparePonies(a: PonyObject, b: PonyObject) {
|
||||
return compareStrings(a.name, b.name) || compareStrings(a.id, b.id);
|
||||
}
|
||||
|
||||
function getDefaultPony(ponies: PonyObject[]) {
|
||||
let result = ponies[0];
|
||||
|
||||
for (let i = 1; i < ponies.length; i++) {
|
||||
if (compareStrings(result.lastUsed, ponies[i].lastUsed) < 0) {
|
||||
result = ponies[i];
|
||||
}
|
||||
}
|
||||
|
||||
return result || createDefaultPonyObject();
|
||||
}
|
||||
|
||||
export function createDefaultPonyObject(): PonyObject {
|
||||
return {
|
||||
id: '',
|
||||
name: '',
|
||||
info: '',
|
||||
ponyInfo: createDefaultPony(),
|
||||
};
|
||||
}
|
||||
|
||||
export function getPonyTag(pony: PonyObject, account: AccountData | undefined) {
|
||||
if (account) {
|
||||
const tag = canUseTag(account, pony.tag || '') ? pony.tag : undefined;
|
||||
return (!tag && account.supporter && !pony.hideSupport) ? `sup${account.supporter}` : tag;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const entityTypeToName = new Map<number, string>();
|
||||
const entityNameToTypes = new Map<string, number[]>();
|
||||
|
||||
export function getEntityNames() {
|
||||
return modStatus.editor.names;
|
||||
}
|
||||
|
||||
export function getEntityTypesFromName(name: string) {
|
||||
return entityNameToTypes.get(name);
|
||||
}
|
||||
|
||||
export function getEntityNameFromType(type: number) {
|
||||
return entityTypeToName.get(type);
|
||||
}
|
||||
|
||||
export function compareFriends(a: Friend, b: Friend) {
|
||||
return a.online !== b.online ? (a.online ? -1 : 1) : a.accountName.localeCompare(b.accountName);
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class Model {
|
||||
loading = true;
|
||||
loadingError?: string;
|
||||
account?: AccountData;
|
||||
ponies: PonyObject[] = [];
|
||||
pending = false;
|
||||
sites: SocialSiteInfo[] = [noneSite];
|
||||
accountPromise!: Promise<AccountData | undefined>;
|
||||
accountChanged = new Subject<void>();
|
||||
protectionErrors = new Subject<void>();
|
||||
authError?: string;
|
||||
accountAlert?: string;
|
||||
mergedAccount = false;
|
||||
updating = false;
|
||||
updatingTakesLongTime = false;
|
||||
suffix = '';
|
||||
friends: Friend[] | undefined = undefined;
|
||||
private _pony: PonyObject = createDefaultPonyObject();
|
||||
constructor(
|
||||
private http: HttpClient,
|
||||
private router: Router,
|
||||
private storage: StorageService,
|
||||
private errorReporter: ErrorReporter,
|
||||
) {
|
||||
this.initialize();
|
||||
|
||||
// handle completed sign-in
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('message', event => {
|
||||
if (event.data && event.data.type === 'loaded-page') {
|
||||
const path = event.data.path;
|
||||
|
||||
if (event.source && 'close' in event.source) {
|
||||
event.source.close();
|
||||
}
|
||||
|
||||
this.initialize();
|
||||
this.accountPromise.then(() => router.navigateByUrl(path));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (DEVELOPMENT) {
|
||||
attachDebugMethod('ddos', () => this.protectionErrors.next());
|
||||
attachDebugMethod('userModel', this);
|
||||
}
|
||||
}
|
||||
private initialize() {
|
||||
this.loading = true;
|
||||
this.account = undefined;
|
||||
this.loadingError = undefined;
|
||||
this.accountAlert = undefined;
|
||||
this.ponies = [];
|
||||
this.friends = undefined;
|
||||
this.sites = [noneSite];
|
||||
this._pony = createDefaultPonyObject();
|
||||
this.storage.setItem('bid', this.storage.getItem('bid') || randomString(20));
|
||||
this.accountPromise = this.initializeAccount();
|
||||
}
|
||||
private initializeAccount(): Promise<AccountData | undefined> {
|
||||
return this.getAccount()
|
||||
.then(account => {
|
||||
if (!account) {
|
||||
throw new Error(ACCESS_ERROR);
|
||||
}
|
||||
|
||||
if ('limit' in account) {
|
||||
throw new Error(LIMIT_ERROR);
|
||||
}
|
||||
|
||||
this.errorReporter.configureUser({ id: account.id, username: account.name });
|
||||
|
||||
try {
|
||||
modStatus.mod = isMod(account);
|
||||
modStatus.check = account.check;
|
||||
modStatus.editor = account.editor || modStatus.editor;
|
||||
} catch { }
|
||||
|
||||
if (modStatus.editor) {
|
||||
modStatus.editor.typeToName.forEach(({ type, name }) => entityTypeToName.set(type, name));
|
||||
modStatus.editor.nameToTypes.forEach(({ types, name }) => entityNameToTypes.set(name, types));
|
||||
}
|
||||
|
||||
this.account = account;
|
||||
this.sites = [noneSite, ...(account.sites || []).map(toSocialSiteInfo)];
|
||||
this.ponies = account.ponies ? account.ponies.sort(comparePonies) : [];
|
||||
this.friends = undefined;
|
||||
|
||||
this.selectPony(getDefaultPony(this.ponies));
|
||||
this.storage.setItem('vid', account.id);
|
||||
this.loading = false;
|
||||
this.accountAlert = account.alert;
|
||||
this.accountChanged.next();
|
||||
this.fetchFriends();
|
||||
|
||||
return account;
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
if (e.message === ACCESS_ERROR) {
|
||||
this.loading = false;
|
||||
this.storage.setItem('vid', '---');
|
||||
} else if (e.message === LIMIT_ERROR) {
|
||||
this.loadingError = 'request-limit';
|
||||
return delay(5000).then(() => this.initializeAccount());
|
||||
} else if (e.message === OFFLINE_ERROR) {
|
||||
this.loadingError = 'cannot-connect';
|
||||
return delay(5000).then(() => this.initializeAccount());
|
||||
} else if (e.message === PROTECTION_ERROR) {
|
||||
this.loadingError = 'cloudflare-error';
|
||||
this.protectionErrors.next();
|
||||
// } else if (e.message === VERSION_ERROR) {
|
||||
// this.updating = true;
|
||||
} else {
|
||||
setTimeout(() => this.loadingError = 'unexpected-error', 5 * SECOND);
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
private fetchFriends() {
|
||||
this.getFriends()
|
||||
.then(friends => {
|
||||
this.friends = friends.map(f => ({
|
||||
...f,
|
||||
online: false,
|
||||
entityId: 0,
|
||||
crc: 0,
|
||||
ponyInfo: f.pony && decodePonyInfo(f.pony, mockPaletteManager) || undefined,
|
||||
actualName: '',
|
||||
})).sort(compareFriends);
|
||||
})
|
||||
.catch(e => {
|
||||
DEVELOPMENT && console.error(e);
|
||||
setTimeout(() => this.fetchFriends(), 5000);
|
||||
});
|
||||
}
|
||||
get characterLimit() {
|
||||
return this.account ? getCharacterLimit(this.account) : 0;
|
||||
}
|
||||
get supporterInviteLimit() {
|
||||
return this.account ? getSupporterInviteLimit(this.account) : 0;
|
||||
}
|
||||
get isMod() {
|
||||
return modStatus.mod;
|
||||
}
|
||||
get modCheck() {
|
||||
return modStatus.check;
|
||||
}
|
||||
get editorInfo() {
|
||||
return modStatus.editor;
|
||||
}
|
||||
get pony() {
|
||||
return this._pony;
|
||||
}
|
||||
get supporter() {
|
||||
return this.account && this.account.supporter || 0;
|
||||
}
|
||||
get missingBirthdate() {
|
||||
return !!this.account && !this.account.birthdate;
|
||||
}
|
||||
computeFriendsCRC() {
|
||||
return this.friends ? computeFriendsCRC(this.friends.map(f => f.accountId)) : 0;
|
||||
}
|
||||
parsePonyObject(pony: PonyObject): PonyObject {
|
||||
try {
|
||||
const ponyInfo = decompressPonyString(pony.info, true);
|
||||
return { ponyInfo, ...pony };
|
||||
} catch (e) {
|
||||
this.errorReporter.reportError(e, { ponyInfo: pony.info });
|
||||
this.errorReporter.reportError('Pony info reading error', { originalError: e.message, ponyInfo: pony.info });
|
||||
throw new Error('Error while reading pony info');
|
||||
}
|
||||
}
|
||||
selectPony(pony: PonyObject) {
|
||||
const copy = this.parsePonyObject(pony);
|
||||
copy.ponyInfo && syncLockedPonyInfo(copy.ponyInfo);
|
||||
this._pony = copy;
|
||||
}
|
||||
// account
|
||||
signIn(provider: OAuthProvider) {
|
||||
this.authError = undefined;
|
||||
this.openAuth(provider.url!);
|
||||
}
|
||||
connectSite(provider: OAuthProvider) {
|
||||
this.authError = undefined;
|
||||
this.openAuth(`${provider.url}/merge`);
|
||||
}
|
||||
signOut() {
|
||||
this.authError = undefined;
|
||||
|
||||
return this.post<AccountData | undefined>('/auth/sign-out', {}, false)
|
||||
.catch(e => console.error(e))
|
||||
.then(() => this.initialize())
|
||||
.then(() => this.router.navigate(['/']));
|
||||
}
|
||||
private openAuth(url: string) {
|
||||
url = `${host.replace(/\/$/, '')}${url}`;
|
||||
|
||||
if (isStandalone()) {
|
||||
window.open(url);
|
||||
} else {
|
||||
location.href = url;
|
||||
}
|
||||
}
|
||||
getAccount() {
|
||||
return this.post<AccountData | { limit: true; } | undefined>('/api1/account', {}, false);
|
||||
}
|
||||
getAccountCharacters() {
|
||||
return this.post<PonyObject[]>('/api/account-characters', {});
|
||||
}
|
||||
updateAccount(account: Partial<UpdateAccountData>) {
|
||||
return this.post<AccountData>('/api/account-update', { account })
|
||||
.then(a => merge(this.account, a));
|
||||
}
|
||||
saveSettings(settings: AccountSettings) {
|
||||
return this.post<AccountData>('/api/account-settings', { settings })
|
||||
.then(a => merge(this.account, a));
|
||||
}
|
||||
removeSite(siteId: string) {
|
||||
return this.post('/api/remove-site', { siteId })
|
||||
.then(() => {
|
||||
if (this.account && this.account.sites) {
|
||||
removeById(this.account.sites, siteId);
|
||||
}
|
||||
});
|
||||
}
|
||||
unhidePlayer(hideId: string) {
|
||||
return this.post('/api/remove-hide', { hideId });
|
||||
}
|
||||
verifyAccount() {
|
||||
const verificationId = this.storage.getItem('vid');
|
||||
const accountId = this.account && this.account.id || '---';
|
||||
|
||||
if (!this.loading && verificationId && accountId !== verificationId) {
|
||||
this.initialize();
|
||||
}
|
||||
}
|
||||
getHides(page: number) {
|
||||
return this.post<HiddenPlayer[]>('/api/get-hides', { page });
|
||||
}
|
||||
getFriends() {
|
||||
return this.post<FriendData[]>('/api/get-friends', {});
|
||||
}
|
||||
// ponies
|
||||
savePony(pony: PonyObject, fast = false) {
|
||||
return Promise.resolve()
|
||||
.then(() => {
|
||||
if (this.pending) {
|
||||
throw new Error('Saving in progress');
|
||||
}
|
||||
|
||||
pony.name = cleanName(pony.name);
|
||||
pony.desc = pony.desc && pony.desc.substr(0, PLAYER_DESC_MAX_LENGTH) || '';
|
||||
|
||||
if (!validatePonyName(pony.name)) {
|
||||
throw new Error(NAME_ERROR);
|
||||
}
|
||||
|
||||
if (pony.ponyInfo) {
|
||||
pony.info = compressPonyString(pony.ponyInfo);
|
||||
}
|
||||
|
||||
const { id, name, desc, site, tag, info, hideSupport, respawnAtSpawn } = pony;
|
||||
|
||||
if (!fast) {
|
||||
this.pending = true;
|
||||
}
|
||||
|
||||
return this.post<PonyObject | undefined>('/api/pony/save', {
|
||||
pony: { id, name, desc, site, tag, info, hideSupport, respawnAtSpawn }
|
||||
});
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
if (e.message === CHARACTER_SAVING_ERROR) {
|
||||
this.errorReporter.reportError(e, { pony });
|
||||
}
|
||||
|
||||
throw e;
|
||||
})
|
||||
.then(newPony => {
|
||||
if (!newPony) {
|
||||
throw new Error('Failed to save pony');
|
||||
}
|
||||
|
||||
if (pony.id) {
|
||||
removeById(this.ponies, pony.id);
|
||||
} else {
|
||||
this.account!.characterCount++;
|
||||
}
|
||||
|
||||
this.ponies.push(newPony);
|
||||
this.ponies.sort(comparePonies);
|
||||
|
||||
if (this.pony === pony) {
|
||||
this.selectPony(newPony);
|
||||
}
|
||||
|
||||
return newPony;
|
||||
})
|
||||
.finally(() => this.pending = false);
|
||||
}
|
||||
removePony(pony: PonyObject) {
|
||||
return this.post('/api/pony/remove', { id: pony.id })
|
||||
.then(() => {
|
||||
removeById(this.ponies, pony.id);
|
||||
this.account!.characterCount--;
|
||||
|
||||
if (this.pony === pony) {
|
||||
this.selectPony(getDefaultPony(this.ponies));
|
||||
}
|
||||
});
|
||||
}
|
||||
loadPonies() {
|
||||
return this.getAccountCharacters()
|
||||
.then(ponies => {
|
||||
if (this.account) {
|
||||
this.account.ponies = ponies || [];
|
||||
this.ponies = this.account.ponies.sort(comparePonies);
|
||||
}
|
||||
});
|
||||
}
|
||||
sortPonies() {
|
||||
this.ponies.sort(comparePonies);
|
||||
}
|
||||
// game
|
||||
status(short: boolean): Promise<GameStatus> {
|
||||
let age = 6;
|
||||
|
||||
if (this.account) {
|
||||
const now = new Date();
|
||||
const currentYear = now.getFullYear();
|
||||
const currentMonth = now.getMonth() + 1;
|
||||
|
||||
if (this.account.birthyear) {
|
||||
age = currentYear - this.account.birthyear;
|
||||
} else if (this.account.birthdate) {
|
||||
const [year, month] = this.account.birthdate.split('-');
|
||||
const before = parseInt(month, 10) > currentMonth;
|
||||
age = Math.max(0, currentYear - parseInt(year, 10) - (before ? 1 : 0));
|
||||
}
|
||||
}
|
||||
|
||||
const params = new HttpParams()
|
||||
.set('short', short.toString())
|
||||
.set('d', age.toString())
|
||||
.set('t', (Date.now() % 0x10000).toString(16));
|
||||
|
||||
return observableToPromise(this.http.get<GameStatus>('/api2/game/status', { params }));
|
||||
}
|
||||
join(serverId: string, ponyId: string): Promise<JoinResponse> {
|
||||
if (this.pending)
|
||||
return Promise.reject(new Error('Joining in progress'));
|
||||
if (!serverId)
|
||||
return Promise.reject(new Error('Invalid server ID'));
|
||||
if (!ponyId)
|
||||
return Promise.reject(new Error('Invalid pony ID'));
|
||||
|
||||
this.pending = true;
|
||||
|
||||
const alert = !!this.accountAlert ? 'y' : '';
|
||||
|
||||
return this.post<JoinResponse>('/api/game/join', { version, ponyId, serverId, alert, url: location.href })
|
||||
.finally(() => this.pending = false);
|
||||
}
|
||||
private post<T = void>(url: string, data: any, authenticate = true): Promise<T> {
|
||||
if (authenticate) {
|
||||
if (!this.account) {
|
||||
return Promise.reject(new Error(NOT_AUTHENTICATED_ERROR));
|
||||
}
|
||||
|
||||
const accountId = this.account.id + this.suffix;
|
||||
const accountName = this.account.name + this.suffix;
|
||||
data = { accountId, accountName, ...data };
|
||||
}
|
||||
|
||||
const params = new HttpParams()
|
||||
.set('t', (Date.now() % 0x10000).toString(16));
|
||||
const headers = new HttpHeaders({ 'api-version': HASH, 'api-bid': this.storage.getItem('bid') || '-' });
|
||||
|
||||
return observableToPromise(this.http.post<T>(url, data, { params, headers }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// import { Observable } from 'rxjs';
|
||||
import { SocketService } from 'ag-sockets/dist/browser';
|
||||
import { ClientAdminActions } from '../../client/clientAdminActions';
|
||||
import { IAdminServerActions, ModelTypes } from '../../common/adminInterfaces';
|
||||
import { removeItem } from '../../common/utils';
|
||||
import { Subscription } from '../../common/interfaces';
|
||||
import { MINUTE } from '../../common/constants';
|
||||
|
||||
type OnModel<T> = (item: T | undefined) => void;
|
||||
|
||||
interface ModelSubscriberConfig<T> {
|
||||
fix?: (item: T) => void;
|
||||
}
|
||||
|
||||
interface ModelSubscription<T> {
|
||||
value: T | undefined;
|
||||
timeout: any;
|
||||
callbacks: OnModel<T>[];
|
||||
}
|
||||
|
||||
const unsubscribeTimeout = 1 * MINUTE;
|
||||
|
||||
export class ModelSubscriber<T> {
|
||||
private subscriptions = new Map<string, ModelSubscription<T>>();
|
||||
// private observables = new Map<string, ModelSubscription<T>>();
|
||||
constructor(
|
||||
private type: ModelTypes,
|
||||
private socket: SocketService<ClientAdminActions, IAdminServerActions>,
|
||||
private config: ModelSubscriberConfig<T> = {},
|
||||
private defaultValue: T | undefined = undefined,
|
||||
) {
|
||||
}
|
||||
// for(id: string) {
|
||||
// return this.createObservable(id);
|
||||
// }
|
||||
// private createObservable(id: string) {
|
||||
// return new Observable<T | undefined>(observer => {
|
||||
// this.socket.server.subscribe(this.model, id);
|
||||
|
||||
// return () => {
|
||||
// this.socket.server.unsubscribe(this.model, id);
|
||||
// };
|
||||
// });
|
||||
// }
|
||||
get(id: string) {
|
||||
const subscription = this.subscriptions.get(id);
|
||||
return subscription && subscription.value;
|
||||
}
|
||||
subscribe(id: string, callback: OnModel<T>): Subscription {
|
||||
const subscription = this.subscriptions.get(id);
|
||||
|
||||
if (subscription) {
|
||||
if (subscription.timeout) {
|
||||
clearTimeout(subscription.timeout);
|
||||
subscription.timeout = 0;
|
||||
}
|
||||
|
||||
subscription.callbacks.push(callback);
|
||||
|
||||
if (subscription.value !== undefined) {
|
||||
callback(subscription.value);
|
||||
}
|
||||
} else {
|
||||
this.socket.server.subscribe(this.type, id);
|
||||
this.subscriptions.set(id, {
|
||||
value: this.defaultValue,
|
||||
timeout: 0,
|
||||
callbacks: [callback],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
unsubscribe: () => this.unsubscribe(id, callback),
|
||||
};
|
||||
}
|
||||
unsubscribe(id: string, callback: OnModel<T>) {
|
||||
const subscription = this.subscriptions.get(id);
|
||||
|
||||
if (subscription) {
|
||||
removeItem(subscription.callbacks, callback);
|
||||
|
||||
if (subscription.callbacks.length === 0) {
|
||||
subscription.timeout = setTimeout(() => {
|
||||
this.socket.server.unsubscribe(this.type, id);
|
||||
this.subscriptions.delete(id);
|
||||
}, unsubscribeTimeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
update(id: string, update: T) {
|
||||
const subscription = this.subscriptions.get(id);
|
||||
|
||||
if (update !== undefined && this.config.fix) {
|
||||
this.config.fix(update);
|
||||
}
|
||||
|
||||
if (subscription) {
|
||||
subscription.value = update;
|
||||
subscription.callbacks.forEach(c => c(update));
|
||||
}
|
||||
}
|
||||
connected() {
|
||||
this.subscriptions.forEach((_, id) => {
|
||||
this.socket.server.subscribe(this.type, id);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { ErrorHandler, Injectable, Injector, InjectionToken } from '@angular/core';
|
||||
import * as Rollbar from 'rollbar';
|
||||
import { version } from '../../client/data';
|
||||
import { HASH } from '../../generated/hash';
|
||||
import { ROLLBAR_ENV, ROLLBAR_TOKEN } from '../../generated/rollbarConfig';
|
||||
import { rollbarCheckIgnore, isIgnoredError } from '../../common/rollbar';
|
||||
|
||||
const host = typeof location === 'undefined' ? '' : location.host;
|
||||
|
||||
const rollbarConfig = {
|
||||
environment: ROLLBAR_ENV,
|
||||
accessToken: ROLLBAR_TOKEN,
|
||||
ignoredMessages: ['disconnected'],
|
||||
hostWhiteList: [host],
|
||||
captureUncaught: true,
|
||||
captureUnhandleRejections: true,
|
||||
// checkIgnore,
|
||||
enabled: true,
|
||||
payload: {
|
||||
environment: ROLLBAR_ENV,
|
||||
version: version, // NOTE: workaround for compilation issue
|
||||
client: {
|
||||
javascript: {
|
||||
source_map_enabled: true,
|
||||
guess_uncaught_frames: true,
|
||||
code_version: HASH,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const RollbarService = new InjectionToken<Rollbar>('rollbar');
|
||||
|
||||
export function rollbarFactory() {
|
||||
if (DEVELOPMENT) {
|
||||
return undefined;
|
||||
} else {
|
||||
const rollbar = Rollbar.init(rollbarConfig);
|
||||
rollbar.configure({ checkIgnore: rollbarCheckIgnore });
|
||||
return rollbar;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RollbarErrorHandler extends ErrorHandler {
|
||||
constructor(private injector: Injector) {
|
||||
super();
|
||||
}
|
||||
handleError(error: any) {
|
||||
super.handleError(error);
|
||||
|
||||
if (!DEVELOPMENT && rollbarConfig.accessToken) {
|
||||
const rollbar = this.injector.get(RollbarService);
|
||||
const err = error.originalError || error || {};
|
||||
|
||||
if (!isIgnoredError(err)) {
|
||||
rollbar.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Injectable, Inject } from '@angular/core';
|
||||
import * as Rollbar from 'rollbar';
|
||||
import { Person, rollbarCheckIgnore, isIgnoredError } from '../../common/rollbar';
|
||||
import { RollbarService } from './rollbarErrorHandler';
|
||||
import { ErrorReporter } from './errorReporter';
|
||||
|
||||
@Injectable()
|
||||
export class RollbarErrorReporter extends ErrorReporter {
|
||||
constructor(@Inject(RollbarService) private rollbar?: Rollbar) {
|
||||
super();
|
||||
}
|
||||
configureUser(person: Person) {
|
||||
if (this.rollbar) {
|
||||
this.rollbar.configure({ payload: { person }, checkIgnore: rollbarCheckIgnore });
|
||||
}
|
||||
}
|
||||
configureData(data: any) {
|
||||
if (this.rollbar) {
|
||||
this.rollbar.configure({ payload: data, checkIgnore: rollbarCheckIgnore });
|
||||
}
|
||||
}
|
||||
captureEvent(data: any) {
|
||||
if (this.rollbar) {
|
||||
this.rollbar.captureEvent(data, 'info');
|
||||
}
|
||||
}
|
||||
reportError(error: any, data?: any) {
|
||||
DEVELOPMENT && console.error(error, data);
|
||||
|
||||
if (this.rollbar && !isIgnoredError(error)) {
|
||||
this.rollbar.error(error, data);
|
||||
}
|
||||
}
|
||||
disable() {
|
||||
if (this.rollbar) {
|
||||
this.rollbar.configure({ enabled: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { AccountSettings, BrowserSettings } from '../../common/interfaces';
|
||||
import { StorageService } from './storageService';
|
||||
import { Model } from './model';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SettingsService {
|
||||
browser: BrowserSettings;
|
||||
private save: (settings: AccountSettings) => boolean = () => false;
|
||||
constructor(private storage: StorageService, private model: Model) {
|
||||
this.browser = this.storage.getJSON('browser-settings', {});
|
||||
}
|
||||
get account(): AccountSettings {
|
||||
return this.model.account ? this.model.account.settings : {};
|
||||
}
|
||||
set account(value) {
|
||||
if (this.model.account) {
|
||||
this.model.account.settings = value;
|
||||
}
|
||||
}
|
||||
saving(save: (settings: AccountSettings) => boolean) {
|
||||
this.save = save;
|
||||
}
|
||||
saveAccountSettings(settings: AccountSettings) {
|
||||
if (this.model.account) {
|
||||
this.model.account.settings = settings;
|
||||
}
|
||||
|
||||
if (settings.filterWords) {
|
||||
settings.filterWords = settings.filterWords.trim();
|
||||
}
|
||||
|
||||
if (this.save(settings)) {
|
||||
return Promise.resolve();
|
||||
} else {
|
||||
return this.model.saveSettings(settings);
|
||||
}
|
||||
}
|
||||
saveBrowserSettings(settings?: BrowserSettings) {
|
||||
this.browser = settings || this.browser;
|
||||
this.storage.setJSON('browser-settings', this.browser);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
/* istanbul ignore next */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class StorageService {
|
||||
private data?: Map<string, string> = undefined;
|
||||
constructor() {
|
||||
try {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
this.data = new Map();
|
||||
}
|
||||
} catch {
|
||||
this.data = new Map();
|
||||
}
|
||||
}
|
||||
getItem(key: string) {
|
||||
if (this.data) {
|
||||
return this.data.get(key);
|
||||
} else {
|
||||
try {
|
||||
const value = localStorage.getItem(key);
|
||||
return value == null ? undefined : value;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
setItem(key: string, data: string) {
|
||||
try {
|
||||
localStorage.setItem(key, data);
|
||||
this.data = undefined;
|
||||
} catch {
|
||||
if (!this.data) {
|
||||
this.data = new Map();
|
||||
}
|
||||
|
||||
this.data.set(key, data);
|
||||
}
|
||||
}
|
||||
removeItem(key: string) {
|
||||
if (this.data) {
|
||||
this.data.delete(key);
|
||||
} else {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
clear() {
|
||||
if (this.data) {
|
||||
this.data.clear();
|
||||
} else {
|
||||
try {
|
||||
localStorage.clear();
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
getJSON<T>(key: string, defaultValue: T): T {
|
||||
try {
|
||||
return JSON.parse(this.getItem(key) || '');
|
||||
} catch {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
setJSON(key: string, value: any) {
|
||||
this.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
getInt(key: string) {
|
||||
return parseInt(this.getItem(key) || '0', 10) | 0;
|
||||
}
|
||||
setInt(key: string, value: number) {
|
||||
this.setItem(key, value.toString(10));
|
||||
}
|
||||
getBoolean(key: string) {
|
||||
return this.getItem(key) === 'true';
|
||||
}
|
||||
setBoolean(key: string, value: boolean) {
|
||||
if (value) {
|
||||
this.setItem(key, 'true');
|
||||
} else {
|
||||
this.removeItem(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user