Phase 20: achievement system
CI / Build, lint & test (push) Failing after 1m19s

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:
funman300
2026-06-25 18:03:54 -07:00
parent f0dbabc409
commit 679f147c6a
15 changed files with 565 additions and 6 deletions
+9 -3
View File
@@ -2,6 +2,7 @@ use crate::middleware::{limit_concurrency, MakeRequestUuid};
use crate::{
config::Config,
db::Pool,
models::achievement::AchievementDefinition,
models::chemistry_style::{load_chemistry_styles, ChemistryStyle},
models::event::EventDefinition,
models::objective::{ObjectiveDefinition, ObjectiveType},
@@ -9,8 +10,8 @@ use crate::{
models::sbc::SbcDefinition,
routes,
services::{
card_db::CardDb, event::load_event_definitions, market,
objective::load_objective_definitions, pack::load_pack_definitions,
achievement::load_achievement_definitions, card_db::CardDb, event::load_event_definitions,
market, objective::load_objective_definitions, pack::load_pack_definitions,
sbc::load_sbc_definitions,
},
};
@@ -37,6 +38,7 @@ pub struct AppState {
pub sbc_defs: Arc<Vec<SbcDefinition>>,
pub event_defs: Arc<Vec<EventDefinition>>,
pub chem_styles: Arc<Vec<ChemistryStyle>>,
pub achievement_defs: Arc<Vec<AchievementDefinition>>,
}
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 event_defs = Arc::new(load_event_definitions(&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!(
"Loaded {} cards, {} packs, {} objectives, {} SBCs, {} events, {} chemistry styles",
"Loaded {} cards, {} packs, {} objectives, {} SBCs, {} events, {} chemistry styles, {} achievements",
card_db.cards.len(),
pack_defs.len(),
obj_defs.len(),
sbc_defs.len(),
event_defs.len(),
chem_styles.len(),
achievement_defs.len(),
);
let state = AppState {
@@ -65,6 +69,7 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
sbc_defs,
event_defs: event_defs.clone(),
chem_styles,
achievement_defs,
};
// 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", put(routes::settings::put_settings))
.route("/division", get(routes::division::get_division))
.route("/achievements", get(routes::achievements::get_achievements))
.route("/notifications", get(routes::notifications::get_notifications))
.route("/notifications/read-all", post(routes::notifications::mark_all_notifications_read))
.route("/notifications/:id/read", patch(routes::notifications::mark_notification_read))