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