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,182 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "first_match",
|
||||||
|
"title": "Debut Match",
|
||||||
|
"description": "Play your very first match.",
|
||||||
|
"icon": "⚽",
|
||||||
|
"trigger": "matches_played",
|
||||||
|
"threshold": 1,
|
||||||
|
"reward_coins": 500,
|
||||||
|
"rarity": "common"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "match_veteran",
|
||||||
|
"title": "Match Veteran",
|
||||||
|
"description": "Play 10 matches.",
|
||||||
|
"icon": "🏟️",
|
||||||
|
"trigger": "matches_played",
|
||||||
|
"threshold": 10,
|
||||||
|
"reward_coins": 1500,
|
||||||
|
"rarity": "common"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "match_pro",
|
||||||
|
"title": "Pro Player",
|
||||||
|
"description": "Play 50 matches.",
|
||||||
|
"icon": "🎖️",
|
||||||
|
"trigger": "matches_played",
|
||||||
|
"threshold": 50,
|
||||||
|
"reward_coins": 4000,
|
||||||
|
"rarity": "rare"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "first_win",
|
||||||
|
"title": "First Blood",
|
||||||
|
"description": "Win your first match.",
|
||||||
|
"icon": "🏆",
|
||||||
|
"trigger": "matches_won",
|
||||||
|
"threshold": 1,
|
||||||
|
"reward_coins": 500,
|
||||||
|
"rarity": "common"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "win_10",
|
||||||
|
"title": "On Fire",
|
||||||
|
"description": "Win 10 matches.",
|
||||||
|
"icon": "🔥",
|
||||||
|
"trigger": "matches_won",
|
||||||
|
"threshold": 10,
|
||||||
|
"reward_coins": 2000,
|
||||||
|
"rarity": "rare"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "win_50",
|
||||||
|
"title": "Unstoppable",
|
||||||
|
"description": "Win 50 matches.",
|
||||||
|
"icon": "👑",
|
||||||
|
"trigger": "matches_won",
|
||||||
|
"threshold": 50,
|
||||||
|
"reward_coins": 6000,
|
||||||
|
"rarity": "epic"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "goalscorer_10",
|
||||||
|
"title": "Goalscorer",
|
||||||
|
"description": "Score 10 goals across all matches.",
|
||||||
|
"icon": "⚡",
|
||||||
|
"trigger": "goals_scored",
|
||||||
|
"threshold": 10,
|
||||||
|
"reward_coins": 750,
|
||||||
|
"rarity": "common"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "top_scorer_50",
|
||||||
|
"title": "Top Scorer",
|
||||||
|
"description": "Score 50 goals across all matches.",
|
||||||
|
"icon": "🥅",
|
||||||
|
"trigger": "goals_scored",
|
||||||
|
"threshold": 50,
|
||||||
|
"reward_coins": 3000,
|
||||||
|
"rarity": "rare"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "first_pack",
|
||||||
|
"title": "Pack Opener",
|
||||||
|
"description": "Open your first pack.",
|
||||||
|
"icon": "📦",
|
||||||
|
"trigger": "packs_opened",
|
||||||
|
"threshold": 1,
|
||||||
|
"reward_coins": 500,
|
||||||
|
"rarity": "common"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pack_10",
|
||||||
|
"title": "Pack Addict",
|
||||||
|
"description": "Open 10 packs.",
|
||||||
|
"icon": "🎁",
|
||||||
|
"trigger": "packs_opened",
|
||||||
|
"threshold": 10,
|
||||||
|
"reward_coins": 2500,
|
||||||
|
"rarity": "rare"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "collector_20",
|
||||||
|
"title": "Collector",
|
||||||
|
"description": "Own 20 cards in your collection.",
|
||||||
|
"icon": "🗂️",
|
||||||
|
"trigger": "cards_owned",
|
||||||
|
"threshold": 20,
|
||||||
|
"reward_coins": 1000,
|
||||||
|
"rarity": "common"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hoarder_50",
|
||||||
|
"title": "Hoarder",
|
||||||
|
"description": "Own 50 cards in your collection.",
|
||||||
|
"icon": "💎",
|
||||||
|
"trigger": "cards_owned",
|
||||||
|
"threshold": 50,
|
||||||
|
"reward_coins": 3500,
|
||||||
|
"rarity": "rare"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "first_sbc",
|
||||||
|
"title": "Squad Builder",
|
||||||
|
"description": "Complete your first SBC challenge.",
|
||||||
|
"icon": "🧩",
|
||||||
|
"trigger": "sbcs_completed",
|
||||||
|
"threshold": 1,
|
||||||
|
"reward_coins": 750,
|
||||||
|
"rarity": "common"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sbc_master",
|
||||||
|
"title": "SBC Master",
|
||||||
|
"description": "Complete 5 SBC challenges.",
|
||||||
|
"icon": "🔧",
|
||||||
|
"trigger": "sbcs_completed",
|
||||||
|
"threshold": 5,
|
||||||
|
"reward_coins": 4000,
|
||||||
|
"rarity": "epic"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "level_5",
|
||||||
|
"title": "Rising Star",
|
||||||
|
"description": "Reach Level 5.",
|
||||||
|
"icon": "⬆",
|
||||||
|
"trigger": "level",
|
||||||
|
"threshold": 5,
|
||||||
|
"reward_coins": 2000,
|
||||||
|
"rarity": "rare"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "level_10",
|
||||||
|
"title": "Elite Player",
|
||||||
|
"description": "Reach Level 10.",
|
||||||
|
"icon": "⭐",
|
||||||
|
"trigger": "level",
|
||||||
|
"threshold": 10,
|
||||||
|
"reward_coins": 5000,
|
||||||
|
"rarity": "epic"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "first_objective",
|
||||||
|
"title": "On a Mission",
|
||||||
|
"description": "Complete your first objective.",
|
||||||
|
"icon": "✅",
|
||||||
|
"trigger": "objectives_completed",
|
||||||
|
"threshold": 1,
|
||||||
|
"reward_coins": 500,
|
||||||
|
"rarity": "common"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "draft_debut",
|
||||||
|
"title": "Draft Veteran",
|
||||||
|
"description": "Complete a draft session.",
|
||||||
|
"icon": "🃏",
|
||||||
|
"trigger": "drafts_completed",
|
||||||
|
"threshold": 1,
|
||||||
|
"reward_coins": 1500,
|
||||||
|
"rarity": "rare"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS player_achievements (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
achievement_id TEXT NOT NULL UNIQUE,
|
||||||
|
unlocked_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_player_achievements_id ON player_achievements (achievement_id);
|
||||||
+9
-3
@@ -2,6 +2,7 @@ use crate::middleware::{limit_concurrency, MakeRequestUuid};
|
|||||||
use crate::{
|
use crate::{
|
||||||
config::Config,
|
config::Config,
|
||||||
db::Pool,
|
db::Pool,
|
||||||
|
models::achievement::AchievementDefinition,
|
||||||
models::chemistry_style::{load_chemistry_styles, ChemistryStyle},
|
models::chemistry_style::{load_chemistry_styles, ChemistryStyle},
|
||||||
models::event::EventDefinition,
|
models::event::EventDefinition,
|
||||||
models::objective::{ObjectiveDefinition, ObjectiveType},
|
models::objective::{ObjectiveDefinition, ObjectiveType},
|
||||||
@@ -9,8 +10,8 @@ use crate::{
|
|||||||
models::sbc::SbcDefinition,
|
models::sbc::SbcDefinition,
|
||||||
routes,
|
routes,
|
||||||
services::{
|
services::{
|
||||||
card_db::CardDb, event::load_event_definitions, market,
|
achievement::load_achievement_definitions, card_db::CardDb, event::load_event_definitions,
|
||||||
objective::load_objective_definitions, pack::load_pack_definitions,
|
market, objective::load_objective_definitions, pack::load_pack_definitions,
|
||||||
sbc::load_sbc_definitions,
|
sbc::load_sbc_definitions,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -37,6 +38,7 @@ pub struct AppState {
|
|||||||
pub sbc_defs: Arc<Vec<SbcDefinition>>,
|
pub sbc_defs: Arc<Vec<SbcDefinition>>,
|
||||||
pub event_defs: Arc<Vec<EventDefinition>>,
|
pub event_defs: Arc<Vec<EventDefinition>>,
|
||||||
pub chem_styles: Arc<Vec<ChemistryStyle>>,
|
pub chem_styles: Arc<Vec<ChemistryStyle>>,
|
||||||
|
pub achievement_defs: Arc<Vec<AchievementDefinition>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
||||||
@@ -46,15 +48,17 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
|||||||
let sbc_defs = Arc::new(load_sbc_definitions(&cfg.data_dir)?);
|
let sbc_defs = Arc::new(load_sbc_definitions(&cfg.data_dir)?);
|
||||||
let event_defs = Arc::new(load_event_definitions(&cfg.data_dir)?);
|
let event_defs = Arc::new(load_event_definitions(&cfg.data_dir)?);
|
||||||
let chem_styles = Arc::new(load_chemistry_styles(&cfg.data_dir)?);
|
let chem_styles = Arc::new(load_chemistry_styles(&cfg.data_dir)?);
|
||||||
|
let achievement_defs = Arc::new(load_achievement_definitions(&cfg.data_dir)?);
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"Loaded {} cards, {} packs, {} objectives, {} SBCs, {} events, {} chemistry styles",
|
"Loaded {} cards, {} packs, {} objectives, {} SBCs, {} events, {} chemistry styles, {} achievements",
|
||||||
card_db.cards.len(),
|
card_db.cards.len(),
|
||||||
pack_defs.len(),
|
pack_defs.len(),
|
||||||
obj_defs.len(),
|
obj_defs.len(),
|
||||||
sbc_defs.len(),
|
sbc_defs.len(),
|
||||||
event_defs.len(),
|
event_defs.len(),
|
||||||
chem_styles.len(),
|
chem_styles.len(),
|
||||||
|
achievement_defs.len(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
@@ -65,6 +69,7 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
|||||||
sbc_defs,
|
sbc_defs,
|
||||||
event_defs: event_defs.clone(),
|
event_defs: event_defs.clone(),
|
||||||
chem_styles,
|
chem_styles,
|
||||||
|
achievement_defs,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Background task: refresh NPC market at startup and every 24 hours
|
// Background task: refresh NPC market at startup and every 24 hours
|
||||||
@@ -194,6 +199,7 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
|||||||
.route("/settings", get(routes::settings::get_settings))
|
.route("/settings", get(routes::settings::get_settings))
|
||||||
.route("/settings", put(routes::settings::put_settings))
|
.route("/settings", put(routes::settings::put_settings))
|
||||||
.route("/division", get(routes::division::get_division))
|
.route("/division", get(routes::division::get_division))
|
||||||
|
.route("/achievements", get(routes::achievements::get_achievements))
|
||||||
.route("/notifications", get(routes::notifications::get_notifications))
|
.route("/notifications", get(routes::notifications::get_notifications))
|
||||||
.route("/notifications/read-all", post(routes::notifications::mark_all_notifications_read))
|
.route("/notifications/read-all", post(routes::notifications::mark_all_notifications_read))
|
||||||
.route("/notifications/:id/read", patch(routes::notifications::mark_notification_read))
|
.route("/notifications/:id/read", patch(routes::notifications::mark_notification_read))
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AchievementDefinition {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
pub icon: String,
|
||||||
|
pub trigger: String,
|
||||||
|
pub threshold: i64,
|
||||||
|
pub reward_coins: i64,
|
||||||
|
pub rarity: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||||
|
pub struct PlayerAchievement {
|
||||||
|
pub id: String,
|
||||||
|
pub achievement_id: String,
|
||||||
|
pub unlocked_at: String,
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::models::profile::LevelUpEvent;
|
use crate::models::{achievement::AchievementDefinition, profile::LevelUpEvent};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -84,4 +84,6 @@ pub struct MatchRewardResult {
|
|||||||
pub season_end: Option<crate::models::season::SeasonEndSummary>,
|
pub season_end: Option<crate::models::season::SeasonEndSummary>,
|
||||||
/// Non-empty when the player levelled up one or more times from this match's XP.
|
/// Non-empty when the player levelled up one or more times from this match's XP.
|
||||||
pub level_ups: Vec<LevelUpEvent>,
|
pub level_ups: Vec<LevelUpEvent>,
|
||||||
|
/// Achievements unlocked as a result of this match.
|
||||||
|
pub achievements_unlocked: Vec<AchievementDefinition>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod achievement;
|
||||||
pub mod card;
|
pub mod card;
|
||||||
pub mod chemistry_style;
|
pub mod chemistry_style;
|
||||||
pub mod notification;
|
pub mod notification;
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
use axum::{extract::State, Json};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
app::AppState,
|
||||||
|
error::AppResult,
|
||||||
|
services::{achievement as ach_svc, club as club_svc, profile as profile_svc},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn get_achievements(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
|
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
|
let _ = ach_svc::check_and_unlock(&state.pool, &state.achievement_defs, &profile.id, &club.id).await;
|
||||||
|
let achievements = ach_svc::list_with_status(&state.pool, &state.achievement_defs).await?;
|
||||||
|
let earned = achievements.iter().filter(|a| a["unlocked"].as_bool().unwrap_or(false)).count();
|
||||||
|
Ok(Json(json!({
|
||||||
|
"achievements": achievements,
|
||||||
|
"earned": earned,
|
||||||
|
"total": achievements.len(),
|
||||||
|
})))
|
||||||
|
}
|
||||||
@@ -69,7 +69,7 @@ pub async fn post_match_result(
|
|||||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
let result =
|
let result =
|
||||||
match_service::process_match(&state.pool, &profile.id, &club.id, &req, &state.obj_defs)
|
match_service::process_match(&state.pool, &profile.id, &club.id, &req, &state.obj_defs, &state.achievement_defs)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod achievements;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod cards;
|
pub mod cards;
|
||||||
pub mod club;
|
pub mod club;
|
||||||
|
|||||||
@@ -139,6 +139,9 @@ pub async fn post_open_pack(
|
|||||||
statistics::increment_packs_opened(&state.pool, &profile.id).await?;
|
statistics::increment_packs_opened(&state.pool, &profile.id).await?;
|
||||||
objective::increment_metric(&state.pool, &profile.id, &state.obj_defs, "packsopened", 1)
|
objective::increment_metric(&state.pool, &profile.id, &state.obj_defs, "packsopened", 1)
|
||||||
.await?;
|
.await?;
|
||||||
|
let _ = crate::services::achievement::check_and_unlock(
|
||||||
|
&state.pool, &state.achievement_defs, &profile.id, &club.id,
|
||||||
|
).await;
|
||||||
|
|
||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,5 +45,11 @@ pub async fn post_sbc_submit(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
if result.passed {
|
||||||
|
let _ = crate::services::achievement::check_and_unlock(
|
||||||
|
&state.pool, &state.achievement_defs, &profile.id, &club.id,
|
||||||
|
).await;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
db::Pool,
|
||||||
error::AppResult,
|
error::AppResult,
|
||||||
models::{
|
models::{
|
||||||
|
achievement::AchievementDefinition,
|
||||||
card::OwnedCard,
|
card::OwnedCard,
|
||||||
match_result::{Match, MatchRewardResult, SubmitMatchRequest},
|
match_result::{Match, MatchRewardResult, SubmitMatchRequest},
|
||||||
objective::ObjectiveDefinition,
|
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};
|
use rand::{seq::SliceRandom, Rng};
|
||||||
|
|
||||||
@@ -91,6 +92,7 @@ pub async fn process_match(
|
|||||||
club_id: &str,
|
club_id: &str,
|
||||||
req: &SubmitMatchRequest,
|
req: &SubmitMatchRequest,
|
||||||
obj_defs: &[ObjectiveDefinition],
|
obj_defs: &[ObjectiveDefinition],
|
||||||
|
ach_defs: &[AchievementDefinition],
|
||||||
) -> AppResult<MatchRewardResult> {
|
) -> AppResult<MatchRewardResult> {
|
||||||
let outcome = if req.goals_for > req.goals_against {
|
let outcome = if req.goals_for > req.goals_against {
|
||||||
"win"
|
"win"
|
||||||
@@ -210,6 +212,11 @@ pub async fn process_match(
|
|||||||
let _ = notification::create(pool, "season_end", "Season complete!", &body).await;
|
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 {
|
Ok(MatchRewardResult {
|
||||||
match_record,
|
match_record,
|
||||||
coins_awarded: coins,
|
coins_awarded: coins,
|
||||||
@@ -218,6 +225,7 @@ pub async fn process_match(
|
|||||||
expired_loans,
|
expired_loans,
|
||||||
season_end,
|
season_end,
|
||||||
level_ups,
|
level_ups,
|
||||||
|
achievements_unlocked,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod achievement;
|
||||||
pub mod card_db;
|
pub mod card_db;
|
||||||
pub mod club;
|
pub mod club;
|
||||||
pub mod notification;
|
pub mod notification;
|
||||||
|
|||||||
@@ -1593,3 +1593,89 @@ async fn test_mark_single_notification_read() {
|
|||||||
assert_eq!(resp.status(), StatusCode::OK);
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Phase 20: Achievement system ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_achievements_endpoint_returns_list() {
|
||||||
|
let app = build_test_app().await;
|
||||||
|
auth(&app, "AchievementsListPlayer").await;
|
||||||
|
|
||||||
|
let (status, json) = json_get(&app, "/achievements").await;
|
||||||
|
assert_eq!(status, StatusCode::OK, "{json}");
|
||||||
|
assert!(json["achievements"].is_array(), "achievements array missing");
|
||||||
|
assert!(json["total"].as_u64().unwrap_or(0) > 0, "no achievements defined");
|
||||||
|
assert!(json["earned"].is_number(), "earned count missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_first_match_achievement_unlocks() {
|
||||||
|
let app = build_test_app().await;
|
||||||
|
auth(&app, "FirstMatchAchPlayer").await;
|
||||||
|
|
||||||
|
let (_, result) = json_post(&app, "/matches/result", serde_json::json!({
|
||||||
|
"squad_id": "dummy", "opponent_name": "Bot",
|
||||||
|
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
|
||||||
|
})).await;
|
||||||
|
|
||||||
|
let unlocked = result["achievements_unlocked"].as_array().unwrap();
|
||||||
|
let has_first_match = unlocked.iter().any(|a| a["id"] == "first_match");
|
||||||
|
assert!(has_first_match, "first_match achievement not in match result: {result}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_first_win_achievement_unlocks_on_win() {
|
||||||
|
let app = build_test_app().await;
|
||||||
|
auth(&app, "FirstWinAchPlayer").await;
|
||||||
|
|
||||||
|
let (_, result) = json_post(&app, "/matches/result", serde_json::json!({
|
||||||
|
"squad_id": "dummy", "opponent_name": "Bot",
|
||||||
|
"goals_for": 2, "goals_against": 0, "mode": "squad_battles"
|
||||||
|
})).await;
|
||||||
|
|
||||||
|
let unlocked = result["achievements_unlocked"].as_array().unwrap();
|
||||||
|
let has_first_win = unlocked.iter().any(|a| a["id"] == "first_win");
|
||||||
|
assert!(has_first_win, "first_win achievement not in match result: {result}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_achievements_not_duplicated_on_second_match() {
|
||||||
|
let app = build_test_app().await;
|
||||||
|
auth(&app, "NoDupAchPlayer").await;
|
||||||
|
|
||||||
|
// First match — first_match unlocks
|
||||||
|
json_post(&app, "/matches/result", serde_json::json!({
|
||||||
|
"squad_id": "dummy", "opponent_name": "Bot",
|
||||||
|
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
|
||||||
|
})).await;
|
||||||
|
|
||||||
|
// Second match — first_match must NOT appear again
|
||||||
|
let (_, result) = json_post(&app, "/matches/result", serde_json::json!({
|
||||||
|
"squad_id": "dummy", "opponent_name": "Bot",
|
||||||
|
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
|
||||||
|
})).await;
|
||||||
|
|
||||||
|
let unlocked = result["achievements_unlocked"].as_array().unwrap();
|
||||||
|
let dup = unlocked.iter().any(|a| a["id"] == "first_match");
|
||||||
|
assert!(!dup, "first_match should not unlock twice");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_achievement_grants_coins() {
|
||||||
|
let app = build_test_app().await;
|
||||||
|
auth(&app, "AchCoinPlayer").await;
|
||||||
|
|
||||||
|
let (_, club_before) = json_get(&app, "/club").await;
|
||||||
|
let coins_before = club_before["coins"].as_i64().unwrap_or(0);
|
||||||
|
|
||||||
|
// first_match achievement grants 500 coins on top of match reward
|
||||||
|
json_post(&app, "/matches/result", serde_json::json!({
|
||||||
|
"squad_id": "dummy", "opponent_name": "Bot",
|
||||||
|
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
|
||||||
|
})).await;
|
||||||
|
|
||||||
|
let (_, club_after) = json_get(&app, "/club").await;
|
||||||
|
let coins_after = club_after["coins"].as_i64().unwrap_or(0);
|
||||||
|
// Should have gained match coins + at least the first_match achievement reward (500)
|
||||||
|
assert!(coins_after > coins_before + 400, "expected achievement coin reward in total");
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user