From f1ac4cbbe7c4a4c9a89cd9dbfb239cd826ef11007500aac3f1065a2ccfa35b3f Mon Sep 17 00:00:00 2001 From: Lixen Wraith Date: Mon, 28 Sep 2026 22:34:03 -0400 Subject: [PATCH] v0.13.0 web and terminal client enhance, replay api, linux server deploy, doc and dependency update --- .github/workflows/test.yml | 4 +- Makefile | 15 + README.md | 17 +- cmd/chess-server/cli/cli.go | 101 +++- cmd/chess-server/main.go | 9 + deploy/README.md | 34 +- deploy/freebsd/chess-backup.sh | 23 - deploy/freebsd/host.sh | 2 +- deploy/freebsd/setup-jail.sh | 22 +- deploy/linux/chess-backup.service | 17 + deploy/linux/chess-backup.timer | 12 + deploy/linux/chessd.service | 61 +++ deploy/linux/setup.sh | 262 +++++++++++ deploy/{freebsd => }/nginx-chess.conf | 29 +- deploy/postgresql/chess-backup.sh | 28 ++ doc/api.md | 170 ++++++- doc/architecture.md | 73 ++- doc/client.md | 52 ++- doc/database.md | 309 +++++++++++++ doc/deployment-linux.md | 228 ++++++++++ doc/deployment.md | 54 ++- doc/development.md | 2 +- doc/todo.md | 96 ++-- go.mod | 2 +- go.sum | 4 +- internal/client/api/client.go | 43 +- internal/client/api/types.go | 31 +- internal/client/command/game.go | 199 +++++++- internal/client/command/registry.go | 4 + internal/client/session/session.go | 1 + internal/server/board/board.go | 119 ----- internal/server/chess/chess_test.go | 370 +++++++++++++++ internal/server/chess/fuzz_test.go | 98 ++++ internal/server/chess/movegen.go | 427 +++++++++++++++++ internal/server/chess/pgn.go | 185 ++++++++ internal/server/chess/position.go | 430 ++++++++++++++++++ internal/server/chess/san.go | 179 ++++++++ internal/server/chess/status.go | 102 +++++ internal/server/chess/stockfish_test.go | 159 +++++++ internal/server/core/api.go | 48 +- internal/server/core/state.go | 42 ++ internal/server/game/game.go | 103 ++++- internal/server/http/handler.go | 182 +++++++- internal/server/http/handler_test.go | 123 ++++- internal/server/http/validator.go | 4 + internal/server/processor/command.go | 18 + internal/server/processor/processor.go | 428 +++++++++++++++-- internal/server/processor/rules_test.go | 97 ++++ internal/server/replay/replay.go | 155 +++++++ internal/server/replay/replay_test.go | 156 +++++++ internal/server/replay/verify.go | 118 +++++ internal/server/service/end_test.go | 245 ++++++++++ internal/server/service/game.go | 238 ++++++++-- internal/server/service/game_test.go | 27 +- internal/server/service/history.go | 155 ++++++- internal/server/service/history_test.go | 156 +++++++ internal/server/service/integrity.go | 239 ++++++++++ internal/server/service/integrity_test.go | 145 ++++++ internal/server/service/service.go | 60 ++- internal/server/service/waiter.go | 6 + internal/server/storage/game.go | 310 ++++++++++--- internal/server/storage/integrity.go | 144 ++++++ internal/server/storage/schema.go | 33 +- internal/server/storage/storage.go | 17 + internal/server/storage/storage_test.go | 234 +++++++++- .../server/webserver/chess-client-web/app.js | 213 ++++++++- .../webserver/chess-client-web/index.html | 29 ++ .../webserver/chess-client-web/style.css | 65 ++- test/README.md | 44 ++ test/test-draw.sh | 214 +++++++++ test/test-replay.sh | 193 ++++++++ 71 files changed, 7677 insertions(+), 537 deletions(-) delete mode 100755 deploy/freebsd/chess-backup.sh create mode 100644 deploy/linux/chess-backup.service create mode 100644 deploy/linux/chess-backup.timer create mode 100644 deploy/linux/chessd.service create mode 100755 deploy/linux/setup.sh rename deploy/{freebsd => }/nginx-chess.conf (52%) create mode 100755 deploy/postgresql/chess-backup.sh create mode 100644 doc/database.md create mode 100644 doc/deployment-linux.md delete mode 100644 internal/server/board/board.go create mode 100644 internal/server/chess/chess_test.go create mode 100644 internal/server/chess/fuzz_test.go create mode 100644 internal/server/chess/movegen.go create mode 100644 internal/server/chess/pgn.go create mode 100644 internal/server/chess/position.go create mode 100644 internal/server/chess/san.go create mode 100644 internal/server/chess/status.go create mode 100644 internal/server/chess/stockfish_test.go create mode 100644 internal/server/processor/rules_test.go create mode 100644 internal/server/replay/replay.go create mode 100644 internal/server/replay/replay_test.go create mode 100644 internal/server/replay/verify.go create mode 100644 internal/server/service/end_test.go create mode 100644 internal/server/service/history_test.go create mode 100644 internal/server/service/integrity.go create mode 100644 internal/server/service/integrity_test.go create mode 100644 internal/server/storage/integrity.go create mode 100755 test/test-draw.sh create mode 100755 test/test-replay.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 66e8aa6..e435262 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,7 +30,9 @@ jobs: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 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 run: test -z "$(gofmt -l .)" || { gofmt -l .; exit 1; } - run: go vet ./... diff --git a/Makefile b/Makefile index 9bb1607..9c95d65 100644 --- a/Makefile +++ b/Makefile @@ -148,6 +148,18 @@ test-db: test-longpoll: 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 .PHONY: db-init db-init: server @@ -229,6 +241,9 @@ help: @echo " make test-api Run API tests" @echo " make test-db Run database 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 "Database targets (CHESS_DSN=$(CHESS_DSN)):" @echo " make db-init Create or migrate the schema" diff --git a/README.md b/README.md index d62a1d0..da9029f 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@

♚♛♜♝♞

- Go 1.26 + Go 1.27 License BSD-3

