v0.10.0 fix to engine game state mgmt, client and web ui updates to match
This commit is contained in:
+240
-205
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user