Files
OpenFUT-Core/src/services/objective.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

240 lines
7.8 KiB
Rust

use crate::{
db::Pool,
error::{AppError, AppResult},
models::objective::{
ClaimRewardResult, ObjectiveDefinition, ObjectiveProgress, ObjectiveWithProgress,
},
};
use anyhow::Context;
use sqlx::{Sqlite, Transaction};
use std::path::Path;
use uuid::Uuid;
pub fn load_objective_definitions(data_dir: &str) -> anyhow::Result<Vec<ObjectiveDefinition>> {
let dir = Path::new(data_dir).join("objectives");
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<ObjectiveDefinition> =
serde_json::from_str(&content).with_context(|| format!("parsing {:?}", path))?;
defs.extend(batch);
}
}
Ok(defs)
}
pub async fn get_objectives_with_progress(
pool: &Pool,
profile_id: &str,
defs: &[ObjectiveDefinition],
) -> AppResult<Vec<ObjectiveWithProgress>> {
let progress_rows = sqlx::query_as::<_, ObjectiveProgress>(
"SELECT id, profile_id, objective_id, current, completed, claimed, updated_at FROM objective_progress WHERE profile_id = ?"
)
.bind(profile_id)
.fetch_all(pool)
.await?;
let result = defs
.iter()
.map(|def| {
let prog = progress_rows.iter().find(|p| p.objective_id == def.id);
ObjectiveWithProgress {
definition: def.clone(),
current: prog.map(|p| p.current).unwrap_or(0),
completed: prog.map(|p| p.completed).unwrap_or(false),
claimed: prog.map(|p| p.claimed).unwrap_or(false),
}
})
.collect();
Ok(result)
}
/// Increment a metric for all objectives that track it.
pub async fn increment_metric(
pool: &Pool,
profile_id: &str,
defs: &[ObjectiveDefinition],
metric: &str,
amount: i64,
) -> AppResult<Vec<String>> {
let mut completed_ids = Vec::new();
for def in defs.iter().filter(|d| d.metric.as_str() == metric) {
let existing = sqlx::query_as::<_, ObjectiveProgress>(
"SELECT id, profile_id, objective_id, current, completed, claimed, updated_at FROM objective_progress WHERE profile_id = ? AND objective_id = ?"
)
.bind(profile_id)
.bind(&def.id)
.fetch_optional(pool)
.await?;
let now = chrono::Utc::now().to_rfc3339();
if let Some(prog) = existing {
if prog.completed {
continue;
}
let new_val = (prog.current + amount).min(def.target);
let now_complete = new_val >= def.target;
sqlx::query(
"UPDATE objective_progress SET current = ?, completed = ?, updated_at = ? WHERE id = ?"
)
.bind(new_val)
.bind(now_complete)
.bind(&now)
.bind(&prog.id)
.execute(pool)
.await?;
if now_complete {
completed_ids.push(def.id.clone());
}
} else {
let new_val = amount.min(def.target);
let now_complete = new_val >= def.target;
let id = Uuid::new_v4().to_string();
sqlx::query(
"INSERT INTO objective_progress (id, profile_id, objective_id, current, completed, claimed, updated_at) VALUES (?, ?, ?, ?, ?, 0, ?)"
)
.bind(&id)
.bind(profile_id)
.bind(&def.id)
.bind(new_val)
.bind(now_complete)
.bind(&now)
.execute(pool)
.await?;
if now_complete {
completed_ids.push(def.id.clone());
}
}
}
Ok(completed_ids)
}
/// Transaction-scoped [`increment_metric`] for the atomic match-completion path.
/// Same semantics, but every read/write runs inside the caller's transaction so
/// objective progress commits (or rolls back) together with the coins, XP, and
/// statistics of the same match. `now` is threaded so one match stamps a single
/// timestamp.
pub async fn increment_metric_tx(
tx: &mut Transaction<'_, Sqlite>,
profile_id: &str,
defs: &[ObjectiveDefinition],
metric: &str,
amount: i64,
now: &str,
) -> AppResult<Vec<String>> {
let mut completed_ids = Vec::new();
for def in defs.iter().filter(|d| d.metric.as_str() == metric) {
let existing = sqlx::query_as::<_, ObjectiveProgress>(
"SELECT id, profile_id, objective_id, current, completed, claimed, updated_at FROM objective_progress WHERE profile_id = ? AND objective_id = ?"
)
.bind(profile_id)
.bind(&def.id)
.fetch_optional(&mut **tx)
.await?;
if let Some(prog) = existing {
if prog.completed {
continue;
}
let new_val = (prog.current + amount).min(def.target);
let now_complete = new_val >= def.target;
sqlx::query(
"UPDATE objective_progress SET current = ?, completed = ?, updated_at = ? WHERE id = ?"
)
.bind(new_val)
.bind(now_complete)
.bind(now)
.bind(&prog.id)
.execute(&mut **tx)
.await?;
if now_complete {
completed_ids.push(def.id.clone());
}
} else {
let new_val = amount.min(def.target);
let now_complete = new_val >= def.target;
let id = Uuid::new_v4().to_string();
sqlx::query(
"INSERT INTO objective_progress (id, profile_id, objective_id, current, completed, claimed, updated_at) VALUES (?, ?, ?, ?, ?, 0, ?)"
)
.bind(&id)
.bind(profile_id)
.bind(&def.id)
.bind(new_val)
.bind(now_complete)
.bind(now)
.execute(&mut **tx)
.await?;
if now_complete {
completed_ids.push(def.id.clone());
}
}
}
Ok(completed_ids)
}
pub async fn claim_objective(
pool: &Pool,
profile_id: &str,
club_id: &str,
defs: &[ObjectiveDefinition],
objective_id: &str,
) -> AppResult<ClaimRewardResult> {
let prog = sqlx::query_as::<_, ObjectiveProgress>(
"SELECT id, profile_id, objective_id, current, completed, claimed, updated_at FROM objective_progress WHERE profile_id = ? AND objective_id = ?"
)
.bind(profile_id)
.bind(objective_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound("objective not started yet".into()))?;
if !prog.completed {
return Err(AppError::BadRequest("objective not completed yet".into()));
}
if prog.claimed {
return Err(AppError::Conflict("reward already claimed".into()));
}
let def = defs
.iter()
.find(|d| d.id == objective_id)
.ok_or_else(|| AppError::NotFound(format!("objective '{objective_id}' not found")))?;
sqlx::query("UPDATE objective_progress SET claimed = 1 WHERE id = ?")
.bind(&prog.id)
.execute(pool)
.await?;
if def.reward_coins > 0 {
crate::services::club::add_coins(pool, club_id, def.reward_coins).await?;
}
if def.reward_xp > 0 {
crate::services::profile::add_xp(pool, profile_id, def.reward_xp).await?;
}
if let Some(pack_id) = &def.reward_pack_id {
crate::services::pack::grant_pack(pool, club_id, pack_id).await?;
}
Ok(ClaimRewardResult {
objective_id: objective_id.to_string(),
coins_granted: def.reward_coins,
xp_granted: def.reward_xp,
pack_granted: def.reward_pack_id.clone(),
})
}