pokemon-showdown/server/room-battle.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

1398 lines
45 KiB
TypeScript

/**
* Room Battle
* Pokemon Showdown - http://pokemonshowdown.com/
*
* This file wraps the simulator in an implementation of the RoomGame
* interface. It also abstracts away the multi-process nature of the
* simulator.
*
* For the actual battle simulation, see sim/
*
* @license MIT
*/
import { execSync } from "child_process";
import { Repl, ProcessManager, type Streams } from '../lib';
import { BattleStream } from "../sim/battle-stream";
import { RoomGamePlayer, RoomGame } from "./room-game";
import type { Tournament } from './tournaments/index';
import type { RoomSettings } from './rooms';
import type { BestOfGame } from './room-battle-bestof';
import type { GameTimerSettings } from '../sim/dex-formats';
type ChannelIndex = 0 | 1 | 2 | 3 | 4;
export type PlayerIndex = 1 | 2 | 3 | 4;
export type ChallengeType = 'rated' | 'unrated' | 'challenge' | 'tour';
interface BattleRequestTracker {
rqid: number;
request: string;
/**
* - true = user has decided,
* - false = user has yet to decide,
* - 'cantUndo' = waiting on other user (U-turn, faint-switch) or uncancellable (trapping ability)
*/
isWait: 'cantUndo' | true | false;
choice: string;
}
/** 5 seconds */
const TICK_TIME = 5;
const SECONDS = 1000;
// Timer constants: In seconds, should be multiple of TICK_TIME
const STARTING_TIME = 150;
const MAX_TURN_TIME = 150;
const STARTING_TIME_CHALLENGE = 300;
const STARTING_GRACE_TIME = 60;
const MAX_TURN_TIME_CHALLENGE = 300;
const DISCONNECTION_TIME = 60;
const DISCONNECTION_BANK_TIME = 300;
// time after a player disabling the timer before they can re-enable it
const TIMER_COOLDOWN = 20 * SECONDS;
const LOCKDOWN_PERIOD = 30 * 60 * 1000; // 30 minutes
export class RoomBattlePlayer extends RoomGamePlayer<RoomBattle> {
readonly slot: SideID;
readonly channelIndex: ChannelIndex;
request: BattleRequestTracker;
wantsTie: boolean;
wantsOpenTeamSheets: boolean | null;
eliminated: boolean;
/**
* Total timer.
*
* Starts at 210 per player in a ladder battle. Goes down by 5
* every tick. Goes up by 10 every turn (with some complications -
* see `nextRequest`), capped at starting time. The player loses if
* this reaches 0.
*
* The equivalent of "Your Time" in VGC.
*
*/
secondsLeft: number;
/**
* Current turn timer.
*
* Set equal to the player's overall timer, but capped at 150
* seconds in a ladder battle. Goes down by 5 every tick.
* Tracked separately from the overall timer, and the player also
* loses if this reaches 0 (except in VGC where the default choice
* is chosen if it reaches 0).
*/
turnSecondsLeft: number;
/**
* Disconnect timer.
*
* Starts at 60 seconds. While the player is disconnected, this
* will go down by 5 every tick. Tracked separately from the
* overall timer, and the player also loses if this reaches 0.
*
* Mostly exists so impatient players don't have to wait the full
* 150 seconds against a disconnected opponent.
*/
dcSecondsLeft: number;
/**
* Is the user actually in the room?
*/
active: boolean;
/**
* Used to track a user's last known connection status, and display
* the proper message when it changes.
*
* `.active` is set right when the user joins/leaves, but `.knownActive`
* is only set after the timer knows about it.
*/
knownActive: boolean;
invite: ID;
/**
* Has the simulator received this player's team yet?
* Basically always yes except when creating a 4-player battle,
* in which case players will need to bring their own team.
*/
hasTeam: boolean;
constructor(user: User | string | null, game: RoomBattle, num: PlayerIndex) {
super(user, game, num);
if (typeof user === 'string') user = null;
this.slot = `p${num}` as SideID;
this.channelIndex = (game.gameType === 'multi' && num > 2 ? num - 2 : num) as ChannelIndex;
this.request = { rqid: 0, request: '', isWait: 'cantUndo', choice: '' };
this.wantsTie = false;
this.wantsOpenTeamSheets = null;
this.active = !!user?.connected;
this.eliminated = false;
this.secondsLeft = 1;
this.turnSecondsLeft = 1;
this.dcSecondsLeft = 1;
this.knownActive = true;
this.invite = '';
this.hasTeam = false;
if (user) {
user.games.add(this.game.roomid);
user.updateSearch();
for (const connection of user.connections) {
if (connection.inRooms.has(game.roomid)) {
Sockets.channelMove(connection.worker, this.game.roomid, this.channelIndex, connection.socketid);
}
}
}
}
override destroy() {
const user = this.getUser();
if (user) {
this.updateChannel(user, 0);
}
this.knownActive = false;
this.active = false;
}
updateChannel(user: User | Connection, channel = this.channelIndex) {
for (const connection of (user.connections || [user])) {
Sockets.channelMove(connection.worker, this.game.roomid, channel, connection.socketid);
}
}
}
export class RoomBattleTimer {
readonly battle: RoomBattle;
readonly timerRequesters: Set<ID>;
timer: NodeJS.Timeout | null;
isFirstTurn: boolean;
/**
* Last tick, as milliseconds since UNIX epoch.
* Represents the last time a tick happened.
*/
lastTick: number;
/** Debug mode; true to output detailed timer info every tick */
debug: boolean;
lastDisabledTime: number;
lastDisabledByUser: null | ID;
settings: GameTimerSettings;
constructor(battle: RoomBattle) {
this.battle = battle;
this.timer = null;
this.timerRequesters = new Set();
this.isFirstTurn = true;
this.lastTick = 0;
this.debug = false;
this.lastDisabledTime = 0;
this.lastDisabledByUser = null;
const format = Dex.formats.get(battle.format, true);
const hasLongTurns = format.gameType !== 'singles';
const isChallenge = (battle.challengeType === 'challenge');
const timerEntry = Dex.formats.getRuleTable(format).timer;
const timerSettings = timerEntry?.[0];
// so that Object.assign doesn't overwrite anything with `undefined`
for (const k in timerSettings) {
// @ts-expect-error prop access
if (timerSettings[k] === undefined) delete timerSettings[k];
}
this.settings = {
dcTimer: !isChallenge,
dcTimerBank: isChallenge,
starting: isChallenge ? STARTING_TIME_CHALLENGE : STARTING_TIME,
grace: STARTING_GRACE_TIME,
addPerTurn: hasLongTurns ? 25 : 10,
maxPerTurn: isChallenge ? MAX_TURN_TIME_CHALLENGE : MAX_TURN_TIME,
maxFirstTurn: isChallenge ? MAX_TURN_TIME_CHALLENGE : MAX_TURN_TIME,
timeoutAutoChoose: false,
accelerate: !timerSettings && !isChallenge,
...timerSettings,
};
if (this.settings.maxPerTurn <= 0) this.settings.maxPerTurn = Infinity;
for (const player of this.battle.players) {
player.secondsLeft = this.settings.starting + this.settings.grace;
player.turnSecondsLeft = -1;
player.dcSecondsLeft = this.settings.dcTimerBank ? DISCONNECTION_BANK_TIME : DISCONNECTION_TIME;
}
}
start(requester?: User) {
const userid = requester ? requester.id : 'staff' as ID;
if (this.timerRequesters.has(userid)) return false;
if (this.battle.ended) {
requester?.sendTo(this.battle.roomid, `|inactiveoff|The timer can't be enabled after a battle has ended.`);
return false;
}
if (this.timer) {
this.battle.room.add(`|inactive|${requester ? requester.name : userid} also wants the timer to be on.`).update();
this.timerRequesters.add(userid);
return false;
}
if (requester && this.battle.playerTable[requester.id] && this.lastDisabledByUser === requester.id) {
const remainingCooldownMs = (this.lastDisabledTime || 0) + TIMER_COOLDOWN - Date.now();
if (remainingCooldownMs > 0) {
this.battle.playerTable[requester.id].sendRoom(
`|inactiveoff|The timer can't be re-enabled so soon after disabling it (${Math.ceil(remainingCooldownMs / SECONDS)} seconds remaining).`
);
return false;
}
}
this.timerRequesters.add(userid);
const requestedBy = requester ? ` (requested by ${requester.name})` : ``;
this.battle.room.add(`|inactive|Battle timer is ON: inactive players will automatically lose when time's up.${requestedBy}`).update();
this.checkActivity();
this.nextRequest();
return true;
}
stop(requester?: User) {
if (requester) {
if (!this.timerRequesters.has(requester.id)) return false;
this.timerRequesters.delete(requester.id);
this.lastDisabledByUser = requester.id;
this.lastDisabledTime = Date.now();
} else {
this.timerRequesters.clear();
}
if (this.timerRequesters.size) {
this.battle.room.add(`|inactive|${requester!.name} no longer wants the timer on, but the timer is staying on because ${[...this.timerRequesters].join(', ')} still does.`).update();
return false;
}
if (this.end()) {
this.battle.room.add(`|inactiveoff|Battle timer is now OFF.`).update();
return true;
}
return false;
}
end() {
this.timerRequesters.clear();
if (!this.timer) return false;
clearTimeout(this.timer);
this.timer = null;
return true;
}
nextRequest() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
if (!this.timerRequesters.size) return;
const players = this.battle.players;
if (players.some(player => player.secondsLeft <= 0)) return;
/** false = U-turn or single faint, true = "new turn" */
let isFull = true;
let isEmpty = true;
for (const player of players) {
if (player.request.isWait) isFull = false;
if (player.request.isWait !== 'cantUndo') isEmpty = false;
}
if (isEmpty) {
// there are no active requests
return;
}
const isFirst = this.isFirstTurn;
this.isFirstTurn = false;
const maxTurnTime = (isFirst ? this.settings.maxFirstTurn : 0) || this.settings.maxPerTurn;
let addPerTurn = isFirst ? 0 : this.settings.addPerTurn;
if (this.settings.accelerate && addPerTurn) {
// after turn 100ish: 15s/turn -> 10s/turn
if (this.battle.requestCount > 200 && addPerTurn > TICK_TIME) {
addPerTurn -= TICK_TIME;
}
// after turn 200ish: 10s/turn -> 7s/turn
if (this.battle.requestCount > 400 && Math.floor(this.battle.requestCount / 2) % 2) {
addPerTurn = 0;
}
}
if (!isFull && addPerTurn > TICK_TIME) {
addPerTurn = TICK_TIME;
}
const room = this.battle.room;
for (const player of players) {
if (!isFirst) {
player.secondsLeft = Math.min(player.secondsLeft + addPerTurn, this.settings.starting);
}
player.turnSecondsLeft = Math.min(player.secondsLeft, maxTurnTime);
const secondsLeft = player.turnSecondsLeft;
let grace = player.secondsLeft - this.settings.starting;
if (grace < 0) grace = 0;
player.sendRoom(`|inactive|Time left: ${secondsLeft} sec this turn | ${player.secondsLeft - grace} sec total` + (grace ? ` | ${grace} sec grace` : ``));
if (secondsLeft <= 30 && secondsLeft < this.settings.starting) {
room.add(`|inactive|${player.name} has ${secondsLeft} seconds left this turn.`);
}
if (this.debug) {
room.add(`||${player.name} | Time left: ${secondsLeft} sec this turn | ${player.secondsLeft} sec total | +${addPerTurn} seconds`);
}
}
room.update();
this.lastTick = Date.now();
this.timer = setTimeout(() => this.nextTick(), TICK_TIME * SECONDS);
}
nextTick() {
if (this.timer) clearTimeout(this.timer);
if (this.battle.ended) return;
const room = this.battle.room;
for (const player of this.battle.players) {
if (player.request.isWait) continue;
if (player.knownActive) {
player.secondsLeft -= TICK_TIME;
player.turnSecondsLeft -= TICK_TIME;
} else {
player.dcSecondsLeft -= TICK_TIME;
if (!this.settings.dcTimerBank) {
player.secondsLeft -= TICK_TIME;
player.turnSecondsLeft -= TICK_TIME;
}
}
const dcSecondsLeft = player.dcSecondsLeft;
if (dcSecondsLeft <= 0) {
player.turnSecondsLeft = 0;
}
const secondsLeft = player.turnSecondsLeft;
if (!secondsLeft) continue;
if (!player.knownActive && (dcSecondsLeft <= secondsLeft || this.settings.dcTimerBank)) {
// dc timer is shown only if it's lower than turn timer or you're in timer bank mode
if (dcSecondsLeft % 30 === 0 || dcSecondsLeft <= 20) {
room.add(`|inactive|${player.name} has ${dcSecondsLeft} seconds to reconnect!`);
}
} else {
// regular turn timer shown
if (secondsLeft % 30 === 0 || secondsLeft <= 20) {
room.add(`|inactive|${player.name} has ${secondsLeft} seconds left.`);
}
}
if (this.debug) {
room.add(`||[${player.name} has ${player.turnSecondsLeft}s this turn / ${player.secondsLeft}s total]`);
}
}
room.update();
if (!this.checkTimeout()) {
this.timer = setTimeout(() => this.nextTick(), TICK_TIME * 1000);
}
}
checkActivity() {
if (this.battle.ended) return;
for (const player of this.battle.players) {
const isActive = !!player.active;
if (isActive === player.knownActive) continue;
if (!isActive) {
// player has disconnected
player.knownActive = false;
if (!this.settings.dcTimerBank) {
// don't wait longer than 6 ticks (1 minute)
if (this.settings.dcTimer) {
player.dcSecondsLeft = DISCONNECTION_TIME;
} else {
// arbitrary large number
player.dcSecondsLeft = DISCONNECTION_TIME * 10;
}
}
if (this.timerRequesters.size) {
let msg = `!`;
if (this.settings.dcTimer) {
msg = ` and has a minute to reconnect!`;
}
if (this.settings.dcTimerBank) {
if (player.dcSecondsLeft > 0) {
msg = ` and has ${player.dcSecondsLeft} seconds to reconnect!`;
} else {
msg = ` and has no disconnection time left!`;
}
}
this.battle.room.add(`|inactive|${player.name} disconnected${msg}`).update();
}
} else {
// player has reconnected
player.knownActive = true;
if (this.timerRequesters.size) {
let timeLeft = ``;
if (!player.request.isWait) {
timeLeft = ` and has ${player.turnSecondsLeft} seconds left`;
}
this.battle.room.add(`|inactive|${player.name} reconnected${timeLeft}.`).update();
}
}
}
}
checkTimeout() {
const players = this.battle.players;
if (players.every(player => player.turnSecondsLeft <= 0)) {
if (!this.settings.timeoutAutoChoose || players.every(player => player.secondsLeft <= 0)) {
this.battle.room.add(`|-message|All players are inactive.`).update();
this.battle.tie();
return true;
}
}
let didSomething = false;
for (const player of players) {
if (!player.id) continue; // already eliminated, relevant for FFA gamesif it
// https://play.pokemonshowdown.com/battle-gen9unratedrandombattle-2255606027-5a6bcd9zlb93e6id5pp7juvhcg5w41spw
// why is this line here?
if (player.turnSecondsLeft > 0) continue;
if (this.settings.timeoutAutoChoose && player.secondsLeft > 0 && player.knownActive) {
void this.battle.stream.write(`>${player.slot} default`);
didSomething = true;
} else {
this.battle.forfeitPlayer(player, ' lost due to inactivity.');
return true;
}
}
return didSomething;
}
}
export interface RoomBattlePlayerOptions {
user: User;
/** should be '' for random teams */
team?: string;
rating?: number;
inviteOnly?: boolean;
hidden?: boolean;
}
export interface RoomBattleOptions {
format: string;
/**
* length should be equal to the format's playerCount, except in two
* special cases:
* - `/importinputlog`, where it's empty (players have to be invited/restored)
* - challenge ffa/multi, where it's 2 (the rest have to be invited)
* - restoring saved battles after a restart (players should be manually restored)
* In all special cases, either `delayedStart` or `inputLog` must be set
*/
players: RoomBattlePlayerOptions[];
delayedStart?: boolean | 'multi';
challengeType?: ChallengeType;
allowRenames?: boolean;
rated?: number | boolean | null;
tour?: Tournament | null;
inputLog?: string;
ratedMessage?: string;
seed?: PRNGSeed;
roomid?: RoomID;
/** For battles restored after a restart */
delayedTimer?: boolean;
/**
* If false and the format is a best-of format, creates a best-of game
* rather than a battle.
*/
isBestOfSubBattle?: boolean;
}
export class RoomBattle extends RoomGame<RoomBattlePlayer> {
override readonly gameid = 'battle' as ID;
override readonly room!: GameRoom;
override readonly title: string;
override readonly allowRenames: boolean;
readonly format: string;
/** Will exist even if the game is unrated, in case it's later forced to be rated */
readonly ladder: string;
readonly gameType: string | undefined;
readonly challengeType: ChallengeType;
/**
* The lower player's rating, for searching purposes.
* 0 for unrated battles. 1 for unknown ratings.
*/
readonly rated: number;
/**
* userid that requested extraction -> playerids that accepted the extraction
*/
readonly allowExtraction: { [k: string]: Set<ID> } = {};
readonly stream: Streams.ObjectReadWriteStream<string>;
override readonly timer: RoomBattleTimer;
started = false;
active = false;
replaySaved: boolean | 'auto' = false;
forcedSettings: { modchat?: string | null, privacy?: string | null } = {};
p1: RoomBattlePlayer = null!;
p2: RoomBattlePlayer = null!;
p3: RoomBattlePlayer = null!;
p4: RoomBattlePlayer = null!;
inviteOnlySetter: ID | null = null;
logData: AnyObject | null = null;
endType: 'forfeit' | 'forced' | 'normal' = 'normal';
/**
* If the battle is ended: an array of the number of Pokemon left for each side.
*/
score: number[] | null = null;
inputLog: string[] | null = null;
turn = 0;
rqid = 1;
requestCount = 0;
options: RoomBattleOptions;
frozen?: boolean;
dataResolvers?: [((args: string[]) => void), ((error: Error) => void)][];
constructor(room: GameRoom, options: RoomBattleOptions) {
super(room);
const format = Dex.formats.get(options.format, true);
this.title = format.name;
this.options = options;
if (!this.title.endsWith(" Battle")) this.title += " Battle";
this.allowRenames = options.allowRenames !== undefined ? !!options.allowRenames : (!options.rated && !options.tour);
this.format = options.format;
this.gameType = format.gameType;
this.challengeType = options.challengeType || 'challenge';
this.rated = options.rated === true ? 1 : options.rated || 0;
this.ladder = typeof format.rated === 'string' ? toID(format.rated) : options.format;
this.playerCap = format.playerCount;
this.stream = PM.createStream();
let ratedMessage = options.ratedMessage || '';
if (this.rated) {
ratedMessage = 'Rated battle';
} else if (this.room.tour) {
ratedMessage = 'Tournament battle';
}
this.room.battle = this;
const battleOptions = {
formatid: this.format,
roomid: this.roomid,
rated: ratedMessage,
seed: options.seed,
};
if (options.inputLog) {
void this.stream.write(options.inputLog);
} else {
void this.stream.write(`>start ` + JSON.stringify(battleOptions));
}
void this.listen();
if (options.players.length > this.playerCap) {
throw new Error(`${options.players.length} players passed to battle ${room.roomid} but ${this.playerCap} players expected`);
}
for (let i = 0; i < this.playerCap; i++) {
const p = options.players[i];
const player = this.addPlayer(p?.user || null, p || null);
if (!player) throw new Error(`failed to create player ${i + 1} in ${room.roomid}`);
}
if (options.inputLog) {
let scanIndex = 0;
for (const player of this.players) {
const nameIndex1 = options.inputLog.indexOf(`"name":"`, scanIndex);
const nameIndex2 = options.inputLog.indexOf(`"`, nameIndex1 + 8);
if (nameIndex1 < 0 || nameIndex2 < 0) break; // shouldn't happen. incomplete inputlog?
scanIndex = nameIndex2 + 1;
const name = options.inputLog.slice(nameIndex1 + 8, nameIndex2);
player.name = name;
player.hasTeam = true;
}
}
this.timer = new RoomBattleTimer(this);
if (Config.forcetimer || this.format.includes('blitz')) this.timer.start();
this.start();
}
checkActive() {
const active = (this.started && !this.ended && this.players.every(p => p.active));
Rooms.global.battleCount += (active ? 1 : 0) - (this.active ? 1 : 0);
this.room.active = active;
this.active = active;
if (Rooms.global.battleCount === 0) Rooms.global.automaticKillRequest();
}
override choose(user: User, data: string) {
if (this.frozen) {
user.popup(`Your battle is currently paused, so you cannot move right now.`);
return;
}
const player = this.playerTable[user.id];
const [choice, rqid] = data.split('|', 2);
if (!player) return;
const request = player.request;
if (request.isWait !== false && request.isWait !== true) {
player.sendRoom(`|error|[Invalid choice] There's nothing to choose`);
return;
}
const allPlayersWait = this.players.every(p => !!p.request.isWait);
if (allPlayersWait || // too late
(rqid && rqid !== `${request.rqid}`)) { // WAY too late
player.sendRoom(`|error|[Invalid choice] Sorry, too late to make a different move; the next turn has already started`);
return;
}
request.isWait = true;
request.choice = choice;
void this.stream.write(`>${player.slot} ${choice}`);
}
override undo(user: User, data: string) {
const player = this.playerTable[user.id];
const [, rqid] = data.split('|', 2);
if (!player) return;
const request = player.request;
if (request.isWait !== true) {
player.sendRoom(`|error|[Invalid choice] There's nothing to cancel`);
return;
}
const allPlayersWait = this.players.every(p => !!p.request.isWait);
if (allPlayersWait || // too late
(rqid && rqid !== `${request.rqid}`)) { // WAY too late
player.sendRoom(`|error|[Invalid choice] Sorry, too late to cancel; the next turn has already started`);
return;
}
request.isWait = false;
void this.stream.write(`>${player.slot} undo`);
}
override joinGame(user: User, slot?: SideID, playerOpts?: { team?: string }) {
if (user.id in this.playerTable) {
user.popup(`You have already joined this battle.`);
return false;
}
const validSlots = this.players.filter(player => !player.id).map(player => player.slot);
if (slot && !validSlots.includes(slot)) {
user.popup(`This battle already has a user in slot ${slot}.`);
return false;
}
if (!validSlots.length) {
user.popup(`This battle already has ${this.playerCap} players.`);
return false;
}
slot ??= this.players.find(player => player.invite === user.id)?.slot;
if (!slot && validSlots.length > 1) {
user.popup(`Which slot would you like to join into? Use something like \`/joingame ${validSlots[0]}\``);
return false;
}
slot ??= validSlots[0];
if (this[slot].invite === user.id) {
this.room.auth.set(user.id, Users.PLAYER_SYMBOL);
} else if (!user.can('joinbattle', null, this.room)) {
user.popup(`You must be set as a player to join a battle you didn't start. Ask a player to use /addplayer on you to join this battle.`);
return false;
}
this.setPlayerUser(this[slot], user, playerOpts);
if (validSlots.length - 1 <= 0) {
// all players have joined, start the battle
// onCreateBattleRoom crashes if some users are unavailable at start of battle
// what do we do??? no clue but I guess just exclude them from the array for now
const users = this.players.map(player => player.getUser()).filter(Boolean) as User[];
Rooms.global.onCreateBattleRoom(users, this.room, { rated: this.rated });
this.started = true;
this.room.add(`|uhtmlchange|invites|`);
} else if (!this.started && this.invitesFull()) {
this.sendInviteForm(true);
}
if (user.inRooms.has(this.roomid)) this.onConnect(user);
this.room.update();
return true;
}
override leaveGame(user: User) {
if (!user) return false; // ...
if (this.room.rated || this.room.tour) {
user.popup(`Players can't be swapped out in a ${this.room.tour ? "tournament" : "rated"} battle.`);
return false;
}
const player = this.playerTable[user.id];
if (!player) {
user.popup(`Failed to leave battle - you're not a player.`);
return false;
}
Chat.runHandlers('onBattleLeave', user, this.room);
this.updatePlayer(player, null);
this.room.update();
return true;
}
override startTimer() {
this.timer.start();
}
async listen() {
let disconnected = false;
try {
for await (const next of this.stream) {
if (!this.room) return; // room deleted in the middle of simulation
this.receive(next.split('\n'));
}
} catch (err: any) {
// Disconnected processes are already crashlogged when they happen;
// also logging every battle room would overwhelm the crashlogger
if (err.message.includes('Process disconnected')) {
disconnected = true;
} else {
Monitor.crashlog(err, 'A sim stream');
}
}
if (!this.ended) {
this.room.add(`|bigerror|The simulator process crashed. We've been notified and will fix this ASAP.`);
if (!disconnected) Monitor.crashlog(new Error(`Sim stream interrupted`), `A sim stream`);
this.started = true;
this.setEnded();
this.checkActive();
}
}
receive(lines: string[]) {
for (const player of this.players) player.wantsTie = false;
switch (lines[0]) {
case 'requesteddata':
lines = lines.slice(1);
const [resolver] = this.dataResolvers!.shift()!;
resolver(lines);
break;
case 'update':
for (const line of lines.slice(1)) {
if (line.startsWith('|turn|')) {
this.turn = parseInt(line.slice(6));
}
this.room.add(line);
if (line.startsWith(`|bigerror|You will auto-tie if `) && Config.allowrequestingties && !this.room.tour) {
this.room.add(`|-hint|If you want to tie earlier, consider using \`/offertie\`.`);
}
}
this.room.update();
if (!this.ended) this.timer.nextRequest();
this.checkActive();
break;
case 'sideupdate': {
const slot = lines[1] as SideID;
const player = this[slot];
if (lines[2].startsWith(`|error|[Invalid choice] Can't do anything`)) {
// ... should not happen
} else if (lines[2].startsWith(`|error|[Invalid choice]`)) {
const undoFailed = lines[2].includes(`Can't undo`);
const request = this[slot].request;
request.isWait = undoFailed ? 'cantUndo' : false;
request.choice = '';
} else if (lines[2].startsWith(`|request|`)) {
this.rqid++;
const request = JSON.parse(lines[2].slice(9));
request.rqid = this.rqid;
const requestJSON = JSON.stringify(request);
this[slot].request = {
rqid: this.rqid,
request: requestJSON,
isWait: request.wait ? 'cantUndo' : false,
choice: '',
};
this.requestCount++;
player?.sendRoom(`|request|${requestJSON}`);
break;
}
player?.sendRoom(lines[2]);
break;
}
case 'error': {
if (process.uptime() * 1000 < LOCKDOWN_PERIOD) {
const error = new Error();
error.stack = lines.slice(1).join('\n');
// lock down the server
Rooms.global.startLockdown(error);
}
break;
}
case 'end':
this.logData = JSON.parse(lines[1]);
this.score = this.logData!.score;
this.inputLog = this.logData!.inputLog;
this.started = true;
void this.end(this.logData!.winner);
break;
}
}
end(winnerName: unknown) {
if (this.ended) return;
this.setEnded();
this.checkActive();
this.timer.end();
// Declare variables here in case we need them for non-rated battles logging.
let p1score = 0.5;
const winnerid = toID(winnerName);
// Check if the battle was rated to update the ladder, return its response, and log the battle.
if (winnerid === this.p1.id) {
p1score = 1;
} else if (winnerid === this.p2.id) {
p1score = 0;
}
Chat.runHandlers('onBattleEnd', this, winnerid, this.players.map(p => p.id));
if (this.room.rated && !this.options.isBestOfSubBattle) {
void this.updateLadder(p1score, winnerid);
} else if (Config.logchallenges) {
void this.logBattle(p1score);
} else if (!this.options.isBestOfSubBattle) {
this.logData = null;
}
this.room.parent?.game?.onBattleWin?.(this.room, winnerid);
// If the room's replay was hidden, don't let users join after the game is over
if (this.room.hideReplay) {
this.room.settings.modjoin = '%';
this.room.setPrivate('hidden');
}
this.room.update();
// so it stops showing up in the users' games list
for (const player of this.players) {
player.getUser()?.games.delete(this.roomid);
}
// If a replay was saved at any point or we were configured to autosavereplays,
// reupload when the battle is over to overwrite the partial data (and potentially
// reflect any changes that may have been made to the replay's hidden status).
if (this.replaySaved || Config.autosavereplays) {
const options = Config.autosavereplays === 'private' ? undefined : 'silent';
return this.room.uploadReplay(undefined, undefined, options);
}
}
async updateLadder(p1score: number, winnerid: ID) {
this.room.rated = 0;
const winner = Users.get(winnerid);
if (winner && !winner.registered) {
this.room.sendUser(winner, '|askreg|' + winner.id);
}
const [score, p1rating, p2rating] = await Ladders(this.ladder).updateRating(
this.p1.name, this.p2.name, p1score, this.room
);
void this.logBattle(score, p1rating, p2rating);
Chat.runHandlers('onBattleRanked', this, winnerid, [p1rating, p2rating], [this.p1.id, this.p2.id]);
}
async logBattle(
p1score: number, p1rating: AnyObject | null = null, p2rating: AnyObject | null = null,
p3rating: AnyObject | null = null, p4rating: AnyObject | null = null
) {
if (Dex.formats.get(this.format, true).noLog) return;
const logData = this.logData;
if (!logData) return;
this.logData = null; // deallocate to save space
logData.log = this.room.getLog(-1).split('\n'); // replay log (exact damage)
// delete some redundant data
for (const rating of [p1rating, p2rating, p3rating, p4rating]) {
if (rating) {
delete rating.formatid;
delete rating.username;
delete rating.rpsigma;
delete rating.sigma;
}
}
logData.p1rating = p1rating;
if (this.replaySaved) logData.replaySaved = this.replaySaved;
logData.p2rating = p2rating;
if (this.playerCap > 2) {
logData.p3rating = p3rating;
logData.p4rating = p4rating;
}
logData.endType = this.endType;
if (!p1rating) logData.ladderError = true;
const date = new Date();
logData.timestamp = `${date}`;
logData.roomid = this.room.roomid;
logData.format = this.room.format;
const logsubfolder = Chat.toTimestamp(date).split(' ')[0];
const logfolder = logsubfolder.split('-', 2).join('-');
const tier = Dex.formats.get(this.room.format).id;
const logpath = `${logfolder}/${tier}/${logsubfolder}/`;
await Monitor.logPath(logpath).mkdirp();
await Monitor.logPath(`${logpath}${this.room.getReplayData().id}.log.json`).write(JSON.stringify(logData));
// console.log(JSON.stringify(logData));
}
override onConnect(user: User, connection: Connection | null = null) {
if (this.ended && this.room.parent?.game?.constructor.name === 'BestOfGame') {
const parentGame = this.room.parent.game as BestOfGame;
parentGame.playerTable[user.id]?.updateReadyButton();
}
// this handles joining a battle in which a user is a participant,
// where the user has already identified before attempting to join
// the battle
const player = this.playerTable[user.id];
if (!player) return;
player.updateChannel(connection || user);
const request = player.request;
if (request) {
let data = `|request|${request.request}`;
if (request.choice) data += `\n|sentchoice|${request.choice}`;
(connection || user).sendTo(this.roomid, data);
}
if (!this.started) {
this.sendInviteForm(connection || user);
}
if (!player.active) this.onJoin(user);
}
override onRename(user: User, oldUserid: ID, isJoining: boolean, isForceRenamed: boolean) {
if (user.id === oldUserid) return;
if (!this.playerTable) {
// !! should never happen but somehow still does
user.games.delete(this.roomid);
return;
}
if (!(oldUserid in this.playerTable)) {
if (user.id in this.playerTable) {
// this handles a user renaming themselves into a user in the
// battle (e.g. by using /nick)
this.onConnect(user);
}
return;
}
if (!this.allowRenames) {
const player = this.playerTable[oldUserid];
if (player) {
const message = isForceRenamed ? " lost by having an inappropriate name." : " forfeited by changing their name.";
this.forfeitPlayer(player, message);
}
if (!(user.id in this.playerTable)) {
user.games.delete(this.roomid);
}
return;
}
if (!user.named) {
this.onLeave(user, oldUserid);
return;
}
if (user.id in this.playerTable) return;
const player = this.playerTable[oldUserid];
if (player) {
this.updatePlayer(player, user);
}
const options = {
name: user.name,
avatar: user.avatar,
};
void this.stream.write(`>player ${player.slot} ` + JSON.stringify(options));
}
override onJoin(user: User) {
const player = this.playerTable[user.id];
if (player && !player.active) {
player.active = true;
this.timer.checkActivity();
this.room.add(`|player|${player.slot}|${user.name}|${user.avatar}|`);
Chat.runHandlers('onBattleJoin', player.slot, user, this);
}
}
override onLeave(user: User, oldUserid?: ID) {
const player = this.playerTable[oldUserid || user.id];
if (player?.active) {
player.sendRoom(`|request|null`);
player.active = false;
this.timer.checkActivity();
this.room.add(`|player|${player.slot}|`);
}
}
win(user: User) {
if (!user) {
this.tie();
return true;
}
const player = this.playerTable[user.id];
if (!player) return false;
void this.stream.write(`>forcewin ${player.slot}`);
}
tie() {
void this.stream.write(`>forcetie`);
}
tiebreak() {
void this.stream.write(`>tiebreak`);
}
override forfeit(user: User | string, message = '') {
if (typeof user !== 'string') user = user.id;
else user = toID(user);
if (!(user in this.playerTable)) return false;
return this.forfeitPlayer(this.playerTable[user], message);
}
forfeitPlayer(player: RoomBattlePlayer, message = '') {
if (this.ended || !this.started || player.eliminated) return false;
player.eliminated = true;
this.room.add(`|-message|${player.name}${message || ' forfeited.'}`);
this.endType = 'forfeit';
if (this.playerCap > 2) {
player.sendRoom(`|request|null`);
this.setPlayerUser(player, null);
}
void this.stream.write(`>forcelose ${player.slot}`);
return true;
}
/**
* playerOpts should be empty only if importing an inputlog
* (so the player isn't recreated)
*/
override addPlayer(user: User | string | null, playerOpts?: RoomBattlePlayerOptions | null) {
const player = super.addPlayer(user);
if (typeof user === 'string') user = null;
if (!player) return null;
const slot = player.slot;
this[slot] = player;
if (playerOpts) {
const options = {
name: player.name,
avatar: user ? `${user.avatar}` : '',
team: playerOpts.team || undefined,
rating: Math.round(playerOpts.rating || 0),
};
void this.stream.write(`>player ${slot} ${JSON.stringify(options)}`);
player.hasTeam = true;
}
if (user) {
this.room.auth.set(player.id, Users.PLAYER_SYMBOL);
}
if (user?.inRooms.has(this.roomid)) this.onConnect(user);
return player;
}
checkPrivacySettings(options: RoomBattleOptions & Partial<RoomSettings>) {
let inviteOnly = false;
const privacySetter = new Set<ID>([]);
for (const p of options.players) {
if (p.user) {
if (p.inviteOnly) {
inviteOnly = true;
privacySetter.add(p.user.id);
} else if (p.hidden) {
privacySetter.add(p.user.id);
}
this.checkForcedUserSettings(p.user);
}
}
if (privacySetter.size) {
const room = this.room;
if (this.forcedSettings.privacy) {
room.setPrivate(false);
room.settings.modjoin = null;
room.add(`|raw|<div class="broadcast-blue"><strong>This battle is required to be public due to a player having a name starting with '${this.forcedSettings.privacy}'.</div>`);
} else if (!options.tour || (room.tour?.allowModjoin)) {
room.setPrivate('hidden');
if (inviteOnly) room.settings.modjoin = '%';
room.privacySetter = privacySetter;
if (inviteOnly) {
room.settings.modjoin = '%';
room.add(`|raw|<div class="broadcast-red"><strong>This battle is invite-only!</strong><br />Users must be invited with <code>/invite</code> (or be staff) to join</div>`);
}
}
}
}
checkForcedUserSettings(user: User) {
this.forcedSettings = {
modchat: this.forcedSettings.modchat || RoomBattle.battleForcedSetting(user, 'modchat'),
privacy: !!this.options.rated && (this.forcedSettings.privacy || RoomBattle.battleForcedSetting(user, 'privacy')),
};
if (
this.players.some(p => p.getUser()?.battleSettings.special) ||
(this.rated && this.forcedSettings.modchat)
) {
this.room.settings.modchat = '\u2606';
}
}
static battleForcedSetting(user: User, key: 'modchat' | 'privacy') {
if (Config.forcedpublicprefixes) {
for (const prefix of Config.forcedpublicprefixes) {
Chat.plugins['username-prefixes']?.prefixManager.addPrefix(prefix, 'privacy');
}
delete Config.forcedpublicprefixes;
}
if (!Config.forcedprefixes) return null;
for (const { type, prefix } of Config.forcedprefixes) {
if (user.id.startsWith(toID(prefix)) && type === key) return prefix;
}
return null;
}
makePlayer(user: User) {
const num = (this.players.length + 1) as PlayerIndex;
return new RoomBattlePlayer(user, this, num);
}
override setPlayerUser(player: RoomBattlePlayer, user: User | null, playerOpts?: { team?: string }) {
if (user === null && this.room.auth.get(player.id) === Users.PLAYER_SYMBOL) {
this.room.auth.set(player.id, '+');
}
super.setPlayerUser(player, user);
player.invite = '';
const slot = player.slot;
if (user) {
player.active = user.inRooms.has(this.roomid);
player.knownActive = true;
const options = {
name: player.name,
avatar: user.avatar,
team: playerOpts?.team,
};
void this.stream.write(`>player ${slot} ` + JSON.stringify(options));
if (playerOpts) player.hasTeam = true;
this.room.add(`|player|${slot}|${player.name}|${user.avatar}|`);
Chat.runHandlers('onBattleJoin', slot as string, user, this);
} else {
player.active = false;
player.knownActive = false;
const options = {
name: '',
};
void this.stream.write(`>player ${slot} ` + JSON.stringify(options));
this.room.add(`|player|${slot}|`);
}
}
start() {
if (this.gameType === 'multi') {
this.room.title = `Team ${this.p1.name} vs. Team ${this.p2.name}`;
} else if (this.gameType === 'freeforall') {
// p1 vs. p2 vs. p3 vs. p4 is too long of a title
this.room.title = `${this.p1.name} and friends`;
} else {
this.room.title = `${this.p1.name} vs. ${this.p2.name}`;
}
this.room.send(`|title|${this.room.title}`);
const suspectTest = Chat.plugins['suspect-tests']?.suspectTests[this.format] ||
Chat.plugins['suspect-tests']?.suspectTests.suspects[this.format];
if (suspectTest) {
const format = Dex.formats.get(this.format);
this.room.add(
`|html|<div class="broadcast-blue"><strong>${format.name} is currently suspecting ${suspectTest.suspect}! ` +
`For information on how to participate check out the <a href="${suspectTest.url}">suspect thread</a>.</strong></div>`
).update();
}
// run onCreateBattleRoom handlers
if (this.options.inputLog && this.players.every(player => player.hasTeam)) {
// already started
this.started = true;
}
const delayStart = this.options.delayedStart || !!this.options.inputLog;
const users = this.players.map(player => {
const user = player.getUser();
if (!user && !delayStart) {
throw new Error(`User ${player.id} not found on ${this.roomid} battle creation`);
}
return user;
});
if (!delayStart) {
Rooms.global.onCreateBattleRoom(users as User[], this.room, { rated: this.rated });
this.started = true;
} else if (delayStart === 'multi') {
this.room.add(`|uhtml|invites|<div class="broadcast broadcast-blue"><strong>This is a 4-player challenge battle</strong><br />The players will need to add more players before the battle can start.</div>`);
}
}
invitesFull() {
return this.players.every(player => player.id || player.invite);
}
/** true = send to every player; falsy = send to no one */
sendInviteForm(connection: Connection | User | null | boolean) {
if (connection === true) {
for (const player of this.players) this.sendInviteForm(player.getUser());
return;
}
if (!connection) return;
const playerForms = this.players.map(player => (
player.id ? (
`<form><label>Player ${player.num}: <strong>${player.name}</strong></label></form>`
) : player.invite ? (
`<form data-submitsend="/msgroom ${this.roomid},/uninvitebattle ${player.invite}"><label>Player ${player.num}: <strong>${player.invite}</strong> (invited) <button>Uninvite</button></label></form>`
) : (
`<form data-submitsend="/msgroom ${this.roomid},/invitebattle {username}, p${player.num}"><label>Player ${player.num}: <input name="username" class="textbox" placeholder="Username" /></label> <button class="button">Add Player</button></form>`
)
));
if (this.gameType === 'multi') {
[playerForms[1], playerForms[2]] = [playerForms[2], playerForms[1]];
playerForms.splice(2, 0, '&mdash; vs &mdash;');
}
connection.sendTo(
this.room,
`|uhtmlchange|invites|<div class="broadcast broadcast-blue"><strong>This battle needs more players to start</strong><br /><br />${playerForms.join(``)}</div>`
);
}
override destroy() {
if (!this.ended) {
this.setEnded();
this.room.parent?.game?.onBattleWin?.(this.room, '');
}
for (const player of this.players) {
player.destroy();
}
this.playerTable = {};
this.players = [];
this.p1 = null!;
this.p2 = null!;
this.p3 = null!;
this.p4 = null!;
void this.stream.destroy();
if (this.active) {
Rooms.global.battleCount += -1;
this.active = false;
}
(this as any).room = null;
if (this.dataResolvers) {
for (const [, reject] of this.dataResolvers) {
// reject the promise, make whatever function called it return undefined
reject(new Error('Battle was destroyed.'));
}
}
}
async getTeam(user: User | string) {
// toID extracts user.id
const id = toID(user);
const player = this.playerTable[id];
if (!player) return;
return this.getPlayerTeam(player);
}
async getPlayerTeam(player: RoomBattlePlayer) {
void this.stream.write(`>requestteam ${player.slot}`);
const teamDataPromise = new Promise<string[]>((resolve, reject) => {
if (!this.dataResolvers) this.dataResolvers = [];
this.dataResolvers.push([resolve, reject]);
});
const resultStrings = await teamDataPromise;
if (!resultStrings) return;
const result = Teams.unpack(resultStrings[0]);
return result;
}
override onChatMessage(message: string, user: User) {
const parts = message.split('\n');
for (const line of parts) {
void this.stream.write(`>chat-inputlogonly ${user.getIdentity(this.room)}|${line}`);
}
}
async getLog(): Promise<string[] | void> {
if (!this.logData) this.logData = {};
void this.stream.write('>requestlog');
const logPromise = new Promise<string[]>((resolve, reject) => {
if (!this.dataResolvers) this.dataResolvers = [];
this.dataResolvers.push([resolve, reject]);
});
const result = await logPromise;
return result;
}
}
export class RoomBattleStream extends BattleStream {
override readonly battle: Battle;
constructor() {
super({ keepAlive: true });
this.battle = null!;
}
override _write(chunk: string) {
const startTime = Date.now();
if (this.battle && Config.debugsimprocesses && process.send) {
process.send('DEBUG\n' + this.battle.inputLog.join('\n') + '\n' + chunk);
}
try {
this._writeLines(chunk);
} catch (err: any) {
const battle = this.battle;
Monitor.crashlog(err, 'A battle', {
chunk,
inputLog: battle ? '\n' + battle.inputLog.join('\n') : '',
log: battle ? '\n' + battle.getDebugLog() : '',
});
this.push(`update\n|html|<div class="broadcast-red"><b>The battle crashed</b><br />Don't worry, we're working on fixing it.</div>`);
if (battle) {
for (const side of battle.sides) {
if (side?.requestState) {
this.push(`sideupdate\n${side.id}\n|error|[Invalid choice] The battle crashed`);
}
}
}
// public crashlogs only have the stack anyways
this.push(`error\n${err.stack}`);
}
if (this.battle) this.battle.sendUpdates();
const deltaTime = Date.now() - startTime;
if (deltaTime > 1000) {
Monitor.slow(`[slow battle] ${deltaTime}ms - ${chunk.replace(/\n/ig, ' | ')}`);
}
}
}
/*********************************************************
* Process manager
*********************************************************/
export const PM = new ProcessManager.StreamProcessManager(module, () => new RoomBattleStream(), message => {
if (message.startsWith(`SLOW\n`)) {
Monitor.slow(message.slice(5));
}
});
if (!PM.isParentProcess) {
// This is a child process!
require('source-map-support').install();
global.Config = require('./config-loader').Config;
global.Dex = require('../sim/dex').Dex;
global.Monitor = {
crashlog(error: Error, source = 'A simulator process', details: AnyObject | null = null) {
const repr = JSON.stringify([error.name, error.message, source, details]);
process.send!(`THROW\n@!!@${repr}\n${error.stack}`);
},
slow(text: string) {
process.send!(`CALLBACK\nSLOW\n${text}`);
},
};
global.__version = { head: '' };
try {
const head = execSync('git rev-parse HEAD', {
stdio: ['ignore', 'pipe', 'ignore'],
});
const merge = execSync('git merge-base origin/master HEAD', {
stdio: ['ignore', 'pipe', 'ignore'],
});
global.__version.head = `${head}`.trim();
const origin = `${merge}`.trim();
if (origin !== global.__version.head) global.__version.origin = origin;
} catch {}
if (Config.crashguard) {
// graceful crash - allow current battles to finish before restarting
process.on('uncaughtException', err => {
Monitor.crashlog(err, 'A simulator process');
});
process.on('unhandledRejection', err => {
Monitor.crashlog(err as any || {}, 'A simulator process Promise');
});
}
// eslint-disable-next-line no-eval
Repl.start(`sim-${process.pid}`, cmd => eval(cmd));
} else {
PM.spawn(global.Config ? Config.simulatorprocesses : 1);
}