v0.13.0 web and terminal client enhance, replay api, linux server deploy, doc and dependency update
test / test (push) Canceled after 0s
test / test (push) Canceled after 0s
This commit is contained in:
1 parent
52ac0fe31a
commit
f1ac4cbbe7
71 files changed
+7677
-537
No files matched your search
@@ -15,6 +15,8 @@ const (
|
||||
CmdMakeMove
|
||||
CmdUndoMove
|
||||
CmdGetBoard
|
||||
CmdResign
|
||||
CmdDraw
|
||||
)
|
||||
|
||||
// Command is a unified structure for all processor operations
|
||||
@@ -84,3 +86,19 @@ func NewGetBoardCommand(gameID string) Command {
|
||||
GameID: gameID,
|
||||
}
|
||||
}
|
||||
|
||||
func NewResignCommand(gameID string, req core.ResignRequest) Command {
|
||||
return Command{
|
||||
Type: CmdResign,
|
||||
GameID: gameID,
|
||||
Args: req,
|
||||
}
|
||||
}
|
||||
|
||||
func NewDrawCommand(gameID string, req core.DrawRequest) Command {
|
||||
return Command{
|
||||
Type: CmdDraw,
|
||||
GameID: gameID,
|
||||
Args: req,
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"chess/internal/server/board"
|
||||
"chess/internal/server/chess"
|
||||
"chess/internal/server/core"
|
||||
"chess/internal/server/engine"
|
||||
"chess/internal/server/game"
|
||||
@@ -19,6 +19,14 @@ import (
|
||||
|
||||
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
|
||||
@@ -63,6 +71,10 @@ func (p *Processor) Execute(cmd Command) ProcessorResponse {
|
||||
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)
|
||||
}
|
||||
@@ -144,28 +156,38 @@ func (p *Processor) handleCreateGame(cmd Command) ProcessorResponse {
|
||||
gameID := p.svc.GenerateGameID()
|
||||
|
||||
// Validate FEN safety, then classify via engine
|
||||
initialFEN := board.StartingFEN
|
||||
initialFEN := chess.StartFEN
|
||||
if args.FEN != "" {
|
||||
if !p.isFENSafe(args.FEN) {
|
||||
return p.errorResponse("invalid FEN format or characters", core.ErrInvalidFEN)
|
||||
}
|
||||
initialFEN = args.FEN
|
||||
// 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 := core.StateOngoing
|
||||
initialState, termination := core.StateOngoing, core.TermNone
|
||||
if err == nil {
|
||||
validatedFEN, initialState, err = p.classifyLocked(initialFEN)
|
||||
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
|
||||
b, err := board.ParseFEN(validatedFEN)
|
||||
start, err := chess.ParseFEN(validatedFEN)
|
||||
if err != nil {
|
||||
return p.errorResponse(fmt.Sprintf("FEN parse error: %v", err), core.ErrInvalidRequest)
|
||||
}
|
||||
@@ -186,7 +208,8 @@ func (p *Processor) handleCreateGame(cmd Command) ProcessorResponse {
|
||||
}
|
||||
}
|
||||
|
||||
if err = p.svc.CreateGame(gameID, whitePlayer, blackPlayer, validatedFEN, b.Turn(), initialState); err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -308,7 +331,7 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
|
||||
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)
|
||||
p.svc.UpdateGameState(cmd.GameID, core.StateStuck, core.TermNone)
|
||||
return p.errorResponse(fmt.Sprintf("failed to queue computer move: %v", err), core.ErrResourceLimit)
|
||||
}
|
||||
|
||||
@@ -351,27 +374,38 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
|
||||
|
||||
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 := core.StateOngoing
|
||||
finalState, termination := core.StateOngoing, core.TermNone
|
||||
if err == nil {
|
||||
newFEN, finalState, err = p.classifyCurrentLocked()
|
||||
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,
|
||||
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) {
|
||||
@@ -455,11 +489,11 @@ func (p *Processor) handleGetBoard(cmd Command) ProcessorResponse {
|
||||
return p.errorResponse("game not found", core.ErrGameNotFound)
|
||||
}
|
||||
|
||||
b, err := board.ParseFEN(g.FEN)
|
||||
pos, err := chess.ParseFEN(g.FEN)
|
||||
if err != nil {
|
||||
return p.errorResponse("error parsing FEN", core.ErrInvalidFEN)
|
||||
}
|
||||
ascii := b.ToASCII()
|
||||
ascii := pos.ASCII()
|
||||
|
||||
return ProcessorResponse{
|
||||
Success: true,
|
||||
@@ -486,38 +520,42 @@ func (p *Processor) triggerComputerMove(gameID string, g game.View) error {
|
||||
}
|
||||
if result.Error != nil {
|
||||
slog.Error("computer engine failed", "game_id", gameID, "error", result.Error)
|
||||
p.svc.UpdateGameState(gameID, core.StateStuck)
|
||||
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, cerr := p.classifyLocked(fen)
|
||||
_, state, termination, cerr := p.classifyLocked(fen)
|
||||
p.mu.Unlock()
|
||||
if cerr != nil || state == core.StateOngoing {
|
||||
p.svc.UpdateGameState(gameID, core.StateStuck) // engines disagree
|
||||
p.svc.UpdateGameState(gameID, core.StateStuck, core.TermNone) // engines disagree
|
||||
return
|
||||
}
|
||||
p.svc.UpdateGameState(gameID, state)
|
||||
p.svc.UpdateGameState(gameID, state, termination)
|
||||
return
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
aerr := p.validationEng.SetPosition(fen, []string{result.Move})
|
||||
var newFEN string
|
||||
finalState := core.StateOngoing
|
||||
finalState, termination := core.StateOngoing, core.TermNone
|
||||
if aerr == nil {
|
||||
newFEN, finalState, aerr = p.classifyCurrentLocked()
|
||||
newFEN, finalState, termination, aerr = p.classifyCurrentLocked()
|
||||
}
|
||||
p.mu.Unlock()
|
||||
if aerr != nil || newFEN == fen {
|
||||
p.svc.UpdateGameState(gameID, core.StateStuck)
|
||||
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,
|
||||
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,
|
||||
@@ -525,60 +563,106 @@ func (p *Processor) triggerComputerMove(gameID string, g game.View) error {
|
||||
}); 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)
|
||||
p.svc.UpdateGameState(gameID, core.StateStuck, core.TermNone)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// 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, err
|
||||
return "", core.StateOngoing, core.TermNone, err
|
||||
}
|
||||
legal, err := p.validationEng.HasLegalMoves()
|
||||
if err != nil {
|
||||
return "", core.StateOngoing, err
|
||||
return "", core.StateOngoing, core.TermNone, err
|
||||
}
|
||||
if legal {
|
||||
return diag.FEN, core.StateOngoing, nil
|
||||
return diag.FEN, core.StateOngoing, core.TermNone, nil
|
||||
}
|
||||
if !diag.InCheck {
|
||||
return diag.FEN, core.StateStalemate, nil
|
||||
return diag.FEN, core.StateStalemate, core.TermStalemate, nil
|
||||
}
|
||||
b, err := board.ParseFEN(diag.FEN)
|
||||
pos, err := chess.ParseFEN(diag.FEN)
|
||||
if err != nil {
|
||||
return "", core.StateOngoing, err
|
||||
return "", core.StateOngoing, core.TermNone, err
|
||||
}
|
||||
if b.Turn() == core.ColorWhite {
|
||||
return diag.FEN, core.StateBlackWins, nil
|
||||
if pos.Turn() == chess.White {
|
||||
return diag.FEN, core.StateBlackWins, core.TermCheckmate, nil
|
||||
}
|
||||
return diag.FEN, core.StateWhiteWins, 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, error) {
|
||||
func (p *Processor) classifyLocked(fen string) (string, core.State, core.Termination, error) {
|
||||
if err := p.validationEng.SetPosition(fen, nil); err != nil {
|
||||
return "", core.StateOngoing, err
|
||||
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(),
|
||||
Moves: g.Moves,
|
||||
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()
|
||||
}
|
||||
|
||||
// Include last move if available
|
||||
if result := g.LastResult; result != nil {
|
||||
@@ -612,3 +696,267 @@ func (p *Processor) Close() error {
|
||||
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)
|
||||
}
|
||||
return p.errorResponse(fmt.Sprintf("failed to update game: %v", err), core.ErrInternalError)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"chess/internal/server/chess"
|
||||
"chess/internal/server/core"
|
||||
"chess/internal/server/game"
|
||||
)
|
||||
|
||||
func TestRulesCheck(t *testing.T) {
|
||||
const promotion = "8/5KP1/5n2/p3k3/1p3p2/1P6/P7/8 w - - 4 55"
|
||||
if r := rulesCheck(promotion, "g7g8"); !errors.Is(r.err, chess.ErrPromotionRequired) {
|
||||
t.Errorf("g7g8: error = %v, want ErrPromotionRequired", r.err)
|
||||
}
|
||||
if r := rulesCheck(promotion, "g7g8q"); r.err != nil || r.fen != "6Q1/5K2/5n2/p3k3/1p3p2/1P6/P7/8 b - - 0 55" {
|
||||
t.Errorf("g7g8q: %+v", r)
|
||||
}
|
||||
if r := rulesCheck(chess.StartFEN, "e2e5"); !errors.Is(r.err, chess.ErrIllegalMove) {
|
||||
t.Errorf("e2e5: error = %v, want ErrIllegalMove", r.err)
|
||||
}
|
||||
if r := rulesCheck("not a fen", "e2e4"); !errors.Is(r.err, chess.ErrInvalidFEN) {
|
||||
t.Errorf("bad FEN: error = %v, want ErrInvalidFEN", r.err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdjudicateDraw(t *testing.T) {
|
||||
cycle := []string{"g1f3", "g8f6", "f3g1", "f6g8"}
|
||||
line, err := chess.Replay(chess.StartFEN, append(cycle, cycle...))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fens := make([]string, 9)
|
||||
for i := range fens {
|
||||
fens[i] = line.Position(i).FEN()
|
||||
}
|
||||
|
||||
for name, tc := range map[string]struct {
|
||||
history []string
|
||||
fen string
|
||||
want core.Termination
|
||||
}{
|
||||
"start": {nil, chess.StartFEN, core.TermNone},
|
||||
"bare kings": {nil, "4k3/8/8/8/8/8/8/4K3 w - - 0 1", core.TermInsufficientMaterial},
|
||||
"knight": {nil, "4k3/8/8/8/8/8/8/3NK3 b - - 3 40", core.TermInsufficientMaterial},
|
||||
"rook": {nil, "4k3/8/8/8/8/8/8/R3K3 w - - 3 40", core.TermNone},
|
||||
"fifty moves": {nil, "4k3/8/8/8/8/8/8/R3K3 w - - 100 90", core.TermFiftyMoveRule},
|
||||
"forty-nine": {nil, "4k3/8/8/8/8/8/8/R3K3 w - - 99 90", core.TermNone},
|
||||
"second occurrence": {fens[:4], fens[4], core.TermNone},
|
||||
"third occurrence": {fens[:8], fens[8], core.TermThreefoldRepetition},
|
||||
"unparsable": {nil, "not a fen", core.TermNone},
|
||||
} {
|
||||
state, termination := adjudicateDraw(tc.history, tc.fen)
|
||||
if termination != tc.want || (tc.want == core.TermNone) != (state == core.StateOngoing) {
|
||||
t.Errorf("%s: got %v %q, want %q", name, state, termination, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestActingColor(t *testing.T) {
|
||||
human := func(claimedBy string) *core.Player {
|
||||
return &core.Player{Type: core.PlayerHuman, ClaimedBy: claimedBy}
|
||||
}
|
||||
computer := &core.Player{Type: core.PlayerComputer}
|
||||
view := func(white, black *core.Player) game.View {
|
||||
return game.View{WhitePlayer: white, BlackPlayer: black}
|
||||
}
|
||||
|
||||
for name, tc := range map[string]struct {
|
||||
view game.View
|
||||
requested string
|
||||
user string
|
||||
want core.Color
|
||||
code string
|
||||
}{
|
||||
"only human side": {view(human(""), computer), "", "", core.ColorWhite, ""},
|
||||
"only human side, black": {view(computer, human("")), "", "", core.ColorBlack, ""},
|
||||
"claimed side of caller": {view(human("u1"), human("")), "", "u1", core.ColorWhite, ""},
|
||||
"explicit color": {view(human(""), human("")), "b", "", core.ColorBlack, ""},
|
||||
"explicit long color": {view(human(""), human("")), "white", "", core.ColorWhite, ""},
|
||||
"ambiguous hot seat": {view(human(""), human("")), "", "", 0, core.ErrInvalidRequest},
|
||||
"self-play needs color": {view(human("u1"), human("u1")), "", "u1", 0, core.ErrInvalidRequest},
|
||||
"computer only": {view(computer, computer), "", "", 0, core.ErrInvalidRequest},
|
||||
"computer side requested": {view(human(""), computer), "b", "", 0, core.ErrInvalidRequest},
|
||||
"claimed by another": {view(human("u1"), computer), "", "u2", 0, core.ErrUnauthorized},
|
||||
"claimed, anonymous caller": {view(human("u1"), computer), "", "", 0, core.ErrUnauthorized},
|
||||
} {
|
||||
color, failure := actingColor(tc.view, tc.requested, tc.user)
|
||||
switch {
|
||||
case tc.code != "" && (failure == nil || failure.Error.Code != tc.code):
|
||||
t.Errorf("%s: got %v %+v, want error %s", name, color, failure, tc.code)
|
||||
case tc.code == "" && (failure != nil || color != tc.want):
|
||||
t.Errorf("%s: got %v %+v, want %v", name, color, failure, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user