feat(backend): keep the game clock across restarts and in saved games

Live game snapshots become version 2 with the clock and each move's time left. On restore the running clock goes on from the start of the turn, so downtime is not charged; version 1 snapshots get a new clock. Saved games keep the initial time (migration 002_clock) and each move's time left.
This commit is contained in:
Halit Aksoy 2026-10-16 01:30:39 +03:00
parent 3d4f5d7d59
commit c610844e26
8 changed files with 126 additions and 26 deletions

View File

@ -0,0 +1,4 @@
// Time each player started with; NULL for games saved before the game clock. Each move's clock is in `moves`.
export const sql = `
ALTER TABLE games ADD COLUMN clock_initial_ms integer;
`;

View File

@ -1,4 +1,5 @@
import { sql as games001 } from "./001_games";
import { sql as clock002 } from "./002_clock";
export interface Migration {
version: string;
@ -8,4 +9,5 @@ export interface Migration {
// Applied in this order; never change a migration that has been released, add a new one instead.
export const migrations: Migration[] = [
{ version: "001_games", sql: games001 },
{ version: "002_clock", sql: clock002 },
];

View File

@ -1,14 +1,14 @@
import { EndReason, GameRecord, GameResult, MoveRecord } from "@mancala/core";
import { EndReason, GameClock, GameRecord, GameResult, MoveRecord } from "@mancala/core";
import { CommonMancalaGame, GameState, GameStep } from "mancala.js";
import { BackendGame, PlayerInfo } from "../models/Game";
import { publicUserId } from "./publicIds";
import { startClock } from "./GameClock";
import { resumeClock, startClock } from "./GameClock";
type HistoryEntry = { playerId: string, moveIndex: number, boardSnapshot: number[], gameSteps: GameStep[] };
// What is kept in Valkey for a live game; enough to rebuild it after a restart.
// What is kept in Valkey for a live game; enough to rebuild it after a restart. Version 1 has no clock.
export interface LiveGameSnapshot {
version: 1;
version: 1 | 2;
id: string;
player1Id: string;
player2Id: string;
@ -21,6 +21,8 @@ export interface LiveGameSnapshot {
moveTimes: number[];
peakSpectators: number;
allSpectatorIds: string[];
clock?: GameClock;
moveRemainingMs?: (number | null)[];
}
export interface GameEnding {
@ -33,7 +35,7 @@ export interface GameEnding {
export function toSnapshot(game: BackendGame): LiveGameSnapshot {
const { mancalaGame } = game;
return {
version: 1,
version: 2,
id: game.id,
player1Id: mancalaGame.player1Id,
player2Id: mancalaGame.player2Id,
@ -46,17 +48,24 @@ export function toSnapshot(game: BackendGame): LiveGameSnapshot {
moveTimes: game.moveTimes,
peakSpectators: game.peakSpectators,
allSpectatorIds: game.allSpectatorIds,
clock: game.clock,
moveRemainingMs: game.moveRemainingMs,
};
}
export function isLiveGameSnapshot(value: unknown): value is LiveGameSnapshot {
const snapshot = value as LiveGameSnapshot;
return typeof snapshot === "object" && snapshot !== null && snapshot.version === 1
&& typeof snapshot.id === "string" && Array.isArray(snapshot.board) && snapshot.board.length === 14
if (typeof snapshot !== "object" || snapshot === null) return false;
const valid = typeof snapshot.id === "string" && Array.isArray(snapshot.board) && snapshot.board.length === 14
&& Array.isArray(snapshot.history) && typeof snapshot.players === "object";
if (snapshot.version === 1) return valid;
return snapshot.version === 2 && valid && typeof snapshot.clock?.initialMs === "number"
&& Array.isArray(snapshot.moveRemainingMs);
}
// Rebuilds a live game with real engine objects (rules, pits), so it can be played on.
// Rebuilds a live game with real engine objects (rules, pits), so it can be played on. The running clock goes on
// from the start of the turn, so the time the backend was down is not charged; games from before the game clock
// get a new clock.
export function fromSnapshot(snapshot: LiveGameSnapshot, params: { now: number, gameClockMs: number }): BackendGame {
const mancalaGame = new CommonMancalaGame(snapshot.id, snapshot.player1Id, snapshot.player2Id);
snapshot.board.forEach((stoneCount, index) => (mancalaGame.board.pits[index].stoneCount = stoneCount));
@ -77,8 +86,8 @@ export function fromSnapshot(snapshot: LiveGameSnapshot, params: { now: number,
players: snapshot.players,
startedAt: snapshot.startedAt,
moveTimes: snapshot.moveTimes,
clock: startClock(params.gameClockMs, params.now),
moveRemainingMs: [],
clock: snapshot.clock ? resumeClock(snapshot.clock, params.now) : startClock(params.gameClockMs, params.now),
moveRemainingMs: snapshot.moveRemainingMs ?? snapshot.history.map(() => null),
};
}
@ -98,13 +107,18 @@ export function toGameRecord(game: BackendGame, ending: GameEnding, endedAt: num
const summary = { userId: publicUserId(id), isGuest: player?.isGuest ?? true };
return player?.name === undefined ? summary : { ...summary, name: player.name };
};
const moves: MoveRecord[] = history.map((entry, i) => ({
const moves: MoveRecord[] = history.map((entry, i) => {
const move: MoveRecord = {
playerId: publicUserId(entry.playerId),
moveIndex: entry.moveIndex,
boardSnapshot: entry.boardSnapshot,
gameSteps: entry.gameSteps,
at: game.moveTimes[i] ?? endedAt,
}));
};
const remainingMs = game.moveRemainingMs[i];
if (typeof remainingMs === "number") move.remainingMs = remainingMs;
return move;
});
const record: GameRecord = {
id: game.id,
player1: playerSummary(mancalaGame.player1Id),
@ -118,6 +132,7 @@ export function toGameRecord(game: BackendGame, ending: GameEnding, endedAt: num
peakSpectators: game.peakSpectators,
totalSpectators: game.allSpectatorIds.length,
moves,
clockInitialMs: game.clock.initialMs,
};
if (ending.leftUserId !== undefined) record.leftUserId = publicUserId(ending.leftUserId);
return record;

View File

@ -15,7 +15,7 @@ export interface GameHistoryRepository {
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const SUMMARY_COLUMNS = `g.id, g.result, g.end_reason, g.left_user_id, g.started_at, g.ended_at, g.move_count,
g.final_board, g.peak_spectators,
g.final_board, g.peak_spectators, g.clock_initial_ms,
p1.user_id AS p1_user_id, p1.name AS p1_name, p1.is_guest AS p1_is_guest,
p2.user_id AS p2_user_id, p2.name AS p2_name, p2.is_guest AS p2_is_guest`;
@ -26,7 +26,7 @@ const FROM_GAMES = `FROM games g
type SummaryRow = {
id: string; result: FinishedGameSummary["result"]; end_reason: FinishedGameSummary["endReason"];
left_user_id: string | null; started_at: Date; ended_at: Date; move_count: number; final_board: number[];
peak_spectators: number;
peak_spectators: number; clock_initial_ms: number | null;
p1_user_id: string; p1_name: string | null; p1_is_guest: boolean;
p2_user_id: string; p2_name: string | null; p2_is_guest: boolean;
};
@ -48,6 +48,7 @@ function toSummary(row: SummaryRow): FinishedGameSummary {
peakSpectators: row.peak_spectators,
};
if (row.left_user_id !== null) summary.leftUserId = row.left_user_id;
if (row.clock_initial_ms !== null) summary.clockInitialMs = row.clock_initial_ms;
return summary;
}
@ -58,14 +59,14 @@ export class PostgresGameHistoryRepository implements GameHistoryRepository {
await this.inTransaction(async (client) => {
const inserted = await client.query(
`INSERT INTO games (id, player1_id, player2_id, result, end_reason, left_user_id, started_at, ended_at,
move_count, final_board, moves, peak_spectators, total_spectators)
move_count, final_board, moves, peak_spectators, total_spectators, clock_initial_ms)
VALUES ($1, $2, $3, $4, $5, $6, to_timestamp($7 / 1000.0), to_timestamp($8 / 1000.0),
$9, $10, $11, $12, $13)
$9, $10, $11, $12, $13, $14)
ON CONFLICT (id) DO NOTHING`,
[record.id, record.player1.userId, record.player2.userId, record.result, record.endReason,
record.leftUserId ?? null, record.startedAt, record.endedAt, record.moveCount,
JSON.stringify(record.board), JSON.stringify(record.moves), record.peakSpectators,
record.totalSpectators]);
record.totalSpectators, record.clockInitialMs ?? null]);
if (inserted.rowCount === 0) return;
for (const [seat, player] of [[1, record.player1], [2, record.player2]] as const) {
await client.query(

View File

@ -19,6 +19,6 @@ export interface BackendGame extends Game {
// Time of each move, aligned with mancalaGame.history.
moveTimes: number[];
clock: GameClock;
// The mover's clock after each move, aligned with mancalaGame.history.
moveRemainingMs: number[];
// The mover's clock after each move, aligned with mancalaGame.history; null for moves made before the game clock.
moveRemainingMs: (number | null)[];
}

View File

@ -317,6 +317,20 @@ describe("GameManager", () => {
});
describe("saving", () => {
it("saves the initial time and each move's time left", async () => {
const game = startGame();
now += 4_000;
playAnyMove(game);
now += 6_000;
playAnyMove(game);
rtmt.receive(ALICE.id, channel_leave_game);
await flushPromises();
const record = repository.records.get(game.id)!;
expect(record.clockInitialMs).toBe(CLOCK_MS);
expect(record.moves.map((move) => move.remainingMs)).toEqual([CLOCK_MS - 4_000, CLOCK_MS - 6_000]);
});
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);
@ -383,6 +397,54 @@ describe("GameManager", () => {
expect(restored.mancalaGame.history).toHaveLength(3);
});
it("restores the clock without charging the time the backend was down", async () => {
const game = startGame();
const first = game.mancalaGame.turnPlayerId;
const firstSeat = game.mancalaGame.player1Id === first ? "player1Ms" : "player2Ms";
const secondSeat = firstSeat === "player1Ms" ? "player2Ms" : "player1Ms";
now += 10_000;
playAnyMove(game);
await flushPromises();
now += 5_000; // the second player thinks, then the backend goes down for a minute
now += 60_000;
createManager();
await gameManager.restoreLiveGames();
const restored = gameStore.getGameByUser(ALICE.id)!;
expect(restored.clock).toEqual({
initialMs: CLOCK_MS, [firstSeat]: CLOCK_MS - 10_000, [secondSeat]: CLOCK_MS, turnStartedAt: now,
});
expect(restored.moveRemainingMs).toEqual([CLOCK_MS - 10_000]);
now += 1_000;
playAnyMove(restored);
expect(restored.clock[secondSeat]).toBe(CLOCK_MS - 1_000);
});
it("gives games from before the game clock a new clock", async () => {
const game = startGame();
playAnyMove(game);
playAnyMove(game);
await flushPromises();
const { clock, moveRemainingMs, ...rest } = liveGameStore.games.get(game.id) as any;
liveGameStore.games.set(game.id, { ...rest, version: 1 });
now += 60_000;
createManager();
expect(await gameManager.restoreLiveGames()).toBe(1);
const restored = gameStore.getGameByUser(ALICE.id)!;
expect(restored.clock).toEqual({ initialMs: CLOCK_MS, player1Ms: CLOCK_MS, player2Ms: CLOCK_MS, turnStartedAt: now });
expect(restored.moveRemainingMs).toEqual([null, null]);
now += 2_000;
playAnyMove(restored);
rtmt.receive(ALICE.id, channel_leave_game);
await flushPromises();
const record = repository.records.get(game.id)!;
expect(record.clockInitialMs).toBe(CLOCK_MS);
expect(record.moves.map((move) => move.remainingMs)).toEqual([undefined, undefined, CLOCK_MS - 2_000]);
});
it("does not bring back a game that has already been saved", async () => {
const game = startGame();
await flushPromises();

View File

@ -60,7 +60,7 @@ describe("PostgresGameHistoryRepository", () => {
it("runs migrations only once", async () => {
await runMigrations(pool);
const { rows } = await pool.query("SELECT version FROM schema_migrations");
expect(rows).toEqual([{ version: "001_games" }]);
expect(rows).toEqual([{ version: "001_games" }, { version: "002_clock" }]);
});
it("saves a game and reads it back unchanged", async () => {
@ -69,6 +69,22 @@ describe("PostgresGameHistoryRepository", () => {
expect(await repository.get(game.id)).toEqual(game);
});
it("keeps the clock: the initial time and each move's time left", async () => {
const game = record({ clockInitialMs: 300_000 });
game.moves[0].remainingMs = 291_500;
await repository.save(game);
expect(await repository.get(game.id)).toEqual(game);
expect((await repository.getRecentForUser(ALICE.userId, 1))[0].clockInitialMs).toBe(300_000);
});
it("reads games saved before the game clock without clock data", async () => {
const game = record();
await repository.save(game);
const saved = await repository.get(game.id);
expect(saved).not.toHaveProperty("clockInitialMs");
expect(saved?.moves[0]).not.toHaveProperty("remainingMs");
});
it("keeps leftUserId for games a player left", async () => {
const game = record({ result: "player2", endReason: "left", leftUserId: ALICE.userId });
await repository.save(game);

View File

@ -10,7 +10,7 @@ Work through the tasks in order; each ends with its tests passing and a commit.
`serverTime` in client messages; 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
- [x] 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, and with it `TURN_TIMEOUT_MS`, `INACTIVE_TIMEOUT_MS`,