docs: add game history and live games spec
This commit is contained in:
parent
20efa40e6c
commit
d0004742f5
201
docs/specs/game-history/design.md
Normal file
201
docs/specs/game-history/design.md
Normal file
@ -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.
|
||||
89
docs/specs/game-history/requirements.md
Normal file
89
docs/specs/game-history/requirements.md
Normal file
@ -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).
|
||||
25
docs/specs/game-history/tasks.md
Normal file
25
docs/specs/game-history/tasks.md
Normal file
@ -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.
|
||||
Loading…
Reference in New Issue
Block a user