Files
OpenFUT-Core/src/models/match_result.rs
T
funman300 b0306a9b1d 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.
2026-08-20 16:38:16 +00:00

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,
}