twenty-one/backend/game.ts

311 lines
No EOL
8.4 KiB
TypeScript

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<string, Game>()
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<T extends GameAction>(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<Events> {
/**
* @deprecated
*/
private _state = getUninitializedGameState()
get state(): DeepReadonly<GameState> {
return this._state
}
private lobbyPlayerIds = new Set<string>()
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<T extends RemoveKey<GameAction, "index">>(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")
}
}