Files
scrabble-game/backend/internal/inttest/game_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

501 lines
16 KiB
Go

//go:build integration
package inttest
import (
"context"
"database/sql"
"errors"
"sync"
"testing"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
)
// newGameService builds a game service over the shared pool and registry.
func newGameService() *game.Service {
return game.NewService(
game.NewStore(testDB),
account.NewStore(testDB),
testRegistry,
game.Config{
DictDir: dictDir(),
DictVersion: testDictVersion,
TimeoutSweepInterval: time.Minute,
CacheTTL: time.Hour,
},
zap.NewNop(),
)
}
// provisionAccount creates a fresh durable account and returns its id.
func provisionAccount(t *testing.T) uuid.UUID {
t.Helper()
acc, err := account.NewStore(testDB).ProvisionByIdentity(context.Background(), account.KindTelegram, "tg-"+uuid.NewString())
if err != nil {
t.Fatalf("provision account: %v", err)
}
return acc.ID
}
// openingSeed returns a seed whose fresh two-player English opening rack has a
// legal move, so a greedy mirror can drive a game.
func openingSeed(t *testing.T) int64 {
t.Helper()
for seed := int64(1); seed <= 200; seed++ {
g, err := engine.New(testRegistry, engine.Options{Variant: engine.VariantEnglish, Version: testDictVersion, Players: 2, Seed: seed})
if err != nil {
t.Fatalf("engine new: %v", err)
}
if _, ok := g.HintView(); ok {
return seed
}
}
t.Fatal("no opening seed found")
return 0
}
// newMirror builds a parallel engine game with the same seed, used to compute
// legal moves to feed the service under test.
func newMirror(t *testing.T, seed int64, players int) *engine.Game {
t.Helper()
g, err := engine.New(testRegistry, engine.Options{Variant: engine.VariantEnglish, Version: testDictVersion, Players: players, Seed: seed})
if err != nil {
t.Fatalf("mirror new: %v", err)
}
return g
}
// readStats reads an account's statistics row.
func readStats(t *testing.T, id uuid.UUID) (wins, losses, draws, maxGame, maxWord int, found bool) {
t.Helper()
row := testDB.QueryRowContext(context.Background(),
`SELECT wins, losses, draws, max_game_points, max_word_points FROM backend.account_stats WHERE account_id = $1`, id)
if err := row.Scan(&wins, &losses, &draws, &maxGame, &maxWord); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return 0, 0, 0, 0, 0, false
}
t.Fatalf("read stats: %v", err)
}
return wins, losses, draws, maxGame, maxWord, true
}
// TestGameLifecycleAndStats drives a greedy two-player game to its natural end
// through the service and checks the finish state and statistics.
func TestGameLifecycleAndStats(t *testing.T) {
ctx := context.Background()
svc := newGameService()
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
seed := openingSeed(t)
g, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour,
HintsAllowed: true, HintsPerPlayer: 1, Seed: seed,
})
if err != nil {
t.Fatalf("create: %v", err)
}
if g.Status != game.StatusActive || g.Players != 2 {
t.Fatalf("unexpected new game: %+v", g)
}
mirror := newMirror(t, seed, 2)
var last game.MoveResult
for i := 0; i < 300 && !mirror.Over(); i++ {
cur := seats[mirror.ToMove()]
if hint, ok := mirror.HintView(); ok {
last, err = svc.SubmitPlay(ctx, g.ID, cur, hint.Dir, hint.Tiles)
if err != nil {
t.Fatalf("submit play: %v", err)
}
if _, err := mirror.SubmitPlay(hint.Dir, hint.Tiles); err != nil {
t.Fatalf("mirror play: %v", err)
}
} else {
last, err = svc.Pass(ctx, g.ID, cur)
if err != nil {
t.Fatalf("pass: %v", err)
}
if _, err := mirror.Pass(); err != nil {
t.Fatalf("mirror pass: %v", err)
}
}
}
if !mirror.Over() {
t.Fatal("greedy game did not finish")
}
if last.Game.Status != game.StatusFinished || last.Game.EndReason == "" {
t.Fatalf("final game not finished: %+v", last.Game)
}
w0, l0, d0, mg0, _, ok0 := readStats(t, seats[0])
w1, l1, d1, mg1, _, ok1 := readStats(t, seats[1])
if !ok0 || !ok1 {
t.Fatal("both players must have a stats row")
}
if mg0 <= 0 || mg1 <= 0 {
t.Errorf("max game points must be positive: %d, %d", mg0, mg1)
}
decisive := (w0 == 1 && l1 == 1) || (w1 == 1 && l0 == 1)
draw := d0 == 1 && d1 == 1 && w0 == 0 && w1 == 0
if !decisive && !draw {
t.Errorf("inconsistent W/L/D: p0(%d/%d/%d) p1(%d/%d/%d)", w0, l0, d0, w1, l1, d1)
}
}
// TestReplayEquivalence plays a few moves through one service, then proves a
// second service with a cold cache rebuilds the identical hidden state from the
// journal.
func TestReplayEquivalence(t *testing.T) {
ctx := context.Background()
svc := newGameService()
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
seed := openingSeed(t)
g, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed,
})
if err != nil {
t.Fatalf("create: %v", err)
}
mirror := newMirror(t, seed, 2)
for i := 0; i < 6 && !mirror.Over(); i++ {
cur := seats[mirror.ToMove()]
if hint, ok := mirror.HintView(); ok {
if _, err := svc.SubmitPlay(ctx, g.ID, cur, hint.Dir, hint.Tiles); err != nil {
t.Fatalf("submit: %v", err)
}
mirror.SubmitPlay(hint.Dir, hint.Tiles)
} else {
if _, err := svc.Pass(ctx, g.ID, cur); err != nil {
t.Fatalf("pass: %v", err)
}
mirror.Pass()
}
}
warm, err := svc.GameState(ctx, g.ID, seats[0])
if err != nil {
t.Fatalf("warm state: %v", err)
}
cold, err := newGameService().GameState(ctx, g.ID, seats[0]) // fresh cache → replay
if err != nil {
t.Fatalf("cold state: %v", err)
}
if warm.BagLen != cold.BagLen {
t.Errorf("bag len warm %d != cold %d", warm.BagLen, cold.BagLen)
}
if !equalStrings(warm.Rack, cold.Rack) {
t.Errorf("rack warm %v != replayed %v", warm.Rack, cold.Rack)
}
}
// TestResignWinnerAndStats checks that a resigner loses and keeps their score
// while the opponent wins, with statistics to match.
func TestResignWinnerAndStats(t *testing.T) {
ctx := context.Background()
svc := newGameService()
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
seed := openingSeed(t)
g, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed,
})
if err != nil {
t.Fatalf("create: %v", err)
}
mirror := newMirror(t, seed, 2)
hint, ok := mirror.HintView()
if !ok {
t.Fatal("no opening move")
}
played, err := svc.SubmitPlay(ctx, g.ID, seats[0], hint.Dir, hint.Tiles) // p0 scores
if err != nil {
t.Fatalf("p0 play: %v", err)
}
score0 := played.Game.Seats[0].Score
res, err := svc.Resign(ctx, g.ID, seats[1]) // p1 (trailing 0) resigns
if err != nil {
t.Fatalf("resign: %v", err)
}
if res.Game.Status != game.StatusFinished || res.Game.EndReason != "resign" {
t.Fatalf("after resign: %+v", res.Game)
}
if !res.Game.Seats[0].IsWinner || res.Game.Seats[1].IsWinner {
t.Errorf("winner flags wrong: %+v", res.Game.Seats)
}
if res.Game.Seats[0].Score != score0 {
t.Errorf("winner score changed on resign: %d -> %d", score0, res.Game.Seats[0].Score)
}
w0, l0, _, _, _, _ := readStats(t, seats[0])
w1, l1, _, _, _, _ := readStats(t, seats[1])
if w0 != 1 || l0 != 0 || w1 != 0 || l1 != 1 {
t.Errorf("resign stats wrong: p0(%d/%d) p1(%d/%d)", w0, l0, w1, l1)
}
}
// TestTimeoutSweep auto-resigns an overdue game and records it as a timeout.
func TestTimeoutSweep(t *testing.T) {
ctx := context.Background()
svc := newGameService()
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
g, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: time.Hour, Seed: openingSeed(t),
})
if err != nil {
t.Fatalf("create: %v", err)
}
backdate(t, g.ID, time.Now().UTC().Add(-2*time.Hour))
// The sweep is global over the shared pool; assert the target game itself,
// not the count, since other tests leave active games behind.
if n, err := svc.SweepTimeouts(ctx, time.Now().UTC()); err != nil || n < 1 {
t.Fatalf("sweep swept %d (err %v), want >= 1", n, err)
}
st, err := svc.History(ctx, g.ID)
if err != nil {
t.Fatalf("history: %v", err)
}
if st.Game.Status != game.StatusFinished || st.Game.EndReason != "timeout" {
t.Fatalf("game not timed out: %+v", st.Game)
}
if !st.Game.Seats[1].IsWinner { // seat 0 was to move and timed out
t.Errorf("opponent should win on timeout: %+v", st.Game.Seats)
}
w1, _, _, _, _, _ := readStats(t, seats[1])
if w1 != 1 {
t.Errorf("opponent wins = %d, want 1", w1)
}
}
// TestTimeoutRespectsAwayWindow keeps a player who is asleep from being timed
// out until their away window ends.
func TestTimeoutRespectsAwayWindow(t *testing.T) {
ctx := context.Background()
svc := newGameService()
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
// Seat 0 (to move) sleeps the whole UTC day except a one-minute gap, so any
// deadline lands inside the window.
setAway(t, seats[0], "UTC", "00:00", "23:59")
g, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: time.Hour, Seed: openingSeed(t),
})
if err != nil {
t.Fatalf("create: %v", err)
}
// Deadline at 12:00 UTC, well inside the away window.
turnStart := time.Date(2026, 6, 2, 11, 0, 0, 0, time.UTC)
backdate(t, g.ID, turnStart)
// A sweep whose clock sits inside the away window must leave the target game
// active. (The sweep is global; assert the target, not the count.)
if _, err := svc.SweepTimeouts(ctx, time.Date(2026, 6, 2, 12, 30, 0, 0, time.UTC)); err != nil {
t.Fatalf("sweep inside away window: %v", err)
}
if status, _ := gameStatus(t, svc, g.ID); status != game.StatusActive {
t.Fatalf("target timed out inside its away window (status %q)", status)
}
// Once the clock passes the window's end, it must time out.
if _, err := svc.SweepTimeouts(ctx, time.Date(2026, 6, 3, 23, 59, 0, 0, time.UTC)); err != nil {
t.Fatalf("sweep after away window: %v", err)
}
if status, reason := gameStatus(t, svc, g.ID); status != game.StatusFinished || reason != "timeout" {
t.Fatalf("target not timed out after window: status %q reason %q", status, reason)
}
}
// gameStatus returns a game's status and end reason via the service.
func gameStatus(t *testing.T, svc *game.Service, id uuid.UUID) (status, endReason string) {
t.Helper()
h, err := svc.History(context.Background(), id)
if err != nil {
t.Fatalf("game status: %v", err)
}
return h.Game.Status, h.Game.EndReason
}
// TestHintPolicy exercises the per-game allowance, the profile wallet and the
// disabled switch.
func TestHintPolicy(t *testing.T) {
ctx := context.Background()
svc := newGameService()
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
seed := openingSeed(t)
g, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour,
HintsAllowed: true, HintsPerPlayer: 1, Seed: seed,
})
if err != nil {
t.Fatalf("create: %v", err)
}
if _, err := svc.Hint(ctx, g.ID, seats[0]); err != nil { // spends the allowance
t.Fatalf("first hint: %v", err)
}
if _, err := svc.Hint(ctx, g.ID, seats[0]); !errors.Is(err, game.ErrNoHintsLeft) {
t.Fatalf("second hint = %v, want ErrNoHintsLeft", err)
}
setHintBalance(t, seats[0], 2)
res, err := svc.Hint(ctx, g.ID, seats[0]) // spends the wallet
if err != nil {
t.Fatalf("wallet hint: %v", err)
}
if res.HintsRemaining != 1 {
t.Errorf("hints remaining = %d, want 1", res.HintsRemaining)
}
off, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour,
HintsAllowed: false, HintsPerPlayer: 1, Seed: seed,
})
if err != nil {
t.Fatalf("create off: %v", err)
}
if _, err := svc.Hint(ctx, off.ID, seats[0]); !errors.Is(err, game.ErrHintsDisabled) {
t.Fatalf("disabled hint = %v, want ErrHintsDisabled", err)
}
}
// TestCheckWordAndComplaint covers the word-check tool and complaint capture.
func TestCheckWordAndComplaint(t *testing.T) {
ctx := context.Background()
svc := newGameService()
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
g, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: openingSeed(t),
})
if err != nil {
t.Fatalf("create: %v", err)
}
if ok, err := svc.CheckWord(ctx, g.ID, "CAT"); err != nil || !ok {
t.Errorf("CheckWord cat = %v, %v; want true", ok, err)
}
if ok, err := svc.CheckWord(ctx, g.ID, "zzzzzz"); err != nil || ok {
t.Errorf("CheckWord zzzzzz = %v, %v; want false", ok, err)
}
c, err := svc.FileComplaint(ctx, g.ID, seats[0], "ZZZZZZ", "should be a word")
if err != nil {
t.Fatalf("file complaint: %v", err)
}
if c.ID == uuid.Nil || c.Word != "zzzzzz" || c.WasValid || c.Status != game.StatusComplaintOpen {
t.Errorf("unexpected complaint: %+v", c)
}
}
// TestEvaluatePlayPreview previews a legal and an illegal play without committing.
func TestEvaluatePlayPreview(t *testing.T) {
ctx := context.Background()
svc := newGameService()
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
seed := openingSeed(t)
g, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed,
})
if err != nil {
t.Fatalf("create: %v", err)
}
hint, _ := newMirror(t, seed, 2).HintView()
eval, err := svc.EvaluatePlay(ctx, g.ID, seats[0], hint.Dir, hint.Tiles)
if err != nil {
t.Fatalf("evaluate: %v", err)
}
if !eval.Valid || eval.Score <= 0 {
t.Errorf("legal preview = %+v, want valid with score", eval)
}
// The same play must still be available afterwards (no commit).
if _, err := svc.SubmitPlay(ctx, g.ID, seats[0], hint.Dir, hint.Tiles); err != nil {
t.Fatalf("submit after evaluate: %v", err)
}
bad, err := svc.EvaluatePlay(ctx, g.ID, seats[1], engine.Horizontal, []engine.TileRecord{{Row: 0, Col: 0, Letter: "q"}})
if err != nil {
t.Fatalf("evaluate illegal: %v", err)
}
if bad.Valid {
t.Error("disconnected play must be invalid")
}
}
// TestConcurrentSubmitSerialized confirms the per-game lock lets only one of two
// racing identical submissions win.
func TestConcurrentSubmitSerialized(t *testing.T) {
ctx := context.Background()
svc := newGameService()
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
seed := openingSeed(t)
g, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed,
})
if err != nil {
t.Fatalf("create: %v", err)
}
hint, _ := newMirror(t, seed, 2).HintView()
var wg sync.WaitGroup
var mu sync.Mutex
var ok int
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if _, err := svc.SubmitPlay(ctx, g.ID, seats[0], hint.Dir, hint.Tiles); err == nil {
mu.Lock()
ok++
mu.Unlock()
}
}()
}
wg.Wait()
if ok != 1 {
t.Errorf("successful submits = %d, want exactly 1", ok)
}
}
func backdate(t *testing.T, gameID uuid.UUID, at time.Time) {
t.Helper()
if _, err := testDB.ExecContext(context.Background(),
`UPDATE backend.games SET turn_started_at = $1 WHERE game_id = $2`, at, gameID); err != nil {
t.Fatalf("backdate: %v", err)
}
}
func setAway(t *testing.T, id uuid.UUID, tz, start, end string) {
t.Helper()
if _, err := testDB.ExecContext(context.Background(),
`UPDATE backend.accounts SET time_zone = $1, away_start = $2::time, away_end = $3::time WHERE account_id = $4`,
tz, start, end, id); err != nil {
t.Fatalf("set away: %v", err)
}
}
func setHintBalance(t *testing.T, id uuid.UUID, n int) {
t.Helper()
if _, err := testDB.ExecContext(context.Background(),
`UPDATE backend.accounts SET hint_balance = $1 WHERE account_id = $2`, n, id); err != nil {
t.Fatalf("set hint balance: %v", err)
}
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}