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:
@@ -1593,3 +1593,89 @@ async fn test_mark_single_notification_read() {
|
||||
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