diff --git a/PRERELEASE.md b/PRERELEASE.md index daaecc5..08ac29f 100644 --- a/PRERELEASE.md +++ b/PRERELEASE.md @@ -27,6 +27,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l | UI | Tab-bar navigation redesign (drop the hamburger) | owner ad-hoc | **done** | | MW | "Multiple words per turn" rule for Russian games (engine v1.1.0) | owner ad-hoc | **done** | | MW2 | Single-word rule connectivity fix: the word must run along its own line through an existing tile (perpendicular-only contact no longer connects); single-tile direction picks the best legal word (engine v1.1.1) | owner ad-hoc | **done** | +| MW3 | Graceful replay degradation: a game whose journalled move became illegal under MW2 is closed as a draw (`end_reason='aborted'`) on open instead of erroring, with an impersonal organizer note in the history + GCG (migration `00002`) | owner ad-hoc | **done** | | OW | Open auto-match: enter the game at once and wait inside it (robot after 90–180 s) | owner ad-hoc | **done** | | DA | Dictionary admin: online release-archive upload → word-diff preview → install/activate; versioned dict volume; active version persisted in DB; resident label = release tag | owner ad-hoc | **done** | | → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) | diff --git a/backend/internal/engine/abort_test.go b/backend/internal/engine/abort_test.go new file mode 100644 index 0000000..a38d174 --- /dev/null +++ b/backend/internal/engine/abort_test.go @@ -0,0 +1,31 @@ +package engine + +import "testing" + +// TestAbortFinishesAsDrawWithoutAdjustment covers Abort, the graceful close used when a +// committed game can no longer be reconstructed from its journal. The game ends as a draw +// (no winner) regardless of the running scores, and the scores are left untouched (no +// end-game rack adjustment). +func TestAbortFinishesAsDrawWithoutAdjustment(t *testing.T) { + g, err := New(testReg, Options{Variant: VariantEnglish, Version: testVersion, Players: 2, Seed: 1}) + if err != nil { + t.Fatalf("new game: %v", err) + } + g.scores[0], g.scores[1] = 10, 5 // a clear leader, so a draw cannot come from equal scores + + g.Abort() + + if !g.Over() { + t.Error("aborted game should be over") + } + if g.Reason() != EndAborted { + t.Errorf("reason = %v, want EndAborted", g.Reason()) + } + res := g.Result() + if res.Winner != -1 { + t.Errorf("winner = %d, want -1 (draw)", res.Winner) + } + if res.Scores[0] != 10 || res.Scores[1] != 5 { + t.Errorf("scores = %v, want [10 5] (no rack adjustment on abort)", res.Scores) + } +} diff --git a/backend/internal/engine/game.go b/backend/internal/engine/game.go index 36e4446..171c2f8 100644 --- a/backend/internal/engine/game.go +++ b/backend/internal/engine/game.go @@ -25,6 +25,10 @@ const ( EndScoreless // EndResign fires when a player resigns. EndResign + // EndAborted fires when a committed game can no longer be reconstructed from its + // journal — a recorded move became illegal under tightened rules — and is closed as a + // draw rather than left unopenable. See (*Game).Abort. + EndAborted ) // String renders the end reason for logs and diagnostics. @@ -38,6 +42,8 @@ func (r EndReason) String() string { return "scoreless" case EndResign: return "resign" + case EndAborted: + return "aborted" } return "unknown" } @@ -370,6 +376,17 @@ func (g *Game) finish(reason EndReason) { g.applyEndAdjustment(reason) } +// Abort closes a still-running game as a draw with EndAborted and no rack adjustment. The +// service calls it when a committed game can no longer be reconstructed from its journal — +// a recorded move became illegal under tightened rules — so the game ends gracefully +// instead of being left unopenable. It is a no-op on an already-finished game. +func (g *Game) Abort() { + if g.over { + return + } + g.finish(EndAborted) +} + // applyEndAdjustment settles the unplayed racks. When a player goes out (bag // empty, rack empty) they gain the sum of every opponent's rack value and each // opponent loses their own. A scoreless stalemate forfeits each player's own @@ -453,6 +470,9 @@ func (g *Game) winner() int { if !g.over { return -1 } + if g.reason == EndAborted { + return -1 // an aborted game is a draw regardless of the running scores + } best, tie := -1, false for i := range g.scores { if g.resigned[i] { diff --git a/backend/internal/game/gcg.go b/backend/internal/game/gcg.go index 20bbe09..6d6de32 100644 --- a/backend/internal/game/gcg.go +++ b/backend/internal/game/gcg.go @@ -36,6 +36,11 @@ func writeGCG(g Game, names []string, moves []HistoryMove) string { fmt.Fprintf(&b, "#note %s timed out (rack %s)\n", nick(mv.Seat), rack) } } + // An aborted game ends in a draw because it could no longer be reconstructed; record it + // as an impersonal organizer note (free-text #note; GCG readers ignore pragmas). + if g.EndReason == "aborted" { + fmt.Fprintln(&b, "#note [organizer] game could not be continued and was ended in a draw") + } return b.String() } diff --git a/backend/internal/game/gcg_test.go b/backend/internal/game/gcg_test.go index 8cc732c..2f2bed2 100644 --- a/backend/internal/game/gcg_test.go +++ b/backend/internal/game/gcg_test.go @@ -61,6 +61,21 @@ func TestWriteGCG(t *testing.T) { } } +func TestWriteGCGAbortedNote(t *testing.T) { + g := Game{ + ID: uuid.MustParse("00000000-0000-7000-8000-000000000002"), + Variant: engine.VariantErudit, + DictVersion: "v1", + Players: 2, + Status: StatusFinished, + EndReason: "aborted", + } + out := writeGCG(g, []string{"Alice", "Bob"}, nil) + if !strings.Contains(out, "#note [organizer]") { + t.Errorf("aborted game GCG missing the organizer note:\n%s", out) + } +} + func TestGCGTilesUppercasesCyrillic(t *testing.T) { if got := gcgTiles([]string{"к", "о", "т", "?"}); got != "КОТ?" { t.Errorf("gcgTiles = %q, want КОТ?", got) diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index 1817b9a..fd9d19c 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -950,6 +950,12 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID) if err != nil { return StateView{}, err } + if g.Reason() == engine.EndAborted { + // liveGame voided the game; re-read so the view reflects the finished/aborted state. + if pre, err = svc.store.GetGame(ctx, gameID); err != nil { + return StateView{}, err + } + } return StateView{ Game: pre, Seat: seat, @@ -1088,6 +1094,13 @@ func (svc *Service) liveGame(ctx context.Context, pre Game) (*engine.Game, error if err != nil { return nil, err } + if g.Reason() == engine.EndAborted && pre.Status != StatusFinished { + // First open after the game became unreplayable: persist the void (a finished draw) + // so the lobby and later opens see it settled. Guarded so it runs exactly once. + if err := svc.voidGame(ctx, pre, g); err != nil { + return nil, err + } + } if !g.Over() { svc.cache.put(pre.ID, g, pre.Variant.String()) } @@ -1120,12 +1133,41 @@ func (svc *Service) replay(ctx context.Context, pre Game) (*engine.Game, error) } for _, mv := range moves { if err := replayMove(g, mv); err != nil { + if errors.Is(err, engine.ErrIllegalPlay) { + // A committed move is no longer legal under the current rules, so the game + // cannot be reconstructed past it: close it as a draw (liveGame persists the + // void) rather than leave it unopenable. Other errors are genuine and propagate. + g.Abort() + break + } return nil, fmt.Errorf("game: replay %s move %d: %w", pre.ID, mv.Seq, err) } } return g, nil } +// voidGame closes pre as a draw because its journal can no longer be replayed; g is the +// partial reconstruction, already Aborted. It persists the finish (end_reason 'aborted'), +// each seat's partial score as a draw, and the draw statistics for the non-guest seats. The +// journal is left intact. +func (svc *Service) voidGame(ctx context.Context, pre Game, g *engine.Game) error { + scores := make([]int, g.Players()) + for i := range scores { + scores[i] = g.Score(i) + } + statSeats, err := svc.nonGuestSeats(ctx, pre.Seats) + if err != nil { + return err + } + return svc.store.VoidGame(ctx, voidCommit{ + gameID: pre.ID, + endReason: g.Reason().String(), + scores: scores, + now: svc.clock(), + stats: buildStats(g, statSeats), + }) +} + // replayMove re-applies one journalled move to g through the decoded engine API. func replayMove(g *engine.Game, mv HistoryMove) error { switch mv.Action { diff --git a/backend/internal/game/store.go b/backend/internal/game/store.go index 3bd9946..035d508 100644 --- a/backend/internal/game/store.go +++ b/backend/internal/game/store.go @@ -87,6 +87,17 @@ type commit struct { stats []statDelta } +// voidCommit is everything voiding an unreplayable game persists: the finish stamp with its +// end reason, each seat's partial score as a draw, and the draw statistics. It appends no +// journal row and leaves the move cursor untouched, so the journal is preserved. +type voidCommit struct { + gameID uuid.UUID + endReason string + scores []int + now time.Time + stats []statDelta +} + // activeGame is the sweeper's view of an in-progress game's turn clock. type activeGame struct { gameID uuid.UUID @@ -571,6 +582,34 @@ func (s *Store) CommitMove(ctx context.Context, c commit) error { }) } +// VoidGame closes a game that can no longer be reconstructed from its journal: it stamps the +// finish (status 'finished', the end reason, finished_at), writes each seat's partial score +// as a draw (is_winner false for all) and upserts the draw statistics, in one transaction. +// Unlike CommitMove it appends no journal row and leaves the move cursor untouched. +func (s *Store) VoidGame(ctx context.Context, v voidCommit) error { + return withTx(ctx, s.db, func(tx *sql.Tx) error { + gu := table.Games.UPDATE( + table.Games.Status, table.Games.EndReason, table.Games.UpdatedAt, table.Games.FinishedAt, + ).SET( + postgres.String(StatusFinished), postgres.String(v.endReason), postgres.TimestampzT(v.now), postgres.TimestampzT(v.now), + ).WHERE(table.Games.GameID.EQ(postgres.UUID(v.gameID))) + if _, err := gu.ExecContext(ctx, tx); err != nil { + return fmt.Errorf("void game: %w", err) + } + for seat, score := range v.scores { + if err := updateSeatScore(ctx, tx, v.gameID, seat, score, true, false); err != nil { + return fmt.Errorf("void seat %d: %w", seat, err) + } + } + for _, d := range v.stats { + if err := upsertStats(ctx, tx, d, v.now); err != nil { + return err + } + } + return nil + }) +} + // updateSeatScore writes a seat's running score, also stamping is_winner when the // game has finished. func updateSeatScore(ctx context.Context, tx *sql.Tx, gameID uuid.UUID, seat, score int, finished, isWinner bool) error { diff --git a/backend/internal/inttest/abort_test.go b/backend/internal/inttest/abort_test.go new file mode 100644 index 0000000..df62c5e --- /dev/null +++ b/backend/internal/inttest/abort_test.go @@ -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) + } +} diff --git a/backend/internal/postgres/migrations/00002_aborted_end_reason.sql b/backend/internal/postgres/migrations/00002_aborted_end_reason.sql new file mode 100644 index 0000000..90b37be --- /dev/null +++ b/backend/internal/postgres/migrations/00002_aborted_end_reason.sql @@ -0,0 +1,16 @@ +-- +goose Up +-- Allow end_reason = 'aborted': a game whose journal can no longer be reconstructed (a +-- recorded move became illegal under tightened rules) is closed as a draw rather than left +-- unopenable. See engine.EndAborted and Service.voidGame. +SET search_path = backend, pg_catalog; +ALTER TABLE games DROP CONSTRAINT games_end_reason_chk; +ALTER TABLE games ADD CONSTRAINT games_end_reason_chk CHECK ( + end_reason IS NULL OR end_reason IN ('out_of_tiles', 'scoreless', 'resign', 'timeout', 'aborted') +); + +-- +goose Down +SET search_path = backend, pg_catalog; +ALTER TABLE games DROP CONSTRAINT games_end_reason_chk; +ALTER TABLE games ADD CONSTRAINT games_end_reason_chk CHECK ( + end_reason IS NULL OR end_reason IN ('out_of_tiles', 'scoreless', 'resign', 'timeout') +); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e435938..319062c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -321,7 +321,9 @@ Key points: becomes an automatic resignation, applied by a background sweeper. The sweeper honours each player's **away window** — a daily local-time sleep interval on the account (default 00:00–07:00, midnight-cross aware) — so a player is never - timed out while asleep. + timed out while asleep. A game whose journal can no longer be replayed — a + committed move made illegal by a later rule change — is instead closed as a + **draw** (`aborted`) on the next open, never left unopenable (§9.1). - **Players**: auto-match is always 2 players; friend games are 2–4 players. `backend` owns turn order and the bag for any player count. A resignation or timeout in a two-player game ends it with the other player winning. In a game @@ -525,7 +527,13 @@ re-deriving it, so the rebuild matches the committed game) and to render history dictionary**: the board for visual replay is reconstructed by applying placements onto an empty grid, since moves were validated at play time and scores are stored. `variant` and `dict_version` are kept as **metadata only** (audit, -complaint review), never as a replay dependency. **GCG export** is derived from +complaint review), never as a replay dependency. Engine replay re-validates each +move, so a committed move that **later becomes illegal** under a tightened rule +(e.g. the single-word connectivity rule) would make the rebuild fail; rather than +leave the game unopenable, the next open closes it gracefully as a **draw** +(`engine.EndAborted` → `end_reason='aborted'`, all seats marked non-winners), +preserves the journal intact, and surfaces an impersonal **organizer note** at the +end of the history and in the GCG export (a free-text `#note`). **GCG export** is derived from the same rows and is likewise self-contained — we ship our own writer (the solver exposes none): the standard Poslfit dialect (UTF-8, `#player`/`#lexicon` pragmas, `8G`/`H8` coordinates, lower-case blanks, `.` pass-throughs, `-TILES` diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index b15f0e4..0287f4d 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -177,6 +177,10 @@ would leak the move journal), and the client shares the `.gcg` file where the platform supports it, otherwise downloads it. Statistics (durable accounts only): wins, losses, draws, max points in a game, and max points for a single move (the best play, which already includes every word it formed plus the all-tiles bonus). +A game that can no longer be continued — because a rule changed and an earlier +move would now be illegal — is closed as a **draw** the moment someone opens it, +never left stuck on an error: the move history shows an impersonal organizer note +at its end (the game could not be continued), and the same note rides the GCG export. ### Administration Operators reach a server-rendered admin console at `${DOMAIN}/_gm` — the backend diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 7182422..c4edff9 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -182,6 +182,10 @@ UTC), суточного окна отсутствия (away; сетка по 10 поддерживает, иначе скачивает его. Статистика (только у постоянных аккаунтов): победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший ход, уже включающий все образованные им слова и бонус за все фишки). +Партия, которую больше нельзя продолжить — из-за изменения правил более ранний ход +стал бы недопустимым, — закрывается **ничьёй** в момент открытия её игроком, а не +остаётся висеть с ошибкой: в конце истории ходов показывается обезличенная заметка +организатора (партию не удалось продолжить), и та же заметка попадает в экспорт GCG. ### Администрирование Оператор открывает серверную админ-консоль по адресу `${DOMAIN}/_gm` — её рендерит diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 580d93a..833efe9 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -917,6 +917,9 @@ {/each} + {#if view.game.endReason === 'aborted'} +

