565 lines
15 KiB
Go
565 lines
15 KiB
Go
// 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()
|
|
} |