diff --git a/apps/backend/src/game/GameManager.ts b/apps/backend/src/game/GameManager.ts index d7a5b1a..6f0932e 100644 --- a/apps/backend/src/game/GameManager.ts +++ b/apps/backend/src/game/GameManager.ts @@ -187,7 +187,6 @@ export class GameManager { public startGame(game: BackendGame) { this.sendMessageToPlayersAndSpectators(game, channel_on_game_start, game); this.sendUserConnectionInfo(game); - this.sendUserConnectionInfo(game); } public sendUserConnectionInfo(game: BackendGame) { @@ -199,6 +198,7 @@ export class GameManager { if (game) { this.gameStore.removeGameByPlayer(game.mancalaGame.player1Id); this.gameStore.removeGameByPlayer(game.mancalaGame.player2Id); + this.gameStore.remove(game.id); } } diff --git a/apps/backend/src/game/gamestore/GameStoreImpl.ts b/apps/backend/src/game/gamestore/GameStoreImpl.ts index d1153b9..0d9391d 100644 --- a/apps/backend/src/game/gamestore/GameStoreImpl.ts +++ b/apps/backend/src/game/gamestore/GameStoreImpl.ts @@ -38,8 +38,7 @@ export class GameStoreImpl implements GameStore { getSpeactatingGameIdsByUser(userId: string): string[] { const speactatingGameIds = []; - for (const gameId in this.gameStore.keys()) { - const game = this.gameStore.get(gameId) as BackendGame; + for (const game of this.gameStore.values()) { const isSpectator = game.spectatorIds.find((value) => value === userId); isSpectator && speactatingGameIds.push(game.id); } diff --git a/apps/backend/tests/game/FakeRtmt.ts b/apps/backend/tests/game/FakeRtmt.ts new file mode 100644 index 0000000..987afc9 --- /dev/null +++ b/apps/backend/tests/game/FakeRtmt.ts @@ -0,0 +1,53 @@ +import { AuthUser } from "../../src/auth"; +import { OnClientConnectionChange, OnMessage, RTMT } from "../../src/rtmt/rtmt"; + +export type SentMessage = { clientID: string, channel: string, message: any }; + +// In-memory RTMT: tests connect users, send messages as them and inspect what the server sent back. +export class FakeRtmt implements RTMT { + readonly sent: SentMessage[] = []; + private readonly listeners = new Map(); + private readonly online = new Map(); + private onConnectionChange?: OnClientConnectionChange; + + connect(user: AuthUser) { + this.online.set(user.id, user); + this.onConnectionChange?.(user.id, true); + } + + disconnect(userId: string) { + this.online.delete(userId); + this.onConnectionChange?.(userId, false); + } + + receive(userId: string, channel: string, message: Object = {}) { + const listener = this.listeners.get(channel); + if (!listener) throw new Error(`No listener for ${channel}`); + listener(userId, message); + } + + messagesTo(userId: string, channel: string): any[] { + return this.sent.filter((m) => m.clientID === userId && m.channel === channel).map((m) => m.message); + } + + sendMessage(clientID: string, channel: string, message: Object) { + // Copy, like serialising over the wire would. + this.sent.push({ clientID, channel, message: JSON.parse(JSON.stringify(message)) }); + } + + listenMessage(channel: string, callback: OnMessage) { + this.listeners.set(channel, callback); + } + + isClientOnline(clientID: string): boolean { + return this.online.has(clientID); + } + + getUser(clientID: string): AuthUser | undefined { + return this.online.get(clientID); + } + + listenOnClientConnectionChange(onUserConnectionChange: OnClientConnectionChange): void { + this.onConnectionChange = onUserConnectionChange; + } +} diff --git a/apps/backend/tests/game/GameManager.test.ts b/apps/backend/tests/game/GameManager.test.ts new file mode 100644 index 0000000..73f5d3a --- /dev/null +++ b/apps/backend/tests/game/GameManager.test.ts @@ -0,0 +1,74 @@ +import { + channel_game_move, + channel_leave_game, + channel_listen_game_events, + channel_new_game, + channel_on_game_start, + channel_on_user_connection_change, +} from "@mancala/core"; +import { GameManager } from "../../src/game/GameManager"; +import { GameStoreImpl } from "../../src/game/gamestore/GameStoreImpl"; +import { MatchMakerImpl } from "../../src/matchmaker/MatchMakerImpl"; +import { BackendGame } from "../../src/models/Game"; +import { FakeRtmt } from "./FakeRtmt"; + +const ALICE = { id: "kc:alice", name: "alice", isGuest: false }; +const BOB = { id: "kc:bob", name: "bob", isGuest: false }; +const CAROL = { id: "kc:carol", name: "carol", isGuest: false }; + +describe("GameManager", () => { + let rtmt: FakeRtmt; + let gameStore: GameStoreImpl; + + beforeEach(() => { + rtmt = new FakeRtmt(); + gameStore = new GameStoreImpl(); + new GameManager({ rtmt, gameStore, matchMaker: new MatchMakerImpl({ rtmt }) }); + [ALICE, BOB, CAROL].forEach((user) => rtmt.connect(user)); + }); + + const startGame = (): BackendGame => { + rtmt.receive(ALICE.id, channel_new_game); + rtmt.receive(BOB.id, channel_new_game); + const game = gameStore.getGameByUser(ALICE.id); + if (!game) throw new Error("game did not start"); + return game; + }; + + it("sends the connection info once when a game starts", () => { + startGame(); + expect(rtmt.messagesTo(ALICE.id, channel_on_game_start)).toHaveLength(1); + expect(rtmt.messagesTo(ALICE.id, channel_on_user_connection_change)).toHaveLength(1); + }); + + it("removes a game from memory when a player leaves", () => { + const game = startGame(); + rtmt.receive(ALICE.id, channel_leave_game); + expect(gameStore.get(game.id)).toBeUndefined(); + expect(gameStore.getGameByUser(BOB.id)).toBeUndefined(); + }); + + it("removes a game from memory when it ends by the rules", () => { + const game = startGame(); + // Alice has a single stone in her last pit, next to her store; playing it empties her side. + const aliceIsPlayer1 = game.mancalaGame.player1Id === ALICE.id; + const board = game.mancalaGame.board; + board.pits.forEach((pit) => (pit.stoneCount = 0)); + board.pits[aliceIsPlayer1 ? 5 : 12].stoneCount = 1; + board.pits[aliceIsPlayer1 ? 7 : 0].stoneCount = 1; + game.mancalaGame.turnPlayerId = ALICE.id; + rtmt.receive(ALICE.id, channel_game_move, { index: 5 }); + expect(game.mancalaGame.state).toBe("ended"); + expect(gameStore.get(game.id)).toBeUndefined(); + }); + + it("stops counting a spectator who disconnects", () => { + const game = startGame(); + rtmt.receive(CAROL.id, channel_listen_game_events, game.id); + expect(game.spectatorIds).toEqual([CAROL.id]); + expect(gameStore.getSpeactatingGameIdsByUser(CAROL.id)).toEqual([game.id]); + + rtmt.disconnect(CAROL.id); + expect(game.spectatorIds).toEqual([]); + }); +}); diff --git a/docs/specs/game-history/tasks.md b/docs/specs/game-history/tasks.md index 2af7219..32f9c40 100644 --- a/docs/specs/game-history/tasks.md +++ b/docs/specs/game-history/tasks.md @@ -7,7 +7,7 @@ Work through the tasks in order; each ends with its tests passing and a commit. - [x] 2. **Core types and history helpers**: `PlayerSummary`, `MoveRecord`, summaries, `GameRecord`, `TURN_TIMEOUT_MS`, new channels, `offlineSince`; `getInitialBoard`, `getBoardAfterMove`, `describeMove`, `createGameAtMove` with unit tests; localized strings. -- [ ] 3. **Backend fixes**: remove spectators on disconnect, remove finished games from memory, send the connection +- [x] 3. **Backend fixes**: remove spectators on disconnect, remove finished games from memory, send the connection info once at game start. - [ ] 4. **Postgres repository**: pool, migration runner, `games` and `game_players`, `PostgresGameHistoryRepository` (save, get, recent for user, popular), integration tests.