ecb21bd218
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m18s
EvaluatePlay (the hottest gameplay call, fired on every tile placement) now uses the warm live-game cache directly: an active game stays cached (mutated in place across moves, evicted only on finish), so the cached engine game and its immutable seat list answer the membership check and the score with no DB read. The cold path (eviction / first load) still loads and validates via the store. The seat list is cached alongside the engine game for the membership fast path. GetGame also folds its two round-trips (game, then seats) into one LEFT JOIN, preserving the contract (same Game, a seatless game still returns empty seats, seat order kept) — one round-trip for every remaining caller. Measured at 500 players: evaluate p99 halves (200 -> 100 ms) and the per-op query count drops. It does NOT cut postgres CPU — that is write-bound (per-move CommitMove plus draft upserts and journal replays), the cheap indexed GetGame reads were never its bottleneck, and postgres runs with headroom (~1.5 of 2 cores). So this is a latency / query-volume optimization, not a DB-CPU one. Regression cover: a non-player evaluate against a warm game asserts the cached-seat membership path; the integration suite exercises GetGame's join across every game op.
635 lines
21 KiB
Go
635 lines
21 KiB
Go
//go:build integration
|
||
|
||
package inttest
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/google/uuid"
|
||
|
||
"scrabble/backend/internal/account"
|
||
"scrabble/backend/internal/engine"
|
||
"scrabble/backend/internal/game"
|
||
)
|
||
|
||
// 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.Tiles)
|
||
if err != nil {
|
||
t.Fatalf("submit play: %v", err)
|
||
}
|
||
if _, err := mirror.SubmitPlay(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, mw0, ok0 := readStats(t, seats[0])
|
||
w1, l1, d1, mg1, mw1, 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)
|
||
}
|
||
|
||
// Each player who made a scoring play gets exactly one per-variant best move (only
|
||
// scrabble_en was played here); its score equals the aggregate max_word_points and it
|
||
// carries the decoded word as tiles.
|
||
accounts := account.NewStore(testDB)
|
||
for _, p := range []struct {
|
||
id uuid.UUID
|
||
maxWord int
|
||
}{{seats[0], mw0}, {seats[1], mw1}} {
|
||
if p.maxWord == 0 {
|
||
continue // a player who only passed has no best move
|
||
}
|
||
st, err := accounts.GetStats(ctx, p.id)
|
||
if err != nil {
|
||
t.Fatalf("get stats: %v", err)
|
||
}
|
||
// The player made at least one scoring play (maxWord > 0), so the moves aggregate is
|
||
// positive; no hints were taken in this greedy game, so the hints aggregate stays 0.
|
||
if st.Moves <= 0 {
|
||
t.Errorf("moves = %d, want > 0", st.Moves)
|
||
}
|
||
if st.HintsUsed != 0 {
|
||
t.Errorf("hints used = %d, want 0 (no hints taken)", st.HintsUsed)
|
||
}
|
||
if len(st.BestMoves) != 1 {
|
||
t.Fatalf("want one best move (only scrabble_en played), got %d: %+v", len(st.BestMoves), st.BestMoves)
|
||
}
|
||
bm := st.BestMoves[0]
|
||
if bm.Variant != engine.VariantEnglish.String() {
|
||
t.Errorf("best move variant = %q, want %q", bm.Variant, engine.VariantEnglish.String())
|
||
}
|
||
if bm.Score != p.maxWord {
|
||
t.Errorf("best move score %d != max_word_points %d", bm.Score, p.maxWord)
|
||
}
|
||
if len(bm.Tiles) == 0 {
|
||
t.Error("best move word is empty")
|
||
}
|
||
for _, tl := range bm.Tiles {
|
||
if tl.Letter == "" {
|
||
t.Error("best move tile has empty letter")
|
||
}
|
||
if tl.Blank && tl.Value != 0 {
|
||
t.Errorf("blank tile must score 0, got %d", tl.Value)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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.Tiles); err != nil {
|
||
t.Fatalf("submit: %v", err)
|
||
}
|
||
mirror.SubmitPlay(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.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)
|
||
}
|
||
}
|
||
|
||
// TestResignOnOpponentTurn checks a player can forfeit on the
|
||
// opponent's turn. Seat 0 plays (so it is seat 1's turn), then seat 0 resigns its own
|
||
// seat while it is not its turn — no ErrNotYourTurn, the game ends, and seat 0 loses
|
||
// despite leading on score.
|
||
func TestResignOnOpponentTurn(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, ok := newMirror(t, seed, 2).HintView()
|
||
if !ok {
|
||
t.Fatal("no opening move")
|
||
}
|
||
if _, err := svc.SubmitPlay(ctx, g.ID, seats[0], hint.Tiles); err != nil { // p0 scores, now p1's turn
|
||
t.Fatalf("p0 play: %v", err)
|
||
}
|
||
|
||
res, err := svc.Resign(ctx, g.ID, seats[0]) // p0 resigns OFF turn
|
||
if err != nil {
|
||
t.Fatalf("off-turn resign = %v, want nil", err)
|
||
}
|
||
if res.Game.Status != game.StatusFinished || res.Game.EndReason != "resign" {
|
||
t.Fatalf("after off-turn resign: %+v", res.Game)
|
||
}
|
||
if res.Game.Seats[0].IsWinner || !res.Game.Seats[1].IsWinner {
|
||
t.Errorf("winner flags wrong (resigner must lose): %+v", res.Game.Seats)
|
||
}
|
||
}
|
||
|
||
// 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:00–07: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)
|
||
}
|
||
// The allowance is spent before the wallet: with an empty wallet, the state now reports no
|
||
// hints left and a zero wallet, so the per-game allowance (HintsRemaining-WalletBalance) is 0.
|
||
st, err := svc.GameState(ctx, g.ID, seats[0])
|
||
if err != nil {
|
||
t.Fatalf("state: %v", err)
|
||
}
|
||
if st.HintsRemaining != 0 || st.WalletBalance != 0 {
|
||
t.Errorf("after allowance hint: hints=%d wallet=%d, want 0/0", st.HintsRemaining, st.WalletBalance)
|
||
}
|
||
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)
|
||
}
|
||
// The allowance stays exhausted; the wallet dropped 2->1, and WalletBalance carries it alone.
|
||
if res.HintsRemaining != 1 || res.WalletBalance != 1 {
|
||
t.Errorf("wallet hint: hints=%d wallet=%d, want 1/1", res.HintsRemaining, res.WalletBalance)
|
||
}
|
||
// game_players.hints_used counts BOTH hints (1 allowance + 1 wallet) — the per-game total
|
||
// that feeds the player's lifetime hint statistics, not just the allowance.
|
||
st2, err := svc.GameState(ctx, g.ID, seats[0])
|
||
if err != nil {
|
||
t.Fatalf("state after wallet hint: %v", err)
|
||
}
|
||
if got := st2.Game.Seats[0].HintsUsed; got != 2 {
|
||
t.Errorf("hints_used = %d after allowance + wallet hint, want 2", got)
|
||
}
|
||
|
||
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: 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 scrabble_en, 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.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)
|
||
}
|
||
if eval.Dir != hint.Dir.String() || len(eval.Words) == 0 {
|
||
t.Errorf("legal preview dir/words = (%q, %v), want dir %q with the words formed", eval.Dir, eval.Words, hint.Dir.String())
|
||
}
|
||
// The same play must still be available afterwards (no commit).
|
||
if _, err := svc.SubmitPlay(ctx, g.ID, seats[0], hint.Tiles); err != nil {
|
||
t.Fatalf("submit after evaluate: %v", err)
|
||
}
|
||
|
||
bad, err := svc.EvaluatePlay(ctx, g.ID, seats[1], []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")
|
||
}
|
||
|
||
// A non-seated account cannot preview: with the game warm in the live cache, the
|
||
// membership check runs against the cached seat list (the hot path that skips GetGame).
|
||
if _, err := svc.EvaluatePlay(ctx, g.ID, provisionAccount(t), hint.Tiles); !errors.Is(err, game.ErrNotAPlayer) {
|
||
t.Errorf("evaluate by a non-player = %v, want ErrNotAPlayer", err)
|
||
}
|
||
}
|
||
|
||
// 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.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 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)
|
||
}
|
||
}
|