From f67c262b0ec8d85dbc1f6a62a50c6dc31a43af5c Mon Sep 17 00:00:00 2001 From: Jose Conde Date: Sun, 7 Jul 2024 23:23:49 +0200 Subject: [PATCH] changes --- .../{exceptions => errors}/ErrorBase.ts | 1 - src/common/errors/SessionCreationError.ts | 7 ++ src/common/errors/SessionNotFoundError.ts | 7 ++ .../SocketDisconnectedError.ts | 0 src/common/utilities.ts | 15 +++ src/game/DominoesGame.ts | 5 +- src/game/MatchSession.ts | 56 +++++---- src/game/NetworkClientNotifier.ts | 10 +- src/game/PlayerInteractionNetwork.ts | 4 +- src/game/dto/MatchSessionState.ts | 2 +- src/game/entities/player/AbstractPlayer.ts | 18 +-- src/game/entities/player/NetworkPlayer.ts | 47 ++------ src/game/entities/player/PlayerHuman.ts | 3 +- src/game/entities/player/PlayerInterface.ts | 10 +- src/server/controllers/GameController.ts | 39 +++++++ .../db/mongo/common/BaseMongoManager.ts | 6 +- src/server/managers/SessionManager.ts | 90 ++++----------- src/server/router/apiRouter.ts | 7 +- src/server/router/gameRouter.ts | 17 +++ .../services/PlayerNotificationService.ts} | 25 ++-- src/server/services/SessionService.ts | 94 ++++++++++++++- src/server/services/SocketIoService.ts | 107 +++++++++++++----- src/server/types/socket/index.d.ts | 8 ++ tsconfig.json | 2 +- 24 files changed, 369 insertions(+), 211 deletions(-) rename src/common/{exceptions => errors}/ErrorBase.ts (71%) create mode 100644 src/common/errors/SessionCreationError.ts create mode 100644 src/common/errors/SessionNotFoundError.ts rename src/common/{exceptions => errors}/SocketDisconnectedError.ts (100%) create mode 100644 src/server/controllers/GameController.ts create mode 100644 src/server/router/gameRouter.ts rename src/{game/PlayerNotificationManager.ts => server/services/PlayerNotificationService.ts} (55%) create mode 100644 src/server/types/socket/index.d.ts diff --git a/src/common/exceptions/ErrorBase.ts b/src/common/errors/ErrorBase.ts similarity index 71% rename from src/common/exceptions/ErrorBase.ts rename to src/common/errors/ErrorBase.ts index a9d7364..f0e2ff1 100644 --- a/src/common/exceptions/ErrorBase.ts +++ b/src/common/errors/ErrorBase.ts @@ -1,7 +1,6 @@ export class ErrorBase extends Error { constructor(message: string) { super(message); - console.log('this.constructor.name :>> ', this.constructor.name); this.name = this.constructor.name; this.stack = (new Error()).stack; } diff --git a/src/common/errors/SessionCreationError.ts b/src/common/errors/SessionCreationError.ts new file mode 100644 index 0000000..dd15fbd --- /dev/null +++ b/src/common/errors/SessionCreationError.ts @@ -0,0 +1,7 @@ +import { ErrorBase } from "./ErrorBase"; + +export class SessionCreationError extends ErrorBase { + constructor() { + super('Session creation error'); + } +} \ No newline at end of file diff --git a/src/common/errors/SessionNotFoundError.ts b/src/common/errors/SessionNotFoundError.ts new file mode 100644 index 0000000..0fd4d16 --- /dev/null +++ b/src/common/errors/SessionNotFoundError.ts @@ -0,0 +1,7 @@ +import { ErrorBase } from "./ErrorBase"; + +export class SessionNotFoundError extends ErrorBase { + constructor() { + super('Session not found'); + } +} \ No newline at end of file diff --git a/src/common/exceptions/SocketDisconnectedError.ts b/src/common/errors/SocketDisconnectedError.ts similarity index 100% rename from src/common/exceptions/SocketDisconnectedError.ts rename to src/common/errors/SocketDisconnectedError.ts diff --git a/src/common/utilities.ts b/src/common/utilities.ts index 7f84961..50b009d 100644 --- a/src/common/utilities.ts +++ b/src/common/utilities.ts @@ -14,6 +14,21 @@ export async function wait(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)); } +export const whileNotUndefined = async (fn: Function, maxQueries: number = 20, millis: number = 500): Promise => { + return new Promise(async (resolve, reject) => { + let result; + while (result === undefined) { + await wait(millis); + result = fn() + if (maxQueries-- < 0) { + reject() + return; + } + } + resolve(result); + }); +} + export function askQuestion(question: string): Promise { return new Promise((resolve) => { // console.log(chalk.yellow(question)); diff --git a/src/game/DominoesGame.ts b/src/game/DominoesGame.ts index 5a653c1..d8a14c6 100644 --- a/src/game/DominoesGame.ts +++ b/src/game/DominoesGame.ts @@ -6,7 +6,7 @@ import { Tile } from "./entities/Tile"; import { LoggingService } from "../common/LoggingService"; import { printBoard, printLine, uuid, wait } from '../common/utilities'; import { GameSummary } from './dto/GameSummary'; -import { PlayerNotificationManager } from './PlayerNotificationManager'; +import { PlayerNotificationService } from '../server/services/PlayerNotificationService'; import { GameState } from './dto/GameState'; export class DominoesGame { @@ -25,13 +25,12 @@ export class DominoesGame { winner: PlayerInterface | null = null; rng: PRNG; handSize: number = 7; - notificationManager: PlayerNotificationManager = new PlayerNotificationManager(); + notificationManager: PlayerNotificationService = new PlayerNotificationService(); lastMove: PlayerMove | null = null; constructor(public players: PlayerInterface[], seed: PRNG) { this.id = uuid(); this.logger.info(`Game ID: ${this.id}`); - this.logger.info(`Seed: ${this.seed}`); this.rng = seed this.board = new Board(seed); this.initializeGame(); diff --git a/src/game/MatchSession.ts b/src/game/MatchSession.ts index f1b44fc..779b256 100644 --- a/src/game/MatchSession.ts +++ b/src/game/MatchSession.ts @@ -4,7 +4,7 @@ import { PlayerInterface } from "./entities/player/PlayerInterface"; import { LoggingService } from "../common/LoggingService"; import { getRandomSeed, uuid, wait } from "../common/utilities"; import { MatchSessionState } from "./dto/MatchSessionState"; -import { PlayerNotificationManager } from './PlayerNotificationManager'; +import { PlayerNotificationService } from '../server/services/PlayerNotificationService'; import seedrandom, { PRNG } from "seedrandom"; import { NetworkPlayer } from "./entities/player/NetworkPlayer"; import { PlayerHuman } from "./entities/player/PlayerHuman"; @@ -16,11 +16,11 @@ export class MatchSession { private waitingForPlayers: boolean = true; private waitingSeconds: number = 0; private logger: LoggingService = new LoggingService(); - private playerNotificationManager = new PlayerNotificationManager(); + private playerNotificationManager = new PlayerNotificationService(); id: string; matchInProgress: boolean = false; - matchWinner: PlayerInterface | null = null; + matchWinner?: PlayerInterface = undefined; maxPlayers: number = 4; mode: string = 'classic'; players: PlayerInterface[] = []; @@ -31,20 +31,19 @@ export class MatchSession { sessionInProgress: boolean = false; state: string = 'created' - constructor(public creator: PlayerInterface, public name?: string) { + constructor(public creator: PlayerInterface, public name?: string, seed?: string) { + this.seed = seed || getRandomSeed(); this.id = uuid(); this.name = name || `Game ${this.id}`; - this.addPlayer(creator); - + this.addPlayer(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(`Points to win: ${this.pointsToWin}`); this.sessionInProgress = true; this.matchInProgress = false; - this.playerNotificationManager.notifyMatchState(this); - this.playerNotificationManager.notifyPlayersState(this.players); } get numPlayers() { @@ -77,14 +76,17 @@ export class MatchSession { this.state = 'started' this.logger.info(`Game #${gameNumber} started`); // this.game.reset() - await this.currentGame.start(); + const gameSummary = await this.currentGame.start(); + this.logger.debug('gameSummary :>> ', gameSummary); this.setScores(); this.checkMatchWinner(); - this.resetReadiness(); + this.resetPlayers(); this.state = 'waiting' await this.playerNotificationManager.notifyMatchState(this); this.playerNotificationManager.sendEventToPlayers('game-finished', this.players); - await this.checkHumanPlayersReady(); + if (this.matchInProgress) { + await this.checkHumanPlayersReady(); + } } this.state = 'end' // await this.game.start(); @@ -103,10 +105,10 @@ export class MatchSession { }, 1000); }); } - - resetReadiness() { + + resetPlayers() { this.players.forEach(player => { - player.ready = false + player.reset() }); } @@ -114,7 +116,10 @@ export class MatchSession { const scores = Array.from(this.scoreboard.values()); const maxScore = Math.max(...scores); if (maxScore >= this.pointsToWin) { - this.matchWinner = this.players.find(player => this.scoreboard.get(player.id) === maxScore)!; + 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; } @@ -122,17 +127,19 @@ export class MatchSession { resetScoreboard() { this.scoreboard = new Map(); this.players.forEach(player => { - this.scoreboard.set(player.id, 0); + this.scoreboard.set(player.name, 0); }); } setScores() { - const totalPips = this.currentGame?.players.reduce((acc, player) => acc + player.pipsCount(), 0); + 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; - this.scoreboard.set(winner.id, this.scoreboard.get(winner.id)! + totalPips!); + 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.id, this.scoreboard.get(winner.teamedWith.id)! + totalPips!); + this.scoreboard.set(winner.teamedWith.name, currentPips + totalPips); } } } @@ -181,9 +188,7 @@ export class MatchSession { return player; } - async start(seed?: string) { - this.seed = seed || getRandomSeed(); - console.log('seed :>> ', this.seed); + async start() { if (this.matchInProgress) { throw new Error("Game already in progress"); } @@ -207,8 +212,9 @@ export class MatchSession { this.logger.info(`${player.name} joined the game!`); } - setPlayerReady(user: string) { - const player = this.players.find(player => player.name === user); + setPlayerReady(userId: string) { + this.logger.debug(userId) + const player = this.players.find(player => player.id === userId); if (!player) { throw new Error("Player not found"); } @@ -242,7 +248,7 @@ export class MatchSession { mode: this.mode, pointsToWin: this.pointsToWin, status: this.sessionInProgress ? 'in progress' : 'waiting', - scoreboard: this.scoreboard, + scoreboard: [...this.scoreboard.entries()], matchWinner: this.matchWinner?.getState() || null, matchInProgress: this.matchInProgress }; diff --git a/src/game/NetworkClientNotifier.ts b/src/game/NetworkClientNotifier.ts index e327a28..ce2438c 100644 --- a/src/game/NetworkClientNotifier.ts +++ b/src/game/NetworkClientNotifier.ts @@ -30,7 +30,15 @@ export class NetworkClientNotifier { } async sendEvent(player: NetworkPlayer, event: string, data?: any) { - this.io.to(player.socketId).emit(event, data); + const eventData = { event, data }; + this.io.to(player.socketId).emit('game-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) { diff --git a/src/game/PlayerInteractionNetwork.ts b/src/game/PlayerInteractionNetwork.ts index 769e832..cc55455 100644 --- a/src/game/PlayerInteractionNetwork.ts +++ b/src/game/PlayerInteractionNetwork.ts @@ -6,7 +6,7 @@ import { Tile } from './entities/Tile'; import { NetworkClientNotifier } from './NetworkClientNotifier'; import { NetworkPlayer } from './entities/player/NetworkPlayer'; import { PlayerMoveSide, PlayerMoveSideType } from './constants'; -import { SocketDisconnectedError } from '../common/exceptions/SocketDisconnectedError'; +import { SocketDisconnectedError } from '../common/errors/SocketDisconnectedError'; export class PlayerInteractionNetwork implements PlayerInteractionInterface { player: PlayerInterface; @@ -19,7 +19,7 @@ export class PlayerInteractionNetwork implements PlayerInteractionInterface { async makeMove(board: Board): Promise { let response = undefined; try { - response = await this.clientNotifier.notifyPlayer(this.player as NetworkPlayer, 'makeMove', { + response = await this.clientNotifier.sendEventWithAck(this.player as NetworkPlayer, 'ask-client-for-move', { freeHands: board.getFreeEnds(), }); } catch (error) { diff --git a/src/game/dto/MatchSessionState.ts b/src/game/dto/MatchSessionState.ts index a5be361..2fc8231 100644 --- a/src/game/dto/MatchSessionState.ts +++ b/src/game/dto/MatchSessionState.ts @@ -14,7 +14,7 @@ export interface MatchSessionState { maxPlayers: number; numPlayers: number; waitingSeconds: number; - scoreboard: Map; + scoreboard: [string, number][]; matchWinner: PlayerDto | null; matchInProgress: boolean; playersReady: number diff --git a/src/game/entities/player/AbstractPlayer.ts b/src/game/entities/player/AbstractPlayer.ts index c571ff0..c8b8ee0 100644 --- a/src/game/entities/player/AbstractPlayer.ts +++ b/src/game/entities/player/AbstractPlayer.ts @@ -6,8 +6,6 @@ import { LoggingService } from "../../../common/LoggingService"; import { EventEmitter } from "stream"; import { PlayerInteractionInterface } from "../../PlayerInteractionInterface"; import { uuid } from "../../../common/utilities"; -import { GameState } from "../../dto/GameState"; -import { MatchSessionState } from "../../dto/MatchSessionState"; import { PlayerDto } from "../../dto/PlayerDto"; export abstract class AbstractPlayer extends EventEmitter implements PlayerInterface { @@ -27,20 +25,16 @@ export abstract class AbstractPlayer extends EventEmitter implements PlayerInter abstract chooseTile(board: Board): Promise; - async notifyGameState(state: GameState): Promise { - } - - async notifyPlayerState(state: PlayerDto): Promise { + async sendEventWithAck(event: string, data: any): Promise { } - async notifyMatchState(state: MatchSessionState): Promise { + async sendEvent(event: string, data: any = {}): Promise { } - async waitForAction(actionId: string): Promise { - return true; - } - - async sendEvent(event: string): Promise { + reset(): void { + this.hand = []; + this.score = 0; + this.ready = false; } pipsCount(): number { diff --git a/src/game/entities/player/NetworkPlayer.ts b/src/game/entities/player/NetworkPlayer.ts index 55a5d20..926cd9c 100644 --- a/src/game/entities/player/NetworkPlayer.ts +++ b/src/game/entities/player/NetworkPlayer.ts @@ -4,55 +4,22 @@ import { PlayerHuman } from "./PlayerHuman"; import { NetworkClientNotifier } from "../../NetworkClientNotifier"; import { Tile } from "../Tile"; import { Board } from "../Board"; -import { GameState } from "../../dto/GameState"; -import { PlayerDto } from "../../dto/PlayerDto"; -import { MatchSessionState } from "../../dto/MatchSessionState"; -import { SocketDisconnectedError } from "../../../common/exceptions/SocketDisconnectedError"; export class NetworkPlayer extends PlayerHuman { - socketId: string; + socketId!: string; playerInteraction: PlayerInteractionInterface = new PlayerInteractionNetwork(this); clientNotifier: NetworkClientNotifier = new NetworkClientNotifier(); - constructor(name: string, socketId: string) { - super(name); + constructor(id: string, name: string, socketId: string ) { + super(id, name); this.socketId = socketId; } - async notifyGameState(state: GameState): Promise { - const response = await this.clientNotifier.notifyPlayer(this, 'gameState', state); - console.log('game state notified :>> ', response); - if (response === undefined || response.status !== 'ok' ) { - throw new SocketDisconnectedError(); - } - } - - async notifyPlayerState(state: PlayerDto): Promise { - const response = await this.clientNotifier.notifyPlayer(this, 'playerState', state); - console.log('player state notified :>> ', response); - if (response === undefined || response.status !== 'ok' ) { - throw new SocketDisconnectedError(); - } + async sendEvent(event: string, data:any = {}): Promise { + this.clientNotifier.sendEvent(this, event, data); } - - async notifyMatchState(state: MatchSessionState): Promise { - const response = await this.clientNotifier.notifyPlayer(this, 'matchState', state); - console.log('session state notified :>> ', response); - if (response === undefined || response.status !== 'ok' ) { - throw new SocketDisconnectedError(); - } - } - async waitForAction(actionId: string): Promise { - const response = await this.clientNotifier.notifyPlayer(this, actionId); - if (response === undefined || response.status !== 'ok' ) { - throw new SocketDisconnectedError(); - } - const { actionResult } = response; - return actionResult; - } - - async sendEvent(event: string): Promise { - this.clientNotifier.sendEvent(this, event); + async sendEventWithAck(event: string, data: any): Promise { + return await this.clientNotifier.sendEventWithAck(this, event, data); } async chooseTile(board: Board): Promise { diff --git a/src/game/entities/player/PlayerHuman.ts b/src/game/entities/player/PlayerHuman.ts index 14b68b6..605427b 100644 --- a/src/game/entities/player/PlayerHuman.ts +++ b/src/game/entities/player/PlayerHuman.ts @@ -8,8 +8,9 @@ import { PlayerInteractionInterface } from '../../PlayerInteractionInterface'; export class PlayerHuman extends AbstractPlayer { playerInteraction: PlayerInteractionInterface = new PlayerInteractionConsole(this); - constructor(name: string) { + constructor(id: string, name: string) { super(name); + this.id = id; } async makeMove(board: Board): Promise { diff --git a/src/game/entities/player/PlayerInterface.ts b/src/game/entities/player/PlayerInterface.ts index 97f9bc9..e4b38fd 100644 --- a/src/game/entities/player/PlayerInterface.ts +++ b/src/game/entities/player/PlayerInterface.ts @@ -18,10 +18,10 @@ export interface PlayerInterface { makeMove(gameState: Board): Promise; chooseTile(board: Board): Promise; pipsCount(): number; - notifyGameState(state: GameState): Promise; - notifyPlayerState(state: PlayerDto): Promise; - notifyMatchState(state: MatchSessionState): Promise; - waitForAction(actionId: string, data: any): Promise; - sendEvent(event: string): Promise; + reset(): void; + + sendEvent(event: string, data: any): Promise; + sendEventWithAck(event: string, data: any): Promise; + getState(): PlayerDto; } \ No newline at end of file diff --git a/src/server/controllers/GameController.ts b/src/server/controllers/GameController.ts new file mode 100644 index 0000000..1ae0519 --- /dev/null +++ b/src/server/controllers/GameController.ts @@ -0,0 +1,39 @@ +import { Request, Response } from "express"; +import { BaseController } from "./BaseController"; +import { SessionService } from "../services/SessionService"; + +export class GameController extends BaseController { + private sessionService: SessionService = new SessionService(); + + public async createMatch(req: Request, res: Response) { + try { + const { user, body } = req; + const { sessionName, seed } = body; + const sessionId = await this.sessionService.createSession(user, sessionName, seed); + res.status(201).json({ sessionId }); + } catch (error) { + this.handleError(res, error); + } + } + + public joinMatch(req: Request, res: Response) { + try { + const { user, body } = req; + const { sessionId } = body; + this.sessionService.joinSession(user, sessionId); + res.status(200).json({ status: 'ok' }); + } catch (error) { + this.handleError(res, error); + } + } + + public listMatches(req: Request, res: Response) { + try { + this.sessionService.listSessions().then((sessions) => { + res.status(200).json(sessions); + }); + } catch (error) { + this.handleError(res, error); + } + } +} \ No newline at end of file diff --git a/src/server/db/mongo/common/BaseMongoManager.ts b/src/server/db/mongo/common/BaseMongoManager.ts index 171b36c..8b073b8 100644 --- a/src/server/db/mongo/common/BaseMongoManager.ts +++ b/src/server/db/mongo/common/BaseMongoManager.ts @@ -9,11 +9,11 @@ export abstract class BaseMongoManager { protected abstract collection?: string; logger = new LoggingService().logger; - create(data: Entity) { + create(data: Entity): Promise{ return mongoExecute( async ({ collection }) => { - await collection?.insertOne(data as any); - return data; + const result = await collection?.insertOne(data as any); + return result?.insertedId; }, { colName: this.collection } ); diff --git a/src/server/managers/SessionManager.ts b/src/server/managers/SessionManager.ts index d606bb1..ccc8bdb 100644 --- a/src/server/managers/SessionManager.ts +++ b/src/server/managers/SessionManager.ts @@ -1,86 +1,38 @@ import { MatchSession } from "../../game/MatchSession"; -import { NetworkPlayer } from "../../game/entities/player/NetworkPlayer"; -import { SessionService } from "../services/SessionService"; import { ManagerBase } from "./ManagerBase"; export class SessionManager extends ManagerBase { - private static sessions: any = {}; - private sessionService: SessionService = new SessionService(); + private static sessions: Map = new Map(); constructor() { super(); this.logger.info('SessionController created'); } - createSession(data: any, socketId: string): any { - const { user, sessionName } = data; - const player = new NetworkPlayer(user, socketId); - const session = new MatchSession(player, sessionName); - SessionManager.sessions[session.id] = session; - this.sessionService.createSession(session); - - return { - status: 'ok', - sessionId: session.id, - playerId: player.id - }; - } - - joinSession(data: any, socketId: string): any { - this.logger.debug('joinSession data :>> ') - this.logger.object(data); - const { user, sessionId } = data; - const session: MatchSession = SessionManager.sessions[sessionId]; - const player = new NetworkPlayer(user, socketId); - session.addPlayer(player); - this.sessionService.updateSession(session); - return { - status: 'ok', - sessionId: session.id, - playerId: player.id - }; - } - - setPlayerReady(data: any): any { - const { user, sessionId } = data; - const session: MatchSession = SessionManager.sessions[sessionId]; - session.setPlayerReady(user) - } - - startSession(data: any): any { - const sessionId: string = data.sessionId; - const seed: string | undefined = data.seed; - const session = SessionManager.sessions[sessionId]; - - if (!session) { - return ({ - status: 'error', - message: 'Session not found' - }); - } else if (session.gameInProgress) { - 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(seed); - return { - status: 'ok' - }; + getSessionPlayer(sessionId: string, userId: string): any { + const session: MatchSession | undefined = SessionManager.sessions.get(sessionId); + if (session !== undefined) { + return session.players.find(player => player.id === userId); } } + updateSocketId(sessionId: string, userId: string, socketId: string): any { + const player = this.getSessionPlayer(sessionId, userId); + if (player !== undefined) { + player.socketId = socketId; + } + } + + setSession(session: MatchSession) { + SessionManager.sessions.set(session.id, session); + } + + deleteSession(session: MatchSession) { + SessionManager.sessions.delete(session.id); + } getSession(id: string) { - return SessionManager.sessions[id]; - } - - deleteSession(id: string) { - delete SessionManager.sessions[id]; - } + return SessionManager.sessions.get(id); + } } \ No newline at end of file diff --git a/src/server/router/apiRouter.ts b/src/server/router/apiRouter.ts index f358c61..997de0e 100644 --- a/src/server/router/apiRouter.ts +++ b/src/server/router/apiRouter.ts @@ -3,6 +3,7 @@ import { AuthController } from '../controllers/AuthController'; import adminRouter from './adminRouter'; import userRouter from './userRouter'; +import gameRouter from './gameRouter'; export default function(): Router { const router = Router(); @@ -15,8 +16,10 @@ export default function(): Router { router.post('/auth/code', (req: Request, res: Response) => authController.twoFactorCodeAuthentication(req, res)); router.post('/login', (req: Request, res: Response) => authController.login(req, res)); - router .use('/admin', adminRouter()); - router .use('/user', userRouter()); + router.use('/admin', adminRouter()); + router.use('/user', userRouter()); + router.use('/game', gameRouter()); + return router; } diff --git a/src/server/router/gameRouter.ts b/src/server/router/gameRouter.ts new file mode 100644 index 0000000..bd4fd45 --- /dev/null +++ b/src/server/router/gameRouter.ts @@ -0,0 +1,17 @@ +import { Request, Response, Router } from 'express'; +import { AuthController } from '../controllers/AuthController'; +import { GameController } from '../controllers/GameController'; + + + +export default function(): Router { + const router = Router(); + const gameController = new GameController(); + 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)); + + return router; +} diff --git a/src/game/PlayerNotificationManager.ts b/src/server/services/PlayerNotificationService.ts similarity index 55% rename from src/game/PlayerNotificationManager.ts rename to src/server/services/PlayerNotificationService.ts index 928f7a4..26205a4 100644 --- a/src/game/PlayerNotificationManager.ts +++ b/src/server/services/PlayerNotificationService.ts @@ -1,36 +1,35 @@ -import { DominoesGame } from "./DominoesGame"; -import { MatchSession } from "./MatchSession"; -import { GameState } from "./dto/GameState"; -import { PlayerInterface } from "./entities/player/PlayerInterface"; +import { DominoesGame } from "../../game/DominoesGame"; +import { MatchSession } from "../../game/MatchSession"; +import { GameState } from "../../game/dto/GameState"; +import { PlayerInterface } from "../../game/entities/player/PlayerInterface"; -export class PlayerNotificationManager { +export class PlayerNotificationService { async notifyGameState(game: DominoesGame) { const gameState: GameState = game.getGameState(); const { players } = game; - let promises: Promise[] = players.map(player => player.notifyGameState(gameState)); + let promises: Promise[] = players.map(player => player.sendEventWithAck('update-game-state', gameState)); return await Promise.all(promises); } async notifyPlayersState(players: PlayerInterface[]) { - let promises: Promise[] = players.map(player => player.notifyPlayerState(player.getState())); + let promises: Promise[] = players.map(player => player.sendEventWithAck('update-player-state', player.getState())); return await Promise.all(promises); } async notifyMatchState(session: MatchSession) { const { players } = session; - let promises: Promise[] = players.map(player => player.notifyMatchState(session.getState())); + let promises: Promise[] = players.map(player => player.sendEventWithAck('update-match-session-state', session.getState())); return await Promise.all(promises); } - async waitForPlayersAction(actionId: string, data: any = {}, players: PlayerInterface[]) { - let promises: Promise[] = players.map(player => player.waitForAction(actionId, data)); + 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[]) { - let promises: Promise[] = players.map(player => player.sendEvent(event)); - return await Promise.all(promises); + async sendEvent(event: string, player: PlayerInterface, data: any = {}) { + player.sendEvent(event, data) } } \ No newline at end of file diff --git a/src/server/services/SessionService.ts b/src/server/services/SessionService.ts index c0446f7..af67951 100644 --- a/src/server/services/SessionService.ts +++ b/src/server/services/SessionService.ts @@ -1,19 +1,107 @@ +import { SessionCreationError } from "../../common/errors/SessionCreationError"; +import { SessionNotFoundError } from "../../common/errors/SessionNotFoundError"; +import { wait, 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 { MatchSessionMongoManager } from "../db/mongo/MatchSessionMongoManager"; +import { SessionManager } from "../managers/SessionManager"; import { ServiceBase } from "./ServiceBase"; +import { SocketIoService } from "./SocketIoService"; export class SessionService extends ServiceBase{ private dbManager: MatchSessionMongoManager = new MatchSessionMongoManager(); + private sessionManager: SessionManager = new SessionManager(); + private notifyService = new PlayerNotificationService(); constructor() { super() } - public createSession(session: MatchSession): any { - this.dbManager.create(matchSessionAdapter(session)); + public async createSession(user: any, sessionName: string, seed: string ): Promise { + let socketClient; + try { + socketClient = await whileNotUndefined(() => SocketIoService.getClient(user._id)); + } catch (error) { + throw new SessionCreationError(); + } + const player = new NetworkPlayer(user._id, user.username, socketClient.socketId); + const session = new MatchSession(player, sessionName, seed); + const dbSessionId = await this.dbManager.create(matchSessionAdapter(session)); + if (dbSessionId === undefined) { + throw new SessionCreationError(); + } + session.id = dbSessionId.toString(); + socketClient.sessionId = session.id; + this.sessionManager.setSession(session); + this.notifyService.notifyMatchState(session); + this.notifyService.notifyPlayersState(session.players); + return session.id; } - public updateSession(session: MatchSession): any { + public async joinSession(user: any, sessionId: string): Promise { + const session: MatchSession | undefined = this.sessionManager.getSession(sessionId); + if (session === undefined) { + throw new SessionNotFoundError(); + } 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; this.dbManager.replaceOne({id: session.id}, matchSessionAdapter(session)); } + + public listSessions(): Promise { + return this.dbManager.listByFilter({}); + } + + public updateSocketId(sessionId: string, userId: string, socketId: string): any { + this.sessionManager.updateSocketId(sessionId, userId, socketId); + } + + 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 updateSession(session: MatchSession): any { + // this.dbManager.replaceOne({id: session.id}, matchSessionAdapter(session)); + // } } \ No newline at end of file diff --git a/src/server/services/SocketIoService.ts b/src/server/services/SocketIoService.ts index 3982d13..fd5351a 100644 --- a/src/server/services/SocketIoService.ts +++ b/src/server/services/SocketIoService.ts @@ -2,14 +2,39 @@ 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"; export class SocketIoService extends ServiceBase{ io: Server - clients: Map = new Map(); + private static clients: Map = new Map(); + private sessionService: SessionService = new SessionService(); + static getClient(id: string) { + return this.clients.get(id); + } + + security = new SecurityManager(); constructor(private httpServer: HttpServer) { super() - this.io = this.socketIo(httpServer); + this.io = this.socketIo(httpServer); + this.io.use(async (socket: Socket, next: any) => { + if (socket.handshake.auth && socket.handshake.auth.token) { + const token = socket.handshake.auth.token; + try { + const user: User = await this.security.verifyJwt(token); + socket.user = user; + next(); + } catch (err) { + this.logger.error(err); + next(new Error('Authentication error')); + } + } else { + next(new Error('Authentication error')); + } + }); this.initListeners(); } @@ -17,8 +42,12 @@ export class SocketIoService extends ServiceBase{ return this.io; } + getUserId(socket: Socket) { + const { user } = socket; + return user?._id?.toString(); + } + private initListeners() { - const sessionController = new SessionManager(); this.io.on('connection', (socket) => { this.logger.debug(`connect ${socket.id}`); if (socket.recovered) { @@ -28,59 +57,79 @@ export class SocketIoService extends ServiceBase{ this.logger.debug("socket.data:", socket.data); } else { this.logger.debug("new connection"); - this.clients.set(socket.id, { alive: true }); - socket.join('room-general') - socket.data.foo = "bar"; - } - - socket.on('pong', () => { - if (this.clients.has(socket.id)) { - this.clients.set(socket.id, { alive: true }); + const { id: socketId, user } = socket; + if (user !== undefined && user._id !== undefined) { + const userId = user._id.toString(); + if (!SocketIoService.clients.has(userId)) { + SocketIoService.clients.set(userId, { socketId, alive: true, user: socket.user }); + socket.join('room-general') + } else { + const client = SocketIoService.clients.get(userId); + this.sessionService.updateSocketId(client.sessionId, userId, socketId); + client.socketId = socketId; + this.logger.debug(`User '${user.username}' already connected. Updating socketId to ${socketId}`); + client.alive = true; + } + } else { + this.logger.error('User not found'); + socket.disconnect(); } - }) - + } socket.on('disconnect', () => { - this.logger.debug('user disconnected'); - this.clients.delete(socket.id); + const id = this.getUserId(socket); + if (id) { + this.logger.info('user disconnected'); + SocketIoService.clients.delete(id); + } }); - socket.on('createSession', (data, callback) => { - const response = sessionController.createSession(data, socket.id); - callback(response); - }); + // socket.on('createSession', (data, callback) => { + // const response = sessionController.createSession(data, socket.id); + // callback(response); + // }); socket.on('startSession', (data, callback) => { - const response = sessionController.startSession(data); + const response = this.sessionService.startSession(data); callback(response); }); - socket.on('joinSession', (data, callback) => { - const response = sessionController.joinSession(data, socket.id); - callback(response); - }); + // socket.on('joinSession', (data, callback) => { + // const response = sessionController.joinSession(data, socket.id); + // callback(response); + // }); socket.on('playerReady', (data, callback) => { - const response = sessionController.setPlayerReady(data); + const response = this.sessionService.setPlayerReady(data); callback(response); }); + socket.on('pong', () => { + const id = this.getUserId(socket); + if (id && SocketIoService.clients.has(id)) { + const client = SocketIoService.clients.get(id); + SocketIoService.clients.set(id, {...client, alive: true }); + } + }) + this.pingClients() }); + + } private pingClients() { setInterval(() => { - for (let [id, client] of this.clients.entries()) { + for (let [id, client] of SocketIoService.clients.entries()) { if (!client.alive) { this.logger.debug(`Client ${id} did not respond. Disconnecting.`); this.io.to(id).disconnectSockets(true); // Disconnect client - this.clients.delete(id); + SocketIoService.clients.delete(id); } else { client.alive = false; // Reset alive status for the next ping - this.io.to(id).emit('ping'); // Send ping message + this.io.to(client.socketId).emit('ping'); // Send ping message } } - }, 30000); + }, 10000); } private socketIo(httpServer: HttpServer): Server { diff --git a/src/server/types/socket/index.d.ts b/src/server/types/socket/index.d.ts new file mode 100644 index 0000000..36082cf --- /dev/null +++ b/src/server/types/socket/index.d.ts @@ -0,0 +1,8 @@ +import { Socket } from 'socket.io'; +import { User } from '../../db/interfaces'; + +declare module 'socket.io' { + interface Socket { + user?: User; + } +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 1582ae3..3cf1dec 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -31,7 +31,7 @@ // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ - "typeRoots": ["./src/server/types"], /* Specify multiple folders that act like './node_modules/@types'. */ + "typeRoots": ["./src/server/types"], /* Specify multiple folders that act like './node_modules/@types'. */ // "types": [], /* Specify type package names to be included without being referenced in a source file. */ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */