v0.13.0 web and terminal client enhance, replay api, linux server deploy, doc and dependency update
test / test (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-09-28 22:35:52 -04:00
1 parent 52ac0fe31a
commit f1ac4cbbe7
71 files changed
+7677 -537

No files matched your search

+157 -13
View File
@@ -157,6 +157,11 @@ Authorization: Bearer <token>
Note: When authenticated, human player IDs match the user's ID. Anonymous players receive unique UUIDs.
A custom `fen` must describe a position that can arise in a game: one king
per side, no pawn on the first or last rank, and the side not to move not in
check; otherwise the response is 400 `INVALID_FEN` with the reason. Castling
rights whose king or rook has left its original square are dropped.
### Get Game
`GET /games/{gameId}`
@@ -190,7 +195,8 @@ the game ID, matching the existing public live-game read model. Persistent
storage must be enabled.
The response contains the initial FEN and an ordered FEN after every move, so a
client can replay the game without running a chess engine.
client can replay the game without running a chess engine. Each move also
carries its SAN, derived on read from the stored position before it.
**Response (200):**
```json
@@ -198,6 +204,8 @@ client can replay the game without running a chess engine.
"gameId": "a1b2c3d4-e5f6-7890-1234-567890abcdef",
"initialFen": "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
"result": "white_wins",
"pgnResult": "1-0",
"termination": "checkmate",
"startTimeUtc": "2026-09-07T12:00:00Z",
"endTimeUtc": "2026-09-07T12:15:00Z",
"players": {
@@ -208,6 +216,7 @@ client can replay the game without running a chess engine.
{
"moveNumber": 1,
"moveUci": "e2e4",
"san": "e4",
"fenAfterMove": "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1",
"playerColor": "w",
"moveTimeUtc": "2026-09-07T12:00:05Z"
@@ -217,28 +226,87 @@ client can replay the game without running a chess engine.
```
`result` is omitted while a game is ongoing. Persisted terminal values are
`white_wins`, `black_wins`, `draw`, and `stalemate`. A claimed player's `name`
is their username when the claim was recorded; it survives later renames and
`white_wins`, `black_wins`, `draw`, and `stalemate`. `pgnResult` is the PGN
token for the same outcome (`1-0`, `0-1`, `1/2-1/2`, or `*` while ongoing),
and `termination` names how it was reached (see
[Game End](#game-end-draws-and-resignation); omitted while ongoing).
`moveNumber` counts plies from 1. `san` is omitted only for a stored move the
rules core cannot notate, which the server logs. A claimed player's `name` is
their username when the claim was recorded; it survives later renames and
account deletion, and is omitted for anonymous and computer players.
Undo stays available after a result (a finished game can be rewound and
played on), so a stored game is never final. Responses carry a strong `ETag`
and `Cache-Control: private, no-cache`: send `If-None-Match` to get 304 while
nothing changed.
Returns 400 for a non-canonical game ID, 404 when the game has no durable
record, and 503 when persistence is disabled or degraded.
### Export PGN
`GET /games/{gameId}/pgn?ply=N`
Returns the stored game in PGN export format as
`application/x-chess-pgn; charset=utf-8` with
`Content-Disposition: attachment; filename="chess-<yyyymmdd>-<id8>.pgn"`.
Same visibility, `ETag`, and error responses as the history. `ply` (optional,
0 up to the number of stored plies) exports only the first N plies; the
result is then `*`, the `Termination` tag is left out, and the filename ends
`-ply<N>.pgn`. A `fetch` of the URL returns the text, so clients can copy it
as well as download it.
```
[Event "Casual game"]
[Site "?"]
[Date "2026.09.27"]
[Round "-"]
[White "alice"]
[Black "Stockfish level 3"]
[Result "0-1"]
[GameId "68007fd1-8970-4d84-950d-2f6ec6375c0b"]
[UTCDate "2026.09.27"]
[UTCTime "08:07:00"]
[WhiteType "human"]
[BlackType "program"]
[PlyCount "4"]
[Termination "normal"]
1. f3 e5 2. g4 Qh4# 0-1
```
The movetext of a finished game ends with a comment naming the outcome
before the result, e.g. `{ Black wins by resignation. } 0-1` or
`{ Draw by threefold repetition. } 1/2-1/2`.
Players are the name snapshot, `Anonymous`, or `Stockfish level N`. A game
from a custom position adds `SetUp "1"` and `FEN` tags, and its movetext
starts at that position's move number (`55. g8=Q` or `40... Kd8`).
Movetext wraps at 80 columns. `Termination` is `normal` for a finished game
and `unterminated` otherwise.
### List My Stored Games
`GET /users/me/games?limit=50&offset=0`
`GET /users/me/games?limit=50&cursor=<nextCursor>&status=finished&color=white`
Returns games associated with the authenticated user at creation time or by a
later first-move slot claim. Requires `Authorization: Bearer <token>` and
persistent storage.
later first-move slot claim, newest first. Requires
`Authorization: Bearer <token>` and persistent storage.
- `limit`: 1-100; defaults to 50
- `offset`: 0-1,000,000; defaults to 0
- `cursor`: the previous page's `nextCursor`; continues after that game, so
games created meanwhile do not shift the page. Opaque; do not construct it.
- `status`: `ongoing` or `finished`
- `color`: `white` or `black`, the side the user claimed
- `offset`: 0-1,000,000; position-based paging for older clients. It cannot
be combined with `cursor`, and pages shift when games are added.
Each item contains game ID, initial FEN, final FEN (after the last stored move,
or the initial FEN when there is none), result/timestamps, players, and move
count. `nextOffset` is present only when another page exists. A game belongs to
a user who created it while authenticated or claimed a slot with a first move;
the association survives later player reconfiguration.
or the initial FEN when there is none), result and its `pgnResult` token,
timestamps, players, and move count. `nextCursor` is present only when another
page exists; `nextOffset` also, on requests without a cursor. A game belongs
to a user who created it while authenticated or claimed a slot with a first
move; the association survives later player reconfiguration.
Returns 400 `INVALID_REQUEST` for a malformed cursor or filter.
**Response (200):**
```json
@@ -249,6 +317,7 @@ the association survives later player reconfiguration.
"initialFen": "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
"finalFen": "r1bqkb1r/pppp1Qpp/2n2n2/4p3/2B1P3/8/PPPP1PPP/RNB1K1NR b KQkq - 0 4",
"result": "white_wins",
"pgnResult": "1-0",
"startTimeUtc": "2026-09-07T12:00:00Z",
"endTimeUtc": "2026-09-07T12:15:00Z",
"moveCount": 7,
@@ -259,7 +328,8 @@ the association survives later player reconfiguration.
}
],
"limit": 50,
"offset": 0
"offset": 0,
"nextCursor": "MTc5MDQ5NjQyMDU4MTg2NS42ODAwN2ZkMS04OTcwLTRkODQtOTUwZC0yZjZlYzYzNzVjMGI"
}
```
@@ -273,15 +343,89 @@ Submits human move or triggers computer move.
{"move": "e2e4"}
```
Moves are UCI: castling is the king's two-square move (`e1g1`), and a
promotion appends the piece (`e7e8q`, `r`, `b`, or `n`). A pawn move to the
last rank without the piece returns 400 `INVALID_MOVE` with
`promotion piece required`.
**Computer move trigger:**
```json
{"move": "cccc"}
```
A move that ends the game (mate, stalemate, or an automatic draw) returns the
terminal state and `termination` in the same response.
### Game End: Draws and Resignation
A live game (`GET /games/{gameId}` and every game response) carries:
| Field | Meaning |
|---|---|
| `state` | `ongoing`, `pending` (computer thinking), `stuck` (engine failed), `white wins`, `black wins`, `stalemate`, or `draw` |
| `termination` | How a finished game ended: `checkmate`, `resignation`, `stalemate`, `insufficient_material`, `threefold_repetition`, `fifty_move_rule`, or `agreement`; omitted while unfinished |
| `drawOffer` | `w` or `b`: that side's draw offer awaits an answer; omitted when none |
| `drawOutcome` | Only on responses to `POST .../draw`: `offered`, `accepted`, or `declined` |
**Automatic draws.** After every move the game is drawn, without a claim, as
on most online servers, when:
- neither side can mate: bare kings, one minor piece, or only bishops all on
squares of one color (two knights do not count: mate is possible);
- the position occurs for the third time with the same side to move,
castling rights, and en-passant possibility (threefold repetition);
- 50 moves by each side pass without a capture or pawn move (the halfmove
clock reaches 100).
Mate and stalemate take precedence. A custom starting position that is
already dead, or whose halfmove clock is at 100, is created drawn.
#### Resign
`POST /games/{gameId}/resign`
```json
{"color": "w"}
```
Ends the game in the other side's favor, with `termination: "resignation"`.
`color` (`w`, `b`, `white`, `black`) may be omitted when the server can infer
the side: the one side the authenticated caller claimed, else the only human
side. Allowed while the game is unfinished, including while the computer is
thinking (its move is discarded) or the engine is stuck.
#### Offer, accept, or decline a draw
`POST /games/{gameId}/draw`
```json
{"action": "offer", "color": "b"}
```
`action` is `offer`, `accept`, or `decline`; `color` works as for resign.
Allowed only while `state` is `ongoing`.
- **To a human:** the offer stands (`drawOffer`) until the opponent accepts,
declines, or makes a move instead, which declines it; the offerer's own
move keeps it. Offering while the opponent's offer stands accepts it.
Repeating a standing offer is a no-op. Waiting long-poll clients are woken
on offers and answers.
- **To the computer:** answered at once in `drawOutcome`. The computer
declines before both sides have made ten moves; after that it accepts when
a short full-strength search rates its own side level or worse.
- **One offer per move:** after an offer is declined, the same side must make
a move before offering again (409 `GAME_CONFLICT`).
Resign and draw share the move endpoint's authorization: a side claimed by a
user acts only for that user (403 `UNAUTHORIZED`), and an authenticated
resignation or acceptance claims an unclaimed side as a first move would.
Other errors are 400: `GAME_OVER` for a finished game, `INVALID_REQUEST` for
an ambiguous or computer side, a missing offer, or a game that is not
ongoing. Both return the game.
### Undo Moves
`POST /games/{gameId}/undo`
Reverts moves from history.
Reverts moves from history. Undo stays available after a result, including a
resignation or an agreed draw: it rewinds the result and any draw offer.
### Configure Players
`PUT /games/{gameId}/players`
+59 -14
View File
@@ -57,7 +57,14 @@ schema is current, which lets a DML-only runtime role start the server.
### Supporting Modules
- **Engine** (`internal/engine`): UCI protocol wrapper for Stockfish process communication
- **Game** (`internal/game`): Game state with snapshot history and player associations
- **Board** (`internal/board`): FEN parsing and ASCII generation
- **Rules core** (`internal/server/chess`): dependency-free position model,
legal move generation, SAN, PGN, ASCII board, dead-material and repetition
detection. It validates custom starting FENs, applies the automatic draw
rules, and shadows the engine on every live move; Stockfish stays the
authority for move legality, mate, and stalemate
- **Replay** (`internal/server/replay`): works on stored game records: SAN
for every ply, PGN assembly with the termination comment, and verification
against the rules (used by `db verify`, `db pgn`, and the integrity sweep)
- **Core** (`internal/core`): Shared types, API models, error constants
- **CLI** (`cmd/chess-server/cli`): Database and user management commands
- **Client** (`cmd/chess-client-cli`, `internal/client`): Interactive debugging client with command registry, session management, and colored terminal output
@@ -84,19 +91,41 @@ schema is current, which lets a DML-only runtime role start the server.
1. HTTP handler receives `POST /games/{id}/moves` with move
2. Optional JWT validation for user verification
3. Creates MakeMoveCommand, calls `processor.Execute()`
4. Processor validates move via locked validation engine
5. If legal, gets new FEN from engine
6. Calls `service.ApplyMoveWithState()` with the FEN, turn, and state that were validated
7. Service rejects a stale concurrent commit or atomically updates the move, optional slot claim, and terminal result
8. The same logical mutation is queued as one database transaction
9. Returns GameResponse
4. The rules core checks the move first only to name a missing promotion
piece; the processor then validates it via the locked validation engine
5. If legal, gets new FEN from engine, and logs a warning if the rules core
disagrees on legality or the resulting position
6. If neither mate nor stalemate, the processor applies the automatic draw
rules to the new position and the game's position history: dead material,
threefold repetition, fifty-move rule
7. Calls `service.ApplyMoveWithState()` with the FEN, turn, state, and
termination that were decided
8. Service rejects a stale concurrent commit or atomically updates the move,
optional slot claim, terminal result, and termination; a move by the
recipient of a draw offer declines it
9. The same logical mutation is queued as one database transaction
10. Returns GameResponse
### Resignation and Draw Agreement
1. `POST /games/{id}/resign` or `/draw` with an optional side
2. The processor resolves the acting side (requested, else the caller's one
claimed side, else the only human side) and checks slot ownership
3. A draw offer to a human is recorded on the game and wakes long-polls; an
offer to a computer is answered at once from a short full-strength search
on the validation engine (after twenty plies; accept when level or worse
for the computer); one offer per own move
4. `service.EndGame()` re-checks under its lock that the game is unfinished,
unchanged, and the accepted offer still stands, then sets result and
termination, claims an empty acting side for an authenticated caller, and
queues one transaction
### Computer Move
1. HTTP handler receives `POST /games/{id}/moves` with `{"move": "cccc"}`
2. Processor sets game state to `pending`
3. Submits task to EngineQueue, returns immediately
4. Worker goroutine calculates move with dedicated Stockfish instance
5. Callback updates game state via service
5. Callback re-validates the move on the validation engine, applies the
automatic draw rules, and commits via the service
6. Client polls for completion
7. Returns GameResponse
@@ -115,8 +144,10 @@ schema is current, which lets a DML-only runtime role start the server.
2. Storage queues a barrier after all previously accepted gameplay writes
3. The writer reaches the barrier only after those transactions finish
4. Storage reads the game row and ordered moves in one REPEATABLE READ transaction
5. The API returns the initial FEN plus every UCI move and resulting FEN
6. This path works after terminal-memory eviction or a server restart
5. The replay package derives SAN for each ply from the stored FEN before it
6. The API returns the initial FEN plus every UCI move, its SAN, and the
resulting FEN, with a strong ETag; `/pgn` renders the same read as PGN
7. This path works after terminal-memory eviction or a server restart
## Persistence Flow
@@ -206,7 +237,7 @@ Commands encapsulate operations with type, arguments, and optional user ID for a
### Player Configuration
Players identified by UUID (authenticated users) or generated IDs (anonymous), configured with type (human/computer), skill level, and search time.
### Storage Schema (version 1)
### Storage Schema (version 2)
```sql
users (
user_id uuid PRIMARY KEY,
@@ -236,6 +267,9 @@ games (
black_... (same columns),
start_time_utc timestamptz NOT NULL,
result text, -- white_wins, black_wins, draw, stalemate
termination text, -- how: checkmate, resignation, stalemate,
-- insufficient_material, threefold_repetition,
-- fifty_move_rule, agreement
end_time_utc timestamptz, -- set exactly when result is set
)
@@ -250,7 +284,9 @@ moves (
)
```
`schema_version` records the applied version. Indexes cover session expiry,
`schema_version` records the applied version; version 2 added
`termination`, constrained to be set exactly with `result` and to fit it
(wins by checkmate or resignation, draws by a draw rule or agreement). Indexes cover session expiry,
each claim column ordered by `(start_time_utc DESC, game_id DESC)` for the game
listing, and the start time of unclaimed games for the anonymous-game purge.
Claims and player IDs deliberately have no foreign key to `users`: history
@@ -264,9 +300,18 @@ outlives deleted accounts, and the name snapshot keeps it readable.
hourly cleanup evicts idle games first, then queues the delete through the
ordered writer while excluding every game still loaded, so no live game can
lose its row.
- A write whose game row was deleted by hand fails only that game: the
writer drops it and the service unloads the game, instead of degrading
storage for everyone.
- With `-db-cleanup report|delete`, the hourly cleanup also runs an integrity
sweep: loaded games without a row, games with missing plies, stored games
failing verification (200 per run, resuming in ID order), games whose
registered players were all deleted, and accounts with unusable password
hashes. `report` only logs; `delete` unloads and removes them.
See [`deploy/postgresql`](../deploy/postgresql) for provisioning and
[deployment.md](./deployment.md) for the jail service.
See [`deploy/postgresql`](../deploy/postgresql) for provisioning,
[deployment.md](./deployment.md) for the jail service, and
[deployment-linux.md](./deployment-linux.md) for systemd.
## Security Architecture
+46 -6
View File
@@ -14,11 +14,11 @@ The chess client is an interactive command-line debugging tool for the chess ser
## Replay Foundation
The Go API client exposes `GetGameHistory(gameID)` and
`GetMyGames(limit, offset)`, including durable result, timestamps, player
claims, move count, ordered UCI moves, and FEN after every move. Interactive
`games`/`replay` commands and terminal playback controls are intentionally
deferred to the dedicated replay iteration; see [Replay Implementation Tasks](./todo.md).
The Go API client exposes `GetGameHistory(gameID)`, `GetGamePGN(gameID, ply)`,
and `GetMyGames(limit, cursor)`, including durable result, timestamps, player
claims, move count, ordered UCI and SAN moves, and FEN after every move. The
`games` and `pgn` commands use them; step-by-step `replay` controls are still
planned, see [Replay Implementation Tasks](./todo.md).
## Building
```bash
@@ -104,10 +104,12 @@ chess > join a1b2c3d4-e5f6-7890-1234-567890abcdef
```
#### `move` / `m`
Make chess move in UCI notation.
Make chess move in UCI notation. Castling is the king's move (`e1g1`); a
promotion appends the piece letter (`q`, `r`, `b`, or `n`).
```
chess > move e2e4
chess > move e7e5
chess > move g7g8q
```
#### `computer` / `c`
@@ -148,6 +150,44 @@ Long-poll for game updates (waits up to 30 seconds).
chess > poll
```
#### `resign`
Resign the current game. The side may be omitted when you play one human side
(or claimed one); name it in a hot-seat game.
```
chess > resign
chess > resign b
```
#### `draw`
Offer (the default), accept, or decline a draw. A computer answers an offer
at once; a human opponent sees the offer in their next response, and it
lapses if they move instead of answering.
```
chess > draw # offer
chess > draw accept b
chess > draw decline w
```
Game outcomes print with how they were reached, e.g. `DRAW by threefold
repetition.` or `Black wins by resignation.`; a pending offer is shown as
`White offers a draw`.
#### `games` / `g`
List your stored games, newest first, 20 per page (requires login). The
result column is the PGN token (`1-0`, `0-1`, `1/2-1/2`, `*`).
```
chess > games # Newest games
chess > games more # Next page
```
#### `pgn`
Print a stored game as PGN (SAN movetext, standard tags).
```
chess > pgn # Current game, every ply
chess > pgn 10 # Current game, first 10 plies
chess > pgn <gameId> [ply] # Another game
```
### Debug Commands
#### `health` / `.`
+309
View File
@@ -0,0 +1,309 @@
# Database Operations
`psql` recipes for inspecting and maintaining the chess database as the
PostgreSQL administrator. Installation is in [deployment.md](deployment.md)
(FreeBSD jail) and [deployment-linux.md](deployment-linux.md) (Linux with
systemd).
## Typical Layout
The server expects one database with its tables in one schema of the same
name, owned by a role that exists only for it:
| Object | Name | Notes |
|---|---|---|
| Database | `chess` | Owned by `postgres`; only the `chess` role may connect |
| Schema | `chess` | All tables; `public` is locked down and stays empty |
| Login role | `chess` | No password, no superuser, create-database, or create-role rights; 20 connections |
| Owner role (split mode only) | `chess_owner` | Cannot log in; owns the schema while `chess` only reads and writes rows |
The service runs as an OS account named `chess` and connects over the local
Unix socket with **peer** authentication, which maps the OS account to the
role of the same name, so no password exists anywhere. The `chess` role has
its `search_path` set to `chess` in this database, so the service never names
the schema. [`deploy/postgresql/setup.sql`](../deploy/postgresql/setup.sql)
creates all of this; the deployment scripts run it once.
Administration uses the `postgres` superuser account, which keeps the
distribution's defaults. Its `search_path` (`"$user", public`) does not
include `chess`, so **every recipe names the schema explicitly** (`chess.games`).
This keeps other databases and tools on the same server unaffected, and a
statement typed into the wrong database fails instead of acting on whatever
it finds.
## Connecting
```sh
# Linux
sudo -u postgres psql -X -d chess
# FreeBSD jail
su -m postgres -c 'psql -X -d chess'
```
Working with the `chess` schema as `postgres`:
| Goal | How |
|---|---|
| List the tables | `\dt chess.*` (a plain `\dt` shows `public`, which is empty) |
| Describe a table | `\d chess.games` |
| List schemas and owners | `\dn+` |
| Query | `SELECT ... FROM chess.games` |
| Unqualified names for one session | `SET search_path = chess;` (lasts until you disconnect) |
| Unqualified names for one invocation | `sudo -u postgres env PGOPTIONS='-c search_path=chess' psql -X -d chess` |
Neither of the last two changes anything stored: the next session starts with
the defaults again. Avoid `ALTER ROLE postgres ... SET search_path`, which
would persist.
The tables:
| Table | Contents |
|---|---|
| `chess.schema_version` | One row: the applied migration |
| `chess.users` | Accounts: lowercase username and email, Argon2id password hash |
| `chess.sessions` | One active session per user; deleting it signs the user out |
| `chess.games` | Players, claims (`*_claimed_by`), name snapshots, `result`, `termination`, times |
| `chess.moves` | Ply `move_number` 1..n, UCI move, FEN after the move |
`result` is `white_wins`, `black_wins`, `stalemate`, or `draw`, and
`termination` says how: `checkmate`, `resignation`, `stalemate`,
`insufficient_material`, `threefold_repetition`, `fifty_move_rule`, or
`agreement`. Both are empty while a game is unfinished.
`\set g '<game-id>'` stores a game ID for the `:'g'` references below.
## Overview
```sql
SELECT version, updated_at FROM chess.schema_version;
SELECT (SELECT count(*) FROM chess.users) AS users,
(SELECT count(*) FROM chess.games) AS games,
(SELECT count(*) FROM chess.games WHERE result IS NULL) AS unfinished,
(SELECT count(*) FROM chess.moves) AS plies,
(SELECT count(*) FROM chess.sessions WHERE expires_at > now()) AS signed_in;
SELECT coalesce(termination, 'unfinished') AS termination, count(*)
FROM chess.games GROUP BY 1 ORDER BY 2 DESC;
```
## Users
```sql
SELECT user_id, username, email, created_at, last_login_at
FROM chess.users ORDER BY created_at;
```
Passwords are not stored. `password_hash` is an Argon2id string in PHC
format, `$argon2id$v=19$m=<KiB>,t=<passes>,p=<lanes>$<salt>$<hash>`, from
which the password cannot be recovered. Show the parameters without the
secret parts:
```sql
SELECT username, split_part(password_hash, '$', 2) AS algorithm,
split_part(password_hash, '$', 4) AS parameters
FROM chess.users;
```
Create, reset, and rename accounts with the server's CLI, not SQL: it
normalizes names, enforces uniqueness rules, and hashes passwords. Without
`-password` it prompts, which keeps the password out of the shell history and
the process list.
```sh
# Linux: chess-db runs `chess-server db ...` as the chess account
sudo chess-db user add -username <name> [-email <addr>]
sudo chess-db user set-password -username <name>
sudo chess-db user list
# FreeBSD jail
su -m chess -c '/home/chess/bin/chess-server db user add -username <name> -dsn "postgres:///chess?host=/tmp"'
```
Other `user` subcommands: `delete`, `set-email`, `set-username`, and
`set-hash` (an existing PHC string). CLI-created accounts are identical to
site registrations, and the CLI ignores the registration cap.
Sign a user out everywhere (takes effect on their next request):
```sql
DELETE FROM chess.sessions
WHERE user_id = (SELECT user_id FROM chess.users WHERE username = 'alice');
```
Deleting a user removes their session; their games keep the claim and the
name snapshot. Games left with no remaining registered player are removed by
the optional integrity sweep (below) once idle for a day.
## Games
Recent games with players, outcome, and length:
```sql
SELECT g.game_id, g.start_time_utc,
coalesce(g.white_name, CASE g.white_type WHEN 2 THEN 'Stockfish L' || g.white_level ELSE 'anonymous' END) AS white,
coalesce(g.black_name, CASE g.black_type WHEN 2 THEN 'Stockfish L' || g.black_level ELSE 'anonymous' END) AS black,
coalesce(g.result || ' by ' || g.termination, 'unfinished') AS outcome,
(SELECT count(*) FROM chess.moves m WHERE m.game_id = g.game_id) AS plies
FROM chess.games g ORDER BY g.start_time_utc DESC LIMIT 20;
```
A user's games: add
`WHERE (SELECT user_id FROM chess.users WHERE username = 'alice') IN (g.white_claimed_by, g.black_claimed_by)`.
Find a game from the 8-digit prefix that `db query` prints:
`SELECT game_id FROM chess.games WHERE game_id::text LIKE '68007fd1%';`.
One game, header and every ply with its FEN:
```sql
\set g '68007fd1-8970-4d84-950d-2f6ec6375c0b'
SELECT * FROM chess.games WHERE game_id = :'g' \gx
SELECT move_number AS ply, player_color, move_uci, fen_after_move, move_time_utc
FROM chess.moves WHERE game_id = :'g' ORDER BY move_number;
```
Final position:
```sql
SELECT coalesce(
(SELECT fen_after_move FROM chess.moves WHERE game_id = :'g' ORDER BY move_number DESC LIMIT 1),
(SELECT initial_fen FROM chess.games WHERE game_id = :'g')) AS final_fen;
```
The move line in UCI with move numbers (numbering follows the initial FEN, so
a game that starts with Black to move begins `N...`):
```sql
WITH s AS (SELECT split_part(initial_fen, ' ', 2) AS side,
split_part(initial_fen, ' ', 6)::int AS fullmove
FROM chess.games WHERE game_id = :'g')
SELECT string_agg(
CASE WHEN m.player_color = 'w'
THEN (s.fullmove + (m.move_number - CASE s.side WHEN 'w' THEN 1 ELSE 0 END) / 2) || '. '
WHEN m.move_number = 1 THEN s.fullmove || '... '
ELSE '' END || m.move_uci,
' ' ORDER BY m.move_number) AS uci_line
FROM chess.moves m, s WHERE m.game_id = :'g';
```
SQL has no SAN; the database stores UCI and notation is derived on read. For
real PGN use the CLI (a full ID or 8-digit prefix; `-ply N` stops after N
plies) or the API:
```sh
sudo chess-db pgn -gameId 68007fd1 # Linux
su -m chess -c '/home/chess/bin/chess-server db pgn -gameId 68007fd1 -dsn "postgres:///chess?host=/tmp"' # FreeBSD
curl -s https://<site>/chess/api/games/<game-id>/pgn
```
`db verify` checks every stored game against the rules (move numbering, each
move legal from the stored position before it, the stored position after it
reproduced, the result and its termination consistent with the final
position) and exits non-zero on any problem:
```sh
sudo chess-db verify
```
### Deleting games
Delete finished or unfinished games at any time:
```sql
BEGIN;
DELETE FROM chess.games WHERE game_id = :'g'; -- its moves are removed by cascade
COMMIT;
```
All games of one user (both colors):
```sql
DELETE FROM chess.games
WHERE (SELECT user_id FROM chess.users WHERE username = 'alice') IN (white_claimed_by, black_claimed_by);
```
A game the server is still playing is unloaded when its next write finds the
row gone (that last move is not stored), or earlier by the integrity sweep;
the server stays healthy. Deleting individual `chess.moves` rows instead leaves a broken line
that replay cannot use: delete the whole game, or let the integrity sweep do
it.
Games without a registered player are already deleted by the server 24 hours
after their last activity (`-anonymous-game-ttl`).
### Integrity sweep
With the server flag `-db-cleanup report` (log only) or `-db-cleanup delete`,
an hourly sweep looks for data the server cannot use:
- games whose rows were deleted while the server held them (unloaded);
- games with missing plies, i.e. deleted move rows;
- games that fail `db verify`, 200 per run in ID order;
- games whose registered players were all deleted, once idle for the
anonymous-game retention;
- accounts whose password hash is not a usable Argon2id string (nobody can
sign in to them).
Each finding is logged at warning level with its reason; `delete` also
removes them. Start with `report`, read the log, then switch. Set it through
`CHESSD_FLAGS` in `/etc/chessd/chessd.env` (Linux) or `chessd_flags` in
`rc.conf` (FreeBSD), and restart the service.
## Starting Fresh
Every option below loses data: take a dump first if any of it matters.
```sh
sudo -u postgres pg_dump -Fc -d chess -f /var/lib/postgresql/chess-$(date +%F).dump # Linux (Debian/Ubuntu path)
su -m postgres -c 'pg_dump -Fc -d chess -f /var/db/postgres/chess-$(date +%F).dump' # FreeBSD
```
The service commands are `systemctl stop|start chessd` on Linux and
`service chessd stop|start` on FreeBSD.
**Clear the data, keep the schema** (accounts, sessions, and games), with the
service stopped:
```sql
TRUNCATE chess.moves, chess.games, chess.sessions, chess.users;
```
**Recreate the tables** (drops and re-runs the migrations), with the service
stopped:
```sh
sudo chess-db delete -confirm && sudo chess-db init # Linux
su -m chess -c '/home/chess/bin/chess-server db delete -confirm -dsn "postgres:///chess?host=/tmp"' # FreeBSD
su -m chess -c '/home/chess/bin/chess-server db init -dsn "postgres:///chess?host=/tmp"'
```
`db init` prints `Database schema ready (version 2)`. In split mode the
`chess` role owns no tables and cannot drop or create them; run both commands
as `postgres` acting as the owner role instead, e.g. on Linux:
```sh
sudo -u postgres /usr/local/bin/chess-server db delete -confirm \
-dsn "dbname=chess user=postgres options='-c role=chess_owner -c search_path=chess'"
```
**Rebuild the database and role** (as on a new host), with the service
stopped:
```sql
DROP DATABASE IF EXISTS chess WITH (FORCE);
DROP ROLE IF EXISTS chess_owner; -- exists only in split mode
DROP ROLE IF EXISTS chess;
```
Then rerun the deployment script (`deploy/linux/setup.sh` or
`deploy/freebsd/setup-jail.sh`) with the same inputs as before. With neither
role nor database present it runs `setup.sql`, creates the tables, starts the
service, and checks `/health`. The JWT key is kept; accounts are gone, so
create them again.
Verify any of them:
```sql
\dt chess.* -- games, moves, schema_version, sessions, users
SELECT version FROM chess.schema_version; -- 2
\drds -- chess: search_path and timeouts in database chess
```
+228
View File
@@ -0,0 +1,228 @@
# Deployment: Linux with systemd and PostgreSQL
A native install on Debian, Ubuntu, Arch, or another systemd distribution
that already runs PostgreSQL. The service is `chessd`, running as a system
account `chess` without a login shell; it reaches PostgreSQL over the local
Unix socket with peer authentication, so no database password exists. A
reverse proxy (nginx) on the same host, if any, forwards `/chess/api/` to it.
The FreeBSD jail layout is in [deployment.md](deployment.md); database
maintenance is in [database.md](database.md).
```
browser ──TLS──▶ nginx ──HTTP 127.0.0.1:8080──▶ chessd (chess-server, user chess)
│ Unix socket, peer auth
▼
PostgreSQL 17+ (database chess)
```
## Prerequisites
PostgreSQL 17 or later (18 recommended), running, with its default local
socket; Stockfish; systemd; Go 1.27 to build.
**Debian / Ubuntu.** Ubuntu 24.04 ships PostgreSQL 16, so 18 comes from the
PostgreSQL project's repository (PGDG). With 18 already installed, check the
cluster with `pg_lsclusters` and skip this.
```sh
sudo apt install postgresql-common stockfish
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
sudo apt install postgresql-18 # creates and starts cluster 18/main
```
The default `pg_hba.conf` already maps local OS accounts to roles of the same
name (`local all all peer`). Stockfish installs as `/usr/games/stockfish`;
the service adds `/usr/games` to its `PATH`.
**Arch.** The package does not create a cluster; initialize one the first
time only:
```sh
sudo pacman -S postgresql stockfish
sudo -u postgres initdb -D /var/lib/postgres/data --encoding=UTF8 \
--locale-provider=builtin --builtin-locale=C.UTF-8 \
--auth-local=peer --auth-host=scram-sha-256
sudo systemctl enable --now postgresql
```
A cluster made by a plain `initdb` trusts every local connection. The chess
account connects either way, but `peer` is what you want: without it, any
local account can connect as any role.
**Existing clusters.** Other databases, roles, and applications on the
server are left alone: the setup creates one role and one database named
`chess`, changes no server settings, and edits `pg_hba.conf` only when asked
(`PG_HBA=yes`, below). It does not change `listen_addresses`; the service
itself only uses the socket.
## Install
```sh
make server # bin/chess-server, static
sudo CHESS_BINARY=bin/chess-server TRUSTED_PROXIES=127.0.0.1 deploy/linux/setup.sh
sudo chess-db user add -username <name> # prompts for the password
```
The script is idempotent: rerun it with a new binary to upgrade. It stops
`chessd`, installs everything, migrates the schema, starts the service, and
fails unless `/health` reports `"storage":"ok"`.
| Input | Default | Meaning |
|---|---|---|
| `CHESS_BINARY` | required | `chess-server` binary to install |
| `TRUSTED_PROXIES` | kept from `chessd.env` | Proxy address(es) whose `X-Real-IP` is trusted; `127.0.0.1` for nginx on this host |
| `CHESSD_HOST`, `CHESSD_PORT` | `127.0.0.1`, `8080` | Listen address, first install only |
| `SPLIT_PRIVILEGES` | `no` | `yes`: the service role only reads and writes rows ([database.md](database.md#typical-layout)) |
| `ENABLE_BACKUP` | `no` | `yes`: nightly `pg_dump` through a systemd timer |
| `PG_HBA` | `no` | `yes`: add `local chess chess peer` to `pg_hba.conf` if the chess account cannot connect |
What it installs:
| Path | Purpose |
|---|---|
| account `chess` | System account, no login shell, home `/var/lib/chessd` |
| `/usr/local/bin/chess-server` | The server, `root:root 0755`; the previous copy kept as `.prev` |
| `/usr/local/sbin/chess-db` | Runs `chess-server db ...` as `chess` against the database: `chess-db user list`, `chess-db verify` |
| `/etc/chessd/chessd.env` | Settings, `root:chess 0640`; written on the first install only |
| `/var/lib/chessd/jwt.key` | JWT signing key, `chess 0600`, generated once; sessions survive restarts |
| `/etc/systemd/system/chessd.service` | The service unit, replaced on every run |
| `chess-backup.service`, `.timer`, `/usr/local/sbin/chess-backup` | With `ENABLE_BACKUP=yes`: dumps to `backups/` in the postgres home, 14 days kept |
PostgreSQL steps, in order: check the server version (17+) and socket as
`postgres`; create role, database, and schema with
[`setup.sql`](../deploy/postgresql/setup.sql) when neither exists (a
half-present pair stops the script); check that the `chess` account can
connect.
### When the chess account cannot connect
The first matching line of `pg_hba.conf` decides. A cluster whose local
lines require a password (`scram-sha-256`, `md5`) refuses the passwordless
`chess` role, and the script stops with the file's path and this line to add
above the other `local` lines:
```
local chess chess peer
```
Then reload (`sudo systemctl reload postgresql`) and rerun, or rerun with
`PG_HBA=yes` to have the line inserted and PostgreSQL reloaded (the previous
file is kept next to it with a timestamp).
## Settings
`/etc/chessd/chessd.env` is read by the unit; restart after a change
(`sudo systemctl restart chessd`).
| Setting | First-install value | Purpose |
|---|---|---|
| `CHESSD_HOST`, `CHESSD_PORT` | `127.0.0.1`, `8080` | Listen address; keep loopback when nginx runs on the host |
| `CHESSD_DSN` | `"host=<socket dir> dbname=chess"` | Database over the socket as the OS account |
| `CHESSD_JWT_KEY` | `/var/lib/chessd/jwt.key` | Stable signing key |
| `CHESSD_TRUSTED_PROXIES` | `TRUSTED_PROXIES` | Comma-separated proxy addresses |
| `CHESSD_FLAGS` | empty | Other server flags, e.g. `-max-users 500 -db-cleanup report` |
Server flags worth knowing: `-max-users` (registration cap, default 100),
`-anonymous-game-ttl` (default `24h`), `-finished-game-ttl` (default `1h`),
`-db-cleanup off|report|delete` (the integrity sweep, see
[database.md](database.md#integrity-sweep)), `-log-level`, `-log-http`.
Changes to the unit belong in a drop-in (`sudo systemctl edit chessd`), which
the script never overwrites.
## Operation
```sh
systemctl status chessd
journalctl -u chessd -f # server log
sudo chess-db user add -username <name> # accounts; see database.md
sudo chess-db verify # check stored games; run after upgrades
curl -s http://127.0.0.1:8080/health # "storage":"ok"
systemctl list-timers chess-backup.timer # with ENABLE_BACKUP=yes
```
**Upgrade:** build, then rerun `setup.sh` with the new `CHESS_BINARY`.
Schema migrations run before the service starts.
**Rollback:** until you delete it, the previous binary is kept:
```sh
sudo systemctl stop chessd
sudo cp /usr/local/bin/chess-server.prev /usr/local/bin/chess-server
sudo systemctl start chessd
```
A newer schema is not downgraded: a binary older than the database refuses to
start (see the log), so keep a dump from before the upgrade.
**Uninstall:**
```sh
sudo systemctl disable --now chessd chess-backup.timer
sudo rm -f /etc/systemd/system/chessd.service /etc/systemd/system/chess-backup.{service,timer} \
/usr/local/bin/chess-server{,.prev} /usr/local/sbin/chess-{db,backup}
sudo systemctl daemon-reload
sudo rm -rf /etc/chessd /var/lib/chessd
sudo -u postgres psql -X -c 'DROP DATABASE chess WITH (FORCE)' -c 'DROP ROLE IF EXISTS chess_owner' -c 'DROP ROLE chess'
sudo userdel chess
```
## nginx on the Same Host
Use [`deploy/nginx-chess.conf`](../deploy/nginx-chess.conf) with
`<server-address>:<port>` = `127.0.0.1:8080`, and install with
`TRUSTED_PROXIES=127.0.0.1`, so the server keys rate limits on the client
address nginx sends in `X-Real-IP`. The browser clients and their
publishing steps are the same as on FreeBSD
([deployment.md](deployment.md#host-nginx-and-web-clients)).
## Service Sandbox
The unit runs the server with systemd's sandboxing: the whole file system is
read-only except `/var/lib/chessd`, home directories and `/tmp` are hidden,
no capabilities or privilege escalation, only Unix and IP sockets, and the
`@system-service` system-call set. Stockfish runs inside the same sandbox.
`systemd-analyze security chessd` summarizes the result.
## Running as Your Own Account (Development)
Without a `chess` account, the server runs as you, with a database owned by
your PostgreSQL role; tables then live in that database's `public` schema,
which is fine for development:
```sh
sudo -u postgres createuser --createdb "$USER" # once, if you have no role yet
createdb chess_dev
bin/chess-server db init -dsn "dbname=chess_dev"
bin/chess-server -dev -dsn "dbname=chess_dev" -serve # API :8080, web client :9090
bin/chess-server db user add -username me -dsn "dbname=chess_dev"
```
`-dev` relaxes rate limits and uses a fixed JWT key; never use it in
production. The test suites need a separate, disposable database whose
tables they drop ([test/README.md](../test/README.md)):
```sh
createdb chess_test
export CHESS_TEST_DSN="dbname=chess_test"
go test ./...
test/run-test-server.sh bin/chess-server # then test/test-*.sh in another terminal
```
## Verification Checklist
```sh
systemctl is-active chessd # active
curl -s http://127.0.0.1:8080/health # "storage":"ok"
ps -o user= -C chess-server # chess
sudo -u postgres psql -X -d chess -c '\dt chess.*' # five tables
sudo chess-db verify # 0 problem(s)
sudo -u chess psql -X -d chess -Atc 'SHOW search_path' # chess
```
The scripts were exercised on Ubuntu 24.04 with PostgreSQL 18.6 and
`systemctl` replaced by a stand-in that starts the unit's `ExecStart` as its
`User` with its `EnvironmentFile`: a first install that stops at
`pg_hba.conf`, the same with `PG_HBA=yes`, an idempotent rerun, an upgrade,
a trusted-proxy change, backups, and a fresh split-privilege install.
`systemd-analyze verify` accepts the units.
+45 -9
View File
@@ -16,6 +16,9 @@ browser ──TLS──▶ host nginx ──HTTP──▶ jail: chessd (chess-se
`host#` runs on the Bastille host as root; `jail#` runs inside the jail as root
(`bastille console <jail>`). Addresses in this document are placeholders.
For a native Linux install with systemd, see
[deployment-linux.md](deployment-linux.md); database recipes are in
[database.md](database.md).
## Scripted Installation and Upgrade
@@ -56,7 +59,7 @@ Their header comments list every input.
unless `/health` reports `"storage":"ok"`.
5. **Host:** point nginx at the jail
([`deploy/freebsd/nginx-chess.conf`](../deploy/freebsd/nginx-chess.conf))
([`deploy/nginx-chess.conf`](../deploy/nginx-chess.conf))
and publish the web client (below).
`setup-jail.sh` inputs:
@@ -105,6 +108,21 @@ rerun, and a fresh split-privilege install.
## Accounts and Data Retention
A new database has no accounts. Create the first one (for example your own)
with the CLI; without `-password` it prompts, so the password stays out of
the shell history and the process list:
```sh
jail# su -m chess -c '/home/chess/bin/chess-server db user add -username <name> -dsn "postgres:///chess?host=/tmp"'
```
The row lands in `chess.users` with an Argon2id hash in `password_hash`; the
password itself is stored nowhere and cannot be read back, only replaced
(`db user set-password`). The tables are in schema `chess`, not `public`:
as `postgres`, list them with `\dt chess.*` and name them as `chess.games`.
[database.md](database.md) explains the layout and has the psql recipes
(inspecting and deleting games, sessions, starting fresh).
- Accounts created by registration on the site and by `chess-server db user
add` are identical and never expire. Public registration closes at
`-max-users` accounts (default 100; `0` removes the limit); the CLI is not
@@ -116,6 +134,10 @@ rerun, and a fresh split-privilege install.
activity**, from memory and from the database, by the hourly cleanup.
`-anonymous-game-ttl` changes the window; `0` keeps them.
- Deleting a user removes their sessions; their games keep the claim and name.
With `-db-cleanup delete`, games left without any registered player are
deleted like anonymous ones.
- A game row deleted by hand while the server still plays it no longer
degrades storage: the server unloads that game and carries on.
## Service (`rc.d/chessd`)
@@ -132,7 +154,7 @@ rerun, and a fresh split-privilege install.
| `chessd_dsn` | `postgres:///chess?host=/tmp` | Database `chess` over the socket as the OS user |
| `chessd_jwt_key` | `${chessd_home}/jwt.key` | JWT signing key; created (0600) on first start if missing |
| `chessd_trusted_proxies` | empty | Proxy address(es) whose `X-Real-IP` is trusted |
| `chessd_flags` | empty | Extra flags, e.g. `-max-users 500` |
| `chessd_flags` | empty | Extra flags, e.g. `-max-users 500 -db-cleanup report` |
The script runs `daemon(8)` as root, which writes the supervisor pidfile
(`/var/run/chessd.pid`), drops to `chess`, restarts the server 5 seconds after
@@ -151,6 +173,7 @@ Server flags:
| `-max-users` | | Registration cap (default 100, `0` = none) |
| `-anonymous-game-ttl` | | Retention of games without a registered player (default `24h`) |
| `-finished-game-ttl` | | Memory retention of finished games (default `1h`) |
| `-db-cleanup` | | Hourly integrity sweep: `off` (default), `report` (log findings), or `delete` ([database.md](database.md#integrity-sweep)) |
Administrative CLI commands run as `chess` so peer authentication selects the
right role; root may use `su -m` although the account has no shell:
@@ -159,11 +182,19 @@ right role; root may use `su -m` although the account has no shell:
jail# su -m chess -c '/home/chess/bin/chess-server db user list -dsn "postgres:///chess?host=/tmp"'
```
`db` subcommands: `init`, `delete -confirm` (drops the tables), `query`
(games, with 8-digit ID prefixes), `pgn -gameId <id|prefix> [-ply N]`,
`verify [-gameId <id|prefix>]` (replays stored games against the rules and
exits non-zero on a mismatch; run it after an upgrade), and `user add|delete|
set-password|set-hash|set-email|set-username|list`.
## Host nginx and Web Clients
The API has no version segment: routes are `/api/...` and `/health`. With the
layout in [`nginx-chess.conf`](../deploy/freebsd/nginx-chess.conf), browsers
call `<origin>/chess/api/...` and nginx maps `/chess/api/` to `/api/`.
layout in [`nginx-chess.conf`](../deploy/nginx-chess.conf), browsers
call `<origin>/chess/api/...` and nginx maps `/chess/api/` to `/api/`. The
bare `/chess` and `/chess/` redirect (302) to the page that embeds the
client, as a short link.
- `X-Real-IP` must carry the real client address (`$remote_addr`, after
`real_ip` processing when PROXY protocol is in front of nginx). The server
@@ -181,10 +212,14 @@ server's API paths:
embedded `/config` endpoint, it uses the `/chess` API prefix.
- **WASM terminal client (if published):** `make wasm` (xterm.js is committed
under `lib/`), then copy `web/chess-client-wasm/`. It derives its API base
as `<origin>/chess`.
as `<origin>/chess`. Its API paths are compiled into `chess-client.wasm`,
so rebuild it whenever `internal/client/api` changes; publish it together
with the `wasm_exec.js` that `make wasm` copies from the same Go toolchain.
Publish the clients in the same change as the server: a client built for
`/api/v1` gets 404 from this release.
`web/chess-client-web` is a symlink to the embedded web client; copy with
`cp -RL` or `rsync -L` so the files, not the link, reach the site. Publish the
clients in the same change as the server, and expect browsers to hold cached
copies until a hard refresh.
## Reference
@@ -253,8 +288,9 @@ Restore the previous web client files on the host as well.
### Backups and restores
- `chess-backup` writes `pg_dump --format=custom` files nightly and removes
those older than 14 days. Copy them off the jail.
- `chess-backup` ([`deploy/postgresql/chess-backup.sh`](../deploy/postgresql/chess-backup.sh),
shared with Linux) writes `pg_dump --format=custom` files nightly and
removes those older than 14 days. Copy them off the jail.
- Restore into a freshly provisioned database:
`su -m postgres -c 'pg_restore --no-owner --role=chess -d chess <dump>'`
(`--role=chess_owner` in split mode).
+1 -1
View File
@@ -2,7 +2,7 @@
## Prerequisites
- Go 1.26+
- Go 1.27+
- Stockfish in PATH
- PostgreSQL 18 (server for persistence; `psql` for the shell test suites)
- Git
+54 -42
View File
@@ -28,6 +28,9 @@ into phases that each ship and test on their own.
- [x] Player-name snapshots on claim, returned in history and listings (D4).
- [x] Scripted FreeBSD jail deployment (`deploy/`).
- [x] Go client DTOs (`GetGameHistory`, `GetMyGames`) including `finalFen`.
- [x] Rules core (`internal/server/chess`, R1) and replay API (R2), below.
- [x] Pawn promotion in the web client (piece picker) and a specific API error
for a promotion without its piece.
## Decisions
@@ -37,53 +40,52 @@ into phases that each ship and test on their own.
| D2 | History visibility | **Decided: shareable by game ID.** The history endpoint stays public to holders of the 122-bit random ID; IDs are never listed publicly and the list endpoint stays authenticated. |
| D3 | Where SAN/PGN is produced | **Decided: server-side Go** (R1), derived on read from the initial FEN and the UCI line: no schema change, one implementation for web and CLI, no third-party script under the CSP. |
| D4 | Player names | **Done.** `white_name`/`black_name` snapshot the claimant's username in the claim transaction and survive renames and deletions; exposed as `players.*.name`. |
| D5 | Draw rules | Open. Recommendation: detect insufficient material, threefold repetition, and the 50/75-move rules in the R1 core; add resign/draw offers later. PGN `Result` is `*` for unterminated games. |
| D5 | Draw rules | **Done.** Draws apply automatically after every move, as on most online servers: dead material, threefold repetition, and the fifty-move rule (FIDE's claimable draws, and so also its 75-move and fivefold rules). Draw offers (a computer answers from its evaluation) and resignation exist; the stored `termination` says how a game ended. |
| D6 | Undo after a result | **Decided: keep allowed**, so a player can step back from a finished game and play a line again. Consequence: a finished game's stored history, result, and end time are rewritten by undo; replay shows the line as it stands now. |
| D7 | List pagination | Open. Recommendation: keyset cursor on `(start_time_utc, game_id)`, keeping offset during v1; the claim indexes are already ordered for it. |
| D7 | List pagination | **Done.** Opaque keyset `cursor` on `(start_time_utc, game_id)`; `offset` kept for existing clients and exclusive with `cursor`. |
## Phase R1 — Notation Core (server, no API change)
## Phase R1 — Notation Core (server) — done
A small, dependency-free chess core in Go (`internal/server/chess`), used by
replay only; Stockfish remains the move validator for live play.
A dependency-free rules core in Go (`internal/server/chess`). Stockfish
remains the move validator for live play; the core notates stored games and
shadows the engine on every live move, logging any disagreement.
- [ ] Board model with FEN parse/serialize (extend `internal/server/board`).
- [ ] Legal move generation: pseudo-legal moves plus king-safety filtering,
castling rights and paths, en passant, promotion.
- [ ] SAN encode (disambiguation by file, rank, or both; `+`/`#`; `O-O`,
`O-O-O`; `=Q`) and SAN decode for later PGN import.
- [ ] PGN writer: Seven Tag Roster (White/Black from the name snapshots, else
"Anonymous" or "Stockfish level N"), `SetUp`/`FEN` tags for custom starts,
`Termination`, 80-column movetext, `Result` from the stored outcome.
- [ ] Draw-rule helpers (D5) and an integrity check that replays the stored UCI
line and compares each generated FEN with the stored `fenAfterMove`.
- [x] Position with strict FEN parse (one king per side, no pawns on the back
ranks, side not to move not in check; castling rights without their rook
dropped) and canonical serialization (en-passant square only when a capture
is legal, so Stockfish's pseudo-legal convention normalizes away).
- [x] Legal move generation with castling paths, en passant, promotion, pins.
- [x] SAN encode (file/rank/square disambiguation, `+`/`#`, `O-O`, `=Q`) and
lenient decode (`0-0`, `e8Q`, annotations, over-disambiguation).
- [x] PGN writer: Seven Tag Roster, supplemental tags, `SetUp`/`FEN` for custom
starts with correct move numbering, 80-column movetext, escaped tag values.
- [x] Draw-rule helpers (D5) and `chess-server db verify`, which replays every
stored game against the stored FENs and results.
- [x] Custom starting FENs validated by the core before the engine sees them.
Test gate:
- [ ] Perft node counts for the standard CPW positions (start, Kiwipete, and
positions 3–6) to depth 4, which exercises castling, en passant, promotion,
and pins.
- [ ] Property tests over random legal games: SAN decode(encode(m)) == m; the
generated FEN sequence is identical to one produced by Stockfish's `d`
command for the same line.
- [ ] Fuzz targets for FEN parsing, SAN decoding, and PGN writing (no panics,
bounded output).
- [ ] Integrity check run over every stored game in a production-shaped copy.
- [x] Perft for the CPW start, Kiwipete, and positions 3–6 (depth 4; position
3 to depth 5).
- [x] Engine cross-check: random games from all six positions, comparing the
legal move set with Stockfish `go perft 1` and the FEN with Stockfish `d` at
every ply (about 6,000 positions per run; skipped without Stockfish).
- [x] Fuzz targets: FEN parse round trip with SAN/UCI round trip of every
legal move, SAN decoding, PGN tag escaping.
- [ ] `db verify` over a copy of the production database once it holds games.
## Phase R2 — Replay API
## Phase R2 — Replay API — done
- [ ] Implement D7 (keyset cursor) once decided.
- [ ] Additive history fields: `san` per move, `outcome`/`termination`, and
`pgnResult`; keep existing fields unchanged.
- [ ] `GET /api/games/{id}/pgn?ply=N`: `application/x-chess-pgn`,
`Content-Disposition: attachment; filename="chess-<date>-<id8>.pgn"`,
moves 1..N (default all). The FEN at any ply is already in the history.
- [ ] `ETag` and `Cache-Control: private, max-age` for terminal games;
`If-None-Match` returns 304.
- [ ] Filters on the list endpoint: result, color, ongoing/finished.
- [ ] Tests: handler tests against PostgreSQL (pgtest), golden PGN files for
mate, stalemate, promotion, castling, en passant, custom FEN, and an ongoing
game; shell-suite coverage for authorization (another user's list, expired
session) and cursor stability under concurrent inserts.
- [x] D7 keyset cursor, plus `status` and `color` filters on the list.
- [x] Additive history fields: `san` per move, `pgnResult`, `termination`.
- [x] `GET /api/games/{id}/pgn?ply=N` as an attachment; `db pgn` in the CLI.
- [x] Strong `ETag` with `Cache-Control: private, no-cache` on history and PGN
(revalidate every time: D6 lets finished games change); `If-None-Match`
returns 304.
- [x] Tests: rules-core unit, fuzz, and engine cross-check; service and
handler tests against PostgreSQL; `test/test-replay.sh` over HTTP (SAN,
PGN, ETag, promotion, FEN validation, cursor paging and filters).
- [x] Terminal client: `games` (cursor paging) and `pgn` commands.
## Phase R3 — Web Replay UI
@@ -101,7 +103,11 @@ Plain JavaScript in the embedded client, no framework.
- [ ] Position rendering assigns the stored FEN for the ply; the browser never
computes moves.
- [ ] Export at the current ply: copy FEN, copy PGN, and download PGN (fetched
from R2 as a `Blob`, saved through a temporary `<a download>`).
from R2 as a `Blob`, saved through a temporary `<a download>`). The live
view's "Copy PGN" button copies UCI moves today; point it at the PGN
endpoint too.
- [ ] Show SAN in the live move list (from the history, or a `san` field on
the live game response).
- [ ] While replaying: stop live long-polling, disable move, undo, new-game,
and player-configuration controls; **Back to live game** restores them.
@@ -129,8 +135,9 @@ Test gate:
## Phase R4 — CLI and WASM Replay (optional)
- [ ] `games` (list with pagination), `replay <gameId|index>`, `next`, `prev`,
`first`, `last`, `goto <ply>`, `auto <ms>`, `fen`, `pgn save <path>`.
- [x] `games` (list with cursor paging) and `pgn [gameId] [ply]`.
- [ ] `replay <gameId|index>`, `next`, `prev`, `first`, `last`, `goto <ply>`,
`auto <ms>`, `fen`, `pgn save <path>`.
- [ ] Replay state is separate from the live session: no polling, moves, undo,
or configuration while replaying.
@@ -156,4 +163,9 @@ Test gate:
- [ ] Retention and user-initiated deletion or anonymization of games.
- [ ] Thread request contexts from HTTP handlers into storage calls; today each
call carries its own deadline.
- [ ] Draw and resign flows (D5) for live play, independent of replay.
- [x] Draw and resign flows (D5) for live play.
- [x] A game row deleted by hand no longer degrades storage; optional
integrity sweep (`-db-cleanup`) for broken or orphaned data.
- [ ] Once `db verify` and the live shadow check stay clean in production,
consider validating human moves with the core instead of the engine: no
engine round trip or lock per move, and Stockfish only for computer play.