v0.13.0 web and terminal client enhance, replay api, linux server deploy, doc and dependency update
test / test (push) Canceled after 0s
test / test (push) Canceled after 0s
This commit is contained in:
1 parent
52ac0fe31a
commit
f1ac4cbbe7
71 files changed
+7677
-537
No files matched your search
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) ||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user