import { Utils } from '../lib'; import { RoomGamePlayer, RoomGame } from "./room-game"; import type { RoomBattlePlayerOptions, RoomBattleOptions } from './room-battle'; import type { PrivacySetting, RoomSettings } from './rooms'; const BEST_OF_IN_BETWEEN_TIME = 40; export class BestOfPlayer extends RoomGamePlayer { wins = 0; ready: boolean | null = null; options: Omit & { user: null }; dcAutoloseTime: number | null = null; constructor(user: User | null, game: BestOfGame, num: number, options: RoomBattlePlayerOptions) { super(user, game, num); this.options = { ...options, user: null }; } avatar() { let avatar = Users.get(this.id)?.avatar; if (!avatar || typeof avatar === 'number') avatar = 'unknownf'; const url = Chat.plugins.avatars?.Avatars.src(avatar) || `https://${Config.routes.client}/sprites/trainers/${avatar}.png`; return url; } updateReadyButton() { const user = this.getUser(); if (!user?.connected) return; this.dcAutoloseTime = null; const room = this.game.room; const battleRoom = this.game.games[this.game.games.length - 1]?.room as Room | undefined; const gameNum = this.game.games.length + 1; if (this.ready === false) { const notification = `|tempnotify|choice|Next game|It's time for game ${gameNum} in your best-of-${this.game.bestOf}!`; if (battleRoom && user.inRooms.has(battleRoom.roomid)) { battleRoom.send(notification); } else { this.sendRoom(notification); } } else { const notification = `|tempnotifyoff|choice`; battleRoom?.sendUser(user, notification); this.sendRoom(notification); } if (this.ready === null) { const button = `|c|~|/uhtml controls,`; this.sendRoom(button); battleRoom?.sendUser(user, button); return; } const cmd = `/msgroom ${room.roomid},/confirmready`; const button = `|c|~|/uhtml controls,

Are you ready for game ${gameNum}, ${this.name}?

` + (this.ready ? ` – waiting for opponent...` : `` ) + `

`; this.sendRoom(button); battleRoom?.sendUser(user, button); } } export class BestOfGame extends RoomGame { override readonly gameid = 'bestof' as ID; override allowRenames = false; override room!: GameRoom; bestOf: number; format: Format; winThreshold: number; options: Omit & { parent: Room, players: null }; forcedSettings: { modchat?: string | null, privacy?: string | null } = {}; ties = 0; games: { room: GameRoom, winner: BestOfPlayer | null | undefined, rated: number }[] = []; playerNum = 0; /** null = tie, undefined = not ended */ winner: BestOfPlayer | null | undefined = undefined; /** when waiting between battles, this is the just-ended battle room, the one with the |tempnotify| */ waitingBattle: GameRoom | null = null; nextBattleTimerEnd: number | null = null; nextBattleTimer: NodeJS.Timeout | null = null; /** Does NOT control bestof's own timer, which is always-on. Controls timers in sub-battles. */ needsTimer = false; score: number[] | null = null; constructor(room: GameRoom, options: RoomBattleOptions) { super(room); this.gameid = 'bestof' as ID; this.format = Dex.formats.get(options.format); this.bestOf = Number(Dex.formats.getRuleTable(this.format).valueRules.get('bestof'))!; this.winThreshold = Math.floor(this.bestOf / 2) + 1; this.title = this.format.name; if (!toID(this.title).includes('bestof')) { this.title += ` (Best-of-${this.bestOf})`; } this.room.bestOf = this; this.options = { ...options, isBestOfSubBattle: true, parent: this.room, allowRenames: false, players: null, }; for (const playerOpts of options.players) { this.addPlayer(playerOpts.user, playerOpts); } process.nextTick(() => this.nextGame()); } override onConnect(user: User) { const player = this.playerTable[user.id]; player?.sendRoom('|cantleave|'); player?.updateReadyButton(); } override makePlayer(user: User | null, options: RoomBattlePlayerOptions): BestOfPlayer { return new BestOfPlayer(user, this, ++this.playerNum, options); } override addPlayer(user: User, options: RoomBattlePlayerOptions) { const player = super.addPlayer(user, options); if (!player) throw new Error(`Failed to make player ${user} in ${this.roomid}`); this.room.auth.set(user.id, Users.PLAYER_SYMBOL); return player; } checkPrivacySettings(options: RoomBattleOptions & Partial) { let inviteOnly = false; const privacySetter = new Set([]); 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|
This best-of set is required to be public due to a player having a name starting with '${this.forcedSettings.privacy}'.
`); } 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|
This best-of set is invite-only!
Users must be invited with /invite (or be staff) to join
`); } } } } checkForcedUserSettings(user: User) { this.forcedSettings = { modchat: this.forcedSettings.modchat || Rooms.RoomBattle.battleForcedSetting(user, 'modchat'), privacy: this.forcedSettings.privacy || Rooms.RoomBattle.battleForcedSetting(user, 'privacy'), }; if ( this.players.some(p => p.getUser()?.battleSettings.special) || (this.options.rated && this.forcedSettings.modchat) ) { this.room.settings.modchat = '\u2606'; } } setPrivacyOfGames(privacy: PrivacySetting) { for (let i = 0; i < this.games.length; i++) { const room = this.games[i].room; const prevRoom = this.games[i - 1]?.room; const gameNum = i + 1; room.setPrivate(privacy); this.room.add(`|uhtmlchange|game${gameNum}|${room.title}`); room.add(`|uhtmlchange|bestof|

Game ${gameNum} of a best-of-${this.bestOf}

`).update(); if (prevRoom) { prevRoom.add(`|uhtmlchange|next|Next: Game ${gameNum} of ${this.bestOf}`).update(); } } this.updateDisplay(); } clearWaiting() { this.waitingBattle = null; for (const player of this.players) { player.ready = null; player.updateReadyButton(); } if (this.nextBattleTimer) { clearInterval(this.nextBattleTimer); this.nextBattleTimerEnd = null; } this.nextBattleTimerEnd = null; this.nextBattleTimer = null; } getOptions(): RoomBattleOptions | null { const players = this.players.map(player => ({ ...player.options, user: player.getUser()!, })); if (players.some(p => !p.user)) { return null; } return { ...this.options, players, }; } nextGame() { const prevBattleRoom = this.waitingBattle; if (!prevBattleRoom && this.games.length) return; // should never happen this.clearWaiting(); const options = this.getOptions(); if (!options) { for (const p of this.players) { if (!p.getUser()) { // tbc this isn't just being offline, it's changing name or being offline for 15 minutes this.forfeitPlayer(p, ` lost by being unavailable at the start of a game.`); return; } } throw new Error(`Failed to get options for ${this.roomid}`); } const battleRoom = Rooms.createBattle(options); // shouldn't happen even in lockdown if (!battleRoom) throw new Error("Failed to create battle for " + this.title); this.games.push({ room: battleRoom, winner: undefined, rated: battleRoom.rated, }); // the absolute result is what counts for rating battleRoom.rated = 0; if (this.needsTimer) { battleRoom.battle?.timer.start(); } const gameNum = this.games.length; const p1 = this.players[0]; const p2 = this.players[1]; battleRoom.add( Utils.html`|html|` + `
${p1.name}${p2.name}
${this.renderWins(p1)}${this.renderWins(p2)}
` ); battleRoom.add( `|uhtml|bestof|

Game ${gameNum} of a best-of-${this.bestOf}

` ).update(); this.room.add(`|html|

Game ${gameNum}

`); this.room.add(Utils.html`|uhtml|game${gameNum}|${battleRoom.title}`); this.updateDisplay(); prevBattleRoom?.add( `|uhtml|next|Next: Game ${gameNum} of ${this.bestOf}` ).update(); } renderWins(player: BestOfPlayer) { const wins = this.games.filter(game => game.winner === player).length; const winBuf = ` `.repeat(wins); const restBuf = ` `.repeat(this.winThreshold - wins); return player.num === 1 ? winBuf + restBuf : restBuf + winBuf; } updateDisplay() { const p1name = this.players[0].name; const p2name = this.players[1].name; let buf = Utils.html`
${p1name} and ${p2name}'s Best-of-${this.bestOf} progress:
`; buf += ''; for (const p of this.players) { buf += Utils.html``; } buf += `
${p.name}: `; for (let i = 0; i < this.bestOf; i++) { if (this.games[i]?.winner === p) { buf += ``; } else { buf += ``; } if (i !== this.bestOf - 1) { buf += ` `; } } buf += `


`; buf += ``; for (const i of [0, null, 1]) { if (i === null) { buf += ``; continue; } buf += Utils.html``; } buf += ``; for (const i of [0, null, 1]) { if (i === null) { buf += ``; continue; } const p = this.players[i]; const mirrorLeftPlayer = !i ? ' style="transform: scaleX(-1)"' : ""; buf += ``; } buf += ``; for (const i of [0, null, 1]) { if (i === null) { buf += ``; continue; } const team = Teams.unpack(this.players[i].options.team || ""); if (!team || !Dex.formats.getRuleTable(this.format).has('teampreview')) { buf += ``; continue; } const mirrorLeftPlayer = !i ? ' style="transform: scaleX(-1)"' : ""; buf += ``; } buf += `
${this.players[i].name}
`; buf += ``; buf += `
vs `; buf += ` `.repeat(3); buf += `
`; buf += ` `.repeat(3); buf += `
`; for (const [j, set] of team.entries()) { if (j % 3 === 0 && j > 1) buf += `
`; buf += ``; } buf += `
`; this.room.add(`|fieldhtml|
${buf}
`); buf = this.games.map(({ room, winner }, index) => { let progress = `being played`; if (winner) progress = Utils.html`won by ${winner.name}`; if (winner === null) progress = `tied`; return Utils.html`

Game ${index + 1}: ${progress}

`; }).join(''); if (this.winner) { buf += Utils.html`

${this.winner.name} won!

`; } else if (this.winner === null) { buf += `

The battle was tied.

`; } this.room.add(`|controlshtml|
${buf}
`); this.room.update(); } override startTimer() { this.needsTimer = true; for (const { room } of this.games) { room.battle?.timer.start(); } } onBattleWin(room: Room, winnerid: ID) { if (this.ended) return; // can happen if the bo3 is destroyed fsr const winner = winnerid ? this.playerTable[winnerid] : null; this.games[this.games.length - 1].winner = winner; if (winner) { winner.wins++; const loserPlayer = room.battle!.players.find(p => p.num !== winner.num); if (loserPlayer && loserPlayer.dcSecondsLeft <= 0) { // disconnection means opp wins the set return this.forfeit(loserPlayer.name, ` lost the series due to inactivity.`); } this.room.add(Utils.html`|html|${winner.name} won game ${this.games.length}!`).update(); if (winner.wins >= this.winThreshold) { return this.end(winner.id); } } else { this.ties++; this.winThreshold = Math.floor((this.bestOf - this.ties) / 2) + 1; this.room.add(`|html|Game ${this.games.length} was a tie.`).update(); } if (this.games.length >= this.bestOf) { return this.end(''); // overall tie } // no one has won, skip onwards this.promptNextGame(room); } promptNextGame(room: Room) { if (!room.battle || this.winner) return; // ??? this.updateDisplay(); this.waitingBattle = room; const now = Date.now(); this.nextBattleTimerEnd = now + BEST_OF_IN_BETWEEN_TIME * 1000; for (const player of this.players) { player.ready = false; const dcAutoloseTime = now + room.battle.players[player.num - 1].dcSecondsLeft * 1000; if (dcAutoloseTime < this.nextBattleTimerEnd) { player.dcAutoloseTime = dcAutoloseTime; } player.updateReadyButton(); } this.nextBattleTimer = setInterval(() => this.pokeNextBattleTimer(), 10000); } pokeNextBattleTimer() { if (!this.nextBattleTimerEnd || !this.nextBattleTimer) return; // ?? if (Date.now() >= this.nextBattleTimerEnd) { return this.nextGame(); } for (const p of this.players) { if (!p.ready) { const now = Date.now() - 100; // fudge to make rounding work better if (p.dcAutoloseTime && now > p.dcAutoloseTime) { return this.forfeit(p.name, ` lost the series due to inactivity.`); } const message = (p.dcAutoloseTime ? `|inactive|${p.name} has ${Chat.toDurationString(p.dcAutoloseTime - now)} to reconnect!` : `|inactive|${p.name} has ${Chat.toDurationString(this.nextBattleTimerEnd - now)} to confirm battle start!` ); this.waitingBattle?.add(message); this.room.add(message); } } this.waitingBattle?.update(); this.room.update(); } confirmReady(user: User) { const player = this.playerTable[user.id]; if (!player) { throw new Chat.ErrorMessage("You aren't a player in this best-of set."); } if (!this.waitingBattle) { throw new Chat.ErrorMessage("The battle is not currently waiting for ready confirmation."); } player.ready = true; player.updateReadyButton(); const readyMsg = `||${player.name} is ready for game ${this.games.length + 1}.`; this.waitingBattle.add(readyMsg).update(); this.room.add(readyMsg).update(); if (this.players.every(p => p.ready)) { this.nextGame(); } } override setEnded() { this.clearWaiting(); super.setEnded(); } end(winnerid: ID) { if (this.ended) return; this.setEnded(); this.room.add(`|allowleave|`).update(); const winner = winnerid ? this.playerTable[winnerid] : null; this.winner = winner; if (winner) { this.room.add(`|win|${winner.name}`); } else { this.room.add(`|tie`); } this.updateDisplay(); this.room.update(); const p1 = this.players[0]; const p2 = this.players[1]; this.score = this.players.map(p => p.wins); this.room.parent?.game?.onBattleWin?.(this.room, winnerid); // run elo stuff here let p1score = 0.5; if (winner === p1) { p1score = 1; } else if (winner === p2) { p1score = 0; } const { rated, room } = this.games[this.games.length - 1]; if (rated) { void room.battle?.updateLadder(p1score, winnerid); } } override forfeit(user: User | string, message = '') { const userid = (typeof user !== 'string') ? user.id : toID(user); const loser = this.playerTable[userid]; if (loser) this.forfeitPlayer(loser, message); } forfeitPlayer(loser: BestOfPlayer, message = '') { if (this.ended || this.winner) return false; this.winner = this.players.find(p => p !== loser)!; this.room.add(`||${loser.name}${message || ' forfeited.'}`); this.end(this.winner.id); const lastBattle = this.games[this.games.length - 1].room.battle; if (lastBattle && !lastBattle.ended) lastBattle.forfeit(loser.id, message); return true; } override destroy() { this.setEnded(); for (const { room } of this.games) room.expire(); this.games = []; for (const p of this.players) p.destroy(); this.players = []; this.playerTable = {}; this.winner = null; } }