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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user