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 -1
View File
@@ -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,
})
}