1df03d4287
CI / Build, lint & test (push) Successful in 3m19s
FIFA 17 training is a ONE-MATCH effect and the trigger is the PLAYER PLAYING, not the match completing: a card applied to someone who stays on the bench or in the reserves "will continue to benefit from the training effect until he plays" (DOCUMENTED, fifauteam's contemporaneous FIFA 17 guide, corroborated across two of its pages). So Core expires exactly the instances the caller names, and never a whole club. The participant list is supplied rather than derived here, deliberately: - The FIFA 17 match wire carries NO lineup. Across 36,149 captured requests the 19 match creates carry 5 keys and the 13 ends carry 6; the tokens "lineup" and "substitut" appear ZERO times, while "kitNumber" appears 1311 times and the same extraction recovers 23 instance ids from PUT /squad/0 in that same pcap. The absence is measured against a working positive control, not assumed. - Core must not resolve it from the squad at completion either: the squad at end is provably not the squad that started (a captured match began 20:33:20 and the next squad save landed 12 minutes later with no /match/end between). Empty participants therefore expires nothing, so a caller that cannot identify who played is inert instead of destructive. The mutation sits inside the existing single match transaction, under the same is_economic guard as coins and statistics, so NoContest voids it exactly as it voids everything else, and a rollback leaves boosts intact. Tests: participant scoping (the benched player keeps his boost), empty-participant inertness, club scoping, replay (a resubmitted completion does not consume a freshly reapplied boost), NoContest, and a BeforeCommit fault that fires AFTER the delete to prove the split-brain state "match rejected but training consumed" cannot occur.
1090 lines
37 KiB
Rust
1090 lines
37 KiB
Rust
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<usize> = (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::<i64>() / 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<Vec<String>> {
|
|
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<MatchResultKind> {
|
|
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<FaultPoint>, 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<MatchCompletionResult> {
|
|
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<FaultPoint>,
|
|
) -> AppResult<MatchCompletionResult> {
|
|
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<MatchCompletionResult> {
|
|
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<Vec<LevelUpEvent>> {
|
|
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<MatchCompletionResult> {
|
|
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);
|
|
}
|
|
}
|