v0.13.1 concession management and record, clients update, version report
test / test (push) Canceled after 0s
test / test (push) Canceled after 0s
This commit is contained in:
1 parent
f1ac4cbbe7
commit
6e8d80c788
30 files changed
+702
-79
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
```
|
```
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,4 +15,5 @@ const (
|
|||||||
ErrUnauthorized = "UNAUTHORIZED"
|
ErrUnauthorized = "UNAUTHORIZED"
|
||||||
ErrConflict = "GAME_CONFLICT"
|
ErrConflict = "GAME_CONFLICT"
|
||||||
ErrStorageUnavailable = "STORAGE_UNAVAILABLE"
|
ErrStorageUnavailable = "STORAGE_UNAVAILABLE"
|
||||||
|
ErrRouteNotFound = "NOT_FOUND"
|
||||||
)
|
)
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 = ©
|
view.EndTimeUTC = ©
|
||||||
}
|
}
|
||||||
|
if g.concession != nil {
|
||||||
|
copy := *g.concession
|
||||||
|
view.Concession = ©
|
||||||
|
}
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,10 +26,11 @@ const rateLimitRate = 10 // req/sec
|
|||||||
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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user