Files
Ilia Denisov 90eaf4964b
Tests · Go / test (push) Successful in 10s
Tests · Integration / integration (push) Successful in 12s
Tests · UI / test (push) Successful in 19s
Tests · Go / test (pull_request) Successful in 9s
Tests · Integration / integration (pull_request) Successful in 12s
Tests · UI / test (pull_request) Successful in 19s
Stage 13: alphabet on the wire (UI alphabet-agnostic, TODO-4)
Live play now exchanges per-variant alphabet indices instead of concrete
letters (rack out; submit-play, evaluate, exchange, word-check in). The client
caches each variant's (index, letter, value) table behind
StateRequest.include_alphabet and renders the rack and blank chooser from it,
dropping the hardcoded value/alphabet tables. History, the durable journal and
GCG stay decoded concrete characters (ARCHITECTURE §9.1, unchanged).

- pkg/fbs: new AlphabetEntry + PlayTile; StateView.rack -> [ubyte] + alphabet;
  StateRequest.include_alphabet; SubmitPlay/Eval tiles -> [PlayTile];
  Exchange tiles + CheckWord word -> [ubyte] (committed Go + TS regenerated).
- engine: AlphabetTable + a cached per-variant codec (LetterForIndex/EncodeRack/
  DecodeTiles/DecodeWord) + BlankIndex sentinel; Go parity test.
- backend server edge maps index<->letter (new thin game.Service.GameVariant);
  game.Service domain methods, engine.Game and the robot keep one letter-based
  play path. The gateway forwards indices verbatim (no alphabet table).
- ui: lib/alphabet.ts in-memory cache; codec encodes/decodes indices; premiums.ts
  is geometry-only; the mock seeds a fixture table; the UI normalises display to
  upper case (codec + cache), leaving placement/board/checkword unchanged.

Parity moved to the Go engine.AlphabetTable test; premiums.ts loses its value
tables. Discharges TODO-4.
2026-06-04 16:26:43 +02:00

595 lines
19 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//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
}
// TestListForAccount checks the lobby "my games" query: it returns exactly the
// games the account is seated in (each with its seats), and nothing for an outsider.
func TestListForAccount(t *testing.T) {
ctx := context.Background()
svc := newGameService()
me, opp := provisionAccount(t), provisionAccount(t)
g1, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: []uuid.UUID{me, opp}, TurnTimeout: 24 * time.Hour, Seed: 1,
})
if err != nil {
t.Fatalf("create g1: %v", err)
}
g2, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: []uuid.UUID{opp, me}, TurnTimeout: 24 * time.Hour, Seed: 2,
})
if err != nil {
t.Fatalf("create g2: %v", err)
}
games, err := svc.ListForAccount(ctx, me)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(games) != 2 {
t.Fatalf("got %d games, want 2", len(games))
}
seen := map[uuid.UUID]bool{}
for _, g := range games {
seen[g.ID] = true
if len(g.Seats) != 2 {
t.Errorf("game %s has %d seats, want 2", g.ID, len(g.Seats))
}
seated := false
for _, s := range g.Seats {
if s.AccountID == me {
seated = true
}
}
if !seated {
t.Errorf("account not found among seats of returned game %s", g.ID)
}
}
if !seen[g1.ID] || !seen[g2.ID] {
t.Errorf("returned games %v missing g1=%s or g2=%s", seen, g1.ID, g2.ID)
}
other := provisionAccount(t)
og, err := svc.ListForAccount(ctx, other)
if err != nil {
t.Fatalf("list other: %v", err)
}
if len(og) != 0 {
t.Errorf("outsider sees %d games, want 0", len(og))
}
}
// 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))
// Disable the to-move account's away window: with the default 00:0007:00
// window the sweeper (correctly) declines to time out a player whose deadline
// fell while they were asleep, which made this test fail whenever CI ran with
// now-1h inside that window (e.g. ~07:00 UTC). An empty window keeps the test
// deterministic regardless of the time of day.
setAway(t, seats[0], "UTC", "00:00", "00:00")
// 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)
}
}
// TestGameVariant covers the edge's lightweight variant lookup (Stage 13): it returns the
// created game's variant and ErrNotFound for an unknown id.
func TestGameVariant(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: 1,
})
if err != nil {
t.Fatalf("create: %v", err)
}
if v, err := svc.GameVariant(ctx, g.ID); err != nil || v != engine.VariantEnglish {
t.Fatalf("GameVariant = %v, %v; want english, nil", v, err)
}
if _, err := svc.GameVariant(ctx, uuid.New()); !errors.Is(err, game.ErrNotFound) {
t.Errorf("GameVariant(unknown) = %v, want ErrNotFound", 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
}
// TestExportGCGRefusesActiveGame checks the Stage 8 finished-only gate: a GCG export
// is allowed only once the game is over, so an active game leaks nothing mid-play.
func TestExportGCGRefusesActiveGame(t *testing.T) {
ctx := context.Background()
gameID, _ := newGameWithSeats(t, 2)
if _, err := newGameService().ExportGCG(ctx, gameID); !errors.Is(err, game.ErrGameActive) {
t.Fatalf("export of active game = %v, want ErrGameActive", err)
}
}