From d0004742f52ea3fa6a16eb582250a92a3369d9c6 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 00:03:32 +0300 Subject: [PATCH] docs: add game history and live games spec --- docs/specs/game-history/design.md | 201 ++++++++++++++++++++++++ docs/specs/game-history/requirements.md | 89 +++++++++++ docs/specs/game-history/tasks.md | 25 +++ 3 files changed, 315 insertions(+) create mode 100644 docs/specs/game-history/design.md create mode 100644 docs/specs/game-history/requirements.md create mode 100644 docs/specs/game-history/tasks.md diff --git a/docs/specs/game-history/design.md b/docs/specs/game-history/design.md new file mode 100644 index 0000000..b63222a --- /dev/null +++ b/docs/specs/game-history/design.md @@ -0,0 +1,201 @@ +# Game history and live games: design + +See `requirements.md` for what is built; requirement ids (R1.1, …) are referenced below. + +## Decisions + +| Decision | Reason | +|---|---| +| **PostgreSQL** for saved games, not MongoDB | MongoDB moved to SSPL. Postgres already runs for Keycloak, so it adds no new technology; `jsonb` stores the move list as a document and the queries (a user's games, most spectated games) are relational. FerretDB was considered (Mongo API on Postgres) but adds a translation layer and compatibility gaps for no gain. | +| One Postgres server, separate databases and users for Keycloak and the game | One container to run and back up on a small server. | +| **Valkey** for live game state and short-lived caches, not Redis | Redis changed its license in 2024; Valkey is the BSD-licensed fork and is protocol compatible. Client: `iovalkey` (MIT). | +| The backend's memory stays the source of truth for live games; Valkey is a write-through copy | The backend runs as a single instance; reading from memory keeps moves fast and simple. Valkey only matters on restart (R1.2). | +| A game is written to Postgres once, when it ends | Live state is already durable in Valkey; one write per game keeps the database simple. | +| Database and cache failures never stop a game | R1.3. Writes are best effort with retry; reads fall back to empty lists. | +| Legacy ids are replaced with a hash before anything is saved or listed | R8.3. The database never holds a legacy userKey. | + +## Infrastructure + +`apps/compose.yaml`: +- `postgres` (`postgres:17.6`) replaces `keycloak-db`. An init script (`apps/postgres/init/01-databases.sh`) creates + the `keycloak` and `mancala` databases, each with its own user, from `KEYCLOAK_DB_PASSWORD` and + `MANCALA_DB_PASSWORD`. Exposed on `127.0.0.1:5433` for local development. +- `valkey` (`valkey/valkey:9.1.2`, append-only file on so live games survive a Valkey restart). Exposed on + `127.0.0.1:6379`. +- Backend configuration: `DATABASE_URL` (default `postgres://mancala:mancala@localhost:5433/mancala`) and + `VALKEY_URL` (default `redis://localhost:6379`). + +## Data model (PostgreSQL) + +Migrations live in `apps/backend/src/db/migrations/` as TypeScript files exporting SQL, applied in order at startup +and recorded in `schema_migrations`. + +```sql +CREATE TABLE games ( + id uuid PRIMARY KEY, + player1_id text NOT NULL, + player2_id text NOT NULL, + result text CHECK (result IN ('player1', 'player2', 'draw')), -- NULL: no winner (inactive) + end_reason text NOT NULL CHECK (end_reason IN ('completed', 'left', 'timeout', 'inactive')), + left_user_id text, + started_at timestamptz NOT NULL, + ended_at timestamptz NOT NULL, + move_count integer NOT NULL, + final_board jsonb NOT NULL, -- 14 stone counts + moves jsonb NOT NULL, -- MoveRecord[] + peak_spectators integer NOT NULL DEFAULT 0, + total_spectators integer NOT NULL DEFAULT 0 +); + +CREATE TABLE game_players ( + game_id uuid NOT NULL REFERENCES games (id) ON DELETE CASCADE, + seat smallint NOT NULL CHECK (seat IN (1, 2)), + user_id text NOT NULL, -- public id (see "Public ids") + name text, + is_guest boolean NOT NULL, + PRIMARY KEY (game_id, seat) +); + +CREATE INDEX game_players_user_id ON game_players (user_id); +CREATE INDEX games_popular ON games (peak_spectators DESC, ended_at DESC); +``` + +Saving uses `INSERT … ON CONFLICT (id) DO NOTHING` in one transaction, so retries are safe. + +## Public ids (R8.3) + +`publicUserId(id)`: Keycloak ids (`kc:…`) are returned unchanged; any other id is a legacy userKey and becomes +`legacy:` + the first 20 hex characters of its SHA-256. Applied to every id in saved games, popular lists and live +previews. Live game messages to the players and spectators of a game, and `GET /game/:id` for a live game, keep raw +ids because the current mobile app compares its userKey with them; this exposure already exists and goes away with +`LEGACY_USERKEY_AUTH` (see `TODO.md`). + +## Shared types (`packages/core`) + +```ts +interface PlayerSummary { userId: string; name?: string; isGuest: boolean } +interface MoveRecord { playerId: string; moveIndex: number; boardSnapshot: number[]; gameSteps: GameStep[]; at: number } +type GameResult = "player1" | "player2" | "draw"; +type EndReason = "completed" | "left" | "timeout" | "inactive"; + +interface LiveGameSummary { + id: string; player1: PlayerSummary; player2: PlayerSummary; board: number[]; + turnPlayerId: string; spectatorCount: number; moveCount: number; startedAt: number; +} +interface FinishedGameSummary { + id: string; player1: PlayerSummary; player2: PlayerSummary; board: number[]; + result: GameResult | null; endReason: EndReason; leftUserId?: string; + startedAt: number; endedAt: number; moveCount: number; peakSpectators: number; +} +interface GameRecord extends FinishedGameSummary { moves: MoveRecord[] } // replay payload +``` + +Live `Game` messages gain `startedAt` and `moveTimes: number[]` (one per history entry), and `UserConnectionInfo` +gains `offlineSince?: number`. `TURN_TIMEOUT_MS` (5 minutes) is exported so the client can show the countdown. + +## Backend + +### Modules +``` +src/db/postgres.ts pool, migrations +src/history/GameHistoryRepository.ts interface + PostgresGameHistoryRepository +src/live/LiveGameStore.ts interface + ValkeyLiveGameStore (snapshots, pending saves) +src/cache/Cache.ts JSON get/set/delete with TTL in Valkey +src/game/GameSerialization.ts BackendGame <-> snapshot, rebuilding the engine objects +src/game/GameRecorder.ts turns a finished game into a GameRecord and saves it, with retry +src/game/LiveGamesBroadcaster.ts top live games, subscriptions, throttled push +src/game/publicIds.ts publicUserId and helpers +``` + +### Game lifecycle (`GameManager`) +- **Start**: as today, plus `startedAt`, empty `moveTimes`, spectator counters; the snapshot is written to Valkey. +- **Move**: the time is appended to `moveTimes`; the snapshot is written to Valkey; live previews are scheduled. +- **End** (R3): the outcome is decided, the game is removed from memory and from Valkey, players and spectators get + the final update, and `GameRecorder` saves the record. + - completed: `mancalaGame.getWonPlayerId()`, draw if none. + - left: the leaving player loses (`left_user_id` is set). + - timeout / inactive: decided by a watcher that runs every 10 seconds, using the time each player went offline + (`offlineSince`, kept per user and cleared when they reconnect). +- **New game while playing** (R2.3): `MatchMaker` is not called; the user gets `on_new_game_rejected` with the game + id. +- **Restart** (R1.2): on startup all snapshots are loaded from Valkey and put back into memory; both players count as + offline from that moment, so the timeout rules start fresh. +- **Fixes needed by this feature**: spectators are removed when they disconnect (the `for…in` loop in + `GameStoreImpl`), finished games are removed from memory, and the connection info is sent once at game start. + +### Saving with retry (R1.3) +`GameRecorder.save(record)`: on failure the record is stored in Valkey under `mancala:pending-save:{id}` (and kept in +memory if Valkey is down too) and retried every 30 seconds and on startup. + +### Valkey keys +| Key | Content | +|---|---| +| `mancala:live:{gameId}` | snapshot of a live game (JSON) | +| `mancala:pending-save:{gameId}` | game record that could not be saved yet | +| `mancala:cache:popular-finished` | popular finished games, 10 s TTL | +| `mancala:cache:recent:{userId}` | a user's recent games, 10 s TTL | + +Caches are deleted when a game ends. The client uses `enableOfflineQueue: false` so commands fail at once instead of +queueing while Valkey is down. + +### HTTP API +| Route | Auth | Response | +|---|---|---| +| `GET /games/me/active` | Bearer token | `{ game: LiveGameSummary \| null }` | +| `GET /games/me?limit=3` | Bearer token | `{ games: FinishedGameSummary[] }` (limit 1–20) | +| `GET /games/popular?limit=5` | none | `{ kind: "live" \| "finished", games: [...] }`; live games if any, else finished | +| `GET /game/:id` | none | a live game as today, else `{ status: "finished", record: GameRecord }`, else 404 | + +The token is the same Keycloak access token used for the WebSocket, checked with the existing `TokenVerifier`. + +### WebSocket channels (added to `channel_names`) +| Channel | Direction | Payload | +|---|---|---| +| `subscribe_live_games` / `unsubscribe_live_games` | client → server | – | +| `on_live_games` | server → client | `{ games: LiveGameSummary[] }`, sent on subscribe and at most once per second when the top 5 change | +| `on_new_game_rejected` | server → client | `{ gameId }` | + +Ordering for live games: spectator count, then most recent move. Preview subscribers are tracked per user and are +not spectators (R4.5). + +## Web app + +### Board size +`BoardView` gets an optional `width` (px). All board components size themselves from a CSS variable +`--board-unit` (default `1vw`, else `width / 100` px) instead of `vw`, so the same board renders at full width in a +game and small in previews. + +### Home page (`routes/Home.tsx`) +1. `ActiveGameBanner`: from `GET /games/me/active`; opponent, whose turn, **Continue**. +2. `LiveGamesSection`: `GET /games/popular` on load, then `subscribe_live_games`; a grid of `GamePreviewCard`s + (read-only board, names, spectator count). Title "Live games" or "Popular games" by `kind`. +3. `RecentGamesSection`: `GET /games/me?limit=3`; cards with opponent, result for this user, score, date, board. + +### Lobby +On `on_new_game_rejected` the lobby shows "You have a game in progress" with **Continue**. + +### Move history (shared by R6 and R7) +- `packages/core/src/history/`: `getInitialBoard()`, `getBoardAfterMove(moves, n)` (n = 0 is the start position), + `describeMove(moves, n)` → `{ number, playerId, pit (1–6), extraTurn, capture, storeGain, gameOver }`, and + `createGameAtMove(game, n)`, which builds the engine objects `PitAnimator` needs to animate move n. + - extra turn: a `GAME_STEP_LAST_STONE_IN_BANK` step; capture: `GAME_STEP_LAST_STONE_IN_EMPTY_PIT` or + `GAME_STEP_DOUBLE_STONE_IN_PIT`; game over: `GAME_STEP_BOARD_CLEARED`; store gain: the mover's store after the + move minus before. +- `MoveList`: the list, highlighting the shown move; clicking selects a move. +- Live game (`GamePage`): `viewedMove` state. When set, the board shows `getBoardAfterMove` and pits are disabled; + `ReviewBanner` shows the move number, "Your turn" when it applies, and **Back to live**. ← / → keys step. +- Countdown: if the opponent is offline on their turn, the info panel shows the time left from `offlineSince`. + +### Replay (`ReplayView`, R7) +`GamePage` renders `ReplayView` when `GET /game/:id` returns a finished record. It starts at the last move with the +result and **Watch from start**. Controls: ⏮ ◀ ▶/⏸ ▶| ⏭, speed 1x/2x/4x, a range input as the timeline, the +`MoveList`, keyboard (← → space). Forward steps go through `PitAnimator` with the games from `createGameAtMove`; +jumps set the board directly. `?move=n` is read on load and updated with `history.replaceState`. + +## Testing +- `packages/core`: unit tests for the history helpers against games played with the engine. +- Backend: unit tests for outcomes, timeouts and public ids with in-memory fakes; integration tests for the Postgres + repository and the Valkey store against real containers (`testcontainers`); the existing WebSocket tests keep + passing. +- End to end in Chrome: two guests play a full game; the home page shows it in recent games and as popular; replay + works; reviewing an earlier move during a live game; restarting the backend in the middle of a game. diff --git a/docs/specs/game-history/requirements.md b/docs/specs/game-history/requirements.md new file mode 100644 index 0000000..23adf54 --- /dev/null +++ b/docs/specs/game-history/requirements.md @@ -0,0 +1,89 @@ +# Game history and live games: requirements + +Status: approved, in development (branch `feature/game-history`, based on `feature/keycloak-auth`). +Scope: backend and web app. The mobile app is out of scope (see `TODO.md`). + +## Goals + +Every game is saved. Players can come back to a game in progress, see their past games, watch popular games live +from the home page, and step through the moves of any game, finished or in progress. + +## Terms + +- **Live game**: a game that is being played right now. +- **Finished game**: a game that has ended for any reason (see R3). +- **Spectator**: a user, other than the two players, who has the game page open. Home page previews do not count. +- **Popularity**: number of spectators; for finished games, the highest number of spectators the game had at once. + +## Requirements + +### R1. Every game is saved +- R1.1 When a game ends, it is stored permanently with its players (id, name, guest flag), result, end reason, + start and end time, every move (player, pit, board after the move, rule events, time of the move), final + board, peak spectator count and number of distinct spectators. +- R1.2 Games in progress survive a backend restart or deploy: after the backend starts again, players who reconnect + continue from the same position, with the same turn. +- R1.3 If the database or the cache is unreachable, games can still be played. A game that could not be saved + when it ended is saved later, once the database is reachable again. + +### R2. Ongoing game +- R2.1 A logged-in user (guest or account) who is a player in a live game sees, at the top of the home page, a + banner with the opponent's name, whose turn it is, and a **Continue** button that opens the game. +- R2.2 This works from any device or browser where the user is logged in with the same identity. +- R2.3 Starting a new game while a game is in progress is not possible: the user is told they have a game in + progress and is offered to continue it. + +### R3. How games end +- R3.1 **Completed**: the game ends by the rules; the player with more stones in their store wins, or it is a draw. +- R3.2 **Left**: a player who leaves the game loses; the other player wins. +- R3.3 **Timeout**: if the player whose turn it is has been offline for 5 minutes while the opponent is online, + the opponent wins. While the opponent is offline on their turn, the waiting player sees a countdown. +- R3.4 **Inactive**: if both players have been offline for 30 minutes, the game ends without a winner. + +### R4. Home page: live and popular games +- R4.1 The home page shows up to 5 live games, most spectated first, each as a preview of the real board with the + players' names and the spectator count. +- R4.2 Previews update live as moves are made, without reloading the page. +- R4.3 If no game is live, the same section shows the 5 most spectated finished games instead, titled as popular + games rather than live games. +- R4.4 Clicking a live game opens it in spectator mode; clicking a finished game opens its replay (R7). +- R4.5 Viewing previews does not change any game's spectator count. + +### R5. Home page: my recent games +- R5.1 A logged-in user sees their last 3 finished games in a separate section: opponent, result from their point + of view (won / lost / draw, and why if the game did not end by the rules), final score, date and a preview of the + final board. +- R5.2 Clicking a game opens its replay. + +### R6. Move history during a live game +- R6.1 Players and spectators of a live game see the list of moves made so far. Each entry shows the move number, + the player, the pit, and what happened: extra turn, capture, stones added to the player's store, game over. +- R6.2 New moves are appended as they happen. +- R6.3 Selecting a move (or using the arrow keys) shows the board as it was after that move. A banner says which + move is shown and offers **Back to live**. The game keeps going in the background. +- R6.4 While an earlier position is shown, the pits cannot be played. If it becomes the user's turn, the banner says + so. + +### R7. Replay of a finished game +- R7.1 Opening a finished game shows its final position, the result and a **Watch from start** button. +- R7.2 Controls: first move, previous move, play/pause, next move, last move, speed (1x, 2x, 4x), and a timeline to + jump to any move. Arrow keys step through moves, space plays or pauses. +- R7.3 Stepping forward animates the move the same way as in a live game; jumping (backwards, timeline, move list) + shows the position without animation. +- R7.4 The move list from R6.1 is shown and can be used to jump to a move. +- R7.5 A link can point to a specific move (`?move=12`). +- R7.6 A player who replays their own game sees the board from their side. +- R7.7 The game's duration is shown. + +### R8. Visibility and privacy +- R8.1 Finished games are public: anyone with the link can open them, and popular finished games appear on the + home page with the players' names. +- R8.2 "My recent games" and the ongoing-game banner are only shown to the user they belong to. +- R8.3 Ids of players who use the legacy userKey login (the current mobile app) are secrets. They are never exposed + in finished games, popular lists or live previews; a derived public id is shown instead. + +## Out of scope (TODO) +- Mobile app support. +- A page with all of a user's games (only the last 3 are shown). +- Showing deleted accounts differently. +- Live game previews and lists across several backend instances (the backend runs as a single instance). diff --git a/docs/specs/game-history/tasks.md b/docs/specs/game-history/tasks.md new file mode 100644 index 0000000..df47b81 --- /dev/null +++ b/docs/specs/game-history/tasks.md @@ -0,0 +1,25 @@ +# Game history and live games: tasks + +Work through the tasks in order; each ends with its tests passing and a commit. Tick a task when it is committed. + +- [ ] 1. **Infrastructure**: `postgres` service with init script for the `keycloak` and `mancala` databases + (replacing `keycloak-db`), `valkey` service, backend `DATABASE_URL` / `VALKEY_URL`, `.env` variables, README. +- [ ] 2. **Core types and history helpers**: `PlayerSummary`, `MoveRecord`, summaries, `GameRecord`, + `TURN_TIMEOUT_MS`, new channels, `offlineSince`; `getInitialBoard`, `getBoardAfterMove`, `describeMove`, + `createGameAtMove` with unit tests; localized strings. +- [ ] 3. **Backend fixes**: remove spectators on disconnect, remove finished games from memory, send the connection + info once at game start. +- [ ] 4. **Postgres repository**: pool, migration runner, `games` and `game_players`, `PostgresGameHistoryRepository` + (save, get, recent for user, popular), integration tests. +- [ ] 5. **Valkey store**: live game snapshots and restore, pending saves, cache; integration tests. +- [ ] 6. **Game lifecycle**: start and move times, spectator counters, outcomes (completed, left, timeout, inactive), + timeout watcher, `offlineSince`, rejecting a new game while playing, saving with retry, restore on startup; + unit tests. +- [ ] 7. **API**: public ids, Bearer auth middleware, `/games/me/active`, `/games/me`, `/games/popular`, + `/game/:id` for finished games, live games channel with throttled push; tests. +- [ ] 8. **Board size**: `--board-unit` in `BoardView`, `PitView`, `StoreView`, `StoneView`; `width` prop. +- [ ] 9. **Home page**: `ActiveGameBanner`, `LiveGamesSection` with live updates, `RecentGamesSection`; lobby + rejection message. +- [ ] 10. **Live game history**: `MoveList`, review mode with `ReviewBanner` and keyboard, timeout countdown. +- [ ] 11. **Replay**: `ReplayView` with controls, speed, timeline, `?move=`, own-side orientation, duration. +- [ ] 12. **End to end and docs**: Chrome tests from `design.md`, README and `TODO.md` updates.