Compare commits
15
Commits
main
..
99b37b5456
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
99b37b5456
|
||
|
|
f9630dea3b
|
||
|
|
820ad7eb27
|
||
|
|
0a85cc88bb
|
||
|
|
ef60cfaac5
|
||
|
|
2a2e82a162
|
||
|
|
6bdc061508
|
||
|
|
52868af4ea
|
||
|
|
a3f4db96fa
|
||
|
|
59486bfe32
|
||
|
|
36c9f70993
|
||
|
|
b79900b1bf
|
||
|
|
0ad608293e
|
||
|
|
b98ea83012
|
||
|
|
8ba4357920
|
@@ -7,4 +7,3 @@ build.sh
|
||||
.chess_history
|
||||
*.wasm
|
||||
catalog.txt
|
||||
combined.txt
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
//go:build !js
|
||||
|
||||
package main
|
||||
|
||||
const defaultAPIBase = "http://localhost:8080"
|
||||
@@ -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"
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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,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"`
|
||||
|
||||
@@ -13,6 +13,4 @@ const (
|
||||
ErrInternalError = "INTERNAL_ERROR"
|
||||
ErrResourceLimit = "RESOURCE_LIMIT"
|
||||
ErrUnauthorized = "UNAUTHORIZED"
|
||||
ErrConflict = "GAME_CONFLICT"
|
||||
ErrStorageUnavailable = "STORAGE_UNAVAILABLE"
|
||||
)
|
||||
@@ -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
@@ -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
@@ -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 = ©
|
||||
}
|
||||
if player := g.players[core.ColorBlack]; player != nil {
|
||||
copy := *player
|
||||
view.BlackPlayer = ©
|
||||
}
|
||||
if g.lastResult != nil {
|
||||
copy := *g.lastResult
|
||||
view.LastResult = ©
|
||||
}
|
||||
if g.endTimeUTC != nil {
|
||||
copy := *g.endTimeUTC
|
||||
view.EndTimeUTC = ©
|
||||
}
|
||||
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 = ©
|
||||
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 ©
|
||||
}
|
||||
|
||||
func (g *Game) InitialFEN() string {
|
||||
if len(g.snapshots) > 0 {
|
||||
return g.snapshots[0].FEN
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}()
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
`
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(¤tVersion); 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, ¬Null, &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
|
||||
|
||||
@@ -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, ¬Null, &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, ¬Used, &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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
# ==============================================================================
|
||||
|
||||
@@ -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 ]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user