/** * Room games * Pokemon Showdown - http://pokemonshowdown.com/ * * Room games are an abstract representation of an activity that a room * can be focused on, such as a battle, tournament, or chat game like * Hangman. Rooms are limited to one roomgame at a time. * * Room games can keep track of designated players. If a user is a player, * they will not be allowed to change name until their games are complete. * * The player system is optional: Some games, like Hangman, don't designate * players and just allow any user in the room to play. * * @license MIT */ // globally Rooms.RoomGamePlayer export class RoomGamePlayer { readonly num: number; readonly game: GameClass; /** * Will be the username of the user playing, but with some exceptions: * * - Creating a game with no users will initialize player names to * "Player 1", "Player 2", etc. * - Players will retain the name of the last active user, even if that * user abandons the game. */ name: string; /** * This will be '' if there's no user associated with the player. * * we explicitly don't hold a direct reference to the user */ id: ID; constructor(user: User | string | null, game: GameClass, num = 0) { this.num = num; if (!user) user = num ? `Player ${num}` : `Player`; this.game = game; this.name = (typeof user === 'string' ? user : user.name); if (typeof user === 'string') user = null; this.id = user ? user.id : ''; if (user && !this.game.isSubGame) { user.games.add(this.game.roomid); user.updateSearch(); } } unlinkUser() { if (!this.id) return; const user = Users.getExact(this.id); if (user && !this.game.isSubGame) { user.games.delete(this.game.roomid); user.updateSearch(); } this.id = ''; } destroy() { this.unlinkUser(); } toString() { return this.id; } send(data: string) { const user = Users.getExact(this.id); if (user) user.send(data); } sendRoom(data: string) { const user = Users.getExact(this.id); if (user) user.sendTo(this.game.roomid, data); } } /** * globally Rooms.RoomGame * * If you don't want to define your own player class, you should extend SimpleRoomGame. */ export abstract class RoomGame { roomid: RoomID; /** * The room this roomgame is in. Rooms can have two RoomGames at a time, * which are available as `this.room.game === this` and `this.room.subGame === this`. */ room: Room; gameid: ID; title: string; allowRenames: boolean; isSubGame: boolean; /** * userid:player table. * * Does not contain userless players: use playerList for the full list. */ playerTable: {[userid: string]: PlayerClass}; players: PlayerClass[]; playerCount: number; playerCap: number; ended: boolean; /** Does `/guess` or `/choose` require the user to be able to talk? */ checkChat = false; /** * We should really resolve this collision at _some_ point, but it will have * to be later. The /timer command is written to be resilient to this. */ timer?: {timerRequesters?: Set, start: (force?: User) => void, stop: (force?: User) => void} | NodeJS.Timer | null; constructor(room: Room, isSubGame = false) { this.roomid = room.roomid; this.room = room; this.gameid = 'game' as ID; this.title = 'Game'; this.allowRenames = false; this.isSubGame = isSubGame; this.playerTable = Object.create(null); this.players = []; this.playerCount = 0; this.playerCap = 0; this.ended = false; if (this.isSubGame) { this.room.subGame = this; } else { this.room.game = this; } } destroy() { if (this.isSubGame) { this.room.subGame = null; } else { this.room.game = null; } // @ts-ignore this.room = null; for (const player of this.players) { player.destroy(); } // @ts-ignore this.players = null; // @ts-ignore this.playerTable = null; } addPlayer(user: User | string | null = null, ...rest: any[]): PlayerClass | null { if (typeof user !== 'string' && user) { if (user.id in this.playerTable) return null; } if (this.playerCap > 0 && this.playerCount >= this.playerCap) return null; const player = this.makePlayer(user, ...rest); if (!player) return null; if (typeof user === 'string') user = null; this.players.push(player); if (user) { this.playerTable[user.id] = player; this.playerCount++; } return player; } updatePlayer(player: PlayerClass, user: User | null) { if (!this.allowRenames) return; if (player.id) { delete this.playerTable[player.id]; } if (user) { player.id = user.id; player.name = user.name; this.playerTable[player.id] = player; this.room.auth.set(user.id, Users.PLAYER_SYMBOL); } else { player.unlinkUser(); } } abstract makePlayer(user: User | string | null, ...rest: any[]): PlayerClass; removePlayer(player: PlayerClass | User) { if (player instanceof Users.User) { // API changed // TODO: deprecate player = this.playerTable[player.id]; if (!player) throw new Error("Player not found"); } if (!this.allowRenames) return false; const playerIndex = this.players.indexOf(player); if (playerIndex < 0) return false; if (player.id) delete this.playerTable[player.id]; this.players.splice(playerIndex, 1); player.destroy(); this.playerCount--; return true; } renamePlayer(user: User, oldUserid: ID) { if (user.id === oldUserid) { this.playerTable[user.id].name = user.name; } else { this.playerTable[user.id] = this.playerTable[oldUserid]; this.playerTable[user.id].id = user.id; this.playerTable[user.id].name = user.name; delete this.playerTable[oldUserid]; } } renameRoom(roomid: RoomID) { for (const player of this.players) { const user = Users.get(player.id); user?.games.delete(this.roomid); user?.games.add(roomid); } this.roomid = roomid; } // Commands: // These are all optional to implement: /** * Called when a user uses /forfeit * Also planned to be used for some force-forfeit situations, such * as when a user changes their name and .allowRenames === false * This is strongly recommended to be supported, as the user is * extremely unlikely to keep playing after this function is * called. */ forfeit?(user: User): void; /** * Called when a user uses /choose [text] * If you have buttons, you are recommended to use this interface * instead of making your own commands. */ choose?(user: User, text: string): void; /** * Called when a user uses /undo [text] */ undo?(user: User, text: string): void; /** * Called when a user uses /joingame [text] */ joinGame?(user: User, text?: string): void; /** * Called when a user uses /leavegame [text] */ leaveGame?(user: User, text?: string): void; // Events: // Note: // A user can have multiple connections. For instance, if you have // two tabs open and connected to PS, those tabs represent two // connections, but a single PS user. Each tab can be in separate // rooms. /** * Called when a user joins a room. (i.e. when the user's first * connection joins) * * While connection is passed, it should not usually be used: * Any handling of connections should happen in onConnect. */ onJoin(user: User, connection: Connection) {} /** * Called when a user is banned from the room this game is taking * place in. */ removeBannedUser(user: User) { if (this.forfeit) this.forfeit(user); } /** * Called when a user in the game is renamed. `isJoining` is true * if the user was previously a guest, but now has a username. * Check `!user.named` for the case where a user previously had a * username but is now a guest. By default, updates a player's * name as long as allowRenames is set to true. */ onRename(user: User, oldUserid: ID, isJoining: boolean, isForceRenamed: boolean) { if (!this.allowRenames || (!user.named && !isForceRenamed)) { if (!(user.id in this.playerTable) && !this.isSubGame) { user.games.delete(this.roomid); user.updateSearch(); } return; } if (!(oldUserid in this.playerTable)) return; if (!user.named) { return this.onLeave(user, oldUserid); } this.renamePlayer(user, oldUserid); } /** * Called when a user leaves the room. (i.e. when the user's last * connection leaves) */ onLeave(user: User, oldUserid?: ID) {} /** * Called each time a connection joins a room (after onJoin if * applicable). By default, this is also called when connection * is updated in some way (such as by changing user or renaming). * If you don't want this behavior, override onUpdateConnection * and/or onRename. */ onConnect(user: User, connection: Connection) {} /** * Called for each connection in a room that changes users by * merging into a different user. By default, runs the onConnect * handler. * * Player updates and an up-to-date report of what's going on in * the game should be sent during `onConnect`. You should rarely * need to handle the other events. */ onUpdateConnection(user: User, connection: Connection) { this.onConnect(user, connection); } /** * Called for every message a user sends while this game is active. * Return an error message to prevent the message from being sent, * an empty string to prevent it with no error message, or * `undefined` to let it through. */ onChatMessage(message: string, user: User): string | void {} /** * Called for every message a user sends while this game is active. * Unlike onChatMessage, this function runs after the message has been added to the room's log. * Do not try to use this to block messages, use onChatMessage for that. */ onLogMessage(message: string, user: User) {} } /** * globally Rooms.SimpleRoomGame * * A RoomGame without a custom player class. Gives a default implementation for makePlayer. */ export class SimpleRoomGame extends RoomGame { makePlayer(user: User | string | null, ...rest: any[]): RoomGamePlayer { const num = this.players.length ? this.players[this.players.length - 1].num : 1; return new RoomGamePlayer(user, this, num); } }