import { DominoesGame } from "./DominoesGame"; import { PlayerAI } from "./entities/player/PlayerAI"; import { PlayerInterface } from "./entities/player/PlayerInterface"; import { LoggingService } from "../common/LoggingService"; import { getRandomSeed, uuid, wait, whileNot } from "../common/utilities"; import { MatchSessionState } from "./dto/MatchSessionState"; import { PlayerNotificationService } from '../server/services/PlayerNotificationService'; import seedrandom, { PRNG } from "seedrandom"; import { PlayerHuman } from "./entities/player/PlayerHuman"; import { GameSummary } from "./dto/GameSummary"; import { PlayerMove } from "./entities/PlayerMove"; import { SessionService } from "../server/services/SessionService"; import { Score } from "../server/db/interfaces"; import { MatchSessionOptions } from "./dto/MatchSessionOptions"; export class MatchSession { private currentGame: DominoesGame | null = null; private minHumanPlayers: number = 1; private gameNumber: number = 0; private waitingForPlayers: boolean = true; private waitingSeconds: number = 0; private logger: LoggingService = new LoggingService(); private notificationService = new PlayerNotificationService(); private winnerIndex: number | null = null; private clientsReady: string[] = []; private gameSummaries: GameSummary[] = []; private sessionService: SessionService = new SessionService(); id: string; matchInProgress: boolean = false; gameInProgress: boolean = false; matchWinner?: PlayerInterface = undefined; maxPlayers: number = 4; mode: string = 'classic'; players: PlayerInterface[] = []; // pointsToWin: number = 50; rng!: PRNG scoreboard: Map = new Map(); seed!: string sessionInProgress: boolean = false; status: string = 'created' name: string constructor(public creator: PlayerInterface, private options: MatchSessionOptions) { const { sessionName, seed, winType, winTarget } = options; this.seed = seed || getRandomSeed(); this.id = uuid(); this.name = sessionName || `Match ${this.id}`; this.addPlayerToSession(creator); this.creator = creator; this.logger.info(`Match session created by: ${creator.name}`); this.logger.info(`Match session ID: ${this.id}`); this.logger.info(`Match session name: ${this.name}`); this.logger.info(`Win type: ${options.winType}`); this.logger.info(`Win target: ${options.winTarget}`); this.sessionInProgress = false; this.waitingForPlayers = true; this.status = 'created'; this.logger.info('Waiting for players to be ready'); } get numPlayers() { return this.players.length; } get numPlayersReady() { return this.players.filter(player => player.ready).length; } get numHumanPlayers() { return this.players.filter(player => player instanceof PlayerHuman).length; } setClientReady(userId: string) { this.logger.trace(`${userId} - ${this.clientsReady}`); if (!this.clientsReady.includes(userId)) { this.logger.trace(`Client ${userId} is ready`) this.clientsReady.push(userId); } this.logger.trace(`${this.clientsReady.length}`); } setCurrentGameClientReady(userId: string) { if (this.currentGame) { this.currentGame.setClientReady(userId); } } async checkAllClientsReady() { try { if (this.currentGame) { const conditionFn = () => { this.logger.trace(`Clients ready: ${this.clientsReady.length}/${this.numHumanPlayers}`); return this.clientsReady.length === this.numHumanPlayers } await whileNot(conditionFn, 50); this.logger.info(`Game #${this.gameNumber} started`); this.currentGame.start(); this.gameInProgress = true; this.clientsReady = []; } } catch (error) { this.logger.error(error, 'Error starting game'); throw new Error('Error starting game (checkAllClientsReadyBeforeStart)'); } } getPlayer(userId: string) { return this.players.find(player => player.id === userId) || null; } playerMove(move: any) { this.logger.trace('Handling player move (playerMove)'); this.logger.trace(`${this.clientsReady.length}`); if (this.currentGame) { if ((move === null) || (move === undefined) || move.type === 'pass') { this.currentGame.finishTurn(null); return; } const player = this.getPlayer(move.playerId); if (!player) { throw new Error("Player not found"); } const tile = player.hand.find(tile => tile.id === move.tile.id); if (!tile) { throw new Error("Tile not found"); } const newMove = new PlayerMove(tile, move.type, move.playerId, move.direction) this.currentGame.finishTurn(newMove); } } // This is the entry point for the game, method called by session host async start(data: any) { if (this.matchInProgress) { throw new Error("Game already in progress"); } this.waitingForPlayers = false; this.sessionInProgress = true; this.status = 'started' this.sessionService.updateSession(this); await this.startMatch(this.seed); } setTeams(data: any) { if (data.teamedWith !== undefined) { const creatorTeam = this.getPlayer(data.teamedWith) if (!creatorTeam) { throw new Error("Teamed player not found"); } this.creator.teamedWith = data.teamedWith; this.creator.team = 1 creatorTeam.teamedWith = this.creator.id; creatorTeam.team = 1; const others = this.players.filter(player => player.team === 0); others[0].teamedWith = others[1].id; others[0].team = 2; others[1].teamedWith = others[0].id; others[1].team = 2; this.players = [this.creator, others[0], creatorTeam, others[1]]; } } addPlayerToSession(player: PlayerInterface) { if (this.numPlayers >= this.maxPlayers) { throw new Error("GameSession is full"); } this.players.push(player); this.logger.info(`${player.name} joined the game!`); } private continueMatch(gameSummary: GameSummary) { this.gameSummaries.push(gameSummary); this.winnerIndex = this.players.findIndex(player => player.id === gameSummary?.winner?.id); this.setScores(gameSummary || undefined); this.checkMatchWinner(); this.resetPlayers(); try { if (!this.matchInProgress) { this.status = 'end' this.notificationService.sendEventToPlayers('server:match-finished', this.players, { lastGame: gameSummary, sessionState: this.getState(true), }); } else { this.status = 'waiting' // await this.playerNotificationManager.notifyMatchState(this); this.notificationService.sendEventToPlayers('server:game-finished', this.players, { lastGame: gameSummary, sessionState: this.getState(true) }); this.waitingForPlayers = true; this.startGame(); } } finally { this.sessionService.updateSession(this); } } private startGame() { try { this.gameNumber += 1; this.logger.info(`Game #${this.gameNumber} started`); this.currentGame = new DominoesGame(this.players, this.rng); if (this.winnerIndex !== null) { this.currentGame.setForcedInitialPlayerIndex(this.winnerIndex); } this.currentGame.on('game-over', (gameSummary: GameSummary) => { this.gameInProgress = false; this.continueMatch(gameSummary); }); this.logger.info(`Waiting for ${this.numHumanPlayers} clients to be ready`); this.checkAllClientsReady(); } catch (error: any) { this.logger.error(error); this.matchInProgress = false; this.status = 'error' this.notificationService.sendEventToPlayers('server:game-error', this.players, { matchState: this.getState(), error: error.message || error, }) } } private async startMatch(seed: string) { try { this.status = 'in-game' this.rng = seedrandom(seed); this.resetScoreboard() this.gameNumber = 0; this.matchInProgress = true // this.playerNotificationManager.notifyMatchState(this); this.winnerIndex = null; this.startGame(); } catch (error) { this.logger.error(error); this.matchInProgress = false; this.status = 'error' } } resetPlayers() { this.players.forEach(player => { player.reset() }); } checkMatchWinner() { const scores = Array.from(this.scoreboard.values()); const maxScore = Math.max(...scores); if (this.options.winType === 'rounds') { this.checkRoundsWinner(maxScore); } else if (this.options.winType === 'points') { this.checkPointsWinner(maxScore); } } checkRoundsWinner(maxScore: number) { if (maxScore >= this.options.winTarget) { this.matchWinner = this.players.find(player => this.scoreboard.get(player.name) === maxScore); if (!this.matchWinner) { throw new Error('Match winner not found'); } this.logger.info(`Match winner: ${this.matchWinner.name} with ${maxScore} points`); this.matchInProgress = false; } } checkPointsWinner(maxScore: number) { if (maxScore >= this.options.winTarget) { this.matchWinner = this.players.find(player => this.scoreboard.get(player.name) === maxScore); if (!this.matchWinner) { throw new Error('Match winner not found'); } this.logger.info(`Match winner: ${this.matchWinner.name} with ${maxScore} points`); this.matchInProgress = false; } } resetScoreboard() { this.scoreboard = new Map(); this.players.forEach(player => { this.scoreboard.set(player.name, 0); }); } setScores(gameSummary?: GameSummary) { if (!gameSummary) { return; } if (this.options.winType === 'rounds') { this.setScoresRounds(gameSummary); } else if (this.options.winType === 'points') { this.setScoresPoints(gameSummary); } } setScoresRounds(gameSummary: GameSummary) { const { winner } = gameSummary; if (winner !== undefined) { const currentScore = this.scoreboard.get(winner.name) ?? 0; this.scoreboard.set(winner.name, 1 + currentScore); } } setScoresPoints(gameSummary: GameSummary) { const { score } = gameSummary; score.forEach(playerScore => { const currentScore = this.scoreboard.get(playerScore.name) ?? 0; this.scoreboard.set(playerScore.name, currentScore + playerScore.score); }); } afterTileAnimation(data: any) { this.currentGame?.setCanAskNextPlayerMove(true); } private endGame(): any { if (this.currentGame !== null) { const { gameBlocked, gameTied, winner } = this.currentGame; gameBlocked ? this.logger.info('Game blocked!') : gameTied ? this.logger.info('Game tied!') : this.logger.info('Game over!'); this.logger.info('Winner: ' + winner?.name + ' with ' + winner?.pipsCount() + ' points'); this.getScore(this.currentGame); this.logger.info('Game ended'); this.currentGame = null; } } private getScore(game: DominoesGame) { const pips = game.players .sort((a,b) => (b.pipsCount() - a.pipsCount())) .map(player => { return `${player.name}: ${player.pipsCount()}`; }); this.logger.info(`Pips count: ${pips.join(', ')}`); const totalPoints = game.players.reduce((acc, player) => acc + player.pipsCount(), 0); if (game.winner !== null) { game.winner.score += totalPoints; } const scores = game.players .sort((a,b) => (b.score - a.score)) .map(player => { return `${player.name}: ${player.score}`; }); this.logger.info(`Scores: ${scores.join(', ')}`); } createPlayerAI(i: number) { const AInames = ["Alice (AI)", "Bob (AI)", "Charlie (AI)", "David (AI)"]; const player = new PlayerAI(AInames[i], this.rng, this.id); player.ready = true; return player; } setPlayerReady(userId: string) { const player = this.players.find(player => player.id === userId); if (!player) { throw new Error("Player not found"); } player.ready = true; this.logger.info(`${player.name} is ready!`); this.notificationService.notifyMatchState(this); if (this.matchInProgress && this.numPlayersReady === this.numHumanPlayers) { this.startGame(); } } toString() { return `GameSession:(${this.id} ${this.name})`; } getState(showPips?: boolean): MatchSessionState { return { id: this.id, name: this.name!, creator: this.creator.id, players: this.players.map(player => player.getState(showPips)), playersReady: this.numPlayersReady, sessionInProgress: this.sessionInProgress, maxPlayers: this.maxPlayers, numPlayers: this.numPlayers, waitingForPlayers: this.waitingForPlayers, waitingSeconds: this.waitingSeconds, seed: this.seed, mode: this.mode, status: this.status, scoreboard: this.getScoreBoardState(), matchWinner: this.matchWinner?.getState(true) || null, matchInProgress: this.matchInProgress, gameSummaries: this.gameSummaries, options: this.options, }; } getScoreBoardState(): Score[] { return Array.from(this.scoreboard).map(([name, score]) => { const player = this.players.find(player => player.name === name); const id = player?.id || name; return { id, name, score } as Score; }); } }