v0.13.0 web and terminal client enhance, replay api, linux server deploy, doc and dependency update
test / test (push) Canceled after 0s
test / test (push) Canceled after 0s
This commit is contained in:
1 parent
52ac0fe31a
commit
f1ac4cbbe7
71 files changed
+7677
-537
No files matched your search
@@ -30,7 +30,9 @@ jobs:
|
|||||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.26.x'
|
go-version: '1.27.x'
|
||||||
|
- name: Install Stockfish for the rules-core cross-check
|
||||||
|
run: sudo apt-get update -qq && sudo apt-get install -y -qq stockfish
|
||||||
- name: gofmt
|
- name: gofmt
|
||||||
run: test -z "$(gofmt -l .)" || { gofmt -l .; exit 1; }
|
run: test -z "$(gofmt -l .)" || { gofmt -l .; exit 1; }
|
||||||
- run: go vet ./...
|
- run: go vet ./...
|
||||||
|
|||||||
@@ -148,6 +148,18 @@ test-db:
|
|||||||
test-longpoll:
|
test-longpoll:
|
||||||
test/test-longpoll.sh
|
test/test-longpoll.sh
|
||||||
|
|
||||||
|
.PHONY: test-endstate
|
||||||
|
test-endstate:
|
||||||
|
test/test-endstate.sh
|
||||||
|
|
||||||
|
.PHONY: test-replay
|
||||||
|
test-replay:
|
||||||
|
test/test-replay.sh
|
||||||
|
|
||||||
|
.PHONY: test-draw
|
||||||
|
test-draw:
|
||||||
|
test/test-draw.sh
|
||||||
|
|
||||||
# Database operations
|
# Database operations
|
||||||
.PHONY: db-init
|
.PHONY: db-init
|
||||||
db-init: server
|
db-init: server
|
||||||
@@ -229,6 +241,9 @@ help:
|
|||||||
@echo " make test-api Run API tests"
|
@echo " make test-api Run API tests"
|
||||||
@echo " make test-db Run database tests"
|
@echo " make test-db Run database tests"
|
||||||
@echo " make test-longpoll Run long-poll tests"
|
@echo " make test-longpoll Run long-poll tests"
|
||||||
|
@echo " make test-endstate Run end-state tests"
|
||||||
|
@echo " make test-replay Run replay (SAN, PGN, listing) tests"
|
||||||
|
@echo " make test-draw Run draw and resignation tests"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Database targets (CHESS_DSN=$(CHESS_DSN)):"
|
@echo "Database targets (CHESS_DSN=$(CHESS_DSN)):"
|
||||||
@echo " make db-init Create or migrate the schema"
|
@echo " make db-init Create or migrate the schema"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<td>
|
<td>
|
||||||
<h1>♚♛♜♝♞</h1>
|
<h1>♚♛♜♝♞</h1>
|
||||||
<p>
|
<p>
|
||||||
<a href="https://golang.org"><img src="https://img.shields.io/badge/Go-1.26-00ADD8?style=flat&logo=go" alt="Go 1.26"></a>
|
<a href="https://golang.org"><img src="https://img.shields.io/badge/Go-1.27-00ADD8?style=flat&logo=go" alt="Go 1.27"></a>
|
||||||
<a href="https://opensource.org/licenses/BSD-3-Clause"><img src="https://img.shields.io/badge/License-BSD_3--Clause-blue.svg" alt="License BSD-3"></a>
|
<a href="https://opensource.org/licenses/BSD-3-Clause"><img src="https://img.shields.io/badge/License-BSD_3--Clause-blue.svg" alt="License BSD-3"></a>
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
@@ -20,24 +20,31 @@ Go backend server providing a RESTful API for chess gameplay with user authentic
|
|||||||
- User registration and JWT authentication
|
- User registration and JWT authentication
|
||||||
- Stockfish engine integration for validation
|
- Stockfish engine integration for validation
|
||||||
- Human vs human, human vs computer, computer vs computer modes
|
- Human vs human, human vs computer, computer vs computer modes
|
||||||
|
- Resignation, draw offers (the computer answers from its evaluation), and
|
||||||
|
automatic draws: dead material, threefold repetition, fifty-move rule
|
||||||
- Custom FEN position support
|
- Custom FEN position support
|
||||||
- Asynchronous engine move calculation
|
- Asynchronous engine move calculation
|
||||||
- Configurable engine strength and thinking time
|
- Configurable engine strength and thinking time
|
||||||
- PostgreSQL 18 persistence with ordered async writes for games
|
- PostgreSQL 18 persistence with ordered async writes for games
|
||||||
- Durable game results, player claims and names, ordered move history, and replay API
|
- Durable game results, player claims and names, ordered move history, and replay API
|
||||||
|
- Dependency-free rules core: SAN history, PGN export, FEN validation, and a
|
||||||
|
shadow check of every engine-validated move
|
||||||
- Games without a registered player are purged 24 hours after their last activity
|
- Games without a registered player are purged 24 hours after their last activity
|
||||||
- Authenticated stored-game listing with bounded pagination
|
- Authenticated stored-game listing with cursor pagination and filters
|
||||||
- Configurable structured debug logs for persistence, cleanup, and engine work
|
- Configurable structured debug logs for persistence, cleanup, and engine work
|
||||||
- User management with secure Argon2id password storage and scoped JWTs
|
- User management with secure Argon2id password storage and scoped JWTs
|
||||||
- Static, CGO-free binaries (cross-compiles for FreeBSD)
|
- Static, CGO-free binaries (cross-compiles for FreeBSD)
|
||||||
|
- Scripted deployment: FreeBSD jail (rc.d) or Linux (sandboxed systemd unit)
|
||||||
|
- Optional hourly integrity sweep of stored games and accounts
|
||||||
- PID file management for singleton enforcement
|
- PID file management for singleton enforcement
|
||||||
- Database CLI for storage and user administration
|
- Database CLI for storage and user administration
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Go 1.26+
|
- Go 1.27+
|
||||||
- Stockfish chess engine (`stockfish` in PATH)
|
- Stockfish chess engine (`stockfish` in PATH)
|
||||||
- PostgreSQL 18 (for persistence and accounts; see [Deployment](./doc/deployment.md))
|
- PostgreSQL 17+, 18 recommended (for persistence and accounts; see
|
||||||
|
[FreeBSD](./doc/deployment.md) or [Linux](./doc/deployment-linux.md) deployment)
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
```bash
|
```bash
|
||||||
@@ -174,6 +181,8 @@ Access the UI at `http://localhost:9090` when server is running with `-serve` fl
|
|||||||
|
|
||||||
- [API Reference](./doc/api.md) - Endpoint specifications including auth
|
- [API Reference](./doc/api.md) - Endpoint specifications including auth
|
||||||
- [Deployment](./doc/deployment.md) - FreeBSD jail, PostgreSQL 18, `chessd` service, nginx, web clients
|
- [Deployment](./doc/deployment.md) - FreeBSD jail, PostgreSQL 18, `chessd` service, nginx, web clients
|
||||||
|
- [Linux Deployment](./doc/deployment-linux.md) - Debian/Ubuntu/Arch, systemd service, development setup
|
||||||
|
- [Database Operations](./doc/database.md) - layout, psql as `postgres`, games, users, integrity sweep, starting fresh
|
||||||
- [Architecture](./doc/architecture.md) - System design with auth layer
|
- [Architecture](./doc/architecture.md) - System design with auth layer
|
||||||
- [Development](./doc/development.md) - Build, test, and user management
|
- [Development](./doc/development.md) - Build, test, and user management
|
||||||
- [Client Guide](./doc/client.md) - Interactive debugging client
|
- [Client Guide](./doc/client.md) - Interactive debugging client
|
||||||
|
|||||||
+100
-1
@@ -11,6 +11,7 @@ import (
|
|||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"chess/internal/server/replay"
|
||||||
"chess/internal/server/storage"
|
"chess/internal/server/storage"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -23,7 +24,7 @@ const dsnEnv = "CHESS_DSN"
|
|||||||
// Run is the entry point for the CLI mini-app
|
// Run is the entry point for the CLI mini-app
|
||||||
func Run(args []string) error {
|
func Run(args []string) error {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return fmt.Errorf("subcommand required: init, delete, query, user")
|
return fmt.Errorf("subcommand required: init, delete, query, pgn, verify, user")
|
||||||
}
|
}
|
||||||
|
|
||||||
switch args[0] {
|
switch args[0] {
|
||||||
@@ -33,6 +34,10 @@ func Run(args []string) error {
|
|||||||
return runDelete(args[1:])
|
return runDelete(args[1:])
|
||||||
case "query":
|
case "query":
|
||||||
return runQuery(args[1:])
|
return runQuery(args[1:])
|
||||||
|
case "pgn":
|
||||||
|
return runPGN(args[1:])
|
||||||
|
case "verify":
|
||||||
|
return runVerify(args[1:])
|
||||||
case "user":
|
case "user":
|
||||||
if len(args) < 2 {
|
if len(args) < 2 {
|
||||||
return fmt.Errorf("user subcommand required: add, delete, set-password, set-hash, set-email, set-username, list")
|
return fmt.Errorf("user subcommand required: add, delete, set-password, set-hash, set-email, set-username, list")
|
||||||
@@ -175,6 +180,100 @@ func runQuery(args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runPGN prints a stored game as PGN. The game may be named by the 8-digit
|
||||||
|
// prefix that `db query` prints.
|
||||||
|
func runPGN(args []string) error {
|
||||||
|
fs, dsn := newFlagSet("pgn")
|
||||||
|
gameID := fs.String("gameId", "", "Game ID or unique prefix of at least 8 hex digits (required)")
|
||||||
|
ply := fs.Int("ply", -1, "Export only the first N plies (default: all)")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *gameID == "" {
|
||||||
|
return errors.New("-gameId is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
store, err := openStore(*dsn, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
id, err := resolveGameID(store, *gameID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
record, moves, err := store.GetGameHistory(id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load game %s: %w", id, err)
|
||||||
|
}
|
||||||
|
pgn, err := replay.BuildPGN(record, moves, *ply)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Print(pgn.Text)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runVerify checks stored games against the rules (see replay.Verify) and
|
||||||
|
// fails when any problem is found.
|
||||||
|
func runVerify(args []string) error {
|
||||||
|
fs, dsn := newFlagSet("verify")
|
||||||
|
gameID := fs.String("gameId", "", "Verify one game (ID or unique prefix); default all")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
store, err := openStore(*dsn, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
var ids []string
|
||||||
|
if *gameID != "" {
|
||||||
|
id, err := resolveGameID(store, *gameID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ids = []string{id}
|
||||||
|
} else {
|
||||||
|
games, err := store.QueryGames("", "")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list games: %w", err)
|
||||||
|
}
|
||||||
|
for _, g := range games {
|
||||||
|
ids = append(ids, g.GameID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plies, problems := 0, 0
|
||||||
|
for _, id := range ids {
|
||||||
|
record, moves, err := store.GetGameHistory(id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load game %s: %w", id, err)
|
||||||
|
}
|
||||||
|
plies += len(moves)
|
||||||
|
for _, problem := range replay.Verify(record, moves) {
|
||||||
|
fmt.Printf("%s: %s\n", id, problem)
|
||||||
|
problems++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Printf("Verified %d game(s), %d plies: %d problem(s)\n", len(ids), plies, problems)
|
||||||
|
if problems > 0 {
|
||||||
|
return fmt.Errorf("%d problem(s) found", problems)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveGameID(store *storage.Store, prefix string) (string, error) {
|
||||||
|
id, err := store.ResolveGameID(prefix)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", fmt.Errorf("no game matches %q", prefix)
|
||||||
|
}
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
func formatStoredPlayer(playerID, claimedBy string, playerType int) string {
|
func formatStoredPlayer(playerID, claimedBy string, playerType int) string {
|
||||||
value := fmt.Sprintf("%s (T%d)", abbreviateID(playerID), playerType)
|
value := fmt.Sprintf("%s (T%d)", abbreviateID(playerID), playerType)
|
||||||
if claimedBy != "" && claimedBy != playerID {
|
if claimedBy != "" && claimedBy != playerID {
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ func main() {
|
|||||||
anonTTL = flag.Duration("anonymous-game-ttl", service.AnonymousGameTTL, "How long games without a registered player survive after their last activity, in memory and in the database (0 keeps them)")
|
anonTTL = flag.Duration("anonymous-game-ttl", service.AnonymousGameTTL, "How long games without a registered player survive after their last activity, in memory and in the database (0 keeps them)")
|
||||||
maxUsers = flag.Int("max-users", service.DefaultMaxUsers, "Accounts at which public registration closes (0 = no limit; CLI-created accounts are not limited)")
|
maxUsers = flag.Int("max-users", service.DefaultMaxUsers, "Accounts at which public registration closes (0 = no limit; CLI-created accounts are not limited)")
|
||||||
jwtFile = flag.String("jwt-secret-file", "", "File holding a stable JWT signing key, mode 0600, at least 32 bytes (default $CHESS_JWT_SECRET_FILE)")
|
jwtFile = flag.String("jwt-secret-file", "", "File holding a stable JWT signing key, mode 0600, at least 32 bytes (default $CHESS_JWT_SECRET_FILE)")
|
||||||
|
dbCleanup = flag.String("db-cleanup", "off", "Hourly integrity sweep of stored games and accounts: off, report (log only), or delete")
|
||||||
|
|
||||||
// Web UI server flags
|
// Web UI server flags
|
||||||
serve = flag.Bool("serve", false, "Enable web UI server")
|
serve = flag.Bool("serve", false, "Enable web UI server")
|
||||||
@@ -151,6 +152,14 @@ func main() {
|
|||||||
svc.SetFinishedGameTTL(*finishedTTL)
|
svc.SetFinishedGameTTL(*finishedTTL)
|
||||||
svc.SetAnonymousGameTTL(*anonTTL)
|
svc.SetAnonymousGameTTL(*anonTTL)
|
||||||
svc.SetMaxUsers(*maxUsers)
|
svc.SetMaxUsers(*maxUsers)
|
||||||
|
integrity, err := service.ParseIntegrityMode(*dbCleanup)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Invalid -db-cleanup: %v", err)
|
||||||
|
}
|
||||||
|
if integrity != service.IntegrityOff && store == nil {
|
||||||
|
log.Fatalf("-db-cleanup %s requires a database (-dsn)", integrity)
|
||||||
|
}
|
||||||
|
svc.SetIntegrityMode(integrity)
|
||||||
|
|
||||||
// Start cleanup job for expired users/sessions
|
// Start cleanup job for expired users/sessions
|
||||||
cleanupCtx, cleanupCancel := context.WithCancel(context.Background())
|
cleanupCtx, cleanupCancel := context.WithCancel(context.Background())
|
||||||
|
|||||||
+27
-7
@@ -1,7 +1,12 @@
|
|||||||
# Deployment Scripts
|
# Deployment Scripts
|
||||||
|
|
||||||
Run in this order; each script's header documents its inputs. The full guide is
|
Each script's header documents its inputs. Day-to-day database work (psql,
|
||||||
[doc/deployment.md](../doc/deployment.md).
|
accounts, deleting games, starting fresh) is in
|
||||||
|
[doc/database.md](../doc/database.md).
|
||||||
|
|
||||||
|
## FreeBSD jail
|
||||||
|
|
||||||
|
Full guide: [doc/deployment.md](../doc/deployment.md). Run in this order:
|
||||||
|
|
||||||
| Order | Where | Step | Purpose |
|
| Order | Where | Step | Purpose |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
@@ -9,13 +14,28 @@ Run in this order; each script's header documents its inputs. The full guide is
|
|||||||
| 2 | Jail, once | `pkg install postgresql18-server postgresql18-client stockfish` | Packages |
|
| 2 | Jail, once | `pkg install postgresql18-server postgresql18-client stockfish` | Packages |
|
||||||
| 3 | Build machine | `make server-freebsd` | Static `bin/chess-server-freebsd-amd64` |
|
| 3 | Build machine | `make server-freebsd` | Static `bin/chess-server-freebsd-amd64` |
|
||||||
| 4 | Jail (root) | `freebsd/setup-jail.sh` | PostgreSQL setup, database, `chessd` service, optional log rotation and backups; rerun to upgrade |
|
| 4 | Jail (root) | `freebsd/setup-jail.sh` | PostgreSQL setup, database, `chessd` service, optional log rotation and backups; rerun to upgrade |
|
||||||
| 5 | Bastille host | `freebsd/nginx-chess.conf` | nginx locations for `/chess/api/` and `/chess/health` |
|
| 5 | Bastille host | `nginx-chess.conf` | nginx locations for `/chess/api/`, `/chess/health`, and the `/chess` short link |
|
||||||
|
| 6 | Jail, once | `chess-server db user add -username <name>` | First account (see the guide) |
|
||||||
|
|
||||||
Supporting files, installed by `setup-jail.sh`:
|
## Linux with systemd
|
||||||
|
|
||||||
|
Full guide: [doc/deployment-linux.md](../doc/deployment-linux.md) (Debian,
|
||||||
|
Ubuntu, Arch; PostgreSQL 17+ already running).
|
||||||
|
|
||||||
|
| Order | Where | Step | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Host | `make server` | Static `bin/chess-server` |
|
||||||
|
| 2 | Host (root) | `CHESS_BINARY=bin/chess-server linux/setup.sh` | Account, database, `chessd` systemd service, key, optional backup timer; rerun to upgrade |
|
||||||
|
| 3 | Host (root) | `chess-db user add -username <name>` | First account |
|
||||||
|
| 4 | nginx | `nginx-chess.conf` with `127.0.0.1:8080` | Optional reverse proxy |
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
- `postgresql/setup.sql` — role, database, and schema (owner or split mode)
|
- `postgresql/setup.sql` — role, database, and schema (owner or split mode)
|
||||||
|
- `postgresql/chess-backup.sh` — nightly `pg_dump` with 14-day retention
|
||||||
|
- `nginx-chess.conf` — nginx locations, with placeholders
|
||||||
- `freebsd/rc.d/chessd` — `daemon(8)` service script
|
- `freebsd/rc.d/chessd` — `daemon(8)` service script
|
||||||
- `freebsd/chess-backup.sh` — nightly `pg_dump` with 14-day retention
|
- `linux/chessd.service` — sandboxed systemd unit; `linux/chess-backup.{service,timer}`
|
||||||
|
|
||||||
Keep deployment-specific values (addresses, host names) in `rc.conf` and the
|
Keep deployment-specific values (addresses, host names) in `rc.conf`,
|
||||||
host nginx configuration, not in this repository.
|
`/etc/chessd/chessd.env`, and the nginx configuration, not in this repository.
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# Nightly logical backup of the chess database. setup-jail.sh installs this as
|
|
||||||
# /usr/local/sbin/chess-backup and schedules it as the postgres user through
|
|
||||||
# /usr/local/etc/cron.d/chess-backup.
|
|
||||||
#
|
|
||||||
# CHESS_BACKUP_DIR destination (default: /var/db/postgres/backups)
|
|
||||||
# CHESS_BACKUP_KEEP days of dumps to keep (default: 14)
|
|
||||||
#
|
|
||||||
# Restore into a freshly provisioned database with:
|
|
||||||
# su -m postgres -c 'pg_restore --no-owner --role=chess -d chess <dump>'
|
|
||||||
# (use --role=chess_owner for the split-privilege setup).
|
|
||||||
set -eu
|
|
||||||
PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin
|
|
||||||
|
|
||||||
dir=${CHESS_BACKUP_DIR:-/var/db/postgres/backups}
|
|
||||||
keep=${CHESS_BACKUP_KEEP:-14}
|
|
||||||
umask 077
|
|
||||||
mkdir -p "$dir"
|
|
||||||
|
|
||||||
target="$dir/chess-$(date -u +%Y%m%dT%H%M%SZ).dump"
|
|
||||||
pg_dump --format=custom --dbname=chess --file="$target.partial"
|
|
||||||
mv "$target.partial" "$target"
|
|
||||||
find "$dir" -name 'chess-*.dump' -type f -mtime "+$keep" -delete
|
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
# restart stops every service in the jail, including a running chess-server.
|
# restart stops every service in the jail, including a running chess-server.
|
||||||
#
|
#
|
||||||
# After setup-jail.sh has run inside the jail, add the location block from
|
# After setup-jail.sh has run inside the jail, add the location block from
|
||||||
# deploy/freebsd/nginx-chess.conf to the host nginx configuration, then
|
# deploy/nginx-chess.conf to the host nginx configuration, then
|
||||||
# nginx -t && service nginx reload
|
# nginx -t && service nginx reload
|
||||||
set -eu
|
set -eu
|
||||||
PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin
|
PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ yes_no() {
|
|||||||
[ "$(id -u)" -eq 0 ] || die "run as root"
|
[ "$(id -u)" -eq 0 ] || die "run as root"
|
||||||
[ "$(uname -s)" = FreeBSD ] || die "this script targets FreeBSD"
|
[ "$(uname -s)" = FreeBSD ] || die "this script targets FreeBSD"
|
||||||
[ -f "$CHESS_BINARY" ] || die "CHESS_BINARY not found: $CHESS_BINARY"
|
[ -f "$CHESS_BINARY" ] || die "CHESS_BINARY not found: $CHESS_BINARY"
|
||||||
for f in "$repo/deploy/postgresql/setup.sql" "$here/rc.d/chessd" "$here/chess-backup.sh"; do
|
for f in "$repo/deploy/postgresql/setup.sql" "$here/rc.d/chessd" "$repo/deploy/postgresql/chess-backup.sh"; do
|
||||||
[ -f "$f" ] || die "missing $f; run from a complete repository copy"
|
[ -f "$f" ] || die "missing $f; run from a complete repository copy"
|
||||||
done
|
done
|
||||||
yes_no SPLIT_PRIVILEGES "$SPLIT_PRIVILEGES"
|
yes_no SPLIT_PRIVILEGES "$SPLIT_PRIVILEGES"
|
||||||
@@ -182,12 +182,20 @@ fi
|
|||||||
|
|
||||||
bindir=$(dirname "$bin")
|
bindir=$(dirname "$bin")
|
||||||
[ -d "$bindir" ] || install -d -o root -g wheel -m 0755 "$bindir"
|
[ -d "$bindir" ] || install -d -o root -g wheel -m 0755 "$bindir"
|
||||||
if [ -f "$bin" ] && ! cmp -s "$CHESS_BINARY" "$bin"; then
|
|
||||||
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.
|
# 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"
|
[ -d "$RC_DIR" ] || install -d "$RC_DIR"
|
||||||
if [ -f "$RC_DIR/chessd" ] && ! cmp -s "$here/rc.d/chessd" "$RC_DIR/chessd"; then
|
if [ -f "$RC_DIR/chessd" ] && ! cmp -s "$here/rc.d/chessd" "$RC_DIR/chessd"; then
|
||||||
@@ -247,7 +255,7 @@ fi
|
|||||||
if [ "$ENABLE_BACKUP" = yes ]; then
|
if [ "$ENABLE_BACKUP" = yes ]; then
|
||||||
# Nightly pg_dump at 03:30 as postgres, keeping 14 days.
|
# Nightly pg_dump at 03:30 as postgres, keeping 14 days.
|
||||||
[ -d "$SBIN_DIR" ] || install -d "$SBIN_DIR"
|
[ -d "$SBIN_DIR" ] || install -d "$SBIN_DIR"
|
||||||
install -o root -g wheel -m 0555 "$here/chess-backup.sh" "$SBIN_DIR/chess-backup"
|
install -o root -g wheel -m 0555 "$repo/deploy/postgresql/chess-backup.sh" "$SBIN_DIR/chess-backup"
|
||||||
[ -d "$BACKUP_DIR" ] || install -d -o postgres -g postgres -m 0700 "$BACKUP_DIR"
|
[ -d "$BACKUP_DIR" ] || install -d -o postgres -g postgres -m 0700 "$BACKUP_DIR"
|
||||||
[ -d "$CRON_DIR" ] || install -d "$CRON_DIR"
|
[ -d "$CRON_DIR" ] || install -d "$CRON_DIR"
|
||||||
printf '%s\n' \
|
printf '%s\n' \
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Executable
+262
@@ -0,0 +1,262 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Install or upgrade chess-server as the systemd service `chessd` on a Linux
|
||||||
|
# host (Debian, Ubuntu, Arch, or another systemd distribution) that already
|
||||||
|
# runs PostgreSQL 17 or later. Run as root from a checkout:
|
||||||
|
#
|
||||||
|
# sudo CHESS_BINARY=bin/chess-server deploy/linux/setup.sh
|
||||||
|
#
|
||||||
|
# Prerequisites: PostgreSQL 17+ running and reachable by the postgres account
|
||||||
|
# over its Unix socket; stockfish; the chess-server binary (`make server`).
|
||||||
|
#
|
||||||
|
# Inputs (environment):
|
||||||
|
# CHESS_BINARY required: chess-server binary to install
|
||||||
|
# TRUSTED_PROXIES reverse proxy address(es) whose X-Real-IP is trusted,
|
||||||
|
# e.g. 127.0.0.1 for nginx on this host; stored in
|
||||||
|
# chessd.env (kept from the existing file when unset)
|
||||||
|
# CHESSD_HOST listen address for a first install (default 127.0.0.1)
|
||||||
|
# CHESSD_PORT listen port for a first install (default 8080)
|
||||||
|
# SPLIT_PRIVILEGES yes|no (default no); see deploy/postgresql/setup.sql
|
||||||
|
# ENABLE_BACKUP yes|no (default no): nightly pg_dump systemd timer
|
||||||
|
# PG_HBA yes|no (default no): if the chess role cannot connect,
|
||||||
|
# add `local chess chess peer` to pg_hba.conf and reload
|
||||||
|
#
|
||||||
|
# What it installs:
|
||||||
|
# account chess (system, no login shell, home /var/lib/chessd)
|
||||||
|
# /usr/local/bin/chess-server (previous copy kept as .prev)
|
||||||
|
# /usr/local/sbin/chess-db runs `chess-server db ...` as chess
|
||||||
|
# /etc/chessd/chessd.env settings, written on first install only
|
||||||
|
# /var/lib/chessd/jwt.key JWT signing key, generated once
|
||||||
|
# /etc/systemd/system/chessd.service (+ chess-backup.{service,timer})
|
||||||
|
#
|
||||||
|
# Steps (idempotent; rerun to upgrade):
|
||||||
|
# 1. checks: root, systemd, PostgreSQL version and socket, stockfish
|
||||||
|
# 2. role, database, and schema from deploy/postgresql/setup.sql when
|
||||||
|
# neither exists; the chess role must connect over the socket
|
||||||
|
# 3. stop chessd; install files; key; schema migration
|
||||||
|
# 4. enable and start chessd; check /health
|
||||||
|
set -euo pipefail
|
||||||
|
umask 022
|
||||||
|
|
||||||
|
here=$(cd "$(dirname "$0")" && pwd)
|
||||||
|
repo=$(cd "$here/../.." && pwd)
|
||||||
|
|
||||||
|
: "${CHESS_BINARY:?set CHESS_BINARY to the chess-server binary to install}"
|
||||||
|
TRUSTED_PROXIES=${TRUSTED_PROXIES-__keep__}
|
||||||
|
CHESSD_HOST=${CHESSD_HOST:-127.0.0.1}
|
||||||
|
CHESSD_PORT=${CHESSD_PORT:-8080}
|
||||||
|
SPLIT_PRIVILEGES=${SPLIT_PRIVILEGES:-no}
|
||||||
|
ENABLE_BACKUP=${ENABLE_BACKUP:-no}
|
||||||
|
PG_HBA=${PG_HBA:-no}
|
||||||
|
# System locations and the service manager; overridable only to test the
|
||||||
|
# script.
|
||||||
|
BIN_DIR=${BIN_DIR:-/usr/local/bin}
|
||||||
|
SBIN_DIR=${SBIN_DIR:-/usr/local/sbin}
|
||||||
|
CONF_DIR=${CONF_DIR:-/etc/chessd}
|
||||||
|
STATE_DIR=${STATE_DIR:-/var/lib/chessd}
|
||||||
|
UNIT_DIR=${UNIT_DIR:-/etc/systemd/system}
|
||||||
|
SYSTEMCTL=${SYSTEMCTL:-systemctl}
|
||||||
|
|
||||||
|
user=chess
|
||||||
|
bin="$BIN_DIR/chess-server"
|
||||||
|
env_file="$CONF_DIR/chessd.env"
|
||||||
|
key="$STATE_DIR/jwt.key"
|
||||||
|
|
||||||
|
log() { printf '==> %s\n' "$*"; }
|
||||||
|
warn() { printf 'setup: warning: %s\n' "$*" >&2; }
|
||||||
|
die() { printf 'setup: %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
# pg runs psql as the postgres account over the default socket (or $PGHOST).
|
||||||
|
pg() { runuser -u postgres -- psql -X -w -A -t -q -v ON_ERROR_STOP=1 "$@"; }
|
||||||
|
|
||||||
|
# --- 1. checks ---------------------------------------------------------------
|
||||||
|
[ "$(id -u)" -eq 0 ] || die "run as root"
|
||||||
|
for setting in SPLIT_PRIVILEGES ENABLE_BACKUP PG_HBA; do
|
||||||
|
case "${!setting}" in
|
||||||
|
yes | no) ;;
|
||||||
|
*) die "$setting must be yes or no" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
if [ "$SYSTEMCTL" = systemctl ] && [ ! -d /run/systemd/system ]; then
|
||||||
|
die "systemd is not running; see doc/deployment-linux.md to run chess-server another way"
|
||||||
|
fi
|
||||||
|
for tool in runuser psql useradd; do
|
||||||
|
command -v "$tool" >/dev/null || die "$tool not found"
|
||||||
|
done
|
||||||
|
[ -f "$CHESS_BINARY" ] || die "CHESS_BINARY not found: $CHESS_BINARY"
|
||||||
|
# A binary for another OS or architecture fails here rather than at start.
|
||||||
|
case "$("$CHESS_BINARY" db 2>&1 || true)" in
|
||||||
|
*"subcommand required"*) ;;
|
||||||
|
*) die "$CHESS_BINARY does not run on this host (wrong OS or architecture?)" ;;
|
||||||
|
esac
|
||||||
|
for f in "$repo/deploy/postgresql/setup.sql" "$here/chessd.service" \
|
||||||
|
"$here/chess-backup.service" "$here/chess-backup.timer" "$repo/deploy/postgresql/chess-backup.sh"; do
|
||||||
|
[ -f "$f" ] || die "missing $f; run from a complete checkout"
|
||||||
|
done
|
||||||
|
if ! command -v stockfish >/dev/null && [ ! -x /usr/games/stockfish ]; then
|
||||||
|
warn "stockfish not found: install it (Debian/Ubuntu: apt install stockfish; Arch: pacman -S stockfish)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
version=$(pg -d postgres -c 'SHOW server_version_num') ||
|
||||||
|
die "cannot reach PostgreSQL as postgres over its Unix socket: start it (systemctl start postgresql; on Arch run initdb first, see doc/deployment-linux.md) or set PGHOST"
|
||||||
|
[ "$version" -ge 170000 ] || die "PostgreSQL 17 or later required (found $version)"
|
||||||
|
socket=${PGHOST:-$(pg -d postgres -c 'SHOW unix_socket_directories' | cut -d, -f1 | tr -d ' ')}
|
||||||
|
case "$socket" in
|
||||||
|
/*) ;;
|
||||||
|
*) die "no usable Unix socket directory ($socket); set PGHOST to it" ;;
|
||||||
|
esac
|
||||||
|
dsn="host=$socket dbname=chess"
|
||||||
|
log "PostgreSQL $version, socket $socket"
|
||||||
|
|
||||||
|
# --- 2. account, role, database ----------------------------------------------
|
||||||
|
if ! id "$user" >/dev/null 2>&1; then
|
||||||
|
nologin=$(command -v nologin || echo /usr/sbin/nologin)
|
||||||
|
log "creating system account $user"
|
||||||
|
useradd --system --user-group --home-dir "$STATE_DIR" --no-create-home \
|
||||||
|
--shell "$nologin" --comment "chess-server" "$user"
|
||||||
|
fi
|
||||||
|
group=$(id -gn "$user")
|
||||||
|
|
||||||
|
role=$(pg -d postgres -c "SELECT 1 FROM pg_roles WHERE rolname = 'chess'")
|
||||||
|
database=$(pg -d postgres -c "SELECT 1 FROM pg_database WHERE datname = 'chess'")
|
||||||
|
case "$role$database" in
|
||||||
|
11) log "role and database chess exist" ;;
|
||||||
|
"")
|
||||||
|
log "creating role chess, database chess, and schema chess (split privileges: $SPLIT_PRIVILEGES)"
|
||||||
|
split=false
|
||||||
|
[ "$SPLIT_PRIVILEGES" = yes ] && split=true
|
||||||
|
# Fed on stdin: postgres may not be able to read the checkout.
|
||||||
|
pg -d postgres -v split="$split" -f - <"$repo/deploy/postgresql/setup.sql"
|
||||||
|
;;
|
||||||
|
*) die "only one of role chess and database chess exists; drop it or create the other (doc/database.md)" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
chess_can_connect() {
|
||||||
|
runuser -u "$user" -- psql -X -w -A -t -q -h "$socket" -d chess -c 'SELECT current_user' >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
if ! chess_can_connect; then
|
||||||
|
hba=$(pg -d postgres -c 'SHOW hba_file')
|
||||||
|
if [ "$PG_HBA" != yes ]; then
|
||||||
|
die "the chess account cannot connect to database chess over $socket. Add this line to
|
||||||
|
$hba above the other 'local' lines, then reload PostgreSQL (or rerun with PG_HBA=yes):
|
||||||
|
local chess chess peer"
|
||||||
|
fi
|
||||||
|
saved="$hba.chessd-$(date -u +%Y%m%dT%H%M%SZ)"
|
||||||
|
cp -p "$hba" "$saved"
|
||||||
|
awk 'BEGIN { done = 0 }
|
||||||
|
!done && /^[[:space:]]*local[[:space:]]/ { print "local chess chess peer"; done = 1 }
|
||||||
|
{ print }
|
||||||
|
END { if (!done) print "local chess chess peer" }' "$saved" >"$hba"
|
||||||
|
pg -d postgres -c 'SELECT pg_reload_conf()' >/dev/null
|
||||||
|
log "pg_hba.conf: added local chess chess peer (previous copy $saved)"
|
||||||
|
chess_can_connect || die "the chess account still cannot connect; check $hba"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 3. files ----------------------------------------------------------------
|
||||||
|
if "$SYSTEMCTL" is-active --quiet chessd 2>/dev/null; then
|
||||||
|
log "stopping chessd"
|
||||||
|
"$SYSTEMCTL" stop chessd
|
||||||
|
fi
|
||||||
|
|
||||||
|
install -d -m 0755 "$BIN_DIR" "$SBIN_DIR"
|
||||||
|
if [ -f "$bin" ] && cmp -s "$CHESS_BINARY" "$bin"; then
|
||||||
|
chown root:root "$bin"
|
||||||
|
chmod 0755 "$bin"
|
||||||
|
else
|
||||||
|
if [ -f "$bin" ]; then
|
||||||
|
log "keeping the previous binary as $bin.prev"
|
||||||
|
cp -p "$bin" "$bin.prev"
|
||||||
|
fi
|
||||||
|
install -o root -g root -m 0755 "$CHESS_BINARY" "$bin"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat >"$SBIN_DIR/chess-db.new" <<EOF
|
||||||
|
#!/bin/sh
|
||||||
|
# Runs \`chess-server db ...\` as the chess account against the chess database,
|
||||||
|
# e.g. chess-db user add -username alice. Installed by deploy/linux/setup.sh.
|
||||||
|
set -a
|
||||||
|
. "$env_file"
|
||||||
|
exec runuser -u $user -- env CHESS_DSN="\$CHESSD_DSN" "$bin" db "\$@"
|
||||||
|
EOF
|
||||||
|
chmod 0755 "$SBIN_DIR/chess-db.new"
|
||||||
|
mv "$SBIN_DIR/chess-db.new" "$SBIN_DIR/chess-db"
|
||||||
|
|
||||||
|
install -d -o root -g "$group" -m 0750 "$CONF_DIR"
|
||||||
|
if [ ! -f "$env_file" ]; then
|
||||||
|
proxies=$TRUSTED_PROXIES
|
||||||
|
[ "$proxies" = __keep__ ] && proxies=
|
||||||
|
log "writing $env_file"
|
||||||
|
cat >"$env_file" <<EOF
|
||||||
|
# chessd settings, read by chessd.service; restart chessd after a change.
|
||||||
|
CHESSD_HOST=$CHESSD_HOST
|
||||||
|
CHESSD_PORT=$CHESSD_PORT
|
||||||
|
# PostgreSQL over the Unix socket as the OS account chess (peer or trust).
|
||||||
|
CHESSD_DSN="$dsn"
|
||||||
|
CHESSD_JWT_KEY=$key
|
||||||
|
# Reverse proxy address(es), comma-separated, whose X-Real-IP is trusted.
|
||||||
|
CHESSD_TRUSTED_PROXIES=$proxies
|
||||||
|
# Extra server flags, e.g. -max-users 500 -db-cleanup report
|
||||||
|
CHESSD_FLAGS=
|
||||||
|
EOF
|
||||||
|
elif [ "$TRUSTED_PROXIES" != __keep__ ]; then
|
||||||
|
sed -i "s|^CHESSD_TRUSTED_PROXIES=.*|CHESSD_TRUSTED_PROXIES=$TRUSTED_PROXIES|" "$env_file"
|
||||||
|
log "$env_file: CHESSD_TRUSTED_PROXIES=$TRUSTED_PROXIES"
|
||||||
|
fi
|
||||||
|
chown root:"$group" "$env_file"
|
||||||
|
chmod 0640 "$env_file"
|
||||||
|
# The settings in effect, which may differ from this run's defaults.
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
settings=$(set -a && . "$env_file" && printf '%s|%s|%s|%s' \
|
||||||
|
"$CHESSD_HOST" "$CHESSD_PORT" "$CHESSD_TRUSTED_PROXIES" "$CHESSD_DSN")
|
||||||
|
IFS='|' read -r host port proxies dsn <<<"$settings"
|
||||||
|
[ -n "$proxies" ] || warn "no trusted proxy: behind a reverse proxy every client shares one rate-limit bucket (set TRUSTED_PROXIES)"
|
||||||
|
|
||||||
|
install -d -o "$user" -g "$group" -m 0700 "$STATE_DIR"
|
||||||
|
if [ ! -f "$key" ]; then
|
||||||
|
log "generating JWT signing key $key"
|
||||||
|
(umask 077 && head -c 48 /dev/urandom | base64 -w0 >"$key")
|
||||||
|
fi
|
||||||
|
chown "$user:$group" "$key"
|
||||||
|
chmod 0600 "$key"
|
||||||
|
|
||||||
|
install -o root -g root -m 0644 "$here/chessd.service" "$UNIT_DIR/chessd.service"
|
||||||
|
if [ "$ENABLE_BACKUP" = yes ]; then
|
||||||
|
install -o root -g root -m 0755 "$repo/deploy/postgresql/chess-backup.sh" "$SBIN_DIR/chess-backup"
|
||||||
|
install -o root -g root -m 0644 "$here/chess-backup.service" "$UNIT_DIR/chess-backup.service"
|
||||||
|
install -o root -g root -m 0644 "$here/chess-backup.timer" "$UNIT_DIR/chess-backup.timer"
|
||||||
|
fi
|
||||||
|
"$SYSTEMCTL" daemon-reload
|
||||||
|
|
||||||
|
log "creating or migrating the chess schema"
|
||||||
|
if [ "$SPLIT_PRIVILEGES" = yes ]; then
|
||||||
|
runuser -u postgres -- "$bin" db init \
|
||||||
|
-dsn "host=$socket dbname=chess user=postgres options='-c role=chess_owner -c search_path=chess'"
|
||||||
|
else
|
||||||
|
runuser -u "$user" -- "$bin" db init -dsn "$dsn"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 4. start ----------------------------------------------------------------
|
||||||
|
if [ "$ENABLE_BACKUP" = yes ]; then
|
||||||
|
"$SYSTEMCTL" enable --now chess-backup.timer
|
||||||
|
log "backups: chess-backup.timer -> $(getent passwd postgres | cut -d: -f6)/backups"
|
||||||
|
fi
|
||||||
|
"$SYSTEMCTL" enable chessd
|
||||||
|
"$SYSTEMCTL" start chessd
|
||||||
|
|
||||||
|
case "$host" in
|
||||||
|
0.0.0.0 | "" | ::) probe=127.0.0.1 ;;
|
||||||
|
*:*) probe="[$host]" ;;
|
||||||
|
*) probe=$host ;;
|
||||||
|
esac
|
||||||
|
url="http://$probe:$port/health"
|
||||||
|
health=
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
if health=$(curl -fsS --max-time 2 "$url" 2>/dev/null); then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
case "$health" in
|
||||||
|
*'"storage":"ok"'*) log "chessd is up: $health" ;;
|
||||||
|
*) die "chessd did not report healthy storage at $url (journalctl -u chessd): ${health:-no answer}" ;;
|
||||||
|
esac
|
||||||
|
log "done. Create an account with: chess-db user add -username <name>"
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
# Host nginx for chess: browsers (web and WASM clients) call
|
# nginx for chess: browsers (web and WASM clients) call <origin>/chess/api/...
|
||||||
# <origin>/chess/api/... and <origin>/chess/health, which map to the server's
|
# and <origin>/chess/health, which map to the server's /api/... and /health;
|
||||||
# /api/... and /health. Replace <jail-address> and <port> (chessd_port,
|
# /chess itself redirects to the project page. Replace <server-address> and
|
||||||
# default 8080).
|
# <port>: the FreeBSD jail's address and chessd_port, or 127.0.0.1 and
|
||||||
|
# CHESSD_PORT for a Linux install on the same host (default port 8080).
|
||||||
#
|
#
|
||||||
# chess-server keys rate limits on X-Real-IP, trusted only from the addresses
|
# chess-server keys rate limits on X-Real-IP, trusted only from the addresses
|
||||||
# in chessd_trusted_proxies: the address this nginx uses to reach the jail.
|
# in its trusted proxies (chessd_trusted_proxies or CHESSD_TRUSTED_PROXIES):
|
||||||
|
# the address this nginx connects from, as the server sees it.
|
||||||
# $remote_addr must be the real client: with PROXY protocol in front of nginx,
|
# $remote_addr must be the real client: with PROXY protocol in front of nginx,
|
||||||
# set real_ip_header proxy_protocol and set_real_ip_from for that hop.
|
# set real_ip_header proxy_protocol and set_real_ip_from for that hop.
|
||||||
|
|
||||||
# --- http {} context ---
|
# --- http {} context ---
|
||||||
upstream chess_api {
|
upstream chess_api {
|
||||||
server <jail-address>:<port>;
|
server <server-address>:<port>;
|
||||||
keepalive 16;
|
keepalive 16;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,10 +45,21 @@ location = /chess/health {
|
|||||||
proxy_read_timeout 10s;
|
proxy_read_timeout 10s;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Everything else under /chess is not served by the API.
|
# Short link: /chess and /chess/ redirect to the page that embeds the client
|
||||||
|
# (adjust /projects/chess/). 302 rather than 301: browsers cache a 301
|
||||||
|
# indefinitely, which would loop if the direction is ever reversed. A server
|
||||||
|
# listening on a non-default port (PROXY protocol or TLS passthrough behind a
|
||||||
|
# front end) needs `absolute_redirect off;` so the port does not leak into
|
||||||
|
# Location. If this server block also answers for other host names, guard with
|
||||||
|
# a host map so only the main site redirects.
|
||||||
location = /chess {
|
location = /chess {
|
||||||
return 404;
|
return 302 /projects/chess/$is_args$args;
|
||||||
}
|
}
|
||||||
|
location = /chess/ {
|
||||||
|
return 302 /projects/chess/$is_args$args;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Everything else under /chess is not served by the API.
|
||||||
location /chess/ {
|
location /chess/ {
|
||||||
return 404;
|
return 404;
|
||||||
}
|
}
|
||||||
Executable
+28
@@ -0,0 +1,28 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Nightly logical backup of the chess database, run as the postgres account.
|
||||||
|
# Installed as /usr/local/sbin/chess-backup by deploy/freebsd/setup-jail.sh
|
||||||
|
# (scheduled through /usr/local/etc/cron.d/chess-backup) and by
|
||||||
|
# deploy/linux/setup.sh (scheduled by the chess-backup.timer systemd unit).
|
||||||
|
#
|
||||||
|
# CHESS_BACKUP_DIR destination (default: backups/ in the postgres home,
|
||||||
|
# e.g. /var/db/postgres/backups on FreeBSD)
|
||||||
|
# CHESS_BACKUP_KEEP days of dumps to keep (default: 14)
|
||||||
|
#
|
||||||
|
# Restore into a freshly provisioned database as the postgres account:
|
||||||
|
# pg_restore --no-owner --role=chess -d chess <dump>
|
||||||
|
# (use --role=chess_owner for the split-privilege setup).
|
||||||
|
set -eu
|
||||||
|
PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin
|
||||||
|
|
||||||
|
dir=${CHESS_BACKUP_DIR:-${HOME:?}/backups}
|
||||||
|
keep=${CHESS_BACKUP_KEEP:-14}
|
||||||
|
umask 077
|
||||||
|
mkdir -p "$dir"
|
||||||
|
|
||||||
|
target="$dir/chess-$(date -u +%Y%m%dT%H%M%SZ).dump"
|
||||||
|
if ! pg_dump --format=custom --dbname=chess --file="$target.partial"; then
|
||||||
|
rm -f "$target.partial"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mv "$target.partial" "$target"
|
||||||
|
find "$dir" -name 'chess-*.dump' -type f -mtime "+$keep" -delete
|
||||||
+157
-13
@@ -157,6 +157,11 @@ Authorization: Bearer <token>
|
|||||||
|
|
||||||
Note: When authenticated, human player IDs match the user's ID. Anonymous players receive unique UUIDs.
|
Note: When authenticated, human player IDs match the user's ID. Anonymous players receive unique UUIDs.
|
||||||
|
|
||||||
|
A custom `fen` must describe a position that can arise in a game: one king
|
||||||
|
per side, no pawn on the first or last rank, and the side not to move not in
|
||||||
|
check; otherwise the response is 400 `INVALID_FEN` with the reason. Castling
|
||||||
|
rights whose king or rook has left its original square are dropped.
|
||||||
|
|
||||||
### Get Game
|
### Get Game
|
||||||
`GET /games/{gameId}`
|
`GET /games/{gameId}`
|
||||||
|
|
||||||
@@ -190,7 +195,8 @@ the game ID, matching the existing public live-game read model. Persistent
|
|||||||
storage must be enabled.
|
storage must be enabled.
|
||||||
|
|
||||||
The response contains the initial FEN and an ordered FEN after every move, so a
|
The response contains the initial FEN and an ordered FEN after every move, so a
|
||||||
client can replay the game without running a chess engine.
|
client can replay the game without running a chess engine. Each move also
|
||||||
|
carries its SAN, derived on read from the stored position before it.
|
||||||
|
|
||||||
**Response (200):**
|
**Response (200):**
|
||||||
```json
|
```json
|
||||||
@@ -198,6 +204,8 @@ client can replay the game without running a chess engine.
|
|||||||
"gameId": "a1b2c3d4-e5f6-7890-1234-567890abcdef",
|
"gameId": "a1b2c3d4-e5f6-7890-1234-567890abcdef",
|
||||||
"initialFen": "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
|
"initialFen": "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
|
||||||
"result": "white_wins",
|
"result": "white_wins",
|
||||||
|
"pgnResult": "1-0",
|
||||||
|
"termination": "checkmate",
|
||||||
"startTimeUtc": "2026-09-07T12:00:00Z",
|
"startTimeUtc": "2026-09-07T12:00:00Z",
|
||||||
"endTimeUtc": "2026-09-07T12:15:00Z",
|
"endTimeUtc": "2026-09-07T12:15:00Z",
|
||||||
"players": {
|
"players": {
|
||||||
@@ -208,6 +216,7 @@ client can replay the game without running a chess engine.
|
|||||||
{
|
{
|
||||||
"moveNumber": 1,
|
"moveNumber": 1,
|
||||||
"moveUci": "e2e4",
|
"moveUci": "e2e4",
|
||||||
|
"san": "e4",
|
||||||
"fenAfterMove": "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1",
|
"fenAfterMove": "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1",
|
||||||
"playerColor": "w",
|
"playerColor": "w",
|
||||||
"moveTimeUtc": "2026-09-07T12:00:05Z"
|
"moveTimeUtc": "2026-09-07T12:00:05Z"
|
||||||
@@ -217,28 +226,87 @@ client can replay the game without running a chess engine.
|
|||||||
```
|
```
|
||||||
|
|
||||||
`result` is omitted while a game is ongoing. Persisted terminal values are
|
`result` is omitted while a game is ongoing. Persisted terminal values are
|
||||||
`white_wins`, `black_wins`, `draw`, and `stalemate`. A claimed player's `name`
|
`white_wins`, `black_wins`, `draw`, and `stalemate`. `pgnResult` is the PGN
|
||||||
is their username when the claim was recorded; it survives later renames and
|
token for the same outcome (`1-0`, `0-1`, `1/2-1/2`, or `*` while ongoing),
|
||||||
|
and `termination` names how it was reached (see
|
||||||
|
[Game End](#game-end-draws-and-resignation); omitted while ongoing).
|
||||||
|
`moveNumber` counts plies from 1. `san` is omitted only for a stored move the
|
||||||
|
rules core cannot notate, which the server logs. A claimed player's `name` is
|
||||||
|
their username when the claim was recorded; it survives later renames and
|
||||||
account deletion, and is omitted for anonymous and computer players.
|
account deletion, and is omitted for anonymous and computer players.
|
||||||
|
|
||||||
|
Undo stays available after a result (a finished game can be rewound and
|
||||||
|
played on), so a stored game is never final. Responses carry a strong `ETag`
|
||||||
|
and `Cache-Control: private, no-cache`: send `If-None-Match` to get 304 while
|
||||||
|
nothing changed.
|
||||||
|
|
||||||
Returns 400 for a non-canonical game ID, 404 when the game has no durable
|
Returns 400 for a non-canonical game ID, 404 when the game has no durable
|
||||||
record, and 503 when persistence is disabled or degraded.
|
record, and 503 when persistence is disabled or degraded.
|
||||||
|
|
||||||
|
### Export PGN
|
||||||
|
`GET /games/{gameId}/pgn?ply=N`
|
||||||
|
|
||||||
|
Returns the stored game in PGN export format as
|
||||||
|
`application/x-chess-pgn; charset=utf-8` with
|
||||||
|
`Content-Disposition: attachment; filename="chess-<yyyymmdd>-<id8>.pgn"`.
|
||||||
|
Same visibility, `ETag`, and error responses as the history. `ply` (optional,
|
||||||
|
0 up to the number of stored plies) exports only the first N plies; the
|
||||||
|
result is then `*`, the `Termination` tag is left out, and the filename ends
|
||||||
|
`-ply<N>.pgn`. A `fetch` of the URL returns the text, so clients can copy it
|
||||||
|
as well as download it.
|
||||||
|
|
||||||
|
```
|
||||||
|
[Event "Casual game"]
|
||||||
|
[Site "?"]
|
||||||
|
[Date "2026.09.27"]
|
||||||
|
[Round "-"]
|
||||||
|
[White "alice"]
|
||||||
|
[Black "Stockfish level 3"]
|
||||||
|
[Result "0-1"]
|
||||||
|
[GameId "68007fd1-8970-4d84-950d-2f6ec6375c0b"]
|
||||||
|
[UTCDate "2026.09.27"]
|
||||||
|
[UTCTime "08:07:00"]
|
||||||
|
[WhiteType "human"]
|
||||||
|
[BlackType "program"]
|
||||||
|
[PlyCount "4"]
|
||||||
|
[Termination "normal"]
|
||||||
|
|
||||||
|
1. f3 e5 2. g4 Qh4# 0-1
|
||||||
|
```
|
||||||
|
|
||||||
|
The movetext of a finished game ends with a comment naming the outcome
|
||||||
|
before the result, e.g. `{ Black wins by resignation. } 0-1` or
|
||||||
|
`{ Draw by threefold repetition. } 1/2-1/2`.
|
||||||
|
|
||||||
|
Players are the name snapshot, `Anonymous`, or `Stockfish level N`. A game
|
||||||
|
from a custom position adds `SetUp "1"` and `FEN` tags, and its movetext
|
||||||
|
starts at that position's move number (`55. g8=Q` or `40... Kd8`).
|
||||||
|
Movetext wraps at 80 columns. `Termination` is `normal` for a finished game
|
||||||
|
and `unterminated` otherwise.
|
||||||
|
|
||||||
### List My Stored Games
|
### List My Stored Games
|
||||||
`GET /users/me/games?limit=50&offset=0`
|
`GET /users/me/games?limit=50&cursor=<nextCursor>&status=finished&color=white`
|
||||||
|
|
||||||
Returns games associated with the authenticated user at creation time or by a
|
Returns games associated with the authenticated user at creation time or by a
|
||||||
later first-move slot claim. Requires `Authorization: Bearer <token>` and
|
later first-move slot claim, newest first. Requires
|
||||||
persistent storage.
|
`Authorization: Bearer <token>` and persistent storage.
|
||||||
|
|
||||||
- `limit`: 1-100; defaults to 50
|
- `limit`: 1-100; defaults to 50
|
||||||
- `offset`: 0-1,000,000; defaults to 0
|
- `cursor`: the previous page's `nextCursor`; continues after that game, so
|
||||||
|
games created meanwhile do not shift the page. Opaque; do not construct it.
|
||||||
|
- `status`: `ongoing` or `finished`
|
||||||
|
- `color`: `white` or `black`, the side the user claimed
|
||||||
|
- `offset`: 0-1,000,000; position-based paging for older clients. It cannot
|
||||||
|
be combined with `cursor`, and pages shift when games are added.
|
||||||
|
|
||||||
Each item contains game ID, initial FEN, final FEN (after the last stored move,
|
Each item contains game ID, initial FEN, final FEN (after the last stored move,
|
||||||
or the initial FEN when there is none), result/timestamps, players, and move
|
or the initial FEN when there is none), result and its `pgnResult` token,
|
||||||
count. `nextOffset` is present only when another page exists. A game belongs to
|
timestamps, players, and move count. `nextCursor` is present only when another
|
||||||
a user who created it while authenticated or claimed a slot with a first move;
|
page exists; `nextOffset` also, on requests without a cursor. A game belongs
|
||||||
the association survives later player reconfiguration.
|
to a user who created it while authenticated or claimed a slot with a first
|
||||||
|
move; the association survives later player reconfiguration.
|
||||||
|
|
||||||
|
Returns 400 `INVALID_REQUEST` for a malformed cursor or filter.
|
||||||
|
|
||||||
**Response (200):**
|
**Response (200):**
|
||||||
```json
|
```json
|
||||||
@@ -249,6 +317,7 @@ the association survives later player reconfiguration.
|
|||||||
"initialFen": "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
|
"initialFen": "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
|
||||||
"finalFen": "r1bqkb1r/pppp1Qpp/2n2n2/4p3/2B1P3/8/PPPP1PPP/RNB1K1NR b KQkq - 0 4",
|
"finalFen": "r1bqkb1r/pppp1Qpp/2n2n2/4p3/2B1P3/8/PPPP1PPP/RNB1K1NR b KQkq - 0 4",
|
||||||
"result": "white_wins",
|
"result": "white_wins",
|
||||||
|
"pgnResult": "1-0",
|
||||||
"startTimeUtc": "2026-09-07T12:00:00Z",
|
"startTimeUtc": "2026-09-07T12:00:00Z",
|
||||||
"endTimeUtc": "2026-09-07T12:15:00Z",
|
"endTimeUtc": "2026-09-07T12:15:00Z",
|
||||||
"moveCount": 7,
|
"moveCount": 7,
|
||||||
@@ -259,7 +328,8 @@ the association survives later player reconfiguration.
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"limit": 50,
|
"limit": 50,
|
||||||
"offset": 0
|
"offset": 0,
|
||||||
|
"nextCursor": "MTc5MDQ5NjQyMDU4MTg2NS42ODAwN2ZkMS04OTcwLTRkODQtOTUwZC0yZjZlYzYzNzVjMGI"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -273,15 +343,89 @@ Submits human move or triggers computer move.
|
|||||||
{"move": "e2e4"}
|
{"move": "e2e4"}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Moves are UCI: castling is the king's two-square move (`e1g1`), and a
|
||||||
|
promotion appends the piece (`e7e8q`, `r`, `b`, or `n`). A pawn move to the
|
||||||
|
last rank without the piece returns 400 `INVALID_MOVE` with
|
||||||
|
`promotion piece required`.
|
||||||
|
|
||||||
**Computer move trigger:**
|
**Computer move trigger:**
|
||||||
```json
|
```json
|
||||||
{"move": "cccc"}
|
{"move": "cccc"}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
A move that ends the game (mate, stalemate, or an automatic draw) returns the
|
||||||
|
terminal state and `termination` in the same response.
|
||||||
|
|
||||||
|
### Game End: Draws and Resignation
|
||||||
|
|
||||||
|
A live game (`GET /games/{gameId}` and every game response) carries:
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `state` | `ongoing`, `pending` (computer thinking), `stuck` (engine failed), `white wins`, `black wins`, `stalemate`, or `draw` |
|
||||||
|
| `termination` | How a finished game ended: `checkmate`, `resignation`, `stalemate`, `insufficient_material`, `threefold_repetition`, `fifty_move_rule`, or `agreement`; omitted while unfinished |
|
||||||
|
| `drawOffer` | `w` or `b`: that side's draw offer awaits an answer; omitted when none |
|
||||||
|
| `drawOutcome` | Only on responses to `POST .../draw`: `offered`, `accepted`, or `declined` |
|
||||||
|
|
||||||
|
**Automatic draws.** After every move the game is drawn, without a claim, as
|
||||||
|
on most online servers, when:
|
||||||
|
|
||||||
|
- neither side can mate: bare kings, one minor piece, or only bishops all on
|
||||||
|
squares of one color (two knights do not count: mate is possible);
|
||||||
|
- the position occurs for the third time with the same side to move,
|
||||||
|
castling rights, and en-passant possibility (threefold repetition);
|
||||||
|
- 50 moves by each side pass without a capture or pawn move (the halfmove
|
||||||
|
clock reaches 100).
|
||||||
|
|
||||||
|
Mate and stalemate take precedence. A custom starting position that is
|
||||||
|
already dead, or whose halfmove clock is at 100, is created drawn.
|
||||||
|
|
||||||
|
#### Resign
|
||||||
|
`POST /games/{gameId}/resign`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"color": "w"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ends the game in the other side's favor, with `termination: "resignation"`.
|
||||||
|
`color` (`w`, `b`, `white`, `black`) may be omitted when the server can infer
|
||||||
|
the side: the one side the authenticated caller claimed, else the only human
|
||||||
|
side. Allowed while the game is unfinished, including while the computer is
|
||||||
|
thinking (its move is discarded) or the engine is stuck.
|
||||||
|
|
||||||
|
#### Offer, accept, or decline a draw
|
||||||
|
`POST /games/{gameId}/draw`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"action": "offer", "color": "b"}
|
||||||
|
```
|
||||||
|
|
||||||
|
`action` is `offer`, `accept`, or `decline`; `color` works as for resign.
|
||||||
|
Allowed only while `state` is `ongoing`.
|
||||||
|
|
||||||
|
- **To a human:** the offer stands (`drawOffer`) until the opponent accepts,
|
||||||
|
declines, or makes a move instead, which declines it; the offerer's own
|
||||||
|
move keeps it. Offering while the opponent's offer stands accepts it.
|
||||||
|
Repeating a standing offer is a no-op. Waiting long-poll clients are woken
|
||||||
|
on offers and answers.
|
||||||
|
- **To the computer:** answered at once in `drawOutcome`. The computer
|
||||||
|
declines before both sides have made ten moves; after that it accepts when
|
||||||
|
a short full-strength search rates its own side level or worse.
|
||||||
|
- **One offer per move:** after an offer is declined, the same side must make
|
||||||
|
a move before offering again (409 `GAME_CONFLICT`).
|
||||||
|
|
||||||
|
Resign and draw share the move endpoint's authorization: a side claimed by a
|
||||||
|
user acts only for that user (403 `UNAUTHORIZED`), and an authenticated
|
||||||
|
resignation or acceptance claims an unclaimed side as a first move would.
|
||||||
|
Other errors are 400: `GAME_OVER` for a finished game, `INVALID_REQUEST` for
|
||||||
|
an ambiguous or computer side, a missing offer, or a game that is not
|
||||||
|
ongoing. Both return the game.
|
||||||
|
|
||||||
### Undo Moves
|
### Undo Moves
|
||||||
`POST /games/{gameId}/undo`
|
`POST /games/{gameId}/undo`
|
||||||
|
|
||||||
Reverts moves from history.
|
Reverts moves from history. Undo stays available after a result, including a
|
||||||
|
resignation or an agreed draw: it rewinds the result and any draw offer.
|
||||||
|
|
||||||
### Configure Players
|
### Configure Players
|
||||||
`PUT /games/{gameId}/players`
|
`PUT /games/{gameId}/players`
|
||||||
|
|||||||
+59
-14
@@ -57,7 +57,14 @@ schema is current, which lets a DML-only runtime role start the server.
|
|||||||
### Supporting Modules
|
### Supporting Modules
|
||||||
- **Engine** (`internal/engine`): UCI protocol wrapper for Stockfish process communication
|
- **Engine** (`internal/engine`): UCI protocol wrapper for Stockfish process communication
|
||||||
- **Game** (`internal/game`): Game state with snapshot history and player associations
|
- **Game** (`internal/game`): Game state with snapshot history and player associations
|
||||||
- **Board** (`internal/board`): FEN parsing and ASCII generation
|
- **Rules core** (`internal/server/chess`): dependency-free position model,
|
||||||
|
legal move generation, SAN, PGN, ASCII board, dead-material and repetition
|
||||||
|
detection. It validates custom starting FENs, applies the automatic draw
|
||||||
|
rules, and shadows the engine on every live move; Stockfish stays the
|
||||||
|
authority for move legality, mate, and stalemate
|
||||||
|
- **Replay** (`internal/server/replay`): works on stored game records: SAN
|
||||||
|
for every ply, PGN assembly with the termination comment, and verification
|
||||||
|
against the rules (used by `db verify`, `db pgn`, and the integrity sweep)
|
||||||
- **Core** (`internal/core`): Shared types, API models, error constants
|
- **Core** (`internal/core`): Shared types, API models, error constants
|
||||||
- **CLI** (`cmd/chess-server/cli`): Database and user management commands
|
- **CLI** (`cmd/chess-server/cli`): Database and user management commands
|
||||||
- **Client** (`cmd/chess-client-cli`, `internal/client`): Interactive debugging client with command registry, session management, and colored terminal output
|
- **Client** (`cmd/chess-client-cli`, `internal/client`): Interactive debugging client with command registry, session management, and colored terminal output
|
||||||
@@ -84,19 +91,41 @@ schema is current, which lets a DML-only runtime role start the server.
|
|||||||
1. HTTP handler receives `POST /games/{id}/moves` with move
|
1. HTTP handler receives `POST /games/{id}/moves` with move
|
||||||
2. Optional JWT validation for user verification
|
2. Optional JWT validation for user verification
|
||||||
3. Creates MakeMoveCommand, calls `processor.Execute()`
|
3. Creates MakeMoveCommand, calls `processor.Execute()`
|
||||||
4. Processor validates move via locked validation engine
|
4. The rules core checks the move first only to name a missing promotion
|
||||||
5. If legal, gets new FEN from engine
|
piece; the processor then validates it via the locked validation engine
|
||||||
6. Calls `service.ApplyMoveWithState()` with the FEN, turn, and state that were validated
|
5. If legal, gets new FEN from engine, and logs a warning if the rules core
|
||||||
7. Service rejects a stale concurrent commit or atomically updates the move, optional slot claim, and terminal result
|
disagrees on legality or the resulting position
|
||||||
8. The same logical mutation is queued as one database transaction
|
6. If neither mate nor stalemate, the processor applies the automatic draw
|
||||||
9. Returns GameResponse
|
rules to the new position and the game's position history: dead material,
|
||||||
|
threefold repetition, fifty-move rule
|
||||||
|
7. Calls `service.ApplyMoveWithState()` with the FEN, turn, state, and
|
||||||
|
termination that were decided
|
||||||
|
8. Service rejects a stale concurrent commit or atomically updates the move,
|
||||||
|
optional slot claim, terminal result, and termination; a move by the
|
||||||
|
recipient of a draw offer declines it
|
||||||
|
9. The same logical mutation is queued as one database transaction
|
||||||
|
10. Returns GameResponse
|
||||||
|
|
||||||
|
### Resignation and Draw Agreement
|
||||||
|
1. `POST /games/{id}/resign` or `/draw` with an optional side
|
||||||
|
2. The processor resolves the acting side (requested, else the caller's one
|
||||||
|
claimed side, else the only human side) and checks slot ownership
|
||||||
|
3. A draw offer to a human is recorded on the game and wakes long-polls; an
|
||||||
|
offer to a computer is answered at once from a short full-strength search
|
||||||
|
on the validation engine (after twenty plies; accept when level or worse
|
||||||
|
for the computer); one offer per own move
|
||||||
|
4. `service.EndGame()` re-checks under its lock that the game is unfinished,
|
||||||
|
unchanged, and the accepted offer still stands, then sets result and
|
||||||
|
termination, claims an empty acting side for an authenticated caller, and
|
||||||
|
queues one transaction
|
||||||
|
|
||||||
### Computer Move
|
### Computer Move
|
||||||
1. HTTP handler receives `POST /games/{id}/moves` with `{"move": "cccc"}`
|
1. HTTP handler receives `POST /games/{id}/moves` with `{"move": "cccc"}`
|
||||||
2. Processor sets game state to `pending`
|
2. Processor sets game state to `pending`
|
||||||
3. Submits task to EngineQueue, returns immediately
|
3. Submits task to EngineQueue, returns immediately
|
||||||
4. Worker goroutine calculates move with dedicated Stockfish instance
|
4. Worker goroutine calculates move with dedicated Stockfish instance
|
||||||
5. Callback updates game state via service
|
5. Callback re-validates the move on the validation engine, applies the
|
||||||
|
automatic draw rules, and commits via the service
|
||||||
6. Client polls for completion
|
6. Client polls for completion
|
||||||
7. Returns GameResponse
|
7. Returns GameResponse
|
||||||
|
|
||||||
@@ -115,8 +144,10 @@ schema is current, which lets a DML-only runtime role start the server.
|
|||||||
2. Storage queues a barrier after all previously accepted gameplay writes
|
2. Storage queues a barrier after all previously accepted gameplay writes
|
||||||
3. The writer reaches the barrier only after those transactions finish
|
3. The writer reaches the barrier only after those transactions finish
|
||||||
4. Storage reads the game row and ordered moves in one REPEATABLE READ transaction
|
4. Storage reads the game row and ordered moves in one REPEATABLE READ transaction
|
||||||
5. The API returns the initial FEN plus every UCI move and resulting FEN
|
5. The replay package derives SAN for each ply from the stored FEN before it
|
||||||
6. This path works after terminal-memory eviction or a server restart
|
6. The API returns the initial FEN plus every UCI move, its SAN, and the
|
||||||
|
resulting FEN, with a strong ETag; `/pgn` renders the same read as PGN
|
||||||
|
7. This path works after terminal-memory eviction or a server restart
|
||||||
|
|
||||||
## Persistence Flow
|
## Persistence Flow
|
||||||
|
|
||||||
@@ -206,7 +237,7 @@ Commands encapsulate operations with type, arguments, and optional user ID for a
|
|||||||
### Player Configuration
|
### Player Configuration
|
||||||
Players identified by UUID (authenticated users) or generated IDs (anonymous), configured with type (human/computer), skill level, and search time.
|
Players identified by UUID (authenticated users) or generated IDs (anonymous), configured with type (human/computer), skill level, and search time.
|
||||||
|
|
||||||
### Storage Schema (version 1)
|
### Storage Schema (version 2)
|
||||||
```sql
|
```sql
|
||||||
users (
|
users (
|
||||||
user_id uuid PRIMARY KEY,
|
user_id uuid PRIMARY KEY,
|
||||||
@@ -236,6 +267,9 @@ games (
|
|||||||
black_... (same columns),
|
black_... (same columns),
|
||||||
start_time_utc timestamptz NOT NULL,
|
start_time_utc timestamptz NOT NULL,
|
||||||
result text, -- white_wins, black_wins, draw, stalemate
|
result text, -- white_wins, black_wins, draw, stalemate
|
||||||
|
termination text, -- how: checkmate, resignation, stalemate,
|
||||||
|
-- insufficient_material, threefold_repetition,
|
||||||
|
-- fifty_move_rule, agreement
|
||||||
end_time_utc timestamptz, -- set exactly when result is set
|
end_time_utc timestamptz, -- set exactly when result is set
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -250,7 +284,9 @@ moves (
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
`schema_version` records the applied version. Indexes cover session expiry,
|
`schema_version` records the applied version; version 2 added
|
||||||
|
`termination`, constrained to be set exactly with `result` and to fit it
|
||||||
|
(wins by checkmate or resignation, draws by a draw rule or agreement). Indexes cover session expiry,
|
||||||
each claim column ordered by `(start_time_utc DESC, game_id DESC)` for the game
|
each claim column ordered by `(start_time_utc DESC, game_id DESC)` for the game
|
||||||
listing, and the start time of unclaimed games for the anonymous-game purge.
|
listing, and the start time of unclaimed games for the anonymous-game purge.
|
||||||
Claims and player IDs deliberately have no foreign key to `users`: history
|
Claims and player IDs deliberately have no foreign key to `users`: history
|
||||||
@@ -264,9 +300,18 @@ outlives deleted accounts, and the name snapshot keeps it readable.
|
|||||||
hourly cleanup evicts idle games first, then queues the delete through the
|
hourly cleanup evicts idle games first, then queues the delete through the
|
||||||
ordered writer while excluding every game still loaded, so no live game can
|
ordered writer while excluding every game still loaded, so no live game can
|
||||||
lose its row.
|
lose its row.
|
||||||
|
- A write whose game row was deleted by hand fails only that game: the
|
||||||
|
writer drops it and the service unloads the game, instead of degrading
|
||||||
|
storage for everyone.
|
||||||
|
- With `-db-cleanup report|delete`, the hourly cleanup also runs an integrity
|
||||||
|
sweep: loaded games without a row, games with missing plies, stored games
|
||||||
|
failing verification (200 per run, resuming in ID order), games whose
|
||||||
|
registered players were all deleted, and accounts with unusable password
|
||||||
|
hashes. `report` only logs; `delete` unloads and removes them.
|
||||||
|
|
||||||
See [`deploy/postgresql`](../deploy/postgresql) for provisioning and
|
See [`deploy/postgresql`](../deploy/postgresql) for provisioning,
|
||||||
[deployment.md](./deployment.md) for the jail service.
|
[deployment.md](./deployment.md) for the jail service, and
|
||||||
|
[deployment-linux.md](./deployment-linux.md) for systemd.
|
||||||
|
|
||||||
## Security Architecture
|
## Security Architecture
|
||||||
|
|
||||||
|
|||||||
+46
-6
@@ -14,11 +14,11 @@ The chess client is an interactive command-line debugging tool for the chess ser
|
|||||||
|
|
||||||
## Replay Foundation
|
## Replay Foundation
|
||||||
|
|
||||||
The Go API client exposes `GetGameHistory(gameID)` and
|
The Go API client exposes `GetGameHistory(gameID)`, `GetGamePGN(gameID, ply)`,
|
||||||
`GetMyGames(limit, offset)`, including durable result, timestamps, player
|
and `GetMyGames(limit, cursor)`, including durable result, timestamps, player
|
||||||
claims, move count, ordered UCI moves, and FEN after every move. Interactive
|
claims, move count, ordered UCI and SAN moves, and FEN after every move. The
|
||||||
`games`/`replay` commands and terminal playback controls are intentionally
|
`games` and `pgn` commands use them; step-by-step `replay` controls are still
|
||||||
deferred to the dedicated replay iteration; see [Replay Implementation Tasks](./todo.md).
|
planned, see [Replay Implementation Tasks](./todo.md).
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
```bash
|
```bash
|
||||||
@@ -104,10 +104,12 @@ chess > join a1b2c3d4-e5f6-7890-1234-567890abcdef
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### `move` / `m`
|
#### `move` / `m`
|
||||||
Make chess move in UCI notation.
|
Make chess move in UCI notation. Castling is the king's move (`e1g1`); a
|
||||||
|
promotion appends the piece letter (`q`, `r`, `b`, or `n`).
|
||||||
```
|
```
|
||||||
chess > move e2e4
|
chess > move e2e4
|
||||||
chess > move e7e5
|
chess > move e7e5
|
||||||
|
chess > move g7g8q
|
||||||
```
|
```
|
||||||
|
|
||||||
#### `computer` / `c`
|
#### `computer` / `c`
|
||||||
@@ -148,6 +150,44 @@ Long-poll for game updates (waits up to 30 seconds).
|
|||||||
chess > poll
|
chess > poll
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### `resign`
|
||||||
|
Resign the current game. The side may be omitted when you play one human side
|
||||||
|
(or claimed one); name it in a hot-seat game.
|
||||||
|
```
|
||||||
|
chess > resign
|
||||||
|
chess > resign b
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `draw`
|
||||||
|
Offer (the default), accept, or decline a draw. A computer answers an offer
|
||||||
|
at once; a human opponent sees the offer in their next response, and it
|
||||||
|
lapses if they move instead of answering.
|
||||||
|
```
|
||||||
|
chess > draw # offer
|
||||||
|
chess > draw accept b
|
||||||
|
chess > draw decline w
|
||||||
|
```
|
||||||
|
|
||||||
|
Game outcomes print with how they were reached, e.g. `DRAW by threefold
|
||||||
|
repetition.` or `Black wins by resignation.`; a pending offer is shown as
|
||||||
|
`White offers a draw`.
|
||||||
|
|
||||||
|
#### `games` / `g`
|
||||||
|
List your stored games, newest first, 20 per page (requires login). The
|
||||||
|
result column is the PGN token (`1-0`, `0-1`, `1/2-1/2`, `*`).
|
||||||
|
```
|
||||||
|
chess > games # Newest games
|
||||||
|
chess > games more # Next page
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `pgn`
|
||||||
|
Print a stored game as PGN (SAN movetext, standard tags).
|
||||||
|
```
|
||||||
|
chess > pgn # Current game, every ply
|
||||||
|
chess > pgn 10 # Current game, first 10 plies
|
||||||
|
chess > pgn <gameId> [ply] # Another game
|
||||||
|
```
|
||||||
|
|
||||||
### Debug Commands
|
### Debug Commands
|
||||||
|
|
||||||
#### `health` / `.`
|
#### `health` / `.`
|
||||||
|
|||||||
+309
@@ -0,0 +1,309 @@
|
|||||||
|
# Database Operations
|
||||||
|
|
||||||
|
`psql` recipes for inspecting and maintaining the chess database as the
|
||||||
|
PostgreSQL administrator. Installation is in [deployment.md](deployment.md)
|
||||||
|
(FreeBSD jail) and [deployment-linux.md](deployment-linux.md) (Linux with
|
||||||
|
systemd).
|
||||||
|
|
||||||
|
## Typical Layout
|
||||||
|
|
||||||
|
The server expects one database with its tables in one schema of the same
|
||||||
|
name, owned by a role that exists only for it:
|
||||||
|
|
||||||
|
| Object | Name | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Database | `chess` | Owned by `postgres`; only the `chess` role may connect |
|
||||||
|
| Schema | `chess` | All tables; `public` is locked down and stays empty |
|
||||||
|
| Login role | `chess` | No password, no superuser, create-database, or create-role rights; 20 connections |
|
||||||
|
| Owner role (split mode only) | `chess_owner` | Cannot log in; owns the schema while `chess` only reads and writes rows |
|
||||||
|
|
||||||
|
The service runs as an OS account named `chess` and connects over the local
|
||||||
|
Unix socket with **peer** authentication, which maps the OS account to the
|
||||||
|
role of the same name, so no password exists anywhere. The `chess` role has
|
||||||
|
its `search_path` set to `chess` in this database, so the service never names
|
||||||
|
the schema. [`deploy/postgresql/setup.sql`](../deploy/postgresql/setup.sql)
|
||||||
|
creates all of this; the deployment scripts run it once.
|
||||||
|
|
||||||
|
Administration uses the `postgres` superuser account, which keeps the
|
||||||
|
distribution's defaults. Its `search_path` (`"$user", public`) does not
|
||||||
|
include `chess`, so **every recipe names the schema explicitly** (`chess.games`).
|
||||||
|
This keeps other databases and tools on the same server unaffected, and a
|
||||||
|
statement typed into the wrong database fails instead of acting on whatever
|
||||||
|
it finds.
|
||||||
|
|
||||||
|
## Connecting
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Linux
|
||||||
|
sudo -u postgres psql -X -d chess
|
||||||
|
# FreeBSD jail
|
||||||
|
su -m postgres -c 'psql -X -d chess'
|
||||||
|
```
|
||||||
|
|
||||||
|
Working with the `chess` schema as `postgres`:
|
||||||
|
|
||||||
|
| Goal | How |
|
||||||
|
|---|---|
|
||||||
|
| List the tables | `\dt chess.*` (a plain `\dt` shows `public`, which is empty) |
|
||||||
|
| Describe a table | `\d chess.games` |
|
||||||
|
| List schemas and owners | `\dn+` |
|
||||||
|
| Query | `SELECT ... FROM chess.games` |
|
||||||
|
| Unqualified names for one session | `SET search_path = chess;` (lasts until you disconnect) |
|
||||||
|
| Unqualified names for one invocation | `sudo -u postgres env PGOPTIONS='-c search_path=chess' psql -X -d chess` |
|
||||||
|
|
||||||
|
Neither of the last two changes anything stored: the next session starts with
|
||||||
|
the defaults again. Avoid `ALTER ROLE postgres ... SET search_path`, which
|
||||||
|
would persist.
|
||||||
|
|
||||||
|
The tables:
|
||||||
|
|
||||||
|
| Table | Contents |
|
||||||
|
|---|---|
|
||||||
|
| `chess.schema_version` | One row: the applied migration |
|
||||||
|
| `chess.users` | Accounts: lowercase username and email, Argon2id password hash |
|
||||||
|
| `chess.sessions` | One active session per user; deleting it signs the user out |
|
||||||
|
| `chess.games` | Players, claims (`*_claimed_by`), name snapshots, `result`, `termination`, times |
|
||||||
|
| `chess.moves` | Ply `move_number` 1..n, UCI move, FEN after the move |
|
||||||
|
|
||||||
|
`result` is `white_wins`, `black_wins`, `stalemate`, or `draw`, and
|
||||||
|
`termination` says how: `checkmate`, `resignation`, `stalemate`,
|
||||||
|
`insufficient_material`, `threefold_repetition`, `fifty_move_rule`, or
|
||||||
|
`agreement`. Both are empty while a game is unfinished.
|
||||||
|
|
||||||
|
`\set g '<game-id>'` stores a game ID for the `:'g'` references below.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT version, updated_at FROM chess.schema_version;
|
||||||
|
|
||||||
|
SELECT (SELECT count(*) FROM chess.users) AS users,
|
||||||
|
(SELECT count(*) FROM chess.games) AS games,
|
||||||
|
(SELECT count(*) FROM chess.games WHERE result IS NULL) AS unfinished,
|
||||||
|
(SELECT count(*) FROM chess.moves) AS plies,
|
||||||
|
(SELECT count(*) FROM chess.sessions WHERE expires_at > now()) AS signed_in;
|
||||||
|
|
||||||
|
SELECT coalesce(termination, 'unfinished') AS termination, count(*)
|
||||||
|
FROM chess.games GROUP BY 1 ORDER BY 2 DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Users
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT user_id, username, email, created_at, last_login_at
|
||||||
|
FROM chess.users ORDER BY created_at;
|
||||||
|
```
|
||||||
|
|
||||||
|
Passwords are not stored. `password_hash` is an Argon2id string in PHC
|
||||||
|
format, `$argon2id$v=19$m=<KiB>,t=<passes>,p=<lanes>$<salt>$<hash>`, from
|
||||||
|
which the password cannot be recovered. Show the parameters without the
|
||||||
|
secret parts:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT username, split_part(password_hash, '$', 2) AS algorithm,
|
||||||
|
split_part(password_hash, '$', 4) AS parameters
|
||||||
|
FROM chess.users;
|
||||||
|
```
|
||||||
|
|
||||||
|
Create, reset, and rename accounts with the server's CLI, not SQL: it
|
||||||
|
normalizes names, enforces uniqueness rules, and hashes passwords. Without
|
||||||
|
`-password` it prompts, which keeps the password out of the shell history and
|
||||||
|
the process list.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Linux: chess-db runs `chess-server db ...` as the chess account
|
||||||
|
sudo chess-db user add -username <name> [-email <addr>]
|
||||||
|
sudo chess-db user set-password -username <name>
|
||||||
|
sudo chess-db user list
|
||||||
|
# FreeBSD jail
|
||||||
|
su -m chess -c '/home/chess/bin/chess-server db user add -username <name> -dsn "postgres:///chess?host=/tmp"'
|
||||||
|
```
|
||||||
|
|
||||||
|
Other `user` subcommands: `delete`, `set-email`, `set-username`, and
|
||||||
|
`set-hash` (an existing PHC string). CLI-created accounts are identical to
|
||||||
|
site registrations, and the CLI ignores the registration cap.
|
||||||
|
|
||||||
|
Sign a user out everywhere (takes effect on their next request):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
DELETE FROM chess.sessions
|
||||||
|
WHERE user_id = (SELECT user_id FROM chess.users WHERE username = 'alice');
|
||||||
|
```
|
||||||
|
|
||||||
|
Deleting a user removes their session; their games keep the claim and the
|
||||||
|
name snapshot. Games left with no remaining registered player are removed by
|
||||||
|
the optional integrity sweep (below) once idle for a day.
|
||||||
|
|
||||||
|
## Games
|
||||||
|
|
||||||
|
Recent games with players, outcome, and length:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT g.game_id, g.start_time_utc,
|
||||||
|
coalesce(g.white_name, CASE g.white_type WHEN 2 THEN 'Stockfish L' || g.white_level ELSE 'anonymous' END) AS white,
|
||||||
|
coalesce(g.black_name, CASE g.black_type WHEN 2 THEN 'Stockfish L' || g.black_level ELSE 'anonymous' END) AS black,
|
||||||
|
coalesce(g.result || ' by ' || g.termination, 'unfinished') AS outcome,
|
||||||
|
(SELECT count(*) FROM chess.moves m WHERE m.game_id = g.game_id) AS plies
|
||||||
|
FROM chess.games g ORDER BY g.start_time_utc DESC LIMIT 20;
|
||||||
|
```
|
||||||
|
|
||||||
|
A user's games: add
|
||||||
|
`WHERE (SELECT user_id FROM chess.users WHERE username = 'alice') IN (g.white_claimed_by, g.black_claimed_by)`.
|
||||||
|
Find a game from the 8-digit prefix that `db query` prints:
|
||||||
|
`SELECT game_id FROM chess.games WHERE game_id::text LIKE '68007fd1%';`.
|
||||||
|
|
||||||
|
One game, header and every ply with its FEN:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
\set g '68007fd1-8970-4d84-950d-2f6ec6375c0b'
|
||||||
|
SELECT * FROM chess.games WHERE game_id = :'g' \gx
|
||||||
|
SELECT move_number AS ply, player_color, move_uci, fen_after_move, move_time_utc
|
||||||
|
FROM chess.moves WHERE game_id = :'g' ORDER BY move_number;
|
||||||
|
```
|
||||||
|
|
||||||
|
Final position:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT coalesce(
|
||||||
|
(SELECT fen_after_move FROM chess.moves WHERE game_id = :'g' ORDER BY move_number DESC LIMIT 1),
|
||||||
|
(SELECT initial_fen FROM chess.games WHERE game_id = :'g')) AS final_fen;
|
||||||
|
```
|
||||||
|
|
||||||
|
The move line in UCI with move numbers (numbering follows the initial FEN, so
|
||||||
|
a game that starts with Black to move begins `N...`):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
WITH s AS (SELECT split_part(initial_fen, ' ', 2) AS side,
|
||||||
|
split_part(initial_fen, ' ', 6)::int AS fullmove
|
||||||
|
FROM chess.games WHERE game_id = :'g')
|
||||||
|
SELECT string_agg(
|
||||||
|
CASE WHEN m.player_color = 'w'
|
||||||
|
THEN (s.fullmove + (m.move_number - CASE s.side WHEN 'w' THEN 1 ELSE 0 END) / 2) || '. '
|
||||||
|
WHEN m.move_number = 1 THEN s.fullmove || '... '
|
||||||
|
ELSE '' END || m.move_uci,
|
||||||
|
' ' ORDER BY m.move_number) AS uci_line
|
||||||
|
FROM chess.moves m, s WHERE m.game_id = :'g';
|
||||||
|
```
|
||||||
|
|
||||||
|
SQL has no SAN; the database stores UCI and notation is derived on read. For
|
||||||
|
real PGN use the CLI (a full ID or 8-digit prefix; `-ply N` stops after N
|
||||||
|
plies) or the API:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo chess-db pgn -gameId 68007fd1 # Linux
|
||||||
|
su -m chess -c '/home/chess/bin/chess-server db pgn -gameId 68007fd1 -dsn "postgres:///chess?host=/tmp"' # FreeBSD
|
||||||
|
curl -s https://<site>/chess/api/games/<game-id>/pgn
|
||||||
|
```
|
||||||
|
|
||||||
|
`db verify` checks every stored game against the rules (move numbering, each
|
||||||
|
move legal from the stored position before it, the stored position after it
|
||||||
|
reproduced, the result and its termination consistent with the final
|
||||||
|
position) and exits non-zero on any problem:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo chess-db verify
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deleting games
|
||||||
|
|
||||||
|
Delete finished or unfinished games at any time:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
BEGIN;
|
||||||
|
DELETE FROM chess.games WHERE game_id = :'g'; -- its moves are removed by cascade
|
||||||
|
COMMIT;
|
||||||
|
```
|
||||||
|
|
||||||
|
All games of one user (both colors):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
DELETE FROM chess.games
|
||||||
|
WHERE (SELECT user_id FROM chess.users WHERE username = 'alice') IN (white_claimed_by, black_claimed_by);
|
||||||
|
```
|
||||||
|
|
||||||
|
A game the server is still playing is unloaded when its next write finds the
|
||||||
|
row gone (that last move is not stored), or earlier by the integrity sweep;
|
||||||
|
the server stays healthy. Deleting individual `chess.moves` rows instead leaves a broken line
|
||||||
|
that replay cannot use: delete the whole game, or let the integrity sweep do
|
||||||
|
it.
|
||||||
|
|
||||||
|
Games without a registered player are already deleted by the server 24 hours
|
||||||
|
after their last activity (`-anonymous-game-ttl`).
|
||||||
|
|
||||||
|
### Integrity sweep
|
||||||
|
|
||||||
|
With the server flag `-db-cleanup report` (log only) or `-db-cleanup delete`,
|
||||||
|
an hourly sweep looks for data the server cannot use:
|
||||||
|
|
||||||
|
- games whose rows were deleted while the server held them (unloaded);
|
||||||
|
- games with missing plies, i.e. deleted move rows;
|
||||||
|
- games that fail `db verify`, 200 per run in ID order;
|
||||||
|
- games whose registered players were all deleted, once idle for the
|
||||||
|
anonymous-game retention;
|
||||||
|
- accounts whose password hash is not a usable Argon2id string (nobody can
|
||||||
|
sign in to them).
|
||||||
|
|
||||||
|
Each finding is logged at warning level with its reason; `delete` also
|
||||||
|
removes them. Start with `report`, read the log, then switch. Set it through
|
||||||
|
`CHESSD_FLAGS` in `/etc/chessd/chessd.env` (Linux) or `chessd_flags` in
|
||||||
|
`rc.conf` (FreeBSD), and restart the service.
|
||||||
|
|
||||||
|
## Starting Fresh
|
||||||
|
|
||||||
|
Every option below loses data: take a dump first if any of it matters.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo -u postgres pg_dump -Fc -d chess -f /var/lib/postgresql/chess-$(date +%F).dump # Linux (Debian/Ubuntu path)
|
||||||
|
su -m postgres -c 'pg_dump -Fc -d chess -f /var/db/postgres/chess-$(date +%F).dump' # FreeBSD
|
||||||
|
```
|
||||||
|
|
||||||
|
The service commands are `systemctl stop|start chessd` on Linux and
|
||||||
|
`service chessd stop|start` on FreeBSD.
|
||||||
|
|
||||||
|
**Clear the data, keep the schema** (accounts, sessions, and games), with the
|
||||||
|
service stopped:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
TRUNCATE chess.moves, chess.games, chess.sessions, chess.users;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Recreate the tables** (drops and re-runs the migrations), with the service
|
||||||
|
stopped:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo chess-db delete -confirm && sudo chess-db init # Linux
|
||||||
|
su -m chess -c '/home/chess/bin/chess-server db delete -confirm -dsn "postgres:///chess?host=/tmp"' # FreeBSD
|
||||||
|
su -m chess -c '/home/chess/bin/chess-server db init -dsn "postgres:///chess?host=/tmp"'
|
||||||
|
```
|
||||||
|
|
||||||
|
`db init` prints `Database schema ready (version 2)`. In split mode the
|
||||||
|
`chess` role owns no tables and cannot drop or create them; run both commands
|
||||||
|
as `postgres` acting as the owner role instead, e.g. on Linux:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo -u postgres /usr/local/bin/chess-server db delete -confirm \
|
||||||
|
-dsn "dbname=chess user=postgres options='-c role=chess_owner -c search_path=chess'"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rebuild the database and role** (as on a new host), with the service
|
||||||
|
stopped:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
DROP DATABASE IF EXISTS chess WITH (FORCE);
|
||||||
|
DROP ROLE IF EXISTS chess_owner; -- exists only in split mode
|
||||||
|
DROP ROLE IF EXISTS chess;
|
||||||
|
```
|
||||||
|
|
||||||
|
Then rerun the deployment script (`deploy/linux/setup.sh` or
|
||||||
|
`deploy/freebsd/setup-jail.sh`) with the same inputs as before. With neither
|
||||||
|
role nor database present it runs `setup.sql`, creates the tables, starts the
|
||||||
|
service, and checks `/health`. The JWT key is kept; accounts are gone, so
|
||||||
|
create them again.
|
||||||
|
|
||||||
|
Verify any of them:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
\dt chess.* -- games, moves, schema_version, sessions, users
|
||||||
|
SELECT version FROM chess.schema_version; -- 2
|
||||||
|
\drds -- chess: search_path and timeouts in database chess
|
||||||
|
```
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
# Deployment: Linux with systemd and PostgreSQL
|
||||||
|
|
||||||
|
A native install on Debian, Ubuntu, Arch, or another systemd distribution
|
||||||
|
that already runs PostgreSQL. The service is `chessd`, running as a system
|
||||||
|
account `chess` without a login shell; it reaches PostgreSQL over the local
|
||||||
|
Unix socket with peer authentication, so no database password exists. A
|
||||||
|
reverse proxy (nginx) on the same host, if any, forwards `/chess/api/` to it.
|
||||||
|
The FreeBSD jail layout is in [deployment.md](deployment.md); database
|
||||||
|
maintenance is in [database.md](database.md).
|
||||||
|
|
||||||
|
```
|
||||||
|
browser ──TLS──▶ nginx ──HTTP 127.0.0.1:8080──▶ chessd (chess-server, user chess)
|
||||||
|
│ Unix socket, peer auth
|
||||||
|
▼
|
||||||
|
PostgreSQL 17+ (database chess)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
PostgreSQL 17 or later (18 recommended), running, with its default local
|
||||||
|
socket; Stockfish; systemd; Go 1.27 to build.
|
||||||
|
|
||||||
|
**Debian / Ubuntu.** Ubuntu 24.04 ships PostgreSQL 16, so 18 comes from the
|
||||||
|
PostgreSQL project's repository (PGDG). With 18 already installed, check the
|
||||||
|
cluster with `pg_lsclusters` and skip this.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo apt install postgresql-common stockfish
|
||||||
|
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
|
||||||
|
sudo apt install postgresql-18 # creates and starts cluster 18/main
|
||||||
|
```
|
||||||
|
|
||||||
|
The default `pg_hba.conf` already maps local OS accounts to roles of the same
|
||||||
|
name (`local all all peer`). Stockfish installs as `/usr/games/stockfish`;
|
||||||
|
the service adds `/usr/games` to its `PATH`.
|
||||||
|
|
||||||
|
**Arch.** The package does not create a cluster; initialize one the first
|
||||||
|
time only:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo pacman -S postgresql stockfish
|
||||||
|
sudo -u postgres initdb -D /var/lib/postgres/data --encoding=UTF8 \
|
||||||
|
--locale-provider=builtin --builtin-locale=C.UTF-8 \
|
||||||
|
--auth-local=peer --auth-host=scram-sha-256
|
||||||
|
sudo systemctl enable --now postgresql
|
||||||
|
```
|
||||||
|
|
||||||
|
A cluster made by a plain `initdb` trusts every local connection. The chess
|
||||||
|
account connects either way, but `peer` is what you want: without it, any
|
||||||
|
local account can connect as any role.
|
||||||
|
|
||||||
|
**Existing clusters.** Other databases, roles, and applications on the
|
||||||
|
server are left alone: the setup creates one role and one database named
|
||||||
|
`chess`, changes no server settings, and edits `pg_hba.conf` only when asked
|
||||||
|
(`PG_HBA=yes`, below). It does not change `listen_addresses`; the service
|
||||||
|
itself only uses the socket.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make server # bin/chess-server, static
|
||||||
|
sudo CHESS_BINARY=bin/chess-server TRUSTED_PROXIES=127.0.0.1 deploy/linux/setup.sh
|
||||||
|
sudo chess-db user add -username <name> # prompts for the password
|
||||||
|
```
|
||||||
|
|
||||||
|
The script is idempotent: rerun it with a new binary to upgrade. It stops
|
||||||
|
`chessd`, installs everything, migrates the schema, starts the service, and
|
||||||
|
fails unless `/health` reports `"storage":"ok"`.
|
||||||
|
|
||||||
|
| Input | Default | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `CHESS_BINARY` | required | `chess-server` binary to install |
|
||||||
|
| `TRUSTED_PROXIES` | kept from `chessd.env` | Proxy address(es) whose `X-Real-IP` is trusted; `127.0.0.1` for nginx on this host |
|
||||||
|
| `CHESSD_HOST`, `CHESSD_PORT` | `127.0.0.1`, `8080` | Listen address, first install only |
|
||||||
|
| `SPLIT_PRIVILEGES` | `no` | `yes`: the service role only reads and writes rows ([database.md](database.md#typical-layout)) |
|
||||||
|
| `ENABLE_BACKUP` | `no` | `yes`: nightly `pg_dump` through a systemd timer |
|
||||||
|
| `PG_HBA` | `no` | `yes`: add `local chess chess peer` to `pg_hba.conf` if the chess account cannot connect |
|
||||||
|
|
||||||
|
What it installs:
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| account `chess` | System account, no login shell, home `/var/lib/chessd` |
|
||||||
|
| `/usr/local/bin/chess-server` | The server, `root:root 0755`; the previous copy kept as `.prev` |
|
||||||
|
| `/usr/local/sbin/chess-db` | Runs `chess-server db ...` as `chess` against the database: `chess-db user list`, `chess-db verify` |
|
||||||
|
| `/etc/chessd/chessd.env` | Settings, `root:chess 0640`; written on the first install only |
|
||||||
|
| `/var/lib/chessd/jwt.key` | JWT signing key, `chess 0600`, generated once; sessions survive restarts |
|
||||||
|
| `/etc/systemd/system/chessd.service` | The service unit, replaced on every run |
|
||||||
|
| `chess-backup.service`, `.timer`, `/usr/local/sbin/chess-backup` | With `ENABLE_BACKUP=yes`: dumps to `backups/` in the postgres home, 14 days kept |
|
||||||
|
|
||||||
|
PostgreSQL steps, in order: check the server version (17+) and socket as
|
||||||
|
`postgres`; create role, database, and schema with
|
||||||
|
[`setup.sql`](../deploy/postgresql/setup.sql) when neither exists (a
|
||||||
|
half-present pair stops the script); check that the `chess` account can
|
||||||
|
connect.
|
||||||
|
|
||||||
|
### When the chess account cannot connect
|
||||||
|
|
||||||
|
The first matching line of `pg_hba.conf` decides. A cluster whose local
|
||||||
|
lines require a password (`scram-sha-256`, `md5`) refuses the passwordless
|
||||||
|
`chess` role, and the script stops with the file's path and this line to add
|
||||||
|
above the other `local` lines:
|
||||||
|
|
||||||
|
```
|
||||||
|
local chess chess peer
|
||||||
|
```
|
||||||
|
|
||||||
|
Then reload (`sudo systemctl reload postgresql`) and rerun, or rerun with
|
||||||
|
`PG_HBA=yes` to have the line inserted and PostgreSQL reloaded (the previous
|
||||||
|
file is kept next to it with a timestamp).
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
`/etc/chessd/chessd.env` is read by the unit; restart after a change
|
||||||
|
(`sudo systemctl restart chessd`).
|
||||||
|
|
||||||
|
| Setting | First-install value | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `CHESSD_HOST`, `CHESSD_PORT` | `127.0.0.1`, `8080` | Listen address; keep loopback when nginx runs on the host |
|
||||||
|
| `CHESSD_DSN` | `"host=<socket dir> dbname=chess"` | Database over the socket as the OS account |
|
||||||
|
| `CHESSD_JWT_KEY` | `/var/lib/chessd/jwt.key` | Stable signing key |
|
||||||
|
| `CHESSD_TRUSTED_PROXIES` | `TRUSTED_PROXIES` | Comma-separated proxy addresses |
|
||||||
|
| `CHESSD_FLAGS` | empty | Other server flags, e.g. `-max-users 500 -db-cleanup report` |
|
||||||
|
|
||||||
|
Server flags worth knowing: `-max-users` (registration cap, default 100),
|
||||||
|
`-anonymous-game-ttl` (default `24h`), `-finished-game-ttl` (default `1h`),
|
||||||
|
`-db-cleanup off|report|delete` (the integrity sweep, see
|
||||||
|
[database.md](database.md#integrity-sweep)), `-log-level`, `-log-http`.
|
||||||
|
|
||||||
|
Changes to the unit belong in a drop-in (`sudo systemctl edit chessd`), which
|
||||||
|
the script never overwrites.
|
||||||
|
|
||||||
|
## Operation
|
||||||
|
|
||||||
|
```sh
|
||||||
|
systemctl status chessd
|
||||||
|
journalctl -u chessd -f # server log
|
||||||
|
sudo chess-db user add -username <name> # accounts; see database.md
|
||||||
|
sudo chess-db verify # check stored games; run after upgrades
|
||||||
|
curl -s http://127.0.0.1:8080/health # "storage":"ok"
|
||||||
|
systemctl list-timers chess-backup.timer # with ENABLE_BACKUP=yes
|
||||||
|
```
|
||||||
|
|
||||||
|
**Upgrade:** build, then rerun `setup.sh` with the new `CHESS_BINARY`.
|
||||||
|
Schema migrations run before the service starts.
|
||||||
|
|
||||||
|
**Rollback:** until you delete it, the previous binary is kept:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo systemctl stop chessd
|
||||||
|
sudo cp /usr/local/bin/chess-server.prev /usr/local/bin/chess-server
|
||||||
|
sudo systemctl start chessd
|
||||||
|
```
|
||||||
|
|
||||||
|
A newer schema is not downgraded: a binary older than the database refuses to
|
||||||
|
start (see the log), so keep a dump from before the upgrade.
|
||||||
|
|
||||||
|
**Uninstall:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo systemctl disable --now chessd chess-backup.timer
|
||||||
|
sudo rm -f /etc/systemd/system/chessd.service /etc/systemd/system/chess-backup.{service,timer} \
|
||||||
|
/usr/local/bin/chess-server{,.prev} /usr/local/sbin/chess-{db,backup}
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo rm -rf /etc/chessd /var/lib/chessd
|
||||||
|
sudo -u postgres psql -X -c 'DROP DATABASE chess WITH (FORCE)' -c 'DROP ROLE IF EXISTS chess_owner' -c 'DROP ROLE chess'
|
||||||
|
sudo userdel chess
|
||||||
|
```
|
||||||
|
|
||||||
|
## nginx on the Same Host
|
||||||
|
|
||||||
|
Use [`deploy/nginx-chess.conf`](../deploy/nginx-chess.conf) with
|
||||||
|
`<server-address>:<port>` = `127.0.0.1:8080`, and install with
|
||||||
|
`TRUSTED_PROXIES=127.0.0.1`, so the server keys rate limits on the client
|
||||||
|
address nginx sends in `X-Real-IP`. The browser clients and their
|
||||||
|
publishing steps are the same as on FreeBSD
|
||||||
|
([deployment.md](deployment.md#host-nginx-and-web-clients)).
|
||||||
|
|
||||||
|
## Service Sandbox
|
||||||
|
|
||||||
|
The unit runs the server with systemd's sandboxing: the whole file system is
|
||||||
|
read-only except `/var/lib/chessd`, home directories and `/tmp` are hidden,
|
||||||
|
no capabilities or privilege escalation, only Unix and IP sockets, and the
|
||||||
|
`@system-service` system-call set. Stockfish runs inside the same sandbox.
|
||||||
|
`systemd-analyze security chessd` summarizes the result.
|
||||||
|
|
||||||
|
## Running as Your Own Account (Development)
|
||||||
|
|
||||||
|
Without a `chess` account, the server runs as you, with a database owned by
|
||||||
|
your PostgreSQL role; tables then live in that database's `public` schema,
|
||||||
|
which is fine for development:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo -u postgres createuser --createdb "$USER" # once, if you have no role yet
|
||||||
|
createdb chess_dev
|
||||||
|
bin/chess-server db init -dsn "dbname=chess_dev"
|
||||||
|
bin/chess-server -dev -dsn "dbname=chess_dev" -serve # API :8080, web client :9090
|
||||||
|
bin/chess-server db user add -username me -dsn "dbname=chess_dev"
|
||||||
|
```
|
||||||
|
|
||||||
|
`-dev` relaxes rate limits and uses a fixed JWT key; never use it in
|
||||||
|
production. The test suites need a separate, disposable database whose
|
||||||
|
tables they drop ([test/README.md](../test/README.md)):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
createdb chess_test
|
||||||
|
export CHESS_TEST_DSN="dbname=chess_test"
|
||||||
|
go test ./...
|
||||||
|
test/run-test-server.sh bin/chess-server # then test/test-*.sh in another terminal
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification Checklist
|
||||||
|
|
||||||
|
```sh
|
||||||
|
systemctl is-active chessd # active
|
||||||
|
curl -s http://127.0.0.1:8080/health # "storage":"ok"
|
||||||
|
ps -o user= -C chess-server # chess
|
||||||
|
sudo -u postgres psql -X -d chess -c '\dt chess.*' # five tables
|
||||||
|
sudo chess-db verify # 0 problem(s)
|
||||||
|
sudo -u chess psql -X -d chess -Atc 'SHOW search_path' # chess
|
||||||
|
```
|
||||||
|
|
||||||
|
The scripts were exercised on Ubuntu 24.04 with PostgreSQL 18.6 and
|
||||||
|
`systemctl` replaced by a stand-in that starts the unit's `ExecStart` as its
|
||||||
|
`User` with its `EnvironmentFile`: a first install that stops at
|
||||||
|
`pg_hba.conf`, the same with `PG_HBA=yes`, an idempotent rerun, an upgrade,
|
||||||
|
a trusted-proxy change, backups, and a fresh split-privilege install.
|
||||||
|
`systemd-analyze verify` accepts the units.
|
||||||
+45
-9
@@ -16,6 +16,9 @@ browser ──TLS──▶ host nginx ──HTTP──▶ jail: chessd (chess-se
|
|||||||
|
|
||||||
`host#` runs on the Bastille host as root; `jail#` runs inside the jail as root
|
`host#` runs on the Bastille host as root; `jail#` runs inside the jail as root
|
||||||
(`bastille console <jail>`). Addresses in this document are placeholders.
|
(`bastille console <jail>`). Addresses in this document are placeholders.
|
||||||
|
For a native Linux install with systemd, see
|
||||||
|
[deployment-linux.md](deployment-linux.md); database recipes are in
|
||||||
|
[database.md](database.md).
|
||||||
|
|
||||||
## Scripted Installation and Upgrade
|
## Scripted Installation and Upgrade
|
||||||
|
|
||||||
@@ -56,7 +59,7 @@ Their header comments list every input.
|
|||||||
unless `/health` reports `"storage":"ok"`.
|
unless `/health` reports `"storage":"ok"`.
|
||||||
|
|
||||||
5. **Host:** point nginx at the jail
|
5. **Host:** point nginx at the jail
|
||||||
([`deploy/freebsd/nginx-chess.conf`](../deploy/freebsd/nginx-chess.conf))
|
([`deploy/nginx-chess.conf`](../deploy/nginx-chess.conf))
|
||||||
and publish the web client (below).
|
and publish the web client (below).
|
||||||
|
|
||||||
`setup-jail.sh` inputs:
|
`setup-jail.sh` inputs:
|
||||||
@@ -105,6 +108,21 @@ rerun, and a fresh split-privilege install.
|
|||||||
|
|
||||||
## Accounts and Data Retention
|
## Accounts and Data Retention
|
||||||
|
|
||||||
|
A new database has no accounts. Create the first one (for example your own)
|
||||||
|
with the CLI; without `-password` it prompts, so the password stays out of
|
||||||
|
the shell history and the process list:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jail# su -m chess -c '/home/chess/bin/chess-server db user add -username <name> -dsn "postgres:///chess?host=/tmp"'
|
||||||
|
```
|
||||||
|
|
||||||
|
The row lands in `chess.users` with an Argon2id hash in `password_hash`; the
|
||||||
|
password itself is stored nowhere and cannot be read back, only replaced
|
||||||
|
(`db user set-password`). The tables are in schema `chess`, not `public`:
|
||||||
|
as `postgres`, list them with `\dt chess.*` and name them as `chess.games`.
|
||||||
|
[database.md](database.md) explains the layout and has the psql recipes
|
||||||
|
(inspecting and deleting games, sessions, starting fresh).
|
||||||
|
|
||||||
- Accounts created by registration on the site and by `chess-server db user
|
- Accounts created by registration on the site and by `chess-server db user
|
||||||
add` are identical and never expire. Public registration closes at
|
add` are identical and never expire. Public registration closes at
|
||||||
`-max-users` accounts (default 100; `0` removes the limit); the CLI is not
|
`-max-users` accounts (default 100; `0` removes the limit); the CLI is not
|
||||||
@@ -116,6 +134,10 @@ rerun, and a fresh split-privilege install.
|
|||||||
activity**, from memory and from the database, by the hourly cleanup.
|
activity**, from memory and from the database, by the hourly cleanup.
|
||||||
`-anonymous-game-ttl` changes the window; `0` keeps them.
|
`-anonymous-game-ttl` changes the window; `0` keeps them.
|
||||||
- Deleting a user removes their sessions; their games keep the claim and name.
|
- Deleting a user removes their sessions; their games keep the claim and name.
|
||||||
|
With `-db-cleanup delete`, games left without any registered player are
|
||||||
|
deleted like anonymous ones.
|
||||||
|
- A game row deleted by hand while the server still plays it no longer
|
||||||
|
degrades storage: the server unloads that game and carries on.
|
||||||
|
|
||||||
## Service (`rc.d/chessd`)
|
## Service (`rc.d/chessd`)
|
||||||
|
|
||||||
@@ -132,7 +154,7 @@ rerun, and a fresh split-privilege install.
|
|||||||
| `chessd_dsn` | `postgres:///chess?host=/tmp` | Database `chess` over the socket as the OS user |
|
| `chessd_dsn` | `postgres:///chess?host=/tmp` | Database `chess` over the socket as the OS user |
|
||||||
| `chessd_jwt_key` | `${chessd_home}/jwt.key` | JWT signing key; created (0600) on first start if missing |
|
| `chessd_jwt_key` | `${chessd_home}/jwt.key` | JWT signing key; created (0600) on first start if missing |
|
||||||
| `chessd_trusted_proxies` | empty | Proxy address(es) whose `X-Real-IP` is trusted |
|
| `chessd_trusted_proxies` | empty | Proxy address(es) whose `X-Real-IP` is trusted |
|
||||||
| `chessd_flags` | empty | Extra flags, e.g. `-max-users 500` |
|
| `chessd_flags` | empty | Extra flags, e.g. `-max-users 500 -db-cleanup report` |
|
||||||
|
|
||||||
The script runs `daemon(8)` as root, which writes the supervisor pidfile
|
The script runs `daemon(8)` as root, which writes the supervisor pidfile
|
||||||
(`/var/run/chessd.pid`), drops to `chess`, restarts the server 5 seconds after
|
(`/var/run/chessd.pid`), drops to `chess`, restarts the server 5 seconds after
|
||||||
@@ -151,6 +173,7 @@ Server flags:
|
|||||||
| `-max-users` | | Registration cap (default 100, `0` = none) |
|
| `-max-users` | | Registration cap (default 100, `0` = none) |
|
||||||
| `-anonymous-game-ttl` | | Retention of games without a registered player (default `24h`) |
|
| `-anonymous-game-ttl` | | Retention of games without a registered player (default `24h`) |
|
||||||
| `-finished-game-ttl` | | Memory retention of finished games (default `1h`) |
|
| `-finished-game-ttl` | | Memory retention of finished games (default `1h`) |
|
||||||
|
| `-db-cleanup` | | Hourly integrity sweep: `off` (default), `report` (log findings), or `delete` ([database.md](database.md#integrity-sweep)) |
|
||||||
|
|
||||||
Administrative CLI commands run as `chess` so peer authentication selects the
|
Administrative CLI commands run as `chess` so peer authentication selects the
|
||||||
right role; root may use `su -m` although the account has no shell:
|
right role; root may use `su -m` although the account has no shell:
|
||||||
@@ -159,11 +182,19 @@ right role; root may use `su -m` although the account has no shell:
|
|||||||
jail# su -m chess -c '/home/chess/bin/chess-server db user list -dsn "postgres:///chess?host=/tmp"'
|
jail# su -m chess -c '/home/chess/bin/chess-server db user list -dsn "postgres:///chess?host=/tmp"'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`db` subcommands: `init`, `delete -confirm` (drops the tables), `query`
|
||||||
|
(games, with 8-digit ID prefixes), `pgn -gameId <id|prefix> [-ply N]`,
|
||||||
|
`verify [-gameId <id|prefix>]` (replays stored games against the rules and
|
||||||
|
exits non-zero on a mismatch; run it after an upgrade), and `user add|delete|
|
||||||
|
set-password|set-hash|set-email|set-username|list`.
|
||||||
|
|
||||||
## Host nginx and Web Clients
|
## Host nginx and Web Clients
|
||||||
|
|
||||||
The API has no version segment: routes are `/api/...` and `/health`. With the
|
The API has no version segment: routes are `/api/...` and `/health`. With the
|
||||||
layout in [`nginx-chess.conf`](../deploy/freebsd/nginx-chess.conf), browsers
|
layout in [`nginx-chess.conf`](../deploy/nginx-chess.conf), browsers
|
||||||
call `<origin>/chess/api/...` and nginx maps `/chess/api/` to `/api/`.
|
call `<origin>/chess/api/...` and nginx maps `/chess/api/` to `/api/`. The
|
||||||
|
bare `/chess` and `/chess/` redirect (302) to the page that embeds the
|
||||||
|
client, as a short link.
|
||||||
|
|
||||||
- `X-Real-IP` must carry the real client address (`$remote_addr`, after
|
- `X-Real-IP` must carry the real client address (`$remote_addr`, after
|
||||||
`real_ip` processing when PROXY protocol is in front of nginx). The server
|
`real_ip` processing when PROXY protocol is in front of nginx). The server
|
||||||
@@ -181,10 +212,14 @@ server's API paths:
|
|||||||
embedded `/config` endpoint, it uses the `/chess` API prefix.
|
embedded `/config` endpoint, it uses the `/chess` API prefix.
|
||||||
- **WASM terminal client (if published):** `make wasm` (xterm.js is committed
|
- **WASM terminal client (if published):** `make wasm` (xterm.js is committed
|
||||||
under `lib/`), then copy `web/chess-client-wasm/`. It derives its API base
|
under `lib/`), then copy `web/chess-client-wasm/`. It derives its API base
|
||||||
as `<origin>/chess`.
|
as `<origin>/chess`. Its API paths are compiled into `chess-client.wasm`,
|
||||||
|
so rebuild it whenever `internal/client/api` changes; publish it together
|
||||||
|
with the `wasm_exec.js` that `make wasm` copies from the same Go toolchain.
|
||||||
|
|
||||||
Publish the clients in the same change as the server: a client built for
|
`web/chess-client-web` is a symlink to the embedded web client; copy with
|
||||||
`/api/v1` gets 404 from this release.
|
`cp -RL` or `rsync -L` so the files, not the link, reach the site. Publish the
|
||||||
|
clients in the same change as the server, and expect browsers to hold cached
|
||||||
|
copies until a hard refresh.
|
||||||
|
|
||||||
## Reference
|
## Reference
|
||||||
|
|
||||||
@@ -253,8 +288,9 @@ Restore the previous web client files on the host as well.
|
|||||||
|
|
||||||
### Backups and restores
|
### Backups and restores
|
||||||
|
|
||||||
- `chess-backup` writes `pg_dump --format=custom` files nightly and removes
|
- `chess-backup` ([`deploy/postgresql/chess-backup.sh`](../deploy/postgresql/chess-backup.sh),
|
||||||
those older than 14 days. Copy them off the jail.
|
shared with Linux) writes `pg_dump --format=custom` files nightly and
|
||||||
|
removes those older than 14 days. Copy them off the jail.
|
||||||
- Restore into a freshly provisioned database:
|
- Restore into a freshly provisioned database:
|
||||||
`su -m postgres -c 'pg_restore --no-owner --role=chess -d chess <dump>'`
|
`su -m postgres -c 'pg_restore --no-owner --role=chess -d chess <dump>'`
|
||||||
(`--role=chess_owner` in split mode).
|
(`--role=chess_owner` in split mode).
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- Go 1.26+
|
- Go 1.27+
|
||||||
- Stockfish in PATH
|
- Stockfish in PATH
|
||||||
- PostgreSQL 18 (server for persistence; `psql` for the shell test suites)
|
- PostgreSQL 18 (server for persistence; `psql` for the shell test suites)
|
||||||
- Git
|
- Git
|
||||||
|
|||||||
+54
-42
@@ -28,6 +28,9 @@ into phases that each ship and test on their own.
|
|||||||
- [x] Player-name snapshots on claim, returned in history and listings (D4).
|
- [x] Player-name snapshots on claim, returned in history and listings (D4).
|
||||||
- [x] Scripted FreeBSD jail deployment (`deploy/`).
|
- [x] Scripted FreeBSD jail deployment (`deploy/`).
|
||||||
- [x] Go client DTOs (`GetGameHistory`, `GetMyGames`) including `finalFen`.
|
- [x] Go client DTOs (`GetGameHistory`, `GetMyGames`) including `finalFen`.
|
||||||
|
- [x] Rules core (`internal/server/chess`, R1) and replay API (R2), below.
|
||||||
|
- [x] Pawn promotion in the web client (piece picker) and a specific API error
|
||||||
|
for a promotion without its piece.
|
||||||
|
|
||||||
## Decisions
|
## Decisions
|
||||||
|
|
||||||
@@ -37,53 +40,52 @@ into phases that each ship and test on their own.
|
|||||||
| D2 | History visibility | **Decided: shareable by game ID.** The history endpoint stays public to holders of the 122-bit random ID; IDs are never listed publicly and the list endpoint stays authenticated. |
|
| D2 | History visibility | **Decided: shareable by game ID.** The history endpoint stays public to holders of the 122-bit random ID; IDs are never listed publicly and the list endpoint stays authenticated. |
|
||||||
| D3 | Where SAN/PGN is produced | **Decided: server-side Go** (R1), derived on read from the initial FEN and the UCI line: no schema change, one implementation for web and CLI, no third-party script under the CSP. |
|
| D3 | Where SAN/PGN is produced | **Decided: server-side Go** (R1), derived on read from the initial FEN and the UCI line: no schema change, one implementation for web and CLI, no third-party script under the CSP. |
|
||||||
| D4 | Player names | **Done.** `white_name`/`black_name` snapshot the claimant's username in the claim transaction and survive renames and deletions; exposed as `players.*.name`. |
|
| D4 | Player names | **Done.** `white_name`/`black_name` snapshot the claimant's username in the claim transaction and survive renames and deletions; exposed as `players.*.name`. |
|
||||||
| D5 | Draw rules | Open. Recommendation: detect insufficient material, threefold repetition, and the 50/75-move rules in the R1 core; add resign/draw offers later. PGN `Result` is `*` for unterminated games. |
|
| D5 | Draw rules | **Done.** Draws apply automatically after every move, as on most online servers: dead material, threefold repetition, and the fifty-move rule (FIDE's claimable draws, and so also its 75-move and fivefold rules). Draw offers (a computer answers from its evaluation) and resignation exist; the stored `termination` says how a game ended. |
|
||||||
| D6 | Undo after a result | **Decided: keep allowed**, so a player can step back from a finished game and play a line again. Consequence: a finished game's stored history, result, and end time are rewritten by undo; replay shows the line as it stands now. |
|
| D6 | Undo after a result | **Decided: keep allowed**, so a player can step back from a finished game and play a line again. Consequence: a finished game's stored history, result, and end time are rewritten by undo; replay shows the line as it stands now. |
|
||||||
| D7 | List pagination | Open. Recommendation: keyset cursor on `(start_time_utc, game_id)`, keeping offset during v1; the claim indexes are already ordered for it. |
|
| D7 | List pagination | **Done.** Opaque keyset `cursor` on `(start_time_utc, game_id)`; `offset` kept for existing clients and exclusive with `cursor`. |
|
||||||
|
|
||||||
## Phase R1 — Notation Core (server, no API change)
|
## Phase R1 — Notation Core (server) — done
|
||||||
|
|
||||||
A small, dependency-free chess core in Go (`internal/server/chess`), used by
|
A dependency-free rules core in Go (`internal/server/chess`). Stockfish
|
||||||
replay only; Stockfish remains the move validator for live play.
|
remains the move validator for live play; the core notates stored games and
|
||||||
|
shadows the engine on every live move, logging any disagreement.
|
||||||
|
|
||||||
- [ ] Board model with FEN parse/serialize (extend `internal/server/board`).
|
- [x] Position with strict FEN parse (one king per side, no pawns on the back
|
||||||
- [ ] Legal move generation: pseudo-legal moves plus king-safety filtering,
|
ranks, side not to move not in check; castling rights without their rook
|
||||||
castling rights and paths, en passant, promotion.
|
dropped) and canonical serialization (en-passant square only when a capture
|
||||||
- [ ] SAN encode (disambiguation by file, rank, or both; `+`/`#`; `O-O`,
|
is legal, so Stockfish's pseudo-legal convention normalizes away).
|
||||||
`O-O-O`; `=Q`) and SAN decode for later PGN import.
|
- [x] Legal move generation with castling paths, en passant, promotion, pins.
|
||||||
- [ ] PGN writer: Seven Tag Roster (White/Black from the name snapshots, else
|
- [x] SAN encode (file/rank/square disambiguation, `+`/`#`, `O-O`, `=Q`) and
|
||||||
"Anonymous" or "Stockfish level N"), `SetUp`/`FEN` tags for custom starts,
|
lenient decode (`0-0`, `e8Q`, annotations, over-disambiguation).
|
||||||
`Termination`, 80-column movetext, `Result` from the stored outcome.
|
- [x] PGN writer: Seven Tag Roster, supplemental tags, `SetUp`/`FEN` for custom
|
||||||
- [ ] Draw-rule helpers (D5) and an integrity check that replays the stored UCI
|
starts with correct move numbering, 80-column movetext, escaped tag values.
|
||||||
line and compares each generated FEN with the stored `fenAfterMove`.
|
- [x] Draw-rule helpers (D5) and `chess-server db verify`, which replays every
|
||||||
|
stored game against the stored FENs and results.
|
||||||
|
- [x] Custom starting FENs validated by the core before the engine sees them.
|
||||||
|
|
||||||
Test gate:
|
Test gate:
|
||||||
|
|
||||||
- [ ] Perft node counts for the standard CPW positions (start, Kiwipete, and
|
- [x] Perft for the CPW start, Kiwipete, and positions 3–6 (depth 4; position
|
||||||
positions 3–6) to depth 4, which exercises castling, en passant, promotion,
|
3 to depth 5).
|
||||||
and pins.
|
- [x] Engine cross-check: random games from all six positions, comparing the
|
||||||
- [ ] Property tests over random legal games: SAN decode(encode(m)) == m; the
|
legal move set with Stockfish `go perft 1` and the FEN with Stockfish `d` at
|
||||||
generated FEN sequence is identical to one produced by Stockfish's `d`
|
every ply (about 6,000 positions per run; skipped without Stockfish).
|
||||||
command for the same line.
|
- [x] Fuzz targets: FEN parse round trip with SAN/UCI round trip of every
|
||||||
- [ ] Fuzz targets for FEN parsing, SAN decoding, and PGN writing (no panics,
|
legal move, SAN decoding, PGN tag escaping.
|
||||||
bounded output).
|
- [ ] `db verify` over a copy of the production database once it holds games.
|
||||||
- [ ] Integrity check run over every stored game in a production-shaped copy.
|
|
||||||
|
|
||||||
## Phase R2 — Replay API
|
## Phase R2 — Replay API — done
|
||||||
|
|
||||||
- [ ] Implement D7 (keyset cursor) once decided.
|
- [x] D7 keyset cursor, plus `status` and `color` filters on the list.
|
||||||
- [ ] Additive history fields: `san` per move, `outcome`/`termination`, and
|
- [x] Additive history fields: `san` per move, `pgnResult`, `termination`.
|
||||||
`pgnResult`; keep existing fields unchanged.
|
- [x] `GET /api/games/{id}/pgn?ply=N` as an attachment; `db pgn` in the CLI.
|
||||||
- [ ] `GET /api/games/{id}/pgn?ply=N`: `application/x-chess-pgn`,
|
- [x] Strong `ETag` with `Cache-Control: private, no-cache` on history and PGN
|
||||||
`Content-Disposition: attachment; filename="chess-<date>-<id8>.pgn"`,
|
(revalidate every time: D6 lets finished games change); `If-None-Match`
|
||||||
moves 1..N (default all). The FEN at any ply is already in the history.
|
returns 304.
|
||||||
- [ ] `ETag` and `Cache-Control: private, max-age` for terminal games;
|
- [x] Tests: rules-core unit, fuzz, and engine cross-check; service and
|
||||||
`If-None-Match` returns 304.
|
handler tests against PostgreSQL; `test/test-replay.sh` over HTTP (SAN,
|
||||||
- [ ] Filters on the list endpoint: result, color, ongoing/finished.
|
PGN, ETag, promotion, FEN validation, cursor paging and filters).
|
||||||
- [ ] Tests: handler tests against PostgreSQL (pgtest), golden PGN files for
|
- [x] Terminal client: `games` (cursor paging) and `pgn` commands.
|
||||||
mate, stalemate, promotion, castling, en passant, custom FEN, and an ongoing
|
|
||||||
game; shell-suite coverage for authorization (another user's list, expired
|
|
||||||
session) and cursor stability under concurrent inserts.
|
|
||||||
|
|
||||||
## Phase R3 — Web Replay UI
|
## Phase R3 — Web Replay UI
|
||||||
|
|
||||||
@@ -101,7 +103,11 @@ Plain JavaScript in the embedded client, no framework.
|
|||||||
- [ ] Position rendering assigns the stored FEN for the ply; the browser never
|
- [ ] Position rendering assigns the stored FEN for the ply; the browser never
|
||||||
computes moves.
|
computes moves.
|
||||||
- [ ] Export at the current ply: copy FEN, copy PGN, and download PGN (fetched
|
- [ ] Export at the current ply: copy FEN, copy PGN, and download PGN (fetched
|
||||||
from R2 as a `Blob`, saved through a temporary `<a download>`).
|
from R2 as a `Blob`, saved through a temporary `<a download>`). The live
|
||||||
|
view's "Copy PGN" button copies UCI moves today; point it at the PGN
|
||||||
|
endpoint too.
|
||||||
|
- [ ] Show SAN in the live move list (from the history, or a `san` field on
|
||||||
|
the live game response).
|
||||||
- [ ] While replaying: stop live long-polling, disable move, undo, new-game,
|
- [ ] While replaying: stop live long-polling, disable move, undo, new-game,
|
||||||
and player-configuration controls; **Back to live game** restores them.
|
and player-configuration controls; **Back to live game** restores them.
|
||||||
|
|
||||||
@@ -129,8 +135,9 @@ Test gate:
|
|||||||
|
|
||||||
## Phase R4 — CLI and WASM Replay (optional)
|
## Phase R4 — CLI and WASM Replay (optional)
|
||||||
|
|
||||||
- [ ] `games` (list with pagination), `replay <gameId|index>`, `next`, `prev`,
|
- [x] `games` (list with cursor paging) and `pgn [gameId] [ply]`.
|
||||||
`first`, `last`, `goto <ply>`, `auto <ms>`, `fen`, `pgn save <path>`.
|
- [ ] `replay <gameId|index>`, `next`, `prev`, `first`, `last`, `goto <ply>`,
|
||||||
|
`auto <ms>`, `fen`, `pgn save <path>`.
|
||||||
- [ ] Replay state is separate from the live session: no polling, moves, undo,
|
- [ ] Replay state is separate from the live session: no polling, moves, undo,
|
||||||
or configuration while replaying.
|
or configuration while replaying.
|
||||||
|
|
||||||
@@ -156,4 +163,9 @@ Test gate:
|
|||||||
- [ ] Retention and user-initiated deletion or anonymization of games.
|
- [ ] Retention and user-initiated deletion or anonymization of games.
|
||||||
- [ ] Thread request contexts from HTTP handlers into storage calls; today each
|
- [ ] Thread request contexts from HTTP handlers into storage calls; today each
|
||||||
call carries its own deadline.
|
call carries its own deadline.
|
||||||
- [ ] Draw and resign flows (D5) for live play, independent of replay.
|
- [x] Draw and resign flows (D5) for live play.
|
||||||
|
- [x] A game row deleted by hand no longer degrades storage; optional
|
||||||
|
integrity sweep (`-db-cleanup`) for broken or orphaned data.
|
||||||
|
- [ ] Once `db verify` and the live shadow check stay clean in production,
|
||||||
|
consider validating human moves with the core instead of the engine: no
|
||||||
|
engine round trip or lock per move, and Stockfish only for computer play.
|
||||||
@@ -14,7 +14,7 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.2 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.2 // indirect
|
github.com/go-playground/universal-translator v0.18.2 // indirect
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd
|
|||||||
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
|
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
github.com/go-playground/locales v0.14.2 h1:d8UmcrM6Nip0hfGZKLGpAvZH37XB4TS0xzK9B56YNCY=
|
||||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
github.com/go-playground/locales v0.14.2/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
github.com/go-playground/universal-translator v0.18.2 h1:LCsMLC9RzmbUMNUPVYD15dmcjwYAJhmX8mPZRW4rAVU=
|
github.com/go-playground/universal-translator v0.18.2 h1:LCsMLC9RzmbUMNUPVYD15dmcjwYAJhmX8mPZRW4rAVU=
|
||||||
github.com/go-playground/universal-translator v0.18.2/go.mod h1:67VZIMp5lQpDWlnStOct22q1bkdJGJqHghbOtmkawxk=
|
github.com/go-playground/universal-translator v0.18.2/go.mod h1:67VZIMp5lQpDWlnStOct22q1bkdJGJqHghbOtmkawxk=
|
||||||
github.com/go-playground/validator/v10 v10.30.5 h1:YyCXvVShZbs2Sm3Mb53eNOlhRXctSOzW5QJAouCTZL4=
|
github.com/go-playground/validator/v10 v10.30.5 h1:YyCXvVShZbs2Sm3Mb53eNOlhRXctSOzW5QJAouCTZL4=
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -140,7 +141,11 @@ func (c *Client) doRequest(method, path string, body any, result any) error {
|
|||||||
return fmt.Errorf("request failed with status %d", resp.StatusCode)
|
return fmt.Errorf("request failed with status %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse success response
|
// Parse success response; a *string receives a non-JSON body as-is
|
||||||
|
if text, ok := result.(*string); ok {
|
||||||
|
*text = string(respBody)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if result != nil && len(respBody) > 0 {
|
if result != nil && len(respBody) > 0 {
|
||||||
if err := json.Unmarshal(respBody, result); err != nil {
|
if err := json.Unmarshal(respBody, result); err != nil {
|
||||||
// For debug, show raw response if parsing fails
|
// For debug, show raw response if parsing fails
|
||||||
@@ -198,6 +203,22 @@ func (c *Client) UndoMoves(gameID string, count int) (*GameResponse, error) {
|
|||||||
return &resp, err
|
return &resp, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resign ends the game in the opponent's favor; color may be "" when the
|
||||||
|
// caller plays exactly one human side.
|
||||||
|
func (c *Client) Resign(gameID, color string) (*GameResponse, error) {
|
||||||
|
var resp GameResponse
|
||||||
|
err := c.doRequest("POST", "/api/games/"+gameID+"/resign", &ResignRequest{Color: color}, &resp)
|
||||||
|
return &resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw offers, accepts, or declines a draw; the response's DrawOutcome says
|
||||||
|
// what happened.
|
||||||
|
func (c *Client) Draw(gameID, action, color string) (*GameResponse, error) {
|
||||||
|
var resp GameResponse
|
||||||
|
err := c.doRequest("POST", "/api/games/"+gameID+"/draw", &DrawRequest{Action: action, Color: color}, &resp)
|
||||||
|
return &resp, err
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) GetBoard(gameID string) (*BoardResponse, error) {
|
func (c *Client) GetBoard(gameID string) (*BoardResponse, error) {
|
||||||
var resp BoardResponse
|
var resp BoardResponse
|
||||||
err := c.doRequest("GET", "/api/games/"+gameID+"/board", nil, &resp)
|
err := c.doRequest("GET", "/api/games/"+gameID+"/board", nil, &resp)
|
||||||
@@ -210,9 +231,25 @@ func (c *Client) GetGameHistory(gameID string) (*GameHistoryResponse, error) {
|
|||||||
return &resp, err
|
return &resp, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) GetMyGames(limit, offset int) (*GameListResponse, error) {
|
// GetGamePGN returns the stored game in PGN; ply < 0 exports every move.
|
||||||
|
func (c *Client) GetGamePGN(gameID string, ply int) (string, error) {
|
||||||
|
path := "/api/games/" + gameID + "/pgn"
|
||||||
|
if ply >= 0 {
|
||||||
|
path += fmt.Sprintf("?ply=%d", ply)
|
||||||
|
}
|
||||||
|
var text string
|
||||||
|
err := c.doRequest("GET", path, nil, &text)
|
||||||
|
return text, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMyGames lists the caller's games, newest first; cursor is "" for the
|
||||||
|
// first page, then the previous page's NextCursor.
|
||||||
|
func (c *Client) GetMyGames(limit int, cursor string) (*GameListResponse, error) {
|
||||||
var resp GameListResponse
|
var resp GameListResponse
|
||||||
path := fmt.Sprintf("/api/users/me/games?limit=%d&offset=%d", limit, offset)
|
path := fmt.Sprintf("/api/users/me/games?limit=%d", limit)
|
||||||
|
if cursor != "" {
|
||||||
|
path += "&cursor=" + url.QueryEscape(cursor)
|
||||||
|
}
|
||||||
err := c.doRequest("GET", path, nil, &resp)
|
err := c.doRequest("GET", path, nil, &resp)
|
||||||
return &resp, err
|
return &resp, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,13 +36,25 @@ type LoginRequest struct {
|
|||||||
|
|
||||||
// Response types
|
// Response types
|
||||||
type GameResponse struct {
|
type GameResponse struct {
|
||||||
GameID string `json:"gameId"`
|
GameID string `json:"gameId"`
|
||||||
FEN string `json:"fen"`
|
FEN string `json:"fen"`
|
||||||
Turn string `json:"turn"`
|
Turn string `json:"turn"`
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
Moves []string `json:"moves"`
|
Termination string `json:"termination,omitempty"`
|
||||||
Players PlayersResponse `json:"players"`
|
DrawOffer string `json:"drawOffer,omitempty"`
|
||||||
LastMove *MoveInfo `json:"lastMove,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 {
|
type PlayersResponse struct {
|
||||||
@@ -104,6 +116,8 @@ type GameHistoryResponse struct {
|
|||||||
GameID string `json:"gameId"`
|
GameID string `json:"gameId"`
|
||||||
InitialFEN string `json:"initialFen"`
|
InitialFEN string `json:"initialFen"`
|
||||||
Result string `json:"result,omitempty"`
|
Result string `json:"result,omitempty"`
|
||||||
|
PGNResult string `json:"pgnResult"`
|
||||||
|
Termination string `json:"termination,omitempty"`
|
||||||
StartTimeUTC time.Time `json:"startTimeUtc"`
|
StartTimeUTC time.Time `json:"startTimeUtc"`
|
||||||
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
|
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
|
||||||
Players PlayersResponse `json:"players"`
|
Players PlayersResponse `json:"players"`
|
||||||
@@ -113,6 +127,7 @@ type GameHistoryResponse struct {
|
|||||||
type HistoryMove struct {
|
type HistoryMove struct {
|
||||||
MoveNumber int `json:"moveNumber"`
|
MoveNumber int `json:"moveNumber"`
|
||||||
MoveUCI string `json:"moveUci"`
|
MoveUCI string `json:"moveUci"`
|
||||||
|
SAN string `json:"san,omitempty"`
|
||||||
FENAfterMove string `json:"fenAfterMove"`
|
FENAfterMove string `json:"fenAfterMove"`
|
||||||
PlayerColor string `json:"playerColor"`
|
PlayerColor string `json:"playerColor"`
|
||||||
MoveTimeUTC time.Time `json:"moveTimeUtc"`
|
MoveTimeUTC time.Time `json:"moveTimeUtc"`
|
||||||
@@ -123,6 +138,7 @@ type GameSummary struct {
|
|||||||
InitialFEN string `json:"initialFen"`
|
InitialFEN string `json:"initialFen"`
|
||||||
FinalFEN string `json:"finalFen"`
|
FinalFEN string `json:"finalFen"`
|
||||||
Result string `json:"result,omitempty"`
|
Result string `json:"result,omitempty"`
|
||||||
|
PGNResult string `json:"pgnResult"`
|
||||||
StartTimeUTC time.Time `json:"startTimeUtc"`
|
StartTimeUTC time.Time `json:"startTimeUtc"`
|
||||||
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
|
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
|
||||||
MoveCount int `json:"moveCount"`
|
MoveCount int `json:"moveCount"`
|
||||||
@@ -134,4 +150,5 @@ type GameListResponse struct {
|
|||||||
Limit int `json:"limit"`
|
Limit int `json:"limit"`
|
||||||
Offset int `json:"offset"`
|
Offset int `json:"offset"`
|
||||||
NextOffset *int `json:"nextOffset,omitempty"`
|
NextOffset *int `json:"nextOffset,omitempty"`
|
||||||
|
NextCursor string `json:"nextCursor,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -33,7 +33,7 @@ func (r *Registry) registerGameCommands() {
|
|||||||
Name: "move",
|
Name: "move",
|
||||||
ShortName: "m",
|
ShortName: "m",
|
||||||
Description: "Make a move",
|
Description: "Make a move",
|
||||||
Usage: "move <uci-move>",
|
Usage: "move <uci-move> (e2e4; castling e1g1; promotion e7e8q, e7e8n)",
|
||||||
Handler: moveHandler,
|
Handler: moveHandler,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -77,6 +77,38 @@ func (r *Registry) registerGameCommands() {
|
|||||||
Handler: deleteGameHandler,
|
Handler: deleteGameHandler,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
r.Register(&Command{
|
||||||
|
Name: "resign",
|
||||||
|
ShortName: "",
|
||||||
|
Description: "Resign the current game",
|
||||||
|
Usage: "resign [w|b] (side optional when you play one human side)",
|
||||||
|
Handler: resignHandler,
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Register(&Command{
|
||||||
|
Name: "draw",
|
||||||
|
ShortName: "",
|
||||||
|
Description: "Offer, accept, or decline a draw",
|
||||||
|
Usage: "draw [offer|accept|decline] [w|b] (default: offer)",
|
||||||
|
Handler: drawHandler,
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Register(&Command{
|
||||||
|
Name: "games",
|
||||||
|
ShortName: "g",
|
||||||
|
Description: "List your stored games, newest first (login required)",
|
||||||
|
Usage: "games [more]",
|
||||||
|
Handler: gamesHandler,
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Register(&Command{
|
||||||
|
Name: "pgn",
|
||||||
|
ShortName: "",
|
||||||
|
Description: "Print a stored game as PGN",
|
||||||
|
Usage: "pgn [gameId] [ply] (default: current game, all plies)",
|
||||||
|
Handler: pgnHandler,
|
||||||
|
})
|
||||||
|
|
||||||
r.Register(&Command{
|
r.Register(&Command{
|
||||||
Name: "poll",
|
Name: "poll",
|
||||||
ShortName: "p",
|
ShortName: "p",
|
||||||
@@ -238,7 +270,7 @@ func joinGameHandler(s *session.Session, args []string) error {
|
|||||||
// hint stays local since it only applies after a successful human move.
|
// hint stays local since it only applies after a successful human move.
|
||||||
func moveHandler(s *session.Session, args []string) error {
|
func moveHandler(s *session.Session, args []string) error {
|
||||||
if len(args) < 1 {
|
if len(args) < 1 {
|
||||||
return fmt.Errorf("usage: move <uci-move>")
|
return fmt.Errorf("usage: move <uci-move> (promotion appends q, r, b, or n: e7e8q)")
|
||||||
}
|
}
|
||||||
|
|
||||||
gameID := s.CurrentGame
|
gameID := s.CurrentGame
|
||||||
@@ -345,21 +377,101 @@ func computerMoveHandler(s *session.Session, args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// printOutcome reports terminal or error states using the server's actual
|
// printOutcome reports terminal or error states using the server's State
|
||||||
// State.String() values ("white wins"/"black wins", not "checkmate").
|
// ("white wins", "draw", ...) and Termination ("checkmate", "resignation",
|
||||||
|
// "threefold_repetition", ...) values, and a pending draw offer.
|
||||||
func printOutcome(resp *api.GameResponse) {
|
func printOutcome(resp *api.GameResponse) {
|
||||||
switch resp.State {
|
switch resp.State {
|
||||||
case "white wins":
|
case "white wins", "black wins":
|
||||||
display.Println(display.Green, "\nCHECKMATE! White wins!")
|
winner := "White"
|
||||||
case "black wins":
|
if resp.State == "black wins" {
|
||||||
display.Println(display.Green, "\nCHECKMATE! Black wins!")
|
winner = "Black"
|
||||||
|
}
|
||||||
|
if resp.Termination == "resignation" {
|
||||||
|
display.Println(display.Green, "\n%s wins by resignation.", winner)
|
||||||
|
} else {
|
||||||
|
display.Println(display.Green, "\nCHECKMATE! %s wins!", winner)
|
||||||
|
}
|
||||||
case "stalemate":
|
case "stalemate":
|
||||||
display.Println(display.Yellow, "\nSTALEMATE! Game drawn.")
|
display.Println(display.Yellow, "\nSTALEMATE! Game drawn.")
|
||||||
case "draw":
|
case "draw":
|
||||||
display.Println(display.Yellow, "\nDRAW! Game drawn.")
|
display.Println(display.Yellow, "\nDRAW %s.", drawReason(resp.Termination))
|
||||||
case "stuck":
|
case "stuck":
|
||||||
display.Println(display.Yellow, "\nEngine error — 'undo' to recover, or 'new'/'delete'.")
|
display.Println(display.Yellow, "\nEngine error — 'undo' to recover, or 'new'/'delete'.")
|
||||||
}
|
}
|
||||||
|
if resp.DrawOffer != "" {
|
||||||
|
offerer := "White"
|
||||||
|
if resp.DrawOffer == "b" {
|
||||||
|
offerer = "Black"
|
||||||
|
}
|
||||||
|
display.Println(display.Magenta, "%s offers a draw: 'draw accept' or 'draw decline'.", offerer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawReason(termination string) string {
|
||||||
|
switch termination {
|
||||||
|
case "insufficient_material":
|
||||||
|
return "by insufficient material"
|
||||||
|
case "threefold_repetition":
|
||||||
|
return "by threefold repetition"
|
||||||
|
case "fifty_move_rule":
|
||||||
|
return "by the fifty-move rule"
|
||||||
|
case "agreement":
|
||||||
|
return "by agreement"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// resignHandler resigns the current game for the given side, or the side the
|
||||||
|
// server infers (the one human side, or the one the user claimed).
|
||||||
|
func resignHandler(s *session.Session, args []string) error {
|
||||||
|
gameID := s.CurrentGame
|
||||||
|
if gameID == "" {
|
||||||
|
return fmt.Errorf("no current game, use 'new' or 'join <gameId>'")
|
||||||
|
}
|
||||||
|
color := ""
|
||||||
|
if len(args) > 0 {
|
||||||
|
color = args[0]
|
||||||
|
}
|
||||||
|
resp, err := s.Client.Resign(gameID, color)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.LastMoveCount = len(resp.Moves)
|
||||||
|
s.CurrentGameState = resp
|
||||||
|
printOutcome(resp)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// drawHandler offers (default), accepts, or declines a draw.
|
||||||
|
func drawHandler(s *session.Session, args []string) error {
|
||||||
|
gameID := s.CurrentGame
|
||||||
|
if gameID == "" {
|
||||||
|
return fmt.Errorf("no current game, use 'new' or 'join <gameId>'")
|
||||||
|
}
|
||||||
|
action, color := "offer", ""
|
||||||
|
for _, arg := range args {
|
||||||
|
switch arg {
|
||||||
|
case "offer", "accept", "decline":
|
||||||
|
action = arg
|
||||||
|
default:
|
||||||
|
color = arg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp, err := s.Client.Draw(gameID, action, color)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.LastMoveCount = len(resp.Moves)
|
||||||
|
s.CurrentGameState = resp
|
||||||
|
switch resp.DrawOutcome {
|
||||||
|
case "offered":
|
||||||
|
display.Println(display.Green, "Draw offered; it stands until answered or the opponent moves.")
|
||||||
|
case "declined":
|
||||||
|
display.Println(display.Yellow, "Draw declined.")
|
||||||
|
}
|
||||||
|
printOutcome(resp)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func undoHandler(s *session.Session, args []string) error {
|
func undoHandler(s *session.Session, args []string) error {
|
||||||
@@ -530,3 +642,72 @@ func pollHandler(s *session.Session, args []string) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// gamesHandler lists the stored games of the signed-in user, 20 at a time;
|
||||||
|
// "games more" continues from the previous page.
|
||||||
|
func gamesHandler(s *session.Session, args []string) error {
|
||||||
|
if s.AuthToken == "" {
|
||||||
|
return fmt.Errorf("login required")
|
||||||
|
}
|
||||||
|
cursor := ""
|
||||||
|
if len(args) > 0 && args[0] == "more" {
|
||||||
|
if s.GamesCursor == "" {
|
||||||
|
return fmt.Errorf("no further games; run 'games' to list from the newest")
|
||||||
|
}
|
||||||
|
cursor = s.GamesCursor
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.Client.GetMyGames(20, cursor)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.GamesCursor = resp.NextCursor
|
||||||
|
if len(resp.Games) == 0 {
|
||||||
|
display.Println(display.Yellow, "No stored games")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n%-36s %-20s %-20s %-7s %5s %s\n", "Game ID", "White", "Black", "Result", "Plies", "Started (UTC)")
|
||||||
|
for _, g := range resp.Games {
|
||||||
|
fmt.Printf("%-36s %-20s %-20s %-7s %5d %s\n", g.GameID,
|
||||||
|
playerLabel(g.Players.White), playerLabel(g.Players.Black), g.PGNResult, g.MoveCount,
|
||||||
|
g.StartTimeUTC.UTC().Format("2006-01-02 15:04"))
|
||||||
|
}
|
||||||
|
if resp.NextCursor != "" {
|
||||||
|
display.Println(display.Cyan, "\nMore games: games more")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func playerLabel(p api.PlayerInfo) string {
|
||||||
|
switch {
|
||||||
|
case p.Type == 2:
|
||||||
|
return fmt.Sprintf("Stockfish L%d", p.Level)
|
||||||
|
case p.Name != "":
|
||||||
|
return p.Name
|
||||||
|
}
|
||||||
|
return "anonymous"
|
||||||
|
}
|
||||||
|
|
||||||
|
// pgnHandler prints the stored game as PGN: the current game unless an ID is
|
||||||
|
// given, and every ply unless a count is given.
|
||||||
|
func pgnHandler(s *session.Session, args []string) error {
|
||||||
|
gameID, ply := s.CurrentGame, -1
|
||||||
|
for _, arg := range args {
|
||||||
|
if n, err := strconv.Atoi(arg); err == nil && n >= 0 {
|
||||||
|
ply = n
|
||||||
|
} else {
|
||||||
|
gameID = arg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if gameID == "" {
|
||||||
|
return fmt.Errorf("no current game, use 'join <gameId>' or 'pgn <gameId>'")
|
||||||
|
}
|
||||||
|
text, err := s.Client.GetGamePGN(gameID, ply)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Print(text)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -126,6 +126,10 @@ func (r *Registry) helpHandler(s *session.Session, args []string) error {
|
|||||||
{"state", "s", ""},
|
{"state", "s", ""},
|
||||||
{"delete", "d", ""},
|
{"delete", "d", ""},
|
||||||
{"poll", "p", ""},
|
{"poll", "p", ""},
|
||||||
|
{"resign", "", ""},
|
||||||
|
{"draw", "", ""},
|
||||||
|
{"games", "g", ""},
|
||||||
|
{"pgn", "", ""},
|
||||||
}
|
}
|
||||||
|
|
||||||
authCommands := []cmdInfo{
|
authCommands := []cmdInfo{
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type Session struct {
|
|||||||
AuthToken string
|
AuthToken string
|
||||||
Username string
|
Username string
|
||||||
LastMoveCount int
|
LastMoveCount int
|
||||||
|
GamesCursor string // next page of the "games" listing
|
||||||
Client *api.Client
|
Client *api.Client
|
||||||
Verbose bool
|
Verbose bool
|
||||||
// Game state for prompt
|
// Game state for prompt
|
||||||
|
|||||||
@@ -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]
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 "*"
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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]) }
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
Move string `json:"move" validate:"required,min=4,max=5"` // "cccc" for computer move, 4-5 chars for UCI moves
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResignRequest ends the game in the opponent's favor. Color may be omitted
|
||||||
|
// when the caller controls exactly one human side.
|
||||||
|
type ResignRequest struct {
|
||||||
|
Color string `json:"color,omitempty" validate:"omitempty,oneof=w b white black"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DrawRequest offers, accepts, or declines a draw by agreement. Offering to a
|
||||||
|
// computer opponent is answered at once.
|
||||||
|
type DrawRequest struct {
|
||||||
|
Action string `json:"action" validate:"required,oneof=offer accept decline"`
|
||||||
|
Color string `json:"color,omitempty" validate:"omitempty,oneof=w b white black"`
|
||||||
|
}
|
||||||
|
|
||||||
type UndoRequest struct {
|
type UndoRequest struct {
|
||||||
Count int `json:"count" validate:"required,min=1,max=300"` // Max based on longest games in history (272), theoretical max 5949
|
Count int `json:"count" validate:"required,min=1,max=300"` // Max based on longest games in history (272), theoretical max 5949
|
||||||
}
|
}
|
||||||
|
|
||||||
// Response types
|
// Response types
|
||||||
|
|
||||||
|
// GameResponse is the live game. Termination names how a finished game ended;
|
||||||
|
// DrawOffer is the color whose draw offer awaits an answer; DrawOutcome
|
||||||
|
// reports what a draw request did ("offered", "accepted", "declined") and is
|
||||||
|
// set only on responses to that request.
|
||||||
type GameResponse struct {
|
type GameResponse struct {
|
||||||
GameID string `json:"gameId"`
|
GameID string `json:"gameId"`
|
||||||
FEN string `json:"fen"`
|
FEN string `json:"fen"`
|
||||||
Turn string `json:"turn"` // "w" or "b"
|
Turn string `json:"turn"` // "w" or "b"
|
||||||
State string `json:"state"` // "ongoing", "white wins", etc
|
State string `json:"state"` // "ongoing", "white wins", etc
|
||||||
Moves []string `json:"moves"`
|
Termination string `json:"termination,omitempty"`
|
||||||
Players PlayersResponse `json:"players"`
|
DrawOffer string `json:"drawOffer,omitempty"`
|
||||||
LastMove *MoveInfo `json:"lastMove,omitempty"`
|
DrawOutcome string `json:"drawOutcome,omitempty"`
|
||||||
|
Moves []string `json:"moves"`
|
||||||
|
Players PlayersResponse `json:"players"`
|
||||||
|
LastMove *MoveInfo `json:"lastMove,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MoveInfo struct {
|
type MoveInfo struct {
|
||||||
@@ -50,19 +70,28 @@ type BoardResponse struct {
|
|||||||
// GameHistoryResponse is the durable replay representation of a game. Moves
|
// GameHistoryResponse is the durable replay representation of a game. Moves
|
||||||
// are ordered and include the resulting FEN so clients do not need an engine
|
// are ordered and include the resulting FEN so clients do not need an engine
|
||||||
// to replay a stored game.
|
// to replay a stored game.
|
||||||
|
//
|
||||||
|
// PGNResult is the PGN result token ("1-0", "0-1", "1/2-1/2", or "*") and
|
||||||
|
// Termination names how a result was reached (see core.Termination; omitted
|
||||||
|
// while the game is unfinished).
|
||||||
type GameHistoryResponse struct {
|
type GameHistoryResponse struct {
|
||||||
GameID string `json:"gameId"`
|
GameID string `json:"gameId"`
|
||||||
InitialFEN string `json:"initialFen"`
|
InitialFEN string `json:"initialFen"`
|
||||||
Result string `json:"result,omitempty"`
|
Result string `json:"result,omitempty"`
|
||||||
|
PGNResult string `json:"pgnResult"`
|
||||||
|
Termination string `json:"termination,omitempty"`
|
||||||
StartTimeUTC time.Time `json:"startTimeUtc"`
|
StartTimeUTC time.Time `json:"startTimeUtc"`
|
||||||
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
|
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
|
||||||
Players PlayersResponse `json:"players"`
|
Players PlayersResponse `json:"players"`
|
||||||
Moves []HistoryMove `json:"moves"`
|
Moves []HistoryMove `json:"moves"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HistoryMove is one ply. SAN is derived on read and omitted only when the
|
||||||
|
// stored move cannot be notated.
|
||||||
type HistoryMove struct {
|
type HistoryMove struct {
|
||||||
MoveNumber int `json:"moveNumber"`
|
MoveNumber int `json:"moveNumber"`
|
||||||
MoveUCI string `json:"moveUci"`
|
MoveUCI string `json:"moveUci"`
|
||||||
|
SAN string `json:"san,omitempty"`
|
||||||
FENAfterMove string `json:"fenAfterMove"`
|
FENAfterMove string `json:"fenAfterMove"`
|
||||||
PlayerColor string `json:"playerColor"`
|
PlayerColor string `json:"playerColor"`
|
||||||
MoveTimeUTC time.Time `json:"moveTimeUtc"`
|
MoveTimeUTC time.Time `json:"moveTimeUtc"`
|
||||||
@@ -76,17 +105,22 @@ type GameSummary struct {
|
|||||||
InitialFEN string `json:"initialFen"`
|
InitialFEN string `json:"initialFen"`
|
||||||
FinalFEN string `json:"finalFen"`
|
FinalFEN string `json:"finalFen"`
|
||||||
Result string `json:"result,omitempty"`
|
Result string `json:"result,omitempty"`
|
||||||
|
PGNResult string `json:"pgnResult"`
|
||||||
StartTimeUTC time.Time `json:"startTimeUtc"`
|
StartTimeUTC time.Time `json:"startTimeUtc"`
|
||||||
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
|
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
|
||||||
MoveCount int `json:"moveCount"`
|
MoveCount int `json:"moveCount"`
|
||||||
Players PlayersResponse `json:"players"`
|
Players PlayersResponse `json:"players"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GameListResponse pages newest first. NextCursor continues the listing and
|
||||||
|
// stays stable while new games are added; NextOffset is kept for clients
|
||||||
|
// that page by position and is omitted on cursor requests.
|
||||||
type GameListResponse struct {
|
type GameListResponse struct {
|
||||||
Games []GameSummary `json:"games"`
|
Games []GameSummary `json:"games"`
|
||||||
Limit int `json:"limit"`
|
Limit int `json:"limit"`
|
||||||
Offset int `json:"offset"`
|
Offset int `json:"offset"`
|
||||||
NextOffset *int `json:"nextOffset,omitempty"`
|
NextOffset *int `json:"nextOffset,omitempty"`
|
||||||
|
NextCursor string `json:"nextCursor,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ErrorResponse struct {
|
type ErrorResponse struct {
|
||||||
|
|||||||
@@ -59,3 +59,45 @@ func (s State) Result() (string, bool) {
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Termination names how a game reached its result.
|
||||||
|
type Termination string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TermNone Termination = ""
|
||||||
|
TermCheckmate Termination = "checkmate"
|
||||||
|
TermStalemate Termination = "stalemate"
|
||||||
|
TermInsufficientMaterial Termination = "insufficient_material"
|
||||||
|
TermThreefoldRepetition Termination = "threefold_repetition"
|
||||||
|
TermFiftyMoveRule Termination = "fifty_move_rule"
|
||||||
|
TermAgreement Termination = "agreement"
|
||||||
|
TermResignation Termination = "resignation"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidFor reports whether a termination can produce the terminal state:
|
||||||
|
// wins come from checkmate or resignation, stalemate from stalemate, and
|
||||||
|
// draws from the draw rules or agreement.
|
||||||
|
func (t Termination) ValidFor(s State) bool {
|
||||||
|
switch s {
|
||||||
|
case StateWhiteWins, StateBlackWins:
|
||||||
|
return t == TermCheckmate || t == TermResignation
|
||||||
|
case StateStalemate:
|
||||||
|
return t == TermStalemate
|
||||||
|
case StateDraw:
|
||||||
|
return t == TermInsufficientMaterial || t == TermThreefoldRepetition ||
|
||||||
|
t == TermFiftyMoveRule || t == TermAgreement
|
||||||
|
default:
|
||||||
|
return t == TermNone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseColor accepts "w", "b", "white", or "black"; anything else is zero.
|
||||||
|
func ParseColor(s string) Color {
|
||||||
|
switch s {
|
||||||
|
case "w", "white":
|
||||||
|
return ColorWhite
|
||||||
|
case "b", "black":
|
||||||
|
return ColorBlack
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"chess/internal/server/board"
|
"chess/internal/server/chess"
|
||||||
"chess/internal/server/core"
|
"chess/internal/server/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,9 +29,13 @@ type Game struct {
|
|||||||
snapshots []Snapshot
|
snapshots []Snapshot
|
||||||
players map[core.Color]*core.Player
|
players map[core.Color]*core.Player
|
||||||
state core.State
|
state core.State
|
||||||
|
termination core.Termination
|
||||||
lastResult *MoveResult
|
lastResult *MoveResult
|
||||||
endTimeUTC *time.Time
|
endTimeUTC *time.Time
|
||||||
lastActivity time.Time // last create, move, undo, reconfiguration, or state change
|
lastActivity time.Time // last create, move, undo, reconfiguration, or state change
|
||||||
|
drawOffer core.Color
|
||||||
|
// offerPly[c] is the ply count when color c last offered a draw, or -1.
|
||||||
|
offerPly map[core.Color]int
|
||||||
}
|
}
|
||||||
|
|
||||||
// View is an immutable copy of the state needed by processors and transports.
|
// View is an immutable copy of the state needed by processors and transports.
|
||||||
@@ -42,11 +46,16 @@ type View struct {
|
|||||||
InitialFEN string
|
InitialFEN string
|
||||||
NextTurnColor core.Color
|
NextTurnColor core.Color
|
||||||
Moves []string
|
Moves []string
|
||||||
WhitePlayer *core.Player
|
// FENs holds the position before the first move and after every move.
|
||||||
BlackPlayer *core.Player
|
FENs []string
|
||||||
State core.State
|
WhitePlayer *core.Player
|
||||||
LastResult *MoveResult
|
BlackPlayer *core.Player
|
||||||
EndTimeUTC *time.Time
|
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 {
|
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.ColorWhite: &whiteCopy,
|
||||||
core.ColorBlack: &blackCopy,
|
core.ColorBlack: &blackCopy,
|
||||||
},
|
},
|
||||||
state: core.StateOngoing,
|
state: core.StateOngoing,
|
||||||
|
offerPly: map[core.Color]int{core.ColorWhite: -1, core.ColorBlack: -1},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +97,17 @@ func (g *Game) View() View {
|
|||||||
InitialFEN: g.InitialFEN(),
|
InitialFEN: g.InitialFEN(),
|
||||||
NextTurnColor: g.NextTurnColor(),
|
NextTurnColor: g.NextTurnColor(),
|
||||||
Moves: g.Moves(),
|
Moves: g.Moves(),
|
||||||
|
FENs: make([]string, len(g.snapshots)),
|
||||||
State: g.state,
|
State: g.state,
|
||||||
|
Termination: g.termination,
|
||||||
|
DrawOffer: g.drawOffer,
|
||||||
|
OfferPly: map[core.Color]int{
|
||||||
|
core.ColorWhite: g.offerPly[core.ColorWhite],
|
||||||
|
core.ColorBlack: g.offerPly[core.ColorBlack],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for i, snapshot := range g.snapshots {
|
||||||
|
view.FENs[i] = snapshot.FEN
|
||||||
}
|
}
|
||||||
if player := g.players[core.ColorWhite]; player != nil {
|
if player := g.players[core.ColorWhite]; player != nil {
|
||||||
copy := *player
|
copy := *player
|
||||||
@@ -156,7 +176,13 @@ func (g *Game) GetPlayer(color core.Color) *core.Player {
|
|||||||
return g.players[color]
|
return g.players[color]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AddSnapshot appends a move. It answers a draw offer made by the other
|
||||||
|
// side: moving instead of accepting declines it. An offer from the mover
|
||||||
|
// stands.
|
||||||
func (g *Game) AddSnapshot(fen string, move string, nextTurnColor core.Color) {
|
func (g *Game) AddSnapshot(fen string, move string, nextTurnColor core.Color) {
|
||||||
|
if g.drawOffer == nextTurnColor {
|
||||||
|
g.drawOffer = 0
|
||||||
|
}
|
||||||
// Get the player ID for the next turn
|
// Get the player ID for the next turn
|
||||||
nextPlayer := g.players[nextTurnColor]
|
nextPlayer := g.players[nextTurnColor]
|
||||||
g.snapshots = append(g.snapshots, Snapshot{
|
g.snapshots = append(g.snapshots, Snapshot{
|
||||||
@@ -195,11 +221,58 @@ func (g *Game) UndoMoves(count int) error {
|
|||||||
|
|
||||||
g.snapshots = g.snapshots[:len(g.snapshots)-count]
|
g.snapshots = g.snapshots[:len(g.snapshots)-count]
|
||||||
g.state = core.StateOngoing // Reset game state when undoing
|
g.state = core.StateOngoing // Reset game state when undoing
|
||||||
g.lastResult = nil // Clear last result
|
g.termination = core.TermNone
|
||||||
|
g.lastResult = nil // Clear last result
|
||||||
g.endTimeUTC = nil
|
g.endTimeUTC = nil
|
||||||
|
g.ClearDrawOffers()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Plies returns the number of moves played.
|
||||||
|
func (g *Game) Plies() int {
|
||||||
|
return len(g.snapshots) - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// DrawOffer returns the color whose draw offer is pending, or 0.
|
||||||
|
func (g *Game) DrawOffer() core.Color {
|
||||||
|
return g.drawOffer
|
||||||
|
}
|
||||||
|
|
||||||
|
// OfferDraw records a pending offer from color and the ply it was made at.
|
||||||
|
func (g *Game) OfferDraw(color core.Color) {
|
||||||
|
g.drawOffer = color
|
||||||
|
g.offerPly[color] = g.Plies()
|
||||||
|
}
|
||||||
|
|
||||||
|
// LastOfferPly returns the ply count at color's last draw offer, or -1.
|
||||||
|
func (g *Game) LastOfferPly(color core.Color) int {
|
||||||
|
return g.offerPly[color]
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordDeclinedOffer counts an offer that was answered at once (by a
|
||||||
|
// computer) against its maker's one-offer-per-move allowance.
|
||||||
|
func (g *Game) RecordDeclinedOffer(color core.Color) {
|
||||||
|
g.offerPly[color] = g.Plies()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeclineDraw withdraws a pending offer.
|
||||||
|
func (g *Game) DeclineDraw() {
|
||||||
|
g.drawOffer = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearDrawOffers forgets the pending offer and the per-move allowance, as
|
||||||
|
// after an undo or a player change.
|
||||||
|
func (g *Game) ClearDrawOffers() {
|
||||||
|
g.drawOffer = 0
|
||||||
|
g.offerPly[core.ColorWhite] = -1
|
||||||
|
g.offerPly[core.ColorBlack] = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Termination returns how a finished game ended.
|
||||||
|
func (g *Game) Termination() core.Termination {
|
||||||
|
return g.termination
|
||||||
|
}
|
||||||
|
|
||||||
func (g *Game) Moves() []string {
|
func (g *Game) Moves() []string {
|
||||||
moves := []string{}
|
moves := []string{}
|
||||||
for i := 1; i < len(g.snapshots); i++ {
|
for i := 1; i < len(g.snapshots); i++ {
|
||||||
@@ -214,6 +287,18 @@ func (g *Game) State() core.State {
|
|||||||
return g.state
|
return g.state
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetOutcome sets the state and how it was reached; a non-terminal state
|
||||||
|
// clears the termination.
|
||||||
|
func (g *Game) SetOutcome(s core.State, termination core.Termination, at time.Time) {
|
||||||
|
g.SetStateAt(s, at)
|
||||||
|
if s.IsTerminal() {
|
||||||
|
g.termination = termination
|
||||||
|
g.drawOffer = 0
|
||||||
|
} else {
|
||||||
|
g.termination = core.TermNone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (g *Game) SetStateAt(s core.State, at time.Time) {
|
func (g *Game) SetStateAt(s core.State, at time.Time) {
|
||||||
g.Touch(at)
|
g.Touch(at)
|
||||||
if s.IsTerminal() {
|
if s.IsTerminal() {
|
||||||
@@ -261,7 +346,7 @@ func (g *Game) InitialFEN() string {
|
|||||||
if len(g.snapshots) > 0 {
|
if len(g.snapshots) > 0 {
|
||||||
return g.snapshots[0].FEN
|
return g.snapshots[0].FEN
|
||||||
}
|
}
|
||||||
return board.StartingFEN
|
return chess.StartFEN
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClaimSlot claims a player slot for a user
|
// ClaimSlot claims a player slot for a user
|
||||||
|
|||||||
+161
-21
@@ -1,9 +1,12 @@
|
|||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"chess/internal/server/core"
|
"chess/internal/server/core"
|
||||||
@@ -166,11 +169,14 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, opts Options)
|
|||||||
// Register game routes with auth middleware
|
// Register game routes with auth middleware
|
||||||
api.Post("/games", OptionalAuth(validateToken), h.CreateGame) // Optional auth for player ID association
|
api.Post("/games", OptionalAuth(validateToken), h.CreateGame) // Optional auth for player ID association
|
||||||
api.Get("/games/:gameId/history", h.GetGameHistory)
|
api.Get("/games/:gameId/history", h.GetGameHistory)
|
||||||
|
api.Get("/games/:gameId/pgn", h.GetGamePGN)
|
||||||
api.Put("/games/:gameId/players", h.ConfigurePlayers)
|
api.Put("/games/:gameId/players", h.ConfigurePlayers)
|
||||||
api.Get("/games/:gameId", h.GetGame)
|
api.Get("/games/:gameId", h.GetGame)
|
||||||
api.Delete("/games/:gameId", h.DeleteGame)
|
api.Delete("/games/:gameId", h.DeleteGame)
|
||||||
api.Post("/games/:gameId/moves", OptionalAuth(validateToken), h.MakeMove)
|
api.Post("/games/:gameId/moves", OptionalAuth(validateToken), h.MakeMove)
|
||||||
api.Post("/games/:gameId/undo", h.UndoMove)
|
api.Post("/games/:gameId/undo", h.UndoMove)
|
||||||
|
api.Post("/games/:gameId/resign", OptionalAuth(validateToken), h.Resign)
|
||||||
|
api.Post("/games/:gameId/draw", OptionalAuth(validateToken), h.Draw)
|
||||||
api.Get("/games/:gameId/board", h.GetBoard)
|
api.Get("/games/:gameId/board", h.GetBoard)
|
||||||
api.Get("/users/me/games", AuthRequired(validateToken), h.GetCurrentUserGames)
|
api.Get("/users/me/games", AuthRequired(validateToken), h.GetCurrentUserGames)
|
||||||
|
|
||||||
@@ -508,6 +514,58 @@ func (h *HTTPHandler) UndoMove(c *fiber.Ctx) error {
|
|||||||
return c.JSON(resp.Data)
|
return c.JSON(resp.Data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resign ends the game in the opponent's favor.
|
||||||
|
func (h *HTTPHandler) Resign(c *fiber.Ctx) error {
|
||||||
|
return h.executeWithBody(c, func(gameID string, body any) processor.Command {
|
||||||
|
return processor.NewResignCommand(gameID, *body.(*core.ResignRequest))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw offers, accepts, or declines a draw by agreement.
|
||||||
|
func (h *HTTPHandler) Draw(c *fiber.Ctx) error {
|
||||||
|
return h.executeWithBody(c, func(gameID string, body any) processor.Command {
|
||||||
|
return processor.NewDrawCommand(gameID, *body.(*core.DrawRequest))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeWithBody runs a game command built from the validated request body,
|
||||||
|
// with the caller's user ID for slot authorization.
|
||||||
|
func (h *HTTPHandler) executeWithBody(c *fiber.Ctx, build func(gameID string, body any) processor.Command) error {
|
||||||
|
gameID := c.Params("gameId")
|
||||||
|
if !isValidUUID(gameID) {
|
||||||
|
return invalidGameID(c)
|
||||||
|
}
|
||||||
|
body := c.Locals("validatedBody")
|
||||||
|
if validated, _ := c.Locals("validated").(bool); !validated || body == nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||||
|
Error: "validation bypass detected", Code: core.ErrInternalError,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
cmd := build(gameID, body)
|
||||||
|
cmd.UserID, _ = c.Locals("userID").(string)
|
||||||
|
|
||||||
|
resp := h.proc.Execute(cmd)
|
||||||
|
if !resp.Success {
|
||||||
|
return c.Status(statusForCode(resp.Error.Code)).JSON(resp.Error)
|
||||||
|
}
|
||||||
|
return c.JSON(resp.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusForCode maps processor error codes to HTTP statuses.
|
||||||
|
func statusForCode(code string) int {
|
||||||
|
switch code {
|
||||||
|
case core.ErrGameNotFound:
|
||||||
|
return fiber.StatusNotFound
|
||||||
|
case core.ErrUnauthorized:
|
||||||
|
return fiber.StatusForbidden
|
||||||
|
case core.ErrConflict:
|
||||||
|
return fiber.StatusConflict
|
||||||
|
case core.ErrInternalError:
|
||||||
|
return fiber.StatusInternalServerError
|
||||||
|
}
|
||||||
|
return fiber.StatusBadRequest
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteGame unloads a live game while retaining its durable history.
|
// DeleteGame unloads a live game while retaining its durable history.
|
||||||
func (h *HTTPHandler) DeleteGame(c *fiber.Ctx) error {
|
func (h *HTTPHandler) DeleteGame(c *fiber.Ctx) error {
|
||||||
gameID := c.Params("gameId")
|
gameID := c.Params("gameId")
|
||||||
@@ -563,34 +621,105 @@ func (h *HTTPHandler) GetBoard(c *fiber.Ctx) error {
|
|||||||
func (h *HTTPHandler) GetGameHistory(c *fiber.Ctx) error {
|
func (h *HTTPHandler) GetGameHistory(c *fiber.Ctx) error {
|
||||||
gameID := c.Params("gameId")
|
gameID := c.Params("gameId")
|
||||||
if !isValidUUID(gameID) {
|
if !isValidUUID(gameID) {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
return invalidGameID(c)
|
||||||
Error: "invalid game ID format", Code: core.ErrInvalidRequest,
|
|
||||||
Details: "game ID must be a valid UUID",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
history, err := h.svc.GetGameHistory(gameID)
|
history, err := h.svc.GetGameHistory(gameID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch {
|
return storedGameError(c, err)
|
||||||
case errors.Is(err, service.ErrStorageDisabled), errors.Is(err, service.ErrStorageUnavailable):
|
}
|
||||||
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
|
body, err := c.App().Config().JSONEncoder(history)
|
||||||
Error: "game history storage unavailable", Code: core.ErrStorageUnavailable,
|
if err != nil {
|
||||||
})
|
return err
|
||||||
case errors.Is(err, service.ErrGameNotFound):
|
}
|
||||||
return c.Status(fiber.StatusNotFound).JSON(core.ErrorResponse{
|
return sendRevalidated(c, fiber.MIMEApplicationJSON, body)
|
||||||
Error: "game history not found", Code: core.ErrGameNotFound,
|
}
|
||||||
})
|
|
||||||
default:
|
// GetGamePGN exports a stored game as a PGN attachment; ?ply=N stops after N
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
// plies. Same visibility as the history.
|
||||||
Error: "failed to load game history", Code: core.ErrInternalError,
|
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
|
// GetCurrentUserGames returns a bounded list suitable for CLI and web game
|
||||||
// pickers. The extra row used to compute nextOffset stays internal.
|
// pickers. The extra row used to detect a next page stays internal.
|
||||||
func (h *HTTPHandler) GetCurrentUserGames(c *fiber.Ctx) error {
|
func (h *HTTPHandler) GetCurrentUserGames(c *fiber.Ctx) error {
|
||||||
userID, ok := c.Locals("userID").(string)
|
userID, ok := c.Locals("userID").(string)
|
||||||
if !ok || userID == "" {
|
if !ok || userID == "" {
|
||||||
@@ -612,9 +741,20 @@ func (h *HTTPHandler) GetCurrentUserGames(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
games, err := h.svc.GetUserGames(userID, limit, offset)
|
games, err := h.svc.GetUserGames(userID, service.UserGamesOptions{
|
||||||
|
Limit: limit,
|
||||||
|
Offset: offset,
|
||||||
|
Cursor: c.Query("cursor"),
|
||||||
|
Color: c.Query("color"),
|
||||||
|
Status: c.Query("status"),
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable) {
|
switch {
|
||||||
|
case errors.Is(err, service.ErrInvalidListQuery):
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||||
|
Error: "invalid list query", Code: core.ErrInvalidRequest, Details: err.Error(),
|
||||||
|
})
|
||||||
|
case errors.Is(err, service.ErrStorageDisabled), errors.Is(err, service.ErrStorageUnavailable):
|
||||||
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
|
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
|
||||||
Error: "stored games unavailable", Code: core.ErrStorageUnavailable,
|
Error: "stored games unavailable", Code: core.ErrStorageUnavailable,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,13 +2,20 @@ package http
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
|
nethttp "net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"chess/internal/server/chess"
|
||||||
|
"chess/internal/server/core"
|
||||||
"chess/internal/server/service"
|
"chess/internal/server/service"
|
||||||
|
"chess/internal/server/storage"
|
||||||
|
"chess/internal/server/storage/pgtest"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestClientIPTrustsProxyHeaderOnlyFromTrustedPeers(t *testing.T) {
|
func TestClientIPTrustsProxyHeaderOnlyFromTrustedPeers(t *testing.T) {
|
||||||
@@ -63,7 +70,7 @@ func TestIsValidUUIDRequiresCanonicalForm(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAPIRoutesAreUnversioned(t *testing.T) {
|
func TestAPIRoutes(t *testing.T) {
|
||||||
svc, err := service.New(nil, []byte("test-secret-test-secret-test-secret"))
|
svc, err := service.New(nil, []byte("test-secret-test-secret-test-secret"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -74,9 +81,6 @@ func TestAPIRoutesAreUnversioned(t *testing.T) {
|
|||||||
registered := make(map[string]bool)
|
registered := make(map[string]bool)
|
||||||
for _, route := range app.GetRoutes(true) {
|
for _, route := range app.GetRoutes(true) {
|
||||||
registered[route.Method+" "+route.Path] = true
|
registered[route.Method+" "+route.Path] = true
|
||||||
if strings.HasPrefix(route.Path, "/api/v1") {
|
|
||||||
t.Errorf("versioned route still registered: %s %s", route.Method, route.Path)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"GET /health",
|
"GET /health",
|
||||||
@@ -87,19 +91,124 @@ func TestAPIRoutesAreUnversioned(t *testing.T) {
|
|||||||
"POST /api/games",
|
"POST /api/games",
|
||||||
"GET /api/games/:gameId",
|
"GET /api/games/:gameId",
|
||||||
"GET /api/games/:gameId/history",
|
"GET /api/games/:gameId/history",
|
||||||
|
"GET /api/games/:gameId/pgn",
|
||||||
"POST /api/games/:gameId/moves",
|
"POST /api/games/:gameId/moves",
|
||||||
|
"POST /api/games/:gameId/resign",
|
||||||
|
"POST /api/games/:gameId/draw",
|
||||||
"GET /api/users/me/games",
|
"GET /api/users/me/games",
|
||||||
} {
|
} {
|
||||||
if !registered[want] {
|
if !registered[want] {
|
||||||
t.Errorf("route %s is not registered", want)
|
t.Errorf("route %s is not registered", want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
response, err := app.Test(httptest.NewRequest("GET", "/api/v1/games/5579b47e-4d3b-4eb3-abbd-846fe94cb955", nil))
|
func TestETagMatches(t *testing.T) {
|
||||||
|
const etag = `"0123abcd"`
|
||||||
|
for header, want := range map[string]bool{
|
||||||
|
"": false,
|
||||||
|
`"0123abcd"`: true,
|
||||||
|
`W/"0123abcd"`: true,
|
||||||
|
`"other", "0123abcd"`: true,
|
||||||
|
`"other",W/"0123abcd" `: true,
|
||||||
|
"*": true,
|
||||||
|
`"0123abc"`: false,
|
||||||
|
`0123abcd`: false,
|
||||||
|
} {
|
||||||
|
if got := etagMatches(header, etag); got != want {
|
||||||
|
t.Errorf("etagMatches(%q) = %v, want %v", header, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoredGameEndpoints(t *testing.T) {
|
||||||
|
store, err := storage.NewStore(pgtest.DSN(t))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if response.StatusCode != fiber.StatusNotFound {
|
if err := store.InitDB(); err != nil {
|
||||||
t.Fatalf("GET /api/v1/... status = %d, want 404", response.StatusCode)
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
svc, err := service.New(store, []byte("test-secret-test-secret-test-secret"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { svc.Shutdown(time.Second) })
|
||||||
|
|
||||||
|
gameID := uuid.NewString()
|
||||||
|
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
|
||||||
|
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
|
||||||
|
if err := svc.CreateGame(gameID, white, black, chess.StartFEN, core.ColorWhite, core.StateOngoing, core.TermNone); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pos, _ := chess.ParseFEN(chess.StartFEN)
|
||||||
|
for i, uci := range []string{"e2e4", "e7e5"} {
|
||||||
|
m, err := pos.ParseUCI(uci)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
next := pos.Play(m)
|
||||||
|
turn := core.ColorWhite
|
||||||
|
if i == 1 {
|
||||||
|
turn = core.ColorBlack
|
||||||
|
}
|
||||||
|
if err := svc.ApplyMoveWithState(gameID, service.MoveCommit{
|
||||||
|
ExpectedFEN: pos.FEN(), ExpectedState: core.StateOngoing, ExpectedTurn: turn,
|
||||||
|
MoveUCI: uci, NewFEN: next.FEN(), State: core.StateOngoing,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pos = next
|
||||||
|
}
|
||||||
|
|
||||||
|
app := NewFiberApp(nil, svc, Options{DevMode: true})
|
||||||
|
get := func(path string, header ...string) (*nethttp.Response, string) {
|
||||||
|
t.Helper()
|
||||||
|
request := httptest.NewRequest("GET", path, nil)
|
||||||
|
for i := 0; i+1 < len(header); i += 2 {
|
||||||
|
request.Header.Set(header[i], header[i+1])
|
||||||
|
}
|
||||||
|
response, err := app.Test(request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(response.Body)
|
||||||
|
return response, string(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
response, body := get("/api/games/" + gameID + "/history")
|
||||||
|
etag := response.Header.Get("ETag")
|
||||||
|
if response.StatusCode != 200 || etag == "" || response.Header.Get("Cache-Control") != "private, no-cache" ||
|
||||||
|
!strings.Contains(body, `"san":"e4"`) || !strings.Contains(body, `"pgnResult":"*"`) ||
|
||||||
|
!strings.HasPrefix(response.Header.Get("Content-Type"), "application/json") {
|
||||||
|
t.Fatalf("history: %d %v %s", response.StatusCode, response.Header, body)
|
||||||
|
}
|
||||||
|
if response, body = get("/api/games/"+gameID+"/history", "If-None-Match", etag); response.StatusCode != 304 || body != "" {
|
||||||
|
t.Fatalf("revalidated history: %d %q", response.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
response, body = get("/api/games/" + gameID + "/pgn")
|
||||||
|
if response.StatusCode != 200 || response.Header.Get("Content-Type") != "application/x-chess-pgn; charset=utf-8" ||
|
||||||
|
!strings.HasPrefix(response.Header.Get("Content-Disposition"), `attachment; filename="chess-`) ||
|
||||||
|
!strings.HasSuffix(body, "\n\n1. e4 e5 *\n") {
|
||||||
|
t.Fatalf("pgn: %d %v\n%s", response.StatusCode, response.Header, body)
|
||||||
|
}
|
||||||
|
if response, body = get("/api/games/" + gameID + "/pgn?ply=1"); response.StatusCode != 200 ||
|
||||||
|
!strings.HasSuffix(body, "\n\n1. e4 *\n") ||
|
||||||
|
!strings.Contains(response.Header.Get("Content-Disposition"), "-ply1.pgn") {
|
||||||
|
t.Fatalf("pgn ply 1: %d %v\n%s", response.StatusCode, response.Header, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
for path, want := range map[string]int{
|
||||||
|
"/api/games/" + gameID + "/pgn?ply=3": 400,
|
||||||
|
"/api/games/" + gameID + "/pgn?ply=-1": 400,
|
||||||
|
"/api/games/" + gameID + "/pgn?ply=x": 400,
|
||||||
|
"/api/games/" + uuid.NewString() + "/pgn": 404,
|
||||||
|
"/api/games/" + uuid.NewString() + "/history": 404,
|
||||||
|
"/api/games/not-a-uuid/pgn": 400,
|
||||||
|
} {
|
||||||
|
if response, body := get(path); response.StatusCode != want {
|
||||||
|
t.Errorf("GET %s = %d %s, want %d", path, response.StatusCode, body, want)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -36,6 +36,10 @@ func validationMiddleware(c *fiber.Ctx) error {
|
|||||||
requestType = &core.MoveRequest{}
|
requestType = &core.MoveRequest{}
|
||||||
case strings.HasSuffix(path, "/undo") && method == fiber.MethodPost:
|
case strings.HasSuffix(path, "/undo") && method == fiber.MethodPost:
|
||||||
requestType = &core.UndoRequest{}
|
requestType = &core.UndoRequest{}
|
||||||
|
case strings.HasSuffix(path, "/resign") && method == fiber.MethodPost:
|
||||||
|
requestType = &core.ResignRequest{}
|
||||||
|
case strings.HasSuffix(path, "/draw") && method == fiber.MethodPost:
|
||||||
|
requestType = &core.DrawRequest{}
|
||||||
default:
|
default:
|
||||||
return c.Next() // No validation for unknown endpoints
|
return c.Next() // No validation for unknown endpoints
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ const (
|
|||||||
CmdMakeMove
|
CmdMakeMove
|
||||||
CmdUndoMove
|
CmdUndoMove
|
||||||
CmdGetBoard
|
CmdGetBoard
|
||||||
|
CmdResign
|
||||||
|
CmdDraw
|
||||||
)
|
)
|
||||||
|
|
||||||
// Command is a unified structure for all processor operations
|
// Command is a unified structure for all processor operations
|
||||||
@@ -84,3 +86,19 @@ func NewGetBoardCommand(gameID string) Command {
|
|||||||
GameID: gameID,
|
GameID: gameID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewResignCommand(gameID string, req core.ResignRequest) Command {
|
||||||
|
return Command{
|
||||||
|
Type: CmdResign,
|
||||||
|
GameID: gameID,
|
||||||
|
Args: req,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDrawCommand(gameID string, req core.DrawRequest) Command {
|
||||||
|
return Command{
|
||||||
|
Type: CmdDraw,
|
||||||
|
GameID: gameID,
|
||||||
|
Args: req,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
"chess/internal/server/board"
|
"chess/internal/server/chess"
|
||||||
"chess/internal/server/core"
|
"chess/internal/server/core"
|
||||||
"chess/internal/server/engine"
|
"chess/internal/server/engine"
|
||||||
"chess/internal/server/game"
|
"chess/internal/server/game"
|
||||||
@@ -19,6 +19,14 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
minSearchTime = 100
|
minSearchTime = 100
|
||||||
|
|
||||||
|
// A computer answers a draw offer only once both sides have made ten
|
||||||
|
// moves, and accepts when its evaluation, from its own side, is level or
|
||||||
|
// worse. The evaluation is a short search on the validation engine; the
|
||||||
|
// one-offer-per-move allowance bounds how often it runs.
|
||||||
|
drawOfferMinPlies = 20
|
||||||
|
drawEvalTimeMs = 100
|
||||||
|
drawAcceptMaxScore = 0 // centipawns
|
||||||
)
|
)
|
||||||
|
|
||||||
// FEN validation regex
|
// FEN validation regex
|
||||||
@@ -63,6 +71,10 @@ func (p *Processor) Execute(cmd Command) ProcessorResponse {
|
|||||||
return p.handleDeleteGame(cmd)
|
return p.handleDeleteGame(cmd)
|
||||||
case CmdGetBoard:
|
case CmdGetBoard:
|
||||||
return p.handleGetBoard(cmd)
|
return p.handleGetBoard(cmd)
|
||||||
|
case CmdResign:
|
||||||
|
return p.handleResign(cmd)
|
||||||
|
case CmdDraw:
|
||||||
|
return p.handleDraw(cmd)
|
||||||
default:
|
default:
|
||||||
return p.errorResponse("unknown command", core.ErrInvalidRequest)
|
return p.errorResponse("unknown command", core.ErrInvalidRequest)
|
||||||
}
|
}
|
||||||
@@ -144,28 +156,38 @@ func (p *Processor) handleCreateGame(cmd Command) ProcessorResponse {
|
|||||||
gameID := p.svc.GenerateGameID()
|
gameID := p.svc.GenerateGameID()
|
||||||
|
|
||||||
// Validate FEN safety, then classify via engine
|
// Validate FEN safety, then classify via engine
|
||||||
initialFEN := board.StartingFEN
|
initialFEN := chess.StartFEN
|
||||||
if args.FEN != "" {
|
if args.FEN != "" {
|
||||||
if !p.isFENSafe(args.FEN) {
|
if !p.isFENSafe(args.FEN) {
|
||||||
return p.errorResponse("invalid FEN format or characters", core.ErrInvalidFEN)
|
return p.errorResponse("invalid FEN format or characters", core.ErrInvalidFEN)
|
||||||
}
|
}
|
||||||
initialFEN = args.FEN
|
// The rules core rejects positions the engine must never see (missing
|
||||||
|
// kings, the side not to move in check) and drops castling rights
|
||||||
|
// without their rook, so both agree on the legal moves.
|
||||||
|
pos, err := chess.ParseFEN(args.FEN)
|
||||||
|
if err != nil {
|
||||||
|
return p.errorResponse(err.Error(), core.ErrInvalidFEN)
|
||||||
|
}
|
||||||
|
initialFEN = pos.FEN()
|
||||||
}
|
}
|
||||||
|
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
err := p.validationEng.NewGame()
|
err := p.validationEng.NewGame()
|
||||||
var validatedFEN string
|
var validatedFEN string
|
||||||
initialState := core.StateOngoing
|
initialState, termination := core.StateOngoing, core.TermNone
|
||||||
if err == nil {
|
if err == nil {
|
||||||
validatedFEN, initialState, err = p.classifyLocked(initialFEN)
|
validatedFEN, initialState, termination, err = p.classifyLocked(initialFEN)
|
||||||
}
|
}
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.errorResponse(fmt.Sprintf("engine validation failed: %v", err), core.ErrInternalError)
|
return p.errorResponse(fmt.Sprintf("engine validation failed: %v", err), core.ErrInternalError)
|
||||||
}
|
}
|
||||||
|
if initialState == core.StateOngoing {
|
||||||
|
initialState, termination = adjudicateDraw(nil, validatedFEN)
|
||||||
|
}
|
||||||
|
|
||||||
// Parse canonical FEN to get starting turn
|
// Parse canonical FEN to get starting turn
|
||||||
b, err := board.ParseFEN(validatedFEN)
|
start, err := chess.ParseFEN(validatedFEN)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.errorResponse(fmt.Sprintf("FEN parse error: %v", err), core.ErrInvalidRequest)
|
return p.errorResponse(fmt.Sprintf("FEN parse error: %v", err), core.ErrInvalidRequest)
|
||||||
}
|
}
|
||||||
@@ -186,7 +208,8 @@ func (p *Processor) handleCreateGame(cmd Command) ProcessorResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = p.svc.CreateGame(gameID, whitePlayer, blackPlayer, validatedFEN, b.Turn(), initialState); err != nil {
|
if err = p.svc.CreateGame(gameID, whitePlayer, blackPlayer, validatedFEN,
|
||||||
|
coreColor(start.Turn()), initialState, termination); err != nil {
|
||||||
return p.errorResponse(fmt.Sprintf("failed to create game: %v", err), core.ErrInternalError)
|
return p.errorResponse(fmt.Sprintf("failed to create game: %v", err), core.ErrInternalError)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,7 +331,7 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
|
|||||||
return p.errorResponse(fmt.Sprintf("failed to start computer move: %v", err), core.ErrInternalError)
|
return p.errorResponse(fmt.Sprintf("failed to start computer move: %v", err), core.ErrInternalError)
|
||||||
}
|
}
|
||||||
if err := p.triggerComputerMove(cmd.GameID, g); err != nil {
|
if err := p.triggerComputerMove(cmd.GameID, g); err != nil {
|
||||||
p.svc.UpdateGameState(cmd.GameID, core.StateStuck)
|
p.svc.UpdateGameState(cmd.GameID, core.StateStuck, core.TermNone)
|
||||||
return p.errorResponse(fmt.Sprintf("failed to queue computer move: %v", err), core.ErrResourceLimit)
|
return p.errorResponse(fmt.Sprintf("failed to queue computer move: %v", err), core.ErrResourceLimit)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,27 +374,38 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
|
|||||||
|
|
||||||
currentFEN := g.FEN
|
currentFEN := g.FEN
|
||||||
|
|
||||||
|
// A pawn move to the last rank needs its promotion piece; say so instead
|
||||||
|
// of the engine's generic rejection.
|
||||||
|
rules := rulesCheck(currentFEN, move)
|
||||||
|
if errors.Is(rules.err, chess.ErrPromotionRequired) {
|
||||||
|
return p.errorResponse("promotion piece required: append q, r, b, or n (e.g. "+move+"q)", core.ErrInvalidMove)
|
||||||
|
}
|
||||||
|
|
||||||
// Validate move and classify the resulting position in one engine session
|
// Validate move and classify the resulting position in one engine session
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
err = p.validationEng.SetPosition(currentFEN, []string{move})
|
err = p.validationEng.SetPosition(currentFEN, []string{move})
|
||||||
var newFEN string
|
var newFEN string
|
||||||
finalState := core.StateOngoing
|
finalState, termination := core.StateOngoing, core.TermNone
|
||||||
if err == nil {
|
if err == nil {
|
||||||
newFEN, finalState, err = p.classifyCurrentLocked()
|
newFEN, finalState, termination, err = p.classifyCurrentLocked()
|
||||||
}
|
}
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Game untouched at pre-move position; retry runs on a respawned engine
|
// Game untouched at pre-move position; retry runs on a respawned engine
|
||||||
return p.errorResponse("engine unavailable", core.ErrInternalError)
|
return p.errorResponse("engine unavailable", core.ErrInternalError)
|
||||||
}
|
}
|
||||||
|
rules.compare(cmd.GameID, move, newFEN != currentFEN, newFEN)
|
||||||
if newFEN == currentFEN {
|
if newFEN == currentFEN {
|
||||||
return p.errorResponse("illegal move", core.ErrInvalidMove)
|
return p.errorResponse("illegal move", core.ErrInvalidMove)
|
||||||
}
|
}
|
||||||
|
if finalState == core.StateOngoing {
|
||||||
|
finalState, termination = adjudicateDraw(g.FENs, newFEN)
|
||||||
|
}
|
||||||
|
|
||||||
// Atomic commit: move + state + metadata, single notification
|
// Atomic commit: move + state + metadata, single notification
|
||||||
if err = p.svc.ApplyMoveWithState(cmd.GameID, service.MoveCommit{
|
if err = p.svc.ApplyMoveWithState(cmd.GameID, service.MoveCommit{
|
||||||
ExpectedFEN: currentFEN, ExpectedState: core.StateOngoing, ExpectedTurn: currentColor,
|
ExpectedFEN: currentFEN, ExpectedState: core.StateOngoing, ExpectedTurn: currentColor,
|
||||||
ActorUserID: cmd.UserID, MoveUCI: move, NewFEN: newFEN, State: finalState,
|
ActorUserID: cmd.UserID, MoveUCI: move, NewFEN: newFEN, State: finalState, Termination: termination,
|
||||||
Result: &game.MoveResult{Move: move, PlayerColor: currentColor, GameState: finalState},
|
Result: &game.MoveResult{Move: move, PlayerColor: currentColor, GameState: finalState},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
if errors.Is(err, service.ErrSlotOwner) {
|
if errors.Is(err, service.ErrSlotOwner) {
|
||||||
@@ -455,11 +489,11 @@ func (p *Processor) handleGetBoard(cmd Command) ProcessorResponse {
|
|||||||
return p.errorResponse("game not found", core.ErrGameNotFound)
|
return p.errorResponse("game not found", core.ErrGameNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
b, err := board.ParseFEN(g.FEN)
|
pos, err := chess.ParseFEN(g.FEN)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.errorResponse("error parsing FEN", core.ErrInvalidFEN)
|
return p.errorResponse("error parsing FEN", core.ErrInvalidFEN)
|
||||||
}
|
}
|
||||||
ascii := b.ToASCII()
|
ascii := pos.ASCII()
|
||||||
|
|
||||||
return ProcessorResponse{
|
return ProcessorResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
@@ -486,38 +520,42 @@ func (p *Processor) triggerComputerMove(gameID string, g game.View) error {
|
|||||||
}
|
}
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
slog.Error("computer engine failed", "game_id", gameID, "error", result.Error)
|
slog.Error("computer engine failed", "game_id", gameID, "error", result.Error)
|
||||||
p.svc.UpdateGameState(gameID, core.StateStuck)
|
p.svc.UpdateGameState(gameID, core.StateStuck, core.TermNone)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if result.Move == "" || result.Move == "(none)" {
|
if result.Move == "" || result.Move == "(none)" {
|
||||||
// Worker says no legal moves; verify against the validation engine.
|
// Worker says no legal moves; verify against the validation engine.
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
_, state, cerr := p.classifyLocked(fen)
|
_, state, termination, cerr := p.classifyLocked(fen)
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
if cerr != nil || state == core.StateOngoing {
|
if cerr != nil || state == core.StateOngoing {
|
||||||
p.svc.UpdateGameState(gameID, core.StateStuck) // engines disagree
|
p.svc.UpdateGameState(gameID, core.StateStuck, core.TermNone) // engines disagree
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
p.svc.UpdateGameState(gameID, state)
|
p.svc.UpdateGameState(gameID, state, termination)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
aerr := p.validationEng.SetPosition(fen, []string{result.Move})
|
aerr := p.validationEng.SetPosition(fen, []string{result.Move})
|
||||||
var newFEN string
|
var newFEN string
|
||||||
finalState := core.StateOngoing
|
finalState, termination := core.StateOngoing, core.TermNone
|
||||||
if aerr == nil {
|
if aerr == nil {
|
||||||
newFEN, finalState, aerr = p.classifyCurrentLocked()
|
newFEN, finalState, termination, aerr = p.classifyCurrentLocked()
|
||||||
}
|
}
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
if aerr != nil || newFEN == fen {
|
if aerr != nil || newFEN == fen {
|
||||||
p.svc.UpdateGameState(gameID, core.StateStuck)
|
p.svc.UpdateGameState(gameID, core.StateStuck, core.TermNone)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
rulesCheck(fen, result.Move).compare(gameID, result.Move, true, newFEN)
|
||||||
|
if finalState == core.StateOngoing {
|
||||||
|
finalState, termination = adjudicateDraw(currentGame.FENs, newFEN)
|
||||||
|
}
|
||||||
|
|
||||||
if err := p.svc.ApplyMoveWithState(gameID, service.MoveCommit{
|
if err := p.svc.ApplyMoveWithState(gameID, service.MoveCommit{
|
||||||
ExpectedFEN: fen, ExpectedState: core.StatePending, ExpectedTurn: color,
|
ExpectedFEN: fen, ExpectedState: core.StatePending, ExpectedTurn: color,
|
||||||
MoveUCI: result.Move, NewFEN: newFEN, State: finalState,
|
MoveUCI: result.Move, NewFEN: newFEN, State: finalState, Termination: termination,
|
||||||
Result: &game.MoveResult{
|
Result: &game.MoveResult{
|
||||||
Move: result.Move, PlayerColor: color,
|
Move: result.Move, PlayerColor: color,
|
||||||
Score: result.Score, Depth: result.Depth, GameState: finalState,
|
Score: result.Score, Depth: result.Depth, GameState: finalState,
|
||||||
@@ -525,60 +563,106 @@ func (p *Processor) triggerComputerMove(gameID string, g game.View) error {
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
slog.Error("failed to apply computer move", "game_id", gameID, "error", err)
|
slog.Error("failed to apply computer move", "game_id", gameID, "error", err)
|
||||||
if !errors.Is(err, service.ErrGameChanged) && !errors.Is(err, service.ErrGameNotFound) {
|
if !errors.Is(err, service.ErrGameChanged) && !errors.Is(err, service.ErrGameNotFound) {
|
||||||
p.svc.UpdateGameState(gameID, core.StateStuck)
|
p.svc.UpdateGameState(gameID, core.StateStuck, core.TermNone)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// classifyCurrentLocked classifies whatever position is loaded in the
|
// classifyCurrentLocked classifies whatever position is loaded in the
|
||||||
// validation engine. Caller holds p.mu, immediately after a SetPosition.
|
// validation engine: mate and stalemate come from the engine. Caller holds
|
||||||
func (p *Processor) classifyCurrentLocked() (fen string, state core.State, err error) {
|
// p.mu, immediately after a SetPosition.
|
||||||
|
func (p *Processor) classifyCurrentLocked() (fen string, state core.State, termination core.Termination, err error) {
|
||||||
diag, err := p.validationEng.Diagnose()
|
diag, err := p.validationEng.Diagnose()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", core.StateOngoing, err
|
return "", core.StateOngoing, core.TermNone, err
|
||||||
}
|
}
|
||||||
legal, err := p.validationEng.HasLegalMoves()
|
legal, err := p.validationEng.HasLegalMoves()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", core.StateOngoing, err
|
return "", core.StateOngoing, core.TermNone, err
|
||||||
}
|
}
|
||||||
if legal {
|
if legal {
|
||||||
return diag.FEN, core.StateOngoing, nil
|
return diag.FEN, core.StateOngoing, core.TermNone, nil
|
||||||
}
|
}
|
||||||
if !diag.InCheck {
|
if !diag.InCheck {
|
||||||
return diag.FEN, core.StateStalemate, nil
|
return diag.FEN, core.StateStalemate, core.TermStalemate, nil
|
||||||
}
|
}
|
||||||
b, err := board.ParseFEN(diag.FEN)
|
pos, err := chess.ParseFEN(diag.FEN)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", core.StateOngoing, err
|
return "", core.StateOngoing, core.TermNone, err
|
||||||
}
|
}
|
||||||
if b.Turn() == core.ColorWhite {
|
if pos.Turn() == chess.White {
|
||||||
return diag.FEN, core.StateBlackWins, nil
|
return diag.FEN, core.StateBlackWins, core.TermCheckmate, nil
|
||||||
}
|
}
|
||||||
return diag.FEN, core.StateWhiteWins, nil
|
return diag.FEN, core.StateWhiteWins, core.TermCheckmate, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// classifyLocked sets a position from fen and classifies it. Caller holds p.mu.
|
// classifyLocked sets a position from fen and classifies it. Caller holds p.mu.
|
||||||
func (p *Processor) classifyLocked(fen string) (string, core.State, error) {
|
func (p *Processor) classifyLocked(fen string) (string, core.State, core.Termination, error) {
|
||||||
if err := p.validationEng.SetPosition(fen, nil); err != nil {
|
if err := p.validationEng.SetPosition(fen, nil); err != nil {
|
||||||
return "", core.StateOngoing, err
|
return "", core.StateOngoing, core.TermNone, err
|
||||||
}
|
}
|
||||||
return p.classifyCurrentLocked()
|
return p.classifyCurrentLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// adjudicateDraw applies the draw rules that end a game without a claim, as
|
||||||
|
// most online servers do: dead material, threefold repetition, and the
|
||||||
|
// fifty-move rule (a claimable draw under FIDE rules). history holds the
|
||||||
|
// positions before fen, oldest first. It returns StateOngoing when no rule
|
||||||
|
// applies. The engine has already ruled out mate and stalemate, which take
|
||||||
|
// precedence.
|
||||||
|
func adjudicateDraw(history []string, fen string) (core.State, core.Termination) {
|
||||||
|
pos, err := chess.ParseFEN(fen)
|
||||||
|
if err != nil {
|
||||||
|
return core.StateOngoing, core.TermNone
|
||||||
|
}
|
||||||
|
if pos.InsufficientMaterial() {
|
||||||
|
return core.StateDraw, core.TermInsufficientMaterial
|
||||||
|
}
|
||||||
|
// Positions before the last capture or pawn move cannot repeat.
|
||||||
|
window := min(len(history), pos.Halfmove())
|
||||||
|
line := make([]*chess.Position, 0, window+1)
|
||||||
|
for _, prior := range history[len(history)-window:] {
|
||||||
|
prev, err := chess.ParseFEN(prior)
|
||||||
|
if err != nil {
|
||||||
|
line = line[:0] // a gap cannot be bridged; count from here on
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
line = append(line, prev)
|
||||||
|
}
|
||||||
|
if chess.Repetitions(append(line, pos)) >= 3 {
|
||||||
|
return core.StateDraw, core.TermThreefoldRepetition
|
||||||
|
}
|
||||||
|
if pos.Halfmove() >= 100 {
|
||||||
|
return core.StateDraw, core.TermFiftyMoveRule
|
||||||
|
}
|
||||||
|
return core.StateOngoing, core.TermNone
|
||||||
|
}
|
||||||
|
|
||||||
|
func coreColor(c chess.Color) core.Color {
|
||||||
|
if c == chess.White {
|
||||||
|
return core.ColorWhite
|
||||||
|
}
|
||||||
|
return core.ColorBlack
|
||||||
|
}
|
||||||
|
|
||||||
// buildGameResponse constructs standard game response
|
// buildGameResponse constructs standard game response
|
||||||
func (p *Processor) buildGameResponse(gameID string, g game.View) core.GameResponse {
|
func (p *Processor) buildGameResponse(gameID string, g game.View) core.GameResponse {
|
||||||
resp := core.GameResponse{
|
resp := core.GameResponse{
|
||||||
GameID: gameID,
|
GameID: gameID,
|
||||||
FEN: g.FEN,
|
FEN: g.FEN,
|
||||||
Turn: g.NextTurnColor.String(),
|
Turn: g.NextTurnColor.String(),
|
||||||
State: g.State.String(),
|
State: g.State.String(),
|
||||||
Moves: g.Moves,
|
Termination: string(g.Termination),
|
||||||
|
Moves: g.Moves,
|
||||||
Players: core.PlayersResponse{
|
Players: core.PlayersResponse{
|
||||||
White: g.WhitePlayer,
|
White: g.WhitePlayer,
|
||||||
Black: g.BlackPlayer,
|
Black: g.BlackPlayer,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
if g.DrawOffer != 0 {
|
||||||
|
resp.DrawOffer = g.DrawOffer.String()
|
||||||
|
}
|
||||||
|
|
||||||
// Include last move if available
|
// Include last move if available
|
||||||
if result := g.LastResult; result != nil {
|
if result := g.LastResult; result != nil {
|
||||||
@@ -612,3 +696,267 @@ func (p *Processor) Close() error {
|
|||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
return errors.Join(queueErr, engineErr)
|
return errors.Join(queueErr, engineErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rulesResult is the rules core's verdict on a move, computed alongside the
|
||||||
|
// engine's. Stockfish stays authoritative; a disagreement is logged so the
|
||||||
|
// core can be trusted, or fixed, before it validates anything.
|
||||||
|
type rulesResult struct {
|
||||||
|
fen string // normalized FEN after the move; empty when illegal
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func rulesCheck(fen, uci string) rulesResult {
|
||||||
|
pos, err := chess.ParseFEN(fen)
|
||||||
|
if err != nil {
|
||||||
|
return rulesResult{err: err}
|
||||||
|
}
|
||||||
|
m, err := pos.ParseUCI(uci)
|
||||||
|
if err != nil {
|
||||||
|
return rulesResult{err: err}
|
||||||
|
}
|
||||||
|
return rulesResult{fen: pos.Play(m).FEN()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r rulesResult) compare(gameID, uci string, engineLegal bool, engineFEN string) {
|
||||||
|
if errors.Is(r.err, chess.ErrInvalidFEN) {
|
||||||
|
slog.Warn("rules core cannot parse game position", "game_id", gameID, "error", r.err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if engineLegal != (r.err == nil) {
|
||||||
|
slog.Warn("rules core and engine disagree on legality",
|
||||||
|
"game_id", gameID, "move", uci, "engine_legal", engineLegal, "rules_error", r.err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !engineLegal {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if normalized, err := chess.NormalizeFEN(engineFEN); err != nil || normalized != r.fen {
|
||||||
|
slog.Warn("rules core and engine disagree on resulting position",
|
||||||
|
"game_id", gameID, "move", uci, "engine_fen", engineFEN, "rules_fen", r.fen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleResign ends the game in the opponent's favor. It is allowed while the
|
||||||
|
// computer is thinking or the engine is stuck: resigning never needs one.
|
||||||
|
func (p *Processor) handleResign(cmd Command) ProcessorResponse {
|
||||||
|
args, ok := cmd.Args.(core.ResignRequest)
|
||||||
|
if !ok {
|
||||||
|
return p.errorResponse("invalid arguments", core.ErrInvalidRequest)
|
||||||
|
}
|
||||||
|
g, err := p.svc.GetGameView(cmd.GameID)
|
||||||
|
if err != nil {
|
||||||
|
return p.errorResponse("game not found", core.ErrGameNotFound)
|
||||||
|
}
|
||||||
|
if g.State.IsTerminal() {
|
||||||
|
return p.errorResponse(fmt.Sprintf("game is over: %s", g.State), core.ErrGameOver)
|
||||||
|
}
|
||||||
|
color, failure := actingColor(g, args.Color, cmd.UserID)
|
||||||
|
if failure != nil {
|
||||||
|
return *failure
|
||||||
|
}
|
||||||
|
winner := core.StateWhiteWins
|
||||||
|
if color == core.ColorWhite {
|
||||||
|
winner = core.StateBlackWins
|
||||||
|
}
|
||||||
|
err = p.svc.EndGame(cmd.GameID, service.EndCommit{
|
||||||
|
ExpectedFEN: g.FEN, Color: color, ActorUserID: cmd.UserID,
|
||||||
|
State: winner, Termination: core.TermResignation,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return p.endGameError(err)
|
||||||
|
}
|
||||||
|
g, _ = p.svc.GetGameView(cmd.GameID)
|
||||||
|
return ProcessorResponse{Success: true, Data: p.buildGameResponse(cmd.GameID, g)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDraw offers, accepts, or declines a draw by agreement. An offer to a
|
||||||
|
// human stands until answered, or until the opponent moves instead; an offer
|
||||||
|
// to a computer is answered at once. Offering while the opponent's offer
|
||||||
|
// stands accepts it.
|
||||||
|
func (p *Processor) handleDraw(cmd Command) ProcessorResponse {
|
||||||
|
args, ok := cmd.Args.(core.DrawRequest)
|
||||||
|
if !ok {
|
||||||
|
return p.errorResponse("invalid arguments", core.ErrInvalidRequest)
|
||||||
|
}
|
||||||
|
g, err := p.svc.GetGameView(cmd.GameID)
|
||||||
|
if err != nil {
|
||||||
|
return p.errorResponse("game not found", core.ErrGameNotFound)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case g.State.IsTerminal():
|
||||||
|
return p.errorResponse(fmt.Sprintf("game is over: %s", g.State), core.ErrGameOver)
|
||||||
|
case g.State != core.StateOngoing:
|
||||||
|
return p.errorResponse("draws can be agreed only while no computer move is running", core.ErrInvalidRequest)
|
||||||
|
}
|
||||||
|
color, failure := actingColor(g, args.Color, cmd.UserID)
|
||||||
|
if failure != nil {
|
||||||
|
return *failure
|
||||||
|
}
|
||||||
|
opponent := core.OppositeColor(color)
|
||||||
|
|
||||||
|
accept := func() ProcessorResponse {
|
||||||
|
err := p.svc.EndGame(cmd.GameID, service.EndCommit{
|
||||||
|
ExpectedFEN: g.FEN, Color: color, ActorUserID: cmd.UserID,
|
||||||
|
State: core.StateDraw, Termination: core.TermAgreement, OfferFrom: opponent,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return p.endGameError(err)
|
||||||
|
}
|
||||||
|
return p.drawResponse(cmd.GameID, "accepted")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch args.Action {
|
||||||
|
case "accept":
|
||||||
|
if g.DrawOffer != opponent {
|
||||||
|
return p.errorResponse("no draw offer from the opponent", core.ErrInvalidRequest)
|
||||||
|
}
|
||||||
|
return accept()
|
||||||
|
case "decline":
|
||||||
|
if err := p.svc.DeclineDraw(cmd.GameID, color, cmd.UserID); err != nil {
|
||||||
|
return p.endGameError(err)
|
||||||
|
}
|
||||||
|
return p.drawResponse(cmd.GameID, "declined")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offer.
|
||||||
|
switch {
|
||||||
|
case g.DrawOffer == opponent:
|
||||||
|
return accept()
|
||||||
|
case g.DrawOffer == color:
|
||||||
|
return p.drawResponse(cmd.GameID, "offered")
|
||||||
|
}
|
||||||
|
if last := g.OfferPly[color]; last >= 0 && len(g.Moves) < last+2 {
|
||||||
|
return p.errorResponse(service.ErrOfferLimit.Error(), core.ErrConflict)
|
||||||
|
}
|
||||||
|
if player := g.Player(opponent); player != nil && player.Type == core.PlayerComputer {
|
||||||
|
agrees := false
|
||||||
|
if len(g.Moves) >= drawOfferMinPlies {
|
||||||
|
if agrees, err = p.computerAcceptsDraw(g.FEN, opponent); err != nil {
|
||||||
|
return p.errorResponse("engine unavailable", core.ErrInternalError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if agrees {
|
||||||
|
err = p.svc.EndGame(cmd.GameID, service.EndCommit{
|
||||||
|
ExpectedFEN: g.FEN, Color: color, ActorUserID: cmd.UserID,
|
||||||
|
State: core.StateDraw, Termination: core.TermAgreement,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return p.endGameError(err)
|
||||||
|
}
|
||||||
|
return p.drawResponse(cmd.GameID, "accepted")
|
||||||
|
}
|
||||||
|
if err := p.svc.RecordDeclinedOffer(cmd.GameID, color, cmd.UserID, g.FEN); err != nil {
|
||||||
|
return p.endGameError(err)
|
||||||
|
}
|
||||||
|
return p.drawResponse(cmd.GameID, "declined")
|
||||||
|
}
|
||||||
|
if err := p.svc.OfferDraw(cmd.GameID, color, cmd.UserID, g.FEN); err != nil {
|
||||||
|
return p.endGameError(err)
|
||||||
|
}
|
||||||
|
return p.drawResponse(cmd.GameID, "offered")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Processor) drawResponse(gameID, outcome string) ProcessorResponse {
|
||||||
|
g, err := p.svc.GetGameView(gameID)
|
||||||
|
if err != nil {
|
||||||
|
return p.errorResponse("game not found", core.ErrGameNotFound)
|
||||||
|
}
|
||||||
|
response := p.buildGameResponse(gameID, g)
|
||||||
|
response.DrawOutcome = outcome
|
||||||
|
return ProcessorResponse{Success: true, Data: response}
|
||||||
|
}
|
||||||
|
|
||||||
|
// computerAcceptsDraw evaluates fen for the computer playing color with a
|
||||||
|
// short full-strength search. UCI scores are from the side to move.
|
||||||
|
func (p *Processor) computerAcceptsDraw(fen string, computer core.Color) (bool, error) {
|
||||||
|
pos, err := chess.ParseFEN(fen)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
if err := p.validationEng.SetPosition(fen, nil); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
result, err := p.validationEng.Search(drawEvalTimeMs)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
score := result.Score
|
||||||
|
if coreColor(pos.Turn()) != computer {
|
||||||
|
score = -score
|
||||||
|
}
|
||||||
|
return score <= drawAcceptMaxScore, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// actingColor resolves which side a resignation or draw request acts for: the
|
||||||
|
// requested color, else the one side the caller claimed, else the only human
|
||||||
|
// side. It enforces slot ownership: a claimed side acts only for its claimant.
|
||||||
|
func actingColor(g game.View, requested, userID string) (core.Color, *ProcessorResponse) {
|
||||||
|
fail := func(message, code string) (core.Color, *ProcessorResponse) {
|
||||||
|
return 0, &ProcessorResponse{Error: &core.ErrorResponse{Error: message, Code: code}}
|
||||||
|
}
|
||||||
|
var color core.Color
|
||||||
|
switch {
|
||||||
|
case requested != "":
|
||||||
|
color = core.ParseColor(requested)
|
||||||
|
default:
|
||||||
|
var claimed, humans []core.Color
|
||||||
|
for _, c := range []core.Color{core.ColorWhite, core.ColorBlack} {
|
||||||
|
player := g.Player(c)
|
||||||
|
if player == nil || player.Type != core.PlayerHuman {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
humans = append(humans, c)
|
||||||
|
if userID != "" && player.ClaimedBy == userID {
|
||||||
|
claimed = append(claimed, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case len(claimed) == 1:
|
||||||
|
color = claimed[0]
|
||||||
|
case len(claimed) == 0 && len(humans) == 1:
|
||||||
|
color = humans[0]
|
||||||
|
case len(humans) == 0:
|
||||||
|
return fail("both sides are played by the computer", core.ErrInvalidRequest)
|
||||||
|
default:
|
||||||
|
return fail("color required: both sides are human", core.ErrInvalidRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
player := g.Player(color)
|
||||||
|
if player == nil {
|
||||||
|
return fail("invalid color", core.ErrInvalidRequest)
|
||||||
|
}
|
||||||
|
if player.Type != core.PlayerHuman {
|
||||||
|
return fail(service.ErrNotHuman.Error(), core.ErrInvalidRequest)
|
||||||
|
}
|
||||||
|
if player.ClaimedBy != "" && player.ClaimedBy != userID {
|
||||||
|
if userID == "" {
|
||||||
|
return fail("slot claimed - authentication required", core.ErrUnauthorized)
|
||||||
|
}
|
||||||
|
return fail("slot claimed by another player", core.ErrUnauthorized)
|
||||||
|
}
|
||||||
|
return color, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// endGameError maps a service refusal to a response; the service re-checks
|
||||||
|
// under its lock what the processor checked on a snapshot.
|
||||||
|
func (p *Processor) endGameError(err error) ProcessorResponse {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, service.ErrGameNotFound):
|
||||||
|
return p.errorResponse("game not found", core.ErrGameNotFound)
|
||||||
|
case errors.Is(err, service.ErrGameOver):
|
||||||
|
return p.errorResponse("game is over", core.ErrGameOver)
|
||||||
|
case errors.Is(err, service.ErrSlotOwner):
|
||||||
|
return p.errorResponse("slot claimed by another player", core.ErrUnauthorized)
|
||||||
|
case errors.Is(err, service.ErrNotHuman):
|
||||||
|
return p.errorResponse(err.Error(), core.ErrInvalidRequest)
|
||||||
|
case errors.Is(err, service.ErrNoDrawOffer):
|
||||||
|
return p.errorResponse("no draw offer from the opponent", core.ErrInvalidRequest)
|
||||||
|
case errors.Is(err, service.ErrOfferLimit):
|
||||||
|
return p.errorResponse(err.Error(), core.ErrConflict)
|
||||||
|
case errors.Is(err, service.ErrGameChanged):
|
||||||
|
return p.errorResponse("game changed; refresh and retry", core.ErrConflict)
|
||||||
|
}
|
||||||
|
return p.errorResponse(fmt.Sprintf("failed to update game: %v", err), core.ErrInternalError)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+210
-28
@@ -17,6 +17,10 @@ var (
|
|||||||
ErrGameNotFound = errors.New("game not found")
|
ErrGameNotFound = errors.New("game not found")
|
||||||
ErrGameChanged = errors.New("game changed while move was being validated")
|
ErrGameChanged = errors.New("game changed while move was being validated")
|
||||||
ErrSlotOwner = errors.New("player slot is owned by another user")
|
ErrSlotOwner = errors.New("player slot is owned by another user")
|
||||||
|
ErrGameOver = errors.New("game is over")
|
||||||
|
ErrNotHuman = errors.New("that side is played by the computer")
|
||||||
|
ErrNoDrawOffer = errors.New("no draw offer from the opponent")
|
||||||
|
ErrOfferLimit = errors.New("one draw offer per move: make a move before offering again")
|
||||||
)
|
)
|
||||||
|
|
||||||
type MoveCommit struct {
|
type MoveCommit struct {
|
||||||
@@ -27,10 +31,24 @@ type MoveCommit struct {
|
|||||||
MoveUCI string
|
MoveUCI string
|
||||||
NewFEN string
|
NewFEN string
|
||||||
State core.State
|
State core.State
|
||||||
|
Termination core.Termination // how State was reached when terminal
|
||||||
Result *game.MoveResult
|
Result *game.MoveResult
|
||||||
At time.Time
|
At time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EndCommit ends a game without a move: a resignation or a draw by
|
||||||
|
// agreement. Color is the side acting (resigning or accepting); an
|
||||||
|
// authenticated actor claims it if unclaimed, as a first move would.
|
||||||
|
type EndCommit struct {
|
||||||
|
ExpectedFEN string
|
||||||
|
Color core.Color
|
||||||
|
ActorUserID string
|
||||||
|
State core.State
|
||||||
|
Termination core.Termination
|
||||||
|
// OfferFrom, when set, requires that side's draw offer to still stand.
|
||||||
|
OfferFrom core.Color
|
||||||
|
}
|
||||||
|
|
||||||
// CreateGame registers a new game with pre-constructed players
|
// CreateGame registers a new game with pre-constructed players
|
||||||
func (s *Service) CreateGame(
|
func (s *Service) CreateGame(
|
||||||
id string,
|
id string,
|
||||||
@@ -38,6 +56,7 @@ func (s *Service) CreateGame(
|
|||||||
initialFEN string,
|
initialFEN string,
|
||||||
startingTurn core.Color,
|
startingTurn core.Color,
|
||||||
initialState core.State,
|
initialState core.State,
|
||||||
|
termination core.Termination,
|
||||||
) error {
|
) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
@@ -48,16 +67,15 @@ func (s *Service) CreateGame(
|
|||||||
|
|
||||||
// Check computer game limit
|
// Check computer game limit
|
||||||
hasComputer := whitePlayer.Type == core.PlayerComputer || blackPlayer.Type == core.PlayerComputer
|
hasComputer := whitePlayer.Type == core.PlayerComputer || blackPlayer.Type == core.PlayerComputer
|
||||||
if hasComputer {
|
if hasComputer && !initialState.IsTerminal() {
|
||||||
if s.computerGames.Load() >= MaxComputerGames {
|
if active := s.activeComputerGamesLocked(); active >= MaxComputerGames {
|
||||||
return fmt.Errorf("computer game limit reached (%d/%d)", s.computerGames.Load(), MaxComputerGames)
|
return fmt.Errorf("computer game limit reached (%d/%d)", active, MaxComputerGames)
|
||||||
}
|
}
|
||||||
s.computerGames.Add(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
g := game.New(initialFEN, whitePlayer, blackPlayer, startingTurn)
|
g := game.New(initialFEN, whitePlayer, blackPlayer, startingTurn)
|
||||||
g.SetStateAt(initialState, now)
|
g.SetOutcome(initialState, termination, now)
|
||||||
s.games[id] = g
|
s.games[id] = g
|
||||||
|
|
||||||
// Persist if storage enabled
|
// Persist if storage enabled
|
||||||
@@ -77,6 +95,7 @@ func (s *Service) CreateGame(
|
|||||||
BlackSearchTime: blackPlayer.SearchTime,
|
BlackSearchTime: blackPlayer.SearchTime,
|
||||||
BlackClaimedBy: blackPlayer.ClaimedBy,
|
BlackClaimedBy: blackPlayer.ClaimedBy,
|
||||||
Result: result,
|
Result: result,
|
||||||
|
Termination: string(g.Termination()),
|
||||||
StartTimeUTC: now,
|
StartTimeUTC: now,
|
||||||
EndTimeUTC: g.EndTimeUTC(),
|
EndTimeUTC: g.EndTimeUTC(),
|
||||||
}
|
}
|
||||||
@@ -106,8 +125,10 @@ func (s *Service) UpdatePlayers(gameID string, whitePlayer, blackPlayer *core.Pl
|
|||||||
oldBlack := g.GetPlayer(core.ColorBlack)
|
oldBlack := g.GetPlayer(core.ColorBlack)
|
||||||
oldHasComputer := g.HasComputerPlayer()
|
oldHasComputer := g.HasComputerPlayer()
|
||||||
newHasComputer := whitePlayer.Type == core.PlayerComputer || blackPlayer.Type == core.PlayerComputer
|
newHasComputer := whitePlayer.Type == core.PlayerComputer || blackPlayer.Type == core.PlayerComputer
|
||||||
if !oldHasComputer && newHasComputer && s.computerGames.Load() >= MaxComputerGames {
|
if !oldHasComputer && newHasComputer && !g.State().IsTerminal() {
|
||||||
return fmt.Errorf("computer game limit reached (%d/%d)", s.computerGames.Load(), MaxComputerGames)
|
if active := s.activeComputerGamesLocked(); active >= MaxComputerGames {
|
||||||
|
return fmt.Errorf("computer game limit reached (%d/%d)", active, MaxComputerGames)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Player configuration is mutable, but historical user association is not.
|
// Player configuration is mutable, but historical user association is not.
|
||||||
@@ -127,14 +148,8 @@ func (s *Service) UpdatePlayers(gameID string, whitePlayer, blackPlayer *core.Pl
|
|||||||
}
|
}
|
||||||
|
|
||||||
g.UpdatePlayers(whitePlayer, blackPlayer)
|
g.UpdatePlayers(whitePlayer, blackPlayer)
|
||||||
|
g.ClearDrawOffers() // an offer made to the previous opponent lapses
|
||||||
g.Touch(time.Now().UTC())
|
g.Touch(time.Now().UTC())
|
||||||
if oldHasComputer != newHasComputer {
|
|
||||||
if newHasComputer {
|
|
||||||
s.computerGames.Add(1)
|
|
||||||
} else {
|
|
||||||
s.computerGames.Add(-1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if s.store != nil {
|
if s.store != nil {
|
||||||
err := s.store.RecordPlayers(gameID, playerRecord(whitePlayer), playerRecord(blackPlayer))
|
err := s.store.RecordPlayers(gameID, playerRecord(whitePlayer), playerRecord(blackPlayer))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -239,8 +254,11 @@ func (s *Service) ApplyMoveWithState(gameID string, commit MoveCommit) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if commit.State.IsTerminal() && !commit.Termination.ValidFor(commit.State) {
|
||||||
|
return fmt.Errorf("termination %q cannot produce %s", commit.Termination, commit.State)
|
||||||
|
}
|
||||||
g.AddSnapshot(commit.NewFEN, commit.MoveUCI, core.OppositeColor(currentTurn))
|
g.AddSnapshot(commit.NewFEN, commit.MoveUCI, core.OppositeColor(currentTurn))
|
||||||
g.SetStateAt(commit.State, at)
|
g.SetOutcome(commit.State, commit.Termination, at)
|
||||||
if commit.Result != nil {
|
if commit.Result != nil {
|
||||||
g.SetLastResult(commit.Result)
|
g.SetLastResult(commit.Result)
|
||||||
}
|
}
|
||||||
@@ -252,10 +270,11 @@ func (s *Service) ApplyMoveWithState(gameID string, commit MoveCommit) error {
|
|||||||
GameID: gameID, MoveNumber: len(g.Moves()), MoveUCI: commit.MoveUCI,
|
GameID: gameID, MoveNumber: len(g.Moves()), MoveUCI: commit.MoveUCI,
|
||||||
FENAfterMove: commit.NewFEN, PlayerColor: currentTurn.String(), MoveTimeUTC: at,
|
FENAfterMove: commit.NewFEN, PlayerColor: currentTurn.String(), MoveTimeUTC: at,
|
||||||
},
|
},
|
||||||
ClaimColor: currentTurn.String(),
|
ClaimColor: currentTurn.String(),
|
||||||
ClaimedBy: claimUserID,
|
ClaimedBy: claimUserID,
|
||||||
Result: result,
|
Result: result,
|
||||||
EndTimeUTC: g.EndTimeUTC(),
|
Termination: string(g.Termination()),
|
||||||
|
EndTimeUTC: g.EndTimeUTC(),
|
||||||
}
|
}
|
||||||
if claimUserID == "" {
|
if claimUserID == "" {
|
||||||
persistence.ClaimColor = ""
|
persistence.ClaimColor = ""
|
||||||
@@ -277,8 +296,9 @@ func (s *Service) ApplyMoveWithState(gameID string, commit MoveCommit) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateGameState sets the game's end state (checkmate, stalemate, etc)
|
// UpdateGameState sets an operational state (pending, stuck) or a terminal
|
||||||
func (s *Service) UpdateGameState(gameID string, state core.State) error {
|
// state found without a move, such as an engine reporting no legal move.
|
||||||
|
func (s *Service) UpdateGameState(gameID string, state core.State, termination core.Termination) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
@@ -286,13 +306,18 @@ func (s *Service) UpdateGameState(gameID string, state core.State) error {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
|
return fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
|
||||||
}
|
}
|
||||||
|
if state.IsTerminal() && !termination.ValidFor(state) {
|
||||||
|
return fmt.Errorf("termination %q cannot produce %s", termination, state)
|
||||||
|
}
|
||||||
|
|
||||||
previousState := g.State()
|
previousState := g.State()
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
g.SetStateAt(state, now)
|
g.SetOutcome(state, termination, now)
|
||||||
if s.store != nil && state.IsTerminal() && !previousState.IsTerminal() {
|
if s.store != nil && state.IsTerminal() && !previousState.IsTerminal() {
|
||||||
result, _ := state.Result()
|
result, _ := state.Result()
|
||||||
if err := s.store.RecordGameResult(gameID, result, now); err != nil {
|
if err := s.store.RecordGameEnd(storage.GameEnd{
|
||||||
|
GameID: gameID, Result: result, Termination: string(termination), EndTimeUTC: now,
|
||||||
|
}); err != nil {
|
||||||
slog.Error("failed to queue game result persistence", "game_id", gameID, "error", err)
|
slog.Error("failed to queue game result persistence", "game_id", gameID, "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -303,6 +328,168 @@ func (s *Service) UpdateGameState(gameID string, state core.State) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EndGame applies a resignation or an agreed draw. It re-checks, under the
|
||||||
|
// service lock, everything the processor validated: the game is unfinished
|
||||||
|
// and unchanged, the acting side is human and the actor may play it, and an
|
||||||
|
// accepted offer still stands.
|
||||||
|
func (s *Service) EndGame(gameID string, commit EndCommit) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
g, ok := s.games[gameID]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
|
||||||
|
}
|
||||||
|
if g.State().IsTerminal() {
|
||||||
|
return ErrGameOver
|
||||||
|
}
|
||||||
|
if !commit.State.IsTerminal() || !commit.Termination.ValidFor(commit.State) {
|
||||||
|
return fmt.Errorf("termination %q cannot produce %s", commit.Termination, commit.State)
|
||||||
|
}
|
||||||
|
if g.CurrentFEN() != commit.ExpectedFEN {
|
||||||
|
return ErrGameChanged
|
||||||
|
}
|
||||||
|
if commit.OfferFrom != 0 && g.DrawOffer() != commit.OfferFrom {
|
||||||
|
return ErrNoDrawOffer
|
||||||
|
}
|
||||||
|
claimUserID, err := actorClaim(g, commit.Color, commit.ActorUserID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if claimUserID != "" {
|
||||||
|
if err := g.ClaimSlot(commit.Color, claimUserID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.SetOutcome(commit.State, commit.Termination, now)
|
||||||
|
|
||||||
|
if s.store != nil {
|
||||||
|
result, _ := commit.State.Result()
|
||||||
|
end := storage.GameEnd{
|
||||||
|
GameID: gameID, Result: result, Termination: string(commit.Termination), EndTimeUTC: now,
|
||||||
|
}
|
||||||
|
if claimUserID != "" {
|
||||||
|
end.ClaimColor, end.ClaimedBy = commit.Color.String(), claimUserID
|
||||||
|
}
|
||||||
|
if err := s.store.RecordGameEnd(end); err != nil {
|
||||||
|
slog.Error("failed to queue game end persistence", "game_id", gameID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.waiter.NotifyGame(gameID, len(g.Moves()), commit.State)
|
||||||
|
slog.Debug("game ended without a move", "game_id", gameID,
|
||||||
|
"state", commit.State.String(), "termination", commit.Termination, "side", commit.Color.String())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// actorClaim authorizes actor to act for color and returns the user ID to
|
||||||
|
// record as the slot's claim, empty when nothing is to be claimed.
|
||||||
|
func actorClaim(g *game.Game, color core.Color, actor string) (string, error) {
|
||||||
|
player := g.GetPlayer(color)
|
||||||
|
if player == nil {
|
||||||
|
return "", errors.New("invalid color")
|
||||||
|
}
|
||||||
|
if player.Type != core.PlayerHuman {
|
||||||
|
return "", ErrNotHuman
|
||||||
|
}
|
||||||
|
owner := g.GetSlotOwner(color)
|
||||||
|
switch {
|
||||||
|
case owner != "" && owner != actor:
|
||||||
|
return "", ErrSlotOwner
|
||||||
|
case owner == "" && actor != "":
|
||||||
|
return actor, nil
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OfferDraw records color's offer for a human opponent to answer. Repeating
|
||||||
|
// a standing offer is a no-op; a new offer needs a move since the last one.
|
||||||
|
func (s *Service) OfferDraw(gameID string, color core.Color, actor, expectedFEN string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
g, err := s.drawTarget(gameID, color, actor, expectedFEN)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if g.DrawOffer() == color {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := offerAllowed(g, color); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
g.OfferDraw(color)
|
||||||
|
g.Touch(time.Now().UTC())
|
||||||
|
s.waiter.NotifyAll(gameID)
|
||||||
|
slog.Debug("draw offered", "game_id", gameID, "side", color.String())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordDeclinedOffer counts an offer the computer answered at once against
|
||||||
|
// color's one-offer-per-move allowance.
|
||||||
|
func (s *Service) RecordDeclinedOffer(gameID string, color core.Color, actor, expectedFEN string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
g, err := s.drawTarget(gameID, color, actor, expectedFEN)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := offerAllowed(g, color); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
g.RecordDeclinedOffer(color)
|
||||||
|
g.Touch(time.Now().UTC())
|
||||||
|
slog.Debug("draw offer declined by the computer", "game_id", gameID, "side", color.String())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeclineDraw lets color turn down the opponent's standing offer.
|
||||||
|
func (s *Service) DeclineDraw(gameID string, color core.Color, actor string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
g, err := s.drawTarget(gameID, color, actor, "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if g.DrawOffer() != core.OppositeColor(color) {
|
||||||
|
return ErrNoDrawOffer
|
||||||
|
}
|
||||||
|
g.DeclineDraw()
|
||||||
|
g.Touch(time.Now().UTC())
|
||||||
|
s.waiter.NotifyAll(gameID)
|
||||||
|
slog.Debug("draw declined", "game_id", gameID, "side", color.String())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// drawTarget returns an unfinished game whose side color the actor may play,
|
||||||
|
// optionally still at expectedFEN. Caller holds s.mu.
|
||||||
|
func (s *Service) drawTarget(gameID string, color core.Color, actor, expectedFEN string) (*game.Game, error) {
|
||||||
|
g, ok := s.games[gameID]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
|
||||||
|
}
|
||||||
|
if g.State().IsTerminal() {
|
||||||
|
return nil, ErrGameOver
|
||||||
|
}
|
||||||
|
if g.State() != core.StateOngoing || (expectedFEN != "" && g.CurrentFEN() != expectedFEN) {
|
||||||
|
return nil, ErrGameChanged
|
||||||
|
}
|
||||||
|
if _, err := actorClaim(g, color, actor); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return g, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func offerAllowed(g *game.Game, color core.Color) error {
|
||||||
|
if last := g.LastOfferPly(color); last >= 0 && g.Plies() < last+2 {
|
||||||
|
return ErrOfferLimit
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// UndoMoves removes the specified number of moves from game history
|
// UndoMoves removes the specified number of moves from game history
|
||||||
func (s *Service) UndoMoves(gameID string, count int) error {
|
func (s *Service) UndoMoves(gameID string, count int) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
@@ -351,11 +538,6 @@ func (s *Service) DeleteGame(gameID string) error {
|
|||||||
return errors.New("cannot delete game while computer move is in progress")
|
return errors.New("cannot delete game while computer move is in progress")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrement computer game count if applicable
|
|
||||||
if g.HasComputerPlayer() {
|
|
||||||
s.computerGames.Add(-1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove from wait registry
|
// Remove from wait registry
|
||||||
s.waiter.RemoveGame(gameID)
|
s.waiter.RemoveGame(gameID)
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func TestMoveCommitClaimsSlotPersistsResultAndRejectsStalePosition(t *testing.T)
|
|||||||
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
|
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
|
||||||
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
|
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
|
||||||
if err := svc.CreateGame(
|
if err := svc.CreateGame(
|
||||||
gameID, white, black, "initial", core.ColorWhite, core.StateOngoing,
|
gameID, white, black, "initial", core.ColorWhite, core.StateOngoing, core.TermNone,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -27,7 +27,7 @@ func TestMoveCommitClaimsSlotPersistsResultAndRejectsStalePosition(t *testing.T)
|
|||||||
ended := time.Date(2026, 9, 7, 2, 3, 4, 0, time.UTC)
|
ended := time.Date(2026, 9, 7, 2, 3, 4, 0, time.UTC)
|
||||||
commit := MoveCommit{
|
commit := MoveCommit{
|
||||||
ExpectedFEN: "initial", ExpectedState: core.StateOngoing, ExpectedTurn: core.ColorWhite,
|
ExpectedFEN: "initial", ExpectedState: core.StateOngoing, ExpectedTurn: core.ColorWhite,
|
||||||
ActorUserID: userID, MoveUCI: "e2e4", NewFEN: "after", State: core.StateWhiteWins, At: ended,
|
ActorUserID: userID, MoveUCI: "e2e4", NewFEN: "after", State: core.StateWhiteWins, Termination: core.TermCheckmate, At: ended,
|
||||||
Result: &game.MoveResult{Move: "e2e4", PlayerColor: core.ColorWhite, GameState: core.StateWhiteWins},
|
Result: &game.MoveResult{Move: "e2e4", PlayerColor: core.ColorWhite, GameState: core.StateWhiteWins},
|
||||||
}
|
}
|
||||||
if err := svc.ApplyMoveWithState(gameID, commit); err != nil {
|
if err := svc.ApplyMoveWithState(gameID, commit); err != nil {
|
||||||
@@ -60,13 +60,13 @@ func TestUndoClearsDurableTerminalResult(t *testing.T) {
|
|||||||
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
|
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
|
||||||
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
|
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
|
||||||
if err := svc.CreateGame(
|
if err := svc.CreateGame(
|
||||||
gameID, white, black, "initial", core.ColorWhite, core.StateOngoing,
|
gameID, white, black, "initial", core.ColorWhite, core.StateOngoing, core.TermNone,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := svc.ApplyMoveWithState(gameID, MoveCommit{
|
if err := svc.ApplyMoveWithState(gameID, MoveCommit{
|
||||||
ExpectedFEN: "initial", ExpectedState: core.StateOngoing, ExpectedTurn: core.ColorWhite,
|
ExpectedFEN: "initial", ExpectedState: core.StateOngoing, ExpectedTurn: core.ColorWhite,
|
||||||
MoveUCI: "e2e4", NewFEN: "after", State: core.StateStalemate,
|
MoveUCI: "e2e4", NewFEN: "after", State: core.StateStalemate, Termination: core.TermStalemate,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -91,7 +91,7 @@ func TestPlayerReconfigurationPreservesClaimAndPersistsConfiguration(t *testing.
|
|||||||
white.ClaimedBy = userID
|
white.ClaimedBy = userID
|
||||||
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
|
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
|
||||||
if err := svc.CreateGame(
|
if err := svc.CreateGame(
|
||||||
gameID, white, black, "initial", core.ColorWhite, core.StateOngoing,
|
gameID, white, black, "initial", core.ColorWhite, core.StateOngoing, core.TermNone,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -137,7 +137,7 @@ func TestCleanupEvictsOnlyMemoryCopyOfTerminalGame(t *testing.T) {
|
|||||||
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
|
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
|
||||||
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
|
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
|
||||||
if err := svc.CreateGame(
|
if err := svc.CreateGame(
|
||||||
gameID, white, black, "terminal", core.ColorWhite, core.StateStalemate,
|
gameID, white, black, "terminal", core.ColorWhite, core.StateStalemate, core.TermStalemate,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -166,7 +166,7 @@ func TestCleanupRemovesIdleAnonymousGamesButKeepsClaimedOnes(t *testing.T) {
|
|||||||
white.ID, white.ClaimedBy = claimant, claimant
|
white.ID, white.ClaimedBy = claimant, claimant
|
||||||
}
|
}
|
||||||
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
|
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
|
||||||
if err := svc.CreateGame(gameID, white, black, "initial", core.ColorWhite, core.StateOngoing); err != nil {
|
if err := svc.CreateGame(gameID, white, black, "initial", core.ColorWhite, core.StateOngoing, core.TermNone); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,7 +202,16 @@ func TestCleanupRemovesIdleAnonymousGamesButKeepsClaimedOnes(t *testing.T) {
|
|||||||
|
|
||||||
func newPersistentTestService(t *testing.T) *Service {
|
func newPersistentTestService(t *testing.T) *Service {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
store, err := storage.NewStore(pgtest.DSN(t))
|
svc, _ := newPersistentTestServiceDSN(t)
|
||||||
|
return svc
|
||||||
|
}
|
||||||
|
|
||||||
|
// newPersistentTestServiceDSN also returns the DSN of the test schema, for
|
||||||
|
// tests that change rows behind the service's back.
|
||||||
|
func newPersistentTestServiceDSN(t *testing.T) (*Service, string) {
|
||||||
|
t.Helper()
|
||||||
|
dsn := pgtest.DSN(t)
|
||||||
|
store, err := storage.NewStore(dsn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -218,5 +227,5 @@ func newPersistentTestService(t *testing.T) *Service {
|
|||||||
t.Errorf("shutdown: %v", err)
|
t.Errorf("shutdown: %v", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return svc
|
return svc, dsn
|
||||||
}
|
}
|
||||||
@@ -2,43 +2,59 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"chess/internal/server/chess"
|
||||||
"chess/internal/server/core"
|
"chess/internal/server/core"
|
||||||
|
"chess/internal/server/replay"
|
||||||
"chess/internal/server/storage"
|
"chess/internal/server/storage"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrInvalidListQuery reports a malformed cursor or filter.
|
||||||
|
ErrInvalidListQuery = errors.New("invalid list query")
|
||||||
|
// ErrPlyOutOfRange reports a PGN ply beyond the stored line.
|
||||||
|
ErrPlyOutOfRange = replay.ErrPlyOutOfRange
|
||||||
|
// ErrNotation reports a stored move the rules core cannot notate.
|
||||||
|
ErrNotation = replay.ErrNotation
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetGameHistory returns a durable replay even after the live game has been
|
// GetGameHistory returns a durable replay even after the live game has been
|
||||||
// evicted from memory or the server has restarted.
|
// evicted from memory or the server has restarted. SAN is derived on read.
|
||||||
func (s *Service) GetGameHistory(gameID string) (*core.GameHistoryResponse, error) {
|
func (s *Service) GetGameHistory(gameID string) (*core.GameHistoryResponse, error) {
|
||||||
if s.store == nil {
|
record, moves, err := s.loadHistory(gameID)
|
||||||
return nil, ErrStorageDisabled
|
|
||||||
}
|
|
||||||
record, moves, err := s.store.GetGameHistory(gameID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if storage.IsGameNotFound(err) {
|
return nil, err
|
||||||
return nil, fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
|
|
||||||
}
|
|
||||||
if isStorageUnavailable(err) {
|
|
||||||
return nil, fmt.Errorf("%w: %v", ErrStorageUnavailable, err)
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("get game history: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
history := &core.GameHistoryResponse{
|
history := &core.GameHistoryResponse{
|
||||||
GameID: record.GameID,
|
GameID: record.GameID,
|
||||||
InitialFEN: record.InitialFEN,
|
InitialFEN: record.InitialFEN,
|
||||||
Result: record.Result,
|
Result: record.Result,
|
||||||
|
PGNResult: chess.ResultToken(record.Result),
|
||||||
|
Termination: record.Termination,
|
||||||
StartTimeUTC: record.StartTimeUTC,
|
StartTimeUTC: record.StartTimeUTC,
|
||||||
EndTimeUTC: record.EndTimeUTC,
|
EndTimeUTC: record.EndTimeUTC,
|
||||||
Players: playersResponse(*record),
|
Players: playersResponse(*record),
|
||||||
Moves: make([]core.HistoryMove, 0, len(moves)),
|
Moves: make([]core.HistoryMove, 0, len(moves)),
|
||||||
}
|
}
|
||||||
for _, move := range moves {
|
san, err := replay.Notate(record.InitialFEN, moves)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("stored game has moves without notation", "game_id", record.GameID, "error", err)
|
||||||
|
}
|
||||||
|
for i, move := range moves {
|
||||||
history.Moves = append(history.Moves, core.HistoryMove{
|
history.Moves = append(history.Moves, core.HistoryMove{
|
||||||
MoveNumber: move.MoveNumber,
|
MoveNumber: move.MoveNumber,
|
||||||
MoveUCI: move.MoveUCI,
|
MoveUCI: move.MoveUCI,
|
||||||
|
SAN: san[i],
|
||||||
FENAfterMove: move.FENAfterMove,
|
FENAfterMove: move.FENAfterMove,
|
||||||
PlayerColor: move.PlayerColor,
|
PlayerColor: move.PlayerColor,
|
||||||
MoveTimeUTC: move.MoveTimeUTC,
|
MoveTimeUTC: move.MoveTimeUTC,
|
||||||
@@ -47,13 +63,79 @@ func (s *Service) GetGameHistory(gameID string) (*core.GameHistoryResponse, erro
|
|||||||
return history, nil
|
return history, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetGamePGN exports the stored game in PGN; see replay.BuildPGN for ply.
|
||||||
|
func (s *Service) GetGamePGN(gameID string, ply int) (*replay.PGN, error) {
|
||||||
|
record, moves, err := s.loadHistory(gameID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pgn, err := replay.BuildPGN(record, moves, ply)
|
||||||
|
if errors.Is(err, replay.ErrNotation) {
|
||||||
|
slog.Warn("stored game cannot be exported as PGN", "game_id", gameID, "error", err)
|
||||||
|
}
|
||||||
|
return pgn, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) loadHistory(gameID string) (*storage.GameRecord, []storage.MoveRecord, error) {
|
||||||
|
if s.store == nil {
|
||||||
|
return nil, nil, ErrStorageDisabled
|
||||||
|
}
|
||||||
|
record, moves, err := s.store.GetGameHistory(gameID)
|
||||||
|
if err != nil {
|
||||||
|
if storage.IsGameNotFound(err) {
|
||||||
|
return nil, nil, fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
|
||||||
|
}
|
||||||
|
if isStorageUnavailable(err) {
|
||||||
|
return nil, nil, fmt.Errorf("%w: %v", ErrStorageUnavailable, err)
|
||||||
|
}
|
||||||
|
return nil, nil, fmt.Errorf("get game history: %w", err)
|
||||||
|
}
|
||||||
|
return record, moves, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserGamesOptions selects a page of the caller's games. Cursor, when set,
|
||||||
|
// continues a previous page and excludes Offset.
|
||||||
|
type UserGamesOptions struct {
|
||||||
|
Limit int
|
||||||
|
Offset int
|
||||||
|
Cursor string
|
||||||
|
Color string // "", "white", or "black"
|
||||||
|
Status string // "", "ongoing", or "finished"
|
||||||
|
}
|
||||||
|
|
||||||
// GetUserGames returns a bounded page of games associated either at creation
|
// GetUserGames returns a bounded page of games associated either at creation
|
||||||
// or by a later slot claim.
|
// or by a later slot claim.
|
||||||
func (s *Service) GetUserGames(userID string, limit, offset int) (*core.GameListResponse, error) {
|
func (s *Service) GetUserGames(userID string, opts UserGamesOptions) (*core.GameListResponse, error) {
|
||||||
if s.store == nil {
|
if s.store == nil {
|
||||||
return nil, ErrStorageDisabled
|
return nil, ErrStorageDisabled
|
||||||
}
|
}
|
||||||
records, err := s.store.QueryGamesForUser(userID, limit+1, offset)
|
query := storage.UserGamesQuery{Limit: opts.Limit + 1, Offset: opts.Offset, Status: opts.Status}
|
||||||
|
switch opts.Color {
|
||||||
|
case "":
|
||||||
|
case "white":
|
||||||
|
query.Color = "w"
|
||||||
|
case "black":
|
||||||
|
query.Color = "b"
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("%w: color must be white or black", ErrInvalidListQuery)
|
||||||
|
}
|
||||||
|
switch opts.Status {
|
||||||
|
case "", "ongoing", "finished":
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("%w: status must be ongoing or finished", ErrInvalidListQuery)
|
||||||
|
}
|
||||||
|
if opts.Cursor != "" {
|
||||||
|
if opts.Offset != 0 {
|
||||||
|
return nil, fmt.Errorf("%w: cursor and offset are exclusive", ErrInvalidListQuery)
|
||||||
|
}
|
||||||
|
after, err := decodeCursor(opts.Cursor)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
query.After = after
|
||||||
|
}
|
||||||
|
|
||||||
|
records, err := s.store.QueryGamesForUser(userID, query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if isStorageUnavailable(err) {
|
if isStorageUnavailable(err) {
|
||||||
return nil, fmt.Errorf("%w: %v", ErrStorageUnavailable, err)
|
return nil, fmt.Errorf("%w: %v", ErrStorageUnavailable, err)
|
||||||
@@ -61,24 +143,29 @@ func (s *Service) GetUserGames(userID string, limit, offset int) (*core.GameList
|
|||||||
return nil, fmt.Errorf("get user games: %w", err)
|
return nil, fmt.Errorf("get user games: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
hasNext := len(records) > limit
|
hasNext := len(records) > opts.Limit
|
||||||
if hasNext {
|
if hasNext {
|
||||||
records = records[:limit]
|
records = records[:opts.Limit]
|
||||||
}
|
}
|
||||||
response := &core.GameListResponse{
|
response := &core.GameListResponse{
|
||||||
Games: make([]core.GameSummary, 0, len(records)),
|
Games: make([]core.GameSummary, 0, len(records)),
|
||||||
Limit: limit,
|
Limit: opts.Limit,
|
||||||
Offset: offset,
|
Offset: opts.Offset,
|
||||||
}
|
}
|
||||||
if hasNext {
|
if hasNext {
|
||||||
next := offset + limit
|
last := records[len(records)-1]
|
||||||
response.NextOffset = &next
|
response.NextCursor = encodeCursor(last.StartTimeUTC, last.GameID)
|
||||||
|
if opts.Cursor == "" {
|
||||||
|
next := opts.Offset + opts.Limit
|
||||||
|
response.NextOffset = &next
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for _, record := range records {
|
for _, record := range records {
|
||||||
response.Games = append(response.Games, core.GameSummary{
|
response.Games = append(response.Games, core.GameSummary{
|
||||||
GameID: record.GameID,
|
GameID: record.GameID,
|
||||||
InitialFEN: record.InitialFEN,
|
InitialFEN: record.InitialFEN,
|
||||||
Result: record.Result,
|
Result: record.Result,
|
||||||
|
PGNResult: chess.ResultToken(record.Result),
|
||||||
StartTimeUTC: record.StartTimeUTC,
|
StartTimeUTC: record.StartTimeUTC,
|
||||||
EndTimeUTC: record.EndTimeUTC,
|
EndTimeUTC: record.EndTimeUTC,
|
||||||
MoveCount: record.MoveCount,
|
MoveCount: record.MoveCount,
|
||||||
@@ -89,6 +176,32 @@ func (s *Service) GetUserGames(userID string, limit, offset int) (*core.GameList
|
|||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cursors are opaque to clients: base64url of "<start µs>.<game ID>". The
|
||||||
|
// microsecond value round-trips PostgreSQL timestamptz exactly.
|
||||||
|
func encodeCursor(start time.Time, gameID string) string {
|
||||||
|
return base64.RawURLEncoding.EncodeToString([]byte(strconv.FormatInt(start.UnixMicro(), 10) + "." + gameID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeCursor(cursor string) (*storage.GameCursor, error) {
|
||||||
|
invalid := fmt.Errorf("%w: malformed cursor", ErrInvalidListQuery)
|
||||||
|
if len(cursor) > 128 {
|
||||||
|
return nil, invalid
|
||||||
|
}
|
||||||
|
raw, err := base64.RawURLEncoding.DecodeString(cursor)
|
||||||
|
if err != nil {
|
||||||
|
return nil, invalid
|
||||||
|
}
|
||||||
|
micros, id, ok := strings.Cut(string(raw), ".")
|
||||||
|
if !ok {
|
||||||
|
return nil, invalid
|
||||||
|
}
|
||||||
|
us, err := strconv.ParseInt(micros, 10, 64)
|
||||||
|
if err != nil || uuid.Validate(id) != nil {
|
||||||
|
return nil, invalid
|
||||||
|
}
|
||||||
|
return &storage.GameCursor{StartTimeUTC: time.UnixMicro(us).UTC(), GameID: id}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func isStorageUnavailable(err error) bool {
|
func isStorageUnavailable(err error) bool {
|
||||||
return errors.Is(err, storage.ErrStorageDegraded) ||
|
return errors.Is(err, storage.ErrStorageDegraded) ||
|
||||||
errors.Is(err, storage.ErrStoreClosed) ||
|
errors.Is(err, storage.ErrStoreClosed) ||
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,16 +29,18 @@ const (
|
|||||||
|
|
||||||
// Service coordinates game state, user management, and storage
|
// Service coordinates game state, user management, and storage
|
||||||
type Service struct {
|
type Service struct {
|
||||||
games map[string]*game.Game
|
games map[string]*game.Game
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
store *storage.Store
|
store *storage.Store
|
||||||
jwt *auth.JWT
|
jwt *auth.JWT
|
||||||
kdf chan struct{} // Argon2id concurrency slots
|
kdf chan struct{} // Argon2id concurrency slots
|
||||||
waiter *WaitRegistry
|
waiter *WaitRegistry
|
||||||
computerGames atomic.Int32 // Active games with computer players
|
finishedTTL time.Duration
|
||||||
finishedTTL time.Duration
|
anonymousTTL time.Duration
|
||||||
anonymousTTL time.Duration
|
maxUsers atomic.Int64
|
||||||
maxUsers atomic.Int64
|
|
||||||
|
integrityMode IntegrityMode
|
||||||
|
integrityCursor string // last game ID the integrity sweep verified; cleanup goroutine only
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a service with optional storage. jwtSecret signs session tokens
|
// New creates a service with optional storage. jwtSecret signs session tokens
|
||||||
@@ -64,6 +66,9 @@ func New(store *storage.Store, jwtSecret []byte) (*Service, error) {
|
|||||||
anonymousTTL: AnonymousGameTTL,
|
anonymousTTL: AnonymousGameTTL,
|
||||||
}
|
}
|
||||||
s.maxUsers.Store(DefaultMaxUsers)
|
s.maxUsers.Store(DefaultMaxUsers)
|
||||||
|
if store != nil {
|
||||||
|
store.SetGameMissingHandler(s.evictMissingGame)
|
||||||
|
}
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,14 +120,30 @@ func (s *Service) RegisterWait(gameID string, moveCount int, ctx context.Context
|
|||||||
return s.waiter.RegisterWait(gameID, moveCount, ctx)
|
return s.waiter.RegisterWait(gameID, moveCount, ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CanCreateComputerGame checks if a new computer game can be created
|
// CanCreateComputerGame reports whether the computer game limit leaves room
|
||||||
|
// for another game; CreateGame re-checks under its lock.
|
||||||
func (s *Service) CanCreateComputerGame() bool {
|
func (s *Service) CanCreateComputerGame() bool {
|
||||||
return s.computerGames.Load() < MaxComputerGames
|
return s.GetComputerGameCount() < MaxComputerGames
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetComputerGameCount returns current computer game count
|
// GetComputerGameCount returns the number of unfinished loaded games with a
|
||||||
func (s *Service) GetComputerGameCount() int32 {
|
// computer player. Finished games stay loaded for a while but need no engine,
|
||||||
return s.computerGames.Load()
|
// so they do not count against MaxComputerGames.
|
||||||
|
func (s *Service) GetComputerGameCount() int {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return s.activeComputerGamesLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// activeComputerGamesLocked counts as GetComputerGameCount. Caller holds s.mu.
|
||||||
|
func (s *Service) activeComputerGamesLocked() int {
|
||||||
|
active := 0
|
||||||
|
for _, g := range s.games {
|
||||||
|
if g.HasComputerPlayer() && !g.State().IsTerminal() {
|
||||||
|
active++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return active
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shutdown gracefully shuts down the service
|
// Shutdown gracefully shuts down the service
|
||||||
@@ -146,8 +167,9 @@ func (s *Service) Shutdown(timeout time.Duration) error {
|
|||||||
return errors.Join(errs...)
|
return errors.Join(errs...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunCleanupJob periodically removes expired sessions, unloads idle games, and
|
// RunCleanupJob periodically removes expired sessions, unloads idle games,
|
||||||
// deletes anonymous games past their retention.
|
// deletes anonymous games past their retention, and runs the integrity sweep
|
||||||
|
// when enabled.
|
||||||
func (s *Service) RunCleanupJob(ctx context.Context, interval time.Duration) {
|
func (s *Service) RunCleanupJob(ctx context.Context, interval time.Duration) {
|
||||||
s.cleanupExpired()
|
s.cleanupExpired()
|
||||||
ticker := time.NewTicker(interval)
|
ticker := time.NewTicker(interval)
|
||||||
@@ -173,6 +195,7 @@ func (s *Service) cleanupExpired() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.cleanupGames(now)
|
s.cleanupGames(now)
|
||||||
|
s.checkIntegrity(now)
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanupGames unloads terminal games after the finished-game TTL and
|
// cleanupGames unloads terminal games after the finished-game TTL and
|
||||||
@@ -199,9 +222,6 @@ func (s *Service) cleanupGames(now time.Time) {
|
|||||||
live = append(live, gameID)
|
live = append(live, gameID)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if g.HasComputerPlayer() {
|
|
||||||
s.computerGames.Add(-1)
|
|
||||||
}
|
|
||||||
delete(s.games, gameID)
|
delete(s.games, gameID)
|
||||||
}
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|||||||
@@ -105,6 +105,12 @@ func (w *WaitRegistry) NotifyGame(gameID string, currentMoveCount int, state cor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NotifyAll wakes every client waiting on a game, for changes that neither
|
||||||
|
// move nor settle it, such as a draw offer.
|
||||||
|
func (w *WaitRegistry) NotifyAll(gameID string) {
|
||||||
|
w.RemoveGame(gameID)
|
||||||
|
}
|
||||||
|
|
||||||
// RemoveGame removes all waiters for a game (called before game deletion)
|
// RemoveGame removes all waiters for a game (called before game deletion)
|
||||||
func (w *WaitRegistry) RemoveGame(gameID string) {
|
func (w *WaitRegistry) RemoveGame(gameID string) {
|
||||||
w.mu.RLock()
|
w.mu.RLock()
|
||||||
|
|||||||
+237
-73
@@ -8,13 +8,15 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
)
|
)
|
||||||
|
|
||||||
const gameSelectColumns = `
|
const gameSelectColumns = `
|
||||||
g.game_id, g.initial_fen,
|
g.game_id, g.initial_fen,
|
||||||
g.white_player_id, g.white_type, g.white_level, g.white_search_time, g.white_claimed_by, g.white_name,
|
g.white_player_id, g.white_type, g.white_level, g.white_search_time, g.white_claimed_by, g.white_name,
|
||||||
g.black_player_id, g.black_type, g.black_level, g.black_search_time, g.black_claimed_by, g.black_name,
|
g.black_player_id, g.black_type, g.black_level, g.black_search_time, g.black_claimed_by, g.black_name,
|
||||||
g.result, g.start_time_utc, g.end_time_utc`
|
g.result, g.termination, g.start_time_utc, g.end_time_utc`
|
||||||
|
|
||||||
// claimantName snapshots a claimant's username inside the writing
|
// claimantName snapshots a claimant's username inside the writing
|
||||||
// transaction; NULL when the slot is unclaimed or the account is gone.
|
// transaction; NULL when the slot is unclaimed or the account is gone.
|
||||||
@@ -33,7 +35,7 @@ func (s *Store) RecordNewGame(record GameRecord) error {
|
|||||||
if record.GameID == "" || record.InitialFEN == "" || record.WhitePlayerID == "" || record.BlackPlayerID == "" {
|
if record.GameID == "" || record.InitialFEN == "" || record.WhitePlayerID == "" || record.BlackPlayerID == "" {
|
||||||
return errors.New("game ID, initial FEN, and player IDs are required")
|
return errors.New("game ID, initial FEN, and player IDs are required")
|
||||||
}
|
}
|
||||||
if err := validateResultTime(record.Result, record.EndTimeUTC); err != nil {
|
if err := validateOutcome(record.Result, record.Termination, record.EndTimeUTC); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,9 +44,9 @@ func (s *Store) RecordNewGame(record GameRecord) error {
|
|||||||
game_id, initial_fen,
|
game_id, initial_fen,
|
||||||
white_player_id, white_type, white_level, white_search_time, white_claimed_by, white_name,
|
white_player_id, white_type, white_level, white_search_time, white_claimed_by, white_name,
|
||||||
black_player_id, black_type, black_level, black_search_time, black_claimed_by, black_name,
|
black_player_id, black_type, black_level, black_search_time, black_claimed_by, black_name,
|
||||||
start_time_utc, result, end_time_utc
|
start_time_utc, result, termination, end_time_utc
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, ` + claimantName("$7") + `,
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, ` + claimantName("$7") + `,
|
||||||
$8, $9, $10, $11, $12, ` + claimantName("$12") + `, $13, $14, $15)`
|
$8, $9, $10, $11, $12, ` + claimantName("$12") + `, $13, $14, $15, $16)`
|
||||||
|
|
||||||
_, err := tx.ExecContext(ctx, query,
|
_, err := tx.ExecContext(ctx, query,
|
||||||
record.GameID, record.InitialFEN,
|
record.GameID, record.InitialFEN,
|
||||||
@@ -52,7 +54,8 @@ func (s *Store) RecordNewGame(record GameRecord) error {
|
|||||||
nullableString(record.WhiteClaimedBy),
|
nullableString(record.WhiteClaimedBy),
|
||||||
record.BlackPlayerID, record.BlackType, record.BlackLevel, record.BlackSearchTime,
|
record.BlackPlayerID, record.BlackType, record.BlackLevel, record.BlackSearchTime,
|
||||||
nullableString(record.BlackClaimedBy),
|
nullableString(record.BlackClaimedBy),
|
||||||
record.StartTimeUTC, nullableString(record.Result), record.EndTimeUTC,
|
record.StartTimeUTC, nullableString(record.Result), nullableString(record.Termination),
|
||||||
|
record.EndTimeUTC,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
@@ -74,7 +77,7 @@ func (s *Store) RecordMove(record MovePersistence) error {
|
|||||||
if (record.ClaimColor == "") != (record.ClaimedBy == "") {
|
if (record.ClaimColor == "") != (record.ClaimedBy == "") {
|
||||||
return errors.New("claim color and claimant must be provided together")
|
return errors.New("claim color and claimant must be provided together")
|
||||||
}
|
}
|
||||||
if err := validateResultTime(record.Result, record.EndTimeUTC); err != nil {
|
if err := validateOutcome(record.Result, record.Termination, record.EndTimeUTC); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,65 +93,88 @@ func (s *Store) RecordMove(record MovePersistence) error {
|
|||||||
record.Move.PlayerColor,
|
record.Move.PlayerColor,
|
||||||
record.Move.MoveTimeUTC,
|
record.Move.MoveTimeUTC,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return gameMissingOr(err, record.Move.GameID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if record.ClaimedBy == "" && record.Result == "" {
|
if record.ClaimedBy == "" && record.Result == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// One UPDATE applies the optional claim and optional result. A claim may
|
return updateGameEnd(ctx, tx, record.Move.GameID, record.ClaimColor, record.ClaimedBy,
|
||||||
// only fill an empty slot or repeat the same claimant.
|
record.Result, record.Termination, record.EndTimeUTC)
|
||||||
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)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecordGameResult persists a terminal transition not accompanied by a move,
|
// GameEnd is a terminal transition not accompanied by a move: a resignation,
|
||||||
// such as a no-legal-moves engine response.
|
// a draw by agreement, or an engine that found no legal move. ClaimColor and
|
||||||
func (s *Store) RecordGameResult(gameID, result string, at time.Time) error {
|
// ClaimedBy optionally record the acting user's claim of an empty slot.
|
||||||
if gameID == "" {
|
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")
|
return errors.New("game ID is required")
|
||||||
}
|
}
|
||||||
if !isValidResult(result) {
|
if end.EndTimeUTC.IsZero() {
|
||||||
return fmt.Errorf("invalid game result %q", result)
|
return errors.New("game end time is required")
|
||||||
}
|
}
|
||||||
if at.IsZero() {
|
ended := end.EndTimeUTC.UTC()
|
||||||
return errors.New("game result time is required")
|
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 {
|
if end.Result == "" {
|
||||||
res, err := tx.ExecContext(ctx,
|
return errors.New("game result is required")
|
||||||
`UPDATE games SET result = $2, end_time_utc = $3 WHERE game_id = $1`,
|
}
|
||||||
gameID, result, at.UTC(),
|
if end.ClaimColor != "" && end.ClaimColor != "w" && end.ClaimColor != "b" {
|
||||||
)
|
return fmt.Errorf("invalid claim color %q", end.ClaimColor)
|
||||||
if err != nil {
|
}
|
||||||
return err
|
if (end.ClaimColor == "") != (end.ClaimedBy == "") {
|
||||||
}
|
return errors.New("claim color and claimant must be provided together")
|
||||||
return requireOneGame(res, gameID)
|
}
|
||||||
|
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.
|
// PlayerRecord is the persistence subset of a player configuration.
|
||||||
type PlayerRecord struct {
|
type PlayerRecord struct {
|
||||||
PlayerID string
|
PlayerID string
|
||||||
@@ -177,7 +203,7 @@ func (s *Store) RecordPlayers(gameID string, white, black PlayerRecord) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return requireOneGame(res, gameID)
|
return requireOneGame(ctx, tx, res, gameID)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,13 +221,13 @@ func (s *Store) RewindGame(gameID string, afterMoveNumber int) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
res, err := tx.ExecContext(ctx,
|
res, err := tx.ExecContext(ctx,
|
||||||
`UPDATE games SET result = NULL, end_time_utc = NULL WHERE game_id = $1`,
|
`UPDATE games SET result = NULL, termination = NULL, end_time_utc = NULL WHERE game_id = $1`,
|
||||||
gameID,
|
gameID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return requireOneGame(res, gameID)
|
return requireOneGame(ctx, tx, res, gameID)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,23 +365,70 @@ func (s *Store) GetGameHistory(gameID string) (*GameRecord, []MoveRecord, error)
|
|||||||
return &record, moves, nil
|
return &record, moves, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// QueryGamesForUser returns a page of games claimed by userID in either color,
|
// UserGamesQuery selects a page of a user's games, newest first. After, when
|
||||||
// newest first. The last move is read through a LATERAL probe of the moves
|
// set, continues strictly below that (start time, game ID) key; Offset
|
||||||
// primary key, so the summary costs one index lookup per game rather than a
|
// remains for clients that page by position and must be 0 with After.
|
||||||
// move count over the whole line.
|
type UserGamesQuery struct {
|
||||||
func (s *Store) QueryGamesForUser(userID string, limit, offset int) ([]GameSummaryRecord, error) {
|
Limit int
|
||||||
if userID == "" || limit < 1 || limit > MaxUserGamesPage || offset < 0 {
|
Offset int
|
||||||
|
After *GameCursor
|
||||||
|
Color string // "" either, "w" or "b": the color the user claimed
|
||||||
|
Status string // "" any, "ongoing", or "finished"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GameCursor is a keyset position in a newest-first game listing.
|
||||||
|
type GameCursor struct {
|
||||||
|
StartTimeUTC time.Time
|
||||||
|
GameID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryGamesForUser returns a page of games claimed by userID, newest first.
|
||||||
|
// The last move is read through a LATERAL probe of the moves primary key, so
|
||||||
|
// the summary costs one index lookup per game rather than a move count over
|
||||||
|
// the whole line.
|
||||||
|
func (s *Store) QueryGamesForUser(userID string, q UserGamesQuery) ([]GameSummaryRecord, error) {
|
||||||
|
if userID == "" || q.Limit < 1 || q.Limit > MaxUserGamesPage || q.Offset < 0 {
|
||||||
return nil, fmt.Errorf("user ID, limit from 1 to %d, and non-negative offset are required",
|
return nil, fmt.Errorf("user ID, limit from 1 to %d, and non-negative offset are required",
|
||||||
MaxUserGamesPage)
|
MaxUserGamesPage)
|
||||||
}
|
}
|
||||||
|
if q.After != nil && (q.Offset != 0 || !validUUID(q.After.GameID)) {
|
||||||
|
return nil, errors.New("a cursor requires a valid game ID and offset 0")
|
||||||
|
}
|
||||||
if !validUUID(userID) {
|
if !validUUID(userID) {
|
||||||
return []GameSummaryRecord{}, nil
|
return []GameSummaryRecord{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
args := []any{userID, q.Limit, q.Offset}
|
||||||
|
var where string
|
||||||
|
switch q.Color {
|
||||||
|
case "":
|
||||||
|
where = "(g.white_claimed_by = $1 OR g.black_claimed_by = $1)"
|
||||||
|
case "w":
|
||||||
|
where = "g.white_claimed_by = $1"
|
||||||
|
case "b":
|
||||||
|
where = "g.black_claimed_by = $1"
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("invalid color filter %q", q.Color)
|
||||||
|
}
|
||||||
|
switch q.Status {
|
||||||
|
case "":
|
||||||
|
case "ongoing":
|
||||||
|
where += " AND g.result IS NULL"
|
||||||
|
case "finished":
|
||||||
|
where += " AND g.result IS NOT NULL"
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("invalid status filter %q", q.Status)
|
||||||
|
}
|
||||||
|
if q.After != nil {
|
||||||
|
args = append(args, q.After.StartTimeUTC, q.After.GameID)
|
||||||
|
where += " AND (g.start_time_utc, g.game_id) < ($4, $5)"
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.flushBeforeRead(); err != nil {
|
if err := s.flushBeforeRead(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
started := time.Now()
|
started := time.Now()
|
||||||
const query = `SELECT ` + gameSelectColumns + `,
|
query := `SELECT ` + gameSelectColumns + `,
|
||||||
COALESCE(last.move_number, 0), COALESCE(last.fen_after_move, g.initial_fen)
|
COALESCE(last.move_number, 0), COALESCE(last.fen_after_move, g.initial_fen)
|
||||||
FROM games g
|
FROM games g
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
@@ -364,12 +437,12 @@ func (s *Store) QueryGamesForUser(userID string, limit, offset int) ([]GameSumma
|
|||||||
ORDER BY m.move_number DESC
|
ORDER BY m.move_number DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) last ON true
|
) last ON true
|
||||||
WHERE g.white_claimed_by = $1 OR g.black_claimed_by = $1
|
WHERE ` + where + `
|
||||||
ORDER BY g.start_time_utc DESC, g.game_id DESC
|
ORDER BY g.start_time_utc DESC, g.game_id DESC
|
||||||
LIMIT $2 OFFSET $3`
|
LIMIT $2 OFFSET $3`
|
||||||
ctx, cancel := opContext()
|
ctx, cancel := opContext()
|
||||||
defer cancel()
|
defer cancel()
|
||||||
rows, err := s.db.QueryContext(ctx, query, userID, limit, offset)
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("query user games: %w", err)
|
return nil, fmt.Errorf("query user games: %w", err)
|
||||||
}
|
}
|
||||||
@@ -389,20 +462,63 @@ func (s *Store) QueryGamesForUser(userID string, limit, offset int) ([]GameSumma
|
|||||||
slog.Debug("storage user games queried",
|
slog.Debug("storage user games queried",
|
||||||
"user_id", userID,
|
"user_id", userID,
|
||||||
"count", len(games),
|
"count", len(games),
|
||||||
"limit", limit,
|
"limit", q.Limit,
|
||||||
"offset", offset,
|
"offset", q.Offset,
|
||||||
|
"cursor", q.After != nil,
|
||||||
"duration", time.Since(started),
|
"duration", time.Since(started),
|
||||||
)
|
)
|
||||||
return games, nil
|
return games, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResolveGameID expands a game ID prefix of at least 8 hexadecimal digits,
|
||||||
|
// as printed by administrative listings, to the unique full ID.
|
||||||
|
func (s *Store) ResolveGameID(prefix string) (string, error) {
|
||||||
|
prefix = strings.ToLower(prefix)
|
||||||
|
if validUUID(prefix) {
|
||||||
|
return prefix, nil
|
||||||
|
}
|
||||||
|
if len(prefix) < 8 || len(prefix) > 36 || strings.Trim(prefix, "0123456789abcdef-") != "" {
|
||||||
|
return "", fmt.Errorf("game ID or a prefix of at least 8 hex digits required, got %q", prefix)
|
||||||
|
}
|
||||||
|
if err := s.flushBeforeRead(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
ctx, cancel := opContext()
|
||||||
|
defer cancel()
|
||||||
|
rows, err := s.db.QueryContext(ctx,
|
||||||
|
`SELECT game_id FROM games WHERE game_id::text LIKE $1 || '%' ORDER BY game_id LIMIT 2`, prefix)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("resolve game ID: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var ids []string
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return "", fmt.Errorf("resolve game ID: %w", err)
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return "", fmt.Errorf("resolve game ID: %w", err)
|
||||||
|
}
|
||||||
|
switch len(ids) {
|
||||||
|
case 0:
|
||||||
|
return "", sql.ErrNoRows
|
||||||
|
case 1:
|
||||||
|
return ids[0], nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("game ID prefix %q is ambiguous", prefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type rowScanner interface {
|
type rowScanner interface {
|
||||||
Scan(dest ...any) error
|
Scan(dest ...any) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// scanGame scans gameSelectColumns followed by any extra destinations.
|
// scanGame scans gameSelectColumns followed by any extra destinations.
|
||||||
func scanGame(scanner rowScanner, record *GameRecord, extra ...any) error {
|
func scanGame(scanner rowScanner, record *GameRecord, extra ...any) error {
|
||||||
var whiteClaimed, whiteName, blackClaimed, blackName, result sql.NullString
|
var whiteClaimed, whiteName, blackClaimed, blackName, result, termination sql.NullString
|
||||||
var endTime sql.NullTime
|
var endTime sql.NullTime
|
||||||
dest := []any{
|
dest := []any{
|
||||||
&record.GameID, &record.InitialFEN,
|
&record.GameID, &record.InitialFEN,
|
||||||
@@ -410,7 +526,7 @@ func scanGame(scanner rowScanner, record *GameRecord, extra ...any) error {
|
|||||||
&whiteClaimed, &whiteName,
|
&whiteClaimed, &whiteName,
|
||||||
&record.BlackPlayerID, &record.BlackType, &record.BlackLevel, &record.BlackSearchTime,
|
&record.BlackPlayerID, &record.BlackType, &record.BlackLevel, &record.BlackSearchTime,
|
||||||
&blackClaimed, &blackName,
|
&blackClaimed, &blackName,
|
||||||
&result, &record.StartTimeUTC, &endTime,
|
&result, &termination, &record.StartTimeUTC, &endTime,
|
||||||
}
|
}
|
||||||
if err := scanner.Scan(append(dest, extra...)...); err != nil {
|
if err := scanner.Scan(append(dest, extra...)...); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -420,20 +536,47 @@ func scanGame(scanner rowScanner, record *GameRecord, extra ...any) error {
|
|||||||
record.BlackClaimedBy = blackClaimed.String
|
record.BlackClaimedBy = blackClaimed.String
|
||||||
record.BlackName = blackName.String
|
record.BlackName = blackName.String
|
||||||
record.Result = result.String
|
record.Result = result.String
|
||||||
|
record.Termination = termination.String
|
||||||
record.StartTimeUTC = record.StartTimeUTC.UTC()
|
record.StartTimeUTC = record.StartTimeUTC.UTC()
|
||||||
record.EndTimeUTC = utcPointer(endTime)
|
record.EndTimeUTC = utcPointer(endTime)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func requireOneGame(result sql.Result, gameID string) error {
|
// ErrGameMissing reports a write for a game whose row no longer exists,
|
||||||
|
// typically deleted by hand while the server still held the game. The writer
|
||||||
|
// drops such a write without degrading storage; see SetGameMissingHandler.
|
||||||
|
var ErrGameMissing = errors.New("game row no longer exists")
|
||||||
|
|
||||||
|
// requireOneGame checks that an UPDATE matched its game, telling a missing
|
||||||
|
// row apart from a row whose guard (such as a claim) did not match.
|
||||||
|
func requireOneGame(ctx context.Context, tx *sql.Tx, result sql.Result, gameID string) error {
|
||||||
rows, err := result.RowsAffected()
|
rows, err := result.RowsAffected()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if rows != 1 {
|
if rows == 1 {
|
||||||
return fmt.Errorf("game %s was not updated", gameID)
|
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 {
|
func isValidResult(result string) bool {
|
||||||
@@ -445,16 +588,37 @@ func isValidResult(result string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateResultTime(result string, ended *time.Time) error {
|
// validTermination mirrors the games_termination_check constraint.
|
||||||
|
func validTermination(result, termination string) bool {
|
||||||
|
switch result {
|
||||||
|
case "white_wins", "black_wins":
|
||||||
|
return termination == "checkmate" || termination == "resignation"
|
||||||
|
case "stalemate":
|
||||||
|
return termination == "stalemate"
|
||||||
|
case "draw":
|
||||||
|
switch termination {
|
||||||
|
case "insufficient_material", "threefold_repetition", "fifty_move_rule", "agreement":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateOutcome checks that a result, its termination, and the end time are
|
||||||
|
// all present and consistent, or all absent.
|
||||||
|
func validateOutcome(result, termination string, ended *time.Time) error {
|
||||||
if result == "" {
|
if result == "" {
|
||||||
if ended != nil {
|
if termination != "" || ended != nil {
|
||||||
return errors.New("end time requires a game result")
|
return errors.New("termination and end time require a game result")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if !isValidResult(result) {
|
if !isValidResult(result) {
|
||||||
return fmt.Errorf("invalid game result %q", result)
|
return fmt.Errorf("invalid game result %q", result)
|
||||||
}
|
}
|
||||||
|
if !validTermination(result, termination) {
|
||||||
|
return fmt.Errorf("termination %q cannot produce result %q", termination, result)
|
||||||
|
}
|
||||||
if ended == nil || ended.IsZero() {
|
if ended == nil || ended.IsZero() {
|
||||||
return errors.New("terminal game result requires an end time")
|
return errors.New("terminal game result requires an end time")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ type GameRecord struct {
|
|||||||
BlackClaimedBy string
|
BlackClaimedBy string
|
||||||
BlackName string
|
BlackName string
|
||||||
Result string
|
Result string
|
||||||
|
Termination string // how Result was reached; empty while unfinished
|
||||||
StartTimeUTC time.Time
|
StartTimeUTC time.Time
|
||||||
EndTimeUTC *time.Time
|
EndTimeUTC *time.Time
|
||||||
}
|
}
|
||||||
@@ -63,17 +64,18 @@ type MoveRecord struct {
|
|||||||
// MovePersistence groups changes caused by one accepted move so the move,
|
// MovePersistence groups changes caused by one accepted move so the move,
|
||||||
// first-move slot claim, and terminal result commit in one transaction.
|
// first-move slot claim, and terminal result commit in one transaction.
|
||||||
type MovePersistence struct {
|
type MovePersistence struct {
|
||||||
Move MoveRecord
|
Move MoveRecord
|
||||||
ClaimColor string
|
ClaimColor string
|
||||||
ClaimedBy string
|
ClaimedBy string
|
||||||
Result string
|
Result string
|
||||||
EndTimeUTC *time.Time
|
Termination string
|
||||||
|
EndTimeUTC *time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// schemaVersion is the newest schema this binary understands. Migrations are
|
// schemaVersion is the newest schema this binary understands. Migrations are
|
||||||
// applied in order inside one transaction; PostgreSQL DDL is transactional, so
|
// applied in order inside one transaction; PostgreSQL DDL is transactional, so
|
||||||
// a failed upgrade leaves the previous version intact.
|
// a failed upgrade leaves the previous version intact.
|
||||||
const schemaVersion = 1
|
const schemaVersion = 2
|
||||||
|
|
||||||
// migrations[v-1] upgrades the schema from version v-1 to v. Never edit a
|
// migrations[v-1] upgrades the schema from version v-1 to v. Never edit a
|
||||||
// released migration; append a new one.
|
// released migration; append a new one.
|
||||||
@@ -161,6 +163,25 @@ CREATE INDEX games_white_claimed_idx ON games (white_claimed_by, start_time_utc
|
|||||||
CREATE INDEX games_black_claimed_idx ON games (black_claimed_by, start_time_utc DESC, game_id DESC)
|
CREATE INDEX games_black_claimed_idx ON games (black_claimed_by, start_time_utc DESC, game_id DESC)
|
||||||
WHERE black_claimed_by IS NOT NULL;
|
WHERE black_claimed_by IS NOT NULL;
|
||||||
`,
|
`,
|
||||||
|
|
||||||
|
// v2: how a result was reached. Results before v2 came only from
|
||||||
|
// checkmate and stalemate (the server never produced "draw"), so the
|
||||||
|
// backfill is exact for them; a hand-written draw becomes "agreement".
|
||||||
|
`
|
||||||
|
ALTER TABLE games ADD COLUMN termination text;
|
||||||
|
UPDATE games SET termination = CASE result
|
||||||
|
WHEN 'stalemate' THEN 'stalemate'
|
||||||
|
WHEN 'draw' THEN 'agreement'
|
||||||
|
ELSE 'checkmate' END
|
||||||
|
WHERE result IS NOT NULL;
|
||||||
|
ALTER TABLE games
|
||||||
|
ADD CONSTRAINT games_result_termination_check CHECK ((result IS NULL) = (termination IS NULL)),
|
||||||
|
ADD CONSTRAINT games_termination_check CHECK (
|
||||||
|
(result IN ('white_wins', 'black_wins') AND termination IN ('checkmate', 'resignation'))
|
||||||
|
OR (result = 'stalemate' AND termination = 'stalemate')
|
||||||
|
OR (result = 'draw' AND termination IN
|
||||||
|
('insufficient_material', 'threefold_repetition', 'fifty_move_rule', 'agreement')));
|
||||||
|
`,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ownedTables lists every table created by migrations, children first.
|
// ownedTables lists every table created by migrations, children first.
|
||||||
|
|||||||
@@ -68,6 +68,15 @@ type Store struct {
|
|||||||
enqueueMu sync.RWMutex
|
enqueueMu sync.RWMutex
|
||||||
closeOnce sync.Once
|
closeOnce sync.Once
|
||||||
closeErr error
|
closeErr error
|
||||||
|
gameMissing atomic.Pointer[func(gameID string)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetGameMissingHandler registers the callback run, on its own goroutine,
|
||||||
|
// when a write finds its game row gone. Such a write is dropped without
|
||||||
|
// degrading storage: later writes for that game fail the same way and write
|
||||||
|
// nothing, so no other game's history is affected.
|
||||||
|
func (s *Store) SetGameMissingHandler(handler func(gameID string)) {
|
||||||
|
s.gameMissing.Store(&handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewStore connects to PostgreSQL and starts the async writer. dsn is a
|
// NewStore connects to PostgreSQL and starts the async writer. dsn is a
|
||||||
@@ -203,6 +212,14 @@ func (s *Store) executeWrite(req writeRequest) error {
|
|||||||
)
|
)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, ErrGameMissing) {
|
||||||
|
slog.Warn("storage write dropped: game row no longer exists",
|
||||||
|
"operation", req.operation, "game_id", req.gameID)
|
||||||
|
if handler := s.gameMissing.Load(); handler != nil && req.gameID != "" {
|
||||||
|
go (*handler)(req.gameID)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
if committing || attempt >= writeAttempts || !isTransient(err) {
|
if committing || attempt >= writeAttempts || !isTransient(err) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ func TestReplayPersistenceIsAtomicAndReadAfterWriteConsistent(t *testing.T) {
|
|||||||
FENAfterMove: "after-e2e4", PlayerColor: "w", MoveTimeUTC: ended,
|
FENAfterMove: "after-e2e4", PlayerColor: "w", MoveTimeUTC: ended,
|
||||||
},
|
},
|
||||||
ClaimColor: "w", ClaimedBy: user,
|
ClaimColor: "w", ClaimedBy: user,
|
||||||
Result: "white_wins", EndTimeUTC: &ended,
|
Result: "white_wins", Termination: "checkmate", EndTimeUTC: &ended,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -137,7 +137,7 @@ func TestReplayPersistenceIsAtomicAndReadAfterWriteConsistent(t *testing.T) {
|
|||||||
t.Fatalf("moves = %+v", moves)
|
t.Fatalf("moves = %+v", moves)
|
||||||
}
|
}
|
||||||
|
|
||||||
owned, err := store.QueryGamesForUser(user, 10, 0)
|
owned, err := store.QueryGamesForUser(user, UserGamesQuery{Limit: 10})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -155,7 +155,7 @@ func TestReplayPersistenceIsAtomicAndReadAfterWriteConsistent(t *testing.T) {
|
|||||||
if record.Result != "" || record.EndTimeUTC != nil || len(moves) != 0 {
|
if record.Result != "" || record.EndTimeUTC != nil || len(moves) != 0 {
|
||||||
t.Fatalf("rewind left stale replay data: game=%+v moves=%+v", record, moves)
|
t.Fatalf("rewind left stale replay data: game=%+v moves=%+v", record, moves)
|
||||||
}
|
}
|
||||||
owned, err = store.QueryGamesForUser(user, 10, 0)
|
owned, err = store.QueryGamesForUser(user, UserGamesQuery{Limit: 10})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -188,6 +188,8 @@ func TestUserGamesArePagedNewestFirstWithoutDuplicates(t *testing.T) {
|
|||||||
record.WhiteClaimedBy = user
|
record.WhiteClaimedBy = user
|
||||||
case 1:
|
case 1:
|
||||||
record.BlackClaimedBy = user
|
record.BlackClaimedBy = user
|
||||||
|
end := record.StartTimeUTC.Add(time.Minute)
|
||||||
|
record.Result, record.Termination, record.EndTimeUTC = "white_wins", "resignation", &end
|
||||||
case 2:
|
case 2:
|
||||||
record.WhiteClaimedBy, record.BlackClaimedBy = user, user // self-play
|
record.WhiteClaimedBy, record.BlackClaimedBy = user, user // self-play
|
||||||
case 3:
|
case 3:
|
||||||
@@ -205,7 +207,7 @@ func TestUserGamesArePagedNewestFirstWithoutDuplicates(t *testing.T) {
|
|||||||
|
|
||||||
var got []string
|
var got []string
|
||||||
for offset := 0; ; offset += 2 {
|
for offset := 0; ; offset += 2 {
|
||||||
page, err := store.QueryGamesForUser(user, 2, offset)
|
page, err := store.QueryGamesForUser(user, UserGamesQuery{Limit: 2, Offset: offset})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -219,9 +221,88 @@ func TestUserGamesArePagedNewestFirstWithoutDuplicates(t *testing.T) {
|
|||||||
if fmt.Sprint(got) != fmt.Sprint(want) {
|
if fmt.Sprint(got) != fmt.Sprint(want) {
|
||||||
t.Fatalf("user games = %v, want %v", got, want)
|
t.Fatalf("user games = %v, want %v", got, want)
|
||||||
}
|
}
|
||||||
if games, err := store.QueryGamesForUser("not-a-uuid", 10, 0); err != nil || len(games) != 0 {
|
if games, err := store.QueryGamesForUser("not-a-uuid", UserGamesQuery{Limit: 10}); err != nil || len(games) != 0 {
|
||||||
t.Fatalf("malformed user ID = %v, %v; want empty", games, err)
|
t.Fatalf("malformed user ID = %v, %v; want empty", games, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keyset pages match offset pages, and a game inserted at the head
|
||||||
|
// between pages does not shift the next page.
|
||||||
|
got = nil
|
||||||
|
var after *GameCursor
|
||||||
|
for page := 0; ; page++ {
|
||||||
|
games, err := store.QueryGamesForUser(user, UserGamesQuery{Limit: 2, After: after})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, game := range games {
|
||||||
|
got = append(got, game.GameID)
|
||||||
|
}
|
||||||
|
if len(games) < 2 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
last := games[len(games)-1]
|
||||||
|
after = &GameCursor{StartTimeUTC: last.StartTimeUTC, GameID: last.GameID}
|
||||||
|
if page == 0 {
|
||||||
|
newer := GameRecord{
|
||||||
|
GameID: uuid.NewString(), InitialFEN: "initial",
|
||||||
|
WhitePlayerID: user, WhiteType: 1, WhiteClaimedBy: user,
|
||||||
|
BlackPlayerID: uuid.NewString(), BlackType: 1,
|
||||||
|
StartTimeUTC: base.Add(time.Hour),
|
||||||
|
}
|
||||||
|
if err := store.RecordNewGame(newer); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fmt.Sprint(got) != fmt.Sprint(want) {
|
||||||
|
t.Fatalf("keyset pages = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
count := func(q UserGamesQuery) int {
|
||||||
|
t.Helper()
|
||||||
|
q.Limit = 50
|
||||||
|
games, err := store.QueryGamesForUser(user, q)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return len(games)
|
||||||
|
}
|
||||||
|
// Five games claimed by user: indexes 0, 1, 2, 4 and the newer one.
|
||||||
|
for _, tc := range []struct {
|
||||||
|
q UserGamesQuery
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{UserGamesQuery{}, 5},
|
||||||
|
{UserGamesQuery{Color: "w"}, 3},
|
||||||
|
{UserGamesQuery{Color: "b"}, 3},
|
||||||
|
{UserGamesQuery{Status: "finished"}, 1},
|
||||||
|
{UserGamesQuery{Status: "ongoing"}, 4},
|
||||||
|
{UserGamesQuery{Color: "b", Status: "finished"}, 1},
|
||||||
|
} {
|
||||||
|
if got := count(tc.q); got != tc.want {
|
||||||
|
t.Errorf("QueryGamesForUser(%+v) = %d games, want %d", tc.q, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, bad := range []UserGamesQuery{
|
||||||
|
{Limit: 1, Color: "x"}, {Limit: 1, Status: "x"},
|
||||||
|
{Limit: 1, Offset: 1, After: &GameCursor{GameID: user}},
|
||||||
|
{Limit: 1, After: &GameCursor{GameID: "x"}},
|
||||||
|
} {
|
||||||
|
if _, err := store.QueryGamesForUser(user, bad); err == nil {
|
||||||
|
t.Errorf("QueryGamesForUser(%+v) succeeded", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := want[0][:8]
|
||||||
|
if id, err := store.ResolveGameID(prefix); err != nil || id != want[0] {
|
||||||
|
t.Errorf("ResolveGameID(%s) = %q, %v; want %s", prefix, id, err, want[0])
|
||||||
|
}
|
||||||
|
if _, err := store.ResolveGameID("0000000"); err == nil {
|
||||||
|
t.Error("ResolveGameID accepted a 7-digit prefix")
|
||||||
|
}
|
||||||
|
if _, err := store.ResolveGameID("ffffffff-ffff"); !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
t.Errorf("ResolveGameID(unknown) error = %v, want sql.ErrNoRows", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryPlansUsePurposeBuiltIndexes(t *testing.T) {
|
func TestQueryPlansUsePurposeBuiltIndexes(t *testing.T) {
|
||||||
@@ -242,6 +323,14 @@ func TestQueryPlansUsePurposeBuiltIndexes(t *testing.T) {
|
|||||||
args: []any{userID},
|
args: []any{userID},
|
||||||
want: []string{"games_white_claimed_idx", "games_black_claimed_idx"},
|
want: []string{"games_white_claimed_idx", "games_black_claimed_idx"},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "keyset page of one color uses its claim index",
|
||||||
|
query: `SELECT game_id FROM games g
|
||||||
|
WHERE g.white_claimed_by = $1 AND (g.start_time_utc, g.game_id) < ($2, $3)
|
||||||
|
ORDER BY g.start_time_utc DESC, g.game_id DESC LIMIT 50`,
|
||||||
|
args: []any{userID, time.Now(), userID},
|
||||||
|
want: []string{"games_white_claimed_idx"},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "last move probe uses the moves primary key",
|
name: "last move probe uses the moves primary key",
|
||||||
query: `SELECT move_number FROM moves WHERE game_id = $1
|
query: `SELECT move_number FROM moves WHERE game_id = $1
|
||||||
@@ -621,6 +710,12 @@ func TestAnonymousGamesArePurgedAfterInactivity(t *testing.T) {
|
|||||||
BlackPlayerID: uuid.NewString(), BlackType: 2, BlackLevel: 5, BlackSearchTime: 100,
|
BlackPlayerID: uuid.NewString(), BlackType: 2, BlackLevel: 5, BlackSearchTime: 100,
|
||||||
StartTimeUTC: started, Result: result, EndTimeUTC: ended,
|
StartTimeUTC: started, Result: result, EndTimeUTC: ended,
|
||||||
}
|
}
|
||||||
|
switch result {
|
||||||
|
case "stalemate":
|
||||||
|
record.Termination = "stalemate"
|
||||||
|
case "white_wins", "black_wins":
|
||||||
|
record.Termination = "checkmate"
|
||||||
|
}
|
||||||
if err := store.RecordNewGame(record); err != nil {
|
if err := store.RecordNewGame(record); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -792,3 +887,132 @@ func explain(t *testing.T, db *sql.DB, query string, args ...any) string {
|
|||||||
}
|
}
|
||||||
return strings.Join(lines, "\n")
|
return strings.Join(lines, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMigrationV2BackfillsTermination(t *testing.T) {
|
||||||
|
dsn := pgtest.DSN(t)
|
||||||
|
store, err := NewStore(dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = store.Close() })
|
||||||
|
|
||||||
|
// A version 1 database with one game of each stored result.
|
||||||
|
if _, err := store.db.Exec(migrations[0]); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := store.db.Exec(`INSERT INTO schema_version (version) VALUES (1)`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ids := map[string]string{}
|
||||||
|
for _, result := range []string{"", "white_wins", "black_wins", "stalemate", "draw"} {
|
||||||
|
id := uuid.NewString()
|
||||||
|
ids[result] = id
|
||||||
|
var end any
|
||||||
|
if result != "" {
|
||||||
|
end = time.Now()
|
||||||
|
}
|
||||||
|
if _, err := store.db.Exec(`INSERT INTO games (game_id, initial_fen, white_player_id, white_type,
|
||||||
|
black_player_id, black_type, result, end_time_utc) VALUES ($1, 'initial', $2, 1, $3, 1, $4, $5)`,
|
||||||
|
id, uuid.NewString(), uuid.NewString(), nullableString(result), end); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.InitDB(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for result, want := range map[string]string{
|
||||||
|
"": "", "white_wins": "checkmate", "black_wins": "checkmate", "stalemate": "stalemate", "draw": "agreement",
|
||||||
|
} {
|
||||||
|
var termination sql.NullString
|
||||||
|
if err := store.db.QueryRow(`SELECT termination FROM games WHERE game_id = $1`, ids[result]).
|
||||||
|
Scan(&termination); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if termination.String != want {
|
||||||
|
t.Errorf("result %q: termination %q, want %q", result, termination.String, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The constraints reject a termination that cannot produce the result.
|
||||||
|
if _, err := store.db.Exec(`UPDATE games SET termination = 'agreement' WHERE game_id = $1`,
|
||||||
|
ids["white_wins"]); err == nil {
|
||||||
|
t.Error("games_termination_check accepted a win by agreement")
|
||||||
|
}
|
||||||
|
if _, err := store.db.Exec(`UPDATE games SET termination = 'resignation' WHERE game_id = $1`,
|
||||||
|
ids[""]); err == nil {
|
||||||
|
t.Error("games_result_termination_check accepted a termination without a result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteForDeletedGameIsDroppedWithoutDegrading(t *testing.T) {
|
||||||
|
store := openStore(t, pgtest.DSN(t))
|
||||||
|
missing := make(chan string, 4)
|
||||||
|
store.SetGameMissingHandler(func(gameID string) { missing <- gameID })
|
||||||
|
|
||||||
|
gameID := uuid.NewString()
|
||||||
|
if err := store.RecordNewGame(GameRecord{
|
||||||
|
GameID: gameID, InitialFEN: "initial",
|
||||||
|
WhitePlayerID: uuid.NewString(), WhiteType: 1,
|
||||||
|
BlackPlayerID: uuid.NewString(), BlackType: 1,
|
||||||
|
StartTimeUTC: time.Now(),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := store.Flush(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// An administrator deletes the game while the server still holds it.
|
||||||
|
if _, err := store.db.Exec(`DELETE FROM games WHERE game_id = $1`, gameID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ended := time.Now()
|
||||||
|
writes := []func() error{
|
||||||
|
func() error {
|
||||||
|
return store.RecordMove(MovePersistence{Move: MoveRecord{
|
||||||
|
GameID: gameID, MoveNumber: 1, MoveUCI: "e2e4", FENAfterMove: "after",
|
||||||
|
PlayerColor: "w", MoveTimeUTC: ended,
|
||||||
|
}})
|
||||||
|
},
|
||||||
|
func() error {
|
||||||
|
return store.RecordGameEnd(GameEnd{
|
||||||
|
GameID: gameID, Result: "black_wins", Termination: "resignation", EndTimeUTC: ended,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
func() error { return store.RewindGame(gameID, 0) },
|
||||||
|
}
|
||||||
|
for i, write := range writes {
|
||||||
|
if err := write(); err != nil {
|
||||||
|
t.Fatalf("write %d not queued: %v", i, err)
|
||||||
|
}
|
||||||
|
if err := store.Flush(context.Background()); err != nil {
|
||||||
|
t.Fatalf("write %d degraded storage: %v", i, err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case id := <-missing:
|
||||||
|
if id != gameID {
|
||||||
|
t.Fatalf("handler got %s, want %s", id, gameID)
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatalf("write %d: missing-game handler not called", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !store.IsHealthy() {
|
||||||
|
t.Fatal("storage degraded by writes for a deleted game")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other games are unaffected.
|
||||||
|
other := uuid.NewString()
|
||||||
|
if err := store.RecordNewGame(GameRecord{
|
||||||
|
GameID: other, InitialFEN: "initial",
|
||||||
|
WhitePlayerID: uuid.NewString(), WhiteType: 1,
|
||||||
|
BlackPlayerID: uuid.NewString(), BlackType: 1,
|
||||||
|
StartTimeUTC: time.Now(),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := store.GetGameHistory(other); err != nil {
|
||||||
|
t.Fatalf("other game: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ let gameState = {
|
|||||||
username: null,
|
username: null,
|
||||||
authBusy: false,
|
authBusy: false,
|
||||||
newGameBusy: false,
|
newGameBusy: false,
|
||||||
|
termination: '',
|
||||||
|
actionBusy: false, // a draw or resign request is in flight
|
||||||
};
|
};
|
||||||
|
|
||||||
// Chess piece Unicode: all black pieces for better fill, white pawn due to inability to override emoji variant display
|
// Chess piece Unicode: all black pieces for better fill, white pawn due to inability to override emoji variant display
|
||||||
@@ -55,6 +57,10 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
|
|
||||||
document.getElementById('new-game-btn').addEventListener('click', showNewGameModal);
|
document.getElementById('new-game-btn').addEventListener('click', showNewGameModal);
|
||||||
document.getElementById('undo-btn').addEventListener('click', undoMoves);
|
document.getElementById('undo-btn').addEventListener('click', undoMoves);
|
||||||
|
document.getElementById('draw-btn').addEventListener('click', offerDraw);
|
||||||
|
document.getElementById('resign-btn').addEventListener('click', showResignModal);
|
||||||
|
document.getElementById('resign-confirm-btn').addEventListener('click', resignGame);
|
||||||
|
document.getElementById('resign-cancel-btn').addEventListener('click', hideResignModal);
|
||||||
document.getElementById('start-game-btn').addEventListener('click', startNewGame);
|
document.getElementById('start-game-btn').addEventListener('click', startNewGame);
|
||||||
document.getElementById('cancel-btn').addEventListener('click', hideNewGameModal);
|
document.getElementById('cancel-btn').addEventListener('click', hideNewGameModal);
|
||||||
document.getElementById('copy-history').addEventListener('click', copyHistory);
|
document.getElementById('copy-history').addEventListener('click', copyHistory);
|
||||||
@@ -79,6 +85,13 @@ document.getElementById('login-submit-btn').addEventListener('click', handleLogi
|
|||||||
document.getElementById('register-submit-btn').addEventListener('click', handleRegister);
|
document.getElementById('register-submit-btn').addEventListener('click', handleRegister);
|
||||||
document.getElementById('auth-cancel-btn').addEventListener('click', hideAuthModal);
|
document.getElementById('auth-cancel-btn').addEventListener('click', hideAuthModal);
|
||||||
document.getElementById('auth-cancel-btn-2').addEventListener('click', hideAuthModal);
|
document.getElementById('auth-cancel-btn-2').addEventListener('click', hideAuthModal);
|
||||||
|
document.querySelectorAll('.promotion-choice').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => closePromotion(btn.dataset.piece));
|
||||||
|
});
|
||||||
|
document.getElementById('promotion-cancel-btn').addEventListener('click', () => closePromotion(null));
|
||||||
|
document.getElementById('promotion-overlay').addEventListener('click', (e) => {
|
||||||
|
if (e.target === e.currentTarget) closePromotion(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
// Auth functions
|
// Auth functions
|
||||||
@@ -435,24 +448,20 @@ function updateTurnIndicator(state, turn) {
|
|||||||
switch(state) {
|
switch(state) {
|
||||||
case 'white wins':
|
case 'white wins':
|
||||||
status = 'white-wins';
|
status = 'white-wins';
|
||||||
tooltipText = 'White Wins';
|
|
||||||
break;
|
break;
|
||||||
case 'black wins':
|
case 'black wins':
|
||||||
status = 'black-wins';
|
status = 'black-wins';
|
||||||
tooltipText = 'Black Wins';
|
|
||||||
break;
|
break;
|
||||||
case 'stalemate':
|
case 'stalemate':
|
||||||
status = 'stalemate';
|
status = 'stalemate';
|
||||||
tooltipText = 'Stalemate';
|
|
||||||
break;
|
break;
|
||||||
case 'draw':
|
case 'draw':
|
||||||
status = 'draw';
|
status = 'draw';
|
||||||
tooltipText = 'Draw';
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
status = 'unknown';
|
status = 'unknown';
|
||||||
tooltipText = 'Game Over';
|
|
||||||
}
|
}
|
||||||
|
tooltipText = describeResult(state, gameState.termination);
|
||||||
} else if (state === 'stuck') {
|
} else if (state === 'stuck') {
|
||||||
status = 'degraded';
|
status = 'degraded';
|
||||||
tooltipText = 'Engine Error';
|
tooltipText = 'Engine Error';
|
||||||
@@ -705,6 +714,7 @@ function renderBoardFromFEN(fen) {
|
|||||||
s.textContent = '';
|
s.textContent = '';
|
||||||
s.classList.remove('white-piece', 'black-piece', 'mated-king');
|
s.classList.remove('white-piece', 'black-piece', 'mated-king');
|
||||||
delete s.dataset.pieceColor;
|
delete s.dataset.pieceColor;
|
||||||
|
delete s.dataset.pieceType;
|
||||||
});
|
});
|
||||||
|
|
||||||
let rank = 7, file = 0;
|
let rank = 7, file = 0;
|
||||||
@@ -765,11 +775,64 @@ function flashSquare(element, success = true) {
|
|||||||
setTimeout(() => element.classList.remove(className), 400);
|
setTimeout(() => element.classList.remove(className), 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A pawn stepping or capturing onto its last rank needs a promotion piece; UCI
|
||||||
|
// requires the suffix (e7e8q), and the server rejects the bare move. Other
|
||||||
|
// moves go to the server as-is, which remains the only legality check.
|
||||||
|
function isPromotionMove(from, to) {
|
||||||
|
const fromEl = document.querySelector(`[data-square="${from}"]`);
|
||||||
|
if (!fromEl || fromEl.dataset.pieceType !== 'p') return false;
|
||||||
|
const fileDelta = Math.abs(from.charCodeAt(0) - to.charCodeAt(0));
|
||||||
|
if (fileDelta > 1) return false;
|
||||||
|
const color = fromEl.dataset.pieceColor;
|
||||||
|
return (color === 'w' && from[1] === '7' && to[1] === '8') ||
|
||||||
|
(color === 'b' && from[1] === '2' && to[1] === '1');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolves with 'q', 'r', 'b' or 'n', or null when cancelled. Keyboard: Q/R/B/N
|
||||||
|
// pick, Escape cancels, Tab/Enter work on the focused button.
|
||||||
|
let promotionResolve = null;
|
||||||
|
|
||||||
|
function choosePromotion(color) {
|
||||||
|
const overlay = document.getElementById('promotion-overlay');
|
||||||
|
const choices = overlay.querySelector('.promotion-choices');
|
||||||
|
choices.classList.toggle('white', color === 'w');
|
||||||
|
choices.classList.toggle('black', color !== 'w');
|
||||||
|
overlay.classList.add('show');
|
||||||
|
document.addEventListener('keydown', handlePromotionKeydown);
|
||||||
|
overlay.querySelector('.promotion-choice[data-piece="q"]').focus();
|
||||||
|
return new Promise(resolve => { promotionResolve = resolve; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePromotion(piece) {
|
||||||
|
document.getElementById('promotion-overlay').classList.remove('show');
|
||||||
|
document.removeEventListener('keydown', handlePromotionKeydown);
|
||||||
|
const resolve = promotionResolve;
|
||||||
|
promotionResolve = null;
|
||||||
|
if (resolve) resolve(piece);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePromotionKeydown(e) {
|
||||||
|
const key = e.key.toLowerCase();
|
||||||
|
if (key === 'q' || key === 'r' || key === 'b' || key === 'n') {
|
||||||
|
e.preventDefault();
|
||||||
|
closePromotion(key);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
closePromotion(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleHumanMove(from, to) {
|
async function handleHumanMove(from, to) {
|
||||||
const move = from + to;
|
let move = from + to;
|
||||||
const fromEl = document.querySelector(`[data-square="${from}"]`);
|
const fromEl = document.querySelector(`[data-square="${from}"]`);
|
||||||
const toEl = document.querySelector(`[data-square="${to}"]`);
|
const toEl = document.querySelector(`[data-square="${to}"]`);
|
||||||
|
|
||||||
|
if (isPromotionMove(from, to)) {
|
||||||
|
const piece = await choosePromotion(fromEl.dataset.pieceColor);
|
||||||
|
if (!piece) return;
|
||||||
|
move += piece;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await authFetch(`${gameState.apiUrl}/api/games/${gameState.gameId}/moves`, {
|
const response = await authFetch(`${gameState.apiUrl}/api/games/${gameState.gameId}/moves`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -892,11 +955,13 @@ async function pollOnce() {
|
|||||||
function lockBoard() {
|
function lockBoard() {
|
||||||
gameState.isLocked = true;
|
gameState.isLocked = true;
|
||||||
updateTurnIndicator('pending', gameState.turn);
|
updateTurnIndicator('pending', gameState.turn);
|
||||||
|
updateGameControls();
|
||||||
}
|
}
|
||||||
|
|
||||||
function unlockBoard() {
|
function unlockBoard() {
|
||||||
gameState.isLocked = false;
|
gameState.isLocked = false;
|
||||||
updateTurnIndicator('', gameState.turn);
|
updateTurnIndicator(gameState.state, gameState.turn);
|
||||||
|
updateGameControls();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function undoMoves() {
|
async function undoMoves() {
|
||||||
@@ -993,9 +1058,11 @@ function renderMoveHistory(moves) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateGameDisplay(game) {
|
function updateGameDisplay(game) {
|
||||||
|
const wasOver = isGameOver(gameState.state);
|
||||||
gameState.fen = game.fen;
|
gameState.fen = game.fen;
|
||||||
gameState.turn = game.turn;
|
gameState.turn = game.turn;
|
||||||
gameState.state = game.state;
|
gameState.state = game.state;
|
||||||
|
gameState.termination = game.termination || '';
|
||||||
gameState.moveList = game.moves || [];
|
gameState.moveList = game.moves || [];
|
||||||
|
|
||||||
renderBoardFromFEN(game.fen);
|
renderBoardFromFEN(game.fen);
|
||||||
@@ -1026,10 +1093,131 @@ function updateGameDisplay(game) {
|
|||||||
// Update undo button
|
// Update undo button
|
||||||
document.getElementById('undo-btn').disabled = !game.moves || game.moves.length < 2;
|
document.getElementById('undo-btn').disabled = !game.moves || game.moves.length < 2;
|
||||||
|
|
||||||
// Handle checkmate visually
|
// Mark the mated king; a resignation leaves the board as it was
|
||||||
if (game.state === 'white wins' || game.state === 'black wins') {
|
if ((game.state === 'white wins' || game.state === 'black wins') &&
|
||||||
|
(game.termination || 'checkmate') === 'checkmate') {
|
||||||
markMatedKing(game);
|
markMatedKing(game);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateGameControls();
|
||||||
|
if (!wasOver && isGameOver(game.state)) {
|
||||||
|
flashMessage(describeResult(game.state, game.termination), 'info', 4000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// describeResult names a finished game's outcome and how it was reached.
|
||||||
|
function describeResult(state, termination) {
|
||||||
|
const winner = state === 'white wins' ? 'White' : 'Black';
|
||||||
|
const loser = state === 'white wins' ? 'Black' : 'White';
|
||||||
|
switch (termination) {
|
||||||
|
case 'resignation': return `${loser} resigned — ${winner} wins`;
|
||||||
|
case 'stalemate': return 'Stalemate — draw';
|
||||||
|
case 'insufficient_material': return 'Draw — insufficient material';
|
||||||
|
case 'threefold_repetition': return 'Draw — threefold repetition';
|
||||||
|
case 'fifty_move_rule': return 'Draw — fifty-move rule';
|
||||||
|
case 'agreement': return 'Draw agreed';
|
||||||
|
}
|
||||||
|
switch (state) {
|
||||||
|
case 'white wins':
|
||||||
|
case 'black wins': return `Checkmate — ${winner} wins`;
|
||||||
|
case 'stalemate': return 'Stalemate — draw';
|
||||||
|
case 'draw': return 'Draw';
|
||||||
|
}
|
||||||
|
return 'Game over';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw needs a game in progress with the board free; resigning only needs
|
||||||
|
// an unfinished game (also while the computer thinks or the engine is stuck).
|
||||||
|
function updateGameControls() {
|
||||||
|
const hasGame = !!gameState.gameId;
|
||||||
|
const idle = !gameState.isLocked && !gameState.actionBusy;
|
||||||
|
document.getElementById('draw-btn').disabled =
|
||||||
|
!hasGame || !idle || gameState.state !== 'ongoing';
|
||||||
|
document.getElementById('resign-btn').disabled =
|
||||||
|
!hasGame || gameState.actionBusy || isGameOver(gameState.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
function playerColor() {
|
||||||
|
return gameState.isPlayerWhite ? 'w' : 'b';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function offerDraw() {
|
||||||
|
if (gameState.actionBusy || !gameState.gameId) return;
|
||||||
|
gameState.actionBusy = true;
|
||||||
|
updateGameControls();
|
||||||
|
try {
|
||||||
|
const response = await authFetch(`${gameState.apiUrl}/api/games/${gameState.gameId}/draw`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'offer', color: playerColor() })
|
||||||
|
});
|
||||||
|
if (response.status === 409) {
|
||||||
|
flashErrorMessage('Make a move before offering again', 2500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
handleApiError('offer draw', null, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const game = await response.json();
|
||||||
|
if (game.drawOutcome === 'declined') {
|
||||||
|
flashMessage('Draw declined', 'info', 2500);
|
||||||
|
}
|
||||||
|
gameState.actionBusy = false;
|
||||||
|
updateGameDisplay(game);
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError('offer draw', error);
|
||||||
|
} finally {
|
||||||
|
gameState.actionBusy = false;
|
||||||
|
updateGameControls();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showResignModal() {
|
||||||
|
if (!gameState.gameId || isGameOver(gameState.state)) return;
|
||||||
|
document.getElementById('resign-overlay').classList.add('show');
|
||||||
|
document.addEventListener('keydown', handleResignKeydown);
|
||||||
|
document.getElementById('resign-cancel-btn').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideResignModal() {
|
||||||
|
document.getElementById('resign-overlay').classList.remove('show');
|
||||||
|
document.removeEventListener('keydown', handleResignKeydown);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResignKeydown(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
hideResignModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resignGame() {
|
||||||
|
hideResignModal();
|
||||||
|
if (gameState.actionBusy || !gameState.gameId) return;
|
||||||
|
gameState.actionBusy = true;
|
||||||
|
updateGameControls();
|
||||||
|
try {
|
||||||
|
const response = await authFetch(`${gameState.apiUrl}/api/games/${gameState.gameId}/resign`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ color: playerColor() })
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
handleApiError('resign', null, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const game = await response.json();
|
||||||
|
stopPolling(); // a computer move in flight is discarded by the server
|
||||||
|
gameState.isLocked = false;
|
||||||
|
gameState.actionBusy = false;
|
||||||
|
updateGameDisplay(game);
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError('resign', error);
|
||||||
|
} finally {
|
||||||
|
gameState.actionBusy = false;
|
||||||
|
updateGameControls();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function markMatedKing(game) {
|
function markMatedKing(game) {
|
||||||
@@ -1135,10 +1323,17 @@ function handleApiError(action, error, response = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function flashErrorMessage(message, duration = 1500) {
|
function flashErrorMessage(message, duration = 1500) {
|
||||||
|
flashMessage(message, 'error', duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shows a short message over the status indicators; 'info' for results and
|
||||||
|
// answers to draw offers, 'error' for failures.
|
||||||
|
function flashMessage(message, type = 'error', duration = 1500) {
|
||||||
const overlay = document.getElementById('error-flash-overlay');
|
const overlay = document.getElementById('error-flash-overlay');
|
||||||
const messageEl = document.getElementById('error-flash-message');
|
const messageEl = document.getElementById('error-flash-message');
|
||||||
|
|
||||||
messageEl.textContent = message;
|
messageEl.textContent = message;
|
||||||
|
messageEl.classList.toggle('info', type === 'info');
|
||||||
overlay.classList.add('show');
|
overlay.classList.add('show');
|
||||||
|
|
||||||
// Clear any pending timeout to avoid premature hide on rapid calls
|
// Clear any pending timeout to avoid premature hide on rapid calls
|
||||||
|
|||||||
@@ -48,6 +48,8 @@
|
|||||||
<div class="controls">
|
<div class="controls">
|
||||||
<button id="new-game-btn" class="btn btn-primary">New Game</button>
|
<button id="new-game-btn" class="btn btn-primary">New Game</button>
|
||||||
<button id="undo-btn" class="btn btn-secondary" disabled>Undo</button>
|
<button id="undo-btn" class="btn btn-secondary" disabled>Undo</button>
|
||||||
|
<button id="draw-btn" class="btn btn-secondary" title="Offer a draw" disabled>Draw</button>
|
||||||
|
<button id="resign-btn" class="btn btn-secondary" title="Resign this game" disabled>Resign</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="move-history-container">
|
<div class="move-history-container">
|
||||||
@@ -147,6 +149,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Resign Confirmation -->
|
||||||
|
<div id="resign-overlay" class="modal-overlay">
|
||||||
|
<div class="modal confirm-modal" role="dialog" aria-modal="true" aria-labelledby="resign-title">
|
||||||
|
<h2 id="resign-title">Resign this game?</h2>
|
||||||
|
<div class="modal-buttons">
|
||||||
|
<button id="resign-confirm-btn" class="btn btn-primary">Resign</button>
|
||||||
|
<button id="resign-cancel-btn" class="btn btn-secondary">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Promotion Modal -->
|
||||||
|
<div id="promotion-overlay" class="modal-overlay">
|
||||||
|
<div class="modal promotion-modal" role="dialog" aria-modal="true" aria-labelledby="promotion-title">
|
||||||
|
<h2 id="promotion-title">Promote to</h2>
|
||||||
|
<div class="promotion-choices">
|
||||||
|
<button class="promotion-choice" data-piece="q" title="Queen (Q)" aria-label="Queen">♛</button>
|
||||||
|
<button class="promotion-choice" data-piece="r" title="Rook (R)" aria-label="Rook">♜</button>
|
||||||
|
<button class="promotion-choice" data-piece="b" title="Bishop (B)" aria-label="Bishop">♝</button>
|
||||||
|
<button class="promotion-choice" data-piece="n" title="Knight (N)" aria-label="Knight">♞</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-buttons">
|
||||||
|
<button id="promotion-cancel-btn" class="btn btn-secondary">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -379,6 +379,10 @@ main {
|
|||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.error-flash-message.info {
|
||||||
|
color: var(--tokyo-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
/* Move History */
|
/* Move History */
|
||||||
.move-history-container {
|
.move-history-container {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
@@ -849,20 +853,21 @@ input[type="range"]:disabled {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Four controls as a 2x2 grid; the column is too short for one per row. */
|
||||||
.controls {
|
.controls {
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
grid-row: 2 / 4;
|
grid-row: 2 / 4;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
grid-template-rows: 1fr 1fr;
|
grid-template-rows: 1fr 1fr;
|
||||||
gap: 0.5rem;
|
gap: 0.4rem;
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
height: auto;
|
height: auto;
|
||||||
min-height: 80px;
|
min-height: 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
padding: 0.5rem;
|
padding: 0.4rem 0.25rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.8rem;
|
||||||
min-height: 35px;
|
min-height: 35px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -907,3 +912,55 @@ input[type="range"]:disabled {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Promotion picker */
|
||||||
|
.promotion-modal {
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promotion-choices {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promotion-choice {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 36px;
|
||||||
|
line-height: 1;
|
||||||
|
background: var(--square-light);
|
||||||
|
border: 2px solid var(--tokyo-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s, background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promotion-choice:hover,
|
||||||
|
.promotion-choice:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--host-royal);
|
||||||
|
background: var(--square-selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
.promotion-choices.white .promotion-choice {
|
||||||
|
color: var(--host-white);
|
||||||
|
text-shadow: 1px 1px 2px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.promotion-choices.black .promotion-choice {
|
||||||
|
color: var(--host-bg);
|
||||||
|
text-shadow: 1px 1px 2px rgba(255,255,255,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Resign confirmation */
|
||||||
|
.confirm-modal {
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-modal h2 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
@@ -80,6 +80,9 @@ curl -X POST http://localhost:8080/api/games \
|
|||||||
| API Functionality | `test-api.sh` | Game operations, moves, undo, rate limiting |
|
| API Functionality | `test-api.sh` | Game operations, moves, undo, rate limiting |
|
||||||
| Database & Auth | `test-db.sh` | User registration, login, JWT tokens, persistence |
|
| Database & Auth | `test-db.sh` | User registration, login, JWT tokens, persistence |
|
||||||
| Long-Polling | `test-longpoll.sh` | Real-time updates, wait behavior, timeouts |
|
| Long-Polling | `test-longpoll.sh` | Real-time updates, wait behavior, timeouts |
|
||||||
|
| End States | `test-endstate.sh` | Checkmate, stalemate, terminal custom positions |
|
||||||
|
| Replay | `test-replay.sh` | SAN history, PGN export, ETag/304, promotion, FEN validation, cursor-paged listing |
|
||||||
|
| Draws & Resignation | `test-draw.sh` | Automatic draws, offers (human and computer), resignation and authorization, long-poll wake-up, hand-deleted game row |
|
||||||
| Test Server | `run-test-server.sh` | Pre-populated test environment |
|
| Test Server | `run-test-server.sh` | Pre-populated test environment |
|
||||||
|
|
||||||
## 1. API Functionality Tests (`test-api.sh`)
|
## 1. API Functionality Tests (`test-api.sh`)
|
||||||
@@ -167,3 +170,44 @@ test/test-longpoll.sh
|
|||||||
3. **Timeout**: Verify 25-second timeout with valid response
|
3. **Timeout**: Verify 25-second timeout with valid response
|
||||||
4. **Skip Wait**: Immediate return when moveCount outdated
|
4. **Skip Wait**: Immediate return when moveCount outdated
|
||||||
5. **Disconnection**: Proper cleanup on client disconnect
|
5. **Disconnection**: Proper cleanup on client disconnect
|
||||||
|
|
||||||
|
## Replay Tests (`test-replay.sh`)
|
||||||
|
|
||||||
|
Exercises the replay API over HTTP with human-vs-human games, so no engine
|
||||||
|
search is involved. Each run registers a fresh account, so it can be repeated
|
||||||
|
against the same server.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1
|
||||||
|
test/run-test-server.sh bin/chess-server
|
||||||
|
# Terminal 2
|
||||||
|
test/test-replay.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Covers SAN and results in the history, the PGN attachment (tag order,
|
||||||
|
movetext, `?ply=`), `ETag`/`If-None-Match`, castling and en passant notation,
|
||||||
|
promotion (a missing piece is rejected, `g8=N` from the custom position that
|
||||||
|
reported the web-client bug), rejected starting FENs, and cursor paging with
|
||||||
|
`status`/`color` filters. `API_DELAY` (seconds, default `0.06`) keeps it under
|
||||||
|
the dev-mode rate limit.
|
||||||
|
|
||||||
|
## Draw and Resignation Tests (`test-draw.sh`)
|
||||||
|
|
||||||
|
Plays short human-vs-human lines, switching a side to the computer where the
|
||||||
|
computer's answer to a draw offer is tested, so the only engine work is two
|
||||||
|
short evaluations. Each run registers a fresh account and can be repeated
|
||||||
|
against the same server.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
test/test-draw.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Covers the automatic draws (K+B v K after a capture, a knight shuffle
|
||||||
|
repeated three times, the hundredth quiet ply, a drawn starting position),
|
||||||
|
undo after a draw, resignation (side inference, hot-seat ambiguity, a side
|
||||||
|
claimed by another user), draw offers between humans (standing offer,
|
||||||
|
decline, one offer per move, acceptance waking a long-poll, lapse on the
|
||||||
|
opponent's move), the computer declining early, accepting in a level
|
||||||
|
position and declining when a queen up, and a game whose row is deleted with
|
||||||
|
psql (needs `CHESS_TEST_DSN` and `psql`; the server unloads the game and
|
||||||
|
stays healthy).
|
||||||
Executable
+214
@@ -0,0 +1,214 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Draw and resignation test suite: automatic draws (dead material, threefold
|
||||||
|
# repetition, fifty-move rule), draw offers between humans and to the
|
||||||
|
# computer, resignation with slot authorization, undo after a result,
|
||||||
|
# long-poll wake-up on offers, and a game whose row is deleted by hand.
|
||||||
|
# Requires: curl, jq, and a server from test/run-test-server.sh; psql and
|
||||||
|
# CHESS_TEST_DSN for the deleted-row case (skipped without them).
|
||||||
|
|
||||||
|
BASE_URL=${BASE_URL:-"http://localhost:8080"}
|
||||||
|
API_URL="${BASE_URL}/api"
|
||||||
|
# Pause between requests; the dev-mode limiter allows 20 per second.
|
||||||
|
API_DELAY=${API_DELAY:-0.06}
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
TMP=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
|
||||||
|
section() { echo -e "\n${CYAN}== $1 ==${NC}"; }
|
||||||
|
ok() { echo -e "${GREEN} ✓ $1${NC}"; ((PASS++)); }
|
||||||
|
bad() { echo -e "${RED} ✗ $1${NC}"; ((FAIL++)); }
|
||||||
|
skip() { echo -e "${YELLOW} - $1${NC}"; }
|
||||||
|
|
||||||
|
check() { # check <description> <expected> <actual>
|
||||||
|
if [ "$2" = "$3" ]; then ok "$1"; else bad "$1: expected '$2', got '$3'"; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# request <method> <path> [curl args...]: body to $TMP/body, status printed.
|
||||||
|
request() {
|
||||||
|
local method=$1 path=$2
|
||||||
|
shift 2
|
||||||
|
sleep "$API_DELAY"
|
||||||
|
: >"$TMP/body"
|
||||||
|
curl -s -o "$TMP/body" -w '%{http_code}' -X "$method" "$@" "$API_URL$path"
|
||||||
|
}
|
||||||
|
|
||||||
|
json() { jq -r "$1" "$TMP/body"; }
|
||||||
|
|
||||||
|
# movetext prints the PGN movetext in $TMP/body on one line (PGN wraps at 80).
|
||||||
|
movetext() { sed -n '/^$/,$p' "$TMP/body" | sed '/^$/d' | tr '\n' ' ' | sed 's/ $//'; }
|
||||||
|
|
||||||
|
post() { # post <path> <json> [token]
|
||||||
|
local auth=()
|
||||||
|
[ -n "$3" ] && auth=(-H "Authorization: Bearer $3")
|
||||||
|
request POST "$1" -H 'Content-Type: application/json' "${auth[@]}" -d "$2"
|
||||||
|
}
|
||||||
|
|
||||||
|
new_game() { # new_game <white type> <black type> [fen]: prints the game ID
|
||||||
|
local body
|
||||||
|
body=$(jq -nc --argjson w "$1" --argjson b "$2" --arg fen "${3:-}" \
|
||||||
|
'{white:{type:$w}, black:{type:$b, level:5, searchTime:100}} + (if $fen == "" then {} else {fen:$fen} end)')
|
||||||
|
post /games "$body" >/dev/null
|
||||||
|
json .gameId
|
||||||
|
}
|
||||||
|
|
||||||
|
play() { # play <gameId> <token|""> <uci>...
|
||||||
|
local id=$1 token=$2 status
|
||||||
|
shift 2
|
||||||
|
for move in "$@"; do
|
||||||
|
status=$(post "/games/$id/moves" "{\"move\":\"$move\"}" "$token")
|
||||||
|
if [ "$status" != 200 ]; then
|
||||||
|
bad "move $move: HTTP $status $(cat "$TMP/body")"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
register() { # register <user> <password>: prints the session token
|
||||||
|
post /auth/register "{\"username\":\"$1\",\"password\":\"$2\"}" >/dev/null
|
||||||
|
json .token
|
||||||
|
}
|
||||||
|
|
||||||
|
if ! curl -sf "$BASE_URL/health" >/dev/null; then
|
||||||
|
echo -e "${RED}Server not reachable at $BASE_URL; start test/run-test-server.sh${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SYMMETRIC="g1f3 g8f6 b1c3 b8c6 e2e3 e7e6 d2d3 d7d6 f1e2 f8e7 c1d2 c8d7 e1g1 e8g8 a2a3 a7a6 h2h3 h7h6 a1b1 a8b8"
|
||||||
|
QUEEN_UP="e2e4 e7e5 g1f3 d8h4 f3h4 b8c6 h4f3 g8f6 b1c3 f8c5 f1e2 d7d6 e1g1 e8g8 d2d3 c8e6 c1e3 c5e3 f2e3 h7h6"
|
||||||
|
|
||||||
|
section "1. Automatic draws"
|
||||||
|
GAME=$(new_game 1 1 "4k3/8/8/8/8/8/3r4/4KB2 w - - 0 1")
|
||||||
|
play "$GAME" "" e1d2
|
||||||
|
check "Kxd2 leaves K+B v K: state" "draw" "$(json .state)"
|
||||||
|
check "termination" "insufficient_material" "$(json .termination)"
|
||||||
|
check "no move after the draw" 400 "$(post "/games/$GAME/moves" '{"move":"e8d8"}')"
|
||||||
|
check "error code" "GAME_OVER" "$(json .code)"
|
||||||
|
|
||||||
|
GAME=$(new_game 1 1)
|
||||||
|
play "$GAME" "" g1f3 g8f6 f3g1 f6g8 g1f3 g8f6 f3g1
|
||||||
|
check "second occurrence plays on" "ongoing" "$(json .state)"
|
||||||
|
play "$GAME" "" f6g8
|
||||||
|
check "third occurrence: threefold" "threefold_repetition" "$(json .termination)"
|
||||||
|
check "history termination" "threefold_repetition" \
|
||||||
|
"$(request GET "/games/$GAME/history" >/dev/null; json .termination)"
|
||||||
|
check "PGN comment" "1. Nf3 Nf6 2. Ng1 Ng8 3. Nf3 Nf6 4. Ng1 Ng8 { Draw by threefold repetition. } 1/2-1/2" \
|
||||||
|
"$(request GET "/games/$GAME/pgn" >/dev/null; movetext)"
|
||||||
|
check "undo reopens the game" 200 "$(post "/games/$GAME/undo" '{"count":1}')"
|
||||||
|
check "state after undo" "ongoing/null" "$(json '.state + "/" + (.termination // "null")')"
|
||||||
|
|
||||||
|
GAME=$(new_game 1 1 "4k3/8/8/8/8/8/8/R3K3 w - - 99 80")
|
||||||
|
play "$GAME" "" a1a2
|
||||||
|
check "hundredth quiet ply: fifty-move rule" "draw/fifty_move_rule" "$(json '.state + "/" + .termination')"
|
||||||
|
|
||||||
|
post /games '{"white":{"type":1},"black":{"type":1},"fen":"4k3/8/8/8/8/8/8/4K3 w - - 0 1"}' >/dev/null
|
||||||
|
check "bare kings are drawn at creation" "draw/insufficient_material" "$(json '.state + "/" + .termination')"
|
||||||
|
|
||||||
|
section "2. Resignation"
|
||||||
|
GAME=$(new_game 1 2)
|
||||||
|
play "$GAME" "" e2e4
|
||||||
|
check "resign infers the one human side" 200 "$(post "/games/$GAME/resign" '{}')"
|
||||||
|
check "result" "black wins/resignation" "$(json '.state + "/" + .termination')"
|
||||||
|
check "resigning twice" 400 "$(post "/games/$GAME/resign" '{}')"
|
||||||
|
check "PGN comment" "1. e4 { Black wins by resignation. } 0-1" \
|
||||||
|
"$(request GET "/games/$GAME/pgn" >/dev/null; movetext)"
|
||||||
|
|
||||||
|
GAME=$(new_game 1 1)
|
||||||
|
check "hot seat needs a color" 400 "$(post "/games/$GAME/resign" '{}')"
|
||||||
|
check "bad color" 400 "$(post "/games/$GAME/resign" '{"color":"red"}')"
|
||||||
|
|
||||||
|
GAME=$(new_game 2 2)
|
||||||
|
check "computer-only game cannot resign" 400 "$(post "/games/$GAME/resign" '{}')"
|
||||||
|
|
||||||
|
PLAYER="draw$(date +%s)$RANDOM"
|
||||||
|
TOKEN=$(register "$PLAYER" DrawPass1234)
|
||||||
|
[ -n "$TOKEN" ] && [ "$TOKEN" != null ] && ok "$PLAYER registered" || bad "register: $(cat "$TMP/body")"
|
||||||
|
GAME=$(new_game 1 1)
|
||||||
|
play "$GAME" "$TOKEN" e2e4 # claims white
|
||||||
|
check "anonymous caller cannot resign a claimed side" 403 "$(post "/games/$GAME/resign" '{"color":"w"}')"
|
||||||
|
check "claimant resigns without naming the side" 200 "$(post "/games/$GAME/resign" '{}' "$TOKEN")"
|
||||||
|
check "claimant's side lost" "black wins" "$(json .state)"
|
||||||
|
check "game listed for the claimant" "0-1" "$(request GET "/users/me/games?limit=1" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" >/dev/null; json '.games[0].pgnResult')"
|
||||||
|
|
||||||
|
section "3. Draw offers between humans"
|
||||||
|
GAME=$(new_game 1 1)
|
||||||
|
play "$GAME" "" e2e4
|
||||||
|
check "black offers" "offered/b" "$(post "/games/$GAME/draw" '{"action":"offer","color":"b"}' >/dev/null; json '.drawOutcome + "/" + .drawOffer')"
|
||||||
|
check "repeating a standing offer" "offered" "$(post "/games/$GAME/draw" '{"action":"offer","color":"b"}' >/dev/null; json .drawOutcome)"
|
||||||
|
check "the offerer cannot accept" 400 "$(post "/games/$GAME/draw" '{"action":"accept","color":"b"}')"
|
||||||
|
check "white declines" "declined/null" "$(post "/games/$GAME/draw" '{"action":"decline","color":"w"}' >/dev/null; json '.drawOutcome + "/" + (.drawOffer // "null")')"
|
||||||
|
check "second offer in one move" 409 "$(post "/games/$GAME/draw" '{"action":"offer","color":"b"}')"
|
||||||
|
play "$GAME" "" e7e5 g1f3
|
||||||
|
post "/games/$GAME/draw" '{"action":"offer","color":"b"}' >/dev/null
|
||||||
|
check "offer allowed after a move" "b" "$(json .drawOffer)"
|
||||||
|
|
||||||
|
# A waiting client wakes up when the offer is answered.
|
||||||
|
MOVES=$(json '.moves | length')
|
||||||
|
START=$(date +%s)
|
||||||
|
curl -s -o "$TMP/poll" "$API_URL/games/$GAME?wait=true&moveCount=$MOVES" &
|
||||||
|
POLL=$!
|
||||||
|
sleep 0.5
|
||||||
|
post "/games/$GAME/draw" '{"action":"accept","color":"w"}' >/dev/null
|
||||||
|
check "accepting ends the game" "draw/agreement" "$(json '.state + "/" + .termination')"
|
||||||
|
wait "$POLL"
|
||||||
|
ELAPSED=$(($(date +%s) - START))
|
||||||
|
[ "$ELAPSED" -lt 10 ] && ok "long-poll woke after ${ELAPSED}s" || bad "long-poll waited ${ELAPSED}s"
|
||||||
|
check "long-poll saw the result" "draw" "$(jq -r .state "$TMP/poll")"
|
||||||
|
|
||||||
|
GAME=$(new_game 1 1)
|
||||||
|
play "$GAME" "" e2e4
|
||||||
|
post "/games/$GAME/draw" '{"action":"offer","color":"w"}' >/dev/null
|
||||||
|
play "$GAME" "" e7e5
|
||||||
|
check "moving instead of answering declines" "null" "$(request GET "/games/$GAME" >/dev/null; json '.drawOffer // "null"')"
|
||||||
|
check "accepting a lapsed offer" 400 "$(post "/games/$GAME/draw" '{"action":"accept","color":"b"}')"
|
||||||
|
check "unknown action" 400 "$(post "/games/$GAME/draw" '{"action":"maybe","color":"w"}')"
|
||||||
|
|
||||||
|
section "4. Draw offers to the computer"
|
||||||
|
GAME=$(new_game 1 2)
|
||||||
|
check "declined before move 10" "declined" "$(post "/games/$GAME/draw" '{"action":"offer"}' >/dev/null; json .drawOutcome)"
|
||||||
|
check "one offer per move" 409 "$(post "/games/$GAME/draw" '{"action":"offer"}')"
|
||||||
|
|
||||||
|
# Level position: play 20 plies as humans, then hand Black to the computer.
|
||||||
|
GAME=$(new_game 1 1)
|
||||||
|
play "$GAME" "" $SYMMETRIC
|
||||||
|
request PUT "/games/$GAME/players" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"white":{"type":1},"black":{"type":2,"level":5,"searchTime":100}}' >/dev/null
|
||||||
|
check "computer accepts in a level position" "accepted/draw/agreement" \
|
||||||
|
"$(post "/games/$GAME/draw" '{"action":"offer"}' >/dev/null; json '.drawOutcome + "/" + .state + "/" + .termination')"
|
||||||
|
|
||||||
|
# White a queen up: the computer playing White declines.
|
||||||
|
GAME=$(new_game 1 1)
|
||||||
|
play "$GAME" "" $QUEEN_UP
|
||||||
|
request PUT "/games/$GAME/players" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"white":{"type":2,"level":5,"searchTime":100},"black":{"type":1}}' >/dev/null
|
||||||
|
check "computer a queen up declines" "declined/ongoing" \
|
||||||
|
"$(post "/games/$GAME/draw" '{"action":"offer"}' >/dev/null; json '.drawOutcome + "/" + .state')"
|
||||||
|
|
||||||
|
section "5. A game whose row is deleted by hand"
|
||||||
|
if [ -z "$CHESS_TEST_DSN" ] || ! command -v psql >/dev/null; then
|
||||||
|
skip "CHESS_TEST_DSN or psql missing; skipped"
|
||||||
|
else
|
||||||
|
GAME=$(new_game 1 1)
|
||||||
|
play "$GAME" "" e2e4
|
||||||
|
request GET "/games/$GAME/history" >/dev/null # flush the writes
|
||||||
|
psql "$CHESS_TEST_DSN" -qAtc "DELETE FROM games WHERE game_id = '$GAME'" >/dev/null
|
||||||
|
check "move after the deletion is accepted in memory" 200 "$(post "/games/$GAME/moves" '{"move":"e7e5"}')"
|
||||||
|
for _ in $(seq 1 20); do
|
||||||
|
[ "$(request GET "/games/$GAME")" = 404 ] && break
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
check "the game is unloaded" 404 "$(request GET "/games/$GAME")"
|
||||||
|
check "storage stays healthy" "ok" "$(curl -s "$BASE_URL/health" | jq -r .storage)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "\n${CYAN}Results: ${GREEN}$PASS passed${NC}, ${RED}$FAIL failed${NC}"
|
||||||
|
[ "$FAIL" -eq 0 ]
|
||||||
Executable
+193
@@ -0,0 +1,193 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Replay API test suite: SAN in history, PGN export, ETag revalidation,
|
||||||
|
# promotion handling, FEN validation, and the cursor-paged game listing.
|
||||||
|
# Requires: curl, jq, and a server from test/run-test-server.sh. Games are
|
||||||
|
# played human-vs-human, so no engine search is involved.
|
||||||
|
|
||||||
|
BASE_URL=${BASE_URL:-"http://localhost:8080"}
|
||||||
|
API_URL="${BASE_URL}/api"
|
||||||
|
# Pause between requests; the dev-mode limiter allows 20 per second.
|
||||||
|
API_DELAY=${API_DELAY:-0.06}
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
TMP=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
|
||||||
|
section() { echo -e "\n${CYAN}== $1 ==${NC}"; }
|
||||||
|
ok() { echo -e "${GREEN} ✓ $1${NC}"; ((PASS++)); }
|
||||||
|
bad() { echo -e "${RED} ✗ $1${NC}"; ((FAIL++)); }
|
||||||
|
|
||||||
|
check() { # check <description> <expected> <actual>
|
||||||
|
if [ "$2" = "$3" ]; then ok "$1"; else bad "$1: expected '$2', got '$3'"; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# request <method> <path> [curl args...]: body to $TMP/body, headers to
|
||||||
|
# $TMP/headers, prints the status code.
|
||||||
|
request() {
|
||||||
|
local method=$1 path=$2
|
||||||
|
shift 2
|
||||||
|
sleep "$API_DELAY"
|
||||||
|
: >"$TMP/body" # curl leaves the file untouched when there is no body (304)
|
||||||
|
curl -s -o "$TMP/body" -D "$TMP/headers" -w '%{http_code}' -X "$method" "$@" "$API_URL$path"
|
||||||
|
}
|
||||||
|
|
||||||
|
header() { grep -i "^$1:" "$TMP/headers" | head -1 | cut -d' ' -f2- | tr -d '\r'; }
|
||||||
|
|
||||||
|
new_game() { # new_game [fen] [token]
|
||||||
|
local body='{"white":{"type":1},"black":{"type":1}}'
|
||||||
|
[ -n "$1" ] && body=$(jq -nc --arg fen "$1" '{white:{type:1},black:{type:1},fen:$fen}')
|
||||||
|
local auth=()
|
||||||
|
[ -n "$2" ] && auth=(-H "Authorization: Bearer $2")
|
||||||
|
request POST /games -H 'Content-Type: application/json' "${auth[@]}" -d "$body" >/dev/null
|
||||||
|
jq -r '.gameId' "$TMP/body"
|
||||||
|
}
|
||||||
|
|
||||||
|
play() { # play <gameId> <token|""> <uci>...
|
||||||
|
local id=$1 token=$2 status
|
||||||
|
shift 2
|
||||||
|
local auth=()
|
||||||
|
[ -n "$token" ] && auth=(-H "Authorization: Bearer $token")
|
||||||
|
for move in "$@"; do
|
||||||
|
status=$(request POST "/games/$id/moves" -H 'Content-Type: application/json' "${auth[@]}" \
|
||||||
|
-d "{\"move\":\"$move\"}")
|
||||||
|
if [ "$status" != 200 ]; then
|
||||||
|
bad "move $move: HTTP $status $(cat "$TMP/body")"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
register() { # register <user> <password>: prints the session token
|
||||||
|
request POST /auth/register -H 'Content-Type: application/json' \
|
||||||
|
-d "{\"username\":\"$1\",\"password\":\"$2\"}" >/dev/null
|
||||||
|
jq -r '.token' "$TMP/body"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ! curl -sf "$BASE_URL/health" >/dev/null; then
|
||||||
|
echo -e "${RED}Server not reachable at $BASE_URL; start test/run-test-server.sh${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
section "1. Checkmate: SAN, result, termination"
|
||||||
|
GAME=$(new_game)
|
||||||
|
play "$GAME" "" f2f3 e7e5 g2g4 d8h4
|
||||||
|
check "history status" 200 "$(request GET "/games/$GAME/history")"
|
||||||
|
check "SAN line" "f3 e5 g4 Qh4#" "$(jq -r '[.moves[].san] | join(" ")' "$TMP/body")"
|
||||||
|
check "pgnResult" "0-1" "$(jq -r .pgnResult "$TMP/body")"
|
||||||
|
check "termination" "checkmate" "$(jq -r .termination "$TMP/body")"
|
||||||
|
check "UCI kept" "d8h4" "$(jq -r '.moves[3].moveUci' "$TMP/body")"
|
||||||
|
|
||||||
|
section "2. ETag revalidation"
|
||||||
|
request GET "/games/$GAME/history" >/dev/null
|
||||||
|
ETAG=$(header ETag)
|
||||||
|
[ -n "$ETAG" ] && ok "ETag present ($ETAG)" || bad "ETag missing"
|
||||||
|
check "Cache-Control" "private, no-cache" "$(header Cache-Control)"
|
||||||
|
check "If-None-Match -> 304" 304 "$(request GET "/games/$GAME/history" -H "If-None-Match: $ETAG")"
|
||||||
|
check "304 has no body" 0 "$(wc -c <"$TMP/body" | tr -d ' ')"
|
||||||
|
check "stale ETag -> 200" 200 "$(request GET "/games/$GAME/history" -H 'If-None-Match: "stale"')"
|
||||||
|
|
||||||
|
section "3. PGN export"
|
||||||
|
check "pgn status" 200 "$(request GET "/games/$GAME/pgn")"
|
||||||
|
check "content type" "application/x-chess-pgn; charset=utf-8" "$(header Content-Type)"
|
||||||
|
case "$(header Content-Disposition)" in
|
||||||
|
"attachment; filename=\"chess-"*"-${GAME:0:8}.pgn\"") ok "attachment filename" ;;
|
||||||
|
*) bad "Content-Disposition: $(header Content-Disposition)" ;;
|
||||||
|
esac
|
||||||
|
check "seven tag roster order" "Event Site Date Round White Black Result" \
|
||||||
|
"$(head -7 "$TMP/body" | sed -E 's/^\[([A-Za-z]+) .*/\1/' | tr '\n' ' ' | sed 's/ $//')"
|
||||||
|
check "movetext" "1. f3 e5 2. g4 Qh4# { Black wins by checkmate. } 0-1" "$(sed -n '/^$/,$p' "$TMP/body" | sed '/^$/d')"
|
||||||
|
grep -q '^\[Termination "normal"\]$' "$TMP/body" && ok "Termination tag" || bad "Termination tag missing"
|
||||||
|
PGN_ETAG=$(header ETag)
|
||||||
|
check "pgn 304" 304 "$(request GET "/games/$GAME/pgn" -H "If-None-Match: $PGN_ETAG")"
|
||||||
|
check "pgn ?ply=2" 200 "$(request GET "/games/$GAME/pgn?ply=2")"
|
||||||
|
check "truncated movetext" "1. f3 e5 *" "$(sed -n '/^$/,$p' "$TMP/body" | sed '/^$/d')"
|
||||||
|
check "?ply=5 rejected" 400 "$(request GET "/games/$GAME/pgn?ply=5")"
|
||||||
|
check "?ply=-1 rejected" 400 "$(request GET "/games/$GAME/pgn?ply=-1")"
|
||||||
|
check "unknown game" 404 "$(request GET "/games/00000000-0000-4000-8000-000000000000/pgn")"
|
||||||
|
check "malformed ID" 400 "$(request GET "/games/not-a-uuid/pgn")"
|
||||||
|
|
||||||
|
section "4. Castling and en passant"
|
||||||
|
GAME=$(new_game)
|
||||||
|
play "$GAME" "" e2e4 d7d5 e4e5 f7f5 e5f6 g8f6 g1f3 e7e6 f1e2 f8d6 e1g1 e8g8
|
||||||
|
request GET "/games/$GAME/history" >/dev/null
|
||||||
|
check "SAN line" "e4 d5 e5 f5 exf6 Nxf6 Nf3 e6 Be2 Bd6 O-O O-O" \
|
||||||
|
"$(jq -r '[.moves[].san] | join(" ")' "$TMP/body")"
|
||||||
|
check "ongoing pgnResult" "*" "$(jq -r .pgnResult "$TMP/body")"
|
||||||
|
check "no termination while ongoing" "null" "$(jq -r .termination "$TMP/body")"
|
||||||
|
|
||||||
|
section "5. Promotion (the reported position)"
|
||||||
|
FEN="8/5KP1/5n2/p3k3/1p3p2/1P6/P7/8 w - - 4 55"
|
||||||
|
GAME=$(new_game "$FEN")
|
||||||
|
[ "$GAME" != null ] && ok "custom FEN game created" || bad "custom FEN game: $(cat "$TMP/body")"
|
||||||
|
check "g7g8 without piece rejected" 400 \
|
||||||
|
"$(request POST "/games/$GAME/moves" -H 'Content-Type: application/json' -d '{"move":"g7g8"}')"
|
||||||
|
case "$(jq -r .error "$TMP/body")" in
|
||||||
|
"promotion piece required"*) ok "error names the missing piece" ;;
|
||||||
|
*) bad "error: $(cat "$TMP/body")" ;;
|
||||||
|
esac
|
||||||
|
play "$GAME" "" g7g8n && ok "g7g8n accepted"
|
||||||
|
request GET "/games/$GAME/history" >/dev/null
|
||||||
|
check "promotion SAN" "g8=N" "$(jq -r '.moves[0].san' "$TMP/body")"
|
||||||
|
check "knight on g8" "6N1/5K2/5n2/p3k3/1p3p2/1P6/P7/8 b - - 0 55" "$(jq -r '.moves[0].fenAfterMove' "$TMP/body")"
|
||||||
|
request GET "/games/$GAME/pgn" >/dev/null
|
||||||
|
grep -q '^\[SetUp "1"\]$' "$TMP/body" && grep -q "^\[FEN \"$FEN\"\]$" "$TMP/body" \
|
||||||
|
&& ok "SetUp/FEN tags" || bad "SetUp/FEN tags missing"
|
||||||
|
check "numbering from move 55" "55. g8=N *" "$(sed -n '/^$/,$p' "$TMP/body" | sed '/^$/d')"
|
||||||
|
|
||||||
|
section "6. Invalid starting positions"
|
||||||
|
for fen in "8/8/8/8/8/8/8/8 w - - 0 1" "4k3/8/8/8/8/8/8/4K2r b - - 0 1" "4k3/8/8/8/8/8/8/P3K3 w - - 0 1"; do
|
||||||
|
status=$(request POST /games -H 'Content-Type: application/json' \
|
||||||
|
-d "$(jq -nc --arg fen "$fen" '{white:{type:1},black:{type:1},fen:$fen}')")
|
||||||
|
check "rejects $fen ($(jq -r .error "$TMP/body"))" 400 "$status"
|
||||||
|
done
|
||||||
|
|
||||||
|
section "7. My games: cursor paging and filters"
|
||||||
|
# A fresh account per run, so games from earlier runs do not interfere.
|
||||||
|
PLAYER="replay$(date +%s)$RANDOM"
|
||||||
|
TOKEN=$(register "$PLAYER" ReplayPass123)
|
||||||
|
[ -n "$TOKEN" ] && [ "$TOKEN" != null ] && ok "$PLAYER registered" || bad "register failed: $(cat "$TMP/body")"
|
||||||
|
IDS=()
|
||||||
|
for i in 1 2 3; do
|
||||||
|
GAME=$(new_game "" "$TOKEN")
|
||||||
|
play "$GAME" "$TOKEN" e2e4
|
||||||
|
IDS=("$GAME" "${IDS[@]}")
|
||||||
|
done
|
||||||
|
# The player takes White only; Black's moves are anonymous and leave that
|
||||||
|
# slot unclaimed.
|
||||||
|
GAME=$(new_game "" "$TOKEN")
|
||||||
|
play "$GAME" "$TOKEN" f2f3 && play "$GAME" "" e7e5 && play "$GAME" "$TOKEN" g2g4 && play "$GAME" "" d8h4
|
||||||
|
IDS=("$GAME" "${IDS[@]}")
|
||||||
|
|
||||||
|
SEEN=()
|
||||||
|
CURSOR=""
|
||||||
|
for page in 1 2 3; do
|
||||||
|
path="/users/me/games?limit=2"
|
||||||
|
[ -n "$CURSOR" ] && path="$path&cursor=$CURSOR"
|
||||||
|
check "page $page status" 200 "$(request GET "$path" -H "Authorization: Bearer $TOKEN")"
|
||||||
|
mapfile -t PAGE < <(jq -r '.games[].gameId' "$TMP/body")
|
||||||
|
SEEN+=("${PAGE[@]}")
|
||||||
|
CURSOR=$(jq -r '.nextCursor // empty' "$TMP/body")
|
||||||
|
[ -z "$CURSOR" ] && break
|
||||||
|
done
|
||||||
|
check "cursor pages cover the games newest first" "${IDS[*]}" "${SEEN[*]}"
|
||||||
|
check "finished filter" "${IDS[0]}" "$(request GET "/users/me/games?status=finished" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" >/dev/null; jq -r '[.games[].gameId] | join(" ")' "$TMP/body")"
|
||||||
|
check "summary pgnResult" "0-1" "$(jq -r '.games[0].pgnResult' "$TMP/body")"
|
||||||
|
check "black filter (White only)" 0 "$(request GET "/users/me/games?color=black" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" >/dev/null; jq '.games | length' "$TMP/body")"
|
||||||
|
check "bad cursor" 400 "$(request GET "/users/me/games?cursor=@@" -H "Authorization: Bearer $TOKEN")"
|
||||||
|
check "cursor with offset" 400 "$(request GET "/users/me/games?offset=2&cursor=$(jq -rn '"x"')" \
|
||||||
|
-H "Authorization: Bearer $TOKEN")"
|
||||||
|
check "bad status" 400 "$(request GET "/users/me/games?status=paused" -H "Authorization: Bearer $TOKEN")"
|
||||||
|
check "listing needs auth" 401 "$(request GET "/users/me/games")"
|
||||||
|
|
||||||
|
echo -e "\n${CYAN}Results: ${GREEN}$PASS passed${NC}, ${RED}$FAIL failed${NC}"
|
||||||
|
[ "$FAIL" -eq 0 ]
|
||||||
Reference in new issue
Block a user