v0.10.0 fix to engine game state mgmt, client and web ui updates to match

This commit is contained in:
2026-07-23 14:03:09 -04:00
parent 882c83c9a4
commit c0fc2a9667
17 changed files with 741 additions and 549 deletions
+83 -64
View File
@@ -6,7 +6,6 @@ import (
"os"
"strconv"
"strings"
"time"
"chess/internal/client/api"
"chess/internal/client/display"
@@ -234,6 +233,9 @@ func joinGameHandler(s *session.Session, args []string) error {
return nil
}
// moveHandler submits a human move. Terminal/error outcomes are reported via
// the shared printOutcome (no-op on "ongoing"/"pending"); the computer-turn
// hint stays local since it only applies after a successful human move.
func moveHandler(s *session.Session, args []string) error {
if len(args) < 1 {
return fmt.Errorf("usage: move <uci-move>")
@@ -256,29 +258,13 @@ func moveHandler(s *session.Session, args []string) error {
s.CurrentGameState = resp
display.Println(display.Green, "Move accepted")
// Check if game ended
switch resp.State {
case "checkmate":
winner := "Black"
if resp.Turn == "b" { // Turn switches after move, so if black's turn after checkmate, white won
winner = "White"
}
display.Println(display.Green, "\nCHECKMATE! %s wins!", winner)
case "stalemate":
display.Println(display.Yellow, "\nSTALEMATE! Game drawn.")
case "draw":
display.Println(display.Yellow, "\nDRAW! Game drawn.")
case "ongoing":
// Check if computer needs to play
currentTurn := resp.Turn
var computerPlayer *api.PlayerInfo
if currentTurn == "w" && resp.Players.White.Type == 2 {
computerPlayer = &resp.Players.White
} else if currentTurn == "b" && resp.Players.Black.Type == 2 {
computerPlayer = &resp.Players.Black
}
printOutcome(resp)
if computerPlayer != nil {
// Hint to trigger the computer if the game continues on a computer's turn
if resp.State == "ongoing" {
isComputerTurn := (resp.Turn == "w" && resp.Players.White.Type == 2) ||
(resp.Turn == "b" && resp.Players.Black.Type == 2)
if isComputerTurn {
display.Println(display.Magenta, "\nComputer's turn. Use 'computer' or 'c' to trigger move.")
}
}
@@ -286,6 +272,12 @@ func moveHandler(s *session.Session, args []string) error {
return nil
}
// computerMoveHandler triggers a computer move and waits for the result via
// the server's long-poll. With the state-aware waiter, a single poll wakes on
// either the applied move (move count delta) or a state-only settle
// (mate-without-move, stuck) — no fixed-interval GET hammering, no hard cap
// below the server's max searchTime. Polls loop only if the wake races the
// pending window (e.g. queue wait), each round costing at most WaitTimeout.
func computerMoveHandler(s *session.Session, args []string) error {
gameID := s.CurrentGame
if gameID == "" {
@@ -294,55 +286,82 @@ func computerMoveHandler(s *session.Session, args []string) error {
c := s.Client
// Baseline BEFORE triggering: the long-poll returns immediately if the
// move count already differs from this value.
baselineMoves := s.LastMoveCount
if s.CurrentGameState != nil {
baselineMoves = len(s.CurrentGameState.Moves)
}
resp, err := c.MakeMove(gameID, "cccc")
if err != nil {
return err
}
if resp.State == "pending" {
display.Println(display.Magenta, "Computer is thinking...")
// Poll for completion
for i := 0; i < 50; i++ {
time.Sleep(200 * time.Millisecond)
resp2, err := c.GetGame(gameID)
if err == nil && resp2.State != "pending" {
s.LastMoveCount = len(resp2.Moves)
s.CurrentGameState = resp2
if resp2.LastMove != nil {
display.Print(display.Magenta, "Computer played: %s", resp2.LastMove.Move)
if resp2.LastMove.Depth > 0 {
fmt.Printf(" (depth %d, score %d)", resp2.LastMove.Depth, resp2.LastMove.Score)
}
fmt.Println()
}
// Check if game ended after computer move
switch resp2.State {
case "checkmate":
winner := "Black"
if resp2.Turn == "b" {
winner = "White"
}
display.Println(display.Green, "\nCHECKMATE! %s wins!", winner)
case "stalemate":
display.Println(display.Yellow, "\nSTALEMATE! Game drawn.")
case "draw":
display.Println(display.Yellow, "\nDRAW! Game drawn.")
}
return nil
}
}
return fmt.Errorf("timeout waiting for computer move")
if resp.State != "pending" {
// Server resolved synchronously (shouldn't normally happen)
s.LastMoveCount = len(resp.Moves)
s.CurrentGameState = resp
display.Println(display.Green, "Move triggered")
printOutcome(resp)
return nil
}
s.LastMoveCount = len(resp.Moves)
s.CurrentGameState = resp
display.Println(display.Green, "Move triggered")
display.Println(display.Magenta, "Computer is thinking...")
// Up to 3 long-poll rounds (~90s ceiling) covers max searchTime (10s)
// plus pathological queue wait, without hanging indefinitely.
const maxPolls = 3
var final *api.GameResponse
for i := 0; i < maxPolls; i++ {
polled, err := c.GetGameWithPoll(gameID, baselineMoves)
if err != nil {
return err
}
if polled.State != "pending" {
final = polled
break
}
// Woke on timeout while still pending; poll again.
}
if final == nil {
return fmt.Errorf("computer move still pending after %d poll rounds", maxPolls)
}
s.LastMoveCount = len(final.Moves)
s.CurrentGameState = final
// A move may legitimately be absent: mate-without-move detection or a
// stuck transition settle the state without applying anything.
if final.LastMove != nil && len(final.Moves) > baselineMoves {
display.Print(display.Magenta, "Computer played: %s", final.LastMove.Move)
if final.LastMove.Depth > 0 {
fmt.Printf(" (depth %d, score %d)", final.LastMove.Depth, final.LastMove.Score)
}
fmt.Println()
}
printOutcome(final)
return nil
}
// printOutcome reports terminal or error states using the server's actual
// State.String() values ("white wins"/"black wins", not "checkmate").
func printOutcome(resp *api.GameResponse) {
switch resp.State {
case "white wins":
display.Println(display.Green, "\nCHECKMATE! White wins!")
case "black wins":
display.Println(display.Green, "\nCHECKMATE! Black wins!")
case "stalemate":
display.Println(display.Yellow, "\nSTALEMATE! Game drawn.")
case "draw":
display.Println(display.Yellow, "\nDRAW! Game drawn.")
case "stuck":
display.Println(display.Yellow, "\nEngine error — 'undo' to recover, or 'new'/'delete'.")
}
}
func undoHandler(s *session.Session, args []string) error {
gameID := s.GetCurrentGame()
if gameID == "" {
@@ -490,7 +509,7 @@ func pollHandler(s *session.Session, args []string) error {
moveCount := s.GetLastMoveCount()
display.Println(display.Cyan, "Long-polling for updates (move count: %d)...", moveCount)
display.Println(display.Cyan, "This may take up to 25 seconds")
display.Println(display.Cyan, "This may take up to 30 seconds")
resp, err := c.GetGameWithPoll(gameID, moveCount)
if err != nil {
@@ -510,4 +529,4 @@ func pollHandler(s *session.Session, args []string) error {
}
return nil
}
}
+240 -205
View File
@@ -2,7 +2,7 @@ package engine
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"os/exec"
@@ -11,13 +11,27 @@ import (
"time"
)
const enginePath = "stockfish"
const (
enginePath = "stockfish"
handshakeTimeout = 5 * time.Second
barrierTimeout = 5 * time.Second
diagnoseTimeout = 3 * time.Second
probeTimeout = 3 * time.Second
lineBuffer = 512
)
var ErrEngineTimeout = errors.New("engine timeout")
// UCI wraps a stockfish process. All engine dialogue is a serialized
// request/response transaction under mu; a single reader goroutine owns stdout
// for the life of the process. Any timeout/EOF kills and respawns the process:
// output desync cannot survive into the next call.
type UCI struct {
cmd *exec.Cmd
stdin io.WriteCloser
stdout *bufio.Scanner
mu sync.Mutex
mu sync.Mutex
cmd *exec.Cmd
stdin io.WriteCloser
lines chan string
alive bool
}
type SearchResult struct {
@@ -28,225 +42,246 @@ type SearchResult struct {
MateIn int
}
func New() (*UCI, error) {
cmd := exec.Command(enginePath)
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, err
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
if err = cmd.Start(); err != nil {
return nil, fmt.Errorf("failed to start engine: %v", err)
}
uci := &UCI{
cmd: cmd,
stdin: stdin,
stdout: bufio.NewScanner(stdout),
}
if err := uci.initialize(); err != nil {
uci.Close()
return nil, err
}
return uci, nil
type Diagnosis struct {
FEN string
InCheck bool
}
// SetSkillLevel sets the Stockfish skill level (0-20)
func (u *UCI) SetSkillLevel(level int) {
func New() (*UCI, error) {
u := &UCI{}
u.mu.Lock()
defer u.mu.Unlock()
if err := u.spawnLocked(); err != nil {
return nil, err
}
return u, nil
}
func (u *UCI) spawnLocked() error {
cmd := exec.Command(enginePath)
stdin, err := cmd.StdinPipe()
if err != nil {
return err
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("start engine: %w", err)
}
lines := make(chan string, lineBuffer)
go func() {
sc := bufio.NewScanner(stdout)
sc.Buffer(make([]byte, 64*1024), 1<<20)
for sc.Scan() {
lines <- sc.Text()
}
close(lines) // EOF: process exited or was killed
}()
u.cmd, u.stdin, u.lines, u.alive = cmd, stdin, lines, true
if _, err := u.txLocked(handshakeTimeout, []string{"uci"}, "uciok", nil); err != nil {
u.killLocked()
return fmt.Errorf("uci handshake: %w", err)
}
if _, err := u.txLocked(handshakeTimeout, []string{"isready"}, "readyok", nil); err != nil {
u.killLocked()
return fmt.Errorf("uci handshake: %w", err)
}
return nil
}
// killLocked hard-stops the process. Reaping is deferred to a goroutine that
// first drains the line channel to completion, so cmd.Wait never races the
// reader's final reads on the stdout pipe.
func (u *UCI) killLocked() {
u.alive = false
if u.cmd != nil && u.cmd.Process != nil {
u.cmd.Process.Kill()
}
if u.stdin != nil {
u.stdin.Close()
}
if u.lines != nil {
go func(ch chan string, cmd *exec.Cmd) {
for range ch {
}
cmd.Wait()
}(u.lines, u.cmd)
}
}
func (u *UCI) restartLocked() {
u.killLocked()
_ = u.spawnLocked() // on failure alive stays false; next tx errors immediately
}
func (u *UCI) drainLocked() {
for {
select {
case _, ok := <-u.lines:
if !ok {
return
}
default:
return
}
}
}
// txLocked: drain stale lines, send commands, read to the terminal prefix.
// visit observes every line including the terminal one. Timeout is
// per-transaction total.
func (u *UCI) txLocked(timeout time.Duration, cmds []string, terminal string, visit func(string)) (string, error) {
if !u.alive {
return "", errors.New("engine not running")
}
u.drainLocked()
for _, c := range cmds {
if _, err := fmt.Fprintln(u.stdin, c); err != nil {
u.restartLocked()
return "", fmt.Errorf("engine write: %w", err)
}
}
deadline := time.NewTimer(timeout)
defer deadline.Stop()
for {
select {
case ln, ok := <-u.lines:
if !ok {
u.restartLocked()
return "", errors.New("engine closed unexpectedly")
}
if visit != nil {
visit(ln)
}
if strings.HasPrefix(ln, terminal) {
return ln, nil
}
case <-deadline.C:
u.restartLocked()
return "", fmt.Errorf("%w awaiting %q", ErrEngineTimeout, terminal)
}
}
}
func (u *UCI) tx(timeout time.Duration, cmds []string, terminal string, visit func(string)) (string, error) {
u.mu.Lock()
defer u.mu.Unlock()
return u.txLocked(timeout, cmds, terminal, visit)
}
func (u *UCI) NewGame() error {
_, err := u.tx(barrierTimeout, []string{"ucinewgame", "isready"}, "readyok", nil)
return err
}
func (u *UCI) SetSkillLevel(level int) error {
if level < 0 {
level = 0
} else if level > 20 {
level = 20
}
u.sendCommand(fmt.Sprintf("setoption name Skill Level value %d", level))
_, err := u.tx(barrierTimeout,
[]string{fmt.Sprintf("setoption name Skill Level value %d", level), "isready"},
"readyok", nil)
return err
}
// Get FEN from Stockfish's debug ('d') command
func (u *UCI) GetFEN() (string, error) {
u.sendCommand("d")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
done := make(chan string, 1)
go func() {
for u.stdout.Scan() {
line := u.stdout.Text()
if strings.HasPrefix(line, "Fen: ") {
done <- strings.TrimPrefix(line, "Fen: ")
return
}
}
done <- ""
}()
select {
case fen := <-done:
if fen == "" {
return "", fmt.Errorf("failed to get FEN from engine")
}
return fen, nil
case <-ctx.Done():
return "", fmt.Errorf("timeout getting FEN")
}
}
func (u *UCI) initialize() error {
u.sendCommand("uci")
// Wait for uciok with timeout
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
done := make(chan bool)
go func() {
for u.stdout.Scan() {
if u.stdout.Text() == "uciok" {
done <- true
return
}
}
done <- false
}()
select {
case success := <-done:
if !success {
return fmt.Errorf("engine closed unexpectedly")
}
case <-ctx.Done():
return fmt.Errorf("timeout waiting for uciok")
}
u.sendCommand("isready")
return u.waitReady()
}
func (u *UCI) waitReady() error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
done := make(chan error)
go func() {
for u.stdout.Scan() {
if u.stdout.Text() == "readyok" {
done <- nil
return
}
}
done <- fmt.Errorf("engine closed unexpectedly")
}()
select {
case err := <-done:
return err
case <-ctx.Done():
return fmt.Errorf("timeout waiting for readyok")
}
}
func (u *UCI) sendCommand(cmd string) {
u.mu.Lock()
defer u.mu.Unlock()
fmt.Fprintln(u.stdin, cmd)
}
func (u *UCI) NewGame() {
u.sendCommand("ucinewgame")
u.sendCommand("isready")
u.waitReady()
}
func (u *UCI) SetPosition(fen string, moves []string) {
cmd := fmt.Sprintf("position fen %s", fen)
func (u *UCI) SetPosition(fen string, moves []string) error {
cmd := "position fen " + fen
if len(moves) > 0 {
cmd += " moves " + strings.Join(moves, " ")
}
u.sendCommand(cmd)
_, err := u.tx(barrierTimeout, []string{cmd, "isready"}, "readyok", nil)
return err
}
// Diagnose runs `d` and consumes its full output. Terminal line is "Checkers:"
// (last line of `d` in current Stockfish; verify against the jailed build —
// see context requests).
func (u *UCI) Diagnose() (Diagnosis, error) {
var d Diagnosis
last, err := u.tx(diagnoseTimeout, []string{"d"}, "Checkers:", func(ln string) {
if s, ok := strings.CutPrefix(ln, "Fen: "); ok {
d.FEN = strings.TrimSpace(s)
}
})
if err != nil {
return Diagnosis{}, err
}
if d.FEN == "" {
return Diagnosis{}, errors.New("d output missing Fen line")
}
d.InCheck = strings.TrimSpace(strings.TrimPrefix(last, "Checkers:")) != ""
return d, nil
}
// HasLegalMoves probes with a depth-1 search: deterministic, milliseconds.
func (u *UCI) HasLegalMoves() (bool, error) {
last, err := u.tx(probeTimeout, []string{"go depth 1"}, "bestmove ", nil)
if err != nil {
return false, err
}
f := strings.Fields(last)
return len(f) >= 2 && f[1] != "(none)", nil
}
func (u *UCI) Search(timeMs int) (*SearchResult, error) {
u.sendCommand(fmt.Sprintf("go movetime %d", timeMs))
result := &SearchResult{}
// Add timeout protection (2x the search time + buffer)
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeMs*2+1000)*time.Millisecond)
defer cancel()
done := make(chan error)
go func() {
for u.stdout.Scan() {
line := u.stdout.Text()
if strings.HasPrefix(line, "info ") {
fields := strings.Fields(line)
for i := 0; i < len(fields)-1; i++ {
switch fields[i] {
case "depth":
fmt.Sscanf(fields[i+1], "%d", &result.Depth)
case "cp":
fmt.Sscanf(fields[i+1], "%d", &result.Score)
result.IsMate = false
case "mate":
fmt.Sscanf(fields[i+1], "%d", &result.MateIn)
result.IsMate = true
// Convert mate score to centipawn equivalent for backwards compatibility
if result.MateIn > 0 {
result.Score = 100000 - result.MateIn
} else {
result.Score = -100000 - result.MateIn
}
}
r := &SearchResult{}
timeout := time.Duration(timeMs)*time.Millisecond + 5*time.Second
last, err := u.tx(timeout, []string{fmt.Sprintf("go movetime %d", timeMs)}, "bestmove ", func(ln string) {
if !strings.HasPrefix(ln, "info ") {
return
}
f := strings.Fields(ln)
for i := 0; i < len(f)-1; i++ {
switch f[i] {
case "depth":
fmt.Sscanf(f[i+1], "%d", &r.Depth)
case "cp":
fmt.Sscanf(f[i+1], "%d", &r.Score)
r.IsMate = false
case "mate":
fmt.Sscanf(f[i+1], "%d", &r.MateIn)
r.IsMate = true
if r.MateIn > 0 {
r.Score = 100000 - r.MateIn
} else {
r.Score = -100000 - r.MateIn
}
}
if strings.HasPrefix(line, "bestmove ") {
parts := strings.Fields(line)
if len(parts) >= 2 {
result.BestMove = parts[1]
}
done <- nil
return
}
}
done <- fmt.Errorf("engine closed unexpectedly")
}()
select {
case err := <-done:
if err != nil {
return nil, err
}
return result, nil
case <-ctx.Done():
return nil, fmt.Errorf("timeout waiting for bestmove")
})
if err != nil {
return nil, err
}
f := strings.Fields(last)
if len(f) >= 2 {
r.BestMove = f[1]
}
return r, nil
}
func (u *UCI) Close() error {
u.sendCommand("quit")
time.Sleep(100 * time.Millisecond)
// Try graceful shutdown first
done := make(chan error, 1)
go func() {
done <- u.cmd.Wait()
}()
select {
case <-done:
return nil
case <-time.After(1 * time.Second):
// Force kill if doesn't exit gracefully
return u.cmd.Process.Kill()
u.mu.Lock()
defer u.mu.Unlock()
if u.alive {
fmt.Fprintln(u.stdin, "quit")
done := make(chan struct{})
go func() { u.cmd.Wait(); close(done) }()
u.alive = false
select {
case <-done:
u.stdin.Close()
return nil
case <-time.After(1 * time.Second):
}
}
}
u.killLocked()
return nil
}
+3 -3
View File
@@ -333,9 +333,10 @@ func (h *HTTPHandler) GetGame(c *fiber.Ctx) error {
}
currentMoveCount := len(g.Moves())
st := g.State()
settled := st != core.StateOngoing && st != core.StatePending
// If move count already different, return immediately
if moveCount != currentMoveCount {
if moveCount != currentMoveCount || settled {
cmd := processor.NewGetGameCommand(gameID)
resp := h.proc.Execute(cmd)
if !resp.Success {
@@ -518,4 +519,3 @@ func (h *HTTPHandler) GetBoard(c *fiber.Ctx) error {
return c.JSON(resp.Data)
}
+133 -113
View File
@@ -114,7 +114,9 @@ func (p *Processor) isMoveSafe(move string) bool {
return true
}
// handleCreateGame creates a new game and triggers computer move if needed
// 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 {
@@ -122,10 +124,10 @@ func (p *Processor) handleCreateGame(cmd Command) ProcessorResponse {
}
// Enforce minimum searchTime for computer players
if args.White.Type == core.PlayerComputer && args.White.SearchTime < 100 {
if args.White.Type == core.PlayerComputer && args.White.SearchTime < minSearchTime {
args.White.SearchTime = minSearchTime
}
if args.Black.Type == core.PlayerComputer && args.Black.SearchTime < 100 {
if args.Black.Type == core.PlayerComputer && args.Black.SearchTime < minSearchTime {
args.Black.SearchTime = minSearchTime
}
@@ -138,10 +140,9 @@ func (p *Processor) handleCreateGame(cmd Command) ProcessorResponse {
)
}
// Generate game ID
gameID := p.svc.GenerateGameID()
// Validate and canonicalize FEN if provided
// Validate FEN safety, then classify via engine
initialFEN := board.StartingFEN
if args.FEN != "" {
if !p.isFENSafe(args.FEN) {
@@ -151,16 +152,18 @@ func (p *Processor) handleCreateGame(cmd Command) ProcessorResponse {
}
p.mu.Lock()
p.validationEng.NewGame()
p.validationEng.SetPosition(initialFEN, []string{})
validatedFEN, err := p.validationEng.GetFEN()
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("invalid FEN: %v", err), core.ErrInvalidRequest)
return p.errorResponse(fmt.Sprintf("engine validation failed: %v", err), core.ErrInternalError)
}
// Parse to get starting turn
// 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)
@@ -170,39 +173,33 @@ func (p *Processor) handleCreateGame(cmd Command) ProcessorResponse {
whitePlayer := core.NewPlayer(args.White, core.ColorWhite)
blackPlayer := core.NewPlayer(args.Black, core.ColorBlack)
// FIX: Only assign authenticated user to ONE human slot
// If both are human, authenticated user gets white; black remains unclaimed
// 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 {
// Only claim black if white is not human (i.e., H vs C scenario)
blackPlayer.ID = cmd.UserID
blackPlayer.ClaimedBy = cmd.UserID
}
}
// Create game in service with fully-formed players
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)
}
// Check if the initial FEN represents a completed game
p.checkGameEnd(gameID, validatedFEN, core.OppositeColor(b.Turn()))
// Get created game
g, err := p.svc.GetGame(gameID)
if err != nil {
return p.errorResponse("game creation failed", core.ErrInternalError)
}
// Build response
response := p.buildGameResponse(gameID, g)
return ProcessorResponse{
Success: true,
Data: response,
Data: p.buildGameResponse(gameID, g),
}
}
@@ -264,7 +261,11 @@ func (p *Processor) handleGetGame(cmd Command) ProcessorResponse {
}
}
// handleMakeMove processes human moves with authorization
// 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 {
@@ -332,11 +333,8 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
}
// Anonymous users can also claim by making a move (slot remains "unclaimed" but move proceeds)
} else if cmd.UserID != "" && slotOwner != cmd.UserID {
// Slot claimed by different user
return p.errorResponse("not your turn - slot claimed by another player", core.ErrUnauthorized)
}
// If slotOwner == cmd.UserID, authorized to proceed
// If slotOwner != "" && cmd.UserID == "", anonymous trying to move claimed slot - block
if slotOwner != "" && cmd.UserID == "" {
return p.errorResponse("slot claimed - authentication required", core.ErrUnauthorized)
}
@@ -349,60 +347,53 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
currentFEN := g.CurrentFEN()
// Validate move with engine
// Validate move and classify the resulting position in one engine session
p.mu.Lock()
p.validationEng.SetPosition(currentFEN, []string{move})
newFEN, err := p.validationEng.GetFEN()
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 || newFEN == currentFEN {
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)
}
// Apply move to game state via service
if err = p.svc.ApplyMove(cmd.GameID, move, newFEN); err != nil {
// 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)
}
// Store move result metadata
p.svc.SetLastMoveResult(cmd.GameID, &game.MoveResult{
Move: move,
PlayerColor: currentColor,
GameState: core.StateOngoing,
})
// Check for checkmate/stalemate
p.checkGameEnd(cmd.GameID, newFEN, currentColor)
// Get updated game
// buildGameResponse populates LastMove from the committed LastResult
g, _ = p.svc.GetGame(cmd.GameID)
response := p.buildGameResponse(cmd.GameID, g)
// Add human move info
response.LastMove = &core.MoveInfo{
Move: move,
PlayerColor: currentColor.String(),
}
return ProcessorResponse{
Success: true,
Data: response,
Data: p.buildGameResponse(cmd.GameID, g),
}
}
// handleUndoMove reverts game state
// 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)
}
// Check game state
switch g.State() {
case core.StatePending:
if g.State() == core.StatePending {
return p.errorResponse("cannot undo while computer move is in progress", core.ErrInvalidRequest)
case core.StateStuck:
return p.errorResponse("cannot undo in stuck game", core.ErrInvalidRequest)
}
args := core.UndoRequest{Count: 1}
@@ -423,11 +414,9 @@ func (p *Processor) handleUndoMove(cmd Command) ProcessorResponse {
p.svc.UpdateGameState(cmd.GameID, core.StateOngoing)
g, _ = p.svc.GetGame(cmd.GameID)
response := p.buildGameResponse(cmd.GameID, g)
return ProcessorResponse{
Success: true,
Data: response,
Data: p.buildGameResponse(cmd.GameID, g),
}
}
@@ -474,64 +463,55 @@ func (p *Processor) handleGetBoard(cmd Command) ProcessorResponse {
}
}
// triggerComputerMove initiates async engine calculation
// 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()
// Submit to queue with callback and computer config
p.queue.SubmitAsync(gameID, fen, color, player, func(result EngineResult) {
// Check if game still exists
currentGame, err := p.svc.GetGame(gameID)
if err != nil {
return // Game was deleted
if err != nil || currentGame.State() != core.StatePending {
return // Deleted, or state resolved elsewhere
}
// Only process if still in pending state
if currentGame.State() != core.StatePending {
return
}
if result.Error != nil {
log.Printf("Engine error for game %s: %v", gameID, result.Error)
log.Printf("engine error for game %s: %v", gameID, result.Error)
p.svc.UpdateGameState(gameID, core.StateStuck)
return
}
// Use centralized state determination
state := p.determineGameEndState(core.OppositeColor(color), &engine.SearchResult{
BestMove: result.Move,
Score: result.Score,
Depth: result.Depth,
IsMate: result.IsMate,
MateIn: result.MateIn,
})
if state != core.StateOngoing {
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
}
// Apply computer move
p.mu.Lock()
p.validationEng.SetPosition(fen, []string{result.Move})
newFEN, _ := p.validationEng.GetFEN()
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.ApplyMove(gameID, result.Move, newFEN)
p.svc.SetLastMoveResult(gameID, &game.MoveResult{
Move: result.Move,
PlayerColor: color,
Score: result.Score,
Depth: result.Depth,
p.svc.ApplyMoveWithState(gameID, result.Move, newFEN, finalState, &game.MoveResult{
Move: result.Move, PlayerColor: color,
Score: result.Score, Depth: result.Depth, GameState: finalState,
})
// Reset to ongoing first
p.svc.UpdateGameState(gameID, core.StateOngoing)
// Check if opponent is checkmated
p.checkGameEnd(gameID, newFEN, color)
})
}
@@ -554,18 +534,58 @@ func (p *Processor) determineGameEndState(lastMoveBy core.Color, searchResult *e
return core.StateOngoing
}
// checkGameEnd determines if game has ended
func (p *Processor) checkGameEnd(gameID, fen string, lastMoveBy core.Color) {
p.mu.Lock()
p.validationEng.SetPosition(fen, []string{})
search, _ := p.validationEng.Search(100)
p.mu.Unlock()
// Use centralized state determination
state := p.determineGameEndState(lastMoveBy, search)
if state != core.StateOngoing {
p.svc.UpdateGameState(gameID, state)
// 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
@@ -610,4 +630,4 @@ func (p *Processor) errorResponse(message, code string) ProcessorResponse {
func (p *Processor) Close() error {
p.queue.Shutdown(5 * time.Second)
return p.validationEng.Close()
}
}
+40 -63
View File
@@ -3,6 +3,7 @@ package processor
import (
"context"
"fmt"
"log"
"sync"
"time"
@@ -69,31 +70,27 @@ func (q *EngineQueue) start() {
// worker processes engine tasks
func (q *EngineQueue) worker(id int) {
defer q.wg.Done()
// Each worker gets its own engine instance
eng, err := engine.New()
if err != nil {
fmt.Printf("Worker %d failed to initialize engine: %v\n", id, err)
return
var eng *engine.UCI
for {
var err error
if eng, err = engine.New(); err == nil {
break
}
log.Printf("worker %d: engine init failed: %v; retrying", id, err)
select {
case <-q.ctx.Done():
return
case <-time.After(2 * time.Second):
}
}
defer eng.Close()
for {
select {
case task, ok := <-q.tasks:
if !ok {
return // Channel closed
return
}
result := q.processTask(eng, task)
// Send result if receiver still listening
select {
case task.Response <- result:
case <-time.After(15 * time.Millisecond):
// Receiver abandoned, discard result
}
task.Response <- q.processTask(eng, task) // Response is buffered(1); never blocks
case <-q.ctx.Done():
return
}
@@ -102,45 +99,36 @@ func (q *EngineQueue) worker(id int) {
// processTask executes a single engine calculation
func (q *EngineQueue) processTask(eng *engine.UCI, task EngineTask) EngineResult {
result := EngineResult{
GameID: task.GameID,
result := EngineResult{GameID: task.GameID}
if err := eng.NewGame(); err != nil {
result.Error = err
return result
}
// Apply computer configuration if provided
if task.Player.Type == core.PlayerComputer {
eng.SetSkillLevel(task.Player.Level)
if err := eng.SetSkillLevel(task.Player.Level); err != nil {
result.Error = err
return result
}
}
// Setup position
eng.SetPosition(task.FEN, []string{})
// Determine search time
searchTime := 1000 // Default 1 second
if err := eng.SetPosition(task.FEN, nil); err != nil {
result.Error = err
return result
}
searchTime := 1000
if task.Player.Type == core.PlayerComputer && task.Player.SearchTime > 0 {
searchTime = task.Player.SearchTime
}
// Search for best move
search, err := eng.Search(searchTime)
if err != nil {
result.Error = fmt.Errorf("engine search failed: %v", err)
result.Error = fmt.Errorf("engine search failed: %w", err)
return result
}
// Check for no legal moves
if search.BestMove == "" || search.BestMove == "(none)" {
result.Move = ""
result.IsMate = search.IsMate
result.MateIn = search.MateIn
result.IsMate, result.MateIn = search.IsMate, search.MateIn
return result
}
result.Move = search.BestMove
result.Score = search.Score
result.Depth = search.Depth
result.IsMate = search.IsMate
result.MateIn = search.MateIn
result.Move, result.Score, result.Depth = search.BestMove, search.Score, search.Depth
result.IsMate, result.MateIn = search.IsMate, search.MateIn
return result
}
@@ -159,32 +147,22 @@ func (q *EngineQueue) Submit(task EngineTask) error {
// SubmitAsync submits a task without blocking for result
func (q *EngineQueue) SubmitAsync(gameID, fen string, color core.Color, player *core.Player, callback func(EngineResult)) error {
respChan := make(chan EngineResult, 1)
task := EngineTask{
GameID: gameID,
FEN: fen,
Color: color,
Player: player,
Response: respChan,
}
if err := q.Submit(task); err != nil {
if err := q.Submit(EngineTask{GameID: gameID, FEN: fen, Color: color, Player: player, Response: respChan}); err != nil {
return err
}
// Handle result in background
budget := 1000
if player.Type == core.PlayerComputer && player.SearchTime > 0 {
budget = player.SearchTime
}
wait := time.Duration(budget)*time.Millisecond*2 + 30*time.Second // search budget + queue-wait headroom
go func() {
select {
case result := <-respChan:
callback(result)
case <-time.After(5 * time.Second):
callback(EngineResult{
GameID: gameID,
Error: fmt.Errorf("engine timeout"),
})
case <-time.After(wait):
callback(EngineResult{GameID: gameID, Error: fmt.Errorf("engine timeout")})
}
}()
return nil
}
@@ -206,4 +184,3 @@ func (q *EngineQueue) Shutdown(timeout time.Duration) error {
return fmt.Errorf("shutdown timeout exceeded")
}
}
+36 -8
View File
@@ -113,7 +113,7 @@ func (s *Service) ApplyMove(gameID, moveUCI, newFEN string) error {
g.AddSnapshot(newFEN, moveUCI, nextTurn)
// Notify waiting clients about the state change
s.waiter.NotifyGame(gameID, len(g.Moves()))
s.waiter.NotifyGame(gameID, len(g.Moves()), g.State())
// Persist if storage enabled
if s.store != nil {
@@ -132,6 +132,36 @@ func (s *Service) ApplyMove(gameID, moveUCI, newFEN string) error {
return nil
}
// ApplyMoveWithState atomically records a move, its resulting state, and move
// metadata, then notifies waiters exactly once with the settled state.
func (s *Service) ApplyMoveWithState(gameID, moveUCI, newFEN string, state core.State, result *game.MoveResult) error {
s.mu.Lock()
defer s.mu.Unlock()
g, ok := s.games[gameID]
if !ok {
return fmt.Errorf("game not found: %s", gameID)
}
currentTurn := g.NextTurnColor()
g.AddSnapshot(newFEN, moveUCI, core.OppositeColor(currentTurn))
g.SetState(state)
if result != nil {
g.SetLastResult(result)
}
s.waiter.NotifyGame(gameID, len(g.Moves()), state)
if s.store != nil {
s.store.RecordMove(storage.MoveRecord{
GameID: gameID, MoveNumber: len(g.Moves()), MoveUCI: moveUCI,
FENAfterMove: newFEN, PlayerColor: currentTurn.String(),
MoveTimeUTC: time.Now().UTC(),
})
}
return nil
}
// UpdateGameState sets the game's end state (checkmate, stalemate, etc)
func (s *Service) UpdateGameState(gameID string, state core.State) error {
s.mu.Lock()
@@ -143,11 +173,8 @@ func (s *Service) UpdateGameState(gameID string, state core.State) error {
}
g.SetState(state)
// Notify if game ended
if state != core.StateOngoing && state != core.StatePending {
s.waiter.NotifyGame(gameID, len(g.Moves()))
}
// Notify unconditionally; the registry decides.
s.waiter.NotifyGame(gameID, len(g.Moves()), state)
return nil
}
@@ -183,7 +210,7 @@ func (s *Service) UndoMoves(gameID string, count int) error {
}
// Notify waiting clients about the undo
s.waiter.NotifyGame(gameID, len(g.Moves()))
s.waiter.NotifyGame(gameID, len(g.Moves()), g.State())
// Delete undone moves from storage if enabled
if s.store != nil {
@@ -214,4 +241,5 @@ func (s *Service) DeleteGame(gameID string) error {
delete(s.games, gameID)
return nil
}
}
+4 -9
View File
@@ -1,6 +1,7 @@
package service
import (
"chess/internal/server/core"
"context"
"fmt"
"sync"
@@ -84,24 +85,19 @@ func (w *WaitRegistry) RegisterWait(gameID string, moveCount int, ctx context.Co
}
// NotifyGame notifies all clients waiting on a game about state change
func (w *WaitRegistry) NotifyGame(gameID string, currentMoveCount int) {
func (w *WaitRegistry) NotifyGame(gameID string, currentMoveCount int, state core.State) {
w.mu.RLock()
waitList := w.waiters[gameID]
w.mu.RUnlock()
if len(waitList) == 0 {
return
}
// Non-blocking notification to all waiters
settled := state != core.StateOngoing && state != core.StatePending
for _, req := range waitList {
// Only notify if move count changed
if req.MoveCount != currentMoveCount {
if settled || req.MoveCount != currentMoveCount {
select {
case req.Notify <- struct{}{}:
// Notification sent
default:
// Channel full or closed, skip slow client
}
}
}
@@ -175,4 +171,3 @@ func (w *WaitRegistry) removeWaiter(gameID string, req *WaitRequest) {
// Stop timer if still running
req.Timer.Stop()
}
@@ -120,9 +120,11 @@ function updateAuthIndicator(authenticated) {
if (authenticated) {
light.setAttribute('data-status', 'authenticated');
indicator.setAttribute('data-status', gameState.username);
indicator.setAttribute('data-tooltip', 'Account');
} else {
light.setAttribute('data-status', 'anonymous');
indicator.setAttribute('data-status', 'anonymous');
indicator.setAttribute('data-status', 'click to login');
indicator.setAttribute('data-tooltip', 'Login');
}
}
@@ -438,6 +440,9 @@ function updateTurnIndicator(state, turn) {
status = 'unknown';
tooltipText = 'Game Over';
}
} else if (state === 'stuck') {
status = 'degraded';
tooltipText = 'Engine Error';
} else if (turn === 'w') {
status = 'white';
tooltipText = 'White';
@@ -637,7 +642,10 @@ async function startNewGame() {
initializeBoard();
updateGameDisplay(game);
document.getElementById('undo-btn').disabled = true;
if (!gameState.isPlayerWhite) triggerComputerMove();
const computerTurn = gameState.isPlayerWhite ? 'b' : 'w';
if (isPlayable(game.state) && game.turn === computerTurn) {
triggerComputerMove();
}
setModalMessage('new-game-modal-message', `Game started - you play ${willBePlayerWhite ? 'White' : 'Black'}`, 'success');
setTimeout(hideNewGameModal, MODAL_SUCCESS_DISPLAY_MS);
@@ -711,7 +719,7 @@ function handleSquareClick(e) {
if (gameState.isLocked) return;
// Block moves after game over
if (isGameOver(gameState.state)) return;
if (!isPlayable(gameState.state)) return;
const squareEl = e.currentTarget;
const { square, pieceColor } = squareEl.dataset;
@@ -776,7 +784,7 @@ async function handleHumanMove(from, to) {
flashSquare(fromEl, true);
flashSquare(toEl, true);
updateGameDisplay(game);
if (!isGameOver(game.state)) {
if (isPlayable(game.state)) {
triggerComputerMove();
}
} catch (error) {
@@ -906,6 +914,9 @@ async function undoMoves() {
const game = await response.json();
gameState.state = game.state;
updateGameDisplay(game);
if (game.state === 'stuck') {
flashErrorMessage('Engine error — Undo to recover or start a new game');
}
} catch (error) {
if (error.message === 'Failed to fetch') {
handleApiError('undo', error);
@@ -1021,6 +1032,9 @@ function markMatedKing(game) {
function isGameOver(state) {
return ['white wins', 'black wins', 'stalemate', 'draw'].includes(state);
}
function isPlayable(state) {
return !isGameOver(state) && state !== 'stuck';
}
function handleApiError(action, error, response = null) {
let serverStatus = 'degraded';
@@ -647,18 +647,26 @@ input[type="range"]::-webkit-slider-thumb {
}
/* Auth Indicator */
.auth-indicator {
cursor: pointer;
border: 1px solid transparent;
border-radius: 6px;
transition: border-color .2s, background .2s;
}
.auth-indicator:hover {
border-color: var(--host-royal);
background: rgba(95, 87, 245, 0.15);
}
.auth-indicator .light[data-status="anonymous"] {
color: var(--tokyo-border);
color: var(--tokyo-yellow);
}
.auth-indicator .light[data-status="authenticated"] {
color: var(--tokyo-green);
}
.auth-indicator {
cursor: pointer;
}
/* --- Modal status message (Issue 2) --- */
.modal-message {
display: none;
@@ -871,41 +879,20 @@ input[type="range"]:disabled {
}
@media (max-width: 530px) {
body {
overflow-y: auto;
overflow-x: auto;
min-width: clamp(440px, 100vw, 530px);
body { min-width: 0; overflow-x: hidden; }
.outer-container { width: 100%; min-width: 0; }
.container { width: calc(100% - 16px); min-width: 0; }
.board-container {
width: min(92vw, 440px);
height: min(92vw, 440px);
padding: 12px;
}
.outer-container {
width: clamp(440px, 100vw, 530px);
min-width: clamp(440px, 100vw, 530px);
padding: 8px;
min-height: 100vh;
height: auto;
display: flex;
justify-content: center;
align-items: flex-start;
overflow: visible;
.board-wrapper {
width: calc(min(92vw, 440px) - 24px);
height: calc(min(92vw, 440px) - 24px);
}
.container {
width: calc(100% - 16px);
min-width: clamp(424px, calc(100vw - 16px), 514px);
border-radius: 12px;
min-height: calc(100vh - 16px);
height: auto;
padding: 1rem;
margin: 0;
display: flex;
flex-direction: column;
justify-content: center;
overflow: visible;
}
.board-container,
.info-panel {
width: clamp(360px, 83vw, 440px);
min-width: clamp(360px, 83vw, 440px);
width: min(92vw, 440px);
min-width: 0;
}
}