v0.2.0 transitioned to api-only, extended and improved features, docs and tests added
This commit is contained in:
63
README.md
63
README.md
@ -1,7 +1,7 @@
|
||||
<table>
|
||||
<tr>
|
||||
<td>
|
||||
<h1>Go Chess</h1>
|
||||
<h1>Go Chess API</h1>
|
||||
<p>
|
||||
<a href="https://golang.org"><img src="https://img.shields.io/badge/Go-1.24-00ADD8?style=flat&logo=go" alt="Go"></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"></a>
|
||||
@ -10,39 +10,60 @@
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
# Go Chess
|
||||
# Chess
|
||||
|
||||
A command-line chess application written in Go.
|
||||
Go backend server providing a RESTful API for chess gameplay. Integrates Stockfish engine for move validation and AI opponents.
|
||||
|
||||
## Features
|
||||
|
||||
* Command-line interface for gameplay.
|
||||
* Uses an stockfish external chess engine for move validation and computer play.
|
||||
* Supports player vs. player, player vs. computer, and computer vs. computer modes.
|
||||
* Start a new game from the standard starting position.
|
||||
* Resume a game from a FEN (Forsyth-Edwards Notation) string.
|
||||
* Move history display.
|
||||
* Move undo functionality.
|
||||
- RESTful API for chess operations
|
||||
- Stockfish engine integration for validation and AI
|
||||
- Human vs human, human vs computer, computer vs computer modes
|
||||
- Custom FEN position support
|
||||
- Asynchronous AI move calculation
|
||||
- Configurable AI strength and thinking time
|
||||
|
||||
## System Requirements
|
||||
## Requirements
|
||||
|
||||
* **Go Version**: 1.24+ (for building from source)
|
||||
* **Engine**: Requires the **Stockfish** chess engine to be installed. The `stockfish` executable must be available in the system's PATH.
|
||||
- Go 1.24+
|
||||
- Stockfish chess engine (`stockfish` in PATH)
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Arch Linux
|
||||
yay -S stockfish
|
||||
|
||||
# FreeBSD
|
||||
pkg install stockfish
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
To build and run the application:
|
||||
```bash
|
||||
git clone https://git.lixen.com/lixen/chess
|
||||
cd chess
|
||||
go build ./cmd/chessd
|
||||
|
||||
```sh
|
||||
# Build the executable
|
||||
go build ./cmd/chess
|
||||
# Standard mode (1 request/second/IP)
|
||||
./chessd
|
||||
|
||||
# Run the application
|
||||
./chess
|
||||
# Development mode (10 requests/second/IP)
|
||||
./chessd -dev
|
||||
|
||||
# Run tests (requires dev mode)
|
||||
./test/test-api.sh
|
||||
```
|
||||
|
||||
Inside the application, type `help` to see available commands.
|
||||
Server listens on `http://localhost:8080`. See [API Reference](./doc/api.md) for endpoints.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [API Reference](./doc/api.md) - Endpoint specifications
|
||||
- [Architecture](./doc/architecture.md) - System design
|
||||
- [Development](./doc/development.md) - Build and test instructions
|
||||
- [Stockfish Integration](./doc/stockfish.md) - Engine communication
|
||||
|
||||
## License
|
||||
|
||||
BSD 3-Clause License
|
||||
BSD 3-Clause
|
||||
@ -1,26 +0,0 @@
|
||||
// FILE: cmd/chess/main.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"chess/internal/cli"
|
||||
"chess/internal/service"
|
||||
clitransport "chess/internal/transport/cli"
|
||||
)
|
||||
|
||||
func main() {
|
||||
svc, err := service.New()
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to start: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer svc.Close()
|
||||
|
||||
view := cli.New(os.Stdin, os.Stdout)
|
||||
handler := clitransport.New(svc, view)
|
||||
|
||||
view.ShowWelcome()
|
||||
handler.Run() // All game loop logic is in the handler
|
||||
}
|
||||
@ -2,7 +2,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
@ -11,8 +10,9 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"chess/internal/http"
|
||||
"chess/internal/processor"
|
||||
"chess/internal/service"
|
||||
"chess/internal/transport/http"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@ -24,48 +24,57 @@ func main() {
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
// Initialize service (includes engine)
|
||||
// 1. Initialize the Service (Pure State Manager)
|
||||
svc, err := service.New()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize service: %v", err)
|
||||
}
|
||||
defer svc.Close()
|
||||
|
||||
// 2. Initialize the Processor (Orchestrator), injecting the service
|
||||
proc, err := processor.New(svc)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize processor: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := svc.Close(); err != nil {
|
||||
log.Printf("Warning: failed to close service cleanly: %v", err)
|
||||
if err := proc.Close(); err != nil {
|
||||
log.Printf("Warning: failed to close processor cleanly: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Create Fiber app with dev mode flag
|
||||
app := http.NewFiberApp(svc, *dev)
|
||||
// 3. Initialize the Fiber App/HTTP Handler, injecting the processor
|
||||
app := http.NewFiberApp(proc, *dev)
|
||||
|
||||
// Server configuration
|
||||
addr := fmt.Sprintf("%s:%d", *host, *port)
|
||||
|
||||
// Start server in goroutine
|
||||
// Start server in a goroutine
|
||||
go func() {
|
||||
log.Printf("Chess API Server starting...")
|
||||
log.Printf("Listening on: http://%s", addr)
|
||||
log.Printf("API Version: v1")
|
||||
log.Printf("Rate Limit: 1 request/second per IP")
|
||||
if *dev {
|
||||
log.Printf("Rate Limit: 10 requests/second per IP (DEV MODE)")
|
||||
} else {
|
||||
log.Printf("Rate Limit: 1 request/second per IP")
|
||||
}
|
||||
log.Printf("Endpoints: http://%s/api/v1/games", addr)
|
||||
log.Printf("Health: http://%s/health", addr)
|
||||
|
||||
if err := app.Listen(addr); err != nil {
|
||||
log.Printf("Server error: %v", err)
|
||||
// This log often prints on graceful shutdown, which is normal.
|
||||
log.Printf("Server listen error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for interrupt signal
|
||||
// Wait for an interrupt signal to gracefully shut down
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
log.Println("Shutting down server...")
|
||||
|
||||
// Graceful shutdown with timeout
|
||||
_, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Graceful shutdown with a timeout
|
||||
if err := app.ShutdownWithTimeout(5 * time.Second); err != nil {
|
||||
log.Printf("Server forced to shutdown: %v", err)
|
||||
}
|
||||
|
||||
160
doc/api.md
Normal file
160
doc/api.md
Normal file
@ -0,0 +1,160 @@
|
||||
# API Reference
|
||||
|
||||
Base URL: `http://localhost:8080/api/v1`
|
||||
|
||||
Content-Type: `application/json` (required for POST/PUT)
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Create Game
|
||||
`POST /games`
|
||||
|
||||
Creates new game with specified players.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"white": {
|
||||
"type": 1,
|
||||
"level": 0,
|
||||
"searchTime": 0
|
||||
},
|
||||
"black": {
|
||||
"type": 2,
|
||||
"level": 15,
|
||||
"searchTime": 1000
|
||||
},
|
||||
"fen": "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
|
||||
}
|
||||
```
|
||||
|
||||
- `type` (integer, required): 1=human, 2=computer
|
||||
- `level` (integer, 0-20): AI skill level for computer players
|
||||
- `searchTime` (integer, 100-10000ms): AI thinking time for computer players
|
||||
- `fen` (string): Starting position in FEN notation (default: standard position)
|
||||
|
||||
**Response (201):**
|
||||
```json
|
||||
{
|
||||
"gameId": "a1b2c3d4-e5f6-7890-1234-567890abcdef",
|
||||
"fen": "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
|
||||
"turn": "w",
|
||||
"state": "ongoing",
|
||||
"moves": [],
|
||||
"players": {
|
||||
"white": {"id": "...", "color": 1, "type": 1},
|
||||
"black": {"id": "...", "color": 2, "type": 2, "level": 15, "searchTime": 1000}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Get Game
|
||||
`GET /games/{gameId}`
|
||||
|
||||
Returns current game state.
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"gameId": "...",
|
||||
"fen": "...",
|
||||
"turn": "w",
|
||||
"state": "ongoing",
|
||||
"moves": ["e2e4", "e7e5"],
|
||||
"players": {...},
|
||||
"lastMove": {
|
||||
"move": "e7e5",
|
||||
"playerColor": "b",
|
||||
"score": 25,
|
||||
"depth": 12
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
States: `ongoing`, `pending` (computer thinking), `white wins`, `black wins`, `draw`, `stalemate`
|
||||
|
||||
### Make Move
|
||||
`POST /games/{gameId}/moves`
|
||||
|
||||
Submits human move or triggers computer move.
|
||||
|
||||
**Human move:**
|
||||
```json
|
||||
{"move": "e2e4"}
|
||||
```
|
||||
|
||||
**Computer move trigger:**
|
||||
```json
|
||||
{"move": "cccc"}
|
||||
```
|
||||
|
||||
Returns updated game state (200) or error (400).
|
||||
|
||||
### Undo Moves
|
||||
`POST /games/{gameId}/undo`
|
||||
|
||||
Reverts moves from history.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{"count": 2}
|
||||
```
|
||||
|
||||
- `count` (integer, 1-300): Number of moves to undo (default: 1)
|
||||
|
||||
### Configure Players
|
||||
`PUT /games/{gameId}/players`
|
||||
|
||||
Changes player configuration mid-game.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"white": {"type": 2, "level": 5, "searchTime": 100},
|
||||
"black": {"type": 1}
|
||||
}
|
||||
```
|
||||
|
||||
### Get Board
|
||||
`GET /games/{gameId}/board`
|
||||
|
||||
Returns ASCII board visualization.
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"fen": "...",
|
||||
"board": " a b c d e f g h\n8 r n b q k b n r 8\n..."
|
||||
}
|
||||
```
|
||||
|
||||
### Delete Game
|
||||
`DELETE /games/{gameId}`
|
||||
|
||||
Removes game from memory. Returns 204 on success.
|
||||
|
||||
## Error Format
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Description",
|
||||
"code": "ERROR_CODE",
|
||||
"details": "Additional context"
|
||||
}
|
||||
```
|
||||
|
||||
Error codes:
|
||||
- `GAME_NOT_FOUND` - Invalid game ID
|
||||
- `INVALID_MOVE` - Illegal chess move
|
||||
- `NOT_HUMAN_TURN` - Wrong player type for turn
|
||||
- `GAME_OVER` - Game already ended
|
||||
- `RATE_LIMIT_EXCEEDED` - Request limit exceeded
|
||||
- `INVALID_REQUEST` - Malformed request
|
||||
- `INVALID_CONTENT_TYPE` - Missing/wrong Content-Type header
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
- Standard: 10 request/second/IP
|
||||
- Development (`-dev`): 20 requests/second/IP
|
||||
|
||||
Exceeding limit returns 429 status.
|
||||
61
doc/architecture.md
Normal file
61
doc/architecture.md
Normal file
@ -0,0 +1,61 @@
|
||||
# Architecture
|
||||
|
||||
## Components
|
||||
|
||||
### Transport Layer (`internal/http`)
|
||||
Fiber web server handling HTTP requests/responses. Implements routing, rate limiting, content-type validation, request parsing. Translates HTTP to internal Command objects.
|
||||
|
||||
### 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.
|
||||
|
||||
### Service Layer (`internal/service`)
|
||||
In-memory state storage without chess logic. Thread-safe game map protected by RWMutex. Manages game lifecycle, snapshots, and player configuration.
|
||||
|
||||
### Supporting Modules
|
||||
- **Engine** (`internal/engine`): UCI protocol wrapper for Stockfish process communication
|
||||
- **Game** (`internal/game`): Game state with snapshot history
|
||||
- **Board** (`internal/board`): FEN parsing and ASCII generation
|
||||
- **Core** (`internal/core`): Shared types, API models, error constants
|
||||
|
||||
## Request Flow
|
||||
|
||||
### Human Move
|
||||
1. HTTP handler receives `POST /games/{id}/moves` with UCI move
|
||||
2. Creates MakeMoveCommand, calls `processor.Execute()`
|
||||
3. Processor validates move via locked validation engine
|
||||
4. If legal, gets new FEN from engine
|
||||
5. Calls `service.ApplyMove()` to update state
|
||||
6. Returns GameResponse
|
||||
|
||||
### Computer Move
|
||||
1. HTTP handler receives `POST /games/{id}/moves` with `{"move": "cccc"}`
|
||||
2. Processor sets game state to `pending`
|
||||
3. Submits task to EngineQueue, returns immediately
|
||||
4. Worker goroutine calculates move with dedicated Stockfish instance
|
||||
5. Callback updates game state via service
|
||||
6. Client polls for completion
|
||||
|
||||
## Concurrency
|
||||
|
||||
- **HTTP Server**: Fiber handles concurrent connections
|
||||
- **Game State**: Single RWMutex protects game map (concurrent reads, serial writes)
|
||||
- **Engine Workers**: Fixed pool (2 workers) with dedicated Stockfish processes
|
||||
- **Validation Engine**: Single mutex-protected instance for synchronous validation
|
||||
|
||||
## Data Structures
|
||||
|
||||
### Game Snapshot
|
||||
```go
|
||||
type Snapshot struct {
|
||||
FEN string
|
||||
PreviousMove string
|
||||
NextTurnColor Color
|
||||
PlayerID string
|
||||
}
|
||||
```
|
||||
|
||||
### Command Pattern
|
||||
Commands encapsulate operations with type and arguments, processed by single Execute method.
|
||||
|
||||
### Player Configuration
|
||||
Players identified by UUID, configured with type (human/computer), skill level, and search time.
|
||||
89
doc/development.md
Normal file
89
doc/development.md
Normal file
@ -0,0 +1,89 @@
|
||||
# Development Guide
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Go 1.24+
|
||||
- Stockfish in PATH
|
||||
- Git
|
||||
- curl, jq (for testing)
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
git clone https://git.lixen.com/lixen/chess
|
||||
cd chess
|
||||
go build ./cmd/chessd
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
### Flags
|
||||
- `-host`: Server host (default: localhost)
|
||||
- `-port`: Server port (default: 8080)
|
||||
- `-dev`: Development mode with relaxed rate limits
|
||||
|
||||
### Modes
|
||||
```bash
|
||||
# Production (1 req/s rate limit)
|
||||
./chessd
|
||||
|
||||
# Development (10 req/s rate limit)
|
||||
./chessd -dev
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
chess/
|
||||
├── cmd/chessd/ # Entry point
|
||||
├── internal/
|
||||
│ ├── board/ # FEN/ASCII operations
|
||||
│ ├── core/ # Shared types
|
||||
│ ├── engine/ # Stockfish UCI wrapper
|
||||
│ ├── game/ # Game state
|
||||
│ ├── http/ # Fiber handlers
|
||||
│ ├── processor/ # Command processing
|
||||
│ └── service/ # State management
|
||||
└── test/ # Test scripts
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Unit tests
|
||||
go test ./...
|
||||
|
||||
# API tests (requires dev mode)
|
||||
./chessd -dev &
|
||||
./test/test-api.sh
|
||||
```
|
||||
|
||||
Test script validates:
|
||||
- Basic CRUD operations
|
||||
- Computer move triggering ("cccc" mechanism)
|
||||
- Pending state protection
|
||||
- Rate limiting
|
||||
- Input validation
|
||||
- Error handling
|
||||
|
||||
## Configuration
|
||||
|
||||
### Fixed Values
|
||||
- Engine path: `"stockfish"` (internal/engine/engine.go)
|
||||
- Worker count: 2 (internal/processor/processor.go)
|
||||
- Queue capacity: 100 (internal/processor/queue.go)
|
||||
- Min search time: 100ms (internal/processor/processor.go)
|
||||
|
||||
### Validation Rules
|
||||
- Player type: 1 (human) or 2 (computer)
|
||||
- Skill level: 0-20
|
||||
- Search time: 100-10000ms
|
||||
- UCI moves: 4-5 characters ([a-h][1-8][a-h][1-8][qrbn]?)
|
||||
- Undo count: 1-300
|
||||
|
||||
## Limitations
|
||||
|
||||
- No persistence (memory only)
|
||||
- Hardcoded Stockfish path
|
||||
- Fixed worker pool size
|
||||
- No game history beyond current session
|
||||
316
doc/stockfish-extended.md
Normal file
316
doc/stockfish-extended.md
Normal file
@ -0,0 +1,316 @@
|
||||
# Stockfish UCI Protocol Reference
|
||||
|
||||
## UCI Protocol Overview
|
||||
|
||||
Universal Chess Interface (UCI) is a text-based protocol for communication between chess engines and GUIs. Commands are line-based with ASCII encoding.
|
||||
|
||||
## Initialization Sequence
|
||||
|
||||
```
|
||||
→ uci
|
||||
← id name Stockfish 16
|
||||
← id author Stockfish developers
|
||||
← option name Debug Log File type string default
|
||||
← option name Threads type spin default 1 min 1 max 1024
|
||||
← option name Hash type spin default 16 min 1 max 33554432
|
||||
← [... more options ...]
|
||||
← uciok
|
||||
|
||||
→ isready
|
||||
← readyok
|
||||
```
|
||||
|
||||
## Core UCI Commands
|
||||
|
||||
### Engine Identification
|
||||
- `uci` - Initialize UCI mode, engine responds with options and `uciok`
|
||||
- `quit` - Terminate engine process
|
||||
|
||||
### Synchronization
|
||||
- `isready` - Synchronization command, engine responds `readyok` when ready
|
||||
- `ucinewgame` - Clear hash tables and reset for new game
|
||||
|
||||
### Position Setup
|
||||
```
|
||||
position [fen <fenstring> | startpos] [moves <move1> ... <moveN>]
|
||||
```
|
||||
- `startpos` - Standard starting position
|
||||
- `fen <fenstring>` - Custom position in FEN notation
|
||||
- `moves` - Apply moves from position in UCI format (e.g., e2e4, e7e8q)
|
||||
|
||||
### Search Commands
|
||||
|
||||
#### Basic Search
|
||||
```
|
||||
go [searchmoves <move1> ... <moveN>] [ponder] [wtime <ms>] [btime <ms>]
|
||||
[winc <ms>] [binc <ms>] [movestogo <n>] [depth <n>] [nodes <n>]
|
||||
[mate <n>] [movetime <ms>] [infinite]
|
||||
```
|
||||
|
||||
Parameters:
|
||||
- `searchmoves` - Restrict search to specific moves
|
||||
- `ponder` - Start pondering mode (thinking on opponent's time)
|
||||
- `wtime/btime` - White/black time remaining (ms)
|
||||
- `winc/binc` - White/black increment per move (ms)
|
||||
- `movestogo` - Moves until next time control
|
||||
- `depth` - Search to fixed depth
|
||||
- `nodes` - Search fixed number of positions
|
||||
- `mate` - Search for mate in N moves
|
||||
- `movetime` - Search for fixed time (ms)
|
||||
- `infinite` - Search until `stop` command
|
||||
|
||||
#### Search Control
|
||||
- `stop` - Stop calculating and return best move
|
||||
- `ponderhit` - Opponent played expected ponder move
|
||||
|
||||
### Engine Options
|
||||
```
|
||||
setoption name <option_name> [value <value>]
|
||||
```
|
||||
|
||||
## Stockfish-Specific Options
|
||||
|
||||
### Search Parameters
|
||||
- `MultiPV` (1-500): Number of principal variations to calculate
|
||||
- `Skill Level` (0-20): Playing strength limitation
|
||||
- `Contempt` (-100 to 100): Draw avoidance tendency
|
||||
- `Analysis Contempt` (Off/White/Black/Both): Contempt perspective
|
||||
- `Move Overhead` (0-5000ms): Time buffer for network/GUI delay
|
||||
- `Slow Mover` (10-1000): Time management aggressiveness
|
||||
- `UCI_AnalyseMode` (true/false): Optimization for analysis
|
||||
- `UCI_Chess960` (true/false): Fischer Random Chess support
|
||||
- `UCI_ShowWDL` (true/false): Show win/draw/loss probabilities
|
||||
- `UCI_LimitStrength` (true/false): Enable ELO limitation
|
||||
- `UCI_Elo` (1320-3190): Target ELO when strength limited
|
||||
|
||||
### Hash Tables
|
||||
- `Hash` (1-33554432 MB): Transposition table size
|
||||
- `Clear Hash`: Clear transposition table
|
||||
- `Ponder` (true/false): Think during opponent's turn
|
||||
|
||||
### Hardware Configuration
|
||||
- `Threads` (1-1024): Search threads (typically CPU cores)
|
||||
- `Use NNUE` (true/false): Neural network evaluation
|
||||
- `EvalFile` (path): Custom NNUE evaluation file
|
||||
|
||||
### Syzygy Tablebases
|
||||
- `SyzygyPath` (path): Directory containing tablebase files
|
||||
- `SyzygyProbeDepth` (1-100): Minimum depth for tablebase probing
|
||||
- `Syzygy50MoveRule` (true/false): Consider 50-move rule
|
||||
- `SyzygyProbeLimit` (0-7): Maximum pieces for probing
|
||||
|
||||
## Debug Commands
|
||||
|
||||
### Board Display
|
||||
```
|
||||
→ d
|
||||
←
|
||||
+---+---+---+---+---+---+---+---+
|
||||
| r | n | b | q | k | b | n | r | 8
|
||||
+---+---+---+---+---+---+---+---+
|
||||
| p | p | p | p | p | p | p | p | 7
|
||||
+---+---+---+---+---+---+---+---+
|
||||
| | | | | | | | | 6
|
||||
[...]
|
||||
+---+---+---+---+---+---+---+---+
|
||||
a b c d e f g h
|
||||
|
||||
Fen: rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1
|
||||
Key: 8F8F01D4562F59FB
|
||||
Checkers:
|
||||
```
|
||||
|
||||
### Evaluation
|
||||
```
|
||||
→ eval
|
||||
← Total evaluation: +0.25 (white side)
|
||||
← [detailed NNUE evaluation breakdown]
|
||||
```
|
||||
|
||||
### Performance Testing
|
||||
```
|
||||
→ bench [depth] [threads] [hash] [fenfile] [limittype] [evaltype]
|
||||
```
|
||||
Default: `bench 13 1 16 default depth mixed`
|
||||
|
||||
## Search Output Format
|
||||
|
||||
### Standard Info String
|
||||
```
|
||||
info depth <d> seldepth <sd> multipv <n> score <score> nodes <n>
|
||||
nps <n> hashfull <n> tbhits <n> time <ms> pv <move1> ... <moveN>
|
||||
```
|
||||
|
||||
Fields:
|
||||
- `depth` - Current search depth
|
||||
- `seldepth` - Selective search depth
|
||||
- `multipv` - PV number (when MultiPV > 1)
|
||||
- `score cp <n>` - Evaluation in centipawns
|
||||
- `score mate <n>` - Mate in N moves (negative if being mated)
|
||||
- `score lowerbound/upperbound` - Bound type in fail-high/low
|
||||
- `nodes` - Nodes searched
|
||||
- `nps` - Nodes per second
|
||||
- `hashfull` - Hash table saturation (per mill)
|
||||
- `tbhits` - Tablebase positions found
|
||||
- `time` - Search time (ms)
|
||||
- `pv` - Principal variation (best line)
|
||||
- `currmove` - Currently searching move
|
||||
- `currmovenumber` - Move number in root move list
|
||||
- `string` - Free-form engine output
|
||||
|
||||
### Win/Draw/Loss Output (UCI_ShowWDL=true)
|
||||
```
|
||||
info depth 20 score cp 15 wdl 395 604 1
|
||||
```
|
||||
WDL values in per mill: win/draw/loss from current side's perspective.
|
||||
|
||||
### Multi-PV Example
|
||||
```
|
||||
setoption name MultiPV value 3
|
||||
go depth 15
|
||||
|
||||
info multipv 1 depth 15 score cp 31 pv e2e4 e7e5 g1f3
|
||||
info multipv 2 depth 15 score cp 20 pv d2d4 d7d5 g1f3
|
||||
info multipv 3 depth 15 score cp 15 pv g1f3 g8f6 d2d4
|
||||
```
|
||||
|
||||
## Best Move Output
|
||||
```
|
||||
bestmove <move> [ponder <move>]
|
||||
```
|
||||
- `bestmove` - Best move in UCI notation
|
||||
- `ponder` - Expected opponent response for pondering
|
||||
|
||||
Special cases:
|
||||
- `bestmove (none)` - No legal moves (checkmate/stalemate)
|
||||
- `bestmove 0000` - Null move (analysis mode only)
|
||||
|
||||
## Advanced Analysis Techniques
|
||||
|
||||
### Infinite Analysis
|
||||
```
|
||||
position fen <position>
|
||||
setoption name UCI_AnalyseMode value true
|
||||
go infinite
|
||||
[... engine thinks until stop ...]
|
||||
stop
|
||||
```
|
||||
|
||||
### Multi-PV Analysis
|
||||
```
|
||||
setoption name MultiPV value 5
|
||||
position startpos moves e2e4 e7e5
|
||||
go depth 20
|
||||
```
|
||||
|
||||
### Mate Search
|
||||
```
|
||||
go mate 7 # Find mate in 7 moves or less
|
||||
```
|
||||
|
||||
### Fixed Node Search
|
||||
```
|
||||
go nodes 1000000 # Analyze exactly 1M positions
|
||||
```
|
||||
|
||||
### Search Move Restriction
|
||||
```
|
||||
position startpos
|
||||
go searchmoves e2e4 d2d4 g1f3 # Only consider these moves
|
||||
```
|
||||
|
||||
## Time Management
|
||||
|
||||
### Tournament Time Control
|
||||
```
|
||||
position startpos moves e2e4 e7e5
|
||||
go wtime 300000 btime 300000 winc 2000 binc 2000 movestogo 40
|
||||
```
|
||||
5 minutes + 2 second increment, 40 moves to time control.
|
||||
|
||||
### Sudden Death
|
||||
```
|
||||
go wtime 60000 btime 60000 # 1 minute each, no increment
|
||||
```
|
||||
|
||||
### Fixed Time Per Move
|
||||
```
|
||||
go movetime 5000 # Think for exactly 5 seconds
|
||||
```
|
||||
|
||||
## Performance Tuning
|
||||
|
||||
### Analysis Optimization
|
||||
```
|
||||
setoption name Threads value 8
|
||||
setoption name Hash value 4096
|
||||
setoption name UCI_AnalyseMode value true
|
||||
setoption name MultiPV value 1
|
||||
```
|
||||
|
||||
### Rapid/Blitz Optimization
|
||||
```
|
||||
setoption name Move Overhead value 100
|
||||
setoption name Slow Mover value 50
|
||||
setoption name Threads value 4
|
||||
```
|
||||
|
||||
### Endgame Optimization
|
||||
```
|
||||
setoption name SyzygyPath value /path/to/tablebases
|
||||
setoption name SyzygyProbeDepth value 1
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
Common error responses:
|
||||
- `Unknown command: <cmd>` - Invalid UCI command
|
||||
- `Illegal move: <move>` - Move not legal in current position
|
||||
- `Invalid position` - FEN parsing failed
|
||||
- `No such option: <name>` - Unknown engine option
|
||||
|
||||
## Protocol Extensions
|
||||
|
||||
### Chess960 (Fischer Random)
|
||||
```
|
||||
setoption name UCI_Chess960 value true
|
||||
position fen <chess960_fen> moves <move1> ...
|
||||
```
|
||||
|
||||
### Debug Logging
|
||||
```
|
||||
setoption name Debug Log File value debug.txt
|
||||
setoption name Use Debug Log value true
|
||||
```
|
||||
|
||||
### NNUE Evaluation
|
||||
```
|
||||
setoption name Use NNUE value true
|
||||
setoption name EvalFile value nn-[hash].nnue
|
||||
```
|
||||
|
||||
## Typical Usage Patterns
|
||||
|
||||
### Game Analysis
|
||||
1. Set analysis mode and resources
|
||||
2. Load position with game moves
|
||||
3. Run infinite analysis
|
||||
4. Stop and retrieve evaluation
|
||||
|
||||
### Opening Preparation
|
||||
1. Set MultiPV to compare variations
|
||||
2. Load opening position
|
||||
3. Search to fixed depth
|
||||
4. Compare evaluations of candidate moves
|
||||
|
||||
### Endgame Study
|
||||
1. Configure tablebase paths
|
||||
2. Load endgame position
|
||||
3. Search for mate or optimal play
|
||||
4. Verify with tablebase hits
|
||||
|
||||
### Engine Match
|
||||
1. Reset with ucinewgame
|
||||
2. Set time controls
|
||||
3. Apply moves incrementally
|
||||
4. Use ponder for thinking on opponent time
|
||||
85
doc/stockfish.md
Normal file
85
doc/stockfish.md
Normal file
@ -0,0 +1,85 @@
|
||||
# Stockfish Integration
|
||||
|
||||
## UCI Protocol Implementation
|
||||
|
||||
### Connection Management
|
||||
Engine process started via `exec.Command("stockfish")` with bidirectional pipes. Initialization sequence:
|
||||
1. Send `uci` → await `uciok`
|
||||
2. Send `isready` → await `readyok`
|
||||
3. Engine ready for commands
|
||||
|
||||
### Commands Used
|
||||
|
||||
#### Position Setting
|
||||
```
|
||||
position fen <fen_string> [moves <move1> <move2> ...]
|
||||
```
|
||||
Sets board state for validation or search.
|
||||
|
||||
#### Move Search
|
||||
```
|
||||
go movetime <milliseconds>
|
||||
```
|
||||
Calculates best move with time constraint. Returns:
|
||||
- `info depth X score cp Y pv ...` (evaluation info)
|
||||
- `bestmove <move>` (final result)
|
||||
|
||||
#### Board State Query
|
||||
```
|
||||
d
|
||||
```
|
||||
Debug command returning board visualization and FEN. Used for move validation.
|
||||
|
||||
#### Configuration
|
||||
```
|
||||
setoption name Skill Level value <0-20>
|
||||
```
|
||||
Sets engine strength for computer players.
|
||||
|
||||
### Response Parsing
|
||||
|
||||
#### Search Results
|
||||
```go
|
||||
type SearchResult struct {
|
||||
BestMove string // UCI format move
|
||||
Score int // Centipawns or mate distance
|
||||
Depth int // Search depth reached
|
||||
IsMate bool // Checkmate detected
|
||||
MateIn int // Moves to mate
|
||||
}
|
||||
```
|
||||
|
||||
Parse `info` lines for evaluation data, `bestmove` for move selection.
|
||||
|
||||
#### FEN Extraction
|
||||
Parse `d` output for line starting with `Fen: ` to get canonical position.
|
||||
|
||||
### Application Usage
|
||||
|
||||
#### Synchronous Validation (Processor)
|
||||
Single mutex-protected engine instance validates moves:
|
||||
1. Set position with current FEN
|
||||
2. Attempt move
|
||||
3. Get new FEN via `d` command
|
||||
4. Compare FENs to determine legality
|
||||
|
||||
#### Asynchronous Calculation (EngineQueue)
|
||||
Worker pool with dedicated engines per worker:
|
||||
1. Receive task with FEN and time limit
|
||||
2. Configure skill level
|
||||
3. Search for best move
|
||||
4. Return result via callback
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Timeout protection (2x search time + 1s buffer)
|
||||
- Process lifecycle management with graceful shutdown
|
||||
- Fallback to force kill if quit fails
|
||||
- "(none)" bestmove indicates no legal moves (checkmate/stalemate)
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
- Reuse engine instances across multiple games
|
||||
- `ucinewgame` between games for cache clearing
|
||||
- Separate engines for validation vs calculation to avoid contention
|
||||
- Fixed worker pool prevents resource exhaustion
|
||||
29
go.mod
29
go.mod
@ -1,23 +1,30 @@
|
||||
module chess
|
||||
|
||||
go 1.24
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/go-playground/validator/v10 v10.28.0
|
||||
github.com/gofiber/fiber/v2 v2.52.9
|
||||
github.com/google/uuid v1.6.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.1.0 // indirect
|
||||
github.com/klauspost/compress v1.17.9 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/klauspost/compress v1.18.1 // 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.16 // indirect
|
||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
|
||||
github.com/rivo/uniseg v0.2.0 // indirect
|
||||
github.com/tinylib/msgp v1.2.5 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/tinylib/msgp v1.5.0 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.51.0 // indirect
|
||||
github.com/valyala/tcplisten v1.0.0 // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
github.com/valyala/fasthttp v1.68.0 // indirect
|
||||
golang.org/x/crypto v0.43.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
)
|
||||
|
||||
70
go.sum
70
go.sum
@ -1,31 +1,57 @@
|
||||
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
|
||||
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
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.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
|
||||
github.com/gabriel-vasile/mimetype v1.4.11/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=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
|
||||
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
|
||||
github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw=
|
||||
github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
|
||||
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.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
|
||||
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
||||
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
||||
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/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-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-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY=
|
||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/tinylib/msgp v1.2.5 h1:WeQg1whrXRFiZusidTQqzETkRpGjFjcIhW6uqWH09po=
|
||||
github.com/tinylib/msgp v1.2.5/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
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.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/tinylib/msgp v1.5.0 h1:GWnqAE54wmnlFazjq2+vgr736Akg58iiHImh+kPY2pc=
|
||||
github.com/tinylib/msgp v1.5.0/go.mod h1:cvjFkb4RiC8qSBOPMGPSzSAx47nAsfhLVTCZZNuHv5o=
|
||||
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.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
|
||||
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
|
||||
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
||||
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
github.com/valyala/fasthttp v1.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFnok=
|
||||
github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4=
|
||||
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.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@ -21,7 +21,7 @@ type Board struct {
|
||||
fullmove int
|
||||
}
|
||||
|
||||
func FEN(fen string) (*Board, error) {
|
||||
func ParseFEN(fen string) (*Board, error) {
|
||||
parts := strings.Fields(fen)
|
||||
if len(parts) != 6 {
|
||||
return nil, fmt.Errorf("invalid FEN: expected 6 parts, got %d", len(parts))
|
||||
@ -54,10 +54,17 @@ func FEN(fen string) (*Board, error) {
|
||||
}
|
||||
|
||||
// Parse game state with validation
|
||||
if len(parts[1]) != 1 || (parts[1][0] != 'w' && parts[1][0] != 'b') {
|
||||
if len(parts[1]) != 1 {
|
||||
return nil, fmt.Errorf("invalid FEN: turn must be 'w' or 'b'")
|
||||
}
|
||||
switch parts[1] {
|
||||
case "w":
|
||||
b.turn = core.ColorWhite
|
||||
case "b":
|
||||
b.turn = core.ColorBlack
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid FEN: turn must be 'w' or 'b'")
|
||||
}
|
||||
b.turn = core.Color(parts[1][0])
|
||||
b.castling = parts[2]
|
||||
b.enPassant = parts[3]
|
||||
|
||||
@ -71,6 +78,30 @@ func FEN(fen string) (*Board, error) {
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// ToASCII creates an ASCII representation of the board
|
||||
func (b *Board) ToASCII() string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(" a b c d e f g h\n")
|
||||
|
||||
for r := 0; r < 8; r++ {
|
||||
sb.WriteString(fmt.Sprintf("%d ", 8-r))
|
||||
for f := 0; f < 8; f++ {
|
||||
square := fmt.Sprintf("%c%c", 'a'+f, '8'-r)
|
||||
piece := b.GetPieceAt(square)
|
||||
|
||||
if piece == 0 {
|
||||
sb.WriteString(". ")
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("%c ", piece))
|
||||
}
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" %d\n", 8-r))
|
||||
}
|
||||
sb.WriteString(" a b c d e f g h")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (b *Board) Turn() core.Color {
|
||||
return b.turn
|
||||
}
|
||||
|
||||
@ -1,293 +0,0 @@
|
||||
// FILE: internal/cli/cli.go
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"chess/internal/board"
|
||||
"chess/internal/core"
|
||||
"chess/internal/game"
|
||||
)
|
||||
|
||||
type CommandType int
|
||||
|
||||
const (
|
||||
CmdNone CommandType = iota
|
||||
CmdNew
|
||||
CmdResume
|
||||
CmdMove
|
||||
CmdUndo
|
||||
CmdColor
|
||||
CmdVerbose
|
||||
CmdHistory
|
||||
CmdHelp
|
||||
CmdQuit
|
||||
)
|
||||
|
||||
type Command struct {
|
||||
Type CommandType
|
||||
Args []string
|
||||
Raw string
|
||||
}
|
||||
|
||||
type ColorTheme string
|
||||
|
||||
const (
|
||||
ThemeOff ColorTheme = "off"
|
||||
ThemeBrown ColorTheme = "brown"
|
||||
ThemeGreen ColorTheme = "green"
|
||||
ThemeGray ColorTheme = "gray"
|
||||
)
|
||||
|
||||
type themeColors struct {
|
||||
lightBg string
|
||||
darkBg string
|
||||
white string
|
||||
black string
|
||||
reset string
|
||||
}
|
||||
|
||||
var themes = map[ColorTheme]themeColors{
|
||||
ThemeOff: {
|
||||
lightBg: "",
|
||||
darkBg: "",
|
||||
white: "",
|
||||
black: "",
|
||||
reset: "",
|
||||
},
|
||||
ThemeBrown: {
|
||||
lightBg: "\033[48;5;230m", // Beige
|
||||
darkBg: "\033[48;5;94m", // Brown
|
||||
white: "\033[97m",
|
||||
black: "\033[30m",
|
||||
reset: "\033[0m",
|
||||
},
|
||||
ThemeGreen: {
|
||||
lightBg: "\033[48;5;157m", // Light green
|
||||
darkBg: "\033[48;5;22m", // Dark green
|
||||
white: "\033[97m",
|
||||
black: "\033[30m",
|
||||
reset: "\033[0m",
|
||||
},
|
||||
ThemeGray: {
|
||||
lightBg: "\033[48;5;251m", // Light gray
|
||||
darkBg: "\033[48;5;240m", // Dark gray
|
||||
white: "\033[97m",
|
||||
black: "\033[30m",
|
||||
reset: "\033[0m",
|
||||
},
|
||||
}
|
||||
|
||||
type CLI struct {
|
||||
input *bufio.Scanner
|
||||
output io.Writer
|
||||
theme ColorTheme
|
||||
verbose bool
|
||||
}
|
||||
|
||||
func New(input io.Reader, output io.Writer) *CLI {
|
||||
return &CLI{
|
||||
input: bufio.NewScanner(input),
|
||||
output: output,
|
||||
theme: ThemeOff,
|
||||
verbose: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Reads a command synchronously
|
||||
func (c *CLI) GetCommand() (*Command, error) {
|
||||
if !c.input.Scan() {
|
||||
if err := c.input.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Command{Type: CmdQuit}, nil
|
||||
}
|
||||
|
||||
input := strings.TrimSpace(c.input.Text())
|
||||
if input == "" {
|
||||
return &Command{Type: CmdNone}, nil
|
||||
}
|
||||
|
||||
return c.parseCommand(input), nil
|
||||
}
|
||||
|
||||
func (c *CLI) parseCommand(input string) *Command {
|
||||
parts := strings.Fields(input)
|
||||
if len(parts) == 0 {
|
||||
return &Command{Type: CmdNone}
|
||||
}
|
||||
|
||||
cmd := parts[0]
|
||||
args := parts[1:]
|
||||
|
||||
switch cmd {
|
||||
case "new":
|
||||
return &Command{Type: CmdNew, Args: args}
|
||||
case "resume":
|
||||
return &Command{Type: CmdResume, Args: args, Raw: input}
|
||||
case "undo":
|
||||
return &Command{Type: CmdUndo, Args: args}
|
||||
case "color":
|
||||
return &Command{Type: CmdColor, Args: args}
|
||||
case "verbose":
|
||||
return &Command{Type: CmdVerbose}
|
||||
case "history":
|
||||
return &Command{Type: CmdHistory}
|
||||
case "help", "?":
|
||||
return &Command{Type: CmdHelp}
|
||||
case "quit", "exit":
|
||||
return &Command{Type: CmdQuit}
|
||||
default:
|
||||
// Assume it's a move
|
||||
return &Command{Type: CmdMove, Args: []string{cmd}}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CLI) SetTheme(theme ColorTheme) error {
|
||||
if _, ok := themes[theme]; !ok {
|
||||
return fmt.Errorf("invalid theme: %s (use: off, brown, green, gray)", theme)
|
||||
}
|
||||
c.theme = theme
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *CLI) ToggleVerbose() bool {
|
||||
c.verbose = !c.verbose
|
||||
return c.verbose
|
||||
}
|
||||
|
||||
func (c *CLI) IsVerbose() bool {
|
||||
return c.verbose
|
||||
}
|
||||
|
||||
func (c *CLI) ShowMessage(msg string) {
|
||||
fmt.Fprintln(c.output, msg)
|
||||
}
|
||||
|
||||
func (c *CLI) ShowError(err error) {
|
||||
c.ShowMessage(fmt.Sprintf("Error: %v\n", err))
|
||||
}
|
||||
|
||||
func (c *CLI) ShowPrompt(prompt string) {
|
||||
fmt.Fprint(c.output, prompt)
|
||||
}
|
||||
|
||||
func (c *CLI) ReadLine() string {
|
||||
if c.input.Scan() {
|
||||
return strings.TrimSpace(c.input.Text())
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c *CLI) DisplayBoard(b *board.Board) {
|
||||
theme := themes[c.theme]
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("\n a b c d e f g h\n")
|
||||
|
||||
for r := 0; r < 8; r++ {
|
||||
sb.WriteString(fmt.Sprintf("%d ", 8-r))
|
||||
for f := 0; f < 8; f++ {
|
||||
// Get piece at position
|
||||
square := fmt.Sprintf("%c%c", 'a'+f, '8'-r)
|
||||
piece := b.GetPieceAt(square)
|
||||
|
||||
if c.theme == ThemeOff {
|
||||
// No colors, just show piece or space
|
||||
if piece == 0 {
|
||||
sb.WriteString(" ")
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("%c ", piece))
|
||||
}
|
||||
} else {
|
||||
// Apply theme colors
|
||||
bg := theme.darkBg
|
||||
if (r+f)%2 == 0 {
|
||||
bg = theme.lightBg
|
||||
}
|
||||
|
||||
if piece == 0 {
|
||||
sb.WriteString(fmt.Sprintf("%s %s", bg, theme.reset))
|
||||
} else {
|
||||
color := theme.black
|
||||
if piece >= 'A' && piece <= 'Z' {
|
||||
color = theme.white
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s%s%c %s", bg, color, piece, theme.reset))
|
||||
}
|
||||
}
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" %d\n", 8-r))
|
||||
}
|
||||
sb.WriteString(" a b c d e f g h\n")
|
||||
|
||||
c.ShowMessage(sb.String())
|
||||
}
|
||||
|
||||
func (c *CLI) ShowHelp() {
|
||||
help := `Commands:
|
||||
new - Start a new game with player type selection
|
||||
resume <FEN> - Resume from a specific board position
|
||||
<move> - Make a move (e.g., e2e4, g1f3)
|
||||
undo [count] - Undo last move(s), default 1
|
||||
color <theme> - Set board color theme (off|brown|green|gray)
|
||||
verbose - Toggle detailed move information
|
||||
history - Show game move history and positions
|
||||
quit/exit - Exit the program
|
||||
help/? - Show this help message
|
||||
|
||||
During any game:
|
||||
Press ENTER - Execute computer move (when it's computer's turn)`
|
||||
|
||||
c.ShowMessage(help)
|
||||
}
|
||||
|
||||
func (c *CLI) ShowWelcome() {
|
||||
c.ShowMessage("Welcome to Chess!")
|
||||
c.ShowMessage("Commands: new, resume <FEN>, <move>, undo, quit/exit, verbose, history, help/?")
|
||||
c.ShowMessage("Example: 'resume 4k3/8/8/8/8/8/8/4K2R w K - 0 1' to start from a puzzle.")
|
||||
c.ShowMessage("Press ENTER to execute computer moves when it's computer's turn.")
|
||||
c.ShowMessage("")
|
||||
}
|
||||
|
||||
func (c *CLI) ShowGameHistory(g *game.Game) {
|
||||
c.ShowMessage(fmt.Sprintf("Starting FEN: %s\n", g.InitialFEN()))
|
||||
|
||||
moves := g.Moves()
|
||||
for i := 0; i < len(moves); i += 2 {
|
||||
moveNum := i/2 + 1
|
||||
white := moves[i]
|
||||
if i+1 < len(moves) {
|
||||
black := moves[i+1]
|
||||
c.ShowMessage(fmt.Sprintf("%d. %s | %s\n", moveNum, white, black))
|
||||
} else {
|
||||
c.ShowMessage(fmt.Sprintf("%d. %s | ...\n", moveNum, white))
|
||||
}
|
||||
}
|
||||
c.ShowMessage(fmt.Sprintf("Current FEN: %s\n", g.CurrentFEN()))
|
||||
c.ShowMessage(fmt.Sprintf("Game state: %s\n", g.State()))
|
||||
}
|
||||
|
||||
func (c *CLI) ShowComputerMove(result *game.MoveResult) {
|
||||
if c.verbose {
|
||||
c.ShowMessage(fmt.Sprintf("Computer (%c): %s (depth=%d, score=%d)\n",
|
||||
result.Player, result.Move, result.Depth, result.Score))
|
||||
} else {
|
||||
// Always show computer moves in non-verbose mode too
|
||||
c.ShowMessage(fmt.Sprintf("Computer (%c): %s\n", result.Player, result.Move))
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CLI) ShowHumanMove(move string) {
|
||||
if c.verbose {
|
||||
c.ShowMessage(fmt.Sprintf("Your move: %s\n", move))
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CLI) ShowGameOver(state core.State) {
|
||||
c.ShowMessage(fmt.Sprintf("\nGame Over: %s\n", state))
|
||||
c.ShowMessage("Start a new game with 'new' or 'resume'.")
|
||||
}
|
||||
53
internal/core/api.go
Normal file
53
internal/core/api.go
Normal file
@ -0,0 +1,53 @@
|
||||
// FILE: internal/core/api.go
|
||||
package core
|
||||
|
||||
// Request types
|
||||
|
||||
type CreateGameRequest struct {
|
||||
White PlayerConfig `json:"white" validate:"required"`
|
||||
Black PlayerConfig `json:"black" validate:"required"`
|
||||
FEN string `json:"fen,omitempty" validate:"omitempty,max=100"`
|
||||
}
|
||||
|
||||
type ConfigurePlayersRequest struct {
|
||||
White PlayerConfig `json:"white" validate:"required"`
|
||||
Black PlayerConfig `json:"black" validate:"required"`
|
||||
}
|
||||
|
||||
type MoveRequest struct {
|
||||
Move string `json:"move" validate:"required,min=4,max=5"` // "cccc" for computer move, 4-5 chars for UCI moves
|
||||
}
|
||||
|
||||
type UndoRequest struct {
|
||||
Count int `json:"count" validate:"required,min=1,max=300"` // Max based on longest games in history (272), theoretical max 5949
|
||||
}
|
||||
|
||||
// Response types
|
||||
|
||||
type GameResponse struct {
|
||||
GameID string `json:"gameId"`
|
||||
FEN string `json:"fen"`
|
||||
Turn string `json:"turn"` // "w" or "b"
|
||||
State string `json:"state"` // "ongoing", "white_wins", etc
|
||||
Moves []string `json:"moves"`
|
||||
Players PlayersResponse `json:"players"`
|
||||
LastMove *MoveInfo `json:"lastMove,omitempty"`
|
||||
}
|
||||
|
||||
type MoveInfo struct {
|
||||
Move string `json:"move"`
|
||||
PlayerColor string `json:"playerColor"` // "w" or "b"
|
||||
Score int `json:"score,omitempty"`
|
||||
Depth int `json:"depth,omitempty"`
|
||||
}
|
||||
|
||||
type BoardResponse struct {
|
||||
FEN string `json:"fen"`
|
||||
Board string `json:"board"` // ASCII representation
|
||||
}
|
||||
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Code string `json:"code"`
|
||||
Details string `json:"details,omitempty"`
|
||||
}
|
||||
@ -1,53 +0,0 @@
|
||||
// FILE: internal/core/core.go
|
||||
package core
|
||||
|
||||
type State int
|
||||
|
||||
const (
|
||||
StateOngoing State = iota
|
||||
StateWhiteWins
|
||||
StateBlackWins
|
||||
StateDraw
|
||||
StateStalemate
|
||||
)
|
||||
|
||||
func (s State) String() string {
|
||||
switch s {
|
||||
case StateWhiteWins:
|
||||
return "White wins"
|
||||
case StateBlackWins:
|
||||
return "Black wins"
|
||||
case StateDraw:
|
||||
return "Draw"
|
||||
case StateStalemate:
|
||||
return "Stalemate"
|
||||
default:
|
||||
return "Ongoing"
|
||||
}
|
||||
}
|
||||
|
||||
type PlayerType int
|
||||
|
||||
const (
|
||||
PlayerHuman PlayerType = iota
|
||||
PlayerComputer
|
||||
)
|
||||
|
||||
type Player struct {
|
||||
ID string
|
||||
Type PlayerType
|
||||
}
|
||||
|
||||
type Color byte
|
||||
|
||||
const (
|
||||
ColorWhite Color = 'w'
|
||||
ColorBlack Color = 'b'
|
||||
)
|
||||
|
||||
func OppositeColor(c Color) Color {
|
||||
if c == ColorWhite {
|
||||
return ColorBlack
|
||||
}
|
||||
return ColorWhite
|
||||
}
|
||||
15
internal/core/error.go
Normal file
15
internal/core/error.go
Normal file
@ -0,0 +1,15 @@
|
||||
// FILE: internal/core/core.go
|
||||
package core
|
||||
|
||||
// Error codes
|
||||
const (
|
||||
ErrGameNotFound = "GAME_NOT_FOUND"
|
||||
ErrInvalidMove = "INVALID_MOVE"
|
||||
ErrNotHumanTurn = "NOT_HUMAN_TURN"
|
||||
ErrGameOver = "GAME_OVER"
|
||||
ErrRateLimitExceeded = "RATE_LIMIT_EXCEEDED"
|
||||
ErrInvalidContent = "INVALID_CONTENT_TYPE"
|
||||
ErrInvalidRequest = "INVALID_REQUEST"
|
||||
ErrInvalidFEN = "INVALID_FEN"
|
||||
ErrInternalError = "INTERNAL_ERROR"
|
||||
)
|
||||
75
internal/core/player.go
Normal file
75
internal/core/player.go
Normal file
@ -0,0 +1,75 @@
|
||||
// FILE: internal/core/player.go
|
||||
package core
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type PlayerType int
|
||||
|
||||
const (
|
||||
PlayerHuman PlayerType = iota + 1
|
||||
PlayerComputer
|
||||
)
|
||||
|
||||
// Player is the complete game entity with all state
|
||||
type Player struct {
|
||||
ID string `json:"id"`
|
||||
Color Color `json:"color"`
|
||||
Type PlayerType `json:"type"`
|
||||
Level int `json:"level,omitempty"` // Only for computer
|
||||
SearchTime int `json:"searchTime,omitempty"` // Only for computer
|
||||
}
|
||||
|
||||
// PlayerConfig for API requests and configuration
|
||||
type PlayerConfig struct {
|
||||
Type PlayerType `json:"type" validate:"required,oneof=1 2"`
|
||||
Level int `json:"level,omitempty" validate:"omitempty,min=0,max=20"`
|
||||
SearchTime int `json:"searchTime,omitempty" validate:"omitempty,min=100,max=10000"` // Processor sets the min value
|
||||
}
|
||||
|
||||
// PlayersResponse for API responses - now contains full Player structs
|
||||
type PlayersResponse struct {
|
||||
White *Player `json:"white"`
|
||||
Black *Player `json:"black"`
|
||||
}
|
||||
|
||||
// NewPlayer creates a Player from PlayerConfig
|
||||
func NewPlayer(config PlayerConfig, color Color) *Player {
|
||||
player := &Player{
|
||||
ID: uuid.New().String(),
|
||||
Color: color,
|
||||
Type: config.Type,
|
||||
}
|
||||
|
||||
if config.Type == PlayerComputer {
|
||||
player.Level = config.Level
|
||||
player.SearchTime = config.SearchTime
|
||||
}
|
||||
|
||||
return player
|
||||
}
|
||||
|
||||
type Color byte
|
||||
|
||||
const (
|
||||
ColorWhite = iota + 1
|
||||
ColorBlack
|
||||
)
|
||||
|
||||
func (c Color) String() string {
|
||||
if c == ColorWhite {
|
||||
return "w"
|
||||
} else if c == ColorBlack {
|
||||
return "b"
|
||||
} else {
|
||||
return "-"
|
||||
}
|
||||
}
|
||||
|
||||
func OppositeColor(c Color) Color {
|
||||
if c == ColorWhite {
|
||||
return ColorBlack
|
||||
}
|
||||
return ColorWhite
|
||||
}
|
||||
35
internal/core/state.go
Normal file
35
internal/core/state.go
Normal file
@ -0,0 +1,35 @@
|
||||
// FILE: internal/core/core.go
|
||||
package core
|
||||
|
||||
type State int
|
||||
|
||||
const (
|
||||
StateOngoing State = iota
|
||||
StatePending // Computer is calculating a move
|
||||
StateStuck // Computer is calculating a move
|
||||
StateWhiteWins
|
||||
StateBlackWins
|
||||
StateDraw
|
||||
StateStalemate
|
||||
)
|
||||
|
||||
func (s State) String() string {
|
||||
switch s {
|
||||
case StatePending:
|
||||
return "pending"
|
||||
case StateStuck:
|
||||
return "stuck"
|
||||
case StateWhiteWins:
|
||||
return "white wins"
|
||||
case StateBlackWins:
|
||||
return "black wins"
|
||||
case StateDraw:
|
||||
return "draw"
|
||||
case StateStalemate:
|
||||
return "stalemate"
|
||||
case StateOngoing:
|
||||
return "ongoing"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
@ -25,6 +25,8 @@ type SearchResult struct {
|
||||
BestMove string
|
||||
Score int
|
||||
Depth int
|
||||
IsMate bool
|
||||
MateIn int
|
||||
}
|
||||
|
||||
func New() (*UCI, error) {
|
||||
@ -40,7 +42,7 @@ func New() (*UCI, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
if err = cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("failed to start engine: %v", err)
|
||||
}
|
||||
|
||||
@ -58,6 +60,16 @@ func New() (*UCI, error) {
|
||||
return uci, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
u.sendCommand(fmt.Sprintf("setoption name Skill Level value %d", level))
|
||||
}
|
||||
|
||||
// Get FEN from Stockfish's debug ('d') command
|
||||
func (u *UCI) GetFEN() (string, error) {
|
||||
u.sendCommand("d")
|
||||
@ -184,6 +196,16 @@ func (u *UCI) Search(timeMs int) (*SearchResult, error) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,34 +9,45 @@ import (
|
||||
)
|
||||
|
||||
type Snapshot struct {
|
||||
FEN string // Board state at this point
|
||||
PreviousMove string // Move that created this position (empty for initial)
|
||||
NextTurn core.Color // Whose turn it is at this position
|
||||
FEN string `json:"fen"`
|
||||
PreviousMove string `json:"previousMove"`
|
||||
NextTurnColor core.Color `json:"nextTurnColor"`
|
||||
PlayerType core.PlayerType `json:"playerType"`
|
||||
PlayerID string `json:"playerId"` // ID of the player whose turn it is
|
||||
}
|
||||
|
||||
// MoveResult tracks the outcome of a move
|
||||
type MoveResult struct {
|
||||
Move string
|
||||
Player core.Color
|
||||
GameState core.State
|
||||
Score int
|
||||
Depth int
|
||||
Move string `json:"move"`
|
||||
PlayerColor core.Color `json:"playerColor"`
|
||||
GameState core.State `json:"gameState"`
|
||||
Score int `json:"score"`
|
||||
Depth int `json:"depth"`
|
||||
}
|
||||
|
||||
type Game struct {
|
||||
snapshots []Snapshot
|
||||
players map[core.Color]*core.Player
|
||||
state core.State
|
||||
lastResult *MoveResult
|
||||
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, startingTurn core.Color) *Game {
|
||||
func New(initialFEN string, whitePlayer, blackPlayer *core.Player, startingTurnColor core.Color) *Game {
|
||||
// Determine which player's turn it is initially
|
||||
var initialPlayerID string
|
||||
if startingTurnColor == core.ColorWhite {
|
||||
initialPlayerID = whitePlayer.ID
|
||||
} else {
|
||||
initialPlayerID = blackPlayer.ID
|
||||
}
|
||||
|
||||
return &Game{
|
||||
snapshots: []Snapshot{
|
||||
{
|
||||
FEN: initialFEN,
|
||||
PreviousMove: "", // No move led to initial position
|
||||
NextTurn: startingTurn,
|
||||
FEN: initialFEN,
|
||||
PreviousMove: "",
|
||||
NextTurnColor: startingTurnColor,
|
||||
PlayerID: initialPlayerID,
|
||||
},
|
||||
},
|
||||
players: map[core.Color]*core.Player{
|
||||
@ -63,26 +74,40 @@ func (g *Game) CurrentFEN() string {
|
||||
return g.CurrentSnapshot().FEN
|
||||
}
|
||||
|
||||
func (g *Game) NextTurn() core.Color {
|
||||
return g.CurrentSnapshot().NextTurn
|
||||
func (g *Game) NextTurnColor() core.Color {
|
||||
return g.CurrentSnapshot().NextTurnColor
|
||||
}
|
||||
|
||||
func (g *Game) NextPlayer() *core.Player {
|
||||
return g.players[g.NextTurn()]
|
||||
return g.players[g.NextTurnColor()]
|
||||
}
|
||||
|
||||
func (g *Game) GetPlayer(color core.Color) *core.Player {
|
||||
return g.players[color]
|
||||
}
|
||||
|
||||
func (g *Game) AddSnapshot(fen string, move string, nextTurn core.Color) {
|
||||
func (g *Game) AddSnapshot(fen string, move string, nextTurnColor core.Color) {
|
||||
// Get the player ID for the next turn
|
||||
nextPlayer := g.players[nextTurnColor]
|
||||
g.snapshots = append(g.snapshots, Snapshot{
|
||||
FEN: fen,
|
||||
PreviousMove: move,
|
||||
NextTurn: nextTurn,
|
||||
FEN: fen,
|
||||
PreviousMove: move,
|
||||
NextTurnColor: nextTurnColor,
|
||||
PlayerID: nextPlayer.ID,
|
||||
})
|
||||
}
|
||||
|
||||
func (g *Game) UpdatePlayers(whitePlayer, blackPlayer *core.Player) {
|
||||
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]
|
||||
currentSnap.PlayerID = g.players[currentSnap.NextTurnColor].ID
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Game) UndoMoves(count int) error {
|
||||
if count < 1 {
|
||||
return fmt.Errorf("invalid undo count: %d", count)
|
||||
|
||||
412
internal/http/handler.go
Normal file
412
internal/http/handler.go
Normal file
@ -0,0 +1,412 @@
|
||||
// FILE: internal/http/handler.go
|
||||
package http
|
||||
|
||||
import (
|
||||
"chess/internal/core"
|
||||
"chess/internal/processor"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||
"github.com/gofiber/fiber/v2/middleware/limiter"
|
||||
"github.com/gofiber/fiber/v2/middleware/logger"
|
||||
"github.com/gofiber/fiber/v2/middleware/recover"
|
||||
)
|
||||
|
||||
const rateLimitRate = 10 // req/sec
|
||||
|
||||
type HTTPHandler struct {
|
||||
proc *processor.Processor
|
||||
}
|
||||
|
||||
func NewHTTPHandler(proc *processor.Processor) *HTTPHandler {
|
||||
return &HTTPHandler{proc: proc}
|
||||
}
|
||||
|
||||
func NewFiberApp(proc *processor.Processor, devMode bool) *fiber.App {
|
||||
// Create handler
|
||||
h := NewHTTPHandler(proc)
|
||||
|
||||
// Initialize Fiber app
|
||||
app := fiber.New(fiber.Config{
|
||||
ErrorHandler: customErrorHandler,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
IdleTimeout: 30 * time.Second,
|
||||
})
|
||||
|
||||
// Global middleware (order matters)
|
||||
app.Use(recover.New())
|
||||
app.Use(logger.New(logger.Config{
|
||||
Format: "${time} ${status} ${method} ${path} ${latency}\n",
|
||||
}))
|
||||
app.Use(cors.New(cors.Config{
|
||||
AllowOrigins: "*",
|
||||
AllowMethods: "GET,POST,PUT,DELETE,OPTIONS",
|
||||
AllowHeaders: "Origin,Content-Type,Accept",
|
||||
}))
|
||||
|
||||
// Health check (no rate limit)
|
||||
app.Get("/health", h.Health)
|
||||
|
||||
// API v1 routes with rate limiting
|
||||
api := app.Group("/api/v1")
|
||||
|
||||
// Rate limiter: 10/20 req/sec per IP with expiry
|
||||
maxReq := rateLimitRate
|
||||
if devMode {
|
||||
maxReq = rateLimitRate * 2 // Loosen rate limiter for testing
|
||||
}
|
||||
api.Use(limiter.New(limiter.Config{
|
||||
Max: maxReq, // Allow requests per second
|
||||
Expiration: 1 * time.Second, // Per second
|
||||
KeyGenerator: func(c *fiber.Ctx) string {
|
||||
// Check X-Forwarded-For first, then X-Real-IP, then RemoteIP
|
||||
if xff := c.Get("X-Forwarded-For"); xff != "" {
|
||||
// Take the first IP from X-Forwarded-For chain
|
||||
if idx := strings.Index(xff, ","); idx != -1 {
|
||||
return strings.TrimSpace(xff[:idx])
|
||||
}
|
||||
return xff
|
||||
}
|
||||
return c.IP()
|
||||
},
|
||||
LimitReached: func(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusTooManyRequests).JSON(core.ErrorResponse{
|
||||
Error: "rate limit exceeded",
|
||||
Code: core.ErrRateLimitExceeded,
|
||||
Details: fmt.Sprintf("%d requests per second allowed", maxReq),
|
||||
})
|
||||
},
|
||||
Storage: nil, // Use in-memory storage (default)
|
||||
SkipFailedRequests: false,
|
||||
SkipSuccessfulRequests: false,
|
||||
}))
|
||||
|
||||
// Content-Type validation for POST and PUT requests
|
||||
api.Use(contentTypeValidator)
|
||||
|
||||
// Middleware validation for sanitization
|
||||
api.Use(validationMiddleware)
|
||||
|
||||
// Register game routes
|
||||
api.Post("/games", h.CreateGame)
|
||||
api.Put("/games/:gameId/players", h.ConfigurePlayers)
|
||||
api.Get("/games/:gameId", h.GetGame)
|
||||
api.Delete("/games/:gameId", h.DeleteGame)
|
||||
api.Post("/games/:gameId/moves", h.MakeMove)
|
||||
api.Post("/games/:gameId/undo", h.UndoMove)
|
||||
api.Get("/games/:gameId/board", h.GetBoard)
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
// contentTypeValidator ensures POST and PUT requests have application/json
|
||||
func contentTypeValidator(c *fiber.Ctx) error {
|
||||
method := c.Method()
|
||||
if method == fiber.MethodPost || method == fiber.MethodPut {
|
||||
contentType := c.Get("Content-Type")
|
||||
if contentType != "application/json" && contentType != "" {
|
||||
return c.Status(fiber.StatusUnsupportedMediaType).JSON(core.ErrorResponse{
|
||||
Error: "unsupported media type",
|
||||
Code: core.ErrInvalidContent,
|
||||
Details: "Content-Type must be application/json",
|
||||
})
|
||||
}
|
||||
}
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
// customErrorHandler provides consistent error responses
|
||||
func customErrorHandler(c *fiber.Ctx, err error) error {
|
||||
code := fiber.StatusInternalServerError
|
||||
response := core.ErrorResponse{
|
||||
Error: "internal server error",
|
||||
Code: core.ErrInternalError,
|
||||
}
|
||||
|
||||
// Check if it's a Fiber error
|
||||
if e, ok := err.(*fiber.Error); ok {
|
||||
code = e.Code
|
||||
response.Error = e.Message
|
||||
|
||||
// Map HTTP status to error codes
|
||||
switch code {
|
||||
case fiber.StatusNotFound:
|
||||
response.Code = core.ErrGameNotFound
|
||||
case fiber.StatusBadRequest:
|
||||
response.Code = core.ErrInvalidRequest
|
||||
case fiber.StatusTooManyRequests:
|
||||
response.Code = core.ErrRateLimitExceeded
|
||||
}
|
||||
}
|
||||
|
||||
return c.Status(code).JSON(response)
|
||||
}
|
||||
|
||||
// Health check endpoint
|
||||
func (h *HTTPHandler) Health(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{
|
||||
"status": "healthy",
|
||||
"time": time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
// CreateGame creates a new game with specified player types
|
||||
func (h *HTTPHandler) CreateGame(c *fiber.Ctx) error {
|
||||
// Ensure middleware validation ran
|
||||
validated, ok := c.Locals("validated").(bool)
|
||||
if !ok || !validated {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||
Error: "validation bypass detected",
|
||||
Code: core.ErrInternalError,
|
||||
})
|
||||
}
|
||||
|
||||
// Retrieve validated parsed body
|
||||
validatedBody := c.Locals("validatedBody")
|
||||
if validatedBody == nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||
Error: "validation data missing",
|
||||
Code: core.ErrInternalError,
|
||||
})
|
||||
}
|
||||
var req core.CreateGameRequest
|
||||
req = *(validatedBody.(*core.CreateGameRequest))
|
||||
|
||||
// Let processor generate game ID via service
|
||||
cmd := processor.NewCreateGameCommand(req)
|
||||
|
||||
resp := h.proc.Execute(cmd)
|
||||
|
||||
// Return appropriate HTTP response
|
||||
if !resp.Success {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(resp.Error)
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(resp.Data)
|
||||
}
|
||||
|
||||
// ConfigurePlayers updates player configuration mid-game
|
||||
func (h *HTTPHandler) ConfigurePlayers(c *fiber.Ctx) error {
|
||||
gameID := c.Params("gameId")
|
||||
|
||||
// Validate UUID format
|
||||
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",
|
||||
})
|
||||
}
|
||||
|
||||
// Ensure middleware validation ran
|
||||
validated, ok := c.Locals("validated").(bool)
|
||||
if !ok || !validated {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||
Error: "validation bypass detected",
|
||||
Code: core.ErrInternalError,
|
||||
})
|
||||
}
|
||||
|
||||
// Retrieve validated parsed body
|
||||
validatedBody := c.Locals("validatedBody")
|
||||
if validatedBody == nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||
Error: "validation data missing",
|
||||
Code: core.ErrInternalError,
|
||||
})
|
||||
}
|
||||
var req core.ConfigurePlayersRequest
|
||||
req = *(validatedBody.(*core.ConfigurePlayersRequest))
|
||||
|
||||
// Create command and execute
|
||||
cmd := processor.NewConfigurePlayersCommand(gameID, req)
|
||||
resp := h.proc.Execute(cmd)
|
||||
|
||||
// Return appropriate HTTP response
|
||||
if !resp.Success {
|
||||
statusCode := fiber.StatusBadRequest
|
||||
if resp.Error.Code == core.ErrGameNotFound {
|
||||
statusCode = fiber.StatusNotFound
|
||||
}
|
||||
return c.Status(statusCode).JSON(resp.Error)
|
||||
}
|
||||
|
||||
return c.JSON(resp.Data)
|
||||
}
|
||||
|
||||
// GetGame retrieves current game state
|
||||
func (h *HTTPHandler) GetGame(c *fiber.Ctx) error {
|
||||
gameID := c.Params("gameId")
|
||||
|
||||
// Validate UUID format
|
||||
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",
|
||||
})
|
||||
}
|
||||
|
||||
// Create command and execute
|
||||
cmd := processor.NewGetGameCommand(gameID)
|
||||
resp := h.proc.Execute(cmd)
|
||||
|
||||
// Return appropriate HTTP response
|
||||
if !resp.Success {
|
||||
return c.Status(fiber.StatusNotFound).JSON(resp.Error)
|
||||
}
|
||||
|
||||
return c.JSON(resp.Data)
|
||||
}
|
||||
|
||||
// MakeMove submits a move
|
||||
func (h *HTTPHandler) MakeMove(c *fiber.Ctx) error {
|
||||
gameID := c.Params("gameId")
|
||||
|
||||
// Validate UUID format
|
||||
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",
|
||||
})
|
||||
}
|
||||
|
||||
// Ensure middleware validation ran
|
||||
validated, ok := c.Locals("validated").(bool)
|
||||
if !ok || !validated {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||
Error: "validation bypass detected",
|
||||
Code: core.ErrInternalError,
|
||||
})
|
||||
}
|
||||
|
||||
// Retrieve validated parsed body
|
||||
validatedBody := c.Locals("validatedBody")
|
||||
if validatedBody == nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||
Error: "validation data missing",
|
||||
Code: core.ErrInternalError,
|
||||
})
|
||||
}
|
||||
var req core.MoveRequest
|
||||
req = *(validatedBody.(*core.MoveRequest))
|
||||
|
||||
// Create command and execute
|
||||
cmd := processor.NewMakeMoveCommand(gameID, req)
|
||||
resp := h.proc.Execute(cmd)
|
||||
|
||||
// Return appropriate HTTP response with correct status code
|
||||
if !resp.Success {
|
||||
statusCode := fiber.StatusBadRequest
|
||||
if resp.Error.Code == core.ErrGameNotFound {
|
||||
statusCode = fiber.StatusNotFound
|
||||
}
|
||||
return c.Status(statusCode).JSON(resp.Error)
|
||||
}
|
||||
|
||||
return c.JSON(resp.Data)
|
||||
}
|
||||
|
||||
// UndoMove undoes one or more moves
|
||||
func (h *HTTPHandler) UndoMove(c *fiber.Ctx) error {
|
||||
gameID := c.Params("gameId")
|
||||
|
||||
// Validate UUID format
|
||||
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",
|
||||
})
|
||||
}
|
||||
|
||||
// Ensure middleware validation ran
|
||||
validated, ok := c.Locals("validated").(bool)
|
||||
if !ok || !validated {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||
Error: "validation bypass detected",
|
||||
Code: core.ErrInternalError,
|
||||
})
|
||||
}
|
||||
|
||||
// Retrieve validated parsed body
|
||||
validatedBody := c.Locals("validatedBody")
|
||||
if validatedBody == nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||
Error: "validation data missing",
|
||||
Code: core.ErrInternalError,
|
||||
})
|
||||
}
|
||||
var req core.UndoRequest
|
||||
req = *(validatedBody.(*core.UndoRequest))
|
||||
|
||||
// Create command and execute
|
||||
cmd := processor.NewUndoMoveCommand(gameID, req)
|
||||
resp := h.proc.Execute(cmd)
|
||||
|
||||
// Return appropriate HTTP response
|
||||
if !resp.Success {
|
||||
statusCode := fiber.StatusBadRequest
|
||||
if resp.Error.Code == core.ErrGameNotFound {
|
||||
statusCode = fiber.StatusNotFound
|
||||
}
|
||||
return c.Status(statusCode).JSON(resp.Error)
|
||||
}
|
||||
|
||||
return c.JSON(resp.Data)
|
||||
}
|
||||
|
||||
// DeleteGame ends and cleans up a game
|
||||
func (h *HTTPHandler) DeleteGame(c *fiber.Ctx) error {
|
||||
gameID := c.Params("gameId")
|
||||
|
||||
// Validate UUID format
|
||||
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",
|
||||
})
|
||||
}
|
||||
|
||||
// Create command and execute
|
||||
cmd := processor.NewDeleteGameCommand(gameID)
|
||||
resp := h.proc.Execute(cmd)
|
||||
|
||||
// Return appropriate HTTP response
|
||||
if !resp.Success {
|
||||
return c.Status(fiber.StatusNotFound).JSON(resp.Error)
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
|
||||
// GetBoard returns ASCII representation of the board
|
||||
func (h *HTTPHandler) GetBoard(c *fiber.Ctx) error {
|
||||
gameID := c.Params("gameId")
|
||||
|
||||
// Validate UUID format
|
||||
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",
|
||||
})
|
||||
}
|
||||
|
||||
// Create command and execute
|
||||
cmd := processor.NewGetBoardCommand(gameID)
|
||||
resp := h.proc.Execute(cmd)
|
||||
|
||||
// Return appropriate HTTP response
|
||||
if !resp.Success {
|
||||
return c.Status(fiber.StatusNotFound).JSON(resp.Error)
|
||||
}
|
||||
|
||||
return c.JSON(resp.Data)
|
||||
}
|
||||
103
internal/http/validator.go
Normal file
103
internal/http/validator.go
Normal file
@ -0,0 +1,103 @@
|
||||
// FILE: internal/http/handler.go
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"chess/internal/core"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Add validator instance near top of file
|
||||
var validate = validator.New()
|
||||
|
||||
// Add custom validation middleware function
|
||||
func validationMiddleware(c *fiber.Ctx) error {
|
||||
// Skip validation for GET, DELETE, OPTIONS
|
||||
method := c.Method()
|
||||
if method == fiber.MethodGet || method == fiber.MethodDelete || method == fiber.MethodOptions {
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
// Determine request type based on path
|
||||
path := c.Path()
|
||||
var requestType interface{}
|
||||
|
||||
switch {
|
||||
case strings.HasSuffix(path, "/games") && method == fiber.MethodPost:
|
||||
requestType = &core.CreateGameRequest{}
|
||||
case strings.HasSuffix(path, "/players") && method == fiber.MethodPut:
|
||||
requestType = &core.ConfigurePlayersRequest{}
|
||||
case strings.HasSuffix(path, "/moves") && method == fiber.MethodPost:
|
||||
requestType = &core.MoveRequest{}
|
||||
case strings.HasSuffix(path, "/undo") && method == fiber.MethodPost:
|
||||
requestType = &core.UndoRequest{}
|
||||
default:
|
||||
return c.Next() // No validation for unknown endpoints
|
||||
}
|
||||
|
||||
// Parse body
|
||||
if err := c.BodyParser(requestType); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||
Error: "invalid request body",
|
||||
Code: core.ErrInvalidRequest,
|
||||
Details: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Validate
|
||||
if errs := validate.Struct(requestType); errs != nil {
|
||||
var details strings.Builder
|
||||
for _, err := range errs.(validator.ValidationErrors) {
|
||||
if details.Len() > 0 {
|
||||
details.WriteString("; ")
|
||||
}
|
||||
switch err.Tag() {
|
||||
case "required":
|
||||
details.WriteString(fmt.Sprintf("%s is required", err.Field()))
|
||||
case "oneof":
|
||||
details.WriteString(fmt.Sprintf("%s must be one of [%s]", err.Field(), err.Param()))
|
||||
case "min":
|
||||
if err.Type().Kind() == reflect.String {
|
||||
details.WriteString(fmt.Sprintf("%s must be at least %s characters", err.Field(), err.Param()))
|
||||
} else {
|
||||
details.WriteString(fmt.Sprintf("%s must be at least %s", err.Field(), err.Param()))
|
||||
}
|
||||
case "max":
|
||||
if err.Type().Kind() == reflect.String {
|
||||
details.WriteString(fmt.Sprintf("%s must be at most %s characters", err.Field(), err.Param()))
|
||||
} else {
|
||||
details.WriteString(fmt.Sprintf("%s must be at most %s", err.Field(), err.Param()))
|
||||
}
|
||||
case "omitempty": // Skip, a control tag that doesn't error
|
||||
continue
|
||||
case "dive": // Skip, panics on wrong type, no error handling since current code does not call validator on slice or map
|
||||
continue
|
||||
default:
|
||||
details.WriteString(fmt.Sprintf("%s failed %s validation", err.Field(), err.Tag()))
|
||||
}
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||
Error: "validation failed",
|
||||
Code: core.ErrInvalidRequest,
|
||||
Details: details.String(),
|
||||
})
|
||||
}
|
||||
|
||||
// Store validated body for handler use
|
||||
c.Locals("validatedBody", requestType)
|
||||
c.Locals("validated", true)
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
func isValidUUID(s string) bool {
|
||||
_, err := uuid.Parse(s)
|
||||
return err == nil
|
||||
}
|
||||
86
internal/processor/command.go
Normal file
86
internal/processor/command.go
Normal file
@ -0,0 +1,86 @@
|
||||
// FILE: internal/processor/command.go
|
||||
package processor
|
||||
|
||||
import (
|
||||
"chess/internal/core"
|
||||
)
|
||||
|
||||
// CommandType defines the type of command being executed
|
||||
type CommandType int
|
||||
|
||||
const (
|
||||
CmdCreateGame CommandType = iota
|
||||
CmdConfigurePlayers
|
||||
CmdGetGame
|
||||
CmdDeleteGame
|
||||
CmdMakeMove
|
||||
CmdUndoMove
|
||||
CmdGetBoard
|
||||
)
|
||||
|
||||
// Command is a unified structure for all processor operations
|
||||
type Command struct {
|
||||
Type CommandType
|
||||
GameID string // For game-specific commands
|
||||
Args interface{} // Command-specific arguments
|
||||
}
|
||||
|
||||
// ProcessorResponse wraps the response with metadata
|
||||
type ProcessorResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Pending bool `json:"pending,omitempty"` // For async operations
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Error *core.ErrorResponse `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func NewCreateGameCommand(req core.CreateGameRequest) Command {
|
||||
return Command{
|
||||
Type: CmdCreateGame,
|
||||
Args: req,
|
||||
}
|
||||
}
|
||||
|
||||
func NewConfigurePlayersCommand(gameID string, req core.ConfigurePlayersRequest) Command {
|
||||
return Command{
|
||||
Type: CmdConfigurePlayers,
|
||||
GameID: gameID,
|
||||
Args: req,
|
||||
}
|
||||
}
|
||||
|
||||
func NewGetGameCommand(gameID string) Command {
|
||||
return Command{
|
||||
Type: CmdGetGame,
|
||||
GameID: gameID,
|
||||
}
|
||||
}
|
||||
|
||||
func NewMakeMoveCommand(gameID string, req core.MoveRequest) Command {
|
||||
return Command{
|
||||
Type: CmdMakeMove,
|
||||
GameID: gameID,
|
||||
Args: req,
|
||||
}
|
||||
}
|
||||
|
||||
func NewUndoMoveCommand(gameID string, req core.UndoRequest) Command {
|
||||
return Command{
|
||||
Type: CmdUndoMove,
|
||||
GameID: gameID,
|
||||
Args: req,
|
||||
}
|
||||
}
|
||||
|
||||
func NewDeleteGameCommand(gameID string) Command {
|
||||
return Command{
|
||||
Type: CmdDeleteGame,
|
||||
GameID: gameID,
|
||||
}
|
||||
}
|
||||
|
||||
func NewGetBoardCommand(gameID string) Command {
|
||||
return Command{
|
||||
Type: CmdGetBoard,
|
||||
GameID: gameID,
|
||||
}
|
||||
}
|
||||
565
internal/processor/processor.go
Normal file
565
internal/processor/processor.go
Normal file
@ -0,0 +1,565 @@
|
||||
// FILE: internal/processor/processor.go
|
||||
|
||||
package processor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"chess/internal/board"
|
||||
"chess/internal/core"
|
||||
"chess/internal/engine"
|
||||
"chess/internal/game"
|
||||
"chess/internal/service"
|
||||
)
|
||||
|
||||
const (
|
||||
minSearchTime = 100
|
||||
)
|
||||
|
||||
// FEN validation regex
|
||||
var fenPattern = regexp.MustCompile(`^[rnbqkpRNBQKP1-8/]+ [wb] [KQkq-]+ [a-h1-8-]+ \d+ \d+$`)
|
||||
|
||||
// Processor orchestrates all game logic and engine interactions
|
||||
type Processor struct {
|
||||
svc *service.Service
|
||||
queue *EngineQueue
|
||||
validationEng *engine.UCI // For synchronous move validation
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// New creates a processor with its own engine instances
|
||||
func New(svc *service.Service) (*Processor, error) {
|
||||
// Create validation engine
|
||||
validationEng, err := engine.New()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create validation engine: %v", err)
|
||||
}
|
||||
|
||||
return &Processor{
|
||||
svc: svc,
|
||||
queue: NewEngineQueue(2), // 2 workers for computer moves
|
||||
validationEng: validationEng,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Processor) Execute(cmd Command) ProcessorResponse {
|
||||
switch cmd.Type {
|
||||
case CmdCreateGame:
|
||||
return p.handleCreateGame(cmd)
|
||||
case CmdConfigurePlayers:
|
||||
return p.handleConfigurePlayers(cmd)
|
||||
case CmdGetGame:
|
||||
return p.handleGetGame(cmd)
|
||||
case CmdMakeMove:
|
||||
return p.handleMakeMove(cmd)
|
||||
case CmdUndoMove:
|
||||
return p.handleUndoMove(cmd)
|
||||
case CmdDeleteGame:
|
||||
return p.handleDeleteGame(cmd)
|
||||
case CmdGetBoard:
|
||||
return p.handleGetBoard(cmd)
|
||||
default:
|
||||
return p.errorResponse("unknown command", core.ErrInvalidRequest)
|
||||
}
|
||||
}
|
||||
|
||||
// isFENSafe check for control characters that could inject UCI commands and FEN pattern match
|
||||
func (p *Processor) isFENSafe(fen string) bool {
|
||||
// Check for control characters
|
||||
for _, r := range fen {
|
||||
if unicode.IsControl(r) && r != ' ' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Validate FEN format
|
||||
return fenPattern.MatchString(fen)
|
||||
}
|
||||
|
||||
func (p *Processor) isMoveSafe(move string) bool {
|
||||
// Check for control characters
|
||||
for _, r := range move {
|
||||
if unicode.IsControl(r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// UCI valid moves are 4-5 characters only
|
||||
// Examples: e2e4 / e1g1 (castle) / a7a8q (promotion)
|
||||
// UCI moves: [a-h][1-8][a-h][1-8][qrbn]?
|
||||
if len(move) < 4 || len(move) > 5 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check each character
|
||||
if move[0] < 'a' || move[0] > 'h' ||
|
||||
move[1] < '1' || move[1] > '8' ||
|
||||
move[2] < 'a' || move[2] > 'h' ||
|
||||
move[3] < '1' || move[3] > '8' {
|
||||
return false
|
||||
}
|
||||
|
||||
// Promotion piece if present
|
||||
if len(move) == 5 {
|
||||
promotion := move[4]
|
||||
if promotion != 'q' && promotion != 'r' && promotion != 'b' && promotion != 'n' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return p.errorResponse("invalid arguments", core.ErrInvalidRequest)
|
||||
}
|
||||
|
||||
// Enforce minimum searchTime for computer players
|
||||
if args.White.Type == core.PlayerComputer && args.White.SearchTime < 100 {
|
||||
args.White.SearchTime = minSearchTime
|
||||
}
|
||||
if args.Black.Type == core.PlayerComputer && args.Black.SearchTime < 100 {
|
||||
args.Black.SearchTime = minSearchTime
|
||||
}
|
||||
|
||||
// Generate game ID
|
||||
gameID := p.svc.GenerateGameID()
|
||||
|
||||
// Validate and canonicalize FEN if provided
|
||||
initialFEN := board.StartingFEN
|
||||
if args.FEN != "" {
|
||||
if !p.isFENSafe(args.FEN) {
|
||||
return p.errorResponse("invalid FEN format or characters", core.ErrInvalidFEN)
|
||||
}
|
||||
initialFEN = args.FEN
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
p.validationEng.NewGame()
|
||||
p.validationEng.SetPosition(initialFEN, []string{})
|
||||
validatedFEN, err := p.validationEng.GetFEN()
|
||||
p.mu.Unlock()
|
||||
|
||||
if err != nil {
|
||||
return p.errorResponse(fmt.Sprintf("invalid FEN: %v", err), core.ErrInvalidRequest)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Create game in service with validated FEN and turn
|
||||
if err = p.svc.CreateGame(gameID, args.White, args.Black, validatedFEN, b.Turn()); err != nil {
|
||||
return p.errorResponse(fmt.Sprintf("failed to create game: %v", err), core.ErrInternalError)
|
||||
}
|
||||
|
||||
// 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: response,
|
||||
}
|
||||
}
|
||||
|
||||
// handleConfigurePlayers updates player configuration mid-game
|
||||
func (p *Processor) handleConfigurePlayers(cmd Command) ProcessorResponse {
|
||||
args, ok := cmd.Args.(core.ConfigurePlayersRequest)
|
||||
if !ok {
|
||||
return p.errorResponse("invalid arguments", core.ErrInvalidRequest)
|
||||
}
|
||||
|
||||
if args.White.Type == core.PlayerComputer && args.White.SearchTime < 100 {
|
||||
args.White.SearchTime = minSearchTime
|
||||
}
|
||||
if args.Black.Type == core.PlayerComputer && args.Black.SearchTime < 100 {
|
||||
args.Black.SearchTime = minSearchTime
|
||||
}
|
||||
|
||||
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 {
|
||||
return p.errorResponse("cannot change players while computer is calculating", core.ErrInvalidRequest)
|
||||
}
|
||||
|
||||
// Update players in service
|
||||
if err = p.svc.UpdatePlayers(cmd.GameID, args.White, args.Black); err != nil {
|
||||
return p.errorResponse(fmt.Sprintf("failed to update players: %v", err), core.ErrInternalError)
|
||||
}
|
||||
|
||||
// Get updated game
|
||||
g, _ = p.svc.GetGame(cmd.GameID)
|
||||
response := p.buildGameResponse(cmd.GameID, g)
|
||||
|
||||
return ProcessorResponse{
|
||||
Success: true,
|
||||
Data: response,
|
||||
}
|
||||
}
|
||||
|
||||
// handleGetGame retrieves game state and triggers computer move if needed
|
||||
func (p *Processor) handleGetGame(cmd Command) ProcessorResponse {
|
||||
g, err := p.svc.GetGame(cmd.GameID)
|
||||
if err != nil {
|
||||
return p.errorResponse("game not found", core.ErrGameNotFound)
|
||||
}
|
||||
|
||||
response := p.buildGameResponse(cmd.GameID, g)
|
||||
|
||||
return ProcessorResponse{
|
||||
Success: true,
|
||||
Data: response,
|
||||
}
|
||||
}
|
||||
|
||||
// handleMakeMove processes human moves
|
||||
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.GetGame(cmd.GameID)
|
||||
if err != nil {
|
||||
return p.errorResponse("game not found", core.ErrGameNotFound)
|
||||
}
|
||||
|
||||
// Validate game 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)
|
||||
case core.StateOngoing:
|
||||
break
|
||||
default:
|
||||
return p.errorResponse("game is in invalid state", core.ErrInvalidRequest)
|
||||
}
|
||||
|
||||
// Handle empty move string - trigger computer move
|
||||
if strings.TrimSpace(args.Move) == "cccc" {
|
||||
if g.NextPlayer().Type != core.PlayerComputer {
|
||||
return p.errorResponse("not computer player's turn", core.ErrNotHumanTurn)
|
||||
}
|
||||
|
||||
// Set state to pending and trigger computer move
|
||||
p.svc.UpdateGameState(cmd.GameID, core.StatePending)
|
||||
p.triggerComputerMove(cmd.GameID, g)
|
||||
|
||||
// Re-fetch for updated state
|
||||
g, _ = p.svc.GetGame(cmd.GameID)
|
||||
response := p.buildGameResponse(cmd.GameID, g)
|
||||
response.LastMove = &core.MoveInfo{
|
||||
PlayerColor: g.NextTurnColor().String(),
|
||||
}
|
||||
|
||||
return ProcessorResponse{
|
||||
Success: true,
|
||||
Pending: true,
|
||||
Data: response,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle human move
|
||||
if g.NextPlayer().Type != core.PlayerHuman {
|
||||
return p.errorResponse("not human player's turn", core.ErrNotHumanTurn)
|
||||
}
|
||||
|
||||
// Normalize and validate move format
|
||||
move := strings.ToLower(strings.TrimSpace(args.Move))
|
||||
if !p.isMoveSafe(move) {
|
||||
return p.errorResponse("invalid move format", core.ErrInvalidMove)
|
||||
}
|
||||
|
||||
currentFEN := g.CurrentFEN()
|
||||
currentColor := g.NextTurnColor()
|
||||
|
||||
// Validate move with engine
|
||||
p.mu.Lock()
|
||||
p.validationEng.SetPosition(currentFEN, []string{move})
|
||||
newFEN, err := p.validationEng.GetFEN()
|
||||
p.mu.Unlock()
|
||||
|
||||
if err != nil || newFEN == currentFEN {
|
||||
return p.errorResponse("illegal move", core.ErrInvalidMove)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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: response,
|
||||
}
|
||||
}
|
||||
|
||||
// handleUndoMove reverts game state
|
||||
func (p *Processor) handleUndoMove(cmd Command) ProcessorResponse {
|
||||
g, err := p.svc.GetGame(cmd.GameID)
|
||||
if err != nil {
|
||||
return p.errorResponse("game not found", core.ErrGameNotFound)
|
||||
}
|
||||
|
||||
// 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}
|
||||
if cmd.Args != nil {
|
||||
if req, ok := cmd.Args.(core.UndoRequest); ok {
|
||||
args = req
|
||||
}
|
||||
}
|
||||
|
||||
if err = p.svc.UndoMoves(cmd.GameID, args.Count); err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
return p.errorResponse("game not found", core.ErrGameNotFound)
|
||||
}
|
||||
return p.errorResponse(err.Error(), core.ErrInvalidRequest)
|
||||
}
|
||||
|
||||
// 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: response,
|
||||
}
|
||||
}
|
||||
|
||||
// handleDeleteGame removes a game
|
||||
func (p *Processor) handleDeleteGame(cmd Command) ProcessorResponse {
|
||||
g, err := p.svc.GetGame(cmd.GameID)
|
||||
if err != nil {
|
||||
return p.errorResponse("game not found", core.ErrGameNotFound)
|
||||
}
|
||||
|
||||
// TODO: gracefully handle deleting game even if pending, discard engine response
|
||||
// Only block deletion if actively computing
|
||||
if g.State() == core.StatePending {
|
||||
return p.errorResponse("cannot delete game while computer move is in progress", core.ErrInvalidRequest)
|
||||
}
|
||||
|
||||
if err = p.svc.DeleteGame(cmd.GameID); err != nil {
|
||||
return p.errorResponse("game not found", core.ErrGameNotFound)
|
||||
}
|
||||
|
||||
return ProcessorResponse{
|
||||
Success: true,
|
||||
}
|
||||
}
|
||||
|
||||
// handleGetBoard returns board visualization
|
||||
func (p *Processor) handleGetBoard(cmd Command) ProcessorResponse {
|
||||
g, err := p.svc.GetGame(cmd.GameID)
|
||||
if err != nil {
|
||||
return p.errorResponse("game not found", core.ErrGameNotFound)
|
||||
}
|
||||
|
||||
b, err := board.ParseFEN(g.CurrentFEN())
|
||||
if err != nil {
|
||||
return p.errorResponse("error parsing FEN", core.ErrInvalidFEN)
|
||||
}
|
||||
ascii := b.ToASCII()
|
||||
|
||||
return ProcessorResponse{
|
||||
Success: true,
|
||||
Data: core.BoardResponse{
|
||||
FEN: g.CurrentFEN(),
|
||||
Board: ascii,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// triggerComputerMove initiates async engine calculation
|
||||
func (p *Processor) triggerComputerMove(gameID string, g *game.Game) {
|
||||
fen := g.CurrentFEN()
|
||||
color := g.NextTurnColor()
|
||||
player := g.NextPlayer()
|
||||
|
||||
// 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 {
|
||||
log.Printf("Engine error for game %s: %v", gameID, result.Error)
|
||||
p.svc.UpdateGameState(gameID, core.StateStuck)
|
||||
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()
|
||||
p.validationEng.SetPosition(fen, []string{result.Move})
|
||||
newFEN, _ := p.validationEng.GetFEN()
|
||||
p.mu.Unlock()
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
// determineGameEndState centralized function to determine game end state based on engine evaluation
|
||||
func (p *Processor) determineGameEndState(lastMoveBy core.Color, searchResult *engine.SearchResult) core.State {
|
||||
// No legal moves detected
|
||||
if searchResult.BestMove == "" || searchResult.BestMove == "(none)" {
|
||||
if searchResult.IsMate {
|
||||
// It's a checkmate - the side that just moved wins
|
||||
if lastMoveBy == core.ColorWhite {
|
||||
return core.StateWhiteWins
|
||||
}
|
||||
return core.StateBlackWins
|
||||
}
|
||||
// Stalemate - no legal moves but not in check
|
||||
return core.StateStalemate
|
||||
}
|
||||
|
||||
// Game continues
|
||||
return core.StateOngoing
|
||||
}
|
||||
|
||||
// checkGameEnd determines if game has ended
|
||||
func (p *Processor) checkGameEnd(gameID, fen string, lastMoveBy core.Color) {
|
||||
p.mu.Lock()
|
||||
p.validationEng.SetPosition(fen, []string{})
|
||||
search, _ := p.validationEng.Search(100)
|
||||
p.mu.Unlock()
|
||||
|
||||
// Use centralized state determination
|
||||
state := p.determineGameEndState(lastMoveBy, search)
|
||||
if state != core.StateOngoing {
|
||||
p.svc.UpdateGameState(gameID, state)
|
||||
}
|
||||
}
|
||||
|
||||
// buildGameResponse constructs standard game response
|
||||
func (p *Processor) buildGameResponse(gameID string, g *game.Game) core.GameResponse {
|
||||
resp := core.GameResponse{
|
||||
GameID: gameID,
|
||||
FEN: g.CurrentFEN(),
|
||||
Turn: g.NextTurnColor().String(),
|
||||
State: g.State().String(),
|
||||
Moves: g.Moves(),
|
||||
Players: core.PlayersResponse{
|
||||
White: g.GetPlayer(core.ColorWhite),
|
||||
Black: g.GetPlayer(core.ColorBlack),
|
||||
},
|
||||
}
|
||||
|
||||
// Include last move if available
|
||||
if result := g.LastResult(); result != nil {
|
||||
resp.LastMove = &core.MoveInfo{
|
||||
Move: result.Move,
|
||||
PlayerColor: result.PlayerColor.String(),
|
||||
Score: result.Score,
|
||||
Depth: result.Depth,
|
||||
}
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
// errorResponse creates error response
|
||||
func (p *Processor) errorResponse(message, code string) ProcessorResponse {
|
||||
return ProcessorResponse{
|
||||
Success: false,
|
||||
Error: &core.ErrorResponse{
|
||||
Error: message,
|
||||
Code: code,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Close cleans up resources
|
||||
func (p *Processor) Close() error {
|
||||
p.queue.Shutdown(5 * time.Second)
|
||||
return p.validationEng.Close()
|
||||
}
|
||||
209
internal/processor/queue.go
Normal file
209
internal/processor/queue.go
Normal file
@ -0,0 +1,209 @@
|
||||
// FILE: internal/processor/queue.go
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"chess/internal/core"
|
||||
"chess/internal/engine"
|
||||
)
|
||||
|
||||
// EngineTask represents a computer move calculation request
|
||||
type EngineTask struct {
|
||||
GameID string
|
||||
FEN string
|
||||
Color core.Color
|
||||
Player *core.Player // Full player config including engine configuration
|
||||
Response chan<- EngineResult
|
||||
}
|
||||
|
||||
// EngineResult contains the outcome of an engine calculation
|
||||
type EngineResult struct {
|
||||
GameID string
|
||||
Move string
|
||||
Score int
|
||||
Depth int
|
||||
IsMate bool
|
||||
MateIn int
|
||||
Error error
|
||||
}
|
||||
|
||||
// EngineQueue manages async engine computations
|
||||
type EngineQueue struct {
|
||||
tasks chan EngineTask
|
||||
workers int
|
||||
wg sync.WaitGroup
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// NewEngineQueue creates a queue with specified worker count
|
||||
func NewEngineQueue(workerCount int) *EngineQueue {
|
||||
if workerCount < 1 {
|
||||
workerCount = 2 // Default
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
q := &EngineQueue{
|
||||
tasks: make(chan EngineTask, 100), // Buffered for queueing
|
||||
workers: workerCount,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
q.start()
|
||||
return q
|
||||
}
|
||||
|
||||
// start initializes the worker pool
|
||||
func (q *EngineQueue) start() {
|
||||
for i := 0; i < q.workers; i++ {
|
||||
q.wg.Add(1)
|
||||
go q.worker(i)
|
||||
}
|
||||
}
|
||||
|
||||
// worker processes engine tasks
|
||||
func (q *EngineQueue) worker(id int) {
|
||||
defer q.wg.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
|
||||
}
|
||||
defer eng.Close()
|
||||
|
||||
for {
|
||||
select {
|
||||
case task, ok := <-q.tasks:
|
||||
if !ok {
|
||||
return // Channel closed
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// processTask executes a single engine calculation
|
||||
func (q *EngineQueue) processTask(eng *engine.UCI, task EngineTask) EngineResult {
|
||||
result := EngineResult{
|
||||
GameID: task.GameID,
|
||||
}
|
||||
|
||||
// Apply computer configuration if provided
|
||||
if task.Player.Type == core.PlayerComputer {
|
||||
eng.SetSkillLevel(task.Player.Level)
|
||||
}
|
||||
|
||||
// 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: %v", err)
|
||||
return result
|
||||
}
|
||||
|
||||
// Check for no legal moves
|
||||
if search.BestMove == "" || search.BestMove == "(none)" {
|
||||
result.Move = ""
|
||||
result.IsMate = search.IsMate
|
||||
result.MateIn = search.MateIn
|
||||
return result
|
||||
}
|
||||
|
||||
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 {
|
||||
select {
|
||||
case q.tasks <- task:
|
||||
return nil
|
||||
case <-q.ctx.Done():
|
||||
return fmt.Errorf("queue is shutting down")
|
||||
default:
|
||||
return fmt.Errorf("queue is full")
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
respChan := make(chan EngineResult, 1)
|
||||
|
||||
task := EngineTask{
|
||||
GameID: gameID,
|
||||
FEN: fen,
|
||||
Color: color,
|
||||
Player: player,
|
||||
Response: respChan,
|
||||
}
|
||||
|
||||
if err := q.Submit(task); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Handle result in background
|
||||
go func() {
|
||||
select {
|
||||
case result := <-respChan:
|
||||
callback(result)
|
||||
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.cancel()
|
||||
close(q.tasks)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
q.wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
case <-time.After(timeout):
|
||||
return fmt.Errorf("shutdown timeout exceeded")
|
||||
}
|
||||
}
|
||||
@ -3,202 +3,137 @@ package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"chess/internal/board"
|
||||
"chess/internal/core"
|
||||
"chess/internal/engine"
|
||||
"chess/internal/game"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Service is a pure state manager for chess games
|
||||
// It has NO knowledge of chess rules or engine interactions
|
||||
type Service struct {
|
||||
games map[string]*game.Game
|
||||
engine *engine.UCI
|
||||
games map[string]*game.Game
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// New creates a new service instance
|
||||
func New() (*Service, error) {
|
||||
eng, err := engine.New()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize engine: %v", err)
|
||||
}
|
||||
|
||||
return &Service{
|
||||
games: make(map[string]*game.Game),
|
||||
engine: eng,
|
||||
games: make(map[string]*game.Game),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) NewGame(id string, whiteType, blackType core.PlayerType, fen ...string) error {
|
||||
initialFEN := board.StartingFEN
|
||||
if len(fen) > 0 && fen[0] != "" {
|
||||
initialFEN = fen[0]
|
||||
// CreateGame creates game with player configuration
|
||||
func (s *Service) CreateGame(id string, whiteConfig, blackConfig core.PlayerConfig, initialFEN string, startingTurn core.Color) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, exists := s.games[id]; exists {
|
||||
return fmt.Errorf("game %s already exists", id)
|
||||
}
|
||||
|
||||
// Use the engine to validate and canonicalize the FEN
|
||||
s.engine.NewGame()
|
||||
s.engine.SetPosition(initialFEN, []string{})
|
||||
validatedFEN, err := s.engine.GetFEN()
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not get FEN from engine: %v", err)
|
||||
}
|
||||
|
||||
b, err := board.FEN(validatedFEN)
|
||||
if err != nil {
|
||||
return fmt.Errorf("engine returned invalid FEN: %v", err)
|
||||
}
|
||||
startingTurn := b.Turn()
|
||||
|
||||
// Setup players based on types
|
||||
whitePlayer := &core.Player{
|
||||
ID: "white",
|
||||
Type: whiteType,
|
||||
}
|
||||
if whiteType == core.PlayerComputer {
|
||||
whitePlayer.ID = "stockfish-white"
|
||||
}
|
||||
|
||||
blackPlayer := &core.Player{
|
||||
ID: "black",
|
||||
Type: blackType,
|
||||
}
|
||||
if blackType == core.PlayerComputer {
|
||||
blackPlayer.ID = "stockfish-black"
|
||||
}
|
||||
|
||||
s.games[id] = game.New(validatedFEN, whitePlayer, blackPlayer, startingTurn)
|
||||
// Create players with UUIDs and config
|
||||
whitePlayer := core.NewPlayer(whiteConfig, core.ColorWhite)
|
||||
blackPlayer := core.NewPlayer(blackConfig, core.ColorBlack)
|
||||
|
||||
s.games[id] = game.New(initialFEN, whitePlayer, blackPlayer, startingTurn)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) MakeHumanMove(gameID, uci string) error {
|
||||
// Basic move format validation
|
||||
uci = strings.ToLower(strings.TrimSpace(uci))
|
||||
if len(uci) < 4 || len(uci) > 5 {
|
||||
return fmt.Errorf("invalid move format: expected e2e4 or e7e8q")
|
||||
}
|
||||
// UpdatePlayers replaces players in an existing game
|
||||
func (s *Service) UpdatePlayers(gameID string, whiteConfig, blackConfig core.PlayerConfig) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
g, ok := s.games[gameID]
|
||||
if !ok {
|
||||
return fmt.Errorf("game not found")
|
||||
return fmt.Errorf("game not found: %s", gameID)
|
||||
}
|
||||
|
||||
// Check if it's human's turn
|
||||
if g.NextPlayer().Type != core.PlayerHuman {
|
||||
return fmt.Errorf("not a human player's turn")
|
||||
}
|
||||
// Create new player instances with new UUIDs
|
||||
whitePlayer := core.NewPlayer(whiteConfig, core.ColorWhite)
|
||||
blackPlayer := core.NewPlayer(blackConfig, core.ColorBlack)
|
||||
|
||||
currentFEN := g.CurrentFEN()
|
||||
humanColor := g.NextTurn()
|
||||
// Update the game's players
|
||||
g.UpdatePlayers(whitePlayer, blackPlayer)
|
||||
|
||||
// Try to apply human move
|
||||
s.engine.SetPosition(currentFEN, []string{uci})
|
||||
|
||||
// Get FEN after human move to check if move was legal
|
||||
humanMoveFEN, err := s.engine.GetFEN()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get position: %v", err)
|
||||
}
|
||||
|
||||
// If position didn't change, move was illegal
|
||||
if humanMoveFEN == currentFEN {
|
||||
return fmt.Errorf("illegal move")
|
||||
}
|
||||
|
||||
// Record human move
|
||||
g.AddSnapshot(humanMoveFEN, uci, core.OppositeColor(humanColor))
|
||||
|
||||
// Check if opponent has any legal moves
|
||||
s.engine.SetPosition(humanMoveFEN, []string{})
|
||||
search, _ := s.engine.Search(100) // Quick search to check for legal moves
|
||||
|
||||
result := &game.MoveResult{
|
||||
Move: uci,
|
||||
Player: humanColor,
|
||||
GameState: core.StateOngoing,
|
||||
}
|
||||
|
||||
if search.BestMove == "" || search.BestMove == "(none)" {
|
||||
// Human checkmated the opponent
|
||||
if humanColor == core.ColorWhite {
|
||||
g.SetState(core.StateWhiteWins)
|
||||
} else {
|
||||
g.SetState(core.StateBlackWins)
|
||||
}
|
||||
result.GameState = g.State()
|
||||
}
|
||||
|
||||
// Store result in game instead of service
|
||||
g.SetLastResult(result)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) MakeComputerMove(gameID string) (*game.MoveResult, 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 nil, fmt.Errorf("game not found: %s", gameID)
|
||||
}
|
||||
|
||||
if g.NextPlayer().Type != core.PlayerComputer {
|
||||
return nil, fmt.Errorf("not computer's turn")
|
||||
}
|
||||
|
||||
currentColor := g.NextTurn()
|
||||
s.engine.SetPosition(g.CurrentFEN(), []string{})
|
||||
search, err := s.engine.Search(1000)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("engine error: %v", err)
|
||||
}
|
||||
|
||||
result := &game.MoveResult{
|
||||
Player: currentColor,
|
||||
Score: search.Score,
|
||||
Depth: search.Depth,
|
||||
GameState: core.StateOngoing,
|
||||
}
|
||||
|
||||
if search.BestMove == "" || search.BestMove == "(none)" {
|
||||
// No legal moves - computer is checkmated
|
||||
if currentColor == core.ColorWhite {
|
||||
g.SetState(core.StateBlackWins)
|
||||
} else {
|
||||
g.SetState(core.StateWhiteWins)
|
||||
}
|
||||
result.GameState = g.State()
|
||||
g.SetLastResult(result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
result.Move = search.BestMove
|
||||
|
||||
// Apply move and get resulting FEN
|
||||
s.engine.SetPosition(g.CurrentFEN(), []string{search.BestMove})
|
||||
newFEN, err := s.engine.GetFEN()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get position: %v", err)
|
||||
}
|
||||
|
||||
g.AddSnapshot(newFEN, search.BestMove, core.OppositeColor(currentColor))
|
||||
|
||||
// Check if opponent has any legal moves
|
||||
s.engine.SetPosition(newFEN, []string{})
|
||||
testSearch, _ := s.engine.Search(100)
|
||||
|
||||
if testSearch.BestMove == "" || testSearch.BestMove == "(none)" {
|
||||
// Computer checkmated the opponent
|
||||
if currentColor == core.ColorWhite {
|
||||
g.SetState(core.StateWhiteWins)
|
||||
} else {
|
||||
g.SetState(core.StateBlackWins)
|
||||
}
|
||||
result.GameState = g.State()
|
||||
}
|
||||
|
||||
// Store result in game
|
||||
g.SetLastResult(result)
|
||||
return result, nil
|
||||
return g, nil
|
||||
}
|
||||
|
||||
func (s *Service) Undo(gameID string, count int) error {
|
||||
// GenerateGameID creates a new unique game ID
|
||||
func (s *Service) GenerateGameID() string {
|
||||
return uuid.New().String()
|
||||
}
|
||||
|
||||
// ApplyMove adds a validated move to the game history
|
||||
// The processor has already validated this move and calculated the new FEN
|
||||
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("game not found: %s", gameID)
|
||||
}
|
||||
|
||||
// Determine whose turn it was before this move
|
||||
currentTurn := g.NextTurnColor()
|
||||
nextTurn := core.OppositeColor(currentTurn)
|
||||
|
||||
// Add the new position to game history
|
||||
g.AddSnapshot(newFEN, moveUCI, nextTurn)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateGameState sets the game's end state (checkmate, stalemate, etc)
|
||||
func (s *Service) UpdateGameState(gameID string, state core.State) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
g, ok := s.games[gameID]
|
||||
if !ok {
|
||||
return fmt.Errorf("game not found: %s", gameID)
|
||||
}
|
||||
|
||||
g.SetState(state)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetLastMoveResult stores metadata about the last move (score, depth, etc)
|
||||
// Used by processor to track computer move evaluations
|
||||
func (s *Service) SetLastMoveResult(gameID string, result *game.MoveResult) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
g, ok := s.games[gameID]
|
||||
if !ok {
|
||||
return fmt.Errorf("game not found: %s", gameID)
|
||||
}
|
||||
|
||||
g.SetLastResult(result)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UndoMoves removes the specified number of moves from game history
|
||||
func (s *Service) UndoMoves(gameID string, count int) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
g, ok := s.games[gameID]
|
||||
if !ok {
|
||||
return fmt.Errorf("game not found: %s", gameID)
|
||||
@ -207,34 +142,25 @@ func (s *Service) Undo(gameID string, count int) error {
|
||||
return g.UndoMoves(count)
|
||||
}
|
||||
|
||||
func (s *Service) GetCurrentBoard(gameID string) (*board.Board, error) {
|
||||
g, ok := s.games[gameID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("game not found: %s", gameID)
|
||||
}
|
||||
|
||||
return board.FEN(g.CurrentFEN())
|
||||
}
|
||||
|
||||
func (s *Service) GetGame(gameID string) (*game.Game, error) {
|
||||
g, ok := s.games[gameID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("game not found: %s", gameID)
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// DeleteGame removes a game from memory
|
||||
func (s *Service) DeleteGame(gameID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, ok := s.games[gameID]; !ok {
|
||||
return fmt.Errorf("game not found: %s", gameID)
|
||||
}
|
||||
|
||||
delete(s.games, gameID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close cleans up resources (currently a no-op as no engine to close)
|
||||
func (s *Service) Close() error {
|
||||
if s.engine != nil {
|
||||
return s.engine.Close()
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Clear all games
|
||||
s.games = make(map[string]*game.Game)
|
||||
return nil
|
||||
}
|
||||
@ -1,248 +0,0 @@
|
||||
// FILE: internal/transport/cli/handler.go
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"chess/internal/cli"
|
||||
"chess/internal/core"
|
||||
"chess/internal/service"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type CLIHandler struct {
|
||||
svc *service.Service
|
||||
view *cli.CLI
|
||||
gameID string
|
||||
}
|
||||
|
||||
func New(svc *service.Service, view *cli.CLI) *CLIHandler {
|
||||
return &CLIHandler{
|
||||
svc: svc,
|
||||
view: view,
|
||||
}
|
||||
}
|
||||
|
||||
// Main game loop - simple command processing
|
||||
func (h *CLIHandler) Run() {
|
||||
for {
|
||||
// Generate prompt based on current game state
|
||||
prompt := h.getPrompt()
|
||||
h.view.ShowPrompt(prompt)
|
||||
|
||||
// Get command (blocking)
|
||||
cmd, err := h.view.GetCommand()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
// Process command - returns false to exit
|
||||
if !h.ProcessCommand(cmd) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generates the appropriate command prompt
|
||||
func (h *CLIHandler) getPrompt() string {
|
||||
prompt := "> "
|
||||
if h.gameID != "" {
|
||||
g, err := h.svc.GetGame(h.gameID)
|
||||
if err == nil && g.State() == core.StateOngoing {
|
||||
// Always show whose turn it is
|
||||
prompt = fmt.Sprintf("[%c]> ", g.NextTurn())
|
||||
if g.NextPlayer().Type == core.PlayerComputer {
|
||||
prompt = "ENTER to execute computer move\n" + prompt
|
||||
}
|
||||
}
|
||||
}
|
||||
return prompt
|
||||
}
|
||||
|
||||
// Handles user commands - returns false to exit
|
||||
func (h *CLIHandler) ProcessCommand(cmd *cli.Command) bool {
|
||||
switch cmd.Type {
|
||||
case cli.CmdQuit:
|
||||
return false
|
||||
|
||||
case cli.CmdNone:
|
||||
// Empty command triggers computer move if it's computer's turn
|
||||
if h.gameID != "" {
|
||||
g, err := h.svc.GetGame(h.gameID)
|
||||
if err == nil && g.State() == core.StateOngoing &&
|
||||
g.NextPlayer().Type == core.PlayerComputer {
|
||||
h.executeComputerMove()
|
||||
}
|
||||
}
|
||||
return true
|
||||
|
||||
case cli.CmdNew:
|
||||
return h.handleNewGame("")
|
||||
|
||||
case cli.CmdResume:
|
||||
if len(cmd.Args) < 1 {
|
||||
h.view.ShowMessage("Usage: resume <FEN string>")
|
||||
return true
|
||||
}
|
||||
fen := strings.Join(cmd.Args, " ")
|
||||
return h.handleNewGame(fen)
|
||||
|
||||
case cli.CmdMove:
|
||||
if h.gameID == "" {
|
||||
h.view.ShowMessage("No active game. Use 'new' or 'resume <FEN>'.")
|
||||
return true
|
||||
}
|
||||
|
||||
g, _ := h.svc.GetGame(h.gameID)
|
||||
if g.NextPlayer().Type != core.PlayerHuman {
|
||||
h.view.ShowMessage("It's not a human player's turn. Press ENTER to execute computer move.")
|
||||
return true
|
||||
}
|
||||
|
||||
if err := h.svc.MakeHumanMove(h.gameID, cmd.Args[0]); err != nil {
|
||||
h.view.ShowError(fmt.Errorf("invalid move: %v", err))
|
||||
return true
|
||||
}
|
||||
|
||||
// Get result and display human move
|
||||
g, _ = h.svc.GetGame(h.gameID)
|
||||
result := g.LastResult()
|
||||
if result != nil {
|
||||
h.view.ShowHumanMove(result.Move)
|
||||
}
|
||||
|
||||
board, _ := h.svc.GetCurrentBoard(h.gameID)
|
||||
h.view.DisplayBoard(board)
|
||||
|
||||
if result != nil && result.GameState != core.StateOngoing {
|
||||
h.view.ShowGameOver(result.GameState)
|
||||
h.gameID = ""
|
||||
}
|
||||
|
||||
case cli.CmdUndo:
|
||||
if h.gameID == "" {
|
||||
h.view.ShowMessage("No active game.")
|
||||
return true
|
||||
}
|
||||
|
||||
// Parse undo count
|
||||
count := 1
|
||||
if len(cmd.Args) > 0 {
|
||||
if n, err := strconv.Atoi(cmd.Args[0]); err == nil && n > 0 {
|
||||
count = n
|
||||
} else {
|
||||
h.view.ShowMessage("Invalid undo count. Usage: undo [count]")
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.svc.Undo(h.gameID, count); err != nil {
|
||||
h.view.ShowError(err)
|
||||
} else {
|
||||
if count == 1 {
|
||||
h.view.ShowMessage("Move undone")
|
||||
} else {
|
||||
h.view.ShowMessage(fmt.Sprintf("%d moves undone", count))
|
||||
}
|
||||
|
||||
board, _ := h.svc.GetCurrentBoard(h.gameID)
|
||||
h.view.DisplayBoard(board)
|
||||
}
|
||||
|
||||
case cli.CmdColor:
|
||||
if len(cmd.Args) < 1 {
|
||||
h.view.ShowMessage("Usage: color <off|brown|green|gray>")
|
||||
return true
|
||||
}
|
||||
|
||||
theme := cli.ColorTheme(cmd.Args[0])
|
||||
if err := h.view.SetTheme(theme); err != nil {
|
||||
h.view.ShowError(err)
|
||||
} else {
|
||||
h.view.ShowMessage(fmt.Sprintf("Color theme set to: %s", theme))
|
||||
if h.gameID != "" {
|
||||
board, _ := h.svc.GetCurrentBoard(h.gameID)
|
||||
h.view.DisplayBoard(board)
|
||||
}
|
||||
}
|
||||
|
||||
case cli.CmdVerbose:
|
||||
verbose := h.view.ToggleVerbose()
|
||||
h.view.ShowMessage(fmt.Sprintf("Verbose mode: %t", verbose))
|
||||
|
||||
case cli.CmdHistory:
|
||||
if h.gameID == "" {
|
||||
h.view.ShowMessage("No active game.")
|
||||
return true
|
||||
}
|
||||
g, _ := h.svc.GetGame(h.gameID)
|
||||
h.view.ShowGameHistory(g)
|
||||
|
||||
case cli.CmdHelp:
|
||||
h.view.ShowHelp()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *CLIHandler) executeComputerMove() {
|
||||
result, err := h.svc.MakeComputerMove(h.gameID)
|
||||
if err != nil {
|
||||
h.view.ShowError(fmt.Errorf("engine error: %v", err))
|
||||
h.gameID = ""
|
||||
return
|
||||
}
|
||||
|
||||
h.view.ShowComputerMove(result)
|
||||
board, _ := h.svc.GetCurrentBoard(h.gameID)
|
||||
h.view.DisplayBoard(board)
|
||||
|
||||
if result.GameState != core.StateOngoing {
|
||||
h.view.ShowGameOver(result.GameState)
|
||||
h.gameID = ""
|
||||
}
|
||||
}
|
||||
|
||||
// Starts a new game with player type selection
|
||||
func (h *CLIHandler) handleNewGame(fen string) bool {
|
||||
// Get player types
|
||||
h.view.ShowPrompt("Select White player (h/c): ")
|
||||
whiteInput := h.view.ReadLine()
|
||||
var whiteType core.PlayerType
|
||||
if whiteInput == "c" || whiteInput == "computer" {
|
||||
whiteType = core.PlayerComputer
|
||||
} else {
|
||||
whiteType = core.PlayerHuman
|
||||
}
|
||||
|
||||
h.view.ShowPrompt("Select Black player (h/c): ")
|
||||
blackInput := h.view.ReadLine()
|
||||
var blackType core.PlayerType
|
||||
if blackInput == "c" || blackInput == "computer" {
|
||||
blackType = core.PlayerComputer
|
||||
} else {
|
||||
blackType = core.PlayerHuman
|
||||
}
|
||||
|
||||
// Create new game
|
||||
h.gameID = uuid.New().String()
|
||||
var fenArray []string
|
||||
if fen != "" {
|
||||
fenArray = []string{fen}
|
||||
}
|
||||
|
||||
if err := h.svc.NewGame(h.gameID, whiteType, blackType, fenArray...); err != nil {
|
||||
h.view.ShowError(fmt.Errorf("could not start the game: %v", err))
|
||||
h.gameID = ""
|
||||
return true
|
||||
}
|
||||
|
||||
h.view.ShowMessage("Game started.")
|
||||
board, _ := h.svc.GetCurrentBoard(h.gameID)
|
||||
h.view.DisplayBoard(board)
|
||||
|
||||
return true
|
||||
}
|
||||
@ -1,316 +0,0 @@
|
||||
// FILE: internal/transport/http/game_handler.go
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"chess/internal/board"
|
||||
"chess/internal/core"
|
||||
"chess/internal/game"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// CreateGame creates a new game with specified player types
|
||||
func (h *HTTPHandler) CreateGame(c *fiber.Ctx) error {
|
||||
var req CreateGameRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(ErrorResponse{
|
||||
Error: "invalid request body",
|
||||
Code: ErrInvalidRequest,
|
||||
Details: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
gameID := uuid.New().String()
|
||||
|
||||
// Create game with proper type conversion
|
||||
var fenArray []string
|
||||
if req.FEN != "" {
|
||||
fenArray = []string{req.FEN}
|
||||
}
|
||||
|
||||
err := h.svc.NewGame(
|
||||
gameID,
|
||||
core.PlayerType(req.White),
|
||||
core.PlayerType(req.Black),
|
||||
fenArray...,
|
||||
)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(ErrorResponse{
|
||||
Error: "failed to create game",
|
||||
Code: ErrInvalidRequest,
|
||||
Details: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Build response - cache game instance
|
||||
g, _ := h.svc.GetGame(gameID)
|
||||
response := h.buildGameResponse(gameID, g)
|
||||
|
||||
// Execute computer move if computer starts
|
||||
if g.NextPlayer().Type == core.PlayerComputer && g.State() == core.StateOngoing {
|
||||
if err := h.executeComputerMove(gameID, g, &response); err != nil {
|
||||
// Log error but return game created successfully
|
||||
fmt.Printf("Warning: failed to execute initial computer move: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(response)
|
||||
}
|
||||
|
||||
// GetGame retrieves current game state, executing computer move if needed
|
||||
func (h *HTTPHandler) GetGame(c *fiber.Ctx) error {
|
||||
gameID := c.Params("gameId")
|
||||
|
||||
g, err := h.svc.GetGame(gameID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(ErrorResponse{
|
||||
Error: "game not found",
|
||||
Code: ErrGameNotFound,
|
||||
})
|
||||
}
|
||||
|
||||
response := h.buildGameResponse(gameID, g)
|
||||
|
||||
// Auto-execute computer move if it's computer's turn
|
||||
if g.NextPlayer().Type == core.PlayerComputer && g.State() == core.StateOngoing {
|
||||
if err := h.executeComputerMove(gameID, g, &response); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(ErrorResponse{
|
||||
Error: "failed to execute computer move",
|
||||
Code: ErrInternalError,
|
||||
Details: err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(response)
|
||||
}
|
||||
|
||||
// MakeMove submits a human player move
|
||||
func (h *HTTPHandler) MakeMove(c *fiber.Ctx) error {
|
||||
gameID := c.Params("gameId")
|
||||
|
||||
var req MoveRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(ErrorResponse{
|
||||
Error: "invalid request body",
|
||||
Code: ErrInvalidRequest,
|
||||
Details: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
g, err := h.svc.GetGame(gameID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(ErrorResponse{
|
||||
Error: "game not found",
|
||||
Code: ErrGameNotFound,
|
||||
})
|
||||
}
|
||||
|
||||
// Check game state BEFORE making move
|
||||
if g.State() != core.StateOngoing {
|
||||
fmt.Printf("DEBUG: Move rejected - game over (state: %s)\n", g.State())
|
||||
return c.Status(fiber.StatusBadRequest).JSON(ErrorResponse{
|
||||
Error: "game is over",
|
||||
Code: ErrGameOver,
|
||||
Details: fmt.Sprintf("game state: %s", g.State()),
|
||||
})
|
||||
}
|
||||
|
||||
// Verify it's human's turn
|
||||
currentPlayer := g.NextPlayer()
|
||||
if currentPlayer.Type != core.PlayerHuman {
|
||||
fmt.Printf("DEBUG: Move rejected - not human turn (current: %v, turn: %c)\n",
|
||||
currentPlayer.Type, g.NextTurn())
|
||||
return c.Status(fiber.StatusBadRequest).JSON(ErrorResponse{
|
||||
Error: "not human player's turn",
|
||||
Code: ErrNotHumanTurn,
|
||||
Details: fmt.Sprintf("current turn: %c", g.NextTurn()),
|
||||
})
|
||||
}
|
||||
|
||||
fmt.Printf("DEBUG: Attempting human move %s for game %s\n", req.Move, gameID)
|
||||
|
||||
// Make human move
|
||||
if err := h.svc.MakeHumanMove(gameID, req.Move); err != nil {
|
||||
fmt.Printf("DEBUG: Move failed: %v\n", err)
|
||||
return c.Status(fiber.StatusBadRequest).JSON(ErrorResponse{
|
||||
Error: "invalid move",
|
||||
Code: ErrInvalidMove,
|
||||
Details: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Get updated game state - refresh g
|
||||
g, _ = h.svc.GetGame(gameID)
|
||||
response := h.buildGameResponse(gameID, g)
|
||||
|
||||
// Include human move info from LastResult
|
||||
if result := g.LastResult(); result != nil {
|
||||
response.LastMove = &MoveInfo{
|
||||
Move: result.Move,
|
||||
Player: colorToString(result.Player),
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("DEBUG: Human move successful, new state: %s, next turn: %c\n",
|
||||
g.State(), g.NextTurn())
|
||||
|
||||
// Execute computer response if needed
|
||||
if g.NextPlayer().Type == core.PlayerComputer && g.State() == core.StateOngoing {
|
||||
fmt.Printf("DEBUG: Executing computer response\n")
|
||||
if err := h.executeComputerMove(gameID, g, &response); err != nil {
|
||||
// Computer move failed, but human move succeeded
|
||||
fmt.Printf("Warning: computer move failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(response)
|
||||
}
|
||||
|
||||
// UndoMove undoes one or more moves
|
||||
func (h *HTTPHandler) UndoMove(c *fiber.Ctx) error {
|
||||
gameID := c.Params("gameId")
|
||||
|
||||
var req UndoRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
// Body parsing failed, use default
|
||||
req.Count = 1
|
||||
}
|
||||
|
||||
if req.Count < 1 {
|
||||
req.Count = 1
|
||||
}
|
||||
|
||||
if err := h.svc.Undo(gameID, req.Count); err != nil {
|
||||
// Determine if game not found or invalid undo
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
return c.Status(fiber.StatusNotFound).JSON(ErrorResponse{
|
||||
Error: "game not found",
|
||||
Code: ErrGameNotFound,
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusBadRequest).JSON(ErrorResponse{
|
||||
Error: "cannot undo moves",
|
||||
Code: ErrInvalidRequest,
|
||||
Details: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Return updated game state
|
||||
g, _ := h.svc.GetGame(gameID)
|
||||
response := h.buildGameResponse(gameID, g)
|
||||
|
||||
return c.JSON(response)
|
||||
}
|
||||
|
||||
// DeleteGame ends and cleans up a game
|
||||
func (h *HTTPHandler) DeleteGame(c *fiber.Ctx) error {
|
||||
gameID := c.Params("gameId")
|
||||
|
||||
if err := h.svc.DeleteGame(gameID); err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(ErrorResponse{
|
||||
Error: "game not found",
|
||||
Code: ErrGameNotFound,
|
||||
})
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
|
||||
// GetBoard returns ASCII representation of the board
|
||||
func (h *HTTPHandler) GetBoard(c *fiber.Ctx) error {
|
||||
gameID := c.Params("gameId")
|
||||
|
||||
g, err := h.svc.GetGame(gameID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(ErrorResponse{
|
||||
Error: "game not found",
|
||||
Code: ErrGameNotFound,
|
||||
})
|
||||
}
|
||||
|
||||
b, _ := h.svc.GetCurrentBoard(gameID)
|
||||
|
||||
// Generate ASCII board
|
||||
ascii := h.generateASCIIBoard(b)
|
||||
|
||||
return c.JSON(BoardResponse{
|
||||
FEN: g.CurrentFEN(),
|
||||
Board: ascii,
|
||||
})
|
||||
}
|
||||
|
||||
// Helper: Build standard game response - FIXED to use GetPlayer()
|
||||
func (h *HTTPHandler) buildGameResponse(gameID string, g *game.Game) GameResponse {
|
||||
whitePlayer := g.GetPlayer(core.ColorWhite)
|
||||
blackPlayer := g.GetPlayer(core.ColorBlack)
|
||||
|
||||
return GameResponse{
|
||||
GameID: gameID,
|
||||
FEN: g.CurrentFEN(),
|
||||
Turn: colorToString(g.NextTurn()),
|
||||
State: stateToString(g.State()),
|
||||
Moves: g.Moves(),
|
||||
Players: PlayersInfo{
|
||||
White: PlayerType(whitePlayer.Type),
|
||||
Black: PlayerType(blackPlayer.Type),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: Execute computer move and update response - FIXED to accept game instance
|
||||
func (h *HTTPHandler) executeComputerMove(gameID string, g *game.Game, response *GameResponse) error {
|
||||
result, err := h.svc.MakeComputerMove(gameID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Refresh game state after computer move
|
||||
g, _ = h.svc.GetGame(gameID)
|
||||
|
||||
// Update response fields
|
||||
response.FEN = g.CurrentFEN()
|
||||
response.Turn = colorToString(g.NextTurn())
|
||||
response.State = stateToString(g.State())
|
||||
response.Moves = g.Moves()
|
||||
|
||||
// Add computer move info
|
||||
if result != nil {
|
||||
response.LastMove = &MoveInfo{
|
||||
Move: result.Move,
|
||||
Player: colorToString(result.Player),
|
||||
Score: result.Score,
|
||||
Depth: result.Depth,
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper: Generate ASCII board representation
|
||||
func (h *HTTPHandler) generateASCIIBoard(b *board.Board) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(" a b c d e f g h\n")
|
||||
|
||||
for r := 0; r < 8; r++ {
|
||||
sb.WriteString(fmt.Sprintf("%d ", 8-r))
|
||||
for f := 0; f < 8; f++ {
|
||||
square := fmt.Sprintf("%c%c", 'a'+f, '8'-r)
|
||||
piece := b.GetPieceAt(square)
|
||||
|
||||
if piece == 0 {
|
||||
sb.WriteString(". ")
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("%c ", piece))
|
||||
}
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" %d\n", 8-r))
|
||||
}
|
||||
sb.WriteString(" a b c d e f g h")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
@ -1,148 +0,0 @@
|
||||
// FILE: internal/transport/http/handler.go
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chess/internal/service"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||
"github.com/gofiber/fiber/v2/middleware/limiter"
|
||||
"github.com/gofiber/fiber/v2/middleware/logger"
|
||||
"github.com/gofiber/fiber/v2/middleware/recover"
|
||||
)
|
||||
|
||||
type HTTPHandler struct {
|
||||
svc *service.Service
|
||||
}
|
||||
|
||||
func NewHTTPHandler(svc *service.Service) *HTTPHandler {
|
||||
return &HTTPHandler{svc: svc}
|
||||
}
|
||||
|
||||
func NewFiberApp(svc *service.Service, devMode bool) *fiber.App {
|
||||
// Create handler
|
||||
h := NewHTTPHandler(svc)
|
||||
|
||||
// Initialize Fiber app
|
||||
app := fiber.New(fiber.Config{
|
||||
ErrorHandler: customErrorHandler,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
IdleTimeout: 30 * time.Second,
|
||||
})
|
||||
|
||||
// Global middleware (order matters)
|
||||
app.Use(recover.New())
|
||||
app.Use(logger.New(logger.Config{
|
||||
Format: "${time} ${status} ${method} ${path} ${latency}\n",
|
||||
}))
|
||||
app.Use(cors.New(cors.Config{
|
||||
AllowOrigins: "*",
|
||||
AllowMethods: "GET,POST,DELETE,OPTIONS",
|
||||
AllowHeaders: "Origin,Content-Type,Accept",
|
||||
}))
|
||||
|
||||
// Health check (no rate limit)
|
||||
app.Get("/health", h.Health)
|
||||
|
||||
// API v1 routes with rate limiting
|
||||
api := app.Group("/api/v1")
|
||||
|
||||
// Rate limiter: 1/10 req/sec per IP with expiry
|
||||
maxReq := 1
|
||||
if devMode {
|
||||
maxReq = 10
|
||||
}
|
||||
api.Use(limiter.New(limiter.Config{
|
||||
Max: maxReq, // Allow requests per second
|
||||
Expiration: 1 * time.Second, // Per second
|
||||
KeyGenerator: func(c *fiber.Ctx) string {
|
||||
// Check X-Forwarded-For first, then X-Real-IP, then RemoteIP
|
||||
if xff := c.Get("X-Forwarded-For"); xff != "" {
|
||||
// Take the first IP from X-Forwarded-For chain
|
||||
if idx := strings.Index(xff, ","); idx != -1 {
|
||||
return strings.TrimSpace(xff[:idx])
|
||||
}
|
||||
return xff
|
||||
}
|
||||
return c.IP()
|
||||
},
|
||||
LimitReached: func(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusTooManyRequests).JSON(ErrorResponse{
|
||||
Error: "rate limit exceeded",
|
||||
Code: ErrRateLimitExceeded,
|
||||
Details: fmt.Sprintf("%d requests per second allowed", maxReq),
|
||||
})
|
||||
},
|
||||
Storage: nil, // Use in-memory storage (default)
|
||||
SkipFailedRequests: false,
|
||||
SkipSuccessfulRequests: false,
|
||||
}))
|
||||
|
||||
// Content-Type validation for POST requests
|
||||
api.Use(contentTypeValidator)
|
||||
|
||||
// Register game routes
|
||||
api.Post("/games", h.CreateGame)
|
||||
api.Get("/games/:gameId", h.GetGame)
|
||||
api.Delete("/games/:gameId", h.DeleteGame)
|
||||
api.Post("/games/:gameId/moves", h.MakeMove)
|
||||
api.Post("/games/:gameId/undo", h.UndoMove)
|
||||
api.Get("/games/:gameId/board", h.GetBoard)
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
// contentTypeValidator ensures POST requests have application/json
|
||||
func contentTypeValidator(c *fiber.Ctx) error {
|
||||
if c.Method() == "POST" {
|
||||
contentType := c.Get("Content-Type")
|
||||
if contentType != "application/json" && contentType != "" {
|
||||
return c.Status(fiber.StatusUnsupportedMediaType).JSON(ErrorResponse{
|
||||
Error: "unsupported media type",
|
||||
Code: ErrInvalidContent,
|
||||
Details: "Content-Type must be application/json",
|
||||
})
|
||||
}
|
||||
}
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
// customErrorHandler provides consistent error responses
|
||||
func customErrorHandler(c *fiber.Ctx, err error) error {
|
||||
code := fiber.StatusInternalServerError
|
||||
response := ErrorResponse{
|
||||
Error: "internal server error",
|
||||
Code: ErrInternalError,
|
||||
}
|
||||
|
||||
// Check if it's a Fiber error
|
||||
if e, ok := err.(*fiber.Error); ok {
|
||||
code = e.Code
|
||||
response.Error = e.Message
|
||||
|
||||
// Map HTTP status to error codes
|
||||
switch code {
|
||||
case fiber.StatusNotFound:
|
||||
response.Code = ErrGameNotFound
|
||||
case fiber.StatusBadRequest:
|
||||
response.Code = ErrInvalidRequest
|
||||
case fiber.StatusTooManyRequests:
|
||||
response.Code = ErrRateLimitExceeded
|
||||
}
|
||||
}
|
||||
|
||||
return c.Status(code).JSON(response)
|
||||
}
|
||||
|
||||
// Health check endpoint
|
||||
func (h *HTTPHandler) Health(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{
|
||||
"status": "healthy",
|
||||
"time": time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
@ -1,119 +0,0 @@
|
||||
// FILE: internal/transport/http/types.go
|
||||
package http
|
||||
|
||||
import (
|
||||
"chess/internal/core"
|
||||
)
|
||||
|
||||
// Request types
|
||||
|
||||
type CreateGameRequest struct {
|
||||
White PlayerType `json:"white"` // 0=human, 1=computer
|
||||
Black PlayerType `json:"black"` // 0=human, 1=computer
|
||||
FEN string `json:"fen,omitempty"`
|
||||
}
|
||||
|
||||
type MoveRequest struct {
|
||||
Move string `json:"move"` // UCI format: "e2e4"
|
||||
}
|
||||
|
||||
type UndoRequest struct {
|
||||
Count int `json:"count,omitempty"` // default: 1
|
||||
}
|
||||
|
||||
// Response types
|
||||
|
||||
type GameResponse struct {
|
||||
GameID string `json:"gameId"`
|
||||
FEN string `json:"fen"`
|
||||
Turn string `json:"turn"` // "w" or "b"
|
||||
State string `json:"state"` // "ongoing", "white_wins", etc
|
||||
Moves []string `json:"moves"`
|
||||
Players PlayersInfo `json:"players"`
|
||||
LastMove *MoveInfo `json:"lastMove,omitempty"`
|
||||
}
|
||||
|
||||
type PlayersInfo struct {
|
||||
White PlayerType `json:"white"`
|
||||
Black PlayerType `json:"black"`
|
||||
}
|
||||
|
||||
type MoveInfo struct {
|
||||
Move string `json:"move"`
|
||||
Player string `json:"player"` // "w" or "b"
|
||||
Score int `json:"score,omitempty"`
|
||||
Depth int `json:"depth,omitempty"`
|
||||
}
|
||||
|
||||
type BoardResponse struct {
|
||||
FEN string `json:"fen"`
|
||||
Board string `json:"board"` // ASCII representation
|
||||
}
|
||||
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Code string `json:"code"`
|
||||
Details string `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
// Custom type for JSON marshaling of PlayerType
|
||||
type PlayerType core.PlayerType
|
||||
|
||||
func (p PlayerType) MarshalJSON() ([]byte, error) {
|
||||
// Map to int for JSON: 0=human, 1=computer
|
||||
return []byte(string('0' + p)), nil
|
||||
}
|
||||
|
||||
func (p *PlayerType) UnmarshalJSON(data []byte) error {
|
||||
if len(data) == 1 && data[0] >= '0' && data[0] <= '1' {
|
||||
*p = PlayerType(data[0] - '0')
|
||||
return nil
|
||||
}
|
||||
// Also accept string format for compatibility
|
||||
str := string(data)
|
||||
if str == `"human"` || str == "human" {
|
||||
*p = PlayerType(core.PlayerHuman)
|
||||
} else if str == `"computer"` || str == "computer" {
|
||||
*p = PlayerType(core.PlayerComputer)
|
||||
} else if str == "0" {
|
||||
*p = PlayerType(core.PlayerHuman)
|
||||
} else if str == "1" {
|
||||
*p = PlayerType(core.PlayerComputer)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func colorToString(c core.Color) string {
|
||||
return string(c)
|
||||
}
|
||||
|
||||
func stateToString(s core.State) string {
|
||||
switch s {
|
||||
case core.StateOngoing:
|
||||
return "ongoing"
|
||||
case core.StateWhiteWins:
|
||||
return "white_wins"
|
||||
case core.StateBlackWins:
|
||||
return "black_wins"
|
||||
case core.StateDraw:
|
||||
return "draw"
|
||||
case core.StateStalemate:
|
||||
return "stalemate"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// Error codes
|
||||
const (
|
||||
ErrGameNotFound = "GAME_NOT_FOUND"
|
||||
ErrInvalidMove = "INVALID_MOVE"
|
||||
ErrNotHumanTurn = "NOT_HUMAN_TURN"
|
||||
ErrGameOver = "GAME_OVER"
|
||||
ErrRateLimitExceeded = "RATE_LIMIT_EXCEEDED"
|
||||
ErrInvalidContent = "INVALID_CONTENT_TYPE"
|
||||
ErrInvalidRequest = "INVALID_REQUEST"
|
||||
ErrInternalError = "INTERNAL_ERROR"
|
||||
)
|
||||
@ -1,29 +0,0 @@
|
||||
// FILE: internal/transport/transport.go
|
||||
package transport
|
||||
|
||||
import (
|
||||
"chess/internal/board"
|
||||
"chess/internal/core"
|
||||
"chess/internal/game"
|
||||
)
|
||||
|
||||
// Handler processes user commands independent of transport medium
|
||||
type Handler interface {
|
||||
HandleNewGame(id string, fen string, whiteType, blackType core.PlayerType) error
|
||||
HandleMove(gameID, move string) error
|
||||
HandleUndo(gameID string) error
|
||||
HandleGetBoard(gameID string) (*board.Board, error)
|
||||
HandleGetGame(gameID string) (*game.Game, error)
|
||||
}
|
||||
|
||||
// View abstracts display/output operations
|
||||
type View interface {
|
||||
DisplayBoard(b *board.Board)
|
||||
ShowMessage(msg string)
|
||||
ShowError(err error)
|
||||
ShowGameHistory(g *game.Game)
|
||||
ShowComputerMove(player core.Color, move string, depth, score int)
|
||||
ShowHumanMove(move string)
|
||||
ShowGameOver(state core.State)
|
||||
ShowPrompt(prompt string)
|
||||
}
|
||||
962
test/test-api.sh
962
test/test-api.sh
File diff suppressed because it is too large
Load Diff
272
test/test-player-config.sh
Executable file
272
test/test-player-config.sh
Executable file
@ -0,0 +1,272 @@
|
||||
#!/usr/bin/env bash
|
||||
# FILE: test-player-config.sh
|
||||
|
||||
# Player Configuration Deep Test Suite
|
||||
# Tests all aspects of player configuration changes mid-game
|
||||
# Debug-focused: prints full responses for analysis
|
||||
|
||||
BASE_URL="http://localhost:8080"
|
||||
API_URL="${BASE_URL}/api/v1"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
MAGENTA='\033[0;35m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Helper to pretty-print JSON
|
||||
print_json() {
|
||||
local label=$1
|
||||
local json=$2
|
||||
echo -e "${CYAN}>>> $label:${NC}"
|
||||
echo "$json" | jq '.' 2>/dev/null || echo "$json"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Helper to extract and display specific fields
|
||||
show_players() {
|
||||
local json=$1
|
||||
local white_type=$(echo "$json" | jq -r '.players.white.type' 2>/dev/null)
|
||||
local black_type=$(echo "$json" | jq -r '.players.black.type' 2>/dev/null)
|
||||
local white_id=$(echo "$json" | jq -r '.players.white.id' 2>/dev/null)
|
||||
local black_id=$(echo "$json" | jq -r '.players.black.id' 2>/dev/null)
|
||||
|
||||
echo -e "${YELLOW}Players State:${NC}"
|
||||
echo " White: type=$white_type, id=$white_id"
|
||||
echo " Black: type=$black_type, id=$black_id"
|
||||
}
|
||||
|
||||
# API request wrapper
|
||||
api_request() {
|
||||
local method=$1
|
||||
local url=$2
|
||||
shift 2
|
||||
curl -s "$@" -X "$method" "$url"
|
||||
}
|
||||
|
||||
wait_for_pending() {
|
||||
local game_id=$1
|
||||
local max_wait=3
|
||||
local waited=0
|
||||
|
||||
while [ $waited -lt $max_wait ]; do
|
||||
local response=$(api_request GET "$API_URL/games/$game_id")
|
||||
local state=$(echo "$response" | jq -r '.state' 2>/dev/null)
|
||||
if [ "$state" != "Pending" ]; then
|
||||
return 0
|
||||
fi
|
||||
sleep 0.2
|
||||
waited=$((waited + 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
echo -e "${CYAN}═══════════════════════════════════════════════════════════${NC}"
|
||||
echo -e "${CYAN}Player Configuration Deep Test Suite${NC}"
|
||||
echo -e "${CYAN}═══════════════════════════════════════════════════════════${NC}"
|
||||
|
||||
# Test 1: Basic player type changes
|
||||
echo -e "\n${GREEN}TEST 1: Create H-v-C, immediately change to C-v-H${NC}"
|
||||
echo "------------------------------------------------------"
|
||||
|
||||
RESPONSE=$(api_request POST "$API_URL/games" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"white": {"type": 1}, "black": {"type": 2, "level": 5}}')
|
||||
GAME1_ID=$(echo "$RESPONSE" | jq -r '.gameId' 2>/dev/null)
|
||||
|
||||
print_json "Initial H-v-C game created" "$RESPONSE"
|
||||
show_players "$RESPONSE"
|
||||
|
||||
# Change configuration
|
||||
RESPONSE=$(api_request PUT "$API_URL/games/$GAME1_ID/players" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"white": {"type": 2, "level": 10}, "black": {"type": 1}}')
|
||||
|
||||
print_json "After configuration change (should be C-v-H)" "$RESPONSE"
|
||||
show_players "$RESPONSE"
|
||||
|
||||
# Verify with GET
|
||||
RESPONSE=$(api_request GET "$API_URL/games/$GAME1_ID")
|
||||
print_json "GET verification" "$RESPONSE"
|
||||
show_players "$RESPONSE"
|
||||
|
||||
# Cleanup
|
||||
api_request DELETE "$API_URL/games/$GAME1_ID" > /dev/null
|
||||
|
||||
# Test 2: Change during active game
|
||||
echo -e "\n${GREEN}TEST 2: H-v-H game with moves, then change to H-v-C${NC}"
|
||||
echo "------------------------------------------------------"
|
||||
|
||||
RESPONSE=$(api_request POST "$API_URL/games" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"white": {"type": 1}, "black": {"type": 1}}')
|
||||
GAME2_ID=$(echo "$RESPONSE" | jq -r '.gameId' 2>/dev/null)
|
||||
|
||||
print_json "Initial H-v-H game" "$RESPONSE"
|
||||
show_players "$RESPONSE"
|
||||
|
||||
# Make some moves
|
||||
echo -e "\n${BLUE}Making moves: e2e4, e7e5, g1f3${NC}"
|
||||
|
||||
RESPONSE=$(api_request POST "$API_URL/games/$GAME2_ID/moves" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"move": "e2e4"}')
|
||||
echo "Move 1 (e2e4): $(echo "$RESPONSE" | jq -r '.state' 2>/dev/null)"
|
||||
|
||||
RESPONSE=$(api_request POST "$API_URL/games/$GAME2_ID/moves" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"move": "e7e5"}')
|
||||
echo "Move 2 (e7e5): $(echo "$RESPONSE" | jq -r '.state' 2>/dev/null)"
|
||||
|
||||
RESPONSE=$(api_request POST "$API_URL/games/$GAME2_ID/moves" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"move": "g1f3"}')
|
||||
echo "Move 3 (g1f3): $(echo "$RESPONSE" | jq -r '.state' 2>/dev/null)"
|
||||
|
||||
# Get current state
|
||||
RESPONSE=$(api_request GET "$API_URL/games/$GAME2_ID")
|
||||
print_json "Game state after 3 moves" "$RESPONSE"
|
||||
echo "Move history: $(echo "$RESPONSE" | jq -r '.moves' 2>/dev/null)"
|
||||
|
||||
# Change to H-v-C
|
||||
echo -e "\n${BLUE}Changing configuration to H-v-C${NC}"
|
||||
RESPONSE=$(api_request PUT "$API_URL/games/$GAME2_ID/players" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"white": {"type": 1}, "black": {"type": 2, "level": 8, "searchTime": 200}}')
|
||||
|
||||
print_json "After config change (should be H-v-C)" "$RESPONSE"
|
||||
show_players "$RESPONSE"
|
||||
|
||||
# Trigger computer move
|
||||
echo -e "\n${BLUE}Triggering computer move (black)${NC}"
|
||||
RESPONSE=$(api_request POST "$API_URL/games/$GAME2_ID/moves" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"move": "cccc"}')
|
||||
|
||||
if echo "$RESPONSE" | jq -r '.state' 2>/dev/null | grep -q "Pending"; then
|
||||
echo "Computer move triggered, waiting..."
|
||||
wait_for_pending "$GAME2_ID"
|
||||
fi
|
||||
|
||||
# Get final state with history
|
||||
RESPONSE=$(api_request GET "$API_URL/games/$GAME2_ID")
|
||||
print_json "Final game state with computer move" "$RESPONSE"
|
||||
echo -e "${MAGENTA}Complete move history: $(echo "$RESPONSE" | jq -r '.moves' 2>/dev/null)${NC}"
|
||||
show_players "$RESPONSE"
|
||||
|
||||
# Cleanup
|
||||
api_request DELETE "$API_URL/games/$GAME2_ID" > /dev/null
|
||||
|
||||
# Test 3: Multiple configuration changes
|
||||
echo -e "\n${GREEN}TEST 3: Multiple configuration changes${NC}"
|
||||
echo "------------------------------------------------------"
|
||||
|
||||
RESPONSE=$(api_request POST "$API_URL/games" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"white": {"type": 2, "level": 15}, "black": {"type": 2, "level": 15}}')
|
||||
GAME3_ID=$(echo "$RESPONSE" | jq -r '.gameId' 2>/dev/null)
|
||||
|
||||
print_json "Initial C-v-C game" "$RESPONSE"
|
||||
show_players "$RESPONSE"
|
||||
|
||||
# Change 1: C-v-C to H-v-H
|
||||
RESPONSE=$(api_request PUT "$API_URL/games/$GAME3_ID/players" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"white": {"type": 1}, "black": {"type": 1}}')
|
||||
print_json "Change 1: Now H-v-H" "$RESPONSE"
|
||||
show_players "$RESPONSE"
|
||||
|
||||
# Change 2: H-v-H to H-v-C
|
||||
RESPONSE=$(api_request PUT "$API_URL/games/$GAME3_ID/players" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"white": {"type": 1}, "black": {"type": 2, "level": 20}}')
|
||||
print_json "Change 2: Now H-v-C" "$RESPONSE"
|
||||
show_players "$RESPONSE"
|
||||
|
||||
# Change 3: H-v-C to C-v-H
|
||||
RESPONSE=$(api_request PUT "$API_URL/games/$GAME3_ID/players" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"white": {"type": 2, "level": 1}, "black": {"type": 1}}')
|
||||
print_json "Change 3: Now C-v-H" "$RESPONSE"
|
||||
show_players "$RESPONSE"
|
||||
|
||||
# Final verification
|
||||
RESPONSE=$(api_request GET "$API_URL/games/$GAME3_ID")
|
||||
print_json "Final GET verification" "$RESPONSE"
|
||||
show_players "$RESPONSE"
|
||||
|
||||
# Cleanup
|
||||
api_request DELETE "$API_URL/games/$GAME3_ID" > /dev/null
|
||||
|
||||
# Test 4: Error cases
|
||||
echo -e "\n${GREEN}TEST 4: Error handling${NC}"
|
||||
echo "------------------------------------------------------"
|
||||
|
||||
# Try to change during pending state
|
||||
RESPONSE=$(api_request POST "$API_URL/games" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"white": {"type": 1}, "black": {"type": 2, "searchTime": 500}}')
|
||||
GAME4_ID=$(echo "$RESPONSE" | jq -r '.gameId' 2>/dev/null)
|
||||
|
||||
# Make human move
|
||||
api_request POST "$API_URL/games/$GAME4_ID/moves" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"move": "e2e4"}' > /dev/null
|
||||
|
||||
# Trigger computer move
|
||||
api_request POST "$API_URL/games/$GAME4_ID/moves" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"move": "cccc"}' > /dev/null
|
||||
|
||||
# Immediately try to change config (should fail)
|
||||
RESPONSE=$(api_request PUT "$API_URL/games/$GAME4_ID/players" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"white": {"type": 2}, "black": {"type": 1}}')
|
||||
|
||||
print_json "Config change during Pending (should error)" "$RESPONSE"
|
||||
|
||||
wait_for_pending "$GAME4_ID"
|
||||
api_request DELETE "$API_URL/games/$GAME4_ID" > /dev/null
|
||||
|
||||
# Test 5: Verify player IDs change
|
||||
echo -e "\n${GREEN}TEST 5: Verify player IDs change on reconfiguration${NC}"
|
||||
echo "------------------------------------------------------"
|
||||
|
||||
RESPONSE=$(api_request POST "$API_URL/games" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"white": {"type": 1}, "black": {"type": 1}}')
|
||||
GAME5_ID=$(echo "$RESPONSE" | jq -r '.gameId' 2>/dev/null)
|
||||
|
||||
WHITE_ID_1=$(echo "$RESPONSE" | jq -r '.players.white.id' 2>/dev/null)
|
||||
BLACK_ID_1=$(echo "$RESPONSE" | jq -r '.players.black.id' 2>/dev/null)
|
||||
echo "Initial IDs: White=$WHITE_ID_1, Black=$BLACK_ID_1"
|
||||
|
||||
# Change configuration (even to same types)
|
||||
RESPONSE=$(api_request PUT "$API_URL/games/$GAME5_ID/players" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"white": {"type": 1}, "black": {"type": 1}}')
|
||||
|
||||
WHITE_ID_2=$(echo "$RESPONSE" | jq -r '.players.white.id' 2>/dev/null)
|
||||
BLACK_ID_2=$(echo "$RESPONSE" | jq -r '.players.black.id' 2>/dev/null)
|
||||
echo "After reconfig: White=$WHITE_ID_2, Black=$BLACK_ID_2"
|
||||
|
||||
if [ "$WHITE_ID_1" != "$WHITE_ID_2" ]; then
|
||||
echo -e "${GREEN}✓ White player ID changed (expected)${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ White player ID unchanged (unexpected)${NC}"
|
||||
fi
|
||||
|
||||
if [ "$BLACK_ID_1" != "$BLACK_ID_2" ]; then
|
||||
echo -e "${GREEN}✓ Black player ID changed (expected)${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ Black player ID unchanged (unexpected)${NC}"
|
||||
fi
|
||||
|
||||
api_request DELETE "$API_URL/games/$GAME5_ID" > /dev/null
|
||||
|
||||
echo -e "\n${CYAN}═══════════════════════════════════════════════════════════${NC}"
|
||||
echo -e "${CYAN}Test Complete${NC}"
|
||||
echo -e "${CYAN}═══════════════════════════════════════════════════════════${NC}"
|
||||
Reference in New Issue
Block a user