v0.13.1 concession management and record, clients update, version report
test / test (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-09-29 02:09:45 -04:00
1 parent f1ac4cbbe7
commit 6e8d80c788
30 files changed
+704 -81

No files matched your search

+4 -1
View File
@@ -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_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) GO ?= $(GO_DEFAULT)
GOFLAGS := -trimpath 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 # Both binaries are pure Go (pgx has no C dependency), so builds are static and
# cross-compile without a C toolchain. # cross-compile without a C toolchain.
CGO := CGO_ENABLED=0 CGO := CGO_ENABLED=0
+3
View File
@@ -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 - Human vs human, human vs computer, computer vs computer modes
- Resignation, draw offers (the computer answers from its evaluation), and - Resignation, draw offers (the computer answers from its evaluation), and
automatic draws: dead material, threefold repetition, fifty-move rule 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 - Custom FEN position support
- Asynchronous engine move calculation - Asynchronous engine move calculation
- Configurable engine strength and thinking time - Configurable engine strength and thinking time
+6 -1
View File
@@ -26,6 +26,10 @@ const (
gracefulShutdownTimeout = time.Second * 5 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() { func main() {
// Check for CLI database commands // Check for CLI database commands
if len(os.Args) > 1 && os.Args[1] == "db" { if len(os.Args) > 1 && os.Args[1] == "db" {
@@ -184,6 +188,7 @@ func main() {
LogRequests: *logHTTP, LogRequests: *logHTTP,
TrustedProxies: trustedProxies, TrustedProxies: trustedProxies,
ProxyHeader: *proxyHeader, ProxyHeader: *proxyHeader,
Version: version,
}) })
// API Server configuration // API Server configuration
@@ -191,7 +196,7 @@ func main() {
// Start API server in a goroutine // Start API server in a goroutine
go func() { 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("API Listening on: http://%s", apiAddr)
log.Printf("Authentication: Enabled (JWT)") log.Printf("Authentication: Enabled (JWT)")
if *dev { if *dev {
+60 -8
View File
@@ -95,17 +95,23 @@ Authorization: Bearer <token>
### Health Check ### Health Check
`GET /health` `GET /health`
Returns server and storage status. Returns server and storage status, and the build the server runs.
**Response (200):** **Response (200):**
```json ```json
{ {
"status": "healthy", "status": "healthy",
"time": 1699123456, "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: Storage states:
- `"disabled"` - No database configured (`-dsn` or `CHESS_DSN`) - `"disabled"` - No database configured (`-dsn` or `CHESS_DSN`)
- `"ok"` - Database operational with auth enabled - `"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 `result` is omitted while a game is ongoing. Persisted terminal values are
`white_wins`, `black_wins`, `draw`, and `stalemate`. `pgnResult` is the PGN `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), 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 their username when the claim was recorded; it survives later renames and
account deletion, and is omitted for anonymous and computer players. 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` 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 and `Cache-Control: private, no-cache`: send `If-None-Match` to get 304 while
nothing changed. 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 | | `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 | | `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` | | `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 **Automatic draws.** After every move the game is drawn, without a claim, as
on most online servers, when: 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 an ambiguous or computer side, a missing offer, or a game that is not
ongoing. Both return the game. 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 ### Undo Moves
`POST /games/{gameId}/undo` `POST /games/{gameId}/undo`
Reverts moves from history. Undo stays available after a result, including a ```json
resignation or an agreed draw: it rewinds the result and any draw offer. {"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 ### Configure Players
`PUT /games/{gameId}/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 Board
`GET /games/{gameId}/board` `GET /games/{gameId}/board`
@@ -441,7 +490,8 @@ Returns ASCII board visualization.
`DELETE /games/{gameId}` `DELETE /games/{gameId}`
Unloads the live game from memory. Its persisted game and move history remain 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 ## Error Format
```json ```json
@@ -453,7 +503,9 @@ available through the history endpoint. Returns 204 on success.
``` ```
Error codes: 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 - `INVALID_MOVE` - Illegal chess move
- `NOT_HUMAN_TURN` - Wrong player type for turn - `NOT_HUMAN_TURN` - Wrong player type for turn
- `GAME_OVER` - Game already ended - `GAME_OVER` - Game already ended
+8 -2
View File
@@ -237,7 +237,7 @@ Commands encapsulate operations with type, arguments, and optional user ID for a
### Player Configuration ### Player Configuration
Players identified by UUID (authenticated users) or generated IDs (anonymous), configured with type (human/computer), skill level, and search time. 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 ```sql
users ( users (
user_id uuid PRIMARY KEY, user_id uuid PRIMARY KEY,
@@ -271,6 +271,10 @@ games (
-- insufficient_material, threefold_repetition, -- insufficient_material, threefold_repetition,
-- fifty_move_rule, agreement -- fifty_move_rule, agreement
end_time_utc timestamptz, -- set exactly when result is set 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 ( moves (
@@ -286,7 +290,9 @@ moves (
`schema_version` records the applied version; version 2 added `schema_version` records the applied version; version 2 added
`termination`, constrained to be set exactly with `result` and to fit it `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 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. 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 Claims and player IDs deliberately have no foreign key to `users`: history
+10 -3
View File
@@ -119,14 +119,18 @@ chess > computer
``` ```
#### `undo` / `u` #### `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 # Undo last move
chess > undo 3 # Undo last 3 moves chess > undo 3 # Undo last 3 moves
``` ```
#### `show` / `h` #### `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 chess > show
``` ```
@@ -145,7 +149,10 @@ chess > delete <gameId> # Unload specific live game
``` ```
#### `poll` / `p` #### `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 chess > poll
``` ```
+16 -4
View File
@@ -62,13 +62,25 @@ The tables:
| `chess.schema_version` | One row: the applied migration | | `chess.schema_version` | One row: the applied migration |
| `chess.users` | Accounts: lowercase username and email, Argon2id password hash | | `chess.users` | Accounts: lowercase username and email, Argon2id password hash |
| `chess.sessions` | One active session per user; deleting it signs the user out | | `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 | | `chess.moves` | Ply `move_number` 1..n, UCI move, FEN after the move |
`result` is `white_wins`, `black_wins`, `stalemate`, or `draw`, and `result` is `white_wins`, `black_wins`, `stalemate`, or `draw`, and
`termination` says how: `checkmate`, `resignation`, `stalemate`, `termination` says how: `checkmate`, `resignation`, `stalemate`,
`insufficient_material`, `threefold_repetition`, `fifty_move_rule`, or `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 '<game-id>'` stores a game ID for the `:'g'` references below. `\set g '<game-id>'` 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"' 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 `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: as `postgres` acting as the owner role instead, e.g. on Linux:
@@ -304,6 +316,6 @@ Verify any of them:
```sql ```sql
\dt chess.* -- games, moves, schema_version, sessions, users \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 \drds -- chess: search_path and timeouts in database chess
``` ```
+4 -2
View File
@@ -137,12 +137,14 @@ systemctl status chessd
journalctl -u chessd -f # server log journalctl -u chessd -f # server log
sudo chess-db user add -username <name> # accounts; see database.md sudo chess-db user add -username <name> # accounts; see database.md
sudo chess-db verify # check stored games; run after upgrades 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 systemctl list-timers chess-backup.timer # with ENABLE_BACKUP=yes
``` ```
**Upgrade:** build, then rerun `setup.sh` with the new `CHESS_BINARY`. **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: **Rollback:** until you delete it, the previous binary is kept:
+6 -1
View File
@@ -302,10 +302,15 @@ Restore the previous web client files on the host as well.
```sh ```sh
jail# service chessd status 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 -6 -l | grep postgres # no TCP listener
jail# sockstat -4 -c | grep ':8080' # peer = nginx address 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 chess -Atc "SHOW search_path"' # chess
jail# su -m chess -c 'psql -X -d postgres -c "select 1"' # rejected by pg_hba jail# su -m chess -c 'psql -X -d postgres -c "select 1"' # rejected by pg_hba
host# curl -s https://<site>/chess/health # through nginx host# curl -s https://<site>/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.
+2 -1
View File
@@ -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. | | 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`. | | 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. | | 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`. | | 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 ## Phase R1 — Notation Core (server) — done
+9
View File
@@ -43,11 +43,20 @@ type GameResponse struct {
Termination string `json:"termination,omitempty"` Termination string `json:"termination,omitempty"`
DrawOffer string `json:"drawOffer,omitempty"` DrawOffer string `json:"drawOffer,omitempty"`
DrawOutcome string `json:"drawOutcome,omitempty"` DrawOutcome string `json:"drawOutcome,omitempty"`
Concession *Concession `json:"concession,omitempty"`
Moves []string `json:"moves"` Moves []string `json:"moves"`
Players PlayersResponse `json:"players"` Players PlayersResponse `json:"players"`
LastMove *MoveInfo `json:"lastMove,omitempty"` 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 { type ResignRequest struct {
Color string `json:"color,omitempty"` Color string `json:"color,omitempty"`
} }
+32 -2
View File
@@ -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 { func drawReason(termination string) string {
switch termination { switch termination {
case "insufficient_material": case "insufficient_material":
@@ -498,6 +515,7 @@ func undoHandler(s *session.Session, args []string) error {
s.SetLastMoveCount(len(resp.Moves)) s.SetLastMoveCount(len(resp.Moves))
s.SetGameState(resp) s.SetGameState(resp)
display.Println(display.Green, "Undid %d move(s)", count) display.Println(display.Green, "Undid %d move(s)", count)
printConcession(resp)
return nil return nil
} }
@@ -561,6 +579,8 @@ func showBoardHandler(s *session.Session, args []string) error {
} }
fmt.Println() fmt.Println()
} }
printOutcome(game)
printConcession(game)
return nil return nil
} }
@@ -619,10 +639,13 @@ func pollHandler(s *session.Session, args []string) error {
c := s.GetClient().(*api.Client) c := s.GetClient().(*api.Client)
moveCount := s.GetLastMoveCount() moveCount := s.GetLastMoveCount()
previous := s.CurrentGameState
display.Println(display.Cyan, "Long-polling for updates (move count: %d)...", moveCount) display.Println(display.Cyan, "Long-polling for updates (move count: %d)...", moveCount)
display.Println(display.Cyan, "This may take up to 30 seconds") 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) resp, err := c.GetGameWithPoll(gameID, moveCount)
if err != nil { if err != nil {
return err return err
@@ -631,14 +654,21 @@ func pollHandler(s *session.Session, args []string) error {
s.SetLastMoveCount(len(resp.Moves)) s.SetLastMoveCount(len(resp.Moves))
s.SetGameState(resp) s.SetGameState(resp)
if len(resp.Moves) > moveCount { switch {
case len(resp.Moves) > moveCount:
display.Println(display.Green, "Game updated! New moves detected") display.Println(display.Green, "Game updated! New moves detected")
if resp.LastMove != nil { if resp.LastMove != nil {
fmt.Printf("Last move: %s\n", resp.LastMove.Move) 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)") display.Println(display.Yellow, "No updates (timeout)")
} }
printOutcome(resp)
return nil return nil
} }
+16 -2
View File
@@ -41,7 +41,8 @@ type UndoRequest struct {
// GameResponse is the live game. Termination names how a finished game ended; // GameResponse is the live game. Termination names how a finished game ended;
// DrawOffer is the color whose draw offer awaits an answer; DrawOutcome // DrawOffer is the color whose draw offer awaits an answer; DrawOutcome
// reports what a draw request did ("offered", "accepted", "declined") and is // 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 { type GameResponse struct {
GameID string `json:"gameId"` GameID string `json:"gameId"`
FEN string `json:"fen"` FEN string `json:"fen"`
@@ -50,11 +51,22 @@ type GameResponse struct {
Termination string `json:"termination,omitempty"` Termination string `json:"termination,omitempty"`
DrawOffer string `json:"drawOffer,omitempty"` DrawOffer string `json:"drawOffer,omitempty"`
DrawOutcome string `json:"drawOutcome,omitempty"` DrawOutcome string `json:"drawOutcome,omitempty"`
Concession *Concession `json:"concession,omitempty"`
Moves []string `json:"moves"` Moves []string `json:"moves"`
Players PlayersResponse `json:"players"` Players PlayersResponse `json:"players"`
LastMove *MoveInfo `json:"lastMove,omitempty"` 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 { type MoveInfo struct {
Move string `json:"move"` Move string `json:"move"`
PlayerColor string `json:"playerColor"` // "w" or "b" 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 // 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 // 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 { type GameHistoryResponse struct {
GameID string `json:"gameId"` GameID string `json:"gameId"`
InitialFEN string `json:"initialFen"` InitialFEN string `json:"initialFen"`
@@ -82,6 +95,7 @@ type GameHistoryResponse struct {
Termination string `json:"termination,omitempty"` Termination string `json:"termination,omitempty"`
StartTimeUTC time.Time `json:"startTimeUtc"` StartTimeUTC time.Time `json:"startTimeUtc"`
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"` EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
Concession *Concession `json:"concession,omitempty"`
Players PlayersResponse `json:"players"` Players PlayersResponse `json:"players"`
Moves []HistoryMove `json:"moves"` Moves []HistoryMove `json:"moves"`
} }
+1
View File
@@ -15,4 +15,5 @@ const (
ErrUnauthorized = "UNAUTHORIZED" ErrUnauthorized = "UNAUTHORIZED"
ErrConflict = "GAME_CONFLICT" ErrConflict = "GAME_CONFLICT"
ErrStorageUnavailable = "STORAGE_UNAVAILABLE" ErrStorageUnavailable = "STORAGE_UNAVAILABLE"
ErrRouteNotFound = "NOT_FOUND"
) )
+7
View File
@@ -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. // ParseColor accepts "w", "b", "white", or "black"; anything else is zero.
func ParseColor(s string) Color { func ParseColor(s string) Color {
switch s { switch s {
+38 -1
View File
@@ -36,6 +36,15 @@ type Game struct {
drawOffer core.Color drawOffer core.Color
// offerPly[c] is the ply count when color c last offered a draw, or -1. // offerPly[c] is the ply count when color c last offered a draw, or -1.
offerPly map[core.Color]int 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. // View is an immutable copy of the state needed by processors and transports.
@@ -54,6 +63,7 @@ type View struct {
Termination core.Termination Termination core.Termination
DrawOffer core.Color // color whose offer awaits an answer; 0 when none DrawOffer core.Color // color whose offer awaits an answer; 0 when none
OfferPly map[core.Color]int OfferPly map[core.Color]int
Concession *Concession
LastResult *MoveResult LastResult *MoveResult
EndTimeUTC *time.Time EndTimeUTC *time.Time
} }
@@ -125,9 +135,28 @@ func (g *Game) View() View {
copy := *g.endTimeUTC copy := *g.endTimeUTC
view.EndTimeUTC = &copy view.EndTimeUTC = &copy
} }
if g.concession != nil {
copy := *g.concession
view.Concession = &copy
}
return view 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 { func (v View) NextPlayer() *core.Player {
if v.NextTurnColor == core.ColorWhite { if v.NextTurnColor == core.ColorWhite {
return v.WhitePlayer return v.WhitePlayer
@@ -268,6 +297,11 @@ func (g *Game) ClearDrawOffers() {
g.offerPly[core.ColorBlack] = -1 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. // Termination returns how a finished game ended.
func (g *Game) Termination() core.Termination { func (g *Game) Termination() core.Termination {
return g.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 // 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) { func (g *Game) SetOutcome(s core.State, termination core.Termination, at time.Time) {
g.SetStateAt(s, at) g.SetStateAt(s, at)
if s.IsTerminal() { if s.IsTerminal() {
g.termination = termination g.termination = termination
g.drawOffer = 0 g.drawOffer = 0
if termination.IsConcession() && g.concession == nil {
g.concession = &Concession{State: s, Termination: termination, Ply: g.Plies()}
}
} else { } else {
g.termination = core.TermNone g.termination = core.TermNone
} }
+22 -20
View File
@@ -24,12 +24,13 @@ const rateLimitRate = 10 // req/sec
// HTTPHandler handles HTTP requests and routes them to the processor // HTTPHandler handles HTTP requests and routes them to the processor
type HTTPHandler struct { type HTTPHandler struct {
proc *processor.Processor proc *processor.Processor
svc *service.Service svc *service.Service
version string
} }
func NewHTTPHandler(proc *processor.Processor, svc *service.Service) *HTTPHandler { func NewHTTPHandler(proc *processor.Processor, svc *service.Service, version string) *HTTPHandler {
return &HTTPHandler{proc: proc, svc: svc} return &HTTPHandler{proc: proc, svc: svc, version: version}
} }
// Options configures the API application. // 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-Real-IP (nginx: proxy_set_header X-Real-IP $remote_addr). Avoid
// X-Forwarded-For: its first entry is supplied by the client. // X-Forwarded-For: its first entry is supplied by the client.
ProxyHeader string ProxyHeader string
// Version is the build identifier /health reports.
Version string
} }
// appConfig returns the Fiber configuration. Client-IP resolution only honors // 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 { func NewFiberApp(proc *processor.Processor, svc *service.Service, opts Options) *fiber.App {
// Create handler // Create handler
h := NewHTTPHandler(proc, svc) h := NewHTTPHandler(proc, svc, opts.Version)
devMode := opts.DevMode devMode := opts.DevMode
// Initialize Fiber app // 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.Post("/games", OptionalAuth(validateToken), h.CreateGame) // Optional auth for player ID association
api.Get("/games/:gameId/history", h.GetGameHistory) api.Get("/games/:gameId/history", h.GetGameHistory)
api.Get("/games/:gameId/pgn", h.GetGamePGN) 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.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/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/resign", OptionalAuth(validateToken), h.Resign)
api.Post("/games/:gameId/draw", OptionalAuth(validateToken), h.Draw) api.Post("/games/:gameId/draw", OptionalAuth(validateToken), h.Draw)
api.Get("/games/:gameId/board", h.GetBoard) api.Get("/games/:gameId/board", h.GetBoard)
@@ -213,9 +216,12 @@ func customErrorHandler(c *fiber.Ctx, err error) error {
response.Error = e.Message response.Error = e.Message
// Map HTTP status to error codes // 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 { switch code {
case fiber.StatusNotFound: case fiber.StatusNotFound:
response.Code = core.ErrGameNotFound response.Code = core.ErrRouteNotFound
case fiber.StatusBadRequest: case fiber.StatusBadRequest:
response.Code = core.ErrInvalidRequest response.Code = core.ErrInvalidRequest
case fiber.StatusTooManyRequests: case fiber.StatusTooManyRequests:
@@ -237,6 +243,7 @@ func (h *HTTPHandler) Health(c *fiber.Ctx) error {
"status": status, "status": status,
"time": time.Now().Unix(), "time": time.Now().Unix(),
"storage": storageHealth, "storage": storageHealth,
"version": h.version,
}) })
} }
@@ -314,15 +321,12 @@ func (h *HTTPHandler) ConfigurePlayers(c *fiber.Ctx) error {
// Create command and execute // Create command and execute
cmd := processor.NewConfigurePlayersCommand(gameID, req) cmd := processor.NewConfigurePlayersCommand(gameID, req)
cmd.UserID, _ = c.Locals("userID").(string)
resp := h.proc.Execute(cmd) resp := h.proc.Execute(cmd)
// Return appropriate HTTP response // Return appropriate HTTP response
if !resp.Success { if !resp.Success {
statusCode := fiber.StatusBadRequest return c.Status(statusForCode(resp.Error.Code)).JSON(resp.Error)
if resp.Error.Code == core.ErrGameNotFound {
statusCode = fiber.StatusNotFound
}
return c.Status(statusCode).JSON(resp.Error)
} }
return c.JSON(resp.Data) return c.JSON(resp.Data)
@@ -500,15 +504,12 @@ func (h *HTTPHandler) UndoMove(c *fiber.Ctx) error {
// Create command and execute // Create command and execute
cmd := processor.NewUndoMoveCommand(gameID, req) cmd := processor.NewUndoMoveCommand(gameID, req)
cmd.UserID, _ = c.Locals("userID").(string)
resp := h.proc.Execute(cmd) resp := h.proc.Execute(cmd)
// Return appropriate HTTP response // Return appropriate HTTP response
if !resp.Success { if !resp.Success {
statusCode := fiber.StatusBadRequest return c.Status(statusForCode(resp.Error.Code)).JSON(resp.Error)
if resp.Error.Code == core.ErrGameNotFound {
statusCode = fiber.StatusNotFound
}
return c.Status(statusCode).JSON(resp.Error)
} }
return c.JSON(resp.Data) return c.JSON(resp.Data)
@@ -581,11 +582,12 @@ func (h *HTTPHandler) DeleteGame(c *fiber.Ctx) error {
// Create command and execute // Create command and execute
cmd := processor.NewDeleteGameCommand(gameID) cmd := processor.NewDeleteGameCommand(gameID)
cmd.UserID, _ = c.Locals("userID").(string)
resp := h.proc.Execute(cmd) resp := h.proc.Execute(cmd)
// Return appropriate HTTP response // Return appropriate HTTP response
if !resp.Success { 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) return c.SendStatus(fiber.StatusNoContent)
+36 -10
View File
@@ -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 { func (p *Processor) handleConfigurePlayers(cmd Command) ProcessorResponse {
args, ok := cmd.Args.(core.ConfigurePlayersRequest) args, ok := cmd.Args.(core.ConfigurePlayersRequest)
if !ok { if !ok {
@@ -253,7 +254,13 @@ func (p *Processor) handleConfigurePlayers(cmd Command) ProcessorResponse {
blackPlayer := core.NewPlayer(args.Black, core.ColorBlack) blackPlayer := core.NewPlayer(args.Black, core.ColorBlack)
// Update players in service // 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) 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: // handleUndoMove reverts game state. StateStuck is deliberately permitted:
// undo -> StateOngoing is the recovery path for engine failures. Terminal // undo -> StateOngoing is the recovery path for engine failures. Terminal
// states are also permitted so a finished game can be rewound. Any reverted-to // states are also permitted so a finished game can be rewound, except a
// snapshot had legal moves made from it, so resetting to Ongoing is sound // 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. // without re-classification.
func (p *Processor) handleUndoMove(cmd Command) ProcessorResponse { func (p *Processor) handleUndoMove(cmd Command) ProcessorResponse {
g, err := p.svc.GetGameView(cmd.GameID) 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 err = p.svc.UndoMoves(cmd.GameID, args.Count, cmd.UserID); err != nil {
if strings.Contains(err.Error(), "not found") { switch {
return p.errorResponse("game not found", core.ErrGameNotFound) 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) 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 { func (p *Processor) handleDeleteGame(cmd Command) ProcessorResponse {
g, err := p.svc.GetGameView(cmd.GameID) g, err := p.svc.GetGameView(cmd.GameID)
if err != nil { 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) return p.errorResponse("cannot delete game while computer move is in progress", core.ErrInvalidRequest)
} }
if err = p.svc.DeleteGame(cmd.GameID); err != nil { if err = p.svc.UnloadGame(cmd.GameID, cmd.UserID); err != nil {
return p.errorResponse("game not found", core.ErrGameNotFound) 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{ return ProcessorResponse{
@@ -663,6 +682,11 @@ func (p *Processor) buildGameResponse(gameID string, g game.View) core.GameRespo
if g.DrawOffer != 0 { if g.DrawOffer != 0 {
resp.DrawOffer = g.DrawOffer.String() 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 // Include last move if available
if result := g.LastResult; result != nil { if result := g.LastResult; result != nil {
@@ -957,6 +981,8 @@ func (p *Processor) endGameError(err error) ProcessorResponse {
return p.errorResponse(err.Error(), core.ErrConflict) return p.errorResponse(err.Error(), core.ErrConflict)
case errors.Is(err, service.ErrGameChanged): case errors.Is(err, service.ErrGameChanged):
return p.errorResponse("game changed; refresh and retry", core.ErrConflict) 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) return p.errorResponse(fmt.Sprintf("failed to update game: %v", err), core.ErrInternalError)
} }
+27
View File
@@ -7,6 +7,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"strconv" "strconv"
"strings"
"chess/internal/server/chess" "chess/internal/server/chess"
"chess/internal/server/core" "chess/internal/server/core"
@@ -69,6 +70,9 @@ func BuildPGN(record *storage.GameRecord, moves []storage.MoveRecord, ply int) (
if complete { if complete {
result = chess.ResultToken(record.Result) result = chess.ResultToken(record.Result)
comment = Describe(record.Result, record.Termination) comment = Describe(record.Result, record.Termination)
if note := continuedAfter(record, len(moves)); note != "" {
comment = strings.TrimSpace(note + " " + comment)
}
} }
start := record.StartTimeUTC.UTC() start := record.StartTimeUTC.UTC()
tags := []chess.Tag{ tags := []chess.Tag{
@@ -105,6 +109,29 @@ func BuildPGN(record *storage.GameRecord, moves []storage.MoveRecord, ply int) (
}, nil }, 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 // Describe returns a sentence for a stored result and termination, such as
// "Black wins by resignation." or "Draw by threefold repetition."; empty for // "Black wins by resignation." or "Draw by threefold repetition."; empty for
// an unfinished game. // an unfinished game.
+21
View File
@@ -154,3 +154,24 @@ func TestBuildPGN(t *testing.T) {
t.Error("ply beyond the line accepted") 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)
}
}
+89 -4
View File
@@ -94,12 +94,97 @@ func TestResignationClaimsAndPersists(t *testing.T) {
t.Fatalf("history after resignation: %+v", history) t.Fatalf("history after resignation: %+v", history)
} }
// Undo after a result rewinds it (D6), in memory and in storage. // Between two humans a resignation is final: neither the other claimant
if err := svc.UndoMoves(gameID, 1); err != nil { // 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) t.Fatal(err)
} }
if history, _ = svc.GetGameHistory(gameID); history.Result != "" || history.Termination != "" { if err := svc.EndGame(gameID, EndCommit{
t.Fatalf("undo kept the resignation: %+v", history) 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)
} }
} }
+72 -5
View File
@@ -21,8 +21,42 @@ var (
ErrNotHuman = errors.New("that side is played by the computer") ErrNotHuman = errors.New("that side is played by the computer")
ErrNoDrawOffer = errors.New("no draw offer from the opponent") ErrNoDrawOffer = errors.New("no draw offer from the opponent")
ErrOfferLimit = errors.New("one draw offer per move: make a move before offering again") 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 { type MoveCommit struct {
ExpectedFEN string ExpectedFEN string
ExpectedState core.State ExpectedState core.State
@@ -108,18 +142,22 @@ func (s *Service) CreateGame(
return nil return nil
} }
// UpdatePlayers replaces players in an existing game // UpdatePlayers replaces players in an existing game on behalf of actor, who
func (s *Service) UpdatePlayers(gameID string, whitePlayer, blackPlayer *core.Player) error { // 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() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
g, ok := s.games[gameID] g, ok := s.games[gameID]
if !ok { if !ok {
return fmt.Errorf("game not found: %s", gameID) return fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
} }
if g.State() == core.StatePending { if g.State() == core.StatePending {
return errors.New("cannot change players while computer is calculating") return errors.New("cannot change players while computer is calculating")
} }
if err := requireControl(g, actor); err != nil {
return err
}
oldWhite := g.GetPlayer(core.ColorWhite) oldWhite := g.GetPlayer(core.ColorWhite)
oldBlack := g.GetPlayer(core.ColorBlack) oldBlack := g.GetPlayer(core.ColorBlack)
@@ -369,6 +407,7 @@ func (s *Service) EndGame(gameID string, commit EndCommit) error {
result, _ := commit.State.Result() result, _ := commit.State.Result()
end := storage.GameEnd{ end := storage.GameEnd{
GameID: gameID, Result: result, Termination: string(commit.Termination), EndTimeUTC: now, GameID: gameID, Result: result, Termination: string(commit.Termination), EndTimeUTC: now,
Ply: g.Plies(),
} }
if claimUserID != "" { if claimUserID != "" {
end.ClaimColor, end.ClaimedBy = commit.Color.String(), claimUserID end.ClaimColor, end.ClaimedBy = commit.Color.String(), claimUserID
@@ -490,8 +529,11 @@ func offerAllowed(g *game.Game, color core.Color) error {
return nil return nil
} }
// UndoMoves removes the specified number of moves from game history // UndoMoves takes back count moves on behalf of actor. Another user's claim
func (s *Service) UndoMoves(gameID string, count int) error { // 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() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@@ -502,6 +544,12 @@ func (s *Service) UndoMoves(gameID string, count int) error {
if g.State() == core.StatePending { if g.State() == core.StatePending {
return errors.New("cannot undo while computer move is in progress") 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()) originalMoveCount := len(g.Moves())
@@ -525,6 +573,21 @@ func (s *Service) UndoMoves(gameID string, count int) error {
return nil 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 // DeleteGame removes a game from the service
func (s *Service) DeleteGame(gameID string) error { func (s *Service) DeleteGame(gameID string) error {
s.mu.Lock() s.mu.Lock()
@@ -534,6 +597,10 @@ func (s *Service) DeleteGame(gameID string) error {
if !ok { if !ok {
return fmt.Errorf("%w: %s", ErrGameNotFound, gameID) 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 { if g.State() == core.StatePending {
return errors.New("cannot delete game while computer move is in progress") return errors.New("cannot delete game while computer move is in progress")
} }
+3 -3
View File
@@ -70,7 +70,7 @@ func TestUndoClearsDurableTerminalResult(t *testing.T) {
}); err != nil { }); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := svc.UndoMoves(gameID, 1); err != nil { if err := svc.UndoMoves(gameID, 1, ""); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -100,7 +100,7 @@ func TestPlayerReconfigurationPreservesClaimAndPersistsConfiguration(t *testing.
replacementBlack := core.NewPlayer( replacementBlack := core.NewPlayer(
core.PlayerConfig{Type: core.PlayerComputer, Level: 12, SearchTime: 500}, core.ColorBlack, 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) t.Fatal(err)
} }
@@ -118,7 +118,7 @@ func TestPlayerReconfigurationPreservesClaimAndPersistsConfiguration(t *testing.
computerWhite := core.NewPlayer( computerWhite := core.NewPlayer(
core.PlayerConfig{Type: core.PlayerComputer, Level: 8, SearchTime: 300}, core.ColorWhite, 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) t.Fatal(err)
} }
history, err = svc.GetGameHistory(gameID) history, err = svc.GetGameHistory(gameID)
+6
View File
@@ -46,6 +46,12 @@ func (s *Service) GetGameHistory(gameID string) (*core.GameHistoryResponse, erro
Players: playersResponse(*record), Players: playersResponse(*record),
Moves: make([]core.HistoryMove, 0, len(moves)), 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) san, err := replay.Notate(record.InitialFEN, moves)
if err != nil { if err != nil {
slog.Warn("stored game has moves without notation", "game_id", record.GameID, "error", err) slog.Warn("stored game has moves without notation", "game_id", record.GameID, "error", err)
+37 -4
View File
@@ -16,7 +16,8 @@ const gameSelectColumns = `
g.game_id, g.initial_fen, 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.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.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 // claimantName snapshots a claimant's username inside the writing
// transaction; NULL when the slot is unclaimed or the account is gone. // transaction; NULL when the slot is unclaimed or the account is gone.
@@ -114,6 +115,9 @@ type GameEnd struct {
EndTimeUTC time.Time EndTimeUTC time.Time
ClaimColor string ClaimColor string
ClaimedBy 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 // 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 == "") { if (end.ClaimColor == "") != (end.ClaimedBy == "") {
return errors.New("claim color and claimant must be provided together") 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 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, if err := updateGameEnd(ctx, tx, end.GameID, end.ClaimColor, end.ClaimedBy,
end.Result, end.Termination, &ended) 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 // RewindGame atomically removes undone moves and clears a previously terminal
// result so replay readers never observe an ongoing line with a stale outcome. // 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 { func (s *Store) RewindGame(gameID string, afterMoveNumber int) error {
if gameID == "" || afterMoveNumber < 0 { if gameID == "" || afterMoveNumber < 0 {
return errors.New("game ID and a non-negative move number are required") 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. // scanGame scans gameSelectColumns followed by any extra destinations.
func scanGame(scanner rowScanner, record *GameRecord, extra ...any) error { func scanGame(scanner rowScanner, record *GameRecord, extra ...any) error {
var whiteClaimed, whiteName, blackClaimed, blackName, result, termination sql.NullString 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{ dest := []any{
&record.GameID, &record.InitialFEN, &record.GameID, &record.InitialFEN,
&record.WhitePlayerID, &record.WhiteType, &record.WhiteLevel, &record.WhiteSearchTime, &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, &record.BlackPlayerID, &record.BlackType, &record.BlackLevel, &record.BlackSearchTime,
&blackClaimed, &blackName, &blackClaimed, &blackName,
&result, &termination, &record.StartTimeUTC, &endTime, &result, &termination, &record.StartTimeUTC, &endTime,
&concessionResult, &concessionTermination, &concessionPly, &concessionTime,
} }
if err := scanner.Scan(append(dest, extra...)...); err != nil { if err := scanner.Scan(append(dest, extra...)...); err != nil {
return err return err
@@ -539,6 +563,10 @@ func scanGame(scanner rowScanner, record *GameRecord, extra ...any) error {
record.Termination = termination.String record.Termination = termination.String
record.StartTimeUTC = record.StartTimeUTC.UTC() record.StartTimeUTC = record.StartTimeUTC.UTC()
record.EndTimeUTC = utcPointer(endTime) record.EndTimeUTC = utcPointer(endTime)
record.ConcessionResult = concessionResult.String
record.ConcessionTermination = concessionTermination.String
record.ConcessionPly = int(concessionPly.Int64)
record.ConcessionTimeUTC = utcPointer(concessionTime)
return nil return nil
} }
@@ -589,6 +617,11 @@ func isValidResult(result string) bool {
} }
// validTermination mirrors the games_termination_check constraint. // 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 { func validTermination(result, termination string) bool {
switch result { switch result {
case "white_wins", "black_wins": case "white_wins", "black_wins":
+32 -1
View File
@@ -41,6 +41,12 @@ type GameRecord struct {
Termination string // how Result was reached; empty while unfinished Termination string // how Result was reached; empty while unfinished
StartTimeUTC time.Time StartTimeUTC time.Time
EndTimeUTC *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 // 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 // schemaVersion is the newest schema this binary understands. Migrations are
// applied in order inside one transaction; PostgreSQL DDL is transactional, so // applied in order inside one transaction; PostgreSQL DDL is transactional, so
// a failed upgrade leaves the previous version intact. // 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 // migrations[v-1] upgrades the schema from version v-1 to v. Never edit a
// released migration; append a new one. // released migration; append a new one.
@@ -182,6 +188,31 @@ ALTER TABLE games
OR (result = 'draw' AND termination IN OR (result = 'draw' AND termination IN
('insufficient_material', 'threefold_repetition', 'fifty_move_rule', 'agreement'))); ('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. // ownedTables lists every table created by migrations, children first.
+75
View File
@@ -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) { func TestWriteForDeletedGameIsDroppedWithoutDegrading(t *testing.T) {
store := openStore(t, pgtest.DSN(t)) store := openStore(t, pgtest.DSN(t))
missing := make(chan string, 4) missing := make(chan string, 4)
@@ -971,6 +971,9 @@ async function undoMoves() {
console.log('No moves to undo'); console.log('No moves to undo');
return; return;
} }
// A resignation or agreed draw stays on record when play continues.
const conceded = isGameOver(gameState.state) &&
(gameState.termination === 'resignation' || gameState.termination === 'agreement');
try { try {
const response = await authFetch(`${gameState.apiUrl}/api/games/${gameState.gameId}/undo`, { const response = await authFetch(`${gameState.apiUrl}/api/games/${gameState.gameId}/undo`, {
@@ -994,6 +997,9 @@ async function undoMoves() {
updateGameDisplay(game); updateGameDisplay(game);
if (game.state === 'stuck') { if (game.state === 'stuck') {
flashErrorMessage('Engine error — Undo to recover or start a new game'); 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) { } catch (error) {
if (error.message === 'Failed to fetch') { if (error.message === 'Failed to fetch') {
@@ -1264,7 +1270,7 @@ function handleApiError(action, error, response = null) {
break; break;
case 403: case 403:
serverStatus = 'healthy'; serverStatus = 'healthy';
if (action === 'move' || action === 'trigger computer move') { if (action === 'move' || action === 'trigger computer move' || action === 'undo') {
statusMessage = 'Slot Claimed'; statusMessage = 'Slot Claimed';
} else { } else {
statusMessage = 'Not Authorized'; statusMessage = 'Not Authorized';
+8 -3
View File
@@ -82,7 +82,7 @@ curl -X POST http://localhost:8080/api/games \
| Long-Polling | `test-longpoll.sh` | Real-time updates, wait behavior, timeouts | | Long-Polling | `test-longpoll.sh` | Real-time updates, wait behavior, timeouts |
| End States | `test-endstate.sh` | Checkmate, stalemate, terminal custom positions | | 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 | | 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 | | Test Server | `run-test-server.sh` | Pre-populated test environment |
## 1. API Functionality Tests (`test-api.sh`) ## 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 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 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. against the same server.
```bash ```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, claimed by another user), draw offers between humans (standing offer,
decline, one offer per move, acceptance waking a long-poll, lapse on the decline, one offer per move, acceptance waking a long-poll, lapse on the
opponent's move), the computer declining early, accepting in a level 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 psql (needs `CHESS_TEST_DSN` and `psql`; the server unloads the game and
stays healthy). stays healthy).
+47 -2
View File
@@ -3,7 +3,9 @@
# Draw and resignation test suite: automatic draws (dead material, threefold # Draw and resignation test suite: automatic draws (dead material, threefold
# repetition, fifty-move rule), draw offers between humans and to the # repetition, fifty-move rule), draw offers between humans and to the
# computer, resignation with slot authorization, undo after a result, # 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 # 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). # 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" \ check "computer a queen up declines" "declined/ongoing" \
"$(post "/games/$GAME/draw" '{"action":"offer"}' >/dev/null; json '.drawOutcome + "/" + .state')" "$(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 if [ -z "$CHESS_TEST_DSN" ] || ! command -v psql >/dev/null; then
skip "CHESS_TEST_DSN or psql missing; skipped" skip "CHESS_TEST_DSN or psql missing; skipped"
else else