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