989 lines
32 KiB
Go
989 lines
32 KiB
Go
package processor
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode"
|
|
|
|
"github.com/lixenwraith/chess/internal/server/chess"
|
|
"github.com/lixenwraith/chess/internal/server/core"
|
|
"github.com/lixenwraith/chess/internal/server/engine"
|
|
"github.com/lixenwraith/chess/internal/server/game"
|
|
"github.com/lixenwraith/chess/internal/server/service"
|
|
)
|
|
|
|
const (
|
|
minSearchTime = 100
|
|
|
|
// A computer answers a draw offer only once both sides have made ten
|
|
// moves, and accepts when its evaluation, from its own side, is level or
|
|
// worse. The evaluation is a short search on the validation engine; the
|
|
// one-offer-per-move allowance bounds how often it runs.
|
|
drawOfferMinPlies = 20
|
|
drawEvalTimeMs = 100
|
|
drawAcceptMaxScore = 0 // centipawns
|
|
)
|
|
|
|
// 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)
|
|
case CmdResign:
|
|
return p.handleResign(cmd)
|
|
case CmdDraw:
|
|
return p.handleDraw(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 := chess.StartFEN
|
|
if args.FEN != "" {
|
|
if !p.isFENSafe(args.FEN) {
|
|
return p.errorResponse("invalid FEN format or characters", core.ErrInvalidFEN)
|
|
}
|
|
// The rules core rejects positions the engine must never see (missing
|
|
// kings, the side not to move in check) and drops castling rights
|
|
// without their rook, so both agree on the legal moves.
|
|
pos, err := chess.ParseFEN(args.FEN)
|
|
if err != nil {
|
|
return p.errorResponse(err.Error(), core.ErrInvalidFEN)
|
|
}
|
|
initialFEN = pos.FEN()
|
|
}
|
|
|
|
p.mu.Lock()
|
|
err := p.validationEng.NewGame()
|
|
var validatedFEN string
|
|
initialState, termination := core.StateOngoing, core.TermNone
|
|
if err == nil {
|
|
validatedFEN, initialState, termination, err = p.classifyLocked(initialFEN)
|
|
}
|
|
p.mu.Unlock()
|
|
if err != nil {
|
|
return p.errorResponse(fmt.Sprintf("engine validation failed: %v", err), core.ErrInternalError)
|
|
}
|
|
if initialState == core.StateOngoing {
|
|
initialState, termination = adjudicateDraw(nil, validatedFEN)
|
|
}
|
|
|
|
// Parse canonical FEN to get starting turn
|
|
start, err := chess.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,
|
|
coreColor(start.Turn()), initialState, termination); err != nil {
|
|
return p.errorResponse(fmt.Sprintf("failed to create game: %v", err), core.ErrInternalError)
|
|
}
|
|
|
|
g, err := p.svc.GetGameView(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; another
|
|
// user's claim refuses it.
|
|
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.GetGameView(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, cmd.UserID); err != nil {
|
|
switch {
|
|
case errors.Is(err, service.ErrGameNotFound):
|
|
return p.endGameError(err)
|
|
case errors.Is(err, service.ErrSlotOwner):
|
|
return p.errorResponse("another player has claimed a side of this game; players cannot be changed", core.ErrUnauthorized)
|
|
}
|
|
return p.errorResponse(fmt.Sprintf("failed to update players: %v", err), core.ErrInternalError)
|
|
}
|
|
|
|
// Get updated game
|
|
g, _ = p.svc.GetGameView(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.GetGameView(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.GetGameView(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()
|
|
if currentPlayer == nil {
|
|
return p.errorResponse("current player is missing", core.ErrInternalError)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
if err := p.svc.BeginComputerMove(cmd.GameID, g.FEN, currentColor); err != nil {
|
|
if errors.Is(err, service.ErrGameChanged) {
|
|
return p.errorResponse("game changed; refresh and retry", core.ErrConflict)
|
|
}
|
|
return p.errorResponse(fmt.Sprintf("failed to start computer move: %v", err), core.ErrInternalError)
|
|
}
|
|
if err := p.triggerComputerMove(cmd.GameID, g); err != nil {
|
|
p.svc.UpdateGameState(cmd.GameID, core.StateStuck, core.TermNone)
|
|
return p.errorResponse(fmt.Sprintf("failed to queue computer move: %v", err), core.ErrResourceLimit)
|
|
}
|
|
|
|
g, _ = p.svc.GetGameView(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 := currentPlayer.ClaimedBy
|
|
|
|
if slotOwner == "" {
|
|
// An authenticated user claims only when the validated move commits.
|
|
// Anonymous moves deliberately leave the slot unclaimed.
|
|
} 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.FEN
|
|
|
|
// A pawn move to the last rank needs its promotion piece; say so instead
|
|
// of the engine's generic rejection.
|
|
rules := rulesCheck(currentFEN, move)
|
|
if errors.Is(rules.err, chess.ErrPromotionRequired) {
|
|
return p.errorResponse("promotion piece required: append q, r, b, or n (e.g. "+move+"q)", core.ErrInvalidMove)
|
|
}
|
|
|
|
// 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, termination := core.StateOngoing, core.TermNone
|
|
if err == nil {
|
|
newFEN, finalState, termination, 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)
|
|
}
|
|
rules.compare(cmd.GameID, move, newFEN != currentFEN, newFEN)
|
|
if newFEN == currentFEN {
|
|
return p.errorResponse("illegal move", core.ErrInvalidMove)
|
|
}
|
|
if finalState == core.StateOngoing {
|
|
finalState, termination = adjudicateDraw(g.FENs, newFEN)
|
|
}
|
|
|
|
// Atomic commit: move + state + metadata, single notification
|
|
if err = p.svc.ApplyMoveWithState(cmd.GameID, service.MoveCommit{
|
|
ExpectedFEN: currentFEN, ExpectedState: core.StateOngoing, ExpectedTurn: currentColor,
|
|
ActorUserID: cmd.UserID, MoveUCI: move, NewFEN: newFEN, State: finalState, Termination: termination,
|
|
Result: &game.MoveResult{Move: move, PlayerColor: currentColor, GameState: finalState},
|
|
}); err != nil {
|
|
if errors.Is(err, service.ErrSlotOwner) {
|
|
return p.errorResponse("not your turn - slot claimed by another player", core.ErrUnauthorized)
|
|
}
|
|
if errors.Is(err, service.ErrGameChanged) {
|
|
return p.errorResponse("game changed while move was being validated; refresh and retry", core.ErrConflict)
|
|
}
|
|
return p.errorResponse(fmt.Sprintf("failed to apply move: %v", err), core.ErrInternalError)
|
|
}
|
|
|
|
// buildGameResponse populates LastMove from the committed LastResult
|
|
g, _ = p.svc.GetGameView(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, except a
|
|
// resignation or agreed draw between two humans; another user's claim refuses
|
|
// an undo. 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.GetGameView(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, cmd.UserID); err != nil {
|
|
switch {
|
|
case errors.Is(err, service.ErrGameNotFound), errors.Is(err, service.ErrConcessionFinal):
|
|
return p.endGameError(err)
|
|
case errors.Is(err, service.ErrSlotOwner):
|
|
return p.errorResponse("another player has claimed a side of this game; moves cannot be taken back", core.ErrUnauthorized)
|
|
}
|
|
return p.errorResponse(err.Error(), core.ErrInvalidRequest)
|
|
}
|
|
|
|
g, _ = p.svc.GetGameView(cmd.GameID)
|
|
return ProcessorResponse{
|
|
Success: true,
|
|
Data: p.buildGameResponse(cmd.GameID, g),
|
|
}
|
|
}
|
|
|
|
// handleDeleteGame unloads a game from live memory for one of its claimants,
|
|
// or anyone when it is unclaimed.
|
|
func (p *Processor) handleDeleteGame(cmd Command) ProcessorResponse {
|
|
g, err := p.svc.GetGameView(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.UnloadGame(cmd.GameID, cmd.UserID); err != nil {
|
|
switch {
|
|
case errors.Is(err, service.ErrGameNotFound):
|
|
return p.endGameError(err)
|
|
case errors.Is(err, service.ErrSlotOwner):
|
|
return p.errorResponse("only a player who claimed a side can unload this game", core.ErrUnauthorized)
|
|
}
|
|
return p.errorResponse(err.Error(), core.ErrInvalidRequest)
|
|
}
|
|
|
|
return ProcessorResponse{
|
|
Success: true,
|
|
}
|
|
}
|
|
|
|
// handleGetBoard returns board visualization
|
|
func (p *Processor) handleGetBoard(cmd Command) ProcessorResponse {
|
|
g, err := p.svc.GetGameView(cmd.GameID)
|
|
if err != nil {
|
|
return p.errorResponse("game not found", core.ErrGameNotFound)
|
|
}
|
|
|
|
pos, err := chess.ParseFEN(g.FEN)
|
|
if err != nil {
|
|
return p.errorResponse("error parsing FEN", core.ErrInvalidFEN)
|
|
}
|
|
ascii := pos.ASCII()
|
|
|
|
return ProcessorResponse{
|
|
Success: true,
|
|
Data: core.BoardResponse{
|
|
FEN: g.FEN,
|
|
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.View) error {
|
|
fen := g.FEN
|
|
color := g.NextTurnColor
|
|
player := g.NextPlayer()
|
|
|
|
return p.queue.SubmitAsync(gameID, fen, color, player, func(result EngineResult) {
|
|
currentGame, err := p.svc.GetGameView(gameID)
|
|
if err != nil || currentGame.State != core.StatePending || currentGame.FEN != fen {
|
|
return // Deleted, or state resolved elsewhere
|
|
}
|
|
if result.Error != nil {
|
|
slog.Error("computer engine failed", "game_id", gameID, "error", result.Error)
|
|
p.svc.UpdateGameState(gameID, core.StateStuck, core.TermNone)
|
|
return
|
|
}
|
|
if result.Move == "" || result.Move == "(none)" {
|
|
// Worker says no legal moves; verify against the validation engine.
|
|
p.mu.Lock()
|
|
_, state, termination, cerr := p.classifyLocked(fen)
|
|
p.mu.Unlock()
|
|
if cerr != nil || state == core.StateOngoing {
|
|
p.svc.UpdateGameState(gameID, core.StateStuck, core.TermNone) // engines disagree
|
|
return
|
|
}
|
|
p.svc.UpdateGameState(gameID, state, termination)
|
|
return
|
|
}
|
|
|
|
p.mu.Lock()
|
|
aerr := p.validationEng.SetPosition(fen, []string{result.Move})
|
|
var newFEN string
|
|
finalState, termination := core.StateOngoing, core.TermNone
|
|
if aerr == nil {
|
|
newFEN, finalState, termination, aerr = p.classifyCurrentLocked()
|
|
}
|
|
p.mu.Unlock()
|
|
if aerr != nil || newFEN == fen {
|
|
p.svc.UpdateGameState(gameID, core.StateStuck, core.TermNone)
|
|
return
|
|
}
|
|
rulesCheck(fen, result.Move).compare(gameID, result.Move, true, newFEN)
|
|
if finalState == core.StateOngoing {
|
|
finalState, termination = adjudicateDraw(currentGame.FENs, newFEN)
|
|
}
|
|
|
|
if err := p.svc.ApplyMoveWithState(gameID, service.MoveCommit{
|
|
ExpectedFEN: fen, ExpectedState: core.StatePending, ExpectedTurn: color,
|
|
MoveUCI: result.Move, NewFEN: newFEN, State: finalState, Termination: termination,
|
|
Result: &game.MoveResult{
|
|
Move: result.Move, PlayerColor: color,
|
|
Score: result.Score, Depth: result.Depth, GameState: finalState,
|
|
},
|
|
}); err != nil {
|
|
slog.Error("failed to apply computer move", "game_id", gameID, "error", err)
|
|
if !errors.Is(err, service.ErrGameChanged) && !errors.Is(err, service.ErrGameNotFound) {
|
|
p.svc.UpdateGameState(gameID, core.StateStuck, core.TermNone)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// classifyCurrentLocked classifies whatever position is loaded in the
|
|
// validation engine: mate and stalemate come from the engine. Caller holds
|
|
// p.mu, immediately after a SetPosition.
|
|
func (p *Processor) classifyCurrentLocked() (fen string, state core.State, termination core.Termination, err error) {
|
|
diag, err := p.validationEng.Diagnose()
|
|
if err != nil {
|
|
return "", core.StateOngoing, core.TermNone, err
|
|
}
|
|
legal, err := p.validationEng.HasLegalMoves()
|
|
if err != nil {
|
|
return "", core.StateOngoing, core.TermNone, err
|
|
}
|
|
if legal {
|
|
return diag.FEN, core.StateOngoing, core.TermNone, nil
|
|
}
|
|
if !diag.InCheck {
|
|
return diag.FEN, core.StateStalemate, core.TermStalemate, nil
|
|
}
|
|
pos, err := chess.ParseFEN(diag.FEN)
|
|
if err != nil {
|
|
return "", core.StateOngoing, core.TermNone, err
|
|
}
|
|
if pos.Turn() == chess.White {
|
|
return diag.FEN, core.StateBlackWins, core.TermCheckmate, nil
|
|
}
|
|
return diag.FEN, core.StateWhiteWins, core.TermCheckmate, nil
|
|
}
|
|
|
|
// classifyLocked sets a position from fen and classifies it. Caller holds p.mu.
|
|
func (p *Processor) classifyLocked(fen string) (string, core.State, core.Termination, error) {
|
|
if err := p.validationEng.SetPosition(fen, nil); err != nil {
|
|
return "", core.StateOngoing, core.TermNone, err
|
|
}
|
|
return p.classifyCurrentLocked()
|
|
}
|
|
|
|
// adjudicateDraw applies the draw rules that end a game without a claim, as
|
|
// most online servers do: dead material, threefold repetition, and the
|
|
// fifty-move rule (a claimable draw under FIDE rules). history holds the
|
|
// positions before fen, oldest first. It returns StateOngoing when no rule
|
|
// applies. The engine has already ruled out mate and stalemate, which take
|
|
// precedence.
|
|
func adjudicateDraw(history []string, fen string) (core.State, core.Termination) {
|
|
pos, err := chess.ParseFEN(fen)
|
|
if err != nil {
|
|
return core.StateOngoing, core.TermNone
|
|
}
|
|
if pos.InsufficientMaterial() {
|
|
return core.StateDraw, core.TermInsufficientMaterial
|
|
}
|
|
// Positions before the last capture or pawn move cannot repeat.
|
|
window := min(len(history), pos.Halfmove())
|
|
line := make([]*chess.Position, 0, window+1)
|
|
for _, prior := range history[len(history)-window:] {
|
|
prev, err := chess.ParseFEN(prior)
|
|
if err != nil {
|
|
line = line[:0] // a gap cannot be bridged; count from here on
|
|
continue
|
|
}
|
|
line = append(line, prev)
|
|
}
|
|
if chess.Repetitions(append(line, pos)) >= 3 {
|
|
return core.StateDraw, core.TermThreefoldRepetition
|
|
}
|
|
if pos.Halfmove() >= 100 {
|
|
return core.StateDraw, core.TermFiftyMoveRule
|
|
}
|
|
return core.StateOngoing, core.TermNone
|
|
}
|
|
|
|
func coreColor(c chess.Color) core.Color {
|
|
if c == chess.White {
|
|
return core.ColorWhite
|
|
}
|
|
return core.ColorBlack
|
|
}
|
|
|
|
// buildGameResponse constructs standard game response
|
|
func (p *Processor) buildGameResponse(gameID string, g game.View) core.GameResponse {
|
|
resp := core.GameResponse{
|
|
GameID: gameID,
|
|
FEN: g.FEN,
|
|
Turn: g.NextTurnColor.String(),
|
|
State: g.State.String(),
|
|
Termination: string(g.Termination),
|
|
Moves: g.Moves,
|
|
Players: core.PlayersResponse{
|
|
White: g.WhitePlayer,
|
|
Black: g.BlackPlayer,
|
|
},
|
|
}
|
|
if g.DrawOffer != 0 {
|
|
resp.DrawOffer = g.DrawOffer.String()
|
|
}
|
|
if c := g.Concession; c != nil {
|
|
resp.Concession = &core.Concession{
|
|
Result: c.State.String(), Termination: string(c.Termination), Ply: c.Ply,
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
queueErr := p.queue.Shutdown(5 * time.Second)
|
|
p.mu.Lock()
|
|
engineErr := p.validationEng.Close()
|
|
p.mu.Unlock()
|
|
return errors.Join(queueErr, engineErr)
|
|
}
|
|
|
|
// rulesResult is the rules core's verdict on a move, computed alongside the
|
|
// engine's. Stockfish stays authoritative; a disagreement is logged so the
|
|
// core can be trusted, or fixed, before it validates anything.
|
|
type rulesResult struct {
|
|
fen string // normalized FEN after the move; empty when illegal
|
|
err error
|
|
}
|
|
|
|
func rulesCheck(fen, uci string) rulesResult {
|
|
pos, err := chess.ParseFEN(fen)
|
|
if err != nil {
|
|
return rulesResult{err: err}
|
|
}
|
|
m, err := pos.ParseUCI(uci)
|
|
if err != nil {
|
|
return rulesResult{err: err}
|
|
}
|
|
return rulesResult{fen: pos.Play(m).FEN()}
|
|
}
|
|
|
|
func (r rulesResult) compare(gameID, uci string, engineLegal bool, engineFEN string) {
|
|
if errors.Is(r.err, chess.ErrInvalidFEN) {
|
|
slog.Warn("rules core cannot parse game position", "game_id", gameID, "error", r.err)
|
|
return
|
|
}
|
|
if engineLegal != (r.err == nil) {
|
|
slog.Warn("rules core and engine disagree on legality",
|
|
"game_id", gameID, "move", uci, "engine_legal", engineLegal, "rules_error", r.err)
|
|
return
|
|
}
|
|
if !engineLegal {
|
|
return
|
|
}
|
|
if normalized, err := chess.NormalizeFEN(engineFEN); err != nil || normalized != r.fen {
|
|
slog.Warn("rules core and engine disagree on resulting position",
|
|
"game_id", gameID, "move", uci, "engine_fen", engineFEN, "rules_fen", r.fen)
|
|
}
|
|
}
|
|
|
|
// handleResign ends the game in the opponent's favor. It is allowed while the
|
|
// computer is thinking or the engine is stuck: resigning never needs one.
|
|
func (p *Processor) handleResign(cmd Command) ProcessorResponse {
|
|
args, ok := cmd.Args.(core.ResignRequest)
|
|
if !ok {
|
|
return p.errorResponse("invalid arguments", core.ErrInvalidRequest)
|
|
}
|
|
g, err := p.svc.GetGameView(cmd.GameID)
|
|
if err != nil {
|
|
return p.errorResponse("game not found", core.ErrGameNotFound)
|
|
}
|
|
if g.State.IsTerminal() {
|
|
return p.errorResponse(fmt.Sprintf("game is over: %s", g.State), core.ErrGameOver)
|
|
}
|
|
color, failure := actingColor(g, args.Color, cmd.UserID)
|
|
if failure != nil {
|
|
return *failure
|
|
}
|
|
winner := core.StateWhiteWins
|
|
if color == core.ColorWhite {
|
|
winner = core.StateBlackWins
|
|
}
|
|
err = p.svc.EndGame(cmd.GameID, service.EndCommit{
|
|
ExpectedFEN: g.FEN, Color: color, ActorUserID: cmd.UserID,
|
|
State: winner, Termination: core.TermResignation,
|
|
})
|
|
if err != nil {
|
|
return p.endGameError(err)
|
|
}
|
|
g, _ = p.svc.GetGameView(cmd.GameID)
|
|
return ProcessorResponse{Success: true, Data: p.buildGameResponse(cmd.GameID, g)}
|
|
}
|
|
|
|
// handleDraw offers, accepts, or declines a draw by agreement. An offer to a
|
|
// human stands until answered, or until the opponent moves instead; an offer
|
|
// to a computer is answered at once. Offering while the opponent's offer
|
|
// stands accepts it.
|
|
func (p *Processor) handleDraw(cmd Command) ProcessorResponse {
|
|
args, ok := cmd.Args.(core.DrawRequest)
|
|
if !ok {
|
|
return p.errorResponse("invalid arguments", core.ErrInvalidRequest)
|
|
}
|
|
g, err := p.svc.GetGameView(cmd.GameID)
|
|
if err != nil {
|
|
return p.errorResponse("game not found", core.ErrGameNotFound)
|
|
}
|
|
switch {
|
|
case g.State.IsTerminal():
|
|
return p.errorResponse(fmt.Sprintf("game is over: %s", g.State), core.ErrGameOver)
|
|
case g.State != core.StateOngoing:
|
|
return p.errorResponse("draws can be agreed only while no computer move is running", core.ErrInvalidRequest)
|
|
}
|
|
color, failure := actingColor(g, args.Color, cmd.UserID)
|
|
if failure != nil {
|
|
return *failure
|
|
}
|
|
opponent := core.OppositeColor(color)
|
|
|
|
accept := func() ProcessorResponse {
|
|
err := p.svc.EndGame(cmd.GameID, service.EndCommit{
|
|
ExpectedFEN: g.FEN, Color: color, ActorUserID: cmd.UserID,
|
|
State: core.StateDraw, Termination: core.TermAgreement, OfferFrom: opponent,
|
|
})
|
|
if err != nil {
|
|
return p.endGameError(err)
|
|
}
|
|
return p.drawResponse(cmd.GameID, "accepted")
|
|
}
|
|
|
|
switch args.Action {
|
|
case "accept":
|
|
if g.DrawOffer != opponent {
|
|
return p.errorResponse("no draw offer from the opponent", core.ErrInvalidRequest)
|
|
}
|
|
return accept()
|
|
case "decline":
|
|
if err := p.svc.DeclineDraw(cmd.GameID, color, cmd.UserID); err != nil {
|
|
return p.endGameError(err)
|
|
}
|
|
return p.drawResponse(cmd.GameID, "declined")
|
|
}
|
|
|
|
// Offer.
|
|
switch {
|
|
case g.DrawOffer == opponent:
|
|
return accept()
|
|
case g.DrawOffer == color:
|
|
return p.drawResponse(cmd.GameID, "offered")
|
|
}
|
|
if last := g.OfferPly[color]; last >= 0 && len(g.Moves) < last+2 {
|
|
return p.errorResponse(service.ErrOfferLimit.Error(), core.ErrConflict)
|
|
}
|
|
if player := g.Player(opponent); player != nil && player.Type == core.PlayerComputer {
|
|
agrees := false
|
|
if len(g.Moves) >= drawOfferMinPlies {
|
|
if agrees, err = p.computerAcceptsDraw(g.FEN, opponent); err != nil {
|
|
return p.errorResponse("engine unavailable", core.ErrInternalError)
|
|
}
|
|
}
|
|
if agrees {
|
|
err = p.svc.EndGame(cmd.GameID, service.EndCommit{
|
|
ExpectedFEN: g.FEN, Color: color, ActorUserID: cmd.UserID,
|
|
State: core.StateDraw, Termination: core.TermAgreement,
|
|
})
|
|
if err != nil {
|
|
return p.endGameError(err)
|
|
}
|
|
return p.drawResponse(cmd.GameID, "accepted")
|
|
}
|
|
if err := p.svc.RecordDeclinedOffer(cmd.GameID, color, cmd.UserID, g.FEN); err != nil {
|
|
return p.endGameError(err)
|
|
}
|
|
return p.drawResponse(cmd.GameID, "declined")
|
|
}
|
|
if err := p.svc.OfferDraw(cmd.GameID, color, cmd.UserID, g.FEN); err != nil {
|
|
return p.endGameError(err)
|
|
}
|
|
return p.drawResponse(cmd.GameID, "offered")
|
|
}
|
|
|
|
func (p *Processor) drawResponse(gameID, outcome string) ProcessorResponse {
|
|
g, err := p.svc.GetGameView(gameID)
|
|
if err != nil {
|
|
return p.errorResponse("game not found", core.ErrGameNotFound)
|
|
}
|
|
response := p.buildGameResponse(gameID, g)
|
|
response.DrawOutcome = outcome
|
|
return ProcessorResponse{Success: true, Data: response}
|
|
}
|
|
|
|
// computerAcceptsDraw evaluates fen for the computer playing color with a
|
|
// short full-strength search. UCI scores are from the side to move.
|
|
func (p *Processor) computerAcceptsDraw(fen string, computer core.Color) (bool, error) {
|
|
pos, err := chess.ParseFEN(fen)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
if err := p.validationEng.SetPosition(fen, nil); err != nil {
|
|
return false, err
|
|
}
|
|
result, err := p.validationEng.Search(drawEvalTimeMs)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
score := result.Score
|
|
if coreColor(pos.Turn()) != computer {
|
|
score = -score
|
|
}
|
|
return score <= drawAcceptMaxScore, nil
|
|
}
|
|
|
|
// actingColor resolves which side a resignation or draw request acts for: the
|
|
// requested color, else the one side the caller claimed, else the only human
|
|
// side. It enforces slot ownership: a claimed side acts only for its claimant.
|
|
func actingColor(g game.View, requested, userID string) (core.Color, *ProcessorResponse) {
|
|
fail := func(message, code string) (core.Color, *ProcessorResponse) {
|
|
return 0, &ProcessorResponse{Error: &core.ErrorResponse{Error: message, Code: code}}
|
|
}
|
|
var color core.Color
|
|
switch {
|
|
case requested != "":
|
|
color = core.ParseColor(requested)
|
|
default:
|
|
var claimed, humans []core.Color
|
|
for _, c := range []core.Color{core.ColorWhite, core.ColorBlack} {
|
|
player := g.Player(c)
|
|
if player == nil || player.Type != core.PlayerHuman {
|
|
continue
|
|
}
|
|
humans = append(humans, c)
|
|
if userID != "" && player.ClaimedBy == userID {
|
|
claimed = append(claimed, c)
|
|
}
|
|
}
|
|
switch {
|
|
case len(claimed) == 1:
|
|
color = claimed[0]
|
|
case len(claimed) == 0 && len(humans) == 1:
|
|
color = humans[0]
|
|
case len(humans) == 0:
|
|
return fail("both sides are played by the computer", core.ErrInvalidRequest)
|
|
default:
|
|
return fail("color required: both sides are human", core.ErrInvalidRequest)
|
|
}
|
|
}
|
|
player := g.Player(color)
|
|
if player == nil {
|
|
return fail("invalid color", core.ErrInvalidRequest)
|
|
}
|
|
if player.Type != core.PlayerHuman {
|
|
return fail(service.ErrNotHuman.Error(), core.ErrInvalidRequest)
|
|
}
|
|
if player.ClaimedBy != "" && player.ClaimedBy != userID {
|
|
if userID == "" {
|
|
return fail("slot claimed - authentication required", core.ErrUnauthorized)
|
|
}
|
|
return fail("slot claimed by another player", core.ErrUnauthorized)
|
|
}
|
|
return color, nil
|
|
}
|
|
|
|
// endGameError maps a service refusal to a response; the service re-checks
|
|
// under its lock what the processor checked on a snapshot.
|
|
func (p *Processor) endGameError(err error) ProcessorResponse {
|
|
switch {
|
|
case errors.Is(err, service.ErrGameNotFound):
|
|
return p.errorResponse("game not found", core.ErrGameNotFound)
|
|
case errors.Is(err, service.ErrGameOver):
|
|
return p.errorResponse("game is over", core.ErrGameOver)
|
|
case errors.Is(err, service.ErrSlotOwner):
|
|
return p.errorResponse("slot claimed by another player", core.ErrUnauthorized)
|
|
case errors.Is(err, service.ErrNotHuman):
|
|
return p.errorResponse(err.Error(), core.ErrInvalidRequest)
|
|
case errors.Is(err, service.ErrNoDrawOffer):
|
|
return p.errorResponse("no draw offer from the opponent", core.ErrInvalidRequest)
|
|
case errors.Is(err, service.ErrOfferLimit):
|
|
return p.errorResponse(err.Error(), core.ErrConflict)
|
|
case errors.Is(err, service.ErrGameChanged):
|
|
return p.errorResponse("game changed; refresh and retry", core.ErrConflict)
|
|
case errors.Is(err, service.ErrConcessionFinal):
|
|
return p.errorResponse(err.Error(), core.ErrGameOver)
|
|
}
|
|
return p.errorResponse(fmt.Sprintf("failed to update game: %v", err), core.ErrInternalError)
|
|
}
|