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
+42
View File
@@ -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 {