Files
chess/internal/server/processor/processor.go
T

634 lines
19 KiB
Go

package processor
import (
"fmt"
"log"
"regexp"
"strings"
"sync"
"time"
"unicode"
"chess/internal/server/board"
"chess/internal/server/core"
"chess/internal/server/engine"
"chess/internal/server/game"
"chess/internal/server/service"
)
const (
minSearchTime = 100
)
// FEN validation regex
var fenPattern = regexp.MustCompile(`^[rnbqkpRNBQKP1-8/]+ [wb] [KQkq-]+ [a-h1-8-]+ \d+ \d+$`)
// Processor handles command execution and coordinates between service and engine layers
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. The initial FEN is classified BEFORE
// persisting: a terminal initial position is terminal in the creation response,
// and engine failure fails the request instead of creating a half-valid game.
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 < minSearchTime {
args.White.SearchTime = minSearchTime
}
if args.Black.Type == core.PlayerComputer && args.Black.SearchTime < minSearchTime {
args.Black.SearchTime = minSearchTime
}
// Check computer game limit
hasComputer := args.White.Type == core.PlayerComputer || args.Black.Type == core.PlayerComputer
if hasComputer && !p.svc.CanCreateComputerGame() {
return p.errorResponse(
fmt.Sprintf("computer game limit reached (%d/%d)", p.svc.GetComputerGameCount(), service.MaxComputerGames),
core.ErrResourceLimit,
)
}
gameID := p.svc.GenerateGameID()
// Validate FEN safety, then classify via engine
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()
err := p.validationEng.NewGame()
var validatedFEN string
initialState := core.StateOngoing
if err == nil {
validatedFEN, initialState, err = p.classifyLocked(initialFEN)
}
p.mu.Unlock()
if err != nil {
return p.errorResponse(fmt.Sprintf("engine validation failed: %v", err), core.ErrInternalError)
}
// Parse canonical FEN 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 players with appropriate IDs
whitePlayer := core.NewPlayer(args.White, core.ColorWhite)
blackPlayer := core.NewPlayer(args.Black, core.ColorBlack)
// Only assign authenticated user to ONE human slot.
// If both are human, authenticated user gets white; black remains unclaimed.
if cmd.UserID != "" {
if args.White.Type == core.PlayerHuman {
whitePlayer.ID = cmd.UserID
whitePlayer.ClaimedBy = cmd.UserID
} else if args.Black.Type == core.PlayerHuman {
blackPlayer.ID = cmd.UserID
blackPlayer.ClaimedBy = cmd.UserID
}
}
if err = p.svc.CreateGame(gameID, whitePlayer, blackPlayer, validatedFEN, b.Turn()); err != nil {
return p.errorResponse(fmt.Sprintf("failed to create game: %v", err), core.ErrInternalError)
}
if initialState != core.StateOngoing {
p.svc.UpdateGameState(gameID, initialState)
}
g, err := p.svc.GetGame(gameID)
if err != nil {
return p.errorResponse("game creation failed", core.ErrInternalError)
}
return ProcessorResponse{
Success: true,
Data: p.buildGameResponse(gameID, g),
}
}
// 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)
}
// Create new player instances
whitePlayer := core.NewPlayer(args.White, core.ColorWhite)
blackPlayer := core.NewPlayer(args.Black, core.ColorBlack)
// Update players in service
if err = p.svc.UpdatePlayers(cmd.GameID, whitePlayer, blackPlayer); 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 with authorization, and the "cccc"
// computer-move trigger. Post-move classification runs BEFORE the move is
// applied; move + final state + metadata commit atomically with one
// notification, so a waking long-poller can never observe "ongoing" on a
// terminal position.
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)
}
currentColor := g.NextTurnColor()
currentPlayer := g.NextPlayer()
// Handle computer move trigger
if strings.TrimSpace(args.Move) == "cccc" {
if currentPlayer.Type != core.PlayerComputer {
return p.errorResponse("not computer player's turn", core.ErrNotHumanTurn)
}
p.svc.UpdateGameState(cmd.GameID, core.StatePending)
p.triggerComputerMove(cmd.GameID, g)
g, _ = p.svc.GetGame(cmd.GameID)
response := p.buildGameResponse(cmd.GameID, g)
response.LastMove = &core.MoveInfo{
PlayerColor: currentColor.String(),
}
return ProcessorResponse{
Success: true,
Pending: true,
Data: response,
}
}
// Human move - validate authorization
if currentPlayer.Type != core.PlayerHuman {
return p.errorResponse("not human player's turn", core.ErrNotHumanTurn)
}
// Authorization: first-move-claims-slot model
slotOwner := g.GetSlotOwner(currentColor)
if slotOwner == "" {
// Slot unclaimed - claim it with this move
if cmd.UserID != "" {
if err := p.svc.ClaimGameSlot(cmd.GameID, currentColor, cmd.UserID); err != nil {
return p.errorResponse(fmt.Sprintf("failed to claim slot: %v", err), core.ErrInternalError)
}
}
// Anonymous users can also claim by making a move (slot remains "unclaimed" but move proceeds)
} else if cmd.UserID != "" && slotOwner != cmd.UserID {
return p.errorResponse("not your turn - slot claimed by another player", core.ErrUnauthorized)
}
if slotOwner != "" && cmd.UserID == "" {
return p.errorResponse("slot claimed - authentication required", core.ErrUnauthorized)
}
// 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()
// Validate move and classify the resulting position in one engine session
p.mu.Lock()
err = p.validationEng.SetPosition(currentFEN, []string{move})
var newFEN string
finalState := core.StateOngoing
if err == nil {
newFEN, finalState, err = p.classifyCurrentLocked()
}
p.mu.Unlock()
if err != nil {
// Game untouched at pre-move position; retry runs on a respawned engine
return p.errorResponse("engine unavailable", core.ErrInternalError)
}
if newFEN == currentFEN {
return p.errorResponse("illegal move", core.ErrInvalidMove)
}
// Atomic commit: move + state + metadata, single notification
if err = p.svc.ApplyMoveWithState(cmd.GameID, move, newFEN, finalState, &game.MoveResult{
Move: move,
PlayerColor: currentColor,
GameState: finalState,
}); err != nil {
return p.errorResponse(fmt.Sprintf("failed to apply move: %v", err), core.ErrInternalError)
}
// buildGameResponse populates LastMove from the committed LastResult
g, _ = p.svc.GetGame(cmd.GameID)
return ProcessorResponse{
Success: true,
Data: p.buildGameResponse(cmd.GameID, g),
}
}
// handleUndoMove reverts game state. StateStuck is deliberately permitted:
// undo -> StateOngoing is the recovery path for engine failures. Terminal
// states are also permitted so a finished game can be rewound. Any reverted-to
// snapshot had legal moves made from it, so resetting to Ongoing is sound
// without re-classification.
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)
}
if g.State() == core.StatePending {
return p.errorResponse("cannot undo while computer move is in progress", 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)
return ProcessorResponse{
Success: true,
Data: p.buildGameResponse(cmd.GameID, g),
}
}
// 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)
}
// 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. The callback
// re-classifies via the validation engine: worker output is never trusted for
// end-state determination, and no-move results are verified against the
// position rather than the IsMate info-line byproduct.
func (p *Processor) triggerComputerMove(gameID string, g *game.Game) {
fen := g.CurrentFEN()
color := g.NextTurnColor()
player := g.NextPlayer()
p.queue.SubmitAsync(gameID, fen, color, player, func(result EngineResult) {
currentGame, err := p.svc.GetGame(gameID)
if err != nil || currentGame.State() != core.StatePending {
return // Deleted, or state resolved elsewhere
}
if result.Error != nil {
log.Printf("engine error for game %s: %v", gameID, result.Error)
p.svc.UpdateGameState(gameID, core.StateStuck)
return
}
if result.Move == "" || result.Move == "(none)" {
// Worker says no legal moves; verify against the validation engine.
p.mu.Lock()
_, state, cerr := p.classifyLocked(fen)
p.mu.Unlock()
if cerr != nil || state == core.StateOngoing {
p.svc.UpdateGameState(gameID, core.StateStuck) // engines disagree
return
}
p.svc.UpdateGameState(gameID, state)
return
}
p.mu.Lock()
aerr := p.validationEng.SetPosition(fen, []string{result.Move})
var newFEN string
finalState := core.StateOngoing
if aerr == nil {
newFEN, finalState, aerr = p.classifyCurrentLocked()
}
p.mu.Unlock()
if aerr != nil || newFEN == fen {
p.svc.UpdateGameState(gameID, core.StateStuck)
return
}
p.svc.ApplyMoveWithState(gameID, result.Move, newFEN, finalState, &game.MoveResult{
Move: result.Move, PlayerColor: color,
Score: result.Score, Depth: result.Depth, GameState: finalState,
})
})
}
// 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
}
// classifyCurrentLocked classifies whatever position is loaded in the
// validation engine. Caller holds p.mu, immediately after a SetPosition.
func (p *Processor) classifyCurrentLocked() (fen string, state core.State, err error) {
diag, err := p.validationEng.Diagnose()
if err != nil {
return "", core.StateOngoing, err
}
legal, err := p.validationEng.HasLegalMoves()
if err != nil {
return "", core.StateOngoing, err
}
if legal {
return diag.FEN, core.StateOngoing, nil
}
if !diag.InCheck {
return diag.FEN, core.StateStalemate, nil
}
b, err := board.ParseFEN(diag.FEN)
if err != nil {
return "", core.StateOngoing, err
}
if b.Turn() == core.ColorWhite {
return diag.FEN, core.StateBlackWins, nil
}
return diag.FEN, core.StateWhiteWins, nil
}
// classifyLocked sets a position from fen and classifies it. Caller holds p.mu.
func (p *Processor) classifyLocked(fen string) (string, core.State, error) {
if err := p.validationEng.SetPosition(fen, nil); err != nil {
return "", core.StateOngoing, err
}
return p.classifyCurrentLocked()
}
// checkGameEnd: retry once (second attempt runs on a respawned process), then
// fail SAFE to StateStuck. Leaving a possibly-terminal position Ongoing is the
// original bug class; Stuck is now recoverable via undo (see handleUndoMove).
func (p *Processor) checkGameEnd(gameID, fen string) {
for attempt := 0; attempt < 2; attempt++ {
p.mu.Lock()
_, state, err := p.classifyLocked(fen)
p.mu.Unlock()
if err == nil {
if state != core.StateOngoing {
p.svc.UpdateGameState(gameID, state)
}
return
}
log.Printf("game %s: end-state check attempt %d failed: %v", gameID, attempt+1, err)
}
p.svc.UpdateGameState(gameID, core.StateStuck)
}
// 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()
}