Stage 17 round 6 (#4/#5/#6 backend): per-game draft store + conflict reset
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 30s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m3s

Foundation for persisting a player's client-side composition: a game_drafts table
(game_id, account_id, rack_order, board_tiles jsonb) with raw-SQL store/service methods —
GetDraft/SaveDraft (seated-player check) and, on every committed move, clearing the actor's
own draft and resetting any opponent's board draft whose cell the play overlapped (the
draft can no longer be placed; the rack order is kept). Integration tests cover the
round-trip, the actor clear, the overlap reset, a non-conflicting survival, and the
outsider rejection. The gateway op slice + UI wiring follow.
This commit is contained in:
Ilia Denisov
2026-06-07 12:29:32 +02:00
parent 2b0b1c0035
commit 06c8039281
4 changed files with 305 additions and 0 deletions
+17
View File
@@ -208,6 +208,7 @@ func (svc *Service) Resign(ctx context.Context, gameID, accountID uuid.UUID) (Mo
if err != nil {
return MoveResult{}, err
}
svc.afterCommitDrafts(ctx, gameID, accountID, rec)
// A resignation carries no think time (it can happen on the opponent's turn), so it
// is intentionally excluded from the move-duration metric.
return MoveResult{Move: rec, Game: post}, nil
@@ -274,12 +275,28 @@ func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID,
if err != nil {
return MoveResult{}, err
}
svc.afterCommitDrafts(ctx, gameID, accountID, rec)
// Record the seat's think time (turn start to commit) for the move-duration
// metric; the timeout path commits separately and is excluded by design.
svc.metrics.recordMoveDuration(ctx, pre.Variant, post.MoveCount, svc.clock().Sub(pre.TurnStartedAt))
return MoveResult{Move: rec, Game: post}, nil
}
// afterCommitDrafts maintains the Stage 17 drafts after a committed move: the actor's own
// composition is consumed, so clear it; a play's tiles may overlap an opponent's board
// draft, which is then reset. Best-effort — the move is already committed, so a draft
// cleanup failure is logged rather than failing the move.
func (svc *Service) afterCommitDrafts(ctx context.Context, gameID, accountID uuid.UUID, rec engine.MoveRecord) {
if err := svc.store.clearDraft(ctx, gameID, accountID); err != nil {
svc.log.Warn("clear actor draft", zap.Error(err))
}
if rec.Action == engine.ActionPlay {
if err := svc.store.resetConflictingBoardDrafts(ctx, gameID, accountID, draftTilesFrom(rec)); err != nil {
svc.log.Warn("reset conflicting board drafts", zap.Error(err))
}
}
}
// commit persists a just-applied transition: the journal row, the post-move turn
// cursor and scores, and on a game-ending move the finish stamp and statistics.
// On a persistence failure it evicts the now-divergent live game so the next