b0306a9b1d
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.
170 lines
5.5 KiB
Rust
170 lines
5.5 KiB
Rust
use crate::models::{achievement::AchievementDefinition, profile::LevelUpEvent};
|
|
use serde::{Deserialize, Serialize};
|
|
use uuid::Uuid;
|
|
|
|
#[allow(dead_code)]
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum MatchOutcome {
|
|
Win,
|
|
Draw,
|
|
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,
|
|
pub opponent_name: String,
|
|
pub goals_for: i64,
|
|
pub goals_against: i64,
|
|
pub mode: String,
|
|
pub goal_positions: Option<Vec<String>>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
|
pub struct Match {
|
|
pub id: String,
|
|
pub profile_id: String,
|
|
pub squad_id: String,
|
|
pub opponent_name: String,
|
|
pub goals_for: i64,
|
|
pub goals_against: i64,
|
|
pub outcome: String,
|
|
pub coins_awarded: i64,
|
|
pub xp_awarded: i64,
|
|
pub mode: String,
|
|
pub played_at: String,
|
|
}
|
|
|
|
impl Match {
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn new(
|
|
profile_id: &str,
|
|
squad_id: &str,
|
|
opponent_name: &str,
|
|
goals_for: i64,
|
|
goals_against: i64,
|
|
mode: &str,
|
|
coins_awarded: i64,
|
|
xp_awarded: i64,
|
|
) -> Self {
|
|
let outcome = if goals_for > goals_against {
|
|
"win"
|
|
} else if goals_for == goals_against {
|
|
"draw"
|
|
} else {
|
|
"loss"
|
|
};
|
|
|
|
Self {
|
|
id: Uuid::new_v4().to_string(),
|
|
profile_id: profile_id.to_string(),
|
|
squad_id: squad_id.to_string(),
|
|
opponent_name: opponent_name.to_string(),
|
|
goals_for,
|
|
goals_against,
|
|
outcome: outcome.to_string(),
|
|
coins_awarded,
|
|
xp_awarded,
|
|
mode: mode.to_string(),
|
|
played_at: chrono::Utc::now().to_rfc3339(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct MatchRewardResult {
|
|
pub match_record: Match,
|
|
pub coins_awarded: i64,
|
|
pub xp_awarded: i64,
|
|
pub objectives_updated: Vec<String>,
|
|
/// Owned card IDs removed because the loan expired this match.
|
|
pub expired_loans: Vec<String>,
|
|
/// Present when this match completed the current season.
|
|
pub season_end: Option<crate::models::season::SeasonEndSummary>,
|
|
/// Non-empty when the player levelled up one or more times from this match's XP.
|
|
pub level_ups: Vec<LevelUpEvent>,
|
|
/// 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,
|
|
}
|