//go:build integration package inttest import ( "context" "encoding/json" "testing" "time" "github.com/google/uuid" "scrabble/backend/internal/engine" "scrabble/backend/internal/game" ) // TestGameStateVoidsUnreplayableGame proves the lazy-void path: a game whose journal holds a // move the current rules reject — here an illegal off-centre first move, standing in for a // move that was legal when made but became illegal under tightened rules — is, on open, // finished as a draw with end_reason 'aborted' instead of surfacing an "illegal play" error. func TestGameStateVoidsUnreplayableGame(t *testing.T) { ctx := context.Background() svc := newGameService() acc0, acc1 := provisionAccount(t), provisionAccount(t) const seed = 1 g, err := svc.Create(ctx, game.CreateParams{ Variant: engine.VariantEnglish, Seats: []uuid.UUID{acc0, acc1}, TurnTimeout: 24 * time.Hour, Seed: seed, }) if err != nil { t.Fatalf("create: %v", err) } // Seat 0's deterministic opening rack; pick two non-blank tiles for the crafted move. mirror, err := engine.New(testRegistry, engine.Options{Variant: engine.VariantEnglish, Version: testDictVersion, Players: 2, Seed: seed}) if err != nil { t.Fatalf("mirror: %v", err) } var letters []string for _, l := range mirror.Hand(0) { if l != "?" { letters = append(letters, l) } if len(letters) == 2 { break } } if len(letters) < 2 { t.Fatal("need two non-blank opening tiles") } // Insert an illegal first move (off-centre, so the engine rejects it) as the only journal row. payload, err := json.Marshal(map[string]any{ "rack": mirror.Hand(0), "dir": "H", "tiles": []map[string]any{ {"row": 0, "col": 0, "letter": letters[0]}, {"row": 0, "col": 1, "letter": letters[1]}, }, "words": []string{letters[0] + letters[1]}, }) if err != nil { t.Fatalf("payload: %v", err) } if _, err := testDB.ExecContext(ctx, `INSERT INTO backend.game_moves (game_id, seq, seat, action, score, running_total, exchanged_count, payload) VALUES ($1, 0, 0, 'play', 0, 0, 0, $2)`, g.ID, string(payload)); err != nil { t.Fatalf("insert crafted move: %v", err) } if _, err := testDB.ExecContext(ctx, `UPDATE backend.games SET move_count = 1 WHERE game_id = $1`, g.ID); err != nil { t.Fatalf("bump move_count: %v", err) } // Open through a fresh service so its live-game cache is cold and the crafted journal is // replayed (the real scenario: a player opening the game in a new session). It must not // error; the game comes back voided as a draw. svc2 := newGameService() view, err := svc2.GameState(ctx, g.ID, acc0) if err != nil { t.Fatalf("GameState on an unreplayable game should not error, got: %v", err) } if view.Game.Status != game.StatusFinished { t.Errorf("status = %q, want %q", view.Game.Status, game.StatusFinished) } if view.Game.EndReason != "aborted" { t.Errorf("end_reason = %q, want aborted", view.Game.EndReason) } for _, s := range view.Game.Seats { if s.IsWinner { t.Errorf("seat %d marked winner; an aborted game is a draw", s.Seat) } } // Idempotent: a second open (fresh cold cache) also succeeds and stays voided. if _, err := newGameService().GameState(ctx, g.ID, acc1); err != nil { t.Fatalf("second GameState should not error: %v", err) } }