311 lines
No EOL
8.4 KiB
TypeScript
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")
|
|
}
|
|
} |