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

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:
Ilia Denisov
2026-06-14 16:57:27 +02:00
parent ff87a3bf62
commit 222eaf730f
15 changed files with 299 additions and 2 deletions
+15
View File
@@ -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)