Files
scrabble-game/backend/internal/engine/resign_test.go
T
Ilia Denisov 751e74b14f
Tests · Go / test (push) Successful in 6s
Tests · Integration / integration (push) Successful in 8s
Tests · Go / test (pull_request) Successful in 5s
Tests · Integration / integration (pull_request) Successful in 8s
Stage 3: game domain (lifecycle, journal+cache, hint, word-check, GCG, stats)
internal/game drives the engine over a single match and owns everything the
engine does not: event-sourced persistence (a games row + an append-only decoded
move journal, with the live engine.Game kept warm in a cache and rebuilt by
replay on a miss), the play/pass/exchange/resign transitions with
validate-at-submit scoring, an unlimited score/legality preview, the hint
(per-game allowance + profile wallet), the word-check tool with complaint
capture, per-player game state, history and GCG export (Poslfit dialect), and a
background turn-timeout sweeper that auto-resigns overdue turns honouring each
player's daily away window. Like Stages 1-2 it is a service/store layer with no
HTTP; the gateway surface lands in Stage 6.

Engine: additive decoded domain API (Direction, SubmitPlay/SubmitExchange/
EvaluatePlay/HintView/Hand, MoveRecord.{Dir,MainRow,MainCol}, Registry.Lookup,
ParseVariant) so internal/game never imports scrabble-solver; and a Resign fix
so the resigner keeps their score yet never wins (the other player wins a
two-player game). Timeout reuses Resign.

Persistence: migration 00002 adds games, game_players, game_moves, complaints,
account_stats and extends accounts with away_start/away_end/hint_balance; go-jet
regenerated. account gained SpendHint. Config adds BACKEND_DICT_DIR (required),
BACKEND_DICT_VERSION, BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL, BACKEND_GAME_CACHE_TTL;
main loads the registry at boot (hard dependency) and starts the sweeper.

Tests: engine resign + decoded-API tests; game unit tests (GCG, away-window
boundaries, hint budget, cache, keyed mutex, payload); inttest integration
(lifecycle, replay equivalence, timeout sweep with away grace, resign stats,
hint policy, word-check/complaint, per-game-lock). Docs (PLAN, ARCHITECTURE,
FUNCTIONAL +_ru, TESTING, README) updated.
2026-06-02 17:33:49 +02:00

82 lines
2.3 KiB
Go

package engine
import "testing"
// TestResignLeadingPlayerStillLoses is the core of the resignation fix: a player
// who resigns loses even when leading on score, the remaining player wins, and
// the resigner's score is frozen (no end-game rack adjustment).
func TestResignLeadingPlayerStillLoses(t *testing.T) {
g := openingGame(t)
hint, ok := g.HintView()
if !ok {
t.Fatal("opening game has no hint")
}
played, err := g.SubmitPlay(hint.Dir, hint.Tiles)
if err != nil {
t.Fatalf("player 0 play: %v", err)
}
if played.Score == 0 {
t.Fatal("opening play scored 0; pick a different seed")
}
if _, err := g.Pass(); err != nil { // player 1
t.Fatalf("player 1 pass: %v", err)
}
// Player 0 is now on turn and leads 0:played.Score; resigning must still lose.
if _, err := g.Resign(); err != nil {
t.Fatalf("player 0 resign: %v", err)
}
if !g.Over() || g.Reason() != EndResign {
t.Fatalf("game over=%v reason=%v, want over with resign", g.Over(), g.Reason())
}
res := g.Result()
if res.Winner != 1 {
t.Errorf("winner = %d, want 1 (the non-resigner) despite the resigner leading", res.Winner)
}
if g.Score(0) != played.Score {
t.Errorf("resigner score = %d, want frozen at %d (no rack adjustment)", g.Score(0), played.Score)
}
if g.Score(1) != 0 {
t.Errorf("opponent score = %d, want 0", g.Score(1))
}
if g.Score(0) <= g.Score(1) {
t.Fatal("test precondition: resigner should lead on raw score")
}
}
// TestResignTrailingPlayerLoses covers the ordinary case: the trailing player
// resigns and the leader wins.
func TestResignTrailingPlayerLoses(t *testing.T) {
g := openingGame(t)
hint, ok := g.HintView()
if !ok {
t.Fatal("opening game has no hint")
}
if _, err := g.SubmitPlay(hint.Dir, hint.Tiles); err != nil { // player 0 scores
t.Fatalf("player 0 play: %v", err)
}
// Player 1 (trailing 0 points) resigns.
if _, err := g.Resign(); err != nil {
t.Fatalf("player 1 resign: %v", err)
}
if res := g.Result(); res.Winner != 0 {
t.Errorf("winner = %d, want 0", res.Winner)
}
}
// TestResignOnFinishedGame rejects a second transition.
func TestResignOnFinishedGame(t *testing.T) {
g := newEnglishGame(t, 1)
if _, err := g.Resign(); err != nil {
t.Fatalf("first resign: %v", err)
}
if _, err := g.Resign(); err == nil {
t.Error("resign on a finished game must error")
}
}