{t('game.abortedNote')}

+ {/if} {/if} @@ -1174,6 +1177,15 @@ .hsc { font-variant-numeric: tabular-nums; } + /* The impersonal organizer note closing an aborted (un-replayable) game, under the grid. */ + .horganizer { + margin: 0; + padding: 8px var(--pad); + color: var(--text-muted); + font-size: 0.85rem; + font-style: italic; + text-align: center; + } .boardwrap { padding: 6px; transition: transform 0.3s ease; diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 38e2241..984b955 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -81,6 +81,7 @@ export const en = { 'game.won': 'You won', 'game.lost': 'You lost', 'game.tied': 'Draw', + 'game.abortedNote': 'This game could not be continued by the organizer and was ended in a draw.', 'game.checkWordPrompt': 'Enter a word', 'game.wordLegal': '“{word}” is valid', 'game.wordIllegal': '“{word}” is not valid', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 260eb9e..7425b6d 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -82,6 +82,7 @@ export const ru: Record = { 'game.won': 'Вы выиграли', 'game.lost': 'Вы проиграли', 'game.tied': 'Ничья', + 'game.abortedNote': 'Партия не может быть продолжена по вине организатора и завершена ничьёй.', 'game.checkWordPrompt': 'Введите слово', 'game.wordLegal': '«{word}» допустимо', 'game.wordIllegal': '«{word}» недопустимо',