import EventEmitter from "eventemitter3" import type { GameAction } from "../shared/game/actions" import { GameState, getNumberCardsSum, getUninitializedGameState, produceNewState } from "../shared/game/state" import type { RemoveKey } from "../shared/RemoveKey" import { random } from "lodash-es" import type { DeepReadonly } from "vue" import { customAlphabet as createNanoIdWithCustomAlphabet } from "nanoid/non-secure" import { LOBBY_CODE_LENGTH, LOBBY_SIZE, SPECIAL_CARD_PROBABILITY } from "../shared/constants" import type { SpecialCardId } from "../shared/game/cards" import { specialCardsMeta } from "../shared/game/cards" import { usersById, usersByToken } from "./user" const generateLobbyCode = createNanoIdWithCustomAlphabet("ABCDEFGHKMNPQRSTUVWXYZ", LOBBY_CODE_LENGTH) const gamesByLobbyCode = new Map() export function getGameByLobbyCode(code: string): Game | null { return gamesByLobbyCode.get(code.toUpperCase()) ?? null } export function createGame(lobbyCode: string = generateLobbyCode()) { const game = new Game(lobbyCode) gamesByLobbyCode.set(game.lobbyCode, game) return game } const NO_REDACTION = Symbol() function redactGameAction(action: T, receiverId: string): T | typeof NO_REDACTION { switch (action.type) { case "deal-number": if (action.isCovert) { if (action.toPlayerId === receiverId) return action return { ...action, number: 0 } } break case "deal-special": if (action.toPlayerId === receiverId) return action return { ...action, cardId: undefined } } return NO_REDACTION } interface Events { broadcast_action: [GameAction] private_action: [string, GameAction] new_round: [] destroyed: [] } export class Game extends EventEmitter { /** * @deprecated */ private _state = getUninitializedGameState() get state(): DeepReadonly { return this._state } private lobbyPlayerIds = new Set() private actions: GameAction[] = [] private get nextIndex() { if (this.actions.length === 0) return 0 return this.actions[this.actions.length - 1].index + 1 } constructor(public lobbyCode: string) { super() } private getPlayers() { return [...this.lobbyPlayerIds.values()].map(id => usersById.get(id)!) } addPlayer(id: string, name: string) { if (this.lobbyPlayerIds.has(id)) return if (this.state.phase !== "pre-start") throw new Error("The game was already started.") if (this.lobbyPlayerIds.size >= LOBBY_SIZE) throw new Error("The game is full.") this.lobbyPlayerIds.add(id) this.addAction({ type: "join", initiatingPlayerId: id, name }) } removePlayerAndDestroy(userId: string) { this.lobbyPlayerIds.delete(userId) this.addAction({ type: "leave", initiatingPlayerId: userId }) this.destroy() } async start() { if (this.state.phase !== "pre-start") throw new Error(`Cannot start the game in this phase: ${this.state.phase}`) const players = this.getPlayers() if (players.length < 2) throw new Error("At least two players are required for starting the game") this.addAction({ type: "start", targetSum: 21, startingPlayerId: players[random(0, players.length - 1)].id, players }) for (const player of players) { this.dealNumberTo(player.id) this.dealSpecialTo(player.id) this.addAction({ type: "hit", initiatingPlayerId: player.id }) } } hit() { const player = this.state.players.find(p => p.id === this.state.activePlayerId)! const sum = getNumberCardsSum(player.numberCards) if (sum >= this.state.targetSum && this.state.activeSpecialCards.find(c => c.id === "force-hit" && c.ownerId !== player.id) === undefined) throw new Error("The player is not allowed to hit if they have reached the target sum.") if (Math.random() > SPECIAL_CARD_PROBABILITY) { this.dealNumberTo(player.id) this.addAction({ type: "hit", initiatingPlayerId: player.id }) this.endIfConditionsAreMet() } else this.dealSpecialTo(player.id) } stay() { const playerId = this.state.activePlayerId if (this.state.activeSpecialCards.find(c => c.id === "force-hit" && c.ownerId !== playerId) !== undefined) throw new Error("An active special card forces the player to hit.") this.addAction({ type: "stay", initiatingPlayerId: playerId }) this.endIfConditionsAreMet() } useSpecialCard(cardId: SpecialCardId) { const player = this.state.players.find(p => p.id === this.state.activePlayerId)! if (!player.specialCards.includes(cardId)) throw new Error("The player does not have this card.") const round = this.state.playerChangeCount / this.state.players.length if (round === 0 && !specialCardsMeta[cardId].isAllowedInFirstRound) throw new Error("This card is not allowed in the first round.") this.addAction({ type: "use-special", initiatingPlayerId: player.id, cardId }) } checkEndConditions() { if (this.state.players.every(p => p.stayed)) return true const endGameCard = this.state.activeSpecialCards.find(c => c.id === "end-game") return endGameCard !== undefined && endGameCard.ownerId === this.state.activePlayerId } end() { let closestSafeValue = 0 let closestSafePlayerIds: string[] = [] let closestBustValue = Number.POSITIVE_INFINITY let closestBustPlayerIds: string[] = [] for (const player of this.state.players) { const sum = getNumberCardsSum(player.numberCards) if (sum <= this.state.targetSum) { if (sum >= closestSafeValue) { if (sum > closestSafeValue) closestSafePlayerIds = [] closestSafeValue = sum closestSafePlayerIds.push(player.id) } } else { if (sum <= closestBustValue) { if (sum < closestBustValue) closestBustPlayerIds = [] closestBustValue = sum closestBustPlayerIds.push(player.id) } } } const winnerIds = closestSafePlayerIds.length !== 0 ? closestSafePlayerIds : closestBustPlayerIds this.addAction({ type: "end", winnerIds, cardsByPlayerId: Object.fromEntries(this.state.players.map(player => [player.id, player.numberCards.map(c => c.number)])) }) } endIfConditionsAreMet() { if (this.checkEndConditions()) this.end() } sendAllOldActionsTo(receiverId: string) { this.actions.forEach(action => this.sendActionTo(action, receiverId)) } announceNewRoundAndDestroy() { this.emit("new_round") this.destroy() } private dealNumberTo(playerId: string) { const player = this.state.players.find(p => p.id === playerId)! if (this.state.activeSpecialCards.find(c => c.id === "double-draw" && c.ownerId === player.id) !== undefined) { const number = this.state.numberCardsStack[random(0, this.state.numberCardsStack.length - 1)] this.addAction({ type: "deal-number", number, toPlayerId: playerId, isCovert: player.nextRoundCovert }) } const number = this.state.numberCardsStack[random(0, this.state.numberCardsStack.length - 1)] this.addAction({ type: "deal-number", number, toPlayerId: playerId, isCovert: player.nextRoundCovert }) } private dealSpecialTo(playerId: string) { let cardId: SpecialCardId // I know. Don't judge me. do { cardId = this.state.weightedSpecialCardIdList[random(0, this.state.weightedSpecialCardIdList.length - 1)] } while (!specialCardsMeta[cardId].isAllowedInFirstRound) this.addAction({ type: "deal-special", toPlayerId: playerId, cardId }) } private addAction>(action: T): T & { index: number } { const fullAction = { ...action, index: this.nextIndex } as T & { index: number } this.actions.push(fullAction) this.applyActionToState(fullAction) this.sendAction(fullAction) return fullAction } private sendAction(action: GameAction) { for (const player of this.state.players) { const redactedAction = redactGameAction(action, player.id) if (redactedAction === NO_REDACTION) { this.emit("broadcast_action", action) break } else { this.emit("private_action", player.id, redactedAction) } } } private sendActionTo(action: GameAction, receiverId: string) { const redactedAction = redactGameAction(action, receiverId) this.emit("private_action", receiverId, redactedAction === NO_REDACTION ? action : redactedAction) } private applyActionToState(action: GameAction) { // noinspection JSDeprecatedSymbols this._state = produceNewState(this._state, action) } private destroy() { if (gamesByLobbyCode.get(this.lobbyCode) === this) gamesByLobbyCode.delete(this.lobbyCode) this.emit("destroyed") } }