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 ?= $(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
+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
- 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
+6 -1
View File
@@ -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 {
+60 -8
View File
@@ -95,17 +95,23 @@ Authorization: Bearer <token>
### 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
+8 -2
View File
@@ -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
+10 -3
View File
@@ -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 <gameId> # 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
```
+16 -4
View File
@@ -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 '<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"'
```
`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
```
+4 -2
View File
@@ -137,12 +137,14 @@ systemctl status chessd
journalctl -u chessd -f # server log
sudo chess-db user add -username <name> # accounts; see database.md
sudo chess-db verify # check stored games; run after upgrades
curl -s http://127.0.0.1:8080/health # "storage":"ok"
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:
+6 -1
View File
@@ -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://<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. |
| 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
+9
View File
@@ -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"`
}
+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 {
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
}
+16 -2
View File
@@ -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"`
}
+1
View File
@@ -15,4 +15,5 @@ const (
ErrUnauthorized = "UNAUTHORIZED"
ErrConflict = "GAME_CONFLICT"
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.
func ParseColor(s string) Color {
switch s {
+38 -1
View File
@@ -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 = &copy
}
if g.concession != nil {
copy := *g.concession
view.Concession = &copy
}
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
}
+22 -20
View File
@@ -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)
+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 {
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)
}
+27
View File
@@ -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.
+21
View File
@@ -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)
}
}
+89 -4
View File
@@ -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)
}
}
+72 -5
View File
@@ -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")
}
+3 -3
View File
@@ -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)
+6
View File
@@ -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)
+37 -4
View File
@@ -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":
+32 -1
View File
@@ -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.
+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) {
store := openStore(t, pgtest.DSN(t))
missing := make(chan string, 4)
@@ -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';
+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 |
| 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).
+47 -2
View File
@@ -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