From 6e8d80c788a1f5b0a804a859db6be83184ef589510cb829253f11b423a31eb9e Mon Sep 17 00:00:00 2001 From: Lixen Wraith Date: Tue, 29 Sep 2026 02:09:45 -0400 Subject: [PATCH] v0.13.1 concession management and record, clients update, version report --- Makefile | 5 +- README.md | 3 + cmd/chess-server/main.go | 7 +- doc/api.md | 68 ++++++++++++-- doc/architecture.md | 10 +- doc/client.md | 13 ++- doc/database.md | 20 +++- doc/deployment-linux.md | 6 +- doc/deployment.md | 7 +- doc/todo.md | 3 +- internal/client/api/types.go | 9 ++ internal/client/command/game.go | 34 ++++++- internal/server/core/api.go | 18 +++- internal/server/core/error.go | 1 + internal/server/core/state.go | 7 ++ internal/server/game/game.go | 39 +++++++- internal/server/http/handler.go | 42 +++++---- internal/server/processor/processor.go | 46 +++++++-- internal/server/replay/replay.go | 27 ++++++ internal/server/replay/replay_test.go | 21 +++++ internal/server/service/end_test.go | 93 ++++++++++++++++++- internal/server/service/game.go | 77 ++++++++++++++- internal/server/service/game_test.go | 6 +- internal/server/service/history.go | 6 ++ internal/server/storage/game.go | 41 +++++++- internal/server/storage/schema.go | 33 ++++++- internal/server/storage/storage_test.go | 75 +++++++++++++++ .../server/webserver/chess-client-web/app.js | 8 +- test/README.md | 11 ++- test/test-draw.sh | 49 +++++++++- 30 files changed, 704 insertions(+), 81 deletions(-) diff --git a/Makefile b/Makefile index 9c95d65..6fb1c72 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,10 @@ CLIENT_SOURCE := ./cmd/chess-client-cli GO_DEFAULT != uname -s | grep -q FreeBSD && sed -n 's/^go \([0-9]*\)\.\([0-9]*\).*/go\1\2/p' go.mod 2>/dev/null | grep . || echo go GO ?= $(GO_DEFAULT) GOFLAGS := -trimpath -LDFLAGS := -s -w +# Build identifier reported by the server's /health; "dev" outside a git +# checkout. +VERSION != git describe --tags --always --dirty 2>/dev/null || echo dev +LDFLAGS := -s -w -X main.version=$(VERSION) # Both binaries are pure Go (pgx has no C dependency), so builds are static and # cross-compile without a C toolchain. CGO := CGO_ENABLED=0 diff --git a/README.md b/README.md index da9029f..4c953b1 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,9 @@ Go backend server providing a RESTful API for chess gameplay with user authentic - Human vs human, human vs computer, computer vs computer modes - Resignation, draw offers (the computer answers from its evaluation), and automatic draws: dead material, threefold repetition, fifty-move rule +- Claimed sides protect players: no takebacks between two players, and a + resignation or agreed draw between humans is final; against the computer it + can be undone and stays on record - Custom FEN position support - Asynchronous engine move calculation - Configurable engine strength and thinking time diff --git a/cmd/chess-server/main.go b/cmd/chess-server/main.go index b52e6ee..ea9f1a2 100644 --- a/cmd/chess-server/main.go +++ b/cmd/chess-server/main.go @@ -26,6 +26,10 @@ const ( gracefulShutdownTimeout = time.Second * 5 ) +// version is set at link time (make: -X main.version=...); /health reports it +// so a deployment can be checked against the build it should be running. +var version = "dev" + func main() { // Check for CLI database commands if len(os.Args) > 1 && os.Args[1] == "db" { @@ -184,6 +188,7 @@ func main() { LogRequests: *logHTTP, TrustedProxies: trustedProxies, ProxyHeader: *proxyHeader, + Version: version, }) // API Server configuration @@ -191,7 +196,7 @@ func main() { // Start API server in a goroutine go func() { - log.Printf("Chess API Server starting...") + log.Printf("Chess API Server %s starting...", version) log.Printf("API Listening on: http://%s", apiAddr) log.Printf("Authentication: Enabled (JWT)") if *dev { diff --git a/doc/api.md b/doc/api.md index 4cbca10..9241c33 100644 --- a/doc/api.md +++ b/doc/api.md @@ -95,17 +95,23 @@ Authorization: Bearer ### Health Check `GET /health` -Returns server and storage status. +Returns server and storage status, and the build the server runs. **Response (200):** ```json { "status": "healthy", "time": 1699123456, - "storage": "ok" + "storage": "ok", + "version": "v0.14.0-3-g1a2b3c4" } ``` +`version` is `git describe` of the source the binary was built from (`make` +sets it), or `dev` for a plain `go build`. Compare it after a deployment: a +client newer than the server gets 404 `NOT_FOUND` for endpoints the server +predates. + Storage states: - `"disabled"` - No database configured (`-dsn` or `CHESS_DSN`) - `"ok"` - Database operational with auth enabled @@ -225,6 +231,11 @@ carries its SAN, derived on read from the stored position before it. } ``` +`concession` (present only when there was one) is the game's first +resignation or agreed draw, in stored-result form (`"result": "black_wins"`); +see [Continuing after a resignation or agreed +draw](#continuing-after-a-resignation-or-agreed-draw). + `result` is omitted while a game is ongoing. Persisted terminal values are `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), @@ -235,7 +246,7 @@ 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 +Undo stays available after most results (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. @@ -366,6 +377,7 @@ A live game (`GET /games/{gameId}` and every game response) carries: | `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` | +| `concession` | The game's first resignation or agreed draw: `{"result": "black wins", "termination": "resignation", "ply": 24}`, `ply` being the moves played when it was made; kept when an undo continues play | **Automatic draws.** After every move the game is drawn, without a claim, as on most online servers, when: @@ -421,16 +433,53 @@ 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. +#### Continuing after a resignation or agreed draw + +A resignation or agreed draw is a *concession*: a result the players chose. + +- **Both sides human:** it is final. `undo` returns 400 `GAME_OVER`, for a + hot-seat game too. +- **Against the computer:** the claimant may `undo` it and play on. The live + `state` and `termination` (and the stored `result` and `termination`) + follow the game; `concession` keeps the first one, in memory and in the + database (`concession_*` columns), through later undos, moves, and results. + History reports it, and the full PGN notes it in the final comment, such as + `{ White resigned at ply 24; play continued. }`. + +### Claims and control + +Anyone holding the game ID may move for, undo, reconfigure, or unload an +unclaimed side; a claim is what protects a player. Moves, resignation, and +draws act for one side and need that side's claim (or none). The requests +below affect both sides, so: + +| Request | Allowed when | +|---|---| +| undo, configure players | no side is claimed by another user: the caller holds every claim, or nothing is claimed | +| delete (unload) | the caller holds a claim, or nothing is claimed | + +Otherwise they return 403 `UNAUTHORIZED`. Two players who each claimed a +side therefore cannot take back each other's moves or swap the opponent for +the computer: takebacks between players are not supported. Send the bearer +token (optional authentication) with these requests. + ### Undo Moves `POST /games/{gameId}/undo` -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. +```json +{"count": 2} +``` + +Reverts `count` plies (default 1). Allowed after a result: it rewinds the +result and clears draw offers. Refused while the computer is thinking, after +a resignation or agreed draw between two humans (400 `GAME_OVER`), and by +another user's claim (403, see [Claims and control](#claims-and-control)). ### Configure Players `PUT /games/{gameId}/players` -Changes player configuration mid-game. +Changes player configuration mid-game; subject to [Claims and +control](#claims-and-control). ### Get Board `GET /games/{gameId}/board` @@ -441,7 +490,8 @@ Returns ASCII board visualization. `DELETE /games/{gameId}` Unloads the live game from memory. Its persisted game and move history remain -available through the history endpoint. Returns 204 on success. +available through the history endpoint. Returns 204 on success, 403 to a +caller holding none of the game's claims. ## Error Format ```json @@ -453,7 +503,9 @@ available through the history endpoint. Returns 204 on success. ``` Error codes: -- `GAME_NOT_FOUND` - Invalid game ID +- `GAME_NOT_FOUND` - No live game with that ID +- `NOT_FOUND` - No such endpoint (for example a server older than the client) +- `UNAUTHORIZED` - A side or the game is claimed by another user - `INVALID_MOVE` - Illegal chess move - `NOT_HUMAN_TURN` - Wrong player type for turn - `GAME_OVER` - Game already ended diff --git a/doc/architecture.md b/doc/architecture.md index 932d492..7b2ebaf 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -237,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 2) +### Storage Schema (version 3) ```sql users ( user_id uuid PRIMARY KEY, @@ -271,6 +271,10 @@ games ( -- insufficient_material, threefold_repetition, -- fifty_move_rule, agreement end_time_utc timestamptz, -- set exactly when result is set + concession_result text, -- first resignation (win) or agreed draw, + concession_termination text, -- kept when an undo continues play + concession_ply integer, -- against the computer; all four set + concession_time_utc timestamptz -- together or all NULL ) moves ( @@ -286,7 +290,9 @@ moves ( `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, +(wins by checkmate or resignation, draws by a draw rule or agreement). +Version 3 added the `concession_*` columns: a rewind clears `result` and +`termination` but keeps them, and the first concession is never overwritten. 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 diff --git a/doc/client.md b/doc/client.md index 8f69ada..8d66f1a 100644 --- a/doc/client.md +++ b/doc/client.md @@ -119,14 +119,18 @@ chess > computer ``` #### `undo` / `u` -Undo one or more moves. +Undo one or more moves. Refused when another user claimed a side (players +cannot take back each other's moves) and after a resignation or agreed draw +between two humans. Against the computer a resignation can be undone; the +client notes that it stays on record. ``` chess > undo # Undo last move chess > undo 3 # Undo last 3 moves ``` #### `show` / `h` -Display board and game state with colored pieces. +Display board and game state with colored pieces, the result, and any +pending draw offer. ``` chess > show ``` @@ -145,7 +149,10 @@ chess > delete # Unload specific live game ``` #### `poll` / `p` -Long-poll for game updates (waits up to 30 seconds). +Long-poll for game updates (waits up to 30 seconds). Returns on a move, an +undo, a result (a resignation or an accepted draw), or a draw offer, and +prints the outcome or offer. In a two-player game, `poll` is how you learn +that the opponent moved, offered a draw, or resigned. ``` chess > poll ``` diff --git a/doc/database.md b/doc/database.md index 0e34c75..a748194 100644 --- a/doc/database.md +++ b/doc/database.md @@ -62,13 +62,25 @@ The tables: | `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.games` | Players, claims (`*_claimed_by`), name snapshots, `result`, `termination`, `concession_*`, 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. +`agreement`. Both are empty while a game is unfinished, and an undo empties +them again. `concession_result`, `concession_termination`, and +`concession_ply` keep the first resignation or agreed draw even when an undo +against the computer continued play: + +```sql +SELECT game_id, concession_result, concession_ply, + coalesce(result, 'unfinished') AS now +FROM chess.games +WHERE concession_result IS NOT NULL + AND (result IS DISTINCT FROM concession_result + OR termination IS DISTINCT FROM concession_termination); -- continued after conceding +``` `\set g ''` stores a game ID for the `:'g'` references below. @@ -276,7 +288,7 @@ su -m chess -c '/home/chess/bin/chess-server db delete -confirm -dsn "postgres:/ 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 +`db init` prints `Database schema ready (version 3)`. 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: @@ -304,6 +316,6 @@ Verify any of them: ```sql \dt chess.* -- games, moves, schema_version, sessions, users -SELECT version FROM chess.schema_version; -- 2 +SELECT version FROM chess.schema_version; -- 3 \drds -- chess: search_path and timeouts in database chess ``` diff --git a/doc/deployment-linux.md b/doc/deployment-linux.md index d32b533..294cf88 100644 --- a/doc/deployment-linux.md +++ b/doc/deployment-linux.md @@ -137,12 +137,14 @@ systemctl status chessd journalctl -u chessd -f # server log sudo chess-db user add -username # 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" +curl -s http://127.0.0.1:8080/health # "storage":"ok", "version" = the build 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. +Schema migrations run before the service starts. `/health` reports the +running build as `version` (`git describe` of the source); check it matches, +and upgrade the server before or with a web client served from elsewhere. **Rollback:** until you delete it, the previous binary is kept: diff --git a/doc/deployment.md b/doc/deployment.md index 6f47ce0..2e51a8c 100644 --- a/doc/deployment.md +++ b/doc/deployment.md @@ -302,10 +302,15 @@ Restore the previous web client files on the host as well. ```sh jail# service chessd status -jail# fetch -qo - http://127.0.0.1:8080/health # "storage":"ok" +jail# fetch -qo - http://127.0.0.1:8080/health # "storage":"ok", "version" = the build jail# sockstat -4 -6 -l | grep postgres # no TCP listener jail# sockstat -4 -c | grep ':8080' # peer = nginx address jail# su -m chess -c 'psql -X -d chess -Atc "SHOW search_path"' # chess jail# su -m chess -c 'psql -X -d postgres -c "select 1"' # rejected by pg_hba host# curl -s https:///chess/health # through nginx ``` + +`version` in `/health` is `git describe` of the checkout the binary was built +from. After an upgrade it must match the new build; a web client copied to a +static site ahead of the server gets 404 `NOT_FOUND` (shown as "Game Not +Found" by older clients) for endpoints the running server predates. diff --git a/doc/todo.md b/doc/todo.md index 1a9ef72..280d94d 100644 --- a/doc/todo.md +++ b/doc/todo.md @@ -41,7 +41,8 @@ into phases that each ship and test on their own. | 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 | **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. | +| 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. **Refined (v3 schema):** a resignation or agreed draw between two humans is final, and undo, player changes, and unload respect other users' claims (no takebacks between players). Against the computer a concession can be undone, but the first one stays on record (`concession_*`) and in history and PGN. | +| D8 | Human-vs-human in the web client | **Open.** The web client plays only against the computer; two-player games run through the API and terminal client (`join`, `poll`). A web mode would need join-by-link, a long-poll for the opponent's moves, and an offer indicator on the Draw button (accept by pressing 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) — done diff --git a/internal/client/api/types.go b/internal/client/api/types.go index f36ff1b..0ff1b93 100644 --- a/internal/client/api/types.go +++ b/internal/client/api/types.go @@ -43,11 +43,20 @@ type GameResponse struct { Termination string `json:"termination,omitempty"` DrawOffer string `json:"drawOffer,omitempty"` DrawOutcome string `json:"drawOutcome,omitempty"` + Concession *Concession `json:"concession,omitempty"` Moves []string `json:"moves"` Players PlayersResponse `json:"players"` LastMove *MoveInfo `json:"lastMove,omitempty"` } +// Concession is the game's first resignation or agreed draw; the server keeps +// it when an undo continues play against the computer. +type Concession struct { + Result string `json:"result"` + Termination string `json:"termination"` + Ply int `json:"ply"` +} + type ResignRequest struct { Color string `json:"color,omitempty"` } diff --git a/internal/client/command/game.go b/internal/client/command/game.go index 200ef80..d232035 100644 --- a/internal/client/command/game.go +++ b/internal/client/command/game.go @@ -408,6 +408,23 @@ func printOutcome(resp *api.GameResponse) { } } +// printConcession notes a resignation or agreed draw that play continued +// past; the server keeps it on record. +func printConcession(resp *api.GameResponse) { + c := resp.Concession + if c == nil || (c.Result == resp.State && c.Termination == resp.Termination) { + return + } + event := "A draw was agreed" + switch c.Result { + case "white wins": + event = "Black resigned" + case "black wins": + event = "White resigned" + } + display.Println(display.Yellow, "%s at ply %d; it stays on record while play continues.", event, c.Ply) +} + func drawReason(termination string) string { switch termination { case "insufficient_material": @@ -498,6 +515,7 @@ func undoHandler(s *session.Session, args []string) error { s.SetLastMoveCount(len(resp.Moves)) s.SetGameState(resp) display.Println(display.Green, "Undid %d move(s)", count) + printConcession(resp) return nil } @@ -561,6 +579,8 @@ func showBoardHandler(s *session.Session, args []string) error { } fmt.Println() } + printOutcome(game) + printConcession(game) return nil } @@ -619,10 +639,13 @@ func pollHandler(s *session.Session, args []string) error { c := s.GetClient().(*api.Client) moveCount := s.GetLastMoveCount() + previous := s.CurrentGameState display.Println(display.Cyan, "Long-polling for updates (move count: %d)...", moveCount) display.Println(display.Cyan, "This may take up to 30 seconds") + // The server answers at once for a finished game, and wakes a poll for a + // move, an undo, a result, or a draw offer. resp, err := c.GetGameWithPoll(gameID, moveCount) if err != nil { return err @@ -631,14 +654,21 @@ func pollHandler(s *session.Session, args []string) error { s.SetLastMoveCount(len(resp.Moves)) s.SetGameState(resp) - if len(resp.Moves) > moveCount { + switch { + case len(resp.Moves) > moveCount: display.Println(display.Green, "Game updated! New moves detected") if resp.LastMove != nil { fmt.Printf("Last move: %s\n", resp.LastMove.Move) } - } else { + case len(resp.Moves) < moveCount: + display.Println(display.Green, "Game updated: %d move(s) taken back", moveCount-len(resp.Moves)) + case previous == nil || previous.GameID != resp.GameID || + previous.State != resp.State || previous.DrawOffer != resp.DrawOffer: + display.Println(display.Green, "Game updated") + default: display.Println(display.Yellow, "No updates (timeout)") } + printOutcome(resp) return nil } diff --git a/internal/server/core/api.go b/internal/server/core/api.go index d5e1877..d91eee2 100644 --- a/internal/server/core/api.go +++ b/internal/server/core/api.go @@ -41,7 +41,8 @@ type UndoRequest struct { // GameResponse is the live game. Termination names how a finished game ended; // DrawOffer is the color whose draw offer awaits an answer; DrawOutcome // reports what a draw request did ("offered", "accepted", "declined") and is -// set only on responses to that request. +// set only on responses to that request. Concession is the game's first +// resignation or agreed draw, kept after an undo continues play. type GameResponse struct { GameID string `json:"gameId"` FEN string `json:"fen"` @@ -50,11 +51,22 @@ type GameResponse struct { Termination string `json:"termination,omitempty"` DrawOffer string `json:"drawOffer,omitempty"` DrawOutcome string `json:"drawOutcome,omitempty"` + Concession *Concession `json:"concession,omitempty"` Moves []string `json:"moves"` Players PlayersResponse `json:"players"` LastMove *MoveInfo `json:"lastMove,omitempty"` } +// Concession records a resignation or agreed draw. Result uses the enclosing +// response's vocabulary: a state ("black wins") in GameResponse, a stored +// result ("black_wins") in GameHistoryResponse. Ply is the number of moves +// played when it was made. +type Concession struct { + Result string `json:"result"` + Termination string `json:"termination"` + Ply int `json:"ply"` +} + type MoveInfo struct { Move string `json:"move"` PlayerColor string `json:"playerColor"` // "w" or "b" @@ -73,7 +85,8 @@ type BoardResponse struct { // // PGNResult is the PGN result token ("1-0", "0-1", "1/2-1/2", or "*") and // Termination names how a result was reached (see core.Termination; omitted -// while the game is unfinished). +// while the game is unfinished). Concession is the game's first resignation +// or agreed draw, kept when an undo continued play past it. type GameHistoryResponse struct { GameID string `json:"gameId"` InitialFEN string `json:"initialFen"` @@ -82,6 +95,7 @@ type GameHistoryResponse struct { Termination string `json:"termination,omitempty"` StartTimeUTC time.Time `json:"startTimeUtc"` EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"` + Concession *Concession `json:"concession,omitempty"` Players PlayersResponse `json:"players"` Moves []HistoryMove `json:"moves"` } diff --git a/internal/server/core/error.go b/internal/server/core/error.go index c3b51d3..a1a288d 100644 --- a/internal/server/core/error.go +++ b/internal/server/core/error.go @@ -15,4 +15,5 @@ const ( ErrUnauthorized = "UNAUTHORIZED" ErrConflict = "GAME_CONFLICT" ErrStorageUnavailable = "STORAGE_UNAVAILABLE" + ErrRouteNotFound = "NOT_FOUND" ) diff --git a/internal/server/core/state.go b/internal/server/core/state.go index 748a2a3..4feeed8 100644 --- a/internal/server/core/state.go +++ b/internal/server/core/state.go @@ -91,6 +91,13 @@ func (t Termination) ValidFor(s State) bool { } } +// IsConcession reports whether the players chose the result: a resignation or +// a draw by agreement. A concession is final when both sides are human; against +// the computer play may continue, and the first concession stays on record. +func (t Termination) IsConcession() bool { + return t == TermResignation || t == TermAgreement +} + // ParseColor accepts "w", "b", "white", or "black"; anything else is zero. func ParseColor(s string) Color { switch s { diff --git a/internal/server/game/game.go b/internal/server/game/game.go index 7a960cf..cf39b99 100644 --- a/internal/server/game/game.go +++ b/internal/server/game/game.go @@ -36,6 +36,15 @@ type Game struct { drawOffer core.Color // offerPly[c] is the ply count when color c last offered a draw, or -1. offerPly map[core.Color]int + // concession is the first resignation or agreed draw; an undo keeps it. + concession *Concession +} + +// Concession is a result the players chose, and the ply it was chosen at. +type Concession struct { + State core.State + Termination core.Termination + Ply int } // View is an immutable copy of the state needed by processors and transports. @@ -54,6 +63,7 @@ type View struct { Termination core.Termination DrawOffer core.Color // color whose offer awaits an answer; 0 when none OfferPly map[core.Color]int + Concession *Concession LastResult *MoveResult EndTimeUTC *time.Time } @@ -125,9 +135,28 @@ func (g *Game) View() View { copy := *g.endTimeUTC view.EndTimeUTC = © } + if g.concession != nil { + copy := *g.concession + view.Concession = © + } return view } +// BothHuman reports whether neither side is played by the computer. +func (v View) BothHuman() bool { + return bothHuman(v.WhitePlayer, v.BlackPlayer) +} + +// BothHuman reports whether neither side is played by the computer. +func (g *Game) BothHuman() bool { + return bothHuman(g.players[core.ColorWhite], g.players[core.ColorBlack]) +} + +func bothHuman(white, black *core.Player) bool { + return white != nil && white.Type == core.PlayerHuman && + black != nil && black.Type == core.PlayerHuman +} + func (v View) NextPlayer() *core.Player { if v.NextTurnColor == core.ColorWhite { return v.WhitePlayer @@ -268,6 +297,11 @@ func (g *Game) ClearDrawOffers() { g.offerPly[core.ColorBlack] = -1 } +// Concession returns the game's first resignation or agreed draw, or nil. +func (g *Game) Concession() *Concession { + return g.concession +} + // Termination returns how a finished game ended. func (g *Game) Termination() core.Termination { return g.termination @@ -288,12 +322,15 @@ func (g *Game) State() core.State { } // SetOutcome sets the state and how it was reached; a non-terminal state -// clears the termination. +// clears the termination. The first concession is remembered. func (g *Game) SetOutcome(s core.State, termination core.Termination, at time.Time) { g.SetStateAt(s, at) if s.IsTerminal() { g.termination = termination g.drawOffer = 0 + if termination.IsConcession() && g.concession == nil { + g.concession = &Concession{State: s, Termination: termination, Ply: g.Plies()} + } } else { g.termination = core.TermNone } diff --git a/internal/server/http/handler.go b/internal/server/http/handler.go index b255134..d156a99 100644 --- a/internal/server/http/handler.go +++ b/internal/server/http/handler.go @@ -24,12 +24,13 @@ const rateLimitRate = 10 // req/sec // HTTPHandler handles HTTP requests and routes them to the processor type HTTPHandler struct { - proc *processor.Processor - svc *service.Service + proc *processor.Processor + svc *service.Service + version string } -func NewHTTPHandler(proc *processor.Processor, svc *service.Service) *HTTPHandler { - return &HTTPHandler{proc: proc, svc: svc} +func NewHTTPHandler(proc *processor.Processor, svc *service.Service, version string) *HTTPHandler { + return &HTTPHandler{proc: proc, svc: svc, version: version} } // Options configures the API application. @@ -43,6 +44,8 @@ type Options struct { // X-Real-IP (nginx: proxy_set_header X-Real-IP $remote_addr). Avoid // X-Forwarded-For: its first entry is supplied by the client. ProxyHeader string + // Version is the build identifier /health reports. + Version string } // appConfig returns the Fiber configuration. Client-IP resolution only honors @@ -75,7 +78,7 @@ func clientIP(c *fiber.Ctx) string { func NewFiberApp(proc *processor.Processor, svc *service.Service, opts Options) *fiber.App { // Create handler - h := NewHTTPHandler(proc, svc) + h := NewHTTPHandler(proc, svc, opts.Version) devMode := opts.DevMode // Initialize Fiber app @@ -170,11 +173,11 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, opts Options) api.Post("/games", OptionalAuth(validateToken), h.CreateGame) // Optional auth for player ID association api.Get("/games/:gameId/history", h.GetGameHistory) api.Get("/games/:gameId/pgn", h.GetGamePGN) - api.Put("/games/:gameId/players", h.ConfigurePlayers) + api.Put("/games/:gameId/players", OptionalAuth(validateToken), h.ConfigurePlayers) api.Get("/games/:gameId", h.GetGame) - api.Delete("/games/:gameId", h.DeleteGame) + api.Delete("/games/:gameId", OptionalAuth(validateToken), h.DeleteGame) api.Post("/games/:gameId/moves", OptionalAuth(validateToken), h.MakeMove) - api.Post("/games/:gameId/undo", h.UndoMove) + api.Post("/games/:gameId/undo", OptionalAuth(validateToken), h.UndoMove) api.Post("/games/:gameId/resign", OptionalAuth(validateToken), h.Resign) api.Post("/games/:gameId/draw", OptionalAuth(validateToken), h.Draw) api.Get("/games/:gameId/board", h.GetBoard) @@ -213,9 +216,12 @@ func customErrorHandler(c *fiber.Ctx, err error) error { response.Error = e.Message // Map HTTP status to error codes + // An unknown route is not a missing game: a client newer than the + // server must not report "game not found" for an endpoint the server + // predates. switch code { case fiber.StatusNotFound: - response.Code = core.ErrGameNotFound + response.Code = core.ErrRouteNotFound case fiber.StatusBadRequest: response.Code = core.ErrInvalidRequest case fiber.StatusTooManyRequests: @@ -237,6 +243,7 @@ func (h *HTTPHandler) Health(c *fiber.Ctx) error { "status": status, "time": time.Now().Unix(), "storage": storageHealth, + "version": h.version, }) } @@ -314,15 +321,12 @@ func (h *HTTPHandler) ConfigurePlayers(c *fiber.Ctx) error { // Create command and execute cmd := processor.NewConfigurePlayersCommand(gameID, req) + cmd.UserID, _ = c.Locals("userID").(string) resp := h.proc.Execute(cmd) // Return appropriate HTTP response if !resp.Success { - statusCode := fiber.StatusBadRequest - if resp.Error.Code == core.ErrGameNotFound { - statusCode = fiber.StatusNotFound - } - return c.Status(statusCode).JSON(resp.Error) + return c.Status(statusForCode(resp.Error.Code)).JSON(resp.Error) } return c.JSON(resp.Data) @@ -500,15 +504,12 @@ func (h *HTTPHandler) UndoMove(c *fiber.Ctx) error { // Create command and execute cmd := processor.NewUndoMoveCommand(gameID, req) + cmd.UserID, _ = c.Locals("userID").(string) resp := h.proc.Execute(cmd) // Return appropriate HTTP response if !resp.Success { - statusCode := fiber.StatusBadRequest - if resp.Error.Code == core.ErrGameNotFound { - statusCode = fiber.StatusNotFound - } - return c.Status(statusCode).JSON(resp.Error) + return c.Status(statusForCode(resp.Error.Code)).JSON(resp.Error) } return c.JSON(resp.Data) @@ -581,11 +582,12 @@ func (h *HTTPHandler) DeleteGame(c *fiber.Ctx) error { // Create command and execute cmd := processor.NewDeleteGameCommand(gameID) + cmd.UserID, _ = c.Locals("userID").(string) resp := h.proc.Execute(cmd) // Return appropriate HTTP response if !resp.Success { - return c.Status(fiber.StatusNotFound).JSON(resp.Error) + return c.Status(statusForCode(resp.Error.Code)).JSON(resp.Error) } return c.SendStatus(fiber.StatusNoContent) diff --git a/internal/server/processor/processor.go b/internal/server/processor/processor.go index a55e3d7..6b5afcc 100644 --- a/internal/server/processor/processor.go +++ b/internal/server/processor/processor.go @@ -224,7 +224,8 @@ func (p *Processor) handleCreateGame(cmd Command) ProcessorResponse { } } -// handleConfigurePlayers updates player configuration mid-game +// handleConfigurePlayers updates player configuration mid-game; another +// user's claim refuses it. func (p *Processor) handleConfigurePlayers(cmd Command) ProcessorResponse { args, ok := cmd.Args.(core.ConfigurePlayersRequest) if !ok { @@ -253,7 +254,13 @@ func (p *Processor) handleConfigurePlayers(cmd Command) ProcessorResponse { blackPlayer := core.NewPlayer(args.Black, core.ColorBlack) // Update players in service - if err = p.svc.UpdatePlayers(cmd.GameID, whitePlayer, blackPlayer); err != nil { + if err = p.svc.UpdatePlayers(cmd.GameID, whitePlayer, blackPlayer, cmd.UserID); err != nil { + switch { + case errors.Is(err, service.ErrGameNotFound): + return p.endGameError(err) + case errors.Is(err, service.ErrSlotOwner): + return p.errorResponse("another player has claimed a side of this game; players cannot be changed", core.ErrUnauthorized) + } return p.errorResponse(fmt.Sprintf("failed to update players: %v", err), core.ErrInternalError) } @@ -427,8 +434,10 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse { // handleUndoMove reverts game state. StateStuck is deliberately permitted: // undo -> StateOngoing is the recovery path for engine failures. Terminal -// states are also permitted so a finished game can be rewound. Any reverted-to -// snapshot had legal moves made from it, so resetting to Ongoing is sound +// states are also permitted so a finished game can be rewound, except a +// resignation or agreed draw between two humans; another user's claim refuses +// an undo. Any reverted-to snapshot had legal moves made from it, so resetting +// to Ongoing is sound // without re-classification. func (p *Processor) handleUndoMove(cmd Command) ProcessorResponse { g, err := p.svc.GetGameView(cmd.GameID) @@ -447,9 +456,12 @@ func (p *Processor) handleUndoMove(cmd Command) ProcessorResponse { } } - if err = p.svc.UndoMoves(cmd.GameID, args.Count); err != nil { - if strings.Contains(err.Error(), "not found") { - return p.errorResponse("game not found", core.ErrGameNotFound) + if err = p.svc.UndoMoves(cmd.GameID, args.Count, cmd.UserID); err != nil { + switch { + case errors.Is(err, service.ErrGameNotFound), errors.Is(err, service.ErrConcessionFinal): + return p.endGameError(err) + case errors.Is(err, service.ErrSlotOwner): + return p.errorResponse("another player has claimed a side of this game; moves cannot be taken back", core.ErrUnauthorized) } return p.errorResponse(err.Error(), core.ErrInvalidRequest) } @@ -461,7 +473,8 @@ func (p *Processor) handleUndoMove(cmd Command) ProcessorResponse { } } -// handleDeleteGame unloads a game from live memory. +// handleDeleteGame unloads a game from live memory for one of its claimants, +// or anyone when it is unclaimed. func (p *Processor) handleDeleteGame(cmd Command) ProcessorResponse { g, err := p.svc.GetGameView(cmd.GameID) if err != nil { @@ -473,8 +486,14 @@ func (p *Processor) handleDeleteGame(cmd Command) ProcessorResponse { return p.errorResponse("cannot delete game while computer move is in progress", core.ErrInvalidRequest) } - if err = p.svc.DeleteGame(cmd.GameID); err != nil { - return p.errorResponse("game not found", core.ErrGameNotFound) + if err = p.svc.UnloadGame(cmd.GameID, cmd.UserID); err != nil { + switch { + case errors.Is(err, service.ErrGameNotFound): + return p.endGameError(err) + case errors.Is(err, service.ErrSlotOwner): + return p.errorResponse("only a player who claimed a side can unload this game", core.ErrUnauthorized) + } + return p.errorResponse(err.Error(), core.ErrInvalidRequest) } return ProcessorResponse{ @@ -663,6 +682,11 @@ func (p *Processor) buildGameResponse(gameID string, g game.View) core.GameRespo if g.DrawOffer != 0 { resp.DrawOffer = g.DrawOffer.String() } + if c := g.Concession; c != nil { + resp.Concession = &core.Concession{ + Result: c.State.String(), Termination: string(c.Termination), Ply: c.Ply, + } + } // Include last move if available if result := g.LastResult; result != nil { @@ -957,6 +981,8 @@ func (p *Processor) endGameError(err error) ProcessorResponse { return p.errorResponse(err.Error(), core.ErrConflict) case errors.Is(err, service.ErrGameChanged): return p.errorResponse("game changed; refresh and retry", core.ErrConflict) + case errors.Is(err, service.ErrConcessionFinal): + return p.errorResponse(err.Error(), core.ErrGameOver) } return p.errorResponse(fmt.Sprintf("failed to update game: %v", err), core.ErrInternalError) } diff --git a/internal/server/replay/replay.go b/internal/server/replay/replay.go index 6772ae0..687bcb6 100644 --- a/internal/server/replay/replay.go +++ b/internal/server/replay/replay.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "strconv" + "strings" "chess/internal/server/chess" "chess/internal/server/core" @@ -69,6 +70,9 @@ func BuildPGN(record *storage.GameRecord, moves []storage.MoveRecord, ply int) ( if complete { result = chess.ResultToken(record.Result) comment = Describe(record.Result, record.Termination) + if note := continuedAfter(record, len(moves)); note != "" { + comment = strings.TrimSpace(note + " " + comment) + } } start := record.StartTimeUTC.UTC() tags := []chess.Tag{ @@ -105,6 +109,29 @@ func BuildPGN(record *storage.GameRecord, moves []storage.MoveRecord, ply int) ( }, nil } +// continuedAfter notes a resignation or agreed draw that an undo took back, +// such as "White resigned at ply 24; play continued."; empty when there was +// none or it is still the game's result. +func continuedAfter(record *storage.GameRecord, plies int) string { + if record.ConcessionResult == "" { + return "" + } + if record.ConcessionResult == record.Result && + record.ConcessionTermination == record.Termination && record.ConcessionPly == plies { + return "" + } + var event string + switch record.ConcessionResult { + case "white_wins": + event = "Black resigned" + case "black_wins": + event = "White resigned" + default: + event = "A draw was agreed" + } + return fmt.Sprintf("%s at ply %d; play continued.", event, record.ConcessionPly) +} + // Describe returns a sentence for a stored result and termination, such as // "Black wins by resignation." or "Draw by threefold repetition."; empty for // an unfinished game. diff --git a/internal/server/replay/replay_test.go b/internal/server/replay/replay_test.go index 5ba1da9..9298290 100644 --- a/internal/server/replay/replay_test.go +++ b/internal/server/replay/replay_test.go @@ -154,3 +154,24 @@ func TestBuildPGN(t *testing.T) { t.Error("ply beyond the line accepted") } } + +// A resignation taken back by an undo is noted before the live result. +func TestBuildPGNNotesContinuedConcession(t *testing.T) { + moves := stored(t, chess.StartFEN, "e2e4", "e7e5", "d2d4") + rec := record(chess.StartFEN, "", "") + rec.ConcessionResult, rec.ConcessionTermination, rec.ConcessionPly = "black_wins", "resignation", 2 + + pgn, err := BuildPGN(rec, moves, -1) + if err != nil { + t.Fatal(err) + } + if !strings.HasSuffix(pgn.Text, "\n\n1. e4 e5 2. d4 { White resigned at ply 2; play continued. } *\n") { + t.Errorf("continued game:\n%s", pgn.Text) + } + + // The concession that is still the result needs no note. + rec.Result, rec.Termination, rec.ConcessionPly = "black_wins", "resignation", 3 + if pgn, _ = BuildPGN(rec, moves, -1); strings.Contains(pgn.Text, "continued") { + t.Errorf("standing resignation noted as continued:\n%s", pgn.Text) + } +} diff --git a/internal/server/service/end_test.go b/internal/server/service/end_test.go index 70d9d95..eb1be6b 100644 --- a/internal/server/service/end_test.go +++ b/internal/server/service/end_test.go @@ -94,12 +94,97 @@ func TestResignationClaimsAndPersists(t *testing.T) { t.Fatalf("history after resignation: %+v", history) } - // Undo after a result rewinds it (D6), in memory and in storage. - if err := svc.UndoMoves(gameID, 1); err != nil { + // Between two humans a resignation is final: neither the other claimant + // nor an anonymous caller can take it back, and the players cannot be + // reconfigured around it. + for _, actor := range []string{alice, bob, ""} { + if err := svc.UndoMoves(gameID, 1, actor); !errors.Is(err, ErrSlotOwner) { + t.Fatalf("undo by %q after a two-player resignation: %v, want ErrSlotOwner", actor, err) + } + } + computer := core.NewPlayer(core.PlayerConfig{Type: core.PlayerComputer, Level: 1, SearchTime: 100}, core.ColorBlack) + white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite) + if err := svc.UpdatePlayers(gameID, white, computer, alice); !errors.Is(err, ErrSlotOwner) { + t.Fatalf("reconfiguring around a resignation: %v, want ErrSlotOwner", err) + } + if history.Concession == nil || history.Concession.Result != "white_wins" || + history.Concession.Termination != "resignation" || history.Concession.Ply != 1 { + t.Fatalf("concession not recorded: %+v", history.Concession) + } +} + +// A single controller (hotseat or anonymous) is refused only by finality. +func TestConcessionBetweenHumansIsFinal(t *testing.T) { + svc := newPersistentTestService(t) + gameID := newHumanGame(t, svc) + move(t, svc, gameID, "", "e2e4") + view, _ := svc.GetGameView(gameID) + if err := svc.OfferDraw(gameID, core.ColorBlack, "", view.FEN); err != nil { t.Fatal(err) } - if history, _ = svc.GetGameHistory(gameID); history.Result != "" || history.Termination != "" { - t.Fatalf("undo kept the resignation: %+v", history) + if err := svc.EndGame(gameID, EndCommit{ + ExpectedFEN: view.FEN, Color: core.ColorWhite, State: core.StateDraw, + Termination: core.TermAgreement, OfferFrom: core.ColorBlack, + }); err != nil { + t.Fatal(err) + } + if err := svc.UndoMoves(gameID, 1, ""); !errors.Is(err, ErrConcessionFinal) { + t.Fatalf("undo after an agreed draw between humans: %v, want ErrConcessionFinal", err) + } +} + +// Against the computer an undo reopens a resigned game; the first +// concession stays on record through the rewind, later moves, and a second +// resignation, while result and termination follow the live game. +func TestConcessionAgainstComputerStaysOnRecord(t *testing.T) { + svc := newPersistentTestService(t) + gameID, alice := uuid.NewString(), uuid.NewString() + white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite) + black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerComputer, Level: 1, SearchTime: 100}, core.ColorBlack) + if err := svc.CreateGame(gameID, white, black, chess.StartFEN, core.ColorWhite, + core.StateOngoing, core.TermNone); err != nil { + t.Fatal(err) + } + move(t, svc, gameID, alice, "e2e4") + move(t, svc, gameID, "", "e7e5") + resign := func() { + t.Helper() + view, _ := svc.GetGameView(gameID) + if err := svc.EndGame(gameID, EndCommit{ + ExpectedFEN: view.FEN, Color: core.ColorWhite, ActorUserID: alice, + State: core.StateBlackWins, Termination: core.TermResignation, + }); err != nil { + t.Fatal(err) + } + } + resign() + + if err := svc.UndoMoves(gameID, 2, ""); !errors.Is(err, ErrSlotOwner) { + t.Fatalf("anonymous undo of a claimed game: %v, want ErrSlotOwner", err) + } + if err := svc.UndoMoves(gameID, 2, alice); err != nil { + t.Fatal(err) + } + view, _ := svc.GetGameView(gameID) + if view.State != core.StateOngoing || view.Concession == nil || + view.Concession.State != core.StateBlackWins || view.Concession.Ply != 2 { + t.Fatalf("view after undoing a resignation: %+v", view) + } + history, err := svc.GetGameHistory(gameID) + if err != nil { + t.Fatal(err) + } + if history.Result != "" || history.Termination != "" || len(history.Moves) != 0 || + history.Concession == nil || history.Concession.Result != "black_wins" || + history.Concession.Termination != "resignation" || history.Concession.Ply != 2 { + t.Fatalf("history after undoing a resignation: %+v", history) + } + + move(t, svc, gameID, alice, "d2d4") + resign() + history, _ = svc.GetGameHistory(gameID) + if history.Result != "black_wins" || len(history.Moves) != 1 || history.Concession.Ply != 2 { + t.Fatalf("second resignation replaced the first concession: %+v", history) } } diff --git a/internal/server/service/game.go b/internal/server/service/game.go index 23eac24..1a67e60 100644 --- a/internal/server/service/game.go +++ b/internal/server/service/game.go @@ -21,8 +21,42 @@ var ( ErrNotHuman = errors.New("that side is played by the computer") ErrNoDrawOffer = errors.New("no draw offer from the opponent") ErrOfferLimit = errors.New("one draw offer per move: make a move before offering again") + // ErrConcessionFinal refuses to continue a game two human players ended + // by resignation or agreement. + ErrConcessionFinal = errors.New("a resignation or agreed draw between two human players is final") ) +// requireControl lets actor rewind or reconfigure g only when no side is +// claimed by another user. A claim is what protects a player's moves; an +// unclaimed side is open to anyone holding the game ID. +func requireControl(g *game.Game, actor string) error { + for _, color := range []core.Color{core.ColorWhite, core.ColorBlack} { + if owner := g.GetSlotOwner(color); owner != "" && owner != actor { + return ErrSlotOwner + } + } + return nil +} + +// requireParticipant lets actor unload g when it holds one of its claims, or +// when nothing is claimed. +func requireParticipant(g *game.Game, actor string) error { + claimed := false + for _, color := range []core.Color{core.ColorWhite, core.ColorBlack} { + switch g.GetSlotOwner(color) { + case "": + case actor: + return nil + default: + claimed = true + } + } + if claimed { + return ErrSlotOwner + } + return nil +} + type MoveCommit struct { ExpectedFEN string ExpectedState core.State @@ -108,18 +142,22 @@ func (s *Service) CreateGame( return nil } -// UpdatePlayers replaces players in an existing game -func (s *Service) UpdatePlayers(gameID string, whitePlayer, blackPlayer *core.Player) error { +// UpdatePlayers replaces players in an existing game on behalf of actor, who +// must not be locked out by another user's claim. +func (s *Service) UpdatePlayers(gameID string, whitePlayer, blackPlayer *core.Player, actor string) error { s.mu.Lock() defer s.mu.Unlock() g, ok := s.games[gameID] if !ok { - return fmt.Errorf("game not found: %s", gameID) + return fmt.Errorf("%w: %s", ErrGameNotFound, gameID) } if g.State() == core.StatePending { return errors.New("cannot change players while computer is calculating") } + if err := requireControl(g, actor); err != nil { + return err + } oldWhite := g.GetPlayer(core.ColorWhite) oldBlack := g.GetPlayer(core.ColorBlack) @@ -369,6 +407,7 @@ func (s *Service) EndGame(gameID string, commit EndCommit) error { result, _ := commit.State.Result() end := storage.GameEnd{ GameID: gameID, Result: result, Termination: string(commit.Termination), EndTimeUTC: now, + Ply: g.Plies(), } if claimUserID != "" { end.ClaimColor, end.ClaimedBy = commit.Color.String(), claimUserID @@ -490,8 +529,11 @@ func offerAllowed(g *game.Game, color core.Color) error { return nil } -// UndoMoves removes the specified number of moves from game history -func (s *Service) UndoMoves(gameID string, count int) error { +// UndoMoves takes back count moves on behalf of actor. Another user's claim +// refuses it, so two players cannot rewind each other; a resignation or agreed +// draw between two humans is final. Against the computer an undo reopens a +// finished game and the first concession stays on record. +func (s *Service) UndoMoves(gameID string, count int, actor string) error { s.mu.Lock() defer s.mu.Unlock() @@ -502,6 +544,12 @@ func (s *Service) UndoMoves(gameID string, count int) error { if g.State() == core.StatePending { return errors.New("cannot undo while computer move is in progress") } + if err := requireControl(g, actor); err != nil { + return err + } + if g.State().IsTerminal() && g.Termination().IsConcession() && g.BothHuman() { + return ErrConcessionFinal + } originalMoveCount := len(g.Moves()) @@ -525,6 +573,21 @@ func (s *Service) UndoMoves(gameID string, count int) error { return nil } +// UnloadGame removes a live game on behalf of actor, who must hold one of +// its claims when it has any. Durable history is kept. +func (s *Service) UnloadGame(gameID string, actor string) error { + s.mu.Lock() + defer s.mu.Unlock() + g, ok := s.games[gameID] + if !ok { + return fmt.Errorf("%w: %s", ErrGameNotFound, gameID) + } + if err := requireParticipant(g, actor); err != nil { + return err + } + return s.deleteGameLocked(gameID, g) +} + // DeleteGame removes a game from the service func (s *Service) DeleteGame(gameID string) error { s.mu.Lock() @@ -534,6 +597,10 @@ func (s *Service) DeleteGame(gameID string) error { if !ok { return fmt.Errorf("%w: %s", ErrGameNotFound, gameID) } + return s.deleteGameLocked(gameID, g) +} + +func (s *Service) deleteGameLocked(gameID string, g *game.Game) error { if g.State() == core.StatePending { return errors.New("cannot delete game while computer move is in progress") } diff --git a/internal/server/service/game_test.go b/internal/server/service/game_test.go index 3b4d942..518fd0e 100644 --- a/internal/server/service/game_test.go +++ b/internal/server/service/game_test.go @@ -70,7 +70,7 @@ func TestUndoClearsDurableTerminalResult(t *testing.T) { }); err != nil { t.Fatal(err) } - if err := svc.UndoMoves(gameID, 1); err != nil { + if err := svc.UndoMoves(gameID, 1, ""); err != nil { t.Fatal(err) } @@ -100,7 +100,7 @@ func TestPlayerReconfigurationPreservesClaimAndPersistsConfiguration(t *testing. replacementBlack := core.NewPlayer( core.PlayerConfig{Type: core.PlayerComputer, Level: 12, SearchTime: 500}, core.ColorBlack, ) - if err := svc.UpdatePlayers(gameID, replacementWhite, replacementBlack); err != nil { + if err := svc.UpdatePlayers(gameID, replacementWhite, replacementBlack, userID); err != nil { t.Fatal(err) } @@ -118,7 +118,7 @@ func TestPlayerReconfigurationPreservesClaimAndPersistsConfiguration(t *testing. computerWhite := core.NewPlayer( core.PlayerConfig{Type: core.PlayerComputer, Level: 8, SearchTime: 300}, core.ColorWhite, ) - if err := svc.UpdatePlayers(gameID, computerWhite, replacementBlack); err != nil { + if err := svc.UpdatePlayers(gameID, computerWhite, replacementBlack, userID); err != nil { t.Fatal(err) } history, err = svc.GetGameHistory(gameID) diff --git a/internal/server/service/history.go b/internal/server/service/history.go index 38e5a3c..a340cf7 100644 --- a/internal/server/service/history.go +++ b/internal/server/service/history.go @@ -46,6 +46,12 @@ func (s *Service) GetGameHistory(gameID string) (*core.GameHistoryResponse, erro Players: playersResponse(*record), Moves: make([]core.HistoryMove, 0, len(moves)), } + if record.ConcessionResult != "" { + history.Concession = &core.Concession{ + Result: record.ConcessionResult, Termination: record.ConcessionTermination, + Ply: record.ConcessionPly, + } + } san, err := replay.Notate(record.InitialFEN, moves) if err != nil { slog.Warn("stored game has moves without notation", "game_id", record.GameID, "error", err) diff --git a/internal/server/storage/game.go b/internal/server/storage/game.go index d351e7f..2a51922 100644 --- a/internal/server/storage/game.go +++ b/internal/server/storage/game.go @@ -16,7 +16,8 @@ const gameSelectColumns = ` g.game_id, g.initial_fen, g.white_player_id, g.white_type, g.white_level, g.white_search_time, g.white_claimed_by, g.white_name, g.black_player_id, g.black_type, g.black_level, g.black_search_time, g.black_claimed_by, g.black_name, - g.result, g.termination, g.start_time_utc, g.end_time_utc` + g.result, g.termination, g.start_time_utc, g.end_time_utc, + g.concession_result, g.concession_termination, g.concession_ply, g.concession_time_utc` // claimantName snapshots a claimant's username inside the writing // transaction; NULL when the slot is unclaimed or the account is gone. @@ -114,6 +115,9 @@ type GameEnd struct { EndTimeUTC time.Time ClaimColor string ClaimedBy string + // Ply is the number of moves played; it is recorded with the first + // resignation or agreed draw (see GameRecord.ConcessionResult). + Ply int } // RecordGameEnd persists the result, how it was reached, and any claim in one @@ -138,9 +142,25 @@ func (s *Store) RecordGameEnd(end GameEnd) error { if (end.ClaimColor == "") != (end.ClaimedBy == "") { return errors.New("claim color and claimant must be provided together") } + if end.Ply < 0 { + return errors.New("ply must not be negative") + } return s.enqueue("record_game_end", end.GameID, func(ctx context.Context, tx *sql.Tx) error { - return updateGameEnd(ctx, tx, end.GameID, end.ClaimColor, end.ClaimedBy, - end.Result, end.Termination, &ended) + if err := updateGameEnd(ctx, tx, end.GameID, end.ClaimColor, end.ClaimedBy, + end.Result, end.Termination, &ended); err != nil { + return err + } + if !isConcession(end.Termination) { + return nil + } + // The first concession stays; a later one after an undo is only the + // current result. + _, err := tx.ExecContext(ctx, `UPDATE games SET + concession_result = $2, concession_termination = $3, + concession_ply = $4, concession_time_utc = $5 + WHERE game_id = $1 AND concession_result IS NULL`, + end.GameID, end.Result, end.Termination, end.Ply, ended) + return err }) } @@ -209,6 +229,7 @@ func (s *Store) RecordPlayers(gameID string, white, black PlayerRecord) error { // RewindGame atomically removes undone moves and clears a previously terminal // result so replay readers never observe an ongoing line with a stale outcome. +// A recorded concession is kept. func (s *Store) RewindGame(gameID string, afterMoveNumber int) error { if gameID == "" || afterMoveNumber < 0 { return errors.New("game ID and a non-negative move number are required") @@ -519,7 +540,9 @@ type rowScanner interface { // scanGame scans gameSelectColumns followed by any extra destinations. func scanGame(scanner rowScanner, record *GameRecord, extra ...any) error { var whiteClaimed, whiteName, blackClaimed, blackName, result, termination sql.NullString - var endTime sql.NullTime + var concessionResult, concessionTermination sql.NullString + var concessionPly sql.NullInt64 + var endTime, concessionTime sql.NullTime dest := []any{ &record.GameID, &record.InitialFEN, &record.WhitePlayerID, &record.WhiteType, &record.WhiteLevel, &record.WhiteSearchTime, @@ -527,6 +550,7 @@ func scanGame(scanner rowScanner, record *GameRecord, extra ...any) error { &record.BlackPlayerID, &record.BlackType, &record.BlackLevel, &record.BlackSearchTime, &blackClaimed, &blackName, &result, &termination, &record.StartTimeUTC, &endTime, + &concessionResult, &concessionTermination, &concessionPly, &concessionTime, } if err := scanner.Scan(append(dest, extra...)...); err != nil { return err @@ -539,6 +563,10 @@ func scanGame(scanner rowScanner, record *GameRecord, extra ...any) error { record.Termination = termination.String record.StartTimeUTC = record.StartTimeUTC.UTC() record.EndTimeUTC = utcPointer(endTime) + record.ConcessionResult = concessionResult.String + record.ConcessionTermination = concessionTermination.String + record.ConcessionPly = int(concessionPly.Int64) + record.ConcessionTimeUTC = utcPointer(concessionTime) return nil } @@ -589,6 +617,11 @@ func isValidResult(result string) bool { } // validTermination mirrors the games_termination_check constraint. +// isConcession reports a result the players chose (core.Termination.IsConcession). +func isConcession(termination string) bool { + return termination == "resignation" || termination == "agreement" +} + func validTermination(result, termination string) bool { switch result { case "white_wins", "black_wins": diff --git a/internal/server/storage/schema.go b/internal/server/storage/schema.go index 6d1c99a..a5303cd 100644 --- a/internal/server/storage/schema.go +++ b/internal/server/storage/schema.go @@ -41,6 +41,12 @@ type GameRecord struct { Termination string // how Result was reached; empty while unfinished StartTimeUTC time.Time EndTimeUTC *time.Time + // The first resignation or agreed draw, kept when an undo continues play; + // ConcessionResult is empty when there was none. + ConcessionResult string + ConcessionTermination string + ConcessionPly int + ConcessionTimeUTC *time.Time } // GameSummaryRecord is a game row plus its replay extent. MoveCount and @@ -75,7 +81,7 @@ type MovePersistence struct { // schemaVersion is the newest schema this binary understands. Migrations are // applied in order inside one transaction; PostgreSQL DDL is transactional, so // a failed upgrade leaves the previous version intact. -const schemaVersion = 2 +const schemaVersion = 3 // migrations[v-1] upgrades the schema from version v-1 to v. Never edit a // released migration; append a new one. @@ -182,6 +188,31 @@ ALTER TABLE games OR (result = 'draw' AND termination IN ('insufficient_material', 'threefold_repetition', 'fifty_move_rule', 'agreement'))); `, + + // v3: the first resignation or agreed draw. An undo against the computer + // may reopen such a game, which clears result and termination; these + // columns keep the concession on record. Ply is the number of moves + // played when it was made. Existing concessions are backfilled from the + // current result and move count. + ` +ALTER TABLE games + ADD COLUMN concession_result text, + ADD COLUMN concession_termination text, + ADD COLUMN concession_ply integer, + ADD COLUMN concession_time_utc timestamptz; +UPDATE games g SET + concession_result = g.result, + concession_termination = g.termination, + concession_ply = (SELECT count(*) FROM moves m WHERE m.game_id = g.game_id), + concession_time_utc = g.end_time_utc +WHERE g.termination IN ('resignation', 'agreement'); +ALTER TABLE games ADD CONSTRAINT games_concession_check CHECK ( + (concession_result IS NULL AND concession_termination IS NULL + AND concession_ply IS NULL AND concession_time_utc IS NULL) + OR (concession_ply >= 0 AND concession_time_utc IS NOT NULL AND ( + (concession_result IN ('white_wins', 'black_wins') AND concession_termination = 'resignation') + OR (concession_result = 'draw' AND concession_termination = 'agreement')))); +`, } // ownedTables lists every table created by migrations, children first. diff --git a/internal/server/storage/storage_test.go b/internal/server/storage/storage_test.go index e2b8807..47b42ac 100644 --- a/internal/server/storage/storage_test.go +++ b/internal/server/storage/storage_test.go @@ -945,6 +945,81 @@ func TestMigrationV2BackfillsTermination(t *testing.T) { } } +func TestMigrationV3BackfillsConcessions(t *testing.T) { + dsn := pgtest.DSN(t) + store, err := NewStore(dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + + // A version 2 database: a resignation after two moves, an agreed draw at + // the start, and a checkmate. + for _, migration := range migrations[:2] { + if _, err := store.db.Exec(migration); err != nil { + t.Fatal(err) + } + } + if _, err := store.db.Exec(`INSERT INTO schema_version (version) VALUES (2)`); err != nil { + t.Fatal(err) + } + ids := map[string]string{} + for _, row := range [][2]string{ + {"black_wins", "resignation"}, {"draw", "agreement"}, {"white_wins", "checkmate"}, + } { + id := uuid.NewString() + ids[row[1]] = id + if _, err := store.db.Exec(`INSERT INTO games (game_id, initial_fen, white_player_id, white_type, + black_player_id, black_type, result, termination, end_time_utc) + VALUES ($1, 'initial', $2, 1, $3, 2, $4, $5, now())`, + id, uuid.NewString(), uuid.NewString(), row[0], row[1]); err != nil { + t.Fatal(err) + } + } + for i, uci := range []string{"e2e4", "e7e5"} { + if _, err := store.db.Exec(`INSERT INTO moves (game_id, move_number, move_uci, fen_after_move, + player_color) VALUES ($1, $2, $3, 'fen', $4)`, + ids["resignation"], i+1, uci, []string{"w", "b"}[i]); err != nil { + t.Fatal(err) + } + } + + if err := store.InitDB(); err != nil { + t.Fatal(err) + } + for termination, want := range map[string]GameRecord{ + "resignation": {ConcessionResult: "black_wins", ConcessionTermination: "resignation", ConcessionPly: 2}, + "agreement": {ConcessionResult: "draw", ConcessionTermination: "agreement", ConcessionPly: 0}, + "checkmate": {}, + } { + records, err := store.QueryGames(ids[termination], "") + if err != nil || len(records) != 1 { + t.Fatalf("%s: %v, %d records", termination, err, len(records)) + } + got := records[0] + if got.ConcessionResult != want.ConcessionResult || + got.ConcessionTermination != want.ConcessionTermination || + got.ConcessionPly != want.ConcessionPly || + (want.ConcessionResult != "") != (got.ConcessionTimeUTC != nil) { + t.Errorf("%s: concession %q %q %d %v", termination, got.ConcessionResult, + got.ConcessionTermination, got.ConcessionPly, got.ConcessionTimeUTC) + } + } + + // Only a resignation or an agreed draw is a concession, and its columns + // are set together. + for name, query := range map[string]string{ + "checkmate concession": `UPDATE games SET concession_result = 'white_wins', + concession_termination = 'checkmate', concession_ply = 1, concession_time_utc = now() + WHERE game_id = $1`, + "partial concession": `UPDATE games SET concession_result = 'draw' WHERE game_id = $1`, + } { + if _, err := store.db.Exec(query, ids["checkmate"]); err == nil { + t.Errorf("games_concession_check accepted a %s", name) + } + } +} + func TestWriteForDeletedGameIsDroppedWithoutDegrading(t *testing.T) { store := openStore(t, pgtest.DSN(t)) missing := make(chan string, 4) diff --git a/internal/server/webserver/chess-client-web/app.js b/internal/server/webserver/chess-client-web/app.js index aacf63d..f867426 100644 --- a/internal/server/webserver/chess-client-web/app.js +++ b/internal/server/webserver/chess-client-web/app.js @@ -971,6 +971,9 @@ async function undoMoves() { console.log('No moves to undo'); return; } + // A resignation or agreed draw stays on record when play continues. + const conceded = isGameOver(gameState.state) && + (gameState.termination === 'resignation' || gameState.termination === 'agreement'); try { const response = await authFetch(`${gameState.apiUrl}/api/games/${gameState.gameId}/undo`, { @@ -994,6 +997,9 @@ async function undoMoves() { updateGameDisplay(game); if (game.state === 'stuck') { flashErrorMessage('Engine error — Undo to recover or start a new game'); + } else if (conceded && game.concession) { + const what = game.concession.termination === 'agreement' ? 'agreed draw' : 'resignation'; + flashMessage(`Play continues; the ${what} stays on record`, 'info', 3000); } } catch (error) { if (error.message === 'Failed to fetch') { @@ -1264,7 +1270,7 @@ function handleApiError(action, error, response = null) { break; case 403: serverStatus = 'healthy'; - if (action === 'move' || action === 'trigger computer move') { + if (action === 'move' || action === 'trigger computer move' || action === 'undo') { statusMessage = 'Slot Claimed'; } else { statusMessage = 'Not Authorized'; diff --git a/test/README.md b/test/README.md index 9451a83..0f40962 100755 --- a/test/README.md +++ b/test/README.md @@ -82,7 +82,7 @@ curl -X POST http://localhost:8080/api/games \ | Long-Polling | `test-longpoll.sh` | Real-time updates, wait behavior, timeouts | | End States | `test-endstate.sh` | Checkmate, stalemate, terminal custom positions | | Replay | `test-replay.sh` | SAN history, PGN export, ETag/304, promotion, FEN validation, cursor-paged listing | -| Draws & Resignation | `test-draw.sh` | Automatic draws, offers (human and computer), resignation and authorization, long-poll wake-up, hand-deleted game row | +| Draws & Resignation | `test-draw.sh` | Automatic draws, offers (human and computer), resignation and authorization, long-poll wake-up, concessions and claim control, server version, hand-deleted game row | | Test Server | `run-test-server.sh` | Pre-populated test environment | ## 1. API Functionality Tests (`test-api.sh`) @@ -195,7 +195,7 @@ the dev-mode rate limit. Plays short human-vs-human lines, switching a side to the computer where the computer's answer to a draw offer is tested, so the only engine work is two -short evaluations. Each run registers a fresh account and can be repeated +short evaluations. Each run registers two fresh accounts and can be repeated against the same server. ```bash @@ -208,6 +208,11 @@ undo after a draw, resignation (side inference, hot-seat ambiguity, a side claimed by another user), draw offers between humans (standing offer, decline, one offer per move, acceptance waking a long-poll, lapse on the opponent's move), the computer declining early, accepting in a level -position and declining when a queen up, and a game whose row is deleted with +position and declining when a queen up, concessions (an agreed draw final in +a hot-seat game; between two players no takeback, no anonymous undo, no +reconfiguration, a final resignation, unload only by a player; against the +computer the claimant undoes a resignation and history and PGN keep it), an +unknown route answered with `NOT_FOUND`, `/health` reporting the build, and a +game whose row is deleted with psql (needs `CHESS_TEST_DSN` and `psql`; the server unloads the game and stays healthy). diff --git a/test/test-draw.sh b/test/test-draw.sh index b15506a..0ce83c7 100755 --- a/test/test-draw.sh +++ b/test/test-draw.sh @@ -3,7 +3,9 @@ # Draw and resignation test suite: automatic draws (dead material, threefold # repetition, fifty-move rule), draw offers between humans and to the # computer, resignation with slot authorization, undo after a result, -# long-poll wake-up on offers, and a game whose row is deleted by hand. +# long-poll wake-up on offers, concessions (final between humans, on record +# against the computer), claim checks on undo, players, and unload, and a game +# whose row is deleted by hand. # Requires: curl, jq, and a server from test/run-test-server.sh; psql and # CHESS_TEST_DSN for the deleted-row case (skipped without them). @@ -193,7 +195,50 @@ request PUT "/games/$GAME/players" -H 'Content-Type: application/json' \ check "computer a queen up declines" "declined/ongoing" \ "$(post "/games/$GAME/draw" '{"action":"offer"}' >/dev/null; json '.drawOutcome + "/" + .state')" -section "5. A game whose row is deleted by hand" +section "5. Concessions" +GAME=$(new_game 1 1) +play "$GAME" "" e2e4 +post "/games/$GAME/draw" '{"action":"offer","color":"w"}' >/dev/null +post "/games/$GAME/draw" '{"action":"accept","color":"b"}' >/dev/null +check "hot seat: undo after an agreed draw" 400 "$(post "/games/$GAME/undo" '{"count":1}')" +check "error code" "GAME_OVER" "$(json .code)" + +OPPONENT="draw$(date +%s)$RANDOM" +OPPONENT_TOKEN=$(register "$OPPONENT" DrawPass1234) +[ -n "$OPPONENT_TOKEN" ] && [ "$OPPONENT_TOKEN" != null ] && ok "$OPPONENT registered" || bad "register: $(cat "$TMP/body")" +GAME=$(new_game 1 1) +play "$GAME" "$TOKEN" e2e4 # claims white +play "$GAME" "$OPPONENT_TOKEN" e7e5 # claims black +check "two players: no takeback by white" 403 "$(post "/games/$GAME/undo" '{"count":1}' "$TOKEN")" +check "two players: no anonymous undo" 403 "$(post "/games/$GAME/undo" '{"count":1}')" +check "two players: players cannot be changed" 403 "$(request PUT "/games/$GAME/players" \ + -H 'Content-Type: application/json' -H "Authorization: Bearer $TOKEN" \ + -d '{"white":{"type":1},"black":{"type":2,"level":5,"searchTime":100}}')" +check "white resigns" "black wins/resignation" \ + "$(post "/games/$GAME/resign" '{}' "$TOKEN" >/dev/null; json '.state + "/" + .termination')" +check "the resignation stands for black too" 403 "$(post "/games/$GAME/undo" '{"count":1}' "$OPPONENT_TOKEN")" +check "an outsider cannot unload the game" 403 "$(request DELETE "/games/$GAME")" +check "a player can" 204 "$(request DELETE "/games/$GAME" -H "Authorization: Bearer $OPPONENT_TOKEN")" + +GAME=$(new_game 1 2) +play "$GAME" "$TOKEN" e2e4 +post "/games/$GAME/resign" '{}' "$TOKEN" >/dev/null +check "computer game: anonymous undo of a claimed side" 403 "$(post "/games/$GAME/undo" '{"count":1}')" +check "computer game: the claimant undoes the resignation" 200 "$(post "/games/$GAME/undo" '{"count":1}' "$TOKEN")" +check "play continues, the concession is kept" "ongoing/black wins/resignation/1" \ + "$(json '.state + "/" + .concession.result + "/" + .concession.termination + "/" + (.concession.ply | tostring)')" +play "$GAME" "$TOKEN" d2d4 +check "history keeps the concession" "black_wins/resignation/1/none" \ + "$(request GET "/games/$GAME/history" >/dev/null; json '.concession.result + "/" + .concession.termination + "/" + (.concession.ply | tostring) + "/" + (.result // "none")')" +check "PGN notes it" "1. d4 { White resigned at ply 1; play continued. } *" \ + "$(request GET "/games/$GAME/pgn" >/dev/null; movetext)" + +section "6. Server" +check "an unknown route is not a missing game" "404/NOT_FOUND" \ + "$(status=$(post "/games/$GAME/surrender" '{}'); echo "$status/$(json .code)")" +check "health reports the build" "true" "$(curl -s "$BASE_URL/health" | jq '.version | length > 0')" + +section "7. A game whose row is deleted by hand" if [ -z "$CHESS_TEST_DSN" ] || ! command -v psql >/dev/null; then skip "CHESS_TEST_DSN or psql missing; skipped" else