feat(backend): end games on time with a scheduled service

GameClockScheduler checks every live game once a second and ends those whose running clock has reached zero, whether or not anyone is connected. The clock replaces the offline turn timeout and the inactivity rule, so both are removed with the offline times they used.
This commit is contained in:
Halit Aksoy 2026-10-16 01:29:22 +03:00
parent 999ae79279
commit 3d4f5d7d59
7 changed files with 111 additions and 137 deletions

View File

@ -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<GameManager, "endGamesOutOfTime">,
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;
}
}

View File

@ -21,8 +21,6 @@ import {
GameMove, GameMove,
GameUsersConnectionInfo, GameUsersConnectionInfo,
getSeat, getSeat,
INACTIVE_TIMEOUT_MS,
TURN_TIMEOUT_MS,
Seat, Seat,
UserConnectionInfo, UserConnectionInfo,
} from "@mancala/core"; } from "@mancala/core";
@ -35,8 +33,6 @@ import { fromSnapshot, GameEnding, getResult, isLiveGameSnapshot, toGameRecord,
import { chargeMove, getRemainingMs, startClock, stopClock } from "./GameClock"; import { chargeMove, getRemainingMs, startClock, stopClock } from "./GameClock";
import { GAME_CLOCK_MS } from "../consts/config"; 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. // What players and spectators receive; leaves out spectator ids and other server-side state.
export type ClientGame = Game & { export type ClientGame = Game & {
startedAt: number, moveTimes: number[], spectatorCount: number, clock: NonNullable<Game["clock"]>, serverTime: number, startedAt: number, moveTimes: number[], spectatorCount: number, clock: NonNullable<Game["clock"]>, serverTime: number,
@ -51,10 +47,7 @@ export class GameManager {
private readonly clock: () => number; private readonly clock: () => number;
// Time each player has for a new game. // Time each player has for a new game.
private readonly gameClockMs: number; private readonly gameClockMs: number;
// When each player in a live game went offline; cleared when they reconnect.
private readonly offlineSince = new Map<string, number>();
private readonly liveGamesListeners: (() => void)[] = []; private readonly liveGamesListeners: (() => void)[] = [];
private timeoutTimer?: NodeJS.Timeout;
constructor(params: { constructor(params: {
gameStore: GameStore, gameStore: GameStore,
@ -86,14 +79,6 @@ export class GameManager {
this.liveGamesListeners.push(listener); 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() { private listenRtmtMessages() {
this.rtmt.listenMessage(channel_new_game, (userKey: string, message: Object) => { this.rtmt.listenMessage(channel_new_game, (userKey: string, message: Object) => {
const activeGame = this.gameStore.getGameByUser(userKey); const activeGame = this.gameStore.getGameByUser(userKey);
@ -189,8 +174,6 @@ export class GameManager {
} }
this.sendMessageToPlayersAndSpectators(game, channel_on_game_end, endMessage); this.sendMessageToPlayersAndSpectators(game, channel_on_game_end, endMessage);
game.spectatorIds = []; game.spectatorIds = [];
this.offlineSince.delete(game.mancalaGame.player1Id);
this.offlineSince.delete(game.mancalaGame.player2Id);
const record = toGameRecord(game, ending, this.clock()); const record = toGameRecord(game, ending, this.clock());
this.liveGameStore.removeGame(game.id).catch((err) => this.logStoreError(err)); this.liveGameStore.removeGame(game.id).catch((err) => this.logStoreError(err));
@ -216,23 +199,14 @@ export class GameManager {
return getSeat(mancalaGame, mancalaGame.turnPlayerId); 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 // Ends every live game whose running clock has reached zero; run by GameClockScheduler. The clock runs whether or
// while the opponent is online; a game where both are offline for INACTIVE_TIMEOUT_MS ends without a winner. // not the players are online, so this also ends games that both players have left.
public checkTimeouts() { public endGamesOutOfTime(now = this.clock()) {
const now = this.clock();
for (const game of this.gameStore.getAll()) { for (const game of this.gameStore.getAll()) {
const { player1Id, player2Id, turnPlayerId } = game.mancalaGame; try {
const player1Offline = this.offlineSince.get(player1Id); if (this.isOutOfTime(game, now)) this.endGameOnTime(game);
const player2Offline = this.offlineSince.get(player2Id); } catch (err) {
if (player1Offline !== undefined && player2Offline !== undefined) { console.error(`(GameManager) Could not end game ${game.id} on time:`, err);
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) });
} }
} }
} }
@ -256,10 +230,8 @@ export class GameManager {
} }
const game = fromSnapshot(snapshot, { now: this.clock(), gameClockMs: this.gameClockMs }); const game = fromSnapshot(snapshot, { now: this.clock(), gameClockMs: this.gameClockMs });
this.gameStore.set(game.id, game); this.gameStore.set(game.id, game);
const now = this.clock();
for (const playerId of [game.mancalaGame.player1Id, game.mancalaGame.player2Id]) { for (const playerId of [game.mancalaGame.player1Id, game.mancalaGame.player2Id]) {
this.gameStore.setGameByUser(playerId, game); this.gameStore.setGameByUser(playerId, game);
if (!this.rtmt.isClientOnline(playerId)) this.offlineSince.set(playerId, now);
} }
game.gameUsersConnectionInfo = this.getGameUsersConnectionInfo(game); game.gameUsersConnectionInfo = this.getGameUsersConnectionInfo(game);
restored++; restored++;
@ -375,12 +347,8 @@ export class GameManager {
} }
private getGameUsersConnectionInfoFromPlayers(player1: PlayerInfo, player2: PlayerInfo): GameUsersConnectionInfo { private getGameUsersConnectionInfoFromPlayers(player1: PlayerInfo, player2: PlayerInfo): GameUsersConnectionInfo {
const toConnectionInfo = (player: PlayerInfo): UserConnectionInfo => { const toConnectionInfo = (player: PlayerInfo): UserConnectionInfo =>
const info: UserConnectionInfo = { ...player, isOnline: this.rtmt.isClientOnline(player.userId) }; ({ ...player, isOnline: this.rtmt.isClientOnline(player.userId) });
const offlineSince = this.offlineSince.get(player.userId);
if (!info.isOnline && offlineSince !== undefined) info.offlineSince = offlineSince;
return info;
};
return { return {
user1ConnectionInfo: toConnectionInfo(player1), user1ConnectionInfo: toConnectionInfo(player1),
user2ConnectionInfo: toConnectionInfo(player2) user2ConnectionInfo: toConnectionInfo(player2)
@ -422,11 +390,7 @@ export class GameManager {
private listenUserConnectionChange() { private listenUserConnectionChange() {
this.rtmt.listenOnClientConnectionChange((clientId: string, isOnline: boolean) => { this.rtmt.listenOnClientConnectionChange((clientId: string, isOnline: boolean) => {
const game = this.gameStore.getGameByUser(clientId); const game = this.gameStore.getGameByUser(clientId);
if (game) { if (game) this.sendUserConnectionInfo(game);
if (isOnline) this.offlineSince.delete(clientId);
else if (!this.offlineSince.has(clientId)) this.offlineSince.set(clientId, this.clock());
this.sendUserConnectionInfo(game);
}
if (!isOnline) { if (!isOnline) {
const speactatingGameIds = this.gameStore.getSpeactatingGameIdsByUser(clientId); const speactatingGameIds = this.gameStore.getSpeactatingGameIdsByUser(clientId);
speactatingGameIds.forEach((gameId) => this.removeSpectator(clientId, gameId)); speactatingGameIds.forEach((gameId) => this.removeSpectator(clientId, gameId));

View File

@ -14,6 +14,7 @@ import { GameRecorder } from "./game/GameRecorder";
import { LiveGames } from "./game/LiveGames"; import { LiveGames } from "./game/LiveGames";
import { GameHistoryService } from "./history/GameHistoryService"; import { GameHistoryService } from "./history/GameHistoryService";
import { ValkeyCache } from "./cache/Cache"; import { ValkeyCache } from "./cache/Cache";
import { GameClockScheduler } from "./game/GameClockScheduler";
const DATABASE_URL = process.env.DATABASE_URL || "postgres://mancala:mancala@localhost:5433/mancala"; const DATABASE_URL = process.env.DATABASE_URL || "postgres://mancala:mancala@localhost:5433/mancala";
const VALKEY_URL = process.env.VALKEY_URL || "redis://localhost:6379"; 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. // Before accepting connections, so reconnecting players find their games.
await gameManager.restoreLiveGames(); await gameManager.restoreLiveGames();
recorder.start(); recorder.start();
gameManager.startTimers(); new GameClockScheduler(gameManager).start();
const expressApp = new ExpressApp({ const expressApp = new ExpressApp({
gameStore, gameManager, liveGames, history, tokenVerifier, legacyUserKeyAuth: authConfig.legacyUserKeyAuth, gameStore, gameManager, liveGames, history, tokenVerifier, legacyUserKeyAuth: authConfig.legacyUserKeyAuth,

View File

@ -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);
});
});

View File

@ -9,8 +9,6 @@ import {
channel_on_game_user_leave, channel_on_game_user_leave,
channel_on_new_game_rejected, channel_on_new_game_rejected,
channel_on_user_connection_change, channel_on_user_connection_change,
INACTIVE_TIMEOUT_MS,
TURN_TIMEOUT_MS,
} from "@mancala/core"; } from "@mancala/core";
import { GameManager } from "../../src/game/GameManager"; import { GameManager } from "../../src/game/GameManager";
import { GameRecorder } from "../../src/game/GameRecorder"; 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", () => { describe("how games end", () => {
it("completed: the winner by the rules, with every move saved", async () => { it("completed: the winner by the rules, with every move saved", async () => {
const game = startGame(); 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", () => { describe("saving", () => {
@ -406,22 +383,6 @@ describe("GameManager", () => {
expect(restored.mancalaGame.history).toHaveLength(3); 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 () => { it("does not bring back a game that has already been saved", async () => {
const game = startGame(); const game = startGame();
await flushPromises(); await flushPromises();

View File

@ -84,12 +84,12 @@ src/db/migrations/002_clock.ts clock_initial_ms column
### Scheduled service (`GameClockScheduler`) ### Scheduled service (`GameClockScheduler`)
```ts ```ts
class GameClockScheduler { class GameClockScheduler {
constructor(gameManager: GameManager, intervalMs = CLOCK_CHECK_INTERVAL_MS /* 1000 */, clock = Date.now) constructor(gameManager: GameManager, intervalMs = CLOCK_CHECK_INTERVAL_MS /* 1000 */)
start(): void // setInterval → gameManager.endGamesOutOfTime(clock()) start(): void // setInterval → gameManager.endGamesOutOfTime(), which reads GameManager's own clock
stop(): void 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 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. `GameRecorder` with retry as today.

View File

@ -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 - [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 `BackendGame`, start, charging moves, refusing late moves, stopping the clock on every ending, `clock` and
`serverTime` in client messages; unit tests. `serverTime` in client messages; unit tests.
- [ ] 3. **Scheduled service**: `endGamesOutOfTime`, `GameClockScheduler` wired in `index.ts`; remove - [x] 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. `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 - [ ] 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. `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 - [ ] 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`. - [ ] 6. **Web app, replay**: clocks in `ReplayView` from `getClockAtMove`.
- [ ] 7. **Mobile app**: clocks in `GameScreen`; type check and bundle. - [ ] 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 - [ ] 8. **End to end and docs**: Chrome tests from `design.md`; README, `AGENTS.md` and `TODO.md` (release the mobile