feat(match): atomic exactly-once match-completion transaction
Add complete_match: one BEGIN/COMMIT that validates identity + result, enforces a durable (profile_id, match_identity) uniqueness guard (migration 0022 match_completions), persists match history, and grants coins + XP/level-ups + W/D/L/DNF statistics + objectives + achievements exactly once. Any failure rolls the whole match back (no compensating cleanup). Handles sequential/restart/concurrent replay, conflicting re-report (first result canonical), DNF (loss economics, own stat bucket) and no-contest (zero economic effect). Adds tx-scoped variants: statistics::record_match_tx/ record_position_goals_tx, objective::increment_metric_tx, achievement::check_and_unlock_tx. New MatchResultKind/CompleteMatchRequest/ MatchCompletionResult models + POST /matches/complete route.
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
-- Durable economic idempotency for match completion.
|
||||
--
|
||||
-- One economic effect per (profile_id, match_identity), independent of any HTTP
|
||||
-- receipt idempotency the game host/adapter layers on top. A sequential replay,
|
||||
-- a restart replay, a concurrent duplicate, or a conflicting re-report of the
|
||||
-- same match all collide on this UNIQUE and are refused BEFORE any coins, XP,
|
||||
-- statistics, objectives, or achievements are applied — the first completion is
|
||||
-- the one canonical result, the rest are idempotent no-ops.
|
||||
--
|
||||
-- `match_identity` is opaque to Core: the game adapter/host derives a stable
|
||||
-- per-match token (e.g. the FIFA17 match-create id). Core never parses it.
|
||||
CREATE TABLE match_completions (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
profile_id TEXT NOT NULL REFERENCES profiles(id),
|
||||
match_identity TEXT NOT NULL,
|
||||
result TEXT NOT NULL, -- canonical: win | draw | loss | dnf | no_contest
|
||||
coins_awarded INTEGER NOT NULL DEFAULT 0,
|
||||
xp_awarded INTEGER NOT NULL DEFAULT 0,
|
||||
match_id TEXT NOT NULL REFERENCES matches(id),
|
||||
completed_at TEXT NOT NULL,
|
||||
UNIQUE(profile_id, match_identity)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_match_completions_profile ON match_completions(profile_id);
|
||||
|
||||
-- W/D/L already live on `statistics`; add the DNF (abandon/quit) bucket so the
|
||||
-- four match outcomes are mutually-exclusive counters. A did-not-finish is
|
||||
-- economically a loss but is tallied here, not in `matches_lost`.
|
||||
ALTER TABLE statistics ADD COLUMN matches_dnf INTEGER NOT NULL DEFAULT 0;
|
||||
Reference in New Issue
Block a user