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, 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(), } } } /// 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>, /// Tick down `loan_matches_remaining` for this squad's starters and remove /// the cards whose loan ran out. /// /// OFF by default so a game whose loan model is its own (FIFA 17 does not /// route loans through Core) is unaffected. Callers of Core's own match /// modes opt in. #[serde(default)] pub expire_loans: bool, /// Advance Core's OWN season model (division progress, and its end-of-season /// coin/pack award). /// /// OFF by default: this grants economy, and it is NOT the same thing as a /// game's native seasons (FIFA 17 offline Seasons are the adapter's, keyed by /// its own wire). Only a caller using Core's season model opts in. #[serde(default)] pub advance_season: bool, /// Owned-card instances that TOOK THE FIELD in this match, whose one-match /// training effects it consumes. /// /// Supplied by the caller rather than derived here, and deliberately so. /// FIFA 17's training rule keys on the player PLAYING, and who played is /// game-specific knowledge Core does not have: its match wire carries no /// lineup at all (LIVE_PROVEN over 36,149 captured requests). Core must also /// not resolve it from the squad at completion time, because the squad at /// end is provably not the squad that started — a captured match began at /// 20:33:20 and the next squad save landed 12 minutes later with no /// `/match/end` in between. The adapter therefore snapshots at kickoff and /// passes the result here. /// /// Empty expires nothing, so a caller that cannot identify participants is /// simply inert instead of clearing a whole club. #[serde(default)] pub participants: Vec, } /// 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, /// Level-ups gained from this match's XP (empty on a replay). pub level_ups: Vec, /// Achievements unlocked by this match (empty on a replay). pub achievements_unlocked: Vec, /// Owned card ids removed because their loan expired on this match. Empty /// unless the caller set `expire_loans`, and empty on a replay. pub expired_loans: Vec, /// Owned card instances whose one-match training effect this match consumed. /// Empty when the caller passed no participants, and empty on a replay — /// the effect is consumed exactly once, by the first completion. pub expired_training: Vec, /// Present when this match ended a Core season. `None` unless the caller set /// `advance_season`, and `None` on a replay. pub season_end: Option, pub match_record: Match, }