use crate::{ db::Pool, error::{AppError, AppResult}, models::{ achievement::AchievementDefinition, card::{OwnedCard, OWNED_CARD_SELECT}, match_result::{CompleteMatchRequest, Match, MatchCompletionResult, MatchResultKind}, objective::ObjectiveDefinition, profile::{coins_for_level, level_for_xp, pack_for_level, LevelUpEvent}, }, services::{ achievement, card_db::CardDb, objective, season as season_svc, statistics, training, }, }; use rand::{seq::SliceRandom, Rng}; use sqlx::{Sqlite, Transaction}; use uuid::Uuid; const FORMATIONS: &[&str] = &["4-3-3", "4-4-2", "4-2-3-1", "4-1-2-1-2", "3-5-2", "5-3-2"]; /// Generate a random AI opponent squad for Squad Battles. /// /// Difficulty bands: /// beginner — overall 55+ (same as "any") /// professional — overall 70+ /// world_class — overall 78+ /// legendary — overall 85+ /// ultimate — overall 90+ pub fn generate_opponent(card_db: &CardDb, difficulty: &str) -> serde_json::Value { let (min_overall, names): (u8, &[&str]) = match difficulty { "professional" => ( 70, &[ "Athletic CF", "City Wanderers", "The Rovers", "United Select", "Blue Stars FC", ], ), "world_class" => ( 78, &[ "Elite Stars FC", "Champions Select", "Premier XI", "Galaxy United", "Titan FC", ], ), "legendary" => ( 85, &[ "Legends United", "Ultimate XI", "Gold Standard FC", "The Icons", "Heritage FC", ], ), "ultimate" => ( 90, &[ "Apex XI", "Pantheon FC", "Gods of FUT", "Invincibles Select", "Eternal XI", ], ), _ => ( 55, &[ "Amateur Town FC", "Sunday League XI", "Park FC", "Village Stars", "Reserve XI", ], ), }; let mut rng = rand::thread_rng(); let all = card_db.by_min_overall(min_overall); // Fall back to lower overall band if not enough cards at this difficulty let pool: Vec<_> = if all.len() >= 11 { all } else { card_db.by_min_overall(55) }; let mut indices: Vec = (0..pool.len()).collect(); indices.shuffle(&mut rng); let cards: Vec<_> = indices .into_iter() .take(11) .map(|i| pool[i].clone()) .collect(); let squad_rating = if cards.is_empty() { 0 } else { cards.iter().map(|c| c.overall as i64).sum::() / cards.len() as i64 }; let name = names[rng.gen_range(0..names.len())]; let formation = FORMATIONS[rng.gen_range(0..FORMATIONS.len())]; serde_json::json!({ "opponent_name": name, "difficulty": difficulty, "squad_rating": squad_rating, "formation": formation, "cards": cards, }) } const COINS_WIN: i64 = 400; const COINS_DRAW: i64 = 150; const COINS_LOSS: i64 = 75; const XP_WIN: i64 = 200; const XP_DRAW: i64 = 75; const XP_LOSS: i64 = 30; /// Decrement `loan_matches_remaining` for each loan card in the squad's starting /// XI, removing the cards whose loan ran out and returning their /// `owned_card_id`s. Runs inside the caller's transaction so a loan that expires /// commits (or rolls back) with the match that consumed it. async fn expire_loans_tx( tx: &mut Transaction<'_, Sqlite>, club_id: &str, squad_id: &str, ) -> AppResult> { let starters: Vec<(String, String)> = sqlx::query_as( "SELECT sp.id, sp.owned_card_id FROM squad_players sp \ JOIN squads s ON s.id = sp.squad_id \ WHERE sp.squad_id = ? AND sp.is_on_bench = 0 AND s.club_id = ?", ) .bind(squad_id) .bind(club_id) .fetch_all(&mut **tx) .await?; let mut expired = Vec::new(); for (_sp_id, owned_id) in starters { let card = sqlx::query_as::<_, OwnedCard>(&format!( "{OWNED_CARD_SELECT} WHERE id = ? AND is_loan = 1" )) .bind(&owned_id) .fetch_optional(&mut **tx) .await?; if let Some(c) = card { let remaining = c.loan_matches_remaining.unwrap_or(0); if remaining <= 1 { sqlx::query("DELETE FROM owned_cards WHERE id = ?") .bind(&owned_id) .execute(&mut **tx) .await?; expired.push(owned_id); } else { sqlx::query("UPDATE owned_cards SET loan_matches_remaining = ? WHERE id = ?") .bind(remaining - 1) .bind(&owned_id) .execute(&mut **tx) .await?; } } } Ok(expired) } // ─────────────────────────── Atomic match completion ──────────────────────── /// Coins + XP for a canonical result. A DNF earns the loss tier — an abandon is /// economically a loss — while a no-contest grants nothing. WIN/DRAW/LOSS keep /// the existing amounts. fn rewards_for(result: MatchResultKind) -> (i64, i64) { match result { MatchResultKind::Win => (COINS_WIN, XP_WIN), MatchResultKind::Draw => (COINS_DRAW, XP_DRAW), MatchResultKind::Loss => (COINS_LOSS, XP_LOSS), MatchResultKind::Dnf => (COINS_LOSS, XP_LOSS), MatchResultKind::NoContest => (0, 0), } } /// Parse a persisted canonical result token back into [`MatchResultKind`]. fn parse_result(s: &str) -> AppResult { Ok(match s { "win" => MatchResultKind::Win, "draw" => MatchResultKind::Draw, "loss" => MatchResultKind::Loss, "dnf" => MatchResultKind::Dnf, "no_contest" => MatchResultKind::NoContest, other => { return Err(AppError::Internal(anyhow::anyhow!( "unknown persisted match result {other:?}" ))) } }) } /// Fault-injection points for [`complete_match`]. Every point is AFTER a durable /// write, so a fault MUST roll the entire match back — integrity comes from the /// SQLite transaction, never from compensating cleanup. Only constructed in /// tests. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum FaultPoint { AfterCompletionRow, AfterHistory, AfterCoins, AfterXp, AfterStatistics, AfterObjectives, BeforeCommit, } fn inject_fault(actual: Option, point: FaultPoint) -> AppResult<()> { if actual == Some(point) { return Err(AppError::Internal(anyhow::anyhow!( "injected match-completion fault at {point:?}" ))); } Ok(()) } /// Complete a match exactly once, atomically. This is the authoritative /// economic entry point for a finished match: it validates the request, refuses /// a duplicate via the durable `(profile_id, match_identity)` guard, and — for /// an economic result — persists history, grants coins, grants XP + level-ups, /// updates W/D/L/DNF statistics, advances objectives, and unlocks achievements, /// all in one transaction that either commits together or rolls back whole. /// /// A replay (sequential, restart, concurrent, or a conflicting re-report of the /// same match) is an idempotent no-op that echoes the persisted canonical /// result with `applied = false`. pub async fn complete_match( pool: &Pool, profile_id: &str, club_id: &str, req: &CompleteMatchRequest, obj_defs: &[ObjectiveDefinition], ach_defs: &[AchievementDefinition], ) -> AppResult { complete_match_inner(pool, profile_id, club_id, req, obj_defs, ach_defs, None).await } #[allow(clippy::too_many_arguments)] async fn complete_match_inner( pool: &Pool, profile_id: &str, club_id: &str, req: &CompleteMatchRequest, obj_defs: &[ObjectiveDefinition], ach_defs: &[AchievementDefinition], fault: Option, ) -> AppResult { if req.goals_for < 0 || req.goals_against < 0 || req.goals_for > 99 || req.goals_against > 99 { return Err(AppError::BadRequest( "goals_for and goals_against must each be between 0 and 99".into(), )); } if req.match_identity.trim().is_empty() { return Err(AppError::BadRequest( "match_identity must not be empty".into(), )); } let result = req.result; let (coins, xp) = rewards_for(result); let outcome = result.as_str(); let now = chrono::Utc::now().to_rfc3339(); let match_id = Uuid::new_v4().to_string(); let completion_id = Uuid::new_v4().to_string(); let mut tx = pool.begin().await?; // 1. Durable match-history row FIRST. This is also the transaction's first // write, so it takes SQLite's single writer lock and serializes // overlapping completions. The `match_completions` guard below carries a // FK to this row, so it must exist before the guard is written. let match_record = Match { id: match_id.clone(), profile_id: profile_id.to_string(), squad_id: req.squad_id.clone(), opponent_name: req.opponent_name.clone(), goals_for: req.goals_for, goals_against: req.goals_against, outcome: outcome.to_string(), coins_awarded: coins, xp_awarded: xp, mode: req.mode.clone(), played_at: now.clone(), }; sqlx::query( "INSERT INTO matches (id, profile_id, squad_id, opponent_name, goals_for, goals_against, outcome, coins_awarded, xp_awarded, mode, played_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(&match_record.id) .bind(&match_record.profile_id) .bind(&match_record.squad_id) .bind(&match_record.opponent_name) .bind(match_record.goals_for) .bind(match_record.goals_against) .bind(&match_record.outcome) .bind(match_record.coins_awarded) .bind(match_record.xp_awarded) .bind(&match_record.mode) .bind(&match_record.played_at) .execute(&mut *tx) .await?; inject_fault(fault, FaultPoint::AfterHistory)?; // 2. Economic-idempotency guard. Enforces the durable // (profile_id, match_identity) uniqueness: a duplicate — sequential, // concurrent, restart, or a conflicting re-report with a different result // — collides here and rolls the whole match (including the history row // just written) back before any reward is granted. let guard = sqlx::query( "INSERT INTO match_completions \ (id, profile_id, match_identity, result, coins_awarded, xp_awarded, match_id, completed_at) \ VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(&completion_id) .bind(profile_id) .bind(&req.match_identity) .bind(outcome) .bind(coins) .bind(xp) .bind(&match_id) .bind(&now) .execute(&mut *tx) .await; match guard { Ok(_) => {} Err(sqlx::Error::Database(e)) if e.is_unique_violation() => { // Already economically completed: roll back this attempt and echo // the canonical persisted result. The first completion wins. tx.rollback().await?; return already_completed(pool, profile_id, club_id, &req.match_identity).await; } Err(e) => { tx.rollback().await?; return Err(e.into()); } } inject_fault(fault, FaultPoint::AfterCompletionRow)?; let mut objectives_updated = Vec::new(); let mut level_ups = Vec::new(); let mut achievements_unlocked = Vec::new(); let mut expired_loans = Vec::new(); let mut expired_training = Vec::new(); let mut season_end = None; // A no-contest is recorded (history + idempotency) but has ZERO economic // effect: no coins, XP, statistics, objectives, or achievements. if result.is_economic() { // 3. Coins. if coins > 0 { let credited = sqlx::query("UPDATE clubs SET coins = coins + ?, updated_at = ? WHERE id = ?") .bind(coins) .bind(&now) .bind(club_id) .execute(&mut *tx) .await?; if credited.rows_affected() != 1 { return Err(AppError::NotFound("club not found".into())); } } inject_fault(fault, FaultPoint::AfterCoins)?; // 4. XP + level-ups (level rewards credited in-transaction). level_ups = grant_xp_with_levelup_tx(&mut tx, profile_id, club_id, xp, &now).await?; inject_fault(fault, FaultPoint::AfterXp)?; // 5. W/D/L/DNF statistics. statistics::record_match_tx( &mut tx, profile_id, outcome, req.goals_for, req.goals_against, coins, &now, ) .await?; if let Some(positions) = &req.goal_positions { statistics::record_position_goals_tx(&mut tx, profile_id, positions).await?; } inject_fault(fault, FaultPoint::AfterStatistics)?; // 6. Objectives. objectives_updated.append( &mut objective::increment_metric_tx( &mut tx, profile_id, obj_defs, "matchesplayed", 1, &now, ) .await?, ); if result == MatchResultKind::Win { objectives_updated.append( &mut objective::increment_metric_tx( &mut tx, profile_id, obj_defs, "matcheswon", 1, &now, ) .await?, ); } objectives_updated.append( &mut objective::increment_metric_tx( &mut tx, profile_id, obj_defs, "goalsscored", req.goals_for, &now, ) .await?, ); objectives_updated.append( &mut objective::increment_metric_tx( &mut tx, profile_id, obj_defs, "coinsearned", coins, &now, ) .await?, ); inject_fault(fault, FaultPoint::AfterObjectives)?; // 7. Achievements. achievements_unlocked = achievement::check_and_unlock_tx(&mut tx, ach_defs, profile_id, club_id, &now).await?; // 8. Opt-in progression. Both default OFF: a game whose loans and // seasons are its own (FIFA 17) must not have Core's model advance — // and Core's season end GRANTS coins and a pack, which would be // invisible economy on a path that never asked for it. if req.expire_loans { expired_loans = expire_loans_tx(&mut tx, club_id, &req.squad_id).await?; } if req.advance_season { season_end = season_svc::record_match_tx(&mut tx, club_id, profile_id, outcome, &now).await?; } // 9. One-match training effects are consumed by the players who took the // field. Inside the same transaction and the same `is_economic` // guard as everything else, so a NoContest voids it exactly as it // voids coins and statistics, and a rollback leaves the boosts intact. expired_training = training::expire_for_instances_tx(&mut tx, club_id, &req.participants).await?; } inject_fault(fault, FaultPoint::BeforeCommit)?; // 8. Echo the post-completion balance, then commit everything atomically. let coins_balance: i64 = sqlx::query_scalar("SELECT coins FROM clubs WHERE id = ?") .bind(club_id) .fetch_one(&mut *tx) .await?; tx.commit().await?; Ok(MatchCompletionResult { applied: true, match_identity: req.match_identity.clone(), result, coins_awarded: coins, xp_awarded: xp, coins_balance, objectives_updated, level_ups, achievements_unlocked, expired_loans, expired_training, season_end, match_record, }) } /// Build the idempotent echo for an already-completed match. Reads the persisted /// canonical result (never re-derives it) so a WIN-then-LOSS re-report returns /// the WIN that actually landed. async fn already_completed( pool: &Pool, profile_id: &str, club_id: &str, match_identity: &str, ) -> AppResult { let (match_id, result_str, coins, xp): (String, String, i64, i64) = sqlx::query_as( "SELECT match_id, result, coins_awarded, xp_awarded FROM match_completions \ WHERE profile_id = ? AND match_identity = ?", ) .bind(profile_id) .bind(match_identity) .fetch_optional(pool) .await? .ok_or_else(|| { AppError::Internal(anyhow::anyhow!( "match_completions row missing after unique violation" )) })?; let result = parse_result(&result_str)?; let match_record = sqlx::query_as::<_, Match>( "SELECT id, profile_id, squad_id, opponent_name, goals_for, goals_against, outcome, coins_awarded, xp_awarded, mode, played_at FROM matches WHERE id = ?", ) .bind(&match_id) .fetch_one(pool) .await?; let coins_balance: i64 = sqlx::query_scalar("SELECT coins FROM clubs WHERE id = ?") .bind(club_id) .fetch_one(pool) .await?; Ok(MatchCompletionResult { applied: false, match_identity: match_identity.to_string(), result, coins_awarded: coins, xp_awarded: xp, coins_balance, objectives_updated: vec![], level_ups: vec![], expired_loans: vec![], expired_training: vec![], season_end: None, achievements_unlocked: vec![], match_record, }) } /// Transaction-scoped XP grant with level-ups. Mirrors /// [`crate::services::profile::add_xp_with_levelup`] but runs entirely inside the /// caller's transaction (level-up coins + packs credited in-tx), so it commits or /// rolls back with the rest of the match. Notifications are intentionally not /// emitted here — they are non-durable side effects the pooled path owns. async fn grant_xp_with_levelup_tx( tx: &mut Transaction<'_, Sqlite>, profile_id: &str, club_id: &str, xp_to_add: i64, now: &str, ) -> AppResult> { let current_xp: i64 = sqlx::query_scalar("SELECT xp FROM profiles WHERE id = ?") .bind(profile_id) .fetch_optional(&mut **tx) .await? .ok_or_else(|| AppError::NotFound(format!("profile '{profile_id}' not found")))?; let old_level = level_for_xp(current_xp); let new_total = current_xp + xp_to_add; let new_level = level_for_xp(new_total); sqlx::query("UPDATE profiles SET xp = ?, level = ?, updated_at = ? WHERE id = ?") .bind(new_total) .bind(new_level) .bind(now) .bind(profile_id) .execute(&mut **tx) .await?; let mut events = Vec::new(); for lvl in (old_level + 1)..=new_level { let coins = coins_for_level(lvl); let pack = pack_for_level(lvl).map(String::from); if coins > 0 { let credited = sqlx::query("UPDATE clubs SET coins = coins + ?, updated_at = ? WHERE id = ?") .bind(coins) .bind(now) .bind(club_id) .execute(&mut **tx) .await?; if credited.rows_affected() != 1 { return Err(AppError::NotFound("club not found".into())); } } if let Some(pack_id) = &pack { sqlx::query( "INSERT INTO packs (id, club_id, definition_id, opened, created_at) VALUES (?, ?, ?, 0, ?)", ) .bind(Uuid::new_v4().to_string()) .bind(club_id) .bind(pack_id) .bind(now) .execute(&mut **tx) .await?; } events.push(LevelUpEvent { new_level: lvl, coins_granted: coins, pack_granted: pack, }); } Ok(events) } #[cfg(test)] mod match_completion_tests { use super::*; use crate::db; use crate::models::objective::{ObjectiveDefinition, ObjectiveMetric, ObjectiveType}; use std::sync::Arc; use tempfile::TempDir; const PROFILE: &str = "profile"; const CLUB: &str = "club"; const START_COINS: i64 = 1000; struct Fixture { pool: Pool, url: String, _dir: TempDir, } async fn seed(pool: &Pool) { sqlx::query( "INSERT INTO profiles (id, username, game_id, created_at, updated_at) \ VALUES (?, ?, 'fifa17', 't', 't')", ) .bind(PROFILE) .bind(PROFILE) .execute(pool) .await .expect("seed profile"); sqlx::query( "INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) \ VALUES (?, ?, ?, ?, 't', 't')", ) .bind(CLUB) .bind(PROFILE) .bind(CLUB) .bind(START_COINS) .execute(pool) .await .expect("seed club"); } async fn new_fixture() -> Fixture { let dir = tempfile::tempdir().expect("tempdir"); let url = format!("sqlite://{}", dir.path().join("core.db").display()); let pool = db::init_pool(&url, 5).await.expect("init pool"); db::run_migrations(&pool).await.expect("migrations"); seed(&pool).await; Fixture { pool, url, _dir: dir, } } fn req(identity: &str, result: MatchResultKind, gf: i64, ga: i64) -> CompleteMatchRequest { CompleteMatchRequest { match_identity: identity.into(), result, squad_id: "squad".into(), opponent_name: "Opponent".into(), goals_for: gf, goals_against: ga, mode: "seasons".into(), goal_positions: None, expire_loans: false, advance_season: false, participants: vec![], } } async fn complete(pool: &Pool, req: &CompleteMatchRequest) -> AppResult { complete_match(pool, PROFILE, CLUB, req, &[], &[]).await } async fn coins(pool: &Pool) -> i64 { sqlx::query_scalar("SELECT coins FROM clubs WHERE id = ?") .bind(CLUB) .fetch_one(pool) .await .unwrap() } async fn xp(pool: &Pool) -> i64 { sqlx::query_scalar("SELECT xp FROM profiles WHERE id = ?") .bind(PROFILE) .fetch_one(pool) .await .unwrap() } async fn stat(pool: &Pool, col: &str) -> i64 { let sql = format!("SELECT {col} FROM statistics WHERE profile_id = ?"); sqlx::query_scalar(&sql) .bind(PROFILE) .fetch_optional(pool) .await .unwrap() .unwrap_or(0) } async fn count(pool: &Pool, table: &str) -> i64 { let sql = format!("SELECT COUNT(*) FROM {table}"); sqlx::query_scalar(&sql).fetch_one(pool).await.unwrap() } #[tokio::test] async fn win_grants_reward_stats_and_history() { let fx = new_fixture().await; let r = complete(&fx.pool, &req("m1", MatchResultKind::Win, 3, 1)) .await .unwrap(); assert!(r.applied); assert_eq!(r.coins_awarded, COINS_WIN); assert_eq!(r.xp_awarded, XP_WIN); assert_eq!(r.coins_balance, START_COINS + COINS_WIN); assert_eq!(r.match_record.outcome, "win"); assert_eq!(coins(&fx.pool).await, START_COINS + COINS_WIN); assert_eq!(xp(&fx.pool).await, XP_WIN); assert_eq!(stat(&fx.pool, "matches_played").await, 1); assert_eq!(stat(&fx.pool, "matches_won").await, 1); assert_eq!(stat(&fx.pool, "goals_scored").await, 3); assert_eq!(stat(&fx.pool, "goals_conceded").await, 1); assert_eq!(stat(&fx.pool, "win_streak").await, 1); assert_eq!(count(&fx.pool, "matches").await, 1); assert_eq!(count(&fx.pool, "match_completions").await, 1); } #[tokio::test] async fn draw_and_loss_reward_tiers() { let fx = new_fixture().await; let d = complete(&fx.pool, &req("d", MatchResultKind::Draw, 1, 1)) .await .unwrap(); assert_eq!(d.coins_awarded, COINS_DRAW); assert_eq!(stat(&fx.pool, "matches_drawn").await, 1); let l = complete(&fx.pool, &req("l", MatchResultKind::Loss, 0, 2)) .await .unwrap(); assert_eq!(l.coins_awarded, COINS_LOSS); assert_eq!(stat(&fx.pool, "matches_lost").await, 1); assert_eq!(coins(&fx.pool).await, START_COINS + COINS_DRAW + COINS_LOSS); // A draw then a loss both reset/keep the streak at zero. assert_eq!(stat(&fx.pool, "win_streak").await, 0); } #[tokio::test] async fn dnf_is_loss_economics_with_its_own_bucket() { let fx = new_fixture().await; let r = complete(&fx.pool, &req("dnf", MatchResultKind::Dnf, 0, 0)) .await .unwrap(); assert!(r.applied); assert_eq!(r.coins_awarded, COINS_LOSS, "DNF earns the loss tier"); assert_eq!(r.xp_awarded, XP_LOSS); assert_eq!(r.match_record.outcome, "dnf"); assert_eq!(stat(&fx.pool, "matches_dnf").await, 1); assert_eq!( stat(&fx.pool, "matches_lost").await, 0, "DNF is not a loss row" ); assert_eq!(stat(&fx.pool, "matches_played").await, 1); assert_eq!(coins(&fx.pool).await, START_COINS + COINS_LOSS); } #[tokio::test] async fn no_contest_has_zero_economic_effect() { let fx = new_fixture().await; let r = complete(&fx.pool, &req("nc", MatchResultKind::NoContest, 0, 0)) .await .unwrap(); assert!(r.applied); assert_eq!(r.coins_awarded, 0); assert_eq!(r.xp_awarded, 0); assert_eq!(r.match_record.outcome, "no_contest"); assert_eq!(coins(&fx.pool).await, START_COINS, "no coins for a void"); assert_eq!(xp(&fx.pool).await, 0); assert_eq!( stat(&fx.pool, "matches_played").await, 0, "not counted as played" ); // Still recorded for history + idempotency. assert_eq!(count(&fx.pool, "matches").await, 1); assert_eq!(count(&fx.pool, "match_completions").await, 1); } #[tokio::test] async fn sequential_duplicate_is_idempotent() { let fx = new_fixture().await; let first = complete(&fx.pool, &req("m", MatchResultKind::Win, 2, 0)) .await .unwrap(); assert!(first.applied); // A stale retry after success: same identity, applied once only. let second = complete(&fx.pool, &req("m", MatchResultKind::Win, 2, 0)) .await .unwrap(); assert!(!second.applied); assert_eq!(second.result, MatchResultKind::Win); assert_eq!(second.coins_balance, START_COINS + COINS_WIN); assert_eq!(coins(&fx.pool).await, START_COINS + COINS_WIN); assert_eq!(xp(&fx.pool).await, XP_WIN); assert_eq!(stat(&fx.pool, "matches_played").await, 1); assert_eq!(count(&fx.pool, "matches").await, 1); assert_eq!(count(&fx.pool, "match_completions").await, 1); } #[tokio::test] async fn conflicting_win_then_loss_keeps_first_canonical() { let fx = new_fixture().await; let win = complete(&fx.pool, &req("x", MatchResultKind::Win, 3, 0)) .await .unwrap(); assert!(win.applied); // Same match re-reported with the OPPOSITE result — only the first wins. let conflict = complete(&fx.pool, &req("x", MatchResultKind::Loss, 0, 3)) .await .unwrap(); assert!(!conflict.applied); assert_eq!(conflict.result, MatchResultKind::Win); assert_eq!(coins(&fx.pool).await, START_COINS + COINS_WIN); assert_eq!(stat(&fx.pool, "matches_won").await, 1); assert_eq!(stat(&fx.pool, "matches_lost").await, 0); } #[tokio::test] async fn restart_replay_survives_pool_reopen() { let fx = new_fixture().await; complete(&fx.pool, &req("x", MatchResultKind::Win, 1, 0)) .await .unwrap(); fx.pool.close().await; // Reopen a fresh pool on the SAME file — the durable guard persists. let pool = db::init_pool(&fx.url, 5).await.unwrap(); db::run_migrations(&pool).await.unwrap(); let replay = complete(&pool, &req("x", MatchResultKind::Win, 1, 0)) .await .unwrap(); assert!(!replay.applied, "replay after restart must not re-apply"); assert_eq!(coins(&pool).await, START_COINS + COINS_WIN); // A genuinely new match still credits. let fresh = complete(&pool, &req("y", MatchResultKind::Win, 1, 0)) .await .unwrap(); assert!(fresh.applied); assert_eq!(coins(&pool).await, START_COINS + 2 * COINS_WIN); } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn concurrent_duplicate_applies_exactly_once() { let fx = new_fixture().await; let pool = Arc::new(fx.pool.clone()); let mut handles = Vec::new(); for _ in 0..8 { let p = pool.clone(); handles.push(tokio::spawn(async move { complete(&p, &req("race", MatchResultKind::Win, 4, 2)).await })); } let mut applied = 0; for h in handles { if let Ok(r) = h.await.unwrap() { if r.applied { applied += 1; } } } assert_eq!(applied, 1, "exactly one racer applies the economic effect"); assert_eq!(coins(&pool).await, START_COINS + COINS_WIN); assert_eq!(count(&pool, "match_completions").await, 1); assert_eq!(count(&pool, "matches").await, 1); assert_eq!(stat(&pool, "matches_played").await, 1); } #[tokio::test] async fn empty_match_identity_is_rejected() { let fx = new_fixture().await; let err = complete(&fx.pool, &req(" ", MatchResultKind::Win, 1, 0)) .await .unwrap_err(); assert!(matches!(err, AppError::BadRequest(_))); assert_eq!(coins(&fx.pool).await, START_COINS); assert_eq!(count(&fx.pool, "match_completions").await, 0); } #[tokio::test] async fn unknown_club_rolls_back_whole_match() { let fx = new_fixture().await; // A completion aimed at a club that does not exist: the coin credit // affects no row, the transaction fails, and NOTHING is persisted. let err = complete_match( &fx.pool, PROFILE, "no-such-club", &req("m", MatchResultKind::Win, 1, 0), &[], &[], ) .await .unwrap_err(); assert!(matches!(err, AppError::NotFound(_))); assert_eq!(count(&fx.pool, "match_completions").await, 0); assert_eq!(count(&fx.pool, "matches").await, 0); assert_eq!(coins(&fx.pool).await, START_COINS); } const ALL_FAULTS: &[FaultPoint] = &[ FaultPoint::AfterCompletionRow, FaultPoint::AfterHistory, FaultPoint::AfterCoins, FaultPoint::AfterXp, FaultPoint::AfterStatistics, FaultPoint::AfterObjectives, FaultPoint::BeforeCommit, ]; #[tokio::test] async fn fault_at_every_stage_rolls_back_completely() { for &fp in ALL_FAULTS { let fx = new_fixture().await; let r = complete_match_inner( &fx.pool, PROFILE, CLUB, &req("m", MatchResultKind::Win, 3, 1), &[], &[], Some(fp), ) .await; assert!(r.is_err(), "{fp:?} should error"); // Re-read persisted state: the whole match rolled back. assert_eq!(coins(&fx.pool).await, START_COINS, "{fp:?} coins"); assert_eq!(xp(&fx.pool).await, 0, "{fp:?} xp"); assert_eq!(stat(&fx.pool, "matches_played").await, 0, "{fp:?} stats"); assert_eq!(count(&fx.pool, "matches").await, 0, "{fp:?} history"); assert_eq!( count(&fx.pool, "match_completions").await, 0, "{fp:?} guard row" ); // The rolled-back guard frees the identity, so a clean retry succeeds. let retry = complete(&fx.pool, &req("m", MatchResultKind::Win, 3, 1)) .await .unwrap(); assert!(retry.applied, "{fp:?} retry-after-fault must apply"); assert_eq!( coins(&fx.pool).await, START_COINS + COINS_WIN, "{fp:?} retry" ); } } /// Training expiry must be atomic with the match, in BOTH directions. /// /// `BeforeCommit` is the discriminating fault: it fires AFTER the training /// delete has already run inside the transaction. If the boost were removed /// outside the transaction — or the transaction did not actually cover it — /// the row would be gone here while the match itself rolled back, which is /// exactly the split-brain state (match rejected, training consumed) that /// must not exist. #[tokio::test] async fn a_rolled_back_match_leaves_training_intact() { let fx = new_fixture().await; sqlx::query( "INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) \ VALUES ('inst', ?, 'card', 0, 't')", ) .bind(CLUB) .execute(&fx.pool) .await .unwrap(); sqlx::query( "INSERT INTO owned_card_training \ (owned_card_id, attribute_index, amount, source_card_id, applied_at) \ VALUES ('inst', 4, 15, 'fifa17_5003012', 't')", ) .execute(&fx.pool) .await .unwrap(); let mut r = req("m", MatchResultKind::Win, 3, 1); r.participants = vec!["inst".into()]; let failed = complete_match_inner( &fx.pool, PROFILE, CLUB, &r, &[], &[], Some(FaultPoint::BeforeCommit), ) .await; assert!(failed.is_err(), "the injected fault must fail the match"); assert_eq!( count(&fx.pool, "owned_card_training").await, 1, "a rolled-back match must NOT consume the boost" ); // And the clean retry consumes it exactly once. let ok = complete(&fx.pool, &r).await.unwrap(); assert_eq!(ok.expired_training, vec!["inst".to_string()]); assert_eq!(count(&fx.pool, "owned_card_training").await, 0); } fn obj(id: &str, metric: ObjectiveMetric, target: i64) -> ObjectiveDefinition { ObjectiveDefinition { id: id.into(), title: id.into(), description: id.into(), objective_type: ObjectiveType::Lifetime, metric, target, reward_coins: 0, reward_pack_id: None, reward_xp: 0, } } fn ach(id: &str, trigger: &str, threshold: i64, reward: i64) -> AchievementDefinition { AchievementDefinition { id: id.into(), title: id.into(), description: id.into(), icon: String::new(), trigger: trigger.into(), threshold, reward_coins: reward, rarity: "common".into(), } } #[tokio::test] async fn objectives_and_achievements_apply_exactly_once() { let fx = new_fixture().await; let objs = vec![ obj("played", ObjectiveMetric::MatchesPlayed, 1), obj("won", ObjectiveMetric::MatchesWon, 1), ]; let achs = vec![ach("first_win", "matches_won", 1, 50)]; let first = complete_match( &fx.pool, PROFILE, CLUB, &req("m", MatchResultKind::Win, 2, 0), &objs, &achs, ) .await .unwrap(); assert!(first.applied); assert_eq!(first.objectives_updated.len(), 2); assert_eq!(first.achievements_unlocked.len(), 1); // Match reward + achievement reward. assert_eq!(coins(&fx.pool).await, START_COINS + COINS_WIN + 50); assert_eq!(count(&fx.pool, "player_achievements").await, 1); // Replay: no double objectives/achievements/coins. let replay = complete_match( &fx.pool, PROFILE, CLUB, &req("m", MatchResultKind::Win, 2, 0), &objs, &achs, ) .await .unwrap(); assert!(!replay.applied); assert!(replay.objectives_updated.is_empty()); assert!(replay.achievements_unlocked.is_empty()); assert_eq!(coins(&fx.pool).await, START_COINS + COINS_WIN + 50); assert_eq!(count(&fx.pool, "player_achievements").await, 1); } }