Spaces:
Paused
Paused
| 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<BestOfGame> { | |
| wins = 0; | |
| ready: boolean | null = null; | |
| options: Omit<RoomBattlePlayerOptions, 'user'> & { 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,<div class="infobox"><p style="margin:6px">Are you ready for game ${gameNum}, ${this.name}?</p><p style="margin:6px">` + | |
| (this.ready ? | |
| `<button class="button" disabled><i class="fa fa-check"></i> I'm ready!</button> – waiting for opponent...` : | |
| `<button class="button notifying" name="send" value="${cmd}">I'm ready!</button>` | |
| ) + | |
| `</p></div>`; | |
| this.sendRoom(button); | |
| battleRoom?.sendUser(user, button); | |
| } | |
| } | |
| export class BestOfGame extends RoomGame<BestOfPlayer> { | |
| override readonly gameid = 'bestof' as ID; | |
| override allowRenames = false; | |
| override room!: GameRoom; | |
| bestOf: number; | |
| format: Format; | |
| winThreshold: number; | |
| options: Omit<RoomBattleOptions, 'players'> & { 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<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 best-of set 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 best-of set 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 || 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}|<a href="/${room.roomid}">${room.title}</a>`); | |
| room.add(`|uhtmlchange|bestof|<h2><strong>Game ${gameNum}</strong> of <a href="/${this.roomid}">a best-of-${this.bestOf}</a></h2>`).update(); | |
| if (prevRoom) { | |
| prevRoom.add(`|uhtmlchange|next|Next: <a href="/${room.roomid}"><strong>Game ${gameNum} of ${this.bestOf}</strong></a>`).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|<table width="100%"><tr><td align="left">${p1.name}</td><td align="right">${p2.name}</tr>` + | |
| `<tr><td align="left">${this.renderWins(p1)}</td><td align="right">${this.renderWins(p2)}</tr></table>` | |
| ); | |
| battleRoom.add( | |
| `|uhtml|bestof|<h2><strong>Game ${gameNum}</strong> of <a href="/${this.roomid}">a best-of-${this.bestOf}</a></h2>` | |
| ).update(); | |
| this.room.add(`|html|<h2>Game ${gameNum}</h2>`); | |
| this.room.add(Utils.html`|uhtml|game${gameNum}|<a href="/${battleRoom.roomid}">${battleRoom.title}</a>`); | |
| this.updateDisplay(); | |
| prevBattleRoom?.add( | |
| `|uhtml|next|Next: <a href="/${battleRoom.roomid}"><strong>Game ${gameNum} of ${this.bestOf}</strong></a>` | |
| ).update(); | |
| } | |
| renderWins(player: BestOfPlayer) { | |
| const wins = this.games.filter(game => game.winner === player).length; | |
| const winBuf = `<i class="fa fa-circle"></i> `.repeat(wins); | |
| const restBuf = `<i class="fa fa-circle-o"></i> `.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`<br /><strong>${p1name} and ${p2name}'s Best-of-${this.bestOf} progress:</strong><br />`; | |
| buf += '<table>'; | |
| for (const p of this.players) { | |
| buf += Utils.html`<tr><td>${p.name}: </td><td>`; | |
| for (let i = 0; i < this.bestOf; i++) { | |
| if (this.games[i]?.winner === p) { | |
| buf += `<i class="fa fa-circle"></i>`; | |
| } else { | |
| buf += `<i class="fa fa-circle-o"></i>`; | |
| } | |
| if (i !== this.bestOf - 1) { | |
| buf += ` `; | |
| } | |
| } | |
| buf += `</td></tr>`; | |
| } | |
| buf += `</table><br /><br />`; | |
| buf += `<table><tr>`; | |
| for (const i of [0, null, 1]) { | |
| if (i === null) { | |
| buf += `<td></td>`; | |
| continue; | |
| } | |
| buf += Utils.html`<td><center><strong>${this.players[i].name}</strong></center></td>`; | |
| } | |
| buf += `</tr><tr>`; | |
| for (const i of [0, null, 1]) { | |
| if (i === null) { | |
| buf += `<td></td>`; | |
| continue; | |
| } | |
| const p = this.players[i]; | |
| const mirrorLeftPlayer = !i ? ' style="transform: scaleX(-1)"' : ""; | |
| buf += `<td><center>`; | |
| buf += `<img class="trainersprite"${mirrorLeftPlayer} src="${p.avatar()}" />`; | |
| buf += `</center></td>`; | |
| } | |
| buf += `</tr><tr>`; | |
| for (const i of [0, null, 1]) { | |
| if (i === null) { | |
| buf += `<td> vs </td>`; | |
| continue; | |
| } | |
| const team = Teams.unpack(this.players[i].options.team || ""); | |
| if (!team || !Dex.formats.getRuleTable(this.format).has('teampreview')) { | |
| buf += `<td>`; | |
| buf += `<psicon pokemon="unknown" /> `.repeat(3); | |
| buf += `<br />`; | |
| buf += `<psicon pokemon="unknown" /> `.repeat(3); | |
| buf += `</td>`; | |
| continue; | |
| } | |
| const mirrorLeftPlayer = !i ? ' style="transform: scaleX(-1)"' : ""; | |
| buf += `<td>`; | |
| for (const [j, set] of team.entries()) { | |
| if (j % 3 === 0 && j > 1) buf += `<br />`; | |
| buf += `<psicon pokemon="${set.species}"${mirrorLeftPlayer} />`; | |
| } | |
| buf += `</td>`; | |
| } | |
| buf += `</tr></table>`; | |
| this.room.add(`|fieldhtml|<center>${buf}</center>`); | |
| 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`<p>Game ${index + 1}: <a href="/${room.roomid}"><strong>${progress}</strong></a></p>`; | |
| }).join(''); | |
| if (this.winner) { | |
| buf += Utils.html`<p>${this.winner.name} won!</p>`; | |
| } else if (this.winner === null) { | |
| buf += `<p>The battle was tied.</p>`; | |
| } | |
| this.room.add(`|controlshtml|<center>${buf}</center>`); | |
| 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; | |
| } | |
| } | |