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.
This commit is contained in:
@@ -250,6 +250,10 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
||||
.route("/matches", get(routes::matches::get_matches))
|
||||
.route("/matches/opponent", get(routes::matches::get_opponent))
|
||||
.route("/matches/result", post(routes::matches::post_match_result))
|
||||
.route(
|
||||
"/matches/complete",
|
||||
post(routes::matches::post_match_complete),
|
||||
)
|
||||
.route("/sbc", get(routes::sbc::get_sbcs))
|
||||
.route("/sbc/status", get(routes::sbc::get_sbc_status))
|
||||
.route("/sbc/submit", post(routes::sbc::post_sbc_submit))
|
||||
|
||||
@@ -11,6 +11,45 @@ pub enum MatchOutcome {
|
||||
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,
|
||||
@@ -87,3 +126,44 @@ pub struct MatchRewardResult {
|
||||
/// 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,
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ pub struct Statistics {
|
||||
pub matches_won: i64,
|
||||
pub matches_drawn: i64,
|
||||
pub matches_lost: i64,
|
||||
pub matches_dnf: i64,
|
||||
pub goals_scored: i64,
|
||||
pub goals_conceded: i64,
|
||||
pub packs_opened: i64,
|
||||
@@ -25,6 +26,7 @@ impl Statistics {
|
||||
matches_won: 0,
|
||||
matches_drawn: 0,
|
||||
matches_lost: 0,
|
||||
matches_dnf: 0,
|
||||
goals_scored: 0,
|
||||
goals_conceded: 0,
|
||||
packs_opened: 0,
|
||||
|
||||
+28
-1
@@ -9,7 +9,9 @@ use serde_json::{json, Value};
|
||||
use crate::{
|
||||
app::AppState,
|
||||
error::AppResult,
|
||||
models::match_result::{Match, MatchRewardResult, SubmitMatchRequest},
|
||||
models::match_result::{
|
||||
CompleteMatchRequest, Match, MatchCompletionResult, MatchRewardResult, SubmitMatchRequest,
|
||||
},
|
||||
services::{club as club_svc, match_service, profile as profile_svc},
|
||||
};
|
||||
|
||||
@@ -83,3 +85,28 @@ pub async fn post_match_result(
|
||||
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
/// `POST /matches/complete` — the authoritative, atomic, exactly-once match
|
||||
/// economy entry point (the game host routes a finished match here). Idempotent
|
||||
/// on `(profile, match_identity)`: a replay returns the persisted canonical
|
||||
/// result with `applied = false` and grants nothing twice.
|
||||
pub async fn post_match_complete(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Json(req): Json<CompleteMatchRequest>,
|
||||
) -> AppResult<Json<MatchCompletionResult>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
let result = match_service::complete_match(
|
||||
&state.pool,
|
||||
&profile.id,
|
||||
&club.id,
|
||||
&req,
|
||||
&state.obj_defs,
|
||||
&state.achievement_defs,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
+168
-1
@@ -1,12 +1,14 @@
|
||||
use crate::{
|
||||
db::Pool,
|
||||
error::AppResult,
|
||||
error::{AppError, AppResult},
|
||||
models::achievement::{AchievementDefinition, PlayerAchievement},
|
||||
services::{club as club_svc, notification},
|
||||
};
|
||||
use anyhow::Context;
|
||||
use std::path::Path;
|
||||
use uuid::Uuid;
|
||||
use sqlx::{Sqlite, Transaction};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
pub fn load_achievement_definitions(data_dir: &str) -> anyhow::Result<Vec<AchievementDefinition>> {
|
||||
let dir = Path::new(data_dir).join("achievements");
|
||||
@@ -185,6 +187,171 @@ pub async fn check_and_unlock(
|
||||
Ok(newly_unlocked)
|
||||
}
|
||||
|
||||
/// Transaction-scoped [`metric_value`] — identical reads, run inside the
|
||||
/// caller's transaction so achievement checks see the same uncommitted state the
|
||||
/// rest of the match-completion transaction just wrote.
|
||||
async fn metric_value_tx(
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
profile_id: &str,
|
||||
club_id: &str,
|
||||
trigger: &str,
|
||||
) -> AppResult<i64> {
|
||||
let v: i64 = match trigger {
|
||||
"matches_played" => {
|
||||
sqlx::query_scalar("SELECT matches_played FROM statistics WHERE profile_id = ?")
|
||||
.bind(profile_id)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?
|
||||
.unwrap_or(0)
|
||||
}
|
||||
"matches_won" => {
|
||||
sqlx::query_scalar("SELECT matches_won FROM statistics WHERE profile_id = ?")
|
||||
.bind(profile_id)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?
|
||||
.unwrap_or(0)
|
||||
}
|
||||
"goals_scored" => {
|
||||
sqlx::query_scalar("SELECT goals_scored FROM statistics WHERE profile_id = ?")
|
||||
.bind(profile_id)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?
|
||||
.unwrap_or(0)
|
||||
}
|
||||
"packs_opened" => {
|
||||
sqlx::query_scalar("SELECT packs_opened FROM statistics WHERE profile_id = ?")
|
||||
.bind(profile_id)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?
|
||||
.unwrap_or(0)
|
||||
}
|
||||
"sbcs_completed" => {
|
||||
sqlx::query_scalar("SELECT sbcs_completed FROM statistics WHERE profile_id = ?")
|
||||
.bind(profile_id)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?
|
||||
.unwrap_or(0)
|
||||
}
|
||||
"cards_owned" => {
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM owned_cards WHERE club_id = ?")
|
||||
.bind(club_id)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?
|
||||
}
|
||||
"level" => sqlx::query_scalar("SELECT level FROM profiles WHERE id = ?")
|
||||
.bind(profile_id)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?
|
||||
.unwrap_or(1),
|
||||
"objectives_completed" => sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM objective_progress WHERE profile_id = ? AND completed = 1",
|
||||
)
|
||||
.bind(profile_id)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?,
|
||||
"drafts_completed" => sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM draft_sessions WHERE profile_id = ? AND status = 'completed'",
|
||||
)
|
||||
.bind(profile_id)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?,
|
||||
_ => 0,
|
||||
};
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Transaction-scoped [`check_and_unlock`] for the atomic match-completion path.
|
||||
/// Unlocks are inserted, coins credited, and notifications written inside the
|
||||
/// caller's transaction (mirroring the inline economy writes elsewhere), so a
|
||||
/// later failure rolls back the whole match — no half-granted achievement.
|
||||
pub async fn check_and_unlock_tx(
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
defs: &[AchievementDefinition],
|
||||
profile_id: &str,
|
||||
club_id: &str,
|
||||
now: &str,
|
||||
) -> AppResult<Vec<AchievementDefinition>> {
|
||||
if defs.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let unlocked_ids: Vec<String> =
|
||||
sqlx::query_scalar("SELECT achievement_id FROM player_achievements")
|
||||
.fetch_all(&mut **tx)
|
||||
.await?;
|
||||
let unlocked_set: HashSet<&str> = unlocked_ids.iter().map(|s| s.as_str()).collect();
|
||||
|
||||
let candidates: Vec<&AchievementDefinition> = defs
|
||||
.iter()
|
||||
.filter(|d| !unlocked_set.contains(d.id.as_str()))
|
||||
.collect();
|
||||
if candidates.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let mut trigger_cache: HashMap<String, i64> = Default::default();
|
||||
let mut newly_unlocked: Vec<AchievementDefinition> = Vec::new();
|
||||
|
||||
for def in candidates {
|
||||
let value = match trigger_cache.get(&def.trigger) {
|
||||
Some(&v) => v,
|
||||
None => {
|
||||
let v = metric_value_tx(tx, profile_id, club_id, &def.trigger).await?;
|
||||
trigger_cache.insert(def.trigger.clone(), v);
|
||||
v
|
||||
}
|
||||
};
|
||||
|
||||
if value >= def.threshold {
|
||||
if def.reward_coins < 0 {
|
||||
return Err(AppError::Internal(anyhow::anyhow!(
|
||||
"achievement {} has a negative reward",
|
||||
def.id
|
||||
)));
|
||||
}
|
||||
let inserted = sqlx::query(
|
||||
"INSERT OR IGNORE INTO player_achievements (id, achievement_id, unlocked_at) VALUES (?, ?, ?)",
|
||||
)
|
||||
.bind(Uuid::new_v4().to_string())
|
||||
.bind(&def.id)
|
||||
.bind(now)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
if inserted.rows_affected() == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
if def.reward_coins > 0 {
|
||||
let credited =
|
||||
sqlx::query("UPDATE clubs SET coins = coins + ?, updated_at = ? WHERE id = ?")
|
||||
.bind(def.reward_coins)
|
||||
.bind(now)
|
||||
.bind(club_id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
if credited.rows_affected() != 1 {
|
||||
return Err(AppError::NotFound("club not found".into()));
|
||||
}
|
||||
}
|
||||
|
||||
let body = format!("{} Reward: {} coins.", def.description, def.reward_coins);
|
||||
sqlx::query(
|
||||
"INSERT INTO notifications (id, kind, title, body, is_read, created_at) VALUES (?, 'achievement', ?, ?, 0, ?)",
|
||||
)
|
||||
.bind(Uuid::new_v4().to_string())
|
||||
.bind(format!("Achievement: {}", def.title))
|
||||
.bind(body)
|
||||
.bind(now)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
newly_unlocked.push(def.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(newly_unlocked)
|
||||
}
|
||||
|
||||
/// Return all achievement definitions annotated with unlock status.
|
||||
pub async fn list_with_status(
|
||||
pool: &Pool,
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
use crate::{
|
||||
db::Pool,
|
||||
error::AppResult,
|
||||
error::{AppError, AppResult},
|
||||
models::{
|
||||
achievement::AchievementDefinition,
|
||||
card::OwnedCard,
|
||||
match_result::{Match, MatchRewardResult, SubmitMatchRequest},
|
||||
match_result::{
|
||||
CompleteMatchRequest, Match, MatchCompletionResult, MatchResultKind, MatchRewardResult,
|
||||
SubmitMatchRequest,
|
||||
},
|
||||
objective::ObjectiveDefinition,
|
||||
profile::{coins_for_level, level_for_xp, pack_for_level, LevelUpEvent},
|
||||
},
|
||||
services::{
|
||||
achievement, card_db::CardDb, club, notification, objective, profile, season as season_svc,
|
||||
@@ -13,6 +17,8 @@ use crate::{
|
||||
},
|
||||
};
|
||||
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"];
|
||||
|
||||
@@ -339,3 +345,808 @@ async fn process_loan_expiry(pool: &Pool, club_id: &str, squad_id: &str) -> AppR
|
||||
|
||||
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();
|
||||
|
||||
// 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?;
|
||||
}
|
||||
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,
|
||||
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![],
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use crate::{
|
||||
use anyhow::Context;
|
||||
use std::path::Path;
|
||||
use uuid::Uuid;
|
||||
use sqlx::{Sqlite, Transaction};
|
||||
|
||||
pub fn load_objective_definitions(data_dir: &str) -> anyhow::Result<Vec<ObjectiveDefinition>> {
|
||||
let dir = Path::new(data_dir).join("objectives");
|
||||
@@ -120,6 +121,72 @@ pub async fn increment_metric(
|
||||
Ok(completed_ids)
|
||||
}
|
||||
|
||||
/// Transaction-scoped [`increment_metric`] for the atomic match-completion path.
|
||||
/// Same semantics, but every read/write runs inside the caller's transaction so
|
||||
/// objective progress commits (or rolls back) together with the coins, XP, and
|
||||
/// statistics of the same match. `now` is threaded so one match stamps a single
|
||||
/// timestamp.
|
||||
pub async fn increment_metric_tx(
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
profile_id: &str,
|
||||
defs: &[ObjectiveDefinition],
|
||||
metric: &str,
|
||||
amount: i64,
|
||||
now: &str,
|
||||
) -> AppResult<Vec<String>> {
|
||||
let mut completed_ids = Vec::new();
|
||||
|
||||
for def in defs.iter().filter(|d| d.metric.as_str() == metric) {
|
||||
let existing = sqlx::query_as::<_, ObjectiveProgress>(
|
||||
"SELECT id, profile_id, objective_id, current, completed, claimed, updated_at FROM objective_progress WHERE profile_id = ? AND objective_id = ?"
|
||||
)
|
||||
.bind(profile_id)
|
||||
.bind(&def.id)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?;
|
||||
|
||||
if let Some(prog) = existing {
|
||||
if prog.completed {
|
||||
continue;
|
||||
}
|
||||
let new_val = (prog.current + amount).min(def.target);
|
||||
let now_complete = new_val >= def.target;
|
||||
sqlx::query(
|
||||
"UPDATE objective_progress SET current = ?, completed = ?, updated_at = ? WHERE id = ?"
|
||||
)
|
||||
.bind(new_val)
|
||||
.bind(now_complete)
|
||||
.bind(now)
|
||||
.bind(&prog.id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
if now_complete {
|
||||
completed_ids.push(def.id.clone());
|
||||
}
|
||||
} else {
|
||||
let new_val = amount.min(def.target);
|
||||
let now_complete = new_val >= def.target;
|
||||
let id = Uuid::new_v4().to_string();
|
||||
sqlx::query(
|
||||
"INSERT INTO objective_progress (id, profile_id, objective_id, current, completed, claimed, updated_at) VALUES (?, ?, ?, ?, ?, 0, ?)"
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(profile_id)
|
||||
.bind(&def.id)
|
||||
.bind(new_val)
|
||||
.bind(now_complete)
|
||||
.bind(now)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
if now_complete {
|
||||
completed_ids.push(def.id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(completed_ids)
|
||||
}
|
||||
|
||||
pub async fn claim_objective(
|
||||
pool: &Pool,
|
||||
profile_id: &str,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::{db::Pool, error::AppResult, models::statistics::Statistics};
|
||||
use sqlx::{Sqlite, Transaction};
|
||||
|
||||
const SELECT_STATS: &str = "SELECT profile_id, matches_played, matches_won, matches_drawn, matches_lost, goals_scored, goals_conceded, packs_opened, sbcs_completed, total_coins_earned, win_streak, best_win_streak, updated_at FROM statistics WHERE profile_id = ?";
|
||||
const SELECT_STATS: &str = "SELECT profile_id, matches_played, matches_won, matches_drawn, matches_lost, matches_dnf, goals_scored, goals_conceded, packs_opened, sbcs_completed, total_coins_earned, win_streak, best_win_streak, updated_at FROM statistics WHERE profile_id = ?";
|
||||
|
||||
pub async fn get_or_create(pool: &Pool, profile_id: &str) -> AppResult<Statistics> {
|
||||
if let Some(s) = sqlx::query_as::<_, Statistics>(SELECT_STATS)
|
||||
@@ -77,6 +78,77 @@ pub async fn record_match(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record a completed match within an existing transaction (the atomic
|
||||
/// match-completion path). `outcome` is `win` | `draw` | `loss` | `dnf`. A DNF
|
||||
/// (abandon/quit) increments its own bucket — never `matches_lost` — and, like a
|
||||
/// loss, resets the win streak. All-or-nothing with the caller's transaction; it
|
||||
/// never commits on its own, so a later failure rolls this back with everything
|
||||
/// else.
|
||||
pub async fn record_match_tx(
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
profile_id: &str,
|
||||
outcome: &str,
|
||||
goals_for: i64,
|
||||
goals_against: i64,
|
||||
coins: i64,
|
||||
now: &str,
|
||||
) -> AppResult<()> {
|
||||
sqlx::query("INSERT OR IGNORE INTO statistics (profile_id, updated_at) VALUES (?, ?)")
|
||||
.bind(profile_id)
|
||||
.bind(now)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
let current_streak: i64 =
|
||||
sqlx::query_scalar("SELECT win_streak FROM statistics WHERE profile_id = ?")
|
||||
.bind(profile_id)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
|
||||
let (w, d, l, dnf) = match outcome {
|
||||
"win" => (1i64, 0i64, 0i64, 0i64),
|
||||
"draw" => (0, 1, 0, 0),
|
||||
"dnf" => (0, 0, 0, 1),
|
||||
_ => (0, 0, 1, 0),
|
||||
};
|
||||
let new_streak = if outcome == "win" {
|
||||
current_streak + 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE statistics SET
|
||||
matches_played = matches_played + 1,
|
||||
matches_won = matches_won + ?,
|
||||
matches_drawn = matches_drawn + ?,
|
||||
matches_lost = matches_lost + ?,
|
||||
matches_dnf = matches_dnf + ?,
|
||||
goals_scored = goals_scored + ?,
|
||||
goals_conceded = goals_conceded + ?,
|
||||
total_coins_earned = total_coins_earned + ?,
|
||||
win_streak = ?,
|
||||
best_win_streak = MAX(best_win_streak, ?),
|
||||
updated_at = ?
|
||||
WHERE profile_id = ?",
|
||||
)
|
||||
.bind(w)
|
||||
.bind(d)
|
||||
.bind(l)
|
||||
.bind(dnf)
|
||||
.bind(goals_for)
|
||||
.bind(goals_against)
|
||||
.bind(coins)
|
||||
.bind(new_streak)
|
||||
.bind(new_streak)
|
||||
.bind(now)
|
||||
.bind(profile_id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn increment_packs_opened(pool: &Pool, profile_id: &str) -> AppResult<()> {
|
||||
get_or_create(pool, profile_id).await?;
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
@@ -121,6 +193,26 @@ pub async fn record_position_goals(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Transaction-scoped [`record_position_goals`] for the atomic match-completion
|
||||
/// path.
|
||||
pub async fn record_position_goals_tx(
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
profile_id: &str,
|
||||
positions: &[String],
|
||||
) -> AppResult<()> {
|
||||
for position in positions {
|
||||
sqlx::query(
|
||||
"INSERT INTO position_goals (profile_id, position, goals) VALUES (?, ?, 1) \
|
||||
ON CONFLICT(profile_id, position) DO UPDATE SET goals = goals + 1",
|
||||
)
|
||||
.bind(profile_id)
|
||||
.bind(position)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_position_goals(pool: &Pool, profile_id: &str) -> AppResult<Vec<(String, i64)>> {
|
||||
let rows: Vec<(String, i64)> = sqlx::query_as(
|
||||
"SELECT position, goals FROM position_goals WHERE profile_id = ? ORDER BY goals DESC",
|
||||
|
||||
Reference in New Issue
Block a user