18 data-driven achievements (achievements.json) across 8 trigger categories: matches_played, matches_won, goals_scored, packs_opened, sbcs_completed, cards_owned, level, objectives_completed, drafts_completed. Rarities span common → epic. Coin rewards range from 500 (first_match) to 6000 (win_50). check_and_unlock() queries the relevant metric from existing tables, skips already-earned achievements via INSERT OR IGNORE, grants coin rewards, and fires a persistent notification per unlock. Trigger values are cached per call to avoid redundant DB round-trips for same-trigger achievements. Checks run automatically after every match result (all triggers), every pack open (packs_opened), and every successful SBC submission (sbcs_completed). GET /achievements returns all definitions annotated with unlocked/unlocked_at, plus earned and total counts. POST /matches/result response gains an achievements_unlocked array (empty when nothing new unlocked). AppState gains achievement_defs (Arc<Vec<AchievementDefinition>>) loaded from data/achievements/**/*.json at startup — same pattern as obj_defs. 5 new tests: list endpoint, first_match unlock, first_win unlock, no-dup guard, coin reward verification. Core now at 82 tests. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
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<Vec<AchievementDefinition>> {
|
||||
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<AchievementDefinition> =
|
||||
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<i64> {
|
||||
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<Vec<AchievementDefinition>> {
|
||||
if defs.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
// Load already-unlocked IDs in one query
|
||||
let unlocked_ids: Vec<String> =
|
||||
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<String, i64> = Default::default();
|
||||
let mut newly_unlocked: Vec<AchievementDefinition> = 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<Vec<serde_json::Value>> {
|
||||
let earned: Vec<PlayerAchievement> = 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())
|
||||
}
|
||||
@@ -2,11 +2,12 @@ use crate::{
|
||||
db::Pool,
|
||||
error::AppResult,
|
||||
models::{
|
||||
achievement::AchievementDefinition,
|
||||
card::OwnedCard,
|
||||
match_result::{Match, MatchRewardResult, SubmitMatchRequest},
|
||||
objective::ObjectiveDefinition,
|
||||
},
|
||||
services::{card_db::CardDb, club, notification, objective, profile, season as season_svc, statistics},
|
||||
services::{achievement, card_db::CardDb, club, notification, objective, profile, season as season_svc, statistics},
|
||||
};
|
||||
use rand::{seq::SliceRandom, Rng};
|
||||
|
||||
@@ -91,6 +92,7 @@ pub async fn process_match(
|
||||
club_id: &str,
|
||||
req: &SubmitMatchRequest,
|
||||
obj_defs: &[ObjectiveDefinition],
|
||||
ach_defs: &[AchievementDefinition],
|
||||
) -> AppResult<MatchRewardResult> {
|
||||
let outcome = if req.goals_for > req.goals_against {
|
||||
"win"
|
||||
@@ -210,6 +212,11 @@ pub async fn process_match(
|
||||
let _ = notification::create(pool, "season_end", "Season complete!", &body).await;
|
||||
}
|
||||
|
||||
let achievements_unlocked =
|
||||
achievement::check_and_unlock(pool, ach_defs, profile_id, club_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(MatchRewardResult {
|
||||
match_record,
|
||||
coins_awarded: coins,
|
||||
@@ -218,6 +225,7 @@ pub async fn process_match(
|
||||
expired_loans,
|
||||
season_end,
|
||||
level_ups,
|
||||
achievements_unlocked,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod achievement;
|
||||
pub mod card_db;
|
||||
pub mod club;
|
||||
pub mod notification;
|
||||
|
||||
Reference in New Issue
Block a user