pokemon-showdown/server/room-battle.ts

1313 lines
40 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 {FS} from "../lib/fs";
import {Utils} from '../lib/utils';
import {StreamProcessManager} from "../lib/process-manager";
import {Repl} from "../lib/repl";
import {BattleStream} from "../sim/battle-stream";
import * as RoomGames from "./room-game";
type ChannelIndex = 0 | 1 | 2 | 3 | 4;
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;
export class RoomBattlePlayer extends RoomGames.RoomGamePlayer {
readonly slot: SideID;
readonly channelIndex: ChannelIndex;
request: BattleRequestTracker;
wantsTie: boolean;
active: boolean;
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;
/**
* 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.
*/
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;
/**
* Used to track a user's last known connection status, and display
* the proper message when it changes.
*/
connected: 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.active = true;
this.eliminated = false;
this.secondsLeft = 1;
this.turnSecondsLeft = 1;
this.dcSecondsLeft = 1;
this.connected = true;
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);
}
}
}
}
getUser() {
return (this.id && Users.get(this.id)) || null;
}
unlinkUser() {
const user = this.getUser();
if (user) {
for (const connection of user.connections) {
Sockets.channelMove(connection.worker, this.game.roomid, 0, connection.socketid);
}
user.games.delete(this.game.roomid);
user.updateSearch();
}
this.id = '';
this.connected = false;
this.active = false;
}
updateChannel(user: User | Connection) {
if (user instanceof Users.Connection) {
// "user" is actually a connection
Sockets.channelMove(user.worker, this.game.roomid, this.channelIndex, user.socketid);
return;
}
for (const connection of user.connections) {
Sockets.channelMove(connection.worker, this.game.roomid, this.channelIndex, connection.socketid);
}
}
toString() {
return this.id;
}
send(data: string) {
const user = this.getUser();
if (user) user.send(data);
}
sendRoom(data: string) {
const user = this.getUser();
if (user) user.sendTo(this.game.roomid, data);
}
}
export class RoomBattleTimer {
readonly battle: RoomBattle;
readonly timerRequesters: Set<ID>;
timer: NodeJS.Timer | 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 hasLongTurns = Dex.getFormat(battle.format, true).gameType !== 'singles';
const isChallenge = (!battle.rated && !battle.room.tour);
const timerEntry = Dex.getRuleTable(Dex.getFormat(battle.format, true)).timer;
const timerSettings = timerEntry?.[0];
// so that Object.assign doesn't overwrite anything with `undefined`
for (const k in timerSettings) {
// @ts-ignore
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,
...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.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.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;
if (player) {
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.connected) {
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.connected && (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 isConnected = !!player?.active;
if (isConnected === player.connected) continue;
if (!isConnected) {
// player has disconnected
player.connected = 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.connected = 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.turnSecondsLeft > 0) continue;
if (this.settings.timeoutAutoChoose && player.secondsLeft > 0 && player.connected) {
void this.battle.stream.write(`>${player.slot} default`);
didSomething = true;
} else {
this.battle.forfeitPlayer(player, ' lost due to inactivity.');
return true;
}
}
return didSomething;
}
}
export class RoomBattle extends RoomGames.RoomGame {
readonly gameid: ID;
readonly room: GameRoom;
readonly title: string;
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>;
readonly timer: RoomBattleTimer;
missingBattleStartMessage: boolean;
started: boolean;
ended: boolean;
active: boolean;
replaySaved: boolean;
forcePublic: string | null = null;
playerTable: {[userid: string]: RoomBattlePlayer};
players: RoomBattlePlayer[];
p1: RoomBattlePlayer;
p2: RoomBattlePlayer;
p3: RoomBattlePlayer;
p4: RoomBattlePlayer;
inviteOnlySetter: ID | null;
logData: AnyObject | null;
endType: string;
/**
* If the battle is ended: an array of the number of Pokemon left for each side.
*/
score: number[] | null;
inputLog: string[] | null;
turn: number;
rqid: number;
requestCount: number;
dataResolvers?: [((args: string[]) => void), ((error: Error) => void)][];
constructor(room: GameRoom, formatid: string, options: AnyObject) {
super(room);
const format = Dex.getFormat(formatid, true);
this.gameid = 'battle' as ID;
this.room = room;
this.title = format.name;
if (!this.title.endsWith(" Battle")) this.title += " Battle";
this.allowRenames = options.allowRenames !== undefined ? !!options.allowRenames : (!options.rated && !options.tour);
this.format = formatid;
this.gameType = format.gameType;
this.challengeType = options.challengeType;
this.rated = options.rated || 0;
this.ladder = typeof format.rated === 'string' ? toID(format.rated) : formatid;
// true when onCreateBattleRoom has been called
this.missingBattleStartMessage = !!options.inputLog;
this.started = false;
this.ended = false;
this.active = false;
this.replaySaved = false;
// TypeScript bug: no `T extends RoomGamePlayer`
this.playerTable = Object.create(null);
// TypeScript bug: no `T extends RoomGamePlayer`
this.players = [];
this.playerCap = this.gameType === 'multi' || this.gameType === 'free-for-all' ? 4 : 2;
this.p1 = null!;
this.p2 = null!;
this.p3 = null!;
this.p4 = null!;
this.inviteOnlySetter = null!;
// data to be logged
this.allowExtraction = {};
this.logData = null;
this.endType = 'normal';
this.score = null;
this.inputLog = null;
this.turn = 0;
this.rqid = 1;
this.requestCount = 0;
this.stream = PM.createStream();
let ratedMessage = '';
if (options.ratedMessage) {
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();
this.addPlayer(options.p1, options.p1team || '', options.p1rating);
this.addPlayer(options.p2, options.p2team || '', options.p2rating);
if (this.playerCap > 2) {
this.addPlayer(options.p3, options.p3team || '', options.p3rating);
this.addPlayer(options.p4, options.p4team || '', options.p4rating);
}
this.timer = new RoomBattleTimer(this);
if (Config.forcetimer || this.format.includes('blitz')) this.timer.start();
this.start();
}
checkActive() {
let active = true;
if (this.ended || !this.started) {
active = false;
} else if (!this.p1 || !this.p1.active) {
active = false;
} else if (!this.p2 || !this.p2.active) {
active = false;
} else if (this.playerCap > 2) {
if (!this.p3 || !this.p3.active) {
active = false;
} else if (!this.p4 || !this.p4.active) {
active = false;
}
}
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();
}
choose(user: User, data: string) {
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}`);
}
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`);
}
joinGame(user: User, slot?: SideID) {
if (!user.can('joinbattle', null, this.room)) {
user.popup(`You must be a 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;
}
if (user.id in this.playerTable) {
user.popup(`You have already joined this battle.`);
return false;
}
const validSlots: SideID[] = [];
for (const player of this.players) {
if (!player.id) validSlots.push(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 two players.`);
return false;
}
if (!slot && validSlots.length > 1) {
user.popup(`Which slot would you like to join into? Use something like \`/joingame ${validSlots[0]}\``);
return false;
}
if (!slot) slot = validSlots[0];
this.updatePlayer(this[slot], user);
if (validSlots.length - 1 < 1 && this.missingBattleStartMessage) {
const users = this.players.map(player => {
const u = player.getUser();
if (!u) throw new Error(`User ${player.name} not found on ${this.roomid} battle creation`);
return u;
});
Rooms.global.onCreateBattleRoom(users, this.room, {rated: this.rated});
this.missingBattleStartMessage = false;
}
if (user.inRooms.has(this.roomid)) this.onConnect(user);
this.room.update();
return true;
}
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;
}
this.updatePlayer(player, null);
this.room.auth.set(user.id, '+');
this.room.update();
return true;
}
async listen() {
let disconnected = false;
try {
for await (const next of this.stream) {
this.receive(next.split('\n'));
}
} catch (err) {
// 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.ended = true;
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 `)) {
if (Config.allowrequestingties) 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 request = this[slot].request;
request.isWait = 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++;
if (player) player.sendRoom(`|request|${requestJSON}`);
break;
}
if (player) player.sendRoom(lines[2]);
break;
}
case 'end':
this.logData = JSON.parse(lines[1]);
this.score = this.logData!.score;
this.inputLog = this.logData!.inputLog;
this.started = true;
if (!this.ended) {
this.ended = true;
void this.onEnd(this.logData!.winner);
this.clearPlayers();
}
this.checkActive();
break;
}
}
async onEnd(winner: any) {
this.timer.end();
// Declare variables here in case we need them for non-rated battles logging.
let p1score = 0.5;
const winnerid = toID(winner);
// Check if the battle was rated to update the ladder, return its response, and log the battle.
const p1name = this.p1.name;
const p2name = this.p2.name;
const p1id = toID(p1name);
const p2id = toID(p2name);
if (this.room.rated) {
this.room.rated = 0;
if (winnerid === p1id) {
p1score = 1;
} else if (winnerid === p2id) {
p1score = 0;
}
winner = Users.get(winnerid);
if (winner && !winner.registered) {
this.room.sendUser(winner, '|askreg|' + winner.id);
}
const [score, p1rating, p2rating] = await Ladders(this.ladder).updateRating(p1name, p2name, p1score, this.room);
void this.logBattle(score, p1rating, p2rating);
} else if (Config.logchallenges) {
if (winnerid === p1id) {
p1score = 1;
} else if (winnerid === p2id) {
p1score = 0;
}
void this.logBattle(p1score);
} else {
this.logData = null;
}
// 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 uploader = Users.get(winnerid || p1id);
if (uploader?.connections[0]) {
Chat.parse('/savereplay silent', this.room, uploader, uploader.connections[0]);
}
}
const parentGame = this.room.parent && this.room.parent.game;
// @ts-ignore - Tournaments aren't TS'd yet
if (parentGame?.onBattleWin) {
// @ts-ignore
parentGame.onBattleWin(this.room, winnerid);
}
// If the room's replay was hidden, disable users from joining after the game is over
if (this.room.hideReplay) {
this.room.settings.modjoin = '%';
this.room.setPrivate('hidden');
}
this.room.update();
}
async logBattle(
p1score: number, p1rating: AnyObject | null = null, p2rating: AnyObject | null = null,
p3rating: AnyObject | null = null, p4rating: AnyObject | null = null
) {
if (Dex.getFormat(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;
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 = this.room.format.toLowerCase().replace(/[^a-z0-9]+/g, '');
const logpath = `logs/${logfolder}/${tier}/${logsubfolder}/`;
await FS(logpath).mkdirp();
await FS(`${logpath}${this.room.getReplayData().id}.log.json`).write(JSON.stringify(logData));
// console.log(JSON.stringify(logData));
}
onConnect(user: User, connection: Connection | null = null) {
// 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 (!player.active) this.onJoin(user);
}
onUpdateConnection(user: User, connection: Connection | null = null) {
this.onConnect(user, connection);
}
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));
}
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}`);
}
}
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`);
}
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) return false;
if (!message) message = ' forfeited.';
this.room.add(`|-message|${player.name}${message}`);
this.endType = 'forfeit';
const otherids = ['p2', 'p1'];
void this.stream.write(`>forcewin ${otherids[player.num - 1]}`);
return true;
}
/**
* Team should be '' for random teams. `null` should be used only if importing
* an inputlog (so the player isn't recreated)
*/
addPlayer(user: User | null, team: string | null, rating = 0) {
// TypeScript bug: no `T extends RoomGamePlayer`
const player = super.addPlayer(user) as RoomBattlePlayer;
if (!player) return null;
const slot = player.slot;
this[slot] = player;
if (team !== null) {
const options = {
name: player.name,
avatar: user ? '' + user.avatar : '',
team,
rating: Math.round(rating),
};
void this.stream.write(`>player ${slot} ${JSON.stringify(options)}`);
}
if (user) {
this.room.auth.set(player.id, Users.PLAYER_SYMBOL);
if (this.rated && !this.forcePublic) {
this.forcePublic = user.battlesForcedPublic();
}
}
if (user?.inRooms.has(this.roomid)) this.onConnect(user);
return player;
}
makePlayer(user: User) {
const num = (this.players.length + 1) as PlayerIndex;
return new RoomBattlePlayer(user, this, num);
}
updatePlayer(player: RoomBattlePlayer, user: User | null) {
super.updatePlayer(player, user);
const slot = player.slot;
if (user) {
const options = {
name: player.name,
avatar: user.avatar,
};
void this.stream.write(`>player ${slot} ` + JSON.stringify(options));
this.room.add(`|player|${slot}|${player.name}|${user.avatar}`);
} else {
const options = {
name: '',
};
void this.stream.write(`>player ${slot} ` + JSON.stringify(options));
this.room.add(`|player|${slot}|`);
}
}
start() {
// on start
this.started = true;
const users = this.players.map(player => {
const user = player.getUser();
if (!user && !this.missingBattleStartMessage) {
throw new Error(`User ${player.name} not found on ${this.roomid} battle creation`);
}
return user;
});
if (!this.missingBattleStartMessage) {
// @ts-ignore The above error should throw if null is found, or this should be skipped
Rooms.global.onCreateBattleRoom(users, this.room, {rated: this.rated});
}
if (this.gameType === 'multi') {
this.room.title = `Team ${this.p1.name} vs. Team ${this.p2.name}`;
} else if (this.gameType === 'free-for-all') {
// 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];
if (suspectTest) {
const format = Dex.getFormat(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();
}
}
clearPlayers() {
for (const player of this.players) {
player.unlinkUser();
}
}
destroy() {
for (const player of this.players) {
player.destroy();
}
this.playerTable = {};
this.players = [];
// @ts-ignore
this.p1 = null;
// @ts-ignore
this.p2 = null;
// @ts-ignore
this.p3 = null;
// @ts-ignore
this.p4 = null;
this.ended = true;
void this.stream.destroy();
if (this.active) {
Rooms.global.battleCount += -1;
this.active = false;
}
// @ts-ignore
this.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) {
const id = user.id;
const player = this.playerTable[id];
if (!player) return;
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 = resultStrings.map(item => Dex.fastUnpackTeam(item))[0];
return result;
}
onChatMessage(message: string, user: User) {
void this.stream.write(`>chat-inputlogonly ${user.getIdentity(this.room.roomid)}|${message}`);
}
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 {
readonly battle: Battle;
constructor() {
super({keepAlive: true});
this.battle = null!;
}
_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) {
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`);
}
}
}
}
if (this.battle) this.battle.sendUpdates();
const deltaTime = Date.now() - startTime;
if (deltaTime > 1000) {
console.log(`[slow battle] ${deltaTime}ms - ${chunk}`);
}
}
_writeLine(type: string, message: string) {
switch (type) {
case 'chat-inputlogonly':
this.battle.inputLog.push(`>chat ${message}`);
break;
case 'chat':
this.battle.inputLog.push(`>chat ${message}`);
this.battle.add('chat', `${message}`);
break;
case 'requestlog':
this.push(`requesteddata\n${this.battle.inputLog.join('\n')}`);
break;
case 'eval':
const battle = this.battle;
battle.inputLog.push(`>${type} ${message}`);
message = message.replace(/\f/g, '\n');
battle.add('', '>>> ' + message.replace(/\n/g, '\n||'));
try {
/* eslint-disable no-eval, @typescript-eslint/no-unused-vars */
const p1 = battle?.sides[0];
const p2 = battle?.sides[1];
const p3 = battle?.sides[2];
const p4 = battle?.sides[3];
const p1active = p1?.active[0];
const p2active = p2?.active[0];
const p3active = p3?.active[0];
const p4active = p4?.active[0];
let result = eval(message);
/* eslint-enable no-eval, @typescript-eslint/no-unused-vars */
if (result?.then) {
result.then((unwrappedResult: any) => {
unwrappedResult = Utils.visualize(unwrappedResult);
battle.add('', 'Promise -> ' + unwrappedResult);
battle.sendUpdates();
}, (error: Error) => {
battle.add('', '<<< error: ' + error.message);
battle.sendUpdates();
});
} else {
result = Utils.visualize(result);
result = result.replace(/\n/g, '\n||');
battle.add('', '<<< ' + result);
}
} catch (e) {
battle.add('', '<<< error: ' + e.message);
}
break;
case 'requestteam':
message = message.trim();
const slotNum = parseInt(message.slice(1)) - 1;
if (isNaN(slotNum) || slotNum < 0) {
throw new Error(`Team requested for slot ${message}, but that slot does not exist.`);
}
const side = this.battle.sides[slotNum];
const team = Dex.packTeam(side.team);
this.push(`requesteddata\n${team}`);
break;
default: super._writeLine(type, message);
}
}
}
/*********************************************************
* Process manager
*********************************************************/
export const PM = new StreamProcessManager(module, () => new RoomBattleStream());
if (!PM.isParentProcess) {
// This is a child process!
global.Config = require('./config-loader').Config;
global.Chat = require('./chat').Chat;
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}`);
},
};
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 (e) {}
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);
}