use crate::{ db::Pool, error::{AppError, AppResult}, models::achievement::{AchievementDefinition, PlayerAchievement}, services::{club as club_svc, notification}, }; use anyhow::Context; use sqlx::{Sqlite, Transaction}; use std::collections::{HashMap, HashSet}; use std::path::Path; use uuid::Uuid; pub fn load_achievement_definitions(data_dir: &str) -> anyhow::Result> { let dir = Path::new(data_dir).join("achievements"); let mut defs = Vec::new(); if !dir.exists() { return Ok(defs); } for entry in std::fs::read_dir(&dir)? { let entry = entry?; let path = entry.path(); if path.extension().map(|e| e == "json").unwrap_or(false) { let content = std::fs::read_to_string(&path).with_context(|| format!("reading {:?}", path))?; let batch: Vec = serde_json::from_str(&content).with_context(|| format!("parsing {:?}", path))?; defs.extend(batch); } } Ok(defs) } /// Query the current value for the given trigger metric. async fn metric_value( pool: &Pool, profile_id: &str, club_id: &str, trigger: &str, ) -> AppResult { let v: i64 = match trigger { "matches_played" => { sqlx::query_scalar("SELECT matches_played FROM statistics WHERE profile_id = ?") .bind(profile_id) .fetch_optional(pool) .await? .unwrap_or(0) } "matches_won" => { sqlx::query_scalar("SELECT matches_won FROM statistics WHERE profile_id = ?") .bind(profile_id) .fetch_optional(pool) .await? .unwrap_or(0) } "goals_scored" => { sqlx::query_scalar("SELECT goals_scored FROM statistics WHERE profile_id = ?") .bind(profile_id) .fetch_optional(pool) .await? .unwrap_or(0) } "packs_opened" => { sqlx::query_scalar("SELECT packs_opened FROM statistics WHERE profile_id = ?") .bind(profile_id) .fetch_optional(pool) .await? .unwrap_or(0) } "sbcs_completed" => { sqlx::query_scalar("SELECT sbcs_completed FROM statistics WHERE profile_id = ?") .bind(profile_id) .fetch_optional(pool) .await? .unwrap_or(0) } "cards_owned" => { sqlx::query_scalar("SELECT COUNT(*) FROM owned_cards WHERE club_id = ?") .bind(club_id) .fetch_one(pool) .await? } "level" => sqlx::query_scalar("SELECT level FROM profiles WHERE id = ?") .bind(profile_id) .fetch_optional(pool) .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(pool) .await?, "drafts_completed" => sqlx::query_scalar( "SELECT COUNT(*) FROM draft_sessions WHERE profile_id = ? AND status = 'completed'", ) .bind(profile_id) .fetch_one(pool) .await?, _ => 0, }; Ok(v) } /// Check all achievement definitions and unlock any not yet earned. /// Returns definitions of newly unlocked achievements. pub async fn check_and_unlock( pool: &Pool, defs: &[AchievementDefinition], profile_id: &str, club_id: &str, ) -> AppResult> { if defs.is_empty() { return Ok(vec![]); } // Load already-unlocked IDs in one query let unlocked_ids: Vec = sqlx::query_scalar("SELECT achievement_id FROM player_achievements") .fetch_all(pool) .await?; let unlocked_set: std::collections::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![]); } // Group candidates by trigger to minimise DB round-trips let mut trigger_cache: std::collections::HashMap = Default::default(); let mut newly_unlocked: Vec = Vec::new(); let now = chrono::Utc::now().to_rfc3339(); for def in candidates { let value = match trigger_cache.get(&def.trigger) { Some(&v) => v, None => { let v = metric_value(pool, profile_id, club_id, &def.trigger).await?; trigger_cache.insert(def.trigger.clone(), v); v } }; if value >= def.threshold { let pa_id = Uuid::new_v4().to_string(); sqlx::query( "INSERT OR IGNORE INTO player_achievements (id, achievement_id, unlocked_at) VALUES (?, ?, ?)", ) .bind(&pa_id) .bind(&def.id) .bind(&now) .execute(pool) .await?; if def.reward_coins > 0 { club_svc::add_coins(pool, club_id, def.reward_coins).await?; } let body = format!("{} Reward: {} coins.", def.description, def.reward_coins); let _ = notification::create( pool, "achievement", &format!("Achievement: {}", def.title), &body, ) .await; newly_unlocked.push(def.clone()); } } 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 { 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> { if defs.is_empty() { return Ok(vec![]); } let unlocked_ids: Vec = 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 = Default::default(); let mut newly_unlocked: Vec = 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, defs: &[AchievementDefinition], ) -> AppResult> { let earned: Vec = sqlx::query_as::<_, PlayerAchievement>( "SELECT id, achievement_id, unlocked_at FROM player_achievements", ) .fetch_all(pool) .await?; let earned_map: std::collections::HashMap<&str, &str> = earned .iter() .map(|pa| (pa.achievement_id.as_str(), pa.unlocked_at.as_str())) .collect(); Ok(defs .iter() .map(|d| { let unlocked_at = earned_map.get(d.id.as_str()).copied(); serde_json::json!({ "id": d.id, "title": d.title, "description": d.description, "icon": d.icon, "trigger": d.trigger, "threshold": d.threshold, "reward_coins": d.reward_coins, "rarity": d.rarity, "unlocked": unlocked_at.is_some(), "unlocked_at": unlocked_at, }) }) .collect()) }