v0.13.0 web and terminal client enhance, replay api, linux server deploy, doc and dependency update
test / test (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-09-28 22:35:52 -04:00
1 parent 52ac0fe31a
commit f1ac4cbbe7
71 files changed
+7677 -537

No files matched your search

+18
View File
@@ -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,
}
}
+388 -40
View File
@@ -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)
}
+97
View File
@@ -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)
}
}
}