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

+245
View File
@@ -0,0 +1,245 @@
package service
import (
"database/sql"
"errors"
"testing"
"time"
"chess/internal/server/chess"
"chess/internal/server/core"
"github.com/google/uuid"
)
// newHumanGame creates a human-vs-human game at the standard start.
func newHumanGame(t *testing.T, svc *Service) string {
t.Helper()
gameID := uuid.NewString()
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
if err := svc.CreateGame(gameID, white, black, chess.StartFEN, core.ColorWhite,
core.StateOngoing, core.TermNone); err != nil {
t.Fatal(err)
}
return gameID
}
// move commits one legal move for the side to move.
func move(t *testing.T, svc *Service, gameID, actor, uci string) {
t.Helper()
view, err := svc.GetGameView(gameID)
if err != nil {
t.Fatal(err)
}
pos, err := chess.ParseFEN(view.FEN)
if err != nil {
t.Fatal(err)
}
m, err := pos.ParseUCI(uci)
if err != nil {
t.Fatal(err)
}
if err := svc.ApplyMoveWithState(gameID, MoveCommit{
ExpectedFEN: view.FEN, ExpectedState: core.StateOngoing, ExpectedTurn: view.NextTurnColor,
ActorUserID: actor, MoveUCI: uci, NewFEN: pos.Play(m).FEN(), State: core.StateOngoing,
}); err != nil {
t.Fatal(err)
}
}
func TestResignationClaimsAndPersists(t *testing.T) {
svc := newPersistentTestService(t)
gameID, alice, bob := newHumanGame(t, svc), uuid.NewString(), uuid.NewString()
move(t, svc, gameID, alice, "e2e4") // alice claims white
view, _ := svc.GetGameView(gameID)
resign := EndCommit{
ExpectedFEN: view.FEN, Color: core.ColorWhite, ActorUserID: bob,
State: core.StateBlackWins, Termination: core.TermResignation,
}
if err := svc.EndGame(gameID, resign); !errors.Is(err, ErrSlotOwner) {
t.Fatalf("resigning another user's side: %v, want ErrSlotOwner", err)
}
stale := resign
stale.ActorUserID, stale.ExpectedFEN = alice, chess.StartFEN
if err := svc.EndGame(gameID, stale); !errors.Is(err, ErrGameChanged) {
t.Fatalf("stale position: %v, want ErrGameChanged", err)
}
wrong := resign
wrong.ActorUserID, wrong.Termination = alice, core.TermAgreement
if err := svc.EndGame(gameID, wrong); err == nil {
t.Fatal("a win by agreement was accepted")
}
// Black resigns anonymously; an authenticated resignation would claim it.
resign.Color, resign.ActorUserID, resign.State = core.ColorBlack, bob, core.StateWhiteWins
if err := svc.EndGame(gameID, resign); err != nil {
t.Fatal(err)
}
if err := svc.EndGame(gameID, resign); !errors.Is(err, ErrGameOver) {
t.Fatalf("second resignation: %v, want ErrGameOver", err)
}
view, _ = svc.GetGameView(gameID)
if view.State != core.StateWhiteWins || view.Termination != core.TermResignation ||
view.BlackPlayer.ClaimedBy != bob || view.EndTimeUTC == nil {
t.Fatalf("view after resignation: %+v", view)
}
history, err := svc.GetGameHistory(gameID)
if err != nil {
t.Fatal(err)
}
if history.Result != "white_wins" || history.Termination != "resignation" ||
history.Players.Black.ClaimedBy != bob || history.PGNResult != "1-0" {
t.Fatalf("history after resignation: %+v", history)
}
// Undo after a result rewinds it (D6), in memory and in storage.
if err := svc.UndoMoves(gameID, 1); err != nil {
t.Fatal(err)
}
if history, _ = svc.GetGameHistory(gameID); history.Result != "" || history.Termination != "" {
t.Fatalf("undo kept the resignation: %+v", history)
}
}
func TestDrawOffersBetweenHumans(t *testing.T) {
svc := newPersistentTestService(t)
gameID := newHumanGame(t, svc)
move(t, svc, gameID, "", "e2e4")
view, _ := svc.GetGameView(gameID)
if err := svc.OfferDraw(gameID, core.ColorBlack, "", view.FEN); err != nil {
t.Fatal(err)
}
if err := svc.OfferDraw(gameID, core.ColorBlack, "", view.FEN); err != nil {
t.Fatalf("repeating a standing offer: %v", err)
}
if view, _ = svc.GetGameView(gameID); view.DrawOffer != core.ColorBlack {
t.Fatalf("offer not recorded: %+v", view)
}
if err := svc.DeclineDraw(gameID, core.ColorBlack, ""); !errors.Is(err, ErrNoDrawOffer) {
t.Fatalf("declining one's own offer: %v", err)
}
if err := svc.DeclineDraw(gameID, core.ColorWhite, ""); err != nil {
t.Fatal(err)
}
// One offer per move: black must move before offering again.
if err := svc.OfferDraw(gameID, core.ColorBlack, "", view.FEN); !errors.Is(err, ErrOfferLimit) {
t.Fatalf("second offer in one move: %v, want ErrOfferLimit", err)
}
move(t, svc, gameID, "", "e7e5")
move(t, svc, gameID, "", "g1f3")
view, _ = svc.GetGameView(gameID)
if err := svc.OfferDraw(gameID, core.ColorBlack, "", view.FEN); err != nil {
t.Fatalf("offer after a move: %v", err)
}
// The offerer's own move keeps the offer standing ...
move(t, svc, gameID, "", "b8c6")
if view, _ = svc.GetGameView(gameID); view.DrawOffer != core.ColorBlack {
t.Fatalf("offer lapsed on the offerer's own move: %v", view.DrawOffer)
}
// ... and the recipient's move instead of an answer declines it.
move(t, svc, gameID, "", "f1c4")
if view, _ = svc.GetGameView(gameID); view.DrawOffer != 0 {
t.Fatalf("offer survived the recipient's move: %v", view.DrawOffer)
}
if err := svc.OfferDraw(gameID, core.ColorWhite, "", view.FEN); err != nil {
t.Fatal(err)
}
view, _ = svc.GetGameView(gameID)
accept := EndCommit{
ExpectedFEN: view.FEN, Color: core.ColorBlack,
State: core.StateDraw, Termination: core.TermAgreement, OfferFrom: core.ColorWhite,
}
if err := svc.EndGame(gameID, accept); err != nil {
t.Fatal(err)
}
history, err := svc.GetGameHistory(gameID)
if err != nil {
t.Fatal(err)
}
if history.Result != "draw" || history.Termination != "agreement" || history.PGNResult != "1/2-1/2" {
t.Fatalf("history after agreement: %+v", history)
}
}
func TestOfferToMissingOfferIsRejected(t *testing.T) {
svc := newPersistentTestService(t)
gameID := newHumanGame(t, svc)
view, _ := svc.GetGameView(gameID)
err := svc.EndGame(gameID, EndCommit{
ExpectedFEN: view.FEN, Color: core.ColorBlack,
State: core.StateDraw, Termination: core.TermAgreement, OfferFrom: core.ColorWhite,
})
if !errors.Is(err, ErrNoDrawOffer) {
t.Fatalf("accepting without an offer: %v, want ErrNoDrawOffer", err)
}
}
func TestGameWithDeletedRowIsUnloadedNotDegraded(t *testing.T) {
svc, dsn := newPersistentTestServiceDSN(t)
gameID := newHumanGame(t, svc)
if _, err := svc.GetGameHistory(gameID); err != nil { // flushes the insert
t.Fatal(err)
}
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec(`DELETE FROM games WHERE game_id = $1`, gameID); err != nil {
t.Fatal(err)
}
move(t, svc, gameID, "", "e2e4") // accepted in memory; its write finds no row
deadline := time.Now().Add(5 * time.Second)
for {
if _, err := svc.GetGameView(gameID); errors.Is(err, ErrGameNotFound) {
break
}
if time.Now().After(deadline) {
t.Fatal("game with a deleted row is still loaded")
}
time.Sleep(10 * time.Millisecond)
}
if health := svc.GetStorageHealth(); health != "ok" {
t.Fatalf("storage health %q after a write for a deleted game", health)
}
}
func TestFinishedGamesDoNotCountAgainstComputerLimit(t *testing.T) {
svc := newPersistentTestService(t)
newComputerGame := func() (string, error) {
gameID := uuid.NewString()
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerComputer, Level: 1, SearchTime: 100}, core.ColorBlack)
return gameID, svc.CreateGame(gameID, white, black, chess.StartFEN, core.ColorWhite,
core.StateOngoing, core.TermNone)
}
var games []string
for i := 0; i < MaxComputerGames; i++ {
gameID, err := newComputerGame()
if err != nil {
t.Fatal(err)
}
games = append(games, gameID)
}
if _, err := newComputerGame(); err == nil {
t.Fatal("computer game limit not enforced")
}
// Resigning one frees its slot while the game stays loaded.
if err := svc.EndGame(games[0], EndCommit{
ExpectedFEN: chess.StartFEN, Color: core.ColorWhite,
State: core.StateBlackWins, Termination: core.TermResignation,
}); err != nil {
t.Fatal(err)
}
if got := svc.GetComputerGameCount(); got != MaxComputerGames-1 {
t.Fatalf("active computer games = %d, want %d", got, MaxComputerGames-1)
}
if _, err := newComputerGame(); err != nil {
t.Fatalf("finished game still counted: %v", err)
}
}
+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)
+18 -9
View File
@@ -19,7 +19,7 @@ func TestMoveCommitClaimsSlotPersistsResultAndRejectsStalePosition(t *testing.T)
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
if err := svc.CreateGame(
gameID, white, black, "initial", core.ColorWhite, core.StateOngoing,
gameID, white, black, "initial", core.ColorWhite, core.StateOngoing, core.TermNone,
); err != nil {
t.Fatal(err)
}
@@ -27,7 +27,7 @@ func TestMoveCommitClaimsSlotPersistsResultAndRejectsStalePosition(t *testing.T)
ended := time.Date(2026, 9, 7, 2, 3, 4, 0, time.UTC)
commit := MoveCommit{
ExpectedFEN: "initial", ExpectedState: core.StateOngoing, ExpectedTurn: core.ColorWhite,
ActorUserID: userID, MoveUCI: "e2e4", NewFEN: "after", State: core.StateWhiteWins, At: ended,
ActorUserID: userID, MoveUCI: "e2e4", NewFEN: "after", State: core.StateWhiteWins, Termination: core.TermCheckmate, At: ended,
Result: &game.MoveResult{Move: "e2e4", PlayerColor: core.ColorWhite, GameState: core.StateWhiteWins},
}
if err := svc.ApplyMoveWithState(gameID, commit); err != nil {
@@ -60,13 +60,13 @@ func TestUndoClearsDurableTerminalResult(t *testing.T) {
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
if err := svc.CreateGame(
gameID, white, black, "initial", core.ColorWhite, core.StateOngoing,
gameID, white, black, "initial", core.ColorWhite, core.StateOngoing, core.TermNone,
); err != nil {
t.Fatal(err)
}
if err := svc.ApplyMoveWithState(gameID, MoveCommit{
ExpectedFEN: "initial", ExpectedState: core.StateOngoing, ExpectedTurn: core.ColorWhite,
MoveUCI: "e2e4", NewFEN: "after", State: core.StateStalemate,
MoveUCI: "e2e4", NewFEN: "after", State: core.StateStalemate, Termination: core.TermStalemate,
}); err != nil {
t.Fatal(err)
}
@@ -91,7 +91,7 @@ func TestPlayerReconfigurationPreservesClaimAndPersistsConfiguration(t *testing.
white.ClaimedBy = userID
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
if err := svc.CreateGame(
gameID, white, black, "initial", core.ColorWhite, core.StateOngoing,
gameID, white, black, "initial", core.ColorWhite, core.StateOngoing, core.TermNone,
); err != nil {
t.Fatal(err)
}
@@ -137,7 +137,7 @@ func TestCleanupEvictsOnlyMemoryCopyOfTerminalGame(t *testing.T) {
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
if err := svc.CreateGame(
gameID, white, black, "terminal", core.ColorWhite, core.StateStalemate,
gameID, white, black, "terminal", core.ColorWhite, core.StateStalemate, core.TermStalemate,
); err != nil {
t.Fatal(err)
}
@@ -166,7 +166,7 @@ func TestCleanupRemovesIdleAnonymousGamesButKeepsClaimedOnes(t *testing.T) {
white.ID, white.ClaimedBy = claimant, claimant
}
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
if err := svc.CreateGame(gameID, white, black, "initial", core.ColorWhite, core.StateOngoing); err != nil {
if err := svc.CreateGame(gameID, white, black, "initial", core.ColorWhite, core.StateOngoing, core.TermNone); err != nil {
t.Fatal(err)
}
}
@@ -202,7 +202,16 @@ func TestCleanupRemovesIdleAnonymousGamesButKeepsClaimedOnes(t *testing.T) {
func newPersistentTestService(t *testing.T) *Service {
t.Helper()
store, err := storage.NewStore(pgtest.DSN(t))
svc, _ := newPersistentTestServiceDSN(t)
return svc
}
// newPersistentTestServiceDSN also returns the DSN of the test schema, for
// tests that change rows behind the service's back.
func newPersistentTestServiceDSN(t *testing.T) (*Service, string) {
t.Helper()
dsn := pgtest.DSN(t)
store, err := storage.NewStore(dsn)
if err != nil {
t.Fatal(err)
}
@@ -218,5 +227,5 @@ func newPersistentTestService(t *testing.T) *Service {
t.Errorf("shutdown: %v", err)
}
})
return svc
return svc, dsn
}
+134 -21
View File
@@ -2,43 +2,59 @@ package service
import (
"context"
"encoding/base64"
"errors"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
"chess/internal/server/chess"
"chess/internal/server/core"
"chess/internal/server/replay"
"chess/internal/server/storage"
"github.com/google/uuid"
)
var (
// ErrInvalidListQuery reports a malformed cursor or filter.
ErrInvalidListQuery = errors.New("invalid list query")
// ErrPlyOutOfRange reports a PGN ply beyond the stored line.
ErrPlyOutOfRange = replay.ErrPlyOutOfRange
// ErrNotation reports a stored move the rules core cannot notate.
ErrNotation = replay.ErrNotation
)
// GetGameHistory returns a durable replay even after the live game has been
// evicted from memory or the server has restarted.
// evicted from memory or the server has restarted. SAN is derived on read.
func (s *Service) GetGameHistory(gameID string) (*core.GameHistoryResponse, error) {
if s.store == nil {
return nil, ErrStorageDisabled
}
record, moves, err := s.store.GetGameHistory(gameID)
record, moves, err := s.loadHistory(gameID)
if err != nil {
if storage.IsGameNotFound(err) {
return nil, fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
}
if isStorageUnavailable(err) {
return nil, fmt.Errorf("%w: %v", ErrStorageUnavailable, err)
}
return nil, fmt.Errorf("get game history: %w", err)
return nil, err
}
history := &core.GameHistoryResponse{
GameID: record.GameID,
InitialFEN: record.InitialFEN,
Result: record.Result,
PGNResult: chess.ResultToken(record.Result),
Termination: record.Termination,
StartTimeUTC: record.StartTimeUTC,
EndTimeUTC: record.EndTimeUTC,
Players: playersResponse(*record),
Moves: make([]core.HistoryMove, 0, len(moves)),
}
for _, move := range moves {
san, err := replay.Notate(record.InitialFEN, moves)
if err != nil {
slog.Warn("stored game has moves without notation", "game_id", record.GameID, "error", err)
}
for i, move := range moves {
history.Moves = append(history.Moves, core.HistoryMove{
MoveNumber: move.MoveNumber,
MoveUCI: move.MoveUCI,
SAN: san[i],
FENAfterMove: move.FENAfterMove,
PlayerColor: move.PlayerColor,
MoveTimeUTC: move.MoveTimeUTC,
@@ -47,13 +63,79 @@ func (s *Service) GetGameHistory(gameID string) (*core.GameHistoryResponse, erro
return history, nil
}
// GetGamePGN exports the stored game in PGN; see replay.BuildPGN for ply.
func (s *Service) GetGamePGN(gameID string, ply int) (*replay.PGN, error) {
record, moves, err := s.loadHistory(gameID)
if err != nil {
return nil, err
}
pgn, err := replay.BuildPGN(record, moves, ply)
if errors.Is(err, replay.ErrNotation) {
slog.Warn("stored game cannot be exported as PGN", "game_id", gameID, "error", err)
}
return pgn, err
}
func (s *Service) loadHistory(gameID string) (*storage.GameRecord, []storage.MoveRecord, error) {
if s.store == nil {
return nil, nil, ErrStorageDisabled
}
record, moves, err := s.store.GetGameHistory(gameID)
if err != nil {
if storage.IsGameNotFound(err) {
return nil, nil, fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
}
if isStorageUnavailable(err) {
return nil, nil, fmt.Errorf("%w: %v", ErrStorageUnavailable, err)
}
return nil, nil, fmt.Errorf("get game history: %w", err)
}
return record, moves, nil
}
// UserGamesOptions selects a page of the caller's games. Cursor, when set,
// continues a previous page and excludes Offset.
type UserGamesOptions struct {
Limit int
Offset int
Cursor string
Color string // "", "white", or "black"
Status string // "", "ongoing", or "finished"
}
// GetUserGames returns a bounded page of games associated either at creation
// or by a later slot claim.
func (s *Service) GetUserGames(userID string, limit, offset int) (*core.GameListResponse, error) {
func (s *Service) GetUserGames(userID string, opts UserGamesOptions) (*core.GameListResponse, error) {
if s.store == nil {
return nil, ErrStorageDisabled
}
records, err := s.store.QueryGamesForUser(userID, limit+1, offset)
query := storage.UserGamesQuery{Limit: opts.Limit + 1, Offset: opts.Offset, Status: opts.Status}
switch opts.Color {
case "":
case "white":
query.Color = "w"
case "black":
query.Color = "b"
default:
return nil, fmt.Errorf("%w: color must be white or black", ErrInvalidListQuery)
}
switch opts.Status {
case "", "ongoing", "finished":
default:
return nil, fmt.Errorf("%w: status must be ongoing or finished", ErrInvalidListQuery)
}
if opts.Cursor != "" {
if opts.Offset != 0 {
return nil, fmt.Errorf("%w: cursor and offset are exclusive", ErrInvalidListQuery)
}
after, err := decodeCursor(opts.Cursor)
if err != nil {
return nil, err
}
query.After = after
}
records, err := s.store.QueryGamesForUser(userID, query)
if err != nil {
if isStorageUnavailable(err) {
return nil, fmt.Errorf("%w: %v", ErrStorageUnavailable, err)
@@ -61,24 +143,29 @@ func (s *Service) GetUserGames(userID string, limit, offset int) (*core.GameList
return nil, fmt.Errorf("get user games: %w", err)
}
hasNext := len(records) > limit
hasNext := len(records) > opts.Limit
if hasNext {
records = records[:limit]
records = records[:opts.Limit]
}
response := &core.GameListResponse{
Games: make([]core.GameSummary, 0, len(records)),
Limit: limit,
Offset: offset,
Limit: opts.Limit,
Offset: opts.Offset,
}
if hasNext {
next := offset + limit
response.NextOffset = &next
last := records[len(records)-1]
response.NextCursor = encodeCursor(last.StartTimeUTC, last.GameID)
if opts.Cursor == "" {
next := opts.Offset + opts.Limit
response.NextOffset = &next
}
}
for _, record := range records {
response.Games = append(response.Games, core.GameSummary{
GameID: record.GameID,
InitialFEN: record.InitialFEN,
Result: record.Result,
PGNResult: chess.ResultToken(record.Result),
StartTimeUTC: record.StartTimeUTC,
EndTimeUTC: record.EndTimeUTC,
MoveCount: record.MoveCount,
@@ -89,6 +176,32 @@ func (s *Service) GetUserGames(userID string, limit, offset int) (*core.GameList
return response, nil
}
// Cursors are opaque to clients: base64url of "<start µs>.<game ID>". The
// microsecond value round-trips PostgreSQL timestamptz exactly.
func encodeCursor(start time.Time, gameID string) string {
return base64.RawURLEncoding.EncodeToString([]byte(strconv.FormatInt(start.UnixMicro(), 10) + "." + gameID))
}
func decodeCursor(cursor string) (*storage.GameCursor, error) {
invalid := fmt.Errorf("%w: malformed cursor", ErrInvalidListQuery)
if len(cursor) > 128 {
return nil, invalid
}
raw, err := base64.RawURLEncoding.DecodeString(cursor)
if err != nil {
return nil, invalid
}
micros, id, ok := strings.Cut(string(raw), ".")
if !ok {
return nil, invalid
}
us, err := strconv.ParseInt(micros, 10, 64)
if err != nil || uuid.Validate(id) != nil {
return nil, invalid
}
return &storage.GameCursor{StartTimeUTC: time.UnixMicro(us).UTC(), GameID: id}, nil
}
func isStorageUnavailable(err error) bool {
return errors.Is(err, storage.ErrStorageDegraded) ||
errors.Is(err, storage.ErrStoreClosed) ||
+156
View File
@@ -0,0 +1,156 @@
package service
import (
"errors"
"strings"
"testing"
"chess/internal/server/chess"
"chess/internal/server/core"
"github.com/google/uuid"
)
// playLine creates a game and commits uci as the players' moves, with FENs
// from the rules core and the given final state (reached by checkmate) on the
// last move.
func playLine(t *testing.T, svc *Service, userID string, black core.PlayerConfig, uci []string, final core.State) string {
t.Helper()
gameID := uuid.NewString()
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
blackPlayer := core.NewPlayer(black, core.ColorBlack)
if err := svc.CreateGame(gameID, white, blackPlayer, chess.StartFEN, core.ColorWhite, core.StateOngoing, core.TermNone); err != nil {
t.Fatal(err)
}
pos, err := chess.ParseFEN(chess.StartFEN)
if err != nil {
t.Fatal(err)
}
for i, text := range uci {
m, err := pos.ParseUCI(text)
if err != nil {
t.Fatal(err)
}
next := pos.Play(m)
state, termination := core.StateOngoing, core.TermNone
if i == len(uci)-1 && final.IsTerminal() {
state, termination = final, core.TermCheckmate
}
turn, actor := core.ColorWhite, userID
if i%2 == 1 {
turn, actor = core.ColorBlack, ""
}
if err := svc.ApplyMoveWithState(gameID, MoveCommit{
ExpectedFEN: pos.FEN(), ExpectedState: core.StateOngoing, ExpectedTurn: turn,
ActorUserID: actor, MoveUCI: text, NewFEN: next.FEN(), State: state, Termination: termination,
}); err != nil {
t.Fatal(err)
}
pos = next
}
return gameID
}
func TestHistoryNotationAndPGN(t *testing.T) {
svc := newPersistentTestService(t)
computer := core.PlayerConfig{Type: core.PlayerComputer, Level: 3, SearchTime: 100}
gameID := playLine(t, svc, uuid.NewString(), computer,
[]string{"f2f3", "e7e5", "g2g4", "d8h4"}, core.StateBlackWins)
history, err := svc.GetGameHistory(gameID)
if err != nil {
t.Fatal(err)
}
var san []string
for _, m := range history.Moves {
san = append(san, m.SAN)
}
if strings.Join(san, " ") != "f3 e5 g4 Qh4#" || history.PGNResult != "0-1" || history.Termination != "checkmate" {
t.Fatalf("history notation = %v, %q, %q", san, history.PGNResult, history.Termination)
}
pgn, err := svc.GetGamePGN(gameID, -1)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
"[Event \"Casual game\"]\n[Site \"?\"]\n", "[White \"Anonymous\"]\n", "[Black \"Stockfish level 3\"]\n",
"[Result \"0-1\"]\n", "[GameId \"" + gameID + "\"]\n", "[BlackType \"program\"]\n", "[PlyCount \"4\"]\n",
"[Termination \"normal\"]\n", "\n\n1. f3 e5 2. g4 Qh4# { Black wins by checkmate. } 0-1\n",
} {
if !strings.Contains(pgn.Text, want) {
t.Errorf("PGN lacks %q:\n%s", want, pgn.Text)
}
}
if !strings.HasPrefix(pgn.Filename, "chess-") || !strings.HasSuffix(pgn.Filename, "-"+gameID[:8]+".pgn") {
t.Errorf("filename = %q", pgn.Filename)
}
partial, err := svc.GetGamePGN(gameID, 2)
if err != nil {
t.Fatal(err)
}
if !strings.HasSuffix(partial.Text, "\n\n1. f3 e5 *\n") || strings.Contains(partial.Text, "Termination") ||
!strings.HasSuffix(partial.Filename, "-ply2.pgn") {
t.Errorf("partial PGN %q:\n%s", partial.Filename, partial.Text)
}
if full, err := svc.GetGamePGN(gameID, 4); err != nil || full.Text != pgn.Text {
t.Errorf("ply equal to the line length should export the whole game: %v", err)
}
if _, err := svc.GetGamePGN(gameID, 5); !errors.Is(err, ErrPlyOutOfRange) {
t.Errorf("ply 5 error = %v, want ErrPlyOutOfRange", err)
}
if _, err := svc.GetGamePGN(uuid.NewString(), -1); !errors.Is(err, ErrGameNotFound) {
t.Errorf("unknown game error = %v, want ErrGameNotFound", err)
}
}
func TestUserGamesCursorAndFilters(t *testing.T) {
svc := newPersistentTestService(t)
userID := uuid.NewString()
human := core.PlayerConfig{Type: core.PlayerHuman}
var ids []string
for i := 0; i < 3; i++ {
ids = append([]string{playLine(t, svc, userID, human, []string{"e2e4"}, core.StateOngoing)}, ids...)
}
finished := playLine(t, svc, userID, human, []string{"f2f3", "e7e5", "g2g4", "d8h4"}, core.StateBlackWins)
ids = append([]string{finished}, ids...)
var got []string
cursor := ""
for {
page, err := svc.GetUserGames(userID, UserGamesOptions{Limit: 3, Cursor: cursor})
if err != nil {
t.Fatal(err)
}
if cursor != "" && page.NextOffset != nil {
t.Error("cursor page reported nextOffset")
}
for _, g := range page.Games {
got = append(got, g.GameID)
}
if page.NextCursor == "" {
break
}
cursor = page.NextCursor
}
if strings.Join(got, ",") != strings.Join(ids, ",") {
t.Fatalf("cursor pages = %v, want %v", got, ids)
}
page, err := svc.GetUserGames(userID, UserGamesOptions{Limit: 10, Status: "finished", Color: "white"})
if err != nil {
t.Fatal(err)
}
if len(page.Games) != 1 || page.Games[0].GameID != finished || page.Games[0].PGNResult != "0-1" {
t.Fatalf("finished games = %+v", page.Games)
}
for _, bad := range []UserGamesOptions{
{Limit: 1, Cursor: "!!"}, {Limit: 1, Cursor: "bm90LWEtY3Vyc29y"}, {Limit: 1, Offset: 1, Cursor: page.NextCursor + "x"},
{Limit: 1, Color: "red"}, {Limit: 1, Status: "paused"},
} {
if _, err := svc.GetUserGames(userID, bad); !errors.Is(err, ErrInvalidListQuery) {
t.Errorf("GetUserGames(%+v) error = %v, want ErrInvalidListQuery", bad, err)
}
}
}
+239
View File
@@ -0,0 +1,239 @@
package service
import (
"context"
"fmt"
"log/slog"
"time"
"chess/internal/server/replay"
"github.com/lixenwraith/auth"
)
// IntegrityMode selects what the periodic integrity sweep does.
type IntegrityMode int
const (
// IntegrityOff skips the sweep.
IntegrityOff IntegrityMode = iota
// IntegrityReport logs what the sweep finds and changes nothing.
IntegrityReport
// IntegrityDelete also unloads and deletes what it finds.
IntegrityDelete
)
// integrityBatch bounds how many stored games one sweep replays against the
// rules; the sweep resumes after the last one next time, so every game is
// checked in turn at a bounded cost per run.
const integrityBatch = 200
// ParseIntegrityMode accepts "off", "report", or "delete".
func ParseIntegrityMode(s string) (IntegrityMode, error) {
switch s {
case "", "off":
return IntegrityOff, nil
case "report":
return IntegrityReport, nil
case "delete":
return IntegrityDelete, nil
}
return IntegrityOff, fmt.Errorf("invalid integrity mode %q (want off, report, or delete)", s)
}
func (m IntegrityMode) String() string {
switch m {
case IntegrityReport:
return "report"
case IntegrityDelete:
return "delete"
}
return "off"
}
// SetIntegrityMode configures the sweep run with every cleanup job.
func (s *Service) SetIntegrityMode(mode IntegrityMode) {
s.mu.Lock()
s.integrityMode = mode
s.mu.Unlock()
}
// integrityFindings is what one sweep found. Keys are IDs, values reasons.
type integrityFindings struct {
vanished map[string]string // loaded games without a database row
games map[string]string // stored games to delete
users map[string]string // accounts to delete
loaded map[string]bool
}
// checkIntegrity finds data that the server cannot use and, in delete mode,
// removes it:
//
// - loaded games whose row was deleted by hand (unloaded from memory);
// - games with missing plies, when move rows were deleted by hand
// (unloaded if loaded, then deleted);
// - stored games that fail replay.Verify, integrityBatch per run
// (loaded games are skipped: their rows may trail memory);
// - games whose registered players were all deleted, once idle past the
// anonymous-game retention, like games that never had one;
// - accounts whose password hash is not a usable Argon2id PHC string, so
// nobody can sign in to them.
//
// Every finding is logged at WARN with its reason.
func (s *Service) checkIntegrity(now time.Time) {
s.mu.RLock()
mode, anonymousTTL := s.integrityMode, s.anonymousTTL
loaded := make(map[string]bool, len(s.games))
for gameID := range s.games {
loaded[gameID] = true
}
s.mu.RUnlock()
if mode == IntegrityOff || s.store == nil {
return
}
findings, err := s.findIntegrityProblems(now, loaded, anonymousTTL)
if err != nil {
slog.Error("integrity sweep failed", "error", err)
return
}
for gameID, reason := range findings.vanished {
slog.Warn("integrity: loaded game has no database row", "game_id", gameID, "reason", reason)
}
for gameID, reason := range findings.games {
slog.Warn("integrity: invalid game", "game_id", gameID, "reason", reason)
}
for userID, reason := range findings.users {
slog.Warn("integrity: unusable account", "user_id", userID, "reason", reason)
}
summary := []any{"mode", mode.String(), "vanished_games", len(findings.vanished),
"invalid_games", len(findings.games), "unusable_accounts", len(findings.users)}
if mode != IntegrityDelete {
if len(findings.vanished)+len(findings.games)+len(findings.users) > 0 {
slog.Info("integrity sweep found problems; nothing changed (report mode)", summary...)
}
return
}
for gameID := range findings.vanished {
s.evictMissingGame(gameID)
}
var games []string
for gameID := range findings.games {
if findings.loaded[gameID] {
s.evictMissingGame(gameID) // unload first: no further writes for it
}
games = append(games, gameID)
}
if err := s.store.DeleteGames(games); err != nil {
slog.Error("integrity sweep failed to queue game deletion", "error", err)
}
var users []string
for userID := range findings.users {
users = append(users, userID)
}
if err := s.store.DeleteUsers(users); err != nil {
slog.Error("integrity sweep failed to delete accounts", "error", err)
}
if len(findings.vanished)+len(games)+len(users) > 0 {
slog.Info("integrity sweep removed invalid data", summary...)
}
}
func (s *Service) findIntegrityProblems(now time.Time, loaded map[string]bool,
anonymousTTL time.Duration) (*integrityFindings, error) {
found := &integrityFindings{
vanished: map[string]string{},
games: map[string]string{},
users: map[string]string{},
loaded: loaded,
}
live := make([]string, 0, len(loaded))
for gameID := range loaded {
live = append(live, gameID)
}
// Every loaded game's insert was queued before the snapshot was taken, so
// after this flush a missing row means the row was deleted.
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := s.store.Flush(ctx); err != nil {
return nil, err
}
exists, err := s.store.ExistingGames(live)
if err != nil {
return nil, err
}
for _, gameID := range live {
if !exists[gameID] {
found.vanished[gameID] = "row deleted outside the server"
}
}
gaps, err := s.store.GamesWithMoveGaps()
if err != nil {
return nil, err
}
for _, gameID := range gaps {
found.games[gameID] = "missing plies: move rows were deleted"
}
ids, err := s.store.GameIDsAfter(s.integrityCursor, integrityBatch)
if err != nil {
return nil, err
}
s.integrityCursor = ""
if len(ids) == integrityBatch {
s.integrityCursor = ids[len(ids)-1]
}
for _, gameID := range ids {
if loaded[gameID] || found.games[gameID] != "" {
continue
}
record, moves, err := s.store.GetGameHistory(gameID)
if err != nil {
continue // deleted since the listing
}
if problems := replay.Verify(record, moves); len(problems) > 0 {
found.games[gameID] = problems[0]
}
}
if anonymousTTL > 0 {
orphans, err := s.store.OrphanedGames(now.Add(-anonymousTTL), live)
if err != nil {
return nil, err
}
for _, gameID := range orphans {
if found.games[gameID] == "" {
found.games[gameID] = "all registered players were deleted"
}
}
}
users, err := s.store.GetAllUsers()
if err != nil {
return nil, err
}
for _, user := range users {
if err := auth.ValidatePHCHashFormat(user.PasswordHash); err != nil {
found.users[user.UserID] = fmt.Sprintf("%s: %v", user.Username, err)
}
}
return found, nil
}
// evictMissingGame unloads a game whose database row disappeared, typically
// deleted by hand. It runs when a write for the game finds the row gone.
func (s *Service) evictMissingGame(gameID string) {
s.mu.Lock()
_, ok := s.games[gameID]
if ok {
delete(s.games, gameID)
}
s.mu.Unlock()
if ok {
s.waiter.RemoveGame(gameID)
slog.Warn("game unloaded: its database row no longer exists", "game_id", gameID)
}
}
+145
View File
@@ -0,0 +1,145 @@
package service
import (
"database/sql"
"errors"
"testing"
"time"
"chess/internal/server/chess"
"chess/internal/server/storage"
"github.com/google/uuid"
"github.com/lixenwraith/auth"
)
func TestIntegritySweep(t *testing.T) {
svc, dsn := newPersistentTestServiceDSN(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
defer db.Close()
exec := func(query string, args ...any) {
t.Helper()
if _, err := db.Exec(query, args...); err != nil {
t.Fatal(err)
}
}
stored := func(gameID string) bool {
t.Helper()
var exists bool
if err := db.QueryRow(`SELECT EXISTS (SELECT 1 FROM games WHERE game_id = $1)`, gameID).
Scan(&exists); err != nil {
t.Fatal(err)
}
return exists
}
// played creates a game, plays the moves, and unloads it from memory.
played := func(uci ...string) string {
t.Helper()
gameID := newHumanGame(t, svc)
for _, u := range uci {
move(t, svc, gameID, "", u)
}
if err := svc.DeleteGame(gameID); err != nil {
t.Fatal(err)
}
return gameID
}
valid := played("e2e4", "e7e5", "g1f3")
gap := played("e2e4", "e7e5", "g1f3")
corrupt := played("e2e4", "e7e5")
loaded := newHumanGame(t, svc)
vanished := newHumanGame(t, svc)
old := time.Now().Add(-48 * time.Hour)
orphan, claimed := uuid.NewString(), uuid.NewString()
aliceHash, err := auth.HashPassword("correct horse battery")
if err != nil {
t.Fatal(err)
}
alice, mallory := uuid.NewString(), uuid.NewString()
exec(`INSERT INTO users (user_id, username, password_hash) VALUES ($1, 'alice', $2), ($3, 'mallory', 'hunter2')`,
alice, aliceHash, mallory)
for gameID, claimant := range map[string]string{orphan: uuid.NewString(), claimed: alice} {
if err := svc.store.RecordNewGame(storage.GameRecord{
GameID: gameID, InitialFEN: chess.StartFEN,
WhitePlayerID: claimant, WhiteType: 1, WhiteClaimedBy: claimant,
BlackPlayerID: uuid.NewString(), BlackType: 1, StartTimeUTC: old,
}); err != nil {
t.Fatal(err)
}
}
if _, err := svc.GetGameHistory(valid); err != nil { // flush queued writes
t.Fatal(err)
}
// Damage made by hand, as with psql.
exec(`DELETE FROM moves WHERE game_id = $1 AND move_number = 2`, gap)
exec(`UPDATE moves SET fen_after_move = $2 WHERE game_id = $1 AND move_number = 2`, corrupt, chess.StartFEN)
exec(`DELETE FROM games WHERE game_id = $1`, vanished)
svc.SetIntegrityMode(IntegrityReport)
svc.checkIntegrity(time.Now())
for _, gameID := range []string{valid, gap, corrupt, orphan, claimed, loaded} {
if !stored(gameID) {
t.Fatalf("report mode deleted game %s", gameID)
}
}
if _, err := svc.GetGameView(vanished); err != nil {
t.Fatal("report mode unloaded a game")
}
svc.SetIntegrityMode(IntegrityDelete)
svc.checkIntegrity(time.Now())
if _, err := svc.GetGameHistory(valid); err != nil { // flush the deletion
t.Fatal(err)
}
for gameID, want := range map[string]bool{
valid: true, loaded: true, claimed: true, gap: false, corrupt: false, orphan: false,
} {
if got := stored(gameID); got != want {
t.Errorf("game %s stored = %v, want %v", gameID, got, want)
}
}
if _, err := svc.GetGameView(vanished); !errors.Is(err, ErrGameNotFound) {
t.Errorf("game with a deleted row still loaded: %v", err)
}
if _, err := svc.GetGameView(loaded); err != nil {
t.Errorf("healthy loaded game was unloaded: %v", err)
}
var users int
if err := db.QueryRow(`SELECT count(*) FROM users WHERE user_id IN ($1, $2)`, alice, mallory).
Scan(&users); err != nil {
t.Fatal(err)
}
if users != 1 {
t.Errorf("accounts left = %d, want alice only", users)
}
if health := svc.GetStorageHealth(); health != "ok" {
t.Errorf("storage health %q after the sweep", health)
}
// A healthy database gives a clean second run.
findings, err := svc.findIntegrityProblems(time.Now(), map[string]bool{loaded: true}, AnonymousGameTTL)
if err != nil {
t.Fatal(err)
}
if len(findings.games)+len(findings.users)+len(findings.vanished) != 0 {
t.Errorf("second sweep found %+v", findings)
}
}
func TestParseIntegrityMode(t *testing.T) {
for in, want := range map[string]IntegrityMode{"": IntegrityOff, "off": IntegrityOff,
"report": IntegrityReport, "delete": IntegrityDelete} {
if got, err := ParseIntegrityMode(in); err != nil || got != want {
t.Errorf("ParseIntegrityMode(%q) = %v, %v", in, got, err)
}
}
if _, err := ParseIntegrityMode("purge"); err == nil {
t.Error("ParseIntegrityMode accepted an unknown mode")
}
}
+40 -20
View File
@@ -29,16 +29,18 @@ const (
// Service coordinates game state, user management, and storage
type Service struct {
games map[string]*game.Game
mu sync.RWMutex
store *storage.Store
jwt *auth.JWT
kdf chan struct{} // Argon2id concurrency slots
waiter *WaitRegistry
computerGames atomic.Int32 // Active games with computer players
finishedTTL time.Duration
anonymousTTL time.Duration
maxUsers atomic.Int64
games map[string]*game.Game
mu sync.RWMutex
store *storage.Store
jwt *auth.JWT
kdf chan struct{} // Argon2id concurrency slots
waiter *WaitRegistry
finishedTTL time.Duration
anonymousTTL time.Duration
maxUsers atomic.Int64
integrityMode IntegrityMode
integrityCursor string // last game ID the integrity sweep verified; cleanup goroutine only
}
// New creates a service with optional storage. jwtSecret signs session tokens
@@ -64,6 +66,9 @@ func New(store *storage.Store, jwtSecret []byte) (*Service, error) {
anonymousTTL: AnonymousGameTTL,
}
s.maxUsers.Store(DefaultMaxUsers)
if store != nil {
store.SetGameMissingHandler(s.evictMissingGame)
}
return s, nil
}
@@ -115,14 +120,30 @@ func (s *Service) RegisterWait(gameID string, moveCount int, ctx context.Context
return s.waiter.RegisterWait(gameID, moveCount, ctx)
}
// CanCreateComputerGame checks if a new computer game can be created
// CanCreateComputerGame reports whether the computer game limit leaves room
// for another game; CreateGame re-checks under its lock.
func (s *Service) CanCreateComputerGame() bool {
return s.computerGames.Load() < MaxComputerGames
return s.GetComputerGameCount() < MaxComputerGames
}
// GetComputerGameCount returns current computer game count
func (s *Service) GetComputerGameCount() int32 {
return s.computerGames.Load()
// GetComputerGameCount returns the number of unfinished loaded games with a
// computer player. Finished games stay loaded for a while but need no engine,
// so they do not count against MaxComputerGames.
func (s *Service) GetComputerGameCount() int {
s.mu.RLock()
defer s.mu.RUnlock()
return s.activeComputerGamesLocked()
}
// activeComputerGamesLocked counts as GetComputerGameCount. Caller holds s.mu.
func (s *Service) activeComputerGamesLocked() int {
active := 0
for _, g := range s.games {
if g.HasComputerPlayer() && !g.State().IsTerminal() {
active++
}
}
return active
}
// Shutdown gracefully shuts down the service
@@ -146,8 +167,9 @@ func (s *Service) Shutdown(timeout time.Duration) error {
return errors.Join(errs...)
}
// RunCleanupJob periodically removes expired sessions, unloads idle games, and
// deletes anonymous games past their retention.
// RunCleanupJob periodically removes expired sessions, unloads idle games,
// deletes anonymous games past their retention, and runs the integrity sweep
// when enabled.
func (s *Service) RunCleanupJob(ctx context.Context, interval time.Duration) {
s.cleanupExpired()
ticker := time.NewTicker(interval)
@@ -173,6 +195,7 @@ func (s *Service) cleanupExpired() {
}
}
s.cleanupGames(now)
s.checkIntegrity(now)
}
// cleanupGames unloads terminal games after the finished-game TTL and
@@ -199,9 +222,6 @@ func (s *Service) cleanupGames(now time.Time) {
live = append(live, gameID)
continue
}
if g.HasComputerPlayer() {
s.computerGames.Add(-1)
}
delete(s.games, gameID)
}
s.mu.Unlock()
+6
View File
@@ -105,6 +105,12 @@ func (w *WaitRegistry) NotifyGame(gameID string, currentMoveCount int, state cor
}
}
// NotifyAll wakes every client waiting on a game, for changes that neither
// move nor settle it, such as a draw offer.
func (w *WaitRegistry) NotifyAll(gameID string) {
w.RemoveGame(gameID)
}
// RemoveGame removes all waiters for a game (called before game deletion)
func (w *WaitRegistry) RemoveGame(gameID string) {
w.mu.RLock()