@@ -20,24 +20,31 @@ Go backend server providing a RESTful API for chess gameplay with user authentic - User registration and JWT authentication - Stockfish engine integration for validation - 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 - Asynchronous engine move calculation - Configurable engine strength and thinking time - PostgreSQL 18 persistence with ordered async writes for games - 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 -- 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 - User management with secure Argon2id password storage and scoped JWTs - 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 - Database CLI for storage and user administration ## Requirements -- Go 1.26+ +- Go 1.27+ - 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 ```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 - [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 - [Development](./doc/development.md) - Build, test, and user management - [Client Guide](./doc/client.md) - Interactive debugging client diff --git a/cmd/chess-server/cli/cli.go b/cmd/chess-server/cli/cli.go index 2f68992..b0806b7 100644 --- a/cmd/chess-server/cli/cli.go +++ b/cmd/chess-server/cli/cli.go @@ -11,6 +11,7 @@ import ( "text/tabwriter" "time" + "chess/internal/server/replay" "chess/internal/server/storage" "github.com/google/uuid" @@ -23,7 +24,7 @@ const dsnEnv = "CHESS_DSN" // Run is the entry point for the CLI mini-app func Run(args []string) error { 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] { @@ -33,6 +34,10 @@ func Run(args []string) error { return runDelete(args[1:]) case "query": return runQuery(args[1:]) + case "pgn": + return runPGN(args[1:]) + case "verify": + return runVerify(args[1:]) case "user": if len(args) < 2 { 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 } +// 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 { value := fmt.Sprintf("%s (T%d)", abbreviateID(playerID), playerType) if claimedBy != "" && claimedBy != playerID { diff --git a/cmd/chess-server/main.go b/cmd/chess-server/main.go index fd4cc65..b52e6ee 100644 --- a/cmd/chess-server/main.go +++ b/cmd/chess-server/main.go @@ -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)") 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)") + dbCleanup = flag.String("db-cleanup", "off", "Hourly integrity sweep of stored games and accounts: off, report (log only), or delete") // Web UI server flags serve = flag.Bool("serve", false, "Enable web UI server") @@ -151,6 +152,14 @@ func main() { svc.SetFinishedGameTTL(*finishedTTL) svc.SetAnonymousGameTTL(*anonTTL) 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 cleanupCtx, cleanupCancel := context.WithCancel(context.Background()) diff --git a/deploy/README.md b/deploy/README.md index 7af1c8a..06f9a82 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,7 +1,12 @@ # Deployment Scripts -Run in this order; each script's header documents its inputs. The full guide is -[doc/deployment.md](../doc/deployment.md). +Each script's header documents its inputs. Day-to-day database work (psql, +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 | |---|---|---|---| @@ -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 | | 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 | -| 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 ` | 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 ` | 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/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/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 -host nginx configuration, not in this repository. +Keep deployment-specific values (addresses, host names) in `rc.conf`, +`/etc/chessd/chessd.env`, and the nginx configuration, not in this repository. diff --git a/deploy/freebsd/chess-backup.sh b/deploy/freebsd/chess-backup.sh deleted file mode 100755 index c0152c7..0000000 --- a/deploy/freebsd/chess-backup.sh +++ /dev/null @@ -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 ' -# (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 diff --git a/deploy/freebsd/host.sh b/deploy/freebsd/host.sh index 3fbe842..14133a8 100755 --- a/deploy/freebsd/host.sh +++ b/deploy/freebsd/host.sh @@ -9,7 +9,7 @@ # 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 -# 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 set -eu PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin diff --git a/deploy/freebsd/setup-jail.sh b/deploy/freebsd/setup-jail.sh index 42be1b5..26e9a48 100644 --- a/deploy/freebsd/setup-jail.sh +++ b/deploy/freebsd/setup-jail.sh @@ -78,7 +78,7 @@ yes_no() { [ "$(id -u)" -eq 0 ] || die "run as root" [ "$(uname -s)" = FreeBSD ] || die "this script targets FreeBSD" [ -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" done yes_no SPLIT_PRIVILEGES "$SPLIT_PRIVILEGES" @@ -182,12 +182,20 @@ fi bindir=$(dirname "$bin") [ -d "$bindir" ] || install -d -o root -g wheel -m 0755 "$bindir" -if [ -f "$bin" ] && ! cmp -s "$CHESS_BINARY" "$bin"; then - log "keeping the previous binary as $bin.prev" - cp -p "$bin" "$bin.prev" -fi # Root-owned: the service account cannot replace its own executable file. -install -o root -g wheel -m 0555 "$CHESS_BINARY" "$bin" +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" + cp -p "$bin" "$bin.prev" + fi + install -o root -g wheel -m 0555 "$CHESS_BINARY" "$bin" +fi [ -d "$RC_DIR" ] || install -d "$RC_DIR" 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 # Nightly pg_dump at 03:30 as postgres, keeping 14 days. [ -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 "$CRON_DIR" ] || install -d "$CRON_DIR" printf '%s\n' \ diff --git a/deploy/linux/chess-backup.service b/deploy/linux/chess-backup.service new file mode 100644 index 0000000..071e941 --- /dev/null +++ b/deploy/linux/chess-backup.service @@ -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 diff --git a/deploy/linux/chess-backup.timer b/deploy/linux/chess-backup.timer new file mode 100644 index 0000000..7750982 --- /dev/null +++ b/deploy/linux/chess-backup.timer @@ -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 diff --git a/deploy/linux/chessd.service b/deploy/linux/chessd.service new file mode 100644 index 0000000..ff8b702 --- /dev/null +++ b/deploy/linux/chessd.service @@ -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 diff --git a/deploy/linux/setup.sh b/deploy/linux/setup.sh new file mode 100755 index 0000000..4272fa6 --- /dev/null +++ b/deploy/linux/setup.sh @@ -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" <"$env_file" <"$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 " diff --git a/deploy/freebsd/nginx-chess.conf b/deploy/nginx-chess.conf similarity index 52% rename from deploy/freebsd/nginx-chess.conf rename to deploy/nginx-chess.conf index bf3524b..330cef4 100644 --- a/deploy/freebsd/nginx-chess.conf +++ b/deploy/nginx-chess.conf @@ -1,16 +1,18 @@ -# Host nginx for chess: browsers (web and WASM clients) call -# /chess/api/... and /chess/health, which map to the server's -# /api/... and /health. Replace and (chessd_port, -# default 8080). +# nginx for chess: browsers (web and WASM clients) call /chess/api/... +# and /chess/health, which map to the server's /api/... and /health; +# /chess itself redirects to the project page. Replace and +# : 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 -# 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, # set real_ip_header proxy_protocol and set_real_ip_from for that hop. # --- http {} context --- upstream chess_api { - server :; + server :; keepalive 16; } @@ -43,10 +45,21 @@ location = /chess/health { 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 { - 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/ { return 404; } diff --git a/deploy/postgresql/chess-backup.sh b/deploy/postgresql/chess-backup.sh new file mode 100755 index 0000000..dbb7911 --- /dev/null +++ b/deploy/postgresql/chess-backup.sh @@ -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 +# (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 diff --git a/doc/api.md b/doc/api.md index 7d6fd8b..4cbca10 100644 --- a/doc/api.md +++ b/doc/api.md @@ -157,6 +157,11 @@ Authorization: Bearer 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 /games/{gameId}` @@ -190,7 +195,8 @@ the game ID, matching the existing public live-game read model. Persistent storage must be enabled. 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):** ```json @@ -198,6 +204,8 @@ client can replay the game without running a chess engine. "gameId": "a1b2c3d4-e5f6-7890-1234-567890abcdef", "initialFen": "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", "result": "white_wins", + "pgnResult": "1-0", + "termination": "checkmate", "startTimeUtc": "2026-09-07T12:00:00Z", "endTimeUtc": "2026-09-07T12:15:00Z", "players": { @@ -208,6 +216,7 @@ client can replay the game without running a chess engine. { "moveNumber": 1, "moveUci": "e2e4", + "san": "e4", "fenAfterMove": "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1", "playerColor": "w", "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 -`white_wins`, `black_wins`, `draw`, and `stalemate`. A claimed player's `name` -is their username when the claim was recorded; it survives later renames and +`white_wins`, `black_wins`, `draw`, and `stalemate`. `pgnResult` is the PGN +token for the same outcome (`1-0`, `0-1`, `1/2-1/2`, or `*` while ongoing), +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. +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 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--.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.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 -`GET /users/me/games?limit=50&offset=0` +`GET /users/me/games?limit=50&cursor=&status=finished&color=white` Returns games associated with the authenticated user at creation time or by a -later first-move slot claim. Requires `Authorization: Bearer ` and -persistent storage. +later first-move slot claim, newest first. Requires +`Authorization: Bearer ` and persistent storage. - `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, -or the initial FEN when there is none), result/timestamps, players, and move -count. `nextOffset` is present only when another page exists. A game belongs to -a user who created it while authenticated or claimed a slot with a first move; -the association survives later player reconfiguration. +or the initial FEN when there is none), result and its `pgnResult` token, +timestamps, players, and move count. `nextCursor` is present only when another +page exists; `nextOffset` also, on requests without a cursor. A game belongs +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):** ```json @@ -249,6 +317,7 @@ the association survives later player reconfiguration. "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", "result": "white_wins", + "pgnResult": "1-0", "startTimeUtc": "2026-09-07T12:00:00Z", "endTimeUtc": "2026-09-07T12:15:00Z", "moveCount": 7, @@ -259,7 +328,8 @@ the association survives later player reconfiguration. } ], "limit": 50, - "offset": 0 + "offset": 0, + "nextCursor": "MTc5MDQ5NjQyMDU4MTg2NS42ODAwN2ZkMS04OTcwLTRkODQtOTUwZC0yZjZlYzYzNzVjMGI" } ``` @@ -273,15 +343,89 @@ Submits human move or triggers computer move. {"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:** ```json {"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 `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 `PUT /games/{gameId}/players` diff --git a/doc/architecture.md b/doc/architecture.md index 58967f4..932d492 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -57,7 +57,14 @@ schema is current, which lets a DML-only runtime role start the server. ### Supporting Modules - **Engine** (`internal/engine`): UCI protocol wrapper for Stockfish process communication - **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 - **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 @@ -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 2. Optional JWT validation for user verification 3. Creates MakeMoveCommand, calls `processor.Execute()` -4. Processor validates move via locked validation engine -5. If legal, gets new FEN from engine -6. Calls `service.ApplyMoveWithState()` with the FEN, turn, and state that were validated -7. Service rejects a stale concurrent commit or atomically updates the move, optional slot claim, and terminal result -8. The same logical mutation is queued as one database transaction -9. Returns GameResponse +4. The rules core checks the move first only to name a missing promotion + piece; the processor then validates it via the locked validation engine +5. If legal, gets new FEN from engine, and logs a warning if the rules core + disagrees on legality or the resulting position +6. If neither mate nor stalemate, the processor applies the automatic draw + 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 1. HTTP handler receives `POST /games/{id}/moves` with `{"move": "cccc"}` 2. Processor sets game state to `pending` 3. Submits task to EngineQueue, returns immediately 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 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 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 -5. The API returns the initial FEN plus every UCI move and resulting FEN -6. This path works after terminal-memory eviction or a server restart +5. The replay package derives SAN for each ply from the stored FEN before it +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 @@ -206,7 +237,7 @@ Commands encapsulate operations with type, arguments, and optional user ID for a ### Player Configuration Players identified by UUID (authenticated users) or generated IDs (anonymous), configured with type (human/computer), skill level, and search time. -### Storage Schema (version 1) +### Storage Schema (version 2) ```sql users ( user_id uuid PRIMARY KEY, @@ -236,6 +267,9 @@ games ( black_... (same columns), start_time_utc timestamptz NOT NULL, 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 ) @@ -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 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 @@ -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 ordered writer while excluding every game still loaded, so no live game can 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 -[deployment.md](./deployment.md) for the jail service. +See [`deploy/postgresql`](../deploy/postgresql) for provisioning, +[deployment.md](./deployment.md) for the jail service, and +[deployment-linux.md](./deployment-linux.md) for systemd. ## Security Architecture diff --git a/doc/client.md b/doc/client.md index b301586..8f69ada 100644 --- a/doc/client.md +++ b/doc/client.md @@ -14,11 +14,11 @@ The chess client is an interactive command-line debugging tool for the chess ser ## Replay Foundation -The Go API client exposes `GetGameHistory(gameID)` and -`GetMyGames(limit, offset)`, including durable result, timestamps, player -claims, move count, ordered UCI moves, and FEN after every move. Interactive -`games`/`replay` commands and terminal playback controls are intentionally -deferred to the dedicated replay iteration; see [Replay Implementation Tasks](./todo.md). +The Go API client exposes `GetGameHistory(gameID)`, `GetGamePGN(gameID, ply)`, +and `GetMyGames(limit, cursor)`, including durable result, timestamps, player +claims, move count, ordered UCI and SAN moves, and FEN after every move. The +`games` and `pgn` commands use them; step-by-step `replay` controls are still +planned, see [Replay Implementation Tasks](./todo.md). ## Building ```bash @@ -104,10 +104,12 @@ chess > join a1b2c3d4-e5f6-7890-1234-567890abcdef ``` #### `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 e7e5 +chess > move g7g8q ``` #### `computer` / `c` @@ -148,6 +150,44 @@ Long-poll for game updates (waits up to 30 seconds). 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 [ply] # Another game +``` + ### Debug Commands #### `health` / `.` diff --git a/doc/database.md b/doc/database.md new file mode 100644 index 0000000..0e34c75 --- /dev/null +++ b/doc/database.md @@ -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 ''` 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=,t=,p=$$`, 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 [-email ] +sudo chess-db user set-password -username +sudo chess-db user list +# FreeBSD jail +su -m chess -c '/home/chess/bin/chess-server db user add -username -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:///chess/api/games//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 +``` diff --git a/doc/deployment-linux.md b/doc/deployment-linux.md new file mode 100644 index 0000000..d32b533 --- /dev/null +++ b/doc/deployment-linux.md @@ -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 # 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= 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 # 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 +`:` = `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. diff --git a/doc/deployment.md b/doc/deployment.md index 53a1cf8..6f47ce0 100644 --- a/doc/deployment.md +++ b/doc/deployment.md @@ -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 (`bastille console `). 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 @@ -56,7 +59,7 @@ Their header comments list every input. unless `/health` reports `"storage":"ok"`. 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). `setup-jail.sh` inputs: @@ -105,6 +108,21 @@ rerun, and a fresh split-privilege install. ## 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 -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 add` are identical and never expire. Public registration closes at `-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. `-anonymous-game-ttl` changes the window; `0` keeps them. - 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`) @@ -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_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_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 (`/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) | | `-anonymous-game-ttl` | | Retention of games without a registered player (default `24h`) | | `-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 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"' ``` +`db` subcommands: `init`, `delete -confirm` (drops the tables), `query` +(games, with 8-digit ID prefixes), `pgn -gameId [-ply N]`, +`verify [-gameId ]` (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 The API has no version segment: routes are `/api/...` and `/health`. With the -layout in [`nginx-chess.conf`](../deploy/freebsd/nginx-chess.conf), browsers -call `/chess/api/...` and nginx maps `/chess/api/` to `/api/`. +layout in [`nginx-chess.conf`](../deploy/nginx-chess.conf), browsers +call `/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 `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. - **WASM terminal client (if published):** `make wasm` (xterm.js is committed under `lib/`), then copy `web/chess-client-wasm/`. It derives its API base - as `/chess`. + as `/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 -`/api/v1` gets 404 from this release. +`web/chess-client-web` is a symlink to the embedded web client; copy with +`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 @@ -253,8 +288,9 @@ Restore the previous web client files on the host as well. ### Backups and restores -- `chess-backup` writes `pg_dump --format=custom` files nightly and removes - those older than 14 days. Copy them off the jail. +- `chess-backup` ([`deploy/postgresql/chess-backup.sh`](../deploy/postgresql/chess-backup.sh), + 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: `su -m postgres -c 'pg_restore --no-owner --role=chess -d chess '` (`--role=chess_owner` in split mode). diff --git a/doc/development.md b/doc/development.md index 343a56a..7f12e8f 100644 --- a/doc/development.md +++ b/doc/development.md @@ -2,7 +2,7 @@ ## Prerequisites -- Go 1.26+ +- Go 1.27+ - Stockfish in PATH - PostgreSQL 18 (server for persistence; `psql` for the shell test suites) - Git diff --git a/doc/todo.md b/doc/todo.md index ed6c992..1a9ef72 100644 --- a/doc/todo.md +++ b/doc/todo.md @@ -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] Scripted FreeBSD jail deployment (`deploy/`). - [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 @@ -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. | | D3 | Where SAN/PGN is produced | **Decided: server-side Go** (R1), derived on read from the initial FEN and the UCI line: no schema change, one implementation for web and CLI, no third-party script under the CSP. | | D4 | Player names | **Done.** `white_name`/`black_name` snapshot the claimant's username in the claim transaction and survive renames and deletions; exposed as `players.*.name`. | -| D5 | Draw rules | 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. | -| 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 -replay only; Stockfish remains the move validator for live play. +A dependency-free rules core in Go (`internal/server/chess`). Stockfish +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`). -- [ ] Legal move generation: pseudo-legal moves plus king-safety filtering, - castling rights and paths, en passant, promotion. -- [ ] SAN encode (disambiguation by file, rank, or both; `+`/`#`; `O-O`, - `O-O-O`; `=Q`) and SAN decode for later PGN import. -- [ ] PGN writer: Seven Tag Roster (White/Black from the name snapshots, else - "Anonymous" or "Stockfish level N"), `SetUp`/`FEN` tags for custom starts, - `Termination`, 80-column movetext, `Result` from the stored outcome. -- [ ] Draw-rule helpers (D5) and an integrity check that replays the stored UCI - line and compares each generated FEN with the stored `fenAfterMove`. +- [x] Position with strict FEN parse (one king per side, no pawns on the back + ranks, side not to move not in check; castling rights without their rook + dropped) and canonical serialization (en-passant square only when a capture + is legal, so Stockfish's pseudo-legal convention normalizes away). +- [x] Legal move generation with castling paths, en passant, promotion, pins. +- [x] SAN encode (file/rank/square disambiguation, `+`/`#`, `O-O`, `=Q`) and + lenient decode (`0-0`, `e8Q`, annotations, over-disambiguation). +- [x] PGN writer: Seven Tag Roster, supplemental tags, `SetUp`/`FEN` for custom + starts with correct move numbering, 80-column movetext, escaped tag values. +- [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: -- [ ] Perft node counts for the standard CPW positions (start, Kiwipete, and - positions 3–6) to depth 4, which exercises castling, en passant, promotion, - and pins. -- [ ] Property tests over random legal games: SAN decode(encode(m)) == m; the - generated FEN sequence is identical to one produced by Stockfish's `d` - command for the same line. -- [ ] Fuzz targets for FEN parsing, SAN decoding, and PGN writing (no panics, - bounded output). -- [ ] Integrity check run over every stored game in a production-shaped copy. +- [x] Perft for the CPW start, Kiwipete, and positions 3–6 (depth 4; position + 3 to depth 5). +- [x] Engine cross-check: random games from all six positions, comparing the + legal move set with Stockfish `go perft 1` and the FEN with Stockfish `d` at + every ply (about 6,000 positions per run; skipped without Stockfish). +- [x] Fuzz targets: FEN parse round trip with SAN/UCI round trip of every + legal move, SAN decoding, PGN tag escaping. +- [ ] `db verify` over a copy of the production database once it holds games. -## Phase R2 — Replay API +## Phase R2 — Replay API — done -- [ ] Implement D7 (keyset cursor) once decided. -- [ ] Additive history fields: `san` per move, `outcome`/`termination`, and - `pgnResult`; keep existing fields unchanged. -- [ ] `GET /api/games/{id}/pgn?ply=N`: `application/x-chess-pgn`, - `Content-Disposition: attachment; filename="chess--.pgn"`, - moves 1..N (default all). The FEN at any ply is already in the history. -- [ ] `ETag` and `Cache-Control: private, max-age` for terminal games; - `If-None-Match` returns 304. -- [ ] Filters on the list endpoint: result, color, ongoing/finished. -- [ ] Tests: handler tests against PostgreSQL (pgtest), golden PGN files for - 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. +- [x] D7 keyset cursor, plus `status` and `color` filters on the list. +- [x] Additive history fields: `san` per move, `pgnResult`, `termination`. +- [x] `GET /api/games/{id}/pgn?ply=N` as an attachment; `db pgn` in the CLI. +- [x] Strong `ETag` with `Cache-Control: private, no-cache` on history and PGN + (revalidate every time: D6 lets finished games change); `If-None-Match` + returns 304. +- [x] Tests: rules-core unit, fuzz, and engine cross-check; service and + handler tests against PostgreSQL; `test/test-replay.sh` over HTTP (SAN, + PGN, ETag, promotion, FEN validation, cursor paging and filters). +- [x] Terminal client: `games` (cursor paging) and `pgn` commands. ## 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 computes moves. - [ ] Export at the current ply: copy FEN, copy PGN, and download PGN (fetched - from R2 as a `Blob`, saved through a temporary ``). + from R2 as a `Blob`, saved through a temporary ``). 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, and player-configuration controls; **Back to live game** restores them. @@ -129,8 +135,9 @@ Test gate: ## Phase R4 — CLI and WASM Replay (optional) -- [ ] `games` (list with pagination), `replay `, `next`, `prev`, - `first`, `last`, `goto `, `auto `, `fen`, `pgn save `. +- [x] `games` (list with cursor paging) and `pgn [gameId] [ply]`. +- [ ] `replay `, `next`, `prev`, `first`, `last`, `goto `, + `auto `, `fen`, `pgn save `. - [ ] Replay state is separate from the live session: no polling, moves, undo, or configuration while replaying. @@ -156,4 +163,9 @@ Test gate: - [ ] Retention and user-initiated deletion or anonymization of games. - [ ] Thread request contexts from HTTP handlers into storage calls; today each 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. diff --git a/go.mod b/go.mod index 1294bb7..a01fb64 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( require ( github.com/clipperhouse/uax29/v2 v2.7.0 // 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/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect diff --git a/go.sum b/go.sum index e07bb20..0225dbc 100644 --- a/go.sum +++ b/go.sum @@ -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/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/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= -github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/locales v0.14.2 h1:d8UmcrM6Nip0hfGZKLGpAvZH37XB4TS0xzK9B56YNCY= +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/go.mod h1:67VZIMp5lQpDWlnStOct22q1bkdJGJqHghbOtmkawxk= github.com/go-playground/validator/v10 v10.30.5 h1:YyCXvVShZbs2Sm3Mb53eNOlhRXctSOzW5QJAouCTZL4= diff --git a/internal/client/api/client.go b/internal/client/api/client.go index aee825b..00d35ba 100644 --- a/internal/client/api/client.go +++ b/internal/client/api/client.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "net/url" "strings" "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) } - // 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 err := json.Unmarshal(respBody, result); err != nil { // 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 } +// 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) { var resp BoardResponse 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 } -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 - 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) return &resp, err } diff --git a/internal/client/api/types.go b/internal/client/api/types.go index 7e94406..f36ff1b 100644 --- a/internal/client/api/types.go +++ b/internal/client/api/types.go @@ -36,13 +36,25 @@ type LoginRequest struct { // Response types type GameResponse struct { - GameID string `json:"gameId"` - FEN string `json:"fen"` - Turn string `json:"turn"` - State string `json:"state"` - Moves []string `json:"moves"` - Players PlayersResponse `json:"players"` - LastMove *MoveInfo `json:"lastMove,omitempty"` + GameID string `json:"gameId"` + FEN string `json:"fen"` + Turn string `json:"turn"` + State string `json:"state"` + Termination string `json:"termination,omitempty"` + DrawOffer string `json:"drawOffer,omitempty"` + DrawOutcome string `json:"drawOutcome,omitempty"` + Moves []string `json:"moves"` + Players PlayersResponse `json:"players"` + 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 { @@ -104,6 +116,8 @@ type GameHistoryResponse struct { GameID string `json:"gameId"` InitialFEN string `json:"initialFen"` Result string `json:"result,omitempty"` + PGNResult string `json:"pgnResult"` + Termination string `json:"termination,omitempty"` StartTimeUTC time.Time `json:"startTimeUtc"` EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"` Players PlayersResponse `json:"players"` @@ -113,6 +127,7 @@ type GameHistoryResponse struct { type HistoryMove struct { MoveNumber int `json:"moveNumber"` MoveUCI string `json:"moveUci"` + SAN string `json:"san,omitempty"` FENAfterMove string `json:"fenAfterMove"` PlayerColor string `json:"playerColor"` MoveTimeUTC time.Time `json:"moveTimeUtc"` @@ -123,6 +138,7 @@ type GameSummary struct { InitialFEN string `json:"initialFen"` FinalFEN string `json:"finalFen"` Result string `json:"result,omitempty"` + PGNResult string `json:"pgnResult"` StartTimeUTC time.Time `json:"startTimeUtc"` EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"` MoveCount int `json:"moveCount"` @@ -134,4 +150,5 @@ type GameListResponse struct { Limit int `json:"limit"` Offset int `json:"offset"` NextOffset *int `json:"nextOffset,omitempty"` + NextCursor string `json:"nextCursor,omitempty"` } diff --git a/internal/client/command/game.go b/internal/client/command/game.go index 7822fd7..200ef80 100644 --- a/internal/client/command/game.go +++ b/internal/client/command/game.go @@ -33,7 +33,7 @@ func (r *Registry) registerGameCommands() { Name: "move", ShortName: "m", Description: "Make a move", - Usage: "move ", + Usage: "move (e2e4; castling e1g1; promotion e7e8q, e7e8n)", Handler: moveHandler, }) @@ -77,6 +77,38 @@ func (r *Registry) registerGameCommands() { 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{ Name: "poll", 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. func moveHandler(s *session.Session, args []string) error { if len(args) < 1 { - return fmt.Errorf("usage: move ") + return fmt.Errorf("usage: move (promotion appends q, r, b, or n: e7e8q)") } gameID := s.CurrentGame @@ -345,21 +377,101 @@ func computerMoveHandler(s *session.Session, args []string) error { return nil } -// printOutcome reports terminal or error states using the server's actual -// State.String() values ("white wins"/"black wins", not "checkmate"). +// printOutcome reports terminal or error states using the server's State +// ("white wins", "draw", ...) and Termination ("checkmate", "resignation", +// "threefold_repetition", ...) values, and a pending draw offer. func printOutcome(resp *api.GameResponse) { switch resp.State { - case "white wins": - display.Println(display.Green, "\nCHECKMATE! White wins!") - case "black wins": - display.Println(display.Green, "\nCHECKMATE! Black wins!") + case "white wins", "black wins": + winner := "White" + if resp.State == "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": display.Println(display.Yellow, "\nSTALEMATE! Game drawn.") case "draw": - display.Println(display.Yellow, "\nDRAW! Game drawn.") + display.Println(display.Yellow, "\nDRAW %s.", drawReason(resp.Termination)) case "stuck": 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 '") + } + 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 '") + } + 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 { @@ -530,3 +642,72 @@ func pollHandler(s *session.Session, args []string) error { 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 ' or 'pgn '") + } + text, err := s.Client.GetGamePGN(gameID, ply) + if err != nil { + return err + } + fmt.Println() + fmt.Print(text) + return nil +} diff --git a/internal/client/command/registry.go b/internal/client/command/registry.go index 59d5aa3..d030114 100644 --- a/internal/client/command/registry.go +++ b/internal/client/command/registry.go @@ -126,6 +126,10 @@ func (r *Registry) helpHandler(s *session.Session, args []string) error { {"state", "s", ""}, {"delete", "d", ""}, {"poll", "p", ""}, + {"resign", "", ""}, + {"draw", "", ""}, + {"games", "g", ""}, + {"pgn", "", ""}, } authCommands := []cmdInfo{ diff --git a/internal/client/session/session.go b/internal/client/session/session.go index e385647..93492c3 100644 --- a/internal/client/session/session.go +++ b/internal/client/session/session.go @@ -12,6 +12,7 @@ type Session struct { AuthToken string Username string LastMoveCount int + GamesCursor string // next page of the "games" listing Client *api.Client Verbose bool // Game state for prompt diff --git a/internal/server/board/board.go b/internal/server/board/board.go deleted file mode 100644 index 5a112e2..0000000 --- a/internal/server/board/board.go +++ /dev/null @@ -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] -} diff --git a/internal/server/chess/chess_test.go b/internal/server/chess/chess_test.go new file mode 100644 index 0000000..b0d01c3 --- /dev/null +++ b/internal/server/chess/chess_test.go @@ -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) + } +} diff --git a/internal/server/chess/fuzz_test.go b/internal/server/chess/fuzz_test.go new file mode 100644 index 0000000..6d17aee --- /dev/null +++ b/internal/server/chess/fuzz_test.go @@ -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) + } + }) +} diff --git a/internal/server/chess/movegen.go b/internal/server/chess/movegen.go new file mode 100644 index 0000000..595eda3 --- /dev/null +++ b/internal/server/chess/movegen.go @@ -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 +} diff --git a/internal/server/chess/pgn.go b/internal/server/chess/pgn.go new file mode 100644 index 0000000..a8f33be --- /dev/null +++ b/internal/server/chess/pgn.go @@ -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 "*" +} diff --git a/internal/server/chess/position.go b/internal/server/chess/position.go new file mode 100644 index 0000000..11f1da6 --- /dev/null +++ b/internal/server/chess/position.go @@ -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() +} diff --git a/internal/server/chess/san.go b/internal/server/chess/san.go new file mode 100644 index 0000000..c356da1 --- /dev/null +++ b/internal/server/chess/san.go @@ -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 +} diff --git a/internal/server/chess/status.go b/internal/server/chess/status.go new file mode 100644 index 0000000..354b439 --- /dev/null +++ b/internal/server/chess/status.go @@ -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]) } diff --git a/internal/server/chess/stockfish_test.go b/internal/server/chess/stockfish_test.go new file mode 100644 index 0000000..eaee238 --- /dev/null +++ b/internal/server/chess/stockfish_test.go @@ -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) +} diff --git a/internal/server/core/api.go b/internal/server/core/api.go index 4f21aaa..d5e1877 100644 --- a/internal/server/core/api.go +++ b/internal/server/core/api.go @@ -19,20 +19,40 @@ type MoveRequest struct { 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 { Count int `json:"count" validate:"required,min=1,max=300"` // Max based on longest games in history (272), theoretical max 5949 } // 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 { - GameID string `json:"gameId"` - FEN string `json:"fen"` - Turn string `json:"turn"` // "w" or "b" - State string `json:"state"` // "ongoing", "white wins", etc - Moves []string `json:"moves"` - Players PlayersResponse `json:"players"` - LastMove *MoveInfo `json:"lastMove,omitempty"` + GameID string `json:"gameId"` + FEN string `json:"fen"` + Turn string `json:"turn"` // "w" or "b" + 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"` + Players PlayersResponse `json:"players"` + LastMove *MoveInfo `json:"lastMove,omitempty"` } type MoveInfo struct { @@ -50,19 +70,28 @@ type BoardResponse struct { // GameHistoryResponse is the durable replay representation of a game. Moves // are ordered and include the resulting FEN so clients do not need an engine // 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 { GameID string `json:"gameId"` InitialFEN string `json:"initialFen"` Result string `json:"result,omitempty"` + PGNResult string `json:"pgnResult"` + Termination string `json:"termination,omitempty"` StartTimeUTC time.Time `json:"startTimeUtc"` EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"` Players PlayersResponse `json:"players"` 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 { MoveNumber int `json:"moveNumber"` MoveUCI string `json:"moveUci"` + SAN string `json:"san,omitempty"` FENAfterMove string `json:"fenAfterMove"` PlayerColor string `json:"playerColor"` MoveTimeUTC time.Time `json:"moveTimeUtc"` @@ -76,17 +105,22 @@ type GameSummary struct { InitialFEN string `json:"initialFen"` FinalFEN string `json:"finalFen"` Result string `json:"result,omitempty"` + PGNResult string `json:"pgnResult"` StartTimeUTC time.Time `json:"startTimeUtc"` EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"` MoveCount int `json:"moveCount"` 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 { Games []GameSummary `json:"games"` Limit int `json:"limit"` Offset int `json:"offset"` NextOffset *int `json:"nextOffset,omitempty"` + NextCursor string `json:"nextCursor,omitempty"` } type ErrorResponse struct { diff --git a/internal/server/core/state.go b/internal/server/core/state.go index 90f8e9f..748a2a3 100644 --- a/internal/server/core/state.go +++ b/internal/server/core/state.go @@ -59,3 +59,45 @@ func (s State) Result() (string, bool) { 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 +} diff --git a/internal/server/game/game.go b/internal/server/game/game.go index 499117b..7a960cf 100644 --- a/internal/server/game/game.go +++ b/internal/server/game/game.go @@ -4,7 +4,7 @@ import ( "fmt" "time" - "chess/internal/server/board" + "chess/internal/server/chess" "chess/internal/server/core" ) @@ -29,9 +29,13 @@ type Game struct { snapshots []Snapshot players map[core.Color]*core.Player state core.State + termination core.Termination lastResult *MoveResult endTimeUTC *time.Time 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. @@ -42,11 +46,16 @@ type View struct { InitialFEN string NextTurnColor core.Color Moves []string - WhitePlayer *core.Player - BlackPlayer *core.Player - State core.State - LastResult *MoveResult - EndTimeUTC *time.Time + // FENs holds the position before the first move and after every move. + FENs []string + WhitePlayer *core.Player + BlackPlayer *core.Player + 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 + EndTimeUTC *time.Time } func New(initialFEN string, whitePlayer, blackPlayer *core.Player, startingTurnColor core.Color) *Game { @@ -77,7 +86,8 @@ func New(initialFEN string, whitePlayer, blackPlayer *core.Player, startingTurnC core.ColorWhite: &whiteCopy, 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(), NextTurnColor: g.NextTurnColor(), Moves: g.Moves(), + FENs: make([]string, len(g.snapshots)), 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 { copy := *player @@ -156,7 +176,13 @@ func (g *Game) GetPlayer(color core.Color) *core.Player { 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) { + if g.drawOffer == nextTurnColor { + g.drawOffer = 0 + } // Get the player ID for the next turn nextPlayer := g.players[nextTurnColor] 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.state = core.StateOngoing // Reset game state when undoing - g.lastResult = nil // Clear last result + g.termination = core.TermNone + g.lastResult = nil // Clear last result g.endTimeUTC = nil + g.ClearDrawOffers() 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 { moves := []string{} for i := 1; i < len(g.snapshots); i++ { @@ -214,6 +287,18 @@ func (g *Game) State() core.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) { g.Touch(at) if s.IsTerminal() { @@ -261,7 +346,7 @@ func (g *Game) InitialFEN() string { if len(g.snapshots) > 0 { return g.snapshots[0].FEN } - return board.StartingFEN + return chess.StartFEN } // ClaimSlot claims a player slot for a user diff --git a/internal/server/http/handler.go b/internal/server/http/handler.go index 26dc564..b255134 100644 --- a/internal/server/http/handler.go +++ b/internal/server/http/handler.go @@ -1,9 +1,12 @@ package http import ( + "crypto/sha256" + "encoding/hex" "errors" "fmt" "strconv" + "strings" "time" "chess/internal/server/core" @@ -166,11 +169,14 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, opts Options) // Register game routes with auth middleware api.Post("/games", OptionalAuth(validateToken), h.CreateGame) // Optional auth for player ID association api.Get("/games/:gameId/history", h.GetGameHistory) + api.Get("/games/:gameId/pgn", h.GetGamePGN) api.Put("/games/:gameId/players", h.ConfigurePlayers) api.Get("/games/:gameId", h.GetGame) api.Delete("/games/:gameId", h.DeleteGame) api.Post("/games/:gameId/moves", OptionalAuth(validateToken), h.MakeMove) 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("/users/me/games", AuthRequired(validateToken), h.GetCurrentUserGames) @@ -508,6 +514,58 @@ func (h *HTTPHandler) UndoMove(c *fiber.Ctx) error { 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. func (h *HTTPHandler) DeleteGame(c *fiber.Ctx) error { gameID := c.Params("gameId") @@ -563,34 +621,105 @@ func (h *HTTPHandler) GetBoard(c *fiber.Ctx) error { func (h *HTTPHandler) GetGameHistory(c *fiber.Ctx) error { gameID := c.Params("gameId") if !isValidUUID(gameID) { - return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{ - Error: "invalid game ID format", Code: core.ErrInvalidRequest, - Details: "game ID must be a valid UUID", - }) + return invalidGameID(c) } history, err := h.svc.GetGameHistory(gameID) if err != nil { - switch { - case errors.Is(err, service.ErrStorageDisabled), errors.Is(err, service.ErrStorageUnavailable): - return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{ - Error: "game history storage unavailable", Code: core.ErrStorageUnavailable, - }) - case errors.Is(err, service.ErrGameNotFound): - return c.Status(fiber.StatusNotFound).JSON(core.ErrorResponse{ - Error: "game history not found", Code: core.ErrGameNotFound, - }) - default: - return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{ - Error: "failed to load game history", Code: core.ErrInternalError, - }) + 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 c.JSON(history) + return false +} + +func invalidGameID(c *fiber.Ctx) error { + return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{ + Error: "invalid game ID format", Code: core.ErrInvalidRequest, + Details: "game ID must be a valid UUID", + }) +} + +func storedGameError(c *fiber.Ctx, err error) error { + switch { + case errors.Is(err, service.ErrStorageDisabled), errors.Is(err, service.ErrStorageUnavailable): + return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{ + Error: "game history storage unavailable", Code: core.ErrStorageUnavailable, + }) + case errors.Is(err, service.ErrGameNotFound): + return c.Status(fiber.StatusNotFound).JSON(core.ErrorResponse{ + 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: + return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{ + Error: "failed to load game history", Code: core.ErrInternalError, + }) + } } // 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 { userID, ok := c.Locals("userID").(string) 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 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{ Error: "stored games unavailable", Code: core.ErrStorageUnavailable, }) diff --git a/internal/server/http/handler_test.go b/internal/server/http/handler_test.go index 14f02f3..26f70ab 100644 --- a/internal/server/http/handler_test.go +++ b/internal/server/http/handler_test.go @@ -2,13 +2,20 @@ package http import ( "io" + nethttp "net/http" "net/http/httptest" "strings" "testing" + "time" + "chess/internal/server/chess" + "chess/internal/server/core" "chess/internal/server/service" + "chess/internal/server/storage" + "chess/internal/server/storage/pgtest" "github.com/gofiber/fiber/v2" + "github.com/google/uuid" ) 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")) if err != nil { t.Fatal(err) @@ -74,9 +81,6 @@ func TestAPIRoutesAreUnversioned(t *testing.T) { registered := make(map[string]bool) for _, route := range app.GetRoutes(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{ "GET /health", @@ -87,19 +91,124 @@ func TestAPIRoutesAreUnversioned(t *testing.T) { "POST /api/games", "GET /api/games/:gameId", "GET /api/games/:gameId/history", + "GET /api/games/:gameId/pgn", "POST /api/games/:gameId/moves", + "POST /api/games/:gameId/resign", + "POST /api/games/:gameId/draw", "GET /api/users/me/games", } { if !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 { t.Fatal(err) } - if response.StatusCode != fiber.StatusNotFound { - t.Fatalf("GET /api/v1/... status = %d, want 404", response.StatusCode) + if err := store.InitDB(); err != nil { + 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) + } } } diff --git a/internal/server/http/validator.go b/internal/server/http/validator.go index 1bc618c..263287e 100644 --- a/internal/server/http/validator.go +++ b/internal/server/http/validator.go @@ -36,6 +36,10 @@ func validationMiddleware(c *fiber.Ctx) error { requestType = &core.MoveRequest{} case strings.HasSuffix(path, "/undo") && method == fiber.MethodPost: 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: return c.Next() // No validation for unknown endpoints } diff --git a/internal/server/processor/command.go b/internal/server/processor/command.go index 966b57f..c714a84 100644 --- a/internal/server/processor/command.go +++ b/internal/server/processor/command.go @@ -15,6 +15,8 @@ const ( CmdMakeMove CmdUndoMove CmdGetBoard + CmdResign + CmdDraw ) // Command is a unified structure for all processor operations @@ -84,3 +86,19 @@ func NewGetBoardCommand(gameID string) Command { 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, + } +} diff --git a/internal/server/processor/processor.go b/internal/server/processor/processor.go index 5e191b5..a55e3d7 100644 --- a/internal/server/processor/processor.go +++ b/internal/server/processor/processor.go @@ -10,7 +10,7 @@ import ( "time" "unicode" - "chess/internal/server/board" + "chess/internal/server/chess" "chess/internal/server/core" "chess/internal/server/engine" "chess/internal/server/game" @@ -19,6 +19,14 @@ import ( const ( 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 @@ -63,6 +71,10 @@ func (p *Processor) Execute(cmd Command) ProcessorResponse { return p.handleDeleteGame(cmd) case CmdGetBoard: return p.handleGetBoard(cmd) + case CmdResign: + return p.handleResign(cmd) + case CmdDraw: + return p.handleDraw(cmd) default: return p.errorResponse("unknown command", core.ErrInvalidRequest) } @@ -144,28 +156,38 @@ func (p *Processor) handleCreateGame(cmd Command) ProcessorResponse { gameID := p.svc.GenerateGameID() // Validate FEN safety, then classify via engine - initialFEN := board.StartingFEN + initialFEN := chess.StartFEN if args.FEN != "" { if !p.isFENSafe(args.FEN) { 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() err := p.validationEng.NewGame() var validatedFEN string - initialState := core.StateOngoing + initialState, termination := core.StateOngoing, core.TermNone if err == nil { - validatedFEN, initialState, err = p.classifyLocked(initialFEN) + validatedFEN, initialState, termination, err = p.classifyLocked(initialFEN) } p.mu.Unlock() if err != nil { 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 - b, err := board.ParseFEN(validatedFEN) + start, err := chess.ParseFEN(validatedFEN) if err != nil { 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) } @@ -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) } 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) } @@ -351,27 +374,38 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse { 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 p.mu.Lock() err = p.validationEng.SetPosition(currentFEN, []string{move}) var newFEN string - finalState := core.StateOngoing + finalState, termination := core.StateOngoing, core.TermNone if err == nil { - newFEN, finalState, err = p.classifyCurrentLocked() + newFEN, finalState, termination, err = p.classifyCurrentLocked() } p.mu.Unlock() if err != nil { // Game untouched at pre-move position; retry runs on a respawned engine return p.errorResponse("engine unavailable", core.ErrInternalError) } + rules.compare(cmd.GameID, move, newFEN != currentFEN, newFEN) if newFEN == currentFEN { return p.errorResponse("illegal move", core.ErrInvalidMove) } + if finalState == core.StateOngoing { + finalState, termination = adjudicateDraw(g.FENs, newFEN) + } // Atomic commit: move + state + metadata, single notification if err = p.svc.ApplyMoveWithState(cmd.GameID, service.MoveCommit{ 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}, }); err != nil { 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) } - b, err := board.ParseFEN(g.FEN) + pos, err := chess.ParseFEN(g.FEN) if err != nil { return p.errorResponse("error parsing FEN", core.ErrInvalidFEN) } - ascii := b.ToASCII() + ascii := pos.ASCII() return ProcessorResponse{ Success: true, @@ -486,38 +520,42 @@ func (p *Processor) triggerComputerMove(gameID string, g game.View) error { } if result.Error != nil { 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 } if result.Move == "" || result.Move == "(none)" { // Worker says no legal moves; verify against the validation engine. p.mu.Lock() - _, state, cerr := p.classifyLocked(fen) + _, state, termination, cerr := p.classifyLocked(fen) p.mu.Unlock() 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 } - p.svc.UpdateGameState(gameID, state) + p.svc.UpdateGameState(gameID, state, termination) return } p.mu.Lock() aerr := p.validationEng.SetPosition(fen, []string{result.Move}) var newFEN string - finalState := core.StateOngoing + finalState, termination := core.StateOngoing, core.TermNone if aerr == nil { - newFEN, finalState, aerr = p.classifyCurrentLocked() + newFEN, finalState, termination, aerr = p.classifyCurrentLocked() } p.mu.Unlock() if aerr != nil || newFEN == fen { - p.svc.UpdateGameState(gameID, core.StateStuck) + p.svc.UpdateGameState(gameID, core.StateStuck, core.TermNone) 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{ 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{ Move: result.Move, PlayerColor: color, Score: result.Score, Depth: result.Depth, GameState: finalState, @@ -525,60 +563,106 @@ func (p *Processor) triggerComputerMove(gameID string, g game.View) error { }); err != nil { slog.Error("failed to apply computer move", "game_id", gameID, "error", err) 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 -// validation engine. Caller holds p.mu, immediately after a SetPosition. -func (p *Processor) classifyCurrentLocked() (fen string, state core.State, err error) { +// validation engine: mate and stalemate come from the engine. Caller holds +// 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() if err != nil { - return "", core.StateOngoing, err + return "", core.StateOngoing, core.TermNone, err } legal, err := p.validationEng.HasLegalMoves() if err != nil { - return "", core.StateOngoing, err + return "", core.StateOngoing, core.TermNone, err } if legal { - return diag.FEN, core.StateOngoing, nil + return diag.FEN, core.StateOngoing, core.TermNone, nil } 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 { - return "", core.StateOngoing, err + return "", core.StateOngoing, core.TermNone, err } - if b.Turn() == core.ColorWhite { - return diag.FEN, core.StateBlackWins, nil + if pos.Turn() == chess.White { + 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. -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 { - return "", core.StateOngoing, err + return "", core.StateOngoing, core.TermNone, err } 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 func (p *Processor) buildGameResponse(gameID string, g game.View) core.GameResponse { resp := core.GameResponse{ - GameID: gameID, - FEN: g.FEN, - Turn: g.NextTurnColor.String(), - State: g.State.String(), - Moves: g.Moves, + GameID: gameID, + FEN: g.FEN, + Turn: g.NextTurnColor.String(), + State: g.State.String(), + Termination: string(g.Termination), + Moves: g.Moves, Players: core.PlayersResponse{ White: g.WhitePlayer, Black: g.BlackPlayer, }, } + if g.DrawOffer != 0 { + resp.DrawOffer = g.DrawOffer.String() + } // Include last move if available if result := g.LastResult; result != nil { @@ -612,3 +696,267 @@ func (p *Processor) Close() error { p.mu.Unlock() 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) +} diff --git a/internal/server/processor/rules_test.go b/internal/server/processor/rules_test.go new file mode 100644 index 0000000..058ba00 --- /dev/null +++ b/internal/server/processor/rules_test.go @@ -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) + } + } +} diff --git a/internal/server/replay/replay.go b/internal/server/replay/replay.go new file mode 100644 index 0000000..6772ae0 --- /dev/null +++ b/internal/server/replay/replay.go @@ -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" +} diff --git a/internal/server/replay/replay_test.go b/internal/server/replay/replay_test.go new file mode 100644 index 0000000..5ba1da9 --- /dev/null +++ b/internal/server/replay/replay_test.go @@ -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") + } +} diff --git a/internal/server/replay/verify.go b/internal/server/replay/verify.go new file mode 100644 index 0000000..3e1bb85 --- /dev/null +++ b/internal/server/replay/verify.go @@ -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 +} diff --git a/internal/server/service/end_test.go b/internal/server/service/end_test.go new file mode 100644 index 0000000..70d9d95 --- /dev/null +++ b/internal/server/service/end_test.go @@ -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) + } +} diff --git a/internal/server/service/game.go b/internal/server/service/game.go index 8072512..23eac24 100644 --- a/internal/server/service/game.go +++ b/internal/server/service/game.go @@ -17,6 +17,10 @@ var ( ErrGameNotFound = errors.New("game not found") ErrGameChanged = errors.New("game changed while move was being validated") 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 { @@ -27,10 +31,24 @@ type MoveCommit struct { MoveUCI string NewFEN string State core.State + Termination core.Termination // how State was reached when terminal Result *game.MoveResult 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 func (s *Service) CreateGame( id string, @@ -38,6 +56,7 @@ func (s *Service) CreateGame( initialFEN string, startingTurn core.Color, initialState core.State, + termination core.Termination, ) error { s.mu.Lock() defer s.mu.Unlock() @@ -48,16 +67,15 @@ func (s *Service) CreateGame( // Check computer game limit hasComputer := whitePlayer.Type == core.PlayerComputer || blackPlayer.Type == core.PlayerComputer - if hasComputer { - if s.computerGames.Load() >= MaxComputerGames { - return fmt.Errorf("computer game limit reached (%d/%d)", s.computerGames.Load(), MaxComputerGames) + if hasComputer && !initialState.IsTerminal() { + if active := s.activeComputerGamesLocked(); active >= MaxComputerGames { + return fmt.Errorf("computer game limit reached (%d/%d)", active, MaxComputerGames) } - s.computerGames.Add(1) } now := time.Now().UTC() g := game.New(initialFEN, whitePlayer, blackPlayer, startingTurn) - g.SetStateAt(initialState, now) + g.SetOutcome(initialState, termination, now) s.games[id] = g // Persist if storage enabled @@ -77,6 +95,7 @@ func (s *Service) CreateGame( BlackSearchTime: blackPlayer.SearchTime, BlackClaimedBy: blackPlayer.ClaimedBy, Result: result, + Termination: string(g.Termination()), StartTimeUTC: now, EndTimeUTC: g.EndTimeUTC(), } @@ -106,8 +125,10 @@ func (s *Service) UpdatePlayers(gameID string, whitePlayer, blackPlayer *core.Pl oldBlack := g.GetPlayer(core.ColorBlack) oldHasComputer := g.HasComputerPlayer() newHasComputer := whitePlayer.Type == core.PlayerComputer || blackPlayer.Type == core.PlayerComputer - if !oldHasComputer && newHasComputer && s.computerGames.Load() >= MaxComputerGames { - return fmt.Errorf("computer game limit reached (%d/%d)", s.computerGames.Load(), MaxComputerGames) + if !oldHasComputer && newHasComputer && !g.State().IsTerminal() { + 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. @@ -127,14 +148,8 @@ func (s *Service) UpdatePlayers(gameID string, whitePlayer, blackPlayer *core.Pl } g.UpdatePlayers(whitePlayer, blackPlayer) + g.ClearDrawOffers() // an offer made to the previous opponent lapses g.Touch(time.Now().UTC()) - if oldHasComputer != newHasComputer { - if newHasComputer { - s.computerGames.Add(1) - } else { - s.computerGames.Add(-1) - } - } if s.store != nil { err := s.store.RecordPlayers(gameID, playerRecord(whitePlayer), playerRecord(blackPlayer)) if err != nil { @@ -239,8 +254,11 @@ func (s *Service) ApplyMoveWithState(gameID string, commit MoveCommit) error { 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.SetStateAt(commit.State, at) + g.SetOutcome(commit.State, commit.Termination, at) if commit.Result != nil { g.SetLastResult(commit.Result) } @@ -252,10 +270,11 @@ func (s *Service) ApplyMoveWithState(gameID string, commit MoveCommit) error { GameID: gameID, MoveNumber: len(g.Moves()), MoveUCI: commit.MoveUCI, FENAfterMove: commit.NewFEN, PlayerColor: currentTurn.String(), MoveTimeUTC: at, }, - ClaimColor: currentTurn.String(), - ClaimedBy: claimUserID, - Result: result, - EndTimeUTC: g.EndTimeUTC(), + ClaimColor: currentTurn.String(), + ClaimedBy: claimUserID, + Result: result, + Termination: string(g.Termination()), + EndTimeUTC: g.EndTimeUTC(), } if claimUserID == "" { persistence.ClaimColor = "" @@ -277,8 +296,9 @@ func (s *Service) ApplyMoveWithState(gameID string, commit MoveCommit) error { return nil } -// UpdateGameState sets the game's end state (checkmate, stalemate, etc) -func (s *Service) UpdateGameState(gameID string, state core.State) error { +// UpdateGameState sets an operational state (pending, stuck) or a terminal +// 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() defer s.mu.Unlock() @@ -286,13 +306,18 @@ func (s *Service) UpdateGameState(gameID string, state core.State) error { if !ok { 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() now := time.Now().UTC() - g.SetStateAt(state, now) + g.SetOutcome(state, termination, now) if s.store != nil && state.IsTerminal() && !previousState.IsTerminal() { 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) } } @@ -303,6 +328,168 @@ func (s *Service) UpdateGameState(gameID string, state core.State) error { 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 func (s *Service) UndoMoves(gameID string, count int) error { 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") } - // Decrement computer game count if applicable - if g.HasComputerPlayer() { - s.computerGames.Add(-1) - } - // Remove from wait registry s.waiter.RemoveGame(gameID) diff --git a/internal/server/service/game_test.go b/internal/server/service/game_test.go index 3f9167e..3b4d942 100644 --- a/internal/server/service/game_test.go +++ b/internal/server/service/game_test.go @@ -19,7 +19,7 @@ func TestMoveCommitClaimsSlotPersistsResultAndRejectsStalePosition(t *testing.T) 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, "initial", core.ColorWhite, core.StateOngoing, + gameID, white, black, "initial", core.ColorWhite, core.StateOngoing, core.TermNone, ); err != nil { t.Fatal(err) } @@ -27,7 +27,7 @@ func TestMoveCommitClaimsSlotPersistsResultAndRejectsStalePosition(t *testing.T) ended := time.Date(2026, 9, 7, 2, 3, 4, 0, time.UTC) commit := MoveCommit{ 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}, } 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) black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack) if err := svc.CreateGame( - gameID, white, black, "initial", core.ColorWhite, core.StateOngoing, + gameID, white, black, "initial", core.ColorWhite, core.StateOngoing, core.TermNone, ); err != nil { t.Fatal(err) } if err := svc.ApplyMoveWithState(gameID, MoveCommit{ 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 { t.Fatal(err) } @@ -91,7 +91,7 @@ func TestPlayerReconfigurationPreservesClaimAndPersistsConfiguration(t *testing. white.ClaimedBy = userID black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack) if err := svc.CreateGame( - gameID, white, black, "initial", core.ColorWhite, core.StateOngoing, + gameID, white, black, "initial", core.ColorWhite, core.StateOngoing, core.TermNone, ); err != nil { t.Fatal(err) } @@ -137,7 +137,7 @@ func TestCleanupEvictsOnlyMemoryCopyOfTerminalGame(t *testing.T) { 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, "terminal", core.ColorWhite, core.StateStalemate, + gameID, white, black, "terminal", core.ColorWhite, core.StateStalemate, core.TermStalemate, ); err != nil { t.Fatal(err) } @@ -166,7 +166,7 @@ func TestCleanupRemovesIdleAnonymousGamesButKeepsClaimedOnes(t *testing.T) { white.ID, white.ClaimedBy = claimant, claimant } 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) } } @@ -202,7 +202,16 @@ func TestCleanupRemovesIdleAnonymousGamesButKeepsClaimedOnes(t *testing.T) { func newPersistentTestService(t *testing.T) *Service { 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 { t.Fatal(err) } @@ -218,5 +227,5 @@ func newPersistentTestService(t *testing.T) *Service { t.Errorf("shutdown: %v", err) } }) - return svc + return svc, dsn } diff --git a/internal/server/service/history.go b/internal/server/service/history.go index 75284b6..38e5a3c 100644 --- a/internal/server/service/history.go +++ b/internal/server/service/history.go @@ -2,43 +2,59 @@ package service import ( "context" + "encoding/base64" "errors" "fmt" + "log/slog" + "strconv" + "strings" + "time" + "chess/internal/server/chess" "chess/internal/server/core" + "chess/internal/server/replay" "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 -// 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) { - if s.store == nil { - return nil, ErrStorageDisabled - } - record, moves, err := s.store.GetGameHistory(gameID) + record, moves, err := s.loadHistory(gameID) if err != nil { - if storage.IsGameNotFound(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) + return nil, err } history := &core.GameHistoryResponse{ GameID: record.GameID, InitialFEN: record.InitialFEN, Result: record.Result, + PGNResult: chess.ResultToken(record.Result), + Termination: record.Termination, StartTimeUTC: record.StartTimeUTC, EndTimeUTC: record.EndTimeUTC, Players: playersResponse(*record), 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{ MoveNumber: move.MoveNumber, MoveUCI: move.MoveUCI, + SAN: san[i], FENAfterMove: move.FENAfterMove, PlayerColor: move.PlayerColor, MoveTimeUTC: move.MoveTimeUTC, @@ -47,13 +63,79 @@ func (s *Service) GetGameHistory(gameID string) (*core.GameHistoryResponse, erro 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 // 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 { 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 isStorageUnavailable(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) } - hasNext := len(records) > limit + hasNext := len(records) > opts.Limit if hasNext { - records = records[:limit] + records = records[:opts.Limit] } response := &core.GameListResponse{ Games: make([]core.GameSummary, 0, len(records)), - Limit: limit, - Offset: offset, + Limit: opts.Limit, + Offset: opts.Offset, } if hasNext { - next := offset + limit - response.NextOffset = &next + last := records[len(records)-1] + response.NextCursor = encodeCursor(last.StartTimeUTC, last.GameID) + if opts.Cursor == "" { + next := opts.Offset + opts.Limit + response.NextOffset = &next + } } for _, record := range records { response.Games = append(response.Games, core.GameSummary{ GameID: record.GameID, InitialFEN: record.InitialFEN, Result: record.Result, + PGNResult: chess.ResultToken(record.Result), StartTimeUTC: record.StartTimeUTC, EndTimeUTC: record.EndTimeUTC, MoveCount: record.MoveCount, @@ -89,6 +176,32 @@ func (s *Service) GetUserGames(userID string, limit, offset int) (*core.GameList return response, nil } +// Cursors are opaque to clients: base64url of ".". 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 { return errors.Is(err, storage.ErrStorageDegraded) || errors.Is(err, storage.ErrStoreClosed) || diff --git a/internal/server/service/history_test.go b/internal/server/service/history_test.go new file mode 100644 index 0000000..1e0fbd4 --- /dev/null +++ b/internal/server/service/history_test.go @@ -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) + } + } +} diff --git a/internal/server/service/integrity.go b/internal/server/service/integrity.go new file mode 100644 index 0000000..7eec504 --- /dev/null +++ b/internal/server/service/integrity.go @@ -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) + } +} diff --git a/internal/server/service/integrity_test.go b/internal/server/service/integrity_test.go new file mode 100644 index 0000000..6cb2daa --- /dev/null +++ b/internal/server/service/integrity_test.go @@ -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") + } +} diff --git a/internal/server/service/service.go b/internal/server/service/service.go index f3fea23..fcebcc0 100644 --- a/internal/server/service/service.go +++ b/internal/server/service/service.go @@ -29,16 +29,18 @@ const ( // Service coordinates game state, user management, and storage type Service struct { - games map[string]*game.Game - mu sync.RWMutex - store *storage.Store - jwt *auth.JWT - kdf chan struct{} // Argon2id concurrency slots - waiter *WaitRegistry - computerGames atomic.Int32 // Active games with computer players - finishedTTL time.Duration - anonymousTTL time.Duration - maxUsers atomic.Int64 + games map[string]*game.Game + mu sync.RWMutex + store *storage.Store + jwt *auth.JWT + kdf chan struct{} // Argon2id concurrency slots + waiter *WaitRegistry + finishedTTL time.Duration + anonymousTTL time.Duration + 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 @@ -64,6 +66,9 @@ func New(store *storage.Store, jwtSecret []byte) (*Service, error) { anonymousTTL: AnonymousGameTTL, } s.maxUsers.Store(DefaultMaxUsers) + if store != nil { + store.SetGameMissingHandler(s.evictMissingGame) + } 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) } -// 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 { - return s.computerGames.Load() < MaxComputerGames + return s.GetComputerGameCount() < MaxComputerGames } -// GetComputerGameCount returns current computer game count -func (s *Service) GetComputerGameCount() int32 { - return s.computerGames.Load() +// GetComputerGameCount returns the number of unfinished loaded games with a +// computer player. Finished games stay loaded for a while but need no engine, +// 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 @@ -146,8 +167,9 @@ func (s *Service) Shutdown(timeout time.Duration) error { return errors.Join(errs...) } -// RunCleanupJob periodically removes expired sessions, unloads idle games, and -// deletes anonymous games past their retention. +// RunCleanupJob periodically removes expired sessions, unloads idle games, +// deletes anonymous games past their retention, and runs the integrity sweep +// when enabled. func (s *Service) RunCleanupJob(ctx context.Context, interval time.Duration) { s.cleanupExpired() ticker := time.NewTicker(interval) @@ -173,6 +195,7 @@ func (s *Service) cleanupExpired() { } } s.cleanupGames(now) + s.checkIntegrity(now) } // 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) continue } - if g.HasComputerPlayer() { - s.computerGames.Add(-1) - } delete(s.games, gameID) } s.mu.Unlock() diff --git a/internal/server/service/waiter.go b/internal/server/service/waiter.go index cd57403..4ce8884 100644 --- a/internal/server/service/waiter.go +++ b/internal/server/service/waiter.go @@ -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) func (w *WaitRegistry) RemoveGame(gameID string) { w.mu.RLock() diff --git a/internal/server/storage/game.go b/internal/server/storage/game.go index 02732a2..d351e7f 100644 --- a/internal/server/storage/game.go +++ b/internal/server/storage/game.go @@ -8,13 +8,15 @@ import ( "log/slog" "strings" "time" + + "github.com/jackc/pgx/v5/pgconn" ) const gameSelectColumns = ` g.game_id, g.initial_fen, g.white_player_id, g.white_type, g.white_level, g.white_search_time, g.white_claimed_by, g.white_name, g.black_player_id, g.black_type, g.black_level, g.black_search_time, g.black_claimed_by, g.black_name, - g.result, g.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 // 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 == "" { 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 } @@ -42,9 +44,9 @@ func (s *Store) RecordNewGame(record GameRecord) error { game_id, initial_fen, 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, - 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") + `, - $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, record.GameID, record.InitialFEN, @@ -52,7 +54,8 @@ func (s *Store) RecordNewGame(record GameRecord) error { nullableString(record.WhiteClaimedBy), record.BlackPlayerID, record.BlackType, record.BlackLevel, record.BlackSearchTime, nullableString(record.BlackClaimedBy), - record.StartTimeUTC, nullableString(record.Result), record.EndTimeUTC, + record.StartTimeUTC, nullableString(record.Result), nullableString(record.Termination), + record.EndTimeUTC, ) return err }) @@ -74,7 +77,7 @@ func (s *Store) RecordMove(record MovePersistence) error { if (record.ClaimColor == "") != (record.ClaimedBy == "") { 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 } @@ -90,65 +93,88 @@ func (s *Store) RecordMove(record MovePersistence) error { record.Move.PlayerColor, record.Move.MoveTimeUTC, ); err != nil { - return err + return gameMissingOr(err, record.Move.GameID) } if record.ClaimedBy == "" && record.Result == "" { return nil } - // One UPDATE applies the optional claim and optional result. A claim may - // only fill an empty slot or repeat the same claimant. - claimColumn, nameColumn := "white_claimed_by", "white_name" - if record.ClaimColor == "b" { - claimColumn, nameColumn = "black_claimed_by", "black_name" - } - var set []string - args := []any{record.Move.GameID} - where := "game_id = $1" - if record.ClaimedBy != "" { - args = append(args, record.ClaimedBy) - param := fmt.Sprintf("$%d", len(args)) - set = append(set, fmt.Sprintf("%s = %s, %s = COALESCE(%s, %s)", - claimColumn, param, nameColumn, nameColumn, claimantName(param))) - where += fmt.Sprintf(" AND (%s IS NULL OR %s = %s)", claimColumn, claimColumn, param) - } - if record.Result != "" { - args = append(args, record.Result, record.EndTimeUTC) - set = append(set, fmt.Sprintf("result = $%d, end_time_utc = $%d", len(args)-1, len(args))) - } - result, err := tx.ExecContext(ctx, - "UPDATE games SET "+strings.Join(set, ", ")+" WHERE "+where, args...) - if err != nil { - return err - } - return requireOneGame(result, record.Move.GameID) + return updateGameEnd(ctx, tx, record.Move.GameID, record.ClaimColor, record.ClaimedBy, + record.Result, record.Termination, record.EndTimeUTC) }) } -// 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 == "" { +// 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 !isValidResult(result) { - return fmt.Errorf("invalid game result %q", result) + if end.EndTimeUTC.IsZero() { + return errors.New("game end time is required") } - if at.IsZero() { - return errors.New("game result time is required") + ended := end.EndTimeUTC.UTC() + if err := validateOutcome(end.Result, end.Termination, &ended); err != nil { + return err } - 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) + 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" + if claimColor == "b" { + claimColumn, nameColumn = "black_claimed_by", "black_name" + } + var set []string + args := []any{gameID} + where := "game_id = $1" + if claimedBy != "" { + args = append(args, claimedBy) + param := fmt.Sprintf("$%d", len(args)) + set = append(set, fmt.Sprintf("%s = %s, %s = COALESCE(%s, %s)", + claimColumn, param, nameColumn, nameColumn, claimantName(param))) + where += fmt.Sprintf(" AND (%s IS NULL OR %s = %s)", claimColumn, claimColumn, param) + } + if result != "" { + args = append(args, result, termination, ended) + set = append(set, fmt.Sprintf("result = $%d, termination = $%d, end_time_utc = $%d", + len(args)-2, len(args)-1, len(args))) + } + res, err := tx.ExecContext(ctx, + "UPDATE games SET "+strings.Join(set, ", ")+" WHERE "+where, args...) + if err != nil { + return err + } + return requireOneGame(ctx, tx, res, gameID) +} + // PlayerRecord is the persistence subset of a player configuration. type PlayerRecord struct { PlayerID string @@ -177,7 +203,7 @@ func (s *Store) RecordPlayers(gameID string, white, black PlayerRecord) error { if err != nil { 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 } 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, ) if err != nil { 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 } -// QueryGamesForUser returns a page of games claimed by userID in either color, -// 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, limit, offset int) ([]GameSummaryRecord, error) { - if userID == "" || limit < 1 || limit > MaxUserGamesPage || offset < 0 { +// UserGamesQuery selects a page of a user's games, newest first. After, when +// set, continues strictly below that (start time, game ID) key; Offset +// remains for clients that page by position and must be 0 with After. +type UserGamesQuery struct { + Limit int + 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", 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) { 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 { return nil, err } started := time.Now() - const query = `SELECT ` + gameSelectColumns + `, + query := `SELECT ` + gameSelectColumns + `, COALESCE(last.move_number, 0), COALESCE(last.fen_after_move, g.initial_fen) FROM games g LEFT JOIN LATERAL ( @@ -364,12 +437,12 @@ func (s *Store) QueryGamesForUser(userID string, limit, offset int) ([]GameSumma ORDER BY m.move_number DESC LIMIT 1 ) 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 LIMIT $2 OFFSET $3` ctx, cancel := opContext() defer cancel() - rows, err := s.db.QueryContext(ctx, query, userID, limit, offset) + rows, err := s.db.QueryContext(ctx, query, args...) if err != nil { 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", "user_id", userID, "count", len(games), - "limit", limit, - "offset", offset, + "limit", q.Limit, + "offset", q.Offset, + "cursor", q.After != nil, "duration", time.Since(started), ) 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 { Scan(dest ...any) error } // scanGame scans gameSelectColumns followed by any extra destinations. 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 dest := []any{ &record.GameID, &record.InitialFEN, @@ -410,7 +526,7 @@ func scanGame(scanner rowScanner, record *GameRecord, extra ...any) error { &whiteClaimed, &whiteName, &record.BlackPlayerID, &record.BlackType, &record.BlackLevel, &record.BlackSearchTime, &blackClaimed, &blackName, - &result, &record.StartTimeUTC, &endTime, + &result, &termination, &record.StartTimeUTC, &endTime, } if err := scanner.Scan(append(dest, extra...)...); err != nil { return err @@ -420,20 +536,47 @@ func scanGame(scanner rowScanner, record *GameRecord, extra ...any) error { record.BlackClaimedBy = blackClaimed.String record.BlackName = blackName.String record.Result = result.String + record.Termination = termination.String record.StartTimeUTC = record.StartTimeUTC.UTC() record.EndTimeUTC = utcPointer(endTime) 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() if err != nil { return err } - if rows != 1 { - return fmt.Errorf("game %s was not updated", gameID) + if rows == 1 { + return nil } - 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) +} + +// 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 { @@ -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 ended != nil { - return errors.New("end time requires a game result") + if termination != "" || ended != nil { + return errors.New("termination and end time require a game result") } return nil } if !isValidResult(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() { return errors.New("terminal game result requires an end time") } diff --git a/internal/server/storage/integrity.go b/internal/server/storage/integrity.go new file mode 100644 index 0000000..869fb3b --- /dev/null +++ b/internal/server/storage/integrity.go @@ -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 +} diff --git a/internal/server/storage/schema.go b/internal/server/storage/schema.go index 1e9e562..6d1c99a 100644 --- a/internal/server/storage/schema.go +++ b/internal/server/storage/schema.go @@ -38,6 +38,7 @@ type GameRecord struct { BlackClaimedBy string BlackName string Result string + Termination string // how Result was reached; empty while unfinished StartTimeUTC time.Time EndTimeUTC *time.Time } @@ -63,17 +64,18 @@ type MoveRecord struct { // MovePersistence groups changes caused by one accepted move so the move, // first-move slot claim, and terminal result commit in one transaction. type MovePersistence struct { - Move MoveRecord - ClaimColor string - ClaimedBy string - Result string - EndTimeUTC *time.Time + Move MoveRecord + ClaimColor string + ClaimedBy string + Result string + Termination string + EndTimeUTC *time.Time } // schemaVersion is the newest schema this binary understands. Migrations are // applied in order inside one transaction; PostgreSQL DDL is transactional, so // a failed upgrade leaves the previous version intact. -const schemaVersion = 1 +const schemaVersion = 2 // migrations[v-1] upgrades the schema from version v-1 to v. Never edit a // 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) 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. diff --git a/internal/server/storage/storage.go b/internal/server/storage/storage.go index 1891096..6d6cdfe 100644 --- a/internal/server/storage/storage.go +++ b/internal/server/storage/storage.go @@ -68,6 +68,15 @@ type Store struct { enqueueMu sync.RWMutex closeOnce sync.Once 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 @@ -203,6 +212,14 @@ func (s *Store) executeWrite(req writeRequest) error { ) 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) { break } diff --git a/internal/server/storage/storage_test.go b/internal/server/storage/storage_test.go index dcaec47..e2b8807 100644 --- a/internal/server/storage/storage_test.go +++ b/internal/server/storage/storage_test.go @@ -116,7 +116,7 @@ func TestReplayPersistenceIsAtomicAndReadAfterWriteConsistent(t *testing.T) { FENAfterMove: "after-e2e4", PlayerColor: "w", MoveTimeUTC: ended, }, ClaimColor: "w", ClaimedBy: user, - Result: "white_wins", EndTimeUTC: &ended, + Result: "white_wins", Termination: "checkmate", EndTimeUTC: &ended, }); err != nil { t.Fatal(err) } @@ -137,7 +137,7 @@ func TestReplayPersistenceIsAtomicAndReadAfterWriteConsistent(t *testing.T) { t.Fatalf("moves = %+v", moves) } - owned, err := store.QueryGamesForUser(user, 10, 0) + owned, err := store.QueryGamesForUser(user, UserGamesQuery{Limit: 10}) if err != nil { t.Fatal(err) } @@ -155,7 +155,7 @@ func TestReplayPersistenceIsAtomicAndReadAfterWriteConsistent(t *testing.T) { if record.Result != "" || record.EndTimeUTC != nil || len(moves) != 0 { 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 { t.Fatal(err) } @@ -188,6 +188,8 @@ func TestUserGamesArePagedNewestFirstWithoutDuplicates(t *testing.T) { record.WhiteClaimedBy = user case 1: record.BlackClaimedBy = user + end := record.StartTimeUTC.Add(time.Minute) + record.Result, record.Termination, record.EndTimeUTC = "white_wins", "resignation", &end case 2: record.WhiteClaimedBy, record.BlackClaimedBy = user, user // self-play case 3: @@ -205,7 +207,7 @@ func TestUserGamesArePagedNewestFirstWithoutDuplicates(t *testing.T) { var got []string 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 { t.Fatal(err) } @@ -219,9 +221,88 @@ func TestUserGamesArePagedNewestFirstWithoutDuplicates(t *testing.T) { if fmt.Sprint(got) != fmt.Sprint(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) } + + // 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) { @@ -242,6 +323,14 @@ func TestQueryPlansUsePurposeBuiltIndexes(t *testing.T) { args: []any{userID}, 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", 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, 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 { 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") } + +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) + } +} diff --git a/internal/server/webserver/chess-client-web/app.js b/internal/server/webserver/chess-client-web/app.js index f0af00d..aacf63d 100644 --- a/internal/server/webserver/chess-client-web/app.js +++ b/internal/server/webserver/chess-client-web/app.js @@ -17,6 +17,8 @@ let gameState = { username: null, authBusy: 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 @@ -55,6 +57,10 @@ document.addEventListener('DOMContentLoaded', async () => { document.getElementById('new-game-btn').addEventListener('click', showNewGameModal); 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('cancel-btn').addEventListener('click', hideNewGameModal); 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('auth-cancel-btn').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 @@ -435,24 +448,20 @@ function updateTurnIndicator(state, turn) { switch(state) { case 'white wins': status = 'white-wins'; - tooltipText = 'White Wins'; break; case 'black wins': status = 'black-wins'; - tooltipText = 'Black Wins'; break; case 'stalemate': status = 'stalemate'; - tooltipText = 'Stalemate'; break; case 'draw': status = 'draw'; - tooltipText = 'Draw'; break; default: status = 'unknown'; - tooltipText = 'Game Over'; } + tooltipText = describeResult(state, gameState.termination); } else if (state === 'stuck') { status = 'degraded'; tooltipText = 'Engine Error'; @@ -705,6 +714,7 @@ function renderBoardFromFEN(fen) { s.textContent = ''; s.classList.remove('white-piece', 'black-piece', 'mated-king'); delete s.dataset.pieceColor; + delete s.dataset.pieceType; }); let rank = 7, file = 0; @@ -765,11 +775,64 @@ function flashSquare(element, success = true) { 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) { - const move = from + to; + let move = from + to; const fromEl = document.querySelector(`[data-square="${from}"]`); const toEl = document.querySelector(`[data-square="${to}"]`); + if (isPromotionMove(from, to)) { + const piece = await choosePromotion(fromEl.dataset.pieceColor); + if (!piece) return; + move += piece; + } + try { const response = await authFetch(`${gameState.apiUrl}/api/games/${gameState.gameId}/moves`, { method: 'POST', @@ -892,11 +955,13 @@ async function pollOnce() { function lockBoard() { gameState.isLocked = true; updateTurnIndicator('pending', gameState.turn); + updateGameControls(); } function unlockBoard() { gameState.isLocked = false; - updateTurnIndicator('', gameState.turn); + updateTurnIndicator(gameState.state, gameState.turn); + updateGameControls(); } async function undoMoves() { @@ -993,9 +1058,11 @@ function renderMoveHistory(moves) { } function updateGameDisplay(game) { + const wasOver = isGameOver(gameState.state); gameState.fen = game.fen; gameState.turn = game.turn; gameState.state = game.state; + gameState.termination = game.termination || ''; gameState.moveList = game.moves || []; renderBoardFromFEN(game.fen); @@ -1026,10 +1093,131 @@ function updateGameDisplay(game) { // Update undo button document.getElementById('undo-btn').disabled = !game.moves || game.moves.length < 2; - // Handle checkmate visually - if (game.state === 'white wins' || game.state === 'black wins') { + // Mark the mated king; a resignation leaves the board as it was + if ((game.state === 'white wins' || game.state === 'black wins') && + (game.termination || 'checkmate') === 'checkmate') { 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) { @@ -1135,10 +1323,17 @@ function handleApiError(action, error, response = null) { } 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 messageEl = document.getElementById('error-flash-message'); messageEl.textContent = message; + messageEl.classList.toggle('info', type === 'info'); overlay.classList.add('show'); // Clear any pending timeout to avoid premature hide on rapid calls diff --git a/internal/server/webserver/chess-client-web/index.html b/internal/server/webserver/chess-client-web/index.html index e775cfc..1999aae 100644 --- a/internal/server/webserver/chess-client-web/index.html +++ b/internal/server/webserver/chess-client-web/index.html @@ -48,6 +48,8 @@
+ +
@@ -147,6 +149,33 @@
+ + + + + + diff --git a/internal/server/webserver/chess-client-web/style.css b/internal/server/webserver/chess-client-web/style.css index 93d6280..b9b7264 100644 --- a/internal/server/webserver/chess-client-web/style.css +++ b/internal/server/webserver/chess-client-web/style.css @@ -379,6 +379,10 @@ main { padding: 0.5rem; } +.error-flash-message.info { + color: var(--tokyo-cyan); +} + /* Move History */ .move-history-container { flex: 1 1 auto; @@ -849,20 +853,21 @@ input[type="range"]:disabled { margin: 0; } + /* Four controls as a 2x2 grid; the column is too short for one per row. */ .controls { grid-column: 1; grid-row: 2 / 4; - grid-template-columns: 1fr; + grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; - gap: 0.5rem; + gap: 0.4rem; align-self: stretch; height: auto; min-height: 80px; } .btn { - padding: 0.5rem; - font-size: 0.85rem; + padding: 0.4rem 0.25rem; + font-size: 0.8rem; min-height: 35px; height: 100%; width: 100%; @@ -907,3 +912,55 @@ input[type="range"]:disabled { 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; +} diff --git a/test/README.md b/test/README.md index 199522f..9451a83 100755 --- a/test/README.md +++ b/test/README.md @@ -80,6 +80,9 @@ curl -X POST http://localhost:8080/api/games \ | API Functionality | `test-api.sh` | Game operations, moves, undo, rate limiting | | Database & Auth | `test-db.sh` | User registration, login, JWT tokens, persistence | | 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 | ## 1. API Functionality Tests (`test-api.sh`) @@ -167,3 +170,44 @@ test/test-longpoll.sh 3. **Timeout**: Verify 25-second timeout with valid response 4. **Skip Wait**: Immediate return when moveCount outdated 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). diff --git a/test/test-draw.sh b/test/test-draw.sh new file mode 100755 index 0000000..b15506a --- /dev/null +++ b/test/test-draw.sh @@ -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 + if [ "$2" = "$3" ]; then ok "$1"; else bad "$1: expected '$2', got '$3'"; fi +} + +# request [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 [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 [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 ... + 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 : 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 ] diff --git a/test/test-replay.sh b/test/test-replay.sh new file mode 100755 index 0000000..46c96ce --- /dev/null +++ b/test/test-replay.sh @@ -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 + if [ "$2" = "$3" ]; then ok "$1"; else bad "$1: expected '$2', got '$3'"; fi +} + +# request [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 ... + 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 : 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 ]