v0.13.1 concession management and record, clients update, version report
test / test (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-09-29 02:09:45 -04:00
1 parent f1ac4cbbe7
commit 6e8d80c788
30 files changed
+704 -81

No files matched your search

+89 -4
View File
@@ -94,12 +94,97 @@ func TestResignationClaimsAndPersists(t *testing.T) {
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 {
// Between two humans a resignation is final: neither the other claimant
// nor an anonymous caller can take it back, and the players cannot be
// reconfigured around it.
for _, actor := range []string{alice, bob, ""} {
if err := svc.UndoMoves(gameID, 1, actor); !errors.Is(err, ErrSlotOwner) {
t.Fatalf("undo by %q after a two-player resignation: %v, want ErrSlotOwner", actor, err)
}
}
computer := core.NewPlayer(core.PlayerConfig{Type: core.PlayerComputer, Level: 1, SearchTime: 100}, core.ColorBlack)
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
if err := svc.UpdatePlayers(gameID, white, computer, alice); !errors.Is(err, ErrSlotOwner) {
t.Fatalf("reconfiguring around a resignation: %v, want ErrSlotOwner", err)
}
if history.Concession == nil || history.Concession.Result != "white_wins" ||
history.Concession.Termination != "resignation" || history.Concession.Ply != 1 {
t.Fatalf("concession not recorded: %+v", history.Concession)
}
}
// A single controller (hotseat or anonymous) is refused only by finality.
func TestConcessionBetweenHumansIsFinal(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 history, _ = svc.GetGameHistory(gameID); history.Result != "" || history.Termination != "" {
t.Fatalf("undo kept the resignation: %+v", history)
if err := svc.EndGame(gameID, EndCommit{
ExpectedFEN: view.FEN, Color: core.ColorWhite, State: core.StateDraw,
Termination: core.TermAgreement, OfferFrom: core.ColorBlack,
}); err != nil {
t.Fatal(err)
}
if err := svc.UndoMoves(gameID, 1, ""); !errors.Is(err, ErrConcessionFinal) {
t.Fatalf("undo after an agreed draw between humans: %v, want ErrConcessionFinal", err)
}
}
// Against the computer an undo reopens a resigned game; the first
// concession stays on record through the rewind, later moves, and a second
// resignation, while result and termination follow the live game.
func TestConcessionAgainstComputerStaysOnRecord(t *testing.T) {
svc := newPersistentTestService(t)
gameID, alice := uuid.NewString(), 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)
if err := svc.CreateGame(gameID, white, black, chess.StartFEN, core.ColorWhite,
core.StateOngoing, core.TermNone); err != nil {
t.Fatal(err)
}
move(t, svc, gameID, alice, "e2e4")
move(t, svc, gameID, "", "e7e5")
resign := func() {
t.Helper()
view, _ := svc.GetGameView(gameID)
if err := svc.EndGame(gameID, EndCommit{
ExpectedFEN: view.FEN, Color: core.ColorWhite, ActorUserID: alice,
State: core.StateBlackWins, Termination: core.TermResignation,
}); err != nil {
t.Fatal(err)
}
}
resign()
if err := svc.UndoMoves(gameID, 2, ""); !errors.Is(err, ErrSlotOwner) {
t.Fatalf("anonymous undo of a claimed game: %v, want ErrSlotOwner", err)
}
if err := svc.UndoMoves(gameID, 2, alice); err != nil {
t.Fatal(err)
}
view, _ := svc.GetGameView(gameID)
if view.State != core.StateOngoing || view.Concession == nil ||
view.Concession.State != core.StateBlackWins || view.Concession.Ply != 2 {
t.Fatalf("view after undoing a resignation: %+v", view)
}
history, err := svc.GetGameHistory(gameID)
if err != nil {
t.Fatal(err)
}
if history.Result != "" || history.Termination != "" || len(history.Moves) != 0 ||
history.Concession == nil || history.Concession.Result != "black_wins" ||
history.Concession.Termination != "resignation" || history.Concession.Ply != 2 {
t.Fatalf("history after undoing a resignation: %+v", history)
}
move(t, svc, gameID, alice, "d2d4")
resign()
history, _ = svc.GetGameHistory(gameID)
if history.Result != "black_wins" || len(history.Moves) != 1 || history.Concession.Ply != 2 {
t.Fatalf("second resignation replaced the first concession: %+v", history)
}
}
+72 -5
View File
@@ -21,8 +21,42 @@ var (
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")
// ErrConcessionFinal refuses to continue a game two human players ended
// by resignation or agreement.
ErrConcessionFinal = errors.New("a resignation or agreed draw between two human players is final")
)
// requireControl lets actor rewind or reconfigure g only when no side is
// claimed by another user. A claim is what protects a player's moves; an
// unclaimed side is open to anyone holding the game ID.
func requireControl(g *game.Game, actor string) error {
for _, color := range []core.Color{core.ColorWhite, core.ColorBlack} {
if owner := g.GetSlotOwner(color); owner != "" && owner != actor {
return ErrSlotOwner
}
}
return nil
}
// requireParticipant lets actor unload g when it holds one of its claims, or
// when nothing is claimed.
func requireParticipant(g *game.Game, actor string) error {
claimed := false
for _, color := range []core.Color{core.ColorWhite, core.ColorBlack} {
switch g.GetSlotOwner(color) {
case "":
case actor:
return nil
default:
claimed = true
}
}
if claimed {
return ErrSlotOwner
}
return nil
}
type MoveCommit struct {
ExpectedFEN string
ExpectedState core.State
@@ -108,18 +142,22 @@ func (s *Service) CreateGame(
return nil
}
// UpdatePlayers replaces players in an existing game
func (s *Service) UpdatePlayers(gameID string, whitePlayer, blackPlayer *core.Player) error {
// UpdatePlayers replaces players in an existing game on behalf of actor, who
// must not be locked out by another user's claim.
func (s *Service) UpdatePlayers(gameID string, whitePlayer, blackPlayer *core.Player, actor string) error {
s.mu.Lock()
defer s.mu.Unlock()
g, ok := s.games[gameID]
if !ok {
return fmt.Errorf("game not found: %s", gameID)
return fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
}
if g.State() == core.StatePending {
return errors.New("cannot change players while computer is calculating")
}
if err := requireControl(g, actor); err != nil {
return err
}
oldWhite := g.GetPlayer(core.ColorWhite)
oldBlack := g.GetPlayer(core.ColorBlack)
@@ -369,6 +407,7 @@ func (s *Service) EndGame(gameID string, commit EndCommit) error {
result, _ := commit.State.Result()
end := storage.GameEnd{
GameID: gameID, Result: result, Termination: string(commit.Termination), EndTimeUTC: now,
Ply: g.Plies(),
}
if claimUserID != "" {
end.ClaimColor, end.ClaimedBy = commit.Color.String(), claimUserID
@@ -490,8 +529,11 @@ func offerAllowed(g *game.Game, color core.Color) error {
return nil
}
// UndoMoves removes the specified number of moves from game history
func (s *Service) UndoMoves(gameID string, count int) error {
// UndoMoves takes back count moves on behalf of actor. Another user's claim
// refuses it, so two players cannot rewind each other; a resignation or agreed
// draw between two humans is final. Against the computer an undo reopens a
// finished game and the first concession stays on record.
func (s *Service) UndoMoves(gameID string, count int, actor string) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -502,6 +544,12 @@ func (s *Service) UndoMoves(gameID string, count int) error {
if g.State() == core.StatePending {
return errors.New("cannot undo while computer move is in progress")
}
if err := requireControl(g, actor); err != nil {
return err
}
if g.State().IsTerminal() && g.Termination().IsConcession() && g.BothHuman() {
return ErrConcessionFinal
}
originalMoveCount := len(g.Moves())
@@ -525,6 +573,21 @@ func (s *Service) UndoMoves(gameID string, count int) error {
return nil
}
// UnloadGame removes a live game on behalf of actor, who must hold one of
// its claims when it has any. Durable history is kept.
func (s *Service) UnloadGame(gameID string, actor string) error {
s.mu.Lock()
defer s.mu.Unlock()
g, ok := s.games[gameID]
if !ok {
return fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
}
if err := requireParticipant(g, actor); err != nil {
return err
}
return s.deleteGameLocked(gameID, g)
}
// DeleteGame removes a game from the service
func (s *Service) DeleteGame(gameID string) error {
s.mu.Lock()
@@ -534,6 +597,10 @@ func (s *Service) DeleteGame(gameID string) error {
if !ok {
return fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
}
return s.deleteGameLocked(gameID, g)
}
func (s *Service) deleteGameLocked(gameID string, g *game.Game) error {
if g.State() == core.StatePending {
return errors.New("cannot delete game while computer move is in progress")
}
+3 -3
View File
@@ -70,7 +70,7 @@ func TestUndoClearsDurableTerminalResult(t *testing.T) {
}); err != nil {
t.Fatal(err)
}
if err := svc.UndoMoves(gameID, 1); err != nil {
if err := svc.UndoMoves(gameID, 1, ""); err != nil {
t.Fatal(err)
}
@@ -100,7 +100,7 @@ func TestPlayerReconfigurationPreservesClaimAndPersistsConfiguration(t *testing.
replacementBlack := core.NewPlayer(
core.PlayerConfig{Type: core.PlayerComputer, Level: 12, SearchTime: 500}, core.ColorBlack,
)
if err := svc.UpdatePlayers(gameID, replacementWhite, replacementBlack); err != nil {
if err := svc.UpdatePlayers(gameID, replacementWhite, replacementBlack, userID); err != nil {
t.Fatal(err)
}
@@ -118,7 +118,7 @@ func TestPlayerReconfigurationPreservesClaimAndPersistsConfiguration(t *testing.
computerWhite := core.NewPlayer(
core.PlayerConfig{Type: core.PlayerComputer, Level: 8, SearchTime: 300}, core.ColorWhite,
)
if err := svc.UpdatePlayers(gameID, computerWhite, replacementBlack); err != nil {
if err := svc.UpdatePlayers(gameID, computerWhite, replacementBlack, userID); err != nil {
t.Fatal(err)
}
history, err = svc.GetGameHistory(gameID)
+6
View File
@@ -46,6 +46,12 @@ func (s *Service) GetGameHistory(gameID string) (*core.GameHistoryResponse, erro
Players: playersResponse(*record),
Moves: make([]core.HistoryMove, 0, len(moves)),
}
if record.ConcessionResult != "" {
history.Concession = &core.Concession{
Result: record.ConcessionResult, Termination: record.ConcessionTermination,
Ply: record.ConcessionPly,
}
}
san, err := replay.Notate(record.InitialFEN, moves)
if err != nil {
slog.Warn("stored game has moves without notation", "game_id", record.GameID, "error", err)