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

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

No files matched your search

+3 -1
View File
@@ -30,7 +30,9 @@ jobs:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with: with:
go-version: '1.26.x' go-version: '1.27.x'
- name: Install Stockfish for the rules-core cross-check
run: sudo apt-get update -qq && sudo apt-get install -y -qq stockfish
- name: gofmt - name: gofmt
run: test -z "$(gofmt -l .)" || { gofmt -l .; exit 1; } run: test -z "$(gofmt -l .)" || { gofmt -l .; exit 1; }
- run: go vet ./... - run: go vet ./...
+15
View File
@@ -148,6 +148,18 @@ test-db:
test-longpoll: test-longpoll:
test/test-longpoll.sh test/test-longpoll.sh
.PHONY: test-endstate
test-endstate:
test/test-endstate.sh
.PHONY: test-replay
test-replay:
test/test-replay.sh
.PHONY: test-draw
test-draw:
test/test-draw.sh
# Database operations # Database operations
.PHONY: db-init .PHONY: db-init
db-init: server db-init: server
@@ -229,6 +241,9 @@ help:
@echo " make test-api Run API tests" @echo " make test-api Run API tests"
@echo " make test-db Run database tests" @echo " make test-db Run database tests"
@echo " make test-longpoll Run long-poll tests" @echo " make test-longpoll Run long-poll tests"
@echo " make test-endstate Run end-state tests"
@echo " make test-replay Run replay (SAN, PGN, listing) tests"
@echo " make test-draw Run draw and resignation tests"
@echo "" @echo ""
@echo "Database targets (CHESS_DSN=$(CHESS_DSN)):" @echo "Database targets (CHESS_DSN=$(CHESS_DSN)):"
@echo " make db-init Create or migrate the schema" @echo " make db-init Create or migrate the schema"
+13 -4
View File
@@ -3,7 +3,7 @@
<td> <td>
<h1>♚♛♜♝♞</h1> <h1>♚♛♜♝♞</h1>
<p> <p>
<a href="https://golang.org"><img src="https://img.shields.io/badge/Go-1.26-00ADD8?style=flat&logo=go" alt="Go 1.26"></a> <a href="https://golang.org"><img src="https://img.shields.io/badge/Go-1.27-00ADD8?style=flat&logo=go" alt="Go 1.27"></a>
<a href="https://opensource.org/licenses/BSD-3-Clause"><img src="https://img.shields.io/badge/License-BSD_3--Clause-blue.svg" alt="License BSD-3"></a> <a href="https://opensource.org/licenses/BSD-3-Clause"><img src="https://img.shields.io/badge/License-BSD_3--Clause-blue.svg" alt="License BSD-3"></a>
</p> </p>
</td> </td>
@@ -20,24 +20,31 @@ Go backend server providing a RESTful API for chess gameplay with user authentic
- User registration and JWT authentication - User registration and JWT authentication
- Stockfish engine integration for validation - Stockfish engine integration for validation
- 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
automatic draws: dead material, threefold repetition, fifty-move rule
- 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
- PostgreSQL 18 persistence with ordered async writes for games - PostgreSQL 18 persistence with ordered async writes for games
- Durable game results, player claims and names, ordered move history, and replay API - Durable game results, player claims and names, ordered move history, and replay API
- Dependency-free rules core: SAN history, PGN export, FEN validation, and a
shadow check of every engine-validated move
- Games without a registered player are purged 24 hours after their last activity - Games without a registered player are purged 24 hours after their last activity
- Authenticated stored-game listing with bounded pagination - Authenticated stored-game listing with cursor pagination and filters
- Configurable structured debug logs for persistence, cleanup, and engine work - Configurable structured debug logs for persistence, cleanup, and engine work
- User management with secure Argon2id password storage and scoped JWTs - User management with secure Argon2id password storage and scoped JWTs
- Static, CGO-free binaries (cross-compiles for FreeBSD) - Static, CGO-free binaries (cross-compiles for FreeBSD)
- Scripted deployment: FreeBSD jail (rc.d) or Linux (sandboxed systemd unit)
- Optional hourly integrity sweep of stored games and accounts
- PID file management for singleton enforcement - PID file management for singleton enforcement
- Database CLI for storage and user administration - Database CLI for storage and user administration
## Requirements ## Requirements
- Go 1.26+ - Go 1.27+
- Stockfish chess engine (`stockfish` in PATH) - Stockfish chess engine (`stockfish` in PATH)
- PostgreSQL 18 (for persistence and accounts; see [Deployment](./doc/deployment.md)) - PostgreSQL 17+, 18 recommended (for persistence and accounts; see
[FreeBSD](./doc/deployment.md) or [Linux](./doc/deployment-linux.md) deployment)
### Installation ### Installation
```bash ```bash
@@ -174,6 +181,8 @@ Access the UI at `http://localhost:9090` when server is running with `-serve` fl
- [API Reference](./doc/api.md) - Endpoint specifications including auth - [API Reference](./doc/api.md) - Endpoint specifications including auth
- [Deployment](./doc/deployment.md) - FreeBSD jail, PostgreSQL 18, `chessd` service, nginx, web clients - [Deployment](./doc/deployment.md) - FreeBSD jail, PostgreSQL 18, `chessd` service, nginx, web clients
- [Linux Deployment](./doc/deployment-linux.md) - Debian/Ubuntu/Arch, systemd service, development setup
- [Database Operations](./doc/database.md) - layout, psql as `postgres`, games, users, integrity sweep, starting fresh
- [Architecture](./doc/architecture.md) - System design with auth layer - [Architecture](./doc/architecture.md) - System design with auth layer
- [Development](./doc/development.md) - Build, test, and user management - [Development](./doc/development.md) - Build, test, and user management
- [Client Guide](./doc/client.md) - Interactive debugging client - [Client Guide](./doc/client.md) - Interactive debugging client
+100 -1
View File
@@ -11,6 +11,7 @@ import (
"text/tabwriter" "text/tabwriter"
"time" "time"
"chess/internal/server/replay"
"chess/internal/server/storage" "chess/internal/server/storage"
"github.com/google/uuid" "github.com/google/uuid"
@@ -23,7 +24,7 @@ const dsnEnv = "CHESS_DSN"
// Run is the entry point for the CLI mini-app // Run is the entry point for the CLI mini-app
func Run(args []string) error { func Run(args []string) error {
if len(args) == 0 { if len(args) == 0 {
return fmt.Errorf("subcommand required: init, delete, query, user") return fmt.Errorf("subcommand required: init, delete, query, pgn, verify, user")
} }
switch args[0] { switch args[0] {
@@ -33,6 +34,10 @@ func Run(args []string) error {
return runDelete(args[1:]) return runDelete(args[1:])
case "query": case "query":
return runQuery(args[1:]) return runQuery(args[1:])
case "pgn":
return runPGN(args[1:])
case "verify":
return runVerify(args[1:])
case "user": case "user":
if len(args) < 2 { if len(args) < 2 {
return fmt.Errorf("user subcommand required: add, delete, set-password, set-hash, set-email, set-username, list") return fmt.Errorf("user subcommand required: add, delete, set-password, set-hash, set-email, set-username, list")
@@ -175,6 +180,100 @@ func runQuery(args []string) error {
return nil return nil
} }
// runPGN prints a stored game as PGN. The game may be named by the 8-digit
// prefix that `db query` prints.
func runPGN(args []string) error {
fs, dsn := newFlagSet("pgn")
gameID := fs.String("gameId", "", "Game ID or unique prefix of at least 8 hex digits (required)")
ply := fs.Int("ply", -1, "Export only the first N plies (default: all)")
if err := fs.Parse(args); err != nil {
return err
}
if *gameID == "" {
return errors.New("-gameId is required")
}
store, err := openStore(*dsn, true)
if err != nil {
return err
}
defer store.Close()
id, err := resolveGameID(store, *gameID)
if err != nil {
return err
}
record, moves, err := store.GetGameHistory(id)
if err != nil {
return fmt.Errorf("load game %s: %w", id, err)
}
pgn, err := replay.BuildPGN(record, moves, *ply)
if err != nil {
return err
}
fmt.Print(pgn.Text)
return nil
}
// runVerify checks stored games against the rules (see replay.Verify) and
// fails when any problem is found.
func runVerify(args []string) error {
fs, dsn := newFlagSet("verify")
gameID := fs.String("gameId", "", "Verify one game (ID or unique prefix); default all")
if err := fs.Parse(args); err != nil {
return err
}
store, err := openStore(*dsn, true)
if err != nil {
return err
}
defer store.Close()
var ids []string
if *gameID != "" {
id, err := resolveGameID(store, *gameID)
if err != nil {
return err
}
ids = []string{id}
} else {
games, err := store.QueryGames("", "")
if err != nil {
return fmt.Errorf("list games: %w", err)
}
for _, g := range games {
ids = append(ids, g.GameID)
}
}
plies, problems := 0, 0
for _, id := range ids {
record, moves, err := store.GetGameHistory(id)
if err != nil {
return fmt.Errorf("load game %s: %w", id, err)
}
plies += len(moves)
for _, problem := range replay.Verify(record, moves) {
fmt.Printf("%s: %s\n", id, problem)
problems++
}
}
fmt.Printf("Verified %d game(s), %d plies: %d problem(s)\n", len(ids), plies, problems)
if problems > 0 {
return fmt.Errorf("%d problem(s) found", problems)
}
return nil
}
func resolveGameID(store *storage.Store, prefix string) (string, error) {
id, err := store.ResolveGameID(prefix)
if errors.Is(err, sql.ErrNoRows) {
return "", fmt.Errorf("no game matches %q", prefix)
}
return id, err
}
func formatStoredPlayer(playerID, claimedBy string, playerType int) string { func formatStoredPlayer(playerID, claimedBy string, playerType int) string {
value := fmt.Sprintf("%s (T%d)", abbreviateID(playerID), playerType) value := fmt.Sprintf("%s (T%d)", abbreviateID(playerID), playerType)
if claimedBy != "" && claimedBy != playerID { if claimedBy != "" && claimedBy != playerID {
+9
View File
@@ -52,6 +52,7 @@ func main() {
anonTTL = flag.Duration("anonymous-game-ttl", service.AnonymousGameTTL, "How long games without a registered player survive after their last activity, in memory and in the database (0 keeps them)") anonTTL = flag.Duration("anonymous-game-ttl", service.AnonymousGameTTL, "How long games without a registered player survive after their last activity, in memory and in the database (0 keeps them)")
maxUsers = flag.Int("max-users", service.DefaultMaxUsers, "Accounts at which public registration closes (0 = no limit; CLI-created accounts are not limited)") maxUsers = flag.Int("max-users", service.DefaultMaxUsers, "Accounts at which public registration closes (0 = no limit; CLI-created accounts are not limited)")
jwtFile = flag.String("jwt-secret-file", "", "File holding a stable JWT signing key, mode 0600, at least 32 bytes (default $CHESS_JWT_SECRET_FILE)") jwtFile = flag.String("jwt-secret-file", "", "File holding a stable JWT signing key, mode 0600, at least 32 bytes (default $CHESS_JWT_SECRET_FILE)")
dbCleanup = flag.String("db-cleanup", "off", "Hourly integrity sweep of stored games and accounts: off, report (log only), or delete")
// Web UI server flags // Web UI server flags
serve = flag.Bool("serve", false, "Enable web UI server") serve = flag.Bool("serve", false, "Enable web UI server")
@@ -151,6 +152,14 @@ func main() {
svc.SetFinishedGameTTL(*finishedTTL) svc.SetFinishedGameTTL(*finishedTTL)
svc.SetAnonymousGameTTL(*anonTTL) svc.SetAnonymousGameTTL(*anonTTL)
svc.SetMaxUsers(*maxUsers) svc.SetMaxUsers(*maxUsers)
integrity, err := service.ParseIntegrityMode(*dbCleanup)
if err != nil {
log.Fatalf("Invalid -db-cleanup: %v", err)
}
if integrity != service.IntegrityOff && store == nil {
log.Fatalf("-db-cleanup %s requires a database (-dsn)", integrity)
}
svc.SetIntegrityMode(integrity)
// Start cleanup job for expired users/sessions // Start cleanup job for expired users/sessions
cleanupCtx, cleanupCancel := context.WithCancel(context.Background()) cleanupCtx, cleanupCancel := context.WithCancel(context.Background())
+27 -7
View File
@@ -1,7 +1,12 @@
# Deployment Scripts # Deployment Scripts
Run in this order; each script's header documents its inputs. The full guide is Each script's header documents its inputs. Day-to-day database work (psql,
[doc/deployment.md](../doc/deployment.md). accounts, deleting games, starting fresh) is in
[doc/database.md](../doc/database.md).
## FreeBSD jail
Full guide: [doc/deployment.md](../doc/deployment.md). Run in this order:
| Order | Where | Step | Purpose | | Order | Where | Step | Purpose |
|---|---|---|---| |---|---|---|---|
@@ -9,13 +14,28 @@ Run in this order; each script's header documents its inputs. The full guide is
| 2 | Jail, once | `pkg install postgresql18-server postgresql18-client stockfish` | Packages | | 2 | Jail, once | `pkg install postgresql18-server postgresql18-client stockfish` | Packages |
| 3 | Build machine | `make server-freebsd` | Static `bin/chess-server-freebsd-amd64` | | 3 | Build machine | `make server-freebsd` | Static `bin/chess-server-freebsd-amd64` |
| 4 | Jail (root) | `freebsd/setup-jail.sh` | PostgreSQL setup, database, `chessd` service, optional log rotation and backups; rerun to upgrade | | 4 | Jail (root) | `freebsd/setup-jail.sh` | PostgreSQL setup, database, `chessd` service, optional log rotation and backups; rerun to upgrade |
| 5 | Bastille host | `freebsd/nginx-chess.conf` | nginx locations for `/chess/api/` and `/chess/health` | | 5 | Bastille host | `nginx-chess.conf` | nginx locations for `/chess/api/`, `/chess/health`, and the `/chess` short link |
| 6 | Jail, once | `chess-server db user add -username <name>` | First account (see the guide) |
Supporting files, installed by `setup-jail.sh`: ## Linux with systemd
Full guide: [doc/deployment-linux.md](../doc/deployment-linux.md) (Debian,
Ubuntu, Arch; PostgreSQL 17+ already running).
| Order | Where | Step | Purpose |
|---|---|---|---|
| 1 | Host | `make server` | Static `bin/chess-server` |
| 2 | Host (root) | `CHESS_BINARY=bin/chess-server linux/setup.sh` | Account, database, `chessd` systemd service, key, optional backup timer; rerun to upgrade |
| 3 | Host (root) | `chess-db user add -username <name>` | First account |
| 4 | nginx | `nginx-chess.conf` with `127.0.0.1:8080` | Optional reverse proxy |
## Files
- `postgresql/setup.sql` — role, database, and schema (owner or split mode) - `postgresql/setup.sql` — role, database, and schema (owner or split mode)
- `postgresql/chess-backup.sh` — nightly `pg_dump` with 14-day retention
- `nginx-chess.conf` — nginx locations, with placeholders
- `freebsd/rc.d/chessd` — `daemon(8)` service script - `freebsd/rc.d/chessd` — `daemon(8)` service script
- `freebsd/chess-backup.sh` — nightly `pg_dump` with 14-day retention - `linux/chessd.service` — sandboxed systemd unit; `linux/chess-backup.{service,timer}`
Keep deployment-specific values (addresses, host names) in `rc.conf` and the Keep deployment-specific values (addresses, host names) in `rc.conf`,
host nginx configuration, not in this repository. `/etc/chessd/chessd.env`, and the nginx configuration, not in this repository.
-23
View File
@@ -1,23 +0,0 @@
#!/bin/sh
# Nightly logical backup of the chess database. setup-jail.sh installs this as
# /usr/local/sbin/chess-backup and schedules it as the postgres user through
# /usr/local/etc/cron.d/chess-backup.
#
# CHESS_BACKUP_DIR destination (default: /var/db/postgres/backups)
# CHESS_BACKUP_KEEP days of dumps to keep (default: 14)
#
# Restore into a freshly provisioned database with:
# su -m postgres -c 'pg_restore --no-owner --role=chess -d chess <dump>'
# (use --role=chess_owner for the split-privilege setup).
set -eu
PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin
dir=${CHESS_BACKUP_DIR:-/var/db/postgres/backups}
keep=${CHESS_BACKUP_KEEP:-14}
umask 077
mkdir -p "$dir"
target="$dir/chess-$(date -u +%Y%m%dT%H%M%SZ).dump"
pg_dump --format=custom --dbname=chess --file="$target.partial"
mv "$target.partial" "$target"
find "$dir" -name 'chess-*.dump' -type f -mtime "+$keep" -delete
+1 -1
View File
@@ -9,7 +9,7 @@
# restart stops every service in the jail, including a running chess-server. # restart stops every service in the jail, including a running chess-server.
# #
# After setup-jail.sh has run inside the jail, add the location block from # After setup-jail.sh has run inside the jail, add the location block from
# deploy/freebsd/nginx-chess.conf to the host nginx configuration, then # deploy/nginx-chess.conf to the host nginx configuration, then
# nginx -t && service nginx reload # nginx -t && service nginx reload
set -eu set -eu
PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin
+12 -4
View File
@@ -78,7 +78,7 @@ yes_no() {
[ "$(id -u)" -eq 0 ] || die "run as root" [ "$(id -u)" -eq 0 ] || die "run as root"
[ "$(uname -s)" = FreeBSD ] || die "this script targets FreeBSD" [ "$(uname -s)" = FreeBSD ] || die "this script targets FreeBSD"
[ -f "$CHESS_BINARY" ] || die "CHESS_BINARY not found: $CHESS_BINARY" [ -f "$CHESS_BINARY" ] || die "CHESS_BINARY not found: $CHESS_BINARY"
for f in "$repo/deploy/postgresql/setup.sql" "$here/rc.d/chessd" "$here/chess-backup.sh"; do for f in "$repo/deploy/postgresql/setup.sql" "$here/rc.d/chessd" "$repo/deploy/postgresql/chess-backup.sh"; do
[ -f "$f" ] || die "missing $f; run from a complete repository copy" [ -f "$f" ] || die "missing $f; run from a complete repository copy"
done done
yes_no SPLIT_PRIVILEGES "$SPLIT_PRIVILEGES" yes_no SPLIT_PRIVILEGES "$SPLIT_PRIVILEGES"
@@ -182,12 +182,20 @@ fi
bindir=$(dirname "$bin") bindir=$(dirname "$bin")
[ -d "$bindir" ] || install -d -o root -g wheel -m 0755 "$bindir" [ -d "$bindir" ] || install -d -o root -g wheel -m 0755 "$bindir"
if [ -f "$bin" ] && ! cmp -s "$CHESS_BINARY" "$bin"; then # Root-owned: the service account cannot replace its own executable file.
if [ -f "$bin" ] && cmp -s "$CHESS_BINARY" "$bin"; then
# Same content, possibly the installed file itself (a rerun with
# CHESS_BINARY pointing at it): install(1) refuses to copy a file onto
# itself, so only normalize ownership and mode.
chown root:wheel "$bin"
chmod 0555 "$bin"
else
if [ -f "$bin" ]; then
log "keeping the previous binary as $bin.prev" log "keeping the previous binary as $bin.prev"
cp -p "$bin" "$bin.prev" cp -p "$bin" "$bin.prev"
fi fi
# Root-owned: the service account cannot replace its own executable file.
install -o root -g wheel -m 0555 "$CHESS_BINARY" "$bin" install -o root -g wheel -m 0555 "$CHESS_BINARY" "$bin"
fi
[ -d "$RC_DIR" ] || install -d "$RC_DIR" [ -d "$RC_DIR" ] || install -d "$RC_DIR"
if [ -f "$RC_DIR/chessd" ] && ! cmp -s "$here/rc.d/chessd" "$RC_DIR/chessd"; then if [ -f "$RC_DIR/chessd" ] && ! cmp -s "$here/rc.d/chessd" "$RC_DIR/chessd"; then
@@ -247,7 +255,7 @@ fi
if [ "$ENABLE_BACKUP" = yes ]; then if [ "$ENABLE_BACKUP" = yes ]; then
# Nightly pg_dump at 03:30 as postgres, keeping 14 days. # Nightly pg_dump at 03:30 as postgres, keeping 14 days.
[ -d "$SBIN_DIR" ] || install -d "$SBIN_DIR" [ -d "$SBIN_DIR" ] || install -d "$SBIN_DIR"
install -o root -g wheel -m 0555 "$here/chess-backup.sh" "$SBIN_DIR/chess-backup" install -o root -g wheel -m 0555 "$repo/deploy/postgresql/chess-backup.sh" "$SBIN_DIR/chess-backup"
[ -d "$BACKUP_DIR" ] || install -d -o postgres -g postgres -m 0700 "$BACKUP_DIR" [ -d "$BACKUP_DIR" ] || install -d -o postgres -g postgres -m 0700 "$BACKUP_DIR"
[ -d "$CRON_DIR" ] || install -d "$CRON_DIR" [ -d "$CRON_DIR" ] || install -d "$CRON_DIR"
printf '%s\n' \ printf '%s\n' \
+17
View File
@@ -0,0 +1,17 @@
# Nightly pg_dump of the chess database; started by chess-backup.timer.
# Installed by deploy/linux/setup.sh when ENABLE_BACKUP=yes. Dumps go to
# backups/ in the postgres home (/var/lib/postgresql on Debian and Ubuntu,
# /var/lib/postgres on Arch) and are kept for 14 days.
[Unit]
Description=Back up the chess database
After=postgresql.service
[Service]
Type=oneshot
User=postgres
Group=postgres
ExecStart=/usr/local/sbin/chess-backup
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=full
+12
View File
@@ -0,0 +1,12 @@
# Runs chess-backup.service nightly; a missed run (machine off) runs at boot.
[Unit]
Description=Nightly chess database backup
[Timer]
OnCalendar=*-*-* 03:30:00
RandomizedDelaySec=15min
Persistent=true
[Install]
WantedBy=timers.target
+61
View File
@@ -0,0 +1,61 @@
# chess-server as a systemd service. Installed by deploy/linux/setup.sh as
# /etc/systemd/system/chessd.service; settings live in /etc/chessd/chessd.env.
# Local changes belong in a drop-in (systemctl edit chessd), which setup.sh
# never overwrites.
[Unit]
Description=Chess API server
Documentation=https://github.com/lixenwraith/chess/blob/main/doc/deployment-linux.md
Wants=network-online.target postgresql.service
After=network-online.target postgresql.service
# PostgreSQL may still be starting at boot; keep retrying instead of giving up.
StartLimitIntervalSec=0
[Service]
Type=exec
User=chess
Group=chess
EnvironmentFile=/etc/chessd/chessd.env
# Stockfish is /usr/games/stockfish on Debian and Ubuntu, /usr/bin elsewhere.
Environment=PATH=/usr/local/bin:/usr/bin:/bin:/usr/games
# ${VAR} stays one argument (the DSN has spaces); $CHESSD_FLAGS splits into
# words and expands to nothing when empty.
ExecStart=/usr/local/bin/chess-server \
-api-host ${CHESSD_HOST} -api-port ${CHESSD_PORT} \
-dsn ${CHESSD_DSN} -jwt-secret-file ${CHESSD_JWT_KEY} \
-trusted-proxies ${CHESSD_TRUSTED_PROXIES} \
$CHESSD_FLAGS
Restart=on-failure
RestartSec=5
# /var/lib/chessd holds the JWT key; the service can read and write nothing
# else. PostgreSQL is reached over its Unix socket under /run.
StateDirectory=chessd
StateDirectoryMode=0700
UMask=0077
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
ProtectProc=invisible
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@privileged
CapabilityBoundingSet=
AmbientCapabilities=
[Install]
WantedBy=multi-user.target
+262
View File
@@ -0,0 +1,262 @@
#!/usr/bin/env bash
# Install or upgrade chess-server as the systemd service `chessd` on a Linux
# host (Debian, Ubuntu, Arch, or another systemd distribution) that already
# runs PostgreSQL 17 or later. Run as root from a checkout:
#
# sudo CHESS_BINARY=bin/chess-server deploy/linux/setup.sh
#
# Prerequisites: PostgreSQL 17+ running and reachable by the postgres account
# over its Unix socket; stockfish; the chess-server binary (`make server`).
#
# Inputs (environment):
# CHESS_BINARY required: chess-server binary to install
# TRUSTED_PROXIES reverse proxy address(es) whose X-Real-IP is trusted,
# e.g. 127.0.0.1 for nginx on this host; stored in
# chessd.env (kept from the existing file when unset)
# CHESSD_HOST listen address for a first install (default 127.0.0.1)
# CHESSD_PORT listen port for a first install (default 8080)
# SPLIT_PRIVILEGES yes|no (default no); see deploy/postgresql/setup.sql
# ENABLE_BACKUP yes|no (default no): nightly pg_dump systemd timer
# PG_HBA yes|no (default no): if the chess role cannot connect,
# add `local chess chess peer` to pg_hba.conf and reload
#
# What it installs:
# account chess (system, no login shell, home /var/lib/chessd)
# /usr/local/bin/chess-server (previous copy kept as .prev)
# /usr/local/sbin/chess-db runs `chess-server db ...` as chess
# /etc/chessd/chessd.env settings, written on first install only
# /var/lib/chessd/jwt.key JWT signing key, generated once
# /etc/systemd/system/chessd.service (+ chess-backup.{service,timer})
#
# Steps (idempotent; rerun to upgrade):
# 1. checks: root, systemd, PostgreSQL version and socket, stockfish
# 2. role, database, and schema from deploy/postgresql/setup.sql when
# neither exists; the chess role must connect over the socket
# 3. stop chessd; install files; key; schema migration
# 4. enable and start chessd; check /health
set -euo pipefail
umask 022
here=$(cd "$(dirname "$0")" && pwd)
repo=$(cd "$here/../.." && pwd)
: "${CHESS_BINARY:?set CHESS_BINARY to the chess-server binary to install}"
TRUSTED_PROXIES=${TRUSTED_PROXIES-__keep__}
CHESSD_HOST=${CHESSD_HOST:-127.0.0.1}
CHESSD_PORT=${CHESSD_PORT:-8080}
SPLIT_PRIVILEGES=${SPLIT_PRIVILEGES:-no}
ENABLE_BACKUP=${ENABLE_BACKUP:-no}
PG_HBA=${PG_HBA:-no}
# System locations and the service manager; overridable only to test the
# script.
BIN_DIR=${BIN_DIR:-/usr/local/bin}
SBIN_DIR=${SBIN_DIR:-/usr/local/sbin}
CONF_DIR=${CONF_DIR:-/etc/chessd}
STATE_DIR=${STATE_DIR:-/var/lib/chessd}
UNIT_DIR=${UNIT_DIR:-/etc/systemd/system}
SYSTEMCTL=${SYSTEMCTL:-systemctl}
user=chess
bin="$BIN_DIR/chess-server"
env_file="$CONF_DIR/chessd.env"
key="$STATE_DIR/jwt.key"
log() { printf '==> %s\n' "$*"; }
warn() { printf 'setup: warning: %s\n' "$*" >&2; }
die() { printf 'setup: %s\n' "$*" >&2; exit 1; }
# pg runs psql as the postgres account over the default socket (or $PGHOST).
pg() { runuser -u postgres -- psql -X -w -A -t -q -v ON_ERROR_STOP=1 "$@"; }
# --- 1. checks ---------------------------------------------------------------
[ "$(id -u)" -eq 0 ] || die "run as root"
for setting in SPLIT_PRIVILEGES ENABLE_BACKUP PG_HBA; do
case "${!setting}" in
yes | no) ;;
*) die "$setting must be yes or no" ;;
esac
done
if [ "$SYSTEMCTL" = systemctl ] && [ ! -d /run/systemd/system ]; then
die "systemd is not running; see doc/deployment-linux.md to run chess-server another way"
fi
for tool in runuser psql useradd; do
command -v "$tool" >/dev/null || die "$tool not found"
done
[ -f "$CHESS_BINARY" ] || die "CHESS_BINARY not found: $CHESS_BINARY"
# A binary for another OS or architecture fails here rather than at start.
case "$("$CHESS_BINARY" db 2>&1 || true)" in
*"subcommand required"*) ;;
*) die "$CHESS_BINARY does not run on this host (wrong OS or architecture?)" ;;
esac
for f in "$repo/deploy/postgresql/setup.sql" "$here/chessd.service" \
"$here/chess-backup.service" "$here/chess-backup.timer" "$repo/deploy/postgresql/chess-backup.sh"; do
[ -f "$f" ] || die "missing $f; run from a complete checkout"
done
if ! command -v stockfish >/dev/null && [ ! -x /usr/games/stockfish ]; then
warn "stockfish not found: install it (Debian/Ubuntu: apt install stockfish; Arch: pacman -S stockfish)"
fi
version=$(pg -d postgres -c 'SHOW server_version_num') ||
die "cannot reach PostgreSQL as postgres over its Unix socket: start it (systemctl start postgresql; on Arch run initdb first, see doc/deployment-linux.md) or set PGHOST"
[ "$version" -ge 170000 ] || die "PostgreSQL 17 or later required (found $version)"
socket=${PGHOST:-$(pg -d postgres -c 'SHOW unix_socket_directories' | cut -d, -f1 | tr -d ' ')}
case "$socket" in
/*) ;;
*) die "no usable Unix socket directory ($socket); set PGHOST to it" ;;
esac
dsn="host=$socket dbname=chess"
log "PostgreSQL $version, socket $socket"
# --- 2. account, role, database ----------------------------------------------
if ! id "$user" >/dev/null 2>&1; then
nologin=$(command -v nologin || echo /usr/sbin/nologin)
log "creating system account $user"
useradd --system --user-group --home-dir "$STATE_DIR" --no-create-home \
--shell "$nologin" --comment "chess-server" "$user"
fi
group=$(id -gn "$user")
role=$(pg -d postgres -c "SELECT 1 FROM pg_roles WHERE rolname = 'chess'")
database=$(pg -d postgres -c "SELECT 1 FROM pg_database WHERE datname = 'chess'")
case "$role$database" in
11) log "role and database chess exist" ;;
"")
log "creating role chess, database chess, and schema chess (split privileges: $SPLIT_PRIVILEGES)"
split=false
[ "$SPLIT_PRIVILEGES" = yes ] && split=true
# Fed on stdin: postgres may not be able to read the checkout.
pg -d postgres -v split="$split" -f - <"$repo/deploy/postgresql/setup.sql"
;;
*) die "only one of role chess and database chess exists; drop it or create the other (doc/database.md)" ;;
esac
chess_can_connect() {
runuser -u "$user" -- psql -X -w -A -t -q -h "$socket" -d chess -c 'SELECT current_user' >/dev/null 2>&1
}
if ! chess_can_connect; then
hba=$(pg -d postgres -c 'SHOW hba_file')
if [ "$PG_HBA" != yes ]; then
die "the chess account cannot connect to database chess over $socket. Add this line to
$hba above the other 'local' lines, then reload PostgreSQL (or rerun with PG_HBA=yes):
local chess chess peer"
fi
saved="$hba.chessd-$(date -u +%Y%m%dT%H%M%SZ)"
cp -p "$hba" "$saved"
awk 'BEGIN { done = 0 }
!done && /^[[:space:]]*local[[:space:]]/ { print "local chess chess peer"; done = 1 }
{ print }
END { if (!done) print "local chess chess peer" }' "$saved" >"$hba"
pg -d postgres -c 'SELECT pg_reload_conf()' >/dev/null
log "pg_hba.conf: added local chess chess peer (previous copy $saved)"
chess_can_connect || die "the chess account still cannot connect; check $hba"
fi
# --- 3. files ----------------------------------------------------------------
if "$SYSTEMCTL" is-active --quiet chessd 2>/dev/null; then
log "stopping chessd"
"$SYSTEMCTL" stop chessd
fi
install -d -m 0755 "$BIN_DIR" "$SBIN_DIR"
if [ -f "$bin" ] && cmp -s "$CHESS_BINARY" "$bin"; then
chown root:root "$bin"
chmod 0755 "$bin"
else
if [ -f "$bin" ]; then
log "keeping the previous binary as $bin.prev"
cp -p "$bin" "$bin.prev"
fi
install -o root -g root -m 0755 "$CHESS_BINARY" "$bin"
fi
cat >"$SBIN_DIR/chess-db.new" <<EOF
#!/bin/sh
# Runs \`chess-server db ...\` as the chess account against the chess database,
# e.g. chess-db user add -username alice. Installed by deploy/linux/setup.sh.
set -a
. "$env_file"
exec runuser -u $user -- env CHESS_DSN="\$CHESSD_DSN" "$bin" db "\$@"
EOF
chmod 0755 "$SBIN_DIR/chess-db.new"
mv "$SBIN_DIR/chess-db.new" "$SBIN_DIR/chess-db"
install -d -o root -g "$group" -m 0750 "$CONF_DIR"
if [ ! -f "$env_file" ]; then
proxies=$TRUSTED_PROXIES
[ "$proxies" = __keep__ ] && proxies=
log "writing $env_file"
cat >"$env_file" <<EOF
# chessd settings, read by chessd.service; restart chessd after a change.
CHESSD_HOST=$CHESSD_HOST
CHESSD_PORT=$CHESSD_PORT
# PostgreSQL over the Unix socket as the OS account chess (peer or trust).
CHESSD_DSN="$dsn"
CHESSD_JWT_KEY=$key
# Reverse proxy address(es), comma-separated, whose X-Real-IP is trusted.
CHESSD_TRUSTED_PROXIES=$proxies
# Extra server flags, e.g. -max-users 500 -db-cleanup report
CHESSD_FLAGS=
EOF
elif [ "$TRUSTED_PROXIES" != __keep__ ]; then
sed -i "s|^CHESSD_TRUSTED_PROXIES=.*|CHESSD_TRUSTED_PROXIES=$TRUSTED_PROXIES|" "$env_file"
log "$env_file: CHESSD_TRUSTED_PROXIES=$TRUSTED_PROXIES"
fi
chown root:"$group" "$env_file"
chmod 0640 "$env_file"
# The settings in effect, which may differ from this run's defaults.
# shellcheck disable=SC1090
settings=$(set -a && . "$env_file" && printf '%s|%s|%s|%s' \
"$CHESSD_HOST" "$CHESSD_PORT" "$CHESSD_TRUSTED_PROXIES" "$CHESSD_DSN")
IFS='|' read -r host port proxies dsn <<<"$settings"
[ -n "$proxies" ] || warn "no trusted proxy: behind a reverse proxy every client shares one rate-limit bucket (set TRUSTED_PROXIES)"
install -d -o "$user" -g "$group" -m 0700 "$STATE_DIR"
if [ ! -f "$key" ]; then
log "generating JWT signing key $key"
(umask 077 && head -c 48 /dev/urandom | base64 -w0 >"$key")
fi
chown "$user:$group" "$key"
chmod 0600 "$key"
install -o root -g root -m 0644 "$here/chessd.service" "$UNIT_DIR/chessd.service"
if [ "$ENABLE_BACKUP" = yes ]; then
install -o root -g root -m 0755 "$repo/deploy/postgresql/chess-backup.sh" "$SBIN_DIR/chess-backup"
install -o root -g root -m 0644 "$here/chess-backup.service" "$UNIT_DIR/chess-backup.service"
install -o root -g root -m 0644 "$here/chess-backup.timer" "$UNIT_DIR/chess-backup.timer"
fi
"$SYSTEMCTL" daemon-reload
log "creating or migrating the chess schema"
if [ "$SPLIT_PRIVILEGES" = yes ]; then
runuser -u postgres -- "$bin" db init \
-dsn "host=$socket dbname=chess user=postgres options='-c role=chess_owner -c search_path=chess'"
else
runuser -u "$user" -- "$bin" db init -dsn "$dsn"
fi
# --- 4. start ----------------------------------------------------------------
if [ "$ENABLE_BACKUP" = yes ]; then
"$SYSTEMCTL" enable --now chess-backup.timer
log "backups: chess-backup.timer -> $(getent passwd postgres | cut -d: -f6)/backups"
fi
"$SYSTEMCTL" enable chessd
"$SYSTEMCTL" start chessd
case "$host" in
0.0.0.0 | "" | ::) probe=127.0.0.1 ;;
*:*) probe="[$host]" ;;
*) probe=$host ;;
esac
url="http://$probe:$port/health"
health=
for _ in $(seq 1 30); do
if health=$(curl -fsS --max-time 2 "$url" 2>/dev/null); then
break
fi
sleep 0.5
done
case "$health" in
*'"storage":"ok"'*) log "chessd is up: $health" ;;
*) die "chessd did not report healthy storage at $url (journalctl -u chessd): ${health:-no answer}" ;;
esac
log "done. Create an account with: chess-db user add -username <name>"
@@ -1,16 +1,18 @@
# Host nginx for chess: browsers (web and WASM clients) call # nginx for chess: browsers (web and WASM clients) call <origin>/chess/api/...
# <origin>/chess/api/... and <origin>/chess/health, which map to the server's # and <origin>/chess/health, which map to the server's /api/... and /health;
# /api/... and /health. Replace <jail-address> and <port> (chessd_port, # /chess itself redirects to the project page. Replace <server-address> and
# default 8080). # <port>: the FreeBSD jail's address and chessd_port, or 127.0.0.1 and
# CHESSD_PORT for a Linux install on the same host (default port 8080).
# #
# chess-server keys rate limits on X-Real-IP, trusted only from the addresses # chess-server keys rate limits on X-Real-IP, trusted only from the addresses
# in chessd_trusted_proxies: the address this nginx uses to reach the jail. # in its trusted proxies (chessd_trusted_proxies or CHESSD_TRUSTED_PROXIES):
# the address this nginx connects from, as the server sees it.
# $remote_addr must be the real client: with PROXY protocol in front of nginx, # $remote_addr must be the real client: with PROXY protocol in front of nginx,
# set real_ip_header proxy_protocol and set_real_ip_from for that hop. # set real_ip_header proxy_protocol and set_real_ip_from for that hop.
# --- http {} context --- # --- http {} context ---
upstream chess_api { upstream chess_api {
server <jail-address>:<port>; server <server-address>:<port>;
keepalive 16; keepalive 16;
} }
@@ -43,10 +45,21 @@ location = /chess/health {
proxy_read_timeout 10s; proxy_read_timeout 10s;
} }
# Everything else under /chess is not served by the API. # Short link: /chess and /chess/ redirect to the page that embeds the client
# (adjust /projects/chess/). 302 rather than 301: browsers cache a 301
# indefinitely, which would loop if the direction is ever reversed. A server
# listening on a non-default port (PROXY protocol or TLS passthrough behind a
# front end) needs `absolute_redirect off;` so the port does not leak into
# Location. If this server block also answers for other host names, guard with
# a host map so only the main site redirects.
location = /chess { location = /chess {
return 404; return 302 /projects/chess/$is_args$args;
} }
location = /chess/ {
return 302 /projects/chess/$is_args$args;
}
# Everything else under /chess is not served by the API.
location /chess/ { location /chess/ {
return 404; return 404;
} }
+28
View File
@@ -0,0 +1,28 @@
#!/bin/sh
# Nightly logical backup of the chess database, run as the postgres account.
# Installed as /usr/local/sbin/chess-backup by deploy/freebsd/setup-jail.sh
# (scheduled through /usr/local/etc/cron.d/chess-backup) and by
# deploy/linux/setup.sh (scheduled by the chess-backup.timer systemd unit).
#
# CHESS_BACKUP_DIR destination (default: backups/ in the postgres home,
# e.g. /var/db/postgres/backups on FreeBSD)
# CHESS_BACKUP_KEEP days of dumps to keep (default: 14)
#
# Restore into a freshly provisioned database as the postgres account:
# pg_restore --no-owner --role=chess -d chess <dump>
# (use --role=chess_owner for the split-privilege setup).
set -eu
PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin
dir=${CHESS_BACKUP_DIR:-${HOME:?}/backups}
keep=${CHESS_BACKUP_KEEP:-14}
umask 077
mkdir -p "$dir"
target="$dir/chess-$(date -u +%Y%m%dT%H%M%SZ).dump"
if ! pg_dump --format=custom --dbname=chess --file="$target.partial"; then
rm -f "$target.partial"
exit 1
fi
mv "$target.partial" "$target"
find "$dir" -name 'chess-*.dump' -type f -mtime "+$keep" -delete
+157 -13
View File
@@ -157,6 +157,11 @@ Authorization: Bearer <token>
Note: When authenticated, human player IDs match the user's ID. Anonymous players receive unique UUIDs. Note: When authenticated, human player IDs match the user's ID. Anonymous players receive unique UUIDs.
A custom `fen` must describe a position that can arise in a game: one king
per side, no pawn on the first or last rank, and the side not to move not in
check; otherwise the response is 400 `INVALID_FEN` with the reason. Castling
rights whose king or rook has left its original square are dropped.
### Get Game ### Get Game
`GET /games/{gameId}` `GET /games/{gameId}`
@@ -190,7 +195,8 @@ the game ID, matching the existing public live-game read model. Persistent
storage must be enabled. storage must be enabled.
The response contains the initial FEN and an ordered FEN after every move, so a The response contains the initial FEN and an ordered FEN after every move, so a
client can replay the game without running a chess engine. client can replay the game without running a chess engine. Each move also
carries its SAN, derived on read from the stored position before it.
**Response (200):** **Response (200):**
```json ```json
@@ -198,6 +204,8 @@ client can replay the game without running a chess engine.
"gameId": "a1b2c3d4-e5f6-7890-1234-567890abcdef", "gameId": "a1b2c3d4-e5f6-7890-1234-567890abcdef",
"initialFen": "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", "initialFen": "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
"result": "white_wins", "result": "white_wins",
"pgnResult": "1-0",
"termination": "checkmate",
"startTimeUtc": "2026-09-07T12:00:00Z", "startTimeUtc": "2026-09-07T12:00:00Z",
"endTimeUtc": "2026-09-07T12:15:00Z", "endTimeUtc": "2026-09-07T12:15:00Z",
"players": { "players": {
@@ -208,6 +216,7 @@ client can replay the game without running a chess engine.
{ {
"moveNumber": 1, "moveNumber": 1,
"moveUci": "e2e4", "moveUci": "e2e4",
"san": "e4",
"fenAfterMove": "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1", "fenAfterMove": "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1",
"playerColor": "w", "playerColor": "w",
"moveTimeUtc": "2026-09-07T12:00:05Z" "moveTimeUtc": "2026-09-07T12:00:05Z"
@@ -217,28 +226,87 @@ client can replay the game without running a chess engine.
``` ```
`result` is omitted while a game is ongoing. Persisted terminal values are `result` is omitted while a game is ongoing. Persisted terminal values are
`white_wins`, `black_wins`, `draw`, and `stalemate`. A claimed player's `name` `white_wins`, `black_wins`, `draw`, and `stalemate`. `pgnResult` is the PGN
is their username when the claim was recorded; it survives later renames and token for the same outcome (`1-0`, `0-1`, `1/2-1/2`, or `*` while ongoing),
and `termination` names how it was reached (see
[Game End](#game-end-draws-and-resignation); omitted while ongoing).
`moveNumber` counts plies from 1. `san` is omitted only for a stored move the
rules core cannot notate, which the server logs. A claimed player's `name` is
their username when the claim was recorded; it survives later renames and
account deletion, and is omitted for anonymous and computer players. account deletion, and is omitted for anonymous and computer players.
Undo stays available after a result (a finished game can be rewound and
played on), so a stored game is never final. Responses carry a strong `ETag`
and `Cache-Control: private, no-cache`: send `If-None-Match` to get 304 while
nothing changed.
Returns 400 for a non-canonical game ID, 404 when the game has no durable Returns 400 for a non-canonical game ID, 404 when the game has no durable
record, and 503 when persistence is disabled or degraded. record, and 503 when persistence is disabled or degraded.
### Export PGN
`GET /games/{gameId}/pgn?ply=N`
Returns the stored game in PGN export format as
`application/x-chess-pgn; charset=utf-8` with
`Content-Disposition: attachment; filename="chess-<yyyymmdd>-<id8>.pgn"`.
Same visibility, `ETag`, and error responses as the history. `ply` (optional,
0 up to the number of stored plies) exports only the first N plies; the
result is then `*`, the `Termination` tag is left out, and the filename ends
`-ply<N>.pgn`. A `fetch` of the URL returns the text, so clients can copy it
as well as download it.
```
[Event "Casual game"]
[Site "?"]
[Date "2026.09.27"]
[Round "-"]
[White "alice"]
[Black "Stockfish level 3"]
[Result "0-1"]
[GameId "68007fd1-8970-4d84-950d-2f6ec6375c0b"]
[UTCDate "2026.09.27"]
[UTCTime "08:07:00"]
[WhiteType "human"]
[BlackType "program"]
[PlyCount "4"]
[Termination "normal"]
1. f3 e5 2. g4 Qh4# 0-1
```
The movetext of a finished game ends with a comment naming the outcome
before the result, e.g. `{ Black wins by resignation. } 0-1` or
`{ Draw by threefold repetition. } 1/2-1/2`.
Players are the name snapshot, `Anonymous`, or `Stockfish level N`. A game
from a custom position adds `SetUp "1"` and `FEN` tags, and its movetext
starts at that position's move number (`55. g8=Q` or `40... Kd8`).
Movetext wraps at 80 columns. `Termination` is `normal` for a finished game
and `unterminated` otherwise.
### List My Stored Games ### List My Stored Games
`GET /users/me/games?limit=50&offset=0` `GET /users/me/games?limit=50&cursor=<nextCursor>&status=finished&color=white`
Returns games associated with the authenticated user at creation time or by a Returns games associated with the authenticated user at creation time or by a
later first-move slot claim. Requires `Authorization: Bearer <token>` and later first-move slot claim, newest first. Requires
persistent storage. `Authorization: Bearer <token>` and persistent storage.
- `limit`: 1-100; defaults to 50 - `limit`: 1-100; defaults to 50
- `offset`: 0-1,000,000; defaults to 0 - `cursor`: the previous page's `nextCursor`; continues after that game, so
games created meanwhile do not shift the page. Opaque; do not construct it.
- `status`: `ongoing` or `finished`
- `color`: `white` or `black`, the side the user claimed
- `offset`: 0-1,000,000; position-based paging for older clients. It cannot
be combined with `cursor`, and pages shift when games are added.
Each item contains game ID, initial FEN, final FEN (after the last stored move, Each item contains game ID, initial FEN, final FEN (after the last stored move,
or the initial FEN when there is none), result/timestamps, players, and move or the initial FEN when there is none), result and its `pgnResult` token,
count. `nextOffset` is present only when another page exists. A game belongs to timestamps, players, and move count. `nextCursor` is present only when another
a user who created it while authenticated or claimed a slot with a first move; page exists; `nextOffset` also, on requests without a cursor. A game belongs
the association survives later player reconfiguration. to a user who created it while authenticated or claimed a slot with a first
move; the association survives later player reconfiguration.
Returns 400 `INVALID_REQUEST` for a malformed cursor or filter.
**Response (200):** **Response (200):**
```json ```json
@@ -249,6 +317,7 @@ the association survives later player reconfiguration.
"initialFen": "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", "initialFen": "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
"finalFen": "r1bqkb1r/pppp1Qpp/2n2n2/4p3/2B1P3/8/PPPP1PPP/RNB1K1NR b KQkq - 0 4", "finalFen": "r1bqkb1r/pppp1Qpp/2n2n2/4p3/2B1P3/8/PPPP1PPP/RNB1K1NR b KQkq - 0 4",
"result": "white_wins", "result": "white_wins",
"pgnResult": "1-0",
"startTimeUtc": "2026-09-07T12:00:00Z", "startTimeUtc": "2026-09-07T12:00:00Z",
"endTimeUtc": "2026-09-07T12:15:00Z", "endTimeUtc": "2026-09-07T12:15:00Z",
"moveCount": 7, "moveCount": 7,
@@ -259,7 +328,8 @@ the association survives later player reconfiguration.
} }
], ],
"limit": 50, "limit": 50,
"offset": 0 "offset": 0,
"nextCursor": "MTc5MDQ5NjQyMDU4MTg2NS42ODAwN2ZkMS04OTcwLTRkODQtOTUwZC0yZjZlYzYzNzVjMGI"
} }
``` ```
@@ -273,15 +343,89 @@ Submits human move or triggers computer move.
{"move": "e2e4"} {"move": "e2e4"}
``` ```
Moves are UCI: castling is the king's two-square move (`e1g1`), and a
promotion appends the piece (`e7e8q`, `r`, `b`, or `n`). A pawn move to the
last rank without the piece returns 400 `INVALID_MOVE` with
`promotion piece required`.
**Computer move trigger:** **Computer move trigger:**
```json ```json
{"move": "cccc"} {"move": "cccc"}
``` ```
A move that ends the game (mate, stalemate, or an automatic draw) returns the
terminal state and `termination` in the same response.
### Game End: Draws and Resignation
A live game (`GET /games/{gameId}` and every game response) carries:
| Field | Meaning |
|---|---|
| `state` | `ongoing`, `pending` (computer thinking), `stuck` (engine failed), `white wins`, `black wins`, `stalemate`, or `draw` |
| `termination` | How a finished game ended: `checkmate`, `resignation`, `stalemate`, `insufficient_material`, `threefold_repetition`, `fifty_move_rule`, or `agreement`; omitted while unfinished |
| `drawOffer` | `w` or `b`: that side's draw offer awaits an answer; omitted when none |
| `drawOutcome` | Only on responses to `POST .../draw`: `offered`, `accepted`, or `declined` |
**Automatic draws.** After every move the game is drawn, without a claim, as
on most online servers, when:
- neither side can mate: bare kings, one minor piece, or only bishops all on
squares of one color (two knights do not count: mate is possible);
- the position occurs for the third time with the same side to move,
castling rights, and en-passant possibility (threefold repetition);
- 50 moves by each side pass without a capture or pawn move (the halfmove
clock reaches 100).
Mate and stalemate take precedence. A custom starting position that is
already dead, or whose halfmove clock is at 100, is created drawn.
#### Resign
`POST /games/{gameId}/resign`
```json
{"color": "w"}
```
Ends the game in the other side's favor, with `termination: "resignation"`.
`color` (`w`, `b`, `white`, `black`) may be omitted when the server can infer
the side: the one side the authenticated caller claimed, else the only human
side. Allowed while the game is unfinished, including while the computer is
thinking (its move is discarded) or the engine is stuck.
#### Offer, accept, or decline a draw
`POST /games/{gameId}/draw`
```json
{"action": "offer", "color": "b"}
```
`action` is `offer`, `accept`, or `decline`; `color` works as for resign.
Allowed only while `state` is `ongoing`.
- **To a human:** the offer stands (`drawOffer`) until the opponent accepts,
declines, or makes a move instead, which declines it; the offerer's own
move keeps it. Offering while the opponent's offer stands accepts it.
Repeating a standing offer is a no-op. Waiting long-poll clients are woken
on offers and answers.
- **To the computer:** answered at once in `drawOutcome`. The computer
declines before both sides have made ten moves; after that it accepts when
a short full-strength search rates its own side level or worse.
- **One offer per move:** after an offer is declined, the same side must make
a move before offering again (409 `GAME_CONFLICT`).
Resign and draw share the move endpoint's authorization: a side claimed by a
user acts only for that user (403 `UNAUTHORIZED`), and an authenticated
resignation or acceptance claims an unclaimed side as a first move would.
Other errors are 400: `GAME_OVER` for a finished game, `INVALID_REQUEST` for
an ambiguous or computer side, a missing offer, or a game that is not
ongoing. Both return the game.
### Undo Moves ### Undo Moves
`POST /games/{gameId}/undo` `POST /games/{gameId}/undo`
Reverts moves from history. Reverts moves from history. Undo stays available after a result, including a
resignation or an agreed draw: it rewinds the result and any draw offer.
### Configure Players ### Configure Players
`PUT /games/{gameId}/players` `PUT /games/{gameId}/players`
+59 -14
View File
@@ -57,7 +57,14 @@ schema is current, which lets a DML-only runtime role start the server.
### Supporting Modules ### Supporting Modules
- **Engine** (`internal/engine`): UCI protocol wrapper for Stockfish process communication - **Engine** (`internal/engine`): UCI protocol wrapper for Stockfish process communication
- **Game** (`internal/game`): Game state with snapshot history and player associations - **Game** (`internal/game`): Game state with snapshot history and player associations
- **Board** (`internal/board`): FEN parsing and ASCII generation - **Rules core** (`internal/server/chess`): dependency-free position model,
legal move generation, SAN, PGN, ASCII board, dead-material and repetition
detection. It validates custom starting FENs, applies the automatic draw
rules, and shadows the engine on every live move; Stockfish stays the
authority for move legality, mate, and stalemate
- **Replay** (`internal/server/replay`): works on stored game records: SAN
for every ply, PGN assembly with the termination comment, and verification
against the rules (used by `db verify`, `db pgn`, and the integrity sweep)
- **Core** (`internal/core`): Shared types, API models, error constants - **Core** (`internal/core`): Shared types, API models, error constants
- **CLI** (`cmd/chess-server/cli`): Database and user management commands - **CLI** (`cmd/chess-server/cli`): Database and user management commands
- **Client** (`cmd/chess-client-cli`, `internal/client`): Interactive debugging client with command registry, session management, and colored terminal output - **Client** (`cmd/chess-client-cli`, `internal/client`): Interactive debugging client with command registry, session management, and colored terminal output
@@ -84,19 +91,41 @@ schema is current, which lets a DML-only runtime role start the server.
1. HTTP handler receives `POST /games/{id}/moves` with move 1. HTTP handler receives `POST /games/{id}/moves` with move
2. Optional JWT validation for user verification 2. Optional JWT validation for user verification
3. Creates MakeMoveCommand, calls `processor.Execute()` 3. Creates MakeMoveCommand, calls `processor.Execute()`
4. Processor validates move via locked validation engine 4. The rules core checks the move first only to name a missing promotion
5. If legal, gets new FEN from engine piece; the processor then validates it via the locked validation engine
6. Calls `service.ApplyMoveWithState()` with the FEN, turn, and state that were validated 5. If legal, gets new FEN from engine, and logs a warning if the rules core
7. Service rejects a stale concurrent commit or atomically updates the move, optional slot claim, and terminal result disagrees on legality or the resulting position
8. The same logical mutation is queued as one database transaction 6. If neither mate nor stalemate, the processor applies the automatic draw
9. Returns GameResponse rules to the new position and the game's position history: dead material,
threefold repetition, fifty-move rule
7. Calls `service.ApplyMoveWithState()` with the FEN, turn, state, and
termination that were decided
8. Service rejects a stale concurrent commit or atomically updates the move,
optional slot claim, terminal result, and termination; a move by the
recipient of a draw offer declines it
9. The same logical mutation is queued as one database transaction
10. Returns GameResponse
### Resignation and Draw Agreement
1. `POST /games/{id}/resign` or `/draw` with an optional side
2. The processor resolves the acting side (requested, else the caller's one
claimed side, else the only human side) and checks slot ownership
3. A draw offer to a human is recorded on the game and wakes long-polls; an
offer to a computer is answered at once from a short full-strength search
on the validation engine (after twenty plies; accept when level or worse
for the computer); one offer per own move
4. `service.EndGame()` re-checks under its lock that the game is unfinished,
unchanged, and the accepted offer still stands, then sets result and
termination, claims an empty acting side for an authenticated caller, and
queues one transaction
### Computer Move ### Computer Move
1. HTTP handler receives `POST /games/{id}/moves` with `{"move": "cccc"}` 1. HTTP handler receives `POST /games/{id}/moves` with `{"move": "cccc"}`
2. Processor sets game state to `pending` 2. Processor sets game state to `pending`
3. Submits task to EngineQueue, returns immediately 3. Submits task to EngineQueue, returns immediately
4. Worker goroutine calculates move with dedicated Stockfish instance 4. Worker goroutine calculates move with dedicated Stockfish instance
5. Callback updates game state via service 5. Callback re-validates the move on the validation engine, applies the
automatic draw rules, and commits via the service
6. Client polls for completion 6. Client polls for completion
7. Returns GameResponse 7. Returns GameResponse
@@ -115,8 +144,10 @@ schema is current, which lets a DML-only runtime role start the server.
2. Storage queues a barrier after all previously accepted gameplay writes 2. Storage queues a barrier after all previously accepted gameplay writes
3. The writer reaches the barrier only after those transactions finish 3. The writer reaches the barrier only after those transactions finish
4. Storage reads the game row and ordered moves in one REPEATABLE READ transaction 4. Storage reads the game row and ordered moves in one REPEATABLE READ transaction
5. The API returns the initial FEN plus every UCI move and resulting FEN 5. The replay package derives SAN for each ply from the stored FEN before it
6. This path works after terminal-memory eviction or a server restart 6. The API returns the initial FEN plus every UCI move, its SAN, and the
resulting FEN, with a strong ETag; `/pgn` renders the same read as PGN
7. This path works after terminal-memory eviction or a server restart
## Persistence Flow ## Persistence Flow
@@ -206,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 1) ### Storage Schema (version 2)
```sql ```sql
users ( users (
user_id uuid PRIMARY KEY, user_id uuid PRIMARY KEY,
@@ -236,6 +267,9 @@ games (
black_... (same columns), black_... (same columns),
start_time_utc timestamptz NOT NULL, start_time_utc timestamptz NOT NULL,
result text, -- white_wins, black_wins, draw, stalemate result text, -- white_wins, black_wins, draw, stalemate
termination text, -- how: checkmate, resignation, stalemate,
-- insufficient_material, threefold_repetition,
-- fifty_move_rule, agreement
end_time_utc timestamptz, -- set exactly when result is set end_time_utc timestamptz, -- set exactly when result is set
) )
@@ -250,7 +284,9 @@ moves (
) )
``` ```
`schema_version` records the applied version. Indexes cover session expiry, `schema_version` records the applied version; version 2 added
`termination`, constrained to be set exactly with `result` and to fit it
(wins by checkmate or resignation, draws by a draw rule or agreement). Indexes cover session expiry,
each claim column ordered by `(start_time_utc DESC, game_id DESC)` for the game 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
@@ -264,9 +300,18 @@ outlives deleted accounts, and the name snapshot keeps it readable.
hourly cleanup evicts idle games first, then queues the delete through the hourly cleanup evicts idle games first, then queues the delete through the
ordered writer while excluding every game still loaded, so no live game can ordered writer while excluding every game still loaded, so no live game can
lose its row. lose its row.
- A write whose game row was deleted by hand fails only that game: the
writer drops it and the service unloads the game, instead of degrading
storage for everyone.
- With `-db-cleanup report|delete`, the hourly cleanup also runs an integrity
sweep: loaded games without a row, games with missing plies, stored games
failing verification (200 per run, resuming in ID order), games whose
registered players were all deleted, and accounts with unusable password
hashes. `report` only logs; `delete` unloads and removes them.
See [`deploy/postgresql`](../deploy/postgresql) for provisioning and See [`deploy/postgresql`](../deploy/postgresql) for provisioning,
[deployment.md](./deployment.md) for the jail service. [deployment.md](./deployment.md) for the jail service, and
[deployment-linux.md](./deployment-linux.md) for systemd.
## Security Architecture ## Security Architecture
+46 -6
View File
@@ -14,11 +14,11 @@ The chess client is an interactive command-line debugging tool for the chess ser
## Replay Foundation ## Replay Foundation
The Go API client exposes `GetGameHistory(gameID)` and The Go API client exposes `GetGameHistory(gameID)`, `GetGamePGN(gameID, ply)`,
`GetMyGames(limit, offset)`, including durable result, timestamps, player and `GetMyGames(limit, cursor)`, including durable result, timestamps, player
claims, move count, ordered UCI moves, and FEN after every move. Interactive claims, move count, ordered UCI and SAN moves, and FEN after every move. The
`games`/`replay` commands and terminal playback controls are intentionally `games` and `pgn` commands use them; step-by-step `replay` controls are still
deferred to the dedicated replay iteration; see [Replay Implementation Tasks](./todo.md). planned, see [Replay Implementation Tasks](./todo.md).
## Building ## Building
```bash ```bash
@@ -104,10 +104,12 @@ chess > join a1b2c3d4-e5f6-7890-1234-567890abcdef
``` ```
#### `move` / `m` #### `move` / `m`
Make chess move in UCI notation. Make chess move in UCI notation. Castling is the king's move (`e1g1`); a
promotion appends the piece letter (`q`, `r`, `b`, or `n`).
``` ```
chess > move e2e4 chess > move e2e4
chess > move e7e5 chess > move e7e5
chess > move g7g8q
``` ```
#### `computer` / `c` #### `computer` / `c`
@@ -148,6 +150,44 @@ Long-poll for game updates (waits up to 30 seconds).
chess > poll chess > poll
``` ```
#### `resign`
Resign the current game. The side may be omitted when you play one human side
(or claimed one); name it in a hot-seat game.
```
chess > resign
chess > resign b
```
#### `draw`
Offer (the default), accept, or decline a draw. A computer answers an offer
at once; a human opponent sees the offer in their next response, and it
lapses if they move instead of answering.
```
chess > draw # offer
chess > draw accept b
chess > draw decline w
```
Game outcomes print with how they were reached, e.g. `DRAW by threefold
repetition.` or `Black wins by resignation.`; a pending offer is shown as
`White offers a draw`.
#### `games` / `g`
List your stored games, newest first, 20 per page (requires login). The
result column is the PGN token (`1-0`, `0-1`, `1/2-1/2`, `*`).
```
chess > games # Newest games
chess > games more # Next page
```
#### `pgn`
Print a stored game as PGN (SAN movetext, standard tags).
```
chess > pgn # Current game, every ply
chess > pgn 10 # Current game, first 10 plies
chess > pgn <gameId> [ply] # Another game
```
### Debug Commands ### Debug Commands
#### `health` / `.` #### `health` / `.`
+309
View File
@@ -0,0 +1,309 @@
# Database Operations
`psql` recipes for inspecting and maintaining the chess database as the
PostgreSQL administrator. Installation is in [deployment.md](deployment.md)
(FreeBSD jail) and [deployment-linux.md](deployment-linux.md) (Linux with
systemd).
## Typical Layout
The server expects one database with its tables in one schema of the same
name, owned by a role that exists only for it:
| Object | Name | Notes |
|---|---|---|
| Database | `chess` | Owned by `postgres`; only the `chess` role may connect |
| Schema | `chess` | All tables; `public` is locked down and stays empty |
| Login role | `chess` | No password, no superuser, create-database, or create-role rights; 20 connections |
| Owner role (split mode only) | `chess_owner` | Cannot log in; owns the schema while `chess` only reads and writes rows |
The service runs as an OS account named `chess` and connects over the local
Unix socket with **peer** authentication, which maps the OS account to the
role of the same name, so no password exists anywhere. The `chess` role has
its `search_path` set to `chess` in this database, so the service never names
the schema. [`deploy/postgresql/setup.sql`](../deploy/postgresql/setup.sql)
creates all of this; the deployment scripts run it once.
Administration uses the `postgres` superuser account, which keeps the
distribution's defaults. Its `search_path` (`"$user", public`) does not
include `chess`, so **every recipe names the schema explicitly** (`chess.games`).
This keeps other databases and tools on the same server unaffected, and a
statement typed into the wrong database fails instead of acting on whatever
it finds.
## Connecting
```sh
# Linux
sudo -u postgres psql -X -d chess
# FreeBSD jail
su -m postgres -c 'psql -X -d chess'
```
Working with the `chess` schema as `postgres`:
| Goal | How |
|---|---|
| List the tables | `\dt chess.*` (a plain `\dt` shows `public`, which is empty) |
| Describe a table | `\d chess.games` |
| List schemas and owners | `\dn+` |
| Query | `SELECT ... FROM chess.games` |
| Unqualified names for one session | `SET search_path = chess;` (lasts until you disconnect) |
| Unqualified names for one invocation | `sudo -u postgres env PGOPTIONS='-c search_path=chess' psql -X -d chess` |
Neither of the last two changes anything stored: the next session starts with
the defaults again. Avoid `ALTER ROLE postgres ... SET search_path`, which
would persist.
The tables:
| Table | Contents |
|---|---|
| `chess.schema_version` | One row: the applied migration |
| `chess.users` | Accounts: lowercase username and email, Argon2id password hash |
| `chess.sessions` | One active session per user; deleting it signs the user out |
| `chess.games` | Players, claims (`*_claimed_by`), name snapshots, `result`, `termination`, times |
| `chess.moves` | Ply `move_number` 1..n, UCI move, FEN after the move |
`result` is `white_wins`, `black_wins`, `stalemate`, or `draw`, and
`termination` says how: `checkmate`, `resignation`, `stalemate`,
`insufficient_material`, `threefold_repetition`, `fifty_move_rule`, or
`agreement`. Both are empty while a game is unfinished.
`\set g '<game-id>'` stores a game ID for the `:'g'` references below.
## Overview
```sql
SELECT version, updated_at FROM chess.schema_version;
SELECT (SELECT count(*) FROM chess.users) AS users,
(SELECT count(*) FROM chess.games) AS games,
(SELECT count(*) FROM chess.games WHERE result IS NULL) AS unfinished,
(SELECT count(*) FROM chess.moves) AS plies,
(SELECT count(*) FROM chess.sessions WHERE expires_at > now()) AS signed_in;
SELECT coalesce(termination, 'unfinished') AS termination, count(*)
FROM chess.games GROUP BY 1 ORDER BY 2 DESC;
```
## Users
```sql
SELECT user_id, username, email, created_at, last_login_at
FROM chess.users ORDER BY created_at;
```
Passwords are not stored. `password_hash` is an Argon2id string in PHC
format, `$argon2id$v=19$m=<KiB>,t=<passes>,p=<lanes>$<salt>$<hash>`, from
which the password cannot be recovered. Show the parameters without the
secret parts:
```sql
SELECT username, split_part(password_hash, '$', 2) AS algorithm,
split_part(password_hash, '$', 4) AS parameters
FROM chess.users;
```
Create, reset, and rename accounts with the server's CLI, not SQL: it
normalizes names, enforces uniqueness rules, and hashes passwords. Without
`-password` it prompts, which keeps the password out of the shell history and
the process list.
```sh
# Linux: chess-db runs `chess-server db ...` as the chess account
sudo chess-db user add -username <name> [-email <addr>]
sudo chess-db user set-password -username <name>
sudo chess-db user list
# FreeBSD jail
su -m chess -c '/home/chess/bin/chess-server db user add -username <name> -dsn "postgres:///chess?host=/tmp"'
```
Other `user` subcommands: `delete`, `set-email`, `set-username`, and
`set-hash` (an existing PHC string). CLI-created accounts are identical to
site registrations, and the CLI ignores the registration cap.
Sign a user out everywhere (takes effect on their next request):
```sql
DELETE FROM chess.sessions
WHERE user_id = (SELECT user_id FROM chess.users WHERE username = 'alice');
```
Deleting a user removes their session; their games keep the claim and the
name snapshot. Games left with no remaining registered player are removed by
the optional integrity sweep (below) once idle for a day.
## Games
Recent games with players, outcome, and length:
```sql
SELECT g.game_id, g.start_time_utc,
coalesce(g.white_name, CASE g.white_type WHEN 2 THEN 'Stockfish L' || g.white_level ELSE 'anonymous' END) AS white,
coalesce(g.black_name, CASE g.black_type WHEN 2 THEN 'Stockfish L' || g.black_level ELSE 'anonymous' END) AS black,
coalesce(g.result || ' by ' || g.termination, 'unfinished') AS outcome,
(SELECT count(*) FROM chess.moves m WHERE m.game_id = g.game_id) AS plies
FROM chess.games g ORDER BY g.start_time_utc DESC LIMIT 20;
```
A user's games: add
`WHERE (SELECT user_id FROM chess.users WHERE username = 'alice') IN (g.white_claimed_by, g.black_claimed_by)`.
Find a game from the 8-digit prefix that `db query` prints:
`SELECT game_id FROM chess.games WHERE game_id::text LIKE '68007fd1%';`.
One game, header and every ply with its FEN:
```sql
\set g '68007fd1-8970-4d84-950d-2f6ec6375c0b'
SELECT * FROM chess.games WHERE game_id = :'g' \gx
SELECT move_number AS ply, player_color, move_uci, fen_after_move, move_time_utc
FROM chess.moves WHERE game_id = :'g' ORDER BY move_number;
```
Final position:
```sql
SELECT coalesce(
(SELECT fen_after_move FROM chess.moves WHERE game_id = :'g' ORDER BY move_number DESC LIMIT 1),
(SELECT initial_fen FROM chess.games WHERE game_id = :'g')) AS final_fen;
```
The move line in UCI with move numbers (numbering follows the initial FEN, so
a game that starts with Black to move begins `N...`):
```sql
WITH s AS (SELECT split_part(initial_fen, ' ', 2) AS side,
split_part(initial_fen, ' ', 6)::int AS fullmove
FROM chess.games WHERE game_id = :'g')
SELECT string_agg(
CASE WHEN m.player_color = 'w'
THEN (s.fullmove + (m.move_number - CASE s.side WHEN 'w' THEN 1 ELSE 0 END) / 2) || '. '
WHEN m.move_number = 1 THEN s.fullmove || '... '
ELSE '' END || m.move_uci,
' ' ORDER BY m.move_number) AS uci_line
FROM chess.moves m, s WHERE m.game_id = :'g';
```
SQL has no SAN; the database stores UCI and notation is derived on read. For
real PGN use the CLI (a full ID or 8-digit prefix; `-ply N` stops after N
plies) or the API:
```sh
sudo chess-db pgn -gameId 68007fd1 # Linux
su -m chess -c '/home/chess/bin/chess-server db pgn -gameId 68007fd1 -dsn "postgres:///chess?host=/tmp"' # FreeBSD
curl -s https://<site>/chess/api/games/<game-id>/pgn
```
`db verify` checks every stored game against the rules (move numbering, each
move legal from the stored position before it, the stored position after it
reproduced, the result and its termination consistent with the final
position) and exits non-zero on any problem:
```sh
sudo chess-db verify
```
### Deleting games
Delete finished or unfinished games at any time:
```sql
BEGIN;
DELETE FROM chess.games WHERE game_id = :'g'; -- its moves are removed by cascade
COMMIT;
```
All games of one user (both colors):
```sql
DELETE FROM chess.games
WHERE (SELECT user_id FROM chess.users WHERE username = 'alice') IN (white_claimed_by, black_claimed_by);
```
A game the server is still playing is unloaded when its next write finds the
row gone (that last move is not stored), or earlier by the integrity sweep;
the server stays healthy. Deleting individual `chess.moves` rows instead leaves a broken line
that replay cannot use: delete the whole game, or let the integrity sweep do
it.
Games without a registered player are already deleted by the server 24 hours
after their last activity (`-anonymous-game-ttl`).
### Integrity sweep
With the server flag `-db-cleanup report` (log only) or `-db-cleanup delete`,
an hourly sweep looks for data the server cannot use:
- games whose rows were deleted while the server held them (unloaded);
- games with missing plies, i.e. deleted move rows;
- games that fail `db verify`, 200 per run in ID order;
- games whose registered players were all deleted, once idle for the
anonymous-game retention;
- accounts whose password hash is not a usable Argon2id string (nobody can
sign in to them).
Each finding is logged at warning level with its reason; `delete` also
removes them. Start with `report`, read the log, then switch. Set it through
`CHESSD_FLAGS` in `/etc/chessd/chessd.env` (Linux) or `chessd_flags` in
`rc.conf` (FreeBSD), and restart the service.
## Starting Fresh
Every option below loses data: take a dump first if any of it matters.
```sh
sudo -u postgres pg_dump -Fc -d chess -f /var/lib/postgresql/chess-$(date +%F).dump # Linux (Debian/Ubuntu path)
su -m postgres -c 'pg_dump -Fc -d chess -f /var/db/postgres/chess-$(date +%F).dump' # FreeBSD
```
The service commands are `systemctl stop|start chessd` on Linux and
`service chessd stop|start` on FreeBSD.
**Clear the data, keep the schema** (accounts, sessions, and games), with the
service stopped:
```sql
TRUNCATE chess.moves, chess.games, chess.sessions, chess.users;
```
**Recreate the tables** (drops and re-runs the migrations), with the service
stopped:
```sh
sudo chess-db delete -confirm && sudo chess-db init # Linux
su -m chess -c '/home/chess/bin/chess-server db delete -confirm -dsn "postgres:///chess?host=/tmp"' # FreeBSD
su -m chess -c '/home/chess/bin/chess-server db init -dsn "postgres:///chess?host=/tmp"'
```
`db init` prints `Database schema ready (version 2)`. In split mode the
`chess` role owns no tables and cannot drop or create them; run both commands
as `postgres` acting as the owner role instead, e.g. on Linux:
```sh
sudo -u postgres /usr/local/bin/chess-server db delete -confirm \
-dsn "dbname=chess user=postgres options='-c role=chess_owner -c search_path=chess'"
```
**Rebuild the database and role** (as on a new host), with the service
stopped:
```sql
DROP DATABASE IF EXISTS chess WITH (FORCE);
DROP ROLE IF EXISTS chess_owner; -- exists only in split mode
DROP ROLE IF EXISTS chess;
```
Then rerun the deployment script (`deploy/linux/setup.sh` or
`deploy/freebsd/setup-jail.sh`) with the same inputs as before. With neither
role nor database present it runs `setup.sql`, creates the tables, starts the
service, and checks `/health`. The JWT key is kept; accounts are gone, so
create them again.
Verify any of them:
```sql
\dt chess.* -- games, moves, schema_version, sessions, users
SELECT version FROM chess.schema_version; -- 2
\drds -- chess: search_path and timeouts in database chess
```
+228
View File
@@ -0,0 +1,228 @@
# Deployment: Linux with systemd and PostgreSQL
A native install on Debian, Ubuntu, Arch, or another systemd distribution
that already runs PostgreSQL. The service is `chessd`, running as a system
account `chess` without a login shell; it reaches PostgreSQL over the local
Unix socket with peer authentication, so no database password exists. A
reverse proxy (nginx) on the same host, if any, forwards `/chess/api/` to it.
The FreeBSD jail layout is in [deployment.md](deployment.md); database
maintenance is in [database.md](database.md).
```
browser ──TLS──▶ nginx ──HTTP 127.0.0.1:8080──▶ chessd (chess-server, user chess)
│ Unix socket, peer auth
▼
PostgreSQL 17+ (database chess)
```
## Prerequisites
PostgreSQL 17 or later (18 recommended), running, with its default local
socket; Stockfish; systemd; Go 1.27 to build.
**Debian / Ubuntu.** Ubuntu 24.04 ships PostgreSQL 16, so 18 comes from the
PostgreSQL project's repository (PGDG). With 18 already installed, check the
cluster with `pg_lsclusters` and skip this.
```sh
sudo apt install postgresql-common stockfish
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
sudo apt install postgresql-18 # creates and starts cluster 18/main
```
The default `pg_hba.conf` already maps local OS accounts to roles of the same
name (`local all all peer`). Stockfish installs as `/usr/games/stockfish`;
the service adds `/usr/games` to its `PATH`.
**Arch.** The package does not create a cluster; initialize one the first
time only:
```sh
sudo pacman -S postgresql stockfish
sudo -u postgres initdb -D /var/lib/postgres/data --encoding=UTF8 \
--locale-provider=builtin --builtin-locale=C.UTF-8 \
--auth-local=peer --auth-host=scram-sha-256
sudo systemctl enable --now postgresql
```
A cluster made by a plain `initdb` trusts every local connection. The chess
account connects either way, but `peer` is what you want: without it, any
local account can connect as any role.
**Existing clusters.** Other databases, roles, and applications on the
server are left alone: the setup creates one role and one database named
`chess`, changes no server settings, and edits `pg_hba.conf` only when asked
(`PG_HBA=yes`, below). It does not change `listen_addresses`; the service
itself only uses the socket.
## Install
```sh
make server # bin/chess-server, static
sudo CHESS_BINARY=bin/chess-server TRUSTED_PROXIES=127.0.0.1 deploy/linux/setup.sh
sudo chess-db user add -username <name> # prompts for the password
```
The script is idempotent: rerun it with a new binary to upgrade. It stops
`chessd`, installs everything, migrates the schema, starts the service, and
fails unless `/health` reports `"storage":"ok"`.
| Input | Default | Meaning |
|---|---|---|
| `CHESS_BINARY` | required | `chess-server` binary to install |
| `TRUSTED_PROXIES` | kept from `chessd.env` | Proxy address(es) whose `X-Real-IP` is trusted; `127.0.0.1` for nginx on this host |
| `CHESSD_HOST`, `CHESSD_PORT` | `127.0.0.1`, `8080` | Listen address, first install only |
| `SPLIT_PRIVILEGES` | `no` | `yes`: the service role only reads and writes rows ([database.md](database.md#typical-layout)) |
| `ENABLE_BACKUP` | `no` | `yes`: nightly `pg_dump` through a systemd timer |
| `PG_HBA` | `no` | `yes`: add `local chess chess peer` to `pg_hba.conf` if the chess account cannot connect |
What it installs:
| Path | Purpose |
|---|---|
| account `chess` | System account, no login shell, home `/var/lib/chessd` |
| `/usr/local/bin/chess-server` | The server, `root:root 0755`; the previous copy kept as `.prev` |
| `/usr/local/sbin/chess-db` | Runs `chess-server db ...` as `chess` against the database: `chess-db user list`, `chess-db verify` |
| `/etc/chessd/chessd.env` | Settings, `root:chess 0640`; written on the first install only |
| `/var/lib/chessd/jwt.key` | JWT signing key, `chess 0600`, generated once; sessions survive restarts |
| `/etc/systemd/system/chessd.service` | The service unit, replaced on every run |
| `chess-backup.service`, `.timer`, `/usr/local/sbin/chess-backup` | With `ENABLE_BACKUP=yes`: dumps to `backups/` in the postgres home, 14 days kept |
PostgreSQL steps, in order: check the server version (17+) and socket as
`postgres`; create role, database, and schema with
[`setup.sql`](../deploy/postgresql/setup.sql) when neither exists (a
half-present pair stops the script); check that the `chess` account can
connect.
### When the chess account cannot connect
The first matching line of `pg_hba.conf` decides. A cluster whose local
lines require a password (`scram-sha-256`, `md5`) refuses the passwordless
`chess` role, and the script stops with the file's path and this line to add
above the other `local` lines:
```
local chess chess peer
```
Then reload (`sudo systemctl reload postgresql`) and rerun, or rerun with
`PG_HBA=yes` to have the line inserted and PostgreSQL reloaded (the previous
file is kept next to it with a timestamp).
## Settings
`/etc/chessd/chessd.env` is read by the unit; restart after a change
(`sudo systemctl restart chessd`).
| Setting | First-install value | Purpose |
|---|---|---|
| `CHESSD_HOST`, `CHESSD_PORT` | `127.0.0.1`, `8080` | Listen address; keep loopback when nginx runs on the host |
| `CHESSD_DSN` | `"host=<socket dir> dbname=chess"` | Database over the socket as the OS account |
| `CHESSD_JWT_KEY` | `/var/lib/chessd/jwt.key` | Stable signing key |
| `CHESSD_TRUSTED_PROXIES` | `TRUSTED_PROXIES` | Comma-separated proxy addresses |
| `CHESSD_FLAGS` | empty | Other server flags, e.g. `-max-users 500 -db-cleanup report` |
Server flags worth knowing: `-max-users` (registration cap, default 100),
`-anonymous-game-ttl` (default `24h`), `-finished-game-ttl` (default `1h`),
`-db-cleanup off|report|delete` (the integrity sweep, see
[database.md](database.md#integrity-sweep)), `-log-level`, `-log-http`.
Changes to the unit belong in a drop-in (`sudo systemctl edit chessd`), which
the script never overwrites.
## Operation
```sh
systemctl status chessd
journalctl -u chessd -f # server log
sudo chess-db user add -username <name> # accounts; see database.md
sudo chess-db verify # check stored games; run after upgrades
curl -s http://127.0.0.1:8080/health # "storage":"ok"
systemctl list-timers chess-backup.timer # with ENABLE_BACKUP=yes
```
**Upgrade:** build, then rerun `setup.sh` with the new `CHESS_BINARY`.
Schema migrations run before the service starts.
**Rollback:** until you delete it, the previous binary is kept:
```sh
sudo systemctl stop chessd
sudo cp /usr/local/bin/chess-server.prev /usr/local/bin/chess-server
sudo systemctl start chessd
```
A newer schema is not downgraded: a binary older than the database refuses to
start (see the log), so keep a dump from before the upgrade.
**Uninstall:**
```sh
sudo systemctl disable --now chessd chess-backup.timer
sudo rm -f /etc/systemd/system/chessd.service /etc/systemd/system/chess-backup.{service,timer} \
/usr/local/bin/chess-server{,.prev} /usr/local/sbin/chess-{db,backup}
sudo systemctl daemon-reload
sudo rm -rf /etc/chessd /var/lib/chessd
sudo -u postgres psql -X -c 'DROP DATABASE chess WITH (FORCE)' -c 'DROP ROLE IF EXISTS chess_owner' -c 'DROP ROLE chess'
sudo userdel chess
```
## nginx on the Same Host
Use [`deploy/nginx-chess.conf`](../deploy/nginx-chess.conf) with
`<server-address>:<port>` = `127.0.0.1:8080`, and install with
`TRUSTED_PROXIES=127.0.0.1`, so the server keys rate limits on the client
address nginx sends in `X-Real-IP`. The browser clients and their
publishing steps are the same as on FreeBSD
([deployment.md](deployment.md#host-nginx-and-web-clients)).
## Service Sandbox
The unit runs the server with systemd's sandboxing: the whole file system is
read-only except `/var/lib/chessd`, home directories and `/tmp` are hidden,
no capabilities or privilege escalation, only Unix and IP sockets, and the
`@system-service` system-call set. Stockfish runs inside the same sandbox.
`systemd-analyze security chessd` summarizes the result.
## Running as Your Own Account (Development)
Without a `chess` account, the server runs as you, with a database owned by
your PostgreSQL role; tables then live in that database's `public` schema,
which is fine for development:
```sh
sudo -u postgres createuser --createdb "$USER" # once, if you have no role yet
createdb chess_dev
bin/chess-server db init -dsn "dbname=chess_dev"
bin/chess-server -dev -dsn "dbname=chess_dev" -serve # API :8080, web client :9090
bin/chess-server db user add -username me -dsn "dbname=chess_dev"
```
`-dev` relaxes rate limits and uses a fixed JWT key; never use it in
production. The test suites need a separate, disposable database whose
tables they drop ([test/README.md](../test/README.md)):
```sh
createdb chess_test
export CHESS_TEST_DSN="dbname=chess_test"
go test ./...
test/run-test-server.sh bin/chess-server # then test/test-*.sh in another terminal
```
## Verification Checklist
```sh
systemctl is-active chessd # active
curl -s http://127.0.0.1:8080/health # "storage":"ok"
ps -o user= -C chess-server # chess
sudo -u postgres psql -X -d chess -c '\dt chess.*' # five tables
sudo chess-db verify # 0 problem(s)
sudo -u chess psql -X -d chess -Atc 'SHOW search_path' # chess
```
The scripts were exercised on Ubuntu 24.04 with PostgreSQL 18.6 and
`systemctl` replaced by a stand-in that starts the unit's `ExecStart` as its
`User` with its `EnvironmentFile`: a first install that stops at
`pg_hba.conf`, the same with `PG_HBA=yes`, an idempotent rerun, an upgrade,
a trusted-proxy change, backups, and a fresh split-privilege install.
`systemd-analyze verify` accepts the units.
+45 -9
View File
@@ -16,6 +16,9 @@ browser ──TLS──▶ host nginx ──HTTP──▶ jail: chessd (chess-se
`host#` runs on the Bastille host as root; `jail#` runs inside the jail as root `host#` runs on the Bastille host as root; `jail#` runs inside the jail as root
(`bastille console <jail>`). Addresses in this document are placeholders. (`bastille console <jail>`). Addresses in this document are placeholders.
For a native Linux install with systemd, see
[deployment-linux.md](deployment-linux.md); database recipes are in
[database.md](database.md).
## Scripted Installation and Upgrade ## Scripted Installation and Upgrade
@@ -56,7 +59,7 @@ Their header comments list every input.
unless `/health` reports `"storage":"ok"`. unless `/health` reports `"storage":"ok"`.
5. **Host:** point nginx at the jail 5. **Host:** point nginx at the jail
([`deploy/freebsd/nginx-chess.conf`](../deploy/freebsd/nginx-chess.conf)) ([`deploy/nginx-chess.conf`](../deploy/nginx-chess.conf))
and publish the web client (below). and publish the web client (below).
`setup-jail.sh` inputs: `setup-jail.sh` inputs:
@@ -105,6 +108,21 @@ rerun, and a fresh split-privilege install.
## Accounts and Data Retention ## Accounts and Data Retention
A new database has no accounts. Create the first one (for example your own)
with the CLI; without `-password` it prompts, so the password stays out of
the shell history and the process list:
```sh
jail# su -m chess -c '/home/chess/bin/chess-server db user add -username <name> -dsn "postgres:///chess?host=/tmp"'
```
The row lands in `chess.users` with an Argon2id hash in `password_hash`; the
password itself is stored nowhere and cannot be read back, only replaced
(`db user set-password`). The tables are in schema `chess`, not `public`:
as `postgres`, list them with `\dt chess.*` and name them as `chess.games`.
[database.md](database.md) explains the layout and has the psql recipes
(inspecting and deleting games, sessions, starting fresh).
- Accounts created by registration on the site and by `chess-server db user - Accounts created by registration on the site and by `chess-server db user
add` are identical and never expire. Public registration closes at add` are identical and never expire. Public registration closes at
`-max-users` accounts (default 100; `0` removes the limit); the CLI is not `-max-users` accounts (default 100; `0` removes the limit); the CLI is not
@@ -116,6 +134,10 @@ rerun, and a fresh split-privilege install.
activity**, from memory and from the database, by the hourly cleanup. activity**, from memory and from the database, by the hourly cleanup.
`-anonymous-game-ttl` changes the window; `0` keeps them. `-anonymous-game-ttl` changes the window; `0` keeps them.
- Deleting a user removes their sessions; their games keep the claim and name. - Deleting a user removes their sessions; their games keep the claim and name.
With `-db-cleanup delete`, games left without any registered player are
deleted like anonymous ones.
- A game row deleted by hand while the server still plays it no longer
degrades storage: the server unloads that game and carries on.
## Service (`rc.d/chessd`) ## Service (`rc.d/chessd`)
@@ -132,7 +154,7 @@ rerun, and a fresh split-privilege install.
| `chessd_dsn` | `postgres:///chess?host=/tmp` | Database `chess` over the socket as the OS user | | `chessd_dsn` | `postgres:///chess?host=/tmp` | Database `chess` over the socket as the OS user |
| `chessd_jwt_key` | `${chessd_home}/jwt.key` | JWT signing key; created (0600) on first start if missing | | `chessd_jwt_key` | `${chessd_home}/jwt.key` | JWT signing key; created (0600) on first start if missing |
| `chessd_trusted_proxies` | empty | Proxy address(es) whose `X-Real-IP` is trusted | | `chessd_trusted_proxies` | empty | Proxy address(es) whose `X-Real-IP` is trusted |
| `chessd_flags` | empty | Extra flags, e.g. `-max-users 500` | | `chessd_flags` | empty | Extra flags, e.g. `-max-users 500 -db-cleanup report` |
The script runs `daemon(8)` as root, which writes the supervisor pidfile The script runs `daemon(8)` as root, which writes the supervisor pidfile
(`/var/run/chessd.pid`), drops to `chess`, restarts the server 5 seconds after (`/var/run/chessd.pid`), drops to `chess`, restarts the server 5 seconds after
@@ -151,6 +173,7 @@ Server flags:
| `-max-users` | | Registration cap (default 100, `0` = none) | | `-max-users` | | Registration cap (default 100, `0` = none) |
| `-anonymous-game-ttl` | | Retention of games without a registered player (default `24h`) | | `-anonymous-game-ttl` | | Retention of games without a registered player (default `24h`) |
| `-finished-game-ttl` | | Memory retention of finished games (default `1h`) | | `-finished-game-ttl` | | Memory retention of finished games (default `1h`) |
| `-db-cleanup` | | Hourly integrity sweep: `off` (default), `report` (log findings), or `delete` ([database.md](database.md#integrity-sweep)) |
Administrative CLI commands run as `chess` so peer authentication selects the Administrative CLI commands run as `chess` so peer authentication selects the
right role; root may use `su -m` although the account has no shell: right role; root may use `su -m` although the account has no shell:
@@ -159,11 +182,19 @@ right role; root may use `su -m` although the account has no shell:
jail# su -m chess -c '/home/chess/bin/chess-server db user list -dsn "postgres:///chess?host=/tmp"' jail# su -m chess -c '/home/chess/bin/chess-server db user list -dsn "postgres:///chess?host=/tmp"'
``` ```
`db` subcommands: `init`, `delete -confirm` (drops the tables), `query`
(games, with 8-digit ID prefixes), `pgn -gameId <id|prefix> [-ply N]`,
`verify [-gameId <id|prefix>]` (replays stored games against the rules and
exits non-zero on a mismatch; run it after an upgrade), and `user add|delete|
set-password|set-hash|set-email|set-username|list`.
## Host nginx and Web Clients ## Host nginx and Web Clients
The API has no version segment: routes are `/api/...` and `/health`. With the The API has no version segment: routes are `/api/...` and `/health`. With the
layout in [`nginx-chess.conf`](../deploy/freebsd/nginx-chess.conf), browsers layout in [`nginx-chess.conf`](../deploy/nginx-chess.conf), browsers
call `<origin>/chess/api/...` and nginx maps `/chess/api/` to `/api/`. call `<origin>/chess/api/...` and nginx maps `/chess/api/` to `/api/`. The
bare `/chess` and `/chess/` redirect (302) to the page that embeds the
client, as a short link.
- `X-Real-IP` must carry the real client address (`$remote_addr`, after - `X-Real-IP` must carry the real client address (`$remote_addr`, after
`real_ip` processing when PROXY protocol is in front of nginx). The server `real_ip` processing when PROXY protocol is in front of nginx). The server
@@ -181,10 +212,14 @@ server's API paths:
embedded `/config` endpoint, it uses the `/chess` API prefix. embedded `/config` endpoint, it uses the `/chess` API prefix.
- **WASM terminal client (if published):** `make wasm` (xterm.js is committed - **WASM terminal client (if published):** `make wasm` (xterm.js is committed
under `lib/`), then copy `web/chess-client-wasm/`. It derives its API base under `lib/`), then copy `web/chess-client-wasm/`. It derives its API base
as `<origin>/chess`. as `<origin>/chess`. Its API paths are compiled into `chess-client.wasm`,
so rebuild it whenever `internal/client/api` changes; publish it together
with the `wasm_exec.js` that `make wasm` copies from the same Go toolchain.
Publish the clients in the same change as the server: a client built for `web/chess-client-web` is a symlink to the embedded web client; copy with
`/api/v1` gets 404 from this release. `cp -RL` or `rsync -L` so the files, not the link, reach the site. Publish the
clients in the same change as the server, and expect browsers to hold cached
copies until a hard refresh.
## Reference ## Reference
@@ -253,8 +288,9 @@ Restore the previous web client files on the host as well.
### Backups and restores ### Backups and restores
- `chess-backup` writes `pg_dump --format=custom` files nightly and removes - `chess-backup` ([`deploy/postgresql/chess-backup.sh`](../deploy/postgresql/chess-backup.sh),
those older than 14 days. Copy them off the jail. shared with Linux) writes `pg_dump --format=custom` files nightly and
removes those older than 14 days. Copy them off the jail.
- Restore into a freshly provisioned database: - Restore into a freshly provisioned database:
`su -m postgres -c 'pg_restore --no-owner --role=chess -d chess <dump>'` `su -m postgres -c 'pg_restore --no-owner --role=chess -d chess <dump>'`
(`--role=chess_owner` in split mode). (`--role=chess_owner` in split mode).
+1 -1
View File
@@ -2,7 +2,7 @@
## Prerequisites ## Prerequisites
- Go 1.26+ - Go 1.27+
- Stockfish in PATH - Stockfish in PATH
- PostgreSQL 18 (server for persistence; `psql` for the shell test suites) - PostgreSQL 18 (server for persistence; `psql` for the shell test suites)
- Git - Git
+54 -42
View File
@@ -28,6 +28,9 @@ into phases that each ship and test on their own.
- [x] Player-name snapshots on claim, returned in history and listings (D4). - [x] Player-name snapshots on claim, returned in history and listings (D4).
- [x] Scripted FreeBSD jail deployment (`deploy/`). - [x] Scripted FreeBSD jail deployment (`deploy/`).
- [x] Go client DTOs (`GetGameHistory`, `GetMyGames`) including `finalFen`. - [x] Go client DTOs (`GetGameHistory`, `GetMyGames`) including `finalFen`.
- [x] Rules core (`internal/server/chess`, R1) and replay API (R2), below.
- [x] Pawn promotion in the web client (piece picker) and a specific API error
for a promotion without its piece.
## Decisions ## Decisions
@@ -37,53 +40,52 @@ into phases that each ship and test on their own.
| D2 | History visibility | **Decided: shareable by game ID.** The history endpoint stays public to holders of the 122-bit random ID; IDs are never listed publicly and the list endpoint stays authenticated. | | D2 | History visibility | **Decided: shareable by game ID.** The history endpoint stays public to holders of the 122-bit random ID; IDs are never listed publicly and the list endpoint stays authenticated. |
| D3 | Where SAN/PGN is produced | **Decided: server-side Go** (R1), derived on read from the initial FEN and the UCI line: no schema change, one implementation for web and CLI, no third-party script under the CSP. | | 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 | Open. Recommendation: detect insufficient material, threefold repetition, and the 50/75-move rules in the R1 core; add resign/draw offers later. PGN `Result` is `*` for unterminated games. | | D5 | Draw rules | **Done.** Draws apply automatically after every move, as on most online servers: dead material, threefold repetition, and the fifty-move rule (FIDE's claimable draws, and so also its 75-move and fivefold rules). Draw offers (a computer answers from its evaluation) and resignation exist; the stored `termination` says how a game ended. |
| D6 | Undo after a result | **Decided: keep allowed**, so a player can step back from a finished game and play a line again. Consequence: a finished game's stored history, result, and end time are rewritten by undo; replay shows the line as it stands now. | | D6 | Undo after a result | **Decided: keep allowed**, so a player can step back from a finished game and play a line again. Consequence: a finished game's stored history, result, and end time are rewritten by undo; replay shows the line as it stands now. |
| D7 | List pagination | Open. Recommendation: keyset cursor on `(start_time_utc, game_id)`, keeping offset during v1; the claim indexes are already ordered for it. | | D7 | List pagination | **Done.** Opaque keyset `cursor` on `(start_time_utc, game_id)`; `offset` kept for existing clients and exclusive with `cursor`. |
## Phase R1 — Notation Core (server, no API change) ## Phase R1 — Notation Core (server) — done
A small, dependency-free chess core in Go (`internal/server/chess`), used by A dependency-free rules core in Go (`internal/server/chess`). Stockfish
replay only; Stockfish remains the move validator for live play. remains the move validator for live play; the core notates stored games and
shadows the engine on every live move, logging any disagreement.
- [ ] Board model with FEN parse/serialize (extend `internal/server/board`). - [x] Position with strict FEN parse (one king per side, no pawns on the back
- [ ] Legal move generation: pseudo-legal moves plus king-safety filtering, ranks, side not to move not in check; castling rights without their rook
castling rights and paths, en passant, promotion. dropped) and canonical serialization (en-passant square only when a capture
- [ ] SAN encode (disambiguation by file, rank, or both; `+`/`#`; `O-O`, is legal, so Stockfish's pseudo-legal convention normalizes away).
`O-O-O`; `=Q`) and SAN decode for later PGN import. - [x] Legal move generation with castling paths, en passant, promotion, pins.
- [ ] PGN writer: Seven Tag Roster (White/Black from the name snapshots, else - [x] SAN encode (file/rank/square disambiguation, `+`/`#`, `O-O`, `=Q`) and
"Anonymous" or "Stockfish level N"), `SetUp`/`FEN` tags for custom starts, lenient decode (`0-0`, `e8Q`, annotations, over-disambiguation).
`Termination`, 80-column movetext, `Result` from the stored outcome. - [x] PGN writer: Seven Tag Roster, supplemental tags, `SetUp`/`FEN` for custom
- [ ] Draw-rule helpers (D5) and an integrity check that replays the stored UCI starts with correct move numbering, 80-column movetext, escaped tag values.
line and compares each generated FEN with the stored `fenAfterMove`. - [x] Draw-rule helpers (D5) and `chess-server db verify`, which replays every
stored game against the stored FENs and results.
- [x] Custom starting FENs validated by the core before the engine sees them.
Test gate: Test gate:
- [ ] Perft node counts for the standard CPW positions (start, Kiwipete, and - [x] Perft for the CPW start, Kiwipete, and positions 3–6 (depth 4; position
positions 3–6) to depth 4, which exercises castling, en passant, promotion, 3 to depth 5).
and pins. - [x] Engine cross-check: random games from all six positions, comparing the
- [ ] Property tests over random legal games: SAN decode(encode(m)) == m; the legal move set with Stockfish `go perft 1` and the FEN with Stockfish `d` at
generated FEN sequence is identical to one produced by Stockfish's `d` every ply (about 6,000 positions per run; skipped without Stockfish).
command for the same line. - [x] Fuzz targets: FEN parse round trip with SAN/UCI round trip of every
- [ ] Fuzz targets for FEN parsing, SAN decoding, and PGN writing (no panics, legal move, SAN decoding, PGN tag escaping.
bounded output). - [ ] `db verify` over a copy of the production database once it holds games.
- [ ] Integrity check run over every stored game in a production-shaped copy.
## Phase R2 — Replay API ## Phase R2 — Replay API — done
- [ ] Implement D7 (keyset cursor) once decided. - [x] D7 keyset cursor, plus `status` and `color` filters on the list.
- [ ] Additive history fields: `san` per move, `outcome`/`termination`, and - [x] Additive history fields: `san` per move, `pgnResult`, `termination`.
`pgnResult`; keep existing fields unchanged. - [x] `GET /api/games/{id}/pgn?ply=N` as an attachment; `db pgn` in the CLI.
- [ ] `GET /api/games/{id}/pgn?ply=N`: `application/x-chess-pgn`, - [x] Strong `ETag` with `Cache-Control: private, no-cache` on history and PGN
`Content-Disposition: attachment; filename="chess-<date>-<id8>.pgn"`, (revalidate every time: D6 lets finished games change); `If-None-Match`
moves 1..N (default all). The FEN at any ply is already in the history. returns 304.
- [ ] `ETag` and `Cache-Control: private, max-age` for terminal games; - [x] Tests: rules-core unit, fuzz, and engine cross-check; service and
`If-None-Match` returns 304. handler tests against PostgreSQL; `test/test-replay.sh` over HTTP (SAN,
- [ ] Filters on the list endpoint: result, color, ongoing/finished. PGN, ETag, promotion, FEN validation, cursor paging and filters).
- [ ] Tests: handler tests against PostgreSQL (pgtest), golden PGN files for - [x] Terminal client: `games` (cursor paging) and `pgn` commands.
mate, stalemate, promotion, castling, en passant, custom FEN, and an ongoing
game; shell-suite coverage for authorization (another user's list, expired
session) and cursor stability under concurrent inserts.
## Phase R3 — Web Replay UI ## Phase R3 — Web Replay UI
@@ -101,7 +103,11 @@ Plain JavaScript in the embedded client, no framework.
- [ ] Position rendering assigns the stored FEN for the ply; the browser never - [ ] Position rendering assigns the stored FEN for the ply; the browser never
computes moves. computes moves.
- [ ] Export at the current ply: copy FEN, copy PGN, and download PGN (fetched - [ ] Export at the current ply: copy FEN, copy PGN, and download PGN (fetched
from R2 as a `Blob`, saved through a temporary `<a download>`). from R2 as a `Blob`, saved through a temporary `<a download>`). The live
view's "Copy PGN" button copies UCI moves today; point it at the PGN
endpoint too.
- [ ] Show SAN in the live move list (from the history, or a `san` field on
the live game response).
- [ ] While replaying: stop live long-polling, disable move, undo, new-game, - [ ] While replaying: stop live long-polling, disable move, undo, new-game,
and player-configuration controls; **Back to live game** restores them. and player-configuration controls; **Back to live game** restores them.
@@ -129,8 +135,9 @@ Test gate:
## Phase R4 — CLI and WASM Replay (optional) ## Phase R4 — CLI and WASM Replay (optional)
- [ ] `games` (list with pagination), `replay <gameId|index>`, `next`, `prev`, - [x] `games` (list with cursor paging) and `pgn [gameId] [ply]`.
`first`, `last`, `goto <ply>`, `auto <ms>`, `fen`, `pgn save <path>`. - [ ] `replay <gameId|index>`, `next`, `prev`, `first`, `last`, `goto <ply>`,
`auto <ms>`, `fen`, `pgn save <path>`.
- [ ] Replay state is separate from the live session: no polling, moves, undo, - [ ] Replay state is separate from the live session: no polling, moves, undo,
or configuration while replaying. or configuration while replaying.
@@ -156,4 +163,9 @@ Test gate:
- [ ] Retention and user-initiated deletion or anonymization of games. - [ ] Retention and user-initiated deletion or anonymization of games.
- [ ] Thread request contexts from HTTP handlers into storage calls; today each - [ ] Thread request contexts from HTTP handlers into storage calls; today each
call carries its own deadline. call carries its own deadline.
- [ ] Draw and resign flows (D5) for live play, independent of replay. - [x] Draw and resign flows (D5) for live play.
- [x] A game row deleted by hand no longer degrades storage; optional
integrity sweep (`-db-cleanup`) for broken or orphaned data.
- [ ] Once `db verify` and the live shadow check stay clean in production,
consider validating human moves with the core instead of the engine: no
engine round trip or lock per move, and Stockfish only for computer play.
+1 -1
View File
@@ -14,7 +14,7 @@ require (
require ( require (
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.15 // indirect github.com/gabriel-vasile/mimetype v1.4.15 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.2 // indirect
github.com/go-playground/universal-translator v0.18.2 // indirect github.com/go-playground/universal-translator v0.18.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
+2 -2
View File
@@ -7,8 +7,8 @@ github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ= github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.2 h1:d8UmcrM6Nip0hfGZKLGpAvZH37XB4TS0xzK9B56YNCY=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/locales v0.14.2/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.2 h1:LCsMLC9RzmbUMNUPVYD15dmcjwYAJhmX8mPZRW4rAVU= github.com/go-playground/universal-translator v0.18.2 h1:LCsMLC9RzmbUMNUPVYD15dmcjwYAJhmX8mPZRW4rAVU=
github.com/go-playground/universal-translator v0.18.2/go.mod h1:67VZIMp5lQpDWlnStOct22q1bkdJGJqHghbOtmkawxk= github.com/go-playground/universal-translator v0.18.2/go.mod h1:67VZIMp5lQpDWlnStOct22q1bkdJGJqHghbOtmkawxk=
github.com/go-playground/validator/v10 v10.30.5 h1:YyCXvVShZbs2Sm3Mb53eNOlhRXctSOzW5QJAouCTZL4= github.com/go-playground/validator/v10 v10.30.5 h1:YyCXvVShZbs2Sm3Mb53eNOlhRXctSOzW5QJAouCTZL4=
+40 -3
View File
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"strings" "strings"
"time" "time"
@@ -140,7 +141,11 @@ func (c *Client) doRequest(method, path string, body any, result any) error {
return fmt.Errorf("request failed with status %d", resp.StatusCode) return fmt.Errorf("request failed with status %d", resp.StatusCode)
} }
// Parse success response // Parse success response; a *string receives a non-JSON body as-is
if text, ok := result.(*string); ok {
*text = string(respBody)
return nil
}
if result != nil && len(respBody) > 0 { if result != nil && len(respBody) > 0 {
if err := json.Unmarshal(respBody, result); err != nil { if err := json.Unmarshal(respBody, result); err != nil {
// For debug, show raw response if parsing fails // For debug, show raw response if parsing fails
@@ -198,6 +203,22 @@ func (c *Client) UndoMoves(gameID string, count int) (*GameResponse, error) {
return &resp, err return &resp, err
} }
// Resign ends the game in the opponent's favor; color may be "" when the
// caller plays exactly one human side.
func (c *Client) Resign(gameID, color string) (*GameResponse, error) {
var resp GameResponse
err := c.doRequest("POST", "/api/games/"+gameID+"/resign", &ResignRequest{Color: color}, &resp)
return &resp, err
}
// Draw offers, accepts, or declines a draw; the response's DrawOutcome says
// what happened.
func (c *Client) Draw(gameID, action, color string) (*GameResponse, error) {
var resp GameResponse
err := c.doRequest("POST", "/api/games/"+gameID+"/draw", &DrawRequest{Action: action, Color: color}, &resp)
return &resp, err
}
func (c *Client) GetBoard(gameID string) (*BoardResponse, error) { func (c *Client) GetBoard(gameID string) (*BoardResponse, error) {
var resp BoardResponse var resp BoardResponse
err := c.doRequest("GET", "/api/games/"+gameID+"/board", nil, &resp) err := c.doRequest("GET", "/api/games/"+gameID+"/board", nil, &resp)
@@ -210,9 +231,25 @@ func (c *Client) GetGameHistory(gameID string) (*GameHistoryResponse, error) {
return &resp, err return &resp, err
} }
func (c *Client) GetMyGames(limit, offset int) (*GameListResponse, error) { // GetGamePGN returns the stored game in PGN; ply < 0 exports every move.
func (c *Client) GetGamePGN(gameID string, ply int) (string, error) {
path := "/api/games/" + gameID + "/pgn"
if ply >= 0 {
path += fmt.Sprintf("?ply=%d", ply)
}
var text string
err := c.doRequest("GET", path, nil, &text)
return text, err
}
// GetMyGames lists the caller's games, newest first; cursor is "" for the
// first page, then the previous page's NextCursor.
func (c *Client) GetMyGames(limit int, cursor string) (*GameListResponse, error) {
var resp GameListResponse var resp GameListResponse
path := fmt.Sprintf("/api/users/me/games?limit=%d&offset=%d", limit, offset) path := fmt.Sprintf("/api/users/me/games?limit=%d", limit)
if cursor != "" {
path += "&cursor=" + url.QueryEscape(cursor)
}
err := c.doRequest("GET", path, nil, &resp) err := c.doRequest("GET", path, nil, &resp)
return &resp, err return &resp, err
} }
+17
View File
@@ -40,11 +40,23 @@ type GameResponse struct {
FEN string `json:"fen"` FEN string `json:"fen"`
Turn string `json:"turn"` Turn string `json:"turn"`
State string `json:"state"` State string `json:"state"`
Termination string `json:"termination,omitempty"`
DrawOffer string `json:"drawOffer,omitempty"`
DrawOutcome string `json:"drawOutcome,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"`
} }
type ResignRequest struct {
Color string `json:"color,omitempty"`
}
type DrawRequest struct {
Action string `json:"action"`
Color string `json:"color,omitempty"`
}
type PlayersResponse struct { type PlayersResponse struct {
White PlayerInfo `json:"white"` White PlayerInfo `json:"white"`
Black PlayerInfo `json:"black"` Black PlayerInfo `json:"black"`
@@ -104,6 +116,8 @@ type GameHistoryResponse struct {
GameID string `json:"gameId"` GameID string `json:"gameId"`
InitialFEN string `json:"initialFen"` InitialFEN string `json:"initialFen"`
Result string `json:"result,omitempty"` Result string `json:"result,omitempty"`
PGNResult string `json:"pgnResult"`
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"`
Players PlayersResponse `json:"players"` Players PlayersResponse `json:"players"`
@@ -113,6 +127,7 @@ type GameHistoryResponse struct {
type HistoryMove struct { type HistoryMove struct {
MoveNumber int `json:"moveNumber"` MoveNumber int `json:"moveNumber"`
MoveUCI string `json:"moveUci"` MoveUCI string `json:"moveUci"`
SAN string `json:"san,omitempty"`
FENAfterMove string `json:"fenAfterMove"` FENAfterMove string `json:"fenAfterMove"`
PlayerColor string `json:"playerColor"` PlayerColor string `json:"playerColor"`
MoveTimeUTC time.Time `json:"moveTimeUtc"` MoveTimeUTC time.Time `json:"moveTimeUtc"`
@@ -123,6 +138,7 @@ type GameSummary struct {
InitialFEN string `json:"initialFen"` InitialFEN string `json:"initialFen"`
FinalFEN string `json:"finalFen"` FinalFEN string `json:"finalFen"`
Result string `json:"result,omitempty"` Result string `json:"result,omitempty"`
PGNResult string `json:"pgnResult"`
StartTimeUTC time.Time `json:"startTimeUtc"` StartTimeUTC time.Time `json:"startTimeUtc"`
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"` EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
MoveCount int `json:"moveCount"` MoveCount int `json:"moveCount"`
@@ -134,4 +150,5 @@ type GameListResponse struct {
Limit int `json:"limit"` Limit int `json:"limit"`
Offset int `json:"offset"` Offset int `json:"offset"`
NextOffset *int `json:"nextOffset,omitempty"` NextOffset *int `json:"nextOffset,omitempty"`
NextCursor string `json:"nextCursor,omitempty"`
} }
+190 -9
View File
@@ -33,7 +33,7 @@ func (r *Registry) registerGameCommands() {
Name: "move", Name: "move",
ShortName: "m", ShortName: "m",
Description: "Make a move", Description: "Make a move",
Usage: "move <uci-move>", Usage: "move <uci-move> (e2e4; castling e1g1; promotion e7e8q, e7e8n)",
Handler: moveHandler, Handler: moveHandler,
}) })
@@ -77,6 +77,38 @@ func (r *Registry) registerGameCommands() {
Handler: deleteGameHandler, Handler: deleteGameHandler,
}) })
r.Register(&Command{
Name: "resign",
ShortName: "",
Description: "Resign the current game",
Usage: "resign [w|b] (side optional when you play one human side)",
Handler: resignHandler,
})
r.Register(&Command{
Name: "draw",
ShortName: "",
Description: "Offer, accept, or decline a draw",
Usage: "draw [offer|accept|decline] [w|b] (default: offer)",
Handler: drawHandler,
})
r.Register(&Command{
Name: "games",
ShortName: "g",
Description: "List your stored games, newest first (login required)",
Usage: "games [more]",
Handler: gamesHandler,
})
r.Register(&Command{
Name: "pgn",
ShortName: "",
Description: "Print a stored game as PGN",
Usage: "pgn [gameId] [ply] (default: current game, all plies)",
Handler: pgnHandler,
})
r.Register(&Command{ r.Register(&Command{
Name: "poll", Name: "poll",
ShortName: "p", ShortName: "p",
@@ -238,7 +270,7 @@ func joinGameHandler(s *session.Session, args []string) error {
// hint stays local since it only applies after a successful human move. // hint stays local since it only applies after a successful human move.
func moveHandler(s *session.Session, args []string) error { func moveHandler(s *session.Session, args []string) error {
if len(args) < 1 { if len(args) < 1 {
return fmt.Errorf("usage: move <uci-move>") return fmt.Errorf("usage: move <uci-move> (promotion appends q, r, b, or n: e7e8q)")
} }
gameID := s.CurrentGame gameID := s.CurrentGame
@@ -345,21 +377,101 @@ func computerMoveHandler(s *session.Session, args []string) error {
return nil return nil
} }
// printOutcome reports terminal or error states using the server's actual // printOutcome reports terminal or error states using the server's State
// State.String() values ("white wins"/"black wins", not "checkmate"). // ("white wins", "draw", ...) and Termination ("checkmate", "resignation",
// "threefold_repetition", ...) values, and a pending draw offer.
func printOutcome(resp *api.GameResponse) { func printOutcome(resp *api.GameResponse) {
switch resp.State { switch resp.State {
case "white wins": case "white wins", "black wins":
display.Println(display.Green, "\nCHECKMATE! White wins!") winner := "White"
case "black wins": if resp.State == "black wins" {
display.Println(display.Green, "\nCHECKMATE! Black wins!") winner = "Black"
}
if resp.Termination == "resignation" {
display.Println(display.Green, "\n%s wins by resignation.", winner)
} else {
display.Println(display.Green, "\nCHECKMATE! %s wins!", winner)
}
case "stalemate": case "stalemate":
display.Println(display.Yellow, "\nSTALEMATE! Game drawn.") display.Println(display.Yellow, "\nSTALEMATE! Game drawn.")
case "draw": case "draw":
display.Println(display.Yellow, "\nDRAW! Game drawn.") display.Println(display.Yellow, "\nDRAW %s.", drawReason(resp.Termination))
case "stuck": case "stuck":
display.Println(display.Yellow, "\nEngine error — 'undo' to recover, or 'new'/'delete'.") display.Println(display.Yellow, "\nEngine error — 'undo' to recover, or 'new'/'delete'.")
} }
if resp.DrawOffer != "" {
offerer := "White"
if resp.DrawOffer == "b" {
offerer = "Black"
}
display.Println(display.Magenta, "%s offers a draw: 'draw accept' or 'draw decline'.", offerer)
}
}
func drawReason(termination string) string {
switch termination {
case "insufficient_material":
return "by insufficient material"
case "threefold_repetition":
return "by threefold repetition"
case "fifty_move_rule":
return "by the fifty-move rule"
case "agreement":
return "by agreement"
}
return ""
}
// resignHandler resigns the current game for the given side, or the side the
// server infers (the one human side, or the one the user claimed).
func resignHandler(s *session.Session, args []string) error {
gameID := s.CurrentGame
if gameID == "" {
return fmt.Errorf("no current game, use 'new' or 'join <gameId>'")
}
color := ""
if len(args) > 0 {
color = args[0]
}
resp, err := s.Client.Resign(gameID, color)
if err != nil {
return err
}
s.LastMoveCount = len(resp.Moves)
s.CurrentGameState = resp
printOutcome(resp)
return nil
}
// drawHandler offers (default), accepts, or declines a draw.
func drawHandler(s *session.Session, args []string) error {
gameID := s.CurrentGame
if gameID == "" {
return fmt.Errorf("no current game, use 'new' or 'join <gameId>'")
}
action, color := "offer", ""
for _, arg := range args {
switch arg {
case "offer", "accept", "decline":
action = arg
default:
color = arg
}
}
resp, err := s.Client.Draw(gameID, action, color)
if err != nil {
return err
}
s.LastMoveCount = len(resp.Moves)
s.CurrentGameState = resp
switch resp.DrawOutcome {
case "offered":
display.Println(display.Green, "Draw offered; it stands until answered or the opponent moves.")
case "declined":
display.Println(display.Yellow, "Draw declined.")
}
printOutcome(resp)
return nil
} }
func undoHandler(s *session.Session, args []string) error { func undoHandler(s *session.Session, args []string) error {
@@ -530,3 +642,72 @@ func pollHandler(s *session.Session, args []string) error {
return nil return nil
} }
// gamesHandler lists the stored games of the signed-in user, 20 at a time;
// "games more" continues from the previous page.
func gamesHandler(s *session.Session, args []string) error {
if s.AuthToken == "" {
return fmt.Errorf("login required")
}
cursor := ""
if len(args) > 0 && args[0] == "more" {
if s.GamesCursor == "" {
return fmt.Errorf("no further games; run 'games' to list from the newest")
}
cursor = s.GamesCursor
}
resp, err := s.Client.GetMyGames(20, cursor)
if err != nil {
return err
}
s.GamesCursor = resp.NextCursor
if len(resp.Games) == 0 {
display.Println(display.Yellow, "No stored games")
return nil
}
fmt.Printf("\n%-36s %-20s %-20s %-7s %5s %s\n", "Game ID", "White", "Black", "Result", "Plies", "Started (UTC)")
for _, g := range resp.Games {
fmt.Printf("%-36s %-20s %-20s %-7s %5d %s\n", g.GameID,
playerLabel(g.Players.White), playerLabel(g.Players.Black), g.PGNResult, g.MoveCount,
g.StartTimeUTC.UTC().Format("2006-01-02 15:04"))
}
if resp.NextCursor != "" {
display.Println(display.Cyan, "\nMore games: games more")
}
return nil
}
func playerLabel(p api.PlayerInfo) string {
switch {
case p.Type == 2:
return fmt.Sprintf("Stockfish L%d", p.Level)
case p.Name != "":
return p.Name
}
return "anonymous"
}
// pgnHandler prints the stored game as PGN: the current game unless an ID is
// given, and every ply unless a count is given.
func pgnHandler(s *session.Session, args []string) error {
gameID, ply := s.CurrentGame, -1
for _, arg := range args {
if n, err := strconv.Atoi(arg); err == nil && n >= 0 {
ply = n
} else {
gameID = arg
}
}
if gameID == "" {
return fmt.Errorf("no current game, use 'join <gameId>' or 'pgn <gameId>'")
}
text, err := s.Client.GetGamePGN(gameID, ply)
if err != nil {
return err
}
fmt.Println()
fmt.Print(text)
return nil
}
+4
View File
@@ -126,6 +126,10 @@ func (r *Registry) helpHandler(s *session.Session, args []string) error {
{"state", "s", ""}, {"state", "s", ""},
{"delete", "d", ""}, {"delete", "d", ""},
{"poll", "p", ""}, {"poll", "p", ""},
{"resign", "", ""},
{"draw", "", ""},
{"games", "g", ""},
{"pgn", "", ""},
} }
authCommands := []cmdInfo{ authCommands := []cmdInfo{
+1
View File
@@ -12,6 +12,7 @@ type Session struct {
AuthToken string AuthToken string
Username string Username string
LastMoveCount int LastMoveCount int
GamesCursor string // next page of the "games" listing
Client *api.Client Client *api.Client
Verbose bool Verbose bool
// Game state for prompt // Game state for prompt
-119
View File
@@ -1,119 +0,0 @@
package board
import (
"fmt"
"strings"
"chess/internal/server/core"
)
const (
StartingFEN = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
)
type Board struct {
squares [8][8]byte
turn core.Color
castling string
enPassant string
halfmove int
fullmove int
}
// FromFEN creates a Board from a FEN string with validation
func ParseFEN(fen string) (*Board, error) {
parts := strings.Fields(fen)
if len(parts) != 6 {
return nil, fmt.Errorf("invalid FEN: expected 6 parts, got %d", len(parts))
}
b := &Board{}
// Parse board
ranks := strings.Split(parts[0], "/")
if len(ranks) != 8 {
return nil, fmt.Errorf("invalid FEN: expected 8 ranks")
}
for r := 0; r < 8; r++ {
file := 0
for _, ch := range ranks[r] {
if ch >= '1' && ch <= '8' {
file += int(ch - '0')
} else {
if file >= 8 {
return nil, fmt.Errorf("invalid FEN: too many pieces in rank %d", r+1)
}
b.squares[r][file] = byte(ch)
file++
}
}
if file != 8 {
return nil, fmt.Errorf("invalid FEN: rank %d has %d files", r+1, file)
}
}
// Parse game state with validation
if len(parts[1]) != 1 {
return nil, fmt.Errorf("invalid FEN: turn must be 'w' or 'b'")
}
switch parts[1] {
case "w":
b.turn = core.ColorWhite
case "b":
b.turn = core.ColorBlack
default:
return nil, fmt.Errorf("invalid FEN: turn must be 'w' or 'b'")
}
b.castling = parts[2]
b.enPassant = parts[3]
if _, err := fmt.Sscanf(parts[4], "%d", &b.halfmove); err != nil {
return nil, fmt.Errorf("invalid FEN: halfmove counter")
}
if _, err := fmt.Sscanf(parts[5], "%d", &b.fullmove); err != nil {
return nil, fmt.Errorf("invalid FEN: fullmove counter")
}
return b, nil
}
// ToASCII creates an ASCII representation of the board
func (b *Board) ToASCII() string {
var sb strings.Builder
sb.WriteString(" a b c d e f g h\n")
for r := 0; r < 8; r++ {
sb.WriteString(fmt.Sprintf("%d ", 8-r))
for f := 0; f < 8; f++ {
square := fmt.Sprintf("%c%c", 'a'+f, '8'-r)
piece := b.GetPieceAt(square)
if piece == 0 {
sb.WriteString(". ")
} else {
sb.WriteString(fmt.Sprintf("%c ", piece))
}
}
sb.WriteString(fmt.Sprintf("%d\n", 8-r))
}
sb.WriteString(" a b c d e f g h")
return sb.String()
}
func (b *Board) Turn() core.Color {
return b.turn
}
func (b *Board) GetPieceAt(square string) byte {
if len(square) != 2 {
return 0
}
if square[0] < 'a' || square[0] > 'h' || square[1] < '1' || square[1] > '8' {
return 0
}
file := square[0] - 'a'
rank := '8' - square[1]
return b.squares[rank][file]
}
+370
View File
@@ -0,0 +1,370 @@
package chess
import (
"errors"
"strings"
"testing"
)
// Perft reference counts from the Chess Programming Wiki "Perft Results"
// page; together they cover castling, en passant, promotion, pins, and checks.
var perftCases = []struct {
name string
fen string
counts []uint64 // depth 1..n
}{
{"start", StartFEN, []uint64{20, 400, 8902, 197281}},
{"kiwipete", "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1",
[]uint64{48, 2039, 97862, 4085603}},
{"position3", "8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1", []uint64{14, 191, 2812, 43238, 674624}},
{"position4", "r3k2r/Pppp1ppp/1b3nbN/nP6/BBP1P3/q4N2/Pp1P2PP/R2Q1RK1 w kq - 0 1",
[]uint64{6, 264, 9467, 422333}},
{"position4-mirrored", "r2q1rk1/pP1p2pp/Q4n2/bbp1p3/Np6/1B3NBn/pPPP1PPP/R3K2R b KQ - 0 1",
[]uint64{6, 264, 9467, 422333}},
{"position5", "rnbq1k1r/pp1Pbppp/2p5/8/2B5/8/PPP1NnPP/RNBQK2R w KQ - 1 8", []uint64{44, 1486, 62379, 2103487}},
{"position6", "r4rk1/1pp1qppp/p1np1n2/2b1p1B1/2B1P1b1/P1NP1N2/1PP1QPPP/R4RK1 w - - 0 10",
[]uint64{46, 2079, 89890, 3894594}},
}
func TestPerft(t *testing.T) {
for _, tc := range perftCases {
t.Run(tc.name, func(t *testing.T) {
pos, err := ParseFEN(tc.fen)
if err != nil {
t.Fatal(err)
}
for i, want := range tc.counts {
depth := i + 1
if testing.Short() && want > 500000 {
break
}
if got := pos.Perft(depth); got != want {
t.Fatalf("perft(%d) = %d, want %d", depth, got, want)
}
}
})
}
}
func TestFENRoundTrip(t *testing.T) {
for _, tc := range perftCases {
pos, err := ParseFEN(tc.fen)
if err != nil {
t.Fatal(err)
}
if got := pos.FEN(); got != tc.fen {
t.Errorf("FEN() = %q, want %q", got, tc.fen)
}
}
}
func TestParseFENRejects(t *testing.T) {
for _, fen := range []string{
"",
"8/8/8/8/8/8/8/8 w - - 0 1", // no kings
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0", // 5 fields
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNRR w KQkq - 0 1", // 9 files
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBN w KQkq - 0 1", // 7 files
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR x KQkq - 0 1", // side
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkqK - 0 1", // castling length
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KK - 0 1", // duplicate right
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq e9 0 1", // ep square
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - -1 1", // halfmove
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 0", // fullmove
"Pnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", // pawn on rank 8
"4k3/8/8/8/8/8/8/4K2r b - - 0 1", // side not to move in check
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1 x", // 7 fields
} {
if _, err := ParseFEN(fen); !errors.Is(err, ErrInvalidFEN) {
t.Errorf("ParseFEN(%q) error = %v, want ErrInvalidFEN", fen, err)
}
}
}
func TestParseFENNormalizes(t *testing.T) {
cases := []struct{ in, want string }{
// Castling rights without their rook are dropped.
{"4k3/8/8/8/8/8/8/4K3 w KQkq - 0 1", "4k3/8/8/8/8/8/8/4K3 w - - 0 1"},
// Right order is canonical.
{"r3k2r/8/8/8/8/8/8/R3K2R w qkQK - 0 1", "r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1"},
// Pseudo-legal en passant (no capturing pawn) is dropped.
{"rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1",
"rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1"},
// A capture that would expose the king (pinned along the rank) is illegal.
{"8/8/8/8/k2pP2R/8/8/4K3 b - e3 0 1", "8/8/8/8/k2pP2R/8/8/4K3 b - - 0 1"},
// A legal en-passant capture is kept.
{"4k3/8/8/8/3pP3/8/8/3K3R b - e3 0 1", "4k3/8/8/8/3pP3/8/8/3K3R b - e3 0 1"},
}
for _, tc := range cases {
got, err := NormalizeFEN(tc.in)
if err != nil {
t.Fatalf("NormalizeFEN(%q): %v", tc.in, err)
}
if got != tc.want {
t.Errorf("NormalizeFEN(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestSAN(t *testing.T) {
cases := []struct {
fen, uci, san string
}{
{StartFEN, "e2e4", "e4"},
{StartFEN, "g1f3", "Nf3"},
{"r1bqkbnr/pppp1ppp/2n5/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R w KQkq - 2 3", "f1b5", "Bb5"},
// Castling both ways.
{"r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1", "e1g1", "O-O"},
{"r3k2r/8/8/8/8/8/8/R3K2R b KQkq - 0 1", "e8c8", "O-O-O"},
// File disambiguation: knights on b1 and f3 both reach d2.
{"4k3/8/8/8/8/5N2/8/1N2K3 w - - 0 1", "b1d2", "Nbd2"},
// Rank disambiguation: rooks on a1 and a5 both reach a3.
{"4k3/8/8/R7/8/8/8/R3K3 w - - 0 1", "a1a3", "R1a3"},
// Both: queens on e4, h4, and h1 all reach e1.
{"1k6/8/8/8/4Q2Q/8/8/K6Q w - - 0 1", "h4e1", "Qh4e1"},
// Pawn capture, en passant, promotion with check and capture.
{"rnbqkbnr/ppp1pppp/8/3p4/4P3/8/PPPP1PPP/RNBQKBNR w KQkq d6 0 2", "e4d5", "exd5"},
{"rnbqkbnr/ppp1p1pp/8/3pPp2/8/8/PPPP1PPP/RNBQKBNR w KQkq f6 0 3", "e5f6", "exf6"},
{"8/5KP1/5n2/p3k3/1p3p2/1P6/P7/8 w - - 4 55", "g7g8q", "g8=Q"},
{"8/5KP1/5n2/p3k3/1p3p2/1P6/P7/8 w - - 4 55", "g7g8n", "g8=N"},
{"3r3k/4P3/8/8/8/8/8/K7 w - - 0 1", "e7d8q", "exd8=Q+"},
// Mate.
{"rnbqkbnr/ppppp2p/5p2/6p1/4P3/8/PPPP1PPP/RNBQKBNR w KQkq g6 0 3", "d1h5", "Qh5#"},
}
for _, tc := range cases {
pos, err := ParseFEN(tc.fen)
if err != nil {
t.Fatalf("%s: %v", tc.fen, err)
}
m, err := pos.ParseUCI(tc.uci)
if err != nil {
t.Fatalf("%s %s: %v", tc.fen, tc.uci, err)
}
if got := pos.SAN(m); got != tc.san {
t.Errorf("%s %s: SAN = %q, want %q", tc.fen, tc.uci, got, tc.san)
}
back, err := pos.ParseSAN(tc.san)
if err != nil || back != m {
t.Errorf("ParseSAN(%q) = %v, %v; want %v", tc.san, back, err, m)
}
}
}
func TestParseSANLenient(t *testing.T) {
pos, err := ParseFEN("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1")
if err != nil {
t.Fatal(err)
}
for in, want := range map[string]string{"0-0": "e1g1", "O-O-O": "e1c1", "Kf1!?": "e1f1", "Ra1b1": "a1b1"} {
m, err := pos.ParseSAN(in)
if err != nil || m.UCI() != want {
t.Errorf("ParseSAN(%q) = %v, %v; want %s", in, m.UCI(), err, want)
}
}
promo, _ := ParseFEN("8/5KP1/5n2/p3k3/1p3p2/1P6/P7/8 w - - 4 55")
if m, err := promo.ParseSAN("g8Q"); err != nil || m.UCI() != "g7g8q" {
t.Errorf("ParseSAN(g8Q) = %v, %v", m.UCI(), err)
}
if _, err := promo.ParseSAN("g8"); !errors.Is(err, ErrIllegalMove) {
t.Errorf("ParseSAN(g8) error = %v, want ErrIllegalMove", err)
}
knights, _ := ParseFEN("4k3/8/8/8/8/5N2/8/1N2K3 w - - 0 1")
if _, err := knights.ParseSAN("Nd2"); err == nil || !strings.Contains(err.Error(), "ambiguous") {
t.Errorf("ParseSAN(Nd2) error = %v, want ambiguous", err)
}
}
func TestParseUCIPromotionRequired(t *testing.T) {
pos, err := ParseFEN("8/5KP1/5n2/p3k3/1p3p2/1P6/P7/8 w - - 4 55")
if err != nil {
t.Fatal(err)
}
if _, err := pos.ParseUCI("g7g8"); !errors.Is(err, ErrPromotionRequired) {
t.Fatalf("ParseUCI(g7g8) error = %v, want ErrPromotionRequired", err)
}
if _, err := pos.ParseUCI("f7g8"); !errors.Is(err, ErrIllegalMove) {
t.Fatalf("ParseUCI(f7g8) error = %v, want ErrIllegalMove", err)
}
}
// The reported game: every stored FEN must match the replay, and the final
// position must accept the promotion that the web client could not send.
func TestReplayReportedGame(t *testing.T) {
uci := strings.Fields(`e2e4 e7e6 d2d4 d7d5 b1c3 a7a6 g1f3 g8f6 f1d3 f6e4 c3e4 d5e4 d3e4 c7c5 c2c3 f8e7
c1e3 c5d4 c3d4 b8d7 e1g1 d7f6 e3g5 f6e4 g5e7 d8e7 d1d3 f7f5 a1c1 e7d6 f3e5 c8d7 f2f3 e4f6 e5d7 d6d7
f1e1 e8g8 d3e3 f8f7 e3e6 a8d8 e6d7 f7d7 c1d1 d7d4 d1d4 d8d4 e1e5 g8f7 e5f5 g7g6 f5a5 f7e7 b2b3 d4d5
a5d5 f6d5 g1f2 e7d6 g2g4 d6e5 f2g3 b7b5 h2h4 d5f6 f3f4 e5e4 g4g5 f6d5 g3g4 a6a5 h4h5 d5e3 g4g3 b5b4
h5g6 h7g6 g3h4 e4f3 h4h3 f3e4 h3g3 e3f5 g3g4 f5e7 g4g3 e7c6 g3g4 e4e3 f4f5 c6e5 g4h4 g6f5 h4h5 e3f4
g5g6 e5d7 g6g7 d7f6 h5g6 f4e5 g6g5 f5f4 g5g6 f6g8 g6f7 g8f6`)
line, err := Replay(StartFEN, uci)
if err != nil {
t.Fatal(err)
}
if got, want := line.Final().FEN(), "8/5KP1/5n2/p3k3/1p3p2/1P6/P7/8 w - - 4 55"; got != want {
t.Fatalf("final FEN = %q, want %q", got, want)
}
if line.SAN[20] != "O-O" || line.SAN[37] != "O-O" {
t.Errorf("castling SAN = %q, %q", line.SAN[20], line.SAN[37])
}
next, err := Replay(StartFEN, append(uci, "g7g8q"))
if err != nil {
t.Fatal(err)
}
if got := next.SAN[len(next.SAN)-1]; got != "g8=Q" {
t.Errorf("promotion SAN = %q", got)
}
}
func TestReplayReportsPly(t *testing.T) {
_, err := Replay(StartFEN, []string{"e2e4", "e7e5", "e1e3"})
var plyErr *PlyError
if !errors.As(err, &plyErr) || plyErr.Ply != 3 || !errors.Is(err, ErrIllegalMove) {
t.Fatalf("error = %v, want illegal move at ply 3", err)
}
}
func TestDrawPrimitives(t *testing.T) {
for fen, want := range map[string]bool{
"4k3/8/8/8/8/8/8/4K3 w - - 0 1": true, // bare kings
"4k3/8/8/8/8/8/8/3NK3 w - - 0 1": true, // one knight
"3bk3/8/8/8/8/8/8/2B1K3 w - - 0 1": true, // bishops on one color
"2b1k3/8/8/8/8/8/8/2B1K3 w - - 0 1": false, // opposite-colored bishops
"4k3/8/8/8/8/8/8/2NNK3 w - - 0 1": false, // two knights can mate with help
"4k3/8/8/8/8/8/P7/4K3 w - - 0 1": false,
} {
pos, err := ParseFEN(fen)
if err != nil {
t.Fatal(err)
}
if got := pos.InsufficientMaterial(); got != want {
t.Errorf("%s: InsufficientMaterial = %v, want %v", fen, got, want)
}
}
cycle := []string{"g1f3", "g8f6", "f3g1", "f6g8"}
line, err := Replay(StartFEN, repeat(cycle, 2))
if err != nil {
t.Fatal(err)
}
for ply, want := range map[int]int{0: 1, 3: 1, 4: 2, 5: 2, 8: 3} {
if got := line.Repetitions(ply); got != want {
t.Errorf("Repetitions(%d) = %d, want %d", ply, got, want)
}
}
// A pawn move in between resets the window: the start position cannot
// recur after 1. e3.
line, err = Replay(StartFEN, append([]string{"e2e3", "e7e6"}, repeat(cycle, 2)...))
if err != nil {
t.Fatal(err)
}
if got := line.Repetitions(10); got != 3 {
t.Errorf("after pawn moves: Repetitions(10) = %d, want 3", got)
}
if got := Repetitions(nil); got != 0 {
t.Errorf("Repetitions(nil) = %d", got)
}
}
func repeat(moves []string, n int) []string {
var out []string
for i := 0; i < n; i++ {
out = append(out, moves...)
}
return out
}
func TestPGN(t *testing.T) {
line, err := Replay(StartFEN, []string{"f2f3", "e7e5", "g2g4", "d8h4"})
if err != nil {
t.Fatal(err)
}
got := PGN{
Tags: []Tag{
{"Event", "Casual game"}, {"Date", "2026.09.27"}, {"White", `Al "the" \ pawn`},
{"Black", "Stockfish level 3"}, {"Termination", "normal"}, {"Bad Tag", "x"}, {"Evil", "a\"]\n[X \"y"},
},
StartFEN: StartFEN,
SAN: line.SAN,
Result: "0-1",
}.String()
want := `[Event "Casual game"]
[Site "?"]
[Date "2026.09.27"]
[Round "-"]
[White "Al \"the\" \\ pawn"]
[Black "Stockfish level 3"]
[Result "0-1"]
[Termination "normal"]
[Evil "a\"][X \"y"]
1. f3 e5 2. g4 Qh4# 0-1
`
if got != want {
t.Fatalf("PGN mismatch\n got:\n%s\nwant:\n%s", got, want)
}
}
func TestPGNCustomStartAndWrap(t *testing.T) {
start := "4k3/8/8/8/8/8/8/R3K3 b - - 0 40"
var uci []string
for i := 0; i < 20; i++ {
// Shuffle king and rook without repeating five times in a row.
uci = append(uci, [][]string{{"e8d8", "a1a2"}, {"d8e8", "a2a1"}}[i%2]...)
}
line, err := Replay(start, uci[:30])
if err != nil {
t.Fatal(err)
}
text := PGN{Tags: []Tag{{"Date", "2026.01.02"}}, StartFEN: start, SAN: line.SAN, Result: "garbage"}.String()
if !strings.Contains(text, "[SetUp \"1\"]\n[FEN \""+start+"\"]\n") {
t.Errorf("missing SetUp/FEN tags:\n%s", text)
}
movetext := text[strings.Index(text, "\n\n")+2:]
if !strings.HasPrefix(movetext, "40... Kd8 41. Ra2 Ke8 ") {
t.Errorf("movetext starts %q", movetext[:30])
}
if !strings.HasSuffix(movetext, " *\n") {
t.Errorf("result token missing: %q", movetext)
}
for _, l := range strings.Split(strings.TrimSuffix(movetext, "\n"), "\n") {
if len(l) > pgnLineWidth {
t.Errorf("line longer than %d: %q", pgnLineWidth, l)
}
}
}
func TestResultToken(t *testing.T) {
for in, want := range map[string]string{"white_wins": "1-0", "black_wins": "0-1", "draw": "1/2-1/2",
"stalemate": "1/2-1/2", "": "*", "other": "*"} {
if got := ResultToken(in); got != want {
t.Errorf("ResultToken(%q) = %q, want %q", in, got, want)
}
}
}
func TestASCII(t *testing.T) {
pos, err := ParseFEN(StartFEN)
if err != nil {
t.Fatal(err)
}
want := ` a b c d e f g h
8 r n b q k b n r 8
7 p p p p p p p p 7
6 . . . . . . . . 6
5 . . . . . . . . 5
4 . . . . . . . . 4
3 . . . . . . . . 3
2 P P P P P P P P 2
1 R N B Q K B N R 1
a b c d e f g h`
if got := pos.ASCII(); got != want {
t.Fatalf("ASCII() =\n%s\nwant\n%s", got, want)
}
}
func TestPGNComment(t *testing.T) {
text := PGN{SAN: []string{"e4", "e5"}, Comment: "White {resigns}.\n", Result: "0-1"}.String()
if !strings.HasSuffix(text, "\n\n1. e4 e5 { White resigns. } 0-1\n") {
t.Fatalf("PGN comment:\n%s", text)
}
}
+98
View File
@@ -0,0 +1,98 @@
package chess
import (
"regexp"
"strings"
"testing"
)
func FuzzParseFEN(f *testing.F) {
for _, tc := range perftCases {
f.Add(tc.fen)
}
f.Add("8/8/8/8/k2pP2R/8/8/4K3 b - e3 0 1")
f.Add("4k3/8/8/8/8/8/8/4K3 w KQkq - 0 1")
f.Fuzz(func(t *testing.T, fen string) {
pos, err := ParseFEN(fen)
if err != nil {
return
}
out := pos.FEN()
again, err := ParseFEN(out)
if err != nil {
t.Fatalf("FEN() output %q does not parse: %v", out, err)
}
if again.FEN() != out {
t.Fatalf("FEN not stable: %q -> %q", out, again.FEN())
}
for _, m := range pos.LegalMoves() {
san := pos.SAN(m)
back, err := pos.ParseSAN(san)
if err != nil || back != m {
t.Fatalf("%s: SAN %q of %s parses to %v, %v", out, san, m.UCI(), back.UCI(), err)
}
if u, err := pos.ParseUCI(m.UCI()); err != nil || u != m {
t.Fatalf("%s: UCI %s parses to %v, %v", out, m.UCI(), u.UCI(), err)
}
}
})
}
func FuzzParseSAN(f *testing.F) {
positions := make([]*Position, 0, len(perftCases))
for _, tc := range perftCases {
pos, err := ParseFEN(tc.fen)
if err != nil {
f.Fatal(err)
}
positions = append(positions, pos)
}
for _, s := range []string{"e4", "Nf3", "O-O", "0-0-0", "exd8=Q+", "Qh4e1", "Rxa1#", "b8Q", "N", "x", "=Q"} {
f.Add(s, uint8(0))
}
f.Fuzz(func(t *testing.T, san string, which uint8) {
pos := positions[int(which)%len(positions)]
m, err := pos.ParseSAN(san)
if err != nil {
return
}
found := false
for _, legal := range pos.LegalMoves() {
found = found || legal == m
}
if !found {
t.Fatalf("ParseSAN(%q) returned non-legal move %s", san, m.UCI())
}
})
}
var tagLine = regexp.MustCompile(`^\[[A-Za-z][A-Za-z0-9_]* "([^"\\]|\\["\\])*"\]$`)
func FuzzPGN(f *testing.F) {
f.Add("Event", "Casual game", "x\"]\n[Y \"z")
f.Add("White", `a\b`, "Ω")
f.Fuzz(func(t *testing.T, name, value, other string) {
text := PGN{
Tags: []Tag{{name, value}, {"White", other}, {"Black", value}},
SAN: []string{"e4", "e5"},
Comment: other,
Result: "*",
}.String()
header, movetext, ok := strings.Cut(text, "\n\n")
if !ok {
t.Fatalf("no blank line between tags and movetext:\n%s", text)
}
for _, l := range strings.Split(header, "\n") {
if !tagLine.MatchString(l) {
t.Fatalf("malformed tag line %q", l)
}
}
// The comment must stay one brace comment: exactly one of each brace,
// in order, before the result.
if !strings.HasPrefix(movetext, "1. e4 e5 ") || !strings.HasSuffix(movetext, "*\n") ||
strings.Count(movetext, "{") > 1 || strings.Count(movetext, "}") != strings.Count(movetext, "{") ||
strings.Index(movetext, "}") < strings.Index(movetext, "{") {
t.Fatalf("movetext = %q", movetext)
}
})
}
+427
View File
@@ -0,0 +1,427 @@
package chess
import (
"errors"
"fmt"
)
const (
sqA1 Square = 0
sqC1 Square = 2
sqD1 Square = 3
sqE1 Square = 4
sqF1 Square = 5
sqG1 Square = 6
sqH1 Square = 7
sqA8 Square = 56
sqC8 Square = 58
sqD8 Square = 59
sqE8 Square = 60
sqF8 Square = 61
sqG8 Square = 62
sqH8 Square = 63
)
// Move is a from-to pair plus the promotion kind, if any. Castling is the
// king's two-square move, as in UCI.
type Move struct {
From, To Square
Promotion Kind
}
// UCI returns long algebraic notation, e.g. "e2e4" or "e7e8q".
func (m Move) UCI() string {
s := m.From.String() + m.To.String()
if m.Promotion != NoKind {
s += string(kindLetters[m.Promotion] + 'a' - 'A')
}
return s
}
// Direction tables: the first four are orthogonal, the last four diagonal.
var (
knightTargets [64][]Square
kingTargets [64][]Square
rays [64][8][]Square
// castleMask[s] holds the rights that survive a move from or to s.
castleMask [64]Castling
)
func init() {
knightDeltas := [8][2]int{{1, 2}, {2, 1}, {2, -1}, {1, -2}, {-1, -2}, {-2, -1}, {-2, 1}, {-1, 2}}
kingDeltas := [8][2]int{{1, 0}, {1, 1}, {0, 1}, {-1, 1}, {-1, 0}, {-1, -1}, {0, -1}, {1, -1}}
dirs := [8][2]int{{0, 1}, {0, -1}, {1, 0}, {-1, 0}, {1, 1}, {1, -1}, {-1, 1}, {-1, -1}}
for s := Square(0); s < 64; s++ {
f, r := s.File(), s.Rank()
for _, d := range knightDeltas {
if onBoard(f+d[0], r+d[1]) {
knightTargets[s] = append(knightTargets[s], MakeSquare(f+d[0], r+d[1]))
}
}
for _, d := range kingDeltas {
if onBoard(f+d[0], r+d[1]) {
kingTargets[s] = append(kingTargets[s], MakeSquare(f+d[0], r+d[1]))
}
}
for i, d := range dirs {
for nf, nr := f+d[0], r+d[1]; onBoard(nf, nr); nf, nr = nf+d[0], nr+d[1] {
rays[s][i] = append(rays[s][i], MakeSquare(nf, nr))
}
}
castleMask[s] = WhiteKingside | WhiteQueenside | BlackKingside | BlackQueenside
}
castleMask[sqE1] &^= WhiteKingside | WhiteQueenside
castleMask[sqH1] &^= WhiteKingside
castleMask[sqA1] &^= WhiteQueenside
castleMask[sqE8] &^= BlackKingside | BlackQueenside
castleMask[sqH8] &^= BlackKingside
castleMask[sqA8] &^= BlackQueenside
}
func onBoard(file, rank int) bool { return file >= 0 && file < 8 && rank >= 0 && rank < 8 }
func (p *Position) kingSquare(c Color) Square {
king := MakePiece(c, King)
for s := Square(0); s < 64; s++ {
if p.board[s] == king {
return s
}
}
return NoSquare
}
// attacked reports whether side `by` attacks square s.
func (p *Position) attacked(s Square, by Color) bool {
if s == NoSquare {
return false
}
f, r := s.File(), s.Rank()
pawn := MakePiece(by, Pawn)
pr := r - 1 // a white pawn attacks upward, so it stands one rank below
if by == Black {
pr = r + 1
}
if pr >= 0 && pr < 8 {
if f > 0 && p.board[MakeSquare(f-1, pr)] == pawn {
return true
}
if f < 7 && p.board[MakeSquare(f+1, pr)] == pawn {
return true
}
}
knight := MakePiece(by, Knight)
for _, t := range knightTargets[s] {
if p.board[t] == knight {
return true
}
}
king := MakePiece(by, King)
for _, t := range kingTargets[s] {
if p.board[t] == king {
return true
}
}
queen := MakePiece(by, Queen)
for i := 0; i < 8; i++ {
slider := MakePiece(by, Rook)
if i >= 4 {
slider = MakePiece(by, Bishop)
}
for _, t := range rays[s][i] {
if piece := p.board[t]; piece != NoPiece {
if piece == slider || piece == queen {
return true
}
break
}
}
}
return false
}
// InCheck reports whether the side to move is in check.
func (p *Position) InCheck() bool {
return p.attacked(p.kingSquare(p.turn), p.turn.Other())
}
var promotionKinds = [4]Kind{Queen, Rook, Bishop, Knight}
// pseudoLegal appends moves that obey piece movement but may leave the
// mover's king in check.
func (p *Position) pseudoLegal(moves []Move) []Move {
us := p.turn
them := us.Other()
forward, startRank, lastRank := 8, 1, 7
if us == Black {
forward, startRank, lastRank = -8, 6, 0
}
addPawn := func(from, to Square) {
if to.Rank() == lastRank {
for _, k := range promotionKinds {
moves = append(moves, Move{From: from, To: to, Promotion: k})
}
return
}
moves = append(moves, Move{From: from, To: to})
}
for s := Square(0); s < 64; s++ {
piece := p.board[s]
if piece == NoPiece || piece.Color() != us {
continue
}
switch piece.Kind() {
case Pawn:
one := s + Square(forward)
if p.board[one] == NoPiece {
addPawn(s, one)
two := one + Square(forward)
if s.Rank() == startRank && p.board[two] == NoPiece {
moves = append(moves, Move{From: s, To: two})
}
}
for _, df := range [2]int{-1, 1} {
f := s.File() + df
if f < 0 || f > 7 {
continue
}
to := one + Square(df)
if target := p.board[to]; target != NoPiece && target.Color() == them {
addPawn(s, to)
} else if to == p.ep && target == NoPiece {
moves = append(moves, Move{From: s, To: to})
}
}
case Knight:
for _, t := range knightTargets[s] {
if target := p.board[t]; target == NoPiece || target.Color() == them {
moves = append(moves, Move{From: s, To: t})
}
}
case King:
for _, t := range kingTargets[s] {
if target := p.board[t]; target == NoPiece || target.Color() == them {
moves = append(moves, Move{From: s, To: t})
}
}
default:
first, last := 0, 8
switch piece.Kind() {
case Rook:
last = 4
case Bishop:
first = 4
}
for i := first; i < last; i++ {
for _, t := range rays[s][i] {
target := p.board[t]
if target == NoPiece {
moves = append(moves, Move{From: s, To: t})
continue
}
if target.Color() == them {
moves = append(moves, Move{From: s, To: t})
}
break
}
}
}
}
return p.castlingMoves(moves)
}
func (p *Position) castlingMoves(moves []Move) []Move {
us, them := p.turn, p.turn.Other()
kingside, queenside := WhiteKingside, WhiteQueenside
e, f, g, d, c, b := sqE1, sqF1, sqG1, sqD1, sqC1, Square(1)
if us == Black {
kingside, queenside = BlackKingside, BlackQueenside
e, f, g, d, c, b = sqE8, sqF8, sqG8, sqD8, sqC8, Square(57)
}
if p.castling&(kingside|queenside) == 0 || p.attacked(e, them) {
return moves
}
if p.castling&kingside != 0 && p.board[f] == NoPiece && p.board[g] == NoPiece &&
!p.attacked(f, them) && !p.attacked(g, them) {
moves = append(moves, Move{From: e, To: g})
}
if p.castling&queenside != 0 && p.board[d] == NoPiece && p.board[c] == NoPiece && p.board[b] == NoPiece &&
!p.attacked(d, them) && !p.attacked(c, them) {
moves = append(moves, Move{From: e, To: c})
}
return moves
}
// LegalMoves returns every legal move in the position.
func (p *Position) LegalMoves() []Move {
candidates := p.pseudoLegal(make([]Move, 0, 48))
legal := candidates[:0]
for _, m := range candidates {
next := p.play(m)
if !next.attacked(next.kingSquare(p.turn), p.turn.Other()) {
legal = append(legal, m)
}
}
return legal
}
// HasLegalMoves reports whether the side to move can move; it stops at the
// first legal move.
func (p *Position) HasLegalMoves() bool {
for _, m := range p.pseudoLegal(make([]Move, 0, 48)) {
next := p.play(m)
if !next.attacked(next.kingSquare(p.turn), p.turn.Other()) {
return true
}
}
return false
}
// play applies a pseudo-legal move and returns the resulting position.
func (p *Position) play(m Move) Position {
next := *p
piece := next.board[m.From]
captured := next.board[m.To]
next.board[m.From] = NoPiece
next.board[m.To] = piece
switch piece.Kind() {
case Pawn:
if m.To == p.ep && captured == NoPiece && m.From.File() != m.To.File() {
next.board[MakeSquare(m.To.File(), m.From.Rank())] = NoPiece
}
if m.Promotion != NoKind {
next.board[m.To] = MakePiece(p.turn, m.Promotion)
}
case King:
switch {
case m.From == sqE1 && m.To == sqG1:
next.board[sqH1], next.board[sqF1] = NoPiece, next.board[sqH1]
case m.From == sqE1 && m.To == sqC1:
next.board[sqA1], next.board[sqD1] = NoPiece, next.board[sqA1]
case m.From == sqE8 && m.To == sqG8:
next.board[sqH8], next.board[sqF8] = NoPiece, next.board[sqH8]
case m.From == sqE8 && m.To == sqC8:
next.board[sqA8], next.board[sqD8] = NoPiece, next.board[sqA8]
}
}
next.castling &= castleMask[m.From] & castleMask[m.To]
next.ep = NoSquare
if piece.Kind() == Pawn && (m.To-m.From == 16 || m.From-m.To == 16) {
next.ep = (m.From + m.To) / 2
}
if piece.Kind() == Pawn || captured != NoPiece {
next.halfmove = 0
} else {
next.halfmove++
}
if p.turn == Black {
next.fullmove++
}
next.turn = p.turn.Other()
return next
}
// Play returns the position after a legal move. It does not re-check
// legality; use ParseUCI or ParseSAN to obtain a legal Move.
func (p *Position) Play(m Move) *Position {
next := p.play(m)
return &next
}
// legalEPSquare returns the en-passant target only when a capture on it is
// legal for the side to move.
func (p *Position) legalEPSquare() Square {
if p.ep == NoSquare {
return NoSquare
}
pawn := MakePiece(p.turn, Pawn)
from := p.ep.Rank() - 1
if p.turn == Black {
from = p.ep.Rank() + 1
}
for _, df := range [2]int{-1, 1} {
f := p.ep.File() + df
if f < 0 || f > 7 {
continue
}
s := MakeSquare(f, from)
if p.board[s] != pawn {
continue
}
next := p.play(Move{From: s, To: p.ep})
if !next.attacked(next.kingSquare(p.turn), p.turn.Other()) {
return p.ep
}
}
return NoSquare
}
// ErrIllegalMove reports a move that is malformed or not legal here.
var ErrIllegalMove = errors.New("illegal move")
// ErrPromotionRequired reports a pawn move to the last rank without a
// promotion piece.
var ErrPromotionRequired = errors.New("promotion piece required")
// ParseUCI resolves a UCI move string against the legal moves.
func (p *Position) ParseUCI(s string) (Move, error) {
return p.parseUCI(s, p.LegalMoves())
}
func (p *Position) parseUCI(s string, legal []Move) (Move, error) {
if len(s) != 4 && len(s) != 5 {
return Move{}, fmt.Errorf("%w: %q", ErrIllegalMove, s)
}
from, err1 := ParseSquare(s[0:2])
to, err2 := ParseSquare(s[2:4])
if err1 != nil || err2 != nil {
return Move{}, fmt.Errorf("%w: %q", ErrIllegalMove, s)
}
m := Move{From: from, To: to}
if len(s) == 5 {
switch s[4] {
case 'q', 'Q':
m.Promotion = Queen
case 'r', 'R':
m.Promotion = Rook
case 'b', 'B':
m.Promotion = Bishop
case 'n', 'N':
m.Promotion = Knight
default:
return Move{}, fmt.Errorf("%w: %q", ErrIllegalMove, s)
}
}
promotable := false
for _, candidate := range legal {
if candidate == m {
return m, nil
}
if candidate.From == from && candidate.To == to && candidate.Promotion != NoKind {
promotable = true
}
}
if promotable && m.Promotion == NoKind {
return Move{}, fmt.Errorf("%w: %q", ErrPromotionRequired, s)
}
return Move{}, fmt.Errorf("%w: %q", ErrIllegalMove, s)
}
// Perft counts leaf nodes of the legal move tree to the given depth.
func (p *Position) Perft(depth int) uint64 {
if depth <= 0 {
return 1
}
moves := p.LegalMoves()
if depth == 1 {
return uint64(len(moves))
}
var n uint64
for _, m := range moves {
next := p.play(m)
n += next.Perft(depth - 1)
}
return n
}
+185
View File
@@ -0,0 +1,185 @@
package chess
import (
"strconv"
"strings"
)
// pgnLineWidth is the export-format line limit recommended by the PGN
// standard (section 8.2.1).
const pgnLineWidth = 80
// Tag is one PGN tag pair.
type Tag struct {
Name, Value string
}
// sevenTagRoster is the mandatory tag order of PGN export format.
var sevenTagRoster = [...]string{"Event", "Site", "Date", "Round", "White", "Black", "Result"}
// PGN is a game ready for export. Tags may hold any of the Seven Tag Roster
// (missing ones are written as "?", Result from the Result field) followed by
// supplemental tags in the given order. StartFEN is written as SetUp/FEN
// tags when it is not the standard start. Comment, if set, is written as a
// brace comment before the result, e.g. "White resigns.".
type PGN struct {
Tags []Tag
StartFEN string
SAN []string
Comment string
Result string // "1-0", "0-1", "1/2-1/2", or "*"
}
// String renders PGN export format: tag section, blank line, movetext
// wrapped at 80 columns and ending with the result, and a final newline.
func (g PGN) String() string {
result := g.Result
switch result {
case "1-0", "0-1", "1/2-1/2":
default:
result = "*"
}
var b strings.Builder
values := make(map[string]string, len(g.Tags))
for _, t := range g.Tags {
values[t.Name] = t.Value
}
for _, name := range sevenTagRoster {
value, ok := values[name]
if name == "Result" {
value, ok = result, true
}
if !ok || value == "" {
value = "?"
if name == "Round" {
value = "-"
}
}
writeTag(&b, name, value)
}
turn, fullmove := White, 1
if g.StartFEN != "" && g.StartFEN != StartFEN {
writeTag(&b, "SetUp", "1")
writeTag(&b, "FEN", g.StartFEN)
if start, err := ParseFEN(g.StartFEN); err == nil {
turn, fullmove = start.turn, start.fullmove
}
}
for _, t := range g.Tags {
if isRosterTag(t.Name) || t.Name == "SetUp" || t.Name == "FEN" || !validTagName(t.Name) {
continue
}
writeTag(&b, t.Name, t.Value)
}
b.WriteByte('\n')
width := 0
emit := func(token string) {
switch {
case width == 0:
case width+1+len(token) > pgnLineWidth:
b.WriteByte('\n')
width = 0
default:
b.WriteByte(' ')
width++
}
b.WriteString(token)
width += len(token)
}
for i, san := range g.SAN {
switch {
case turn == White:
emit(strconv.Itoa(fullmove) + ".")
case i == 0:
emit(strconv.Itoa(fullmove) + "...")
}
emit(san)
if turn == Black {
fullmove++
}
turn = turn.Other()
}
if words := strings.Fields(commentText(g.Comment)); len(words) > 0 {
// A comment may be broken across lines at spaces, so it wraps like
// movetext; braces cannot nest and are removed from the text.
emit("{")
for _, word := range words {
emit(word)
}
emit("}")
}
emit(result)
b.WriteByte('\n')
return b.String()
}
// commentText drops braces and control characters, which would end or
// corrupt a brace comment.
func commentText(s string) string {
return strings.Map(func(r rune) rune {
if r == '{' || r == '}' || r < 0x20 || r == 0x7f {
return -1
}
return r
}, s)
}
func isRosterTag(name string) bool {
for _, r := range sevenTagRoster {
if r == name {
return true
}
}
return false
}
// validTagName accepts PGN tag symbols: letters, digits, and underscore,
// starting with a letter.
func validTagName(name string) bool {
if name == "" || len(name) > 64 {
return false
}
for i := 0; i < len(name); i++ {
c := name[i]
letter := c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z'
if !letter && (i == 0 || !(c >= '0' && c <= '9' || c == '_')) {
return false
}
}
return true
}
// writeTag writes one tag pair, escaping backslash and quote and dropping
// control characters so a value cannot break out of its string token.
func writeTag(b *strings.Builder, name, value string) {
b.WriteByte('[')
b.WriteString(name)
b.WriteString(` "`)
for _, r := range value {
switch {
case r == '\\' || r == '"':
b.WriteByte('\\')
b.WriteRune(r)
case r < 0x20 || r == 0x7f:
default:
b.WriteRune(r)
}
}
b.WriteString("\"]\n")
}
// ResultToken maps a stored result to its PGN token.
func ResultToken(result string) string {
switch result {
case "white_wins":
return "1-0"
case "black_wins":
return "0-1"
case "draw", "stalemate":
return "1/2-1/2"
}
return "*"
}
+430
View File
@@ -0,0 +1,430 @@
// Package chess is a dependency-free rules core for replay and notation: FEN,
// legal move generation, SAN, PGN, and draw-rule detection. Stockfish remains
// the move validator for live play; this package derives notation from stored
// games and cross-checks the engine.
package chess
import (
"errors"
"fmt"
"strconv"
"strings"
)
// StartFEN is the standard initial position.
const StartFEN = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
// maxFENLength bounds parser input; a legal FEN is far shorter.
const maxFENLength = 128
// Color is the side to move or a piece's owner.
type Color uint8
const (
White Color = iota
Black
)
// Other returns the opposing color.
func (c Color) Other() Color { return c ^ 1 }
func (c Color) String() string {
if c == White {
return "w"
}
return "b"
}
// Kind is a piece type without color.
type Kind uint8
const (
NoKind Kind = iota
Pawn
Knight
Bishop
Rook
Queen
King
)
// Piece is a Kind with a Color; the zero value is an empty square.
type Piece uint8
const NoPiece Piece = 0
// MakePiece combines a color and kind.
func MakePiece(c Color, k Kind) Piece { return Piece(k) | Piece(c)<<3 }
func (p Piece) Kind() Kind { return Kind(p & 7) }
func (p Piece) Color() Color { return Color(p >> 3) }
// Square indexes the board from a1 = 0 to h8 = 63.
type Square int8
const NoSquare Square = -1
// MakeSquare returns the square at file 0-7 (a-h) and rank 0-7 (1-8).
func MakeSquare(file, rank int) Square { return Square(rank*8 + file) }
func (s Square) File() int { return int(s) & 7 }
func (s Square) Rank() int { return int(s) >> 3 }
func (s Square) String() string {
if s < 0 || s > 63 {
return "-"
}
return string([]byte{byte('a' + s.File()), byte('1' + s.Rank())})
}
// ParseSquare parses algebraic coordinates such as "e4".
func ParseSquare(s string) (Square, error) {
if len(s) != 2 || s[0] < 'a' || s[0] > 'h' || s[1] < '1' || s[1] > '8' {
return NoSquare, fmt.Errorf("invalid square %q", s)
}
return MakeSquare(int(s[0]-'a'), int(s[1]-'1')), nil
}
// Castling is a set of castling rights.
type Castling uint8
const (
WhiteKingside Castling = 1 << iota
WhiteQueenside
BlackKingside
BlackQueenside
)
// Position is an immutable-by-convention board state; Play returns a copy.
type Position struct {
board [64]Piece
turn Color
castling Castling
// ep is the en-passant target after any double pawn step, whether or not
// a capture is possible; FEN output prints it only when one is legal.
ep Square
halfmove int
fullmove int
}
var (
pieceLetters = map[byte]Piece{
'P': MakePiece(White, Pawn), 'N': MakePiece(White, Knight), 'B': MakePiece(White, Bishop),
'R': MakePiece(White, Rook), 'Q': MakePiece(White, Queen), 'K': MakePiece(White, King),
'p': MakePiece(Black, Pawn), 'n': MakePiece(Black, Knight), 'b': MakePiece(Black, Bishop),
'r': MakePiece(Black, Rook), 'q': MakePiece(Black, Queen), 'k': MakePiece(Black, King),
}
kindLetters = [...]byte{NoKind: ' ', Pawn: 'P', Knight: 'N', Bishop: 'B', Rook: 'R', Queen: 'Q', King: 'K'}
)
// Letter returns the FEN letter of a piece (uppercase for White).
func (p Piece) Letter() byte {
if p == NoPiece {
return ' '
}
l := kindLetters[p.Kind()]
if p.Color() == Black {
l += 'a' - 'A'
}
return l
}
// ErrInvalidFEN wraps every FEN parse failure.
var ErrInvalidFEN = errors.New("invalid FEN")
func fenError(format string, args ...any) error {
return fmt.Errorf("%w: %s", ErrInvalidFEN, fmt.Sprintf(format, args...))
}
// ParseFEN parses and validates a six-field FEN. It rejects positions the
// rules cannot arise from in ways that matter to move generation: a king
// count other than one per side, pawns on the first or last rank, or the side
// not to move being in check. Castling rights whose king or rook is not on
// its original square are dropped, and an en-passant square is kept only
// when a double step could have produced it.
func ParseFEN(fen string) (*Position, error) {
if len(fen) > maxFENLength {
return nil, fenError("longer than %d bytes", maxFENLength)
}
fields := strings.Fields(fen)
if len(fields) != 6 {
return nil, fenError("expected 6 fields, got %d", len(fields))
}
p := &Position{ep: NoSquare}
ranks := strings.Split(fields[0], "/")
if len(ranks) != 8 {
return nil, fenError("expected 8 ranks")
}
kings := [2]int{}
for i, row := range ranks {
rank := 7 - i
file := 0
for j := 0; j < len(row); j++ {
ch := row[j]
if ch >= '1' && ch <= '8' {
file += int(ch - '0')
if file > 8 {
return nil, fenError("rank %d overflows", rank+1)
}
continue
}
piece, ok := pieceLetters[ch]
if !ok {
return nil, fenError("unknown piece %q", ch)
}
if file >= 8 {
return nil, fenError("rank %d overflows", rank+1)
}
if piece.Kind() == Pawn && (rank == 0 || rank == 7) {
return nil, fenError("pawn on rank %d", rank+1)
}
if piece.Kind() == King {
kings[piece.Color()]++
}
p.board[MakeSquare(file, rank)] = piece
file++
}
if file != 8 {
return nil, fenError("rank %d has %d files", rank+1, file)
}
}
if kings[White] != 1 || kings[Black] != 1 {
return nil, fenError("each side needs exactly one king")
}
switch fields[1] {
case "w":
p.turn = White
case "b":
p.turn = Black
default:
return nil, fenError("side to move must be w or b")
}
if fields[2] != "-" {
if len(fields[2]) > 4 {
return nil, fenError("castling field too long")
}
for i := 0; i < len(fields[2]); i++ {
var right Castling
switch fields[2][i] {
case 'K':
right = WhiteKingside
case 'Q':
right = WhiteQueenside
case 'k':
right = BlackKingside
case 'q':
right = BlackQueenside
default:
return nil, fenError("invalid castling right %q", fields[2][i])
}
if p.castling&right != 0 {
return nil, fenError("duplicate castling right %q", fields[2][i])
}
p.castling |= right
}
p.castling &= p.castlingSupported()
}
if fields[3] != "-" {
sq, err := ParseSquare(fields[3])
if err != nil {
return nil, fenError("en passant: %v", err)
}
if p.epPlausible(sq) {
p.ep = sq
}
}
var err error
if p.halfmove, err = parseCounter(fields[4], 0); err != nil {
return nil, fenError("halfmove clock: %v", err)
}
if p.fullmove, err = parseCounter(fields[5], 1); err != nil {
return nil, fenError("fullmove number: %v", err)
}
if p.attacked(p.kingSquare(p.turn.Other()), p.turn) {
return nil, fenError("side not to move is in check")
}
return p, nil
}
func parseCounter(s string, minimum int) (int, error) {
if len(s) > 5 {
return 0, errors.New("out of range")
}
n, err := strconv.Atoi(s)
if err != nil || n < minimum {
return 0, errors.New("out of range")
}
return n, nil
}
// castlingSupported returns the rights whose king and rook stand on their
// original squares.
func (p *Position) castlingSupported() Castling {
var c Castling
wk, bk := MakePiece(White, King), MakePiece(Black, King)
wr, br := MakePiece(White, Rook), MakePiece(Black, Rook)
if p.board[sqE1] == wk {
if p.board[sqH1] == wr {
c |= WhiteKingside
}
if p.board[sqA1] == wr {
c |= WhiteQueenside
}
}
if p.board[sqE8] == bk {
if p.board[sqH8] == br {
c |= BlackKingside
}
if p.board[sqA8] == br {
c |= BlackQueenside
}
}
return c
}
// epPlausible reports whether the opponent's last move could have been a
// double pawn step past sq.
func (p *Position) epPlausible(sq Square) bool {
them := p.turn.Other()
var pawnRank, targetRank, originRank int
if them == White {
targetRank, pawnRank, originRank = 2, 3, 1
} else {
targetRank, pawnRank, originRank = 5, 4, 6
}
if sq.Rank() != targetRank {
return false
}
pawnSq := MakeSquare(sq.File(), pawnRank)
origin := MakeSquare(sq.File(), originRank)
return p.board[pawnSq] == MakePiece(them, Pawn) && p.board[sq] == NoPiece && p.board[origin] == NoPiece
}
// FEN serializes the position. The en-passant square is printed only when
// an en-passant capture is legal, so equal positions have equal FENs
// regardless of which convention produced the input.
func (p *Position) FEN() string {
var b strings.Builder
b.Grow(90)
for rank := 7; rank >= 0; rank-- {
empty := 0
for file := 0; file < 8; file++ {
piece := p.board[MakeSquare(file, rank)]
if piece == NoPiece {
empty++
continue
}
if empty > 0 {
b.WriteByte(byte('0' + empty))
empty = 0
}
b.WriteByte(piece.Letter())
}
if empty > 0 {
b.WriteByte(byte('0' + empty))
}
if rank > 0 {
b.WriteByte('/')
}
}
b.WriteByte(' ')
b.WriteString(p.turn.String())
b.WriteByte(' ')
b.WriteString(p.castlingString())
b.WriteByte(' ')
b.WriteString(p.legalEPSquare().String())
b.WriteByte(' ')
b.WriteString(strconv.Itoa(p.halfmove))
b.WriteByte(' ')
b.WriteString(strconv.Itoa(p.fullmove))
return b.String()
}
// Key identifies a position for repetition: placement, side to move,
// castling rights, and a legal en-passant square.
func (p *Position) Key() string {
fen := p.FEN()
// Drop the two counters.
for i, n := len(fen)-1, 0; i >= 0; i-- {
if fen[i] == ' ' {
n++
if n == 2 {
return fen[:i]
}
}
}
return fen
}
func (p *Position) castlingString() string {
if p.castling == 0 {
return "-"
}
var b []byte
for _, r := range []struct {
right Castling
letter byte
}{{WhiteKingside, 'K'}, {WhiteQueenside, 'Q'}, {BlackKingside, 'k'}, {BlackQueenside, 'q'}} {
if p.castling&r.right != 0 {
b = append(b, r.letter)
}
}
return string(b)
}
// Turn returns the side to move.
func (p *Position) Turn() Color { return p.turn }
// Fullmove returns the FEN fullmove number.
func (p *Position) Fullmove() int { return p.fullmove }
// Halfmove returns the halfmove clock for the fifty-move rule.
func (p *Position) Halfmove() int { return p.halfmove }
// PieceAt returns the piece on a square.
func (p *Position) PieceAt(s Square) Piece {
if s < 0 || s > 63 {
return NoPiece
}
return p.board[s]
}
// NormalizeFEN parses and re-serializes a FEN, so FENs that differ only in
// en-passant convention or castling-right order compare equal.
func NormalizeFEN(fen string) (string, error) {
p, err := ParseFEN(fen)
if err != nil {
return "", err
}
return p.FEN(), nil
}
// ASCII renders the board from White's side: files on the first and last
// lines, ranks at both ends of each row, "." for an empty square.
func (p *Position) ASCII() string {
var b strings.Builder
b.Grow(200)
b.WriteString(" a b c d e f g h\n")
for rank := 7; rank >= 0; rank-- {
b.WriteByte(byte('1' + rank))
b.WriteByte(' ')
for file := 0; file < 8; file++ {
if piece := p.board[MakeSquare(file, rank)]; piece != NoPiece {
b.WriteByte(piece.Letter())
} else {
b.WriteByte('.')
}
b.WriteByte(' ')
}
b.WriteByte(byte('1' + rank))
b.WriteByte('\n')
}
b.WriteString(" a b c d e f g h")
return b.String()
}
+179
View File
@@ -0,0 +1,179 @@
package chess
import (
"fmt"
"strings"
)
// maxSANLength bounds parser input; the longest SAN is "Qa1xb2+" plus
// annotation glyphs.
const maxSANLength = 16
// SAN returns Standard Algebraic Notation for a legal move, with "+" or "#".
func (p *Position) SAN(m Move) string {
return p.san(m, p.LegalMoves())
}
func (p *Position) san(m Move, legal []Move) string {
var b strings.Builder
piece := p.board[m.From]
switch {
case piece.Kind() == King && m.To-m.From == 2:
b.WriteString("O-O")
case piece.Kind() == King && m.From-m.To == 2:
b.WriteString("O-O-O")
case piece.Kind() == Pawn:
if m.From.File() != m.To.File() {
b.WriteByte(byte('a' + m.From.File()))
b.WriteByte('x')
}
b.WriteString(m.To.String())
if m.Promotion != NoKind {
b.WriteByte('=')
b.WriteByte(kindLetters[m.Promotion])
}
default:
b.WriteByte(kindLetters[piece.Kind()])
b.WriteString(p.disambiguation(m, legal))
if p.board[m.To] != NoPiece {
b.WriteByte('x')
}
b.WriteString(m.To.String())
}
next := p.play(m)
if next.InCheck() {
if next.HasLegalMoves() {
b.WriteByte('+')
} else {
b.WriteByte('#')
}
}
return b.String()
}
// disambiguation returns the file, rank, or square needed to tell m apart
// from other legal moves of the same piece kind to the same square.
func (p *Position) disambiguation(m Move, legal []Move) string {
kind := p.board[m.From].Kind()
ambiguous, sameFile, sameRank := false, false, false
for _, other := range legal {
if other.To != m.To || other.From == m.From || p.board[other.From].Kind() != kind {
continue
}
ambiguous = true
if other.From.File() == m.From.File() {
sameFile = true
}
if other.From.Rank() == m.From.Rank() {
sameRank = true
}
}
switch {
case !ambiguous:
return ""
case !sameFile:
return string(byte('a' + m.From.File()))
case !sameRank:
return string(byte('1' + m.From.Rank()))
default:
return m.From.String()
}
}
// ParseSAN resolves a SAN move against the legal moves. It accepts check and
// annotation suffixes, "0-0" castling, promotion with or without "=", and
// redundant disambiguation.
func (p *Position) ParseSAN(s string) (Move, error) {
if len(s) == 0 || len(s) > maxSANLength {
return Move{}, fmt.Errorf("%w: %q", ErrIllegalMove, s)
}
text := strings.TrimRight(s, "+#!?")
legal := p.LegalMoves()
switch text {
case "O-O", "0-0", "O-O-O", "0-0-0":
kingFrom, delta := sqE1, Square(2)
if p.turn == Black {
kingFrom = sqE8
}
if len(text) == 5 {
delta = -2
}
for _, m := range legal {
if m.From == kingFrom && m.To == kingFrom+delta && p.board[m.From].Kind() == King {
return m, nil
}
}
return Move{}, fmt.Errorf("%w: %q", ErrIllegalMove, s)
}
kind := Pawn
if len(text) > 0 && strings.IndexByte("NBRQK", text[0]) >= 0 {
kind = kindOfLetter(text[0])
text = text[1:]
}
promotion := NoKind
if n := len(text); n >= 2 && strings.IndexByte("NBRQ", text[n-1]) >= 0 {
promotion = kindOfLetter(text[n-1])
text = strings.TrimSuffix(text[:n-1], "=")
}
if len(text) < 2 {
return Move{}, fmt.Errorf("%w: %q", ErrIllegalMove, s)
}
to, err := ParseSquare(text[len(text)-2:])
if err != nil {
return Move{}, fmt.Errorf("%w: %q", ErrIllegalMove, s)
}
text = strings.TrimSuffix(text[:len(text)-2], "x")
fromFile, fromRank := -1, -1
for i := 0; i < len(text); i++ {
switch c := text[i]; {
case c >= 'a' && c <= 'h' && fromFile < 0 && fromRank < 0:
fromFile = int(c - 'a')
case c >= '1' && c <= '8' && fromRank < 0:
fromRank = int(c - '1')
default:
return Move{}, fmt.Errorf("%w: %q", ErrIllegalMove, s)
}
}
var found Move
matches := 0
for _, m := range legal {
if m.To != to || m.Promotion != promotion || p.board[m.From].Kind() != kind {
continue
}
if fromFile >= 0 && m.From.File() != fromFile || fromRank >= 0 && m.From.Rank() != fromRank {
continue
}
found = m
matches++
}
switch matches {
case 1:
return found, nil
case 0:
return Move{}, fmt.Errorf("%w: %q", ErrIllegalMove, s)
default:
return Move{}, fmt.Errorf("%w: ambiguous %q", ErrIllegalMove, s)
}
}
func kindOfLetter(c byte) Kind {
switch c {
case 'N':
return Knight
case 'B':
return Bishop
case 'R':
return Rook
case 'Q':
return Queen
case 'K':
return King
}
return NoKind
}
+102
View File
@@ -0,0 +1,102 @@
package chess
import "strconv"
// InsufficientMaterial reports a dead position by material alone: bare
// kings, a single minor piece, or only bishops, all on squares of one color.
// Two knights are not included: mate is possible with the loser's help.
func (p *Position) InsufficientMaterial() bool {
minors := 0
bishopColors := [2]bool{}
knights := 0
for s := Square(0); s < 64; s++ {
switch p.board[s].Kind() {
case Pawn, Rook, Queen:
return false
case Knight:
minors++
knights++
case Bishop:
minors++
bishopColors[(s.File()+s.Rank())&1] = true
}
}
if minors <= 1 {
return true
}
return knights == 0 && !(bishopColors[0] && bishopColors[1])
}
// Repetitions counts how often the last of positions occurs in the line,
// itself included. positions[i] is the position after i plies of one game.
// Only positions since the last capture or pawn move can repeat, and only
// every other ply has the same side to move, so at most halfmove/2 earlier
// positions are compared.
func Repetitions(positions []*Position) int {
last := len(positions) - 1
if last < 0 {
return 0
}
key := positions[last].Key()
count := 1
for i := last - 2; i >= 0 && last-i <= positions[last].halfmove; i -= 2 {
if positions[i].Key() == key {
count++
}
}
return count
}
// Line is a replayed game: the positions before and after every ply.
type Line struct {
Start *Position
Moves []Move
SAN []string
positions []*Position // positions[i] is the position after i plies
}
// Replay applies UCI moves from a starting FEN. On an illegal move it
// returns the line up to that ply together with the error.
func Replay(startFEN string, uci []string) (*Line, error) {
start, err := ParseFEN(startFEN)
if err != nil {
return nil, err
}
line := &Line{
Start: start,
Moves: make([]Move, 0, len(uci)),
SAN: make([]string, 0, len(uci)),
positions: append(make([]*Position, 0, len(uci)+1), start),
}
pos := start
for i, text := range uci {
legal := pos.LegalMoves()
m, err := pos.parseUCI(text, legal)
if err != nil {
return line, &PlyError{Ply: i + 1, Err: err}
}
line.SAN = append(line.SAN, pos.san(m, legal))
line.Moves = append(line.Moves, m)
pos = pos.Play(m)
line.positions = append(line.positions, pos)
}
return line, nil
}
// PlyError locates a replay failure; Ply counts from 1.
type PlyError struct {
Ply int
Err error
}
func (e *PlyError) Error() string { return "ply " + strconv.Itoa(e.Ply) + ": " + e.Err.Error() }
func (e *PlyError) Unwrap() error { return e.Err }
// Position returns the position after ply plies (0 is the start).
func (l *Line) Position(ply int) *Position { return l.positions[ply] }
// Final returns the position after the last replayed ply.
func (l *Line) Final() *Position { return l.positions[len(l.positions)-1] }
// Repetitions counts occurrences of the position after ply, itself included.
func (l *Line) Repetitions(ply int) int { return Repetitions(l.positions[:ply+1]) }
+159
View File
@@ -0,0 +1,159 @@
package chess
import (
"bufio"
"io"
"math/rand/v2"
"os/exec"
"slices"
"strings"
"testing"
"time"
)
// stockfish drives a Stockfish process for cross-checks.
type stockfish struct {
cmd *exec.Cmd
in io.WriteCloser
out *bufio.Scanner
}
func startStockfish(t *testing.T) *stockfish {
t.Helper()
path, err := exec.LookPath("stockfish")
if err != nil {
if _, statErr := exec.LookPath("/usr/games/stockfish"); statErr != nil {
t.Skip("stockfish not found; skipping engine cross-check")
}
path = "/usr/games/stockfish"
}
cmd := exec.Command(path)
in, err := cmd.StdinPipe()
if err != nil {
t.Fatal(err)
}
outPipe, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
sf := &stockfish{cmd: cmd, in: in, out: bufio.NewScanner(outPipe)}
t.Cleanup(func() {
io.WriteString(in, "quit\n")
done := make(chan struct{})
go func() { cmd.Wait(); close(done) }()
select {
case <-done:
case <-time.After(5 * time.Second):
cmd.Process.Kill()
}
})
sf.send(t, "uci")
sf.until(t, "uciok", nil)
return sf
}
func (sf *stockfish) send(t *testing.T, cmd string) {
t.Helper()
if _, err := io.WriteString(sf.in, cmd+"\n"); err != nil {
t.Fatal(err)
}
}
func (sf *stockfish) until(t *testing.T, prefix string, visit func(string)) string {
t.Helper()
for sf.out.Scan() {
line := sf.out.Text()
if visit != nil {
visit(line)
}
if strings.HasPrefix(line, prefix) {
return line
}
}
t.Fatalf("stockfish closed before %q: %v", prefix, sf.out.Err())
return ""
}
// position returns Stockfish's FEN and sorted legal moves (from perft 1).
func (sf *stockfish) position(t *testing.T, fen string) (string, []string) {
t.Helper()
sf.send(t, "position fen "+fen)
sf.send(t, "d")
var sfFEN string
sf.until(t, "Checkers:", func(l string) {
if s, ok := strings.CutPrefix(l, "Fen: "); ok {
sfFEN = s
}
})
sf.send(t, "go perft 1")
var moves []string
sf.until(t, "Nodes searched:", func(l string) {
if uci, count, ok := strings.Cut(l, ": "); ok && count == "1" && len(uci) >= 4 && len(uci) <= 5 {
moves = append(moves, uci)
}
})
slices.Sort(moves)
return sfFEN, moves
}
// TestAgainstStockfish plays random legal games and compares, at every ply,
// the legal move set with Stockfish's perft and the FEN after each move with
// Stockfish's own (normalized for its en-passant convention).
func TestAgainstStockfish(t *testing.T) {
sf := startStockfish(t)
games, maxPlies := 40, 160
if testing.Short() {
games = 5
}
rng := rand.New(rand.NewPCG(20260927, 1))
starts := []string{StartFEN}
for _, tc := range perftCases[1:] {
starts = append(starts, tc.fen)
}
checked := 0
for g := 0; g < games; g++ {
pos, err := ParseFEN(starts[g%len(starts)])
if err != nil {
t.Fatal(err)
}
for ply := 0; ply < maxPlies; ply++ {
fen := pos.FEN()
sfFEN, sfMoves := sf.position(t, fen)
normalized, err := NormalizeFEN(sfFEN)
if err != nil {
t.Fatalf("stockfish FEN %q: %v", sfFEN, err)
}
if normalized != fen {
t.Fatalf("game %d ply %d: FEN %q, stockfish %q", g, ply, fen, sfFEN)
}
legal := pos.LegalMoves()
ours := make([]string, len(legal))
for i, m := range legal {
ours[i] = m.UCI()
}
slices.Sort(ours)
if !slices.Equal(ours, sfMoves) {
t.Fatalf("game %d ply %d %s:\n ours %v\n sf %v", g, ply, fen, ours, sfMoves)
}
checked++
if len(legal) == 0 {
break
}
// Prefer captures and promotions now and then to reach endgames.
m := legal[rng.IntN(len(legal))]
if rng.IntN(3) == 0 {
for _, c := range legal {
if pos.board[c.To] != NoPiece || c.Promotion != NoKind {
m = c
break
}
}
}
pos = pos.Play(m)
}
}
t.Logf("compared %d positions with stockfish", checked)
}
+34
View File
@@ -19,17 +19,37 @@ type MoveRequest struct {
Move string `json:"move" validate:"required,min=4,max=5"` // "cccc" for computer move, 4-5 chars for UCI moves Move string `json:"move" validate:"required,min=4,max=5"` // "cccc" for computer move, 4-5 chars for UCI moves
} }
// ResignRequest ends the game in the opponent's favor. Color may be omitted
// when the caller controls exactly one human side.
type ResignRequest struct {
Color string `json:"color,omitempty" validate:"omitempty,oneof=w b white black"`
}
// DrawRequest offers, accepts, or declines a draw by agreement. Offering to a
// computer opponent is answered at once.
type DrawRequest struct {
Action string `json:"action" validate:"required,oneof=offer accept decline"`
Color string `json:"color,omitempty" validate:"omitempty,oneof=w b white black"`
}
type UndoRequest struct { type UndoRequest struct {
Count int `json:"count" validate:"required,min=1,max=300"` // Max based on longest games in history (272), theoretical max 5949 Count int `json:"count" validate:"required,min=1,max=300"` // Max based on longest games in history (272), theoretical max 5949
} }
// Response types // Response types
// GameResponse is the live game. Termination names how a finished game ended;
// DrawOffer is the color whose draw offer awaits an answer; DrawOutcome
// reports what a draw request did ("offered", "accepted", "declined") and is
// set only on responses to that request.
type GameResponse struct { type GameResponse struct {
GameID string `json:"gameId"` GameID string `json:"gameId"`
FEN string `json:"fen"` FEN string `json:"fen"`
Turn string `json:"turn"` // "w" or "b" Turn string `json:"turn"` // "w" or "b"
State string `json:"state"` // "ongoing", "white wins", etc State string `json:"state"` // "ongoing", "white wins", etc
Termination string `json:"termination,omitempty"`
DrawOffer string `json:"drawOffer,omitempty"`
DrawOutcome string `json:"drawOutcome,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"`
@@ -50,19 +70,28 @@ type BoardResponse struct {
// GameHistoryResponse is the durable replay representation of a game. Moves // GameHistoryResponse is the durable replay representation of a game. Moves
// are ordered and include the resulting FEN so clients do not need an engine // are ordered and include the resulting FEN so clients do not need an engine
// to replay a stored game. // to replay a stored game.
//
// PGNResult is the PGN result token ("1-0", "0-1", "1/2-1/2", or "*") and
// Termination names how a result was reached (see core.Termination; omitted
// while the game is unfinished).
type GameHistoryResponse struct { type GameHistoryResponse struct {
GameID string `json:"gameId"` GameID string `json:"gameId"`
InitialFEN string `json:"initialFen"` InitialFEN string `json:"initialFen"`
Result string `json:"result,omitempty"` Result string `json:"result,omitempty"`
PGNResult string `json:"pgnResult"`
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"`
Players PlayersResponse `json:"players"` Players PlayersResponse `json:"players"`
Moves []HistoryMove `json:"moves"` Moves []HistoryMove `json:"moves"`
} }
// HistoryMove is one ply. SAN is derived on read and omitted only when the
// stored move cannot be notated.
type HistoryMove struct { type HistoryMove struct {
MoveNumber int `json:"moveNumber"` MoveNumber int `json:"moveNumber"`
MoveUCI string `json:"moveUci"` MoveUCI string `json:"moveUci"`
SAN string `json:"san,omitempty"`
FENAfterMove string `json:"fenAfterMove"` FENAfterMove string `json:"fenAfterMove"`
PlayerColor string `json:"playerColor"` PlayerColor string `json:"playerColor"`
MoveTimeUTC time.Time `json:"moveTimeUtc"` MoveTimeUTC time.Time `json:"moveTimeUtc"`
@@ -76,17 +105,22 @@ type GameSummary struct {
InitialFEN string `json:"initialFen"` InitialFEN string `json:"initialFen"`
FinalFEN string `json:"finalFen"` FinalFEN string `json:"finalFen"`
Result string `json:"result,omitempty"` Result string `json:"result,omitempty"`
PGNResult string `json:"pgnResult"`
StartTimeUTC time.Time `json:"startTimeUtc"` StartTimeUTC time.Time `json:"startTimeUtc"`
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"` EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
MoveCount int `json:"moveCount"` MoveCount int `json:"moveCount"`
Players PlayersResponse `json:"players"` Players PlayersResponse `json:"players"`
} }
// GameListResponse pages newest first. NextCursor continues the listing and
// stays stable while new games are added; NextOffset is kept for clients
// that page by position and is omitted on cursor requests.
type GameListResponse struct { type GameListResponse struct {
Games []GameSummary `json:"games"` Games []GameSummary `json:"games"`
Limit int `json:"limit"` Limit int `json:"limit"`
Offset int `json:"offset"` Offset int `json:"offset"`
NextOffset *int `json:"nextOffset,omitempty"` NextOffset *int `json:"nextOffset,omitempty"`
NextCursor string `json:"nextCursor,omitempty"`
} }
type ErrorResponse struct { type ErrorResponse struct {
+42
View File
@@ -59,3 +59,45 @@ func (s State) Result() (string, bool) {
return "", false return "", false
} }
} }
// Termination names how a game reached its result.
type Termination string
const (
TermNone Termination = ""
TermCheckmate Termination = "checkmate"
TermStalemate Termination = "stalemate"
TermInsufficientMaterial Termination = "insufficient_material"
TermThreefoldRepetition Termination = "threefold_repetition"
TermFiftyMoveRule Termination = "fifty_move_rule"
TermAgreement Termination = "agreement"
TermResignation Termination = "resignation"
)
// ValidFor reports whether a termination can produce the terminal state:
// wins come from checkmate or resignation, stalemate from stalemate, and
// draws from the draw rules or agreement.
func (t Termination) ValidFor(s State) bool {
switch s {
case StateWhiteWins, StateBlackWins:
return t == TermCheckmate || t == TermResignation
case StateStalemate:
return t == TermStalemate
case StateDraw:
return t == TermInsufficientMaterial || t == TermThreefoldRepetition ||
t == TermFiftyMoveRule || t == TermAgreement
default:
return t == TermNone
}
}
// ParseColor accepts "w", "b", "white", or "black"; anything else is zero.
func ParseColor(s string) Color {
switch s {
case "w", "white":
return ColorWhite
case "b", "black":
return ColorBlack
}
return 0
}
+87 -2
View File
@@ -4,7 +4,7 @@ import (
"fmt" "fmt"
"time" "time"
"chess/internal/server/board" "chess/internal/server/chess"
"chess/internal/server/core" "chess/internal/server/core"
) )
@@ -29,9 +29,13 @@ type Game struct {
snapshots []Snapshot snapshots []Snapshot
players map[core.Color]*core.Player players map[core.Color]*core.Player
state core.State state core.State
termination core.Termination
lastResult *MoveResult lastResult *MoveResult
endTimeUTC *time.Time endTimeUTC *time.Time
lastActivity time.Time // last create, move, undo, reconfiguration, or state change lastActivity time.Time // last create, move, undo, reconfiguration, or state change
drawOffer core.Color
// offerPly[c] is the ply count when color c last offered a draw, or -1.
offerPly map[core.Color]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.
@@ -42,9 +46,14 @@ type View struct {
InitialFEN string InitialFEN string
NextTurnColor core.Color NextTurnColor core.Color
Moves []string Moves []string
// FENs holds the position before the first move and after every move.
FENs []string
WhitePlayer *core.Player WhitePlayer *core.Player
BlackPlayer *core.Player BlackPlayer *core.Player
State core.State State core.State
Termination core.Termination
DrawOffer core.Color // color whose offer awaits an answer; 0 when none
OfferPly map[core.Color]int
LastResult *MoveResult LastResult *MoveResult
EndTimeUTC *time.Time EndTimeUTC *time.Time
} }
@@ -78,6 +87,7 @@ func New(initialFEN string, whitePlayer, blackPlayer *core.Player, startingTurnC
core.ColorBlack: &blackCopy, core.ColorBlack: &blackCopy,
}, },
state: core.StateOngoing, state: core.StateOngoing,
offerPly: map[core.Color]int{core.ColorWhite: -1, core.ColorBlack: -1},
} }
} }
@@ -87,7 +97,17 @@ func (g *Game) View() View {
InitialFEN: g.InitialFEN(), InitialFEN: g.InitialFEN(),
NextTurnColor: g.NextTurnColor(), NextTurnColor: g.NextTurnColor(),
Moves: g.Moves(), Moves: g.Moves(),
FENs: make([]string, len(g.snapshots)),
State: g.state, State: g.state,
Termination: g.termination,
DrawOffer: g.drawOffer,
OfferPly: map[core.Color]int{
core.ColorWhite: g.offerPly[core.ColorWhite],
core.ColorBlack: g.offerPly[core.ColorBlack],
},
}
for i, snapshot := range g.snapshots {
view.FENs[i] = snapshot.FEN
} }
if player := g.players[core.ColorWhite]; player != nil { if player := g.players[core.ColorWhite]; player != nil {
copy := *player copy := *player
@@ -156,7 +176,13 @@ func (g *Game) GetPlayer(color core.Color) *core.Player {
return g.players[color] return g.players[color]
} }
// AddSnapshot appends a move. It answers a draw offer made by the other
// side: moving instead of accepting declines it. An offer from the mover
// stands.
func (g *Game) AddSnapshot(fen string, move string, nextTurnColor core.Color) { func (g *Game) AddSnapshot(fen string, move string, nextTurnColor core.Color) {
if g.drawOffer == nextTurnColor {
g.drawOffer = 0
}
// Get the player ID for the next turn // Get the player ID for the next turn
nextPlayer := g.players[nextTurnColor] nextPlayer := g.players[nextTurnColor]
g.snapshots = append(g.snapshots, Snapshot{ g.snapshots = append(g.snapshots, Snapshot{
@@ -195,11 +221,58 @@ func (g *Game) UndoMoves(count int) error {
g.snapshots = g.snapshots[:len(g.snapshots)-count] g.snapshots = g.snapshots[:len(g.snapshots)-count]
g.state = core.StateOngoing // Reset game state when undoing g.state = core.StateOngoing // Reset game state when undoing
g.termination = core.TermNone
g.lastResult = nil // Clear last result g.lastResult = nil // Clear last result
g.endTimeUTC = nil g.endTimeUTC = nil
g.ClearDrawOffers()
return nil return nil
} }
// Plies returns the number of moves played.
func (g *Game) Plies() int {
return len(g.snapshots) - 1
}
// DrawOffer returns the color whose draw offer is pending, or 0.
func (g *Game) DrawOffer() core.Color {
return g.drawOffer
}
// OfferDraw records a pending offer from color and the ply it was made at.
func (g *Game) OfferDraw(color core.Color) {
g.drawOffer = color
g.offerPly[color] = g.Plies()
}
// LastOfferPly returns the ply count at color's last draw offer, or -1.
func (g *Game) LastOfferPly(color core.Color) int {
return g.offerPly[color]
}
// RecordDeclinedOffer counts an offer that was answered at once (by a
// computer) against its maker's one-offer-per-move allowance.
func (g *Game) RecordDeclinedOffer(color core.Color) {
g.offerPly[color] = g.Plies()
}
// DeclineDraw withdraws a pending offer.
func (g *Game) DeclineDraw() {
g.drawOffer = 0
}
// ClearDrawOffers forgets the pending offer and the per-move allowance, as
// after an undo or a player change.
func (g *Game) ClearDrawOffers() {
g.drawOffer = 0
g.offerPly[core.ColorWhite] = -1
g.offerPly[core.ColorBlack] = -1
}
// Termination returns how a finished game ended.
func (g *Game) Termination() core.Termination {
return g.termination
}
func (g *Game) Moves() []string { func (g *Game) Moves() []string {
moves := []string{} moves := []string{}
for i := 1; i < len(g.snapshots); i++ { for i := 1; i < len(g.snapshots); i++ {
@@ -214,6 +287,18 @@ func (g *Game) State() core.State {
return g.state return g.state
} }
// SetOutcome sets the state and how it was reached; a non-terminal state
// clears the termination.
func (g *Game) SetOutcome(s core.State, termination core.Termination, at time.Time) {
g.SetStateAt(s, at)
if s.IsTerminal() {
g.termination = termination
g.drawOffer = 0
} else {
g.termination = core.TermNone
}
}
func (g *Game) SetStateAt(s core.State, at time.Time) { func (g *Game) SetStateAt(s core.State, at time.Time) {
g.Touch(at) g.Touch(at)
if s.IsTerminal() { if s.IsTerminal() {
@@ -261,7 +346,7 @@ func (g *Game) InitialFEN() string {
if len(g.snapshots) > 0 { if len(g.snapshots) > 0 {
return g.snapshots[0].FEN return g.snapshots[0].FEN
} }
return board.StartingFEN return chess.StartFEN
} }
// ClaimSlot claims a player slot for a user // ClaimSlot claims a player slot for a user
+147 -7
View File
@@ -1,9 +1,12 @@
package http package http
import ( import (
"crypto/sha256"
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"strconv" "strconv"
"strings"
"time" "time"
"chess/internal/server/core" "chess/internal/server/core"
@@ -166,11 +169,14 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, opts Options)
// Register game routes with auth middleware // Register game routes with auth middleware
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.Put("/games/:gameId/players", h.ConfigurePlayers) api.Put("/games/:gameId/players", h.ConfigurePlayers)
api.Get("/games/:gameId", h.GetGame) api.Get("/games/:gameId", h.GetGame)
api.Delete("/games/:gameId", h.DeleteGame) api.Delete("/games/:gameId", 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", h.UndoMove)
api.Post("/games/:gameId/resign", OptionalAuth(validateToken), h.Resign)
api.Post("/games/:gameId/draw", OptionalAuth(validateToken), h.Draw)
api.Get("/games/:gameId/board", h.GetBoard) api.Get("/games/:gameId/board", h.GetBoard)
api.Get("/users/me/games", AuthRequired(validateToken), h.GetCurrentUserGames) api.Get("/users/me/games", AuthRequired(validateToken), h.GetCurrentUserGames)
@@ -508,6 +514,58 @@ func (h *HTTPHandler) UndoMove(c *fiber.Ctx) error {
return c.JSON(resp.Data) return c.JSON(resp.Data)
} }
// Resign ends the game in the opponent's favor.
func (h *HTTPHandler) Resign(c *fiber.Ctx) error {
return h.executeWithBody(c, func(gameID string, body any) processor.Command {
return processor.NewResignCommand(gameID, *body.(*core.ResignRequest))
})
}
// Draw offers, accepts, or declines a draw by agreement.
func (h *HTTPHandler) Draw(c *fiber.Ctx) error {
return h.executeWithBody(c, func(gameID string, body any) processor.Command {
return processor.NewDrawCommand(gameID, *body.(*core.DrawRequest))
})
}
// executeWithBody runs a game command built from the validated request body,
// with the caller's user ID for slot authorization.
func (h *HTTPHandler) executeWithBody(c *fiber.Ctx, build func(gameID string, body any) processor.Command) error {
gameID := c.Params("gameId")
if !isValidUUID(gameID) {
return invalidGameID(c)
}
body := c.Locals("validatedBody")
if validated, _ := c.Locals("validated").(bool); !validated || body == nil {
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
Error: "validation bypass detected", Code: core.ErrInternalError,
})
}
cmd := build(gameID, body)
cmd.UserID, _ = c.Locals("userID").(string)
resp := h.proc.Execute(cmd)
if !resp.Success {
return c.Status(statusForCode(resp.Error.Code)).JSON(resp.Error)
}
return c.JSON(resp.Data)
}
// statusForCode maps processor error codes to HTTP statuses.
func statusForCode(code string) int {
switch code {
case core.ErrGameNotFound:
return fiber.StatusNotFound
case core.ErrUnauthorized:
return fiber.StatusForbidden
case core.ErrConflict:
return fiber.StatusConflict
case core.ErrInternalError:
return fiber.StatusInternalServerError
}
return fiber.StatusBadRequest
}
// DeleteGame unloads a live game while retaining its durable history. // DeleteGame unloads a live game while retaining its durable history.
func (h *HTTPHandler) DeleteGame(c *fiber.Ctx) error { func (h *HTTPHandler) DeleteGame(c *fiber.Ctx) error {
gameID := c.Params("gameId") gameID := c.Params("gameId")
@@ -563,14 +621,79 @@ func (h *HTTPHandler) GetBoard(c *fiber.Ctx) error {
func (h *HTTPHandler) GetGameHistory(c *fiber.Ctx) error { func (h *HTTPHandler) GetGameHistory(c *fiber.Ctx) error {
gameID := c.Params("gameId") gameID := c.Params("gameId")
if !isValidUUID(gameID) { if !isValidUUID(gameID) {
return invalidGameID(c)
}
history, err := h.svc.GetGameHistory(gameID)
if err != nil {
return storedGameError(c, err)
}
body, err := c.App().Config().JSONEncoder(history)
if err != nil {
return err
}
return sendRevalidated(c, fiber.MIMEApplicationJSON, body)
}
// GetGamePGN exports a stored game as a PGN attachment; ?ply=N stops after N
// plies. Same visibility as the history.
func (h *HTTPHandler) GetGamePGN(c *fiber.Ctx) error {
gameID := c.Params("gameId")
if !isValidUUID(gameID) {
return invalidGameID(c)
}
ply, err := queryInt(c, "ply", -1, 0, 1_000_000)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
Error: "invalid ply", Code: core.ErrInvalidRequest, Details: err.Error(),
})
}
pgn, err := h.svc.GetGamePGN(gameID, ply)
if err != nil {
return storedGameError(c, err)
}
// The name is built from a date and the hexadecimal game ID only.
c.Set(fiber.HeaderContentDisposition, `attachment; filename="`+pgn.Filename+`"`)
return sendRevalidated(c, "application/x-chess-pgn; charset=utf-8", []byte(pgn.Text))
}
// sendRevalidated sends a stored-game representation with a strong ETag and
// answers a matching If-None-Match with 304. Stored games still change
// (moves, undo after a result), so caches must revalidate on every use
// rather than keep a copy for a fixed time.
func sendRevalidated(c *fiber.Ctx, contentType string, body []byte) error {
sum := sha256.Sum256(body)
etag := `"` + hex.EncodeToString(sum[:16]) + `"`
c.Set(fiber.HeaderETag, etag)
c.Set(fiber.HeaderCacheControl, "private, no-cache")
if etagMatches(c.Get(fiber.HeaderIfNoneMatch), etag) {
return c.SendStatus(fiber.StatusNotModified)
}
c.Set(fiber.HeaderContentType, contentType)
return c.Send(body)
}
// etagMatches applies the weak comparison If-None-Match requires (RFC 9110
// section 13.1.2) to a comma-separated header value.
func etagMatches(header, etag string) bool {
for _, candidate := range strings.Split(header, ",") {
candidate = strings.TrimPrefix(strings.TrimSpace(candidate), "W/")
if candidate == "*" || candidate == etag {
return true
}
}
return false
}
func invalidGameID(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{ return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
Error: "invalid game ID format", Code: core.ErrInvalidRequest, Error: "invalid game ID format", Code: core.ErrInvalidRequest,
Details: "game ID must be a valid UUID", Details: "game ID must be a valid UUID",
}) })
} }
history, err := h.svc.GetGameHistory(gameID) func storedGameError(c *fiber.Ctx, err error) error {
if err != nil {
switch { switch {
case errors.Is(err, service.ErrStorageDisabled), errors.Is(err, service.ErrStorageUnavailable): case errors.Is(err, service.ErrStorageDisabled), errors.Is(err, service.ErrStorageUnavailable):
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{ return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
@@ -580,17 +703,23 @@ func (h *HTTPHandler) GetGameHistory(c *fiber.Ctx) error {
return c.Status(fiber.StatusNotFound).JSON(core.ErrorResponse{ return c.Status(fiber.StatusNotFound).JSON(core.ErrorResponse{
Error: "game history not found", Code: core.ErrGameNotFound, Error: "game history not found", Code: core.ErrGameNotFound,
}) })
case errors.Is(err, service.ErrPlyOutOfRange):
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
Error: "invalid ply", Code: core.ErrInvalidRequest, Details: err.Error(),
})
case errors.Is(err, service.ErrNotation):
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
Error: "game cannot be exported as PGN", Code: core.ErrInternalError,
})
default: default:
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{ return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
Error: "failed to load game history", Code: core.ErrInternalError, Error: "failed to load game history", Code: core.ErrInternalError,
}) })
} }
} }
return c.JSON(history)
}
// GetCurrentUserGames returns a bounded list suitable for CLI and web game // GetCurrentUserGames returns a bounded list suitable for CLI and web game
// pickers. The extra row used to compute nextOffset stays internal. // pickers. The extra row used to detect a next page stays internal.
func (h *HTTPHandler) GetCurrentUserGames(c *fiber.Ctx) error { func (h *HTTPHandler) GetCurrentUserGames(c *fiber.Ctx) error {
userID, ok := c.Locals("userID").(string) userID, ok := c.Locals("userID").(string)
if !ok || userID == "" { if !ok || userID == "" {
@@ -612,9 +741,20 @@ func (h *HTTPHandler) GetCurrentUserGames(c *fiber.Ctx) error {
}) })
} }
games, err := h.svc.GetUserGames(userID, limit, offset) games, err := h.svc.GetUserGames(userID, service.UserGamesOptions{
Limit: limit,
Offset: offset,
Cursor: c.Query("cursor"),
Color: c.Query("color"),
Status: c.Query("status"),
})
if err != nil { if err != nil {
if errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable) { switch {
case errors.Is(err, service.ErrInvalidListQuery):
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
Error: "invalid list query", Code: core.ErrInvalidRequest, Details: err.Error(),
})
case errors.Is(err, service.ErrStorageDisabled), errors.Is(err, service.ErrStorageUnavailable):
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{ return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
Error: "stored games unavailable", Code: core.ErrStorageUnavailable, Error: "stored games unavailable", Code: core.ErrStorageUnavailable,
}) })
+116 -7
View File
@@ -2,13 +2,20 @@ package http
import ( import (
"io" "io"
nethttp "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
"time"
"chess/internal/server/chess"
"chess/internal/server/core"
"chess/internal/server/service" "chess/internal/server/service"
"chess/internal/server/storage"
"chess/internal/server/storage/pgtest"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/google/uuid"
) )
func TestClientIPTrustsProxyHeaderOnlyFromTrustedPeers(t *testing.T) { func TestClientIPTrustsProxyHeaderOnlyFromTrustedPeers(t *testing.T) {
@@ -63,7 +70,7 @@ func TestIsValidUUIDRequiresCanonicalForm(t *testing.T) {
} }
} }
func TestAPIRoutesAreUnversioned(t *testing.T) { func TestAPIRoutes(t *testing.T) {
svc, err := service.New(nil, []byte("test-secret-test-secret-test-secret")) svc, err := service.New(nil, []byte("test-secret-test-secret-test-secret"))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -74,9 +81,6 @@ func TestAPIRoutesAreUnversioned(t *testing.T) {
registered := make(map[string]bool) registered := make(map[string]bool)
for _, route := range app.GetRoutes(true) { for _, route := range app.GetRoutes(true) {
registered[route.Method+" "+route.Path] = true registered[route.Method+" "+route.Path] = true
if strings.HasPrefix(route.Path, "/api/v1") {
t.Errorf("versioned route still registered: %s %s", route.Method, route.Path)
}
} }
for _, want := range []string{ for _, want := range []string{
"GET /health", "GET /health",
@@ -87,19 +91,124 @@ func TestAPIRoutesAreUnversioned(t *testing.T) {
"POST /api/games", "POST /api/games",
"GET /api/games/:gameId", "GET /api/games/:gameId",
"GET /api/games/:gameId/history", "GET /api/games/:gameId/history",
"GET /api/games/:gameId/pgn",
"POST /api/games/:gameId/moves", "POST /api/games/:gameId/moves",
"POST /api/games/:gameId/resign",
"POST /api/games/:gameId/draw",
"GET /api/users/me/games", "GET /api/users/me/games",
} { } {
if !registered[want] { if !registered[want] {
t.Errorf("route %s is not registered", want) t.Errorf("route %s is not registered", want)
} }
} }
}
response, err := app.Test(httptest.NewRequest("GET", "/api/v1/games/5579b47e-4d3b-4eb3-abbd-846fe94cb955", nil)) func TestETagMatches(t *testing.T) {
const etag = `"0123abcd"`
for header, want := range map[string]bool{
"": false,
`"0123abcd"`: true,
`W/"0123abcd"`: true,
`"other", "0123abcd"`: true,
`"other",W/"0123abcd" `: true,
"*": true,
`"0123abc"`: false,
`0123abcd`: false,
} {
if got := etagMatches(header, etag); got != want {
t.Errorf("etagMatches(%q) = %v, want %v", header, got, want)
}
}
}
func TestStoredGameEndpoints(t *testing.T) {
store, err := storage.NewStore(pgtest.DSN(t))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if response.StatusCode != fiber.StatusNotFound { if err := store.InitDB(); err != nil {
t.Fatalf("GET /api/v1/... status = %d, want 404", response.StatusCode) t.Fatal(err)
}
svc, err := service.New(store, []byte("test-secret-test-secret-test-secret"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { svc.Shutdown(time.Second) })
gameID := uuid.NewString()
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
if err := svc.CreateGame(gameID, white, black, chess.StartFEN, core.ColorWhite, core.StateOngoing, core.TermNone); err != nil {
t.Fatal(err)
}
pos, _ := chess.ParseFEN(chess.StartFEN)
for i, uci := range []string{"e2e4", "e7e5"} {
m, err := pos.ParseUCI(uci)
if err != nil {
t.Fatal(err)
}
next := pos.Play(m)
turn := core.ColorWhite
if i == 1 {
turn = core.ColorBlack
}
if err := svc.ApplyMoveWithState(gameID, service.MoveCommit{
ExpectedFEN: pos.FEN(), ExpectedState: core.StateOngoing, ExpectedTurn: turn,
MoveUCI: uci, NewFEN: next.FEN(), State: core.StateOngoing,
}); err != nil {
t.Fatal(err)
}
pos = next
}
app := NewFiberApp(nil, svc, Options{DevMode: true})
get := func(path string, header ...string) (*nethttp.Response, string) {
t.Helper()
request := httptest.NewRequest("GET", path, nil)
for i := 0; i+1 < len(header); i += 2 {
request.Header.Set(header[i], header[i+1])
}
response, err := app.Test(request)
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(response.Body)
return response, string(body)
}
response, body := get("/api/games/" + gameID + "/history")
etag := response.Header.Get("ETag")
if response.StatusCode != 200 || etag == "" || response.Header.Get("Cache-Control") != "private, no-cache" ||
!strings.Contains(body, `"san":"e4"`) || !strings.Contains(body, `"pgnResult":"*"`) ||
!strings.HasPrefix(response.Header.Get("Content-Type"), "application/json") {
t.Fatalf("history: %d %v %s", response.StatusCode, response.Header, body)
}
if response, body = get("/api/games/"+gameID+"/history", "If-None-Match", etag); response.StatusCode != 304 || body != "" {
t.Fatalf("revalidated history: %d %q", response.StatusCode, body)
}
response, body = get("/api/games/" + gameID + "/pgn")
if response.StatusCode != 200 || response.Header.Get("Content-Type") != "application/x-chess-pgn; charset=utf-8" ||
!strings.HasPrefix(response.Header.Get("Content-Disposition"), `attachment; filename="chess-`) ||
!strings.HasSuffix(body, "\n\n1. e4 e5 *\n") {
t.Fatalf("pgn: %d %v\n%s", response.StatusCode, response.Header, body)
}
if response, body = get("/api/games/" + gameID + "/pgn?ply=1"); response.StatusCode != 200 ||
!strings.HasSuffix(body, "\n\n1. e4 *\n") ||
!strings.Contains(response.Header.Get("Content-Disposition"), "-ply1.pgn") {
t.Fatalf("pgn ply 1: %d %v\n%s", response.StatusCode, response.Header, body)
}
for path, want := range map[string]int{
"/api/games/" + gameID + "/pgn?ply=3": 400,
"/api/games/" + gameID + "/pgn?ply=-1": 400,
"/api/games/" + gameID + "/pgn?ply=x": 400,
"/api/games/" + uuid.NewString() + "/pgn": 404,
"/api/games/" + uuid.NewString() + "/history": 404,
"/api/games/not-a-uuid/pgn": 400,
} {
if response, body := get(path); response.StatusCode != want {
t.Errorf("GET %s = %d %s, want %d", path, response.StatusCode, body, want)
}
} }
} }
+4
View File
@@ -36,6 +36,10 @@ func validationMiddleware(c *fiber.Ctx) error {
requestType = &core.MoveRequest{} requestType = &core.MoveRequest{}
case strings.HasSuffix(path, "/undo") && method == fiber.MethodPost: case strings.HasSuffix(path, "/undo") && method == fiber.MethodPost:
requestType = &core.UndoRequest{} requestType = &core.UndoRequest{}
case strings.HasSuffix(path, "/resign") && method == fiber.MethodPost:
requestType = &core.ResignRequest{}
case strings.HasSuffix(path, "/draw") && method == fiber.MethodPost:
requestType = &core.DrawRequest{}
default: default:
return c.Next() // No validation for unknown endpoints return c.Next() // No validation for unknown endpoints
} }
+18
View File
@@ -15,6 +15,8 @@ const (
CmdMakeMove CmdMakeMove
CmdUndoMove CmdUndoMove
CmdGetBoard CmdGetBoard
CmdResign
CmdDraw
) )
// Command is a unified structure for all processor operations // Command is a unified structure for all processor operations
@@ -84,3 +86,19 @@ func NewGetBoardCommand(gameID string) Command {
GameID: gameID, GameID: gameID,
} }
} }
func NewResignCommand(gameID string, req core.ResignRequest) Command {
return Command{
Type: CmdResign,
GameID: gameID,
Args: req,
}
}
func NewDrawCommand(gameID string, req core.DrawRequest) Command {
return Command{
Type: CmdDraw,
GameID: gameID,
Args: req,
}
}
+383 -35
View File
@@ -10,7 +10,7 @@ import (
"time" "time"
"unicode" "unicode"
"chess/internal/server/board" "chess/internal/server/chess"
"chess/internal/server/core" "chess/internal/server/core"
"chess/internal/server/engine" "chess/internal/server/engine"
"chess/internal/server/game" "chess/internal/server/game"
@@ -19,6 +19,14 @@ import (
const ( const (
minSearchTime = 100 minSearchTime = 100
// A computer answers a draw offer only once both sides have made ten
// moves, and accepts when its evaluation, from its own side, is level or
// worse. The evaluation is a short search on the validation engine; the
// one-offer-per-move allowance bounds how often it runs.
drawOfferMinPlies = 20
drawEvalTimeMs = 100
drawAcceptMaxScore = 0 // centipawns
) )
// FEN validation regex // FEN validation regex
@@ -63,6 +71,10 @@ func (p *Processor) Execute(cmd Command) ProcessorResponse {
return p.handleDeleteGame(cmd) return p.handleDeleteGame(cmd)
case CmdGetBoard: case CmdGetBoard:
return p.handleGetBoard(cmd) return p.handleGetBoard(cmd)
case CmdResign:
return p.handleResign(cmd)
case CmdDraw:
return p.handleDraw(cmd)
default: default:
return p.errorResponse("unknown command", core.ErrInvalidRequest) return p.errorResponse("unknown command", core.ErrInvalidRequest)
} }
@@ -144,28 +156,38 @@ func (p *Processor) handleCreateGame(cmd Command) ProcessorResponse {
gameID := p.svc.GenerateGameID() gameID := p.svc.GenerateGameID()
// Validate FEN safety, then classify via engine // Validate FEN safety, then classify via engine
initialFEN := board.StartingFEN initialFEN := chess.StartFEN
if args.FEN != "" { if args.FEN != "" {
if !p.isFENSafe(args.FEN) { if !p.isFENSafe(args.FEN) {
return p.errorResponse("invalid FEN format or characters", core.ErrInvalidFEN) return p.errorResponse("invalid FEN format or characters", core.ErrInvalidFEN)
} }
initialFEN = args.FEN // The rules core rejects positions the engine must never see (missing
// kings, the side not to move in check) and drops castling rights
// without their rook, so both agree on the legal moves.
pos, err := chess.ParseFEN(args.FEN)
if err != nil {
return p.errorResponse(err.Error(), core.ErrInvalidFEN)
}
initialFEN = pos.FEN()
} }
p.mu.Lock() p.mu.Lock()
err := p.validationEng.NewGame() err := p.validationEng.NewGame()
var validatedFEN string var validatedFEN string
initialState := core.StateOngoing initialState, termination := core.StateOngoing, core.TermNone
if err == nil { if err == nil {
validatedFEN, initialState, err = p.classifyLocked(initialFEN) validatedFEN, initialState, termination, err = p.classifyLocked(initialFEN)
} }
p.mu.Unlock() p.mu.Unlock()
if err != nil { if err != nil {
return p.errorResponse(fmt.Sprintf("engine validation failed: %v", err), core.ErrInternalError) return p.errorResponse(fmt.Sprintf("engine validation failed: %v", err), core.ErrInternalError)
} }
if initialState == core.StateOngoing {
initialState, termination = adjudicateDraw(nil, validatedFEN)
}
// Parse canonical FEN to get starting turn // Parse canonical FEN to get starting turn
b, err := board.ParseFEN(validatedFEN) start, err := chess.ParseFEN(validatedFEN)
if err != nil { if err != nil {
return p.errorResponse(fmt.Sprintf("FEN parse error: %v", err), core.ErrInvalidRequest) return p.errorResponse(fmt.Sprintf("FEN parse error: %v", err), core.ErrInvalidRequest)
} }
@@ -186,7 +208,8 @@ func (p *Processor) handleCreateGame(cmd Command) ProcessorResponse {
} }
} }
if err = p.svc.CreateGame(gameID, whitePlayer, blackPlayer, validatedFEN, b.Turn(), initialState); err != nil { if err = p.svc.CreateGame(gameID, whitePlayer, blackPlayer, validatedFEN,
coreColor(start.Turn()), initialState, termination); err != nil {
return p.errorResponse(fmt.Sprintf("failed to create game: %v", err), core.ErrInternalError) return p.errorResponse(fmt.Sprintf("failed to create game: %v", err), core.ErrInternalError)
} }
@@ -308,7 +331,7 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
return p.errorResponse(fmt.Sprintf("failed to start computer move: %v", err), core.ErrInternalError) return p.errorResponse(fmt.Sprintf("failed to start computer move: %v", err), core.ErrInternalError)
} }
if err := p.triggerComputerMove(cmd.GameID, g); err != nil { if err := p.triggerComputerMove(cmd.GameID, g); err != nil {
p.svc.UpdateGameState(cmd.GameID, core.StateStuck) p.svc.UpdateGameState(cmd.GameID, core.StateStuck, core.TermNone)
return p.errorResponse(fmt.Sprintf("failed to queue computer move: %v", err), core.ErrResourceLimit) return p.errorResponse(fmt.Sprintf("failed to queue computer move: %v", err), core.ErrResourceLimit)
} }
@@ -351,27 +374,38 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
currentFEN := g.FEN currentFEN := g.FEN
// A pawn move to the last rank needs its promotion piece; say so instead
// of the engine's generic rejection.
rules := rulesCheck(currentFEN, move)
if errors.Is(rules.err, chess.ErrPromotionRequired) {
return p.errorResponse("promotion piece required: append q, r, b, or n (e.g. "+move+"q)", core.ErrInvalidMove)
}
// Validate move and classify the resulting position in one engine session // Validate move and classify the resulting position in one engine session
p.mu.Lock() p.mu.Lock()
err = p.validationEng.SetPosition(currentFEN, []string{move}) err = p.validationEng.SetPosition(currentFEN, []string{move})
var newFEN string var newFEN string
finalState := core.StateOngoing finalState, termination := core.StateOngoing, core.TermNone
if err == nil { if err == nil {
newFEN, finalState, err = p.classifyCurrentLocked() newFEN, finalState, termination, err = p.classifyCurrentLocked()
} }
p.mu.Unlock() p.mu.Unlock()
if err != nil { if err != nil {
// Game untouched at pre-move position; retry runs on a respawned engine // Game untouched at pre-move position; retry runs on a respawned engine
return p.errorResponse("engine unavailable", core.ErrInternalError) return p.errorResponse("engine unavailable", core.ErrInternalError)
} }
rules.compare(cmd.GameID, move, newFEN != currentFEN, newFEN)
if newFEN == currentFEN { if newFEN == currentFEN {
return p.errorResponse("illegal move", core.ErrInvalidMove) return p.errorResponse("illegal move", core.ErrInvalidMove)
} }
if finalState == core.StateOngoing {
finalState, termination = adjudicateDraw(g.FENs, newFEN)
}
// Atomic commit: move + state + metadata, single notification // Atomic commit: move + state + metadata, single notification
if err = p.svc.ApplyMoveWithState(cmd.GameID, service.MoveCommit{ if err = p.svc.ApplyMoveWithState(cmd.GameID, service.MoveCommit{
ExpectedFEN: currentFEN, ExpectedState: core.StateOngoing, ExpectedTurn: currentColor, ExpectedFEN: currentFEN, ExpectedState: core.StateOngoing, ExpectedTurn: currentColor,
ActorUserID: cmd.UserID, MoveUCI: move, NewFEN: newFEN, State: finalState, ActorUserID: cmd.UserID, MoveUCI: move, NewFEN: newFEN, State: finalState, Termination: termination,
Result: &game.MoveResult{Move: move, PlayerColor: currentColor, GameState: finalState}, Result: &game.MoveResult{Move: move, PlayerColor: currentColor, GameState: finalState},
}); err != nil { }); err != nil {
if errors.Is(err, service.ErrSlotOwner) { if errors.Is(err, service.ErrSlotOwner) {
@@ -455,11 +489,11 @@ func (p *Processor) handleGetBoard(cmd Command) ProcessorResponse {
return p.errorResponse("game not found", core.ErrGameNotFound) return p.errorResponse("game not found", core.ErrGameNotFound)
} }
b, err := board.ParseFEN(g.FEN) pos, err := chess.ParseFEN(g.FEN)
if err != nil { if err != nil {
return p.errorResponse("error parsing FEN", core.ErrInvalidFEN) return p.errorResponse("error parsing FEN", core.ErrInvalidFEN)
} }
ascii := b.ToASCII() ascii := pos.ASCII()
return ProcessorResponse{ return ProcessorResponse{
Success: true, Success: true,
@@ -486,38 +520,42 @@ func (p *Processor) triggerComputerMove(gameID string, g game.View) error {
} }
if result.Error != nil { if result.Error != nil {
slog.Error("computer engine failed", "game_id", gameID, "error", result.Error) slog.Error("computer engine failed", "game_id", gameID, "error", result.Error)
p.svc.UpdateGameState(gameID, core.StateStuck) p.svc.UpdateGameState(gameID, core.StateStuck, core.TermNone)
return return
} }
if result.Move == "" || result.Move == "(none)" { if result.Move == "" || result.Move == "(none)" {
// Worker says no legal moves; verify against the validation engine. // Worker says no legal moves; verify against the validation engine.
p.mu.Lock() p.mu.Lock()
_, state, cerr := p.classifyLocked(fen) _, state, termination, cerr := p.classifyLocked(fen)
p.mu.Unlock() p.mu.Unlock()
if cerr != nil || state == core.StateOngoing { if cerr != nil || state == core.StateOngoing {
p.svc.UpdateGameState(gameID, core.StateStuck) // engines disagree p.svc.UpdateGameState(gameID, core.StateStuck, core.TermNone) // engines disagree
return return
} }
p.svc.UpdateGameState(gameID, state) p.svc.UpdateGameState(gameID, state, termination)
return return
} }
p.mu.Lock() p.mu.Lock()
aerr := p.validationEng.SetPosition(fen, []string{result.Move}) aerr := p.validationEng.SetPosition(fen, []string{result.Move})
var newFEN string var newFEN string
finalState := core.StateOngoing finalState, termination := core.StateOngoing, core.TermNone
if aerr == nil { if aerr == nil {
newFEN, finalState, aerr = p.classifyCurrentLocked() newFEN, finalState, termination, aerr = p.classifyCurrentLocked()
} }
p.mu.Unlock() p.mu.Unlock()
if aerr != nil || newFEN == fen { if aerr != nil || newFEN == fen {
p.svc.UpdateGameState(gameID, core.StateStuck) p.svc.UpdateGameState(gameID, core.StateStuck, core.TermNone)
return return
} }
rulesCheck(fen, result.Move).compare(gameID, result.Move, true, newFEN)
if finalState == core.StateOngoing {
finalState, termination = adjudicateDraw(currentGame.FENs, newFEN)
}
if err := p.svc.ApplyMoveWithState(gameID, service.MoveCommit{ if err := p.svc.ApplyMoveWithState(gameID, service.MoveCommit{
ExpectedFEN: fen, ExpectedState: core.StatePending, ExpectedTurn: color, ExpectedFEN: fen, ExpectedState: core.StatePending, ExpectedTurn: color,
MoveUCI: result.Move, NewFEN: newFEN, State: finalState, MoveUCI: result.Move, NewFEN: newFEN, State: finalState, Termination: termination,
Result: &game.MoveResult{ Result: &game.MoveResult{
Move: result.Move, PlayerColor: color, Move: result.Move, PlayerColor: color,
Score: result.Score, Depth: result.Depth, GameState: finalState, Score: result.Score, Depth: result.Depth, GameState: finalState,
@@ -525,47 +563,89 @@ func (p *Processor) triggerComputerMove(gameID string, g game.View) error {
}); err != nil { }); err != nil {
slog.Error("failed to apply computer move", "game_id", gameID, "error", err) slog.Error("failed to apply computer move", "game_id", gameID, "error", err)
if !errors.Is(err, service.ErrGameChanged) && !errors.Is(err, service.ErrGameNotFound) { if !errors.Is(err, service.ErrGameChanged) && !errors.Is(err, service.ErrGameNotFound) {
p.svc.UpdateGameState(gameID, core.StateStuck) p.svc.UpdateGameState(gameID, core.StateStuck, core.TermNone)
} }
} }
}) })
} }
// classifyCurrentLocked classifies whatever position is loaded in the // classifyCurrentLocked classifies whatever position is loaded in the
// validation engine. Caller holds p.mu, immediately after a SetPosition. // validation engine: mate and stalemate come from the engine. Caller holds
func (p *Processor) classifyCurrentLocked() (fen string, state core.State, err error) { // p.mu, immediately after a SetPosition.
func (p *Processor) classifyCurrentLocked() (fen string, state core.State, termination core.Termination, err error) {
diag, err := p.validationEng.Diagnose() diag, err := p.validationEng.Diagnose()
if err != nil { if err != nil {
return "", core.StateOngoing, err return "", core.StateOngoing, core.TermNone, err
} }
legal, err := p.validationEng.HasLegalMoves() legal, err := p.validationEng.HasLegalMoves()
if err != nil { if err != nil {
return "", core.StateOngoing, err return "", core.StateOngoing, core.TermNone, err
} }
if legal { if legal {
return diag.FEN, core.StateOngoing, nil return diag.FEN, core.StateOngoing, core.TermNone, nil
} }
if !diag.InCheck { if !diag.InCheck {
return diag.FEN, core.StateStalemate, nil return diag.FEN, core.StateStalemate, core.TermStalemate, nil
} }
b, err := board.ParseFEN(diag.FEN) pos, err := chess.ParseFEN(diag.FEN)
if err != nil { if err != nil {
return "", core.StateOngoing, err return "", core.StateOngoing, core.TermNone, err
} }
if b.Turn() == core.ColorWhite { if pos.Turn() == chess.White {
return diag.FEN, core.StateBlackWins, nil return diag.FEN, core.StateBlackWins, core.TermCheckmate, nil
} }
return diag.FEN, core.StateWhiteWins, nil return diag.FEN, core.StateWhiteWins, core.TermCheckmate, nil
} }
// classifyLocked sets a position from fen and classifies it. Caller holds p.mu. // classifyLocked sets a position from fen and classifies it. Caller holds p.mu.
func (p *Processor) classifyLocked(fen string) (string, core.State, error) { func (p *Processor) classifyLocked(fen string) (string, core.State, core.Termination, error) {
if err := p.validationEng.SetPosition(fen, nil); err != nil { if err := p.validationEng.SetPosition(fen, nil); err != nil {
return "", core.StateOngoing, err return "", core.StateOngoing, core.TermNone, err
} }
return p.classifyCurrentLocked() return p.classifyCurrentLocked()
} }
// adjudicateDraw applies the draw rules that end a game without a claim, as
// most online servers do: dead material, threefold repetition, and the
// fifty-move rule (a claimable draw under FIDE rules). history holds the
// positions before fen, oldest first. It returns StateOngoing when no rule
// applies. The engine has already ruled out mate and stalemate, which take
// precedence.
func adjudicateDraw(history []string, fen string) (core.State, core.Termination) {
pos, err := chess.ParseFEN(fen)
if err != nil {
return core.StateOngoing, core.TermNone
}
if pos.InsufficientMaterial() {
return core.StateDraw, core.TermInsufficientMaterial
}
// Positions before the last capture or pawn move cannot repeat.
window := min(len(history), pos.Halfmove())
line := make([]*chess.Position, 0, window+1)
for _, prior := range history[len(history)-window:] {
prev, err := chess.ParseFEN(prior)
if err != nil {
line = line[:0] // a gap cannot be bridged; count from here on
continue
}
line = append(line, prev)
}
if chess.Repetitions(append(line, pos)) >= 3 {
return core.StateDraw, core.TermThreefoldRepetition
}
if pos.Halfmove() >= 100 {
return core.StateDraw, core.TermFiftyMoveRule
}
return core.StateOngoing, core.TermNone
}
func coreColor(c chess.Color) core.Color {
if c == chess.White {
return core.ColorWhite
}
return core.ColorBlack
}
// buildGameResponse constructs standard game response // buildGameResponse constructs standard game response
func (p *Processor) buildGameResponse(gameID string, g game.View) core.GameResponse { func (p *Processor) buildGameResponse(gameID string, g game.View) core.GameResponse {
resp := core.GameResponse{ resp := core.GameResponse{
@@ -573,12 +653,16 @@ func (p *Processor) buildGameResponse(gameID string, g game.View) core.GameRespo
FEN: g.FEN, FEN: g.FEN,
Turn: g.NextTurnColor.String(), Turn: g.NextTurnColor.String(),
State: g.State.String(), State: g.State.String(),
Termination: string(g.Termination),
Moves: g.Moves, Moves: g.Moves,
Players: core.PlayersResponse{ Players: core.PlayersResponse{
White: g.WhitePlayer, White: g.WhitePlayer,
Black: g.BlackPlayer, Black: g.BlackPlayer,
}, },
} }
if g.DrawOffer != 0 {
resp.DrawOffer = g.DrawOffer.String()
}
// Include last move if available // Include last move if available
if result := g.LastResult; result != nil { if result := g.LastResult; result != nil {
@@ -612,3 +696,267 @@ func (p *Processor) Close() error {
p.mu.Unlock() p.mu.Unlock()
return errors.Join(queueErr, engineErr) return errors.Join(queueErr, engineErr)
} }
// rulesResult is the rules core's verdict on a move, computed alongside the
// engine's. Stockfish stays authoritative; a disagreement is logged so the
// core can be trusted, or fixed, before it validates anything.
type rulesResult struct {
fen string // normalized FEN after the move; empty when illegal
err error
}
func rulesCheck(fen, uci string) rulesResult {
pos, err := chess.ParseFEN(fen)
if err != nil {
return rulesResult{err: err}
}
m, err := pos.ParseUCI(uci)
if err != nil {
return rulesResult{err: err}
}
return rulesResult{fen: pos.Play(m).FEN()}
}
func (r rulesResult) compare(gameID, uci string, engineLegal bool, engineFEN string) {
if errors.Is(r.err, chess.ErrInvalidFEN) {
slog.Warn("rules core cannot parse game position", "game_id", gameID, "error", r.err)
return
}
if engineLegal != (r.err == nil) {
slog.Warn("rules core and engine disagree on legality",
"game_id", gameID, "move", uci, "engine_legal", engineLegal, "rules_error", r.err)
return
}
if !engineLegal {
return
}
if normalized, err := chess.NormalizeFEN(engineFEN); err != nil || normalized != r.fen {
slog.Warn("rules core and engine disagree on resulting position",
"game_id", gameID, "move", uci, "engine_fen", engineFEN, "rules_fen", r.fen)
}
}
// handleResign ends the game in the opponent's favor. It is allowed while the
// computer is thinking or the engine is stuck: resigning never needs one.
func (p *Processor) handleResign(cmd Command) ProcessorResponse {
args, ok := cmd.Args.(core.ResignRequest)
if !ok {
return p.errorResponse("invalid arguments", core.ErrInvalidRequest)
}
g, err := p.svc.GetGameView(cmd.GameID)
if err != nil {
return p.errorResponse("game not found", core.ErrGameNotFound)
}
if g.State.IsTerminal() {
return p.errorResponse(fmt.Sprintf("game is over: %s", g.State), core.ErrGameOver)
}
color, failure := actingColor(g, args.Color, cmd.UserID)
if failure != nil {
return *failure
}
winner := core.StateWhiteWins
if color == core.ColorWhite {
winner = core.StateBlackWins
}
err = p.svc.EndGame(cmd.GameID, service.EndCommit{
ExpectedFEN: g.FEN, Color: color, ActorUserID: cmd.UserID,
State: winner, Termination: core.TermResignation,
})
if err != nil {
return p.endGameError(err)
}
g, _ = p.svc.GetGameView(cmd.GameID)
return ProcessorResponse{Success: true, Data: p.buildGameResponse(cmd.GameID, g)}
}
// handleDraw offers, accepts, or declines a draw by agreement. An offer to a
// human stands until answered, or until the opponent moves instead; an offer
// to a computer is answered at once. Offering while the opponent's offer
// stands accepts it.
func (p *Processor) handleDraw(cmd Command) ProcessorResponse {
args, ok := cmd.Args.(core.DrawRequest)
if !ok {
return p.errorResponse("invalid arguments", core.ErrInvalidRequest)
}
g, err := p.svc.GetGameView(cmd.GameID)
if err != nil {
return p.errorResponse("game not found", core.ErrGameNotFound)
}
switch {
case g.State.IsTerminal():
return p.errorResponse(fmt.Sprintf("game is over: %s", g.State), core.ErrGameOver)
case g.State != core.StateOngoing:
return p.errorResponse("draws can be agreed only while no computer move is running", core.ErrInvalidRequest)
}
color, failure := actingColor(g, args.Color, cmd.UserID)
if failure != nil {
return *failure
}
opponent := core.OppositeColor(color)
accept := func() ProcessorResponse {
err := p.svc.EndGame(cmd.GameID, service.EndCommit{
ExpectedFEN: g.FEN, Color: color, ActorUserID: cmd.UserID,
State: core.StateDraw, Termination: core.TermAgreement, OfferFrom: opponent,
})
if err != nil {
return p.endGameError(err)
}
return p.drawResponse(cmd.GameID, "accepted")
}
switch args.Action {
case "accept":
if g.DrawOffer != opponent {
return p.errorResponse("no draw offer from the opponent", core.ErrInvalidRequest)
}
return accept()
case "decline":
if err := p.svc.DeclineDraw(cmd.GameID, color, cmd.UserID); err != nil {
return p.endGameError(err)
}
return p.drawResponse(cmd.GameID, "declined")
}
// Offer.
switch {
case g.DrawOffer == opponent:
return accept()
case g.DrawOffer == color:
return p.drawResponse(cmd.GameID, "offered")
}
if last := g.OfferPly[color]; last >= 0 && len(g.Moves) < last+2 {
return p.errorResponse(service.ErrOfferLimit.Error(), core.ErrConflict)
}
if player := g.Player(opponent); player != nil && player.Type == core.PlayerComputer {
agrees := false
if len(g.Moves) >= drawOfferMinPlies {
if agrees, err = p.computerAcceptsDraw(g.FEN, opponent); err != nil {
return p.errorResponse("engine unavailable", core.ErrInternalError)
}
}
if agrees {
err = p.svc.EndGame(cmd.GameID, service.EndCommit{
ExpectedFEN: g.FEN, Color: color, ActorUserID: cmd.UserID,
State: core.StateDraw, Termination: core.TermAgreement,
})
if err != nil {
return p.endGameError(err)
}
return p.drawResponse(cmd.GameID, "accepted")
}
if err := p.svc.RecordDeclinedOffer(cmd.GameID, color, cmd.UserID, g.FEN); err != nil {
return p.endGameError(err)
}
return p.drawResponse(cmd.GameID, "declined")
}
if err := p.svc.OfferDraw(cmd.GameID, color, cmd.UserID, g.FEN); err != nil {
return p.endGameError(err)
}
return p.drawResponse(cmd.GameID, "offered")
}
func (p *Processor) drawResponse(gameID, outcome string) ProcessorResponse {
g, err := p.svc.GetGameView(gameID)
if err != nil {
return p.errorResponse("game not found", core.ErrGameNotFound)
}
response := p.buildGameResponse(gameID, g)
response.DrawOutcome = outcome
return ProcessorResponse{Success: true, Data: response}
}
// computerAcceptsDraw evaluates fen for the computer playing color with a
// short full-strength search. UCI scores are from the side to move.
func (p *Processor) computerAcceptsDraw(fen string, computer core.Color) (bool, error) {
pos, err := chess.ParseFEN(fen)
if err != nil {
return false, err
}
p.mu.Lock()
defer p.mu.Unlock()
if err := p.validationEng.SetPosition(fen, nil); err != nil {
return false, err
}
result, err := p.validationEng.Search(drawEvalTimeMs)
if err != nil {
return false, err
}
score := result.Score
if coreColor(pos.Turn()) != computer {
score = -score
}
return score <= drawAcceptMaxScore, nil
}
// actingColor resolves which side a resignation or draw request acts for: the
// requested color, else the one side the caller claimed, else the only human
// side. It enforces slot ownership: a claimed side acts only for its claimant.
func actingColor(g game.View, requested, userID string) (core.Color, *ProcessorResponse) {
fail := func(message, code string) (core.Color, *ProcessorResponse) {
return 0, &ProcessorResponse{Error: &core.ErrorResponse{Error: message, Code: code}}
}
var color core.Color
switch {
case requested != "":
color = core.ParseColor(requested)
default:
var claimed, humans []core.Color
for _, c := range []core.Color{core.ColorWhite, core.ColorBlack} {
player := g.Player(c)
if player == nil || player.Type != core.PlayerHuman {
continue
}
humans = append(humans, c)
if userID != "" && player.ClaimedBy == userID {
claimed = append(claimed, c)
}
}
switch {
case len(claimed) == 1:
color = claimed[0]
case len(claimed) == 0 && len(humans) == 1:
color = humans[0]
case len(humans) == 0:
return fail("both sides are played by the computer", core.ErrInvalidRequest)
default:
return fail("color required: both sides are human", core.ErrInvalidRequest)
}
}
player := g.Player(color)
if player == nil {
return fail("invalid color", core.ErrInvalidRequest)
}
if player.Type != core.PlayerHuman {
return fail(service.ErrNotHuman.Error(), core.ErrInvalidRequest)
}
if player.ClaimedBy != "" && player.ClaimedBy != userID {
if userID == "" {
return fail("slot claimed - authentication required", core.ErrUnauthorized)
}
return fail("slot claimed by another player", core.ErrUnauthorized)
}
return color, nil
}
// endGameError maps a service refusal to a response; the service re-checks
// under its lock what the processor checked on a snapshot.
func (p *Processor) endGameError(err error) ProcessorResponse {
switch {
case errors.Is(err, service.ErrGameNotFound):
return p.errorResponse("game not found", core.ErrGameNotFound)
case errors.Is(err, service.ErrGameOver):
return p.errorResponse("game is over", core.ErrGameOver)
case errors.Is(err, service.ErrSlotOwner):
return p.errorResponse("slot claimed by another player", core.ErrUnauthorized)
case errors.Is(err, service.ErrNotHuman):
return p.errorResponse(err.Error(), core.ErrInvalidRequest)
case errors.Is(err, service.ErrNoDrawOffer):
return p.errorResponse("no draw offer from the opponent", core.ErrInvalidRequest)
case errors.Is(err, service.ErrOfferLimit):
return p.errorResponse(err.Error(), core.ErrConflict)
case errors.Is(err, service.ErrGameChanged):
return p.errorResponse("game changed; refresh and retry", core.ErrConflict)
}
return p.errorResponse(fmt.Sprintf("failed to update game: %v", err), core.ErrInternalError)
}
+97
View File
@@ -0,0 +1,97 @@
package processor
import (
"errors"
"testing"
"chess/internal/server/chess"
"chess/internal/server/core"
"chess/internal/server/game"
)
func TestRulesCheck(t *testing.T) {
const promotion = "8/5KP1/5n2/p3k3/1p3p2/1P6/P7/8 w - - 4 55"
if r := rulesCheck(promotion, "g7g8"); !errors.Is(r.err, chess.ErrPromotionRequired) {
t.Errorf("g7g8: error = %v, want ErrPromotionRequired", r.err)
}
if r := rulesCheck(promotion, "g7g8q"); r.err != nil || r.fen != "6Q1/5K2/5n2/p3k3/1p3p2/1P6/P7/8 b - - 0 55" {
t.Errorf("g7g8q: %+v", r)
}
if r := rulesCheck(chess.StartFEN, "e2e5"); !errors.Is(r.err, chess.ErrIllegalMove) {
t.Errorf("e2e5: error = %v, want ErrIllegalMove", r.err)
}
if r := rulesCheck("not a fen", "e2e4"); !errors.Is(r.err, chess.ErrInvalidFEN) {
t.Errorf("bad FEN: error = %v, want ErrInvalidFEN", r.err)
}
}
func TestAdjudicateDraw(t *testing.T) {
cycle := []string{"g1f3", "g8f6", "f3g1", "f6g8"}
line, err := chess.Replay(chess.StartFEN, append(cycle, cycle...))
if err != nil {
t.Fatal(err)
}
fens := make([]string, 9)
for i := range fens {
fens[i] = line.Position(i).FEN()
}
for name, tc := range map[string]struct {
history []string
fen string
want core.Termination
}{
"start": {nil, chess.StartFEN, core.TermNone},
"bare kings": {nil, "4k3/8/8/8/8/8/8/4K3 w - - 0 1", core.TermInsufficientMaterial},
"knight": {nil, "4k3/8/8/8/8/8/8/3NK3 b - - 3 40", core.TermInsufficientMaterial},
"rook": {nil, "4k3/8/8/8/8/8/8/R3K3 w - - 3 40", core.TermNone},
"fifty moves": {nil, "4k3/8/8/8/8/8/8/R3K3 w - - 100 90", core.TermFiftyMoveRule},
"forty-nine": {nil, "4k3/8/8/8/8/8/8/R3K3 w - - 99 90", core.TermNone},
"second occurrence": {fens[:4], fens[4], core.TermNone},
"third occurrence": {fens[:8], fens[8], core.TermThreefoldRepetition},
"unparsable": {nil, "not a fen", core.TermNone},
} {
state, termination := adjudicateDraw(tc.history, tc.fen)
if termination != tc.want || (tc.want == core.TermNone) != (state == core.StateOngoing) {
t.Errorf("%s: got %v %q, want %q", name, state, termination, tc.want)
}
}
}
func TestActingColor(t *testing.T) {
human := func(claimedBy string) *core.Player {
return &core.Player{Type: core.PlayerHuman, ClaimedBy: claimedBy}
}
computer := &core.Player{Type: core.PlayerComputer}
view := func(white, black *core.Player) game.View {
return game.View{WhitePlayer: white, BlackPlayer: black}
}
for name, tc := range map[string]struct {
view game.View
requested string
user string
want core.Color
code string
}{
"only human side": {view(human(""), computer), "", "", core.ColorWhite, ""},
"only human side, black": {view(computer, human("")), "", "", core.ColorBlack, ""},
"claimed side of caller": {view(human("u1"), human("")), "", "u1", core.ColorWhite, ""},
"explicit color": {view(human(""), human("")), "b", "", core.ColorBlack, ""},
"explicit long color": {view(human(""), human("")), "white", "", core.ColorWhite, ""},
"ambiguous hot seat": {view(human(""), human("")), "", "", 0, core.ErrInvalidRequest},
"self-play needs color": {view(human("u1"), human("u1")), "", "u1", 0, core.ErrInvalidRequest},
"computer only": {view(computer, computer), "", "", 0, core.ErrInvalidRequest},
"computer side requested": {view(human(""), computer), "b", "", 0, core.ErrInvalidRequest},
"claimed by another": {view(human("u1"), computer), "", "u2", 0, core.ErrUnauthorized},
"claimed, anonymous caller": {view(human("u1"), computer), "", "", 0, core.ErrUnauthorized},
} {
color, failure := actingColor(tc.view, tc.requested, tc.user)
switch {
case tc.code != "" && (failure == nil || failure.Error.Code != tc.code):
t.Errorf("%s: got %v %+v, want error %s", name, color, failure, tc.code)
case tc.code == "" && (failure != nil || color != tc.want):
t.Errorf("%s: got %v %+v, want %v", name, color, failure, tc.want)
}
}
}
+155
View File
@@ -0,0 +1,155 @@
// Package replay works on stored games: it derives SAN for their moves,
// exports them as PGN, and checks them against the rules. It reads the
// records that storage returns and never writes.
package replay
import (
"errors"
"fmt"
"strconv"
"chess/internal/server/chess"
"chess/internal/server/core"
"chess/internal/server/storage"
)
var (
// ErrPlyOutOfRange reports a PGN ply beyond the stored line.
ErrPlyOutOfRange = errors.New("ply out of range")
// ErrNotation reports a stored move the rules core cannot notate.
ErrNotation = errors.New("cannot derive notation")
)
// Notate derives SAN for each stored move from the stored position before
// it, so one unparsable ply does not affect the others. Plies it cannot
// notate are left empty and the first failure is returned.
func Notate(initialFEN string, moves []storage.MoveRecord) ([]string, error) {
san := make([]string, len(moves))
before := initialFEN
var firstErr error
for i, move := range moves {
pos, err := chess.ParseFEN(before)
if err == nil {
var m chess.Move
if m, err = pos.ParseUCI(move.MoveUCI); err == nil {
san[i] = pos.SAN(m)
}
}
if err != nil && firstErr == nil {
firstErr = fmt.Errorf("%w: ply %d (%s): %v", ErrNotation, move.MoveNumber, move.MoveUCI, err)
}
before = move.FENAfterMove
}
return san, firstErr
}
// PGN is an export and its suggested download name.
type PGN struct {
Text string
Filename string
}
// BuildPGN exports a stored game. ply < 0 exports every move; otherwise the
// first ply moves, with result "*" and no termination when that stops short
// of the end of the line.
func BuildPGN(record *storage.GameRecord, moves []storage.MoveRecord, ply int) (*PGN, error) {
if ply > len(moves) {
return nil, fmt.Errorf("%w: game has %d plies", ErrPlyOutOfRange, len(moves))
}
san, err := Notate(record.InitialFEN, moves)
if err != nil {
return nil, err
}
complete := ply < 0 || ply == len(moves)
if !complete {
san = san[:ply]
}
result, comment := "*", ""
if complete {
result = chess.ResultToken(record.Result)
comment = Describe(record.Result, record.Termination)
}
start := record.StartTimeUTC.UTC()
tags := []chess.Tag{
{Name: "Event", Value: "Casual game"},
{Name: "Date", Value: start.Format("2006.01.02")},
{Name: "White", Value: playerName(record.WhiteType, record.WhiteLevel, record.WhiteName)},
{Name: "Black", Value: playerName(record.BlackType, record.BlackLevel, record.BlackName)},
{Name: "GameId", Value: record.GameID},
{Name: "UTCDate", Value: start.Format("2006.01.02")},
{Name: "UTCTime", Value: start.Format("15:04:05")},
{Name: "WhiteType", Value: playerType(record.WhiteType)},
{Name: "BlackType", Value: playerType(record.BlackType)},
{Name: "PlyCount", Value: strconv.Itoa(len(san))},
}
if complete {
// PGN's Termination values describe how play stopped; mate, draws,
// and resignation are all "normal". The reason goes in the comment.
value := "unterminated"
if record.Result != "" {
value = "normal"
}
tags = append(tags, chess.Tag{Name: "Termination", Value: value})
}
name := fmt.Sprintf("chess-%s-%s", start.Format("20060102"), record.GameID[:8])
if !complete {
name += fmt.Sprintf("-ply%d", ply)
}
return &PGN{
Text: chess.PGN{
Tags: tags, StartFEN: record.InitialFEN, SAN: san, Comment: comment, Result: result,
}.String(),
Filename: name + ".pgn",
}, nil
}
// Describe returns a sentence for a stored result and termination, such as
// "Black wins by resignation." or "Draw by threefold repetition."; empty for
// an unfinished game.
func Describe(result, termination string) string {
winner := ""
switch result {
case "white_wins":
winner = "White"
case "black_wins":
winner = "Black"
case "":
return ""
}
switch core.Termination(termination) {
case core.TermCheckmate:
return winner + " wins by checkmate."
case core.TermResignation:
return winner + " wins by resignation."
case core.TermStalemate:
return "Draw by stalemate."
case core.TermInsufficientMaterial:
return "Draw by insufficient material."
case core.TermThreefoldRepetition:
return "Draw by threefold repetition."
case core.TermFiftyMoveRule:
return "Draw by the fifty-move rule."
case core.TermAgreement:
return "Draw by agreement."
}
return ""
}
func playerName(playerType, level int, name string) string {
switch {
case core.PlayerType(playerType) == core.PlayerComputer:
return "Stockfish level " + strconv.Itoa(level)
case name != "":
return name
}
return "Anonymous"
}
func playerType(t int) string {
if core.PlayerType(t) == core.PlayerComputer {
return "program"
}
return "human"
}
+156
View File
@@ -0,0 +1,156 @@
package replay
import (
"strings"
"testing"
"time"
"chess/internal/server/chess"
"chess/internal/server/storage"
)
// stored returns the stored form of a line played from start.
func stored(t *testing.T, start string, uci ...string) []storage.MoveRecord {
t.Helper()
line, err := chess.Replay(start, uci)
if err != nil {
t.Fatal(err)
}
moves := make([]storage.MoveRecord, len(uci))
for i, u := range uci {
moves[i] = storage.MoveRecord{
MoveNumber: i + 1, MoveUCI: u, FENAfterMove: line.Position(i + 1).FEN(),
PlayerColor: line.Position(i).Turn().String(),
}
}
return moves
}
func record(start, result, termination string) *storage.GameRecord {
return &storage.GameRecord{
GameID: "68007fd1-8970-4d84-950d-2f6ec6375c0b", InitialFEN: start,
Result: result, Termination: termination,
StartTimeUTC: time.Date(2026, 9, 28, 1, 2, 3, 0, time.UTC),
}
}
func TestVerifyConsistentGames(t *testing.T) {
cycle := []string{"g1f3", "g8f6", "f3g1", "f6g8"}
for name, tc := range map[string]struct {
start, result, termination string
uci []string
}{
"checkmate": {chess.StartFEN, "black_wins", "checkmate", []string{"f2f3", "e7e5", "g2g4", "d8h4"}},
"resignation": {chess.StartFEN, "white_wins", "resignation", []string{"e2e4"}},
"agreement": {chess.StartFEN, "draw", "agreement", []string{"e2e4", "e7e5"}},
"stalemate": {"7k/5Q2/5K2/8/8/8/8/8 w - - 0 1", "stalemate", "stalemate", []string{"f6g6"}},
"dead": {"4k3/8/8/8/8/8/3r4/4KB2 w - - 0 1", "draw", "insufficient_material", []string{"e1d2"}},
"threefold": {chess.StartFEN, "draw", "threefold_repetition", append(cycle, cycle...)},
"fifty": {"4k3/8/8/8/8/8/8/R3K3 w - - 99 80", "draw", "fifty_move_rule", []string{"a1a2"}},
"ongoing": {chess.StartFEN, "", "", []string{"e2e4"}},
// A dead position left unfinished predates automatic draws.
"legacy dead": {"4k3/8/8/8/8/8/8/4K3 w - - 0 1", "", "", nil},
} {
problems := Verify(record(tc.start, tc.result, tc.termination), stored(t, tc.start, tc.uci...))
if len(problems) != 0 {
t.Errorf("%s: problems %v", name, problems)
}
}
}
func TestVerifyFindsProblems(t *testing.T) {
mate := stored(t, chess.StartFEN, "f2f3", "e7e5", "g2g4", "d8h4")
gap := append(append([]storage.MoveRecord{}, mate[:1]...), mate[2:]...)
corrupt := stored(t, chess.StartFEN, "e2e4", "e7e5")
corrupt[1].FENAfterMove = chess.StartFEN
illegal := stored(t, chess.StartFEN, "e2e4")
illegal[0].MoveUCI = "e2e5"
wrongSide := stored(t, chess.StartFEN, "e2e4")
wrongSide[0].PlayerColor = "b"
for name, tc := range map[string]struct {
record *storage.GameRecord
moves []storage.MoveRecord
want string
}{
"deleted ply": {record(chess.StartFEN, "black_wins", "checkmate"), gap, "ply 2 missing (next stored ply is 3)"},
"wrong position": {record(chess.StartFEN, "", ""), corrupt, "ply 2 e7e5: stored"},
"illegal move": {record(chess.StartFEN, "", ""), illegal, "ply 1 e2e5: illegal move"},
"wrong side": {record(chess.StartFEN, "", ""), wrongSide, `stored as played by "b"`},
"wrong winner": {record(chess.StartFEN, "white_wins", "checkmate"), mate, "does not hold"},
"missing result": {record(chess.StartFEN, "", ""), mate, "checkmate but no result"},
"mismatched pair": {record(chess.StartFEN, "draw", "checkmate"), mate, "cannot come from"},
"orphan term": {record(chess.StartFEN, "", "agreement"), mate[:1], "without a result"},
"false repetition": {record(chess.StartFEN, "draw", "threefold_repetition"), mate[:2], "does not hold"},
"false dead": {record(chess.StartFEN, "draw", "insufficient_material"), mate[:2], "does not hold"},
"false fifty": {record(chess.StartFEN, "draw", "fifty_move_rule"), mate[:2], "does not hold"},
"bad initial": {record("8/8/8/8/8/8/8/8 w - - 0 1", "", ""), nil, "initial position"},
"premature mate": {record(chess.StartFEN, "black_wins", "checkmate"), mate[:3], "does not hold"},
"unknown result": {record(chess.StartFEN, "abandoned", "resignation"), mate[:1], "cannot come from"},
"stalemate as mate": {record("7k/5Q2/5K2/8/8/8/8/8 w - - 0 1", "white_wins", "checkmate"), stored(t, "7k/5Q2/5K2/8/8/8/8/8 w - - 0 1", "f6g6"), "does not hold"},
} {
problems := Verify(tc.record, tc.moves)
if len(problems) == 0 || !strings.Contains(strings.Join(problems, "; "), tc.want) {
t.Errorf("%s: problems = %v, want %q", name, problems, tc.want)
}
}
}
func TestVerifyAcceptsEnginePassantConvention(t *testing.T) {
// Stockfish prints an en-passant square whenever a pawn could capture,
// even when the capture is illegal; the rules core prints it only when
// legal. Both describe the same position.
moves := stored(t, chess.StartFEN, "e2e4", "d7d5", "e4e5", "f7f5")
moves[0].FENAfterMove = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1"
if problems := Verify(record(chess.StartFEN, "", ""), moves); len(problems) != 0 {
t.Fatalf("problems %v", problems)
}
}
func TestDescribe(t *testing.T) {
for _, tc := range []struct{ result, termination, want string }{
{"white_wins", "checkmate", "White wins by checkmate."},
{"black_wins", "resignation", "Black wins by resignation."},
{"stalemate", "stalemate", "Draw by stalemate."},
{"draw", "agreement", "Draw by agreement."},
{"draw", "threefold_repetition", "Draw by threefold repetition."},
{"draw", "fifty_move_rule", "Draw by the fifty-move rule."},
{"draw", "insufficient_material", "Draw by insufficient material."},
{"", "", ""},
} {
if got := Describe(tc.result, tc.termination); got != tc.want {
t.Errorf("Describe(%q, %q) = %q, want %q", tc.result, tc.termination, got, tc.want)
}
}
}
func TestBuildPGN(t *testing.T) {
moves := stored(t, chess.StartFEN, "e2e4", "e7e5")
rec := record(chess.StartFEN, "white_wins", "resignation")
rec.BlackType, rec.BlackLevel, rec.WhiteType, rec.WhiteName = 2, 4, 1, "alice"
pgn, err := BuildPGN(rec, moves, -1)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"[White \"alice\"]\n", "[Black \"Stockfish level 4\"]\n", "[Result \"1-0\"]\n",
"[Termination \"normal\"]\n", "\n\n1. e4 e5 { White wins by resignation. } 1-0\n"} {
if !strings.Contains(pgn.Text, want) {
t.Errorf("PGN lacks %q:\n%s", want, pgn.Text)
}
}
if pgn.Filename != "chess-20260928-68007fd1.pgn" {
t.Errorf("filename %q", pgn.Filename)
}
partial, err := BuildPGN(rec, moves, 1)
if err != nil {
t.Fatal(err)
}
if !strings.HasSuffix(partial.Text, "\n\n1. e4 *\n") || partial.Filename != "chess-20260928-68007fd1-ply1.pgn" {
t.Errorf("partial %q:\n%s", partial.Filename, partial.Text)
}
if _, err := BuildPGN(rec, moves, 3); err == nil {
t.Error("ply beyond the line accepted")
}
}
+118
View File
@@ -0,0 +1,118 @@
package replay
import (
"fmt"
"chess/internal/server/chess"
"chess/internal/server/core"
"chess/internal/server/storage"
)
// Verify checks a stored game against the rules and returns one line per
// problem, empty when the game is consistent:
//
// - the initial position parses, and moves are numbered 1..n with no gap
// (a gap means rows were deleted);
// - each move is made by the side to move, is legal from the stored
// position before it, and produces the stored position after it;
// - the result and its termination belong together, and a termination
// decided by the position (mate, stalemate, dead material, repetition,
// fifty moves) holds in the final position;
// - a mated or stalemated final position has its result recorded.
//
// Draw rules that became automatic later are not required of older games:
// an unfinished game in a drawn position is not a problem.
func Verify(record *storage.GameRecord, moves []storage.MoveRecord) []string {
var problems []string
pos, err := chess.ParseFEN(record.InitialFEN)
if err != nil {
return []string{fmt.Sprintf("initial position: %v", err)}
}
positions := []*chess.Position{pos}
for i, move := range moves {
if move.MoveNumber != i+1 {
return append(problems, fmt.Sprintf("ply %d missing (next stored ply is %d)", i+1, move.MoveNumber))
}
if move.PlayerColor != pos.Turn().String() {
problems = append(problems, fmt.Sprintf("ply %d %s: stored as played by %q, but %q is to move",
move.MoveNumber, move.MoveUCI, move.PlayerColor, pos.Turn().String()))
}
m, err := pos.ParseUCI(move.MoveUCI)
if err != nil {
problems = append(problems, fmt.Sprintf("ply %d %s: %v", move.MoveNumber, move.MoveUCI, err))
} else if want := pos.Play(m).FEN(); !sameFEN(want, move.FENAfterMove) {
problems = append(problems, fmt.Sprintf("ply %d %s: stored %q, rules give %q",
move.MoveNumber, move.MoveUCI, move.FENAfterMove, want))
}
// Continue from the stored position so one fault is reported once.
if pos, err = chess.ParseFEN(move.FENAfterMove); err != nil {
return append(problems, fmt.Sprintf("ply %d stored position: %v", move.MoveNumber, err))
}
positions = append(positions, pos)
}
return append(problems, verifyOutcome(record.Result, record.Termination, positions)...)
}
func verifyOutcome(result, termination string, positions []*chess.Position) []string {
final := positions[len(positions)-1]
hasMoves := final.HasLegalMoves()
mated := !hasMoves && final.InCheck()
stalemated := !hasMoves && !final.InCheck()
if result == "" {
switch {
case termination != "":
return []string{fmt.Sprintf("termination %q without a result", termination)}
case mated:
return []string{"final position is checkmate but no result is stored"}
case stalemated:
return []string{"final position is stalemate but no result is stored"}
}
return nil
}
state, ok := stateOf(result)
if !ok || !core.Termination(termination).ValidFor(state) {
return []string{fmt.Sprintf("result %q cannot come from termination %q", result, termination)}
}
var holds bool
switch core.Termination(termination) {
case core.TermCheckmate:
// The side to move is the one mated.
holds = mated && (final.Turn() == chess.White) == (result == "black_wins")
case core.TermStalemate:
holds = stalemated
case core.TermInsufficientMaterial:
holds = final.InsufficientMaterial()
case core.TermThreefoldRepetition:
holds = chess.Repetitions(positions) >= 3
case core.TermFiftyMoveRule:
holds = final.Halfmove() >= 100
case core.TermResignation, core.TermAgreement:
holds = true // decided by the players, not the position
}
if !holds {
return []string{fmt.Sprintf("stored %s by %s does not hold in the final position", result, termination)}
}
return nil
}
func stateOf(result string) (core.State, bool) {
switch result {
case "white_wins":
return core.StateWhiteWins, true
case "black_wins":
return core.StateBlackWins, true
case "stalemate":
return core.StateStalemate, true
case "draw":
return core.StateDraw, true
}
return 0, false
}
func sameFEN(a, b string) bool {
na, errA := chess.NormalizeFEN(a)
nb, errB := chess.NormalizeFEN(b)
return errA == nil && errB == nil && na == nb
}
+245
View File
@@ -0,0 +1,245 @@
package service
import (
"database/sql"
"errors"
"testing"
"time"
"chess/internal/server/chess"
"chess/internal/server/core"
"github.com/google/uuid"
)
// newHumanGame creates a human-vs-human game at the standard start.
func newHumanGame(t *testing.T, svc *Service) string {
t.Helper()
gameID := uuid.NewString()
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
if err := svc.CreateGame(gameID, white, black, chess.StartFEN, core.ColorWhite,
core.StateOngoing, core.TermNone); err != nil {
t.Fatal(err)
}
return gameID
}
// move commits one legal move for the side to move.
func move(t *testing.T, svc *Service, gameID, actor, uci string) {
t.Helper()
view, err := svc.GetGameView(gameID)
if err != nil {
t.Fatal(err)
}
pos, err := chess.ParseFEN(view.FEN)
if err != nil {
t.Fatal(err)
}
m, err := pos.ParseUCI(uci)
if err != nil {
t.Fatal(err)
}
if err := svc.ApplyMoveWithState(gameID, MoveCommit{
ExpectedFEN: view.FEN, ExpectedState: core.StateOngoing, ExpectedTurn: view.NextTurnColor,
ActorUserID: actor, MoveUCI: uci, NewFEN: pos.Play(m).FEN(), State: core.StateOngoing,
}); err != nil {
t.Fatal(err)
}
}
func TestResignationClaimsAndPersists(t *testing.T) {
svc := newPersistentTestService(t)
gameID, alice, bob := newHumanGame(t, svc), uuid.NewString(), uuid.NewString()
move(t, svc, gameID, alice, "e2e4") // alice claims white
view, _ := svc.GetGameView(gameID)
resign := EndCommit{
ExpectedFEN: view.FEN, Color: core.ColorWhite, ActorUserID: bob,
State: core.StateBlackWins, Termination: core.TermResignation,
}
if err := svc.EndGame(gameID, resign); !errors.Is(err, ErrSlotOwner) {
t.Fatalf("resigning another user's side: %v, want ErrSlotOwner", err)
}
stale := resign
stale.ActorUserID, stale.ExpectedFEN = alice, chess.StartFEN
if err := svc.EndGame(gameID, stale); !errors.Is(err, ErrGameChanged) {
t.Fatalf("stale position: %v, want ErrGameChanged", err)
}
wrong := resign
wrong.ActorUserID, wrong.Termination = alice, core.TermAgreement
if err := svc.EndGame(gameID, wrong); err == nil {
t.Fatal("a win by agreement was accepted")
}
// Black resigns anonymously; an authenticated resignation would claim it.
resign.Color, resign.ActorUserID, resign.State = core.ColorBlack, bob, core.StateWhiteWins
if err := svc.EndGame(gameID, resign); err != nil {
t.Fatal(err)
}
if err := svc.EndGame(gameID, resign); !errors.Is(err, ErrGameOver) {
t.Fatalf("second resignation: %v, want ErrGameOver", err)
}
view, _ = svc.GetGameView(gameID)
if view.State != core.StateWhiteWins || view.Termination != core.TermResignation ||
view.BlackPlayer.ClaimedBy != bob || view.EndTimeUTC == nil {
t.Fatalf("view after resignation: %+v", view)
}
history, err := svc.GetGameHistory(gameID)
if err != nil {
t.Fatal(err)
}
if history.Result != "white_wins" || history.Termination != "resignation" ||
history.Players.Black.ClaimedBy != bob || history.PGNResult != "1-0" {
t.Fatalf("history after resignation: %+v", history)
}
// Undo after a result rewinds it (D6), in memory and in storage.
if err := svc.UndoMoves(gameID, 1); err != nil {
t.Fatal(err)
}
if history, _ = svc.GetGameHistory(gameID); history.Result != "" || history.Termination != "" {
t.Fatalf("undo kept the resignation: %+v", history)
}
}
func TestDrawOffersBetweenHumans(t *testing.T) {
svc := newPersistentTestService(t)
gameID := newHumanGame(t, svc)
move(t, svc, gameID, "", "e2e4")
view, _ := svc.GetGameView(gameID)
if err := svc.OfferDraw(gameID, core.ColorBlack, "", view.FEN); err != nil {
t.Fatal(err)
}
if err := svc.OfferDraw(gameID, core.ColorBlack, "", view.FEN); err != nil {
t.Fatalf("repeating a standing offer: %v", err)
}
if view, _ = svc.GetGameView(gameID); view.DrawOffer != core.ColorBlack {
t.Fatalf("offer not recorded: %+v", view)
}
if err := svc.DeclineDraw(gameID, core.ColorBlack, ""); !errors.Is(err, ErrNoDrawOffer) {
t.Fatalf("declining one's own offer: %v", err)
}
if err := svc.DeclineDraw(gameID, core.ColorWhite, ""); err != nil {
t.Fatal(err)
}
// One offer per move: black must move before offering again.
if err := svc.OfferDraw(gameID, core.ColorBlack, "", view.FEN); !errors.Is(err, ErrOfferLimit) {
t.Fatalf("second offer in one move: %v, want ErrOfferLimit", err)
}
move(t, svc, gameID, "", "e7e5")
move(t, svc, gameID, "", "g1f3")
view, _ = svc.GetGameView(gameID)
if err := svc.OfferDraw(gameID, core.ColorBlack, "", view.FEN); err != nil {
t.Fatalf("offer after a move: %v", err)
}
// The offerer's own move keeps the offer standing ...
move(t, svc, gameID, "", "b8c6")
if view, _ = svc.GetGameView(gameID); view.DrawOffer != core.ColorBlack {
t.Fatalf("offer lapsed on the offerer's own move: %v", view.DrawOffer)
}
// ... and the recipient's move instead of an answer declines it.
move(t, svc, gameID, "", "f1c4")
if view, _ = svc.GetGameView(gameID); view.DrawOffer != 0 {
t.Fatalf("offer survived the recipient's move: %v", view.DrawOffer)
}
if err := svc.OfferDraw(gameID, core.ColorWhite, "", view.FEN); err != nil {
t.Fatal(err)
}
view, _ = svc.GetGameView(gameID)
accept := EndCommit{
ExpectedFEN: view.FEN, Color: core.ColorBlack,
State: core.StateDraw, Termination: core.TermAgreement, OfferFrom: core.ColorWhite,
}
if err := svc.EndGame(gameID, accept); err != nil {
t.Fatal(err)
}
history, err := svc.GetGameHistory(gameID)
if err != nil {
t.Fatal(err)
}
if history.Result != "draw" || history.Termination != "agreement" || history.PGNResult != "1/2-1/2" {
t.Fatalf("history after agreement: %+v", history)
}
}
func TestOfferToMissingOfferIsRejected(t *testing.T) {
svc := newPersistentTestService(t)
gameID := newHumanGame(t, svc)
view, _ := svc.GetGameView(gameID)
err := svc.EndGame(gameID, EndCommit{
ExpectedFEN: view.FEN, Color: core.ColorBlack,
State: core.StateDraw, Termination: core.TermAgreement, OfferFrom: core.ColorWhite,
})
if !errors.Is(err, ErrNoDrawOffer) {
t.Fatalf("accepting without an offer: %v, want ErrNoDrawOffer", err)
}
}
func TestGameWithDeletedRowIsUnloadedNotDegraded(t *testing.T) {
svc, dsn := newPersistentTestServiceDSN(t)
gameID := newHumanGame(t, svc)
if _, err := svc.GetGameHistory(gameID); err != nil { // flushes the insert
t.Fatal(err)
}
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec(`DELETE FROM games WHERE game_id = $1`, gameID); err != nil {
t.Fatal(err)
}
move(t, svc, gameID, "", "e2e4") // accepted in memory; its write finds no row
deadline := time.Now().Add(5 * time.Second)
for {
if _, err := svc.GetGameView(gameID); errors.Is(err, ErrGameNotFound) {
break
}
if time.Now().After(deadline) {
t.Fatal("game with a deleted row is still loaded")
}
time.Sleep(10 * time.Millisecond)
}
if health := svc.GetStorageHealth(); health != "ok" {
t.Fatalf("storage health %q after a write for a deleted game", health)
}
}
func TestFinishedGamesDoNotCountAgainstComputerLimit(t *testing.T) {
svc := newPersistentTestService(t)
newComputerGame := func() (string, error) {
gameID := 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)
return gameID, svc.CreateGame(gameID, white, black, chess.StartFEN, core.ColorWhite,
core.StateOngoing, core.TermNone)
}
var games []string
for i := 0; i < MaxComputerGames; i++ {
gameID, err := newComputerGame()
if err != nil {
t.Fatal(err)
}
games = append(games, gameID)
}
if _, err := newComputerGame(); err == nil {
t.Fatal("computer game limit not enforced")
}
// Resigning one frees its slot while the game stays loaded.
if err := svc.EndGame(games[0], EndCommit{
ExpectedFEN: chess.StartFEN, Color: core.ColorWhite,
State: core.StateBlackWins, Termination: core.TermResignation,
}); err != nil {
t.Fatal(err)
}
if got := svc.GetComputerGameCount(); got != MaxComputerGames-1 {
t.Fatalf("active computer games = %d, want %d", got, MaxComputerGames-1)
}
if _, err := newComputerGame(); err != nil {
t.Fatalf("finished game still counted: %v", err)
}
}
+206 -24
View File
@@ -17,6 +17,10 @@ var (
ErrGameNotFound = errors.New("game not found") ErrGameNotFound = errors.New("game not found")
ErrGameChanged = errors.New("game changed while move was being validated") ErrGameChanged = errors.New("game changed while move was being validated")
ErrSlotOwner = errors.New("player slot is owned by another user") ErrSlotOwner = errors.New("player slot is owned by another user")
ErrGameOver = errors.New("game is over")
ErrNotHuman = errors.New("that side is played by the computer")
ErrNoDrawOffer = errors.New("no draw offer from the opponent")
ErrOfferLimit = errors.New("one draw offer per move: make a move before offering again")
) )
type MoveCommit struct { type MoveCommit struct {
@@ -27,10 +31,24 @@ type MoveCommit struct {
MoveUCI string MoveUCI string
NewFEN string NewFEN string
State core.State State core.State
Termination core.Termination // how State was reached when terminal
Result *game.MoveResult Result *game.MoveResult
At time.Time At time.Time
} }
// EndCommit ends a game without a move: a resignation or a draw by
// agreement. Color is the side acting (resigning or accepting); an
// authenticated actor claims it if unclaimed, as a first move would.
type EndCommit struct {
ExpectedFEN string
Color core.Color
ActorUserID string
State core.State
Termination core.Termination
// OfferFrom, when set, requires that side's draw offer to still stand.
OfferFrom core.Color
}
// CreateGame registers a new game with pre-constructed players // CreateGame registers a new game with pre-constructed players
func (s *Service) CreateGame( func (s *Service) CreateGame(
id string, id string,
@@ -38,6 +56,7 @@ func (s *Service) CreateGame(
initialFEN string, initialFEN string,
startingTurn core.Color, startingTurn core.Color,
initialState core.State, initialState core.State,
termination core.Termination,
) error { ) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@@ -48,16 +67,15 @@ func (s *Service) CreateGame(
// Check computer game limit // Check computer game limit
hasComputer := whitePlayer.Type == core.PlayerComputer || blackPlayer.Type == core.PlayerComputer hasComputer := whitePlayer.Type == core.PlayerComputer || blackPlayer.Type == core.PlayerComputer
if hasComputer { if hasComputer && !initialState.IsTerminal() {
if s.computerGames.Load() >= MaxComputerGames { if active := s.activeComputerGamesLocked(); active >= MaxComputerGames {
return fmt.Errorf("computer game limit reached (%d/%d)", s.computerGames.Load(), MaxComputerGames) return fmt.Errorf("computer game limit reached (%d/%d)", active, MaxComputerGames)
} }
s.computerGames.Add(1)
} }
now := time.Now().UTC() now := time.Now().UTC()
g := game.New(initialFEN, whitePlayer, blackPlayer, startingTurn) g := game.New(initialFEN, whitePlayer, blackPlayer, startingTurn)
g.SetStateAt(initialState, now) g.SetOutcome(initialState, termination, now)
s.games[id] = g s.games[id] = g
// Persist if storage enabled // Persist if storage enabled
@@ -77,6 +95,7 @@ func (s *Service) CreateGame(
BlackSearchTime: blackPlayer.SearchTime, BlackSearchTime: blackPlayer.SearchTime,
BlackClaimedBy: blackPlayer.ClaimedBy, BlackClaimedBy: blackPlayer.ClaimedBy,
Result: result, Result: result,
Termination: string(g.Termination()),
StartTimeUTC: now, StartTimeUTC: now,
EndTimeUTC: g.EndTimeUTC(), EndTimeUTC: g.EndTimeUTC(),
} }
@@ -106,8 +125,10 @@ func (s *Service) UpdatePlayers(gameID string, whitePlayer, blackPlayer *core.Pl
oldBlack := g.GetPlayer(core.ColorBlack) oldBlack := g.GetPlayer(core.ColorBlack)
oldHasComputer := g.HasComputerPlayer() oldHasComputer := g.HasComputerPlayer()
newHasComputer := whitePlayer.Type == core.PlayerComputer || blackPlayer.Type == core.PlayerComputer newHasComputer := whitePlayer.Type == core.PlayerComputer || blackPlayer.Type == core.PlayerComputer
if !oldHasComputer && newHasComputer && s.computerGames.Load() >= MaxComputerGames { if !oldHasComputer && newHasComputer && !g.State().IsTerminal() {
return fmt.Errorf("computer game limit reached (%d/%d)", s.computerGames.Load(), MaxComputerGames) if active := s.activeComputerGamesLocked(); active >= MaxComputerGames {
return fmt.Errorf("computer game limit reached (%d/%d)", active, MaxComputerGames)
}
} }
// Player configuration is mutable, but historical user association is not. // Player configuration is mutable, but historical user association is not.
@@ -127,14 +148,8 @@ func (s *Service) UpdatePlayers(gameID string, whitePlayer, blackPlayer *core.Pl
} }
g.UpdatePlayers(whitePlayer, blackPlayer) g.UpdatePlayers(whitePlayer, blackPlayer)
g.ClearDrawOffers() // an offer made to the previous opponent lapses
g.Touch(time.Now().UTC()) g.Touch(time.Now().UTC())
if oldHasComputer != newHasComputer {
if newHasComputer {
s.computerGames.Add(1)
} else {
s.computerGames.Add(-1)
}
}
if s.store != nil { if s.store != nil {
err := s.store.RecordPlayers(gameID, playerRecord(whitePlayer), playerRecord(blackPlayer)) err := s.store.RecordPlayers(gameID, playerRecord(whitePlayer), playerRecord(blackPlayer))
if err != nil { if err != nil {
@@ -239,8 +254,11 @@ func (s *Service) ApplyMoveWithState(gameID string, commit MoveCommit) error {
return err return err
} }
} }
if commit.State.IsTerminal() && !commit.Termination.ValidFor(commit.State) {
return fmt.Errorf("termination %q cannot produce %s", commit.Termination, commit.State)
}
g.AddSnapshot(commit.NewFEN, commit.MoveUCI, core.OppositeColor(currentTurn)) g.AddSnapshot(commit.NewFEN, commit.MoveUCI, core.OppositeColor(currentTurn))
g.SetStateAt(commit.State, at) g.SetOutcome(commit.State, commit.Termination, at)
if commit.Result != nil { if commit.Result != nil {
g.SetLastResult(commit.Result) g.SetLastResult(commit.Result)
} }
@@ -255,6 +273,7 @@ func (s *Service) ApplyMoveWithState(gameID string, commit MoveCommit) error {
ClaimColor: currentTurn.String(), ClaimColor: currentTurn.String(),
ClaimedBy: claimUserID, ClaimedBy: claimUserID,
Result: result, Result: result,
Termination: string(g.Termination()),
EndTimeUTC: g.EndTimeUTC(), EndTimeUTC: g.EndTimeUTC(),
} }
if claimUserID == "" { if claimUserID == "" {
@@ -277,8 +296,9 @@ func (s *Service) ApplyMoveWithState(gameID string, commit MoveCommit) error {
return nil return nil
} }
// UpdateGameState sets the game's end state (checkmate, stalemate, etc) // UpdateGameState sets an operational state (pending, stuck) or a terminal
func (s *Service) UpdateGameState(gameID string, state core.State) error { // state found without a move, such as an engine reporting no legal move.
func (s *Service) UpdateGameState(gameID string, state core.State, termination core.Termination) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@@ -286,13 +306,18 @@ func (s *Service) UpdateGameState(gameID string, state core.State) error {
if !ok { if !ok {
return fmt.Errorf("%w: %s", ErrGameNotFound, gameID) return fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
} }
if state.IsTerminal() && !termination.ValidFor(state) {
return fmt.Errorf("termination %q cannot produce %s", termination, state)
}
previousState := g.State() previousState := g.State()
now := time.Now().UTC() now := time.Now().UTC()
g.SetStateAt(state, now) g.SetOutcome(state, termination, now)
if s.store != nil && state.IsTerminal() && !previousState.IsTerminal() { if s.store != nil && state.IsTerminal() && !previousState.IsTerminal() {
result, _ := state.Result() result, _ := state.Result()
if err := s.store.RecordGameResult(gameID, result, now); err != nil { if err := s.store.RecordGameEnd(storage.GameEnd{
GameID: gameID, Result: result, Termination: string(termination), EndTimeUTC: now,
}); err != nil {
slog.Error("failed to queue game result persistence", "game_id", gameID, "error", err) slog.Error("failed to queue game result persistence", "game_id", gameID, "error", err)
} }
} }
@@ -303,6 +328,168 @@ func (s *Service) UpdateGameState(gameID string, state core.State) error {
return nil return nil
} }
// EndGame applies a resignation or an agreed draw. It re-checks, under the
// service lock, everything the processor validated: the game is unfinished
// and unchanged, the acting side is human and the actor may play it, and an
// accepted offer still stands.
func (s *Service) EndGame(gameID string, commit EndCommit) error {
s.mu.Lock()
defer s.mu.Unlock()
g, ok := s.games[gameID]
if !ok {
return fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
}
if g.State().IsTerminal() {
return ErrGameOver
}
if !commit.State.IsTerminal() || !commit.Termination.ValidFor(commit.State) {
return fmt.Errorf("termination %q cannot produce %s", commit.Termination, commit.State)
}
if g.CurrentFEN() != commit.ExpectedFEN {
return ErrGameChanged
}
if commit.OfferFrom != 0 && g.DrawOffer() != commit.OfferFrom {
return ErrNoDrawOffer
}
claimUserID, err := actorClaim(g, commit.Color, commit.ActorUserID)
if err != nil {
return err
}
now := time.Now().UTC()
if claimUserID != "" {
if err := g.ClaimSlot(commit.Color, claimUserID); err != nil {
return err
}
}
g.SetOutcome(commit.State, commit.Termination, now)
if s.store != nil {
result, _ := commit.State.Result()
end := storage.GameEnd{
GameID: gameID, Result: result, Termination: string(commit.Termination), EndTimeUTC: now,
}
if claimUserID != "" {
end.ClaimColor, end.ClaimedBy = commit.Color.String(), claimUserID
}
if err := s.store.RecordGameEnd(end); err != nil {
slog.Error("failed to queue game end persistence", "game_id", gameID, "error", err)
}
}
s.waiter.NotifyGame(gameID, len(g.Moves()), commit.State)
slog.Debug("game ended without a move", "game_id", gameID,
"state", commit.State.String(), "termination", commit.Termination, "side", commit.Color.String())
return nil
}
// actorClaim authorizes actor to act for color and returns the user ID to
// record as the slot's claim, empty when nothing is to be claimed.
func actorClaim(g *game.Game, color core.Color, actor string) (string, error) {
player := g.GetPlayer(color)
if player == nil {
return "", errors.New("invalid color")
}
if player.Type != core.PlayerHuman {
return "", ErrNotHuman
}
owner := g.GetSlotOwner(color)
switch {
case owner != "" && owner != actor:
return "", ErrSlotOwner
case owner == "" && actor != "":
return actor, nil
}
return "", nil
}
// OfferDraw records color's offer for a human opponent to answer. Repeating
// a standing offer is a no-op; a new offer needs a move since the last one.
func (s *Service) OfferDraw(gameID string, color core.Color, actor, expectedFEN string) error {
s.mu.Lock()
defer s.mu.Unlock()
g, err := s.drawTarget(gameID, color, actor, expectedFEN)
if err != nil {
return err
}
if g.DrawOffer() == color {
return nil
}
if err := offerAllowed(g, color); err != nil {
return err
}
g.OfferDraw(color)
g.Touch(time.Now().UTC())
s.waiter.NotifyAll(gameID)
slog.Debug("draw offered", "game_id", gameID, "side", color.String())
return nil
}
// RecordDeclinedOffer counts an offer the computer answered at once against
// color's one-offer-per-move allowance.
func (s *Service) RecordDeclinedOffer(gameID string, color core.Color, actor, expectedFEN string) error {
s.mu.Lock()
defer s.mu.Unlock()
g, err := s.drawTarget(gameID, color, actor, expectedFEN)
if err != nil {
return err
}
if err := offerAllowed(g, color); err != nil {
return err
}
g.RecordDeclinedOffer(color)
g.Touch(time.Now().UTC())
slog.Debug("draw offer declined by the computer", "game_id", gameID, "side", color.String())
return nil
}
// DeclineDraw lets color turn down the opponent's standing offer.
func (s *Service) DeclineDraw(gameID string, color core.Color, actor string) error {
s.mu.Lock()
defer s.mu.Unlock()
g, err := s.drawTarget(gameID, color, actor, "")
if err != nil {
return err
}
if g.DrawOffer() != core.OppositeColor(color) {
return ErrNoDrawOffer
}
g.DeclineDraw()
g.Touch(time.Now().UTC())
s.waiter.NotifyAll(gameID)
slog.Debug("draw declined", "game_id", gameID, "side", color.String())
return nil
}
// drawTarget returns an unfinished game whose side color the actor may play,
// optionally still at expectedFEN. Caller holds s.mu.
func (s *Service) drawTarget(gameID string, color core.Color, actor, expectedFEN string) (*game.Game, error) {
g, ok := s.games[gameID]
if !ok {
return nil, fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
}
if g.State().IsTerminal() {
return nil, ErrGameOver
}
if g.State() != core.StateOngoing || (expectedFEN != "" && g.CurrentFEN() != expectedFEN) {
return nil, ErrGameChanged
}
if _, err := actorClaim(g, color, actor); err != nil {
return nil, err
}
return g, nil
}
func offerAllowed(g *game.Game, color core.Color) error {
if last := g.LastOfferPly(color); last >= 0 && g.Plies() < last+2 {
return ErrOfferLimit
}
return nil
}
// UndoMoves removes the specified number of moves from game history // UndoMoves removes the specified number of moves from game history
func (s *Service) UndoMoves(gameID string, count int) error { func (s *Service) UndoMoves(gameID string, count int) error {
s.mu.Lock() s.mu.Lock()
@@ -351,11 +538,6 @@ func (s *Service) DeleteGame(gameID string) error {
return errors.New("cannot delete game while computer move is in progress") return errors.New("cannot delete game while computer move is in progress")
} }
// Decrement computer game count if applicable
if g.HasComputerPlayer() {
s.computerGames.Add(-1)
}
// Remove from wait registry // Remove from wait registry
s.waiter.RemoveGame(gameID) s.waiter.RemoveGame(gameID)
+18 -9
View File
@@ -19,7 +19,7 @@ func TestMoveCommitClaimsSlotPersistsResultAndRejectsStalePosition(t *testing.T)
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite) white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack) black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
if err := svc.CreateGame( if err := svc.CreateGame(
gameID, white, black, "initial", core.ColorWhite, core.StateOngoing, gameID, white, black, "initial", core.ColorWhite, core.StateOngoing, core.TermNone,
); err != nil { ); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -27,7 +27,7 @@ func TestMoveCommitClaimsSlotPersistsResultAndRejectsStalePosition(t *testing.T)
ended := time.Date(2026, 9, 7, 2, 3, 4, 0, time.UTC) ended := time.Date(2026, 9, 7, 2, 3, 4, 0, time.UTC)
commit := MoveCommit{ commit := MoveCommit{
ExpectedFEN: "initial", ExpectedState: core.StateOngoing, ExpectedTurn: core.ColorWhite, ExpectedFEN: "initial", ExpectedState: core.StateOngoing, ExpectedTurn: core.ColorWhite,
ActorUserID: userID, MoveUCI: "e2e4", NewFEN: "after", State: core.StateWhiteWins, At: ended, ActorUserID: userID, MoveUCI: "e2e4", NewFEN: "after", State: core.StateWhiteWins, Termination: core.TermCheckmate, At: ended,
Result: &game.MoveResult{Move: "e2e4", PlayerColor: core.ColorWhite, GameState: core.StateWhiteWins}, Result: &game.MoveResult{Move: "e2e4", PlayerColor: core.ColorWhite, GameState: core.StateWhiteWins},
} }
if err := svc.ApplyMoveWithState(gameID, commit); err != nil { if err := svc.ApplyMoveWithState(gameID, commit); err != nil {
@@ -60,13 +60,13 @@ func TestUndoClearsDurableTerminalResult(t *testing.T) {
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite) white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack) black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
if err := svc.CreateGame( if err := svc.CreateGame(
gameID, white, black, "initial", core.ColorWhite, core.StateOngoing, gameID, white, black, "initial", core.ColorWhite, core.StateOngoing, core.TermNone,
); err != nil { ); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := svc.ApplyMoveWithState(gameID, MoveCommit{ if err := svc.ApplyMoveWithState(gameID, MoveCommit{
ExpectedFEN: "initial", ExpectedState: core.StateOngoing, ExpectedTurn: core.ColorWhite, ExpectedFEN: "initial", ExpectedState: core.StateOngoing, ExpectedTurn: core.ColorWhite,
MoveUCI: "e2e4", NewFEN: "after", State: core.StateStalemate, MoveUCI: "e2e4", NewFEN: "after", State: core.StateStalemate, Termination: core.TermStalemate,
}); err != nil { }); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -91,7 +91,7 @@ func TestPlayerReconfigurationPreservesClaimAndPersistsConfiguration(t *testing.
white.ClaimedBy = userID white.ClaimedBy = userID
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack) black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
if err := svc.CreateGame( if err := svc.CreateGame(
gameID, white, black, "initial", core.ColorWhite, core.StateOngoing, gameID, white, black, "initial", core.ColorWhite, core.StateOngoing, core.TermNone,
); err != nil { ); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -137,7 +137,7 @@ func TestCleanupEvictsOnlyMemoryCopyOfTerminalGame(t *testing.T) {
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite) white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack) black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
if err := svc.CreateGame( if err := svc.CreateGame(
gameID, white, black, "terminal", core.ColorWhite, core.StateStalemate, gameID, white, black, "terminal", core.ColorWhite, core.StateStalemate, core.TermStalemate,
); err != nil { ); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -166,7 +166,7 @@ func TestCleanupRemovesIdleAnonymousGamesButKeepsClaimedOnes(t *testing.T) {
white.ID, white.ClaimedBy = claimant, claimant white.ID, white.ClaimedBy = claimant, claimant
} }
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack) black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
if err := svc.CreateGame(gameID, white, black, "initial", core.ColorWhite, core.StateOngoing); err != nil { if err := svc.CreateGame(gameID, white, black, "initial", core.ColorWhite, core.StateOngoing, core.TermNone); err != nil {
t.Fatal(err) t.Fatal(err)
} }
} }
@@ -202,7 +202,16 @@ func TestCleanupRemovesIdleAnonymousGamesButKeepsClaimedOnes(t *testing.T) {
func newPersistentTestService(t *testing.T) *Service { func newPersistentTestService(t *testing.T) *Service {
t.Helper() t.Helper()
store, err := storage.NewStore(pgtest.DSN(t)) svc, _ := newPersistentTestServiceDSN(t)
return svc
}
// newPersistentTestServiceDSN also returns the DSN of the test schema, for
// tests that change rows behind the service's back.
func newPersistentTestServiceDSN(t *testing.T) (*Service, string) {
t.Helper()
dsn := pgtest.DSN(t)
store, err := storage.NewStore(dsn)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -218,5 +227,5 @@ func newPersistentTestService(t *testing.T) *Service {
t.Errorf("shutdown: %v", err) t.Errorf("shutdown: %v", err)
} }
}) })
return svc return svc, dsn
} }
+133 -20
View File
@@ -2,43 +2,59 @@ package service
import ( import (
"context" "context"
"encoding/base64"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"strconv"
"strings"
"time"
"chess/internal/server/chess"
"chess/internal/server/core" "chess/internal/server/core"
"chess/internal/server/replay"
"chess/internal/server/storage" "chess/internal/server/storage"
"github.com/google/uuid"
)
var (
// ErrInvalidListQuery reports a malformed cursor or filter.
ErrInvalidListQuery = errors.New("invalid list query")
// ErrPlyOutOfRange reports a PGN ply beyond the stored line.
ErrPlyOutOfRange = replay.ErrPlyOutOfRange
// ErrNotation reports a stored move the rules core cannot notate.
ErrNotation = replay.ErrNotation
) )
// GetGameHistory returns a durable replay even after the live game has been // GetGameHistory returns a durable replay even after the live game has been
// evicted from memory or the server has restarted. // evicted from memory or the server has restarted. SAN is derived on read.
func (s *Service) GetGameHistory(gameID string) (*core.GameHistoryResponse, error) { func (s *Service) GetGameHistory(gameID string) (*core.GameHistoryResponse, error) {
if s.store == nil { record, moves, err := s.loadHistory(gameID)
return nil, ErrStorageDisabled
}
record, moves, err := s.store.GetGameHistory(gameID)
if err != nil { if err != nil {
if storage.IsGameNotFound(err) { return nil, err
return nil, fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
}
if isStorageUnavailable(err) {
return nil, fmt.Errorf("%w: %v", ErrStorageUnavailable, err)
}
return nil, fmt.Errorf("get game history: %w", err)
} }
history := &core.GameHistoryResponse{ history := &core.GameHistoryResponse{
GameID: record.GameID, GameID: record.GameID,
InitialFEN: record.InitialFEN, InitialFEN: record.InitialFEN,
Result: record.Result, Result: record.Result,
PGNResult: chess.ResultToken(record.Result),
Termination: record.Termination,
StartTimeUTC: record.StartTimeUTC, StartTimeUTC: record.StartTimeUTC,
EndTimeUTC: record.EndTimeUTC, EndTimeUTC: record.EndTimeUTC,
Players: playersResponse(*record), Players: playersResponse(*record),
Moves: make([]core.HistoryMove, 0, len(moves)), Moves: make([]core.HistoryMove, 0, len(moves)),
} }
for _, move := range moves { san, err := replay.Notate(record.InitialFEN, moves)
if err != nil {
slog.Warn("stored game has moves without notation", "game_id", record.GameID, "error", err)
}
for i, move := range moves {
history.Moves = append(history.Moves, core.HistoryMove{ history.Moves = append(history.Moves, core.HistoryMove{
MoveNumber: move.MoveNumber, MoveNumber: move.MoveNumber,
MoveUCI: move.MoveUCI, MoveUCI: move.MoveUCI,
SAN: san[i],
FENAfterMove: move.FENAfterMove, FENAfterMove: move.FENAfterMove,
PlayerColor: move.PlayerColor, PlayerColor: move.PlayerColor,
MoveTimeUTC: move.MoveTimeUTC, MoveTimeUTC: move.MoveTimeUTC,
@@ -47,13 +63,79 @@ func (s *Service) GetGameHistory(gameID string) (*core.GameHistoryResponse, erro
return history, nil return history, nil
} }
// GetGamePGN exports the stored game in PGN; see replay.BuildPGN for ply.
func (s *Service) GetGamePGN(gameID string, ply int) (*replay.PGN, error) {
record, moves, err := s.loadHistory(gameID)
if err != nil {
return nil, err
}
pgn, err := replay.BuildPGN(record, moves, ply)
if errors.Is(err, replay.ErrNotation) {
slog.Warn("stored game cannot be exported as PGN", "game_id", gameID, "error", err)
}
return pgn, err
}
func (s *Service) loadHistory(gameID string) (*storage.GameRecord, []storage.MoveRecord, error) {
if s.store == nil {
return nil, nil, ErrStorageDisabled
}
record, moves, err := s.store.GetGameHistory(gameID)
if err != nil {
if storage.IsGameNotFound(err) {
return nil, nil, fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
}
if isStorageUnavailable(err) {
return nil, nil, fmt.Errorf("%w: %v", ErrStorageUnavailable, err)
}
return nil, nil, fmt.Errorf("get game history: %w", err)
}
return record, moves, nil
}
// UserGamesOptions selects a page of the caller's games. Cursor, when set,
// continues a previous page and excludes Offset.
type UserGamesOptions struct {
Limit int
Offset int
Cursor string
Color string // "", "white", or "black"
Status string // "", "ongoing", or "finished"
}
// GetUserGames returns a bounded page of games associated either at creation // GetUserGames returns a bounded page of games associated either at creation
// or by a later slot claim. // or by a later slot claim.
func (s *Service) GetUserGames(userID string, limit, offset int) (*core.GameListResponse, error) { func (s *Service) GetUserGames(userID string, opts UserGamesOptions) (*core.GameListResponse, error) {
if s.store == nil { if s.store == nil {
return nil, ErrStorageDisabled return nil, ErrStorageDisabled
} }
records, err := s.store.QueryGamesForUser(userID, limit+1, offset) query := storage.UserGamesQuery{Limit: opts.Limit + 1, Offset: opts.Offset, Status: opts.Status}
switch opts.Color {
case "":
case "white":
query.Color = "w"
case "black":
query.Color = "b"
default:
return nil, fmt.Errorf("%w: color must be white or black", ErrInvalidListQuery)
}
switch opts.Status {
case "", "ongoing", "finished":
default:
return nil, fmt.Errorf("%w: status must be ongoing or finished", ErrInvalidListQuery)
}
if opts.Cursor != "" {
if opts.Offset != 0 {
return nil, fmt.Errorf("%w: cursor and offset are exclusive", ErrInvalidListQuery)
}
after, err := decodeCursor(opts.Cursor)
if err != nil {
return nil, err
}
query.After = after
}
records, err := s.store.QueryGamesForUser(userID, query)
if err != nil { if err != nil {
if isStorageUnavailable(err) { if isStorageUnavailable(err) {
return nil, fmt.Errorf("%w: %v", ErrStorageUnavailable, err) return nil, fmt.Errorf("%w: %v", ErrStorageUnavailable, err)
@@ -61,24 +143,29 @@ func (s *Service) GetUserGames(userID string, limit, offset int) (*core.GameList
return nil, fmt.Errorf("get user games: %w", err) return nil, fmt.Errorf("get user games: %w", err)
} }
hasNext := len(records) > limit hasNext := len(records) > opts.Limit
if hasNext { if hasNext {
records = records[:limit] records = records[:opts.Limit]
} }
response := &core.GameListResponse{ response := &core.GameListResponse{
Games: make([]core.GameSummary, 0, len(records)), Games: make([]core.GameSummary, 0, len(records)),
Limit: limit, Limit: opts.Limit,
Offset: offset, Offset: opts.Offset,
} }
if hasNext { if hasNext {
next := offset + limit last := records[len(records)-1]
response.NextCursor = encodeCursor(last.StartTimeUTC, last.GameID)
if opts.Cursor == "" {
next := opts.Offset + opts.Limit
response.NextOffset = &next response.NextOffset = &next
} }
}
for _, record := range records { for _, record := range records {
response.Games = append(response.Games, core.GameSummary{ response.Games = append(response.Games, core.GameSummary{
GameID: record.GameID, GameID: record.GameID,
InitialFEN: record.InitialFEN, InitialFEN: record.InitialFEN,
Result: record.Result, Result: record.Result,
PGNResult: chess.ResultToken(record.Result),
StartTimeUTC: record.StartTimeUTC, StartTimeUTC: record.StartTimeUTC,
EndTimeUTC: record.EndTimeUTC, EndTimeUTC: record.EndTimeUTC,
MoveCount: record.MoveCount, MoveCount: record.MoveCount,
@@ -89,6 +176,32 @@ func (s *Service) GetUserGames(userID string, limit, offset int) (*core.GameList
return response, nil return response, nil
} }
// Cursors are opaque to clients: base64url of "<start µs>.<game ID>". The
// microsecond value round-trips PostgreSQL timestamptz exactly.
func encodeCursor(start time.Time, gameID string) string {
return base64.RawURLEncoding.EncodeToString([]byte(strconv.FormatInt(start.UnixMicro(), 10) + "." + gameID))
}
func decodeCursor(cursor string) (*storage.GameCursor, error) {
invalid := fmt.Errorf("%w: malformed cursor", ErrInvalidListQuery)
if len(cursor) > 128 {
return nil, invalid
}
raw, err := base64.RawURLEncoding.DecodeString(cursor)
if err != nil {
return nil, invalid
}
micros, id, ok := strings.Cut(string(raw), ".")
if !ok {
return nil, invalid
}
us, err := strconv.ParseInt(micros, 10, 64)
if err != nil || uuid.Validate(id) != nil {
return nil, invalid
}
return &storage.GameCursor{StartTimeUTC: time.UnixMicro(us).UTC(), GameID: id}, nil
}
func isStorageUnavailable(err error) bool { func isStorageUnavailable(err error) bool {
return errors.Is(err, storage.ErrStorageDegraded) || return errors.Is(err, storage.ErrStorageDegraded) ||
errors.Is(err, storage.ErrStoreClosed) || errors.Is(err, storage.ErrStoreClosed) ||
+156
View File
@@ -0,0 +1,156 @@
package service
import (
"errors"
"strings"
"testing"
"chess/internal/server/chess"
"chess/internal/server/core"
"github.com/google/uuid"
)
// playLine creates a game and commits uci as the players' moves, with FENs
// from the rules core and the given final state (reached by checkmate) on the
// last move.
func playLine(t *testing.T, svc *Service, userID string, black core.PlayerConfig, uci []string, final core.State) string {
t.Helper()
gameID := uuid.NewString()
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
blackPlayer := core.NewPlayer(black, core.ColorBlack)
if err := svc.CreateGame(gameID, white, blackPlayer, chess.StartFEN, core.ColorWhite, core.StateOngoing, core.TermNone); err != nil {
t.Fatal(err)
}
pos, err := chess.ParseFEN(chess.StartFEN)
if err != nil {
t.Fatal(err)
}
for i, text := range uci {
m, err := pos.ParseUCI(text)
if err != nil {
t.Fatal(err)
}
next := pos.Play(m)
state, termination := core.StateOngoing, core.TermNone
if i == len(uci)-1 && final.IsTerminal() {
state, termination = final, core.TermCheckmate
}
turn, actor := core.ColorWhite, userID
if i%2 == 1 {
turn, actor = core.ColorBlack, ""
}
if err := svc.ApplyMoveWithState(gameID, MoveCommit{
ExpectedFEN: pos.FEN(), ExpectedState: core.StateOngoing, ExpectedTurn: turn,
ActorUserID: actor, MoveUCI: text, NewFEN: next.FEN(), State: state, Termination: termination,
}); err != nil {
t.Fatal(err)
}
pos = next
}
return gameID
}
func TestHistoryNotationAndPGN(t *testing.T) {
svc := newPersistentTestService(t)
computer := core.PlayerConfig{Type: core.PlayerComputer, Level: 3, SearchTime: 100}
gameID := playLine(t, svc, uuid.NewString(), computer,
[]string{"f2f3", "e7e5", "g2g4", "d8h4"}, core.StateBlackWins)
history, err := svc.GetGameHistory(gameID)
if err != nil {
t.Fatal(err)
}
var san []string
for _, m := range history.Moves {
san = append(san, m.SAN)
}
if strings.Join(san, " ") != "f3 e5 g4 Qh4#" || history.PGNResult != "0-1" || history.Termination != "checkmate" {
t.Fatalf("history notation = %v, %q, %q", san, history.PGNResult, history.Termination)
}
pgn, err := svc.GetGamePGN(gameID, -1)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
"[Event \"Casual game\"]\n[Site \"?\"]\n", "[White \"Anonymous\"]\n", "[Black \"Stockfish level 3\"]\n",
"[Result \"0-1\"]\n", "[GameId \"" + gameID + "\"]\n", "[BlackType \"program\"]\n", "[PlyCount \"4\"]\n",
"[Termination \"normal\"]\n", "\n\n1. f3 e5 2. g4 Qh4# { Black wins by checkmate. } 0-1\n",
} {
if !strings.Contains(pgn.Text, want) {
t.Errorf("PGN lacks %q:\n%s", want, pgn.Text)
}
}
if !strings.HasPrefix(pgn.Filename, "chess-") || !strings.HasSuffix(pgn.Filename, "-"+gameID[:8]+".pgn") {
t.Errorf("filename = %q", pgn.Filename)
}
partial, err := svc.GetGamePGN(gameID, 2)
if err != nil {
t.Fatal(err)
}
if !strings.HasSuffix(partial.Text, "\n\n1. f3 e5 *\n") || strings.Contains(partial.Text, "Termination") ||
!strings.HasSuffix(partial.Filename, "-ply2.pgn") {
t.Errorf("partial PGN %q:\n%s", partial.Filename, partial.Text)
}
if full, err := svc.GetGamePGN(gameID, 4); err != nil || full.Text != pgn.Text {
t.Errorf("ply equal to the line length should export the whole game: %v", err)
}
if _, err := svc.GetGamePGN(gameID, 5); !errors.Is(err, ErrPlyOutOfRange) {
t.Errorf("ply 5 error = %v, want ErrPlyOutOfRange", err)
}
if _, err := svc.GetGamePGN(uuid.NewString(), -1); !errors.Is(err, ErrGameNotFound) {
t.Errorf("unknown game error = %v, want ErrGameNotFound", err)
}
}
func TestUserGamesCursorAndFilters(t *testing.T) {
svc := newPersistentTestService(t)
userID := uuid.NewString()
human := core.PlayerConfig{Type: core.PlayerHuman}
var ids []string
for i := 0; i < 3; i++ {
ids = append([]string{playLine(t, svc, userID, human, []string{"e2e4"}, core.StateOngoing)}, ids...)
}
finished := playLine(t, svc, userID, human, []string{"f2f3", "e7e5", "g2g4", "d8h4"}, core.StateBlackWins)
ids = append([]string{finished}, ids...)
var got []string
cursor := ""
for {
page, err := svc.GetUserGames(userID, UserGamesOptions{Limit: 3, Cursor: cursor})
if err != nil {
t.Fatal(err)
}
if cursor != "" && page.NextOffset != nil {
t.Error("cursor page reported nextOffset")
}
for _, g := range page.Games {
got = append(got, g.GameID)
}
if page.NextCursor == "" {
break
}
cursor = page.NextCursor
}
if strings.Join(got, ",") != strings.Join(ids, ",") {
t.Fatalf("cursor pages = %v, want %v", got, ids)
}
page, err := svc.GetUserGames(userID, UserGamesOptions{Limit: 10, Status: "finished", Color: "white"})
if err != nil {
t.Fatal(err)
}
if len(page.Games) != 1 || page.Games[0].GameID != finished || page.Games[0].PGNResult != "0-1" {
t.Fatalf("finished games = %+v", page.Games)
}
for _, bad := range []UserGamesOptions{
{Limit: 1, Cursor: "!!"}, {Limit: 1, Cursor: "bm90LWEtY3Vyc29y"}, {Limit: 1, Offset: 1, Cursor: page.NextCursor + "x"},
{Limit: 1, Color: "red"}, {Limit: 1, Status: "paused"},
} {
if _, err := svc.GetUserGames(userID, bad); !errors.Is(err, ErrInvalidListQuery) {
t.Errorf("GetUserGames(%+v) error = %v, want ErrInvalidListQuery", bad, err)
}
}
}
+239
View File
@@ -0,0 +1,239 @@
package service
import (
"context"
"fmt"
"log/slog"
"time"
"chess/internal/server/replay"
"github.com/lixenwraith/auth"
)
// IntegrityMode selects what the periodic integrity sweep does.
type IntegrityMode int
const (
// IntegrityOff skips the sweep.
IntegrityOff IntegrityMode = iota
// IntegrityReport logs what the sweep finds and changes nothing.
IntegrityReport
// IntegrityDelete also unloads and deletes what it finds.
IntegrityDelete
)
// integrityBatch bounds how many stored games one sweep replays against the
// rules; the sweep resumes after the last one next time, so every game is
// checked in turn at a bounded cost per run.
const integrityBatch = 200
// ParseIntegrityMode accepts "off", "report", or "delete".
func ParseIntegrityMode(s string) (IntegrityMode, error) {
switch s {
case "", "off":
return IntegrityOff, nil
case "report":
return IntegrityReport, nil
case "delete":
return IntegrityDelete, nil
}
return IntegrityOff, fmt.Errorf("invalid integrity mode %q (want off, report, or delete)", s)
}
func (m IntegrityMode) String() string {
switch m {
case IntegrityReport:
return "report"
case IntegrityDelete:
return "delete"
}
return "off"
}
// SetIntegrityMode configures the sweep run with every cleanup job.
func (s *Service) SetIntegrityMode(mode IntegrityMode) {
s.mu.Lock()
s.integrityMode = mode
s.mu.Unlock()
}
// integrityFindings is what one sweep found. Keys are IDs, values reasons.
type integrityFindings struct {
vanished map[string]string // loaded games without a database row
games map[string]string // stored games to delete
users map[string]string // accounts to delete
loaded map[string]bool
}
// checkIntegrity finds data that the server cannot use and, in delete mode,
// removes it:
//
// - loaded games whose row was deleted by hand (unloaded from memory);
// - games with missing plies, when move rows were deleted by hand
// (unloaded if loaded, then deleted);
// - stored games that fail replay.Verify, integrityBatch per run
// (loaded games are skipped: their rows may trail memory);
// - games whose registered players were all deleted, once idle past the
// anonymous-game retention, like games that never had one;
// - accounts whose password hash is not a usable Argon2id PHC string, so
// nobody can sign in to them.
//
// Every finding is logged at WARN with its reason.
func (s *Service) checkIntegrity(now time.Time) {
s.mu.RLock()
mode, anonymousTTL := s.integrityMode, s.anonymousTTL
loaded := make(map[string]bool, len(s.games))
for gameID := range s.games {
loaded[gameID] = true
}
s.mu.RUnlock()
if mode == IntegrityOff || s.store == nil {
return
}
findings, err := s.findIntegrityProblems(now, loaded, anonymousTTL)
if err != nil {
slog.Error("integrity sweep failed", "error", err)
return
}
for gameID, reason := range findings.vanished {
slog.Warn("integrity: loaded game has no database row", "game_id", gameID, "reason", reason)
}
for gameID, reason := range findings.games {
slog.Warn("integrity: invalid game", "game_id", gameID, "reason", reason)
}
for userID, reason := range findings.users {
slog.Warn("integrity: unusable account", "user_id", userID, "reason", reason)
}
summary := []any{"mode", mode.String(), "vanished_games", len(findings.vanished),
"invalid_games", len(findings.games), "unusable_accounts", len(findings.users)}
if mode != IntegrityDelete {
if len(findings.vanished)+len(findings.games)+len(findings.users) > 0 {
slog.Info("integrity sweep found problems; nothing changed (report mode)", summary...)
}
return
}
for gameID := range findings.vanished {
s.evictMissingGame(gameID)
}
var games []string
for gameID := range findings.games {
if findings.loaded[gameID] {
s.evictMissingGame(gameID) // unload first: no further writes for it
}
games = append(games, gameID)
}
if err := s.store.DeleteGames(games); err != nil {
slog.Error("integrity sweep failed to queue game deletion", "error", err)
}
var users []string
for userID := range findings.users {
users = append(users, userID)
}
if err := s.store.DeleteUsers(users); err != nil {
slog.Error("integrity sweep failed to delete accounts", "error", err)
}
if len(findings.vanished)+len(games)+len(users) > 0 {
slog.Info("integrity sweep removed invalid data", summary...)
}
}
func (s *Service) findIntegrityProblems(now time.Time, loaded map[string]bool,
anonymousTTL time.Duration) (*integrityFindings, error) {
found := &integrityFindings{
vanished: map[string]string{},
games: map[string]string{},
users: map[string]string{},
loaded: loaded,
}
live := make([]string, 0, len(loaded))
for gameID := range loaded {
live = append(live, gameID)
}
// Every loaded game's insert was queued before the snapshot was taken, so
// after this flush a missing row means the row was deleted.
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := s.store.Flush(ctx); err != nil {
return nil, err
}
exists, err := s.store.ExistingGames(live)
if err != nil {
return nil, err
}
for _, gameID := range live {
if !exists[gameID] {
found.vanished[gameID] = "row deleted outside the server"
}
}
gaps, err := s.store.GamesWithMoveGaps()
if err != nil {
return nil, err
}
for _, gameID := range gaps {
found.games[gameID] = "missing plies: move rows were deleted"
}
ids, err := s.store.GameIDsAfter(s.integrityCursor, integrityBatch)
if err != nil {
return nil, err
}
s.integrityCursor = ""
if len(ids) == integrityBatch {
s.integrityCursor = ids[len(ids)-1]
}
for _, gameID := range ids {
if loaded[gameID] || found.games[gameID] != "" {
continue
}
record, moves, err := s.store.GetGameHistory(gameID)
if err != nil {
continue // deleted since the listing
}
if problems := replay.Verify(record, moves); len(problems) > 0 {
found.games[gameID] = problems[0]
}
}
if anonymousTTL > 0 {
orphans, err := s.store.OrphanedGames(now.Add(-anonymousTTL), live)
if err != nil {
return nil, err
}
for _, gameID := range orphans {
if found.games[gameID] == "" {
found.games[gameID] = "all registered players were deleted"
}
}
}
users, err := s.store.GetAllUsers()
if err != nil {
return nil, err
}
for _, user := range users {
if err := auth.ValidatePHCHashFormat(user.PasswordHash); err != nil {
found.users[user.UserID] = fmt.Sprintf("%s: %v", user.Username, err)
}
}
return found, nil
}
// evictMissingGame unloads a game whose database row disappeared, typically
// deleted by hand. It runs when a write for the game finds the row gone.
func (s *Service) evictMissingGame(gameID string) {
s.mu.Lock()
_, ok := s.games[gameID]
if ok {
delete(s.games, gameID)
}
s.mu.Unlock()
if ok {
s.waiter.RemoveGame(gameID)
slog.Warn("game unloaded: its database row no longer exists", "game_id", gameID)
}
}
+145
View File
@@ -0,0 +1,145 @@
package service
import (
"database/sql"
"errors"
"testing"
"time"
"chess/internal/server/chess"
"chess/internal/server/storage"
"github.com/google/uuid"
"github.com/lixenwraith/auth"
)
func TestIntegritySweep(t *testing.T) {
svc, dsn := newPersistentTestServiceDSN(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
defer db.Close()
exec := func(query string, args ...any) {
t.Helper()
if _, err := db.Exec(query, args...); err != nil {
t.Fatal(err)
}
}
stored := func(gameID string) bool {
t.Helper()
var exists bool
if err := db.QueryRow(`SELECT EXISTS (SELECT 1 FROM games WHERE game_id = $1)`, gameID).
Scan(&exists); err != nil {
t.Fatal(err)
}
return exists
}
// played creates a game, plays the moves, and unloads it from memory.
played := func(uci ...string) string {
t.Helper()
gameID := newHumanGame(t, svc)
for _, u := range uci {
move(t, svc, gameID, "", u)
}
if err := svc.DeleteGame(gameID); err != nil {
t.Fatal(err)
}
return gameID
}
valid := played("e2e4", "e7e5", "g1f3")
gap := played("e2e4", "e7e5", "g1f3")
corrupt := played("e2e4", "e7e5")
loaded := newHumanGame(t, svc)
vanished := newHumanGame(t, svc)
old := time.Now().Add(-48 * time.Hour)
orphan, claimed := uuid.NewString(), uuid.NewString()
aliceHash, err := auth.HashPassword("correct horse battery")
if err != nil {
t.Fatal(err)
}
alice, mallory := uuid.NewString(), uuid.NewString()
exec(`INSERT INTO users (user_id, username, password_hash) VALUES ($1, 'alice', $2), ($3, 'mallory', 'hunter2')`,
alice, aliceHash, mallory)
for gameID, claimant := range map[string]string{orphan: uuid.NewString(), claimed: alice} {
if err := svc.store.RecordNewGame(storage.GameRecord{
GameID: gameID, InitialFEN: chess.StartFEN,
WhitePlayerID: claimant, WhiteType: 1, WhiteClaimedBy: claimant,
BlackPlayerID: uuid.NewString(), BlackType: 1, StartTimeUTC: old,
}); err != nil {
t.Fatal(err)
}
}
if _, err := svc.GetGameHistory(valid); err != nil { // flush queued writes
t.Fatal(err)
}
// Damage made by hand, as with psql.
exec(`DELETE FROM moves WHERE game_id = $1 AND move_number = 2`, gap)
exec(`UPDATE moves SET fen_after_move = $2 WHERE game_id = $1 AND move_number = 2`, corrupt, chess.StartFEN)
exec(`DELETE FROM games WHERE game_id = $1`, vanished)
svc.SetIntegrityMode(IntegrityReport)
svc.checkIntegrity(time.Now())
for _, gameID := range []string{valid, gap, corrupt, orphan, claimed, loaded} {
if !stored(gameID) {
t.Fatalf("report mode deleted game %s", gameID)
}
}
if _, err := svc.GetGameView(vanished); err != nil {
t.Fatal("report mode unloaded a game")
}
svc.SetIntegrityMode(IntegrityDelete)
svc.checkIntegrity(time.Now())
if _, err := svc.GetGameHistory(valid); err != nil { // flush the deletion
t.Fatal(err)
}
for gameID, want := range map[string]bool{
valid: true, loaded: true, claimed: true, gap: false, corrupt: false, orphan: false,
} {
if got := stored(gameID); got != want {
t.Errorf("game %s stored = %v, want %v", gameID, got, want)
}
}
if _, err := svc.GetGameView(vanished); !errors.Is(err, ErrGameNotFound) {
t.Errorf("game with a deleted row still loaded: %v", err)
}
if _, err := svc.GetGameView(loaded); err != nil {
t.Errorf("healthy loaded game was unloaded: %v", err)
}
var users int
if err := db.QueryRow(`SELECT count(*) FROM users WHERE user_id IN ($1, $2)`, alice, mallory).
Scan(&users); err != nil {
t.Fatal(err)
}
if users != 1 {
t.Errorf("accounts left = %d, want alice only", users)
}
if health := svc.GetStorageHealth(); health != "ok" {
t.Errorf("storage health %q after the sweep", health)
}
// A healthy database gives a clean second run.
findings, err := svc.findIntegrityProblems(time.Now(), map[string]bool{loaded: true}, AnonymousGameTTL)
if err != nil {
t.Fatal(err)
}
if len(findings.games)+len(findings.users)+len(findings.vanished) != 0 {
t.Errorf("second sweep found %+v", findings)
}
}
func TestParseIntegrityMode(t *testing.T) {
for in, want := range map[string]IntegrityMode{"": IntegrityOff, "off": IntegrityOff,
"report": IntegrityReport, "delete": IntegrityDelete} {
if got, err := ParseIntegrityMode(in); err != nil || got != want {
t.Errorf("ParseIntegrityMode(%q) = %v, %v", in, got, err)
}
}
if _, err := ParseIntegrityMode("purge"); err == nil {
t.Error("ParseIntegrityMode accepted an unknown mode")
}
}
+31 -11
View File
@@ -35,10 +35,12 @@ type Service struct {
jwt *auth.JWT jwt *auth.JWT
kdf chan struct{} // Argon2id concurrency slots kdf chan struct{} // Argon2id concurrency slots
waiter *WaitRegistry waiter *WaitRegistry
computerGames atomic.Int32 // Active games with computer players
finishedTTL time.Duration finishedTTL time.Duration
anonymousTTL time.Duration anonymousTTL time.Duration
maxUsers atomic.Int64 maxUsers atomic.Int64
integrityMode IntegrityMode
integrityCursor string // last game ID the integrity sweep verified; cleanup goroutine only
} }
// New creates a service with optional storage. jwtSecret signs session tokens // New creates a service with optional storage. jwtSecret signs session tokens
@@ -64,6 +66,9 @@ func New(store *storage.Store, jwtSecret []byte) (*Service, error) {
anonymousTTL: AnonymousGameTTL, anonymousTTL: AnonymousGameTTL,
} }
s.maxUsers.Store(DefaultMaxUsers) s.maxUsers.Store(DefaultMaxUsers)
if store != nil {
store.SetGameMissingHandler(s.evictMissingGame)
}
return s, nil return s, nil
} }
@@ -115,14 +120,30 @@ func (s *Service) RegisterWait(gameID string, moveCount int, ctx context.Context
return s.waiter.RegisterWait(gameID, moveCount, ctx) return s.waiter.RegisterWait(gameID, moveCount, ctx)
} }
// CanCreateComputerGame checks if a new computer game can be created // CanCreateComputerGame reports whether the computer game limit leaves room
// for another game; CreateGame re-checks under its lock.
func (s *Service) CanCreateComputerGame() bool { func (s *Service) CanCreateComputerGame() bool {
return s.computerGames.Load() < MaxComputerGames return s.GetComputerGameCount() < MaxComputerGames
} }
// GetComputerGameCount returns current computer game count // GetComputerGameCount returns the number of unfinished loaded games with a
func (s *Service) GetComputerGameCount() int32 { // computer player. Finished games stay loaded for a while but need no engine,
return s.computerGames.Load() // so they do not count against MaxComputerGames.
func (s *Service) GetComputerGameCount() int {
s.mu.RLock()
defer s.mu.RUnlock()
return s.activeComputerGamesLocked()
}
// activeComputerGamesLocked counts as GetComputerGameCount. Caller holds s.mu.
func (s *Service) activeComputerGamesLocked() int {
active := 0
for _, g := range s.games {
if g.HasComputerPlayer() && !g.State().IsTerminal() {
active++
}
}
return active
} }
// Shutdown gracefully shuts down the service // Shutdown gracefully shuts down the service
@@ -146,8 +167,9 @@ func (s *Service) Shutdown(timeout time.Duration) error {
return errors.Join(errs...) return errors.Join(errs...)
} }
// RunCleanupJob periodically removes expired sessions, unloads idle games, and // RunCleanupJob periodically removes expired sessions, unloads idle games,
// deletes anonymous games past their retention. // deletes anonymous games past their retention, and runs the integrity sweep
// when enabled.
func (s *Service) RunCleanupJob(ctx context.Context, interval time.Duration) { func (s *Service) RunCleanupJob(ctx context.Context, interval time.Duration) {
s.cleanupExpired() s.cleanupExpired()
ticker := time.NewTicker(interval) ticker := time.NewTicker(interval)
@@ -173,6 +195,7 @@ func (s *Service) cleanupExpired() {
} }
} }
s.cleanupGames(now) s.cleanupGames(now)
s.checkIntegrity(now)
} }
// cleanupGames unloads terminal games after the finished-game TTL and // cleanupGames unloads terminal games after the finished-game TTL and
@@ -199,9 +222,6 @@ func (s *Service) cleanupGames(now time.Time) {
live = append(live, gameID) live = append(live, gameID)
continue continue
} }
if g.HasComputerPlayer() {
s.computerGames.Add(-1)
}
delete(s.games, gameID) delete(s.games, gameID)
} }
s.mu.Unlock() s.mu.Unlock()
+6
View File
@@ -105,6 +105,12 @@ func (w *WaitRegistry) NotifyGame(gameID string, currentMoveCount int, state cor
} }
} }
// NotifyAll wakes every client waiting on a game, for changes that neither
// move nor settle it, such as a draw offer.
func (w *WaitRegistry) NotifyAll(gameID string) {
w.RemoveGame(gameID)
}
// RemoveGame removes all waiters for a game (called before game deletion) // RemoveGame removes all waiters for a game (called before game deletion)
func (w *WaitRegistry) RemoveGame(gameID string) { func (w *WaitRegistry) RemoveGame(gameID string) {
w.mu.RLock() w.mu.RLock()
+229 -65
View File
@@ -8,13 +8,15 @@ import (
"log/slog" "log/slog"
"strings" "strings"
"time" "time"
"github.com/jackc/pgx/v5/pgconn"
) )
const gameSelectColumns = ` 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.start_time_utc, g.end_time_utc` g.result, g.termination, g.start_time_utc, g.end_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.
@@ -33,7 +35,7 @@ func (s *Store) RecordNewGame(record GameRecord) error {
if record.GameID == "" || record.InitialFEN == "" || record.WhitePlayerID == "" || record.BlackPlayerID == "" { if record.GameID == "" || record.InitialFEN == "" || record.WhitePlayerID == "" || record.BlackPlayerID == "" {
return errors.New("game ID, initial FEN, and player IDs are required") return errors.New("game ID, initial FEN, and player IDs are required")
} }
if err := validateResultTime(record.Result, record.EndTimeUTC); err != nil { if err := validateOutcome(record.Result, record.Termination, record.EndTimeUTC); err != nil {
return err return err
} }
@@ -42,9 +44,9 @@ func (s *Store) RecordNewGame(record GameRecord) error {
game_id, initial_fen, game_id, initial_fen,
white_player_id, white_type, white_level, white_search_time, white_claimed_by, white_name, white_player_id, white_type, white_level, white_search_time, white_claimed_by, white_name,
black_player_id, black_type, black_level, black_search_time, black_claimed_by, black_name, black_player_id, black_type, black_level, black_search_time, black_claimed_by, black_name,
start_time_utc, result, end_time_utc start_time_utc, result, termination, end_time_utc
) VALUES ($1, $2, $3, $4, $5, $6, $7, ` + claimantName("$7") + `, ) VALUES ($1, $2, $3, $4, $5, $6, $7, ` + claimantName("$7") + `,
$8, $9, $10, $11, $12, ` + claimantName("$12") + `, $13, $14, $15)` $8, $9, $10, $11, $12, ` + claimantName("$12") + `, $13, $14, $15, $16)`
_, err := tx.ExecContext(ctx, query, _, err := tx.ExecContext(ctx, query,
record.GameID, record.InitialFEN, record.GameID, record.InitialFEN,
@@ -52,7 +54,8 @@ func (s *Store) RecordNewGame(record GameRecord) error {
nullableString(record.WhiteClaimedBy), nullableString(record.WhiteClaimedBy),
record.BlackPlayerID, record.BlackType, record.BlackLevel, record.BlackSearchTime, record.BlackPlayerID, record.BlackType, record.BlackLevel, record.BlackSearchTime,
nullableString(record.BlackClaimedBy), nullableString(record.BlackClaimedBy),
record.StartTimeUTC, nullableString(record.Result), record.EndTimeUTC, record.StartTimeUTC, nullableString(record.Result), nullableString(record.Termination),
record.EndTimeUTC,
) )
return err return err
}) })
@@ -74,7 +77,7 @@ func (s *Store) RecordMove(record MovePersistence) error {
if (record.ClaimColor == "") != (record.ClaimedBy == "") { if (record.ClaimColor == "") != (record.ClaimedBy == "") {
return errors.New("claim color and claimant must be provided together") return errors.New("claim color and claimant must be provided together")
} }
if err := validateResultTime(record.Result, record.EndTimeUTC); err != nil { if err := validateOutcome(record.Result, record.Termination, record.EndTimeUTC); err != nil {
return err return err
} }
@@ -90,63 +93,86 @@ func (s *Store) RecordMove(record MovePersistence) error {
record.Move.PlayerColor, record.Move.PlayerColor,
record.Move.MoveTimeUTC, record.Move.MoveTimeUTC,
); err != nil { ); err != nil {
return err return gameMissingOr(err, record.Move.GameID)
} }
if record.ClaimedBy == "" && record.Result == "" { if record.ClaimedBy == "" && record.Result == "" {
return nil return nil
} }
// One UPDATE applies the optional claim and optional result. A claim may return updateGameEnd(ctx, tx, record.Move.GameID, record.ClaimColor, record.ClaimedBy,
// only fill an empty slot or repeat the same claimant. record.Result, record.Termination, record.EndTimeUTC)
})
}
// GameEnd is a terminal transition not accompanied by a move: a resignation,
// a draw by agreement, or an engine that found no legal move. ClaimColor and
// ClaimedBy optionally record the acting user's claim of an empty slot.
type GameEnd struct {
GameID string
Result string
Termination string
EndTimeUTC time.Time
ClaimColor string
ClaimedBy string
}
// RecordGameEnd persists the result, how it was reached, and any claim in one
// transaction.
func (s *Store) RecordGameEnd(end GameEnd) error {
if end.GameID == "" {
return errors.New("game ID is required")
}
if end.EndTimeUTC.IsZero() {
return errors.New("game end time is required")
}
ended := end.EndTimeUTC.UTC()
if err := validateOutcome(end.Result, end.Termination, &ended); err != nil {
return err
}
if end.Result == "" {
return errors.New("game result is required")
}
if end.ClaimColor != "" && end.ClaimColor != "w" && end.ClaimColor != "b" {
return fmt.Errorf("invalid claim color %q", end.ClaimColor)
}
if (end.ClaimColor == "") != (end.ClaimedBy == "") {
return errors.New("claim color and claimant must be provided together")
}
return s.enqueue("record_game_end", end.GameID, func(ctx context.Context, tx *sql.Tx) error {
return updateGameEnd(ctx, tx, end.GameID, end.ClaimColor, end.ClaimedBy,
end.Result, end.Termination, &ended)
})
}
// updateGameEnd applies an optional claim and an optional result in one
// UPDATE. A claim may only fill an empty slot or repeat the same claimant.
func updateGameEnd(ctx context.Context, tx *sql.Tx, gameID, claimColor, claimedBy,
result, termination string, ended *time.Time) error {
claimColumn, nameColumn := "white_claimed_by", "white_name" claimColumn, nameColumn := "white_claimed_by", "white_name"
if record.ClaimColor == "b" { if claimColor == "b" {
claimColumn, nameColumn = "black_claimed_by", "black_name" claimColumn, nameColumn = "black_claimed_by", "black_name"
} }
var set []string var set []string
args := []any{record.Move.GameID} args := []any{gameID}
where := "game_id = $1" where := "game_id = $1"
if record.ClaimedBy != "" { if claimedBy != "" {
args = append(args, record.ClaimedBy) args = append(args, claimedBy)
param := fmt.Sprintf("$%d", len(args)) param := fmt.Sprintf("$%d", len(args))
set = append(set, fmt.Sprintf("%s = %s, %s = COALESCE(%s, %s)", set = append(set, fmt.Sprintf("%s = %s, %s = COALESCE(%s, %s)",
claimColumn, param, nameColumn, nameColumn, claimantName(param))) claimColumn, param, nameColumn, nameColumn, claimantName(param)))
where += fmt.Sprintf(" AND (%s IS NULL OR %s = %s)", claimColumn, claimColumn, param) where += fmt.Sprintf(" AND (%s IS NULL OR %s = %s)", claimColumn, claimColumn, param)
} }
if record.Result != "" { if result != "" {
args = append(args, record.Result, record.EndTimeUTC) args = append(args, result, termination, ended)
set = append(set, fmt.Sprintf("result = $%d, end_time_utc = $%d", len(args)-1, len(args))) set = append(set, fmt.Sprintf("result = $%d, termination = $%d, end_time_utc = $%d",
len(args)-2, len(args)-1, len(args)))
} }
result, err := tx.ExecContext(ctx, res, err := tx.ExecContext(ctx,
"UPDATE games SET "+strings.Join(set, ", ")+" WHERE "+where, args...) "UPDATE games SET "+strings.Join(set, ", ")+" WHERE "+where, args...)
if err != nil { if err != nil {
return err return err
} }
return requireOneGame(result, record.Move.GameID) return requireOneGame(ctx, tx, res, gameID)
})
}
// RecordGameResult persists a terminal transition not accompanied by a move,
// such as a no-legal-moves engine response.
func (s *Store) RecordGameResult(gameID, result string, at time.Time) error {
if gameID == "" {
return errors.New("game ID is required")
}
if !isValidResult(result) {
return fmt.Errorf("invalid game result %q", result)
}
if at.IsZero() {
return errors.New("game result time is required")
}
return s.enqueue("record_game_result", gameID, func(ctx context.Context, tx *sql.Tx) error {
res, err := tx.ExecContext(ctx,
`UPDATE games SET result = $2, end_time_utc = $3 WHERE game_id = $1`,
gameID, result, at.UTC(),
)
if err != nil {
return err
}
return requireOneGame(res, gameID)
})
} }
// PlayerRecord is the persistence subset of a player configuration. // PlayerRecord is the persistence subset of a player configuration.
@@ -177,7 +203,7 @@ func (s *Store) RecordPlayers(gameID string, white, black PlayerRecord) error {
if err != nil { if err != nil {
return err return err
} }
return requireOneGame(res, gameID) return requireOneGame(ctx, tx, res, gameID)
}) })
} }
@@ -195,13 +221,13 @@ func (s *Store) RewindGame(gameID string, afterMoveNumber int) error {
return err return err
} }
res, err := tx.ExecContext(ctx, res, err := tx.ExecContext(ctx,
`UPDATE games SET result = NULL, end_time_utc = NULL WHERE game_id = $1`, `UPDATE games SET result = NULL, termination = NULL, end_time_utc = NULL WHERE game_id = $1`,
gameID, gameID,
) )
if err != nil { if err != nil {
return err return err
} }
return requireOneGame(res, gameID) return requireOneGame(ctx, tx, res, gameID)
}) })
} }
@@ -339,23 +365,70 @@ func (s *Store) GetGameHistory(gameID string) (*GameRecord, []MoveRecord, error)
return &record, moves, nil return &record, moves, nil
} }
// QueryGamesForUser returns a page of games claimed by userID in either color, // UserGamesQuery selects a page of a user's games, newest first. After, when
// newest first. The last move is read through a LATERAL probe of the moves // set, continues strictly below that (start time, game ID) key; Offset
// primary key, so the summary costs one index lookup per game rather than a // remains for clients that page by position and must be 0 with After.
// move count over the whole line. type UserGamesQuery struct {
func (s *Store) QueryGamesForUser(userID string, limit, offset int) ([]GameSummaryRecord, error) { Limit int
if userID == "" || limit < 1 || limit > MaxUserGamesPage || offset < 0 { Offset int
After *GameCursor
Color string // "" either, "w" or "b": the color the user claimed
Status string // "" any, "ongoing", or "finished"
}
// GameCursor is a keyset position in a newest-first game listing.
type GameCursor struct {
StartTimeUTC time.Time
GameID string
}
// QueryGamesForUser returns a page of games claimed by userID, newest first.
// The last move is read through a LATERAL probe of the moves primary key, so
// the summary costs one index lookup per game rather than a move count over
// the whole line.
func (s *Store) QueryGamesForUser(userID string, q UserGamesQuery) ([]GameSummaryRecord, error) {
if userID == "" || q.Limit < 1 || q.Limit > MaxUserGamesPage || q.Offset < 0 {
return nil, fmt.Errorf("user ID, limit from 1 to %d, and non-negative offset are required", return nil, fmt.Errorf("user ID, limit from 1 to %d, and non-negative offset are required",
MaxUserGamesPage) MaxUserGamesPage)
} }
if q.After != nil && (q.Offset != 0 || !validUUID(q.After.GameID)) {
return nil, errors.New("a cursor requires a valid game ID and offset 0")
}
if !validUUID(userID) { if !validUUID(userID) {
return []GameSummaryRecord{}, nil return []GameSummaryRecord{}, nil
} }
args := []any{userID, q.Limit, q.Offset}
var where string
switch q.Color {
case "":
where = "(g.white_claimed_by = $1 OR g.black_claimed_by = $1)"
case "w":
where = "g.white_claimed_by = $1"
case "b":
where = "g.black_claimed_by = $1"
default:
return nil, fmt.Errorf("invalid color filter %q", q.Color)
}
switch q.Status {
case "":
case "ongoing":
where += " AND g.result IS NULL"
case "finished":
where += " AND g.result IS NOT NULL"
default:
return nil, fmt.Errorf("invalid status filter %q", q.Status)
}
if q.After != nil {
args = append(args, q.After.StartTimeUTC, q.After.GameID)
where += " AND (g.start_time_utc, g.game_id) < ($4, $5)"
}
if err := s.flushBeforeRead(); err != nil { if err := s.flushBeforeRead(); err != nil {
return nil, err return nil, err
} }
started := time.Now() started := time.Now()
const query = `SELECT ` + gameSelectColumns + `, query := `SELECT ` + gameSelectColumns + `,
COALESCE(last.move_number, 0), COALESCE(last.fen_after_move, g.initial_fen) COALESCE(last.move_number, 0), COALESCE(last.fen_after_move, g.initial_fen)
FROM games g FROM games g
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
@@ -364,12 +437,12 @@ func (s *Store) QueryGamesForUser(userID string, limit, offset int) ([]GameSumma
ORDER BY m.move_number DESC ORDER BY m.move_number DESC
LIMIT 1 LIMIT 1
) last ON true ) last ON true
WHERE g.white_claimed_by = $1 OR g.black_claimed_by = $1 WHERE ` + where + `
ORDER BY g.start_time_utc DESC, g.game_id DESC ORDER BY g.start_time_utc DESC, g.game_id DESC
LIMIT $2 OFFSET $3` LIMIT $2 OFFSET $3`
ctx, cancel := opContext() ctx, cancel := opContext()
defer cancel() defer cancel()
rows, err := s.db.QueryContext(ctx, query, userID, limit, offset) rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil { if err != nil {
return nil, fmt.Errorf("query user games: %w", err) return nil, fmt.Errorf("query user games: %w", err)
} }
@@ -389,20 +462,63 @@ func (s *Store) QueryGamesForUser(userID string, limit, offset int) ([]GameSumma
slog.Debug("storage user games queried", slog.Debug("storage user games queried",
"user_id", userID, "user_id", userID,
"count", len(games), "count", len(games),
"limit", limit, "limit", q.Limit,
"offset", offset, "offset", q.Offset,
"cursor", q.After != nil,
"duration", time.Since(started), "duration", time.Since(started),
) )
return games, nil return games, nil
} }
// ResolveGameID expands a game ID prefix of at least 8 hexadecimal digits,
// as printed by administrative listings, to the unique full ID.
func (s *Store) ResolveGameID(prefix string) (string, error) {
prefix = strings.ToLower(prefix)
if validUUID(prefix) {
return prefix, nil
}
if len(prefix) < 8 || len(prefix) > 36 || strings.Trim(prefix, "0123456789abcdef-") != "" {
return "", fmt.Errorf("game ID or a prefix of at least 8 hex digits required, got %q", prefix)
}
if err := s.flushBeforeRead(); err != nil {
return "", err
}
ctx, cancel := opContext()
defer cancel()
rows, err := s.db.QueryContext(ctx,
`SELECT game_id FROM games WHERE game_id::text LIKE $1 || '%' ORDER BY game_id LIMIT 2`, prefix)
if err != nil {
return "", fmt.Errorf("resolve game ID: %w", err)
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return "", fmt.Errorf("resolve game ID: %w", err)
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
return "", fmt.Errorf("resolve game ID: %w", err)
}
switch len(ids) {
case 0:
return "", sql.ErrNoRows
case 1:
return ids[0], nil
default:
return "", fmt.Errorf("game ID prefix %q is ambiguous", prefix)
}
}
type rowScanner interface { type rowScanner interface {
Scan(dest ...any) error Scan(dest ...any) error
} }
// 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 sql.NullString var whiteClaimed, whiteName, blackClaimed, blackName, result, termination sql.NullString
var endTime sql.NullTime var endTime sql.NullTime
dest := []any{ dest := []any{
&record.GameID, &record.InitialFEN, &record.GameID, &record.InitialFEN,
@@ -410,7 +526,7 @@ func scanGame(scanner rowScanner, record *GameRecord, extra ...any) error {
&whiteClaimed, &whiteName, &whiteClaimed, &whiteName,
&record.BlackPlayerID, &record.BlackType, &record.BlackLevel, &record.BlackSearchTime, &record.BlackPlayerID, &record.BlackType, &record.BlackLevel, &record.BlackSearchTime,
&blackClaimed, &blackName, &blackClaimed, &blackName,
&result, &record.StartTimeUTC, &endTime, &result, &termination, &record.StartTimeUTC, &endTime,
} }
if err := scanner.Scan(append(dest, extra...)...); err != nil { if err := scanner.Scan(append(dest, extra...)...); err != nil {
return err return err
@@ -420,20 +536,47 @@ func scanGame(scanner rowScanner, record *GameRecord, extra ...any) error {
record.BlackClaimedBy = blackClaimed.String record.BlackClaimedBy = blackClaimed.String
record.BlackName = blackName.String record.BlackName = blackName.String
record.Result = result.String record.Result = result.String
record.Termination = termination.String
record.StartTimeUTC = record.StartTimeUTC.UTC() record.StartTimeUTC = record.StartTimeUTC.UTC()
record.EndTimeUTC = utcPointer(endTime) record.EndTimeUTC = utcPointer(endTime)
return nil return nil
} }
func requireOneGame(result sql.Result, gameID string) error { // ErrGameMissing reports a write for a game whose row no longer exists,
// typically deleted by hand while the server still held the game. The writer
// drops such a write without degrading storage; see SetGameMissingHandler.
var ErrGameMissing = errors.New("game row no longer exists")
// requireOneGame checks that an UPDATE matched its game, telling a missing
// row apart from a row whose guard (such as a claim) did not match.
func requireOneGame(ctx context.Context, tx *sql.Tx, result sql.Result, gameID string) error {
rows, err := result.RowsAffected() rows, err := result.RowsAffected()
if err != nil { if err != nil {
return err return err
} }
if rows != 1 { if rows == 1 {
return nil
}
var exists bool
if err := tx.QueryRowContext(ctx,
`SELECT EXISTS (SELECT 1 FROM games WHERE game_id = $1)`, gameID,
).Scan(&exists); err != nil {
return err
}
if !exists {
return fmt.Errorf("%w: %s", ErrGameMissing, gameID)
}
return fmt.Errorf("game %s was not updated", gameID) return fmt.Errorf("game %s was not updated", gameID)
} }
return nil
// gameMissingOr maps the moves-to-games foreign-key violation, raised when a
// move is inserted for a deleted game, to ErrGameMissing.
func gameMissingOr(err error, gameID string) error {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23503" && pgErr.ConstraintName == "moves_game_id_fkey" {
return fmt.Errorf("%w: %s", ErrGameMissing, gameID)
}
return err
} }
func isValidResult(result string) bool { func isValidResult(result string) bool {
@@ -445,16 +588,37 @@ func isValidResult(result string) bool {
} }
} }
func validateResultTime(result string, ended *time.Time) error { // validTermination mirrors the games_termination_check constraint.
func validTermination(result, termination string) bool {
switch result {
case "white_wins", "black_wins":
return termination == "checkmate" || termination == "resignation"
case "stalemate":
return termination == "stalemate"
case "draw":
switch termination {
case "insufficient_material", "threefold_repetition", "fifty_move_rule", "agreement":
return true
}
}
return false
}
// validateOutcome checks that a result, its termination, and the end time are
// all present and consistent, or all absent.
func validateOutcome(result, termination string, ended *time.Time) error {
if result == "" { if result == "" {
if ended != nil { if termination != "" || ended != nil {
return errors.New("end time requires a game result") return errors.New("termination and end time require a game result")
} }
return nil return nil
} }
if !isValidResult(result) { if !isValidResult(result) {
return fmt.Errorf("invalid game result %q", result) return fmt.Errorf("invalid game result %q", result)
} }
if !validTermination(result, termination) {
return fmt.Errorf("termination %q cannot produce result %q", termination, result)
}
if ended == nil || ended.IsZero() { if ended == nil || ended.IsZero() {
return errors.New("terminal game result requires an end time") return errors.New("terminal game result requires an end time")
} }
+144
View File
@@ -0,0 +1,144 @@
package storage
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"time"
)
// Queries for the integrity sweep. None of them change data except the two
// Delete methods.
// ExistingGames reports which of ids have a games row. Flush first when ids
// include games whose insert may still be queued.
func (s *Store) ExistingGames(ids []string) (map[string]bool, error) {
exists := make(map[string]bool, len(ids))
if len(ids) == 0 {
return exists, nil
}
ctx, cancel := opContext()
defer cancel()
rows, err := s.db.QueryContext(ctx,
`SELECT game_id FROM games WHERE game_id = ANY($1::uuid[])`, ids)
if err != nil {
return nil, fmt.Errorf("query existing games: %w", err)
}
return exists, collectIDs(rows, func(id string) { exists[id] = true })
}
// GamesWithMoveGaps returns games whose moves are not numbered 1..n: a move
// row was deleted from the start or the middle of the line.
func (s *Store) GamesWithMoveGaps() ([]string, error) {
ctx, cancel := opContext()
defer cancel()
rows, err := s.db.QueryContext(ctx, `SELECT game_id FROM moves
GROUP BY game_id HAVING count(*) <> max(move_number) ORDER BY game_id`)
if err != nil {
return nil, fmt.Errorf("query move gaps: %w", err)
}
var ids []string
return ids, collectIDs(rows, func(id string) { ids = append(ids, id) })
}
// GameIDsAfter returns up to limit game IDs above after, in ID order; an
// empty after starts at the lowest.
func (s *Store) GameIDsAfter(after string, limit int) ([]string, error) {
if after == "" {
after = "00000000-0000-0000-0000-000000000000"
} else if !validUUID(after) {
return nil, fmt.Errorf("invalid game ID %q", after)
}
ctx, cancel := opContext()
defer cancel()
rows, err := s.db.QueryContext(ctx,
`SELECT game_id FROM games WHERE game_id > $1 ORDER BY game_id LIMIT $2`, after, limit)
if err != nil {
return nil, fmt.Errorf("query game IDs: %w", err)
}
var ids []string
return ids, collectIDs(rows, func(id string) { ids = append(ids, id) })
}
// OrphanedGames returns games that registered users claimed but whose
// claimants were all deleted, idle since cutoff by the anonymous-game rule
// (start, last move, and end all before it), excluding live games.
func (s *Store) OrphanedGames(cutoff time.Time, live []string) ([]string, error) {
if live == nil {
live = []string{}
}
ctx, cancel := opContext()
defer cancel()
rows, err := s.db.QueryContext(ctx, `SELECT g.game_id FROM games g
WHERE (g.white_claimed_by IS NOT NULL OR g.black_claimed_by IS NOT NULL)
AND NOT EXISTS (
SELECT 1 FROM users u
WHERE u.user_id IN (g.white_claimed_by, g.black_claimed_by)
)
AND g.start_time_utc < $1
AND (g.end_time_utc IS NULL OR g.end_time_utc < $1)
AND NOT EXISTS (
SELECT 1 FROM moves m
WHERE m.game_id = g.game_id AND m.move_time_utc >= $1
)
AND NOT (g.game_id = ANY($2::uuid[]))
ORDER BY g.game_id`, cutoff.UTC(), live)
if err != nil {
return nil, fmt.Errorf("query orphaned games: %w", err)
}
var ids []string
return ids, collectIDs(rows, func(id string) { ids = append(ids, id) })
}
// DeleteGames queues deletion of games and, by cascade, their moves. It runs
// through the ordered writer, after every write queued before it.
func (s *Store) DeleteGames(ids []string) error {
if len(ids) == 0 {
return nil
}
return s.enqueue("delete_games", "", func(ctx context.Context, tx *sql.Tx) error {
result, err := tx.ExecContext(ctx, `DELETE FROM games WHERE game_id = ANY($1::uuid[])`, ids)
if err != nil {
return err
}
if deleted, err := result.RowsAffected(); err == nil {
slog.Info("storage games deleted", "count", deleted)
}
return nil
})
}
// DeleteUsers deletes accounts and, by cascade, their sessions. Their games
// keep the claims and name snapshots.
func (s *Store) DeleteUsers(ids []string) error {
if len(ids) == 0 {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), writeTimeout)
defer cancel()
result, err := s.db.ExecContext(ctx, `DELETE FROM users WHERE user_id = ANY($1::uuid[])`, ids)
if err != nil {
return fmt.Errorf("delete users: %w", err)
}
if deleted, err := result.RowsAffected(); err == nil {
slog.Info("storage users deleted", "count", deleted)
}
return nil
}
func collectIDs(rows *sql.Rows, add func(string)) error {
defer rows.Close()
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return err
}
add(id)
}
if err := rows.Err(); err != nil {
return errors.Join(errors.New("iterate game IDs"), err)
}
return nil
}
+22 -1
View File
@@ -38,6 +38,7 @@ type GameRecord struct {
BlackClaimedBy string BlackClaimedBy string
BlackName string BlackName string
Result string Result string
Termination string // how Result was reached; empty while unfinished
StartTimeUTC time.Time StartTimeUTC time.Time
EndTimeUTC *time.Time EndTimeUTC *time.Time
} }
@@ -67,13 +68,14 @@ type MovePersistence struct {
ClaimColor string ClaimColor string
ClaimedBy string ClaimedBy string
Result string Result string
Termination string
EndTimeUTC *time.Time EndTimeUTC *time.Time
} }
// 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 = 1 const schemaVersion = 2
// 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.
@@ -161,6 +163,25 @@ CREATE INDEX games_white_claimed_idx ON games (white_claimed_by, start_time_utc
CREATE INDEX games_black_claimed_idx ON games (black_claimed_by, start_time_utc DESC, game_id DESC) CREATE INDEX games_black_claimed_idx ON games (black_claimed_by, start_time_utc DESC, game_id DESC)
WHERE black_claimed_by IS NOT NULL; WHERE black_claimed_by IS NOT NULL;
`, `,
// v2: how a result was reached. Results before v2 came only from
// checkmate and stalemate (the server never produced "draw"), so the
// backfill is exact for them; a hand-written draw becomes "agreement".
`
ALTER TABLE games ADD COLUMN termination text;
UPDATE games SET termination = CASE result
WHEN 'stalemate' THEN 'stalemate'
WHEN 'draw' THEN 'agreement'
ELSE 'checkmate' END
WHERE result IS NOT NULL;
ALTER TABLE games
ADD CONSTRAINT games_result_termination_check CHECK ((result IS NULL) = (termination IS NULL)),
ADD CONSTRAINT games_termination_check CHECK (
(result IN ('white_wins', 'black_wins') AND termination IN ('checkmate', 'resignation'))
OR (result = 'stalemate' AND termination = 'stalemate')
OR (result = 'draw' AND termination IN
('insufficient_material', 'threefold_repetition', 'fifty_move_rule', 'agreement')));
`,
} }
// ownedTables lists every table created by migrations, children first. // ownedTables lists every table created by migrations, children first.
+17
View File
@@ -68,6 +68,15 @@ type Store struct {
enqueueMu sync.RWMutex enqueueMu sync.RWMutex
closeOnce sync.Once closeOnce sync.Once
closeErr error closeErr error
gameMissing atomic.Pointer[func(gameID string)]
}
// SetGameMissingHandler registers the callback run, on its own goroutine,
// when a write finds its game row gone. Such a write is dropped without
// degrading storage: later writes for that game fail the same way and write
// nothing, so no other game's history is affected.
func (s *Store) SetGameMissingHandler(handler func(gameID string)) {
s.gameMissing.Store(&handler)
} }
// NewStore connects to PostgreSQL and starts the async writer. dsn is a // NewStore connects to PostgreSQL and starts the async writer. dsn is a
@@ -203,6 +212,14 @@ func (s *Store) executeWrite(req writeRequest) error {
) )
return nil return nil
} }
if errors.Is(err, ErrGameMissing) {
slog.Warn("storage write dropped: game row no longer exists",
"operation", req.operation, "game_id", req.gameID)
if handler := s.gameMissing.Load(); handler != nil && req.gameID != "" {
go (*handler)(req.gameID)
}
return err
}
if committing || attempt >= writeAttempts || !isTransient(err) { if committing || attempt >= writeAttempts || !isTransient(err) {
break break
} }
+229 -5
View File
@@ -116,7 +116,7 @@ func TestReplayPersistenceIsAtomicAndReadAfterWriteConsistent(t *testing.T) {
FENAfterMove: "after-e2e4", PlayerColor: "w", MoveTimeUTC: ended, FENAfterMove: "after-e2e4", PlayerColor: "w", MoveTimeUTC: ended,
}, },
ClaimColor: "w", ClaimedBy: user, ClaimColor: "w", ClaimedBy: user,
Result: "white_wins", EndTimeUTC: &ended, Result: "white_wins", Termination: "checkmate", EndTimeUTC: &ended,
}); err != nil { }); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -137,7 +137,7 @@ func TestReplayPersistenceIsAtomicAndReadAfterWriteConsistent(t *testing.T) {
t.Fatalf("moves = %+v", moves) t.Fatalf("moves = %+v", moves)
} }
owned, err := store.QueryGamesForUser(user, 10, 0) owned, err := store.QueryGamesForUser(user, UserGamesQuery{Limit: 10})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -155,7 +155,7 @@ func TestReplayPersistenceIsAtomicAndReadAfterWriteConsistent(t *testing.T) {
if record.Result != "" || record.EndTimeUTC != nil || len(moves) != 0 { if record.Result != "" || record.EndTimeUTC != nil || len(moves) != 0 {
t.Fatalf("rewind left stale replay data: game=%+v moves=%+v", record, moves) t.Fatalf("rewind left stale replay data: game=%+v moves=%+v", record, moves)
} }
owned, err = store.QueryGamesForUser(user, 10, 0) owned, err = store.QueryGamesForUser(user, UserGamesQuery{Limit: 10})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -188,6 +188,8 @@ func TestUserGamesArePagedNewestFirstWithoutDuplicates(t *testing.T) {
record.WhiteClaimedBy = user record.WhiteClaimedBy = user
case 1: case 1:
record.BlackClaimedBy = user record.BlackClaimedBy = user
end := record.StartTimeUTC.Add(time.Minute)
record.Result, record.Termination, record.EndTimeUTC = "white_wins", "resignation", &end
case 2: case 2:
record.WhiteClaimedBy, record.BlackClaimedBy = user, user // self-play record.WhiteClaimedBy, record.BlackClaimedBy = user, user // self-play
case 3: case 3:
@@ -205,7 +207,7 @@ func TestUserGamesArePagedNewestFirstWithoutDuplicates(t *testing.T) {
var got []string var got []string
for offset := 0; ; offset += 2 { for offset := 0; ; offset += 2 {
page, err := store.QueryGamesForUser(user, 2, offset) page, err := store.QueryGamesForUser(user, UserGamesQuery{Limit: 2, Offset: offset})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -219,9 +221,88 @@ func TestUserGamesArePagedNewestFirstWithoutDuplicates(t *testing.T) {
if fmt.Sprint(got) != fmt.Sprint(want) { if fmt.Sprint(got) != fmt.Sprint(want) {
t.Fatalf("user games = %v, want %v", got, want) t.Fatalf("user games = %v, want %v", got, want)
} }
if games, err := store.QueryGamesForUser("not-a-uuid", 10, 0); err != nil || len(games) != 0 { if games, err := store.QueryGamesForUser("not-a-uuid", UserGamesQuery{Limit: 10}); err != nil || len(games) != 0 {
t.Fatalf("malformed user ID = %v, %v; want empty", games, err) t.Fatalf("malformed user ID = %v, %v; want empty", games, err)
} }
// Keyset pages match offset pages, and a game inserted at the head
// between pages does not shift the next page.
got = nil
var after *GameCursor
for page := 0; ; page++ {
games, err := store.QueryGamesForUser(user, UserGamesQuery{Limit: 2, After: after})
if err != nil {
t.Fatal(err)
}
for _, game := range games {
got = append(got, game.GameID)
}
if len(games) < 2 {
break
}
last := games[len(games)-1]
after = &GameCursor{StartTimeUTC: last.StartTimeUTC, GameID: last.GameID}
if page == 0 {
newer := GameRecord{
GameID: uuid.NewString(), InitialFEN: "initial",
WhitePlayerID: user, WhiteType: 1, WhiteClaimedBy: user,
BlackPlayerID: uuid.NewString(), BlackType: 1,
StartTimeUTC: base.Add(time.Hour),
}
if err := store.RecordNewGame(newer); err != nil {
t.Fatal(err)
}
}
}
if fmt.Sprint(got) != fmt.Sprint(want) {
t.Fatalf("keyset pages = %v, want %v", got, want)
}
count := func(q UserGamesQuery) int {
t.Helper()
q.Limit = 50
games, err := store.QueryGamesForUser(user, q)
if err != nil {
t.Fatal(err)
}
return len(games)
}
// Five games claimed by user: indexes 0, 1, 2, 4 and the newer one.
for _, tc := range []struct {
q UserGamesQuery
want int
}{
{UserGamesQuery{}, 5},
{UserGamesQuery{Color: "w"}, 3},
{UserGamesQuery{Color: "b"}, 3},
{UserGamesQuery{Status: "finished"}, 1},
{UserGamesQuery{Status: "ongoing"}, 4},
{UserGamesQuery{Color: "b", Status: "finished"}, 1},
} {
if got := count(tc.q); got != tc.want {
t.Errorf("QueryGamesForUser(%+v) = %d games, want %d", tc.q, got, tc.want)
}
}
for _, bad := range []UserGamesQuery{
{Limit: 1, Color: "x"}, {Limit: 1, Status: "x"},
{Limit: 1, Offset: 1, After: &GameCursor{GameID: user}},
{Limit: 1, After: &GameCursor{GameID: "x"}},
} {
if _, err := store.QueryGamesForUser(user, bad); err == nil {
t.Errorf("QueryGamesForUser(%+v) succeeded", bad)
}
}
prefix := want[0][:8]
if id, err := store.ResolveGameID(prefix); err != nil || id != want[0] {
t.Errorf("ResolveGameID(%s) = %q, %v; want %s", prefix, id, err, want[0])
}
if _, err := store.ResolveGameID("0000000"); err == nil {
t.Error("ResolveGameID accepted a 7-digit prefix")
}
if _, err := store.ResolveGameID("ffffffff-ffff"); !errors.Is(err, sql.ErrNoRows) {
t.Errorf("ResolveGameID(unknown) error = %v, want sql.ErrNoRows", err)
}
} }
func TestQueryPlansUsePurposeBuiltIndexes(t *testing.T) { func TestQueryPlansUsePurposeBuiltIndexes(t *testing.T) {
@@ -242,6 +323,14 @@ func TestQueryPlansUsePurposeBuiltIndexes(t *testing.T) {
args: []any{userID}, args: []any{userID},
want: []string{"games_white_claimed_idx", "games_black_claimed_idx"}, want: []string{"games_white_claimed_idx", "games_black_claimed_idx"},
}, },
{
name: "keyset page of one color uses its claim index",
query: `SELECT game_id FROM games g
WHERE g.white_claimed_by = $1 AND (g.start_time_utc, g.game_id) < ($2, $3)
ORDER BY g.start_time_utc DESC, g.game_id DESC LIMIT 50`,
args: []any{userID, time.Now(), userID},
want: []string{"games_white_claimed_idx"},
},
{ {
name: "last move probe uses the moves primary key", name: "last move probe uses the moves primary key",
query: `SELECT move_number FROM moves WHERE game_id = $1 query: `SELECT move_number FROM moves WHERE game_id = $1
@@ -621,6 +710,12 @@ func TestAnonymousGamesArePurgedAfterInactivity(t *testing.T) {
BlackPlayerID: uuid.NewString(), BlackType: 2, BlackLevel: 5, BlackSearchTime: 100, BlackPlayerID: uuid.NewString(), BlackType: 2, BlackLevel: 5, BlackSearchTime: 100,
StartTimeUTC: started, Result: result, EndTimeUTC: ended, StartTimeUTC: started, Result: result, EndTimeUTC: ended,
} }
switch result {
case "stalemate":
record.Termination = "stalemate"
case "white_wins", "black_wins":
record.Termination = "checkmate"
}
if err := store.RecordNewGame(record); err != nil { if err := store.RecordNewGame(record); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -792,3 +887,132 @@ func explain(t *testing.T, db *sql.DB, query string, args ...any) string {
} }
return strings.Join(lines, "\n") return strings.Join(lines, "\n")
} }
func TestMigrationV2BackfillsTermination(t *testing.T) {
dsn := pgtest.DSN(t)
store, err := NewStore(dsn)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = store.Close() })
// A version 1 database with one game of each stored result.
if _, err := store.db.Exec(migrations[0]); err != nil {
t.Fatal(err)
}
if _, err := store.db.Exec(`INSERT INTO schema_version (version) VALUES (1)`); err != nil {
t.Fatal(err)
}
ids := map[string]string{}
for _, result := range []string{"", "white_wins", "black_wins", "stalemate", "draw"} {
id := uuid.NewString()
ids[result] = id
var end any
if result != "" {
end = time.Now()
}
if _, err := store.db.Exec(`INSERT INTO games (game_id, initial_fen, white_player_id, white_type,
black_player_id, black_type, result, end_time_utc) VALUES ($1, 'initial', $2, 1, $3, 1, $4, $5)`,
id, uuid.NewString(), uuid.NewString(), nullableString(result), end); err != nil {
t.Fatal(err)
}
}
if err := store.InitDB(); err != nil {
t.Fatal(err)
}
for result, want := range map[string]string{
"": "", "white_wins": "checkmate", "black_wins": "checkmate", "stalemate": "stalemate", "draw": "agreement",
} {
var termination sql.NullString
if err := store.db.QueryRow(`SELECT termination FROM games WHERE game_id = $1`, ids[result]).
Scan(&termination); err != nil {
t.Fatal(err)
}
if termination.String != want {
t.Errorf("result %q: termination %q, want %q", result, termination.String, want)
}
}
// The constraints reject a termination that cannot produce the result.
if _, err := store.db.Exec(`UPDATE games SET termination = 'agreement' WHERE game_id = $1`,
ids["white_wins"]); err == nil {
t.Error("games_termination_check accepted a win by agreement")
}
if _, err := store.db.Exec(`UPDATE games SET termination = 'resignation' WHERE game_id = $1`,
ids[""]); err == nil {
t.Error("games_result_termination_check accepted a termination without a result")
}
}
func TestWriteForDeletedGameIsDroppedWithoutDegrading(t *testing.T) {
store := openStore(t, pgtest.DSN(t))
missing := make(chan string, 4)
store.SetGameMissingHandler(func(gameID string) { missing <- gameID })
gameID := uuid.NewString()
if err := store.RecordNewGame(GameRecord{
GameID: gameID, InitialFEN: "initial",
WhitePlayerID: uuid.NewString(), WhiteType: 1,
BlackPlayerID: uuid.NewString(), BlackType: 1,
StartTimeUTC: time.Now(),
}); err != nil {
t.Fatal(err)
}
if err := store.Flush(context.Background()); err != nil {
t.Fatal(err)
}
// An administrator deletes the game while the server still holds it.
if _, err := store.db.Exec(`DELETE FROM games WHERE game_id = $1`, gameID); err != nil {
t.Fatal(err)
}
ended := time.Now()
writes := []func() error{
func() error {
return store.RecordMove(MovePersistence{Move: MoveRecord{
GameID: gameID, MoveNumber: 1, MoveUCI: "e2e4", FENAfterMove: "after",
PlayerColor: "w", MoveTimeUTC: ended,
}})
},
func() error {
return store.RecordGameEnd(GameEnd{
GameID: gameID, Result: "black_wins", Termination: "resignation", EndTimeUTC: ended,
})
},
func() error { return store.RewindGame(gameID, 0) },
}
for i, write := range writes {
if err := write(); err != nil {
t.Fatalf("write %d not queued: %v", i, err)
}
if err := store.Flush(context.Background()); err != nil {
t.Fatalf("write %d degraded storage: %v", i, err)
}
select {
case id := <-missing:
if id != gameID {
t.Fatalf("handler got %s, want %s", id, gameID)
}
case <-time.After(5 * time.Second):
t.Fatalf("write %d: missing-game handler not called", i)
}
}
if !store.IsHealthy() {
t.Fatal("storage degraded by writes for a deleted game")
}
// Other games are unaffected.
other := uuid.NewString()
if err := store.RecordNewGame(GameRecord{
GameID: other, InitialFEN: "initial",
WhitePlayerID: uuid.NewString(), WhiteType: 1,
BlackPlayerID: uuid.NewString(), BlackType: 1,
StartTimeUTC: time.Now(),
}); err != nil {
t.Fatal(err)
}
if _, _, err := store.GetGameHistory(other); err != nil {
t.Fatalf("other game: %v", err)
}
}
@@ -17,6 +17,8 @@ let gameState = {
username: null, username: null,
authBusy: false, authBusy: false,
newGameBusy: false, newGameBusy: false,
termination: '',
actionBusy: false, // a draw or resign request is in flight
}; };
// Chess piece Unicode: all black pieces for better fill, white pawn due to inability to override emoji variant display // Chess piece Unicode: all black pieces for better fill, white pawn due to inability to override emoji variant display
@@ -55,6 +57,10 @@ document.addEventListener('DOMContentLoaded', async () => {
document.getElementById('new-game-btn').addEventListener('click', showNewGameModal); document.getElementById('new-game-btn').addEventListener('click', showNewGameModal);
document.getElementById('undo-btn').addEventListener('click', undoMoves); document.getElementById('undo-btn').addEventListener('click', undoMoves);
document.getElementById('draw-btn').addEventListener('click', offerDraw);
document.getElementById('resign-btn').addEventListener('click', showResignModal);
document.getElementById('resign-confirm-btn').addEventListener('click', resignGame);
document.getElementById('resign-cancel-btn').addEventListener('click', hideResignModal);
document.getElementById('start-game-btn').addEventListener('click', startNewGame); document.getElementById('start-game-btn').addEventListener('click', startNewGame);
document.getElementById('cancel-btn').addEventListener('click', hideNewGameModal); document.getElementById('cancel-btn').addEventListener('click', hideNewGameModal);
document.getElementById('copy-history').addEventListener('click', copyHistory); document.getElementById('copy-history').addEventListener('click', copyHistory);
@@ -79,6 +85,13 @@ document.getElementById('login-submit-btn').addEventListener('click', handleLogi
document.getElementById('register-submit-btn').addEventListener('click', handleRegister); document.getElementById('register-submit-btn').addEventListener('click', handleRegister);
document.getElementById('auth-cancel-btn').addEventListener('click', hideAuthModal); document.getElementById('auth-cancel-btn').addEventListener('click', hideAuthModal);
document.getElementById('auth-cancel-btn-2').addEventListener('click', hideAuthModal); document.getElementById('auth-cancel-btn-2').addEventListener('click', hideAuthModal);
document.querySelectorAll('.promotion-choice').forEach(btn => {
btn.addEventListener('click', () => closePromotion(btn.dataset.piece));
});
document.getElementById('promotion-cancel-btn').addEventListener('click', () => closePromotion(null));
document.getElementById('promotion-overlay').addEventListener('click', (e) => {
if (e.target === e.currentTarget) closePromotion(null);
});
// Auth functions // Auth functions
@@ -435,24 +448,20 @@ function updateTurnIndicator(state, turn) {
switch(state) { switch(state) {
case 'white wins': case 'white wins':
status = 'white-wins'; status = 'white-wins';
tooltipText = 'White Wins';
break; break;
case 'black wins': case 'black wins':
status = 'black-wins'; status = 'black-wins';
tooltipText = 'Black Wins';
break; break;
case 'stalemate': case 'stalemate':
status = 'stalemate'; status = 'stalemate';
tooltipText = 'Stalemate';
break; break;
case 'draw': case 'draw':
status = 'draw'; status = 'draw';
tooltipText = 'Draw';
break; break;
default: default:
status = 'unknown'; status = 'unknown';
tooltipText = 'Game Over';
} }
tooltipText = describeResult(state, gameState.termination);
} else if (state === 'stuck') { } else if (state === 'stuck') {
status = 'degraded'; status = 'degraded';
tooltipText = 'Engine Error'; tooltipText = 'Engine Error';
@@ -705,6 +714,7 @@ function renderBoardFromFEN(fen) {
s.textContent = ''; s.textContent = '';
s.classList.remove('white-piece', 'black-piece', 'mated-king'); s.classList.remove('white-piece', 'black-piece', 'mated-king');
delete s.dataset.pieceColor; delete s.dataset.pieceColor;
delete s.dataset.pieceType;
}); });
let rank = 7, file = 0; let rank = 7, file = 0;
@@ -765,11 +775,64 @@ function flashSquare(element, success = true) {
setTimeout(() => element.classList.remove(className), 400); setTimeout(() => element.classList.remove(className), 400);
} }
// A pawn stepping or capturing onto its last rank needs a promotion piece; UCI
// requires the suffix (e7e8q), and the server rejects the bare move. Other
// moves go to the server as-is, which remains the only legality check.
function isPromotionMove(from, to) {
const fromEl = document.querySelector(`[data-square="${from}"]`);
if (!fromEl || fromEl.dataset.pieceType !== 'p') return false;
const fileDelta = Math.abs(from.charCodeAt(0) - to.charCodeAt(0));
if (fileDelta > 1) return false;
const color = fromEl.dataset.pieceColor;
return (color === 'w' && from[1] === '7' && to[1] === '8') ||
(color === 'b' && from[1] === '2' && to[1] === '1');
}
// Resolves with 'q', 'r', 'b' or 'n', or null when cancelled. Keyboard: Q/R/B/N
// pick, Escape cancels, Tab/Enter work on the focused button.
let promotionResolve = null;
function choosePromotion(color) {
const overlay = document.getElementById('promotion-overlay');
const choices = overlay.querySelector('.promotion-choices');
choices.classList.toggle('white', color === 'w');
choices.classList.toggle('black', color !== 'w');
overlay.classList.add('show');
document.addEventListener('keydown', handlePromotionKeydown);
overlay.querySelector('.promotion-choice[data-piece="q"]').focus();
return new Promise(resolve => { promotionResolve = resolve; });
}
function closePromotion(piece) {
document.getElementById('promotion-overlay').classList.remove('show');
document.removeEventListener('keydown', handlePromotionKeydown);
const resolve = promotionResolve;
promotionResolve = null;
if (resolve) resolve(piece);
}
function handlePromotionKeydown(e) {
const key = e.key.toLowerCase();
if (key === 'q' || key === 'r' || key === 'b' || key === 'n') {
e.preventDefault();
closePromotion(key);
} else if (e.key === 'Escape') {
e.preventDefault();
closePromotion(null);
}
}
async function handleHumanMove(from, to) { async function handleHumanMove(from, to) {
const move = from + to; let move = from + to;
const fromEl = document.querySelector(`[data-square="${from}"]`); const fromEl = document.querySelector(`[data-square="${from}"]`);
const toEl = document.querySelector(`[data-square="${to}"]`); const toEl = document.querySelector(`[data-square="${to}"]`);
if (isPromotionMove(from, to)) {
const piece = await choosePromotion(fromEl.dataset.pieceColor);
if (!piece) return;
move += piece;
}
try { try {
const response = await authFetch(`${gameState.apiUrl}/api/games/${gameState.gameId}/moves`, { const response = await authFetch(`${gameState.apiUrl}/api/games/${gameState.gameId}/moves`, {
method: 'POST', method: 'POST',
@@ -892,11 +955,13 @@ async function pollOnce() {
function lockBoard() { function lockBoard() {
gameState.isLocked = true; gameState.isLocked = true;
updateTurnIndicator('pending', gameState.turn); updateTurnIndicator('pending', gameState.turn);
updateGameControls();
} }
function unlockBoard() { function unlockBoard() {
gameState.isLocked = false; gameState.isLocked = false;
updateTurnIndicator('', gameState.turn); updateTurnIndicator(gameState.state, gameState.turn);
updateGameControls();
} }
async function undoMoves() { async function undoMoves() {
@@ -993,9 +1058,11 @@ function renderMoveHistory(moves) {
} }
function updateGameDisplay(game) { function updateGameDisplay(game) {
const wasOver = isGameOver(gameState.state);
gameState.fen = game.fen; gameState.fen = game.fen;
gameState.turn = game.turn; gameState.turn = game.turn;
gameState.state = game.state; gameState.state = game.state;
gameState.termination = game.termination || '';
gameState.moveList = game.moves || []; gameState.moveList = game.moves || [];
renderBoardFromFEN(game.fen); renderBoardFromFEN(game.fen);
@@ -1026,10 +1093,131 @@ function updateGameDisplay(game) {
// Update undo button // Update undo button
document.getElementById('undo-btn').disabled = !game.moves || game.moves.length < 2; document.getElementById('undo-btn').disabled = !game.moves || game.moves.length < 2;
// Handle checkmate visually // Mark the mated king; a resignation leaves the board as it was
if (game.state === 'white wins' || game.state === 'black wins') { if ((game.state === 'white wins' || game.state === 'black wins') &&
(game.termination || 'checkmate') === 'checkmate') {
markMatedKing(game); markMatedKing(game);
} }
updateGameControls();
if (!wasOver && isGameOver(game.state)) {
flashMessage(describeResult(game.state, game.termination), 'info', 4000);
}
}
// describeResult names a finished game's outcome and how it was reached.
function describeResult(state, termination) {
const winner = state === 'white wins' ? 'White' : 'Black';
const loser = state === 'white wins' ? 'Black' : 'White';
switch (termination) {
case 'resignation': return `${loser} resigned — ${winner} wins`;
case 'stalemate': return 'Stalemate — draw';
case 'insufficient_material': return 'Draw — insufficient material';
case 'threefold_repetition': return 'Draw — threefold repetition';
case 'fifty_move_rule': return 'Draw — fifty-move rule';
case 'agreement': return 'Draw agreed';
}
switch (state) {
case 'white wins':
case 'black wins': return `Checkmate — ${winner} wins`;
case 'stalemate': return 'Stalemate — draw';
case 'draw': return 'Draw';
}
return 'Game over';
}
// Draw needs a game in progress with the board free; resigning only needs
// an unfinished game (also while the computer thinks or the engine is stuck).
function updateGameControls() {
const hasGame = !!gameState.gameId;
const idle = !gameState.isLocked && !gameState.actionBusy;
document.getElementById('draw-btn').disabled =
!hasGame || !idle || gameState.state !== 'ongoing';
document.getElementById('resign-btn').disabled =
!hasGame || gameState.actionBusy || isGameOver(gameState.state);
}
function playerColor() {
return gameState.isPlayerWhite ? 'w' : 'b';
}
async function offerDraw() {
if (gameState.actionBusy || !gameState.gameId) return;
gameState.actionBusy = true;
updateGameControls();
try {
const response = await authFetch(`${gameState.apiUrl}/api/games/${gameState.gameId}/draw`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'offer', color: playerColor() })
});
if (response.status === 409) {
flashErrorMessage('Make a move before offering again', 2500);
return;
}
if (!response.ok) {
handleApiError('offer draw', null, response);
return;
}
const game = await response.json();
if (game.drawOutcome === 'declined') {
flashMessage('Draw declined', 'info', 2500);
}
gameState.actionBusy = false;
updateGameDisplay(game);
} catch (error) {
handleApiError('offer draw', error);
} finally {
gameState.actionBusy = false;
updateGameControls();
}
}
function showResignModal() {
if (!gameState.gameId || isGameOver(gameState.state)) return;
document.getElementById('resign-overlay').classList.add('show');
document.addEventListener('keydown', handleResignKeydown);
document.getElementById('resign-cancel-btn').focus();
}
function hideResignModal() {
document.getElementById('resign-overlay').classList.remove('show');
document.removeEventListener('keydown', handleResignKeydown);
}
function handleResignKeydown(e) {
if (e.key === 'Escape') {
e.preventDefault();
hideResignModal();
}
}
async function resignGame() {
hideResignModal();
if (gameState.actionBusy || !gameState.gameId) return;
gameState.actionBusy = true;
updateGameControls();
try {
const response = await authFetch(`${gameState.apiUrl}/api/games/${gameState.gameId}/resign`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ color: playerColor() })
});
if (!response.ok) {
handleApiError('resign', null, response);
return;
}
const game = await response.json();
stopPolling(); // a computer move in flight is discarded by the server
gameState.isLocked = false;
gameState.actionBusy = false;
updateGameDisplay(game);
} catch (error) {
handleApiError('resign', error);
} finally {
gameState.actionBusy = false;
updateGameControls();
}
} }
function markMatedKing(game) { function markMatedKing(game) {
@@ -1135,10 +1323,17 @@ function handleApiError(action, error, response = null) {
} }
function flashErrorMessage(message, duration = 1500) { function flashErrorMessage(message, duration = 1500) {
flashMessage(message, 'error', duration);
}
// Shows a short message over the status indicators; 'info' for results and
// answers to draw offers, 'error' for failures.
function flashMessage(message, type = 'error', duration = 1500) {
const overlay = document.getElementById('error-flash-overlay'); const overlay = document.getElementById('error-flash-overlay');
const messageEl = document.getElementById('error-flash-message'); const messageEl = document.getElementById('error-flash-message');
messageEl.textContent = message; messageEl.textContent = message;
messageEl.classList.toggle('info', type === 'info');
overlay.classList.add('show'); overlay.classList.add('show');
// Clear any pending timeout to avoid premature hide on rapid calls // Clear any pending timeout to avoid premature hide on rapid calls
@@ -48,6 +48,8 @@
<div class="controls"> <div class="controls">
<button id="new-game-btn" class="btn btn-primary">New Game</button> <button id="new-game-btn" class="btn btn-primary">New Game</button>
<button id="undo-btn" class="btn btn-secondary" disabled>Undo</button> <button id="undo-btn" class="btn btn-secondary" disabled>Undo</button>
<button id="draw-btn" class="btn btn-secondary" title="Offer a draw" disabled>Draw</button>
<button id="resign-btn" class="btn btn-secondary" title="Resign this game" disabled>Resign</button>
</div> </div>
<div class="move-history-container"> <div class="move-history-container">
@@ -147,6 +149,33 @@
</div> </div>
</div> </div>
<!-- Resign Confirmation -->
<div id="resign-overlay" class="modal-overlay">
<div class="modal confirm-modal" role="dialog" aria-modal="true" aria-labelledby="resign-title">
<h2 id="resign-title">Resign this game?</h2>
<div class="modal-buttons">
<button id="resign-confirm-btn" class="btn btn-primary">Resign</button>
<button id="resign-cancel-btn" class="btn btn-secondary">Cancel</button>
</div>
</div>
</div>
<!-- Promotion Modal -->
<div id="promotion-overlay" class="modal-overlay">
<div class="modal promotion-modal" role="dialog" aria-modal="true" aria-labelledby="promotion-title">
<h2 id="promotion-title">Promote to</h2>
<div class="promotion-choices">
<button class="promotion-choice" data-piece="q" title="Queen (Q)" aria-label="Queen">♛</button>
<button class="promotion-choice" data-piece="r" title="Rook (R)" aria-label="Rook">♜</button>
<button class="promotion-choice" data-piece="b" title="Bishop (B)" aria-label="Bishop">♝</button>
<button class="promotion-choice" data-piece="n" title="Knight (N)" aria-label="Knight">♞</button>
</div>
<div class="modal-buttons">
<button id="promotion-cancel-btn" class="btn btn-secondary">Cancel</button>
</div>
</div>
</div>
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
</html> </html>
@@ -379,6 +379,10 @@ main {
padding: 0.5rem; padding: 0.5rem;
} }
.error-flash-message.info {
color: var(--tokyo-cyan);
}
/* Move History */ /* Move History */
.move-history-container { .move-history-container {
flex: 1 1 auto; flex: 1 1 auto;
@@ -849,20 +853,21 @@ input[type="range"]:disabled {
margin: 0; margin: 0;
} }
/* Four controls as a 2x2 grid; the column is too short for one per row. */
.controls { .controls {
grid-column: 1; grid-column: 1;
grid-row: 2 / 4; grid-row: 2 / 4;
grid-template-columns: 1fr; grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr; grid-template-rows: 1fr 1fr;
gap: 0.5rem; gap: 0.4rem;
align-self: stretch; align-self: stretch;
height: auto; height: auto;
min-height: 80px; min-height: 80px;
} }
.btn { .btn {
padding: 0.5rem; padding: 0.4rem 0.25rem;
font-size: 0.85rem; font-size: 0.8rem;
min-height: 35px; min-height: 35px;
height: 100%; height: 100%;
width: 100%; width: 100%;
@@ -907,3 +912,55 @@ input[type="range"]:disabled {
min-width: 0; min-width: 0;
} }
} }
/* Promotion picker */
.promotion-modal {
max-width: 320px;
}
.promotion-choices {
display: flex;
justify-content: center;
gap: 0.5rem;
}
.promotion-choice {
width: 56px;
height: 56px;
display: flex;
justify-content: center;
align-items: center;
font-size: 36px;
line-height: 1;
background: var(--square-light);
border: 2px solid var(--tokyo-border);
border-radius: 6px;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.promotion-choice:hover,
.promotion-choice:focus-visible {
outline: none;
border-color: var(--host-royal);
background: var(--square-selected);
}
.promotion-choices.white .promotion-choice {
color: var(--host-white);
text-shadow: 1px 1px 2px rgba(0,0,0,0.5);
}
.promotion-choices.black .promotion-choice {
color: var(--host-bg);
text-shadow: 1px 1px 2px rgba(255,255,255,0.2);
}
/* Resign confirmation */
.confirm-modal {
max-width: 320px;
}
.confirm-modal h2 {
font-size: 1.2rem;
}
+44
View File
@@ -80,6 +80,9 @@ curl -X POST http://localhost:8080/api/games \
| API Functionality | `test-api.sh` | Game operations, moves, undo, rate limiting | | API Functionality | `test-api.sh` | Game operations, moves, undo, rate limiting |
| Database & Auth | `test-db.sh` | User registration, login, JWT tokens, persistence | | Database & Auth | `test-db.sh` | User registration, login, JWT tokens, persistence |
| 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 |
| 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 |
| 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`)
@@ -167,3 +170,44 @@ test/test-longpoll.sh
3. **Timeout**: Verify 25-second timeout with valid response 3. **Timeout**: Verify 25-second timeout with valid response
4. **Skip Wait**: Immediate return when moveCount outdated 4. **Skip Wait**: Immediate return when moveCount outdated
5. **Disconnection**: Proper cleanup on client disconnect 5. **Disconnection**: Proper cleanup on client disconnect
## Replay Tests (`test-replay.sh`)
Exercises the replay API over HTTP with human-vs-human games, so no engine
search is involved. Each run registers a fresh account, so it can be repeated
against the same server.
```bash
# Terminal 1
test/run-test-server.sh bin/chess-server
# Terminal 2
test/test-replay.sh
```
Covers SAN and results in the history, the PGN attachment (tag order,
movetext, `?ply=`), `ETag`/`If-None-Match`, castling and en passant notation,
promotion (a missing piece is rejected, `g8=N` from the custom position that
reported the web-client bug), rejected starting FENs, and cursor paging with
`status`/`color` filters. `API_DELAY` (seconds, default `0.06`) keeps it under
the dev-mode rate limit.
## Draw and Resignation Tests (`test-draw.sh`)
Plays short human-vs-human lines, switching a side to the computer where the
computer's answer to a draw offer is tested, so the only engine work is two
short evaluations. Each run registers a fresh account and can be repeated
against the same server.
```bash
test/test-draw.sh
```
Covers the automatic draws (K+B v K after a capture, a knight shuffle
repeated three times, the hundredth quiet ply, a drawn starting position),
undo after a draw, resignation (side inference, hot-seat ambiguity, a side
claimed by another user), draw offers between humans (standing offer,
decline, one offer per move, acceptance waking a long-poll, lapse on the
opponent's move), the computer declining early, accepting in a level
position and declining when a queen up, and a game whose row is deleted with
psql (needs `CHESS_TEST_DSN` and `psql`; the server unloads the game and
stays healthy).
+214
View File
@@ -0,0 +1,214 @@
#!/usr/bin/env bash
# Draw and resignation test suite: automatic draws (dead material, threefold
# repetition, fifty-move rule), draw offers between humans and to the
# computer, resignation with slot authorization, undo after a result,
# long-poll wake-up on offers, and a game whose row is deleted by hand.
# 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).
BASE_URL=${BASE_URL:-"http://localhost:8080"}
API_URL="${BASE_URL}/api"
# Pause between requests; the dev-mode limiter allows 20 per second.
API_DELAY=${API_DELAY:-0.06}
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
PASS=0
FAIL=0
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
section() { echo -e "\n${CYAN}== $1 ==${NC}"; }
ok() { echo -e "${GREEN} ✓ $1${NC}"; ((PASS++)); }
bad() { echo -e "${RED} ✗ $1${NC}"; ((FAIL++)); }
skip() { echo -e "${YELLOW} - $1${NC}"; }
check() { # check <description> <expected> <actual>
if [ "$2" = "$3" ]; then ok "$1"; else bad "$1: expected '$2', got '$3'"; fi
}
# request <method> <path> [curl args...]: body to $TMP/body, status printed.
request() {
local method=$1 path=$2
shift 2
sleep "$API_DELAY"
: >"$TMP/body"
curl -s -o "$TMP/body" -w '%{http_code}' -X "$method" "$@" "$API_URL$path"
}
json() { jq -r "$1" "$TMP/body"; }
# movetext prints the PGN movetext in $TMP/body on one line (PGN wraps at 80).
movetext() { sed -n '/^$/,$p' "$TMP/body" | sed '/^$/d' | tr '\n' ' ' | sed 's/ $//'; }
post() { # post <path> <json> [token]
local auth=()
[ -n "$3" ] && auth=(-H "Authorization: Bearer $3")
request POST "$1" -H 'Content-Type: application/json' "${auth[@]}" -d "$2"
}
new_game() { # new_game <white type> <black type> [fen]: prints the game ID
local body
body=$(jq -nc --argjson w "$1" --argjson b "$2" --arg fen "${3:-}" \
'{white:{type:$w}, black:{type:$b, level:5, searchTime:100}} + (if $fen == "" then {} else {fen:$fen} end)')
post /games "$body" >/dev/null
json .gameId
}
play() { # play <gameId> <token|""> <uci>...
local id=$1 token=$2 status
shift 2
for move in "$@"; do
status=$(post "/games/$id/moves" "{\"move\":\"$move\"}" "$token")
if [ "$status" != 200 ]; then
bad "move $move: HTTP $status $(cat "$TMP/body")"
return 1
fi
done
}
register() { # register <user> <password>: prints the session token
post /auth/register "{\"username\":\"$1\",\"password\":\"$2\"}" >/dev/null
json .token
}
if ! curl -sf "$BASE_URL/health" >/dev/null; then
echo -e "${RED}Server not reachable at $BASE_URL; start test/run-test-server.sh${NC}"
exit 1
fi
SYMMETRIC="g1f3 g8f6 b1c3 b8c6 e2e3 e7e6 d2d3 d7d6 f1e2 f8e7 c1d2 c8d7 e1g1 e8g8 a2a3 a7a6 h2h3 h7h6 a1b1 a8b8"
QUEEN_UP="e2e4 e7e5 g1f3 d8h4 f3h4 b8c6 h4f3 g8f6 b1c3 f8c5 f1e2 d7d6 e1g1 e8g8 d2d3 c8e6 c1e3 c5e3 f2e3 h7h6"
section "1. Automatic draws"
GAME=$(new_game 1 1 "4k3/8/8/8/8/8/3r4/4KB2 w - - 0 1")
play "$GAME" "" e1d2
check "Kxd2 leaves K+B v K: state" "draw" "$(json .state)"
check "termination" "insufficient_material" "$(json .termination)"
check "no move after the draw" 400 "$(post "/games/$GAME/moves" '{"move":"e8d8"}')"
check "error code" "GAME_OVER" "$(json .code)"
GAME=$(new_game 1 1)
play "$GAME" "" g1f3 g8f6 f3g1 f6g8 g1f3 g8f6 f3g1
check "second occurrence plays on" "ongoing" "$(json .state)"
play "$GAME" "" f6g8
check "third occurrence: threefold" "threefold_repetition" "$(json .termination)"
check "history termination" "threefold_repetition" \
"$(request GET "/games/$GAME/history" >/dev/null; json .termination)"
check "PGN comment" "1. Nf3 Nf6 2. Ng1 Ng8 3. Nf3 Nf6 4. Ng1 Ng8 { Draw by threefold repetition. } 1/2-1/2" \
"$(request GET "/games/$GAME/pgn" >/dev/null; movetext)"
check "undo reopens the game" 200 "$(post "/games/$GAME/undo" '{"count":1}')"
check "state after undo" "ongoing/null" "$(json '.state + "/" + (.termination // "null")')"
GAME=$(new_game 1 1 "4k3/8/8/8/8/8/8/R3K3 w - - 99 80")
play "$GAME" "" a1a2
check "hundredth quiet ply: fifty-move rule" "draw/fifty_move_rule" "$(json '.state + "/" + .termination')"
post /games '{"white":{"type":1},"black":{"type":1},"fen":"4k3/8/8/8/8/8/8/4K3 w - - 0 1"}' >/dev/null
check "bare kings are drawn at creation" "draw/insufficient_material" "$(json '.state + "/" + .termination')"
section "2. Resignation"
GAME=$(new_game 1 2)
play "$GAME" "" e2e4
check "resign infers the one human side" 200 "$(post "/games/$GAME/resign" '{}')"
check "result" "black wins/resignation" "$(json '.state + "/" + .termination')"
check "resigning twice" 400 "$(post "/games/$GAME/resign" '{}')"
check "PGN comment" "1. e4 { Black wins by resignation. } 0-1" \
"$(request GET "/games/$GAME/pgn" >/dev/null; movetext)"
GAME=$(new_game 1 1)
check "hot seat needs a color" 400 "$(post "/games/$GAME/resign" '{}')"
check "bad color" 400 "$(post "/games/$GAME/resign" '{"color":"red"}')"
GAME=$(new_game 2 2)
check "computer-only game cannot resign" 400 "$(post "/games/$GAME/resign" '{}')"
PLAYER="draw$(date +%s)$RANDOM"
TOKEN=$(register "$PLAYER" DrawPass1234)
[ -n "$TOKEN" ] && [ "$TOKEN" != null ] && ok "$PLAYER registered" || bad "register: $(cat "$TMP/body")"
GAME=$(new_game 1 1)
play "$GAME" "$TOKEN" e2e4 # claims white
check "anonymous caller cannot resign a claimed side" 403 "$(post "/games/$GAME/resign" '{"color":"w"}')"
check "claimant resigns without naming the side" 200 "$(post "/games/$GAME/resign" '{}' "$TOKEN")"
check "claimant's side lost" "black wins" "$(json .state)"
check "game listed for the claimant" "0-1" "$(request GET "/users/me/games?limit=1" \
-H "Authorization: Bearer $TOKEN" >/dev/null; json '.games[0].pgnResult')"
section "3. Draw offers between humans"
GAME=$(new_game 1 1)
play "$GAME" "" e2e4
check "black offers" "offered/b" "$(post "/games/$GAME/draw" '{"action":"offer","color":"b"}' >/dev/null; json '.drawOutcome + "/" + .drawOffer')"
check "repeating a standing offer" "offered" "$(post "/games/$GAME/draw" '{"action":"offer","color":"b"}' >/dev/null; json .drawOutcome)"
check "the offerer cannot accept" 400 "$(post "/games/$GAME/draw" '{"action":"accept","color":"b"}')"
check "white declines" "declined/null" "$(post "/games/$GAME/draw" '{"action":"decline","color":"w"}' >/dev/null; json '.drawOutcome + "/" + (.drawOffer // "null")')"
check "second offer in one move" 409 "$(post "/games/$GAME/draw" '{"action":"offer","color":"b"}')"
play "$GAME" "" e7e5 g1f3
post "/games/$GAME/draw" '{"action":"offer","color":"b"}' >/dev/null
check "offer allowed after a move" "b" "$(json .drawOffer)"
# A waiting client wakes up when the offer is answered.
MOVES=$(json '.moves | length')
START=$(date +%s)
curl -s -o "$TMP/poll" "$API_URL/games/$GAME?wait=true&moveCount=$MOVES" &
POLL=$!
sleep 0.5
post "/games/$GAME/draw" '{"action":"accept","color":"w"}' >/dev/null
check "accepting ends the game" "draw/agreement" "$(json '.state + "/" + .termination')"
wait "$POLL"
ELAPSED=$(($(date +%s) - START))
[ "$ELAPSED" -lt 10 ] && ok "long-poll woke after ${ELAPSED}s" || bad "long-poll waited ${ELAPSED}s"
check "long-poll saw the result" "draw" "$(jq -r .state "$TMP/poll")"
GAME=$(new_game 1 1)
play "$GAME" "" e2e4
post "/games/$GAME/draw" '{"action":"offer","color":"w"}' >/dev/null
play "$GAME" "" e7e5
check "moving instead of answering declines" "null" "$(request GET "/games/$GAME" >/dev/null; json '.drawOffer // "null"')"
check "accepting a lapsed offer" 400 "$(post "/games/$GAME/draw" '{"action":"accept","color":"b"}')"
check "unknown action" 400 "$(post "/games/$GAME/draw" '{"action":"maybe","color":"w"}')"
section "4. Draw offers to the computer"
GAME=$(new_game 1 2)
check "declined before move 10" "declined" "$(post "/games/$GAME/draw" '{"action":"offer"}' >/dev/null; json .drawOutcome)"
check "one offer per move" 409 "$(post "/games/$GAME/draw" '{"action":"offer"}')"
# Level position: play 20 plies as humans, then hand Black to the computer.
GAME=$(new_game 1 1)
play "$GAME" "" $SYMMETRIC
request PUT "/games/$GAME/players" -H 'Content-Type: application/json' \
-d '{"white":{"type":1},"black":{"type":2,"level":5,"searchTime":100}}' >/dev/null
check "computer accepts in a level position" "accepted/draw/agreement" \
"$(post "/games/$GAME/draw" '{"action":"offer"}' >/dev/null; json '.drawOutcome + "/" + .state + "/" + .termination')"
# White a queen up: the computer playing White declines.
GAME=$(new_game 1 1)
play "$GAME" "" $QUEEN_UP
request PUT "/games/$GAME/players" -H 'Content-Type: application/json' \
-d '{"white":{"type":2,"level":5,"searchTime":100},"black":{"type":1}}' >/dev/null
check "computer a queen up declines" "declined/ongoing" \
"$(post "/games/$GAME/draw" '{"action":"offer"}' >/dev/null; json '.drawOutcome + "/" + .state')"
section "5. A game whose row is deleted by hand"
if [ -z "$CHESS_TEST_DSN" ] || ! command -v psql >/dev/null; then
skip "CHESS_TEST_DSN or psql missing; skipped"
else
GAME=$(new_game 1 1)
play "$GAME" "" e2e4
request GET "/games/$GAME/history" >/dev/null # flush the writes
psql "$CHESS_TEST_DSN" -qAtc "DELETE FROM games WHERE game_id = '$GAME'" >/dev/null
check "move after the deletion is accepted in memory" 200 "$(post "/games/$GAME/moves" '{"move":"e7e5"}')"
for _ in $(seq 1 20); do
[ "$(request GET "/games/$GAME")" = 404 ] && break
sleep 0.1
done
check "the game is unloaded" 404 "$(request GET "/games/$GAME")"
check "storage stays healthy" "ok" "$(curl -s "$BASE_URL/health" | jq -r .storage)"
fi
echo -e "\n${CYAN}Results: ${GREEN}$PASS passed${NC}, ${RED}$FAIL failed${NC}"
[ "$FAIL" -eq 0 ]
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env bash
# Replay API test suite: SAN in history, PGN export, ETag revalidation,
# promotion handling, FEN validation, and the cursor-paged game listing.
# Requires: curl, jq, and a server from test/run-test-server.sh. Games are
# played human-vs-human, so no engine search is involved.
BASE_URL=${BASE_URL:-"http://localhost:8080"}
API_URL="${BASE_URL}/api"
# Pause between requests; the dev-mode limiter allows 20 per second.
API_DELAY=${API_DELAY:-0.06}
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
PASS=0
FAIL=0
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
section() { echo -e "\n${CYAN}== $1 ==${NC}"; }
ok() { echo -e "${GREEN} ✓ $1${NC}"; ((PASS++)); }
bad() { echo -e "${RED} ✗ $1${NC}"; ((FAIL++)); }
check() { # check <description> <expected> <actual>
if [ "$2" = "$3" ]; then ok "$1"; else bad "$1: expected '$2', got '$3'"; fi
}
# request <method> <path> [curl args...]: body to $TMP/body, headers to
# $TMP/headers, prints the status code.
request() {
local method=$1 path=$2
shift 2
sleep "$API_DELAY"
: >"$TMP/body" # curl leaves the file untouched when there is no body (304)
curl -s -o "$TMP/body" -D "$TMP/headers" -w '%{http_code}' -X "$method" "$@" "$API_URL$path"
}
header() { grep -i "^$1:" "$TMP/headers" | head -1 | cut -d' ' -f2- | tr -d '\r'; }
new_game() { # new_game [fen] [token]
local body='{"white":{"type":1},"black":{"type":1}}'
[ -n "$1" ] && body=$(jq -nc --arg fen "$1" '{white:{type:1},black:{type:1},fen:$fen}')
local auth=()
[ -n "$2" ] && auth=(-H "Authorization: Bearer $2")
request POST /games -H 'Content-Type: application/json' "${auth[@]}" -d "$body" >/dev/null
jq -r '.gameId' "$TMP/body"
}
play() { # play <gameId> <token|""> <uci>...
local id=$1 token=$2 status
shift 2
local auth=()
[ -n "$token" ] && auth=(-H "Authorization: Bearer $token")
for move in "$@"; do
status=$(request POST "/games/$id/moves" -H 'Content-Type: application/json' "${auth[@]}" \
-d "{\"move\":\"$move\"}")
if [ "$status" != 200 ]; then
bad "move $move: HTTP $status $(cat "$TMP/body")"
return 1
fi
done
}
register() { # register <user> <password>: prints the session token
request POST /auth/register -H 'Content-Type: application/json' \
-d "{\"username\":\"$1\",\"password\":\"$2\"}" >/dev/null
jq -r '.token' "$TMP/body"
}
if ! curl -sf "$BASE_URL/health" >/dev/null; then
echo -e "${RED}Server not reachable at $BASE_URL; start test/run-test-server.sh${NC}"
exit 1
fi
section "1. Checkmate: SAN, result, termination"
GAME=$(new_game)
play "$GAME" "" f2f3 e7e5 g2g4 d8h4
check "history status" 200 "$(request GET "/games/$GAME/history")"
check "SAN line" "f3 e5 g4 Qh4#" "$(jq -r '[.moves[].san] | join(" ")' "$TMP/body")"
check "pgnResult" "0-1" "$(jq -r .pgnResult "$TMP/body")"
check "termination" "checkmate" "$(jq -r .termination "$TMP/body")"
check "UCI kept" "d8h4" "$(jq -r '.moves[3].moveUci' "$TMP/body")"
section "2. ETag revalidation"
request GET "/games/$GAME/history" >/dev/null
ETAG=$(header ETag)
[ -n "$ETAG" ] && ok "ETag present ($ETAG)" || bad "ETag missing"
check "Cache-Control" "private, no-cache" "$(header Cache-Control)"
check "If-None-Match -> 304" 304 "$(request GET "/games/$GAME/history" -H "If-None-Match: $ETAG")"
check "304 has no body" 0 "$(wc -c <"$TMP/body" | tr -d ' ')"
check "stale ETag -> 200" 200 "$(request GET "/games/$GAME/history" -H 'If-None-Match: "stale"')"
section "3. PGN export"
check "pgn status" 200 "$(request GET "/games/$GAME/pgn")"
check "content type" "application/x-chess-pgn; charset=utf-8" "$(header Content-Type)"
case "$(header Content-Disposition)" in
"attachment; filename=\"chess-"*"-${GAME:0:8}.pgn\"") ok "attachment filename" ;;
*) bad "Content-Disposition: $(header Content-Disposition)" ;;
esac
check "seven tag roster order" "Event Site Date Round White Black Result" \
"$(head -7 "$TMP/body" | sed -E 's/^\[([A-Za-z]+) .*/\1/' | tr '\n' ' ' | sed 's/ $//')"
check "movetext" "1. f3 e5 2. g4 Qh4# { Black wins by checkmate. } 0-1" "$(sed -n '/^$/,$p' "$TMP/body" | sed '/^$/d')"
grep -q '^\[Termination "normal"\]$' "$TMP/body" && ok "Termination tag" || bad "Termination tag missing"
PGN_ETAG=$(header ETag)
check "pgn 304" 304 "$(request GET "/games/$GAME/pgn" -H "If-None-Match: $PGN_ETAG")"
check "pgn ?ply=2" 200 "$(request GET "/games/$GAME/pgn?ply=2")"
check "truncated movetext" "1. f3 e5 *" "$(sed -n '/^$/,$p' "$TMP/body" | sed '/^$/d')"
check "?ply=5 rejected" 400 "$(request GET "/games/$GAME/pgn?ply=5")"
check "?ply=-1 rejected" 400 "$(request GET "/games/$GAME/pgn?ply=-1")"
check "unknown game" 404 "$(request GET "/games/00000000-0000-4000-8000-000000000000/pgn")"
check "malformed ID" 400 "$(request GET "/games/not-a-uuid/pgn")"
section "4. Castling and en passant"
GAME=$(new_game)
play "$GAME" "" e2e4 d7d5 e4e5 f7f5 e5f6 g8f6 g1f3 e7e6 f1e2 f8d6 e1g1 e8g8
request GET "/games/$GAME/history" >/dev/null
check "SAN line" "e4 d5 e5 f5 exf6 Nxf6 Nf3 e6 Be2 Bd6 O-O O-O" \
"$(jq -r '[.moves[].san] | join(" ")' "$TMP/body")"
check "ongoing pgnResult" "*" "$(jq -r .pgnResult "$TMP/body")"
check "no termination while ongoing" "null" "$(jq -r .termination "$TMP/body")"
section "5. Promotion (the reported position)"
FEN="8/5KP1/5n2/p3k3/1p3p2/1P6/P7/8 w - - 4 55"
GAME=$(new_game "$FEN")
[ "$GAME" != null ] && ok "custom FEN game created" || bad "custom FEN game: $(cat "$TMP/body")"
check "g7g8 without piece rejected" 400 \
"$(request POST "/games/$GAME/moves" -H 'Content-Type: application/json' -d '{"move":"g7g8"}')"
case "$(jq -r .error "$TMP/body")" in
"promotion piece required"*) ok "error names the missing piece" ;;
*) bad "error: $(cat "$TMP/body")" ;;
esac
play "$GAME" "" g7g8n && ok "g7g8n accepted"
request GET "/games/$GAME/history" >/dev/null
check "promotion SAN" "g8=N" "$(jq -r '.moves[0].san' "$TMP/body")"
check "knight on g8" "6N1/5K2/5n2/p3k3/1p3p2/1P6/P7/8 b - - 0 55" "$(jq -r '.moves[0].fenAfterMove' "$TMP/body")"
request GET "/games/$GAME/pgn" >/dev/null
grep -q '^\[SetUp "1"\]$' "$TMP/body" && grep -q "^\[FEN \"$FEN\"\]$" "$TMP/body" \
&& ok "SetUp/FEN tags" || bad "SetUp/FEN tags missing"
check "numbering from move 55" "55. g8=N *" "$(sed -n '/^$/,$p' "$TMP/body" | sed '/^$/d')"
section "6. Invalid starting positions"
for fen in "8/8/8/8/8/8/8/8 w - - 0 1" "4k3/8/8/8/8/8/8/4K2r b - - 0 1" "4k3/8/8/8/8/8/8/P3K3 w - - 0 1"; do
status=$(request POST /games -H 'Content-Type: application/json' \
-d "$(jq -nc --arg fen "$fen" '{white:{type:1},black:{type:1},fen:$fen}')")
check "rejects $fen ($(jq -r .error "$TMP/body"))" 400 "$status"
done
section "7. My games: cursor paging and filters"
# A fresh account per run, so games from earlier runs do not interfere.
PLAYER="replay$(date +%s)$RANDOM"
TOKEN=$(register "$PLAYER" ReplayPass123)
[ -n "$TOKEN" ] && [ "$TOKEN" != null ] && ok "$PLAYER registered" || bad "register failed: $(cat "$TMP/body")"
IDS=()
for i in 1 2 3; do
GAME=$(new_game "" "$TOKEN")
play "$GAME" "$TOKEN" e2e4
IDS=("$GAME" "${IDS[@]}")
done
# The player takes White only; Black's moves are anonymous and leave that
# slot unclaimed.
GAME=$(new_game "" "$TOKEN")
play "$GAME" "$TOKEN" f2f3 && play "$GAME" "" e7e5 && play "$GAME" "$TOKEN" g2g4 && play "$GAME" "" d8h4
IDS=("$GAME" "${IDS[@]}")
SEEN=()
CURSOR=""
for page in 1 2 3; do
path="/users/me/games?limit=2"
[ -n "$CURSOR" ] && path="$path&cursor=$CURSOR"
check "page $page status" 200 "$(request GET "$path" -H "Authorization: Bearer $TOKEN")"
mapfile -t PAGE < <(jq -r '.games[].gameId' "$TMP/body")
SEEN+=("${PAGE[@]}")
CURSOR=$(jq -r '.nextCursor // empty' "$TMP/body")
[ -z "$CURSOR" ] && break
done
check "cursor pages cover the games newest first" "${IDS[*]}" "${SEEN[*]}"
check "finished filter" "${IDS[0]}" "$(request GET "/users/me/games?status=finished" \
-H "Authorization: Bearer $TOKEN" >/dev/null; jq -r '[.games[].gameId] | join(" ")' "$TMP/body")"
check "summary pgnResult" "0-1" "$(jq -r '.games[0].pgnResult' "$TMP/body")"
check "black filter (White only)" 0 "$(request GET "/users/me/games?color=black" \
-H "Authorization: Bearer $TOKEN" >/dev/null; jq '.games | length' "$TMP/body")"
check "bad cursor" 400 "$(request GET "/users/me/games?cursor=@@" -H "Authorization: Bearer $TOKEN")"
check "cursor with offset" 400 "$(request GET "/users/me/games?offset=2&cursor=$(jq -rn '"x"')" \
-H "Authorization: Bearer $TOKEN")"
check "bad status" 400 "$(request GET "/users/me/games?status=paused" -H "Authorization: Bearer $TOKEN")"
check "listing needs auth" 401 "$(request GET "/users/me/games")"
echo -e "\n${CYAN}Results: ${GREEN}$PASS passed${NC}, ${RED}$FAIL failed${NC}"
[ "$FAIL" -eq 0 ]