diff --git a/apps/backend/src/db/migrations/002_clock.ts b/apps/backend/src/db/migrations/002_clock.ts new file mode 100644 index 0000000..461ecff --- /dev/null +++ b/apps/backend/src/db/migrations/002_clock.ts @@ -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; +`; diff --git a/apps/backend/src/db/migrations/index.ts b/apps/backend/src/db/migrations/index.ts index 7c5e1ab..ce0b17f 100644 --- a/apps/backend/src/db/migrations/index.ts +++ b/apps/backend/src/db/migrations/index.ts @@ -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 }, ]; diff --git a/apps/backend/src/game/GameSerialization.ts b/apps/backend/src/game/GameSerialization.ts index 0be5e13..d88374e 100644 --- a/apps/backend/src/game/GameSerialization.ts +++ b/apps/backend/src/game/GameSerialization.ts @@ -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) => ({ - playerId: publicUserId(entry.playerId), - moveIndex: entry.moveIndex, - boardSnapshot: entry.boardSnapshot, - gameSteps: entry.gameSteps, - at: game.moveTimes[i] ?? endedAt, - })); + 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; diff --git a/apps/backend/src/history/GameHistoryRepository.ts b/apps/backend/src/history/GameHistoryRepository.ts index 19f6231..055dff9 100644 --- a/apps/backend/src/history/GameHistoryRepository.ts +++ b/apps/backend/src/history/GameHistoryRepository.ts @@ -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( diff --git a/apps/backend/src/models/Game.ts b/apps/backend/src/models/Game.ts index ee9c3c0..c9fd6ff 100644 --- a/apps/backend/src/models/Game.ts +++ b/apps/backend/src/models/Game.ts @@ -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)[]; } diff --git a/apps/backend/tests/game/GameManager.test.ts b/apps/backend/tests/game/GameManager.test.ts index d3a11fd..2ad8a21 100644 --- a/apps/backend/tests/game/GameManager.test.ts +++ b/apps/backend/tests/game/GameManager.test.ts @@ -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(); diff --git a/apps/backend/tests/history/PostgresGameHistoryRepository.test.ts b/apps/backend/tests/history/PostgresGameHistoryRepository.test.ts index 4cc0f43..7aaecc0 100644 --- a/apps/backend/tests/history/PostgresGameHistoryRepository.test.ts +++ b/apps/backend/tests/history/PostgresGameHistoryRepository.test.ts @@ -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); diff --git a/docs/specs/game-clock/tasks.md b/docs/specs/game-clock/tasks.md index cbcc559..911337c 100644 --- a/docs/specs/game-clock/tasks.md +++ b/docs/specs/game-clock/tasks.md @@ -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`,