From 679f147c6a6a3e1047afbb2ec7fdaf76329c1961 Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 25 Jun 2026 18:03:54 -0700 Subject: [PATCH] Phase 20: achievement system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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>) 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 --- data/achievements/achievements.json | 182 +++++++++++++++++++++++ migrations/0010_achievements.sql | 7 + src/app.rs | 12 +- src/models/achievement.rs | 20 +++ src/models/match_result.rs | 4 +- src/models/mod.rs | 1 + src/routes/achievements.rs | 21 +++ src/routes/matches.rs | 2 +- src/routes/mod.rs | 1 + src/routes/packs.rs | 3 + src/routes/sbc.rs | 6 + src/services/achievement.rs | 215 ++++++++++++++++++++++++++++ src/services/match_service.rs | 10 +- src/services/mod.rs | 1 + tests/integration_test.rs | 86 +++++++++++ 15 files changed, 565 insertions(+), 6 deletions(-) create mode 100644 data/achievements/achievements.json create mode 100644 migrations/0010_achievements.sql create mode 100644 src/models/achievement.rs create mode 100644 src/routes/achievements.rs create mode 100644 src/services/achievement.rs diff --git a/data/achievements/achievements.json b/data/achievements/achievements.json new file mode 100644 index 0000000..7561978 --- /dev/null +++ b/data/achievements/achievements.json @@ -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" + } +] diff --git a/migrations/0010_achievements.sql b/migrations/0010_achievements.sql new file mode 100644 index 0000000..4698f2e --- /dev/null +++ b/migrations/0010_achievements.sql @@ -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); diff --git a/src/app.rs b/src/app.rs index 26105a0..14ea7b4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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>, pub event_defs: Arc>, pub chem_styles: Arc>, + pub achievement_defs: Arc>, } pub async fn build(pool: Pool, cfg: Config) -> Result { @@ -46,15 +48,17 @@ pub async fn build(pool: Pool, cfg: Config) -> Result { 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 { 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 { .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)) diff --git a/src/models/achievement.rs b/src/models/achievement.rs new file mode 100644 index 0000000..232d5bb --- /dev/null +++ b/src/models/achievement.rs @@ -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, +} diff --git a/src/models/match_result.rs b/src/models/match_result.rs index 8f8b0e4..3062f0c 100644 --- a/src/models/match_result.rs +++ b/src/models/match_result.rs @@ -1,4 +1,4 @@ -use crate::models::profile::LevelUpEvent; +use crate::models::{achievement::AchievementDefinition, profile::LevelUpEvent}; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -84,4 +84,6 @@ pub struct MatchRewardResult { pub season_end: Option, /// Non-empty when the player levelled up one or more times from this match's XP. pub level_ups: Vec, + /// Achievements unlocked as a result of this match. + pub achievements_unlocked: Vec, } diff --git a/src/models/mod.rs b/src/models/mod.rs index 6a18b9e..45069a7 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,3 +1,4 @@ +pub mod achievement; pub mod card; pub mod chemistry_style; pub mod notification; diff --git a/src/routes/achievements.rs b/src/routes/achievements.rs new file mode 100644 index 0000000..c48093a --- /dev/null +++ b/src/routes/achievements.rs @@ -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) -> AppResult> { + 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(), + }))) +} diff --git a/src/routes/matches.rs b/src/routes/matches.rs index 0a592ca..e266670 100644 --- a/src/routes/matches.rs +++ b/src/routes/matches.rs @@ -69,7 +69,7 @@ pub async fn post_match_result( let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; 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?; Ok(Json(result)) diff --git a/src/routes/mod.rs b/src/routes/mod.rs index aef7f7d..10e87c3 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,3 +1,4 @@ +pub mod achievements; pub mod auth; pub mod cards; pub mod club; diff --git a/src/routes/packs.rs b/src/routes/packs.rs index 42ec4a5..002168d 100644 --- a/src/routes/packs.rs +++ b/src/routes/packs.rs @@ -139,6 +139,9 @@ pub async fn post_open_pack( statistics::increment_packs_opened(&state.pool, &profile.id).await?; objective::increment_metric(&state.pool, &profile.id, &state.obj_defs, "packsopened", 1) .await?; + let _ = crate::services::achievement::check_and_unlock( + &state.pool, &state.achievement_defs, &profile.id, &club.id, + ).await; Ok(Json(result)) } diff --git a/src/routes/sbc.rs b/src/routes/sbc.rs index 07b032f..2690246 100644 --- a/src/routes/sbc.rs +++ b/src/routes/sbc.rs @@ -45,5 +45,11 @@ pub async fn post_sbc_submit( ) .await?; + if result.passed { + let _ = crate::services::achievement::check_and_unlock( + &state.pool, &state.achievement_defs, &profile.id, &club.id, + ).await; + } + Ok(Json(result)) } diff --git a/src/services/achievement.rs b/src/services/achievement.rs new file mode 100644 index 0000000..7690e4d --- /dev/null +++ b/src/services/achievement.rs @@ -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> { + 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 = + 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 { + 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> { + if defs.is_empty() { + return Ok(vec![]); + } + + // Load already-unlocked IDs in one query + let unlocked_ids: Vec = + 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 = Default::default(); + let mut newly_unlocked: Vec = 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> { + let earned: Vec = 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()) +} diff --git a/src/services/match_service.rs b/src/services/match_service.rs index d4b5ec0..c1b0ce1 100644 --- a/src/services/match_service.rs +++ b/src/services/match_service.rs @@ -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 { 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, }) } diff --git a/src/services/mod.rs b/src/services/mod.rs index 3065a33..5248858 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1,3 +1,4 @@ +pub mod achievement; pub mod card_db; pub mod club; pub mod notification; diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 2208d22..2dcc9c8 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -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"); +}