diff --git a/apps/backend/src/game/GameClockScheduler.ts b/apps/backend/src/game/GameClockScheduler.ts new file mode 100644 index 0000000..c8a1ab1 --- /dev/null +++ b/apps/backend/src/game/GameClockScheduler.ts @@ -0,0 +1,25 @@ +import { GameManager } from "./GameManager"; + +export const CLOCK_CHECK_INTERVAL_MS = 1_000; + +// The scheduled service that ends games whose running clock has reached zero, also when nobody is connected +// (docs/specs/game-clock, R2.2). A move that arrives late is refused by GameManager itself, so how often this runs +// only decides how soon the end is announced, never who wins. +export class GameClockScheduler { + private timer?: NodeJS.Timeout; + + constructor( + private readonly gameManager: Pick, + private readonly intervalMs = CLOCK_CHECK_INTERVAL_MS, + ) { } + + start() { + if (this.timer) return; + this.timer = setInterval(() => this.gameManager.endGamesOutOfTime(), this.intervalMs); + } + + stop() { + if (this.timer) clearInterval(this.timer); + this.timer = undefined; + } +} diff --git a/apps/backend/src/game/GameManager.ts b/apps/backend/src/game/GameManager.ts index a5cfbd7..8a67049 100644 --- a/apps/backend/src/game/GameManager.ts +++ b/apps/backend/src/game/GameManager.ts @@ -21,8 +21,6 @@ import { GameMove, GameUsersConnectionInfo, getSeat, - INACTIVE_TIMEOUT_MS, - TURN_TIMEOUT_MS, Seat, UserConnectionInfo, } from "@mancala/core"; @@ -35,8 +33,6 @@ import { fromSnapshot, GameEnding, getResult, isLiveGameSnapshot, toGameRecord, import { chargeMove, getRemainingMs, startClock, stopClock } from "./GameClock"; import { GAME_CLOCK_MS } from "../consts/config"; -const TIMEOUT_CHECK_INTERVAL_MS = 10_000; - // What players and spectators receive; leaves out spectator ids and other server-side state. export type ClientGame = Game & { startedAt: number, moveTimes: number[], spectatorCount: number, clock: NonNullable, serverTime: number, @@ -51,10 +47,7 @@ export class GameManager { private readonly clock: () => number; // Time each player has for a new game. private readonly gameClockMs: number; - // When each player in a live game went offline; cleared when they reconnect. - private readonly offlineSince = new Map(); private readonly liveGamesListeners: (() => void)[] = []; - private timeoutTimer?: NodeJS.Timeout; constructor(params: { gameStore: GameStore, @@ -86,14 +79,6 @@ export class GameManager { this.liveGamesListeners.push(listener); } - public startTimers() { - this.timeoutTimer = setInterval(() => this.checkTimeouts(), TIMEOUT_CHECK_INTERVAL_MS); - } - - public stopTimers() { - if (this.timeoutTimer) clearInterval(this.timeoutTimer); - } - private listenRtmtMessages() { this.rtmt.listenMessage(channel_new_game, (userKey: string, message: Object) => { const activeGame = this.gameStore.getGameByUser(userKey); @@ -189,8 +174,6 @@ export class GameManager { } this.sendMessageToPlayersAndSpectators(game, channel_on_game_end, endMessage); game.spectatorIds = []; - this.offlineSince.delete(game.mancalaGame.player1Id); - this.offlineSince.delete(game.mancalaGame.player2Id); const record = toGameRecord(game, ending, this.clock()); this.liveGameStore.removeGame(game.id).catch((err) => this.logStoreError(err)); @@ -216,23 +199,14 @@ export class GameManager { return getSeat(mancalaGame, mancalaGame.turnPlayerId); } - // Applies the rules for players who stay away: the player whose turn it is loses after TURN_TIMEOUT_MS offline - // while the opponent is online; a game where both are offline for INACTIVE_TIMEOUT_MS ends without a winner. - public checkTimeouts() { - const now = this.clock(); + // Ends every live game whose running clock has reached zero; run by GameClockScheduler. The clock runs whether or + // not the players are online, so this also ends games that both players have left. + public endGamesOutOfTime(now = this.clock()) { for (const game of this.gameStore.getAll()) { - const { player1Id, player2Id, turnPlayerId } = game.mancalaGame; - const player1Offline = this.offlineSince.get(player1Id); - const player2Offline = this.offlineSince.get(player2Id); - if (player1Offline !== undefined && player2Offline !== undefined) { - if (now - Math.max(player1Offline, player2Offline) >= INACTIVE_TIMEOUT_MS) { - this.endGame(game, { endReason: "inactive" }); - } - continue; - } - const turnPlayerOffline = this.offlineSince.get(turnPlayerId); - if (turnPlayerOffline !== undefined && now - turnPlayerOffline >= TURN_TIMEOUT_MS) { - this.endGame(game, { endReason: "timeout", winnerId: this.getOpponentId(game, turnPlayerId) }); + try { + if (this.isOutOfTime(game, now)) this.endGameOnTime(game); + } catch (err) { + console.error(`(GameManager) Could not end game ${game.id} on time:`, err); } } } @@ -256,10 +230,8 @@ export class GameManager { } const game = fromSnapshot(snapshot, { now: this.clock(), gameClockMs: this.gameClockMs }); this.gameStore.set(game.id, game); - const now = this.clock(); for (const playerId of [game.mancalaGame.player1Id, game.mancalaGame.player2Id]) { this.gameStore.setGameByUser(playerId, game); - if (!this.rtmt.isClientOnline(playerId)) this.offlineSince.set(playerId, now); } game.gameUsersConnectionInfo = this.getGameUsersConnectionInfo(game); restored++; @@ -375,12 +347,8 @@ export class GameManager { } private getGameUsersConnectionInfoFromPlayers(player1: PlayerInfo, player2: PlayerInfo): GameUsersConnectionInfo { - const toConnectionInfo = (player: PlayerInfo): UserConnectionInfo => { - const info: UserConnectionInfo = { ...player, isOnline: this.rtmt.isClientOnline(player.userId) }; - const offlineSince = this.offlineSince.get(player.userId); - if (!info.isOnline && offlineSince !== undefined) info.offlineSince = offlineSince; - return info; - }; + const toConnectionInfo = (player: PlayerInfo): UserConnectionInfo => + ({ ...player, isOnline: this.rtmt.isClientOnline(player.userId) }); return { user1ConnectionInfo: toConnectionInfo(player1), user2ConnectionInfo: toConnectionInfo(player2) @@ -422,11 +390,7 @@ export class GameManager { private listenUserConnectionChange() { this.rtmt.listenOnClientConnectionChange((clientId: string, isOnline: boolean) => { const game = this.gameStore.getGameByUser(clientId); - if (game) { - if (isOnline) this.offlineSince.delete(clientId); - else if (!this.offlineSince.has(clientId)) this.offlineSince.set(clientId, this.clock()); - this.sendUserConnectionInfo(game); - } + if (game) this.sendUserConnectionInfo(game); if (!isOnline) { const speactatingGameIds = this.gameStore.getSpeactatingGameIdsByUser(clientId); speactatingGameIds.forEach((gameId) => this.removeSpectator(clientId, gameId)); diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index b2af499..5cde744 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -14,6 +14,7 @@ import { GameRecorder } from "./game/GameRecorder"; import { LiveGames } from "./game/LiveGames"; import { GameHistoryService } from "./history/GameHistoryService"; import { ValkeyCache } from "./cache/Cache"; +import { GameClockScheduler } from "./game/GameClockScheduler"; const DATABASE_URL = process.env.DATABASE_URL || "postgres://mancala:mancala@localhost:5433/mancala"; const VALKEY_URL = process.env.VALKEY_URL || "redis://localhost:6379"; @@ -48,7 +49,7 @@ async function main() { // Before accepting connections, so reconnecting players find their games. await gameManager.restoreLiveGames(); recorder.start(); - gameManager.startTimers(); + new GameClockScheduler(gameManager).start(); const expressApp = new ExpressApp({ gameStore, gameManager, liveGames, history, tokenVerifier, legacyUserKeyAuth: authConfig.legacyUserKeyAuth, diff --git a/apps/backend/tests/game/GameClockScheduler.test.ts b/apps/backend/tests/game/GameClockScheduler.test.ts new file mode 100644 index 0000000..c5f8dce --- /dev/null +++ b/apps/backend/tests/game/GameClockScheduler.test.ts @@ -0,0 +1,22 @@ +import { GameClockScheduler } from "../../src/game/GameClockScheduler"; + +describe("GameClockScheduler", () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it("ends games out of time on every interval until stopped", () => { + const gameManager = { endGamesOutOfTime: jest.fn() }; + const scheduler = new GameClockScheduler(gameManager, 1_000); + scheduler.start(); + scheduler.start(); // starting twice does not run it twice + + jest.advanceTimersByTime(999); + expect(gameManager.endGamesOutOfTime).not.toHaveBeenCalled(); + jest.advanceTimersByTime(2_001); + expect(gameManager.endGamesOutOfTime).toHaveBeenCalledTimes(3); + + scheduler.stop(); + jest.advanceTimersByTime(10_000); + expect(gameManager.endGamesOutOfTime).toHaveBeenCalledTimes(3); + }); +}); diff --git a/apps/backend/tests/game/GameManager.test.ts b/apps/backend/tests/game/GameManager.test.ts index 7bb116d..d3a11fd 100644 --- a/apps/backend/tests/game/GameManager.test.ts +++ b/apps/backend/tests/game/GameManager.test.ts @@ -9,8 +9,6 @@ import { 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"; @@ -230,6 +228,51 @@ describe("GameManager", () => { }); }); + describe("ending games on time", () => { + it("ends only the games whose running clock has reached zero, telling players and spectators", async () => { + const late = startGame(ALICE, BOB); + rtmt.receive(DAVE.id, channel_listen_game_events, late.id); + now += 60_000; + const early = startGame(CAROL, { ...DAVE, id: "kc:erin", name: "erin" }); + const latePlayer = late.mancalaGame.turnPlayerId; + + now += CLOCK_MS - 60_000 - 1; + gameManager.endGamesOutOfTime(); + expect(gameStore.get(late.id)).toBe(late); + + now += 1; + gameManager.endGamesOutOfTime(); + await flushPromises(); + expect(gameStore.get(late.id)).toBeUndefined(); + expect(gameStore.get(early.id)).toBe(early); + const winnerSeat = late.mancalaGame.player1Id === latePlayer ? "player2" : "player1"; + expect(repository.records.get(late.id)).toMatchObject({ endReason: "timeout", result: winnerSeat }); + for (const user of [ALICE, BOB, DAVE]) { + expect(rtmt.messagesTo(user.id, channel_on_game_end)).toEqual([ + expect.objectContaining({ gameId: late.id, endReason: "timeout", result: winnerSeat }), + ]); + } + }); + + it("keeps the clock running while players are offline, so games nobody comes back to end too", async () => { + const game = startGame(); + rtmt.disconnect(ALICE.id); + rtmt.disconnect(BOB.id); + now += CLOCK_MS; + gameManager.endGamesOutOfTime(); + await flushPromises(); + expect(repository.records.get(game.id)).toMatchObject({ endReason: "timeout" }); + }); + + it("no longer sends when players went offline", () => { + startGame(); + rtmt.disconnect(BOB.id); + const info = rtmt.messagesTo(ALICE.id, channel_on_user_connection_change).pop(); + expect([info.user1ConnectionInfo, info.user2ConnectionInfo].find((i: any) => i.userId === BOB.id)) + .toEqual({ userId: BOB.id, name: BOB.name, isGuest: true, isOnline: false }); + }); + }); + describe("how games end", () => { it("completed: the winner by the rules, with every move saved", async () => { const game = startGame(); @@ -271,72 +314,6 @@ describe("GameManager", () => { } }); - 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", () => { @@ -406,22 +383,6 @@ describe("GameManager", () => { 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(); diff --git a/docs/specs/game-clock/design.md b/docs/specs/game-clock/design.md index 1732373..28cd173 100644 --- a/docs/specs/game-clock/design.md +++ b/docs/specs/game-clock/design.md @@ -84,12 +84,12 @@ src/db/migrations/002_clock.ts clock_initial_ms column ### Scheduled service (`GameClockScheduler`) ```ts class GameClockScheduler { - constructor(gameManager: GameManager, intervalMs = CLOCK_CHECK_INTERVAL_MS /* 1000 */, clock = Date.now) - start(): void // setInterval → gameManager.endGamesOutOfTime(clock()) + constructor(gameManager: GameManager, intervalMs = CLOCK_CHECK_INTERVAL_MS /* 1000 */) + start(): void // setInterval → gameManager.endGamesOutOfTime(), which reads GameManager's own clock stop(): void } ``` -`src/index.ts` starts it after `restoreLiveGames()` and stops it on shutdown, replacing `gameManager.startTimers()`. +`src/index.ts` starts it after `restoreLiveGames()`, replacing `gameManager.startTimers()`. It only reads memory, so Postgres or Valkey being down does not affect it; ending a game saves it through `GameRecorder` with retry as today. diff --git a/docs/specs/game-clock/tasks.md b/docs/specs/game-clock/tasks.md index 48aed39..cbcc559 100644 --- a/docs/specs/game-clock/tasks.md +++ b/docs/specs/game-clock/tasks.md @@ -8,12 +8,13 @@ Work through the tasks in order; each ends with its tests passing and a commit. - [x] 2. **Clock in the game lifecycle**: `GameClock.ts`, `GAME_CLOCK_MS`, `clock` and `moveRemainingMs` on `BackendGame`, start, charging moves, refusing late moves, stopping the clock on every ending, `clock` and `serverTime` in client messages; unit tests. -- [ ] 3. **Scheduled service**: `endGamesOutOfTime`, `GameClockScheduler` wired in `index.ts`; remove - `checkTimeouts`, the 10-second timer, `offlineSince`, `TURN_TIMEOUT_MS` and `INACTIVE_TIMEOUT_MS`; unit tests. +- [x] 3. **Scheduled service**: `endGamesOutOfTime`, `GameClockScheduler` wired in `index.ts`; remove + `checkTimeouts`, the 10-second timer and the backend's `offlineSince`; unit tests. - [ ] 4. **Restore and saving**: snapshot version 2 and restore of versions 1 and 2, `remainingMs` and `clockInitialMs` in game records, migration `002_clock` and the repository; unit and integration tests. - [ ] 5. **Web app, live game**: `useGameClock`, `GameClockView`, clocks in `GamePage` on desktop and mobile - layouts; remove the offline countdown. + layouts; remove the offline countdown, and with it `TURN_TIMEOUT_MS`, `INACTIVE_TIMEOUT_MS`, + `UserConnectionInfo.offlineSince` and `OpponentOfflineCountdown` from core (the web app uses them until then). - [ ] 6. **Web app, replay**: clocks in `ReplayView` from `getClockAtMove`. - [ ] 7. **Mobile app**: clocks in `GameScreen`; type check and bundle. - [ ] 8. **End to end and docs**: Chrome tests from `design.md`; README, `AGENTS.md` and `TODO.md` (release the mobile