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
+86
View File
@@ -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");
}