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:
parent
999ae79279
commit
3d4f5d7d59
25
apps/backend/src/game/GameClockScheduler.ts
Normal file
25
apps/backend/src/game/GameClockScheduler.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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<Game["clock"]>, 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<string, number>();
|
||||
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));
|
||||
|
||||
@ -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,
|
||||
|
||||
22
apps/backend/tests/game/GameClockScheduler.test.ts
Normal file
22
apps/backend/tests/game/GameClockScheduler.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user