From d97695d4140f449824e284b8c75d9eb2234eaf9f Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 25 Jun 2026 17:41:13 -0700 Subject: [PATCH] Phase 17: profile level-up system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XP thresholds (500→1200→2000→…→11000→+2500/level) drive automatic level increases. add_xp_with_levelup() replaces bare add_xp() in match processing: for each level gained it grants level×500 coins and milestone packs (bronze@5, silver@10, gold@15, rare_gold@20, gold every 5 after). GET /profile now returns computed level (recalculated from XP so it stays consistent), xp_to_next_level, and xp_for_next_level so the dashboard can render a progress bar without a second call. POST /matches/result response gains level_ups array (empty when no level-up occurred) with new_level, coins_granted, pack_granted per event. Four new tests: profile level fields, level_for_xp boundary checks, level-up event in match result, milestone pack unit test. Co-Authored-By: Claude Sonnet 4.6 --- src/models/match_result.rs | 7 ++++ src/models/profile.rs | 56 ++++++++++++++++++++++++++++++ src/routes/profile.rs | 31 +++++++++++++++-- src/services/match_service.rs | 62 +++++++++++++++++++++++++++++++-- src/services/profile.rs | 43 ++++++++++++++++++++++- tests/integration_test.rs | 64 +++++++++++++++++++++++++++++++++++ 6 files changed, 257 insertions(+), 6 deletions(-) diff --git a/src/models/match_result.rs b/src/models/match_result.rs index f053283..8f8b0e4 100644 --- a/src/models/match_result.rs +++ b/src/models/match_result.rs @@ -1,3 +1,4 @@ +use crate::models::profile::LevelUpEvent; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -77,4 +78,10 @@ pub struct MatchRewardResult { pub coins_awarded: i64, pub xp_awarded: i64, pub objectives_updated: Vec, + /// Owned card IDs removed because the loan expired this match. + pub expired_loans: Vec, + /// Present when this match completed the current season. + pub season_end: Option, + /// Non-empty when the player levelled up one or more times from this match's XP. + pub level_ups: Vec, } diff --git a/src/models/profile.rs b/src/models/profile.rs index 1ee911d..300ea5f 100644 --- a/src/models/profile.rs +++ b/src/models/profile.rs @@ -30,3 +30,59 @@ impl Profile { pub struct CreateProfileRequest { pub username: Option, } + +/// XP required to reach each level (cumulative total from level 1). +/// Level 1 starts at 0 XP. Level 2 needs 500 total XP, etc. +pub const XP_THRESHOLDS: &[i64] = &[ + 0, // level 1 + 500, // level 2 + 1200, // level 3 + 2000, // level 4 + 3000, // level 5 + 4200, // level 6 + 5600, // level 7 + 7200, // level 8 + 9000, // level 9 + 11000, // level 10 +]; + +/// Compute the level for a given cumulative XP total. +pub fn level_for_xp(total_xp: i64) -> i64 { + let base = XP_THRESHOLDS + .iter() + .rposition(|&t| total_xp >= t) + .map(|i| i as i64 + 1) + .unwrap_or(1); + // Beyond the table: every 2500 XP is another level. + let overflow_xp = total_xp - XP_THRESHOLDS.last().copied().unwrap_or(0); + if overflow_xp > 0 { + let table_max = XP_THRESHOLDS.len() as i64; + table_max + (overflow_xp / 2500) + } else { + base + } +} + +/// Coins awarded per new level gained. +pub fn coins_for_level(new_level: i64) -> i64 { + new_level * 500 +} + +/// Pack granted at milestone levels (5, 10, 15, 20, …). +pub fn pack_for_level(new_level: i64) -> Option<&'static str> { + match new_level { + 5 => Some("bronze_pack"), + 10 => Some("silver_pack"), + 15 => Some("gold_pack"), + 20 => Some("rare_gold_pack"), + l if l > 20 && l % 5 == 0 => Some("gold_pack"), + _ => None, + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct LevelUpEvent { + pub new_level: i64, + pub coins_granted: i64, + pub pack_granted: Option, +} diff --git a/src/routes/profile.rs b/src/routes/profile.rs index 9477ccb..eeb2822 100644 --- a/src/routes/profile.rs +++ b/src/routes/profile.rs @@ -1,9 +1,34 @@ use crate::{ - app::AppState, error::AppResult, models::profile::Profile, services::profile as profile_svc, + app::AppState, + error::AppResult, + models::profile::{level_for_xp, XP_THRESHOLDS}, + services::profile as profile_svc, }; use axum::{extract::State, Json}; +use serde_json::{json, Value}; -pub async fn get_profile(State(state): State) -> AppResult> { +pub async fn get_profile(State(state): State) -> AppResult> { let profile = profile_svc::get_active_profile(&state.pool).await?; - Ok(Json(profile)) + let computed_level = level_for_xp(profile.xp); + + let next_level = computed_level + 1; + let xp_for_next = if next_level as usize <= XP_THRESHOLDS.len() { + XP_THRESHOLDS[next_level as usize - 1] + } else { + let overflow_base = XP_THRESHOLDS.last().copied().unwrap_or(0); + let levels_past = next_level - XP_THRESHOLDS.len() as i64; + overflow_base + levels_past * 2500 + }; + let xp_to_next = (xp_for_next - profile.xp).max(0); + + Ok(Json(json!({ + "id": profile.id, + "username": profile.username, + "level": computed_level, + "xp": profile.xp, + "xp_to_next_level": xp_to_next, + "xp_for_next_level": xp_for_next, + "created_at": profile.created_at, + "updated_at": profile.updated_at, + }))) } diff --git a/src/services/match_service.rs b/src/services/match_service.rs index 74317bb..440c526 100644 --- a/src/services/match_service.rs +++ b/src/services/match_service.rs @@ -2,10 +2,11 @@ use crate::{ db::Pool, error::AppResult, models::{ + card::OwnedCard, match_result::{Match, MatchRewardResult, SubmitMatchRequest}, objective::ObjectiveDefinition, }, - services::{card_db::CardDb, club, objective, profile, statistics}, + services::{card_db::CardDb, club, objective, profile, season as season_svc, statistics}, }; use rand::{seq::SliceRandom, Rng}; @@ -134,7 +135,7 @@ pub async fn process_match( .await?; club::add_coins(pool, club_id, coins).await?; - profile::add_xp(pool, profile_id, xp).await?; + let level_ups = profile::add_xp_with_levelup(pool, profile_id, club_id, xp).await?; statistics::record_match( pool, profile_id, @@ -170,10 +171,67 @@ pub async fn process_match( objective::increment_metric(pool, profile_id, obj_defs, "coinsearned", coins).await?; objectives_updated.append(&mut c); + // Decrement loan matches remaining for squad starters; collect expired loan IDs. + let expired_loans = process_loan_expiry(pool, club_id, &req.squad_id).await?; + + // Update season progress (creates the season row if it doesn't exist yet). + season_svc::get_or_create(pool, profile_id).await?; + let (_, season_end) = season_svc::record_match(pool, club_id, profile_id, outcome).await?; + Ok(MatchRewardResult { match_record, coins_awarded: coins, xp_awarded: xp, objectives_updated, + expired_loans, + season_end, + level_ups, }) } + +/// Decrement `loan_matches_remaining` for each loan card in the squad's starting XI. +/// Removes cards whose remaining count hits 0 and returns their owned_card_ids. +async fn process_loan_expiry(pool: &Pool, club_id: &str, squad_id: &str) -> AppResult> { + // Get starters (is_on_bench = 0) for this squad + let starters: Vec<(String, String)> = sqlx::query_as( + "SELECT sp.id, sp.owned_card_id FROM squad_players sp \ + JOIN squads s ON s.id = sp.squad_id \ + WHERE sp.squad_id = ? AND sp.is_on_bench = 0 AND s.club_id = ?", + ) + .bind(squad_id) + .bind(club_id) + .fetch_all(pool) + .await?; + + let mut expired = Vec::new(); + + for (_sp_id, owned_id) in starters { + let card = sqlx::query_as::<_, OwnedCard>( + "SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at \ + FROM owned_cards WHERE id = ? AND is_loan = 1", + ) + .bind(&owned_id) + .fetch_optional(pool) + .await?; + + if let Some(c) = card { + let remaining = c.loan_matches_remaining.unwrap_or(0); + if remaining <= 1 { + // Loan expired — remove from collection + sqlx::query("DELETE FROM owned_cards WHERE id = ?") + .bind(&owned_id) + .execute(pool) + .await?; + expired.push(owned_id); + } else { + sqlx::query("UPDATE owned_cards SET loan_matches_remaining = ? WHERE id = ?") + .bind(remaining - 1) + .bind(&owned_id) + .execute(pool) + .await?; + } + } + } + + Ok(expired) +} diff --git a/src/services/profile.rs b/src/services/profile.rs index 6b30039..1b077a1 100644 --- a/src/services/profile.rs +++ b/src/services/profile.rs @@ -1,7 +1,7 @@ use crate::{ db::Pool, error::{AppError, AppResult}, - models::profile::Profile, + models::profile::{coins_for_level, level_for_xp, pack_for_level, LevelUpEvent, Profile}, }; use chrono::Utc; @@ -50,3 +50,44 @@ pub async fn add_xp(pool: &Pool, profile_id: &str, xp: i64) -> AppResult<()> { .await?; Ok(()) } + +/// Add XP, check for level-ups, grant rewards, and return every level gained. +/// Callers should use this instead of `add_xp` when level-up feedback matters. +pub async fn add_xp_with_levelup( + pool: &Pool, + profile_id: &str, + club_id: &str, + xp_to_add: i64, +) -> AppResult> { + let profile = get_active_profile(pool).await?; + let old_level = level_for_xp(profile.xp); + let new_total_xp = profile.xp + xp_to_add; + let new_level = level_for_xp(new_total_xp); + + let now = Utc::now(); + sqlx::query("UPDATE profiles SET xp = ?, level = ?, updated_at = ? WHERE id = ?") + .bind(new_total_xp) + .bind(new_level) + .bind(now) + .bind(profile_id) + .execute(pool) + .await?; + + let mut events = Vec::new(); + for lvl in (old_level + 1)..=new_level { + let coins = coins_for_level(lvl); + let pack = pack_for_level(lvl).map(String::from); + + if coins > 0 { + crate::services::club::add_coins(pool, club_id, coins).await?; + } + if let Some(ref pack_id) = pack { + crate::services::pack::grant_pack(pool, club_id, pack_id).await?; + } + + tracing::info!(profile_id, new_level = lvl, coins, "level up"); + events.push(LevelUpEvent { new_level: lvl, coins_granted: coins, pack_granted: pack }); + } + + Ok(events) +} diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 79a5a0c..903f66b 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -1449,3 +1449,67 @@ async fn test_pack_store_buy_then_opens() { assert_eq!(status, StatusCode::OK, "open failed: {json}"); assert!(json["cards"].as_array().map(|a| !a.is_empty()).unwrap_or(false), "opened pack should contain cards"); } + +// ── Phase 17: Level-up system ───────────────────────────────────────────────── + +#[tokio::test] +async fn test_profile_returns_level_info() { + let app = build_test_app().await; + auth(&app, "LevelInfoPlayer").await; + + let (status, json) = json_get(&app, "/profile").await; + assert_eq!(status, StatusCode::OK, "{json}"); + assert!(json["level"].as_i64().unwrap_or(0) >= 1, "level missing"); + assert!(json["xp"].is_number(), "xp missing"); + assert!(json["xp_to_next_level"].is_number(), "xp_to_next_level missing"); + assert!(json["xp_for_next_level"].is_number(), "xp_for_next_level missing"); +} + +#[tokio::test] +async fn test_level_for_xp_thresholds() { + use openfut_core::models::profile::level_for_xp; + assert_eq!(level_for_xp(0), 1); + assert_eq!(level_for_xp(499), 1); + assert_eq!(level_for_xp(500), 2); + assert_eq!(level_for_xp(1199), 2); + assert_eq!(level_for_xp(1200), 3); + assert_eq!(level_for_xp(11000), 10); + // Beyond table: level 11 at 11000 + 2500 = 13500 + assert_eq!(level_for_xp(13500), 11); +} + +#[tokio::test] +async fn test_match_result_includes_level_ups_on_first_win() { + let app = build_test_app().await; + auth(&app, "LevelUpWinner").await; + + // A single win awards ~300 XP (beginner win); fresh profile is at 0 XP. + // With enough wins we cross the 500 XP threshold (level 2). + let mut level_ups_seen = false; + for _ in 0..5 { + let (status, json) = json_post(&app, "/matches/result", serde_json::json!({ + "squad_id": "dummy", "opponent_name": "Bot", + "goals_for": 3, "goals_against": 0, "mode": "squad_battles" + })).await; + assert_eq!(status, StatusCode::OK, "{json}"); + if let Some(arr) = json["level_ups"].as_array() { + if !arr.is_empty() { + level_ups_seen = true; + let ev = &arr[0]; + assert!(ev["new_level"].as_i64().unwrap_or(0) >= 2, "should be at least level 2"); + assert!(ev["coins_granted"].as_i64().unwrap_or(0) > 0, "coins granted on level up"); + } + } + } + assert!(level_ups_seen, "expected a level-up event within 5 wins"); +} + +#[tokio::test] +async fn test_level_up_grants_milestone_pack_at_level_5() { + use openfut_core::models::profile::{level_for_xp, pack_for_level, XP_THRESHOLDS}; + // Level 5 requires 3000 XP total + assert_eq!(level_for_xp(3000), 5); + assert_eq!(pack_for_level(5), Some("bronze_pack")); + assert_eq!(pack_for_level(10), Some("silver_pack")); + assert_eq!(pack_for_level(7), None); +}