Files
OpenFUT-Core/src/services/achievement.rs
T
funman300 2fb835200f
CI / Build, lint & test (push) Successful in 4m1s
style(core): cargo fmt match/club agent additions
2026-08-20 17:59:02 +00:00

391 lines
13 KiB
Rust

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<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)
}
/// 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,
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())
}