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