v0.2.0 transitioned to api-only, extended and improved features, docs and tests added

This commit is contained in:
2025-10-29 23:28:19 -04:00
parent b98ea83012
commit 0ad608293e
32 changed files with 3683 additions and 1670 deletions

View File

@ -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

View File

@ -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
}

View File

@ -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")
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
View 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
View 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
View 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
View 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
View 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
View File

@ -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
View File

@ -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=

View File

@ -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
}

View File

@ -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
View 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"`
}

View File

@ -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
View 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
View 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
View 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"
}
}

View File

@ -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
}
}
}
}

View File

@ -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, 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
}
func New(initialFEN string, whitePlayer, blackPlayer *core.Player, startingTurn core.Color) *Game {
return &Game{
snapshots: []Snapshot{
{
FEN: initialFEN,
PreviousMove: "", // No move led to initial position
NextTurn: startingTurn,
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,
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
View 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
View 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
}

View 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,
}
}

View 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
View 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")
}
}

View File

@ -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
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,
}, 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")
return g, nil
}
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)
// GenerateGameID creates a new unique game ID
func (s *Service) GenerateGameID() string {
return uuid.New().String()
}
result := &game.MoveResult{
Player: currentColor,
Score: search.Score,
Depth: search.Depth,
GameState: core.StateOngoing,
// 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)
}
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)
// 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
}
result.GameState = g.State()
// 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 result, nil
return nil
}
result.Move = search.BestMove
// 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()
// 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
}
func (s *Service) Undo(gameID string, count int) error {
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
}

View File

@ -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
}

View File

@ -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()
}

View File

@ -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(),
})
}

View File

@ -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"
)

View File

@ -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)
}

File diff suppressed because it is too large Load Diff

272
test/test-player-config.sh Executable file
View 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}"