mirror of
https://github.com/smogon/pokemon-showdown.git
synced 2026-06-02 22:08:36 -05:00
PS prefers to keep battle streams open (for `/evalbattle`) until manually ended, but this is confusing some other users. We now automatically end streams unless the `keepAlive` option is set. Fixes #5157
1075 lines
32 KiB
JavaScript
1075 lines
32 KiB
JavaScript
/**
|
|
* 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
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const FS = require('../lib/fs');
|
|
|
|
/** 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;
|
|
|
|
class BattlePlayer {
|
|
/**
|
|
* @param {User} user
|
|
* @param {Battle} game
|
|
* @param {PlayerSlot} slot
|
|
*/
|
|
constructor(user, game, slot) {
|
|
this.userid = user.userid;
|
|
this.name = user.name;
|
|
this.game = game;
|
|
user.games.add(this.game.id);
|
|
user.updateSearch();
|
|
|
|
this.slot = slot;
|
|
this.slotNum = Number(slot.charAt(1)) - 1;
|
|
this.active = true;
|
|
|
|
for (const connection of user.connections) {
|
|
if (connection.inRooms.has(game.id)) {
|
|
Sockets.channelMove(connection.worker, this.game.id, this.slotNum + 1, connection.socketid);
|
|
}
|
|
}
|
|
}
|
|
destroy() {
|
|
let user = Users(this.userid);
|
|
if (user) {
|
|
for (const connection of user.connections) {
|
|
Sockets.channelMove(connection.worker, this.game.id, 0, connection.socketid);
|
|
}
|
|
user.games.delete(this.game.id);
|
|
user.updateSearch();
|
|
}
|
|
this.game[this.slot] = null;
|
|
}
|
|
updateChannel(/** @type {User | Connection} */ user) {
|
|
if (user instanceof Users.Connection) {
|
|
// "user" is actually a connection
|
|
Sockets.channelMove(user.worker, this.game.id, this.slotNum + 1, user.socketid);
|
|
return;
|
|
}
|
|
for (const connection of user.connections) {
|
|
Sockets.channelMove(connection.worker, this.game.id, this.slotNum + 1, connection.socketid);
|
|
}
|
|
}
|
|
|
|
toString() {
|
|
return this.userid;
|
|
}
|
|
send(/** @type {string} */ data) {
|
|
let user = Users(this.userid);
|
|
if (user) user.send(data);
|
|
}
|
|
sendRoom(/** @type {string} */ data) {
|
|
let user = Users(this.userid);
|
|
if (user) user.sendTo(this.game.id, data);
|
|
}
|
|
}
|
|
|
|
class BattleTimer {
|
|
/**
|
|
* @param {Battle} battle
|
|
*/
|
|
constructor(battle) {
|
|
/** @type {Battle} */
|
|
this.battle = battle;
|
|
|
|
/** @type {NodeJS.Timer?} */
|
|
this.timer = null;
|
|
/** @type {Set<string>} */
|
|
this.timerRequesters = new Set();
|
|
/**
|
|
* 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.
|
|
*
|
|
* @type {number[]}
|
|
*/
|
|
this.secondsLeft = [];
|
|
/**
|
|
* 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.
|
|
*
|
|
* @type {number[]}
|
|
*/
|
|
this.turnSecondsLeft = [];
|
|
/**
|
|
* 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.
|
|
*
|
|
* @type {number[]}
|
|
*/
|
|
this.dcSecondsLeft = [];
|
|
/**
|
|
* Used to track a user's last known connection status, and display
|
|
* the proper message when it changes.
|
|
* @type {boolean[]}
|
|
*/
|
|
this.connected = [];
|
|
|
|
/**
|
|
* Last tick, as milliseconds since UNIX epoch.
|
|
* Represents the last time a tick happened.
|
|
*/
|
|
this.lastTick = 0;
|
|
|
|
/** Debug mode; true to output detailed timer info every tick */
|
|
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 timerSettings = Dex.getFormat(battle.format, true).timer;
|
|
|
|
// so that Object.assign doesn't overwrite anything with `undefined`
|
|
for (const k in timerSettings) {
|
|
// @ts-ignore
|
|
if (timerSettings[k] === undefined) delete timerSettings[k];
|
|
}
|
|
|
|
/** @type {GameTimerSettings} */
|
|
this.settings = Object.assign({
|
|
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 (let slotNum = 0; slotNum < 2; slotNum++) {
|
|
this.secondsLeft.push(this.settings.starting + this.settings.grace);
|
|
this.turnSecondsLeft.push(-1);
|
|
this.dcSecondsLeft.push(this.settings.dcTimerBank ? DISCONNECTION_BANK_TIME : DISCONNECTION_TIME);
|
|
this.connected.push(true);
|
|
}
|
|
}
|
|
start(/** @type {User} */ requester) {
|
|
let userid = requester ? requester.userid : 'staff';
|
|
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.players[requester.userid] && this.lastDisabledByUser === requester.userid) {
|
|
const remainingCooldownMs = (this.lastDisabledTime || 0) + TIMER_COOLDOWN - Date.now();
|
|
if (remainingCooldownMs > 0) {
|
|
this.battle.players[requester.userid].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);
|
|
this.nextRequest(true);
|
|
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();
|
|
return true;
|
|
}
|
|
stop(/** @type {User} */ requester) {
|
|
if (requester) {
|
|
if (!this.timerRequesters.has(requester.userid)) return false;
|
|
this.timerRequesters.delete(requester.userid);
|
|
this.lastDisabledByUser = requester.userid;
|
|
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.timer) return false;
|
|
clearTimeout(this.timer);
|
|
this.timer = null;
|
|
this.battle.room.add(`|inactiveoff|Battle timer is now OFF.`).update();
|
|
return true;
|
|
}
|
|
waitingForChoice(/** @type {PlayerSlot} */ slot) {
|
|
return !this.battle.requests[slot].isWait;
|
|
}
|
|
nextRequest(isFirst = false) {
|
|
if (this.timer) clearTimeout(this.timer);
|
|
if (!this.timerRequesters.size) return;
|
|
if (this.secondsLeft.some(t => t <= 0)) return;
|
|
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;
|
|
}
|
|
// after turn 200ish: 10s/turn -> 7s/turn
|
|
if (this.battle.requestCount > 400 && this.battle.requestCount % 2) {
|
|
addPerTurn = 0;
|
|
}
|
|
}
|
|
|
|
const room = this.battle.room;
|
|
for (const slotNum of this.secondsLeft.keys()) {
|
|
const slot = /** @type {PlayerSlot} */ ('p' + (slotNum + 1));
|
|
const player = this.battle[slot];
|
|
const playerName = this.battle.playerNames[slotNum];
|
|
|
|
if (!isFirst) {
|
|
this.secondsLeft[slotNum] = Math.min(this.secondsLeft[slotNum] + addPerTurn, this.settings.starting);
|
|
}
|
|
this.turnSecondsLeft[slotNum] = Math.min(this.secondsLeft[slotNum], maxTurnTime);
|
|
|
|
const secondsLeft = this.turnSecondsLeft[slotNum];
|
|
let grace = this.secondsLeft[slotNum] - this.settings.starting;
|
|
if (grace < 0) grace = 0;
|
|
if (player) player.sendRoom(`|inactive|Time left: ${secondsLeft} sec this turn | ${this.secondsLeft[slotNum] - grace} sec total` + (grace ? ` | ${grace} sec grace` : ``));
|
|
if (secondsLeft <= 30) {
|
|
room.add(`|inactive|${playerName} has ${secondsLeft} seconds left this turn.`);
|
|
}
|
|
if (this.debug) {
|
|
room.add(`||${playerName} | Time left: ${secondsLeft} sec this turn | ${this.secondsLeft[slotNum]} 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 slotNum of this.secondsLeft.keys()) {
|
|
const slot = /** @type {PlayerSlot} */ ('p' + (slotNum + 1));
|
|
const connected = this.connected[slotNum];
|
|
|
|
if (!this.waitingForChoice(slot)) continue;
|
|
if (connected) {
|
|
this.secondsLeft[slotNum] -= TICK_TIME;
|
|
this.turnSecondsLeft[slotNum] -= TICK_TIME;
|
|
} else {
|
|
this.dcSecondsLeft[slotNum] -= TICK_TIME;
|
|
if (!this.settings.dcTimerBank) {
|
|
this.secondsLeft[slotNum] -= TICK_TIME;
|
|
this.turnSecondsLeft[slotNum] -= TICK_TIME;
|
|
}
|
|
}
|
|
|
|
let dcSecondsLeft = this.dcSecondsLeft[slotNum];
|
|
if (dcSecondsLeft <= 0) this.turnSecondsLeft[slotNum] = 0;
|
|
const secondsLeft = this.turnSecondsLeft[slotNum];
|
|
if (!secondsLeft) continue;
|
|
|
|
if (!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|${this.battle.playerNames[slotNum]} has ${dcSecondsLeft} seconds to reconnect!`);
|
|
}
|
|
} else {
|
|
// regular turn timer shown
|
|
if (secondsLeft % 30 === 0 || secondsLeft <= 20) {
|
|
room.add(`|inactive|${this.battle.playerNames[slotNum]} has ${secondsLeft} seconds left.`);
|
|
}
|
|
}
|
|
}
|
|
if (this.debug) {
|
|
room.add(`||[${this.battle.playerNames[0]} has ${this.turnSecondsLeft[0]}s this turn / ${this.secondsLeft[0]}s total]`);
|
|
room.add(`||[${this.battle.playerNames[0]} has ${this.turnSecondsLeft[0]}s this turn / ${this.secondsLeft[0]}s total]`);
|
|
}
|
|
room.update();
|
|
if (!this.checkTimeout()) {
|
|
this.timer = setTimeout(() => this.nextTick(), TICK_TIME * 1000);
|
|
}
|
|
}
|
|
checkActivity() {
|
|
for (const slotNum of this.secondsLeft.keys()) {
|
|
const slot = /** @type {PlayerSlot} */ ('p' + (slotNum + 1));
|
|
const player = this.battle[slot];
|
|
const isConnected = !!(player && player.active);
|
|
|
|
if (isConnected === this.connected[slotNum]) continue;
|
|
|
|
if (!isConnected) {
|
|
// player has disconnected
|
|
this.connected[slotNum] = false;
|
|
if (!this.settings.dcTimerBank) {
|
|
// don't wait longer than 6 ticks (1 minute)
|
|
if (this.settings.dcTimer) {
|
|
this.dcSecondsLeft[slotNum] = DISCONNECTION_TIME;
|
|
} else {
|
|
// arbitrary large number
|
|
this.dcSecondsLeft[slotNum] = 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 (this.dcSecondsLeft[slotNum] > 0) {
|
|
msg = ` and has ${this.dcSecondsLeft[slotNum]} seconds to reconnect!`;
|
|
} else {
|
|
msg = ` and has no disconnection time left!`;
|
|
}
|
|
}
|
|
this.battle.room.add(`|inactive|${this.battle.playerNames[slotNum]} disconnected${msg}`).update();
|
|
}
|
|
} else {
|
|
// player has reconnected
|
|
this.connected[slotNum] = true;
|
|
if (this.timerRequesters.size) {
|
|
let timeLeft = ``;
|
|
if (this.waitingForChoice(slot)) {
|
|
timeLeft = ` and has ${this.turnSecondsLeft[slotNum]} seconds left`;
|
|
}
|
|
this.battle.room.add(`|inactive|${this.battle.playerNames[slotNum]} reconnected${timeLeft}.`).update();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
checkTimeout() {
|
|
if (this.turnSecondsLeft.every(t => t <= 0)) {
|
|
if (!this.settings.timeoutAutoChoose || this.secondsLeft.every(t => t <= 0)) {
|
|
this.battle.room.add(`|-message|All players are inactive.`).update();
|
|
this.battle.tie();
|
|
return true;
|
|
}
|
|
}
|
|
let didSomething = false;
|
|
for (const [slotNum, seconds] of this.turnSecondsLeft.entries()) {
|
|
if (seconds) continue;
|
|
if (this.settings.timeoutAutoChoose && this.secondsLeft[slotNum] > 0 && this.connected[slotNum]) {
|
|
const slot = 'p' + (slotNum + 1);
|
|
this.battle.stream.write(`>${slot} default`);
|
|
didSomething = true;
|
|
} else {
|
|
this.battle.forfeitSlot(slotNum, ' lost due to inactivity.');
|
|
return true;
|
|
}
|
|
}
|
|
return didSomething;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @typedef {object} BattleRequestTracker
|
|
* @property {number} rqid
|
|
* @property {string} request
|
|
* @property {'cantUndo' | true | false} isWait true = user has decided, false = user has yet to decide, 'cantUndo' = waiting on other user (U-turn, faint-switch) or uncancellable (trapping ability)
|
|
* @property {string} choice
|
|
*/
|
|
|
|
class Battle {
|
|
/**
|
|
* @param {GameRoom} room
|
|
* @param {string} formatid
|
|
* @param {AnyObject} options
|
|
*/
|
|
constructor(room, formatid, options) {
|
|
let format = Dex.getFormat(formatid, true);
|
|
this.gameid = 'battle';
|
|
this.id = room.id;
|
|
/** @type {GameRoom} */
|
|
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;
|
|
/**
|
|
* The lower player's rating, for searching purposes.
|
|
* 0 for unrated battles. 1 for unknown ratings.
|
|
* @type {number}
|
|
*/
|
|
this.rated = options.rated || 0;
|
|
this.started = false;
|
|
this.ended = false;
|
|
this.active = false;
|
|
|
|
/** @type {{[userid: string]: BattlePlayer}} */
|
|
this.players = Object.create(null);
|
|
this.playerCount = 0;
|
|
this.playerCap = 2;
|
|
/** @type {BattlePlayer?} */
|
|
this.p1 = null;
|
|
/** @type {BattlePlayer?} */
|
|
this.p2 = null;
|
|
|
|
/**
|
|
* p1 and p2 may be null in unrated games, but playerNames retains
|
|
* the most recent usernames in those slots, for use by various
|
|
* functions that need names for the slots.
|
|
*/
|
|
this.playerNames = ["Player 1", "Player 2"];
|
|
/**
|
|
* @type {{p1: BattleRequestTracker, p2: BattleRequestTracker}}
|
|
*/
|
|
this.requests = {
|
|
p1: /** @type {BattleRequestTracker} */ ({rqid: 0, request: '', isWait: 'cantUndo', choice: ''}),
|
|
p2: /** @type {BattleRequestTracker} */ ({rqid: 0, request: '', isWait: 'cantUndo', choice: ''}),
|
|
};
|
|
this.timer = new BattleTimer(this);
|
|
|
|
// data to be logged
|
|
/**
|
|
* Has this player consented to input log export? If so, set this
|
|
* to the userid allowed to export.
|
|
* @type {[string, string]?}
|
|
*/
|
|
this.allowExtraction = null;
|
|
|
|
this.logData = null;
|
|
this.endType = 'normal';
|
|
this.score = null;
|
|
this.inputLog = null;
|
|
|
|
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';
|
|
}
|
|
|
|
// @ts-ignore
|
|
this.room.game = this;
|
|
this.room.battle = this;
|
|
|
|
let battleOptions = {
|
|
formatid: this.format,
|
|
id: this.id,
|
|
rated: ratedMessage,
|
|
seed: options.seed,
|
|
};
|
|
if (options.inputLog) {
|
|
this.stream.write(options.inputLog);
|
|
} else {
|
|
this.stream.write(`>start ` + JSON.stringify(battleOptions));
|
|
}
|
|
if (Config.forcetimer) this.timer.start();
|
|
|
|
this.listen();
|
|
|
|
if (options.p1) this.addPlayer(options.p1, 'p1', options.p1team, true);
|
|
if (options.p2) this.addPlayer(options.p2, 'p2', options.p2team, true);
|
|
}
|
|
|
|
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;
|
|
}
|
|
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();
|
|
}
|
|
/**
|
|
* @param {User} user
|
|
* @param {string} data
|
|
*/
|
|
choose(user, data) {
|
|
const player = this.players[user.userid];
|
|
const [choice, rqid] = data.split('|', 2);
|
|
if (!player) return;
|
|
let request = this.requests[player.slot];
|
|
if (request.isWait !== false && request.isWait !== true) {
|
|
player.sendRoom(`|error|[Invalid choice] There's nothing to choose`);
|
|
return;
|
|
}
|
|
if ((this.requests.p1.isWait && this.requests.p2.isWait) || // 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;
|
|
}
|
|
user.lastDecision = Date.now();
|
|
request.isWait = true;
|
|
request.choice = choice;
|
|
|
|
this.stream.write(`>${player.slot} ${choice}`);
|
|
}
|
|
/**
|
|
* @param {User} user
|
|
* @param {string} data
|
|
*/
|
|
undo(user, data) {
|
|
const player = this.players[user.userid];
|
|
const [, rqid] = data.split('|', 2);
|
|
if (!player) return;
|
|
let request = this.requests[player.slot];
|
|
if (request.isWait !== true) {
|
|
player.sendRoom(`|error|[Invalid choice] There's nothing to cancel`);
|
|
return;
|
|
}
|
|
if ((this.requests.p1.isWait && this.requests.p2.isWait) || // 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;
|
|
|
|
this.stream.write(`>${player.slot} undo`);
|
|
}
|
|
/**
|
|
* @param {User} user
|
|
*/
|
|
joinGame(user) {
|
|
if (this.playerCount >= 2) {
|
|
user.popup(`This battle already has two players.`);
|
|
return false;
|
|
}
|
|
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 (!this.addPlayer(user)) {
|
|
user.popup(`Failed to join battle.`);
|
|
return false;
|
|
}
|
|
this.room.update();
|
|
return true;
|
|
}
|
|
/**
|
|
* @param {User} user
|
|
*/
|
|
leaveGame(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;
|
|
}
|
|
if (!this.removePlayer(user)) {
|
|
user.popup(`Failed to leave battle.`);
|
|
return false;
|
|
}
|
|
this.room.auth[user.userid] = '+';
|
|
this.room.update();
|
|
return true;
|
|
}
|
|
|
|
async listen() {
|
|
let next;
|
|
while ((next = await this.stream.read())) {
|
|
this.receive(next.split('\n'));
|
|
}
|
|
if (!this.ended) {
|
|
this.room.add(`|bigerror|The simulator process has crashed. We've been notified and will fix this ASAP.`);
|
|
Monitor.crashlog(new Error(`Process disconnected`), `A battle`);
|
|
this.started = true;
|
|
this.ended = true;
|
|
this.checkActive();
|
|
}
|
|
}
|
|
receive(/** @type {string[]} */ lines) {
|
|
switch (lines[0]) {
|
|
case 'update':
|
|
for (const line of lines.slice(1)) {
|
|
this.room.add(line);
|
|
}
|
|
this.room.update();
|
|
if (!this.ended) this.timer.nextRequest();
|
|
this.checkActive();
|
|
break;
|
|
|
|
case 'sideupdate': {
|
|
let slot = /** @type {PlayerSlot} */ (lines[1]);
|
|
let 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]`)) {
|
|
let request = this.requests[slot];
|
|
request.isWait = false;
|
|
request.choice = '';
|
|
} else if (lines[2].startsWith(`|request|`)) {
|
|
this.rqid++;
|
|
let request = JSON.parse(lines[2].slice(9));
|
|
request.rqid = this.rqid;
|
|
const requestJSON = JSON.stringify(request);
|
|
this.requests[slot] = {
|
|
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;
|
|
this.onEnd(this.logData.winner);
|
|
this.removeAllPlayers();
|
|
}
|
|
this.checkActive();
|
|
break;
|
|
}
|
|
}
|
|
/**
|
|
* @param {any} winner
|
|
*/
|
|
async onEnd(winner) {
|
|
// 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.
|
|
let p1name = this.playerNames[0];
|
|
let p2name = this.playerNames[1];
|
|
let p1id = toId(p1name);
|
|
let 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.userid);
|
|
}
|
|
const [score, p1rating, p2rating] = await Ladders(this.format).updateRating(p1name, p2name, p1score, this.room);
|
|
this.logBattle(score, p1rating, p2rating);
|
|
} else if (Config.logchallenges) {
|
|
if (winnerid === p1id) {
|
|
p1score = 1;
|
|
} else if (winnerid === p2id) {
|
|
p1score = 0;
|
|
}
|
|
this.logBattle(p1score);
|
|
} else {
|
|
this.logData = null;
|
|
}
|
|
if (Config.autosavereplays) {
|
|
let uploader = Users.get(winnerid || p1id);
|
|
if (uploader && uploader.connections[0]) {
|
|
Chat.parse('/savereplay', this.room, uploader, uploader.connections[0]);
|
|
}
|
|
}
|
|
const parentGame = this.room.parent && this.room.parent.game;
|
|
if (parentGame && parentGame.onBattleWin) {
|
|
parentGame.onBattleWin(this.room, winnerid);
|
|
}
|
|
this.room.update();
|
|
}
|
|
/**
|
|
* @param {number} p1score
|
|
* @param {AnyObject?} p1rating
|
|
* @param {AnyObject?} p2rating
|
|
*/
|
|
async logBattle(p1score, p1rating = null, p2rating = null) {
|
|
if (Dex.getFormat(this.format, true).noLog) return;
|
|
let logData = this.logData;
|
|
if (!logData) return;
|
|
this.logData = null; // deallocate to save space
|
|
logData.log = this.room.getLog(3).split('\n'); // replay log (exact damage)
|
|
|
|
// delete some redundant data
|
|
if (p1rating) {
|
|
delete p1rating.formatid;
|
|
delete p1rating.username;
|
|
delete p1rating.rpsigma;
|
|
delete p1rating.sigma;
|
|
}
|
|
if (p2rating) {
|
|
delete p2rating.formatid;
|
|
delete p2rating.username;
|
|
delete p2rating.rpsigma;
|
|
delete p2rating.sigma;
|
|
}
|
|
|
|
logData.p1rating = p1rating;
|
|
logData.p2rating = p2rating;
|
|
logData.endType = this.endType;
|
|
if (!p1rating) logData.ladderError = true;
|
|
const date = new Date();
|
|
logData.timestamp = '' + date;
|
|
logData.id = this.room.id;
|
|
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.id + '.log.json').write(JSON.stringify(logData));
|
|
//console.log(JSON.stringify(logData));
|
|
}
|
|
/**
|
|
* @param {User} user
|
|
* @param {Connection?} connection
|
|
*/
|
|
onConnect(user, connection = 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.players[user.userid];
|
|
if (!player) return;
|
|
player.updateChannel(connection || user);
|
|
const request = this.requests[player.slot];
|
|
if (request) {
|
|
let data = `|request|${request.request}`;
|
|
if (request.choice) data += `\n|sentchoice|${request.choice}`;
|
|
(connection || user).sendTo(this.id, data);
|
|
}
|
|
if (!player.active) this.onJoin(user);
|
|
}
|
|
/**
|
|
* @param {User} user
|
|
* @param {Connection?} connection
|
|
*/
|
|
onUpdateConnection(user, connection = null) {
|
|
this.onConnect(user, connection);
|
|
}
|
|
/**
|
|
* @param {User} user
|
|
* @param {string} oldUserid
|
|
* @param {boolean} isJoining
|
|
* @param {boolean} isForceRenamed
|
|
*/
|
|
onRename(user, oldUserid, isJoining, isForceRenamed) {
|
|
if (user.userid === oldUserid) return;
|
|
if (!this.players) {
|
|
// !! should never happen but somehow still does
|
|
user.games.delete(this.id);
|
|
return;
|
|
}
|
|
if (!(oldUserid in this.players)) {
|
|
if (user.userid in this.players) {
|
|
// this handles a user renaming themselves into a user in the
|
|
// battle (e.g. by using /nick)
|
|
this.onConnect(user);
|
|
}
|
|
return;
|
|
}
|
|
if (!this.allowRenames) {
|
|
let player = this.players[oldUserid];
|
|
if (player) {
|
|
const message = isForceRenamed ? " lost by having an inappropriate name." : " forfeited by changing their name.";
|
|
this.forfeitSlot(player.slotNum, message);
|
|
}
|
|
if (!(user.userid in this.players)) {
|
|
user.games.delete(this.id);
|
|
}
|
|
return;
|
|
}
|
|
if (user.userid in this.players) return;
|
|
let player = this.players[oldUserid];
|
|
this.players[user.userid] = player;
|
|
player.userid = user.userid;
|
|
player.name = user.name;
|
|
delete this.players[oldUserid];
|
|
const options = {
|
|
name: user.name,
|
|
avatar: user.avatar,
|
|
};
|
|
this.stream.write(`>player ${player.slot} ` + JSON.stringify(options));
|
|
}
|
|
/**
|
|
* @param {User} user
|
|
*/
|
|
onJoin(user) {
|
|
let player = this.players[user.userid];
|
|
if (player && !player.active) {
|
|
player.active = true;
|
|
this.timer.checkActivity();
|
|
this.room.add(`|player|${player.slot}|${user.name}|${user.avatar}`);
|
|
}
|
|
}
|
|
/**
|
|
* @param {User} user
|
|
*/
|
|
onLeave(user) {
|
|
let player = this.players[user.userid];
|
|
if (player && player.active) {
|
|
player.sendRoom(`|request|null`);
|
|
player.active = false;
|
|
this.timer.checkActivity();
|
|
this.room.add(`|player|${player.slot}|`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {User} user
|
|
*/
|
|
win(user) {
|
|
if (!user) {
|
|
this.tie();
|
|
return true;
|
|
}
|
|
let player = this.players[user.userid];
|
|
if (!player) return false;
|
|
this.stream.write(`>forcewin ${player.slot}`);
|
|
}
|
|
tie() {
|
|
this.stream.write(`>forcetie`);
|
|
}
|
|
tiebreak() {
|
|
this.stream.write(`>tiebreak`);
|
|
}
|
|
/**
|
|
* @param {User} user
|
|
* @param {string} message
|
|
*/
|
|
forfeit(user, message = '') {
|
|
if (!this.players) {
|
|
// should never happen
|
|
console.log("user is: " + user.name);
|
|
console.log(" alts: " + Object.keys(user.prevNames));
|
|
console.log(" battle: " + this.id);
|
|
return false;
|
|
}
|
|
let slotNum = -1;
|
|
if (user.userid in this.players) slotNum = this.players[user.userid].slotNum;
|
|
if (slotNum === -1) return false;
|
|
return this.forfeitSlot(slotNum, message);
|
|
}
|
|
|
|
/**
|
|
* @param {number} slotNum
|
|
* @param {string} message
|
|
*/
|
|
forfeitSlot(slotNum, message = '') {
|
|
if (this.ended || !this.started) return false;
|
|
|
|
let name = this.playerNames[slotNum];
|
|
|
|
if (!message) message = ' forfeited.';
|
|
this.room.add(`|-message|${name}${message}`);
|
|
this.endType = 'forfeit';
|
|
const otherids = ['p2', 'p1'];
|
|
this.stream.write(`>forcewin ${otherids[slotNum]}`);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @param {User} user
|
|
* @param {PlayerSlot?} slot
|
|
* @param {string} team
|
|
*/
|
|
addPlayer(user, slot = null, team = '', initializing = false) {
|
|
if (user.userid in this.players) return false;
|
|
if (this.playerCount >= this.playerCap) return false;
|
|
let player = this.makePlayer(user, slot, team);
|
|
if (!player) return false;
|
|
this.players[user.userid] = player;
|
|
this.playerCount++;
|
|
this.room.auth[user.userid] = Users.PLAYER_SYMBOL;
|
|
if (user.inRooms.has(this.id)) this.onConnect(user);
|
|
if (this.playerCount >= 2) {
|
|
// @ts-ignore
|
|
this.room.title = `${this.p1.name} vs. ${this.p2.name}`;
|
|
this.room.send(`|title|${this.room.title}`);
|
|
}
|
|
if (!initializing) {
|
|
this.room.add(`|player|${player.slot}|${user.name}|${user.avatar}`);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @param {User} user
|
|
* @param {PlayerSlot?} slot
|
|
* @param {string} team
|
|
*/
|
|
makePlayer(user, slot = null, team = '') {
|
|
if (!slot) {
|
|
let slotNum = 0;
|
|
while (this[/** @type {PlayerSlot} */ ('p' + (slotNum + 1))]) slotNum++;
|
|
slot = /** @type {PlayerSlot} */ ('p' + (slotNum + 1));
|
|
}
|
|
// console.log('joining: ' + user.name + ' ' + slot);
|
|
|
|
if (this[slot]) throw new Error(`Player already exists in ${slot} in ${this.id}`);
|
|
let slotNum = parseInt(slot.charAt(1)) - 1;
|
|
let player = new BattlePlayer(user, this, slot);
|
|
this[slot] = player;
|
|
this.playerNames[slotNum] = player.name;
|
|
|
|
/**@type {{name: string, avatar: string, team?: string}} */
|
|
let options = {
|
|
name: player.name,
|
|
avatar: '' + user.avatar,
|
|
};
|
|
if (!this.started) {
|
|
options.team = team;
|
|
}
|
|
this.stream.write(`>player ${slot} ` + JSON.stringify(options));
|
|
if (this.started) this.onUpdateConnection(user);
|
|
if (this.p1 && this.p2) {
|
|
this.started = true;
|
|
const user1 = Users(this.p1.userid);
|
|
const user2 = Users(this.p2.userid);
|
|
if (!user1) throw new Error(`User ${this.p1.userid} not found on ${this.id} battle creation`);
|
|
if (!user2) throw new Error(`User ${this.p2.userid} not found on ${this.id} battle creation`);
|
|
Rooms.global.onCreateBattleRoom(user1, user2, this.room, {rated: this.rated});
|
|
}
|
|
return player;
|
|
}
|
|
|
|
/**
|
|
* @param {User} user
|
|
*/
|
|
removePlayer(user) {
|
|
if (!this.allowRenames) return false;
|
|
let player = this.players[user.userid];
|
|
if (!player) return false;
|
|
if (player.active) {
|
|
this.room.add(`|player|${player.slot}|`);
|
|
}
|
|
player.destroy();
|
|
delete this.players[user.userid];
|
|
this.playerCount--;
|
|
return true;
|
|
}
|
|
|
|
removeAllPlayers() {
|
|
for (let i in this.players) {
|
|
this.players[i].destroy();
|
|
delete this.players[i];
|
|
this.playerCount--;
|
|
}
|
|
}
|
|
|
|
destroy() {
|
|
this.ended = true;
|
|
this.stream.destroy();
|
|
if (this.active) {
|
|
Rooms.global.battleCount += -1;
|
|
this.active = false;
|
|
}
|
|
|
|
for (let i in this.players) {
|
|
this.players[i].destroy();
|
|
}
|
|
// @ts-ignore
|
|
this.players = null;
|
|
// @ts-ignore
|
|
this.room = null;
|
|
}
|
|
}
|
|
|
|
exports.RoomBattlePlayer = BattlePlayer;
|
|
exports.RoomBattleTimer = BattleTimer;
|
|
exports.RoomBattle = Battle;
|
|
|
|
/*********************************************************
|
|
* Process manager
|
|
*********************************************************/
|
|
|
|
const StreamProcessManager = require('../lib/process-manager').StreamProcessManager;
|
|
|
|
const PM = new StreamProcessManager(module, () => {
|
|
const BattleStream = require('../sim/battle-stream').BattleStream;
|
|
return new BattleStream({keepAlive: true});
|
|
});
|
|
|
|
if (!PM.isParentProcess) {
|
|
// This is a child process!
|
|
// @ts-ignore This file doesn't exist on the repository, so Travis checks fail if this isn't ignored
|
|
global.Config = require('../config/config');
|
|
global.Chat = require('./chat');
|
|
// @ts-ignore ???
|
|
global.Monitor = {
|
|
/**
|
|
* @param {Error} error
|
|
* @param {string} source
|
|
* @param {{}?} details
|
|
*/
|
|
crashlog(error, source = 'A simulator process', details = null) {
|
|
const repr = JSON.stringify([error.name, error.message, source, details]);
|
|
// @ts-ignore
|
|
process.send(`THROW\n@!!@${repr}\n${error.stack}`);
|
|
},
|
|
};
|
|
global.__version = '';
|
|
try {
|
|
const execSync = require('child_process').execSync;
|
|
const out = execSync('git merge-base master HEAD', {
|
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
});
|
|
global.__version = ('' + out).trim();
|
|
} 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, 'A simulator process Promise');
|
|
});
|
|
}
|
|
|
|
require('../lib/repl').start(`sim-${process.pid}`, cmd => eval(cmd));
|
|
} else {
|
|
PM.spawn(global.Config ? Config.simulatorprocesses : 1);
|
|
}
|
|
|
|
exports.PM = PM;
|