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

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

No files matched your search

+210 -28
View File
@@ -17,6 +17,10 @@ var (
ErrGameNotFound = errors.New("game not found")
ErrGameChanged = errors.New("game changed while move was being validated")
ErrSlotOwner = errors.New("player slot is owned by another user")
ErrGameOver = errors.New("game is over")
ErrNotHuman = errors.New("that side is played by the computer")
ErrNoDrawOffer = errors.New("no draw offer from the opponent")
ErrOfferLimit = errors.New("one draw offer per move: make a move before offering again")
)
type MoveCommit struct {
@@ -27,10 +31,24 @@ type MoveCommit struct {
MoveUCI string
NewFEN string
State core.State
Termination core.Termination // how State was reached when terminal
Result *game.MoveResult
At time.Time
}
// EndCommit ends a game without a move: a resignation or a draw by
// agreement. Color is the side acting (resigning or accepting); an
// authenticated actor claims it if unclaimed, as a first move would.
type EndCommit struct {
ExpectedFEN string
Color core.Color
ActorUserID string
State core.State
Termination core.Termination
// OfferFrom, when set, requires that side's draw offer to still stand.
OfferFrom core.Color
}
// CreateGame registers a new game with pre-constructed players
func (s *Service) CreateGame(
id string,
@@ -38,6 +56,7 @@ func (s *Service) CreateGame(
initialFEN string,
startingTurn core.Color,
initialState core.State,
termination core.Termination,
) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -48,16 +67,15 @@ func (s *Service) CreateGame(
// Check computer game limit
hasComputer := whitePlayer.Type == core.PlayerComputer || blackPlayer.Type == core.PlayerComputer
if hasComputer {
if s.computerGames.Load() >= MaxComputerGames {
return fmt.Errorf("computer game limit reached (%d/%d)", s.computerGames.Load(), MaxComputerGames)
if hasComputer && !initialState.IsTerminal() {
if active := s.activeComputerGamesLocked(); active >= MaxComputerGames {
return fmt.Errorf("computer game limit reached (%d/%d)", active, MaxComputerGames)
}
s.computerGames.Add(1)
}
now := time.Now().UTC()
g := game.New(initialFEN, whitePlayer, blackPlayer, startingTurn)
g.SetStateAt(initialState, now)
g.SetOutcome(initialState, termination, now)
s.games[id] = g
// Persist if storage enabled
@@ -77,6 +95,7 @@ func (s *Service) CreateGame(
BlackSearchTime: blackPlayer.SearchTime,
BlackClaimedBy: blackPlayer.ClaimedBy,
Result: result,
Termination: string(g.Termination()),
StartTimeUTC: now,
EndTimeUTC: g.EndTimeUTC(),
}
@@ -106,8 +125,10 @@ func (s *Service) UpdatePlayers(gameID string, whitePlayer, blackPlayer *core.Pl
oldBlack := g.GetPlayer(core.ColorBlack)
oldHasComputer := g.HasComputerPlayer()
newHasComputer := whitePlayer.Type == core.PlayerComputer || blackPlayer.Type == core.PlayerComputer
if !oldHasComputer && newHasComputer && s.computerGames.Load() >= MaxComputerGames {
return fmt.Errorf("computer game limit reached (%d/%d)", s.computerGames.Load(), MaxComputerGames)
if !oldHasComputer && newHasComputer && !g.State().IsTerminal() {
if active := s.activeComputerGamesLocked(); active >= MaxComputerGames {
return fmt.Errorf("computer game limit reached (%d/%d)", active, MaxComputerGames)
}
}
// Player configuration is mutable, but historical user association is not.
@@ -127,14 +148,8 @@ func (s *Service) UpdatePlayers(gameID string, whitePlayer, blackPlayer *core.Pl
}
g.UpdatePlayers(whitePlayer, blackPlayer)
g.ClearDrawOffers() // an offer made to the previous opponent lapses
g.Touch(time.Now().UTC())
if oldHasComputer != newHasComputer {
if newHasComputer {
s.computerGames.Add(1)
} else {
s.computerGames.Add(-1)
}
}
if s.store != nil {
err := s.store.RecordPlayers(gameID, playerRecord(whitePlayer), playerRecord(blackPlayer))
if err != nil {
@@ -239,8 +254,11 @@ func (s *Service) ApplyMoveWithState(gameID string, commit MoveCommit) error {
return err
}
}
if commit.State.IsTerminal() && !commit.Termination.ValidFor(commit.State) {
return fmt.Errorf("termination %q cannot produce %s", commit.Termination, commit.State)
}
g.AddSnapshot(commit.NewFEN, commit.MoveUCI, core.OppositeColor(currentTurn))
g.SetStateAt(commit.State, at)
g.SetOutcome(commit.State, commit.Termination, at)
if commit.Result != nil {
g.SetLastResult(commit.Result)
}
@@ -252,10 +270,11 @@ func (s *Service) ApplyMoveWithState(gameID string, commit MoveCommit) error {
GameID: gameID, MoveNumber: len(g.Moves()), MoveUCI: commit.MoveUCI,
FENAfterMove: commit.NewFEN, PlayerColor: currentTurn.String(), MoveTimeUTC: at,
},
ClaimColor: currentTurn.String(),
ClaimedBy: claimUserID,
Result: result,
EndTimeUTC: g.EndTimeUTC(),
ClaimColor: currentTurn.String(),
ClaimedBy: claimUserID,
Result: result,
Termination: string(g.Termination()),
EndTimeUTC: g.EndTimeUTC(),
}
if claimUserID == "" {
persistence.ClaimColor = ""
@@ -277,8 +296,9 @@ func (s *Service) ApplyMoveWithState(gameID string, commit MoveCommit) error {
return nil
}
// UpdateGameState sets the game's end state (checkmate, stalemate, etc)
func (s *Service) UpdateGameState(gameID string, state core.State) error {
// UpdateGameState sets an operational state (pending, stuck) or a terminal
// state found without a move, such as an engine reporting no legal move.
func (s *Service) UpdateGameState(gameID string, state core.State, termination core.Termination) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -286,13 +306,18 @@ func (s *Service) UpdateGameState(gameID string, state core.State) error {
if !ok {
return fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
}
if state.IsTerminal() && !termination.ValidFor(state) {
return fmt.Errorf("termination %q cannot produce %s", termination, state)
}
previousState := g.State()
now := time.Now().UTC()
g.SetStateAt(state, now)
g.SetOutcome(state, termination, now)
if s.store != nil && state.IsTerminal() && !previousState.IsTerminal() {
result, _ := state.Result()
if err := s.store.RecordGameResult(gameID, result, now); err != nil {
if err := s.store.RecordGameEnd(storage.GameEnd{
GameID: gameID, Result: result, Termination: string(termination), EndTimeUTC: now,
}); err != nil {
slog.Error("failed to queue game result persistence", "game_id", gameID, "error", err)
}
}
@@ -303,6 +328,168 @@ func (s *Service) UpdateGameState(gameID string, state core.State) error {
return nil
}
// EndGame applies a resignation or an agreed draw. It re-checks, under the
// service lock, everything the processor validated: the game is unfinished
// and unchanged, the acting side is human and the actor may play it, and an
// accepted offer still stands.
func (s *Service) EndGame(gameID string, commit EndCommit) error {
s.mu.Lock()
defer s.mu.Unlock()
g, ok := s.games[gameID]
if !ok {
return fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
}
if g.State().IsTerminal() {
return ErrGameOver
}
if !commit.State.IsTerminal() || !commit.Termination.ValidFor(commit.State) {
return fmt.Errorf("termination %q cannot produce %s", commit.Termination, commit.State)
}
if g.CurrentFEN() != commit.ExpectedFEN {
return ErrGameChanged
}
if commit.OfferFrom != 0 && g.DrawOffer() != commit.OfferFrom {
return ErrNoDrawOffer
}
claimUserID, err := actorClaim(g, commit.Color, commit.ActorUserID)
if err != nil {
return err
}
now := time.Now().UTC()
if claimUserID != "" {
if err := g.ClaimSlot(commit.Color, claimUserID); err != nil {
return err
}
}
g.SetOutcome(commit.State, commit.Termination, now)
if s.store != nil {
result, _ := commit.State.Result()
end := storage.GameEnd{
GameID: gameID, Result: result, Termination: string(commit.Termination), EndTimeUTC: now,
}
if claimUserID != "" {
end.ClaimColor, end.ClaimedBy = commit.Color.String(), claimUserID
}
if err := s.store.RecordGameEnd(end); err != nil {
slog.Error("failed to queue game end persistence", "game_id", gameID, "error", err)
}
}
s.waiter.NotifyGame(gameID, len(g.Moves()), commit.State)
slog.Debug("game ended without a move", "game_id", gameID,
"state", commit.State.String(), "termination", commit.Termination, "side", commit.Color.String())
return nil
}
// actorClaim authorizes actor to act for color and returns the user ID to
// record as the slot's claim, empty when nothing is to be claimed.
func actorClaim(g *game.Game, color core.Color, actor string) (string, error) {
player := g.GetPlayer(color)
if player == nil {
return "", errors.New("invalid color")
}
if player.Type != core.PlayerHuman {
return "", ErrNotHuman
}
owner := g.GetSlotOwner(color)
switch {
case owner != "" && owner != actor:
return "", ErrSlotOwner
case owner == "" && actor != "":
return actor, nil
}
return "", nil
}
// OfferDraw records color's offer for a human opponent to answer. Repeating
// a standing offer is a no-op; a new offer needs a move since the last one.
func (s *Service) OfferDraw(gameID string, color core.Color, actor, expectedFEN string) error {
s.mu.Lock()
defer s.mu.Unlock()
g, err := s.drawTarget(gameID, color, actor, expectedFEN)
if err != nil {
return err
}
if g.DrawOffer() == color {
return nil
}
if err := offerAllowed(g, color); err != nil {
return err
}
g.OfferDraw(color)
g.Touch(time.Now().UTC())
s.waiter.NotifyAll(gameID)
slog.Debug("draw offered", "game_id", gameID, "side", color.String())
return nil
}
// RecordDeclinedOffer counts an offer the computer answered at once against
// color's one-offer-per-move allowance.
func (s *Service) RecordDeclinedOffer(gameID string, color core.Color, actor, expectedFEN string) error {
s.mu.Lock()
defer s.mu.Unlock()
g, err := s.drawTarget(gameID, color, actor, expectedFEN)
if err != nil {
return err
}
if err := offerAllowed(g, color); err != nil {
return err
}
g.RecordDeclinedOffer(color)
g.Touch(time.Now().UTC())
slog.Debug("draw offer declined by the computer", "game_id", gameID, "side", color.String())
return nil
}
// DeclineDraw lets color turn down the opponent's standing offer.
func (s *Service) DeclineDraw(gameID string, color core.Color, actor string) error {
s.mu.Lock()
defer s.mu.Unlock()
g, err := s.drawTarget(gameID, color, actor, "")
if err != nil {
return err
}
if g.DrawOffer() != core.OppositeColor(color) {
return ErrNoDrawOffer
}
g.DeclineDraw()
g.Touch(time.Now().UTC())
s.waiter.NotifyAll(gameID)
slog.Debug("draw declined", "game_id", gameID, "side", color.String())
return nil
}
// drawTarget returns an unfinished game whose side color the actor may play,
// optionally still at expectedFEN. Caller holds s.mu.
func (s *Service) drawTarget(gameID string, color core.Color, actor, expectedFEN string) (*game.Game, error) {
g, ok := s.games[gameID]
if !ok {
return nil, fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
}
if g.State().IsTerminal() {
return nil, ErrGameOver
}
if g.State() != core.StateOngoing || (expectedFEN != "" && g.CurrentFEN() != expectedFEN) {
return nil, ErrGameChanged
}
if _, err := actorClaim(g, color, actor); err != nil {
return nil, err
}
return g, nil
}
func offerAllowed(g *game.Game, color core.Color) error {
if last := g.LastOfferPly(color); last >= 0 && g.Plies() < last+2 {
return ErrOfferLimit
}
return nil
}
// UndoMoves removes the specified number of moves from game history
func (s *Service) UndoMoves(gameID string, count int) error {
s.mu.Lock()
@@ -351,11 +538,6 @@ func (s *Service) DeleteGame(gameID string) error {
return errors.New("cannot delete game while computer move is in progress")
}
// Decrement computer game count if applicable
if g.HasComputerPlayer() {
s.computerGames.Add(-1)
}
// Remove from wait registry
s.waiter.RemoveGame(gameID)