import { channel_game_move, channel_leave_game, channel_listen_game_events, channel_new_game, channel_on_game_end, channel_on_game_start, channel_on_game_update, channel_on_game_user_leave, channel_on_new_game_rejected, channel_on_user_connection_change, INACTIVE_TIMEOUT_MS, TURN_TIMEOUT_MS, } from "@mancala/core"; import { GameManager } from "../../src/game/GameManager"; import { GameRecorder } from "../../src/game/GameRecorder"; import { GameStoreImpl } from "../../src/game/gamestore/GameStoreImpl"; import { publicUserId } from "../../src/game/publicIds"; import { MatchMakerImpl } from "../../src/matchmaker/MatchMakerImpl"; import { BackendGame } from "../../src/models/Game"; import { FakeRtmt } from "./FakeRtmt"; import { flushPromises, InMemoryGameHistoryRepository, InMemoryLiveGameStore } from "./fakes"; const ALICE = { id: "kc:alice", name: "alice", isGuest: false }; const BOB = { id: "kc:bob", name: "guest-1a2b3c4d", isGuest: true }; const CAROL = { id: "kc:carol", name: "carol", isGuest: false }; const DAVE = { id: "kc:dave", name: "dave", isGuest: false }; const LEGACY = { id: "0b9f6d1e-7c1a-4f7e-9a51-3f0c2b6d8e11", isGuest: true }; const CLOCK_MS = 5 * 60_000; describe("GameManager", () => { let now: number; let rtmt: FakeRtmt; let gameStore: GameStoreImpl; let liveGameStore: InMemoryLiveGameStore; let repository: InMemoryGameHistoryRepository; let gameManager: GameManager; const createManager = () => { rtmt = new FakeRtmt(); gameStore = new GameStoreImpl(); const recorder = new GameRecorder(repository, liveGameStore); gameManager = new GameManager({ rtmt, gameStore, matchMaker: new MatchMakerImpl({ rtmt }), liveGameStore, recorder, clock: () => now, gameClockMs: CLOCK_MS, }); return recorder; }; beforeEach(() => { now = Date.UTC(2026, 9, 16, 12); liveGameStore = new InMemoryLiveGameStore(); repository = new InMemoryGameHistoryRepository(); createManager(); [ALICE, BOB, CAROL, DAVE].forEach((user) => rtmt.connect(user)); }); const startGame = (first = ALICE, second = BOB): BackendGame => { rtmt.receive(first.id, channel_new_game); rtmt.receive(second.id, channel_new_game); const game = gameStore.getGameByUser(first.id); if (!game) throw new Error("game did not start"); return game; }; // Plays the current player's first non-empty pit. const playAnyMove = (game: BackendGame) => { const { turnPlayerId } = game.mancalaGame; const pit = [0, 1, 2, 3, 4, 5].find((p) => game.mancalaGame.board.pits[game.mancalaGame.getBoardIndexByPlayerId(turnPlayerId, p)].stoneCount > 0)!; rtmt.receive(turnPlayerId, channel_game_move, { index: pit }); }; // Leaves the given player a single stone next to their store; playing it empties their side and ends the game. const setUpLastMove = (game: BackendGame, playerId: string) => { const isPlayer1 = game.mancalaGame.player1Id === playerId; const pits = game.mancalaGame.board.pits; pits.forEach((pit) => (pit.stoneCount = 0)); pits[isPlayer1 ? 5 : 12].stoneCount = 1; pits[isPlayer1 ? 7 : 0].stoneCount = 1; game.mancalaGame.turnPlayerId = playerId; }; describe("while a game is played", () => { 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("does not send spectator ids to clients", () => { const game = startGame(); rtmt.receive(CAROL.id, channel_listen_game_events, game.id); playAnyMove(game); const update = rtmt.messagesTo(ALICE.id, channel_on_game_update).pop(); expect(update).not.toHaveProperty("spectatorIds"); expect(update).not.toHaveProperty("allSpectatorIds"); expect(update.spectatorCount).toBe(1); }); it("records the time of each move", () => { const game = startGame(); now += 1000; playAnyMove(game); now += 2000; playAnyMove(game); expect(game.moveTimes).toEqual([game.startedAt + 1000, game.startedAt + 3000]); expect(rtmt.messagesTo(ALICE.id, channel_on_game_update).pop().moveTimes).toHaveLength(2); }); it("rejects a new game for a player who has one in progress", () => { const game = startGame(); rtmt.receive(ALICE.id, channel_new_game); rtmt.receive(CAROL.id, channel_new_game); expect(rtmt.messagesTo(ALICE.id, channel_on_new_game_rejected)).toEqual([{ gameId: game.id }]); expect(gameStore.getGameByUser(ALICE.id)).toBe(game); expect(gameStore.getGameByUser(CAROL.id)).toBeUndefined(); }); it("counts spectators: removed on disconnect, peak and distinct total kept", async () => { const game = startGame(); rtmt.receive(CAROL.id, channel_listen_game_events, game.id); rtmt.receive(DAVE.id, channel_listen_game_events, game.id); rtmt.disconnect(CAROL.id); rtmt.connect(CAROL); rtmt.receive(CAROL.id, channel_listen_game_events, game.id); rtmt.disconnect(DAVE.id); expect(game.spectatorIds).toEqual([CAROL.id]); expect(game.peakSpectators).toBe(2); rtmt.receive(ALICE.id, channel_leave_game); await flushPromises(); expect(repository.records.get(game.id)).toMatchObject({ peakSpectators: 2, totalSpectators: 2 }); }); it("keeps a snapshot of the live game until it ends", async () => { const game = startGame(); playAnyMove(game); await flushPromises(); expect(liveGameStore.games.get(game.id)).toMatchObject({ id: game.id, moveTimes: [now] }); rtmt.receive(ALICE.id, channel_leave_game); await flushPromises(); expect(liveGameStore.games.has(game.id)).toBe(false); }); }); describe("clock", () => { const seatOf = (game: BackendGame, playerId: string) => game.mancalaGame.player1Id === playerId ? "player1Ms" : "player2Ms"; const opponentOf = (playerId: string) => playerId === ALICE.id ? BOB.id : ALICE.id; it("starts with the full time for both players, running for the player who moves first", () => { const game = startGame(); expect(game.clock).toEqual({ initialMs: CLOCK_MS, player1Ms: CLOCK_MS, player2Ms: CLOCK_MS, turnStartedAt: now }); expect(rtmt.messagesTo(ALICE.id, channel_on_game_start)[0]).toMatchObject({ clock: game.clock, serverTime: now }); }); it("a move deducts the turn's time from the mover only and starts the opponent's clock", () => { const game = startGame(); const first = game.mancalaGame.turnPlayerId; now += 7_000; playAnyMove(game); expect(game.mancalaGame.turnPlayerId).toBe(opponentOf(first)); expect(game.clock[seatOf(game, first)]).toBe(CLOCK_MS - 7_000); expect(game.clock[seatOf(game, opponentOf(first))]).toBe(CLOCK_MS); expect(game.clock.turnStartedAt).toBe(now); expect(rtmt.messagesTo(BOB.id, channel_on_game_update).pop()).toMatchObject({ clock: game.clock, serverTime: now }); now += 3_000; playAnyMove(game); expect(game.clock[seatOf(game, opponentOf(first))]).toBe(CLOCK_MS - 3_000); expect(game.moveRemainingMs).toEqual([CLOCK_MS - 7_000, CLOCK_MS - 3_000]); }); it("an extra turn keeps the mover's clock running", () => { const game = startGame(); const mover = game.mancalaGame.turnPlayerId; now += 2_000; // Four stones from the fourth pit: the last one lands in the mover's store. rtmt.receive(mover, channel_game_move, { index: 3 }); expect(game.mancalaGame.turnPlayerId).toBe(mover); now += 1_000; playAnyMove(game); expect(game.moveRemainingMs).toEqual([CLOCK_MS - 2_000, CLOCK_MS - 3_000]); }); it("a move with time left is played", () => { const game = startGame(); now += CLOCK_MS - 1; playAnyMove(game); expect(game.mancalaGame.history).toHaveLength(1); expect(game.moveRemainingMs).toEqual([1]); }); it("a move that arrives after the time has run out is not played, and the opponent wins", async () => { const game = startGame(); const mover = game.mancalaGame.turnPlayerId; now += CLOCK_MS; playAnyMove(game); await flushPromises(); expect(game.mancalaGame.history).toHaveLength(0); expect(gameStore.get(game.id)).toBeUndefined(); const winnerSeat = game.mancalaGame.player1Id === mover ? "player2" : "player1"; expect(repository.records.get(game.id)).toMatchObject({ endReason: "timeout", result: winnerSeat }); const end = rtmt.messagesTo(mover, channel_on_game_end)[0]; expect(end).toMatchObject({ endReason: "timeout", winnerId: opponentOf(mover) }); expect(end.clock).toEqual({ initialMs: CLOCK_MS, [seatOf(game, mover)]: 0, [seatOf(game, opponentOf(mover))]: CLOCK_MS }); }); it("stops when the game ends, after deducting the running player's time", () => { const game = startGame(); const mover = game.mancalaGame.turnPlayerId; now += 4_000; rtmt.receive(ALICE.id, channel_leave_game); expect(game.clock).toEqual({ initialMs: CLOCK_MS, [seatOf(game, mover)]: CLOCK_MS - 4_000, [seatOf(game, opponentOf(mover))]: CLOCK_MS, }); expect(rtmt.messagesTo(BOB.id, channel_on_game_end)[0].clock).toEqual(game.clock); }); it("stops when the game is completed by the rules", () => { const game = startGame(); playAnyMove(game); setUpLastMove(game, ALICE.id); now += 1_000; rtmt.receive(ALICE.id, channel_game_move, { index: 5 }); expect(game.clock.turnStartedAt).toBeUndefined(); expect(rtmt.messagesTo(BOB.id, channel_on_game_end)[0].clock).toEqual(game.clock); }); }); describe("how games end", () => { it("completed: the winner by the rules, with every move saved", async () => { const game = startGame(); playAnyMove(game); setUpLastMove(game, ALICE.id); rtmt.receive(ALICE.id, channel_game_move, { index: 5 }); await flushPromises(); // Alice's stone goes into her store and empties her side, so she also collects Bob's last stone: 2-0. const aliceSeat = game.mancalaGame.player1Id === ALICE.id ? "player1" : "player2"; const record = repository.records.get(game.id)!; expect(record.endReason).toBe("completed"); expect(record.result).toBe(aliceSeat); expect(record.moves).toHaveLength(2); expect(record.moves.map((m) => m.at)).toEqual(game.moveTimes); expect(gameStore.get(game.id)).toBeUndefined(); expect(rtmt.messagesTo(BOB.id, channel_on_game_end)).toEqual([ { gameId: game.id, result: aliceSeat, endReason: "completed", winnerId: ALICE.id, clock: expect.any(Object) }, ]); }); it("left: the player who leaves loses", async () => { const game = startGame(); rtmt.receive(CAROL.id, channel_listen_game_events, game.id); rtmt.receive(ALICE.id, channel_leave_game); await flushPromises(); const record = repository.records.get(game.id)!; const bobSeat = game.mancalaGame.player1Id === BOB.id ? "player1" : "player2"; expect(record).toMatchObject({ endReason: "left", result: bobSeat, leftUserId: ALICE.id }); for (const user of [ALICE, BOB, CAROL]) { expect(rtmt.messagesTo(user.id, channel_on_game_user_leave)).toEqual([ALICE.id]); expect(rtmt.messagesTo(user.id, channel_on_game_end)).toEqual([ { gameId: game.id, result: bobSeat, endReason: "left", winnerId: BOB.id, leftUserId: ALICE.id, clock: expect.any(Object), }, ]); } }); it("timeout: the player whose turn it is loses after 5 minutes offline", async () => { const game = startGame(); const turnPlayer = game.mancalaGame.turnPlayerId; const waitingPlayer = turnPlayer === ALICE.id ? BOB.id : ALICE.id; rtmt.disconnect(turnPlayer); const offlineAt = now; const info = rtmt.messagesTo(waitingPlayer, channel_on_user_connection_change).pop(); const turnPlayerInfo = [info.user1ConnectionInfo, info.user2ConnectionInfo].find((i: any) => i.userId === turnPlayer); expect(turnPlayerInfo).toMatchObject({ isOnline: false, offlineSince: offlineAt }); now = offlineAt + TURN_TIMEOUT_MS - 1; gameManager.checkTimeouts(); expect(gameStore.get(game.id)).toBe(game); now = offlineAt + TURN_TIMEOUT_MS; gameManager.checkTimeouts(); await flushPromises(); expect(gameStore.get(game.id)).toBeUndefined(); const record = repository.records.get(game.id)!; expect(record.endReason).toBe("timeout"); expect(record.result).toBe(game.mancalaGame.player1Id === waitingPlayer ? "player1" : "player2"); }); it("timeout does not apply when the waiting player is offline too, or when it is not the offline player's turn", () => { const game = startGame(); const turnPlayer = game.mancalaGame.turnPlayerId; const waitingPlayer = turnPlayer === ALICE.id ? BOB.id : ALICE.id; rtmt.disconnect(waitingPlayer); now += TURN_TIMEOUT_MS * 2; gameManager.checkTimeouts(); expect(gameStore.get(game.id)).toBe(game); rtmt.disconnect(turnPlayer); now += TURN_TIMEOUT_MS * 2; gameManager.checkTimeouts(); expect(gameStore.get(game.id)).toBe(game); }); it("a reconnect resets the offline time", () => { const game = startGame(); const turnPlayer = game.mancalaGame.turnPlayerId; const user = turnPlayer === ALICE.id ? ALICE : BOB; rtmt.disconnect(turnPlayer); now += TURN_TIMEOUT_MS - 1000; rtmt.connect(user); rtmt.disconnect(turnPlayer); now += 2000; gameManager.checkTimeouts(); expect(gameStore.get(game.id)).toBe(game); }); it("inactive: both players offline for 30 minutes ends the game without a winner", async () => { const game = startGame(); rtmt.disconnect(ALICE.id); now += 60_000; rtmt.disconnect(BOB.id); now += INACTIVE_TIMEOUT_MS - 1; gameManager.checkTimeouts(); expect(gameStore.get(game.id)).toBe(game); now += 1; gameManager.checkTimeouts(); await flushPromises(); expect(repository.records.get(game.id)).toMatchObject({ endReason: "inactive", result: null }); }); }); describe("saving", () => { it("replaces legacy ids with public ids in saved games", async () => { rtmt.connect({ id: LEGACY.id, isGuest: true }); const game = startGame(ALICE, { ...LEGACY, name: undefined } as any); playAnyMove(game); rtmt.receive(LEGACY.id, channel_leave_game); await flushPromises(); const saved = JSON.stringify(repository.records.get(game.id)); expect(saved).not.toContain(LEGACY.id); expect(saved).toContain(publicUserId(LEGACY.id)); expect(saved).toContain(ALICE.id); }); it("keeps a game that could not be saved and saves it once the database is back", async () => { repository.failing = true; const game = startGame(); rtmt.receive(ALICE.id, channel_leave_game); await flushPromises(); expect(liveGameStore.pending.has(game.id)).toBe(true); // A restarted backend picks the pending record up from Valkey. repository.failing = false; const recorder = createManager(); await recorder.retryPending(); expect(repository.records.has(game.id)).toBe(true); expect(liveGameStore.pending.has(game.id)).toBe(false); }); it("keeps games playable while Valkey and the database are down", async () => { liveGameStore.failing = true; repository.failing = true; const game = startGame(); playAnyMove(game); playAnyMove(game); rtmt.receive(ALICE.id, channel_leave_game); await flushPromises(); expect(game.mancalaGame.history).toHaveLength(2); expect(gameStore.get(game.id)).toBeUndefined(); }); }); describe("restart", () => { it("restores live games, which can then be played on", async () => { const game = startGame(); playAnyMove(game); playAnyMove(game); await flushPromises(); const board = game.mancalaGame.board.getStoneArray(); const turnPlayer = game.mancalaGame.turnPlayerId; createManager(); expect(await gameManager.restoreLiveGames()).toBe(1); const restored = gameStore.getGameByUser(ALICE.id)!; expect(restored.id).toBe(game.id); expect(restored.mancalaGame.board.getStoneArray()).toEqual(board); expect(restored.mancalaGame.turnPlayerId).toBe(turnPlayer); expect(restored.mancalaGame.history).toHaveLength(2); expect(gameStore.getGameByUser(BOB.id)).toBe(restored); rtmt.connect(ALICE); rtmt.connect(BOB); expect(rtmt.messagesTo(ALICE.id, channel_on_game_update)).toHaveLength(1); playAnyMove(restored); expect(restored.mancalaGame.history).toHaveLength(3); }); it("counts players as offline from the restart, so the timeout rules start over", () => { startGame(); createManager(); return gameManager.restoreLiveGames().then(() => { const game = gameStore.getGameByUser(ALICE.id)!; const waiting = game.mancalaGame.turnPlayerId === ALICE.id ? BOB : ALICE; rtmt.connect(waiting); now += TURN_TIMEOUT_MS - 1; gameManager.checkTimeouts(); expect(gameStore.get(game.id)).toBe(game); now += 1; gameManager.checkTimeouts(); expect(gameStore.get(game.id)).toBeUndefined(); }); }); it("does not bring back a game that has already been saved", async () => { const game = startGame(); await flushPromises(); const snapshot = liveGameStore.games.get(game.id); rtmt.receive(ALICE.id, channel_leave_game); await flushPromises(); liveGameStore.games.set(game.id, snapshot); // as if removing it had failed createManager(); expect(await gameManager.restoreLiveGames()).toBe(0); await flushPromises(); expect(liveGameStore.games.has(game.id)).toBe(false); }); }); });