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:
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user