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