Compare commits

..
15 Commits
Author SHA256 Message Date
lixen 99b37b5456 v0.9.1 web ui fixes and improvements 2026-02-25 10:22:24 -05:00
lixen f9630dea3b v0.9.1 web ui fixes and improvements 2026-02-25 10:19:52 -05:00
lixen 820ad7eb27 v0.9.0 user and session management improvement, xterm.js addons 2026-02-07 15:37:52 -05:00
lixen 0a85cc88bb v0.8.1 web ui responsive style improved 2025-11-19 08:43:12 -05:00
lixen ef60cfaac5 v0.8.0 wasm client added, cli client and makefile updated 2025-11-14 05:57:37 -05:00
lixen 2a2e82a162 v0.7.1 client readline removed for cross-platform compatibility with wasm, client logic fix fixes and refactor 2025-11-13 14:37:44 -05:00
lixen 6bdc061508 v0.7.0 cli client with readline added, directory structure updated 2025-11-13 08:55:06 -05:00
lixen 52868af4ea v0.6.0 multi-user game support with longpoll, tests and doc updated 2025-11-05 12:08:18 -05:00
lixen a3f4db96fa v0.5.0 user support with auth added, tests and doc updated 2025-11-05 02:56:41 -05:00
lixen 59486bfe32 v0.4.1 web ui improved, docs updated 2025-11-01 16:19:57 -04:00
lixen 36c9f70993 v0.4.0 web server added 2025-10-31 03:27:22 -04:00
lixen b79900b1bf v0.3.0 storage with sqlite3 and pid management added 2025-10-30 09:52:23 -04:00
lixen 0ad608293e v0.2.0 transitioned to api-only, extended and improved features, docs and tests added 2025-10-29 23:28:19 -04:00
lixen b98ea83012 v0.1.1 api with fiber added, basic functionlity tested 2025-10-27 05:16:08 -04:00
lixen 8ba4357920 v0.1.0 chess game in go, using external stockfish engine 2025-10-26 17:34:50 -04:00
54 changed files with 1292 additions and 4676 deletions
-1
View File
@@ -7,4 +7,3 @@ build.sh
.chess_history
*.wasm
catalog.txt
combined.txt
+7 -18
View File
@@ -10,8 +10,6 @@ GO := go
GOROOT := $(shell go env GOROOT)
GOFLAGS := -trimpath
LDFLAGS := -s -w
CGO_SERVER := CGO_ENABLED=1
CGO_CLIENT := CGO_ENABLED=0
# Build info
GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
@@ -44,29 +42,27 @@ build: server client
.PHONY: server
server: $(SERVER_BINARY)
# Build server — CGO required for go-sqlite3
$(SERVER_BINARY): $(BINARY_DIR)
$(CGO_SERVER) $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(SERVER_BINARY) $(SERVER_SOURCE)
$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(SERVER_BINARY) $(SERVER_SOURCE)
@echo "Built server: $(SERVER_BINARY)"
# Build client only
.PHONY: client
client: $(CLIENT_BINARY)
# Build client — pure Go, no CGO
$(CLIENT_BINARY): $(BINARY_DIR)
$(CGO_CLIENT) $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(CLIENT_BINARY) $(CLIENT_SOURCE)
$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(CLIENT_BINARY) $(CLIENT_SOURCE)
@echo "Built client: $(CLIENT_BINARY)"
# Create bin directory
$(BINARY_DIR):
@mkdir -p $(BINARY_DIR)
# Build WASM client — CGO incompatible with js/wasm target
# Build WASM client
.PHONY: wasm
wasm: $(WASM_DIR)
@echo "Building WASM client..."
$(CGO_CLIENT) GOOS=js GOARCH=wasm $(GO) build $(GOFLAGS) \
GOOS=js GOARCH=wasm $(GO) build $(GOFLAGS) \
-ldflags "$(LDFLAGS)" \
-o $(WASM_BINARY) $(CLIENT_SOURCE)
@cp "$(WASM_EXEC_SRC)" $(WASM_DIR)/
@@ -152,18 +148,11 @@ db-clean:
# ☣ DESTRUCTIVE: Removes database
rm -f db/chess.db db/chess.db-*
# Cross-compile server for FreeBSD from Linux (requires zig or freebsd cross toolchain)
# Usage: make server-freebsd-cross CC="zig cc -target x86_64-freebsd"
.PHONY: server-freebsd-cross
server-freebsd-cross: $(BINARY_DIR)
$(CGO_SERVER) GOOS=freebsd GOARCH=amd64 CC="$(CC)" \ $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(SERVER_BINARY) $(SERVER_SOURCE)
@echo "Built FreeBSD server (cross): $(SERVER_BINARY)"
# Development build (with race detector) — native only, CGO required for server
# Development build (with race detector)
.PHONY: dev
dev:
$(CGO_SERVER) $(GO) build -race -o $(SERVER_BINARY) $(SERVER_SOURCE)
$(CGO_CLIENT) $(GO) build -race -o $(CLIENT_BINARY) $(CLIENT_SOURCE)
$(GO) build -race -o $(SERVER_BINARY) $(SERVER_SOURCE)
$(GO) build -race -o $(CLIENT_BINARY) $(CLIENT_SOURCE)
@echo "Built with race detector enabled"
# Clean build artifacts
+4 -14
View File
@@ -3,7 +3,7 @@
<td>
<h1>♚♛♜♝♞</h1>
<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.25-00ADD8?style=flat&logo=go" alt="Go 1.25"></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>
</td>
@@ -24,16 +24,13 @@ Go backend server providing a RESTful API for chess gameplay with user authentic
- Asynchronous engine move calculation
- Configurable engine strength and thinking time
- SQLite persistence with async writes for games
- Durable game results, player claims, ordered move history, and replay API
- Authenticated stored-game listing with bounded pagination
- Configurable structured debug logs for persistence, cleanup, and engine work
- User management with secure Argon2 password storage
- PID file management for singleton enforcement
- Database CLI for storage and user administration
## Requirements
- Go 1.26+
- Go 1.25+
- Stockfish chess engine (`stockfish` in PATH)
- SQLite3 (for persistence features)
@@ -81,10 +78,7 @@ go build ./cmd/chess-server
./chess-server -storage-path chess.db
# Development mode with all features
./chess-server -dev -storage-path chess.db -pid /tmp/chess-server.pid -pid-lock -api-port 9090
# Detailed persistence and HTTP diagnostics
./chess-server -dev -storage-path chess.db -log-level debug -log-http=true
./chess-server -dev -storage-path chess.db -pid /tmp/chess-server.pid -pid-lock -port 9090
# Initialize database with user support
./chess-server db init -path chess.db
@@ -141,16 +135,13 @@ The chess server includes an embedded web UI for playing games through a browser
# Full example with authentication enabled
./chess-server -dev -serve -web-port 9090 -api-port 8080 -storage-path chess.db
# Override the API origin seen by browsers when it differs from the listen address
./chess-server -serve -web-api-url https://api.example.com
```
### Features
- Visual chess board with drag-and-drop moves
- Human vs Computer gameplay
- Configurable engine strength (0-20)
- UCI move history with move numbers
- Move history with algebraic notation
- FEN display and custom starting positions
- Real-time server health monitoring
- User authentication support
@@ -164,7 +155,6 @@ Access the UI at `http://localhost:9090` when server is running with `-serve` fl
- [Architecture](./doc/architecture.md) - System design with auth layer
- [Development](./doc/development.md) - Build, test, and user management
- [Client Guide](./doc/client.md) - Interactive debugging client
- [Replay Implementation Tasks](./doc/todo.md) - Remaining CLI, web, archive, and production work
- [Stockfish Integration](./doc/stockfish.md) - Engine communication
## License
+3 -4
View File
@@ -30,8 +30,8 @@ func runClient() (restart bool) {
}()
s := &session.Session{
APIBaseURL: defaultAPIBase,
Client: api.New(defaultAPIBase),
APIBaseURL: "http://localhost:8080",
Client: api.New("http://localhost:8080"),
Verbose: false,
}
@@ -40,8 +40,7 @@ func runClient() (restart bool) {
display.Println(display.Cyan, "Chess Debug Client")
display.Println(display.Cyan, "API: %s", s.APIBaseURL)
fmt.Println("Type 'help' for commands")
fmt.Println()
fmt.Println("Type 'help' for commands\n")
registry := command.NewRegistry(s)
-5
View File
@@ -1,5 +0,0 @@
//go:build !js
package main
const defaultAPIBase = "http://localhost:8080"
-11
View File
@@ -1,11 +0,0 @@
//go:build js && wasm
package main
import "syscall/js"
// Derive base URL from the page's own origin at runtime
// When served via nginx at domain.com, origin = "https://domain.com"
// and the chess API proxy lives at /chess — so BaseURL = "https://comain.com/chess".
// Works correctly for any deployment domain without rebuilding
var defaultAPIBase = js.Global().Get("location").Get("origin").String() + "/chess"
+8 -37
View File
@@ -1,8 +1,6 @@
package cli
import (
"database/sql"
"errors"
"flag"
"fmt"
"os"
@@ -124,27 +122,17 @@ func runQuery(args []string) error {
// Print results in tabular format
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "Game ID\tWhite Player\tBlack Player\tResult\tStarted\tEnded")
fmt.Fprintln(w, strings.Repeat("-", 130))
fmt.Fprintln(w, "Game ID\tWhite Player\tBlack Player\tStart Time")
fmt.Fprintln(w, strings.Repeat("-", 80))
for _, g := range games {
whiteInfo := formatStoredPlayer(g.WhitePlayerID, g.WhiteClaimedBy, g.WhiteType)
blackInfo := formatStoredPlayer(g.BlackPlayerID, g.BlackClaimedBy, g.BlackType)
result := g.Result
if result == "" {
result = "ongoing"
}
ended := "-"
if g.EndTimeUTC != nil {
ended = g.EndTimeUTC.Format("2006-01-02 15:04:05")
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
abbreviateID(g.GameID),
whiteInfo := fmt.Sprintf("%s (T%d)", g.WhitePlayerID[:8], g.WhiteType)
blackInfo := fmt.Sprintf("%s (T%d)", g.BlackPlayerID[:8], g.BlackType)
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
g.GameID[:8]+"...",
whiteInfo,
blackInfo,
result,
g.StartTimeUTC.Format("2006-01-02 15:04:05"),
ended,
)
}
w.Flush()
@@ -153,21 +141,6 @@ func runQuery(args []string) error {
return nil
}
func formatStoredPlayer(playerID, claimedBy string, playerType int) string {
value := fmt.Sprintf("%s (T%d)", abbreviateID(playerID), playerType)
if claimedBy != "" && claimedBy != playerID {
value += " claim:" + abbreviateID(claimedBy)
}
return value
}
func abbreviateID(value string) string {
if len(value) <= 8 {
return value
}
return value[:8] + "..."
}
func runUser(subcommand string, args []string) error {
switch subcommand {
case "add":
@@ -262,11 +235,9 @@ func runUserAdd(args []string) error {
var userID string
for attempts := 0; attempts < 10; attempts++ {
userID = uuid.New().String()
if _, err := store.GetUserByID(userID); errors.Is(err, sql.ErrNoRows) {
if _, err := store.GetUserByID(userID); err != nil {
// User doesn't exist, ID is unique
break
} else if err != nil {
return fmt.Errorf("failed to check generated user ID: %w", err)
}
if attempts == 9 {
return fmt.Errorf("failed to generate unique user ID after 10 attempts")
@@ -591,7 +562,7 @@ func runUserList(args []string) error {
expires = u.ExpiresAt.Format("2006-01-02 15:04")
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
abbreviateID(u.UserID),
u.UserID[:8]+"...",
u.Username,
u.AccountType,
email,
+11 -42
View File
@@ -8,7 +8,6 @@ import (
"flag"
"fmt"
"log"
"log/slog"
"os"
"os/signal"
"syscall"
@@ -44,35 +43,14 @@ func main() {
storagePath = flag.String("storage-path", "", "Path to SQLite database file (disables persistence if empty)")
pidPath = flag.String("pid", "", "Optional path to write PID file")
pidLock = flag.Bool("pid-lock", false, "Lock PID file to allow only one instance (requires -pid)")
logLevel = flag.String("log-level", "info", "Log level: debug, info, warn, or error")
logHTTP = flag.Bool("log-http", true, "Log HTTP requests")
finishedTTL = flag.Duration("finished-game-ttl", service.FinishedGameTTL, "How long completed games remain in memory (0 disables eviction)")
// Web UI server flags
serve = flag.Bool("serve", false, "Enable web UI server")
webHost = flag.String("web-host", "localhost", "Web UI server host")
webPort = flag.Int("web-port", 9090, "Web UI server port")
webAPIURL = flag.String("web-api-url", "", "Browser-visible API base URL (defaults to the API listen address)")
)
flag.Parse()
var level slog.Level
if err := level.UnmarshalText([]byte(*logLevel)); err != nil {
log.Fatalf("Invalid -log-level %q: %v", *logLevel, err)
}
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: level,
ReplaceAttr: func(_ []string, attr slog.Attr) slog.Attr {
if attr.Key == slog.TimeKey {
attr.Value = slog.TimeValue(attr.Value.Time().UTC())
}
return attr
},
})))
// slog.SetDefault bridges the standard logger through the structured
// handler. Avoid embedding a second timestamp inside its message.
log.SetFlags(0)
// Validate PID flags
if *pidLock && *pidPath == "" {
log.Fatal("Error: -pid-lock flag requires the -pid flag to be set")
@@ -97,9 +75,11 @@ func main() {
if err != nil {
log.Fatalf("Failed to initialize storage: %v", err)
}
if err := store.InitDB(); err != nil {
log.Fatalf("Failed to initialize schema: %v", err)
defer func() {
if err := store.Close(); err != nil {
log.Printf("Warning: failed to close storage cleanly: %v", err)
}
}()
} else {
log.Printf("Persistent storage disabled (use -storage-path to enable)")
}
@@ -121,27 +101,20 @@ func main() {
// 2. Initialize the Service with optional storage and auth
svc := service.New(store, jwtSecret)
svc.SetFinishedGameTTL(*finishedTTL)
// Start cleanup job for expired users/sessions
cleanupCtx, cleanupCancel := context.WithCancel(context.Background())
cleanupDone := make(chan struct{})
go func() {
defer close(cleanupDone)
svc.RunCleanupJob(cleanupCtx, service.CleanupJobInterval)
}()
go svc.RunCleanupJob(cleanupCtx, service.CleanupJobInterval)
// 3. Initialize the Processor (Orchestrator), injecting the service
proc, err := processor.New(svc)
if err != nil {
cleanupCancel()
<-cleanupDone
svc.Shutdown(gracefulShutdownTimeout)
log.Fatalf("Failed to initialize processor: %v", err)
}
// 4. Initialize the Fiber App/HTTP Handler, injecting processor and service
app := http.NewFiberApp(proc, svc, *dev, *logHTTP)
app := http.NewFiberApp(proc, svc, *dev)
// API Server configuration
apiAddr := fmt.Sprintf("%s:%d", *apiHost, *apiPort)
@@ -175,16 +148,13 @@ func main() {
if *serve {
webAddr := fmt.Sprintf("%s:%d", *webHost, *webPort)
apiURL := fmt.Sprintf("http://%s", apiAddr)
if *webAPIURL != "" {
apiURL = *webAPIURL
}
go func() {
log.Printf("Web UI Server starting...")
log.Printf("Web UI Listening on: http://%s", webAddr)
log.Printf("Web UI API target: %s", apiURL)
if err := webserver.Start(*webHost, *webPort, apiURL, *logHTTP); err != nil {
if err := webserver.Start(*webHost, *webPort, apiURL); err != nil {
log.Printf("Web UI server error: %v", err)
}
}()
@@ -206,15 +176,14 @@ func main() {
log.Printf("Server forced to shutdown: %v", err)
}
cleanupCancel() // Stop cleanup before closing processor and storage.
<-cleanupDone
// Close processor before the service so engine callbacks have settled.
// Close processor after service shutdown
if err = proc.Close(); err != nil {
log.Printf("Processor close error: %v", err)
}
// Shutdown service (wait registry, accepted storage writes, database).
cleanupCancel() // Stop cleanup job
// Shutdown service first (includes wait registry cleanup)
if err = svc.Shutdown(gracefulShutdownTimeout); err != nil {
log.Printf("Service shutdown error: %v", err)
}
+5 -88
View File
@@ -103,9 +103,7 @@ Returns server and storage status.
Storage states:
- `"disabled"` - No storage path configured
- `"ok"` - Database operational with auth enabled
- `"degraded"` - A persistence write failed or the write queue filled; live games continue in memory, but durable history is no longer complete
The top-level `status` is also `"degraded"` when storage is degraded.
- `"degraded"` - Write failures detected, operating memory-only
### Create Game
`POST /games`
@@ -158,7 +156,7 @@ Returns current game state.
**Long-polling support:**
Add query parameters for real-time updates:
- `wait=true` - Enable long-polling (waits up to 30 seconds)
- `wait=true` - Enable long-polling (waits up to 25 seconds)
- `moveCount=N` - Last known move count
Returns immediately if game state changed, otherwise waits for updates:
@@ -169,86 +167,10 @@ GET /games/{gameId}?wait=true&moveCount=5
Response includes all game data. Compare `moves` array length to detect changes.
**Timeout behavior:**
- Returns current state after 30 seconds even if no changes
- Returns current state after 25 seconds even if no changes
- Client disconnection cancels wait immediately
- Game deletion notifies all waiting clients
### Get Durable Game History
`GET /games/{gameId}/history`
Returns the persisted replay line even after the live game has been unloaded
from memory or the server has restarted. History is public to anyone who knows
the game ID, matching the existing public live-game read model. Persistent
storage must be enabled.
The response contains the initial FEN and an ordered FEN after every move, so a
client can replay the game without running a chess engine.
**Response (200):**
```json
{
"gameId": "a1b2c3d4-e5f6-7890-1234-567890abcdef",
"initialFen": "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
"result": "white_wins",
"startTimeUtc": "2026-09-07T12:00:00Z",
"endTimeUtc": "2026-09-07T12:15:00Z",
"players": {
"white": {"id": "user-id", "color": 1, "type": 1, "claimedBy": "user-id"},
"black": {"id": "player-id", "color": 2, "type": 1}
},
"moves": [
{
"moveNumber": 1,
"moveUci": "e2e4",
"fenAfterMove": "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1",
"playerColor": "w",
"moveTimeUtc": "2026-09-07T12:00:05Z"
}
]
}
```
`result` is omitted while a game is ongoing. Persisted terminal values are
`white_wins`, `black_wins`, `draw`, and `stalemate`.
Returns 404 when the game has no durable record and 503 when persistence is
disabled.
### List My Stored Games
`GET /users/me/games?limit=50&offset=0`
Returns games associated with the authenticated user at creation time or by a
later first-move slot claim. Requires `Authorization: Bearer <token>` and
persistent storage.
- `limit`: 1-100; defaults to 50
- `offset`: 0-1,000,000; defaults to 0
Each item contains game ID, initial FEN, result/timestamps, players, and move
count. `nextOffset` is present only when another page exists.
**Response (200):**
```json
{
"games": [
{
"gameId": "a1b2c3d4-e5f6-7890-1234-567890abcdef",
"initialFen": "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
"result": "white_wins",
"startTimeUtc": "2026-09-07T12:00:00Z",
"endTimeUtc": "2026-09-07T12:15:00Z",
"moveCount": 41,
"players": {
"white": {"id": "user-id", "color": 1, "type": 1, "claimedBy": "user-id"},
"black": {"id": "player-id", "color": 2, "type": 1}
}
}
],
"limit": 50,
"offset": 0
}
```
### Make Move
`POST /games/{gameId}/moves`
@@ -282,8 +204,7 @@ Returns ASCII board visualization.
### Delete Game
`DELETE /games/{gameId}`
Unloads the live game from memory. Its persisted game and move history remain
available through the history endpoint. Returns 204 on success.
Removes game from memory. Returns 204 on success.
## Error Format
```json
@@ -299,8 +220,6 @@ Error codes:
- `INVALID_MOVE` - Illegal chess move
- `NOT_HUMAN_TURN` - Wrong player type for turn
- `GAME_OVER` - Game already ended
- `GAME_CONFLICT` - Game changed while a move was being validated; refresh and retry
- `STORAGE_UNAVAILABLE` - Durable history/list storage is disabled or degraded
- `RATE_LIMIT_EXCEEDED` - Request limit exceeded
- `INVALID_REQUEST` - Malformed request
- `INVALID_CONTENT_TYPE` - Missing/wrong Content-Type header
@@ -323,6 +242,4 @@ Tokens are HS256-signed JWTs valid for 7 days. Include in Authorization header:
Authorization: Bearer <token>
```
Token claims include `sub` (user ID), `username`, `email`, `session_id`, and
`exp` (expiration). Authentication requires the session to exist, be unexpired,
and belong to the JWT subject.
Token claims include `sub` (user ID), `username`, `email`, and `exp` (expiration).
+29 -71
View File
@@ -2,51 +2,34 @@
## Components
### Transport Layer (`internal/server/http`)
### Transport Layer (`internal/http`)
Fiber web server handling HTTP requests/responses. Implements routing, rate limiting, content-type validation, JWT authentication middleware, request parsing. Translates HTTP to internal Command objects.
### Processing Layer (`internal/server/processor`)
### Processing Layer (`internal/processor`)
Central command handler containing business logic. Single `Execute(Command)` entry point decouples transport from logic. Uses synchronous UCI engine for validation, asynchronous EngineQueue for computer moves. Commands include optional user context for authenticated operations.
### Service Layer (`internal/server/service`)
In-memory live-game storage with authentication support. A mutex protects each
game transition and callers receive immutable game views rather than mutable
game pointers. The service manages game lifecycle, snapshots, player
configuration, user accounts, JWT tokens, persistence, and terminal-game
eviction. Eviction removes only the memory copy; durable replay data remains.
### Service Layer (`internal/service`)
In-memory state storage with authentication support. Thread-safe game map protected by RWMutex. Manages game lifecycle, snapshots, player configuration, user accounts, and JWT token generation. Coordinates with storage layer for persistence of both games and users.
#### Long-Polling Registry (`internal/service/waiter.go`)
Manages clients waiting for game state changes via HTTP long-polling. Tracks move counts per client, sends notifications on state changes, enforces 30-second timeout. Non-blocking notification pattern handles slow clients gracefully. Coordinates with service layer for game updates and deletion events.
Manages clients waiting for game state changes via HTTP long-polling. Tracks move counts per client, sends notifications on state changes, enforces 25-second timeout. Non-blocking notification pattern handles slow clients gracefully. Coordinates with service layer for game updates and deletion events.
#### Authentication Module (`internal/service/user.go`, `internal/http/auth.go`)
- **Password Hashing**: Argon2id for secure password storage
- **JWT Management**: HS256 tokens with 7-day expiration
- **User Operations**: Registration, login, profile management
- **Session Tracking**: One persisted session per user, with JWT subject/session
binding and last-login timestamps
- **Session Tracking**: Last login timestamps
### Storage Layer (`internal/server/storage`)
SQLite persistence with ordered asynchronous writes for gameplay and
synchronous writes for authentication. A bounded channel (1,000 operations)
feeds one transactional writer. Move persistence groups the move, first-move
slot claim, and terminal result in one transaction. Replay reads insert a
barrier behind accepted writes and then read the game and moves in one SQLite
snapshot.
WAL, foreign-key enforcement, a five-second busy timeout, and NORMAL
synchronous mode are configured in the connection string so every pooled
connection receives the same settings. The pool is deliberately small (eight
open, four idle) because SQLite has one writer. A write failure or full queue
marks storage degraded; live play remains available in memory and `/health`
reports that durable history may be incomplete.
### Storage Layer (`internal/storage`)
SQLite persistence with async writes for games, synchronous writes for authentication operations. Buffered channel (1000 ops) processes game writes sequentially in background. User operations use direct database access for consistency. Graceful degradation on write failures. WAL mode for development environments.
### Supporting Modules
- **Engine** (`internal/engine`): UCI protocol wrapper for Stockfish process communication
- **Game** (`internal/game`): Game state with snapshot history and player associations
- **Board** (`internal/board`): FEN parsing and ASCII generation
- **Core** (`internal/core`): Shared types, API models, error constants
- **CLI** (`cmd/chess-server/cli`): Database and user management commands
- **Client** (`cmd/chess-client-cli`, `internal/client`): Interactive debugging client with command registry, session management, and colored terminal output
- **CLI** (`cmd/chessd/cli`): Database and user management commands
- **Client** (`cmd/chess-client`, `internal/client`): Interactive debugging client with command registry, session management, and colored terminal output
## Request Flow
@@ -72,10 +55,9 @@ reports that durable history may be incomplete.
3. Creates MakeMoveCommand, calls `processor.Execute()`
4. Processor validates move via locked validation engine
5. If legal, gets new FEN from engine
6. Calls `service.ApplyMoveWithState()` with the FEN, turn, and state that were validated
7. Service rejects a stale concurrent commit or atomically updates the move, optional slot claim, and terminal result
8. The same logical mutation is queued as one SQLite transaction
9. Returns GameResponse
6. Calls `service.ApplyMove()` to update state
7. Persists move with player identification
8. Returns GameResponse
### Computer Move
1. HTTP handler receives `POST /games/{id}/moves` with `{"move": "cccc"}`
@@ -90,50 +72,38 @@ reports that durable history may be incomplete.
1. Client sends `GET /games/{id}?wait=true&moveCount=N`
2. Handler creates context from HTTP connection
3. Registers wait with WaitRegistry using game ID and move count
4. If game state unchanged, blocks up to 30 seconds
4. If game state unchanged, blocks up to 25 seconds
5. On any game update, NotifyGame sends to all waiters
6. Returns immediately with current state
7. Client disconnection cancels wait via context
8. Game deletion notifies and removes all waiters
### Durable Replay Read
1. Client requests `GET /api/v1/games/{id}/history`
2. Storage queues a barrier after all previously accepted gameplay writes
3. The writer reaches the barrier only after those transactions finish
4. Storage reads the game row and ordered moves in one read transaction
5. The API returns the initial FEN plus every UCI move and resulting FEN
6. This path works after terminal-memory eviction or a server restart
## Persistence Flow
### User Write Operations (Synchronous)
1. Service serializes public registrations within the process
2. Storage checks uniqueness and capacity in a transaction
3. At capacity, the oldest temporary user is evicted in that transaction
4. The new user and initial session commit together; any failure rolls back the entire operation
5. Login replaces the user's single session with one SQLite UPSERT
6. Other account mutations commit before success is returned
1. Service layer calls storage method directly (CreateUser, UpdateUserPassword, etc.)
2. Operations use database transactions for consistency
3. Unique constraint checks within transaction
4. Immediate commit or rollback
5. Returns success or specific error (duplicate username, etc.)
### Game Write Operations (Asynchronous)
1. Service layer calls a storage method (`RecordNewGame`, `RecordMove`, `RecordPlayers`, or `RewindGame`)
1. Service layer calls storage method (RecordNewGame, RecordMove, DeleteUndoneMoves)
2. Operation queued to buffered channel (non-blocking)
3. Writer goroutine processes queue sequentially
4. Each logical mutation commits in one transaction
5. A full queue or write failure is logged and triggers degraded memory-only mode
6. Shutdown rejects new writes and drains every write already accepted
4. Transactions ensure atomicity
5. Failures trigger degradation to memory-only mode
### Query Operations
1. Replay-sensitive game reads wait on the async-write barrier
2. User-game filtering matches creation-time player IDs and later claim IDs
3. Move lookup uses the `UNIQUE(game_id, move_number)` index prefix and returns move order directly
4. Username and email lookups are case-insensitive
5. CLI database queries format records as tabular output
1. CLI invokes Store.QueryGames or Store.GetUserByUsername with filters
2. Direct database read (no queue)
3. Case-insensitive matching for usernames/emails
4. Results formatted as tabular output
## Concurrency
- **HTTP Server**: Fiber handles concurrent connections
- **Game State**: Single RWMutex protects game map (concurrent reads, serial writes)
- **Move Validation**: Optimistic FEN/state/turn checks reject a result if the game changed while Stockfish was validating
- **Engine Workers**: Fixed pool (2 workers) with dedicated Stockfish processes
- **Validation Engine**: Single mutex-protected instance for synchronous validation
- **Storage Writer**: Single goroutine processes game write queue sequentially
@@ -170,7 +140,6 @@ type Snapshot struct {
"sub": "user-id",
"username": "alice",
"email": "alice@example.com",
"session_id": "session-id",
"exp": 1234567890
}
```
@@ -205,11 +174,7 @@ games (
black_type INTEGER,
black_level INTEGER,
black_search_time INTEGER,
start_time_utc DATETIME,
result TEXT, -- white_wins, black_wins, draw, or stalemate
end_time_utc DATETIME,
white_claimed_by TEXT, -- user that claimed the slot after creation
black_claimed_by TEXT
start_time_utc DATETIME
)
-- Move history
@@ -221,17 +186,10 @@ moves (
fen_after_move TEXT,
player_color TEXT,
move_time_utc DATETIME,
FOREIGN KEY (game_id) REFERENCES games(game_id) ON DELETE CASCADE,
UNIQUE (game_id, move_number)
FOREIGN KEY (game_id) REFERENCES games(game_id)
)
```
Schema version 2 is applied idempotently to legacy databases with guarded
`ALTER TABLE ADD COLUMN` migrations. Redundant indexes formerly duplicating
UNIQUE constraints or useful index prefixes are removed. Purpose-built partial
indexes cover non-empty email uniqueness, temporary-user cleanup, session
expiry, and post-creation game claims.
## Security Architecture
### Authentication Flow
@@ -251,5 +209,5 @@ expiry, and post-creation game claims.
- Passwords never stored in plaintext
- JWT secret rotates on restart (or fixed in dev mode)
- User IDs use UUIDs with collision detection
- Transactions keep registration, sessions, moves, claims, results, and rewinds internally consistent
- Transactions ensure data consistency
- Case-insensitive queries prevent duplicate accounts
+6 -14
View File
@@ -12,14 +12,6 @@ The chess client is an interactive command-line debugging tool for the chess ser
- Verbose mode for detailed API request/response inspection
- Long-polling support for real-time game updates
## Replay Foundation
The Go API client exposes `GetGameHistory(gameID)` and
`GetMyGames(limit, offset)`, including durable result, timestamps, player
claims, move count, ordered UCI moves, and FEN after every move. Interactive
`games`/`replay` commands and terminal playback controls are intentionally
deferred to the dedicated replay iteration; see [Replay Implementation Tasks](./todo.md).
## Building
```bash
go build ./cmd/chess-client-cli
@@ -136,14 +128,14 @@ chess > state
```
#### `delete` / `d`
Unload a live game from server memory. Durable history remains available.
Delete game from server.
```
chess > delete # Unload current live game
chess > delete <gameId> # Unload specific live game
chess > delete # Delete current game
chess > delete <gameId> # Delete specific game
```
#### `poll` / `p`
Long-poll for game updates (waits up to 30 seconds).
Long-poll for game updates (waits up to 25 seconds).
```
chess > poll
```
@@ -238,9 +230,9 @@ ASCII board with colored pieces:
```
### Move History
Displayed in UCI notation with move numbers:
Displayed in algebraic notation with move numbers:
```
History: 1.e2e4 e7e5 2.g1f3 b8c6 3.f1b5
History: 1.e4 e5 2.Nf3 Nc6 3.Bb5
```
## Workflows
+31 -55
View File
@@ -2,7 +2,7 @@
## Prerequisites
- Go 1.26+
- Go 1.24+
- Stockfish in PATH
- SQLite3
- Git
@@ -14,7 +14,7 @@
git clone https://github.com/lixenwraith/chess
cd chess
go build ./cmd/chess-server
go build ./cmd/chess-client-cli
go build ./cmd/chess-client
```
## Running
@@ -29,30 +29,20 @@ go build ./cmd/chess-client-cli
- `-storage-path`: SQLite database file path (enables persistence and authentication)
- `-pid`: PID file path for process tracking
- `-pid-lock`: Enable exclusive locking (requires -pid)
- `-log-level`: `debug`, `info`, `warn`, or `error` (default: `info`)
- `-log-http`: Enable API and web request logs (default: `true`)
- `-finished-game-ttl`: How long terminal games stay in memory (default: `1h`; `0` disables eviction)
- `-web-api-url`: Browser-visible API origin for the embedded web client; useful when its public origin differs from the listen address
### Modes
```bash
# In-memory only (no persistence or auth)
./chess-server
./chessd
# With persistence and authentication
./chess-server -storage-path ./db/chess.db
./chessd -storage-path ./db/chess.db
# Development with all features
./chess-server -dev -storage-path chess.db -pid /tmp/chess-server.pid -serve
# Detailed persistence, engine-queue, cleanup, and request logs
./chess-server -dev -storage-path chess.db -serve -log-level debug -log-http=true
# Web UI is public at one origin while the API is exposed at another
./chess-server -serve -web-api-url https://api.example.test
./chessd -dev -storage-path chess.db -pid /tmp/chessd.pid -serve
# Initialize database with user tables
./chess-server db init -path chess.db
./chessd db init -path chess.db
```
## Database Management
@@ -60,64 +50,60 @@ go build ./cmd/chess-client-cli
### Schema Initialization
```bash
# Create all tables (users, games, moves)
./chess-server db init -path chess.db
./chessd db init -path chess.db
```
### User Management CLI
```bash
# Add user with password
./chess-server db user add -path chess.db -username alice -password SecurePass123
./chessd db user add -path chess.db -username alice -password SecurePass123
# Add user with email
./chess-server db user add -path chess.db -username bob -email bob@example.com -password BobPass456
./chessd db user add -path chess.db -username bob -email bob@example.com -password BobPass456
# Interactive password input
./chess-server db user add -path chess.db -username charlie -interactive
./chessd db user add -path chess.db -username charlie -interactive
# List all users
./chess-server db user list -path chess.db
./chessd db user list -path chess.db
# Update password
./chess-server db user set-password -path chess.db -username alice -password NewPass789
./chessd db user set-password -path chess.db -username alice -password NewPass789
# Update email
./chess-server db user set-email -path chess.db -username alice -email newemail@example.com
./chessd db user set-email -path chess.db -username alice -email newemail@example.com
# Update username
./chess-server db user set-username -path chess.db -current alice -new alice2
./chessd db user set-username -path chess.db -current alice -new alice2
# Import with existing Argon2 hash
./chess-server db user set-hash -path chess.db -username alice -hash '$argon2id$v=19$m=65536,t=3,p=2$...'
./chessd db user set-hash -path chess.db -username alice -hash '$argon2id$v=19$m=65536,t=3,p=2$...'
# Delete user
./chess-server db user delete -path chess.db -username alice
./chessd db user delete -path chess.db -username alice
```
### Game Query CLI
```bash
# Query all games
./chess-server db query -path chess.db -gameId "*"
./chessd db query -path chess.db -gameId "*"
# Query games for specific user
./chess-server db query -path chess.db -playerId "550e8400-e29b-41d4-a716-446655440000"
./chessd db query -path chess.db -playerId "550e8400-e29b-41d4-a716-446655440000"
# Query specific game
./chess-server db query -path chess.db -gameId "a1b2c3d4-e5f6-7890-1234-567890abcdef"
./chessd db query -path chess.db -gameId "a1b2c3d4-e5f6-7890-1234-567890abcdef"
# Delete database (destructive)
./chess-server db delete -path chess.db
./chessd db delete -path chess.db
```
## Authentication Configuration
### JWT Secret Management
- **Production**: A cryptographically secure 32-byte secret is generated on
startup. This intentionally invalidates JWTs after a restart even though the
SQLite session rows remain; configuring a stable deployment secret is tracked
in `doc/todo.md`.
- **Production**: Cryptographically secure 32-byte secret generated on startup
- **Development** (`-dev`): Fixed secret for testing consistency
- **Sessions**: Stored for 7 days and renewed on each login; effective token
lifetime is also bounded by signing-key rotation
- **Sessions**: Valid for 7 days, renewed on each login
### Password Requirements
- Minimum 8 characters
@@ -185,12 +171,6 @@ See [test documentation](../test/README.md) for comprehensive test suites coveri
# Test real-time game updates via long-polling
./test/test-longpoll.sh
# Unit, migration, and persistence tests
go test ./...
# Concurrency checks for the state/persistence boundary
go test -race ./internal/server/storage ./internal/server/service
```
## Configuration
@@ -200,10 +180,10 @@ go test -race ./internal/server/storage ./internal/server/service
- Worker count: 2 (internal/processor/processor.go)
- Queue capacity: 100 (internal/processor/queue.go)
- Min search time: 100ms (internal/processor/processor.go)
- Write queue: 1000 operations (internal/server/storage/storage.go)
- DB connections: 8 max, 4 idle (internal/server/storage/storage.go)
- Write queue: 1000 operations (internal/storage/storage.go)
- DB connections: 25 max, 5 idle (internal/storage/storage.go)
- JWT expiration: 7 days (internal/service/user.go)
- Long-poll timeout: 30 seconds (internal/server/service/waiter.go)
- Long-poll timeout: 25 seconds (internal/service/waiter.go)
- Long-poll channel buffer: 1 (internal/service/waiter.go)
### Authentication Configuration
@@ -213,13 +193,11 @@ go test -race ./internal/server/storage ./internal/server/service
- Hash algorithm: Argon2id (memory-hard, side-channel resistant)
### Storage Configuration
- WAL mode and NORMAL synchronous mode enabled on every connection
- Foreign key constraints and a five-second busy timeout enabled on every connection
- Async write pattern for games; shutdown drains every accepted write
- Replay reads wait for prior queued writes and use one read transaction
- WAL mode enabled in development for concurrency
- Foreign key constraints enforced
- Async write pattern for games with 2-second drain on shutdown
- Synchronous writes for user operations (data consistency)
- Registration capacity/eviction, user creation, and initial session are atomic
- A full queue or write failure degrades to memory-only and is visible in logs and `/health`
- Degradation to memory-only on write failures
- Case-insensitive collation for usernames and emails
### Rate Limiting Configuration
@@ -271,8 +249,6 @@ go test -race ./internal/server/storage ./internal/server/service
- No password recovery mechanism
- No email verification for registration
- Fixed worker pool size for engine calculations
- No push-based game updates (30-second long-polling is used)
- Live games are not rehydrated after restart; persisted games are currently replay-only
- Database history has no automatic retention policy
- Curated-game metadata and replay controls are deferred to [Replay Implementation Tasks](./todo.md)
- No real-time game updates (polling required)
- Long-polling limited to 25 seconds per request
- REST API only
-266
View File
@@ -1,266 +0,0 @@
# Replay Implementation Tasks
This plan covers the remaining work needed for first-class replay in the web
and CLI clients, durable player-game browsing, and a curated archive of famous
games. The persistence/API foundation completed by the database audit is listed
first so later work does not duplicate or bypass it.
## Foundation Available Now
- [x] Store terminal `result` and `end_time_utc` on each game.
- [x] Persist `white_claimed_by` and `black_claimed_by`, including claims made
on the first valid move after game creation.
- [x] Commit each move, first-move claim, and move-caused terminal result in one
SQLite transaction.
- [x] Rewind moves and clear a stale terminal result/end time in one transaction.
- [x] Return ordered UCI moves with `fenAfterMove` through
`GET /api/v1/games/{gameId}/history`.
- [x] Return bounded pages through authenticated
`GET /api/v1/users/me/games?limit=&offset=`.
- [x] Provide matching Go client DTOs and methods (`GetGameHistory`,
`GetMyGames`) without prematurely adding CLI presentation.
- [x] Evict terminal games from memory after a configurable TTL while retaining
durable rows and moves.
- [x] Add an async-write barrier and a single SQLite read snapshot for immediate,
internally consistent replay reads.
- [x] Configure browser API origin through `/config`, with `/chess` fallback for
the static deployment at `lixen.com/projects/chess/`.
- [x] Add debug-level persistence, cleanup, engine queue, and lifecycle logging.
## Decisions Required Before Replay UI Work
| Decision | Current behavior | Decision needed |
|---|---|---|
| History visibility | Public to anyone with a game UUID, like live game reads | Keep public, make games private by default, or add per-game visibility |
| Live mutation authorization | Configure, undo, computer-trigger, and unload remain UUID-based; claimed slots protect human moves only | Define owner/opponent/spectator permissions before replay and archive UI expose more game discovery |
| Database retention | Indefinite; only terminal in-memory state is evicted | Retention by account type, archive status, age, or explicit deletion |
| Delete semantics | `DELETE /games/{id}` unloads memory but retains history | Name it “close/unload,” or add a separate authorized durable delete |
| Durability guarantee | Gameplay continues after a write failure; health becomes degraded | Keep best-effort, acknowledge writes, retry with an outbox, or fail gameplay closed |
| Result model | `white_wins`, `black_wins`, `draw`, `stalemate` | Keep compatibility or split outcome (`1-0`, `0-1`, `1/2-1/2`) from termination reason |
| Archived-game owner | Not created | Protected system/demo user, separate archive owner table, or ownerless source records |
| Replay notation | UCI plus authoritative FEN after each move | Add SAN and canonical PGN at import/write time or derive them on read |
Record these choices in an ADR before changing the v1 response contract. Until
privacy is decided, do not add searchable public player-game indexes or expose
usernames in public history.
## Phase 1 — Complete the Durable Game Model
### Results and termination
- [ ] Represent outcome separately from termination reason. Candidate fields:
`outcome`, `termination`, and optional `result_detail`.
- [ ] Detect and persist all supported draw paths, not only stalemate:
insufficient material, repetition, fifty/seventy-five-move rule, and agreed
draw if that interaction is added.
- [ ] Define behavior for resignation, timeout, abandonment, engine failure,
and administrative termination.
- [ ] Add constraints covering valid combinations: an end time requires a
terminal outcome; an ongoing game has neither.
- [ ] Decide whether undoing a finished rated/player game is allowed. If yes,
preserve an audit event rather than silently rewriting official history.
### Stable participant metadata
- [ ] Snapshot display names at game start/end so replay remains readable after
a user rename or temporary-account deletion.
- [ ] Separate historical participant identity from mutable controller config.
Changing a human slot to a computer must never remove the user's game link.
- [ ] Decide whether anonymous players receive a durable pseudonym, remain
unnamed, or are excluded from archive browsing.
- [ ] Add optional clocks/time-control metadata before timeout results are
supported.
### Notation and integrity
- [ ] Add SAN per ply and canonical PGN, or add a deterministic backend
converter from the stored initial FEN/UCI line.
- [ ] Validate that `move_number`, `player_color`, FEN side-to-move, and the
previous position form one legal continuous line.
- [ ] Add a stored content hash for import idempotency and corruption checks.
- [ ] Add a repair/audit CLI command that reports broken game rows without
mutating them; make repair an explicit separate operation.
- [ ] Define a schema-migration policy beyond v2, including forward-version
rejection, backup instructions, and rollback limitations.
## Phase 2 — Replay and Library APIs
### Player games
- [ ] Add filters to the authenticated list: `status`, `result`, color, opponent
type, and date range.
- [ ] Replace offset pagination with a stable `(start_time_utc, game_id)` cursor
before the table grows large; retain v1 offset parameters during migration.
- [ ] Return a compact display label/opponent summary so clients do not recreate
association logic.
- [ ] Define whether an authenticated user may list a game merely created for
their random player ID versus one explicitly claimed by them.
- [ ] Add authorization tests for expired/deleted sessions and attempts to list
another user's games.
### Replay payload
- [ ] Version the history payload before adding annotations, evaluations,
comments, variations, clocks, or PGN tags.
- [ ] Include a canonical final FEN and normalized outcome/termination fields.
- [ ] Decide whether long games return one payload or paged/chunked moves.
- [ ] Add `ETag`/`If-None-Match` for immutable finished histories.
- [ ] Add a downloadable PGN response with correct `Content-Type` and filename.
- [ ] Return an explicit “ongoing/incomplete” marker when history is requested
before a terminal result.
### Live-game restoration
- [ ] Decide whether a server restart should make unfinished games playable or
replay-only.
- [ ] If play must resume, load the last persisted FEN, next turn, player config,
claims, and move list into memory at startup.
- [ ] Mark games interrupted in `pending` state as recoverable `stuck` or
`ongoing`; never re-submit an engine task blindly.
- [ ] Define reconciliation when the service previously entered degraded mode
and memory contains moves absent from SQLite.
## Phase 3 — Curated Famous-Game Archive
### Schema and ownership
- [ ] Add a game origin such as `player`, `curated`, or `imported`.
- [ ] Add searchable archive metadata: title, event, site, event date, round,
white/black display names, Elo values, ECO/opening, source URL, source license,
attribution text, and import timestamp.
- [ ] Add publication state, featured flag, and explicit featured rank/order.
- [ ] Create a protected demo/system identity only if ownership remains tied to
users. It must not consume temporary-user capacity, expire, authenticate, or
be evicted/deleted through normal user tools.
- [ ] Prefer a separate protected archive owner over credentials embedded in
seed scripts.
- [ ] Add only indexes backed by actual archive queries; confirm each with
`EXPLAIN QUERY PLAN` and a representative data volume.
### Import pipeline
- [ ] Add `chess-server db archive import` for one PGN or a directory.
- [ ] Parse PGN tags, comments, NAGs, and variations deliberately; document
which are preserved and which are discarded in the first version.
- [ ] Validate every main-line move from its initial position and generate the
authoritative FEN sequence before opening the transaction.
- [ ] Import a game and all moves in one transaction.
- [ ] Make repeated imports idempotent by source key/content hash.
- [ ] Add dry-run, structured error output, per-file summary, and all-or-nothing
versus continue-on-error modes.
- [ ] Preserve source attribution and verify redistribution rights for every
bundled collection.
- [ ] Seed a small, reviewed fixture set in tests; keep large archives outside
the executable and repository unless licensing and binary size are accepted.
### Archive API
- [ ] Add a public, bounded curated list endpoint with stable sorting.
- [ ] Add exact filters required by the UI (featured, player name, event, year,
ECO); do not expose an unconstrained database query API.
- [ ] Reuse the same history representation for player and curated games.
- [ ] Cache immutable curated list/history responses and invalidate only on
archive administration.
## Phase 4 — CLI Replay Experience
- [ ] Add `games`/`games mine` to call `GetMyGames`, show pagination, result,
colors, opponent/controller, date, and move count.
- [ ] Add `games featured` after the curated endpoint exists.
- [ ] Add `replay <gameId>` and allow selection from a prior list result.
- [ ] Render the initial FEN before ply 1; never assume the standard start.
- [ ] Add next/previous/start/end navigation, move-number jump, and optional
autoplay speed.
- [ ] Display UCI initially and SAN once the backend contract supplies it.
- [ ] Clearly separate replay state from live session state: replay commands
must not poll, move, undo, configure, or delete the live game.
- [ ] Add `pgn save <path>` after the PGN endpoint is defined.
- [ ] Cover empty lists, ongoing histories, custom FEN, malformed/incomplete
history, expired auth, server restart, and deleted live-memory state.
## Phase 5 — Web Replay Experience
- [ ] Add “My games” for authenticated users and a separate “Classic games”
collection available without login.
- [ ] Build accessible loading, empty, pagination, and error states.
- [ ] Add a replay route/deep link, for example `?replay=<gameId>`, that works
beneath `/projects/chess/` and does not assume the API shares that path.
- [ ] Initialize from `initialFen`; step by assigning the stored
`fenAfterMove`, not by replaying moves through a browser chess engine.
- [ ] Add previous/next/start/end buttons, move-list selection, keyboard
controls, autoplay speed, pause, and current-ply announcement.
- [ ] Disable move, computer-trigger, undo, and player-configuration actions in
replay mode.
- [ ] Stop live long-polling when replay mode begins and restore it only when a
live game is explicitly reopened.
- [ ] Show result, termination, players, date/event, source attribution, and
custom-start notice.
- [ ] Make browser back/forward restore list filters and replay ply.
- [ ] Test both embedded `/config` and the deployed `/chess` fallback, including
CORS and reverse-proxy headers.
- [ ] Add responsive and accessibility checks for board orientation, focus,
screen-reader labels, reduced motion, and high contrast.
## Phase 6 — Durability, Operations, and Scale
- [ ] Choose and implement the durability contract from the decision table.
For acknowledged persistence, return success only after a writer receipt or
use a durable outbox with retries and ordering.
- [ ] Expose counters/metrics for queue depth, enqueue rejection, write latency,
failed transaction, barrier latency, replay read latency, and terminal-memory
eviction.
- [ ] Add request/game correlation fields to logs without logging JWTs,
passwords, or full private payloads.
- [ ] Configure a stable production JWT signing key (prefer a secret file or
deployment secret) so persisted sessions can survive a server restart;
document rotation and invalidation procedures.
- [ ] Add a bounded degraded-mode recovery procedure; current behavior requires
operator intervention/restart and cannot reconstruct missing writes.
- [ ] Benchmark list and history queries with realistic user/archive sizes and
verify query plans in CI.
- [ ] Set WAL checkpoint and database backup procedures; test online backup and
restore with active reads/writes.
- [ ] Define database retention separately for anonymous, temporary-user,
permanent-user, and curated games.
- [ ] Add authorized durable deletion/anonymization if required by the privacy
policy, with archive records protected from accidental cascades.
## Required Test Matrix
- [ ] Upgrade a production-shaped legacy database to every new schema version
and reopen it with foreign keys enabled on multiple pooled connections.
- [ ] Read history immediately after create, move, terminal move, slot claim,
player reconfiguration, and undo—without sleeps.
- [ ] Run concurrent legal moves from one position; exactly one may commit and
the loser must receive `GAME_CONFLICT`.
- [ ] Submit duplicate computer triggers; only one engine task may run.
- [ ] Fill the write queue/fault SQLite and assert degraded health, visible
logging, and documented client behavior.
- [ ] Shut down with queued writes and prove all accepted writes drain.
- [ ] Restart after a finished game and replay the exact FEN sequence/result.
- [ ] Evict a terminal game from memory and replay it from SQLite.
- [ ] Change a claimed human slot to computer and verify “My games” association
remains.
- [ ] Verify registration duplicate/session failures roll back account creation
and capacity eviction.
- [ ] Exercise public/private history rules for anonymous, owner, opponent, and
unrelated authenticated clients.
- [ ] Validate imported PGNs with promotions, castling, en passant, custom FEN,
comments, and every supported result.
- [ ] Run Go unit/race tests, HTTP integration scripts, JavaScript syntax/tests,
and browser end-to-end replay navigation in CI.
## Replay Definition of Done
- A finished player game survives restart, appears once in its owner's list,
and replays deterministically from the stored initial FEN to the stored final
FEN in both clients.
- A curated game is imported idempotently with source attribution, appears in a
stable public collection, and uses the same replay path as a player game.
- Undo, player reconfiguration, terminal eviction, and concurrent requests
cannot produce a stale result, missing claim, duplicate ply, or mixed history
snapshot.
- Privacy, retention, durable deletion, and degraded-write behavior are
documented and enforced consistently by API, storage, web, and CLI layers.
- Query plans and benchmarks show no redundant indexes or unbounded list scans
at the agreed deployment size.
+18 -18
View File
@@ -1,33 +1,33 @@
module chess
go 1.26.0
go 1.25.4
require (
github.com/go-playground/validator/v10 v10.30.3
github.com/gofiber/fiber/v2 v2.52.14
github.com/go-playground/validator/v10 v10.30.1
github.com/gofiber/fiber/v2 v2.52.12
github.com/google/uuid v1.6.0
github.com/lixenwraith/auth v0.0.0-20260718210909-e4eb41658be8
github.com/mattn/go-sqlite3 v1.14.48
golang.org/x/term v0.45.0
github.com/lixenwraith/auth v0.0.0-20251104131016-e5a810f4e226
github.com/mattn/go-sqlite3 v1.14.34
golang.org/x/term v0.40.0
)
require (
github.com/andybalholm/brotli v1.2.2 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.14 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/klauspost/compress v1.19.1 // indirect
github.com/leodido/go-urn v1.5.0 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.23 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.20 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/tinylib/msgp v1.6.3 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.72.0 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
github.com/valyala/fasthttp v1.69.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
)
-75
View File
@@ -1,17 +1,11 @@
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gabriel-vasile/mimetype v1.4.14 h1:8eyElddS5wbWNDG4sIupw+IX2jEjHX2aqAAq/9C3M8s=
github.com/gabriel-vasile/mimetype v1.4.14/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -20,117 +14,48 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
github.com/gofiber/fiber/v2 v2.52.12 h1:0LdToKclcPOj8PktUdIKo9BUohjjwfnQl42Dhw8/WUw=
github.com/gofiber/fiber/v2 v2.52.12/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/gofiber/fiber/v2 v2.52.13 h1:TOKP64iqC9b5P49VrBW5tHhUOvDyrtJ0xePEfzJbCbk=
github.com/gofiber/fiber/v2 v2.52.13/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/gofiber/fiber/v2 v2.52.14 h1:Of3L+9qVFaQNwPlcmEdl5IIodHz8BSE0j37R7rWu4pE=
github.com/gofiber/fiber/v2 v2.52.14/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
github.com/lixenwraith/auth v0.0.0-20251104131016-e5a810f4e226 h1:c7wfyZGdy6RkM/b6mIazoYrAS+3qDL7d9M1CFm2e1VA=
github.com/lixenwraith/auth v0.0.0-20251104131016-e5a810f4e226/go.mod h1:1Kfy3ggtRbgrzR+qg99SaeUmmnUZKtur8uOSQsbWaPw=
github.com/lixenwraith/auth v0.0.0-20260718210909-e4eb41658be8 h1:AOj9NHdpB3mSRXHYr4iOhFypovIDtzwWMAM80o6JNEY=
github.com/lixenwraith/auth v0.0.0-20260718210909-e4eb41658be8/go.mod h1:anmBvIoOyZGcw/TaZPZAvzCYoFOBgVJji5MGP5MFZJE=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ=
github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/mattn/go-sqlite3 v1.14.45 h1:6KA/spDguL3KV8rnybG7ezSaE4SeMR3KC9VbUoAQaIk=
github.com/mattn/go-sqlite3 v1.14.45/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo=
github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/mattn/go-sqlite3 v1.14.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/TFs=
github.com/mattn/go-sqlite3 v1.14.48/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k=
github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA=
github.com/valyala/fasthttp v1.72.0 h1:R7kYdoWhn1ye1fVpP+cDHDJwYm3NkwLliwgzJ/Abg7M=
github.com/valyala/fasthttp v1.72.0/go.mod h1:zsbLTYqcpIktdQytlVBwIjY9La5d6bs990nBxWg8efk=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+5 -18
View File
@@ -23,7 +23,7 @@ type Client struct {
func New(baseURL string) *Client {
return &Client{
BaseURL: strings.TrimRight(baseURL, "/"),
BaseURL: baseURL,
HTTPClient: &http.Client{
Timeout: HttpTimeout,
},
@@ -81,7 +81,7 @@ func (c *Client) doRequest(method, path string, body any, result any) error {
json.Unmarshal([]byte(bodyStr), &prettyBody)
prettyJSON, _ := json.MarshalIndent(prettyBody, "", " ")
display.Println(display.Cyan, "Request Body:")
display.Println(display.Reset, "%s", string(prettyJSON))
display.Println(display.Reset, string(prettyJSON))
} else {
display.Print(display.Blue, "%s\n", bodyStr)
}
@@ -114,10 +114,10 @@ func (c *Client) doRequest(method, path string, body any, result any) error {
if err := json.Unmarshal(respBody, &prettyResp); err == nil {
prettyJSON, _ := json.MarshalIndent(prettyResp, "", " ")
display.Println(display.Cyan, "Response Body:")
display.Println(display.Reset, "%s", string(prettyJSON))
display.Println(display.Reset, string(prettyJSON))
} else {
display.Println(display.Cyan, "Response:")
display.Println(display.Reset, "%s", string(respBody))
display.Println(display.Reset, string(respBody))
}
}
@@ -135,7 +135,7 @@ func (c *Client) doRequest(method, path string, body any, result any) error {
}
}
} else if !c.Verbose {
display.Println(display.Red, "%s", string(respBody))
display.Println(display.Red, string(respBody))
}
return fmt.Errorf("request failed with status %d", resp.StatusCode)
}
@@ -204,19 +204,6 @@ func (c *Client) GetBoard(gameID string) (*BoardResponse, error) {
return &resp, err
}
func (c *Client) GetGameHistory(gameID string) (*GameHistoryResponse, error) {
var resp GameHistoryResponse
err := c.doRequest("GET", "/api/v1/games/"+gameID+"/history", nil, &resp)
return &resp, err
}
func (c *Client) GetMyGames(limit, offset int) (*GameListResponse, error) {
var resp GameListResponse
path := fmt.Sprintf("/api/v1/users/me/games?limit=%d&offset=%d", limit, offset)
err := c.doRequest("GET", path, nil, &resp)
return &resp, err
}
func (c *Client) Register(username, password, email string) (*AuthResponse, error) {
req := &RegisterRequest{
Username: username,
-37
View File
@@ -52,11 +52,9 @@ type PlayersResponse struct {
type PlayerInfo struct {
ID string `json:"id"`
Color int `json:"color"`
Type int `json:"type"`
Level int `json:"level,omitempty"`
SearchTime int `json:"searchTime,omitempty"`
ClaimedBy string `json:"claimedBy,omitempty"`
}
type MoveInfo struct {
@@ -98,38 +96,3 @@ type HealthResponse struct {
Time int64 `json:"time"`
Storage string `json:"storage,omitempty"`
}
type GameHistoryResponse struct {
GameID string `json:"gameId"`
InitialFEN string `json:"initialFen"`
Result string `json:"result,omitempty"`
StartTimeUTC time.Time `json:"startTimeUtc"`
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
Players PlayersResponse `json:"players"`
Moves []HistoryMove `json:"moves"`
}
type HistoryMove struct {
MoveNumber int `json:"moveNumber"`
MoveUCI string `json:"moveUci"`
FENAfterMove string `json:"fenAfterMove"`
PlayerColor string `json:"playerColor"`
MoveTimeUTC time.Time `json:"moveTimeUtc"`
}
type GameSummary struct {
GameID string `json:"gameId"`
InitialFEN string `json:"initialFen"`
Result string `json:"result,omitempty"`
StartTimeUTC time.Time `json:"startTimeUtc"`
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
MoveCount int `json:"moveCount"`
Players PlayersResponse `json:"players"`
}
type GameListResponse struct {
Games []GameSummary `json:"games"`
Limit int `json:"limit"`
Offset int `json:"offset"`
NextOffset *int `json:"nextOffset,omitempty"`
}
+56 -75
View File
@@ -6,6 +6,7 @@ import (
"os"
"strconv"
"strings"
"time"
"chess/internal/client/api"
"chess/internal/client/display"
@@ -72,7 +73,7 @@ func (r *Registry) registerGameCommands() {
r.Register(&Command{
Name: "delete",
ShortName: "d",
Description: "Unload a live game (history retained)",
Description: "Delete a game",
Usage: "delete [gameId]",
Handler: deleteGameHandler,
})
@@ -233,9 +234,6 @@ func joinGameHandler(s *session.Session, args []string) error {
return nil
}
// moveHandler submits a human move. Terminal/error outcomes are reported via
// the shared printOutcome (no-op on "ongoing"/"pending"); the computer-turn
// hint stays local since it only applies after a successful human move.
func moveHandler(s *session.Session, args []string) error {
if len(args) < 1 {
return fmt.Errorf("usage: move <uci-move>")
@@ -258,13 +256,29 @@ func moveHandler(s *session.Session, args []string) error {
s.CurrentGameState = resp
display.Println(display.Green, "Move accepted")
printOutcome(resp)
// Check if game ended
switch resp.State {
case "checkmate":
winner := "Black"
if resp.Turn == "b" { // Turn switches after move, so if black's turn after checkmate, white won
winner = "White"
}
display.Println(display.Green, "\nCHECKMATE! %s wins!", winner)
case "stalemate":
display.Println(display.Yellow, "\nSTALEMATE! Game drawn.")
case "draw":
display.Println(display.Yellow, "\nDRAW! Game drawn.")
case "ongoing":
// Check if computer needs to play
currentTurn := resp.Turn
var computerPlayer *api.PlayerInfo
if currentTurn == "w" && resp.Players.White.Type == 2 {
computerPlayer = &resp.Players.White
} else if currentTurn == "b" && resp.Players.Black.Type == 2 {
computerPlayer = &resp.Players.Black
}
// Hint to trigger the computer if the game continues on a computer's turn
if resp.State == "ongoing" {
isComputerTurn := (resp.Turn == "w" && resp.Players.White.Type == 2) ||
(resp.Turn == "b" && resp.Players.Black.Type == 2)
if isComputerTurn {
if computerPlayer != nil {
display.Println(display.Magenta, "\nComputer's turn. Use 'computer' or 'c' to trigger move.")
}
}
@@ -272,12 +286,6 @@ func moveHandler(s *session.Session, args []string) error {
return nil
}
// computerMoveHandler triggers a computer move and waits for the result via
// the server's long-poll. With the state-aware waiter, a single poll wakes on
// either the applied move (move count delta) or a state-only settle
// (mate-without-move, stuck) — no fixed-interval GET hammering, no hard cap
// below the server's max searchTime. Polls loop only if the wake races the
// pending window (e.g. queue wait), each round costing at most WaitTimeout.
func computerMoveHandler(s *session.Session, args []string) error {
gameID := s.CurrentGame
if gameID == "" {
@@ -286,80 +294,53 @@ func computerMoveHandler(s *session.Session, args []string) error {
c := s.Client
// Baseline BEFORE triggering: the long-poll returns immediately if the
// move count already differs from this value.
baselineMoves := s.LastMoveCount
if s.CurrentGameState != nil {
baselineMoves = len(s.CurrentGameState.Moves)
}
resp, err := c.MakeMove(gameID, "cccc")
if err != nil {
return err
}
if resp.State != "pending" {
// Server resolved synchronously (shouldn't normally happen)
s.LastMoveCount = len(resp.Moves)
s.CurrentGameState = resp
display.Println(display.Green, "Move triggered")
printOutcome(resp)
return nil
}
if resp.State == "pending" {
display.Println(display.Magenta, "Computer is thinking...")
// Up to 3 long-poll rounds (~90s ceiling) covers max searchTime (10s)
// plus pathological queue wait, without hanging indefinitely.
const maxPolls = 3
var final *api.GameResponse
for i := 0; i < maxPolls; i++ {
polled, err := c.GetGameWithPoll(gameID, baselineMoves)
if err != nil {
return err
}
if polled.State != "pending" {
final = polled
break
}
// Woke on timeout while still pending; poll again.
}
if final == nil {
return fmt.Errorf("computer move still pending after %d poll rounds", maxPolls)
}
s.LastMoveCount = len(final.Moves)
s.CurrentGameState = final
// A move may legitimately be absent: mate-without-move detection or a
// stuck transition settle the state without applying anything.
if final.LastMove != nil && len(final.Moves) > baselineMoves {
display.Print(display.Magenta, "Computer played: %s", final.LastMove.Move)
if final.LastMove.Depth > 0 {
fmt.Printf(" (depth %d, score %d)", final.LastMove.Depth, final.LastMove.Score)
// Poll for completion
for i := 0; i < 50; i++ {
time.Sleep(200 * time.Millisecond)
resp2, err := c.GetGame(gameID)
if err == nil && resp2.State != "pending" {
s.LastMoveCount = len(resp2.Moves)
s.CurrentGameState = resp2
if resp2.LastMove != nil {
display.Print(display.Magenta, "Computer played: %s", resp2.LastMove.Move)
if resp2.LastMove.Depth > 0 {
fmt.Printf(" (depth %d, score %d)", resp2.LastMove.Depth, resp2.LastMove.Score)
}
fmt.Println()
}
printOutcome(final)
return nil
// Check if game ended after computer move
switch resp2.State {
case "checkmate":
winner := "Black"
if resp2.Turn == "b" {
winner = "White"
}
// printOutcome reports terminal or error states using the server's actual
// State.String() values ("white wins"/"black wins", not "checkmate").
func printOutcome(resp *api.GameResponse) {
switch resp.State {
case "white wins":
display.Println(display.Green, "\nCHECKMATE! White wins!")
case "black wins":
display.Println(display.Green, "\nCHECKMATE! Black wins!")
display.Println(display.Green, "\nCHECKMATE! %s wins!", winner)
case "stalemate":
display.Println(display.Yellow, "\nSTALEMATE! Game drawn.")
case "draw":
display.Println(display.Yellow, "\nDRAW! Game drawn.")
case "stuck":
display.Println(display.Yellow, "\nEngine error — 'undo' to recover, or 'new'/'delete'.")
}
return nil
}
}
return fmt.Errorf("timeout waiting for computer move")
}
s.LastMoveCount = len(resp.Moves)
s.CurrentGameState = resp
display.Println(display.Green, "Move triggered")
return nil
}
func undoHandler(s *session.Session, args []string) error {
@@ -495,7 +476,7 @@ func deleteGameHandler(s *session.Session, args []string) error {
s.SetLastMoveCount(0)
}
fmt.Printf("%sLive game unloaded (history retained): %s%s\n", display.Green, gameID, display.Reset)
fmt.Printf("%sGame deleted: %s%s\n", display.Green, gameID, display.Reset)
return nil
}
@@ -509,7 +490,7 @@ func pollHandler(s *session.Session, args []string) error {
moveCount := s.GetLastMoveCount()
display.Println(display.Cyan, "Long-polling for updates (move count: %d)...", moveCount)
display.Println(display.Cyan, "This may take up to 30 seconds")
display.Println(display.Cyan, "This may take up to 25 seconds")
resp, err := c.GetGameWithPoll(gameID, moveCount)
if err != nil {
+1 -43
View File
@@ -1,7 +1,5 @@
package core
import "time"
// Request types
type CreateGameRequest struct {
@@ -29,7 +27,7 @@ type GameResponse struct {
GameID string `json:"gameId"`
FEN string `json:"fen"`
Turn string `json:"turn"` // "w" or "b"
State string `json:"state"` // "ongoing", "white wins", etc
State string `json:"state"` // "ongoing", "white_wins", etc
Moves []string `json:"moves"`
Players PlayersResponse `json:"players"`
LastMove *MoveInfo `json:"lastMove,omitempty"`
@@ -47,46 +45,6 @@ type BoardResponse struct {
Board string `json:"board"` // ASCII representation
}
// GameHistoryResponse is the durable replay representation of a game. Moves
// are ordered and include the resulting FEN so clients do not need an engine
// to replay a stored game.
type GameHistoryResponse struct {
GameID string `json:"gameId"`
InitialFEN string `json:"initialFen"`
Result string `json:"result,omitempty"`
StartTimeUTC time.Time `json:"startTimeUtc"`
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
Players PlayersResponse `json:"players"`
Moves []HistoryMove `json:"moves"`
}
type HistoryMove struct {
MoveNumber int `json:"moveNumber"`
MoveUCI string `json:"moveUci"`
FENAfterMove string `json:"fenAfterMove"`
PlayerColor string `json:"playerColor"`
MoveTimeUTC time.Time `json:"moveTimeUtc"`
}
// GameSummary is intentionally sufficient for a client-side game picker; the
// full move list remains on the per-game history endpoint.
type GameSummary struct {
GameID string `json:"gameId"`
InitialFEN string `json:"initialFen"`
Result string `json:"result,omitempty"`
StartTimeUTC time.Time `json:"startTimeUtc"`
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
MoveCount int `json:"moveCount"`
Players PlayersResponse `json:"players"`
}
type GameListResponse struct {
Games []GameSummary `json:"games"`
Limit int `json:"limit"`
Offset int `json:"offset"`
NextOffset *int `json:"nextOffset,omitempty"`
}
type ErrorResponse struct {
Error string `json:"error"`
Code string `json:"code"`
-2
View File
@@ -13,6 +13,4 @@ const (
ErrInternalError = "INTERNAL_ERROR"
ErrResourceLimit = "RESOURCE_LIMIT"
ErrUnauthorized = "UNAUTHORIZED"
ErrConflict = "GAME_CONFLICT"
ErrStorageUnavailable = "STORAGE_UNAVAILABLE"
)
+1 -28
View File
@@ -5,7 +5,7 @@ type State int
const (
StateOngoing State = iota
StatePending // Computer is calculating a move
StateStuck // Engine work failed and requires recovery or undo
StateStuck // Computer is calculating a move
StateWhiteWins
StateBlackWins
StateDraw
@@ -32,30 +32,3 @@ func (s State) String() string {
return "unknown"
}
}
// IsTerminal reports whether the game has a durable result. Pending and stuck
// are operational states and must not be archived as completed games.
func (s State) IsTerminal() bool {
switch s {
case StateWhiteWins, StateBlackWins, StateDraw, StateStalemate:
return true
default:
return false
}
}
// Result returns the stable persistence/API value for a terminal state.
func (s State) Result() (string, bool) {
switch s {
case StateWhiteWins:
return "white_wins", true
case StateBlackWins:
return "black_wins", true
case StateDraw:
return "draw", true
case StateStalemate:
return "stalemate", true
default:
return "", false
}
}
+180 -215
View File
@@ -2,7 +2,7 @@ package engine
import (
"bufio"
"errors"
"context"
"fmt"
"io"
"os/exec"
@@ -11,27 +11,13 @@ import (
"time"
)
const (
enginePath = "stockfish"
handshakeTimeout = 5 * time.Second
barrierTimeout = 5 * time.Second
diagnoseTimeout = 3 * time.Second
probeTimeout = 3 * time.Second
lineBuffer = 512
)
const enginePath = "stockfish"
var ErrEngineTimeout = errors.New("engine timeout")
// UCI wraps a stockfish process. All engine dialogue is a serialized
// request/response transaction under mu; a single reader goroutine owns stdout
// for the life of the process. Any timeout/EOF kills and respawns the process:
// output desync cannot survive into the next call.
type UCI struct {
mu sync.Mutex
cmd *exec.Cmd
stdin io.WriteCloser
lines chan string
alive bool
stdout *bufio.Scanner
mu sync.Mutex
}
type SearchResult struct {
@@ -42,246 +28,225 @@ type SearchResult struct {
MateIn int
}
type Diagnosis struct {
FEN string
InCheck bool
}
func New() (*UCI, error) {
u := &UCI{}
u.mu.Lock()
defer u.mu.Unlock()
if err := u.spawnLocked(); err != nil {
return nil, err
}
return u, nil
}
func (u *UCI) spawnLocked() error {
cmd := exec.Command(enginePath)
stdin, err := cmd.StdinPipe()
if err != nil {
return err
return nil, err
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("start engine: %w", err)
return nil, err
}
lines := make(chan string, lineBuffer)
go func() {
sc := bufio.NewScanner(stdout)
sc.Buffer(make([]byte, 64*1024), 1<<20)
for sc.Scan() {
lines <- sc.Text()
}
close(lines) // EOF: process exited or was killed
}()
u.cmd, u.stdin, u.lines, u.alive = cmd, stdin, lines, true
if _, err := u.txLocked(handshakeTimeout, []string{"uci"}, "uciok", nil); err != nil {
u.killLocked()
return fmt.Errorf("uci handshake: %w", err)
}
if _, err := u.txLocked(handshakeTimeout, []string{"isready"}, "readyok", nil); err != nil {
u.killLocked()
return fmt.Errorf("uci handshake: %w", err)
}
return nil
if err = cmd.Start(); err != nil {
return nil, fmt.Errorf("failed to start engine: %v", err)
}
// killLocked hard-stops the process. Reaping is deferred to a goroutine that
// first drains the line channel to completion, so cmd.Wait never races the
// reader's final reads on the stdout pipe.
func (u *UCI) killLocked() {
u.alive = false
if u.cmd != nil && u.cmd.Process != nil {
u.cmd.Process.Kill()
}
if u.stdin != nil {
u.stdin.Close()
}
if u.lines != nil {
go func(ch chan string, cmd *exec.Cmd) {
for range ch {
}
cmd.Wait()
}(u.lines, u.cmd)
}
uci := &UCI{
cmd: cmd,
stdin: stdin,
stdout: bufio.NewScanner(stdout),
}
func (u *UCI) restartLocked() {
u.killLocked()
_ = u.spawnLocked() // on failure alive stays false; next tx errors immediately
if err := uci.initialize(); err != nil {
uci.Close()
return nil, err
}
func (u *UCI) drainLocked() {
for {
select {
case _, ok := <-u.lines:
if !ok {
return
}
default:
return
}
}
return uci, nil
}
// txLocked: drain stale lines, send commands, read to the terminal prefix.
// visit observes every line including the terminal one. Timeout is
// per-transaction total.
func (u *UCI) txLocked(timeout time.Duration, cmds []string, terminal string, visit func(string)) (string, error) {
if !u.alive {
return "", errors.New("engine not running")
}
u.drainLocked()
for _, c := range cmds {
if _, err := fmt.Fprintln(u.stdin, c); err != nil {
u.restartLocked()
return "", fmt.Errorf("engine write: %w", err)
}
}
deadline := time.NewTimer(timeout)
defer deadline.Stop()
for {
select {
case ln, ok := <-u.lines:
if !ok {
u.restartLocked()
return "", errors.New("engine closed unexpectedly")
}
if visit != nil {
visit(ln)
}
if strings.HasPrefix(ln, terminal) {
return ln, nil
}
case <-deadline.C:
u.restartLocked()
return "", fmt.Errorf("%w awaiting %q", ErrEngineTimeout, terminal)
}
}
}
func (u *UCI) tx(timeout time.Duration, cmds []string, terminal string, visit func(string)) (string, error) {
u.mu.Lock()
defer u.mu.Unlock()
return u.txLocked(timeout, cmds, terminal, visit)
}
func (u *UCI) NewGame() error {
_, err := u.tx(barrierTimeout, []string{"ucinewgame", "isready"}, "readyok", nil)
return err
}
func (u *UCI) SetSkillLevel(level int) error {
// SetSkillLevel sets the Stockfish skill level (0-20)
func (u *UCI) SetSkillLevel(level int) {
if level < 0 {
level = 0
} else if level > 20 {
level = 20
}
_, err := u.tx(barrierTimeout,
[]string{fmt.Sprintf("setoption name Skill Level value %d", level), "isready"},
"readyok", nil)
return err
u.sendCommand(fmt.Sprintf("setoption name Skill Level value %d", level))
}
func (u *UCI) SetPosition(fen string, moves []string) error {
cmd := "position fen " + fen
// Get FEN from Stockfish's debug ('d') command
func (u *UCI) GetFEN() (string, error) {
u.sendCommand("d")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
done := make(chan string, 1)
go func() {
for u.stdout.Scan() {
line := u.stdout.Text()
if strings.HasPrefix(line, "Fen: ") {
done <- strings.TrimPrefix(line, "Fen: ")
return
}
}
done <- ""
}()
select {
case fen := <-done:
if fen == "" {
return "", fmt.Errorf("failed to get FEN from engine")
}
return fen, nil
case <-ctx.Done():
return "", fmt.Errorf("timeout getting FEN")
}
}
func (u *UCI) initialize() error {
u.sendCommand("uci")
// Wait for uciok with timeout
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
done := make(chan bool)
go func() {
for u.stdout.Scan() {
if u.stdout.Text() == "uciok" {
done <- true
return
}
}
done <- false
}()
select {
case success := <-done:
if !success {
return fmt.Errorf("engine closed unexpectedly")
}
case <-ctx.Done():
return fmt.Errorf("timeout waiting for uciok")
}
u.sendCommand("isready")
return u.waitReady()
}
func (u *UCI) waitReady() error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
done := make(chan error)
go func() {
for u.stdout.Scan() {
if u.stdout.Text() == "readyok" {
done <- nil
return
}
}
done <- fmt.Errorf("engine closed unexpectedly")
}()
select {
case err := <-done:
return err
case <-ctx.Done():
return fmt.Errorf("timeout waiting for readyok")
}
}
func (u *UCI) sendCommand(cmd string) {
u.mu.Lock()
defer u.mu.Unlock()
fmt.Fprintln(u.stdin, cmd)
}
func (u *UCI) NewGame() {
u.sendCommand("ucinewgame")
u.sendCommand("isready")
u.waitReady()
}
func (u *UCI) SetPosition(fen string, moves []string) {
cmd := fmt.Sprintf("position fen %s", fen)
if len(moves) > 0 {
cmd += " moves " + strings.Join(moves, " ")
}
_, err := u.tx(barrierTimeout, []string{cmd, "isready"}, "readyok", nil)
return err
}
// Diagnose runs `d` and consumes its full output. Terminal line is "Checkers:"
// (last line of `d` in current Stockfish; verify against the jailed build —
// see context requests).
func (u *UCI) Diagnose() (Diagnosis, error) {
var d Diagnosis
last, err := u.tx(diagnoseTimeout, []string{"d"}, "Checkers:", func(ln string) {
if s, ok := strings.CutPrefix(ln, "Fen: "); ok {
d.FEN = strings.TrimSpace(s)
}
})
if err != nil {
return Diagnosis{}, err
}
if d.FEN == "" {
return Diagnosis{}, errors.New("d output missing Fen line")
}
d.InCheck = strings.TrimSpace(strings.TrimPrefix(last, "Checkers:")) != ""
return d, nil
}
// HasLegalMoves probes with a depth-1 search: deterministic, milliseconds.
func (u *UCI) HasLegalMoves() (bool, error) {
last, err := u.tx(probeTimeout, []string{"go depth 1"}, "bestmove ", nil)
if err != nil {
return false, err
}
f := strings.Fields(last)
return len(f) >= 2 && f[1] != "(none)", nil
u.sendCommand(cmd)
}
func (u *UCI) Search(timeMs int) (*SearchResult, error) {
r := &SearchResult{}
timeout := time.Duration(timeMs)*time.Millisecond + 5*time.Second
last, err := u.tx(timeout, []string{fmt.Sprintf("go movetime %d", timeMs)}, "bestmove ", func(ln string) {
if !strings.HasPrefix(ln, "info ") {
u.sendCommand(fmt.Sprintf("go movetime %d", timeMs))
result := &SearchResult{}
// Add timeout protection (2x the search time + buffer)
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeMs*2+1000)*time.Millisecond)
defer cancel()
done := make(chan error)
go func() {
for u.stdout.Scan() {
line := u.stdout.Text()
if strings.HasPrefix(line, "info ") {
fields := strings.Fields(line)
for i := 0; i < len(fields)-1; i++ {
switch fields[i] {
case "depth":
fmt.Sscanf(fields[i+1], "%d", &result.Depth)
case "cp":
fmt.Sscanf(fields[i+1], "%d", &result.Score)
result.IsMate = false
case "mate":
fmt.Sscanf(fields[i+1], "%d", &result.MateIn)
result.IsMate = true
// Convert mate score to centipawn equivalent for backwards compatibility
if result.MateIn > 0 {
result.Score = 100000 - result.MateIn
} else {
result.Score = -100000 - result.MateIn
}
}
}
}
if strings.HasPrefix(line, "bestmove ") {
parts := strings.Fields(line)
if len(parts) >= 2 {
result.BestMove = parts[1]
}
done <- nil
return
}
f := strings.Fields(ln)
for i := 0; i < len(f)-1; i++ {
switch f[i] {
case "depth":
fmt.Sscanf(f[i+1], "%d", &r.Depth)
case "cp":
fmt.Sscanf(f[i+1], "%d", &r.Score)
r.IsMate = false
case "mate":
fmt.Sscanf(f[i+1], "%d", &r.MateIn)
r.IsMate = true
if r.MateIn > 0 {
r.Score = 100000 - r.MateIn
} else {
r.Score = -100000 - r.MateIn
}
}
}
})
done <- fmt.Errorf("engine closed unexpectedly")
}()
select {
case err := <-done:
if err != nil {
return nil, err
}
f := strings.Fields(last)
if len(f) >= 2 {
r.BestMove = f[1]
return result, nil
case <-ctx.Done():
return nil, fmt.Errorf("timeout waiting for bestmove")
}
return r, nil
}
func (u *UCI) Close() error {
u.mu.Lock()
defer u.mu.Unlock()
if u.alive {
fmt.Fprintln(u.stdin, "quit")
done := make(chan struct{})
go func() { u.cmd.Wait(); close(done) }()
u.alive = false
u.sendCommand("quit")
time.Sleep(100 * time.Millisecond)
// Try graceful shutdown first
done := make(chan error, 1)
go func() {
done <- u.cmd.Wait()
}()
select {
case <-done:
u.stdin.Close()
return nil
case <-time.After(1 * time.Second):
// Force kill if doesn't exit gracefully
return u.cmd.Process.Kill()
}
}
u.killLocked()
return nil
}
+10 -108
View File
@@ -2,7 +2,6 @@ package game
import (
"fmt"
"time"
"chess/internal/server/board"
"chess/internal/server/core"
@@ -26,41 +25,20 @@ type MoveResult struct {
}
type Game struct {
snapshots []Snapshot
players map[core.Color]*core.Player
state core.State
lastResult *MoveResult
endTimeUTC *time.Time
}
// View is an immutable copy of the state needed by processors and transports.
// Service returns views instead of exposing mutable Game pointers outside its
// lock, preventing torn responses and concurrent move races.
type View struct {
FEN string
InitialFEN string
NextTurnColor core.Color
Moves []string
WhitePlayer *core.Player
BlackPlayer *core.Player
State core.State
LastResult *MoveResult
EndTimeUTC *time.Time
snapshots []Snapshot `json:"snapshots"`
players map[core.Color]*core.Player `json:"players"`
state core.State `json:"state"`
lastResult *MoveResult `json:"lastResult,omitempty"`
}
func New(initialFEN string, whitePlayer, blackPlayer *core.Player, startingTurnColor core.Color) *Game {
// Determine which player's turn it is initially
var initialPlayerID string
var initialPlayerType core.PlayerType
if startingTurnColor == core.ColorWhite {
initialPlayerID = whitePlayer.ID
initialPlayerType = whitePlayer.Type
} else {
initialPlayerID = blackPlayer.ID
initialPlayerType = blackPlayer.Type
}
whiteCopy := *whitePlayer
blackCopy := *blackPlayer
return &Game{
snapshots: []Snapshot{
@@ -68,69 +46,19 @@ func New(initialFEN string, whitePlayer, blackPlayer *core.Player, startingTurnC
FEN: initialFEN,
PreviousMove: "",
NextTurnColor: startingTurnColor,
PlayerType: initialPlayerType,
PlayerID: initialPlayerID,
},
},
players: map[core.Color]*core.Player{
core.ColorWhite: &whiteCopy,
core.ColorBlack: &blackCopy,
core.ColorWhite: whitePlayer,
core.ColorBlack: blackPlayer,
},
state: core.StateOngoing,
}
}
func (g *Game) View() View {
view := View{
FEN: g.CurrentFEN(),
InitialFEN: g.InitialFEN(),
NextTurnColor: g.NextTurnColor(),
Moves: g.Moves(),
State: g.state,
}
if player := g.players[core.ColorWhite]; player != nil {
copy := *player
view.WhitePlayer = &copy
}
if player := g.players[core.ColorBlack]; player != nil {
copy := *player
view.BlackPlayer = &copy
}
if g.lastResult != nil {
copy := *g.lastResult
view.LastResult = &copy
}
if g.endTimeUTC != nil {
copy := *g.endTimeUTC
view.EndTimeUTC = &copy
}
return view
}
func (v View) NextPlayer() *core.Player {
if v.NextTurnColor == core.ColorWhite {
return v.WhitePlayer
}
return v.BlackPlayer
}
func (v View) Player(color core.Color) *core.Player {
if color == core.ColorWhite {
return v.WhitePlayer
}
if color == core.ColorBlack {
return v.BlackPlayer
}
return nil
}
func (g *Game) SetLastResult(result *MoveResult) {
if result == nil {
g.lastResult = nil
return
}
copy := *result
g.lastResult = &copy
g.lastResult = result
}
func (g *Game) LastResult() *MoveResult {
@@ -166,23 +94,18 @@ func (g *Game) AddSnapshot(fen string, move string, nextTurnColor core.Color) {
FEN: fen,
PreviousMove: move,
NextTurnColor: nextTurnColor,
PlayerType: nextPlayer.Type,
PlayerID: nextPlayer.ID,
})
}
func (g *Game) UpdatePlayers(whitePlayer, blackPlayer *core.Player) {
whiteCopy := *whitePlayer
blackCopy := *blackPlayer
g.players[core.ColorWhite] = &whiteCopy
g.players[core.ColorBlack] = &blackCopy
g.players[core.ColorWhite] = whitePlayer
g.players[core.ColorBlack] = blackPlayer
// Update current snapshot's PlayerID to reflect new player
if len(g.snapshots) > 0 {
currentSnap := &g.snapshots[len(g.snapshots)-1]
currentPlayer := g.players[currentSnap.NextTurnColor]
currentSnap.PlayerID = currentPlayer.ID
currentSnap.PlayerType = currentPlayer.Type
currentSnap.PlayerID = g.players[currentSnap.NextTurnColor].ID
}
}
@@ -199,7 +122,6 @@ func (g *Game) UndoMoves(count int) error {
g.snapshots = g.snapshots[:len(g.snapshots)-count]
g.state = core.StateOngoing // Reset game state when undoing
g.lastResult = nil // Clear last result
g.endTimeUTC = nil
return nil
}
@@ -218,29 +140,9 @@ func (g *Game) State() core.State {
}
func (g *Game) SetState(s core.State) {
g.SetStateAt(s, time.Now().UTC())
}
func (g *Game) SetStateAt(s core.State, at time.Time) {
if s.IsTerminal() {
if !g.state.IsTerminal() || g.endTimeUTC == nil {
ended := at.UTC()
g.endTimeUTC = &ended
}
} else if g.state.IsTerminal() {
g.endTimeUTC = nil
}
g.state = s
}
func (g *Game) EndTimeUTC() *time.Time {
if g.endTimeUTC == nil {
return nil
}
copy := *g.endTimeUTC
return &copy
}
func (g *Game) InitialFEN() string {
if len(g.snapshots) > 0 {
return g.snapshots[0].FEN
+18 -20
View File
@@ -1,7 +1,6 @@
package http
import (
"errors"
"fmt"
"regexp"
"strings"
@@ -91,21 +90,9 @@ func (h *HTTPHandler) RegisterHandler(c *fiber.Ctx) error {
req.Email = strings.ToLower(req.Email)
}
// Create the user and initial session atomically (temp by default via API).
user, sessionID, err := h.svc.RegisterUser(req.Username, req.Email, req.Password, false)
// Create user (temp by default via API)
user, err := h.svc.CreateUser(req.Username, req.Email, req.Password, false)
if err != nil {
if errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable) {
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
Error: "authentication storage unavailable", Code: core.ErrStorageUnavailable,
})
}
if errors.Is(err, service.ErrAtCapacity) || errors.Is(err, service.ErrPermanentSlotsFull) {
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
Error: "registration temporarily unavailable",
Code: core.ErrResourceLimit,
Details: err.Error(),
})
}
if strings.Contains(err.Error(), "already exists") {
return c.Status(fiber.StatusConflict).JSON(core.ErrorResponse{
Error: "user already exists",
@@ -113,12 +100,28 @@ func (h *HTTPHandler) RegisterHandler(c *fiber.Ctx) error {
Details: "username or email already taken",
})
}
if strings.Contains(err.Error(), "limit") || strings.Contains(err.Error(), "capacity") {
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
Error: "registration temporarily unavailable",
Code: core.ErrResourceLimit,
Details: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
Error: "failed to create user",
Code: core.ErrInternalError,
})
}
// Create session for new user
sessionID, err := h.svc.CreateUserSession(user.UserID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
Error: "failed to create session",
Code: core.ErrInternalError,
})
}
// Generate JWT token
token, err := h.svc.GenerateUserToken(user.UserID, sessionID)
if err != nil {
@@ -189,11 +192,6 @@ func (h *HTTPHandler) LoginHandler(c *fiber.Ctx) error {
// Authenticate user and create session (invalidates previous session)
user, sessionID, err := h.svc.AuthenticateUser(req.Identifier, req.Password)
if err != nil {
if errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable) {
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
Error: "authentication storage unavailable", Code: core.ErrStorageUnavailable,
})
}
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
Error: "invalid credentials",
Code: core.ErrInvalidRequest,
+12 -107
View File
@@ -1,7 +1,6 @@
package http
import (
"errors"
"fmt"
"strconv"
"strings"
@@ -30,27 +29,23 @@ func NewHTTPHandler(proc *processor.Processor, svc *service.Service) *HTTPHandle
return &HTTPHandler{proc: proc, svc: svc}
}
func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode, logRequests bool) *fiber.App {
func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode bool) *fiber.App {
// Create handler
h := NewHTTPHandler(proc, svc)
// Initialize Fiber app
app := fiber.New(fiber.Config{
ErrorHandler: customErrorHandler,
ReadTimeout: 15 * time.Second,
WriteTimeout: 35 * time.Second,
IdleTimeout: 60 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 30 * time.Second,
})
// Global middleware (order matters)
app.Use(recover.New())
if logRequests {
app.Use(logger.New(logger.Config{
Format: "${time} HTTP ${status} ${method} ${path} ${latency}\n",
TimeFormat: time.RFC3339,
TimeZone: "UTC",
Format: "${time} ${status} ${method} ${path} ${latency}\n",
}))
}
app.Use(cors.New(cors.Config{
AllowOrigins: "*",
AllowMethods: "GET,POST,PUT,DELETE,OPTIONS",
@@ -141,14 +136,12 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode, logRe
// Register game routes with auth middleware
api.Post("/games", OptionalAuth(validateToken), h.CreateGame) // Optional auth for player ID association
api.Get("/games/:gameId/history", h.GetGameHistory)
api.Put("/games/:gameId/players", h.ConfigurePlayers)
api.Get("/games/:gameId", h.GetGame)
api.Delete("/games/:gameId", h.DeleteGame)
api.Post("/games/:gameId/moves", OptionalAuth(validateToken), h.MakeMove)
api.Post("/games/:gameId/undo", h.UndoMove)
api.Get("/games/:gameId/board", h.GetBoard)
api.Get("/users/me/games", AuthRequired(validateToken), h.GetCurrentUserGames)
return app
}
@@ -198,15 +191,10 @@ func customErrorHandler(c *fiber.Ctx, err error) error {
// Health check endpoint with storage status
func (h *HTTPHandler) Health(c *fiber.Ctx) error {
storageHealth := h.svc.GetStorageHealth()
status := "healthy"
if storageHealth == "degraded" {
status = "degraded"
}
return c.JSON(fiber.Map{
"status": status,
"status": "healthy",
"time": time.Now().Unix(),
"storage": storageHealth,
"storage": h.svc.GetStorageHealth(),
})
}
@@ -336,7 +324,7 @@ func (h *HTTPHandler) GetGame(c *fiber.Ctx) error {
}
// First check if game exists and get current state
g, err := h.svc.GetGameView(gameID)
g, err := h.svc.GetGame(gameID)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(core.ErrorResponse{
Error: "game not found",
@@ -344,11 +332,10 @@ func (h *HTTPHandler) GetGame(c *fiber.Ctx) error {
})
}
currentMoveCount := len(g.Moves)
st := g.State
settled := st != core.StateOngoing && st != core.StatePending
currentMoveCount := len(g.Moves())
// If move count already different, return immediately
if moveCount != currentMoveCount || settled {
if moveCount != currentMoveCount {
cmd := processor.NewGetGameCommand(gameID)
resp := h.proc.Execute(cmd)
if !resp.Success {
@@ -426,8 +413,6 @@ func (h *HTTPHandler) MakeMove(c *fiber.Ctx) error {
statusCode = fiber.StatusNotFound
case core.ErrUnauthorized:
statusCode = fiber.StatusForbidden
case core.ErrConflict:
statusCode = fiber.StatusConflict
}
return c.Status(statusCode).JSON(resp.Error)
}
@@ -484,7 +469,7 @@ func (h *HTTPHandler) UndoMove(c *fiber.Ctx) error {
return c.JSON(resp.Data)
}
// DeleteGame unloads a live game while retaining its durable history.
// DeleteGame ends and cleans up a game
func (h *HTTPHandler) DeleteGame(c *fiber.Ctx) error {
gameID := c.Params("gameId")
@@ -533,83 +518,3 @@ func (h *HTTPHandler) GetBoard(c *fiber.Ctx) error {
return c.JSON(resp.Data)
}
// GetGameHistory serves persisted replay data. Histories are public by game ID,
// matching the existing public live-game read model.
func (h *HTTPHandler) GetGameHistory(c *fiber.Ctx) error {
gameID := c.Params("gameId")
if !isValidUUID(gameID) {
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
Error: "invalid game ID format", Code: core.ErrInvalidRequest,
Details: "game ID must be a valid UUID",
})
}
history, err := h.svc.GetGameHistory(gameID)
if err != nil {
switch {
case errors.Is(err, service.ErrStorageDisabled), errors.Is(err, service.ErrStorageUnavailable):
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
Error: "game history storage unavailable", Code: core.ErrStorageUnavailable,
})
case errors.Is(err, service.ErrGameNotFound):
return c.Status(fiber.StatusNotFound).JSON(core.ErrorResponse{
Error: "game history not found", Code: core.ErrGameNotFound,
})
default:
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
Error: "failed to load game history", Code: core.ErrInternalError,
})
}
}
return c.JSON(history)
}
// GetCurrentUserGames returns a bounded list suitable for CLI and web game
// pickers. The extra row used to compute nextOffset stays internal.
func (h *HTTPHandler) GetCurrentUserGames(c *fiber.Ctx) error {
userID, ok := c.Locals("userID").(string)
if !ok || userID == "" {
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
Error: "unauthorized", Code: core.ErrUnauthorized,
})
}
limit, err := queryInt(c, "limit", 50, 1, 100)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
Error: "invalid pagination", Code: core.ErrInvalidRequest, Details: err.Error(),
})
}
offset, err := queryInt(c, "offset", 0, 0, 1_000_000)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
Error: "invalid pagination", Code: core.ErrInvalidRequest, Details: err.Error(),
})
}
games, err := h.svc.GetUserGames(userID, limit, offset)
if err != nil {
if errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable) {
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
Error: "stored games unavailable", Code: core.ErrStorageUnavailable,
})
}
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
Error: "failed to load stored games", Code: core.ErrInternalError,
})
}
return c.JSON(games)
}
func queryInt(c *fiber.Ctx, name string, defaultValue, minimum, maximum int) (int, error) {
raw := c.Query(name)
if raw == "" {
return defaultValue, nil
}
value, err := strconv.Atoi(raw)
if err != nil || value < minimum || value > maximum {
return 0, fmt.Errorf("%s must be between %d and %d", name, minimum, maximum)
}
return value, nil
}
+5 -22
View File
@@ -1,11 +1,9 @@
package http
import (
"errors"
"strings"
"chess/internal/server/core"
"chess/internal/server/service"
"github.com/gofiber/fiber/v2"
)
@@ -20,20 +18,15 @@ func AuthRequired(validateToken TokenValidator) fiber.Handler {
if token == "" {
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
Error: "missing authorization token",
Code: core.ErrUnauthorized,
Code: core.ErrInvalidRequest,
})
}
userID, claims, err := validateToken(token)
if err != nil {
if errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable) {
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
Error: "authentication storage unavailable", Code: core.ErrStorageUnavailable,
})
}
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
Error: "invalid or expired token",
Code: core.ErrUnauthorized,
Code: core.ErrInvalidRequest,
})
}
@@ -45,8 +38,7 @@ func AuthRequired(validateToken TokenValidator) fiber.Handler {
}
}
// OptionalAuth permits an absent token but rejects an invalid token instead of
// silently downgrading an intended authenticated request to anonymous access.
// OptionalAuth validates JWT if present but allows anonymous access
func OptionalAuth(validateToken TokenValidator) fiber.Handler {
return func(c *fiber.Ctx) error {
token := extractBearerToken(c.Get("Authorization"))
@@ -55,21 +47,12 @@ func OptionalAuth(validateToken TokenValidator) fiber.Handler {
}
userID, claims, err := validateToken(token)
if err != nil {
if errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable) {
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
Error: "authentication storage unavailable", Code: core.ErrStorageUnavailable,
})
}
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
Error: "invalid or expired token", Code: core.ErrUnauthorized,
})
}
if err == nil {
c.Locals("userID", userID)
if sessionID, ok := claims["session_id"].(string); ok {
c.Locals("sessionID", sessionID)
}
}
return c.Next()
}
}
-60
View File
@@ -1,60 +0,0 @@
package http
import (
"errors"
"net/http/httptest"
"testing"
"chess/internal/server/service"
"github.com/gofiber/fiber/v2"
)
func TestOptionalAuthAllowsAbsenceButRejectsInvalidToken(t *testing.T) {
app := fiber.New()
app.Get("/optional", OptionalAuth(func(string) (string, map[string]any, error) {
return "", nil, errors.New("invalid token")
}), func(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusNoContent)
})
request := httptest.NewRequest("GET", "/optional", nil)
response, err := app.Test(request)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != fiber.StatusNoContent {
t.Fatalf("anonymous status = %d, want %d", response.StatusCode, fiber.StatusNoContent)
}
request = httptest.NewRequest("GET", "/optional", nil)
request.Header.Set("Authorization", "Bearer invalid")
response, err = app.Test(request)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != fiber.StatusUnauthorized {
t.Fatalf("invalid-token status = %d, want %d", response.StatusCode, fiber.StatusUnauthorized)
}
}
func TestAuthMiddlewareReportsStorageUnavailable(t *testing.T) {
for _, middleware := range []func(TokenValidator) fiber.Handler{AuthRequired, OptionalAuth} {
app := fiber.New()
app.Get("/protected", middleware(func(string) (string, map[string]any, error) {
return "", nil, service.ErrStorageUnavailable
}), func(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusNoContent)
})
request := httptest.NewRequest("GET", "/protected", nil)
request.Header.Set("Authorization", "Bearer token")
response, err := app.Test(request)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != fiber.StatusServiceUnavailable {
t.Fatalf("status = %d, want %d", response.StatusCode, fiber.StatusServiceUnavailable)
}
}
}
+159 -199
View File
@@ -1,9 +1,8 @@
package processor
import (
"errors"
"fmt"
"log/slog"
"log"
"regexp"
"strings"
"sync"
@@ -115,9 +114,7 @@ func (p *Processor) isMoveSafe(move string) bool {
return true
}
// handleCreateGame creates a new game. The initial FEN is classified BEFORE
// persisting: a terminal initial position is terminal in the creation response,
// and engine failure fails the request instead of creating a half-valid game.
// handleCreateGame creates a new game and triggers computer move if needed
func (p *Processor) handleCreateGame(cmd Command) ProcessorResponse {
args, ok := cmd.Args.(core.CreateGameRequest)
if !ok {
@@ -125,10 +122,10 @@ func (p *Processor) handleCreateGame(cmd Command) ProcessorResponse {
}
// Enforce minimum searchTime for computer players
if args.White.Type == core.PlayerComputer && args.White.SearchTime < minSearchTime {
if args.White.Type == core.PlayerComputer && args.White.SearchTime < 100 {
args.White.SearchTime = minSearchTime
}
if args.Black.Type == core.PlayerComputer && args.Black.SearchTime < minSearchTime {
if args.Black.Type == core.PlayerComputer && args.Black.SearchTime < 100 {
args.Black.SearchTime = minSearchTime
}
@@ -141,9 +138,10 @@ func (p *Processor) handleCreateGame(cmd Command) ProcessorResponse {
)
}
// Generate game ID
gameID := p.svc.GenerateGameID()
// Validate FEN safety, then classify via engine
// Validate and canonicalize FEN if provided
initialFEN := board.StartingFEN
if args.FEN != "" {
if !p.isFENSafe(args.FEN) {
@@ -153,18 +151,16 @@ func (p *Processor) handleCreateGame(cmd Command) ProcessorResponse {
}
p.mu.Lock()
err := p.validationEng.NewGame()
var validatedFEN string
initialState := core.StateOngoing
if err == nil {
validatedFEN, initialState, err = p.classifyLocked(initialFEN)
}
p.validationEng.NewGame()
p.validationEng.SetPosition(initialFEN, []string{})
validatedFEN, err := p.validationEng.GetFEN()
p.mu.Unlock()
if err != nil {
return p.errorResponse(fmt.Sprintf("engine validation failed: %v", err), core.ErrInternalError)
return p.errorResponse(fmt.Sprintf("invalid FEN: %v", err), core.ErrInvalidRequest)
}
// Parse canonical FEN to get starting turn
// Parse to get starting turn
b, err := board.ParseFEN(validatedFEN)
if err != nil {
return p.errorResponse(fmt.Sprintf("FEN parse error: %v", err), core.ErrInvalidRequest)
@@ -174,30 +170,39 @@ func (p *Processor) handleCreateGame(cmd Command) ProcessorResponse {
whitePlayer := core.NewPlayer(args.White, core.ColorWhite)
blackPlayer := core.NewPlayer(args.Black, core.ColorBlack)
// Only assign authenticated user to ONE human slot.
// If both are human, authenticated user gets white; black remains unclaimed.
// FIX: Only assign authenticated user to ONE human slot
// If both are human, authenticated user gets white; black remains unclaimed
if cmd.UserID != "" {
if args.White.Type == core.PlayerHuman {
whitePlayer.ID = cmd.UserID
whitePlayer.ClaimedBy = cmd.UserID
} else if args.Black.Type == core.PlayerHuman {
// Only claim black if white is not human (i.e., H vs C scenario)
blackPlayer.ID = cmd.UserID
blackPlayer.ClaimedBy = cmd.UserID
}
}
if err = p.svc.CreateGame(gameID, whitePlayer, blackPlayer, validatedFEN, b.Turn(), initialState); err != nil {
// Create game in service with fully-formed players
if err = p.svc.CreateGame(gameID, whitePlayer, blackPlayer, validatedFEN, b.Turn()); err != nil {
return p.errorResponse(fmt.Sprintf("failed to create game: %v", err), core.ErrInternalError)
}
g, err := p.svc.GetGameView(gameID)
// Check if the initial FEN represents a completed game
p.checkGameEnd(gameID, validatedFEN, core.OppositeColor(b.Turn()))
// Get created game
g, err := p.svc.GetGame(gameID)
if err != nil {
return p.errorResponse("game creation failed", core.ErrInternalError)
}
// Build response
response := p.buildGameResponse(gameID, g)
return ProcessorResponse{
Success: true,
Data: p.buildGameResponse(gameID, g),
Data: response,
}
}
@@ -215,13 +220,13 @@ func (p *Processor) handleConfigurePlayers(cmd Command) ProcessorResponse {
args.Black.SearchTime = minSearchTime
}
g, err := p.svc.GetGameView(cmd.GameID)
g, err := p.svc.GetGame(cmd.GameID)
if err != nil {
return p.errorResponse("game not found", core.ErrGameNotFound)
}
// Block configuration changes during computer move
if g.State == core.StatePending {
if g.State() == core.StatePending {
return p.errorResponse("cannot change players while computer is calculating", core.ErrInvalidRequest)
}
@@ -235,7 +240,7 @@ func (p *Processor) handleConfigurePlayers(cmd Command) ProcessorResponse {
}
// Get updated game
g, _ = p.svc.GetGameView(cmd.GameID)
g, _ = p.svc.GetGame(cmd.GameID)
response := p.buildGameResponse(cmd.GameID, g)
return ProcessorResponse{
@@ -246,7 +251,7 @@ func (p *Processor) handleConfigurePlayers(cmd Command) ProcessorResponse {
// handleGetGame retrieves game state and triggers computer move if needed
func (p *Processor) handleGetGame(cmd Command) ProcessorResponse {
g, err := p.svc.GetGameView(cmd.GameID)
g, err := p.svc.GetGame(cmd.GameID)
if err != nil {
return p.errorResponse("game not found", core.ErrGameNotFound)
}
@@ -259,41 +264,34 @@ func (p *Processor) handleGetGame(cmd Command) ProcessorResponse {
}
}
// handleMakeMove processes human moves with authorization, and the "cccc"
// computer-move trigger. Post-move classification runs BEFORE the move is
// applied; move + final state + metadata commit atomically with one
// notification, so a waking long-poller can never observe "ongoing" on a
// terminal position.
// handleMakeMove processes human moves with authorization
func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
args, ok := cmd.Args.(core.MoveRequest)
if !ok {
return p.errorResponse("invalid arguments", core.ErrInvalidRequest)
}
g, err := p.svc.GetGameView(cmd.GameID)
g, err := p.svc.GetGame(cmd.GameID)
if err != nil {
return p.errorResponse("game not found", core.ErrGameNotFound)
}
// Validate game state
switch g.State {
switch g.State() {
case core.StatePending:
return p.errorResponse("computer move in progress", core.ErrInvalidRequest)
case core.StateStuck:
return p.errorResponse("game is stuck due to engine error", core.ErrGameOver)
case core.StateWhiteWins, core.StateBlackWins, core.StateDraw, core.StateStalemate:
return p.errorResponse(fmt.Sprintf("game is over: %s", g.State), core.ErrGameOver)
return p.errorResponse(fmt.Sprintf("game is over: %s", g.State()), core.ErrGameOver)
case core.StateOngoing:
break
default:
return p.errorResponse("game is in invalid state", core.ErrInvalidRequest)
}
currentColor := g.NextTurnColor
currentColor := g.NextTurnColor()
currentPlayer := g.NextPlayer()
if currentPlayer == nil {
return p.errorResponse("current player is missing", core.ErrInternalError)
}
// Handle computer move trigger
if strings.TrimSpace(args.Move) == "cccc" {
@@ -301,18 +299,10 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
return p.errorResponse("not computer player's turn", core.ErrNotHumanTurn)
}
if err := p.svc.BeginComputerMove(cmd.GameID, g.FEN, currentColor); err != nil {
if errors.Is(err, service.ErrGameChanged) {
return p.errorResponse("game changed; refresh and retry", core.ErrConflict)
}
return p.errorResponse(fmt.Sprintf("failed to start computer move: %v", err), core.ErrInternalError)
}
if err := p.triggerComputerMove(cmd.GameID, g); err != nil {
p.svc.UpdateGameState(cmd.GameID, core.StateStuck)
return p.errorResponse(fmt.Sprintf("failed to queue computer move: %v", err), core.ErrResourceLimit)
}
p.svc.UpdateGameState(cmd.GameID, core.StatePending)
p.triggerComputerMove(cmd.GameID, g)
g, _ = p.svc.GetGameView(cmd.GameID)
g, _ = p.svc.GetGame(cmd.GameID)
response := p.buildGameResponse(cmd.GameID, g)
response.LastMove = &core.MoveInfo{
PlayerColor: currentColor.String(),
@@ -331,14 +321,22 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
}
// Authorization: first-move-claims-slot model
slotOwner := currentPlayer.ClaimedBy
slotOwner := g.GetSlotOwner(currentColor)
if slotOwner == "" {
// An authenticated user claims only when the validated move commits.
// Anonymous moves deliberately leave the slot unclaimed.
// Slot unclaimed - claim it with this move
if cmd.UserID != "" {
if err := p.svc.ClaimGameSlot(cmd.GameID, currentColor, cmd.UserID); err != nil {
return p.errorResponse(fmt.Sprintf("failed to claim slot: %v", err), core.ErrInternalError)
}
}
// Anonymous users can also claim by making a move (slot remains "unclaimed" but move proceeds)
} else if cmd.UserID != "" && slotOwner != cmd.UserID {
// Slot claimed by different user
return p.errorResponse("not your turn - slot claimed by another player", core.ErrUnauthorized)
}
// If slotOwner == cmd.UserID, authorized to proceed
// If slotOwner != "" && cmd.UserID == "", anonymous trying to move claimed slot - block
if slotOwner != "" && cmd.UserID == "" {
return p.errorResponse("slot claimed - authentication required", core.ErrUnauthorized)
}
@@ -349,61 +347,62 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
return p.errorResponse("invalid move format", core.ErrInvalidMove)
}
currentFEN := g.FEN
currentFEN := g.CurrentFEN()
// Validate move and classify the resulting position in one engine session
// Validate move with engine
p.mu.Lock()
err = p.validationEng.SetPosition(currentFEN, []string{move})
var newFEN string
finalState := core.StateOngoing
if err == nil {
newFEN, finalState, err = p.classifyCurrentLocked()
}
p.validationEng.SetPosition(currentFEN, []string{move})
newFEN, err := p.validationEng.GetFEN()
p.mu.Unlock()
if err != nil {
// Game untouched at pre-move position; retry runs on a respawned engine
return p.errorResponse("engine unavailable", core.ErrInternalError)
}
if newFEN == currentFEN {
if err != nil || newFEN == currentFEN {
return p.errorResponse("illegal move", core.ErrInvalidMove)
}
// Atomic commit: move + state + metadata, single notification
if err = p.svc.ApplyMoveWithState(cmd.GameID, service.MoveCommit{
ExpectedFEN: currentFEN, ExpectedState: core.StateOngoing, ExpectedTurn: currentColor,
ActorUserID: cmd.UserID, MoveUCI: move, NewFEN: newFEN, State: finalState,
Result: &game.MoveResult{Move: move, PlayerColor: currentColor, GameState: finalState},
}); err != nil {
if errors.Is(err, service.ErrSlotOwner) {
return p.errorResponse("not your turn - slot claimed by another player", core.ErrUnauthorized)
}
if errors.Is(err, service.ErrGameChanged) {
return p.errorResponse("game changed while move was being validated; refresh and retry", core.ErrConflict)
}
// Apply move to game state via service
if err = p.svc.ApplyMove(cmd.GameID, move, newFEN); err != nil {
return p.errorResponse(fmt.Sprintf("failed to apply move: %v", err), core.ErrInternalError)
}
// buildGameResponse populates LastMove from the committed LastResult
g, _ = p.svc.GetGameView(cmd.GameID)
// Store move result metadata
p.svc.SetLastMoveResult(cmd.GameID, &game.MoveResult{
Move: move,
PlayerColor: currentColor,
GameState: core.StateOngoing,
})
// Check for checkmate/stalemate
p.checkGameEnd(cmd.GameID, newFEN, currentColor)
// Get updated game
g, _ = p.svc.GetGame(cmd.GameID)
response := p.buildGameResponse(cmd.GameID, g)
// Add human move info
response.LastMove = &core.MoveInfo{
Move: move,
PlayerColor: currentColor.String(),
}
return ProcessorResponse{
Success: true,
Data: p.buildGameResponse(cmd.GameID, g),
Data: response,
}
}
// handleUndoMove reverts game state. StateStuck is deliberately permitted:
// undo -> StateOngoing is the recovery path for engine failures. Terminal
// states are also permitted so a finished game can be rewound. Any reverted-to
// snapshot had legal moves made from it, so resetting to Ongoing is sound
// without re-classification.
// handleUndoMove reverts game state
func (p *Processor) handleUndoMove(cmd Command) ProcessorResponse {
g, err := p.svc.GetGameView(cmd.GameID)
g, err := p.svc.GetGame(cmd.GameID)
if err != nil {
return p.errorResponse("game not found", core.ErrGameNotFound)
}
if g.State == core.StatePending {
// Check game state
switch g.State() {
case core.StatePending:
return p.errorResponse("cannot undo while computer move is in progress", core.ErrInvalidRequest)
case core.StateStuck:
return p.errorResponse("cannot undo in stuck game", core.ErrInvalidRequest)
}
args := core.UndoRequest{Count: 1}
@@ -420,22 +419,27 @@ func (p *Processor) handleUndoMove(cmd Command) ProcessorResponse {
return p.errorResponse(err.Error(), core.ErrInvalidRequest)
}
g, _ = p.svc.GetGameView(cmd.GameID)
// Reset game state to ongoing after undo
p.svc.UpdateGameState(cmd.GameID, core.StateOngoing)
g, _ = p.svc.GetGame(cmd.GameID)
response := p.buildGameResponse(cmd.GameID, g)
return ProcessorResponse{
Success: true,
Data: p.buildGameResponse(cmd.GameID, g),
Data: response,
}
}
// handleDeleteGame unloads a game from live memory.
// handleDeleteGame removes a game
func (p *Processor) handleDeleteGame(cmd Command) ProcessorResponse {
g, err := p.svc.GetGameView(cmd.GameID)
g, err := p.svc.GetGame(cmd.GameID)
if err != nil {
return p.errorResponse("game not found", core.ErrGameNotFound)
}
// Only block deletion if actively computing
if g.State == core.StatePending {
if g.State() == core.StatePending {
return p.errorResponse("cannot delete game while computer move is in progress", core.ErrInvalidRequest)
}
@@ -450,12 +454,12 @@ func (p *Processor) handleDeleteGame(cmd Command) ProcessorResponse {
// handleGetBoard returns board visualization
func (p *Processor) handleGetBoard(cmd Command) ProcessorResponse {
g, err := p.svc.GetGameView(cmd.GameID)
g, err := p.svc.GetGame(cmd.GameID)
if err != nil {
return p.errorResponse("game not found", core.ErrGameNotFound)
}
b, err := board.ParseFEN(g.FEN)
b, err := board.ParseFEN(g.CurrentFEN())
if err != nil {
return p.errorResponse("error parsing FEN", core.ErrInvalidFEN)
}
@@ -464,70 +468,70 @@ func (p *Processor) handleGetBoard(cmd Command) ProcessorResponse {
return ProcessorResponse{
Success: true,
Data: core.BoardResponse{
FEN: g.FEN,
FEN: g.CurrentFEN(),
Board: ascii,
},
}
}
// triggerComputerMove initiates async engine calculation. The callback
// re-classifies via the validation engine: worker output is never trusted for
// end-state determination, and no-move results are verified against the
// position rather than the IsMate info-line byproduct.
func (p *Processor) triggerComputerMove(gameID string, g game.View) error {
fen := g.FEN
color := g.NextTurnColor
// triggerComputerMove initiates async engine calculation
func (p *Processor) triggerComputerMove(gameID string, g *game.Game) {
fen := g.CurrentFEN()
color := g.NextTurnColor()
player := g.NextPlayer()
return p.queue.SubmitAsync(gameID, fen, color, player, func(result EngineResult) {
currentGame, err := p.svc.GetGameView(gameID)
if err != nil || currentGame.State != core.StatePending || currentGame.FEN != fen {
return // Deleted, or state resolved elsewhere
// Submit to queue with callback and computer config
p.queue.SubmitAsync(gameID, fen, color, player, func(result EngineResult) {
// Check if game still exists
currentGame, err := p.svc.GetGame(gameID)
if err != nil {
return // Game was deleted
}
// Only process if still in pending state
if currentGame.State() != core.StatePending {
return
}
if result.Error != nil {
slog.Error("computer engine failed", "game_id", gameID, "error", result.Error)
log.Printf("Engine error for game %s: %v", gameID, result.Error)
p.svc.UpdateGameState(gameID, core.StateStuck)
return
}
if result.Move == "" || result.Move == "(none)" {
// Worker says no legal moves; verify against the validation engine.
p.mu.Lock()
_, state, cerr := p.classifyLocked(fen)
p.mu.Unlock()
if cerr != nil || state == core.StateOngoing {
p.svc.UpdateGameState(gameID, core.StateStuck) // engines disagree
return
}
// Use centralized state determination
state := p.determineGameEndState(core.OppositeColor(color), &engine.SearchResult{
BestMove: result.Move,
Score: result.Score,
Depth: result.Depth,
IsMate: result.IsMate,
MateIn: result.MateIn,
})
if state != core.StateOngoing {
p.svc.UpdateGameState(gameID, state)
return
}
// Apply computer move
p.mu.Lock()
aerr := p.validationEng.SetPosition(fen, []string{result.Move})
var newFEN string
finalState := core.StateOngoing
if aerr == nil {
newFEN, finalState, aerr = p.classifyCurrentLocked()
}
p.validationEng.SetPosition(fen, []string{result.Move})
newFEN, _ := p.validationEng.GetFEN()
p.mu.Unlock()
if aerr != nil || newFEN == fen {
p.svc.UpdateGameState(gameID, core.StateStuck)
return
}
if err := p.svc.ApplyMoveWithState(gameID, service.MoveCommit{
ExpectedFEN: fen, ExpectedState: core.StatePending, ExpectedTurn: color,
MoveUCI: result.Move, NewFEN: newFEN, State: finalState,
Result: &game.MoveResult{
Move: result.Move, PlayerColor: color,
Score: result.Score, Depth: result.Depth, GameState: finalState,
},
}); err != nil {
slog.Error("failed to apply computer move", "game_id", gameID, "error", err)
if !errors.Is(err, service.ErrGameChanged) && !errors.Is(err, service.ErrGameNotFound) {
p.svc.UpdateGameState(gameID, core.StateStuck)
}
}
p.svc.ApplyMove(gameID, result.Move, newFEN)
p.svc.SetLastMoveResult(gameID, &game.MoveResult{
Move: result.Move,
PlayerColor: color,
Score: result.Score,
Depth: result.Depth,
})
// Reset to ongoing first
p.svc.UpdateGameState(gameID, core.StateOngoing)
// Check if opponent is checkmated
p.checkGameEnd(gameID, newFEN, color)
})
}
@@ -550,77 +554,36 @@ func (p *Processor) determineGameEndState(lastMoveBy core.Color, searchResult *e
return core.StateOngoing
}
// classifyCurrentLocked classifies whatever position is loaded in the
// validation engine. Caller holds p.mu, immediately after a SetPosition.
func (p *Processor) classifyCurrentLocked() (fen string, state core.State, err error) {
diag, err := p.validationEng.Diagnose()
if err != nil {
return "", core.StateOngoing, err
}
legal, err := p.validationEng.HasLegalMoves()
if err != nil {
return "", core.StateOngoing, err
}
if legal {
return diag.FEN, core.StateOngoing, nil
}
if !diag.InCheck {
return diag.FEN, core.StateStalemate, nil
}
b, err := board.ParseFEN(diag.FEN)
if err != nil {
return "", core.StateOngoing, err
}
if b.Turn() == core.ColorWhite {
return diag.FEN, core.StateBlackWins, nil
}
return diag.FEN, core.StateWhiteWins, nil
}
// classifyLocked sets a position from fen and classifies it. Caller holds p.mu.
func (p *Processor) classifyLocked(fen string) (string, core.State, error) {
if err := p.validationEng.SetPosition(fen, nil); err != nil {
return "", core.StateOngoing, err
}
return p.classifyCurrentLocked()
}
// checkGameEnd: retry once (second attempt runs on a respawned process), then
// fail SAFE to StateStuck. Leaving a possibly-terminal position Ongoing is the
// original bug class; Stuck is now recoverable via undo (see handleUndoMove).
func (p *Processor) checkGameEnd(gameID, fen string) {
for attempt := 0; attempt < 2; attempt++ {
// checkGameEnd determines if game has ended
func (p *Processor) checkGameEnd(gameID, fen string, lastMoveBy core.Color) {
p.mu.Lock()
_, state, err := p.classifyLocked(fen)
p.validationEng.SetPosition(fen, []string{})
search, _ := p.validationEng.Search(100)
p.mu.Unlock()
if err == nil {
// Use centralized state determination
state := p.determineGameEndState(lastMoveBy, search)
if state != core.StateOngoing {
p.svc.UpdateGameState(gameID, state)
}
return
}
slog.Warn("game end-state check failed",
"game_id", gameID, "attempt", attempt+1, "error", err)
}
p.svc.UpdateGameState(gameID, core.StateStuck)
}
// buildGameResponse constructs standard game response
func (p *Processor) buildGameResponse(gameID string, g game.View) core.GameResponse {
func (p *Processor) buildGameResponse(gameID string, g *game.Game) core.GameResponse {
resp := core.GameResponse{
GameID: gameID,
FEN: g.FEN,
Turn: g.NextTurnColor.String(),
State: g.State.String(),
Moves: g.Moves,
FEN: g.CurrentFEN(),
Turn: g.NextTurnColor().String(),
State: g.State().String(),
Moves: g.Moves(),
Players: core.PlayersResponse{
White: g.WhitePlayer,
Black: g.BlackPlayer,
White: g.GetPlayer(core.ColorWhite),
Black: g.GetPlayer(core.ColorBlack),
},
}
// Include last move if available
if result := g.LastResult; result != nil {
if result := g.LastResult(); result != nil {
resp.LastMove = &core.MoveInfo{
Move: result.Move,
PlayerColor: result.PlayerColor.String(),
@@ -645,9 +608,6 @@ func (p *Processor) errorResponse(message, code string) ProcessorResponse {
// Close cleans up resources
func (p *Processor) Close() error {
queueErr := p.queue.Shutdown(5 * time.Second)
p.mu.Lock()
engineErr := p.validationEng.Close()
p.mu.Unlock()
return errors.Join(queueErr, engineErr)
p.queue.Shutdown(5 * time.Second)
return p.validationEng.Close()
}
+60 -86
View File
@@ -3,7 +3,6 @@ package processor
import (
"context"
"fmt"
"log/slog"
"sync"
"time"
@@ -36,12 +35,8 @@ type EngineQueue struct {
tasks chan EngineTask
workers int
wg sync.WaitGroup
callbackWG sync.WaitGroup
ctx context.Context
cancel context.CancelFunc
submitMu sync.RWMutex
closed bool
shutdownOnce sync.Once
}
// NewEngineQueue creates a queue with specified worker count
@@ -74,28 +69,31 @@ func (q *EngineQueue) start() {
// worker processes engine tasks
func (q *EngineQueue) worker(id int) {
defer q.wg.Done()
var eng *engine.UCI
for {
var err error
if eng, err = engine.New(); err == nil {
break
}
slog.Warn("engine worker initialization failed; retrying", "worker", id, "error", err)
select {
case <-q.ctx.Done():
// Each worker gets its own engine instance
eng, err := engine.New()
if err != nil {
fmt.Printf("Worker %d failed to initialize engine: %v\n", id, err)
return
case <-time.After(2 * time.Second):
}
}
defer eng.Close()
slog.Debug("engine worker started", "worker", id)
for {
select {
case task, ok := <-q.tasks:
if !ok {
return
return // Channel closed
}
task.Response <- q.processTask(eng, task) // Response is buffered(1); never blocks
result := q.processTask(eng, task)
// Send result if receiver still listening
select {
case task.Response <- result:
case <-time.After(100 * time.Millisecond):
// Receiver abandoned, discard result
}
case <-q.ctx.Done():
return
}
@@ -104,57 +102,52 @@ func (q *EngineQueue) worker(id int) {
// processTask executes a single engine calculation
func (q *EngineQueue) processTask(eng *engine.UCI, task EngineTask) EngineResult {
started := time.Now()
defer func() {
slog.Debug("engine task completed", "game_id", task.GameID, "duration", time.Since(started))
}()
result := EngineResult{GameID: task.GameID}
if err := eng.NewGame(); err != nil {
result.Error = err
return result
result := EngineResult{
GameID: task.GameID,
}
// Apply computer configuration if provided
if task.Player.Type == core.PlayerComputer {
if err := eng.SetSkillLevel(task.Player.Level); err != nil {
result.Error = err
return result
eng.SetSkillLevel(task.Player.Level)
}
}
if err := eng.SetPosition(task.FEN, nil); err != nil {
result.Error = err
return result
}
searchTime := 1000
// Setup position
eng.SetPosition(task.FEN, []string{})
// Determine search time
searchTime := 1000 // Default 1 second
if task.Player.Type == core.PlayerComputer && task.Player.SearchTime > 0 {
searchTime = task.Player.SearchTime
}
// Search for best move
search, err := eng.Search(searchTime)
if err != nil {
result.Error = fmt.Errorf("engine search failed: %w", err)
result.Error = fmt.Errorf("engine search failed: %v", err)
return result
}
// Check for no legal moves
if search.BestMove == "" || search.BestMove == "(none)" {
result.IsMate, result.MateIn = search.IsMate, search.MateIn
result.Move = ""
result.IsMate = search.IsMate
result.MateIn = search.MateIn
return result
}
result.Move, result.Score, result.Depth = search.BestMove, search.Score, search.Depth
result.IsMate, result.MateIn = search.IsMate, search.MateIn
result.Move = search.BestMove
result.Score = search.Score
result.Depth = search.Depth
result.IsMate = search.IsMate
result.MateIn = search.MateIn
return result
}
// Submit adds a task to the queue
func (q *EngineQueue) Submit(task EngineTask) error {
q.submitMu.RLock()
defer q.submitMu.RUnlock()
return q.submitLocked(task)
}
func (q *EngineQueue) submitLocked(task EngineTask) error {
if q.closed {
return fmt.Errorf("queue is shutting down")
}
select {
case q.tasks <- task:
slog.Debug("engine task queued", "game_id", task.GameID, "queue_depth", len(q.tasks))
return nil
case <-q.ctx.Done():
return fmt.Errorf("queue is shutting down")
@@ -165,63 +158,44 @@ func (q *EngineQueue) submitLocked(task EngineTask) error {
// SubmitAsync submits a task without blocking for result
func (q *EngineQueue) SubmitAsync(gameID, fen string, color core.Color, player *core.Player, callback func(EngineResult)) error {
if player == nil {
return fmt.Errorf("computer player is missing")
}
if callback == nil {
return fmt.Errorf("engine callback is missing")
}
respChan := make(chan EngineResult, 1)
q.submitMu.RLock()
err := q.submitLocked(EngineTask{
GameID: gameID, FEN: fen, Color: color, Player: player, Response: respChan,
})
if err == nil {
// Registered while the submit lock is held, so Shutdown cannot begin
// waiting between a successful send and this Add.
q.callbackWG.Add(1)
task := EngineTask{
GameID: gameID,
FEN: fen,
Color: color,
Player: player,
Response: respChan,
}
q.submitMu.RUnlock()
if err != nil {
if err := q.Submit(task); err != nil {
return err
}
budget := 1000
if player.Type == core.PlayerComputer && player.SearchTime > 0 {
budget = player.SearchTime
}
wait := time.Duration(budget)*time.Millisecond*2 + 30*time.Second // search budget + queue-wait headroom
// Handle result in background
go func() {
defer q.callbackWG.Done()
timer := time.NewTimer(wait)
defer timer.Stop()
select {
case result := <-respChan:
callback(result)
case <-timer.C:
callback(EngineResult{GameID: gameID, Error: fmt.Errorf("engine timeout")})
case <-q.ctx.Done():
// Live state is intentionally abandoned during server shutdown. The
// last fully committed position remains the durable replay boundary.
return
case <-time.After(5 * time.Second):
callback(EngineResult{
GameID: gameID,
Error: fmt.Errorf("engine timeout"),
})
}
}()
return nil
}
// Shutdown gracefully stops the queue
func (q *EngineQueue) Shutdown(timeout time.Duration) error {
q.shutdownOnce.Do(func() {
q.submitMu.Lock()
q.closed = true
q.cancel()
close(q.tasks)
q.submitMu.Unlock()
})
done := make(chan struct{})
go func() {
q.wg.Wait()
q.callbackWG.Wait()
close(done)
}()
-40
View File
@@ -1,40 +0,0 @@
package processor
import (
"context"
"sync/atomic"
"testing"
"time"
"chess/internal/server/core"
)
func TestEngineQueueShutdownCancelsAndWaitsForCallback(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
queue := &EngineQueue{
tasks: make(chan EngineTask, 1),
workers: 1,
ctx: ctx,
cancel: cancel,
}
player := &core.Player{Type: core.PlayerComputer, SearchTime: 10_000}
var callbackCalled atomic.Bool
if err := queue.SubmitAsync("game-1", "fen", core.ColorWhite, player, func(EngineResult) {
callbackCalled.Store(true)
}); err != nil {
t.Fatal(err)
}
if err := queue.Shutdown(time.Second); err != nil {
t.Fatal(err)
}
if callbackCalled.Load() {
t.Fatal("shutdown callback mutated live state after cancellation")
}
if err := queue.Submit(EngineTask{}); err == nil {
t.Fatal("submit succeeded after shutdown")
}
if err := queue.Shutdown(time.Second); err != nil {
t.Fatalf("second shutdown was not idempotent: %v", err)
}
}
+40 -210
View File
@@ -1,9 +1,7 @@
package service
import (
"errors"
"fmt"
"log/slog"
"time"
"chess/internal/server/core"
@@ -13,32 +11,8 @@ import (
"github.com/google/uuid"
)
var (
ErrGameNotFound = errors.New("game not found")
ErrGameChanged = errors.New("game changed while move was being validated")
ErrSlotOwner = errors.New("player slot is owned by another user")
)
type MoveCommit struct {
ExpectedFEN string
ExpectedState core.State
ExpectedTurn core.Color
ActorUserID string
MoveUCI string
NewFEN string
State core.State
Result *game.MoveResult
At time.Time
}
// CreateGame registers a new game with pre-constructed players
func (s *Service) CreateGame(
id string,
whitePlayer, blackPlayer *core.Player,
initialFEN string,
startingTurn core.Color,
initialState core.State,
) error {
func (s *Service) CreateGame(id string, whitePlayer, blackPlayer *core.Player, initialFEN string, startingTurn core.Color) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -55,14 +29,11 @@ func (s *Service) CreateGame(
s.computerGames.Add(1)
}
now := time.Now().UTC()
g := game.New(initialFEN, whitePlayer, blackPlayer, startingTurn)
g.SetStateAt(initialState, now)
s.games[id] = g
// Store game with provided players
s.games[id] = game.New(initialFEN, whitePlayer, blackPlayer, startingTurn)
// Persist if storage enabled
if s.store != nil {
result, _ := initialState.Result()
record := storage.GameRecord{
GameID: id,
InitialFEN: initialFEN,
@@ -70,21 +41,14 @@ func (s *Service) CreateGame(
WhiteType: int(whitePlayer.Type),
WhiteLevel: whitePlayer.Level,
WhiteSearchTime: whitePlayer.SearchTime,
WhiteClaimedBy: whitePlayer.ClaimedBy,
BlackPlayerID: blackPlayer.ID,
BlackType: int(blackPlayer.Type),
BlackLevel: blackPlayer.Level,
BlackSearchTime: blackPlayer.SearchTime,
BlackClaimedBy: blackPlayer.ClaimedBy,
Result: result,
StartTimeUTC: now,
EndTimeUTC: g.EndTimeUTC(),
StartTimeUTC: time.Now().UTC(),
}
if err := s.store.RecordNewGame(record); err != nil {
slog.Error("failed to queue game persistence", "game_id", id, "error", err)
s.store.RecordNewGame(record)
}
}
slog.Debug("game created", "game_id", id, "state", initialState.String(), "persistent", s.store != nil)
return nil
}
@@ -98,85 +62,23 @@ func (s *Service) UpdatePlayers(gameID string, whitePlayer, blackPlayer *core.Pl
if !ok {
return fmt.Errorf("game not found: %s", gameID)
}
if g.State() == core.StatePending {
return errors.New("cannot change players while computer is calculating")
}
oldWhite := g.GetPlayer(core.ColorWhite)
oldBlack := g.GetPlayer(core.ColorBlack)
oldHasComputer := g.HasComputerPlayer()
newHasComputer := whitePlayer.Type == core.PlayerComputer || blackPlayer.Type == core.PlayerComputer
if !oldHasComputer && newHasComputer && s.computerGames.Load() >= MaxComputerGames {
return fmt.Errorf("computer game limit reached (%d/%d)", s.computerGames.Load(), MaxComputerGames)
}
// Player configuration is mutable, but historical user association is not.
// Preserve a human ID while the slot remains human, and preserve any claim
// even if the slot later becomes computer-controlled.
if oldWhite != nil {
if oldWhite.Type == core.PlayerHuman && whitePlayer.Type == core.PlayerHuman {
whitePlayer.ID = oldWhite.ID
}
whitePlayer.ClaimedBy = oldWhite.ClaimedBy
}
if oldBlack != nil {
if oldBlack.Type == core.PlayerHuman && blackPlayer.Type == core.PlayerHuman {
blackPlayer.ID = oldBlack.ID
}
blackPlayer.ClaimedBy = oldBlack.ClaimedBy
}
// Update the game's players
g.UpdatePlayers(whitePlayer, blackPlayer)
if oldHasComputer != newHasComputer {
if newHasComputer {
s.computerGames.Add(1)
} else {
s.computerGames.Add(-1)
}
}
if s.store != nil {
err := s.store.RecordPlayers(gameID, playerRecord(whitePlayer), playerRecord(blackPlayer))
if err != nil {
slog.Error("failed to queue player persistence", "game_id", gameID, "error", err)
}
}
slog.Debug("game players updated", "game_id", gameID)
return nil
}
// GetGameView retrieves an immutable game snapshot by ID.
func (s *Service) GetGameView(gameID string) (game.View, error) {
// GetGame retrieves a game by ID
func (s *Service) GetGame(gameID string) (*game.Game, error) {
s.mu.RLock()
defer s.mu.RUnlock()
g, ok := s.games[gameID]
if !ok {
return game.View{}, fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
return nil, fmt.Errorf("game not found: %s", gameID)
}
return g.View(), nil
}
// BeginComputerMove is an optimistic state transition: only the request that
// observed the current ongoing position may enqueue engine work.
func (s *Service) BeginComputerMove(gameID, expectedFEN string, expectedTurn core.Color) 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() != core.StateOngoing || g.CurrentFEN() != expectedFEN || g.NextTurnColor() != expectedTurn {
return ErrGameChanged
}
if player := g.NextPlayer(); player == nil || player.Type != core.PlayerComputer {
return errors.New("current player is not a computer")
}
g.SetStateAt(core.StatePending, time.Now().UTC())
s.waiter.NotifyGame(gameID, len(g.Moves()), core.StatePending)
slog.Debug("computer move started", "game_id", gameID, "turn", expectedTurn.String())
return nil
return g, nil
}
// GenerateGameID creates a new unique game ID
@@ -193,86 +95,40 @@ func (s *Service) GenerateGameID() string {
}
}
// ApplyMoveWithState verifies that the position validated by the processor is
// still current, then commits the move, optional first-move claim, and result as
// one in-memory transition and one SQLite transaction.
func (s *Service) ApplyMoveWithState(gameID string, commit MoveCommit) error {
// ApplyMove adds a validated move to the game history
func (s *Service) ApplyMove(gameID, moveUCI, newFEN string) error {
s.mu.Lock()
defer s.mu.Unlock()
g, ok := s.games[gameID]
if !ok {
return fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
return fmt.Errorf("game not found: %s", gameID)
}
// Determine whose turn it was before this move
currentTurn := g.NextTurnColor()
if g.CurrentFEN() != commit.ExpectedFEN ||
g.State() != commit.ExpectedState ||
currentTurn != commit.ExpectedTurn {
return ErrGameChanged
}
nextTurn := core.OppositeColor(currentTurn)
currentPlayer := g.NextPlayer()
claimUserID := ""
if currentPlayer == nil {
return errors.New("current player is missing")
}
if currentPlayer.Type == core.PlayerHuman {
owner := g.GetSlotOwner(currentTurn)
switch {
case owner != "" && commit.ActorUserID == "":
return ErrSlotOwner
case owner != "" && owner != commit.ActorUserID:
return ErrSlotOwner
case owner == "" && commit.ActorUserID != "":
claimUserID = commit.ActorUserID
}
}
// Add the new position to game history
g.AddSnapshot(newFEN, moveUCI, nextTurn)
at := commit.At.UTC()
if commit.At.IsZero() {
at = time.Now().UTC()
}
if claimUserID != "" {
if err := g.ClaimSlot(currentTurn, claimUserID); err != nil {
return err
}
}
g.AddSnapshot(commit.NewFEN, commit.MoveUCI, core.OppositeColor(currentTurn))
g.SetStateAt(commit.State, at)
if commit.Result != nil {
g.SetLastResult(commit.Result)
}
// Notify waiting clients about the state change
s.waiter.NotifyGame(gameID, len(g.Moves()))
// Persist if storage enabled
if s.store != nil {
result, _ := commit.State.Result()
persistence := storage.MovePersistence{
Move: storage.MoveRecord{
GameID: gameID, MoveNumber: len(g.Moves()), MoveUCI: commit.MoveUCI,
FENAfterMove: commit.NewFEN, PlayerColor: currentTurn.String(), MoveTimeUTC: at,
},
ClaimColor: currentTurn.String(),
ClaimedBy: claimUserID,
Result: result,
EndTimeUTC: g.EndTimeUTC(),
}
if claimUserID == "" {
persistence.ClaimColor = ""
}
if err := s.store.RecordMove(persistence); err != nil {
slog.Error("failed to queue move persistence",
"game_id", gameID, "move_number", len(g.Moves()), "error", err)
moveNumber := len(g.Moves())
record := storage.MoveRecord{
GameID: gameID,
MoveNumber: moveNumber,
MoveUCI: moveUCI,
FENAfterMove: newFEN,
PlayerColor: currentTurn.String(),
MoveTimeUTC: time.Now().UTC(),
}
s.store.RecordMove(record)
}
s.waiter.NotifyGame(gameID, len(g.Moves()), commit.State)
slog.Debug("game move applied",
"game_id", gameID,
"move_number", len(g.Moves()),
"move", commit.MoveUCI,
"state", commit.State.String(),
"slot_claimed", claimUserID != "",
)
return nil
}
@@ -283,21 +139,15 @@ func (s *Service) UpdateGameState(gameID string, state core.State) error {
g, ok := s.games[gameID]
if !ok {
return fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
return fmt.Errorf("game not found: %s", gameID)
}
previousState := g.State()
now := time.Now().UTC()
g.SetStateAt(state, now)
if s.store != nil && state.IsTerminal() && !previousState.IsTerminal() {
result, _ := state.Result()
if err := s.store.RecordGameResult(gameID, result, now); err != nil {
slog.Error("failed to queue game result persistence", "game_id", gameID, "error", err)
g.SetState(state)
// Notify if game ended
if state != core.StateOngoing && state != core.StatePending {
s.waiter.NotifyGame(gameID, len(g.Moves()))
}
}
// Notify unconditionally; the registry decides.
s.waiter.NotifyGame(gameID, len(g.Moves()), state)
slog.Debug("game state updated", "game_id", gameID, "from", previousState.String(), "to", state.String())
return nil
}
@@ -309,7 +159,7 @@ func (s *Service) SetLastMoveResult(gameID string, result *game.MoveResult) erro
g, ok := s.games[gameID]
if !ok {
return fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
return fmt.Errorf("game not found: %s", gameID)
}
g.SetLastResult(result)
@@ -323,10 +173,7 @@ func (s *Service) UndoMoves(gameID string, count int) error {
g, ok := s.games[gameID]
if !ok {
return fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
}
if g.State() == core.StatePending {
return errors.New("cannot undo while computer move is in progress")
return fmt.Errorf("game not found: %s", gameID)
}
originalMoveCount := len(g.Moves())
@@ -336,16 +183,13 @@ func (s *Service) UndoMoves(gameID string, count int) error {
}
// Notify waiting clients about the undo
s.waiter.NotifyGame(gameID, len(g.Moves()), g.State())
s.waiter.NotifyGame(gameID, len(g.Moves()))
// Delete undone moves from storage if enabled
if s.store != nil {
remainingMoves := originalMoveCount - count
if err := s.store.RewindGame(gameID, remainingMoves); err != nil {
slog.Error("failed to queue game rewind persistence", "game_id", gameID, "error", err)
s.store.DeleteUndoneMoves(gameID, remainingMoves)
}
}
slog.Debug("game moves undone", "game_id", gameID, "count", count, "remaining_moves", len(g.Moves()))
return nil
}
@@ -357,10 +201,7 @@ func (s *Service) DeleteGame(gameID string) error {
g, ok := s.games[gameID]
if !ok {
return fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
}
if g.State() == core.StatePending {
return errors.New("cannot delete game while computer move is in progress")
return fmt.Errorf("game not found: %s", gameID)
}
// Decrement computer game count if applicable
@@ -372,16 +213,5 @@ func (s *Service) DeleteGame(gameID string) error {
s.waiter.RemoveGame(gameID)
delete(s.games, gameID)
slog.Debug("game unloaded from memory", "game_id", gameID)
return nil
}
func playerRecord(player *core.Player) storage.PlayerRecord {
return storage.PlayerRecord{
PlayerID: player.ID,
Type: int(player.Type),
Level: player.Level,
SearchTime: player.SearchTime,
ClaimedBy: player.ClaimedBy,
}
}
-164
View File
@@ -1,164 +0,0 @@
package service
import (
"errors"
"path/filepath"
"testing"
"time"
"chess/internal/server/core"
"chess/internal/server/game"
"chess/internal/server/storage"
)
func TestMoveCommitClaimsSlotPersistsResultAndRejectsStalePosition(t *testing.T) {
svc := newPersistentTestService(t)
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
if err := svc.CreateGame(
"game-1", white, black, "initial", core.ColorWhite, core.StateOngoing,
); err != nil {
t.Fatal(err)
}
ended := time.Date(2026, 9, 7, 2, 3, 4, 0, time.UTC)
commit := MoveCommit{
ExpectedFEN: "initial", ExpectedState: core.StateOngoing, ExpectedTurn: core.ColorWhite,
ActorUserID: "user-1", MoveUCI: "e2e4", NewFEN: "after", State: core.StateWhiteWins, At: ended,
Result: &game.MoveResult{Move: "e2e4", PlayerColor: core.ColorWhite, GameState: core.StateWhiteWins},
}
if err := svc.ApplyMoveWithState("game-1", commit); err != nil {
t.Fatal(err)
}
view, err := svc.GetGameView("game-1")
if err != nil {
t.Fatal(err)
}
if view.WhitePlayer.ClaimedBy != "user-1" || view.State != core.StateWhiteWins {
t.Fatalf("in-memory move did not settle atomically: %+v", view)
}
history, err := svc.GetGameHistory("game-1")
if err != nil {
t.Fatal(err)
}
if history.Players.White.ClaimedBy != "user-1" || history.Result != "white_wins" || len(history.Moves) != 1 {
t.Fatalf("history did not settle atomically: %+v", history)
}
if err := svc.ApplyMoveWithState("game-1", commit); !errors.Is(err, ErrGameChanged) {
t.Fatalf("stale move error = %v, want ErrGameChanged", err)
}
}
func TestUndoClearsDurableTerminalResult(t *testing.T) {
svc := newPersistentTestService(t)
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
if err := svc.CreateGame(
"game-1", white, black, "initial", core.ColorWhite, core.StateOngoing,
); err != nil {
t.Fatal(err)
}
if err := svc.ApplyMoveWithState("game-1", MoveCommit{
ExpectedFEN: "initial", ExpectedState: core.StateOngoing, ExpectedTurn: core.ColorWhite,
MoveUCI: "e2e4", NewFEN: "after", State: core.StateStalemate,
}); err != nil {
t.Fatal(err)
}
if err := svc.UndoMoves("game-1", 1); err != nil {
t.Fatal(err)
}
history, err := svc.GetGameHistory("game-1")
if err != nil {
t.Fatal(err)
}
if history.Result != "" || history.EndTimeUTC != nil || len(history.Moves) != 0 {
t.Fatalf("undo left terminal persistence: %+v", history)
}
}
func TestPlayerReconfigurationPreservesClaimAndPersistsConfiguration(t *testing.T) {
svc := newPersistentTestService(t)
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
white.ID = "user-1"
white.ClaimedBy = "user-1"
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
if err := svc.CreateGame(
"game-1", white, black, "initial", core.ColorWhite, core.StateOngoing,
); err != nil {
t.Fatal(err)
}
replacementWhite := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
replacementBlack := core.NewPlayer(
core.PlayerConfig{Type: core.PlayerComputer, Level: 12, SearchTime: 500}, core.ColorBlack,
)
if err := svc.UpdatePlayers("game-1", replacementWhite, replacementBlack); err != nil {
t.Fatal(err)
}
history, err := svc.GetGameHistory("game-1")
if err != nil {
t.Fatal(err)
}
if history.Players.White.ID != "user-1" || history.Players.White.ClaimedBy != "user-1" {
t.Fatalf("white ownership was replaced: %+v", history.Players.White)
}
if history.Players.Black.Type != core.PlayerComputer || history.Players.Black.Level != 12 {
t.Fatalf("black configuration was not persisted: %+v", history.Players.Black)
}
computerWhite := core.NewPlayer(
core.PlayerConfig{Type: core.PlayerComputer, Level: 8, SearchTime: 300}, core.ColorWhite,
)
if err := svc.UpdatePlayers("game-1", computerWhite, replacementBlack); err != nil {
t.Fatal(err)
}
history, err = svc.GetGameHistory("game-1")
if err != nil {
t.Fatal(err)
}
if history.Players.White.ClaimedBy != "user-1" {
t.Fatalf("player-type change discarded historical ownership: %+v", history.Players.White)
}
}
func TestCleanupEvictsOnlyMemoryCopyOfTerminalGame(t *testing.T) {
svc := newPersistentTestService(t)
svc.SetFinishedGameTTL(time.Nanosecond)
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
if err := svc.CreateGame(
"game-1", white, black, "terminal", core.ColorWhite, core.StateStalemate,
); err != nil {
t.Fatal(err)
}
svc.cleanupFinishedGames(time.Now().UTC().Add(time.Second))
if _, err := svc.GetGameView("game-1"); !errors.Is(err, ErrGameNotFound) {
t.Fatalf("terminal game remains in memory: %v", err)
}
if history, err := svc.GetGameHistory("game-1"); err != nil || history.Result != "stalemate" {
t.Fatalf("durable history was removed: history=%+v err=%v", history, err)
}
}
func newPersistentTestService(t *testing.T) *Service {
t.Helper()
store, err := storage.NewStore(filepath.Join(t.TempDir(), "chess.db"), false)
if err != nil {
t.Fatal(err)
}
if err := store.InitDB(); err != nil {
t.Fatal(err)
}
svc := New(store, []byte("test-secret-test-secret-test-secret"))
t.Cleanup(func() {
if err := svc.Shutdown(time.Second); err != nil {
t.Errorf("shutdown: %v", err)
}
})
return svc
}
-109
View File
@@ -1,109 +0,0 @@
package service
import (
"context"
"errors"
"fmt"
"chess/internal/server/core"
"chess/internal/server/storage"
)
// GetGameHistory returns a durable replay even after the live game has been
// evicted from memory or the server has restarted.
func (s *Service) GetGameHistory(gameID string) (*core.GameHistoryResponse, error) {
if s.store == nil {
return nil, ErrStorageDisabled
}
record, moves, err := s.store.GetGameHistory(gameID)
if err != nil {
if storage.IsGameNotFound(err) {
return nil, fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
}
if isStorageUnavailable(err) {
return nil, fmt.Errorf("%w: %v", ErrStorageUnavailable, err)
}
return nil, fmt.Errorf("get game history: %w", err)
}
history := &core.GameHistoryResponse{
GameID: record.GameID,
InitialFEN: record.InitialFEN,
Result: record.Result,
StartTimeUTC: record.StartTimeUTC,
EndTimeUTC: record.EndTimeUTC,
Players: playersResponse(*record),
Moves: make([]core.HistoryMove, 0, len(moves)),
}
for _, move := range moves {
history.Moves = append(history.Moves, core.HistoryMove{
MoveNumber: move.MoveNumber,
MoveUCI: move.MoveUCI,
FENAfterMove: move.FENAfterMove,
PlayerColor: move.PlayerColor,
MoveTimeUTC: move.MoveTimeUTC,
})
}
return history, nil
}
// GetUserGames returns a bounded page of games associated either at creation
// or by a later slot claim.
func (s *Service) GetUserGames(userID string, limit, offset int) (*core.GameListResponse, error) {
if s.store == nil {
return nil, ErrStorageDisabled
}
records, err := s.store.QueryGamesForUser(userID, limit+1, offset)
if err != nil {
if isStorageUnavailable(err) {
return nil, fmt.Errorf("%w: %v", ErrStorageUnavailable, err)
}
return nil, fmt.Errorf("get user games: %w", err)
}
hasNext := len(records) > limit
if hasNext {
records = records[:limit]
}
response := &core.GameListResponse{
Games: make([]core.GameSummary, 0, len(records)),
Limit: limit,
Offset: offset,
}
if hasNext {
next := offset + limit
response.NextOffset = &next
}
for _, record := range records {
response.Games = append(response.Games, core.GameSummary{
GameID: record.GameID,
InitialFEN: record.InitialFEN,
Result: record.Result,
StartTimeUTC: record.StartTimeUTC,
EndTimeUTC: record.EndTimeUTC,
MoveCount: record.MoveCount,
Players: playersResponse(record.GameRecord),
})
}
return response, nil
}
func isStorageUnavailable(err error) bool {
return errors.Is(err, storage.ErrStorageDegraded) ||
errors.Is(err, storage.ErrStoreClosed) ||
errors.Is(err, storage.ErrWriteQueueFull) ||
errors.Is(err, context.DeadlineExceeded)
}
func playersResponse(record storage.GameRecord) core.PlayersResponse {
return core.PlayersResponse{
White: &core.Player{
ID: record.WhitePlayerID, Color: core.ColorWhite, Type: core.PlayerType(record.WhiteType),
Level: record.WhiteLevel, SearchTime: record.WhiteSearchTime, ClaimedBy: record.WhiteClaimedBy,
},
Black: &core.Player{
ID: record.BlackPlayerID, Color: core.ColorBlack, Type: core.PlayerType(record.BlackType),
Level: record.BlackLevel, SearchTime: record.BlackSearchTime, ClaimedBy: record.BlackClaimedBy,
},
}
}
+16 -77
View File
@@ -5,7 +5,6 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"sync"
"sync/atomic"
"time"
@@ -21,19 +20,16 @@ const (
TempUserTTL = 24 * time.Hour
SessionTTL = 7 * 24 * time.Hour
CleanupJobInterval = 1 * time.Hour
FinishedGameTTL = 1 * time.Hour
)
// Service coordinates game state, user management, and storage
type Service struct {
games map[string]*game.Game
mu sync.RWMutex
userMu sync.Mutex
store *storage.Store
jwtSecret []byte
waiter *WaitRegistry
computerGames atomic.Int32 // Active games with computer players
finishedTTL time.Duration
}
// New creates a new service instance with optional storage
@@ -43,19 +39,9 @@ func New(store *storage.Store, jwtSecret []byte) *Service {
store: store,
jwtSecret: jwtSecret,
waiter: NewWaitRegistry(),
finishedTTL: FinishedGameTTL,
}
}
// SetFinishedGameTTL configures how long terminal games remain in memory.
// Durable rows and moves are never removed by this cleanup. A non-positive
// duration disables terminal-game eviction.
func (s *Service) SetFinishedGameTTL(ttl time.Duration) {
s.mu.Lock()
s.finishedTTL = ttl
s.mu.Unlock()
}
// GetStorageHealth returns the storage component status
func (s *Service) GetStorageHealth() string {
if s.store == nil {
@@ -69,13 +55,6 @@ func (s *Service) GetStorageHealth() string {
// RegisterWait registers a client to wait for game state changes
func (s *Service) RegisterWait(gameID string, moveCount int, ctx context.Context) <-chan struct{} {
s.mu.RLock()
defer s.mu.RUnlock()
if _, ok := s.games[gameID]; !ok {
notify := make(chan struct{})
close(notify)
return notify
}
return s.waiter.RegisterWait(gameID, moveCount, ctx)
}
@@ -101,9 +80,6 @@ func (s *Service) GetComputerGameCount() int32 {
// ClaimGameSlot claims a player slot for a user
func (s *Service) ClaimGameSlot(gameID string, color core.Color, userID string) error {
if userID == "" {
return errors.New("claimant user ID is required")
}
s.mu.Lock()
defer s.mu.Unlock()
@@ -112,17 +88,7 @@ func (s *Service) ClaimGameSlot(gameID string, color core.Color, userID string)
return fmt.Errorf("game not found: %s", gameID)
}
if err := g.ClaimSlot(color, userID); err != nil {
return err
}
if s.store != nil {
if err := s.store.RecordSlotClaim(gameID, color.String(), userID); err != nil {
slog.Error("failed to queue slot claim persistence",
"game_id", gameID, "color", color.String(), "error", err)
}
}
slog.Debug("game slot claimed", "game_id", gameID, "color", color.String(), "user_id", userID)
return nil
return g.ClaimSlot(color, userID)
}
// GetSlotOwner returns the user who claimed a slot
@@ -147,8 +113,9 @@ func (s *Service) Shutdown(timeout time.Duration) error {
}
s.mu.Lock()
defer s.mu.Unlock()
s.games = make(map[string]*game.Game)
s.mu.Unlock()
if s.store != nil {
if err := s.store.Close(); err != nil {
@@ -161,7 +128,6 @@ func (s *Service) Shutdown(timeout time.Duration) error {
// RunCleanupJob runs periodic cleanup of expired users and sessions
func (s *Service) RunCleanupJob(ctx context.Context, interval time.Duration) {
s.cleanupExpired()
ticker := time.NewTicker(interval)
defer ticker.Stop()
@@ -176,49 +142,22 @@ func (s *Service) RunCleanupJob(ctx context.Context, interval time.Duration) {
}
func (s *Service) cleanupExpired() {
if s.store != nil {
if deleted, err := s.store.DeleteExpiredTempUsers(); err != nil {
slog.Error("cleanup failed to delete expired users", "error", err)
} else if deleted > 0 {
slog.Info("cleanup deleted expired temporary users", "count", deleted)
}
if deleted, err := s.store.DeleteExpiredSessions(); err != nil {
slog.Error("cleanup failed to delete expired sessions", "error", err)
} else if deleted > 0 {
slog.Info("cleanup deleted expired sessions", "count", deleted)
}
}
s.cleanupFinishedGames(time.Now().UTC())
}
func (s *Service) cleanupFinishedGames(now time.Time) {
s.mu.Lock()
if s.finishedTTL <= 0 {
s.mu.Unlock()
if s.store == nil {
return
}
cutoff := now.Add(-s.finishedTTL)
removed := make([]string, 0)
for gameID, g := range s.games {
ended := g.EndTimeUTC()
if !g.State().IsTerminal() || ended == nil || ended.After(cutoff) {
continue
}
if g.HasComputerPlayer() {
s.computerGames.Add(-1)
}
delete(s.games, gameID)
removed = append(removed, gameID)
}
s.mu.Unlock()
for _, gameID := range removed {
s.waiter.RemoveGame(gameID)
// Cleanup expired temp users
if deleted, err := s.store.DeleteExpiredTempUsers(); err != nil {
// Log but don't fail
fmt.Printf("cleanup: failed to delete expired users: %v\n", err)
} else if deleted > 0 {
fmt.Printf("cleanup: deleted %d expired temp users\n", deleted)
}
if len(removed) > 0 {
slog.Info("cleanup evicted terminal games from memory",
"count", len(removed), "retention", s.finishedTTL)
// Cleanup expired sessions
if deleted, err := s.store.DeleteExpiredSessions(); err != nil {
fmt.Printf("cleanup: failed to delete expired sessions: %v\n", err)
} else if deleted > 0 {
fmt.Printf("cleanup: deleted %d expired sessions\n", deleted)
}
}
+58 -107
View File
@@ -1,10 +1,7 @@
package service
import (
"database/sql"
"errors"
"fmt"
"log/slog"
"strings"
"time"
@@ -14,13 +11,6 @@ import (
"github.com/lixenwraith/auth"
)
var (
ErrStorageDisabled = errors.New("storage disabled")
ErrStorageUnavailable = errors.New("storage unavailable")
ErrAtCapacity = errors.New("at capacity")
ErrPermanentSlotsFull = errors.New("permanent slots full")
)
// User represents a registered user account
type User struct {
UserID string
@@ -33,25 +23,14 @@ type User struct {
// CreateUser creates new user with registration limits enforcement
func (s *Service) CreateUser(username, email, password string, permanent bool) (*User, error) {
user, _, err := s.createUser(username, email, password, permanent, false)
return user, err
}
// RegisterUser creates the account and its initial session in one SQLite
// transaction, so a successful registration always returns a usable account.
func (s *Service) RegisterUser(username, email, password string, permanent bool) (*User, string, error) {
return s.createUser(username, email, password, permanent, true)
}
func (s *Service) createUser(
username, email, password string,
permanent, withSession bool,
) (*User, string, error) {
s.userMu.Lock()
defer s.userMu.Unlock()
if s.store == nil {
return nil, "", ErrStorageDisabled
return nil, fmt.Errorf("storage disabled")
}
// Check registration limits
total, permCount, _, err := s.store.GetUserCounts()
if err != nil {
return nil, fmt.Errorf("failed to check user limits: %w", err)
}
// Determine account type
@@ -59,22 +38,32 @@ func (s *Service) createUser(
var expiresAt *time.Time
if permanent {
if permCount >= PermanentSlots {
return nil, fmt.Errorf("permanent user slots full (%d/%d)", permCount, PermanentSlots)
}
accountType = "permanent"
} else {
expiry := time.Now().UTC().Add(TempUserTTL)
expiresAt = &expiry
}
// Handle capacity - remove oldest temp user if at max
if total >= MaxUsers {
if err := s.removeOldestTempUser(); err != nil {
return nil, fmt.Errorf("at capacity and cannot make room: %w", err)
}
}
// Hash password
passwordHash, err := auth.HashPassword(password)
if err != nil {
return nil, "", fmt.Errorf("failed to hash password: %w", err)
return nil, fmt.Errorf("failed to hash password: %w", err)
}
// Generate unique user ID
userID, err := s.generateUniqueUserID()
if err != nil {
return nil, "", fmt.Errorf("failed to generate unique ID: %w", err)
return nil, fmt.Errorf("failed to generate unique ID: %w", err)
}
// Create user record
@@ -97,46 +86,35 @@ func (s *Service) createUser(
ExpiresAt: expiresAt,
}
var sessionID string
var session *storage.SessionRecord
if withSession {
sessionID = uuid.New().String()
session = &storage.SessionRecord{
SessionID: sessionID,
UserID: userID,
CreatedAt: user.CreatedAt,
ExpiresAt: user.CreatedAt.Add(SessionTTL),
if err = s.store.CreateUser(record); err != nil {
return nil, err
}
}
limits := storage.UserLimits{
MaxUsers: MaxUsers,
PermanentSlots: PermanentSlots,
}
if err = s.store.CreateUserWithinLimits(record, session, limits); err != nil {
switch {
case errors.Is(err, storage.ErrUserAlreadyExists):
return nil, "", fmt.Errorf("username or email already exists: %w", err)
case errors.Is(err, storage.ErrPermanentCapacity):
return nil, "", fmt.Errorf("%w (%d maximum)", ErrPermanentSlotsFull, PermanentSlots)
case errors.Is(err, storage.ErrUserCapacity):
return nil, "", fmt.Errorf("%w: no temporary account can be replaced", ErrAtCapacity)
default:
return nil, "", fmt.Errorf("%w: create user: %v", ErrStorageUnavailable, err)
}
}
slog.Debug("user created",
"user_id", userID,
"account_type", accountType,
"initial_session", withSession,
)
return user, sessionID, nil
return user, nil
}
// removeOldestTempUser removes the oldest temporary user to make room
func (s *Service) removeOldestTempUser() error {
oldest, err := s.store.GetOldestTempUser()
if err != nil {
return fmt.Errorf("no temp users to remove: %w", err)
}
// Delete their session first
_ = s.store.DeleteSessionByUserID(oldest.UserID)
// Delete the user
if err := s.store.DeleteUserByID(oldest.UserID); err != nil {
return fmt.Errorf("failed to remove oldest user: %w", err)
}
return nil
}
// AuthenticateUser verifies credentials and creates a new session
func (s *Service) AuthenticateUser(identifier, password string) (*User, string, error) {
if s.store == nil {
return nil, "", ErrStorageDisabled
return nil, "", fmt.Errorf("storage disabled")
}
var userRecord *storage.UserRecord
@@ -151,9 +129,6 @@ func (s *Service) AuthenticateUser(identifier, password string) (*User, string,
if err != nil {
auth.HashPassword(password) // Timing attack prevention
if !errors.Is(err, sql.ErrNoRows) {
return nil, "", fmt.Errorf("%w: look up user: %v", ErrStorageUnavailable, err)
}
return nil, "", fmt.Errorf("invalid credentials")
}
@@ -179,14 +154,11 @@ func (s *Service) AuthenticateUser(identifier, password string) (*User, string,
}
if err := s.store.CreateSession(sessionRecord); err != nil {
return nil, "", fmt.Errorf("%w: create session: %v", ErrStorageUnavailable, err)
return nil, "", fmt.Errorf("failed to create session: %w", err)
}
slog.Debug("user authenticated", "user_id", userRecord.UserID)
// Update last login
if err := s.store.UpdateUserLastLoginSync(userRecord.UserID, time.Now().UTC()); err != nil {
slog.Warn("failed to record user login time", "user_id", userRecord.UserID, "error", err)
}
_ = s.store.UpdateUserLastLoginSync(userRecord.UserID, time.Now().UTC())
return &User{
UserID: userRecord.UserID,
@@ -201,39 +173,29 @@ func (s *Service) AuthenticateUser(identifier, password string) (*User, string,
// ValidateSession checks if a session is valid
func (s *Service) ValidateSession(sessionID string) (bool, error) {
if s.store == nil {
return false, ErrStorageDisabled
return false, fmt.Errorf("storage disabled")
}
valid, err := s.store.IsSessionValid(sessionID)
if err != nil {
return false, fmt.Errorf("%w: validate session: %v", ErrStorageUnavailable, err)
}
return valid, nil
return s.store.IsSessionValid(sessionID)
}
// InvalidateSession removes a session (logout)
func (s *Service) InvalidateSession(sessionID string) error {
if s.store == nil {
return ErrStorageDisabled
return fmt.Errorf("storage disabled")
}
if err := s.store.DeleteSession(sessionID); err != nil {
return fmt.Errorf("%w: invalidate session: %v", ErrStorageUnavailable, err)
}
return nil
return s.store.DeleteSession(sessionID)
}
// GetUserByID retrieves user information by user ID
func (s *Service) GetUserByID(userID string) (*User, error) {
if s.store == nil {
return nil, ErrStorageDisabled
return nil, fmt.Errorf("storage disabled")
}
userRecord, err := s.store.GetUserByID(userID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("user not found")
}
return nil, fmt.Errorf("%w: get user: %v", ErrStorageUnavailable, err)
}
return &User{
UserID: userRecord.UserID,
@@ -268,20 +230,13 @@ func (s *Service) ValidateToken(token string) (string, map[string]any, error) {
return "", nil, err
}
if s.store == nil {
return "", nil, ErrStorageDisabled
}
sessionID, ok := claims["session_id"].(string)
if !ok || sessionID == "" {
return "", nil, fmt.Errorf("token has no persisted session")
}
valid, err := s.store.IsSessionValidForUser(sessionID, userID)
if err != nil {
return "", nil, fmt.Errorf("%w: validate token session: %v", ErrStorageUnavailable, err)
}
if !valid {
// Validate session is still active
if sessionID, ok := claims["session_id"].(string); ok && s.store != nil {
valid, err := s.store.IsSessionValid(sessionID)
if err != nil || !valid {
return "", nil, fmt.Errorf("session invalidated")
}
}
return userID, claims, nil
}
@@ -292,22 +247,19 @@ func (s *Service) generateUniqueUserID() (string, error) {
for i := 0; i < maxAttempts; i++ {
id := uuid.New().String()
if _, err := s.store.GetUserByID(id); errors.Is(err, sql.ErrNoRows) {
if _, err := s.store.GetUserByID(id); err != nil {
return id, nil
} else if err != nil {
return "", fmt.Errorf("%w: check generated user ID: %v", ErrStorageUnavailable, err)
}
}
return "", fmt.Errorf("failed to generate unique user ID")
}
// CreateUserSession creates a session for a trusted internal caller without
// re-authenticating. Public registration uses RegisterUser so account and
// initial session creation remain atomic.
// CreateUserSession creates a session for a user without re-authenticating
// Used after registration to avoid redundant password hashing
func (s *Service) CreateUserSession(userID string) (string, error) {
if s.store == nil {
return "", ErrStorageDisabled
return "", fmt.Errorf("storage disabled")
}
sessionID := uuid.New().String()
@@ -319,9 +271,8 @@ func (s *Service) CreateUserSession(userID string) (string, error) {
}
if err := s.store.CreateSession(sessionRecord); err != nil {
return "", fmt.Errorf("%w: create session: %v", ErrStorageUnavailable, err)
return "", fmt.Errorf("failed to create session: %w", err)
}
slog.Debug("user session created", "user_id", userID)
return sessionID, nil
}
-48
View File
@@ -1,48 +0,0 @@
package service
import (
"testing"
"github.com/lixenwraith/auth"
)
func TestValidateTokenRequiresSessionBoundToSubject(t *testing.T) {
svc := newPersistentTestService(t)
user, sessionID, err := svc.RegisterUser("alice", "", "Password1", false)
if err != nil {
t.Fatal(err)
}
validToken, err := svc.GenerateUserToken(user.UserID, sessionID)
if err != nil {
t.Fatal(err)
}
if gotUserID, _, err := svc.ValidateToken(validToken); err != nil || gotUserID != user.UserID {
t.Fatalf("valid token rejected: user=%q err=%v", gotUserID, err)
}
missingSession, err := auth.GenerateHS256Token(svc.jwtSecret, user.UserID, nil, SessionTTL)
if err != nil {
t.Fatal(err)
}
if _, _, err := svc.ValidateToken(missingSession); err == nil {
t.Fatal("token without a persisted session was accepted")
}
wrongSubject, err := auth.GenerateHS256Token(svc.jwtSecret, "other-user", map[string]any{
"session_id": sessionID,
}, SessionTTL)
if err != nil {
t.Fatal(err)
}
if _, _, err := svc.ValidateToken(wrongSubject); err == nil {
t.Fatal("session was accepted for a different JWT subject")
}
if err := svc.InvalidateSession(sessionID); err != nil {
t.Fatal(err)
}
if _, _, err := svc.ValidateToken(validToken); err == nil {
t.Fatal("invalidated session was accepted")
}
}
+57 -47
View File
@@ -1,7 +1,6 @@
package service
import (
"chess/internal/server/core"
"context"
"fmt"
"sync"
@@ -10,7 +9,7 @@ import (
const (
// WaitTimeout is the maximum time a client can wait for notifications
WaitTimeout = 30 * time.Second
WaitTimeout = 25 * time.Second
// WaitChannelBuffer size for notification channels
WaitChannelBuffer = 1
@@ -22,8 +21,6 @@ type WaitRegistry struct {
waiters map[string][]*WaitRequest // gameID → waiting clients
shutdown chan struct{}
wg sync.WaitGroup
closed bool
shutdownOnce sync.Once
}
// WaitRequest represents a single client waiting for game updates
@@ -33,8 +30,6 @@ type WaitRequest struct {
Timer *time.Timer // Timeout timer
Context context.Context // Client connection context
GameID string // Game being watched
done chan struct{}
finish sync.Once
}
// NewWaitRegistry creates a new wait registry
@@ -48,12 +43,7 @@ func NewWaitRegistry() *WaitRegistry {
// RegisterWait registers a client to wait for game state changes
func (w *WaitRegistry) RegisterWait(gameID string, moveCount int, ctx context.Context) <-chan struct{} {
w.mu.Lock()
if w.closed {
w.mu.Unlock()
notify := make(chan struct{})
close(notify)
return notify
}
defer w.mu.Unlock()
// Create wait request
req := &WaitRequest{
@@ -61,12 +51,11 @@ func (w *WaitRegistry) RegisterWait(gameID string, moveCount int, ctx context.Co
Notify: make(chan struct{}, WaitChannelBuffer),
Context: ctx,
GameID: gameID,
done: make(chan struct{}),
}
// Setup timeout timer
req.Timer = time.AfterFunc(WaitTimeout, func() {
w.complete(req)
w.handleTimeout(req)
})
// Add to waiters map
@@ -74,15 +63,20 @@ func (w *WaitRegistry) RegisterWait(gameID string, moveCount int, ctx context.Co
// Setup cleanup on context cancellation
w.wg.Add(1)
w.mu.Unlock()
go func() {
defer w.wg.Done()
select {
case <-ctx.Done():
w.complete(req)
// Client disconnected
w.removeWaiter(gameID, req)
case <-req.Notify:
// Notification received
req.Timer.Stop()
w.removeWaiter(gameID, req)
case <-w.shutdown:
w.complete(req)
case <-req.done:
// Server shutting down
req.Timer.Stop()
close(req.Notify)
}
}()
@@ -90,41 +84,48 @@ func (w *WaitRegistry) RegisterWait(gameID string, moveCount int, ctx context.Co
}
// NotifyGame notifies all clients waiting on a game about state change
func (w *WaitRegistry) NotifyGame(gameID string, currentMoveCount int, state core.State) {
func (w *WaitRegistry) NotifyGame(gameID string, currentMoveCount int) {
w.mu.RLock()
waitList := append([]*WaitRequest(nil), w.waiters[gameID]...)
waitList := w.waiters[gameID]
w.mu.RUnlock()
if len(waitList) == 0 {
return
}
settled := state != core.StateOngoing && state != core.StatePending
// Non-blocking notification to all waiters
for _, req := range waitList {
if settled || req.MoveCount != currentMoveCount {
w.complete(req)
// Only notify if move count changed
if req.MoveCount != currentMoveCount {
select {
case req.Notify <- struct{}{}:
// Notification sent
default:
// Channel full or closed, skip slow client
}
}
}
}
// RemoveGame removes all waiters for a game (called before game deletion)
func (w *WaitRegistry) RemoveGame(gameID string) {
w.mu.RLock()
waitList := append([]*WaitRequest(nil), w.waiters[gameID]...)
w.mu.RUnlock()
w.mu.Lock()
waitList := w.waiters[gameID]
delete(w.waiters, gameID)
w.mu.Unlock()
// Notify all waiters that game is gone
for _, req := range waitList {
w.complete(req)
select {
case req.Notify <- struct{}{}:
default:
}
}
}
// Shutdown gracefully shuts down the wait registry
func (w *WaitRegistry) Shutdown(timeout time.Duration) error {
w.shutdownOnce.Do(func() {
w.mu.Lock()
w.closed = true
close(w.shutdown)
w.mu.Unlock()
})
// Wait for all goroutines with timeout
done := make(chan struct{})
@@ -141,27 +142,36 @@ func (w *WaitRegistry) Shutdown(timeout time.Duration) error {
}
}
// complete removes a waiter and closes its notification channel exactly once.
// The registry never consumes Notify itself: the HTTP handler is its sole
// consumer, so a state-change signal cannot be lost to a cleanup goroutine.
func (w *WaitRegistry) complete(req *WaitRequest) {
req.finish.Do(func() {
req.Timer.Stop()
// handleTimeout handles wait request timeout
func (w *WaitRegistry) handleTimeout(req *WaitRequest) {
// Send timeout notification
select {
case req.Notify <- struct{}{}:
// Timeout notification sent
default:
// Channel full or closed
}
}
// removeWaiter removes a specific waiter from the registry
func (w *WaitRegistry) removeWaiter(gameID string, req *WaitRequest) {
w.mu.Lock()
waitList := w.waiters[req.GameID]
defer w.mu.Unlock()
waitList := w.waiters[gameID]
for i, waiter := range waitList {
if waiter == req {
w.waiters[req.GameID] = append(waitList[:i], waitList[i+1:]...)
// Remove from slice
w.waiters[gameID] = append(waitList[:i], waitList[i+1:]...)
break
}
}
if len(w.waiters[req.GameID]) == 0 {
delete(w.waiters, req.GameID)
}
w.mu.Unlock()
close(req.done)
close(req.Notify)
})
// Clean up empty entries
if len(w.waiters[gameID]) == 0 {
delete(w.waiters, gameID)
}
// Stop timer if still running
req.Timer.Stop()
}
-81
View File
@@ -1,81 +0,0 @@
package service
import (
"context"
"sync"
"testing"
"time"
"chess/internal/server/core"
)
func TestWaitRegistryDeliversNotificationToCallerAndRemovesWaiter(t *testing.T) {
registry := NewWaitRegistry()
notify := registry.RegisterWait("game-1", 0, context.Background())
registry.NotifyGame("game-1", 1, core.StateOngoing)
select {
case <-notify:
case <-time.After(time.Second):
t.Fatal("game update was consumed before reaching the caller")
}
select {
case _, ok := <-notify:
if ok {
t.Fatal("completed notification channel returned another value")
}
default:
t.Fatal("completed notification channel was not closed")
}
registry.mu.RLock()
remaining := len(registry.waiters["game-1"])
registry.mu.RUnlock()
if remaining != 0 {
t.Fatalf("completed waiter remains registered: %d", remaining)
}
if err := registry.Shutdown(time.Second); err != nil {
t.Fatal(err)
}
}
func TestWaitRegistryCompletionAndShutdownAreConcurrentAndIdempotent(t *testing.T) {
registry := NewWaitRegistry()
ctx, cancel := context.WithCancel(context.Background())
notify := registry.RegisterWait("game-1", 0, ctx)
var callers sync.WaitGroup
callers.Add(3)
go func() {
defer callers.Done()
registry.NotifyGame("game-1", 1, core.StateOngoing)
}()
go func() {
defer callers.Done()
registry.RemoveGame("game-1")
}()
go func() {
defer callers.Done()
cancel()
}()
callers.Wait()
select {
case <-notify:
case <-time.After(time.Second):
t.Fatal("concurrent completion did not notify caller")
}
if err := registry.Shutdown(time.Second); err != nil {
t.Fatal(err)
}
if err := registry.Shutdown(time.Second); err != nil {
t.Fatalf("second shutdown failed: %v", err)
}
afterShutdown := registry.RegisterWait("game-2", 0, context.Background())
select {
case <-afterShutdown:
default:
t.Fatal("registration after shutdown did not return a closed signal")
}
}
+84 -424
View File
@@ -1,477 +1,137 @@
package storage
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"time"
"log"
)
const gameSelectColumns = `
g.game_id, g.initial_fen,
g.white_player_id, g.white_type, g.white_level, g.white_search_time, g.white_claimed_by,
g.black_player_id, g.black_type, g.black_level, g.black_search_time, g.black_claimed_by,
g.result, g.start_time_utc, g.end_time_utc`
// RecordNewGame asynchronously records a new game. Terminal custom-FEN games
// include their result in this insert rather than relying on a second write.
// RecordNewGame asynchronously records a new game
func (s *Store) RecordNewGame(record GameRecord) error {
if record.GameID == "" || record.InitialFEN == "" || record.WhitePlayerID == "" || record.BlackPlayerID == "" {
return errors.New("game ID, initial FEN, and player IDs are required")
}
if err := validateResultTime(record.Result, record.EndTimeUTC); err != nil {
return err
if !s.healthStatus.Load() {
return nil // Silently drop if degraded
}
return s.enqueue("record_game", record.GameID, func(tx *sql.Tx) error {
const query = `INSERT INTO games (
select {
case s.writeChan <- func(tx *sql.Tx) error {
query := `INSERT INTO games (
game_id, initial_fen,
white_player_id, white_type, white_level, white_search_time, white_claimed_by,
black_player_id, black_type, black_level, black_search_time, black_claimed_by,
start_time_utc, result, end_time_utc
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
white_player_id, white_type, white_level, white_search_time,
black_player_id, black_type, black_level, black_search_time,
start_time_utc
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
_, err := tx.Exec(query,
record.GameID, record.InitialFEN,
record.WhitePlayerID, record.WhiteType, record.WhiteLevel, record.WhiteSearchTime,
nullableString(record.WhiteClaimedBy),
record.BlackPlayerID, record.BlackType, record.BlackLevel, record.BlackSearchTime,
nullableString(record.BlackClaimedBy),
record.StartTimeUTC, nullableString(record.Result), record.EndTimeUTC,
record.StartTimeUTC,
)
return err
})
}:
return nil
default:
// Channel full, drop write
log.Printf("Storage write queue full, dropping game record")
return nil
}
}
// RecordMove atomically persists an accepted move and any first-move claim or
// terminal result caused by that move.
func (s *Store) RecordMove(record MovePersistence) error {
if record.Move.GameID == "" || record.Move.MoveNumber < 1 ||
record.Move.MoveUCI == "" || record.Move.FENAfterMove == "" {
return errors.New("move game ID, positive move number, UCI, and resulting FEN are required")
}
if record.Move.PlayerColor != "w" && record.Move.PlayerColor != "b" {
return fmt.Errorf("invalid move color %q", record.Move.PlayerColor)
}
if record.ClaimColor != "" && record.ClaimColor != "w" && record.ClaimColor != "b" {
return fmt.Errorf("invalid claim color %q", record.ClaimColor)
}
if (record.ClaimColor == "") != (record.ClaimedBy == "") {
return errors.New("claim color and claimant must be provided together")
}
if err := validateResultTime(record.Result, record.EndTimeUTC); err != nil {
return err
// RecordMove asynchronously records a move
func (s *Store) RecordMove(record MoveRecord) error {
if !s.healthStatus.Load() {
return nil // Silently drop if degraded
}
return s.enqueue("record_move", record.Move.GameID, func(tx *sql.Tx) error {
const insertMove = `INSERT INTO moves (
select {
case s.writeChan <- func(tx *sql.Tx) error {
query := `INSERT INTO moves (
game_id, move_number, move_uci, fen_after_move, player_color, move_time_utc
) VALUES (?, ?, ?, ?, ?, ?)`
if _, err := tx.Exec(insertMove,
record.Move.GameID,
record.Move.MoveNumber,
record.Move.MoveUCI,
record.Move.FENAfterMove,
record.Move.PlayerColor,
record.Move.MoveTimeUTC,
); err != nil {
return err
}
if record.ClaimedBy != "" {
column := "white_claimed_by"
if record.ClaimColor == "b" {
column = "black_claimed_by"
}
query := `UPDATE games SET ` + column + ` = ?
WHERE game_id = ? AND (` + column + ` IS NULL OR ` + column + ` = '' OR ` + column + ` = ?)`
result, err := tx.Exec(query, record.ClaimedBy, record.Move.GameID, record.ClaimedBy)
if err != nil {
return err
}
if err := requireOneGame(result, record.Move.GameID); err != nil {
return err
}
}
if record.Result != "" {
result, err := tx.Exec(
`UPDATE games SET result = ?, end_time_utc = ? WHERE game_id = ?`,
record.Result, record.EndTimeUTC, record.Move.GameID,
_, err := tx.Exec(query,
record.GameID, record.MoveNumber, record.MoveUCI,
record.FENAfterMove, record.PlayerColor, record.MoveTimeUTC,
)
if err != nil {
return err
}
return requireOneGame(result, record.Move.GameID)
}
}:
return nil
})
default:
// Channel full, drop write
log.Printf("Storage write queue full, dropping move record")
return nil
}
}
// RecordGameResult persists a terminal transition not accompanied by a move,
// such as a no-legal-moves engine response.
func (s *Store) RecordGameResult(gameID, result string, at time.Time) error {
if gameID == "" {
return errors.New("game ID is required")
// DeleteUndoneMoves asynchronously deletes moves after undo
func (s *Store) DeleteUndoneMoves(gameID string, afterMoveNumber int) error {
if !s.healthStatus.Load() {
return nil // Silently drop if degraded
}
if !isValidResult(result) {
return fmt.Errorf("invalid game result %q", result)
}
if at.IsZero() {
return errors.New("game result time is required")
}
return s.enqueue("record_game_result", gameID, func(tx *sql.Tx) error {
res, err := tx.Exec(
`UPDATE games SET result = ?, end_time_utc = ? WHERE game_id = ?`,
result, at.UTC(), gameID,
)
if err != nil {
select {
case s.writeChan <- func(tx *sql.Tx) error {
query := `DELETE FROM moves WHERE game_id = ? AND move_number > ?`
_, err := tx.Exec(query, gameID, afterMoveNumber)
return err
}:
return nil
default:
// Channel full, drop write
log.Printf("Storage write queue full, dropping undo operation")
return nil
}
return requireOneGame(res, gameID)
})
}
// RecordSlotClaim persists a claim made independently from a move.
func (s *Store) RecordSlotClaim(gameID, color, userID string) error {
if gameID == "" || userID == "" {
return errors.New("game ID and claimant are required")
}
if color != "w" && color != "b" {
return fmt.Errorf("invalid claim color %q", color)
}
column := "white_claimed_by"
if color == "b" {
column = "black_claimed_by"
}
return s.enqueue("record_slot_claim", gameID, func(tx *sql.Tx) error {
query := `UPDATE games SET ` + column + ` = ?
WHERE game_id = ? AND (` + column + ` IS NULL OR ` + column + ` = '' OR ` + column + ` = ?)`
res, err := tx.Exec(query, userID, gameID, userID)
if err != nil {
return err
}
return requireOneGame(res, gameID)
})
}
// RecordPlayers keeps persisted player configuration aligned with in-memory
// configuration changes.
func (s *Store) RecordPlayers(gameID string, white, black PlayerRecord) error {
if gameID == "" || white.PlayerID == "" || black.PlayerID == "" {
return errors.New("game ID and player IDs are required")
}
return s.enqueue("record_players", gameID, func(tx *sql.Tx) error {
const query = `UPDATE games SET
white_player_id = ?, white_type = ?, white_level = ?, white_search_time = ?, white_claimed_by = ?,
black_player_id = ?, black_type = ?, black_level = ?, black_search_time = ?, black_claimed_by = ?
WHERE game_id = ?`
res, err := tx.Exec(query,
white.PlayerID, white.Type, white.Level, white.SearchTime, nullableString(white.ClaimedBy),
black.PlayerID, black.Type, black.Level, black.SearchTime, nullableString(black.ClaimedBy),
gameID,
)
if err != nil {
return err
}
return requireOneGame(res, gameID)
})
}
// PlayerRecord is the persistence subset of a player configuration.
type PlayerRecord struct {
PlayerID string
Type int
Level int
SearchTime int
ClaimedBy string
}
// RewindGame atomically removes undone moves and clears a previously terminal
// result so replay readers never observe an ongoing line with a stale outcome.
func (s *Store) RewindGame(gameID string, afterMoveNumber int) error {
if gameID == "" || afterMoveNumber < 0 {
return errors.New("game ID and a non-negative move number are required")
}
return s.enqueue("rewind_game", gameID, func(tx *sql.Tx) error {
if _, err := tx.Exec(
`DELETE FROM moves WHERE game_id = ? AND move_number > ?`,
gameID, afterMoveNumber,
); err != nil {
return err
}
res, err := tx.Exec(
`UPDATE games SET result = NULL, end_time_utc = NULL WHERE game_id = ?`,
gameID,
)
if err != nil {
return err
}
return requireOneGame(res, gameID)
})
}
// QueryGames retrieves games with optional filtering. A player filter matches
// both creation-time player IDs and claims made after game creation.
// QueryGames retrieves games with optional filtering
func (s *Store) QueryGames(gameID, playerID string) ([]GameRecord, error) {
if err := s.flushBeforeRead(); err != nil {
return nil, err
}
started := time.Now()
query := `SELECT ` + gameSelectColumns + ` FROM games g WHERE 1=1`
query := `SELECT
game_id, initial_fen,
white_player_id, white_type, white_level, white_search_time,
black_player_id, black_type, black_level, black_search_time,
start_time_utc
FROM games WHERE 1=1`
var args []any
// Handle gameID filtering
if gameID != "" && gameID != "*" {
query += " AND g.game_id = ?"
query += " AND game_id = ?"
args = append(args, gameID)
}
// Handle playerID filtering
if playerID != "" && playerID != "*" {
query += ` AND (g.white_player_id = ? OR g.black_player_id = ?
OR g.white_claimed_by = ? OR g.black_claimed_by = ?)`
args = append(args, playerID, playerID, playerID, playerID)
query += " AND (white_player_id = ? OR black_player_id = ?)"
args = append(args, playerID, playerID)
}
query += " ORDER BY g.start_time_utc DESC, g.game_id DESC"
query += " ORDER BY start_time_utc DESC"
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("query games: %w", err)
return nil, fmt.Errorf("query failed: %w", err)
}
defer rows.Close()
games := make([]GameRecord, 0)
var games []GameRecord
for rows.Next() {
var record GameRecord
if err := scanGame(rows, &record); err != nil {
return nil, fmt.Errorf("scan game: %w", err)
}
games = append(games, record)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate games: %w", err)
}
slog.Debug("storage games queried", "count", len(games), "duration", time.Since(started))
return games, nil
}
func (s *Store) GetGameRecord(gameID string) (*GameRecord, error) {
if err := s.flushBeforeRead(); err != nil {
return nil, err
}
return getGameRecord(s.db, gameID)
}
type gameQueryer interface {
Query(query string, args ...any) (*sql.Rows, error)
QueryRow(query string, args ...any) *sql.Row
}
func getGameRecord(queryer gameQueryer, gameID string) (*GameRecord, error) {
var record GameRecord
row := queryer.QueryRow(`SELECT `+gameSelectColumns+` FROM games g WHERE g.game_id = ?`, gameID)
if err := scanGame(row, &record); err != nil {
return nil, err
}
return &record, nil
}
// GetMovesForGame returns the complete, undo-consistent replay line.
func (s *Store) GetMovesForGame(gameID string) ([]MoveRecord, error) {
if err := s.flushBeforeRead(); err != nil {
return nil, err
}
return getMovesForGame(s.db, gameID)
}
func getMovesForGame(queryer gameQueryer, gameID string) ([]MoveRecord, error) {
const query = `SELECT move_id, game_id, move_number, move_uci,
fen_after_move, player_color, move_time_utc
FROM moves WHERE game_id = ? ORDER BY move_number ASC`
rows, err := queryer.Query(query, gameID)
if err != nil {
return nil, fmt.Errorf("query game moves: %w", err)
}
defer rows.Close()
moves := make([]MoveRecord, 0)
for rows.Next() {
var move MoveRecord
if err := rows.Scan(
&move.MoveID, &move.GameID, &move.MoveNumber, &move.MoveUCI,
&move.FENAfterMove, &move.PlayerColor, &move.MoveTimeUTC,
); err != nil {
return nil, fmt.Errorf("scan game move: %w", err)
}
moves = append(moves, move)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate game moves: %w", err)
}
return moves, nil
}
// GetGameHistory uses one write barrier and one read transaction for a
// consistent game-and-moves snapshot.
func (s *Store) GetGameHistory(gameID string) (*GameRecord, []MoveRecord, error) {
if err := s.flushBeforeRead(); err != nil {
return nil, nil, err
}
started := time.Now()
tx, err := s.db.BeginTx(context.Background(), &sql.TxOptions{ReadOnly: true})
if err != nil {
return nil, nil, fmt.Errorf("begin game history read: %w", err)
}
defer tx.Rollback()
record, err := getGameRecord(tx, gameID)
if err != nil {
return nil, nil, err
}
moves, err := getMovesForGame(tx, gameID)
if err != nil {
return nil, nil, err
}
if err := tx.Commit(); err != nil {
return nil, nil, fmt.Errorf("finish game history read: %w", err)
}
slog.Debug("storage game history queried",
"game_id", gameID, "move_count", len(moves), "duration", time.Since(started))
return record, moves, nil
}
func (s *Store) QueryGamesForUser(userID string, limit, offset int) ([]GameSummaryRecord, error) {
if userID == "" || limit < 1 || limit > 101 || offset < 0 {
return nil, errors.New("user ID, limit from 1 to 101, and non-negative offset are required")
}
if err := s.flushBeforeRead(); err != nil {
return nil, err
}
started := time.Now()
query := `SELECT ` + gameSelectColumns + `,
(SELECT COUNT(*) FROM moves m WHERE m.game_id = g.game_id) AS move_count
FROM games g
WHERE g.white_player_id = ? OR g.black_player_id = ?
OR g.white_claimed_by = ? OR g.black_claimed_by = ?
ORDER BY g.start_time_utc DESC, g.game_id DESC
LIMIT ? OFFSET ?`
rows, err := s.db.Query(query, userID, userID, userID, userID, limit, offset)
if err != nil {
return nil, fmt.Errorf("query user games: %w", err)
}
defer rows.Close()
games := make([]GameSummaryRecord, 0)
for rows.Next() {
var summary GameSummaryRecord
if err := scanGameSummary(rows, &summary); err != nil {
return nil, fmt.Errorf("scan user game: %w", err)
}
games = append(games, summary)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate user games: %w", err)
}
slog.Debug("storage user games queried",
"user_id", userID,
"count", len(games),
"limit", limit,
"offset", offset,
"duration", time.Since(started),
var g GameRecord
err := rows.Scan(
&g.GameID, &g.InitialFEN,
&g.WhitePlayerID, &g.WhiteType, &g.WhiteLevel, &g.WhiteSearchTime,
&g.BlackPlayerID, &g.BlackType, &g.BlackLevel, &g.BlackSearchTime,
&g.StartTimeUTC,
)
if err != nil {
return nil, fmt.Errorf("scan failed: %w", err)
}
games = append(games, g)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows iteration failed: %w", err)
}
return games, nil
}
type rowScanner interface {
Scan(dest ...any) error
}
func scanGame(scanner rowScanner, record *GameRecord) error {
var whiteClaimed, blackClaimed, result sql.NullString
var endTime sql.NullTime
if err := scanner.Scan(
&record.GameID, &record.InitialFEN,
&record.WhitePlayerID, &record.WhiteType, &record.WhiteLevel, &record.WhiteSearchTime, &whiteClaimed,
&record.BlackPlayerID, &record.BlackType, &record.BlackLevel, &record.BlackSearchTime, &blackClaimed,
&result, &record.StartTimeUTC, &endTime,
); err != nil {
return err
}
record.WhiteClaimedBy = whiteClaimed.String
record.BlackClaimedBy = blackClaimed.String
record.Result = result.String
if endTime.Valid {
ended := endTime.Time
record.EndTimeUTC = &ended
}
return nil
}
func scanGameSummary(scanner rowScanner, summary *GameSummaryRecord) error {
var whiteClaimed, blackClaimed, result sql.NullString
var endTime sql.NullTime
if err := scanner.Scan(
&summary.GameID, &summary.InitialFEN,
&summary.WhitePlayerID, &summary.WhiteType, &summary.WhiteLevel, &summary.WhiteSearchTime, &whiteClaimed,
&summary.BlackPlayerID, &summary.BlackType, &summary.BlackLevel, &summary.BlackSearchTime, &blackClaimed,
&result, &summary.StartTimeUTC, &endTime, &summary.MoveCount,
); err != nil {
return err
}
summary.WhiteClaimedBy = whiteClaimed.String
summary.BlackClaimedBy = blackClaimed.String
summary.Result = result.String
if endTime.Valid {
ended := endTime.Time
summary.EndTimeUTC = &ended
}
return nil
}
func requireOneGame(result sql.Result, gameID string) error {
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows != 1 {
return fmt.Errorf("game %s was not updated", gameID)
}
return nil
}
func nullableString(value string) any {
if value == "" {
return nil
}
return value
}
func isValidResult(result string) bool {
switch result {
case "white_wins", "black_wins", "draw", "stalemate":
return true
default:
return false
}
}
func validateResultTime(result string, ended *time.Time) error {
if result == "" {
if ended != nil {
return errors.New("end time requires a game result")
}
return nil
}
if !isValidResult(result) {
return fmt.Errorf("invalid game result %q", result)
}
if ended == nil || ended.IsZero() {
return errors.New("terminal game result requires an end time")
}
return nil
}
// IsGameNotFound keeps callers independent from database/sql details.
func IsGameNotFound(err error) bool {
return errors.Is(err, sql.ErrNoRows)
}
+12 -40
View File
@@ -30,20 +30,11 @@ type GameRecord struct {
WhiteType int `db:"white_type"`
WhiteLevel int `db:"white_level"`
WhiteSearchTime int `db:"white_search_time"`
WhiteClaimedBy string `db:"white_claimed_by"`
BlackPlayerID string `db:"black_player_id"`
BlackType int `db:"black_type"`
BlackLevel int `db:"black_level"`
BlackSearchTime int `db:"black_search_time"`
BlackClaimedBy string `db:"black_claimed_by"`
Result string `db:"result"`
StartTimeUTC time.Time `db:"start_time_utc"`
EndTimeUTC *time.Time `db:"end_time_utc"`
}
type GameSummaryRecord struct {
GameRecord
MoveCount int `db:"move_count"`
}
// MoveRecord represents a row in the moves table
@@ -57,19 +48,7 @@ type MoveRecord struct {
MoveTimeUTC time.Time `db:"move_time_utc"`
}
// MovePersistence groups changes caused by one accepted move so the move,
// first-move slot claim, and terminal result commit in one transaction.
type MovePersistence struct {
Move MoveRecord
ClaimColor string
ClaimedBy string
Result string
EndTimeUTC *time.Time
}
// Schema defines tables only. Indexes are applied after legacy column
// migrations so upgrading an older games table never references a missing
// column.
// Schema defines the SQLite database structure
const Schema = `
CREATE TABLE IF NOT EXISTS users (
user_id TEXT PRIMARY KEY,
@@ -82,6 +61,12 @@ CREATE TABLE IF NOT EXISTS users (
last_login_at DATETIME
);
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_account_type ON users(account_type);
CREATE INDEX IF NOT EXISTS idx_users_expires_at ON users(expires_at);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique ON users(email) WHERE email IS NOT NULL AND email != '';
CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY,
user_id TEXT NOT NULL UNIQUE,
@@ -90,6 +75,9 @@ CREATE TABLE IF NOT EXISTS sessions (
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
CREATE TABLE IF NOT EXISTS games (
game_id TEXT PRIMARY KEY,
initial_fen TEXT NOT NULL,
@@ -101,11 +89,7 @@ CREATE TABLE IF NOT EXISTS games (
black_type INTEGER NOT NULL,
black_level INTEGER NOT NULL DEFAULT 0,
black_search_time INTEGER NOT NULL DEFAULT 1000,
start_time_utc DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
result TEXT CHECK(result IS NULL OR result IN ('white_wins', 'black_wins', 'draw', 'stalemate')),
end_time_utc DATETIME,
white_claimed_by TEXT,
black_claimed_by TEXT
start_time_utc DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS moves (
@@ -119,20 +103,8 @@ CREATE TABLE IF NOT EXISTS moves (
FOREIGN KEY (game_id) REFERENCES games(game_id) ON DELETE CASCADE,
UNIQUE(game_id, move_number)
);
`
const Indexes = `
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique
ON users(email) WHERE email IS NOT NULL AND email != '';
CREATE INDEX IF NOT EXISTS idx_users_temp_created_at
ON users(created_at) WHERE account_type = 'temp';
CREATE INDEX IF NOT EXISTS idx_users_temp_expires_at
ON users(expires_at) WHERE account_type = 'temp' AND expires_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_moves_game_id ON moves(game_id);
CREATE INDEX IF NOT EXISTS idx_games_white_player ON games(white_player_id);
CREATE INDEX IF NOT EXISTS idx_games_black_player ON games(black_player_id);
CREATE INDEX IF NOT EXISTS idx_games_white_claimed ON games(white_claimed_by)
WHERE white_claimed_by IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_games_black_claimed ON games(black_claimed_by)
WHERE black_claimed_by IS NOT NULL;
`
+22 -43
View File
@@ -2,23 +2,30 @@ package storage
import (
"fmt"
"log/slog"
"time"
)
// CreateSession creates or replaces the session for a user (single session per user)
func (s *Store) CreateSession(record SessionRecord) error {
const query = `INSERT INTO sessions (session_id, user_id, created_at, expires_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
session_id = excluded.session_id,
created_at = excluded.created_at,
expires_at = excluded.expires_at`
if _, err := s.db.Exec(query, record.SessionID, record.UserID, record.CreatedAt, record.ExpiresAt); err != nil {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
// Delete any existing session for this user
deleteQuery := `DELETE FROM sessions WHERE user_id = ?`
if _, err := tx.Exec(deleteQuery, record.UserID); err != nil {
return fmt.Errorf("failed to delete existing session: %w", err)
}
// Insert new session
insertQuery := `INSERT INTO sessions (session_id, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)`
if _, err := tx.Exec(insertQuery, record.SessionID, record.UserID, record.CreatedAt, record.ExpiresAt); err != nil {
return fmt.Errorf("failed to create session: %w", err)
}
slog.Debug("storage session created", "user_id", record.UserID, "expires_at", record.ExpiresAt)
return nil
return tx.Commit()
}
// GetSession retrieves a session by ID
@@ -53,9 +60,6 @@ func (s *Store) GetSessionByUserID(userID string) (*SessionRecord, error) {
func (s *Store) DeleteSession(sessionID string) error {
query := `DELETE FROM sessions WHERE session_id = ?`
_, err := s.db.Exec(query, sessionID)
if err == nil {
slog.Debug("storage session deleted")
}
return err
}
@@ -63,9 +67,6 @@ func (s *Store) DeleteSession(sessionID string) error {
func (s *Store) DeleteSessionByUserID(userID string) error {
query := `DELETE FROM sessions WHERE user_id = ?`
_, err := s.db.Exec(query, userID)
if err == nil {
slog.Debug("storage user sessions deleted", "user_id", userID)
}
return err
}
@@ -76,38 +77,16 @@ func (s *Store) DeleteExpiredSessions() (int64, error) {
if err != nil {
return 0, err
}
deleted, err := result.RowsAffected()
if err == nil && deleted > 0 {
slog.Debug("storage expired sessions deleted", "count", deleted)
}
return deleted, err
return result.RowsAffected()
}
// IsSessionValid checks if a session exists and is not expired
func (s *Store) IsSessionValid(sessionID string) (bool, error) {
var valid bool
const query = `SELECT EXISTS(
SELECT 1 FROM sessions WHERE session_id = ? AND expires_at > ?
)`
err := s.db.QueryRow(query, sessionID, time.Now().UTC()).Scan(&valid)
var count int
query := `SELECT COUNT(*) FROM sessions WHERE session_id = ? AND expires_at > ?`
err := s.db.QueryRow(query, sessionID, time.Now().UTC()).Scan(&count)
if err != nil {
return false, err
}
return valid, nil
}
// IsSessionValidForUser verifies both expiry and the binding between a JWT
// subject and its persisted session. Checking only the session ID would allow
// a malformed server-issued token to authenticate as the wrong subject.
func (s *Store) IsSessionValidForUser(sessionID, userID string) (bool, error) {
var valid bool
const query = `SELECT EXISTS(
SELECT 1 FROM sessions
WHERE session_id = ? AND user_id = ? AND expires_at > ?
)`
err := s.db.QueryRow(query, sessionID, userID, time.Now().UTC()).Scan(&valid)
if err != nil {
return false, err
}
return valid, nil
return count > 0, nil
}
+57 -297
View File
@@ -3,11 +3,9 @@ package storage
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"log"
"os"
"strings"
"sync"
"sync/atomic"
"time"
@@ -15,67 +13,48 @@ import (
_ "github.com/mattn/go-sqlite3"
)
const (
writeQueueCapacity = 1000
flushTimeout = 5 * time.Second
schemaVersion = 2
)
var memoryStoreCounter atomic.Uint64
var (
ErrStorageDegraded = errors.New("storage is degraded")
ErrStoreClosed = errors.New("storage is closed")
ErrWriteQueueFull = errors.New("storage write queue is full")
)
type writeRequest struct {
operation string
gameID string
run func(*sql.Tx) error
barrier chan error
}
// Store handles SQLite database operations with async writes for games and sync writes for auth
type Store struct {
db *sql.DB
path string
writeChan chan writeRequest
writeChan chan func(*sql.Tx) error
healthStatus atomic.Bool
writeFailed atomic.Bool
closed atomic.Bool
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
enqueueMu sync.RWMutex
closeOnce sync.Once
closeErr error
}
// NewStore creates a new storage instance with async writer
func NewStore(dataSourceName string, devMode bool) (*Store, error) {
dsn := sqliteDSN(dataSourceName)
db, err := sql.Open("sqlite3", dsn)
db, err := sql.Open("sqlite3", dataSourceName)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
if err := db.Ping(); err != nil {
// Enable WAL mode in development for better concurrency
if devMode {
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
db.Close()
return nil, fmt.Errorf("failed to connect to database: %w", err)
return nil, fmt.Errorf("failed to enable WAL mode: %w", err)
}
}
// SQLite benefits from a small pool. WAL and busy_timeout are configured in
// the DSN for every connection, unlike connection-local PRAGMA calls.
db.SetMaxOpenConns(8)
db.SetMaxIdleConns(4)
// Enable foreign keys
if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
db.Close()
return nil, fmt.Errorf("failed to enable foreign keys: %w", err)
}
// Configure connection pool
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
ctx, cancel := context.WithCancel(context.Background())
s := &Store{
db: db,
path: dataSourceName,
writeChan: make(chan writeRequest, writeQueueCapacity),
writeChan: make(chan func(*sql.Tx) error, 1000), // Buffered for async writes
ctx: ctx,
cancel: cancel,
}
@@ -86,34 +65,10 @@ func NewStore(dataSourceName string, devMode bool) (*Store, error) {
// Start async writer
s.wg.Add(1)
go s.writerLoop()
slog.Debug("storage opened",
"path", dataSourceName,
"dev_mode", devMode,
"write_queue_capacity", writeQueueCapacity,
"max_open_connections", 8,
)
return s, nil
}
func sqliteDSN(dataSourceName string) string {
if dataSourceName == ":memory:" {
// Each Store needs a private shared-cache database: shared cache keeps
// that Store's pooled connections on one database, while the unique name
// prevents independent in-memory stores from leaking into each other.
dataSourceName = fmt.Sprintf(
"file:chess-memory-%d?mode=memory&cache=shared",
memoryStoreCounter.Add(1),
)
}
separator := "?"
if strings.Contains(dataSourceName, "?") {
separator = "&"
}
return dataSourceName + separator +
"_foreign_keys=on&_busy_timeout=5000&_journal_mode=WAL&_synchronous=NORMAL"
}
// IsHealthy returns true if the storage is operational
func (s *Store) IsHealthy() bool {
return s.healthStatus.Load()
@@ -122,291 +77,96 @@ func (s *Store) IsHealthy() bool {
// writerLoop processes async write operations
func (s *Store) writerLoop() {
defer s.wg.Done()
slog.Debug("storage writer started")
defer slog.Debug("storage writer stopped")
for {
select {
case <-s.ctx.Done():
// Every accepted operation is drained before shutdown. The queue is
// bounded, so shutdown remains bounded by actual database work rather
// than an arbitrary timer that can discard replay history.
// Drain remaining writes with timeout
deadline := time.After(2 * time.Second)
for {
select {
case req := <-s.writeChan:
s.handleWrite(req)
case fn := <-s.writeChan:
if s.healthStatus.Load() {
s.executeWrite(fn)
}
case <-deadline:
return
default:
return
}
}
case req := <-s.writeChan:
s.handleWrite(req)
}
}
}
func (s *Store) handleWrite(req writeRequest) {
if req.run == nil {
if req.barrier != nil {
var err error
case fn := <-s.writeChan:
// Skip if already degraded
if !s.healthStatus.Load() {
err = ErrStorageDegraded
continue
}
req.barrier <- err
close(req.barrier)
s.executeWrite(fn)
}
return
}
if s.writeFailed.Load() {
slog.Error("storage write skipped after earlier transaction failure",
"operation", req.operation, "game_id", req.gameID)
if req.barrier != nil {
req.barrier <- ErrStorageDegraded
close(req.barrier)
}
return
}
err := s.executeWrite(req)
if req.barrier != nil {
req.barrier <- err
close(req.barrier)
}
}
// executeWrite runs a transactional write operation
func (s *Store) executeWrite(req writeRequest) error {
started := time.Now()
func (s *Store) executeWrite(fn func(*sql.Tx) error) {
tx, err := s.db.Begin()
if err != nil {
s.writeFailed.Store(true)
log.Printf("Storage degraded: failed to begin transaction: %v", err)
s.healthStatus.Store(false)
slog.Error("storage degraded: failed to begin transaction",
"operation", req.operation, "game_id", req.gameID, "error", err)
return err
return
}
if err := req.run(tx); err != nil {
rollbackErr := tx.Rollback()
s.writeFailed.Store(true)
if err := fn(tx); err != nil {
tx.Rollback()
log.Printf("Storage degraded: write operation failed: %v", err)
s.healthStatus.Store(false)
slog.Error("storage degraded: write operation failed",
"operation", req.operation,
"game_id", req.gameID,
"error", err,
"rollback_error", rollbackErr,
)
return err
return
}
if err := tx.Commit(); err != nil {
s.writeFailed.Store(true)
log.Printf("Storage degraded: failed to commit: %v", err)
s.healthStatus.Store(false)
slog.Error("storage degraded: failed to commit",
"operation", req.operation, "game_id", req.gameID, "error", err)
return err
return
}
slog.Debug("storage write committed",
"operation", req.operation,
"game_id", req.gameID,
"duration", time.Since(started),
"queue_depth", len(s.writeChan),
)
return nil
}
func (s *Store) enqueue(operation, gameID string, fn func(*sql.Tx) error) error {
s.enqueueMu.RLock()
defer s.enqueueMu.RUnlock()
if s.closed.Load() {
return ErrStoreClosed
}
if !s.healthStatus.Load() {
return ErrStorageDegraded
}
select {
case s.writeChan <- writeRequest{operation: operation, gameID: gameID, run: fn}:
slog.Debug("storage write queued",
"operation", operation,
"game_id", gameID,
"queue_depth", len(s.writeChan),
)
return nil
default:
s.healthStatus.Store(false)
slog.Error("storage degraded: write queue full",
"operation", operation,
"game_id", gameID,
"queue_capacity", cap(s.writeChan),
)
return ErrWriteQueueFull
}
}
// Flush waits until every write queued before this call has completed. Replay
// reads use this barrier to provide read-after-write consistency while normal
// gameplay retains the low-latency async write path.
func (s *Store) Flush(ctx context.Context) error {
s.enqueueMu.RLock()
if s.closed.Load() {
s.enqueueMu.RUnlock()
return ErrStoreClosed
}
if !s.healthStatus.Load() {
s.enqueueMu.RUnlock()
return ErrStorageDegraded
}
done := make(chan error, 1)
select {
case s.writeChan <- writeRequest{operation: "flush", barrier: done}:
s.enqueueMu.RUnlock()
case <-ctx.Done():
s.enqueueMu.RUnlock()
return ctx.Err()
}
select {
case err := <-done:
return err
case <-ctx.Done():
return ctx.Err()
}
}
func (s *Store) flushBeforeRead() error {
ctx, cancel := context.WithTimeout(context.Background(), flushTimeout)
defer cancel()
return s.Flush(ctx)
}
// Close gracefully closes the database connection
func (s *Store) Close() error {
s.closeOnce.Do(func() {
s.enqueueMu.Lock()
s.closed.Store(true)
// Signal writer to stop
s.cancel()
s.enqueueMu.Unlock()
// Wait for writer with timeout
done := make(chan struct{})
go func() {
s.wg.Wait()
if s.db != nil {
s.closeErr = s.db.Close()
close(done)
}()
select {
case <-done:
// Writer finished cleanly
case <-time.After(2 * time.Second):
log.Printf("Warning: storage writer shutdown timeout, some writes may be lost")
}
})
return s.closeErr
if s.db != nil {
return s.db.Close()
}
return nil
}
// InitDB creates the database schema
func (s *Store) InitDB() error {
started := time.Now()
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
var currentVersion int
if err := tx.QueryRow("PRAGMA user_version").Scan(&currentVersion); err != nil {
return fmt.Errorf("failed to read schema version: %w", err)
}
if currentVersion > schemaVersion {
return fmt.Errorf(
"database schema version %d is newer than supported version %d",
currentVersion,
schemaVersion,
)
}
if _, err := tx.Exec(Schema); err != nil {
return fmt.Errorf("failed to create schema: %w", err)
}
columns := []struct {
name string
definition string
}{
{"result", "TEXT CHECK(result IS NULL OR result IN ('white_wins', 'black_wins', 'draw', 'stalemate'))"},
{"end_time_utc", "DATETIME"},
{"white_claimed_by", "TEXT"},
{"black_claimed_by", "TEXT"},
}
for _, column := range columns {
if err := ensureColumn(tx, "games", column.name, column.definition); err != nil {
return err
}
}
// These indexes duplicate UNIQUE constraints or are superseded by targeted
// partial/composite indexes. Drop them during upgrades as well as omitting
// them from new databases.
for _, name := range []string{
"idx_users_username",
"idx_users_email",
"idx_users_account_type",
"idx_users_expires_at",
"idx_sessions_user_id",
"idx_moves_game_id",
"idx_games_finished_end_time",
} {
if _, err := tx.Exec("DROP INDEX IF EXISTS " + name); err != nil {
return fmt.Errorf("failed to remove redundant index %s: %w", name, err)
}
}
if _, err := tx.Exec(Indexes); err != nil {
return fmt.Errorf("failed to create indexes: %w", err)
}
if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", schemaVersion)); err != nil {
return fmt.Errorf("failed to record schema version: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit schema: %w", err)
}
slog.Debug("storage schema ready", "version", schemaVersion, "duration", time.Since(started))
return nil
}
func ensureColumn(tx *sql.Tx, table, column, definition string) error {
rows, err := tx.Query("PRAGMA table_info(" + table + ")")
if err != nil {
return fmt.Errorf("failed to inspect %s schema: %w", table, err)
}
found := false
for rows.Next() {
var cid int
var name, columnType string
var notNull, primaryKey int
var defaultValue any
if err := rows.Scan(&cid, &name, &columnType, &notNull, &defaultValue, &primaryKey); err != nil {
rows.Close()
return fmt.Errorf("failed to inspect %s column: %w", table, err)
}
if name == column {
found = true
}
}
if err := rows.Close(); err != nil {
return fmt.Errorf("failed to close %s schema rows: %w", table, err)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("failed to inspect %s schema: %w", table, err)
}
if found {
return nil
}
if _, err := tx.Exec("ALTER TABLE " + table + " ADD COLUMN " + column + " " + definition); err != nil {
return fmt.Errorf("failed to add %s.%s: %w", table, column, err)
}
slog.Debug("storage schema column added", "table", table, "column", column)
return nil
return tx.Commit()
}
// DeleteDB removes the database file
-525
View File
@@ -1,525 +0,0 @@
package storage
import (
"context"
"database/sql"
"errors"
"path/filepath"
"strings"
"testing"
"time"
_ "github.com/mattn/go-sqlite3"
)
func TestInitDBMigratesLegacySchemaAndRemovesRedundantIndexes(t *testing.T) {
path := filepath.Join(t.TempDir(), "legacy.db")
db, err := sql.Open("sqlite3", path)
if err != nil {
t.Fatal(err)
}
const legacy = `
CREATE TABLE users (
user_id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL COLLATE NOCASE,
email TEXT COLLATE NOCASE,
password_hash TEXT NOT NULL,
account_type TEXT NOT NULL DEFAULT 'temp',
created_at DATETIME NOT NULL,
expires_at DATETIME,
last_login_at DATETIME
);
CREATE TABLE sessions (
session_id TEXT PRIMARY KEY,
user_id TEXT NOT NULL UNIQUE,
created_at DATETIME NOT NULL,
expires_at DATETIME NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
);
CREATE TABLE games (
game_id TEXT PRIMARY KEY,
initial_fen TEXT NOT NULL,
white_player_id TEXT NOT NULL,
white_type INTEGER NOT NULL,
white_level INTEGER NOT NULL DEFAULT 0,
white_search_time INTEGER NOT NULL DEFAULT 1000,
black_player_id TEXT NOT NULL,
black_type INTEGER NOT NULL,
black_level INTEGER NOT NULL DEFAULT 0,
black_search_time INTEGER NOT NULL DEFAULT 1000,
start_time_utc DATETIME NOT NULL
);
CREATE TABLE moves (
move_id INTEGER PRIMARY KEY AUTOINCREMENT,
game_id TEXT NOT NULL,
move_number INTEGER NOT NULL,
move_uci TEXT NOT NULL,
fen_after_move TEXT NOT NULL,
player_color TEXT NOT NULL,
move_time_utc DATETIME NOT NULL,
FOREIGN KEY (game_id) REFERENCES games(game_id) ON DELETE CASCADE,
UNIQUE(game_id, move_number)
);
CREATE INDEX idx_users_username ON users(username);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_account_type ON users(account_type);
CREATE INDEX idx_users_expires_at ON users(expires_at);
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
CREATE INDEX idx_moves_game_id ON moves(game_id);`
if _, err := db.Exec(legacy); err != nil {
t.Fatalf("create legacy schema: %v", err)
}
if err := db.Close(); err != nil {
t.Fatal(err)
}
store, err := NewStore(path, false)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = store.Close() })
if err := store.InitDB(); err != nil {
t.Fatalf("migrate schema: %v", err)
}
columns := tableColumns(t, store.db, "games")
for _, column := range []string{"result", "end_time_utc", "white_claimed_by", "black_claimed_by"} {
if !columns[column] {
t.Errorf("migration did not add games.%s", column)
}
}
indexes := schemaIndexes(t, store.db)
for _, obsolete := range []string{
"idx_users_username", "idx_users_email", "idx_users_account_type",
"idx_users_expires_at", "idx_sessions_user_id", "idx_moves_game_id",
"idx_games_finished_end_time",
} {
if indexes[obsolete] {
t.Errorf("redundant index %s remains", obsolete)
}
}
for _, required := range []string{
"idx_users_email_unique", "idx_users_temp_created_at", "idx_users_temp_expires_at",
"idx_sessions_expires_at", "idx_games_white_player", "idx_games_black_player",
"idx_games_white_claimed", "idx_games_black_claimed",
} {
if !indexes[required] {
t.Errorf("required index %s is missing", required)
}
}
var version, foreignKeys int
if err := store.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil {
t.Fatal(err)
}
if version != 2 {
t.Errorf("schema version = %d, want 2", version)
}
if err := store.db.QueryRow("PRAGMA foreign_keys").Scan(&foreignKeys); err != nil {
t.Fatal(err)
}
if foreignKeys != 1 {
t.Errorf("foreign_keys = %d, want 1", foreignKeys)
}
}
func TestReplayPersistenceIsAtomicAndReadAfterWriteConsistent(t *testing.T) {
store := newTestStore(t)
started := time.Date(2026, 9, 7, 1, 2, 3, 0, time.UTC)
ended := started.Add(5 * time.Minute)
if err := store.RecordNewGame(GameRecord{
GameID: "game-1", InitialFEN: "initial",
WhitePlayerID: "anonymous-white", WhiteType: 1,
BlackPlayerID: "black", BlackType: 1,
StartTimeUTC: started,
}); err != nil {
t.Fatal(err)
}
if err := store.RecordMove(MovePersistence{
Move: MoveRecord{
GameID: "game-1", MoveNumber: 1, MoveUCI: "e2e4",
FENAfterMove: "after-e2e4", PlayerColor: "w", MoveTimeUTC: ended,
},
ClaimColor: "w", ClaimedBy: "user-1",
Result: "white_wins", EndTimeUTC: &ended,
}); err != nil {
t.Fatal(err)
}
// GetGameHistory must observe both queued writes without sleeps or polling.
gameRecord, moves, err := store.GetGameHistory("game-1")
if err != nil {
t.Fatal(err)
}
if gameRecord.WhiteClaimedBy != "user-1" || gameRecord.Result != "white_wins" || gameRecord.EndTimeUTC == nil {
t.Fatalf("durable game mutation incomplete: %+v", gameRecord)
}
if len(moves) != 1 || moves[0].MoveNumber != 1 || moves[0].FENAfterMove != "after-e2e4" {
t.Fatalf("moves = %+v", moves)
}
owned, err := store.QueryGamesForUser("user-1", 10, 0)
if err != nil {
t.Fatal(err)
}
if len(owned) != 1 || owned[0].MoveCount != 1 {
t.Fatalf("claimed game lookup = %+v", owned)
}
if err := store.RewindGame("game-1", 0); err != nil {
t.Fatal(err)
}
gameRecord, moves, err = store.GetGameHistory("game-1")
if err != nil {
t.Fatal(err)
}
if gameRecord.Result != "" || gameRecord.EndTimeUTC != nil || len(moves) != 0 {
t.Fatalf("rewind left stale replay data: game=%+v moves=%+v", gameRecord, moves)
}
}
func TestConnectionSettingsApplyAcrossPool(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
connections := make([]*sql.Conn, 0, 8)
defer func() {
for _, connection := range connections {
_ = connection.Close()
}
}()
// Keep each connection checked out so the pool must create eight distinct
// SQLite connections, then verify connection-local PRAGMAs on every one.
for range 8 {
connection, err := store.db.Conn(ctx)
if err != nil {
t.Fatal(err)
}
connections = append(connections, connection)
}
for i, connection := range connections {
var foreignKeys, busyTimeout, synchronous int
var journalMode string
if err := connection.QueryRowContext(ctx, "PRAGMA foreign_keys").Scan(&foreignKeys); err != nil {
t.Fatal(err)
}
if err := connection.QueryRowContext(ctx, "PRAGMA busy_timeout").Scan(&busyTimeout); err != nil {
t.Fatal(err)
}
if err := connection.QueryRowContext(ctx, "PRAGMA synchronous").Scan(&synchronous); err != nil {
t.Fatal(err)
}
if err := connection.QueryRowContext(ctx, "PRAGMA journal_mode").Scan(&journalMode); err != nil {
t.Fatal(err)
}
if foreignKeys != 1 || busyTimeout != 5000 || synchronous != 1 || !strings.EqualFold(journalMode, "wal") {
t.Errorf(
"connection %d settings: foreign_keys=%d busy_timeout=%d synchronous=%d journal_mode=%s",
i, foreignKeys, busyTimeout, synchronous, journalMode,
)
}
}
}
func TestAcceptedWriteCommitsAfterQueueAdmissionFailureMarksHealthDegraded(t *testing.T) {
store := newTestStore(t)
store.healthStatus.Store(false) // Queue saturation rejects new work but accepted work must drain.
done := make(chan error, 1)
store.handleWrite(writeRequest{
operation: "accepted_before_saturation",
run: func(tx *sql.Tx) error {
_, err := tx.Exec(`INSERT INTO users
(user_id, username, password_hash, account_type, created_at)
VALUES ('user-1', 'alice', 'hash', 'permanent', ?)`, time.Now().UTC())
return err
},
barrier: done,
})
if err := <-done; err != nil {
t.Fatalf("accepted write was discarded: %v", err)
}
if _, err := store.GetUserByID("user-1"); err != nil {
t.Fatalf("accepted write was not committed: %v", err)
}
}
func TestWritesAfterTransactionFailureAreSkippedExplicitly(t *testing.T) {
store := newTestStore(t)
failed := make(chan error, 1)
store.handleWrite(writeRequest{
operation: "forced_failure",
run: func(*sql.Tx) error { return errors.New("forced failure") },
barrier: failed,
})
if err := <-failed; err == nil {
t.Fatal("forced transaction failure was not reported")
}
ran := false
skipped := make(chan error, 1)
store.handleWrite(writeRequest{
operation: "after_failure",
run: func(*sql.Tx) error {
ran = true
return nil
},
barrier: skipped,
})
if err := <-skipped; !errors.Is(err, ErrStorageDegraded) {
t.Fatalf("skipped write error = %v, want ErrStorageDegraded", err)
}
if ran {
t.Fatal("write ran after an earlier transaction broke ordering")
}
}
func TestNewerSchemaVersionIsRejected(t *testing.T) {
store := newTestStore(t)
if _, err := store.db.Exec("PRAGMA user_version = 3"); err != nil {
t.Fatal(err)
}
if err := store.InitDB(); err == nil || !strings.Contains(err.Error(), "newer than supported") {
t.Fatalf("InitDB error = %v, want newer-version rejection", err)
}
var version int
if err := store.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil {
t.Fatal(err)
}
if version != 3 {
t.Fatalf("newer schema version was overwritten: %d", version)
}
}
func TestQueryPlansUseOnlyPurposeBuiltOrConstraintIndexes(t *testing.T) {
store := newTestStore(t)
now := time.Now().UTC()
tests := []struct {
name string
query string
args []any
want []string
}{
{
name: "email partial uniqueness",
query: `SELECT user_id FROM users
WHERE email = ? COLLATE NOCASE AND email IS NOT NULL AND email != ''`,
args: []any{"alice@example.com"}, want: []string{"idx_users_email_unique"},
},
{
name: "temporary expiry cleanup",
query: `SELECT user_id FROM users
WHERE account_type = 'temp' AND expires_at IS NOT NULL AND expires_at < ?`,
args: []any{now}, want: []string{"idx_users_temp_expires_at"},
},
{
name: "oldest temporary account",
query: `SELECT user_id FROM users
WHERE account_type = 'temp' ORDER BY created_at ASC LIMIT 1`,
want: []string{"idx_users_temp_created_at"},
},
{
name: "ordered moves use composite unique constraint",
query: `SELECT move_uci FROM moves
WHERE game_id = ? ORDER BY move_number ASC`,
args: []any{"game-1"}, want: []string{"sqlite_autoindex_moves_1"},
},
{
name: "all user association branches",
query: `SELECT game_id,
(SELECT COUNT(*) FROM moves m WHERE m.game_id = games.game_id)
FROM games WHERE white_player_id = ? OR black_player_id = ?
OR white_claimed_by = ? OR black_claimed_by = ?
ORDER BY start_time_utc DESC, game_id DESC LIMIT ? OFFSET ?`,
args: []any{"user-1", "user-1", "user-1", "user-1", 50, 0},
want: []string{
"idx_games_white_player", "idx_games_black_player",
"idx_games_white_claimed", "idx_games_black_claimed",
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
plan := explainQueryPlan(t, store.db, test.query, test.args...)
for _, index := range test.want {
if !strings.Contains(plan, index) {
t.Errorf("query plan does not use %s:\n%s", index, plan)
}
}
})
}
}
func TestForeignKeyCascadeAppliesToSessions(t *testing.T) {
store := newTestStore(t)
now := time.Now().UTC()
if err := store.CreateUser(UserRecord{
UserID: "user-1", Username: "user1", PasswordHash: "hash",
AccountType: "permanent", CreatedAt: now,
}); err != nil {
t.Fatal(err)
}
if err := store.CreateSession(SessionRecord{
SessionID: "session-1", UserID: "user-1", CreatedAt: now, ExpiresAt: now.Add(time.Hour),
}); err != nil {
t.Fatal(err)
}
if err := store.DeleteUser("user-1"); err != nil {
t.Fatal(err)
}
if _, err := store.GetSession("session-1"); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("session survived user cascade: %v", err)
}
}
func TestLimitedUserCreationIsAtomicWithInitialSession(t *testing.T) {
store := newTestStore(t)
now := time.Now().UTC()
limits := UserLimits{MaxUsers: 1, PermanentSlots: 1}
first := UserRecord{
UserID: "user-1", Username: "alice", PasswordHash: "hash",
AccountType: "temp", CreatedAt: now, ExpiresAt: timePointer(now.Add(time.Hour)),
}
firstSession := SessionRecord{
SessionID: "session-1", UserID: first.UserID,
CreatedAt: now, ExpiresAt: now.Add(time.Hour),
}
if err := store.CreateUserWithinLimits(first, &firstSession, limits); err != nil {
t.Fatal(err)
}
duplicate := first
duplicate.UserID = "user-duplicate"
if err := store.CreateUserWithinLimits(duplicate, nil, limits); !errors.Is(err, ErrUserAlreadyExists) {
t.Fatalf("duplicate error = %v, want ErrUserAlreadyExists", err)
}
if _, err := store.GetUserByID(first.UserID); err != nil {
t.Fatalf("duplicate registration evicted existing user: %v", err)
}
second := UserRecord{
UserID: "user-2", Username: "bob", PasswordHash: "hash",
AccountType: "temp", CreatedAt: now.Add(time.Minute),
ExpiresAt: timePointer(now.Add(2 * time.Hour)),
}
secondSession := SessionRecord{
SessionID: "session-2", UserID: second.UserID,
CreatedAt: now.Add(time.Minute), ExpiresAt: now.Add(2 * time.Hour),
}
if err := store.CreateUserWithinLimits(second, &secondSession, limits); err != nil {
t.Fatal(err)
}
if _, err := store.GetUserByID(first.UserID); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("oldest temporary user was not replaced: %v", err)
}
if _, err := store.GetSession(firstSession.SessionID); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("evicted user's session survived cascade: %v", err)
}
if _, err := store.GetSession(secondSession.SessionID); err != nil {
t.Fatalf("initial session not committed with user: %v", err)
}
third := UserRecord{
UserID: "user-3", Username: "charlie", PasswordHash: "hash",
AccountType: "temp", CreatedAt: now.Add(2 * time.Minute),
}
conflictingSession := SessionRecord{
SessionID: secondSession.SessionID, UserID: third.UserID,
CreatedAt: now, ExpiresAt: now.Add(time.Hour),
}
wideLimits := UserLimits{MaxUsers: 10, PermanentSlots: 2}
if err := store.CreateUserWithinLimits(third, &conflictingSession, wideLimits); err == nil {
t.Fatal("expected duplicate session failure")
}
if _, err := store.GetUserByID(third.UserID); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("session failure did not roll back user: %v", err)
}
}
func timePointer(value time.Time) *time.Time {
return &value
}
func newTestStore(t *testing.T) *Store {
t.Helper()
store, err := NewStore(filepath.Join(t.TempDir(), "chess.db"), false)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = store.Close() })
if err := store.InitDB(); err != nil {
t.Fatal(err)
}
return store
}
func tableColumns(t *testing.T, db *sql.DB, table string) map[string]bool {
t.Helper()
rows, err := db.Query("PRAGMA table_info(" + table + ")")
if err != nil {
t.Fatal(err)
}
defer rows.Close()
columns := make(map[string]bool)
for rows.Next() {
var cid, notNull, primaryKey int
var name, columnType string
var defaultValue any
if err := rows.Scan(&cid, &name, &columnType, &notNull, &defaultValue, &primaryKey); err != nil {
t.Fatal(err)
}
columns[name] = true
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
return columns
}
func schemaIndexes(t *testing.T, db *sql.DB) map[string]bool {
t.Helper()
rows, err := db.Query(`SELECT name FROM sqlite_schema WHERE type = 'index'`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
indexes := make(map[string]bool)
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
t.Fatal(err)
}
indexes[name] = true
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
return indexes
}
func explainQueryPlan(t *testing.T, db *sql.DB, query string, args ...any) string {
t.Helper()
rows, err := db.Query("EXPLAIN QUERY PLAN "+query, args...)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var details []string
for rows.Next() {
var id, parent, notUsed int
var detail string
if err := rows.Scan(&id, &parent, &notUsed, &detail); err != nil {
t.Fatal(err)
}
details = append(details, detail)
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
return strings.Join(details, "\n")
}
+33 -111
View File
@@ -2,22 +2,16 @@ package storage
import (
"database/sql"
"errors"
"fmt"
"log/slog"
"log"
"time"
)
var (
ErrUserAlreadyExists = errors.New("username or email already exists")
ErrUserCapacity = errors.New("user capacity reached")
ErrPermanentCapacity = errors.New("permanent user capacity reached")
)
// UserLimits defines registration constraints
type UserLimits struct {
MaxUsers int
PermanentSlots int
TempTTL time.Duration
}
// DefaultUserLimits returns default POC limits
@@ -25,6 +19,7 @@ func DefaultUserLimits() UserLimits {
return UserLimits{
MaxUsers: 100,
PermanentSlots: 10,
TempTTL: 24 * time.Hour,
}
}
@@ -32,8 +27,8 @@ func DefaultUserLimits() UserLimits {
func (s *Store) GetUserCounts() (total, permanent, temp int, err error) {
query := `SELECT
COUNT(*) as total,
COUNT(CASE WHEN account_type = 'permanent' THEN 1 END) as permanent,
COUNT(CASE WHEN account_type = 'temp' THEN 1 END) as temp
SUM(CASE WHEN account_type = 'permanent' THEN 1 ELSE 0 END) as permanent,
SUM(CASE WHEN account_type = 'temp' THEN 1 ELSE 0 END) as temp
FROM users`
err = s.db.QueryRow(query).Scan(&total, &permanent, &temp)
@@ -43,7 +38,6 @@ func (s *Store) GetUserCounts() (total, permanent, temp int, err error) {
// GetOldestTempUser returns the oldest temporary user for replacement
func (s *Store) GetOldestTempUser() (*UserRecord, error) {
var user UserRecord
var email sql.NullString
query := `SELECT user_id, username, email, password_hash, account_type, created_at, expires_at, last_login_at
FROM users
WHERE account_type = 'temp'
@@ -51,51 +45,28 @@ func (s *Store) GetOldestTempUser() (*UserRecord, error) {
LIMIT 1`
err := s.db.QueryRow(query).Scan(
&user.UserID, &user.Username, &email,
&user.UserID, &user.Username, &user.Email,
&user.PasswordHash, &user.AccountType, &user.CreatedAt,
&user.ExpiresAt, &user.LastLoginAt,
)
if err != nil {
return nil, err
}
user.Email = email.String
return &user, nil
}
// DeleteExpiredTempUsers removes temporary users past their expiry
func (s *Store) DeleteExpiredTempUsers() (int64, error) {
query := `DELETE FROM users
WHERE account_type = 'temp' AND expires_at IS NOT NULL AND expires_at < ?`
query := `DELETE FROM users WHERE account_type = 'temp' AND expires_at < ?`
result, err := s.db.Exec(query, time.Now().UTC())
if err != nil {
return 0, err
}
deleted, err := result.RowsAffected()
if err == nil && deleted > 0 {
slog.Debug("storage expired temporary users deleted", "count", deleted)
}
return deleted, err
return result.RowsAffected()
}
// CreateUser creates an administratively managed user without applying the
// public-registration capacity policy.
// CreateUser creates user with transaction isolation to prevent race conditions
func (s *Store) CreateUser(record UserRecord) error {
return s.createUser(record, nil, nil)
}
// CreateUserWithinLimits atomically applies registration limits, evicts the
// oldest temporary account when required, creates the user, and optionally
// creates its initial session. No account is evicted on a duplicate request,
// and a session failure rolls back the user and eviction together.
func (s *Store) CreateUserWithinLimits(
record UserRecord,
session *SessionRecord,
limits UserLimits,
) error {
return s.createUser(record, session, &limits)
}
func (s *Store) createUser(record UserRecord, session *SessionRecord, limits *UserLimits) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
@@ -108,37 +79,7 @@ func (s *Store) createUser(record UserRecord, session *SessionRecord, limits *Us
return err
}
if exists {
return ErrUserAlreadyExists
}
if limits != nil {
var total, permanent int
if err := tx.QueryRow(`SELECT COUNT(*),
COUNT(CASE WHEN account_type = 'permanent' THEN 1 END)
FROM users`).Scan(&total, &permanent); err != nil {
return fmt.Errorf("count users: %w", err)
}
if record.AccountType == "permanent" && permanent >= limits.PermanentSlots {
return ErrPermanentCapacity
}
if total >= limits.MaxUsers {
result, err := tx.Exec(`DELETE FROM users WHERE user_id = (
SELECT user_id FROM users
WHERE account_type = 'temp'
ORDER BY created_at ASC
LIMIT 1
)`)
if err != nil {
return fmt.Errorf("evict oldest temporary user: %w", err)
}
deleted, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("inspect temporary user eviction: %w", err)
}
if deleted != 1 {
return ErrUserCapacity
}
}
return fmt.Errorf("username or email already exists")
}
// Insert user
@@ -153,36 +94,14 @@ func (s *Store) createUser(record UserRecord, session *SessionRecord, limits *Us
if err != nil {
return err
}
if session != nil {
if session.UserID != record.UserID {
return errors.New("initial session user does not match new user")
}
if _, err := tx.Exec(
`INSERT INTO sessions (session_id, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)`,
session.SessionID, session.UserID, session.CreatedAt, session.ExpiresAt,
); err != nil {
return fmt.Errorf("create initial session: %w", err)
}
}
if err := tx.Commit(); err != nil {
return err
}
slog.Debug("storage user created",
"user_id", record.UserID,
"account_type", record.AccountType,
"initial_session", session != nil,
)
return nil
return tx.Commit()
}
// DeleteUserByID removes a user by ID (synchronous, for replacement logic)
func (s *Store) DeleteUserByID(userID string) error {
query := `DELETE FROM users WHERE user_id = ?`
_, err := s.db.Exec(query, userID)
if err == nil {
slog.Debug("storage user deleted", "user_id", userID)
}
return err
}
@@ -200,9 +119,7 @@ func (s *Store) userExists(tx *sql.Tx, username, email string) (bool, error) {
args := []any{username}
if email != "" {
query = `SELECT COUNT(*) FROM users
WHERE username = ? COLLATE NOCASE
OR (email = ? COLLATE NOCASE AND email IS NOT NULL AND email != '')`
query = `SELECT COUNT(*) FROM users WHERE username = ? COLLATE NOCASE OR email = ? COLLATE NOCASE`
args = append(args, email)
}
@@ -248,16 +165,14 @@ func (s *Store) GetAllUsers() ([]UserRecord, error) {
var users []UserRecord
for rows.Next() {
var user UserRecord
var email sql.NullString
err := rows.Scan(
&user.UserID, &user.Username, &email,
&user.UserID, &user.Username, &user.Email,
&user.PasswordHash, &user.AccountType, &user.CreatedAt,
&user.ExpiresAt, &user.LastLoginAt,
)
if err != nil {
return nil, err
}
user.Email = email.String
users = append(users, user)
}
@@ -277,62 +192,69 @@ func (s *Store) UpdateUserLastLoginSync(userID string, loginTime time.Time) erro
// GetUserByUsername retrieves user by username with case-insensitive matching
func (s *Store) GetUserByUsername(username string) (*UserRecord, error) {
var user UserRecord
var email sql.NullString
query := `SELECT user_id, username, email, password_hash, account_type, created_at, expires_at, last_login_at
FROM users WHERE username = ? COLLATE NOCASE`
err := s.db.QueryRow(query, username).Scan(
&user.UserID, &user.Username, &email,
&user.UserID, &user.Username, &user.Email,
&user.PasswordHash, &user.AccountType, &user.CreatedAt,
&user.ExpiresAt, &user.LastLoginAt,
)
if err != nil {
return nil, err
}
user.Email = email.String
return &user, nil
}
// GetUserByEmail retrieves user by email with case-insensitive matching
func (s *Store) GetUserByEmail(email string) (*UserRecord, error) {
var user UserRecord
var emailNull sql.NullString
query := `SELECT user_id, username, email, password_hash, account_type, created_at, expires_at, last_login_at
FROM users WHERE email = ? COLLATE NOCASE AND email IS NOT NULL AND email != ''`
FROM users WHERE email = ? COLLATE NOCASE`
err := s.db.QueryRow(query, email).Scan(
&user.UserID, &user.Username, &emailNull,
&user.UserID, &user.Username, &user.Email,
&user.PasswordHash, &user.AccountType, &user.CreatedAt,
&user.ExpiresAt, &user.LastLoginAt,
)
if err != nil {
return nil, err
}
user.Email = emailNull.String
return &user, nil
}
// GetUserByID retrieves user by unique user ID
func (s *Store) GetUserByID(userID string) (*UserRecord, error) {
var user UserRecord
var email sql.NullString
query := `SELECT user_id, username, email, password_hash, account_type, created_at, expires_at, last_login_at
FROM users WHERE user_id = ?`
err := s.db.QueryRow(query, userID).Scan(
&user.UserID, &user.Username, &email,
&user.UserID, &user.Username, &user.Email,
&user.PasswordHash, &user.AccountType, &user.CreatedAt,
&user.ExpiresAt, &user.LastLoginAt,
)
if err != nil {
return nil, err
}
user.Email = email.String
return &user, nil
}
// DeleteUser removes a user synchronously. Account operations are consistency
// sensitive and should not be reported successful before SQLite commits them.
// DeleteUser removes a user from the database (async)
func (s *Store) DeleteUser(userID string) error {
return s.DeleteUserByID(userID)
if !s.healthStatus.Load() {
return nil
}
select {
case s.writeChan <- func(tx *sql.Tx) error {
query := `DELETE FROM users WHERE user_id = ?`
_, err := tx.Exec(query, userID)
return err
}:
return nil
default:
log.Printf("Storage write queue full, dropping user deletion")
return nil
}
}
+58 -219
View File
@@ -15,8 +15,6 @@ let gameState = {
authToken: null,
userId: null,
username: null,
authBusy: false,
newGameBusy: false,
};
// Chess piece Unicode: all black pieces for better fill, white pawn due to inability to override emoji variant display
@@ -25,34 +23,11 @@ const pieceMap = {
'P': '♙', 'R': '♜', 'N': '♞', 'B': '♝', 'Q': '♛', 'K': '♚'
};
// How long a success message stays visible in a modal before it auto-closes
const MODAL_SUCCESS_DISPLAY_MS = 700;
// Shared helpers: show/clear a status line inside a modal. Distinct from
// flashErrorMessage, which is not visible while a modal's backdrop is up.
function setModalMessage(elementId, message, type = 'error') {
const el = document.getElementById(elementId);
if (!el) return;
el.textContent = message;
el.classList.remove('error', 'success');
el.classList.add('show', type);
}
function clearModalMessage(elementId) {
const el = document.getElementById(elementId);
if (!el) return;
el.textContent = '';
el.classList.remove('show', 'error', 'success');
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', async () => {
const config = await getConfig();
gameState.apiUrl = config.apiUrl;
// Check for existing session on load
restoreAuthSession();
document.getElementById('new-game-btn').addEventListener('click', showNewGameModal);
document.getElementById('undo-btn').addEventListener('click', undoMoves);
document.getElementById('start-game-btn').addEventListener('click', startNewGame);
@@ -80,6 +55,8 @@ document.getElementById('register-submit-btn').addEventListener('click', handleR
document.getElementById('auth-cancel-btn').addEventListener('click', hideAuthModal);
document.getElementById('auth-cancel-btn-2').addEventListener('click', hideAuthModal);
// Check for existing session on load
restoreAuthSession();
// Auth functions
function restoreAuthSession() {
@@ -120,11 +97,9 @@ function updateAuthIndicator(authenticated) {
if (authenticated) {
light.setAttribute('data-status', 'authenticated');
indicator.setAttribute('data-status', gameState.username);
indicator.setAttribute('data-tooltip', 'Account');
} else {
light.setAttribute('data-status', 'anonymous');
indicator.setAttribute('data-status', 'click to login');
indicator.setAttribute('data-tooltip', 'Login');
indicator.setAttribute('data-status', 'anonymous');
}
}
@@ -139,25 +114,9 @@ function handleAuthClick() {
}
}
// Disables/enables every interactive control in the auth modal at once, and tracks
// whether a login/register request is in flight (or its success message is showing).
// Guards re-entrancy from the Enter-key handler, which bypasses individual button
// disabled state, and stops the user editing fields or switching tabs mid-request.
function setAuthModalBusy(busy) {
gameState.authBusy = busy;
document.getElementById('login-submit-btn').disabled = busy;
document.getElementById('register-submit-btn').disabled = busy;
document.getElementById('auth-cancel-btn').disabled = busy;
document.getElementById('auth-cancel-btn-2').disabled = busy;
document.querySelectorAll('.auth-tab').forEach(t => t.disabled = busy);
document.querySelectorAll('.auth-form input').forEach(i => i.disabled = busy);
}
function showAuthModal() {
document.getElementById('auth-modal-overlay').classList.add('show');
document.getElementById('login-identifier').focus();
// Remove first to prevent duplicate registrations
document.removeEventListener('keydown', handleAuthModalKeydown);
document.addEventListener('keydown', handleAuthModalKeydown);
}
@@ -165,21 +124,12 @@ function hideAuthModal() {
document.getElementById('auth-modal-overlay').classList.remove('show');
document.querySelectorAll('.auth-form input').forEach(input => input.value = '');
document.removeEventListener('keydown', handleAuthModalKeydown);
clearModalMessage('auth-modal-message');
setAuthModalBusy(false);
}
function handleAuthModalKeydown(e) {
const modal = document.getElementById('auth-modal-overlay');
if (!modal.classList.contains('show')) return;
// While a request is in flight, block just Enter (re-submit) and Escape
// (close); everything else (Tab, copy shortcuts, etc.) passes through.
if (gameState.authBusy) {
if (e.key === 'Enter' || e.key === 'Escape') e.preventDefault();
return;
}
if (e.key === 'Escape') {
e.preventDefault();
hideAuthModal();
@@ -200,138 +150,83 @@ function switchAuthTab(tab) {
document.getElementById('login-form').style.display = tab === 'login' ? 'block' : 'none';
document.getElementById('register-form').style.display = tab === 'register' ? 'block' : 'none';
clearModalMessage('auth-modal-message');
}
// Shared helper: safely parse error response regardless of Content-Type
async function parseErrorResponse(response) {
try {
return await response.json();
} catch {
return { error: `Server error (${response.status})`, details: null };
}
}
async function handleLogin() {
if (gameState.authBusy) return;
const identifier = document.getElementById('login-identifier').value.trim();
const password = document.getElementById('login-password').value;
if (!identifier || !password) {
setModalMessage('auth-modal-message', 'Fill all fields', 'error');
flashErrorMessage('Fill all fields');
return;
}
setAuthModalBusy(true);
clearModalMessage('auth-modal-message');
let response;
try {
response = await fetch(`${gameState.apiUrl}/api/v1/auth/login`, {
const response = await fetch(`${gameState.apiUrl}/api/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier, password })
});
} catch (error) {
const errorInfo = handleApiError('login', error);
setModalMessage('auth-modal-message', errorInfo.statusMessage, 'error');
setAuthModalBusy(false);
return;
}
if (!response.ok) {
const err = await parseErrorResponse(response);
setModalMessage('auth-modal-message', err.details || err.error || 'Login failed', 'error');
setAuthModalBusy(false);
return;
}
let auth;
try {
auth = await response.json();
} catch (error) {
console.error('Login: response OK but JSON parse failed:', error);
setModalMessage('auth-modal-message', 'Unexpected response from server', 'error');
setAuthModalBusy(false);
const err = await response.json();
flashErrorMessage(err.error || 'Login failed');
return;
}
const auth = await response.json();
gameState.authToken = auth.token;
gameState.userId = auth.userId;
gameState.username = auth.username;
localStorage.setItem('authToken', auth.token);
updateAuthIndicator(true);
setModalMessage('auth-modal-message', `Logged in as ${auth.username}`, 'success');
setTimeout(hideAuthModal, MODAL_SUCCESS_DISPLAY_MS);
hideAuthModal();
} catch (error) {
flashErrorMessage('Connection failed');
}
}
async function handleRegister() {
if (gameState.authBusy) return;
const username = document.getElementById('register-username').value.trim();
const email = document.getElementById('register-email').value.trim();
const password = document.getElementById('register-password').value;
if (!username || !password) {
setModalMessage('auth-modal-message', 'Username and password required', 'error');
flashErrorMessage('Username and password required');
return;
}
if (password.length < 8) {
setModalMessage('auth-modal-message', 'Password min 8 chars', 'error');
return;
}
if (!/[a-zA-Z]/.test(password) || !/[0-9]/.test(password)) {
setModalMessage('auth-modal-message', 'Password needs a letter and number', 'error');
flashErrorMessage('Password min 8 chars');
return;
}
setAuthModalBusy(true);
clearModalMessage('auth-modal-message');
try {
const body = { username, password };
if (email) body.email = email;
let response;
try {
response = await fetch(`${gameState.apiUrl}/api/v1/auth/register`, {
const response = await fetch(`${gameState.apiUrl}/api/v1/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
} catch (error) {
const errorInfo = handleApiError('register', error);
setModalMessage('auth-modal-message', errorInfo.statusMessage, 'error');
setAuthModalBusy(false);
return;
}
if (!response.ok) {
const err = await parseErrorResponse(response);
setModalMessage('auth-modal-message', err.details || err.error || 'Registration failed', 'error');
setAuthModalBusy(false);
return;
}
let auth;
try {
auth = await response.json();
} catch (error) {
console.error('Register: response OK but JSON parse failed:', error);
setModalMessage('auth-modal-message', 'Unexpected response from server', 'error');
setAuthModalBusy(false);
const err = await response.json();
flashErrorMessage(err.details || err.error || 'Registration failed');
return;
}
const auth = await response.json();
gameState.authToken = auth.token;
gameState.userId = auth.userId;
gameState.username = auth.username;
localStorage.setItem('authToken', auth.token);
updateAuthIndicator(true);
setModalMessage('auth-modal-message', `Account created, welcome ${auth.username}`, 'success');
setTimeout(hideAuthModal, MODAL_SUCCESS_DISPLAY_MS);
hideAuthModal();
} catch (error) {
flashErrorMessage('Connection failed');
}
}
async function handleLogout() {
@@ -358,18 +253,15 @@ function authFetch(url, options = {}) {
async function getConfig() {
try {
const response = await fetch('./config', { cache: 'no-store' });
if (!response.ok) throw new Error(`config returned ${response.status}`);
const config = await response.json();
if (typeof config.apiUrl !== 'string' || !config.apiUrl.trim()) {
throw new Error('config is missing apiUrl');
const response = await fetch('/config');
const contentType = response.headers.get('content-type') || '';
if (!response.ok || !contentType.includes('application/json')) {
throw new Error(`unexpected response: ${response.status} ${contentType}`);
}
return { apiUrl: config.apiUrl.replace(/\/+$/, '') };
return await response.json();
} catch (error) {
// Static production hosting currently reverse-proxies the API here.
// The embedded server supplies /config and does not use this fallback.
console.debug('Using default API route:', error.message);
return { apiUrl: '/chess' };
console.error('Failed to get config:', error);
return { apiUrl: 'http://localhost:8080' };
}
}
@@ -453,9 +345,6 @@ function updateTurnIndicator(state, turn) {
status = 'unknown';
tooltipText = 'Game Over';
}
} else if (state === 'stuck') {
status = 'degraded';
tooltipText = 'Engine Error';
} else if (turn === 'w') {
status = 'white';
tooltipText = 'White';
@@ -471,19 +360,6 @@ function updateTurnIndicator(state, turn) {
indicator.setAttribute('data-status', tooltipText);
}
// Disables/enables every interactive control in the new-game modal at once, and
// tracks whether a create-game request is in flight (or its success message is
// showing). Same rationale as setAuthModalBusy.
function setNewGameModalBusy(busy) {
gameState.newGameBusy = busy;
document.getElementById('start-game-btn').disabled = busy;
document.getElementById('cancel-btn').disabled = busy;
document.getElementById('computer-level').disabled = busy;
document.getElementById('search-time').disabled = busy;
document.getElementById('starting-fen').disabled = busy;
document.querySelectorAll('input[name="player-color"]').forEach(r => r.disabled = busy);
}
function showNewGameModal() {
const modal = document.getElementById('modal-overlay');
modal.classList.add('show');
@@ -494,8 +370,6 @@ function hideNewGameModal() {
const modal = document.getElementById('modal-overlay');
modal.classList.remove('show');
teardownModalKeyboardNav();
clearModalMessage('new-game-modal-message');
setNewGameModalBusy(false);
}
function setupModalKeyboardNav() {
@@ -510,14 +384,6 @@ function handleModalKeydown(e) {
const modal = document.getElementById('modal-overlay');
if (!modal.classList.contains('show')) return;
// While a request is in flight, block just Enter (re-submit) and Escape
// (close); the color/level/time shortcuts fall through as no-ops since
// those controls are disabled and there's nothing else bound to those keys.
if (gameState.newGameBusy) {
if (e.key === 'Enter' || e.key === 'Escape') e.preventDefault();
return;
}
switch(e.key) {
case 'Enter':
e.preventDefault();
@@ -593,16 +459,14 @@ function copyHistory() {
}
async function startNewGame() {
if (gameState.newGameBusy) return;
const playerColor = document.querySelector('input[name="player-color"]:checked').value;
const computerLevel = parseInt(document.getElementById('computer-level').value);
const searchTime = parseInt(document.getElementById('search-time').value);
const startingFEN = document.getElementById('starting-fen').value.trim();
const willBePlayerWhite = (playerColor === 'white');
gameState.isPlayerWhite = (playerColor === 'white');
const whiteConfig = willBePlayerWhite ? { type: 1 } : { type: 2, level: computerLevel, searchTime: searchTime };
const blackConfig = willBePlayerWhite ? { type: 2, level: computerLevel, searchTime: searchTime } : { type: 1 };
const whiteConfig = gameState.isPlayerWhite ? { type: 1 } : { type: 2, level: computerLevel, searchTime: searchTime };
const blackConfig = gameState.isPlayerWhite ? { type: 2, level: computerLevel, searchTime: searchTime } : { type: 1 };
const requestBody = {
white: whiteConfig,
@@ -614,54 +478,34 @@ async function startNewGame() {
requestBody.fen = startingFEN;
}
setNewGameModalBusy(true);
clearModalMessage('new-game-modal-message');
let response;
try {
response = await authFetch(`${gameState.apiUrl}/api/v1/games`, {
const response = await authFetch(`${gameState.apiUrl}/api/v1/games`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
});
} catch (error) {
const errorInfo = handleApiError('create game', error);
setModalMessage('new-game-modal-message', errorInfo.statusMessage, 'error');
setNewGameModalBusy(false);
return;
}
if (!response.ok) {
const errorInfo = handleApiError('create game', null, response);
setModalMessage('new-game-modal-message', errorInfo.statusMessage, 'error');
setNewGameModalBusy(false);
return;
throw new Error(errorInfo.statusMessage);
}
let game;
try {
game = await response.json();
} catch (error) {
console.error('Create game: response OK but JSON parse failed:', error);
setModalMessage('new-game-modal-message', 'Unexpected response from server', 'error');
setNewGameModalBusy(false);
return;
}
// isPlayerWhite is only committed to global state now that success is confirmed
gameState.isPlayerWhite = willBePlayerWhite;
const game = await response.json();
gameState.gameId = game.gameId;
gameState.moveList = [];
hideNewGameModal();
initializeBoard();
updateGameDisplay(game);
document.getElementById('undo-btn').disabled = true;
const computerTurn = gameState.isPlayerWhite ? 'b' : 'w';
if (isPlayable(game.state) && game.turn === computerTurn) {
triggerComputerMove();
}
if (!gameState.isPlayerWhite) triggerComputerMove();
setModalMessage('new-game-modal-message', `Game started - you play ${willBePlayerWhite ? 'White' : 'Black'}`, 'success');
setTimeout(hideNewGameModal, MODAL_SUCCESS_DISPLAY_MS);
} catch (error) {
if (error.message === 'Failed to fetch') {
handleApiError('create game', error);
} else {
flashErrorMessage(error.message);
}
updateTurnIndicator('', '');
}
}
function initializeBoard() {
@@ -732,7 +576,7 @@ function handleSquareClick(e) {
if (gameState.isLocked) return;
// Block moves after game over
if (!isPlayable(gameState.state)) return;
if (isGameOver(gameState.state)) return;
const squareEl = e.currentTarget;
const { square, pieceColor } = squareEl.dataset;
@@ -797,7 +641,7 @@ async function handleHumanMove(from, to) {
flashSquare(fromEl, true);
flashSquare(toEl, true);
updateGameDisplay(game);
if (isPlayable(game.state)) {
if (!isGameOver(game.state)) {
triggerComputerMove();
}
} catch (error) {
@@ -848,7 +692,7 @@ async function pollOnce() {
gameState.pollController = new AbortController();
try {
const response = await authFetch(
const response = await fetch(
`${gameState.apiUrl}/api/v1/games/${gameState.gameId}?wait=true&moveCount=${moveCount}`,
{ signal: gameState.pollController.signal }
);
@@ -908,7 +752,7 @@ async function undoMoves() {
}
try {
const response = await authFetch(`${gameState.apiUrl}/api/v1/games/${gameState.gameId}/undo`, {
const response = await fetch(`${gameState.apiUrl}/api/v1/games/${gameState.gameId}/undo`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ count: 2 })
@@ -927,9 +771,6 @@ async function undoMoves() {
const game = await response.json();
gameState.state = game.state;
updateGameDisplay(game);
if (game.state === 'stuck') {
flashErrorMessage('Engine error — Undo to recover or start a new game');
}
} catch (error) {
if (error.message === 'Failed to fetch') {
handleApiError('undo', error);
@@ -1045,9 +886,6 @@ function markMatedKing(game) {
function isGameOver(state) {
return ['white wins', 'black wins', 'stalemate', 'draw'].includes(state);
}
function isPlayable(state) {
return !isGameOver(state) && state !== 'stuck';
}
function handleApiError(action, error, response = null) {
let serverStatus = 'degraded';
@@ -1134,17 +972,18 @@ function handleApiError(action, error, response = null) {
};
}
function flashErrorMessage(message, duration = 1500) {
function flashErrorMessage(message) {
const overlay = document.getElementById('error-flash-overlay');
const messageEl = document.getElementById('error-flash-message');
// Set message text
messageEl.textContent = message;
// Show overlay
overlay.classList.add('show');
// Clear any pending timeout to avoid premature hide on rapid calls
if (overlay._flashTimeout) clearTimeout(overlay._flashTimeout);
overlay._flashTimeout = setTimeout(() => {
// Auto-hide after animation completes
setTimeout(() => {
overlay.classList.remove('show');
overlay._flashTimeout = null;
}, duration);
}, 1500);
}
@@ -75,8 +75,6 @@
<button class="auth-tab" data-tab="register">Register</button>
</div>
<div id="auth-modal-message" class="modal-message"></div>
<!-- Login Form -->
<div id="login-form" class="auth-form">
<div class="form-group">
@@ -119,7 +117,6 @@
<div id="modal-overlay" class="modal-overlay">
<div class="modal">
<h2>New Game</h2>
<div id="new-game-modal-message" class="modal-message"></div>
<div class="form-group">
<label class="group-label">Your Color</label>
<div class="radio-group">
@@ -647,71 +647,16 @@ input[type="range"]::-webkit-slider-thumb {
}
/* Auth Indicator */
.auth-indicator {
cursor: pointer;
border: 1px solid transparent;
border-radius: 6px;
transition: border-color .2s, background .2s;
}
.auth-indicator:hover {
border-color: var(--host-royal);
background: rgba(95, 87, 245, 0.15);
}
.auth-indicator .light[data-status="anonymous"] {
color: var(--tokyo-yellow);
color: var(--tokyo-border);
}
.auth-indicator .light[data-status="authenticated"] {
color: var(--tokyo-green);
}
/* --- Modal status message (Issue 2) --- */
.modal-message {
display: none;
margin-bottom: 1rem;
padding: 0.6rem 0.75rem;
border-radius: 6px;
font-size: 0.85rem;
text-align: center;
}
.modal-message.show {
display: block;
}
.modal-message.error {
background: rgba(247, 118, 142, 0.12);
color: var(--tokyo-red);
border: 1px solid rgba(247, 118, 142, 0.4);
}
.modal-message.success {
background: rgba(158, 206, 106, 0.12);
color: var(--tokyo-green);
border: 1px solid rgba(158, 206, 106, 0.4);
}
/* --- Disabled state while a modal request is in flight (Issue 1) --- */
.auth-form input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.auth-tab:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.fen-input:disabled,
input[type="range"]:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.radio-group input:disabled + span {
opacity: 0.5;
.auth-indicator {
cursor: pointer;
}
/* Mobile/Responsiveness */
@@ -879,20 +824,41 @@ input[type="range"]:disabled {
}
@media (max-width: 530px) {
body { min-width: 0; overflow-x: hidden; }
.outer-container { width: 100%; min-width: 0; }
.container { width: calc(100% - 16px); min-width: 0; }
.board-container {
width: min(92vw, 440px);
height: min(92vw, 440px);
padding: 12px;
body {
overflow-y: auto;
overflow-x: auto;
min-width: clamp(440px, 100vw, 530px);
}
.board-wrapper {
width: calc(min(92vw, 440px) - 24px);
height: calc(min(92vw, 440px) - 24px);
.outer-container {
width: clamp(440px, 100vw, 530px);
min-width: clamp(440px, 100vw, 530px);
padding: 8px;
min-height: 100vh;
height: auto;
display: flex;
justify-content: center;
align-items: flex-start;
overflow: visible;
}
.container {
width: calc(100% - 16px);
min-width: clamp(424px, calc(100vw - 16px), 514px);
border-radius: 12px;
min-height: calc(100vh - 16px);
height: auto;
padding: 1rem;
margin: 0;
display: flex;
flex-direction: column;
justify-content: center;
overflow: visible;
}
.board-container,
.info-panel {
width: min(92vw, 440px);
min-width: 0;
width: clamp(360px, 83vw, 440px);
min-width: clamp(360px, 83vw, 440px);
}
}
+4 -8
View File
@@ -16,7 +16,7 @@ import (
var webFS embed.FS
// Start initializes and starts the web UI server
func Start(host string, port int, apiURL string, logRequests bool) error {
func Start(host string, port int, apiURL string) error {
app := fiber.New(fiber.Config{
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
@@ -24,17 +24,13 @@ func Start(host string, port int, apiURL string, logRequests bool) error {
})
// Middleware
if logRequests {
app.Use(logger.New(logger.Config{
Format: "${time} WEB ${status} ${method} ${path} ${latency}\n",
TimeFormat: time.RFC3339,
TimeZone: "UTC",
}))
}
app.Use(cors.New())
// Create a sub-filesystem rooted at the embedded web client.
webContent, err := fs.Sub(webFS, "chess-client-web")
// Create a sub-filesystem that points to the 'web' directory
webContent, err := fs.Sub(webFS, "web")
if err != nil {
return fmt.Errorf("failed to create web sub-filesystem: %w", err)
}
@@ -46,7 +42,7 @@ func Start(host string, port int, apiURL string, logRequests bool) error {
})
})
// Serve static files from the embedded client directory.
// Serve static files from the embedded 'web' directory
app.Get("*", func(c *fiber.Ctx) error {
path := c.Path()
-22
View File
@@ -1,22 +0,0 @@
package webserver
import (
"io/fs"
"testing"
)
func TestEmbeddedWebClientRoot(t *testing.T) {
content, err := fs.Sub(webFS, "chess-client-web")
if err != nil {
t.Fatal(err)
}
for _, name := range []string{"index.html", "app.js", "style.css"} {
data, err := fs.ReadFile(content, name)
if err != nil {
t.Errorf("read embedded %s: %v", name, err)
}
if len(data) == 0 {
t.Errorf("embedded %s is empty", name)
}
}
}
+11 -16
View File
@@ -8,7 +8,7 @@ This directory contains comprehensive test suites for the Chess API server, cove
- `curl` - HTTP client
- `sqlite3` - SQLite CLI (for database tests)
- `base64` - Base64 encoder (for JWT tests)
- Compiled `bin/chess-server` binary (or pass another path to the scripts)
- Compiled `chessd` binary in accessible path
## Running the test server
From repo root
@@ -16,21 +16,17 @@ From repo root
test/run-test-server.sh
```
Pass the binary path as the first argument when it is not at `bin/chess-server`.
The server runs with `-dev`, debug logging, HTTP request logging, and relaxed rate limiting. WAL is enabled for every persistent server mode.
Pass binary path as first argument of the script if it's not placed in current directory `./chessd`.
Server will run with '-dev' option, enabling db WAL mode and relaxing rate limiting.
Will clean up test database and temporary files, so it's preferred for clean testing.
Can be used for all the tests.
Logging can be adjusted without editing the script:
```bash
LOG_LEVEL=info LOG_HTTP=false test/run-test-server.sh bin/chess-server
```
### Pre-configured Users
| Username | Password | Email |
|----------|----------|-------|
| alice | AlicePass123 | alice@example.com |
| bob | BobPass456 | bob@example.com |
| charlie | CharliePass789 | - |
### Features
- Automatically initializes database schema
@@ -38,7 +34,6 @@ LOG_LEVEL=info LOG_HTTP=false test/run-test-server.sh bin/chess-server
- Runs on port 8080 (API) and 9090 (Web UI)
- Development mode with relaxed rate limits
- Fixed JWT secret for consistent tokens
- Debug persistence and engine-queue logs by default
- Graceful shutdown on Ctrl+C
### Manual Testing Examples
@@ -72,8 +67,8 @@ Tests core game mechanics and API endpoints.
### Running the test
```bash
# Terminal 1: Start server in development mode
test/run-test-server.sh bin/chess-server
# Direct (no cleanup required): bin/chess-server -dev
test/run-test-server.sh ./chessd
# Direct (no cleanup required): ./chessd -dev
# Terminal 2: Run API tests
test/test-api.sh
@@ -97,11 +92,11 @@ Tests user management, authentication, and persistence via API integration.
### Running the test
```bash
# Terminal 1: Start test server with database
# Server is running with -dev and persistent WAL storage
test/test-db-server.sh bin/chess-server
# Server is running with -dev option (WAL mode db)
test/test-db-server.sh ./chessd
# Terminal 2: Run API integration tests
test/test-db.sh bin/chess-server
test/test-db.sh ./chessd
```
### Coverage
@@ -128,8 +123,8 @@ Tests real-time game updates via HTTP long-polling.
### Running the test
```bash
# Terminal 1: Start server with storage
test/run-test-server.sh bin/chess-server
# Direct (test.db cleanup required): bin/chess-server -dev -storage-path test.db
test/run-test-server.sh ./chessd
# Direct (test.db cleanup required): ./chessd -dev -storage-path test.db
# Terminal 2: Run long-polling tests
test/test-longpoll.sh
+2 -7
View File
@@ -7,8 +7,6 @@ CHESS_SERVER_EXEC=${1:-"bin/chess-server"}
TEST_DB="test.db"
PID_FILE="/tmp/chess-server_test.pid"
API_PORT=${API_PORT:-8080}
LOG_LEVEL=${LOG_LEVEL:-debug}
LOG_HTTP=${LOG_HTTP:-true}
# Colors for output
GREEN='\033[0;32m'
@@ -20,7 +18,7 @@ NC='\033[0m'
# Check executable
if [ ! -x "$CHESS_SERVER_EXEC" ]; then
echo -e "${RED}Error: chess-server executable not found or not executable: $CHESS_SERVER_EXEC${NC}"
echo "Provide the path to chess-server as the first argument or build bin/chess-server."
echo "Provide the path to chess-server binary as first argument or place it in the current directory."
echo "Build the binary if not available: go build ./cmd/chess-server"
exit 1
fi
@@ -92,8 +90,7 @@ echo "Configuration:"
echo " Executable: $CHESS_SERVER_EXEC"
echo " Database: $TEST_DB"
echo " Port: $API_PORT"
echo " Mode: Development (relaxed rate limits; persistent WAL storage)"
echo " Log level: $LOG_LEVEL (HTTP requests: $LOG_HTTP)"
echo " Mode: Development (WAL enabled, relaxed rate limits)"
echo " Purpose: Backend for chess-server tests"
echo " PID File: $PID_FILE"
echo ""
@@ -109,8 +106,6 @@ echo ""
# Start chess-server in foreground with dev mode and storage
"$CHESS_SERVER_EXEC" \
-dev \
-log-level "$LOG_LEVEL" \
-log-http="$LOG_HTTP" \
-storage-path "$TEST_DB" \
-api-port "$API_PORT" \
-pid "$PID_FILE" \
+18 -20
View File
@@ -225,31 +225,28 @@ test_case "2.3: Login with Username"
RESPONSE=$(api_request POST "$API_URL/auth/login" \
-H "Content-Type: application/json" \
-d "{\"identifier\": \"$TEST_USER1\", \"password\": \"$TEST_PASS1\"}")
TOKEN_ALICE_S1=$(echo "$RESPONSE" | jq -r '.token' 2>/dev/null) # kept for 2.4b
TOKEN_ALICE=$(echo "$RESPONSE" | jq -r '.token' 2>/dev/null)
USER_ID_ALICE=$(echo "$RESPONSE" | jq -r '.userId' 2>/dev/null)
if [ -n "$TOKEN_ALICE_S1" ] && [ "$TOKEN_ALICE_S1" != "null" ]; then
echo -e "${GREEN} ✓ Login successful for $TEST_USER1${NC}"; ((PASS++))
if [ -n "$TOKEN_ALICE" ] && [ "$TOKEN_ALICE" != "null" ]; then
echo -e "${GREEN} ✓ Login successful for $TEST_USER1${NC}"
((PASS++))
else
echo -e "${RED} ✗ Login failed${NC}"; ((FAIL++))
echo -e "${RED} ✗ Login failed${NC}"
((FAIL++))
fi
test_case "2.4: Login with Email (re-login: supersedes previous session)"
test_case "2.4: Login with Email"
RESPONSE=$(api_request POST "$API_URL/auth/login" \
-H "Content-Type: application/json" \
-d "{\"identifier\": \"$TEST_EMAIL1\", \"password\": \"$TEST_PASS1\"}")
TOKEN_ALICE=$(echo "$RESPONSE" | jq -r '.token' 2>/dev/null) # ONLY this token is valid from here on
if echo "$TOKEN_ALICE" | grep -q "^ey"; then
echo -e "${GREEN} ✓ Email login successful${NC}"; ((PASS++))
if echo "$RESPONSE" | jq -r '.token' 2>/dev/null | grep -q "^ey"; then
echo -e "${GREEN} ✓ Email login successful${NC}"
((PASS++))
else
echo -e "${RED} ✗ Email login failed${NC}"; ((FAIL++))
echo -e "${RED} ✗ Email login failed${NC}"
((FAIL++))
fi
test_case "2.4b: Single-Session Enforcement (prior token invalidated by re-login)"
STATUS=$(api_request GET "$API_URL/auth/me" \
-o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer $TOKEN_ALICE_S1")
assert_status 401 "$STATUS" "Superseded session token rejected"
test_case "2.5: Invalid Credentials"
STATUS=$(api_request POST "$API_URL/auth/login" \
-o /dev/null -w "%{http_code}" \
@@ -301,19 +298,20 @@ else
((FAIL++))
fi
test_case "3.3: HvH Creation Claims Only One Slot for Creator"
test_case "3.3: Both Players Same Authenticated User"
RESPONSE=$(api_request POST "$API_URL/games" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN_ALICE" \
-d '{"white": {"type": 1}, "black": {"type": 1}}')
WHITE_ID=$(echo "$RESPONSE" | jq -r '.players.white.id' 2>/dev/null)
BLACK_ID=$(echo "$RESPONSE" | jq -r '.players.black.id' 2>/dev/null)
BLACK_CLAIMED=$(echo "$RESPONSE" | jq -r '.players.black.claimedBy // empty' 2>/dev/null)
if [ "$WHITE_ID" = "$USER_ID_ALICE" ] && [ "$BLACK_ID" != "$USER_ID_ALICE" ] && [ -z "$BLACK_CLAIMED" ]; then
echo -e "${GREEN}Creator claims white only; black remains claimable${NC}"; ((PASS++))
if [ "$WHITE_ID" = "$USER_ID_ALICE" ] && [ "$BLACK_ID" = "$USER_ID_ALICE" ]; then
echo -e "${GREEN}Same user can play both sides${NC}"
((PASS++))
else
echo -e "${RED}Slot assignment wrong: white=$WHITE_ID black=$BLACK_ID claimedBy=$BLACK_CLAIMED${NC}"; ((FAIL++))
echo -e "${RED}Both sides should be same user${NC}"
((FAIL++))
fi
# ==============================================================================
-94
View File
@@ -1,94 +0,0 @@
#!/usr/bin/env bash
BASE_URL="${BASE_URL:-http://localhost:8080}"
API_URL="${BASE_URL}/api/v1"
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
PASS=0; FAIL=0
t() { echo -e "\n${YELLOW}▶ TEST: $1${NC}"; }
ok() { echo -e "${GREEN}$1${NC}"; ((PASS++)); }
ko() { echo -e "${RED}$1${NC}"; ((FAIL++)); }
req(){ local m=$1 u=$2; shift 2; curl -s "$@" -X "$m" "$u"; }
assert_field() { # json field expected name
local a
a=$(echo "$1" | jq -r "$2" 2>/dev/null)
if [ "$a" = "$3" ]; then
ok "$4: $2 = '$a'"
else
ko "$4: expected $2 = '$3', got '$a'"
fi
}
MATE_FEN="rnb1kbnr/pppp1ppp/8/4p3/6Pq/5P2/PPPPP2P/RNBQKBNR w KQkq - 1 3" # fool's mate: white to move, mated
STALE_FEN="7k/5Q2/6K1/8/8/8/8/8 b - - 0 1" # black to move, stalemate
PREMATE_W="7k/5Q2/5K2/8/8/8/8/8 w - - 0 1" # f7g7 mates
PREMATE_B="rnbqkbnr/pppp1ppp/8/4p3/6P1/5P2/PPPPP2P/RNBQKBNR b KQkq - 0 2" # black computer: d8h4#
t "E1: Terminal FEN at creation (HvH)"
R=$(req POST "$API_URL/games" -H "Content-Type: application/json" \
-d "{\"white\":{\"type\":1},\"black\":{\"type\":1},\"fen\":\"$MATE_FEN\"}")
assert_field "$R" '.state' "black wins" "Creation response carries mate"
G=$(echo "$R" | jq -r '.gameId'); [ "$G" != "null" ] && req DELETE "$API_URL/games/$G" >/dev/null
t "E2: Terminal FEN at creation (human white vs computer) — original repro case 1"
R=$(req POST "$API_URL/games" -H "Content-Type: application/json" \
-d "{\"white\":{\"type\":1},\"black\":{\"type\":2,\"searchTime\":100},\"fen\":\"$MATE_FEN\"}")
assert_field "$R" '.state' "black wins" "Mate detected, no client trigger needed"
G=$(echo "$R" | jq -r '.gameId'); [ "$G" != "null" ] && req DELETE "$API_URL/games/$G" >/dev/null
t "E3: Terminal FEN at creation (computer white vs human) — original repro case 2"
R=$(req POST "$API_URL/games" -H "Content-Type: application/json" \
-d "{\"white\":{\"type\":2,\"searchTime\":100},\"black\":{\"type\":1},\"fen\":\"$MATE_FEN\"}")
assert_field "$R" '.state' "black wins" "Mate detected at creation, not via cccc path"
G=$(echo "$R" | jq -r '.gameId'); [ "$G" != "null" ] && req DELETE "$API_URL/games/$G" >/dev/null
t "E4: Stalemate FEN at creation"
R=$(req POST "$API_URL/games" -H "Content-Type: application/json" \
-d "{\"white\":{\"type\":1},\"black\":{\"type\":1},\"fen\":\"$STALE_FEN\"}")
assert_field "$R" '.state' "stalemate" "Stalemate not misclassified as mate"
G=$(echo "$R" | jq -r '.gameId'); [ "$G" != "null" ] && req DELETE "$API_URL/games/$G" >/dev/null
t "E5: Mate delivered by human move"
R=$(req POST "$API_URL/games" -H "Content-Type: application/json" \
-d "{\"white\":{\"type\":1},\"black\":{\"type\":1},\"fen\":\"$PREMATE_W\"}")
G=$(echo "$R" | jq -r '.gameId')
R=$(req POST "$API_URL/games/$G/moves" -H "Content-Type: application/json" -d '{"move":"f7g7"}')
assert_field "$R" '.state' "white wins" "Move response carries terminal state"
assert_field "$R" '.lastMove.move' "f7g7" "LastMove present in terminal response"
req DELETE "$API_URL/games/$G" >/dev/null
t "E6: Mate delivered by computer + long-poll wakes with settled state (<5s)"
R=$(req POST "$API_URL/games" -H "Content-Type: application/json" \
-d "{\"white\":{\"type\":1},\"black\":{\"type\":2,\"level\":20,\"searchTime\":1000},\"fen\":\"$PREMATE_B\"}")
G=$(echo "$R" | jq -r '.gameId')
N=$(echo "$R" | jq -r '.moves | length')
req POST "$API_URL/games/$G/moves" -H "Content-Type: application/json" -d '{"move":"cccc"}' >/dev/null
S=$(date +%s)
R=$(req GET "$API_URL/games/$G?wait=true&moveCount=$N")
E=$(( $(date +%s) - S ))
assert_field "$R" '.state' "black wins" "Computer mate classified"
[ "$E" -lt 5 ] && ok "Poll woke in ${E}s (state-aware notify, no 30s stall)" \
|| ko "Poll took ${E}s — state-only/atomic wake regressed"
req DELETE "$API_URL/games/$G" >/dev/null
t "E7: Delete during long-poll wakes waiter promptly"
R=$(req POST "$API_URL/games" -H "Content-Type: application/json" \
-d '{"white":{"type":1},"black":{"type":1}}')
G=$(echo "$R" | jq -r '.gameId')
S=$(date +%s)
req GET "$API_URL/games/$G?wait=true&moveCount=0" > /tmp/es_poll.json &
P=$!
sleep 1
req DELETE "$API_URL/games/$G" >/dev/null
wait $P
E=$(( $(date +%s) - S ))
CODE=$(jq -r '.code' /tmp/es_poll.json 2>/dev/null)
if [ "$E" -lt 5 ]; then
ok "Poll woke in ${E}s (state-aware notify, no 30s stall)"
else
ko "Poll took ${E}s — state-only/atomic wake regressed"
fi
echo -e "\n${CYAN}Passed: $PASS Failed: $FAIL${NC}"
[ $FAIL -eq 0 ]
+4 -4
View File
@@ -125,7 +125,7 @@ test_multiple_waiters() {
# Test 3: Timeout behavior
test_timeout() {
log_test "Timeout behavior (this takes 30 seconds)"
log_test "Timeout behavior (this takes 25 seconds)"
# Create a game
GAME_ID=$(create_game 1 1)
@@ -139,10 +139,10 @@ test_timeout() {
elapsed=$((end_time - start_time))
# Check timeout was ~25 seconds
if [ "$elapsed" -ge 29 ] && [ "$elapsed" -le 31 ]; then
log_info "✓ Request timed out after ~30 seconds"
if [ "$elapsed" -ge 24 ] && [ "$elapsed" -le 26 ]; then
log_info "✓ Request timed out after ~25 seconds"
else
log_error "✗ Timeout was $elapsed seconds (expected ~30)"
log_error "✗ Timeout was $elapsed seconds (expected ~25)"
exit 1
fi