From 5f117667a40d4f150b76cc4f8bbfee3e8276760c Mon Sep 17 00:00:00 2001 From: Jose Conde Date: Fri, 12 Jul 2024 16:27:52 +0200 Subject: [PATCH] reworked --- package-lock.json | 13 + package.json | 2 + src/common/utilities.ts | 30 ++- src/game/DominoesGame.ts | 208 +++++++++------ src/game/MatchSession.ts | 250 +++++++++++------- src/game/NetworkClientNotifier.ts | 34 +-- src/game/PlayerInteractionAI.ts | 120 +++++++++ src/game/PlayerInteractionConsole.ts | 6 + src/game/PlayerInteractionInterface.ts | 3 + src/game/PlayerInteractionNetwork.ts | 12 +- src/game/constants.ts | 17 ++ src/game/dto/GameSummary.ts | 5 +- src/game/dto/MatchSummary.ts | 6 + src/game/entities/Board.ts | 4 + src/game/entities/Tile.ts | 1 + src/game/entities/player/AbstractPlayer.ts | 30 +-- src/game/entities/player/PlayerAI.ts | 66 +---- src/game/entities/player/PlayerHuman.ts | 8 - src/game/entities/player/PlayerInterface.ts | 5 +- src/server/controllers/AuthController.ts | 4 +- src/server/controllers/GameController.ts | 36 ++- src/server/db/interfaces.ts | 4 + .../db/mongo/common/BaseMongoManager.ts | 72 +++-- src/server/managers/SessionManager.ts | 4 +- src/server/router/gameRouter.ts | 6 +- src/server/services/InteractionService.ts | 110 ++++++++ .../services/PlayerNotificationService.ts | 37 +-- src/server/services/SessionService.ts | 73 ++--- src/server/services/SocketIoService.ts | 64 +++-- 29 files changed, 823 insertions(+), 407 deletions(-) create mode 100644 src/game/PlayerInteractionAI.ts create mode 100644 src/game/dto/MatchSummary.ts create mode 100644 src/server/services/InteractionService.ts diff --git a/package-lock.json b/package-lock.json index 62c5350..92b10cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "pino-http": "^10.2.0", "pino-pretty": "^11.2.1", "pino-rotating-file-stream": "^0.0.2", + "pubsub-js": "^1.9.4", "seedrandom": "^3.0.5", "socket.io": "^4.7.5" }, @@ -32,6 +33,7 @@ "@types/node": "^20.14.8", "@types/nodemailer": "^6.4.15", "@types/nodemailer-express-handlebars": "^4.0.5", + "@types/pubsub-js": "^1.8.6", "@types/seedrandom": "^3.0.8", "ts-node": "^10.9.2", "typescript": "^5.5.2" @@ -257,6 +259,12 @@ "@types/nodemailer": "*" } }, + "node_modules/@types/pubsub-js": { + "version": "1.8.6", + "resolved": "https://registry.npmjs.org/@types/pubsub-js/-/pubsub-js-1.8.6.tgz", + "integrity": "sha512-Kwug5cwV0paUDm/NfwDx1sp9xI0bGIvmWJjJWCU8NngkCCMt3EIC7oPDvb6fV7BR8kPpFyyBu4D11bda/2MdPA==", + "dev": true + }, "node_modules/@types/qs": { "version": "6.9.15", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.15.tgz", @@ -1696,6 +1704,11 @@ "node": ">= 0.10" } }, + "node_modules/pubsub-js": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/pubsub-js/-/pubsub-js-1.9.4.tgz", + "integrity": "sha512-hJYpaDvPH4w8ZX/0Fdf9ma1AwRgU353GfbaVfPjfJQf1KxZ2iHaHl3fAUw1qlJIR5dr4F3RzjGaWohYUEyoh7A==" + }, "node_modules/pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", diff --git a/package.json b/package.json index bba0d4f..5468d5d 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "pino-http": "^10.2.0", "pino-pretty": "^11.2.1", "pino-rotating-file-stream": "^0.0.2", + "pubsub-js": "^1.9.4", "seedrandom": "^3.0.5", "socket.io": "^4.7.5" }, @@ -46,6 +47,7 @@ "@types/node": "^20.14.8", "@types/nodemailer": "^6.4.15", "@types/nodemailer-express-handlebars": "^4.0.5", + "@types/pubsub-js": "^1.8.6", "@types/seedrandom": "^3.0.8", "ts-node": "^10.9.2", "typescript": "^5.5.2" diff --git a/src/common/utilities.ts b/src/common/utilities.ts index 50b009d..3c36d7f 100644 --- a/src/common/utilities.ts +++ b/src/common/utilities.ts @@ -4,6 +4,9 @@ import * as readline from 'readline'; import { Tile } from '../game/entities/Tile'; import chalk from 'chalk'; import { Board } from '../game/entities/Board'; +import { LoggingService } from './LoggingService'; + +const logger = new LoggingService(); const rl = readline.createInterface({ input: process.stdin, @@ -29,6 +32,21 @@ export const whileNotUndefined = async (fn: Function, maxQueries: number = 20, m }); } +export const whileNot = async (fn: Function, maxQueries: number = 20, millis: number = 500): Promise => { + return new Promise(async (resolve, reject) => { + let result: boolean = false; + while (result === false) { + await wait(millis); + result = fn() + if (maxQueries-- < 0) { + reject() + return; + } + } + resolve(); + }); +} + export function askQuestion(question: string): Promise { return new Promise((resolve) => { // console.log(chalk.yellow(question)); @@ -46,7 +64,7 @@ export function getRandomSeed(): string { } export function printTiles(prefix:string, tiles: Tile[]): void { - console.log(`${prefix}${tiles.join(' ')}`); + logger.info(`${prefix}${tiles.join(' ')}`); } export function printSelection(prefix:string, tiles: Tile[]): void { @@ -55,22 +73,22 @@ export function printSelection(prefix:string, tiles: Tile[]): void { return `(${index > 9 ? `${index})`: `${index}) `} ` }).join(' '); printTiles(prefix, tiles); - console.log(`${Array(prefix.length).join((' '))} ${line}`); + logger.info(`${Array(prefix.length).join((' '))} ${line}`); } export function printBoard(board: Board, highlighted: boolean = false): void { if (highlighted) - console.log(chalk.cyan(`Board: ${board.tiles.length > 0 ? board.tiles.join(' ') : '--empty--'}`)); + logger.info(chalk.cyan(`Board: ${board.tiles.length > 0 ? board.tiles.map(t => t.toString()).join(' ') : '--empty--'}`)); else - console.log(chalk.gray(`Board: ${board.tiles.length > 0 ? board.tiles.join(' ') : '--empty--'}`)); + logger.info(chalk.gray(`Board: ${board.tiles.length > 0 ? board.tiles.map(t => t.toString()).join(' ') : '--empty--'}`)); } export function printLine(msg: string): void { - console.log(chalk.grey(msg)); + logger.info(chalk.grey(msg)); } export function printError(msg: string): void { - console.log(chalk.red(msg)); + logger.info(chalk.red(msg)); } export function uuid() { diff --git a/src/game/DominoesGame.ts b/src/game/DominoesGame.ts index 7d1e222..60ea9b1 100644 --- a/src/game/DominoesGame.ts +++ b/src/game/DominoesGame.ts @@ -1,15 +1,15 @@ import { PRNG } from 'seedrandom'; +import {EventEmitter} from 'events'; import { Board } from "./entities/Board"; import { PlayerMove } from "./entities/PlayerMove"; import { PlayerInterface } from "./entities/player/PlayerInterface"; import { Tile } from "./entities/Tile"; import { LoggingService } from "../common/LoggingService"; -import { printBoard, printLine, uuid, wait } from '../common/utilities'; -import { GameSummary } from './dto/GameSummary'; +import { printBoard, printLine, uuid, wait, whileNotUndefined } from '../common/utilities'; import { PlayerNotificationService } from '../server/services/PlayerNotificationService'; import { GameState } from './dto/GameState'; -export class DominoesGame { +export class DominoesGame extends EventEmitter { private id: string; private seed: string | undefined; autoDeal: boolean = true; @@ -25,11 +25,13 @@ export class DominoesGame { winner: PlayerInterface | null = null; rng: PRNG; handSize: number = 7; - notificationManager: PlayerNotificationService = new PlayerNotificationService(); + notificationService: PlayerNotificationService = new PlayerNotificationService(); lastMove: PlayerMove | null = null; forcedInitialPlayerIndex: number | null = null; + canAskNextPlayerMove: boolean = true; constructor(public players: PlayerInterface[], seed: PRNG) { + super(); this.id = uuid(); this.logger.info(`Game ID: ${this.id}`); this.rng = seed @@ -76,6 +78,7 @@ export class DominoesGame { } nextPlayer() { + this.logger.debug('Turn ended'); this.currentPlayerIndex = (this.currentPlayerIndex + 1) % this.players.length; } @@ -88,10 +91,7 @@ export class DominoesGame { return hasWinner || this.gameBlocked; } - getWinner(): PlayerInterface | null { - if (!this.gameOver) { - return null; - } + getWinner(): PlayerInterface { const winnerNoTiles = this.players.find(player => player.hand.length === 0); if (winnerNoTiles !== undefined) { return winnerNoTiles; @@ -123,35 +123,70 @@ export class DominoesGame { } } } + this.logger.debug('Starting player index: ' + startingIndex); return startingIndex === -1 ? 0 : startingIndex; } - async playTurn(): Promise { - const player = this.players[this.currentPlayerIndex]; - console.log(`${player.name}'s turn (${player.hand.length} tiles)`); - printBoard(this.board); - - // let playerMove: PlayerMove | null = null; - // while(playerMove === null) { - // try { - // playerMove = await player.makeMove(this.board); - // } catch (error) { - // this.logger.error(error, 'Error making move'); - // } - // } - const playerMove = await player.makeMove(this.board); - printBoard(this.board, true); - this.lastMove = playerMove; - if (playerMove === null) { - console.log('Player cannot move'); - this.blockedCount += 1; - this.nextPlayer(); - return; - } - this.blockedCount = 0; - this.board.play(playerMove); - player.hand = player.hand.filter(tile => tile !== playerMove.tile); - this.nextPlayer(); + playTurn() { + try { + const player = this.players[this.currentPlayerIndex]; + this.notificationService.sendEventToPlayers('server:next-turn', this.players, this.getGameState()); + this.logger.debug(`${player.name}'s turn (${player.hand.length} tiles)`); + printBoard(this.board) + player.askForMove(this.board); + } catch (error) { + this.logger.error(error, 'Error playing turn'); + } + } + + finishTurn(playerMove: PlayerMove | null) { + try { + this.lastMove = playerMove; + if (playerMove === null) { + console.log('Player cannot move'); + this.blockedCount += 1; + + this.gameBlocked = this.isBlocked(); + if (this.gameBlocked) { + this.gameEnded(); + return; + } + this.nextPlayer(); + this.playTurn(); + return; + } + const player = this.players[this.currentPlayerIndex]; + this.blockedCount = 0; + this.board.play(playerMove); + player.hand = player.hand.filter(tile => tile !== playerMove.tile); + this.notificationService.sendEventToPlayers('server:server-player-move', this.players, { move: playerMove }); + this.canAskNextPlayerMove = false; + // whileNotUndefined(() => this.canAskNextPlayerMove === true ? {} : undefined); + this.gameOver = this.isGameOver(); + if (!this.gameOver) { + this.printPlayersHand(); + this.nextPlayer(); + this.playTurn(); + } else { + this.gameEnded(); + } + } catch (error) { + this.logger.error(error, 'Error finishing move'); + } + } + + gameEnded() { + this.gameInProgress = false; + this.winner = this.getWinner(); + this.setScores(); + const summary = { + gameId: this.id, + isBlocked: this.gameBlocked, + isTied: this.gameTied, + winner: this.winner?.getState(), + score: this.players.map(player => ({name: player.name, score: player.score})) + } + this.emit('game-over', summary); } resetPlayersScore() { @@ -160,63 +195,85 @@ export class DominoesGame { } } - async start(): Promise { - this.resetPlayersScore(); - this.gameInProgress = false; - this.tileSelectionPhase = true; - await this.notificationManager.notifyGameState(this); - await this.notificationManager.notifyPlayersState(this.players); - this.logger.debug('clients received boneyard :>> ' + this.board.boneyard); - await wait(1000); - - if (this.autoDeal) { - this.dealTiles(); - await this.notificationManager.notifyGameState(this); - await this.notificationManager.notifyPlayersState(this.players); - } else { - await this.tilesSelection(); - } - - this.tileSelectionPhase = false; - this.gameInProgress = true; - this.currentPlayerIndex = (this.forcedInitialPlayerIndex !== null) ? this.forcedInitialPlayerIndex : this.getStartingPlayerIndex(); - printLine(`${this.players[this.currentPlayerIndex].name} is the starting player:`); - while (!this.gameOver) { - await this.playTurn(); - await this.notificationManager.notifyGameState(this); - await this.notificationManager.notifyPlayersState(this.players); - this.gameBlocked = this.isBlocked(); - this.gameOver = this.isGameOver(); - } - this.gameInProgress = false; - this.winner = this.getWinner(); - - return { - gameId: this.id, - isBlocked: this.gameBlocked, - isTied: this.gameTied, - winner: this.winner - }; + setCanAskNextPlayerMove(value: boolean) { + this.canAskNextPlayerMove = value } - dealTiles() { + private deal() { + if (this.autoDeal) { + this.autoDealTiles(); + } else { + // await this.tilesSelection(); + } + this.printPlayersHand(); + } + + printPlayersHand() { + for (let player of this.players) { + this.logger.debug(`${player.name}'s hand (${player.hand.length}): ${player.hand.map(tile => tile.toString())}`); + } + } + + async start(): Promise { + try { + // Initalize game + this.gameInProgress = false; + this.resetPlayersScore(); + this.tileSelectionPhase = true; + // await this.notificationManager.notifyGameState(this); + // await this.notificationManager.notifyPlayersState(this.players); + this.deal(); + const extractStates = (p: PlayerInterface) => { + return p.getState() + }; + await this.notificationService.sendEventToPlayers('server:hand-dealt', this.players, extractStates); + + this.tileSelectionPhase = false; + + // Start game + this.gameInProgress = true; + this.currentPlayerIndex = (this.forcedInitialPlayerIndex !== null) ? this.forcedInitialPlayerIndex : this.getStartingPlayerIndex(); + + printLine(`${this.players[this.currentPlayerIndex].name} is the starting player:`); + this.logger.debug("Before play turn") + this.playTurn(); + // await this.notificationManager.notifyGameState(this); + // await this.notificationManager.notifyPlayersState(this.players); + } catch (error) { + this.logger.error(error, 'Error starting game'); + } + } + + setScores() { + const totalPips = this.players.reduce((acc, player) => acc + player.pipsCount(), 0) || 0; + if (this.winner !== null) { + const winner = this.winner; + winner.score = totalPips; + if (winner.teamedWith !== null) { + winner.teamedWith.score = totalPips; + } + } + } + + private autoDealTiles() { for (let i = 0; i < this.handSize; i++) { for (let player of this.players) { const tile: Tile | undefined = this.board.boneyard.pop(); if (tile !== undefined) { tile.revealed = true; + tile.playerId = player.id; player.hand.push(tile); } } } } - async tilesSelection() { + private async tilesSelection() { while (this.board.boneyard.length > 0) { for (let player of this.players) { const choosen = await player.chooseTile(this.board); - await this.notificationManager.notifyGameState(this); - await this.notificationManager.notifyPlayersState(this.players); + // await this.notificationService.notifyGameState(this); + // await this.notificationService.notifyPlayersState(this.players); if (this.board.boneyard.length === 0) { break; } @@ -240,6 +297,7 @@ export class DominoesGame { currentPlayer: currentPlayer.getState(), board: this.board.tiles.map(tile => ({ id: tile.id, + playerId: tile.playerId, pips: tile.pips })), boardFreeEnds: this.board.getFreeEnds(), diff --git a/src/game/MatchSession.ts b/src/game/MatchSession.ts index 41118c3..6959dff 100644 --- a/src/game/MatchSession.ts +++ b/src/game/MatchSession.ts @@ -2,24 +2,30 @@ 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 } from "../common/utilities"; +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 { NetworkPlayer } from "./entities/player/NetworkPlayer"; import { PlayerHuman } from "./entities/player/PlayerHuman"; +import { GameSummary } from "./dto/GameSummary"; +import { PlayerMove } from "./entities/PlayerMove"; 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 playerNotificationManager = new PlayerNotificationService(); + private notificationService = new PlayerNotificationService(); + private winnerIndex: number | null = null; + private clientsReady: string[] = []; id: string; matchInProgress: boolean = false; + gameInProgress: boolean = false; matchWinner?: PlayerInterface = undefined; maxPlayers: number = 4; mode: string = 'classic'; @@ -35,7 +41,7 @@ export class MatchSession { this.seed = seed || getRandomSeed(); this.id = uuid(); this.name = name || `Game ${this.id}`; - this.addPlayer(creator); + this.addPlayerToSession(creator); this.creator = creator; this.logger.info(`Match session created by: ${creator.name}`); @@ -43,7 +49,8 @@ export class MatchSession { this.logger.info(`Match session name: ${this.name}`); this.logger.info(`Points to win: ${this.pointsToWin}`); this.sessionInProgress = true; - this.matchInProgress = false; + this.waitingForPlayers = true; + this.logger.info('Waiting for players to be ready'); } get numPlayers() { @@ -58,56 +65,137 @@ export class MatchSession { return this.players.filter(player => player instanceof PlayerHuman).length; } - private async startMatch(seed: string) { - this.rng = seedrandom(seed); - const missingPlayers = this.maxPlayers - this.numPlayers; - for (let i = 0; i < missingPlayers; i++) { - this.addPlayer(this.createPlayerAI(i)); + 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.state = 'ready' - this.resetScoreboard() - let gameNumber: number = 0; - this.matchInProgress = true - this.playerNotificationManager.notifyMatchState(this); - let winnerIndex: number | null = null; - while (this.matchInProgress) { - this.currentGame = new DominoesGame(this.players, this.rng); - if (winnerIndex !== null) { - this.currentGame.setForcedInitialPlayerIndex(winnerIndex); - } - gameNumber += 1; - this.state = 'started' - this.logger.info(`Game #${gameNumber} started`); - // this.game.reset() - const gameSummary = await this.currentGame.start(); - winnerIndex = this.players.findIndex(player => player.id === gameSummary.winner?.id); - this.setScores(); - this.checkMatchWinner(); - this.resetPlayers(); - this.state = 'waiting' - await this.playerNotificationManager.notifyMatchState(this); - this.playerNotificationManager.sendEventToPlayers('game-finished', this.players); - if (this.matchInProgress) { - await this.checkHumanPlayersReady(); - } - } - this.state = 'end' - // await this.game.start(); - return this.endGame(); } - async checkHumanPlayersReady() { - this.logger.info('Waiting for human players to be ready'); - return new Promise((resolve) => { - const interval = setInterval(() => { - this.logger.debug(`Human players ready: ${this.numPlayersReady}/${this.numHumanPlayers}`) - if (this.numPlayersReady === this.numHumanPlayers) { - clearInterval(interval); - resolve(true); + async checkAllClientsReadyBeforeStart() { + try { + if (this.currentGame) { + const conditionFn = () => { + this.logger.trace(`Clients ready: ${this.clientsReady.length}/${this.numHumanPlayers}`); + return this.clientsReady.length === this.numHumanPlayers } - }, 1000); - }); + await whileNot(conditionFn, 10); + this.logger.info(`Game #${this.gameNumber} started`); + this.currentGame.start(); + this.gameInProgress = true; + } + } catch (error) { + this.logger.error(error, 'Error starting game'); + } } + + getPlayer(userId: string) { + return this.players.find(player => player.id === userId); + } + + playerMove(move: any) { + 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) + this.currentGame.finishTurn(newMove); + } + } + + // This is the entry point for the game, method called by session host + async start() { + if (this.matchInProgress) { + throw new Error("Game already in progress"); + } + this.waitingForPlayers = false; + await this.startMatch(this.seed); + } + + 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.winnerIndex = this.players.findIndex(player => player.id === gameSummary?.winner?.id); + if (this.winnerIndex !== null) { + this.currentGame?.setForcedInitialPlayerIndex(this.winnerIndex); + } + this.setScores(gameSummary || undefined); + this.checkMatchWinner(); + this.resetPlayers(); + if (!this.matchInProgress) { + this.state = 'end' + this.notificationService.sendEventToPlayers('server:match-finished', this.players, { + lastGame: gameSummary, + sessionState: this.getState(), + }); + } else { + this.state = 'waiting' + // await this.playerNotificationManager.notifyMatchState(this); + this.notificationService.sendEventToPlayers('server:game-finished', this.players, { + lastGame: gameSummary, + sessionState: this.getState() + }); + this.waitingForPlayers = true; + } + } + + private startGame() { + this.gameNumber += 1; + this.logger.info(`Game #${this.gameNumber} started`); + this.currentGame = new DominoesGame(this.players, this.rng); + 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.checkAllClientsReadyBeforeStart(); + } + + private async startMatch(seed: string) { + try { + this.state = '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.state = 'error' + } + } + + // async checkHumanPlayersReady() { + // this.logger.info('Waiting for human players to be ready'); + // return new Promise((resolve) => { + // const interval = setInterval(() => { + // this.logger.debug(`Human players ready: ${this.numPlayersReady}/${this.numHumanPlayers}`) + // if (this.numPlayersReady === this.numHumanPlayers) { + // clearInterval(interval); + // resolve(true); + // } + // }, 1000); + // }); + // } resetPlayers() { this.players.forEach(player => { @@ -134,17 +222,19 @@ export class MatchSession { }); } - setScores() { - const totalPips = this.currentGame?.players.reduce((acc, player) => acc + player.pipsCount(), 0) || 0; - if (this.currentGame && this.currentGame.winner !== null) { - const winner = this.currentGame.winner; - const currentPips = this.scoreboard.get(winner.name) || 0; - this.logger.debug (`${winner.name} has ${currentPips} points`); - this.scoreboard.set(winner.name, currentPips + totalPips); - if (winner.teamedWith !== null) { - this.scoreboard.set(winner.teamedWith.name, currentPips + totalPips); - } + setScores(gameSummary?: GameSummary) { + if (!gameSummary) { + return; } + 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 { @@ -155,13 +245,6 @@ export class MatchSession { this.getScore(this.currentGame); this.logger.info('Game ended'); this.currentGame = null; - this.playerNotificationManager.notifyMatchState(this); - - return { - gameBlocked, - gameTied, - winner - }; } } @@ -186,46 +269,23 @@ export class MatchSession { createPlayerAI(i: number) { const AInames = ["Alice (AI)", "Bob (AI)", "Charlie (AI)", "David (AI)"]; - const player = new PlayerAI(AInames[i], this.rng); + const player = new PlayerAI(AInames[i], this.rng, this.id); player.ready = true; return player; } - async start() { - if (this.matchInProgress) { - throw new Error("Game already in progress"); - } - this.waitingForPlayers = true; - this.logger.info('Waiting for players to be ready'); - while (this.numPlayers < this.maxPlayers) { - this.waitingSeconds += 1; - this.logger.info(`Waiting for players to join: ${this.waitingSeconds}`); - await wait(1000); - } - this.waitingForPlayers = false; - this.logger.info('All players joined'); - await this.startMatch(this.seed); - } - - addPlayer(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!`); - } - setPlayerReady(userId: string) { - this.logger.debug(userId) 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.playerNotificationManager.notifyMatchState(this); - } - + this.notificationService.notifyMatchState(this); + if (this.matchInProgress && this.numPlayersReady === this.numHumanPlayers) { + this.startGame(); + } + } toString() { return `GameSession:(${this.id} ${this.name})`; diff --git a/src/game/NetworkClientNotifier.ts b/src/game/NetworkClientNotifier.ts index ce2438c..6c118b0 100644 --- a/src/game/NetworkClientNotifier.ts +++ b/src/game/NetworkClientNotifier.ts @@ -1,5 +1,6 @@ import { NetworkPlayer } from "./entities/player/NetworkPlayer"; import { LoggingService } from "../common/LoggingService"; +import { ServerEvents } from "./constants"; export class NetworkClientNotifier { static instance: NetworkClientNotifier; @@ -17,33 +18,20 @@ export class NetworkClientNotifier { this.io = io; } - async notifyPlayer(player: NetworkPlayer, event: string, data: any = {}, timeoutSecs: number = 900): Promise { - try { - const response = await this.io.to(player.socketId) - .timeout(timeoutSecs * 1000) - .emitWithAck(event, data); - return response[0] - } catch (error) { - this.logger.error(error); - return false; - } - } - async sendEvent(player: NetworkPlayer, event: string, data?: any) { const eventData = { event, data }; - this.io.to(player.socketId).emit('game-event', eventData); + this.io.to(player.socketId).emit(ServerEvents.SERVER_EVENT, eventData); } async sendEventWithAck(player: NetworkPlayer, event: string, data: any, timeoutSecs: number = 900) { - const eventData = { event, data }; - const response = await this.io.to(player.socketId) - .timeout(timeoutSecs * 1000).emitWithAck('game-event-ack', eventData); - return response[0]; - } - - async broadcast(event: string, data: any) { - const responses = await this.io.emit(event, data); - this.logger.debug('responses :>> ', responses); - return true; + try { + const eventData = { event, data }; + const response = await this.io.to(player.socketId) + .timeout(timeoutSecs * 1000).emitWithAck(ServerEvents.SERVER_EVENT_WITH_ACK, eventData); + return response[0]; + } catch (error) { + this.logger.error(error, 'sendEventWithAck error'); + return null; + } } } diff --git a/src/game/PlayerInteractionAI.ts b/src/game/PlayerInteractionAI.ts new file mode 100644 index 0000000..f765fbe --- /dev/null +++ b/src/game/PlayerInteractionAI.ts @@ -0,0 +1,120 @@ +import { LoggingService } from "../common/LoggingService"; +import { printLine, wait } from "../common/utilities"; +import { InteractionService } from "../server/services/InteractionService"; +import { PlayerMoveSide } from "./constants"; +import { Board } from "./entities/Board"; +import { PlayerAI } from "./entities/player/PlayerAI"; +import { PlayerInterface } from "./entities/player/PlayerInterface"; +import { PlayerMove } from "./entities/PlayerMove"; +import { Tile } from "./entities/Tile"; +import { PlayerInteractionInterface } from "./PlayerInteractionInterface"; + +export class PlayerInteractionAI implements PlayerInteractionInterface { + player: PlayerInterface; + interactionService: InteractionService = new InteractionService(); + logger: LoggingService = new LoggingService(); + + constructor(player: PlayerInterface) { + this.player = player; + } + + askForMove(board: Board): void { + this.logger.trace('Asking for move (AI)'); + let move: PlayerMove | null = null; + if (board.tiles.length === 0) { + printLine('playing the first tile'); + const highestPair = this.getHighestPair(); + if (highestPair !== null) { + move = new PlayerMove(highestPair, PlayerMoveSide.BOTH, this.player.id); + } + const maxTile = this.getMaxTile(); + if (maxTile !== null) { + move = new PlayerMove(maxTile, PlayerMoveSide.BOTH, this.player.id); + } + } else { + move = this.chooseTileGreed(board); + } + const rndWait = Math.floor(Math.random() * 1500) + 2000; + setTimeout(() => { + this.interactionService.playerMove({ + sessionId: (this.player).sessionId, + move + }); + this.logger.trace('Move sent to server (AI'); + }, rndWait); + } + + async makeMove(board: Board): Promise { + const rndWait = Math.floor(Math.random() * 1000) + 1000; + await wait(rndWait); // Simulate thinking time + if (board.tiles.length === 0) { + printLine('playing the first tile'); + const highestPair = this.getHighestPair(); + if (highestPair !== null) { + return new PlayerMove(highestPair, PlayerMoveSide.BOTH, this.player.id); + } + const maxTile = this.getMaxTile(); + if (maxTile !== null) { + return new PlayerMove(maxTile, PlayerMoveSide.BOTH, this.player.id); + } + } + // Analyze the game state + // Return the best move based on strategy + return this.chooseTileGreed(board); + } + + async chooseTile(board: Board): Promise { + const randomWait = Math.floor((Math.random() * 1000) + 500); + await wait(randomWait); // Simulate thinking time + const randomIndex = Math.floor((this.player).rng() * board.boneyard.length); + const tile = board.boneyard.splice(randomIndex, 1)[0]; + this.player.hand.push(tile); + printLine(`${this.player.name} has chosen a tile`); + return tile; + } + + private getHighestPair(): Tile | null { + if (this.player.hand.length === 0) { + return null; + } + + let highestPair: Tile | null = null; + const pairs = this.player.hand.filter(tile => tile.pips[0] === tile.pips[1]); + pairs.forEach(tile => { + if (tile.count > (highestPair?.count ?? 0)) { + highestPair = tile; + } + }); + return highestPair; + } + + private chooseTileGreed(board: Board): PlayerMove | null { // greed algorithm + let bestMove: PlayerMove |null = null; + let bestTileScore: number = -1; + const validMoves: PlayerMove[] = board.getValidMoves(this.player); + + validMoves.forEach(move => { + const { tile } = move; + const tileScore = tile.pips[0] + tile.pips[1]; + if (tileScore > bestTileScore) { + bestMove = move; + bestTileScore = tileScore; + } + }); + return bestMove; + } + + getMaxTile(): Tile | null { + if (this.player.hand.length === 0) { + return null; + } + + let maxTile: Tile | null = null; + this.player.hand.forEach(tile => { + if (tile.count > (maxTile?.count ?? 0)) { + maxTile = tile; + } + }); + return maxTile; + } +} \ No newline at end of file diff --git a/src/game/PlayerInteractionConsole.ts b/src/game/PlayerInteractionConsole.ts index 876fa3d..fde03e5 100644 --- a/src/game/PlayerInteractionConsole.ts +++ b/src/game/PlayerInteractionConsole.ts @@ -5,14 +5,20 @@ import { PlayerMove } from "./entities/PlayerMove"; import { Tile } from "./entities/Tile"; import { PlayerMoveSide, PlayerMoveSideType } from "./constants"; import { PlayerInteractionInterface } from "./PlayerInteractionInterface"; +import { InteractionService } from "../server/services/InteractionService"; export class PlayerInteractionConsole implements PlayerInteractionInterface { player: PlayerInterface; + interactionService: InteractionService = new InteractionService(); constructor(player: PlayerInterface) { this.player = player; } + askForMove(board: Board): void { + wait(100) + } + async makeMove(board: Board): Promise { let move: PlayerMove | null = null; let tile: Tile; diff --git a/src/game/PlayerInteractionInterface.ts b/src/game/PlayerInteractionInterface.ts index 0c3a382..b969b74 100644 --- a/src/game/PlayerInteractionInterface.ts +++ b/src/game/PlayerInteractionInterface.ts @@ -1,3 +1,4 @@ +import { InteractionService } from "../server/services/InteractionService"; import { Board } from "./entities/Board"; import { PlayerInterface } from "./entities/player/PlayerInterface"; import { PlayerMove } from "./entities/PlayerMove"; @@ -5,7 +6,9 @@ import { Tile } from "./entities/Tile"; export interface PlayerInteractionInterface { player: PlayerInterface; + interactionService: InteractionService; + askForMove(board: Board): void; makeMove(board: Board): Promise; chooseTile(board: Board): Promise } \ No newline at end of file diff --git a/src/game/PlayerInteractionNetwork.ts b/src/game/PlayerInteractionNetwork.ts index cc55455..9f92009 100644 --- a/src/game/PlayerInteractionNetwork.ts +++ b/src/game/PlayerInteractionNetwork.ts @@ -7,20 +7,30 @@ import { NetworkClientNotifier } from './NetworkClientNotifier'; import { NetworkPlayer } from './entities/player/NetworkPlayer'; import { PlayerMoveSide, PlayerMoveSideType } from './constants'; import { SocketDisconnectedError } from '../common/errors/SocketDisconnectedError'; +import { InteractionService } from '../server/services/InteractionService'; export class PlayerInteractionNetwork implements PlayerInteractionInterface { player: PlayerInterface; + interactionService: InteractionService = new InteractionService(); clientNotifier = new NetworkClientNotifier(); constructor(player: PlayerInterface) { this.player = player; } + + askForMove(board: Board): void { + this.clientNotifier.sendEvent(this.player as NetworkPlayer, 'server:player-turn', { + freeHands: board.getFreeEnds(), + isFirstMove: board.tiles.length === 0 + }); + } async makeMove(board: Board): Promise { let response = undefined; try { response = await this.clientNotifier.sendEventWithAck(this.player as NetworkPlayer, 'ask-client-for-move', { freeHands: board.getFreeEnds(), + isFirstMove: board.tiles.length === 0 }); } catch (error) { throw new SocketDisconnectedError(); @@ -39,7 +49,7 @@ export class PlayerInteractionNetwork implements PlayerInteractionInterface { async chooseTile(board: Board): Promise { const { player: { hand} } = this; - const response: any = await this.clientNotifier.notifyPlayer(this.player as NetworkPlayer, 'chooseTile'); + const response: any = await this.clientNotifier.sendEventWithAck(this.player as NetworkPlayer, 'ask-client-for-tile', { boneyard: board.boneyard }) const index: number = board.boneyard.findIndex(t => t.id === response.tileId); const tile = board.boneyard.splice(index, 1)[0]; tile.revealed = true; diff --git a/src/game/constants.ts b/src/game/constants.ts index 38b446f..3507814 100644 --- a/src/game/constants.ts +++ b/src/game/constants.ts @@ -13,3 +13,20 @@ export const JointValue: { [key: string]: JointValueType } = { RIGHT: 1, NONE: 2 }; + +export const ClientEvents = { + CLIENT_EVENT: 'client:event', + CLIENT_EVENT_WITH_ACK: 'client:event-with-ack' + +}; + +export const ServerEvents = { + SERVER_EVENT: 'server:game-event', + SERVER_EVENT_WITH_ACK: 'server:game-event-ack' +}; + +export const EventActions = { + START_SESSION: 'client:start-session', + PLAYER_READY: 'client:set-player-ready', + TILE_ANIMATION_ENDED: 'client:tile-animation-ended' +}; diff --git a/src/game/dto/GameSummary.ts b/src/game/dto/GameSummary.ts index 347054f..36fdec3 100644 --- a/src/game/dto/GameSummary.ts +++ b/src/game/dto/GameSummary.ts @@ -1,8 +1,9 @@ -import { PlayerInterface } from "../entities/player/PlayerInterface"; +import { PlayerDto } from "./PlayerDto"; export interface GameSummary { gameId: string; isBlocked: boolean; isTied: boolean; - winner: PlayerInterface | null; + winner: PlayerDto; + score: { name: string; score: number; }[] } \ No newline at end of file diff --git a/src/game/dto/MatchSummary.ts b/src/game/dto/MatchSummary.ts new file mode 100644 index 0000000..2e1f6f9 --- /dev/null +++ b/src/game/dto/MatchSummary.ts @@ -0,0 +1,6 @@ +import { GameSummary } from "./GameSummary"; + +export interface MatchSummary { + lastGameSummary: GameSummary; + scoreboard: { player: string; score: number; }[]; +} \ No newline at end of file diff --git a/src/game/entities/Board.ts b/src/game/entities/Board.ts index 10562ad..7d60a41 100644 --- a/src/game/entities/Board.ts +++ b/src/game/entities/Board.ts @@ -3,10 +3,12 @@ import { PlayerMoveSideType, PlayerMoveSide, JointValue } from "../constants"; import { PlayerInterface } from "./player/PlayerInterface"; import { PlayerMove } from "./PlayerMove"; import { Tile } from "./Tile"; +import { LoggingService } from "../../common/LoggingService"; export class Board { tiles: Tile[] = []; boneyard: Tile[] = []; + logger = new LoggingService(); constructor(private rng: PRNG) {} @@ -52,6 +54,8 @@ export class Board { play(playerMove: PlayerMove): void { const { type, tile } = playerMove; + + const boneTile = this.boneyard.find(t => t.id === tile.id); tile.revealed = true; if (type === PlayerMoveSide.LEFT) { this.playTileLeft(tile); diff --git a/src/game/entities/Tile.ts b/src/game/entities/Tile.ts index b6dcf50..160e809 100644 --- a/src/game/entities/Tile.ts +++ b/src/game/entities/Tile.ts @@ -5,6 +5,7 @@ export class Tile { pips: [number, number]; revealed: boolean = true; flipped: boolean = false; + playerId?: string; constructor(pips: [number, number]) { this.id = uuid(); diff --git a/src/game/entities/player/AbstractPlayer.ts b/src/game/entities/player/AbstractPlayer.ts index c8b8ee0..55dc10e 100644 --- a/src/game/entities/player/AbstractPlayer.ts +++ b/src/game/entities/player/AbstractPlayer.ts @@ -19,11 +19,19 @@ export abstract class AbstractPlayer extends EventEmitter implements PlayerInter constructor(public name: string) { super(); + } + + askForMove(board: Board): void { + this.playerInteraction.askForMove(board); } - abstract makeMove(board: Board): Promise; - abstract chooseTile(board: Board): Promise; - + async makeMove(board: Board): Promise { + return await this.playerInteraction.makeMove(board); + } + + async chooseTile(board: Board): Promise { + return this.playerInteraction.chooseTile(board); + } async sendEventWithAck(event: string, data: any): Promise { } @@ -41,20 +49,7 @@ export abstract class AbstractPlayer extends EventEmitter implements PlayerInter return this.hand.reduce((acc, tile) => acc + tile.pips[0] + tile.pips[1], 0); } - getHighestPair(): Tile | null { - if (this.hand.length === 0) { - return null; - } - - let highestPair: Tile | null = null; - const pairs = this.hand.filter(tile => tile.pips[0] === tile.pips[1]); - pairs.forEach(tile => { - if (tile.count > (highestPair?.count ?? 0)) { - highestPair = tile; - } - }); - return highestPair; - } + getState(showPips: boolean = false): PlayerDto { return { @@ -66,6 +61,7 @@ export abstract class AbstractPlayer extends EventEmitter implements PlayerInter id: tile.id, pips: tile.pips, flipped: tile.revealed, + playerId: tile.playerId, }; if (showPips) { d.pips = tile.pips; diff --git a/src/game/entities/player/PlayerAI.ts b/src/game/entities/player/PlayerAI.ts index 2729366..06d5572 100644 --- a/src/game/entities/player/PlayerAI.ts +++ b/src/game/entities/player/PlayerAI.ts @@ -6,71 +6,17 @@ import { PlayerMove } from "../PlayerMove"; import { SimulatedBoard } from "../../SimulatedBoard"; import { Tile } from "../Tile"; import { PRNG } from "seedrandom"; +import { PlayerInteractionInterface } from "../../PlayerInteractionInterface"; +import { PlayerInteractionAI } from "../../PlayerInteractionAI"; +import { InteractionService } from "../../../server/services/InteractionService"; export class PlayerAI extends AbstractPlayer { - constructor(name: string, private rng: PRNG) { + playerInteraction: PlayerInteractionInterface = new PlayerInteractionAI(this); + + constructor(name: string, public rng: PRNG, public sessionId: string) { super(name); - } - - async makeMove(board: Board): Promise { - const rndWait = Math.floor(Math.random() * 1000) + 1000; - await wait(rndWait); // Simulate thinking time - if (board.tiles.length === 0) { - printLine('playing the first tile'); - const highestPair = this.getHighestPair(); - if (highestPair !== null) { - return new PlayerMove(highestPair, PlayerMoveSide.BOTH, this.id); - } - const maxTile = this.getMaxTile(); - if (maxTile !== null) { - return new PlayerMove(maxTile, PlayerMoveSide.BOTH, this.id); - } - } - // Analyze the game state - // Return the best move based on strategy - return this.chooseTileGreed(board); - } - - async chooseTile(board: Board): Promise { - const randomWait = Math.floor((Math.random() * 1000) + 500); - await wait(randomWait); // Simulate thinking time - const randomIndex = Math.floor(this.rng() * board.boneyard.length); - const tile = board.boneyard.splice(randomIndex, 1)[0]; - this.hand.push(tile); - printLine(`${this.name} has chosen a tile`); - return tile; - } - - getMaxTile(): Tile | null { - if (this.hand.length === 0) { - return null; - } - - let maxTile: Tile | null = null; - this.hand.forEach(tile => { - if (tile.count > (maxTile?.count ?? 0)) { - maxTile = tile; - } - }); - return maxTile; } - chooseTileGreed(board: Board): PlayerMove | null { // greed algorithm - let bestMove: PlayerMove |null = null; - let bestTileScore: number = -1; - const validMoves: PlayerMove[] = board.getValidMoves(this); - - validMoves.forEach(move => { - const { tile } = move; - const tileScore = tile.pips[0] + tile.pips[1]; - if (tileScore > bestTileScore) { - bestMove = move; - bestTileScore = tileScore; - } - }); - return bestMove; - } - chooseTileRandom(board: Board): Tile | null { // random algorithm const validTiles: Tile[] = this.hand.filter(tile => board.isValidMove(tile, null, this)); return validTiles[Math.floor(this.rng() * validTiles.length)]; diff --git a/src/game/entities/player/PlayerHuman.ts b/src/game/entities/player/PlayerHuman.ts index 605427b..bb6a12a 100644 --- a/src/game/entities/player/PlayerHuman.ts +++ b/src/game/entities/player/PlayerHuman.ts @@ -12,12 +12,4 @@ export class PlayerHuman extends AbstractPlayer { super(name); this.id = id; } - - async makeMove(board: Board): Promise { - return await this.playerInteraction.makeMove(board); - } - - async chooseTile(board: Board): Promise { - return this.playerInteraction.chooseTile(board); - } } \ No newline at end of file diff --git a/src/game/entities/player/PlayerInterface.ts b/src/game/entities/player/PlayerInterface.ts index e4b38fd..73376d4 100644 --- a/src/game/entities/player/PlayerInterface.ts +++ b/src/game/entities/player/PlayerInterface.ts @@ -14,8 +14,9 @@ export interface PlayerInterface { teamedWith: PlayerInterface | null; playerInteraction: PlayerInteractionInterface; ready: boolean; - - makeMove(gameState: Board): Promise; + + askForMove(board: Board): void; + makeMove(board: Board): Promise; chooseTile(board: Board): Promise; pipsCount(): number; reset(): void; diff --git a/src/server/controllers/AuthController.ts b/src/server/controllers/AuthController.ts index 44dfdce..abccd04 100644 --- a/src/server/controllers/AuthController.ts +++ b/src/server/controllers/AuthController.ts @@ -124,14 +124,14 @@ export class AuthController extends BaseController { return false; } - const tokenFromDb = await new ApiTokenMongoManager().getById(token._id.toString()); + const tokenFromDb: Token = await new ApiTokenMongoManager().getById(token._id.toString()) as Token; if (!tokenFromDb) { return false; } const { roles } = tokenFromDb; - const validRoles = rolesToCheck.filter((r: string) => roles.includes(r)); + const validRoles = rolesToCheck.filter((r: string) => roles?.includes(r) || false); return validRoles.length === rolesToCheck.length; } diff --git a/src/server/controllers/GameController.ts b/src/server/controllers/GameController.ts index 1ae0519..ad07bbe 100644 --- a/src/server/controllers/GameController.ts +++ b/src/server/controllers/GameController.ts @@ -16,22 +16,42 @@ export class GameController extends BaseController { } } - public joinMatch(req: Request, res: Response) { + public async joinMatch(req: Request, res: Response) { try { - const { user, body } = req; - const { sessionId } = body; - this.sessionService.joinSession(user, sessionId); + const { user, params } = req; + const { sessionId } = params; + await this.sessionService.joinSession(user, sessionId); res.status(200).json({ status: 'ok' }); } catch (error) { this.handleError(res, error); } } - public listMatches(req: Request, res: Response) { + public async listMatches(req: Request, res: Response) { try { - this.sessionService.listSessions().then((sessions) => { - res.status(200).json(sessions); - }); + const sessions = await this.sessionService.listJoinableSessions() + res.status(200).json(sessions); + } catch (error) { + this.handleError(res, error); + } + } + + public async getMatch(req: Request, res: Response) { + try { + const { sessionId } = req.params; + const session = this.sessionService.getSession(sessionId) + res.status(200).json(session); + } catch (error) { + this.handleError(res, error); + } + } + + public async deleteMatch(req: Request, res: Response) { + try { + const { sessionId } = req.params; + await this.sessionService.deleteSession(sessionId); + this.logger.info(`Session ${sessionId} deleted`); + res.status(200).json({ status: 'ok' }); } catch (error) { this.handleError(res, error); } diff --git a/src/server/db/interfaces.ts b/src/server/db/interfaces.ts index 8f76ca6..20944e0 100644 --- a/src/server/db/interfaces.ts +++ b/src/server/db/interfaces.ts @@ -39,6 +39,10 @@ export interface User extends EntityMongo { namespace?: Namespace; } +export interface DbMatchSessionUpdate extends EntityMongo { + state?: string; +} + export interface DbMatchSession extends EntityMongo { id: string; name: string; diff --git a/src/server/db/mongo/common/BaseMongoManager.ts b/src/server/db/mongo/common/BaseMongoManager.ts index 8b073b8..72b676b 100644 --- a/src/server/db/mongo/common/BaseMongoManager.ts +++ b/src/server/db/mongo/common/BaseMongoManager.ts @@ -1,6 +1,6 @@ import { ObjectId } from "mongodb"; import { mongoExecute } from "./mongoDBPool"; -import { Entity } from "../../interfaces"; +import { Entity, EntityMongo } from "../../interfaces"; import { LoggingService } from "../../../../common/LoggingService"; import toObjectId from "./mongoUtils"; @@ -9,7 +9,8 @@ export abstract class BaseMongoManager { protected abstract collection?: string; logger = new LoggingService().logger; - create(data: Entity): Promise{ + async create(data: Entity): Promise { + this.stampEntity(data); return mongoExecute( async ({ collection }) => { const result = await collection?.insertOne(data as any); @@ -19,25 +20,27 @@ export abstract class BaseMongoManager { ); } - delete(id: string) { + async delete(id: string): Promise { return mongoExecute( async ({ collection }) => { - await collection?.deleteOne({ _id: this.toObjectId(id) }); + const result = await collection?.deleteOne({ _id: this.toObjectId(id) }); + return result?.deletedCount || 0; }, { colName: this.collection } ); } - deleteByFilter(filter: any) { + async deleteByFilter(filter: any): Promise { return mongoExecute( async ({ collection }) => { - await collection?.deleteOne(filter); + const result = await collection?.deleteOne(filter); + return result?.deletedCount || 0; }, { colName: this.collection } ); } - getById(id: string) { + async getById(id: string): Promise { return mongoExecute( async ({ collection }) => { return await collection?.findOne({ _id: this.toObjectId(id) }); @@ -46,7 +49,7 @@ export abstract class BaseMongoManager { ); } - getByFilter(filter: any) { + async getByFilter(filter: any): Promise { return mongoExecute( async ({ collection }) => { return await collection?.findOne(filter); @@ -55,51 +58,71 @@ export abstract class BaseMongoManager { ); } - list() { + async list(sortCriteria?: any, pagination?: {pageSize: number, page: number}): Promise { return mongoExecute( async ({ collection }) => { - return await collection?.find().toArray(); + const cursor = collection?.find(); + if (sortCriteria) { + cursor?.sort(sortCriteria); + } + if (pagination) { + cursor?.skip(pagination.pageSize * (pagination.page - 1)).limit(pagination.pageSize); + } + return await cursor?.toArray(); }, { colName: this.collection } ); } - listByFilter(filter: any) { + async listByFilter(filter: any, sortCriteria?: any, pagination?: {pageSize: number, page: number}): Promise { return mongoExecute( async ({ collection }) => { - return await collection?.find(filter).toArray(); + const cursor = collection?.find(filter); + if (sortCriteria) { + cursor?.sort(sortCriteria); + } + if (pagination) { + cursor?.skip(pagination.pageSize * (pagination.page - 1)).limit(pagination.pageSize); + } + return await cursor?.toArray(); }, { colName: this.collection } ); } - update(object: Entity) { + async update(object: EntityMongo): Promise { const data: any = { ...object }; const id = data._id; - delete data._id; + this.stampEntity(data, false); + delete data._id; return mongoExecute(async ({ collection }) => { - return await collection?.updateOne( + const result = await collection?.updateOne( { _id: this.toObjectId(id) }, { $set: data } ); + return result?.modifiedCount || 0; }, { colName: this.collection }); } - updateMany(filter: any, data: Entity) { + async updateMany(filter: any, data: Entity): Promise{ + + this.stampEntity(data, false); return mongoExecute(async ({ collection }) => { - return await collection?.updateMany(filter, { $set: data as any }); + const result = await collection?.updateMany(filter, { $set: data as any }); + return result?.modifiedCount || 0; }, { colName: this.collection }); } - replaceOne(filter: any, object: Entity) { + async replaceOne(filter: any, object: Entity): Promise { return mongoExecute(async ({collection}) => { - return await collection?.replaceOne(filter, object); + const result = await collection?.replaceOne(filter, object); + return result?.modifiedCount || 0; }, {colName: this.collection}); } - aggregation(pipeline: any) { + async aggregation(pipeline: any): Promise { return mongoExecute( async ({ collection }) => { return await collection?.aggregate(pipeline).toArray(); @@ -108,7 +131,7 @@ export abstract class BaseMongoManager { ); } - aggregationOne(pipeline: any) { + async aggregationOne(pipeline: any): Promise { return mongoExecute( async ({ collection }) => { return await collection?.aggregate(pipeline).next(); @@ -120,4 +143,11 @@ export abstract class BaseMongoManager { protected toObjectId = (oid: string) => { return toObjectId(oid); }; + + protected stampEntity(entity: Entity, isCreate: boolean = true) { + if (isCreate) { + entity.createdAt = Date.now(); + } + entity.modifiedAt = Date.now(); + } } diff --git a/src/server/managers/SessionManager.ts b/src/server/managers/SessionManager.ts index ccc8bdb..47be9d5 100644 --- a/src/server/managers/SessionManager.ts +++ b/src/server/managers/SessionManager.ts @@ -28,8 +28,8 @@ export class SessionManager extends ManagerBase { SessionManager.sessions.set(session.id, session); } - deleteSession(session: MatchSession) { - SessionManager.sessions.delete(session.id); + deleteSession(sessionId: string) { + SessionManager.sessions.delete(sessionId); } getSession(id: string) { diff --git a/src/server/router/gameRouter.ts b/src/server/router/gameRouter.ts index bd4fd45..2f0736b 100644 --- a/src/server/router/gameRouter.ts +++ b/src/server/router/gameRouter.ts @@ -10,8 +10,10 @@ export default function(): Router { const { authenticate } = AuthController router.post('/match', authenticate({ roles: ['user']}), (req: Request, res: Response) => gameController.createMatch(req, res)); - router.patch('/match/join', authenticate({ roles: ['user']}), (req: Request, res: Response) => gameController.joinMatch(req, res)); - router.get('/match/list', authenticate({ roles: ['user']}), (req: Request, res: Response) => gameController.listMatches(req, res)); + router.get('/match', authenticate({ roles: ['user']}), (req: Request, res: Response) => gameController.listMatches(req, res)); + router.get('/match/:sessionId', authenticate({ roles: ['user']}), (req: Request, res: Response) => gameController.getMatch(req, res)); + router.put('/match/:sessionId', authenticate({ roles: ['user']}), (req: Request, res: Response) => gameController.joinMatch(req, res)); + router.delete('/match/:sessionId', authenticate({ roles: ['user']}), (req: Request, res: Response) => gameController.deleteMatch(req, res)); return router; } diff --git a/src/server/services/InteractionService.ts b/src/server/services/InteractionService.ts new file mode 100644 index 0000000..007d1aa --- /dev/null +++ b/src/server/services/InteractionService.ts @@ -0,0 +1,110 @@ +import PubSub from "pubsub-js"; +import { EventActions } from "../../game/constants"; +import { MatchSession } from "../../game/MatchSession"; +import { SessionManager } from "../managers/SessionManager"; +import { PlayerNotificationService } from "./PlayerNotificationService"; +import { ServiceBase } from "./ServiceBase"; +import { PlayerMove } from "../../game/entities/PlayerMove"; + +export class InteractionService extends ServiceBase{ + private sessionManager: SessionManager = new SessionManager(); + private notifyService = new PlayerNotificationService(); + + async handleClientEventWithAck(data: any): Promise { + const { event, data: eventData } = data; + this.logger.trace(`Handling event: ${event} with ack`); + switch(event) { + case EventActions.START_SESSION: + return this.onStartSession(eventData); + default: + // PubSub.publish(event, eventData); + break; + } + } + + handleClientEvent(data: any): any { + const { event, data: eventData } = data; + this.logger.trace(`Handling event: ${event}`); + switch(event) { + case 'client:player-move': + this.onClientMoveResponse(eventData); + break; + case 'client:set-client-ready': + this.onClientReady(eventData); + break; + case EventActions.PLAYER_READY: + this.onPlayerReady(eventData); + break; + default: + PubSub.publish(event, eventData); + break; + } + } + + private onStartSession(data: any): any { + const sessionId: string = data.sessionId; + const session: MatchSession | undefined = this.sessionManager.getSession(sessionId); + + if (session === undefined) { + return ({ + status: 'error', + message: 'Session not found' + }); + } else if (session.matchInProgress) { + return { + status: 'error', + message: 'Game already in progress' + }; + } else { + const missingHumans = session.maxPlayers - session.numPlayers; + for (let i = 0; i < missingHumans; i++) { + session.addPlayerToSession(session.createPlayerAI(i)); + } + this.notifyService.sendEventToPlayers('server:match-starting', session.players); + session.start(); + return { + status: 'ok' + }; + } + } + + public playerMove(data: any) { + this.onClientMoveResponse(data); + } + + private onClientMoveResponse(data: any): any { + const { sessionId, move }: { sessionId: string, move: PlayerMove } = data; + const session: MatchSession | undefined = this.sessionManager.getSession(sessionId); + if (session !== undefined) { + session.playerMove(move); + return { + status: 'ok' + }; + } + } + + private onPlayerReady(data: any): any { + const { userId, sessionId } = data; + const session: MatchSession | undefined = this.sessionManager.getSession(sessionId); + if (session !== undefined) { + session.setPlayerReady(userId) + this.notifyService.notifyMatchState(session); + this.notifyService.notifyPlayersState(session.players); + } + } + + private onClientReady(data: any): any { + const { sessionId, userId } = data; + const session: MatchSession | undefined = this.sessionManager.getSession(sessionId); + session?.setClientReady(userId); + return { + status: 'ok' + } + } + + public updateSocketId(sessionId: string, userId: string, socketId: string): any { + return this.sessionManager.updateSocketId(sessionId, userId, socketId); + } + + +} \ No newline at end of file diff --git a/src/server/services/PlayerNotificationService.ts b/src/server/services/PlayerNotificationService.ts index 26205a4..643a5a7 100644 --- a/src/server/services/PlayerNotificationService.ts +++ b/src/server/services/PlayerNotificationService.ts @@ -1,35 +1,42 @@ import { DominoesGame } from "../../game/DominoesGame"; import { MatchSession } from "../../game/MatchSession"; +import { NetworkClientNotifier } from "../../game/NetworkClientNotifier"; import { GameState } from "../../game/dto/GameState"; +import { NetworkPlayer } from "../../game/entities/player/NetworkPlayer"; import { PlayerInterface } from "../../game/entities/player/PlayerInterface"; +import { ServiceBase } from "./ServiceBase"; -export class PlayerNotificationService { +export class PlayerNotificationService extends ServiceBase { + clientNotifier: NetworkClientNotifier = new NetworkClientNotifier(); - async notifyGameState(game: DominoesGame) { + notifyGameState(game: DominoesGame) { const gameState: GameState = game.getGameState(); const { players } = game; - let promises: Promise[] = players.map(player => player.sendEventWithAck('update-game-state', gameState)); - return await Promise.all(promises); + players.map(player => player.sendEvent('update-game-state', gameState)); } - async notifyPlayersState(players: PlayerInterface[]) { - let promises: Promise[] = players.map(player => player.sendEventWithAck('update-player-state', player.getState())); - return await Promise.all(promises); + notifyPlayersState(players: PlayerInterface[]) { + players.map(player => player.sendEvent('update-player-state', player.getState())); } - async notifyMatchState(session: MatchSession) { + notifyMatchState(session: MatchSession) { const { players } = session; - let promises: Promise[] = players.map(player => player.sendEventWithAck('update-match-session-state', session.getState())); - return await Promise.all(promises); + players.map(player => player.sendEvent('update-match-session-state', session.getState())); } - async sendEventToPlayers(event: string, players: PlayerInterface[], data: any = {}) { - let promises: Promise[] = players.map(player => player.sendEvent(event, data)); - return await Promise.all(promises); + async sendEventToPlayers(event: string, players: PlayerInterface[], data: Function | any = {}) { + players.forEach((player) => { + let dataTosend = data; + if (typeof data === 'function') { + dataTosend = data(player); + } + this.clientNotifier.sendEvent(player as NetworkPlayer, event, dataTosend); + }); } - async sendEvent(event: string, player: PlayerInterface, data: any = {}) { - player.sendEvent(event, data) + sendEvent(event: string, player: PlayerInterface, data: any = {}) { + this.logger.debug(`Sending event '${event}' to player ${player.id}`); + this.clientNotifier.sendEvent(player as NetworkPlayer, event, data); } } \ No newline at end of file diff --git a/src/server/services/SessionService.ts b/src/server/services/SessionService.ts index af67951..a3a2b07 100644 --- a/src/server/services/SessionService.ts +++ b/src/server/services/SessionService.ts @@ -1,15 +1,16 @@ import { SessionCreationError } from "../../common/errors/SessionCreationError"; import { SessionNotFoundError } from "../../common/errors/SessionNotFoundError"; -import { wait, whileNotUndefined } from "../../common/utilities"; +import { whileNotUndefined } from "../../common/utilities"; import { NetworkPlayer } from "../../game/entities/player/NetworkPlayer"; import { MatchSession } from "../../game/MatchSession"; import { PlayerNotificationService } from "./PlayerNotificationService"; import { matchSessionAdapter } from "../db/DbAdapter"; -import { DbMatchSession } from "../db/interfaces"; +import { DbMatchSession, DbMatchSessionUpdate } from "../db/interfaces"; import { MatchSessionMongoManager } from "../db/mongo/MatchSessionMongoManager"; import { SessionManager } from "../managers/SessionManager"; import { ServiceBase } from "./ServiceBase"; import { SocketIoService } from "./SocketIoService"; +import toObjectId from "../db/mongo/common/mongoUtils"; export class SessionService extends ServiceBase{ private dbManager: MatchSessionMongoManager = new MatchSessionMongoManager(); @@ -37,68 +38,48 @@ export class SessionService extends ServiceBase{ this.sessionManager.setSession(session); this.notifyService.notifyMatchState(session); this.notifyService.notifyPlayersState(session.players); + this.logger.debug(`Session ${session.id} created`); return session.id; } - public async joinSession(user: any, sessionId: string): Promise { + public async joinSession(user: any, sessionId: string): Promise { const session: MatchSession | undefined = this.sessionManager.getSession(sessionId); if (session === undefined) { throw new SessionNotFoundError(); - } let socketClient; + } + let socketClient; try { socketClient = await whileNotUndefined(() => SocketIoService.getClient(user._id)); } catch (error) { throw new SessionCreationError(); } - const player = new NetworkPlayer(user._id, user.name, socketClient.socketId); - session.addPlayer(player); - socketClient.sessionId = session.id; + const player = new NetworkPlayer(user._id, user.username, socketClient.socketId); this.dbManager.replaceOne({id: session.id}, matchSessionAdapter(session)); + session.addPlayerToSession(player); + socketClient.sessionId = session.id; + this.notifyService.notifyMatchState(session); + this.notifyService.notifyPlayersState(session.players); + return sessionId } - public listSessions(): Promise { - return this.dbManager.listByFilter({}); + public async listJoinableSessions(): Promise { + return await this.dbManager.listByFilter( + { state: 'created' }, + { createdAt: -1 }, + { page: 1, pageSize: 5 }) as DbMatchSession[]; } - public updateSocketId(sessionId: string, userId: string, socketId: string): any { - this.sessionManager.updateSocketId(sessionId, userId, socketId); + public async getSession(sessionId: string): Promise { + return await this.dbManager.getById(sessionId) as DbMatchSession | undefined; } - setPlayerReady(data: any): any { - const { userId, sessionId } = data; - const session: MatchSession | undefined = this.sessionManager.getSession(sessionId); - if (session !== undefined) { - session.setPlayerReady(userId) - this.notifyService.notifyMatchState(session); - this.notifyService.notifyPlayersState(session.players); - } - } - - startSession(data: any): any { - const sessionId: string = data.sessionId; - const seed: string | undefined = data.seed; - const session: MatchSession | undefined = this.sessionManager.getSession(sessionId); - - if (session === undefined) { - return ({ - status: 'error', - message: 'Session not found' - }); - } else if (session.matchInProgress) { - return { - status: 'error', - message: 'Game already in progress' - }; - } else { - const missingHumans = session.maxPlayers - session.numPlayers; - for (let i = 0; i < missingHumans; i++) { - session.addPlayer(session.createPlayerAI(i)); - } - session.start(); - return { - status: 'ok' - }; - } + public async deleteSession(sessionId: string): Promise { + this.sessionManager.deleteSession(sessionId); + const session = { + _id: toObjectId(sessionId), + state: 'deleted' + } as DbMatchSessionUpdate; + return this.dbManager.update(session); } // public updateSession(session: MatchSession): any { diff --git a/src/server/services/SocketIoService.ts b/src/server/services/SocketIoService.ts index fd5351a..0fe7590 100644 --- a/src/server/services/SocketIoService.ts +++ b/src/server/services/SocketIoService.ts @@ -1,16 +1,16 @@ import { Server as HttpServer } from "http"; import { ServiceBase } from "./ServiceBase"; import { Server } from "socket.io"; -import { SessionManager } from "../managers/SessionManager"; import { SecurityManager } from "../managers/SecurityManager"; import { User } from "../db/interfaces"; import { Socket } from "socket.io"; -import { SessionService } from "./SessionService"; +import { InteractionService } from "./InteractionService"; +import { ClientEvents } from "../../game/constants"; export class SocketIoService extends ServiceBase{ io: Server private static clients: Map = new Map(); - private sessionService: SessionService = new SessionService(); + private interactionService: InteractionService = new InteractionService(); static getClient(id: string) { return this.clients.get(id); @@ -65,7 +65,7 @@ export class SocketIoService extends ServiceBase{ socket.join('room-general') } else { const client = SocketIoService.clients.get(userId); - this.sessionService.updateSocketId(client.sessionId, userId, socketId); + this.interactionService.updateSocketId(client.sessionId, userId, socketId); client.socketId = socketId; this.logger.debug(`User '${user.username}' already connected. Updating socketId to ${socketId}`); client.alive = true; @@ -82,25 +82,14 @@ export class SocketIoService extends ServiceBase{ SocketIoService.clients.delete(id); } }); - - // socket.on('createSession', (data, callback) => { - // const response = sessionController.createSession(data, socket.id); - // callback(response); - // }); - socket.on('startSession', (data, callback) => { - const response = this.sessionService.startSession(data); - callback(response); + socket.on(ClientEvents.CLIENT_EVENT, (data) => { + this.interactionService.handleClientEvent(data); }); - - // socket.on('joinSession', (data, callback) => { - // const response = sessionController.joinSession(data, socket.id); - // callback(response); - // }); - socket.on('playerReady', (data, callback) => { - const response = this.sessionService.setPlayerReady(data); - callback(response); + socket.on(ClientEvents.CLIENT_EVENT_WITH_ACK, (data, callback) => { + const result = this.interactionService.handleClientEventWithAck(data); + callback(result); }); socket.on('pong', () => { @@ -110,11 +99,42 @@ export class SocketIoService extends ServiceBase{ SocketIoService.clients.set(id, {...client, alive: true }); } }) + socket.onAny((event, ...args) => { + if (['pong'].includes(event)) return; + let logStr = `Event received: ${event}` + + if (event.startsWith('client:') && args.length > 0) { + logStr = `${logStr} (${args[0].event})`; + } + this.logger.debug(logStr); + }); this.pingClients() - }); - + // // socket.on('createSession', (data, callback) => { + // // const response = sessionController.createSession(data, socket.id); + // // callback(response); + // // }); + + // socket.on('startSession', (data, callback) => { + // const response = this.sessionService.startSession(data); + // callback(response); + // }); + + // socket.on('client:tile-animation-ended', (data) => { + // this.sessionService.onClientEndTileAnimation(data); + // }); + + // socket.on('joinSession', (data, callback) => { + // const response = sessionController.joinSession(data, socket.id); + // callback(response); + // }); + + // socket.on('playerReady', (data, callback) => { + // const response = this.sessionService.setPlayerReady(data); + // callback(response); + // }); + }); } private pingClients() {