feat(game): void unreplayable games as a draw instead of erroring
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 46s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m13s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 46s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m13s
A committed move that becomes illegal under a tightened rule (the single-word connectivity fix) makes engine replay fail, which left such games unopenable — an empty screen and an 'illegal play' error. Now the first open closes the game gracefully as a draw (engine.EndAborted -> end_reason 'aborted', no winner), preserves the journal, and surfaces an impersonal organizer note at the end of the move history and in the GCG export. - engine: EndAborted + Abort() (draw, no rack adjustment; winner -1). - service: replay aborts on ErrIllegalPlay; liveGame persists the void once (lazy, on open); GameState re-reads for the settled view. - store: VoidGame finishes the game and stamps a draw without a journal row. - migration 00002: allow end_reason 'aborted'. - ui: organizer note under the history grid; i18n en/ru. - docs: ARCHITECTURE 6/9.1, FUNCTIONAL(+ru), PRERELEASE MW3.
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
//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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user