use crate::{ db::Pool, error::AppResult, models::achievement::{AchievementDefinition, PlayerAchievement}, services::{club as club_svc, notification}, }; use anyhow::Context; 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) } /// 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()) }