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:
funman300
2026-08-20 16:38:16 +00:00
parent a034e74c16
commit b0306a9b1d
9 changed files with 1284 additions and 5 deletions
+80
View File
@@ -11,6 +11,45 @@ pub enum MatchOutcome {
Loss,
}
/// Canonical, game-independent economic result of a completed match. The game
/// adapter maps its own wire (FIFA17 `endReason`, score, …) onto this — Core
/// never sees a game-specific reason string.
///
/// * `Win` / `Draw` / `Loss` — a finished match; standard reward tiers.
/// * `Dnf` — did-not-finish (abandon/quit). Economically a loss, but tallied in
/// its own statistics bucket and never in `matches_lost`.
/// * `NoContest` — a voided match. Zero economic effect: no coins, XP, or
/// W/D/L/DNF change; recorded only for history + idempotency.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MatchResultKind {
Win,
Draw,
Loss,
Dnf,
NoContest,
}
impl MatchResultKind {
/// The canonical lowercase token persisted in `matches.outcome` and
/// `match_completions.result`.
pub fn as_str(self) -> &'static str {
match self {
MatchResultKind::Win => "win",
MatchResultKind::Draw => "draw",
MatchResultKind::Loss => "loss",
MatchResultKind::Dnf => "dnf",
MatchResultKind::NoContest => "no_contest",
}
}
/// Whether this result applies any economic effect (coins / XP / statistics /
/// objectives / achievements). `NoContest` is the only non-economic result.
pub fn is_economic(self) -> bool {
!matches!(self, MatchResultKind::NoContest)
}
}
#[derive(Debug, Deserialize)]
pub struct SubmitMatchRequest {
pub squad_id: String,
@@ -87,3 +126,44 @@ pub struct MatchRewardResult {
/// Achievements unlocked as a result of this match.
pub achievements_unlocked: Vec<AchievementDefinition>,
}
/// Request to atomically complete a match exactly once. `match_identity` is the
/// opaque, host-supplied per-match token that keys durable economic idempotency
/// (persona/profile + match_identity). `result` is the canonical outcome the
/// game adapter derived from its wire; `goals_for`/`goals_against` are recorded
/// for history and statistics (0-0 is normal for a DNF/no-contest).
#[derive(Debug, Clone, Deserialize)]
pub struct CompleteMatchRequest {
pub match_identity: String,
pub result: MatchResultKind,
pub squad_id: String,
pub opponent_name: String,
pub goals_for: i64,
pub goals_against: i64,
pub mode: String,
#[serde(default)]
pub goal_positions: Option<Vec<String>>,
}
/// Outcome of [`crate::services::match_service::complete_match`].
#[derive(Debug, Serialize)]
pub struct MatchCompletionResult {
/// `true` when THIS call applied the economic effect; `false` on an
/// idempotent replay of an already-completed match (the persisted canonical
/// result is echoed unchanged).
pub applied: bool,
pub match_identity: String,
pub result: MatchResultKind,
pub coins_awarded: i64,
pub xp_awarded: i64,
/// Club balance after completion — echoed so the host can render the wire
/// reward body without a second round-trip.
pub coins_balance: i64,
/// Objectives completed by this match (empty on a replay).
pub objectives_updated: Vec<String>,
/// Level-ups gained from this match's XP (empty on a replay).
pub level_ups: Vec<LevelUpEvent>,
/// Achievements unlocked by this match (empty on a replay).
pub achievements_unlocked: Vec<AchievementDefinition>,
pub match_record: Match,
}