pokemon-showdown/server/rooms.ts
Guangcong Luo 78439b4a02
Update to ESLint 9 (#10926)
ESLint has a whole new config format, so I figure it's a good time to
make the config system saner.

- First, we no longer have separate eslint-no-types configs. Lint
  performance shouldn't be enough of a problem to justify the
  relevant maintenance complexity.

- Second, our base config should work out-of-the-box now. `npx eslint`
  will work as expected, without any CLI flags. You should still use
  `npm run lint` which adds the `--cached` flag for performance.

- Third, whatever updates I did fixed style linting, which apparently
  has been bugged for quite some time, considering all the obvious
  mixed-tabs-and-spaces issues I found in the upgrade.

Also here are some changes to our style rules. In particular:

- Curly brackets (for objects etc) now have spaces inside them. Sorry
  for the huge change. ESLint doesn't support our old style, and most
  projects use Prettier style, so we might as well match them in this way.
  See https://github.com/eslint-stylistic/eslint-stylistic/issues/415

- String + number concatenation is no longer allowed. We now
  consistently use template strings for this.
2025-02-25 20:03:46 -08:00

2296 lines
71 KiB
TypeScript

/**
* Rooms
* Pokemon Showdown - http://pokemonshowdown.com/
*
* Every chat room and battle is a room, and what they do is done in
* rooms.ts. There's also a global room which every user is in, and
* handles miscellaneous things like welcoming the user.
*
* `Rooms.rooms` is the global table of all rooms, a `Map` of `RoomID:Room`.
* Rooms should normally be accessed with `Rooms.get(roomid)`.
*
* All rooms extend `BasicRoom`, whose important properties like `.users`
* and `.game` are documented near the the top of its class definition.
*
* @license MIT
*/
const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'.split('');
const TIMEOUT_EMPTY_DEALLOCATE = 10 * 60 * 1000;
const TIMEOUT_INACTIVE_DEALLOCATE = 40 * 60 * 1000;
const REPORT_USER_STATS_INTERVAL = 10 * 60 * 1000;
const MAX_CHATROOM_ID_LENGTH = 225;
const CRASH_REPORT_THROTTLE = 60 * 60 * 1000;
const LAST_BATTLE_WRITE_THROTTLE = 10;
const RETRY_AFTER_LOGIN = null;
import { FS, Utils, Streams } from '../lib';
import { type RoomSection, RoomSections } from './chat-commands/room-settings';
import { type QueuedHunt } from './chat-plugins/scavengers';
import { type ScavengerGameTemplate } from './chat-plugins/scavenger-games';
import { type RepeatedPhrase } from './chat-plugins/repeats';
import {
PM as RoomBattlePM, RoomBattle, RoomBattlePlayer, RoomBattleTimer, type RoomBattleOptions,
} from "./room-battle";
import { BestOfGame } from './room-battle-bestof';
import { RoomGame, SimpleRoomGame, RoomGamePlayer } from './room-game';
import { MinorActivity, type MinorActivityData } from './room-minor-activity';
import { Roomlogs, type Roomlog } from './roomlogs';
import { RoomAuth } from './user-groups';
import { type PartialModlogEntry, mainModlog } from './modlog';
import { Replays } from './replays';
import * as crypto from 'crypto';
/*********************************************************
* the Room object.
*********************************************************/
interface MuteEntry {
userid: ID;
time: number;
guestNum: number;
autoconfirmed: string;
}
interface ChatRoomTable {
title: string;
desc: string;
userCount: number;
section?: string;
subRooms?: string[];
spotlight?: string;
privacy: RoomSettings['isPrivate'];
}
interface ShowRequest {
name: string;
link: string;
comment: string;
dimensions?: [number, number, boolean];
}
interface BattleRoomTable {
p1?: string;
p2?: string;
minElo?: 'tour' | number;
}
interface UserTable {
[userid: string]: User;
}
export interface RoomSettings {
title: string;
auth: { [userid: string]: GroupSymbol };
creationTime: number;
section?: RoomSection;
readonly autojoin?: boolean;
aliases?: string[];
banwords?: string[];
isPrivate?: PrivacySetting;
modjoin?: AuthLevel | true | null;
modchat?: AuthLevel | null;
staffRoom?: boolean;
language?: ID | false;
slowchat?: number | false;
events?: { [k: string]: RoomEvent | RoomEventAlias | RoomEventCategory };
filterStretching?: boolean;
filterEmojis?: boolean;
filterCaps?: boolean;
filterLinks?: boolean;
jeopardyDisabled?: boolean;
mafiaDisabled?: boolean;
unoDisabled?: boolean;
hangmanDisabled?: boolean;
auctionDisabled?: boolean;
gameNumber?: number;
highTraffic?: boolean;
spotlight?: string;
parentid?: string | null;
desc?: string | null;
introMessage?: string | null;
staffMessage?: string | null;
rulesLink?: string | null;
dataCommandTierDisplay?: 'tiers' | 'doubles tiers' | 'National Dex tiers' | 'numbers';
requestShowEnabled?: boolean | null;
permissions?: { [k: string]: GroupSymbol };
minorActivity?: PollData | AnnouncementData;
minorActivityQueue?: MinorActivityData[];
repeats?: RepeatedPhrase[];
topics?: string[];
autoModchat?: {
rank: GroupSymbol,
time: number,
// stores previous modchat setting. if true, modchat was fully off
active: boolean | AuthLevel,
};
tournaments?: TournamentRoomSettings;
defaultFormat?: string;
scavSettings?: AnyObject;
scavQueue?: QueuedHunt[];
// should not ever be saved because they're inapplicable to persistent rooms
/** This includes groupchats, battles, and help-ticket rooms. */
isPersonal?: boolean;
isHelp?: boolean;
noLogTimes?: boolean;
noAutoTruncate?: boolean;
isMultichannel?: boolean;
}
export type MessageHandler = (room: BasicRoom, message: string) => void;
export type Room = GameRoom | ChatRoom;
export type PrivacySetting = boolean | 'hidden' | 'voice' | 'unlisted';
import type { AnnouncementData } from './chat-plugins/announcements';
import type { PollData } from './chat-plugins/poll';
import type { AutoResponder } from './chat-plugins/responder';
import type { RoomEvent, RoomEventAlias, RoomEventCategory } from './chat-plugins/room-events';
import type { Tournament, TournamentRoomSettings } from './tournaments/index';
export abstract class BasicRoom {
/** to rename use room.rename */
readonly roomid: RoomID;
title: string;
readonly type: 'chat' | 'battle';
readonly users: UserTable;
/**
* Scrollback log. This is the log that's sent to users when
* joining the room. Should roughly match what's on everyone's
* screen.
*/
readonly log: Roomlog;
/**
* The room's current RoomGame, if it exists. Each room can have 0 to 2
* `RoomGame`s, and `this.game.room === this`.
* Rooms may also have an additional game in `this.subGame`.
* However, `subGame`s do not update `user.game`.
*/
game: RoomGame | null;
subGame: RoomGame | null;
/**
* The room's current battle. Battles are a type of RoomGame, so in battle
* rooms (which can only be `GameRoom`s), `this.battle === this.game`.
* In all other rooms, `this.battle` is `null`.
*/
battle: RoomBattle | null;
/**
* The room's current best-of set. Best-of sets are a type of RoomGame, so in best-of set
* rooms (which can only be `GameRoom`s), `this.bestof === this.game`.
* In all other rooms, `this.bestof` is `null`.
*/
bestOf: BestOfGame | null;
/**
* The game room's current tournament. If the room is a battle room whose
* battle is part of a tournament, `this.tour === this.parent.game`.
* In all other rooms, `this.tour` is `null`.
*/
tour: Tournament | null;
auth: RoomAuth;
/** use `setParent` to set this */
readonly parent: Room | null;
/** use `subroom.setParent` to set this, or `clearSubRooms` to clear it */
readonly subRooms: ReadonlyMap<string, Room> | null;
readonly muteQueue: MuteEntry[];
userCount: number;
active: boolean;
muteTimer: NodeJS.Timeout | null;
modchatTimer: NodeJS.Timeout | null;
lastUpdate: number;
lastBroadcast: string;
lastBroadcastTime: number;
settings: RoomSettings;
/** If true, this room's settings will be saved in config/chatrooms.json, allowing it to stay past restarts. */
persist: boolean;
scavgame: ScavengerGameTemplate | null;
scavLeaderboard: AnyObject;
responder?: AutoResponder | null;
privacySetter?: Set<ID> | null;
hideReplay: boolean;
reportJoins: boolean;
batchJoins: number;
reportJoinsInterval: NodeJS.Timeout | null;
minorActivity: MinorActivity | null;
minorActivityQueue: MinorActivityData[] | null;
banwordRegex: RegExp | true | null;
logUserStatsInterval: NodeJS.Timeout | null;
expireTimer: NodeJS.Timeout | null;
userList: string;
pendingApprovals: Map<string, ShowRequest> | null;
messagesSent: number;
/**
* These handlers will be invoked every n messages.
* handler:number-of-messages map
*/
nthMessageHandlers: Map<MessageHandler, number>;
constructor(roomid: RoomID, title?: string, options: Partial<RoomSettings> = {}) {
this.users = Object.create(null);
this.type = 'chat';
this.muteQueue = [];
this.battle = null;
this.bestOf = null;
this.game = null;
this.subGame = null;
this.tour = null;
this.roomid = roomid;
this.title = (title || roomid);
this.parent = null;
this.userCount = 0;
this.game = null;
this.active = false;
this.muteTimer = null;
this.lastUpdate = 0;
this.lastBroadcast = '';
this.lastBroadcastTime = 0;
// room settings
this.settings = {
title: this.title,
auth: Object.create(null),
creationTime: Date.now(),
};
this.persist = false;
this.hideReplay = false;
this.subRooms = null;
this.scavgame = null;
this.scavLeaderboard = {};
this.auth = new RoomAuth(this);
this.reportJoins = true;
this.batchJoins = 0;
this.reportJoinsInterval = null;
options.title = this.title;
if (options.isHelp) options.noAutoTruncate = true;
this.reportJoins = !!(Config.reportjoins || options.isPersonal);
this.batchJoins = options.isPersonal ? 0 : Config.reportjoinsperiod || 0;
if (!options.auth) options.auth = {};
this.log = Roomlogs.create(this, options);
this.banwordRegex = null;
this.settings = options as RoomSettings;
if (!this.settings.creationTime) this.settings.creationTime = Date.now();
this.auth.load();
if (!options.isPersonal) this.persist = true;
this.minorActivity = null;
this.minorActivityQueue = null;
if (options.parentid) {
this.setParent(Rooms.get(options.parentid) || null);
}
this.subRooms = null;
this.active = false;
this.muteTimer = null;
this.modchatTimer = null;
this.logUserStatsInterval = null;
this.expireTimer = null;
if (Config.logchat) {
this.roomlog('NEW CHATROOM: ' + this.roomid);
if (Config.loguserstats) {
this.logUserStatsInterval = setInterval(() => this.logUserStats(), Config.loguserstats);
}
}
this.userList = '';
if (this.batchJoins) {
this.userList = this.getUserList();
}
this.pendingApprovals = null;
this.messagesSent = 0;
this.nthMessageHandlers = new Map();
this.tour = null;
this.game = null;
this.battle = null;
this.validateTitle(this.title, this.roomid);
}
toString() {
return this.roomid;
}
/**
* Send a room message to all users in the room, without recording it
* in the scrollback log.
*/
send(message: string) {
if (this.roomid !== 'lobby') message = '>' + this.roomid + '\n' + message;
if (this.userCount) Sockets.roomBroadcast(this.roomid, message);
}
sendMods(data: string) {
this.sendRankedUsers(data, '*');
}
sendRankedUsers(data: string, minRank: GroupSymbol = '+') {
if (this.settings.staffRoom) {
if (!this.log) throw new Error(`Staff room ${this.roomid} has no log`);
this.log.add(data);
return;
}
for (const i in this.users) {
const user = this.users[i];
// hardcoded for performance reasons (this is an inner loop)
if (user.isStaff || this.auth.atLeast(user, minRank)) {
user.sendTo(this, data);
}
}
}
/**
* Send a room message to a single user.
*/
sendUser(user: Connection | User, message: string) {
user.sendTo(this, message);
}
/**
* Add a room message to the room log, so it shows up in the room
* for everyone, and appears in the scrollback for new users who
* join.
*/
add(message: string) {
this.log.add(message);
return this;
}
roomlog(message: string) {
this.log.roomlog(message);
return this;
}
/**
* Writes an entry to the modlog for that room, and the global modlog if entry.isGlobal is true.
*/
modlog(entry: PartialModlogEntry) {
const override = this.tour ? `${this.roomid} tournament: ${this.tour.roomid}` : undefined;
this.log.modlog(entry, override);
return this;
}
uhtmlchange(name: string, message: string) {
this.log.uhtmlchange(name, message);
}
attributedUhtmlchange(user: User, name: string, message: string) {
this.log.attributedUhtmlchange(user, name, message);
}
hideText(userids: ID[], lineCount = 0, hideRevealButton?: boolean) {
const cleared = this.log.clearText(userids, lineCount);
for (const userid of cleared) {
this.send(`|hidelines|${hideRevealButton ? 'delete' : 'hide'}|${userid}|${lineCount}`);
}
this.update();
}
/**
* Inserts (sanitized) HTML into the room log.
*/
addRaw(message: string) {
return this.add('|raw|' + message);
}
/**
* Inserts some text into the room log, attributed to user. The
* attribution will not appear, and is used solely as a hint not to
* highlight the user.
*/
addByUser(user: User, text: string): this {
return this.add('|c|' + user.getIdentity(this) + '|/log ' + text);
}
/**
* Like addByUser, but without logging
*/
sendByUser(user: User | null, text: string) {
this.send('|c|' + (user ? user.getIdentity(this) : '~') + '|/log ' + text);
}
/**
* Like addByUser, but sends to mods only.
*/
sendModsByUser(user: User, text: string) {
this.sendMods('|c|' + user.getIdentity(this) + '|/log ' + text);
}
update() {
if (!this.log.broadcastBuffer.length) return;
if (this.reportJoinsInterval) {
clearInterval(this.reportJoinsInterval);
this.reportJoinsInterval = null;
this.userList = this.getUserList();
}
this.send(this.log.broadcastBuffer.join('\n'));
this.log.broadcastBuffer = [];
this.log.truncate();
this.pokeExpireTimer();
}
getUserList() {
let buffer = '';
let counter = 0;
for (const i in this.users) {
if (!this.users[i].named) {
continue;
}
counter++;
buffer += ',' + this.users[i].getIdentityWithStatus(this);
}
const msg = `|users|${counter}${buffer}`;
return msg;
}
nextGameNumber() {
const gameNumber = (this.settings.gameNumber || 0) + 1;
this.settings.gameNumber = gameNumber;
this.saveSettings();
return gameNumber;
}
// mute handling
runMuteTimer(forceReschedule = false) {
if (forceReschedule && this.muteTimer) {
clearTimeout(this.muteTimer);
this.muteTimer = null;
}
if (this.muteTimer || this.muteQueue.length === 0) return;
const timeUntilExpire = this.muteQueue[0].time - Date.now();
if (timeUntilExpire <= 1000) { // one second of leeway
this.unmute(this.muteQueue[0].userid, "Your mute in '" + this.title + "' has expired.");
// runMuteTimer() is called again in unmute() so this function instance should be closed
return;
}
this.muteTimer = setTimeout(() => {
this.muteTimer = null;
this.runMuteTimer(true);
}, timeUntilExpire);
}
isMuted(user: User): ID | undefined {
if (!user) return;
if (this.muteQueue) {
for (const entry of this.muteQueue) {
if (user.id === entry.userid ||
user.guestNum === entry.guestNum ||
(user.autoconfirmed && user.autoconfirmed === entry.autoconfirmed)) {
if (entry.time - Date.now() < 0) {
this.unmute(user.id);
return;
} else {
return entry.userid;
}
}
}
}
if (this.parent) return this.parent.isMuted(user);
}
getMuteTime(user: User): number | undefined {
const userid = this.isMuted(user);
if (!userid) return;
for (const entry of this.muteQueue) {
if (userid === entry.userid) {
return entry.time - Date.now();
}
}
if (this.parent) return this.parent.getMuteTime(user);
}
getGame<T extends RoomGame>(constructor: new (...args: any[]) => T, subGame = false): T | null {
// TODO: switch to `static readonly gameid` when all game files are TypeScripted
if (subGame && this.subGame && this.subGame.constructor.name === constructor.name) return this.subGame as T;
if (this.game && this.game.constructor.name === constructor.name) return this.game as T;
return null;
}
getMinorActivity<T extends MinorActivity>(constructor: new (...args: any[]) => T): T | null {
if (this.minorActivity?.constructor.name === constructor.name) return this.minorActivity as T;
return null;
}
getMinorActivityQueue(settings = false): MinorActivityData[] | null {
const usedQueue = settings ? this.settings.minorActivityQueue : this.minorActivityQueue;
if (!usedQueue?.length) return null;
return usedQueue;
}
queueMinorActivity(activity: MinorActivityData): void {
if (!this.minorActivityQueue) this.minorActivityQueue = [];
this.minorActivityQueue.push(activity);
this.settings.minorActivityQueue = this.minorActivityQueue;
}
clearMinorActivityQueue(slot?: number, depth = 1) {
if (!this.minorActivityQueue) return;
if (slot === undefined) {
this.minorActivityQueue = null;
delete this.settings.minorActivityQueue;
this.saveSettings();
} else {
this.minorActivityQueue.splice(slot, depth);
this.settings.minorActivityQueue = this.minorActivityQueue;
this.saveSettings();
if (!this.minorActivityQueue.length) this.clearMinorActivityQueue();
}
}
setMinorActivity(activity: MinorActivity | null, noDisplay = false): void {
this.minorActivity?.endTimer();
this.minorActivity = activity;
if (this.minorActivity) {
this.minorActivity.save();
if (!noDisplay) this.minorActivity.display();
} else {
delete this.settings.minorActivity;
this.saveSettings();
}
}
saveSettings() {
if (!this.persist) return;
if (!Rooms.global) return; // during initialization
Rooms.global.writeChatRoomData();
}
checkModjoin(user: User) {
if (user.id in this.users) return true;
if (!this.settings.modjoin) return true;
// users with a room rank can always join
if (this.auth.has(user.id)) return true;
const modjoinSetting = this.settings.modjoin !== true ? this.settings.modjoin : this.settings.modchat;
if (!modjoinSetting) return true;
if (!Users.Auth.isAuthLevel(modjoinSetting)) {
Monitor.error(`Invalid modjoin setting in ${this.roomid}: ${modjoinSetting}`);
}
return (
this.auth.atLeast(user, modjoinSetting) || Users.globalAuth.atLeast(user, modjoinSetting)
);
}
mute(user: User, setTime?: number) {
const userid = user.id;
if (!setTime) setTime = 7 * 60000; // default time: 7 minutes
if (setTime > 90 * 60000) setTime = 90 * 60000; // limit 90 minutes
// If the user is already muted, the existing queue position for them should be removed
if (this.isMuted(user)) this.unmute(userid);
// Place the user in a queue for the unmute timer
for (let i = 0; i <= this.muteQueue.length; i++) {
const time = Date.now() + setTime;
if (i === this.muteQueue.length || time < this.muteQueue[i].time) {
const entry = {
userid,
time,
guestNum: user.guestNum,
autoconfirmed: user.autoconfirmed,
};
this.muteQueue.splice(i, 0, entry);
// The timer needs to be switched to the new entry if it is to be unmuted
// before the entry the timer is currently running for
if (i === 0 && this.muteTimer) {
clearTimeout(this.muteTimer);
this.muteTimer = null;
}
break;
}
}
this.runMuteTimer();
user.updateIdentity();
if (!(this.settings.isPrivate === true || this.settings.isPersonal)) {
void Punishments.monitorRoomPunishments(user);
}
return userid;
}
unmute(userid: string, notifyText?: string) {
let successUserid = '';
const user = Users.get(userid);
let autoconfirmed = '';
if (user) {
userid = user.id;
autoconfirmed = user.autoconfirmed;
}
for (const [i, entry] of this.muteQueue.entries()) {
if (entry.userid === userid ||
(user && entry.guestNum === user.guestNum) ||
(autoconfirmed && entry.autoconfirmed === autoconfirmed)) {
if (i === 0) {
this.muteQueue.splice(0, 1);
this.runMuteTimer(true);
} else {
this.muteQueue.splice(i, 1);
}
successUserid = entry.userid;
break;
}
}
if (user && successUserid && userid in this.users) {
user.updateIdentity();
if (notifyText) user.popup(notifyText);
}
return successUserid;
}
logUserStats() {
let total = 0;
let guests = 0;
const groups: { [k: string]: number } = {};
for (const group of Config.groupsranking) {
groups[group] = 0;
}
for (const i in this.users) {
const user = this.users[i];
++total;
if (!user.named) {
++guests;
}
++groups[this.auth.get(user.id)];
}
let entry = `|userstats|total:${total}|guests:${guests}`;
for (const i in groups) {
entry += `|${i}:${groups[i]}`;
}
this.roomlog(entry);
}
pokeExpireTimer() {
if (this.expireTimer) clearTimeout(this.expireTimer);
if (this.settings.isPersonal) {
this.expireTimer = setTimeout(() => this.expire(), TIMEOUT_INACTIVE_DEALLOCATE);
} else {
this.expireTimer = null;
}
}
expire() {
this.send('|expire|');
this.destroy();
}
reportJoin(type: 'j' | 'l' | 'n', entry: string, user: User) {
const canTalk = this.auth.atLeast(user, this.settings.modchat ?? 'unlocked') && !this.isMuted(user);
if (this.reportJoins && (canTalk || this.auth.has(user.id))) {
this.add(`|${type}|${entry}`).update();
return;
}
let ucType = '';
switch (type) {
case 'j': ucType = 'J'; break;
case 'l': ucType = 'L'; break;
case 'n': ucType = 'N'; break;
}
entry = `|${ucType}|${entry}`;
if (this.batchJoins) {
this.log.broadcastBuffer.push(entry);
if (!this.reportJoinsInterval) {
this.reportJoinsInterval = setTimeout(
() => this.update(), this.batchJoins
);
}
} else {
this.send(entry);
}
this.roomlog(entry);
}
getIntroMessage(user: User) {
let message = Utils.html`\n|raw|<div class="infobox"> You joined ${this.title}`;
if (this.settings.modchat) {
message += ` [${this.settings.modchat} or higher to talk]`;
}
if (this.settings.modjoin) {
const modjoin = this.settings.modjoin === true ? this.settings.modchat : this.settings.modjoin;
message += ` [${modjoin} or higher to join]`;
}
if (this.settings.slowchat) {
message += ` [Slowchat ${this.settings.slowchat}s]`;
}
message += `</div>`;
if (this.settings.introMessage) {
message += `\n|raw|<div class="infobox infobox-roomintro"><div ${(this.settings.section !== 'official' ? 'class="infobox-limited"' : '')}>` +
this.settings.introMessage.replace(/\n/g, '') +
`</div></div>`;
}
const staffIntro = this.getStaffIntroMessage(user);
if (staffIntro) message += `\n${staffIntro}`;
return message;
}
getStaffIntroMessage(user: User) {
if (!user.can('mute', null, this)) return ``;
const messages = [];
if (this.settings.staffMessage) {
messages.push(`|raw|<div class="infobox">(Staff intro:)<br /><div>` +
this.settings.staffMessage.replace(/\n/g, '') +
`</div>`);
}
if (this.pendingApprovals?.size) {
let message = `|raw|<div class="infobox">`;
message += `<details open><summary>(Pending media requests: ${this.pendingApprovals.size})</summary>`;
for (const [userid, entry] of this.pendingApprovals) {
message += `<div class="infobox">`;
message += `<strong>Requester ID:</strong> ${userid}<br />`;
if (entry.dimensions) {
const [width, height, resized] = entry.dimensions;
message += `<strong>Link:</strong><br /> <img src="${entry.link}" width="${width}" height="${height}"><br />`;
if (resized) message += `(Resized)<br />`;
} else {
message += `<strong>Link:</strong><br /> <a href="${entry.link}"">Link</a><br />`;
}
message += `<strong>Comment:</strong> ${entry.comment ? entry.comment : 'None.'}<br />`;
message += `<button class="button" name="send" value="/approveshow ${userid}">Approve</button>` +
`<button class="button" name="send" value="/denyshow ${userid}">Deny</button></div>`;
message += `<hr />`;
}
message += `</details></div>`;
messages.push(message);
}
if (
!this.settings.isPrivate && !this.settings.isPersonal &&
this.settings.modchat && this.settings.modchat !== 'autoconfirmed'
) {
messages.push(`|raw|<div class="broadcast-red">Modchat currently set to ${this.settings.modchat}</div>`);
}
return messages.join('\n');
}
getSubRooms(includeSecret = false) {
if (!this.subRooms) return [];
return [...this.subRooms.values()].filter(
room => includeSecret ? true : !room.settings.isPrivate && !room.settings.isPersonal
);
}
validateTitle(newTitle: string, newID?: string, oldID?: string) {
if (!newID) newID = toID(newTitle);
// `,` is a delimiter used by a lot of /commands
// `|` and `[` are delimiters used by the protocol
// `-` has special meaning in roomids
if (newTitle.includes(',') || newTitle.includes('|')) {
throw new Chat.ErrorMessage(`Room title "${newTitle}" can't contain any of: ,|`);
}
if ((!newID.includes('-') || newID.startsWith('groupchat-')) && newTitle.includes('-')) {
throw new Chat.ErrorMessage(`Room title "${newTitle}" can't contain -`);
}
if (newID.length > MAX_CHATROOM_ID_LENGTH) throw new Chat.ErrorMessage("The given room title is too long.");
if (newID !== oldID && Rooms.search(newTitle)) throw new Chat.ErrorMessage(`The room '${newTitle}' already exists.`);
}
setParent(room: Room | null) {
if (this.parent === room) return;
if (this.parent) {
(this.parent.subRooms as any).delete(this.roomid);
if (!this.parent.subRooms!.size) {
(this.parent.subRooms as any) = null;
}
}
(this as any).parent = room;
if (room) {
if (!room.subRooms) {
(room as any).subRooms = new Map();
}
(room as any).subRooms.set(this.roomid, this);
this.settings.parentid = room.roomid;
} else {
delete this.settings.parentid;
}
this.saveSettings();
for (const userid in this.users) {
this.users[userid].updateIdentity(this.roomid);
}
}
clearSubRooms() {
if (!this.subRooms) return;
for (const room of this.subRooms.values()) {
(room as any).parent = null;
}
(this as any).subRooms = null;
// this doesn't update parentid or subroom user symbols because it's
// intended to be used for cleanup only
}
setPrivate(privacy: PrivacySetting) {
this.settings.isPrivate = privacy;
this.saveSettings();
if (privacy) {
for (const user of Object.values(this.users)) {
if (!user.named) {
user.leaveRoom(this.roomid);
user.popup(`The room <<${this.roomid}>> has been made private; you must log in to be in private rooms.`);
}
}
}
if (this.battle || this.bestOf) {
if (privacy) {
if (this.roomid.endsWith('pw')) return true;
// This is the same password generation approach as genPassword in the client replays.lib.php
// but obviously will not match given mt_rand there uses a different RNG and seed.
let password = '';
for (let i = 0; i < 31; i++) password += ALPHABET[crypto.randomInt(0, ALPHABET.length - 1)];
this.rename(this.title, `${this.roomid}-${password}pw` as RoomID, true);
} else {
if (!this.roomid.endsWith('pw')) return true;
const lastDashIndex = this.roomid.lastIndexOf('-');
if (lastDashIndex < 0) throw new Error(`invalid battle ID ${this.roomid}`);
this.rename(this.title, this.roomid.slice(0, lastDashIndex) as RoomID);
}
}
this.bestOf?.setPrivacyOfGames(privacy);
if (this.game) {
for (const player of this.game.players) {
player.getUser()?.updateSearch();
}
}
}
validateSection(section: string) {
const target = toID(section);
if (!RoomSections.sections.includes(target as any)) {
throw new Chat.ErrorMessage(`"${target}" is not a valid room section. Valid categories include: ${RoomSections.sections.join(', ')}`);
}
return target as RoomSection;
}
setSection(section?: string) {
if (!this.persist) {
throw new Chat.ErrorMessage(`You cannot change the section of temporary rooms.`);
}
if (section) {
const validatedSection = this.validateSection(section);
if (this.settings.isPrivate && [true, 'hidden'].includes(this.settings.isPrivate)) {
throw new Chat.ErrorMessage(`Only public rooms can change their section.`);
}
const oldSection = this.settings.section;
if (oldSection === section) {
throw new Chat.ErrorMessage(`${this.title}'s room section is already set to "${RoomSections.sectionNames[oldSection]}".`);
}
this.settings.section = validatedSection;
this.saveSettings();
return validatedSection;
}
delete this.settings.section;
this.saveSettings();
return undefined;
}
/**
* Displays a warning popup to all non-staff users users in the room.
* Returns a list of all the user IDs that were warned.
*/
warnParticipants(message: string) {
const warned = Object.values(this.users).filter(u => !u.can('lock'));
for (const user of warned) {
user.popup(`|modal|${message}`);
}
return warned;
}
/**
* @param newID Add this param if the roomid is different from `toID(newTitle)`
* @param noAlias Set this param to true to not redirect aliases and the room's old name to its new name.
*/
rename(newTitle: string, newID?: RoomID, noAlias?: boolean) {
if (!newID) newID = toID(newTitle) as RoomID;
const oldID = this.roomid;
this.validateTitle(newTitle, newID, oldID);
if (this.type === 'chat' && this.game) {
throw new Chat.ErrorMessage(`Please finish your game (${this.game.title}) before renaming ${this.roomid}.`);
}
(this as any).roomid = newID;
this.title = this.settings.title = newTitle;
this.saveSettings();
if (newID === oldID) {
for (const user of Object.values(this.users)) {
user.sendTo(this, `|title|${newTitle}`);
}
return;
}
Rooms.rooms.delete(oldID);
Rooms.rooms.set(newID, this as Room);
if (this.battle && oldID) {
for (const player of this.battle.players) {
if (player.invite) {
const chall = Ladders.challenges.searchByRoom(player.invite, oldID);
if (chall) chall.roomid = this.roomid;
}
}
}
if (oldID === 'lobby') {
Rooms.lobby = null;
} else if (newID === 'lobby') {
Rooms.lobby = this as ChatRoom;
}
if (!noAlias) {
for (const [alias, roomid] of Rooms.aliases.entries()) {
if (roomid === oldID) {
Rooms.aliases.set(alias, newID);
}
}
// add an alias from the old id
Rooms.aliases.set(oldID, newID);
if (!this.settings.aliases) this.settings.aliases = [];
// resolve an old (fixed) bug in /renameroom
if (!this.settings.aliases.includes(oldID)) this.settings.aliases.push(oldID);
} else {
// clear aliases
for (const [alias, roomid] of Rooms.aliases.entries()) {
if (roomid === oldID) {
Rooms.aliases.delete(alias);
}
}
this.settings.aliases = undefined;
}
this.game?.renameRoom(newID);
for (const user of Object.values(this.users)) {
user.moveConnections(oldID, newID);
user.send(`>${oldID}\n|noinit|rename|${newID}|${newTitle}`);
}
if (this.parent?.subRooms) {
(this as any).parent.subRooms.delete(oldID);
(this as any).parent.subRooms.set(newID, this as ChatRoom);
}
if (this.subRooms) {
for (const subRoom of this.subRooms.values()) {
(subRoom as any).parent = this as ChatRoom;
subRoom.settings.parentid = newID;
}
}
this.saveSettings();
Punishments.renameRoom(oldID, newID);
void this.log.rename(newID);
}
onConnect(user: User, connection: Connection) {
const userList = this.userList ? this.userList : this.getUserList();
this.sendUser(
connection,
'|init|chat\n|title|' + this.title + '\n' + userList + '\n' + this.log.getScrollback() + this.getIntroMessage(user)
);
this.minorActivity?.onConnect?.(user, connection);
this.game?.onConnect?.(user, connection);
}
onJoin(user: User, connection: Connection) {
if (!user) return false; // ???
if (this.users[user.id]) return false;
Chat.runHandlers('onBeforeRoomJoin', this, user, connection);
if (user.named) {
this.reportJoin('j', user.getIdentityWithStatus(this), user);
}
const staffIntro = this.getStaffIntroMessage(user);
if (staffIntro) this.sendUser(user, staffIntro);
this.users[user.id] = user;
this.userCount++;
this.checkAutoModchat(user);
this.game?.onJoin?.(user, connection);
Chat.runHandlers('onRoomJoin', this, user, connection);
return true;
}
onRename(user: User, oldid: ID, joining: boolean) {
if (user.id === oldid) {
return this.onUpdateIdentity(user);
}
if (!this.users[oldid]) {
Monitor.crashlog(new Error(`user ${oldid} not in room ${this.roomid}`));
}
if (this.users[user.id]) {
Monitor.crashlog(new Error(`user ${user.id} already in room ${this.roomid}`));
}
delete this.users[oldid];
this.users[user.id] = user;
if (joining) {
this.reportJoin('j', user.getIdentityWithStatus(this), user);
const staffIntro = this.getStaffIntroMessage(user);
if (staffIntro) this.sendUser(user, staffIntro);
} else if (!user.named) {
this.reportJoin('l', ' ' + oldid, user);
} else {
this.reportJoin('n', user.getIdentityWithStatus(this) + '|' + oldid, user);
}
this.minorActivity?.onRename?.(user, oldid, joining);
this.checkAutoModchat(user);
return true;
}
/**
* onRename, but without a userid change
*/
onUpdateIdentity(user: User) {
if (user?.connected) {
if (!this.users[user.id]) return false;
if (user.named) {
this.reportJoin('n', user.getIdentityWithStatus(this) + '|' + user.id, user);
}
}
return true;
}
onLeave(user: User) {
if (!user) return false; // ...
if (!(user.id in this.users)) {
Monitor.crashlog(new Error(`user ${user.id} already left`));
return false;
}
delete this.users[user.id];
this.userCount--;
if (user.named) {
this.reportJoin('l', user.getIdentity(this), user);
}
this.game?.onLeave?.(user);
this.runAutoModchat();
return true;
}
runAutoModchat() {
if (!this.settings.autoModchat || this.settings.autoModchat.active) return;
// they are staff and online
const staff = Object.values(this.users).filter(u => this.auth.atLeast(u, '%'));
if (!staff.length) {
const { time } = this.settings.autoModchat;
if (!time || time < 5) {
throw new Error(`Invalid time setting for automodchat (${Utils.visualize(this.settings.autoModchat)})`);
}
if (this.modchatTimer) return;
this.modchatTimer = setTimeout(() => {
if (!this.settings.autoModchat) return;
const { rank } = this.settings.autoModchat;
const oldSetting = this.settings.modchat;
this.settings.modchat = rank;
this.add(
// always gonna be minutes so we can just use the number directly lol
`|raw|<div class="broadcast-blue"><strong>This room has had no active staff for ${time} minutes,` +
` and has had modchat set to ${rank}.</strong></div>`
).update();
this.modlog({
action: 'AUTOMODCHAT ACTIVATE',
});
// automodchat will always exist
this.settings.autoModchat.active = oldSetting || true;
this.saveSettings();
this.modchatTimer = null;
}, time * 60 * 1000);
}
}
checkAutoModchat(user: User) {
if (user.can('mute', null, this, 'modchat')) {
if (this.modchatTimer) {
clearTimeout(this.modchatTimer);
}
if (this.settings.autoModchat?.active) {
const oldSetting = this.settings.autoModchat.active;
if (typeof oldSetting === 'string') {
this.settings.modchat = oldSetting;
} else {
delete this.settings.modchat;
}
this.settings.autoModchat.active = false;
this.saveSettings();
}
}
}
destroy(): void {
// deallocate ourself
if (this.game) {
this.game.destroy();
this.game = null;
this.battle = null;
this.tour = null;
}
// remove references to ourself
for (const i in this.users) {
this.users[i].leaveRoom(this as Room, null);
delete this.users[i];
}
this.setParent(null);
this.clearSubRooms();
Chat.runHandlers('onRoomDestroy', this.roomid);
Rooms.global.deregisterChatRoom(this.roomid);
Rooms.global.delistChatRoom(this.roomid);
if (this.settings.aliases) {
for (const alias of this.settings.aliases) {
Rooms.aliases.delete(alias);
}
}
this.active = false;
// Ensure there aren't any pending messages that could restart the expire timer
this.update();
// Clear any active timers for the room
if (this.muteTimer) {
clearTimeout(this.muteTimer);
this.muteTimer = null;
}
if (this.expireTimer) {
clearTimeout(this.expireTimer);
this.expireTimer = null;
}
if (this.reportJoinsInterval) {
clearInterval(this.reportJoinsInterval);
}
this.reportJoinsInterval = null;
if (this.logUserStatsInterval) {
clearInterval(this.logUserStatsInterval);
}
this.logUserStatsInterval = null;
void this.log.destroy();
Rooms.rooms.delete(this.roomid);
if (this.roomid === 'lobby') Rooms.lobby = null;
}
tr(strings: string | TemplateStringsArray, ...keys: any[]) {
return Chat.tr(this.settings.language || 'english' as ID, strings, ...keys);
}
}
export class GlobalRoomState {
readonly settingsList: RoomSettings[];
readonly chatRooms: ChatRoom[];
/**
* Rooms that users autojoin upon connecting
*/
readonly autojoinList: RoomID[];
/**
* Rooms that users autojoin upon logging in
*/
readonly modjoinedAutojoinList: RoomID[];
readonly ladderIpLog: Streams.WriteStream;
readonly reportUserStatsInterval: NodeJS.Timeout;
lockdown: boolean | 'pre' | 'ddos';
battleCount: number;
lastReportedCrash: number;
lastBattle: number;
lastWrittenBattle: number;
maxUsers: number;
maxUsersDate: number;
formatList: string;
constructor() {
this.settingsList = [];
try {
this.settingsList = require(FS('config/chatrooms.json').path);
if (!Array.isArray(this.settingsList)) this.settingsList = [];
} catch {} // file doesn't exist [yet]
if (!this.settingsList.length) {
this.settingsList = [{
title: 'Lobby',
auth: {},
creationTime: Date.now(),
autojoin: true,
section: 'official',
}, {
title: 'Staff',
auth: {},
creationTime: Date.now(),
isPrivate: 'hidden',
modjoin: '%',
autojoin: true,
}];
}
this.chatRooms = [];
this.autojoinList = [];
this.modjoinedAutojoinList = [];
for (const [i, settings] of this.settingsList.entries()) {
if (!settings?.title) {
Monitor.warn(`ERROR: Room number ${i} has no data and could not be loaded.`);
continue;
}
if ((settings as any).staffAutojoin) {
// convert old staffAutojoin format
delete (settings as any).staffAutojoin;
(settings as any).autojoin = true;
if (!settings.modjoin) settings.modjoin = '%';
if (settings.isPrivate === true) settings.isPrivate = 'hidden';
}
// We're okay with assinging type `ID` to `RoomID` here
// because the hyphens in chatrooms don't have any special
// meaning, unlike in helptickets, groupchats, battles etc
// where they are used for shared modlogs and the like
const id = toID(settings.title) as RoomID;
Monitor.notice("RESTORE CHATROOM: " + id);
const room = Rooms.createChatRoom(id, settings.title, settings);
if (room.settings.aliases) {
for (const alias of room.settings.aliases) {
Rooms.aliases.set(alias, id);
}
}
this.chatRooms.push(room);
if (room.settings.autojoin) {
if (room.settings.modjoin) {
this.modjoinedAutojoinList.push(id);
} else {
this.autojoinList.push(id);
}
}
}
Rooms.lobby = Rooms.rooms.get('lobby') as ChatRoom;
// init battle room logging
if (Config.logladderip) {
this.ladderIpLog = Monitor.logPath('ladderip/ladderip.txt').createAppendStream();
} else {
// Prevent there from being two possible hidden classes an instance
// of GlobalRoom can have.
this.ladderIpLog = new Streams.WriteStream({ write() { return undefined; } });
}
this.reportUserStatsInterval = setInterval(
() => this.reportUserStats(),
REPORT_USER_STATS_INTERVAL
);
// init users
this.maxUsers = 0;
this.maxUsersDate = 0;
this.lockdown = false;
this.battleCount = 0;
this.lastReportedCrash = 0;
this.formatList = '';
let lastBattle;
try {
lastBattle = Monitor.logPath('lastbattle.txt').readSync('utf8');
} catch {}
this.lastBattle = Number(lastBattle) || 0;
this.lastWrittenBattle = this.lastBattle;
void this.loadBattles();
}
async serializeBattleRoom(room: Room) {
if (!room.battle || room.battle.ended) return null;
room.battle.frozen = true;
const log = await room.battle.getLog();
const players = room.battle.players.map(p => p.id).filter(Boolean);
if (!players.length || !log?.length) return null; // shouldn't happen???
// players can be empty right after `/importinputlog`
return {
roomid: room.roomid,
inputLog: log.join('\n'),
players,
title: room.title,
rated: room.battle.rated,
timer: {
...room.battle.timer.settings,
active: !!room.battle.timer.timer || false,
},
};
}
deserializeBattleRoom(battle: NonNullable<Awaited<ReturnType<GlobalRoomState['serializeBattleRoom']>>>) {
const { inputLog, players, roomid, title, rated, timer } = battle;
const [, formatid] = roomid.split('-');
const room = Rooms.createBattle({
format: formatid,
inputLog,
roomid,
title,
rated: Number(rated),
players: [],
delayedTimer: timer.active,
});
if (!room?.battle) return false; // shouldn't happen???
if (timer) { // json blob of settings
Object.assign(room.battle.timer.settings, timer);
}
for (const [i, playerid] of players.entries()) {
room.auth.set(playerid, Users.PLAYER_SYMBOL);
const player = room.battle.players[i];
(player.id as string) = playerid;
room.battle.playerTable[playerid] = player;
player.hasTeam = true;
const user = Users.getExact(playerid);
player.name = user?.name || playerid; // in case user hasn't reconnected yet
user?.joinRoom(room);
}
return true;
}
async saveBattles() {
let count = 0;
const out = Monitor.logPath('battles.jsonl.progress').createAppendStream();
for (const room of Rooms.rooms.values()) {
if (!room.battle || room.battle.ended) continue;
room.battle.frozen = true;
room.battle.timer.stop();
const b = await this.serializeBattleRoom(room);
if (!b) continue;
await out.writeLine(JSON.stringify(b));
count++;
}
await out.writeEnd();
await Monitor.logPath('battles.jsonl.progress').rename(Monitor.logPath('battles.jsonl').path);
return count;
}
battlesLoading = false;
async loadBattles() {
this.battlesLoading = true;
for (const u of Users.users.values()) {
u.send(
`|pm|~|${u.getIdentity()}|/uhtml restartmsg,` +
`<div class="broadcast-red"><b>Your battles are currently being restored.<br />Please be patient as they load.</div>`
);
}
const startTime = Date.now();
let count = 0;
let input;
try {
const stream = Monitor.logPath('battles.jsonl').createReadStream();
await stream.fd;
input = stream.byLine();
} catch {
return;
}
for await (const line of input) {
if (!line) continue;
if (this.deserializeBattleRoom(JSON.parse(line))) count++;
}
for (const u of Users.users.values()) {
u.send(`|pm|~|${u.getIdentity()}|/uhtmlchange restartmsg,`);
}
await Monitor.logPath('battles.jsonl').unlinkIfExists();
Monitor.notice(`Loaded ${count} battles in ${Date.now() - startTime}ms`);
this.battlesLoading = false;
}
rejoinGames(user: User) {
for (const room of Rooms.rooms.values()) {
const player = room.game && !room.game.ended && room.game.playerTable[user.id];
if (!player) continue;
// prevents players from being re-added to games like Scavengers after they've finished
if (player.completed) continue;
user.games.add(room.roomid);
player.name = user.name;
user.joinRoom(room.roomid);
}
}
modlog(entry: PartialModlogEntry, overrideID?: string) {
void Rooms.Modlog.write('global', entry, overrideID);
}
writeChatRoomData() {
FS('config/chatrooms.json').writeUpdate(() => (
JSON.stringify(this.settingsList)
.replace(/\{"title":/g, '\n{"title":')
.replace(/\]$/, '\n]')
), { throttle: 5000 });
}
writeNumRooms() {
if (this.lockdown) {
if (this.lastBattle === this.lastWrittenBattle) return;
this.lastWrittenBattle = this.lastBattle;
} else {
// batch writes so we don't have to write them every new battle
// very probably premature optimization, considering by default we
// write significantly larger log files every new battle
if (this.lastBattle < this.lastWrittenBattle) return;
this.lastWrittenBattle = this.lastBattle + LAST_BATTLE_WRITE_THROTTLE;
}
Monitor.logPath('lastbattle.txt').writeUpdate(
() => `${this.lastWrittenBattle}`
);
}
reportUserStats() {
if (this.maxUsersDate) {
void LoginServer.request('updateuserstats', {
date: this.maxUsersDate,
users: this.maxUsers,
});
this.maxUsersDate = 0;
}
void LoginServer.request('updateuserstats', {
date: Date.now(),
users: Users.onlineCount,
});
}
get formatListText() {
if (this.formatList) {
return this.formatList;
}
this.formatList = `|formats${Ladders.formatsListPrefix || ''}`;
let section = '';
let prevSection = '';
let curColumn = 1;
for (const format of Dex.formats.all()) {
if (format.section) section = format.section;
if (format.column) curColumn = format.column;
if (!format.name) continue;
if (!format.challengeShow && !format.searchShow && !format.tournamentShow) continue;
if (section !== prevSection) {
prevSection = section;
this.formatList += `|,${curColumn}|${section}`;
}
this.formatList += `|${format.name}`;
let displayCode = 0;
if (format.team) displayCode |= 1;
if (format.searchShow) displayCode |= 2;
if (format.challengeShow) displayCode |= 4;
if (format.tournamentShow) displayCode |= 8;
const ruleTable = Dex.formats.getRuleTable(format);
const level = ruleTable.adjustLevel || ruleTable.adjustLevelDown || ruleTable.maxLevel;
if (level === 50) displayCode |= 16;
// 32 was previously used for Multi Battles
if (format.bestOfDefault) displayCode |= 64;
if (format.teraPreviewDefault) displayCode |= 128;
this.formatList += ',' + displayCode.toString(16);
}
return this.formatList;
}
get configRankList() {
if (Config.nocustomgrouplist) return '';
// putting the resultant object in Config would enable this to be run again should config.js be reloaded.
if (Config.rankList) {
return Config.rankList;
}
const rankList = [];
for (const rank in Config.groups) {
if (!Config.groups[rank] || !rank) continue;
const tarGroup = Config.groups[rank];
const groupType = tarGroup.id === 'bot' || (!tarGroup.mute && !tarGroup.root) ?
'normal' : (tarGroup.root || tarGroup.declare) ? 'leadership' : 'staff';
rankList.push({
symbol: rank,
name: (Config.groups[rank].name || null),
type: groupType }); // send the first character in the rank, incase they put a string several characters long
}
const typeOrder = ['punishment', 'normal', 'staff', 'leadership'];
Utils.sortBy(rankList, rank => -typeOrder.indexOf(rank.type));
// add the punishment types at the very end.
for (const rank in Config.punishgroups) {
rankList.push({ symbol: Config.punishgroups[rank].symbol, name: Config.punishgroups[rank].name, type: 'punishment' });
}
Config.rankList = '|customgroups|' + JSON.stringify(rankList) + '\n';
return Config.rankList;
}
/**
* @param filter formatfilter, elofilter, usernamefilter
*/
getBattles(filter: string) {
const rooms: GameRoom[] = [];
const [formatFilter, eloFilterString, usernameFilter] = filter.split(',');
const eloFilter = +eloFilterString;
for (const room of Rooms.rooms.values()) {
if (!room?.active || room.settings.isPrivate) continue;
if (room.type !== 'battle') continue;
if (formatFilter && formatFilter !== room.format) continue;
if (eloFilter && (!room.rated || room.rated < eloFilter)) continue;
if (usernameFilter && room.battle) {
const p1userid = room.battle.p1.id;
const p2userid = room.battle.p2.id;
if (!p1userid || !p2userid) continue;
if (!p1userid.startsWith(usernameFilter) && !p2userid.startsWith(usernameFilter)) continue;
}
rooms.push(room);
}
const roomTable: { [roomid: string]: BattleRoomTable } = {};
for (let i = rooms.length - 1; i >= rooms.length - 100 && i >= 0; i--) {
const room = rooms[i];
const roomData: BattleRoomTable = {};
if (room.active && room.battle) {
if (room.battle.p1) roomData.p1 = room.battle.p1.name;
if (room.battle.p2) roomData.p2 = room.battle.p2.name;
if (room.tour) roomData.minElo = 'tour';
if (room.rated) roomData.minElo = Math.floor(room.rated);
}
if (!roomData.p1 || !roomData.p2) continue;
roomTable[room.roomid] = roomData;
}
return roomTable;
}
getRooms(user: User) {
const roomsData: {
chat: ChatRoomTable[],
sectionTitles: string[],
userCount: number,
battleCount: number,
} = {
chat: [],
sectionTitles: Object.values(RoomSections.sectionNames),
userCount: Users.onlineCount,
battleCount: this.battleCount,
};
for (const room of this.chatRooms) {
if (!room) continue;
if (room.parent) continue;
if (
room.settings.modjoin ||
(room.settings.isPrivate && !(['hidden', 'voice'] as any).includes(room.settings.isPrivate)) ||
(room.settings.isPrivate === 'voice' && user.tempGroup === ' ')
) continue;
const roomData: ChatRoomTable = {
title: room.title,
desc: room.settings.desc || '',
userCount: room.userCount,
section: room.settings.section ?
(RoomSections.sectionNames[room.settings.section] || room.settings.section) : undefined,
privacy: !room.settings.isPrivate ? undefined : room.settings.isPrivate,
};
const subrooms = room.getSubRooms().map(r => r.title);
if (subrooms.length) roomData.subRooms = subrooms;
if (room.settings.spotlight) roomData.spotlight = room.settings.spotlight;
roomsData.chat.push(roomData);
}
return roomsData;
}
sendAll(message: string) {
Sockets.roomBroadcast('', message);
}
addChatRoom(title: string) {
const id = toID(title) as RoomID;
if (['battles', 'rooms', 'ladder', 'teambuilder', 'home', 'all', 'public'].includes(id)) {
return false;
}
if (Rooms.rooms.has(id)) return false;
const settings = {
title,
auth: {},
creationTime: Date.now(),
};
const room = Rooms.createChatRoom(id, title, settings);
if (id === 'lobby') Rooms.lobby = room;
this.settingsList.push(settings);
this.chatRooms.push(room);
this.writeChatRoomData();
return true;
}
prepBattleRoom(format: string) {
// console.log('BATTLE START BETWEEN: ' + p1.id + ' ' + p2.id);
const roomPrefix = `battle-${toID(Dex.formats.get(format).name)}-`;
let battleNum = this.lastBattle;
let roomid: RoomID;
do {
roomid = `${roomPrefix}${++battleNum}` as RoomID;
} while (Rooms.rooms.has(roomid));
this.lastBattle = battleNum;
this.writeNumRooms();
return roomid;
}
onCreateBattleRoom(players: User[], room: GameRoom, options: AnyObject) {
for (const player of players) {
if (player.statusType === 'idle') {
player.setStatusType('online');
}
}
if (Config.reportbattles) {
if (typeof Config.reportbattles === 'string') {
Config.reportbattles = [Config.reportbattles];
} else if (Config.reportbattles === true) {
Config.reportbattles = ['lobby'];
}
for (const roomid of Config.reportbattles) {
const reportRoom = Rooms.get(roomid);
if (reportRoom) {
const reportPlayers = players.map(p => p.getIdentity()).join('|');
reportRoom
.add(`|b|${room.roomid}|${reportPlayers}`)
.update();
}
}
}
if (Config.logladderip && options.rated) {
const ladderIpLogString = players.map(p => `${p.id}: ${p.latestIp}\n`).join('');
void this.ladderIpLog.write(ladderIpLogString);
}
for (const player of players) {
Chat.runHandlers('onBattleStart', player, room);
}
}
deregisterChatRoom(id: string) {
id = toID(id);
const room = Rooms.get(id);
if (!room) return false; // room doesn't exist
if (!room.persist) return false; // room isn't registered
// deregister from global settings
// looping from the end is a pretty trivial optimization, but the
// assumption is that more recently added rooms are more likely to
// be deleted
for (let i = this.settingsList.length - 1; i >= 0; i--) {
if (id === toID(this.settingsList[i].title)) {
this.settingsList.splice(i, 1);
this.writeChatRoomData();
break;
}
}
room.persist = false;
return true;
}
delistChatRoom(id: RoomID) {
id = toID(id) as RoomID;
if (!Rooms.rooms.has(id)) return false; // room doesn't exist
for (let i = this.chatRooms.length - 1; i >= 0; i--) {
if (id === this.chatRooms[i].roomid) {
this.chatRooms.splice(i, 1);
break;
}
}
}
removeChatRoom(id: string) {
id = toID(id);
const room = Rooms.get(id);
if (!room) return false; // room doesn't exist
room.destroy();
return true;
}
autojoinRooms(user: User, connection: Connection) {
// we only autojoin regular rooms if the client requests it with /autojoin
// note that this restriction doesn't apply to modjoined autojoin rooms
let includesLobby = false;
for (const roomName of this.autojoinList) {
user.joinRoom(roomName, connection);
if (roomName === 'lobby') includesLobby = true;
}
if (!includesLobby && Config.serverid !== 'showdown') user.send(`>lobby\n|deinit`);
}
checkAutojoin(user: User, connection?: Connection) {
if (!user.named) return;
for (let [i, roomid] of this.modjoinedAutojoinList.entries()) {
const room = Rooms.get(roomid);
if (!room) {
this.modjoinedAutojoinList.splice(i, 1);
i--;
continue;
}
if (room.checkModjoin(user)) {
user.joinRoom(room.roomid, connection);
}
}
for (const conn of user.connections) {
if (conn.autojoins) {
const autojoins = conn.autojoins.split(',') as RoomID[];
for (const roomName of autojoins) {
void user.tryJoinRoom(roomName, conn);
}
conn.autojoins = '';
}
}
}
handleConnect(user: User, connection: Connection) {
connection.send(user.getUpdateuserText() + '\n' + this.configRankList + this.formatListText);
if (Users.users.size > this.maxUsers) {
this.maxUsers = Users.users.size;
this.maxUsersDate = Date.now();
}
}
startLockdown(err: Error | null = null, slow = false) {
if (this.lockdown && err) return;
const devRoom = Rooms.get('development');
const stack = (err ? Utils.escapeHTML(err.stack!).split(`\n`).slice(0, 2).join(`<br />`) : ``);
for (const [id, curRoom] of Rooms.rooms) {
if (err) {
if (id === 'staff' || id === 'development' || (!devRoom && id === 'lobby')) {
curRoom.addRaw(`<div class="broadcast-red"><b>The server needs to restart because of a crash:</b> ${stack}<br />Please restart the server.</div>`);
curRoom.addRaw(`<div class="broadcast-red">You will not be able to start new battles until the server restarts.</div>`);
curRoom.update();
} else {
curRoom.addRaw(`<div class="broadcast-red"><b>The server needs to restart because of a crash.</b><br />No new battles can be started until the server is done restarting.</div>`).update();
}
} else {
curRoom.addRaw(`<div class="broadcast-red"><b>The server is restarting soon.</b><br />Please finish your battles quickly. No new battles can be started until the server resets in a few minutes.</div>`).update();
}
const game = curRoom.game;
// @ts-expect-error TODO: revisit when game.timer is standardized
if (!slow && game?.timer && typeof game.timer.start === 'function' && !game.ended) {
// @ts-expect-error see above
game.timer.start();
if (curRoom.settings.modchat !== '+') {
curRoom.settings.modchat = '+';
curRoom.addRaw(`<div class="broadcast-red"><b>Moderated chat was set to +!</b><br />Only users of rank + and higher can talk.</div>`).update();
}
}
}
for (const user of Users.users.values()) {
user.send(`|pm|~|${user.tempGroup}${user.name}|/raw <div class="broadcast-red"><b>The server is restarting soon.</b><br />Please finish your battles quickly. No new battles can be started until the server resets in a few minutes.</div>`);
}
this.lockdown = true;
this.writeNumRooms();
this.lastReportedCrash = Date.now();
}
automaticKillRequest() {
const notifyPlaces: RoomID[] = ['development', 'staff', 'upperstaff'];
if (Config.autolockdown === undefined) Config.autolockdown = true; // on by default
if (Config.autolockdown && Rooms.global.lockdown === true && Rooms.global.battleCount === 0) {
// The server is in lockdown, the final battle has finished, and the option is set
// so we will now automatically kill the server here if it is not updating.
if (Monitor.updateServerLock) {
this.notifyRooms(
notifyPlaces,
`|html|<div class="broadcast-red"><b>Automatic server lockdown kill canceled.</b><br /><br />The server tried to automatically kill itself upon the final battle finishing, but the server was updating while trying to kill itself.</div>`
);
return;
}
// final warning
this.notifyRooms(
notifyPlaces,
`|html|<div class="broadcast-red"><b>The server is about to automatically kill itself in 10 seconds.</b></div>`
);
// kill server in 10 seconds if it's still set to
setTimeout(() => {
if (Config.autolockdown && Rooms.global.lockdown === true) {
// finally kill the server
process.exit();
} else {
this.notifyRooms(
notifyPlaces,
`|html|<div class="broadcsat-red"><b>Automatic server lockdown kill canceled.</b><br /><br />In the last final seconds, the automatic lockdown was manually disabled.</div>`
);
}
}, 10 * 1000);
}
}
notifyRooms(rooms: RoomID[], message: string) {
if (!rooms || !message) return;
for (const roomid of rooms) {
const curRoom = Rooms.get(roomid);
if (curRoom) curRoom.add(message).update();
}
}
reportCrash(err: Error | string, crasher = "The server") {
const time = Date.now();
if (time - this.lastReportedCrash < CRASH_REPORT_THROTTLE) {
return;
}
this.lastReportedCrash = time;
const stack = typeof err === 'string' ? err : err?.stack || err?.message || err?.name || '';
const [stackFirst, stackRest] = Utils.splitFirst(Utils.escapeHTML(stack), `<br />`);
let fullStack = `<b>${crasher} crashed:</b> ` + stackFirst;
if (stackRest) fullStack = `<details class="readmore"><summary>${fullStack}</summary>${stackRest}</details>`;
let crashMessage = `|html|<div class="broadcast-red">${fullStack}</div>`;
let privateCrashMessage = null;
const upperStaffRoom = Rooms.get('upperstaff');
let hasPrivateTerm = stack.includes('private');
for (const term of (Config.privatecrashterms || [])) {
if (typeof term === 'string' ? stack.includes(term) : term.test(stack)) {
hasPrivateTerm = true;
break;
}
}
if (hasPrivateTerm) {
if (upperStaffRoom) {
privateCrashMessage = crashMessage;
crashMessage = `|html|<div class="broadcast-red"><b>${crasher} crashed in private code</b> <a href="/upperstaff">Read more</a></div>`;
} else {
crashMessage = `|html|<div class="broadcast-red"><b>${crasher} crashed in private code</b></div>`;
}
}
const devRoom = Rooms.get('development');
if (devRoom) {
devRoom.add(crashMessage).update();
} else {
Rooms.lobby?.add(crashMessage).update();
Rooms.get('staff')?.add(crashMessage).update();
}
if (privateCrashMessage) {
upperStaffRoom!.add(privateCrashMessage).update();
}
}
/**
* Destroys personal rooms of a (punished) user
* Returns a list of the user's remaining public auth
*/
destroyPersonalRooms(userid: ID) {
const roomauth = [];
for (const [id, curRoom] of Rooms.rooms) {
if (curRoom.settings.isPersonal && curRoom.auth.get(userid) === Users.HOST_SYMBOL) {
curRoom.destroy();
} else {
if (curRoom.settings.isPrivate || curRoom.battle || !curRoom.persist) {
continue;
}
if (curRoom.auth.has(userid)) {
let oldGroup = curRoom.auth.get(userid) as string;
if (oldGroup === ' ') oldGroup = 'whitelist in ';
roomauth.push(`${oldGroup}${id}`);
}
}
}
return roomauth;
}
}
export class ChatRoom extends BasicRoom {
// This is not actually used, this is just a fake class to keep
// TypeScript happy
override battle = null;
override active = false as const;
override type = 'chat' as const;
}
export class GameRoom extends BasicRoom {
declare readonly type: 'battle';
readonly format: string;
p1: User | null;
p2: User | null;
p3: User | null;
p4: User | null;
/**
* The lower player's rating, for searching purposes.
* 0 for unrated battles. 1 for unknown ratings.
*/
rated: number;
declare battle: RoomBattle | null;
declare bestOf: BestOfGame | null;
declare game: RoomGame;
modchatUser: string;
constructor(roomid: RoomID, title: string, options: Partial<RoomSettings & RoomBattleOptions>) {
options.noLogTimes = true;
options.noAutoTruncate = true;
options.isMultichannel = true;
super(roomid, title, options);
this.reportJoins = !!Config.reportbattlejoins;
this.settings.modchat = (Config.battlemodchat || null);
this.type = 'battle';
this.format = options.format || '';
// console.log("NEW BATTLE");
this.tour = options.tour || null;
this.setParent((options as any).parent || this.tour?.room || null);
this.p1 = options.players?.[0]?.user || null;
this.p2 = options.players?.[1]?.user || null;
this.p3 = options.players?.[2]?.user || null;
this.p4 = options.players?.[3]?.user || null;
this.rated = options.rated === true ? 1 : options.rated || 0;
this.battle = null;
this.bestOf = null;
this.game = null!;
this.modchatUser = '';
this.active = false;
}
/**
* - logNum = 0 : spectator log (no exact HP)
* - logNum = 1, 2, 3, 4 : player log (exact HP for that player)
* - logNum = -1 : debug log (exact HP for all players)
*/
getLog(channel: -1 | 0 | 1 | 2 | 3 | 4 = 0) {
return this.log.getScrollback(channel);
}
getLogForUser(user: User) {
if (!(user.id in this.game.playerTable)) return this.getLog();
return this.getLog(this.game.playerTable[user.id].num as 0);
}
update(excludeUser: User | null = null) {
if (!this.log.broadcastBuffer.length) return;
if (this.userCount) {
Sockets.channelBroadcast(this.roomid, `>${this.roomid}\n${this.log.broadcastBuffer.join('\n')}`);
}
this.log.broadcastBuffer = [];
this.pokeExpireTimer();
}
pokeExpireTimer() {
// empty rooms time out after ten minutes
if (!this.userCount) {
if (this.expireTimer) clearTimeout(this.expireTimer);
this.expireTimer = setTimeout(() => this.expire(), TIMEOUT_EMPTY_DEALLOCATE);
} else {
if (this.expireTimer) clearTimeout(this.expireTimer);
this.expireTimer = setTimeout(() => this.expire(), TIMEOUT_INACTIVE_DEALLOCATE);
}
}
requestModchat(user: User | null) {
if (!user) {
this.modchatUser = '';
} else if (!this.modchatUser || this.modchatUser === user.id || this.auth.get(user.id) !== Users.PLAYER_SYMBOL) {
this.modchatUser = user.id;
} else {
return "Modchat can only be changed by the user who turned it on, or by staff";
}
}
onConnect(user: User, connection: Connection) {
this.sendUser(connection, '|init|battle\n|title|' + this.title + '\n' + this.getLogForUser(user));
this.game?.onConnect?.(user, connection);
}
onJoin(user: User, connection: Connection) {
if (!user) return false; // ???
if (this.users[user.id]) return false;
Chat.runHandlers('onBeforeRoomJoin', this, user, connection);
if (user.named) {
this.reportJoin('j', user.getIdentityWithStatus(this), user);
}
this.users[user.id] = user;
this.userCount++;
this.checkAutoModchat(user);
this.minorActivity?.onConnect?.(user, connection);
this.game?.onJoin?.(user, connection);
Chat.runHandlers('onRoomJoin', this, user, connection);
return true;
}
/**
* Sends this room's replay to the connection to be uploaded to the replay
* server. To be clear, the replay goes:
*
* PS server -> user -> loginserver
*
* NOT: PS server -> loginserver
*
* That's why this function requires a connection. For details, see the top
* comment inside this function.
*/
async uploadReplay(user?: User, connection?: Connection, options?: 'forpunishment' | 'silent' | 'auto') {
// The reason we don't upload directly to the loginserver, unlike every
// other interaction with the loginserver, is because it takes so much
// bandwidth that it can get identified as a DoS attack by PHP, Apache, or
// Cloudflare, and blocked.
// While I'm sure this is configurable, it's a huge pain, and getting it
// wrong, especially while migrating infrastructure, leads to everything
// being unusable and panic while we figure out how to unblock our servers
// from each other. It's just easier to "spread out" the bandwidth.
// TODO: My ideal long-term fix would be to just have a database (probably
// Postgres) shared between client and server, acting as both the server's
// battle logs as well as the client's replay database, which both client
// and server have write access to.
const battle = this.battle;
if (!battle) return;
// retrieve spectator log (0) if there are privacy concerns
const format = Dex.formats.get(this.format, true);
// custom games always show full details
// random-team battles show full details if the battle is ended
// otherwise, don't show full details
let hideDetails = !format.id.includes('customgame');
if (format.team && battle.ended) hideDetails = false;
const log = this.getLog(hideDetails ? 0 : -1);
let rating: number | undefined;
if (battle.ended && this.rated) rating = this.rated;
let { id, password } = this.getReplayData();
const silent = options === 'forpunishment' || options === 'silent' || options === 'auto';
if (silent) connection = undefined;
const isPrivate = this.settings.isPrivate || this.hideReplay;
const hidden = options === 'auto' ? 10 :
options === 'forpunishment' || (this as any).unlistReplay ? 2 :
isPrivate ? 1 :
0;
if (isPrivate && hidden === 10) {
password = Replays.generatePassword();
}
if (battle.replaySaved !== true && hidden === 10) {
battle.replaySaved = 'auto';
} else {
battle.replaySaved = true;
}
// If we have a direct connetion to a Replays database, just upload the replay
// directly.
if (Replays.db) {
const idWithServer = Config.serverid === 'showdown' ? id : `${Config.serverid}-${id}`;
try {
const fullid = await Replays.add({
id: idWithServer,
log,
players: battle.players.map(p => p.name),
format: format.name,
rating: rating || null,
private: hidden,
password,
inputlog: battle.inputLog?.join('\n') || null,
uploadtime: Math.trunc(Date.now() / 1000),
});
const url = `https://${Config.routes.replays}/${fullid}`;
connection?.popup(
`|html|<p>Your replay has been uploaded! It's available at:</p><p> ` +
`<a class="no-panel-intercept" href="${url}" target="_blank">${url}</a> ` +
`<copytext value="${url}">Copy</copytext>`
);
} catch (e) {
connection?.popup(`Your replay could not be saved: ${e}`);
throw e;
}
return;
}
// Otherwise, (we're probably a side server), upload the replay through LoginServer
const [result] = await LoginServer.request('addreplay', {
id,
log,
players: battle.players.map(p => p.name).join(','),
format: format.name,
rating, // will probably do nothing
hidden: hidden === 0 ? '' : hidden,
inputlog: battle.inputLog?.join('\n') || undefined,
password,
});
if (result?.errorip) {
connection?.popup(`This server's request IP ${result.errorip} is not a registered server.`);
return;
}
const fullid = result?.replayid;
const url = `https://${Config.routes.replays}/${fullid}`;
connection?.popup(
`|html|<p>Your replay has been uploaded! It's available at:</p><p> ` +
`<a class="no-panel-intercept" href="${url}" target="_blank">${url}</a> ` +
`<copytext value="${url}">Copy</copytext>`
);
}
getReplayData() {
if (!this.roomid.endsWith('pw')) return { id: this.roomid.slice(7), password: null };
const end = this.roomid.length - 2;
const lastHyphen = this.roomid.lastIndexOf('-', end);
return { id: this.roomid.slice(7, lastHyphen), password: this.roomid.slice(lastHyphen + 1, end) };
}
}
function getRoom(roomid?: string | BasicRoom) {
if (typeof roomid === 'string') {
// Accounts for private battles that were made public
if ((roomid.startsWith('battle-') || roomid.startsWith('game-bestof')) && roomid.endsWith('pw')) {
const room = Rooms.rooms.get(roomid.slice(0, roomid.lastIndexOf('-')) as RoomID);
if (room) return room;
}
return Rooms.rooms.get(roomid as RoomID);
}
return roomid as Room;
}
export const Rooms = {
Modlog: mainModlog,
/**
* The main roomid:Room table. Please do not hold a reference to a
* room long-term; just store the roomid and grab it from here (with
* the Rooms.get(roomid) accessor) when necessary.
*/
rooms: new Map<RoomID, Room>(),
aliases: new Map<string, RoomID>(),
get: getRoom,
search(name: string): Room | undefined {
return getRoom(name) || getRoom(toID(name)) || getRoom(Rooms.aliases.get(toID(name)));
},
createGameRoom(roomid: RoomID, title: string, options: Partial<RoomSettings & RoomBattleOptions>) {
if (Rooms.rooms.has(roomid)) throw new Error(`Room ${roomid} already exists`);
Monitor.debug("NEW BATTLE ROOM: " + roomid);
const room = new GameRoom(roomid, title, options);
Rooms.rooms.set(roomid, room);
return room;
},
createChatRoom(roomid: RoomID, title: string, options: Partial<RoomSettings>) {
if (Rooms.rooms.has(roomid)) throw new Error(`Room ${roomid} already exists`);
const room: ChatRoom = new (BasicRoom as any)(roomid, title, options);
Rooms.rooms.set(roomid, room);
return room;
},
/**
* Can return null during lockdown, so make sure to handle that case.
* No need for UI; this function sends popups to users.
*/
createBattle(options: RoomBattleOptions & Partial<RoomSettings>) {
const players = options.players.map(player => player.user);
const format = Dex.formats.get(options.format);
if (players.length > format.playerCount) {
throw new Error(`${players.length} players were provided, but the format is a ${format.playerCount}-player format.`);
}
if (new Set(players).size < players.length) {
throw new Error(`Players can't battle themselves`);
}
for (const user of players) {
Ladders.cancelSearches(user);
}
const isBestOf = Dex.formats.getRuleTable(format).valueRules.get('bestof');
if (Rooms.global.lockdown === 'pre' && isBestOf && !options.isBestOfSubBattle) {
for (const user of players) {
user.popup(`The server will be restarting soon. Best-of-${isBestOf} battles cannot be started at this time.`);
}
return null;
}
// gotta allow new bo3 child battles to start
if (Rooms.global.lockdown === true && !options.isBestOfSubBattle) {
for (const user of players) {
user.popup("The server is restarting. Battles will be available again in a few minutes.");
}
return null;
}
const p1Special = players.length ? players[0].battleSettings.special : undefined;
let mismatch = `"${p1Special}"`;
for (const user of players) {
if (user.battleSettings.special !== p1Special) {
mismatch += ` vs. "${user.battleSettings.special}"`;
}
user.battleSettings.special = undefined;
}
if (mismatch !== `"${p1Special}"`) {
for (const user of players) {
user.popup(`Your special battle settings don't match: ${mismatch}`);
}
return null;
} else if (p1Special) {
options.ratedMessage = p1Special;
}
// options.rated is a number representing the lowest player rating, for searching purposes
// options.rated < 0 or falsy means "unrated", and will be converted to 0 here
// options.rated === true is converted to 1 (used in tests sometimes)
options.rated = Math.max(+options.rated! || 0, 0);
const p1 = players[0];
const p2 = players[1];
const p1name = p1 ? p1.name : "Player 1";
const p2name = p2 ? p2.name : "Player 2";
let roomTitle;
let roomid = options.roomid;
if (format.gameType === 'multi') {
roomTitle = `Team ${p1name} vs. Team ${p2name}`;
} else if (format.gameType === 'freeforall') {
// p1 vs. p2 vs. p3 vs. p4 is too long of a title
roomTitle = `${p1name} and friends`;
} else if (isBestOf && !options.isBestOfSubBattle) {
roomTitle = `${p1name} vs. ${p2name}`;
roomid ||= `game-bestof${isBestOf}-${format.id}-${++Rooms.global.lastBattle}` as RoomID;
} else if (options.title) {
roomTitle = options.title;
} else {
roomTitle = `${p1name} vs. ${p2name}`;
}
roomid ||= Rooms.global.prepBattleRoom(options.format);
options.isPersonal = true;
const room = Rooms.createGameRoom(roomid, roomTitle, options);
let game: RoomBattle | BestOfGame;
if (options.isBestOfSubBattle || !isBestOf) {
game = new RoomBattle(room, options);
} else {
game = new BestOfGame(room, options);
}
room.game = game;
if (options.isBestOfSubBattle && room.parent) {
room.setPrivate(room.parent.settings.isPrivate || false);
} else {
game.checkPrivacySettings(options);
}
for (const p of players) {
if (p) {
p.joinRoom(room);
Monitor.countBattle(p.latestIp, p.name);
}
}
return room;
},
global: null! as GlobalRoomState,
lobby: null as ChatRoom | null,
BasicRoom,
GlobalRoomState,
GameRoom,
ChatRoom: BasicRoom as typeof ChatRoom,
RoomGame,
SimpleRoomGame,
RoomGamePlayer,
MinorActivity,
RETRY_AFTER_LOGIN,
Roomlogs,
RoomBattle,
BestOfGame,
RoomBattlePlayer,
RoomBattleTimer,
PM: RoomBattlePM,
Replays,
};