v0.11.0 harden persistence and prepare game replays

This commit is contained in:
2026-09-07 14:39:00 -04:00
parent 21dea47694
commit 5d2be4abb2
42 changed files with 3591 additions and 693 deletions
+85 -65
View File
@@ -1,8 +1,9 @@
package processor
import (
"errors"
"fmt"
"log"
"log/slog"
"regexp"
"strings"
"sync"
@@ -185,14 +186,11 @@ func (p *Processor) handleCreateGame(cmd Command) ProcessorResponse {
}
}
if err = p.svc.CreateGame(gameID, whitePlayer, blackPlayer, validatedFEN, b.Turn()); err != nil {
if err = p.svc.CreateGame(gameID, whitePlayer, blackPlayer, validatedFEN, b.Turn(), initialState); err != nil {
return p.errorResponse(fmt.Sprintf("failed to create game: %v", err), core.ErrInternalError)
}
if initialState != core.StateOngoing {
p.svc.UpdateGameState(gameID, initialState)
}
g, err := p.svc.GetGame(gameID)
g, err := p.svc.GetGameView(gameID)
if err != nil {
return p.errorResponse("game creation failed", core.ErrInternalError)
}
@@ -217,13 +215,13 @@ func (p *Processor) handleConfigurePlayers(cmd Command) ProcessorResponse {
args.Black.SearchTime = minSearchTime
}
g, err := p.svc.GetGame(cmd.GameID)
g, err := p.svc.GetGameView(cmd.GameID)
if err != nil {
return p.errorResponse("game not found", core.ErrGameNotFound)
}
// Block configuration changes during computer move
if g.State() == core.StatePending {
if g.State == core.StatePending {
return p.errorResponse("cannot change players while computer is calculating", core.ErrInvalidRequest)
}
@@ -237,7 +235,7 @@ func (p *Processor) handleConfigurePlayers(cmd Command) ProcessorResponse {
}
// Get updated game
g, _ = p.svc.GetGame(cmd.GameID)
g, _ = p.svc.GetGameView(cmd.GameID)
response := p.buildGameResponse(cmd.GameID, g)
return ProcessorResponse{
@@ -248,7 +246,7 @@ func (p *Processor) handleConfigurePlayers(cmd Command) ProcessorResponse {
// handleGetGame retrieves game state and triggers computer move if needed
func (p *Processor) handleGetGame(cmd Command) ProcessorResponse {
g, err := p.svc.GetGame(cmd.GameID)
g, err := p.svc.GetGameView(cmd.GameID)
if err != nil {
return p.errorResponse("game not found", core.ErrGameNotFound)
}
@@ -272,27 +270,30 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
return p.errorResponse("invalid arguments", core.ErrInvalidRequest)
}
g, err := p.svc.GetGame(cmd.GameID)
g, err := p.svc.GetGameView(cmd.GameID)
if err != nil {
return p.errorResponse("game not found", core.ErrGameNotFound)
}
// Validate game state
switch g.State() {
switch g.State {
case core.StatePending:
return p.errorResponse("computer move in progress", core.ErrInvalidRequest)
case core.StateStuck:
return p.errorResponse("game is stuck due to engine error", core.ErrGameOver)
case core.StateWhiteWins, core.StateBlackWins, core.StateDraw, core.StateStalemate:
return p.errorResponse(fmt.Sprintf("game is over: %s", g.State()), core.ErrGameOver)
return p.errorResponse(fmt.Sprintf("game is over: %s", g.State), core.ErrGameOver)
case core.StateOngoing:
break
default:
return p.errorResponse("game is in invalid state", core.ErrInvalidRequest)
}
currentColor := g.NextTurnColor()
currentColor := g.NextTurnColor
currentPlayer := g.NextPlayer()
if currentPlayer == nil {
return p.errorResponse("current player is missing", core.ErrInternalError)
}
// Handle computer move trigger
if strings.TrimSpace(args.Move) == "cccc" {
@@ -300,10 +301,18 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
return p.errorResponse("not computer player's turn", core.ErrNotHumanTurn)
}
p.svc.UpdateGameState(cmd.GameID, core.StatePending)
p.triggerComputerMove(cmd.GameID, g)
if err := p.svc.BeginComputerMove(cmd.GameID, g.FEN, currentColor); err != nil {
if errors.Is(err, service.ErrGameChanged) {
return p.errorResponse("game changed; refresh and retry", core.ErrConflict)
}
return p.errorResponse(fmt.Sprintf("failed to start computer move: %v", err), core.ErrInternalError)
}
if err := p.triggerComputerMove(cmd.GameID, g); err != nil {
p.svc.UpdateGameState(cmd.GameID, core.StateStuck)
return p.errorResponse(fmt.Sprintf("failed to queue computer move: %v", err), core.ErrResourceLimit)
}
g, _ = p.svc.GetGame(cmd.GameID)
g, _ = p.svc.GetGameView(cmd.GameID)
response := p.buildGameResponse(cmd.GameID, g)
response.LastMove = &core.MoveInfo{
PlayerColor: currentColor.String(),
@@ -322,16 +331,11 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
}
// Authorization: first-move-claims-slot model
slotOwner := g.GetSlotOwner(currentColor)
slotOwner := currentPlayer.ClaimedBy
if slotOwner == "" {
// Slot unclaimed - claim it with this move
if cmd.UserID != "" {
if err := p.svc.ClaimGameSlot(cmd.GameID, currentColor, cmd.UserID); err != nil {
return p.errorResponse(fmt.Sprintf("failed to claim slot: %v", err), core.ErrInternalError)
}
}
// Anonymous users can also claim by making a move (slot remains "unclaimed" but move proceeds)
// An authenticated user claims only when the validated move commits.
// Anonymous moves deliberately leave the slot unclaimed.
} else if cmd.UserID != "" && slotOwner != cmd.UserID {
return p.errorResponse("not your turn - slot claimed by another player", core.ErrUnauthorized)
}
@@ -345,7 +349,7 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
return p.errorResponse("invalid move format", core.ErrInvalidMove)
}
currentFEN := g.CurrentFEN()
currentFEN := g.FEN
// Validate move and classify the resulting position in one engine session
p.mu.Lock()
@@ -365,16 +369,22 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
}
// 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,
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,
Result: &game.MoveResult{Move: move, PlayerColor: currentColor, GameState: finalState},
}); err != nil {
if errors.Is(err, service.ErrSlotOwner) {
return p.errorResponse("not your turn - slot claimed by another player", core.ErrUnauthorized)
}
if errors.Is(err, service.ErrGameChanged) {
return p.errorResponse("game changed while move was being validated; refresh and retry", core.ErrConflict)
}
return p.errorResponse(fmt.Sprintf("failed to apply move: %v", err), core.ErrInternalError)
}
// buildGameResponse populates LastMove from the committed LastResult
g, _ = p.svc.GetGame(cmd.GameID)
g, _ = p.svc.GetGameView(cmd.GameID)
return ProcessorResponse{
Success: true,
Data: p.buildGameResponse(cmd.GameID, g),
@@ -387,12 +397,12 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
// 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)
g, err := p.svc.GetGameView(cmd.GameID)
if err != nil {
return p.errorResponse("game not found", core.ErrGameNotFound)
}
if g.State() == core.StatePending {
if g.State == core.StatePending {
return p.errorResponse("cannot undo while computer move is in progress", core.ErrInvalidRequest)
}
@@ -410,25 +420,22 @@ func (p *Processor) handleUndoMove(cmd Command) ProcessorResponse {
return p.errorResponse(err.Error(), core.ErrInvalidRequest)
}
// Reset game state to ongoing after undo
p.svc.UpdateGameState(cmd.GameID, core.StateOngoing)
g, _ = p.svc.GetGame(cmd.GameID)
g, _ = p.svc.GetGameView(cmd.GameID)
return ProcessorResponse{
Success: true,
Data: p.buildGameResponse(cmd.GameID, g),
}
}
// handleDeleteGame removes a game
// handleDeleteGame unloads a game from live memory.
func (p *Processor) handleDeleteGame(cmd Command) ProcessorResponse {
g, err := p.svc.GetGame(cmd.GameID)
g, err := p.svc.GetGameView(cmd.GameID)
if err != nil {
return p.errorResponse("game not found", core.ErrGameNotFound)
}
// Only block deletion if actively computing
if g.State() == core.StatePending {
if g.State == core.StatePending {
return p.errorResponse("cannot delete game while computer move is in progress", core.ErrInvalidRequest)
}
@@ -443,12 +450,12 @@ func (p *Processor) handleDeleteGame(cmd Command) ProcessorResponse {
// handleGetBoard returns board visualization
func (p *Processor) handleGetBoard(cmd Command) ProcessorResponse {
g, err := p.svc.GetGame(cmd.GameID)
g, err := p.svc.GetGameView(cmd.GameID)
if err != nil {
return p.errorResponse("game not found", core.ErrGameNotFound)
}
b, err := board.ParseFEN(g.CurrentFEN())
b, err := board.ParseFEN(g.FEN)
if err != nil {
return p.errorResponse("error parsing FEN", core.ErrInvalidFEN)
}
@@ -457,7 +464,7 @@ func (p *Processor) handleGetBoard(cmd Command) ProcessorResponse {
return ProcessorResponse{
Success: true,
Data: core.BoardResponse{
FEN: g.CurrentFEN(),
FEN: g.FEN,
Board: ascii,
},
}
@@ -467,18 +474,18 @@ func (p *Processor) handleGetBoard(cmd Command) ProcessorResponse {
// 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()
func (p *Processor) triggerComputerMove(gameID string, g game.View) error {
fen := g.FEN
color := g.NextTurnColor
player := g.NextPlayer()
p.queue.SubmitAsync(gameID, fen, color, player, func(result EngineResult) {
currentGame, err := p.svc.GetGame(gameID)
if err != nil || currentGame.State() != core.StatePending {
return p.queue.SubmitAsync(gameID, fen, color, player, func(result EngineResult) {
currentGame, err := p.svc.GetGameView(gameID)
if err != nil || currentGame.State != core.StatePending || currentGame.FEN != fen {
return // Deleted, or state resolved elsewhere
}
if result.Error != nil {
log.Printf("engine error for game %s: %v", gameID, result.Error)
slog.Error("computer engine failed", "game_id", gameID, "error", result.Error)
p.svc.UpdateGameState(gameID, core.StateStuck)
return
}
@@ -508,10 +515,19 @@ func (p *Processor) triggerComputerMove(gameID string, g *game.Game) {
return
}
p.svc.ApplyMoveWithState(gameID, result.Move, newFEN, finalState, &game.MoveResult{
Move: result.Move, PlayerColor: color,
Score: result.Score, Depth: result.Depth, GameState: finalState,
})
if err := p.svc.ApplyMoveWithState(gameID, service.MoveCommit{
ExpectedFEN: fen, ExpectedState: core.StatePending, ExpectedTurn: color,
MoveUCI: result.Move, NewFEN: newFEN, State: finalState,
Result: &game.MoveResult{
Move: result.Move, PlayerColor: color,
Score: result.Score, Depth: result.Depth, GameState: finalState,
},
}); err != nil {
slog.Error("failed to apply computer move", "game_id", gameID, "error", err)
if !errors.Is(err, service.ErrGameChanged) && !errors.Is(err, service.ErrGameNotFound) {
p.svc.UpdateGameState(gameID, core.StateStuck)
}
}
})
}
@@ -583,27 +599,28 @@ func (p *Processor) checkGameEnd(gameID, fen string) {
}
return
}
log.Printf("game %s: end-state check attempt %d failed: %v", gameID, attempt+1, err)
slog.Warn("game end-state check failed",
"game_id", gameID, "attempt", attempt+1, "error", err)
}
p.svc.UpdateGameState(gameID, core.StateStuck)
}
// buildGameResponse constructs standard game response
func (p *Processor) buildGameResponse(gameID string, g *game.Game) core.GameResponse {
func (p *Processor) buildGameResponse(gameID string, g game.View) core.GameResponse {
resp := core.GameResponse{
GameID: gameID,
FEN: g.CurrentFEN(),
Turn: g.NextTurnColor().String(),
State: g.State().String(),
Moves: g.Moves(),
FEN: g.FEN,
Turn: g.NextTurnColor.String(),
State: g.State.String(),
Moves: g.Moves,
Players: core.PlayersResponse{
White: g.GetPlayer(core.ColorWhite),
Black: g.GetPlayer(core.ColorBlack),
White: g.WhitePlayer,
Black: g.BlackPlayer,
},
}
// Include last move if available
if result := g.LastResult(); result != nil {
if result := g.LastResult; result != nil {
resp.LastMove = &core.MoveInfo{
Move: result.Move,
PlayerColor: result.PlayerColor.String(),
@@ -628,6 +645,9 @@ func (p *Processor) errorResponse(message, code string) ProcessorResponse {
// Close cleans up resources
func (p *Processor) Close() error {
p.queue.Shutdown(5 * time.Second)
return p.validationEng.Close()
queueErr := p.queue.Shutdown(5 * time.Second)
p.mu.Lock()
engineErr := p.validationEng.Close()
p.mu.Unlock()
return errors.Join(queueErr, engineErr)
}
+59 -11
View File
@@ -3,7 +3,7 @@ package processor
import (
"context"
"fmt"
"log"
"log/slog"
"sync"
"time"
@@ -33,11 +33,15 @@ type EngineResult struct {
// EngineQueue manages async engine computations
type EngineQueue struct {
tasks chan EngineTask
workers int
wg sync.WaitGroup
ctx context.Context
cancel context.CancelFunc
tasks chan EngineTask
workers int
wg sync.WaitGroup
callbackWG sync.WaitGroup
ctx context.Context
cancel context.CancelFunc
submitMu sync.RWMutex
closed bool
shutdownOnce sync.Once
}
// NewEngineQueue creates a queue with specified worker count
@@ -76,7 +80,7 @@ func (q *EngineQueue) worker(id int) {
if eng, err = engine.New(); err == nil {
break
}
log.Printf("worker %d: engine init failed: %v; retrying", id, err)
slog.Warn("engine worker initialization failed; retrying", "worker", id, "error", err)
select {
case <-q.ctx.Done():
return
@@ -84,6 +88,7 @@ func (q *EngineQueue) worker(id int) {
}
}
defer eng.Close()
slog.Debug("engine worker started", "worker", id)
for {
select {
case task, ok := <-q.tasks:
@@ -99,6 +104,10 @@ func (q *EngineQueue) worker(id int) {
// processTask executes a single engine calculation
func (q *EngineQueue) processTask(eng *engine.UCI, task EngineTask) EngineResult {
started := time.Now()
defer func() {
slog.Debug("engine task completed", "game_id", task.GameID, "duration", time.Since(started))
}()
result := EngineResult{GameID: task.GameID}
if err := eng.NewGame(); err != nil {
result.Error = err
@@ -134,8 +143,18 @@ func (q *EngineQueue) processTask(eng *engine.UCI, task EngineTask) EngineResult
// Submit adds a task to the queue
func (q *EngineQueue) Submit(task EngineTask) error {
q.submitMu.RLock()
defer q.submitMu.RUnlock()
return q.submitLocked(task)
}
func (q *EngineQueue) submitLocked(task EngineTask) error {
if q.closed {
return fmt.Errorf("queue is shutting down")
}
select {
case q.tasks <- task:
slog.Debug("engine task queued", "game_id", task.GameID, "queue_depth", len(q.tasks))
return nil
case <-q.ctx.Done():
return fmt.Errorf("queue is shutting down")
@@ -146,8 +165,24 @@ 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 {
if player == nil {
return fmt.Errorf("computer player is missing")
}
if callback == nil {
return fmt.Errorf("engine callback is missing")
}
respChan := make(chan EngineResult, 1)
if err := q.Submit(EngineTask{GameID: gameID, FEN: fen, Color: color, Player: player, Response: respChan}); err != nil {
q.submitMu.RLock()
err := q.submitLocked(EngineTask{
GameID: gameID, FEN: fen, Color: color, Player: player, Response: respChan,
})
if err == nil {
// Registered while the submit lock is held, so Shutdown cannot begin
// waiting between a successful send and this Add.
q.callbackWG.Add(1)
}
q.submitMu.RUnlock()
if err != nil {
return err
}
budget := 1000
@@ -156,11 +191,18 @@ func (q *EngineQueue) SubmitAsync(gameID, fen string, color core.Color, player *
}
wait := time.Duration(budget)*time.Millisecond*2 + 30*time.Second // search budget + queue-wait headroom
go func() {
defer q.callbackWG.Done()
timer := time.NewTimer(wait)
defer timer.Stop()
select {
case result := <-respChan:
callback(result)
case <-time.After(wait):
case <-timer.C:
callback(EngineResult{GameID: gameID, Error: fmt.Errorf("engine timeout")})
case <-q.ctx.Done():
// Live state is intentionally abandoned during server shutdown. The
// last fully committed position remains the durable replay boundary.
return
}
}()
return nil
@@ -168,12 +210,18 @@ func (q *EngineQueue) SubmitAsync(gameID, fen string, color core.Color, player *
// Shutdown gracefully stops the queue
func (q *EngineQueue) Shutdown(timeout time.Duration) error {
q.cancel()
close(q.tasks)
q.shutdownOnce.Do(func() {
q.submitMu.Lock()
q.closed = true
q.cancel()
close(q.tasks)
q.submitMu.Unlock()
})
done := make(chan struct{})
go func() {
q.wg.Wait()
q.callbackWG.Wait()
close(done)
}()
+40
View File
@@ -0,0 +1,40 @@
package processor
import (
"context"
"sync/atomic"
"testing"
"time"
"chess/internal/server/core"
)
func TestEngineQueueShutdownCancelsAndWaitsForCallback(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
queue := &EngineQueue{
tasks: make(chan EngineTask, 1),
workers: 1,
ctx: ctx,
cancel: cancel,
}
player := &core.Player{Type: core.PlayerComputer, SearchTime: 10_000}
var callbackCalled atomic.Bool
if err := queue.SubmitAsync("game-1", "fen", core.ColorWhite, player, func(EngineResult) {
callbackCalled.Store(true)
}); err != nil {
t.Fatal(err)
}
if err := queue.Shutdown(time.Second); err != nil {
t.Fatal(err)
}
if callbackCalled.Load() {
t.Fatal("shutdown callback mutated live state after cancellation")
}
if err := queue.Submit(EngineTask{}); err == nil {
t.Fatal("submit succeeded after shutdown")
}
if err := queue.Shutdown(time.Second); err != nil {
t.Fatalf("second shutdown was not idempotent: %v", err)
}
}