236 lines
No EOL
5.6 KiB
TypeScript
236 lines
No EOL
5.6 KiB
TypeScript
import EventEmitter from "eventemitter3"
|
|
import type { GameAction } from "../shared/game/actions"
|
|
import { prismaClient } from "./prisma"
|
|
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 } from "../shared/lobbyCode"
|
|
|
|
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() {
|
|
const game = new Game()
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
return NO_REDACTION
|
|
}
|
|
|
|
interface Events {
|
|
broadcast_action: [GameAction]
|
|
private_action: [string, GameAction]
|
|
destroyed: []
|
|
}
|
|
|
|
export class Game extends EventEmitter<Events> {
|
|
lobbyCode = generateLobbyCode()
|
|
|
|
/**
|
|
* @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() {
|
|
super()
|
|
}
|
|
|
|
private async getPlayers() {
|
|
return await prismaClient.user.findMany({
|
|
where: {
|
|
id: {
|
|
in: [...this.lobbyPlayerIds]
|
|
}
|
|
},
|
|
select: {
|
|
id: true,
|
|
name: true
|
|
}
|
|
})
|
|
}
|
|
|
|
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 >= 3) 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() {
|
|
const players = await 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,
|
|
players
|
|
})
|
|
|
|
for (const player of players) {
|
|
this.dealNumberTo(player.id, true)
|
|
}
|
|
}
|
|
|
|
hit() {
|
|
const playerId = this.state.activePlayerId
|
|
|
|
this.addAction({
|
|
type: "hit",
|
|
initiatingPlayerId: playerId
|
|
})
|
|
|
|
this.dealNumberTo(playerId, false)
|
|
}
|
|
|
|
stay() {
|
|
const playerId = this.state.activePlayerId
|
|
|
|
this.addAction({
|
|
type: "stay",
|
|
initiatingPlayerId: playerId
|
|
})
|
|
|
|
if (this.state.players.every(p => p.stayed)) {
|
|
this.end()
|
|
}
|
|
}
|
|
|
|
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)]))
|
|
})
|
|
}
|
|
|
|
sendAllOldActionsTo(receiverId: string) {
|
|
this.actions.forEach(action => this.sendActionTo(action, receiverId))
|
|
}
|
|
|
|
private dealNumberTo(playerId: string, isCovert: boolean) {
|
|
const number = this.state.numberCardsStack[random(0, this.state.numberCardsStack.length - 1)]
|
|
|
|
this.addAction({
|
|
type: "deal-number",
|
|
number,
|
|
toPlayerId: playerId,
|
|
isCovert
|
|
})
|
|
}
|
|
|
|
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() {
|
|
gamesByLobbyCode.delete(this.lobbyCode)
|
|
this.emit("destroyed")
|
|
}
|
|
} |