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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::models::profile::LevelUpEvent;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -77,4 +78,10 @@ pub struct MatchRewardResult {
|
|||||||
pub coins_awarded: i64,
|
pub coins_awarded: i64,
|
||||||
pub xp_awarded: i64,
|
pub xp_awarded: i64,
|
||||||
pub objectives_updated: Vec<String>,
|
pub objectives_updated: Vec<String>,
|
||||||
|
/// Owned card IDs removed because the loan expired this match.
|
||||||
|
pub expired_loans: Vec<String>,
|
||||||
|
/// Present when this match completed the current season.
|
||||||
|
pub season_end: Option<crate::models::season::SeasonEndSummary>,
|
||||||
|
/// Non-empty when the player levelled up one or more times from this match's XP.
|
||||||
|
pub level_ups: Vec<LevelUpEvent>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,3 +30,59 @@ impl Profile {
|
|||||||
pub struct CreateProfileRequest {
|
pub struct CreateProfileRequest {
|
||||||
pub username: Option<String>,
|
pub username: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
}
|
||||||
|
|||||||
+28
-3
@@ -1,9 +1,34 @@
|
|||||||
use crate::{
|
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 axum::{extract::State, Json};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
pub async fn get_profile(State(state): State<AppState>) -> AppResult<Json<Profile>> {
|
pub async fn get_profile(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
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,
|
||||||
|
})))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ use crate::{
|
|||||||
db::Pool,
|
db::Pool,
|
||||||
error::AppResult,
|
error::AppResult,
|
||||||
models::{
|
models::{
|
||||||
|
card::OwnedCard,
|
||||||
match_result::{Match, MatchRewardResult, SubmitMatchRequest},
|
match_result::{Match, MatchRewardResult, SubmitMatchRequest},
|
||||||
objective::ObjectiveDefinition,
|
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};
|
use rand::{seq::SliceRandom, Rng};
|
||||||
|
|
||||||
@@ -134,7 +135,7 @@ pub async fn process_match(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
club::add_coins(pool, club_id, coins).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(
|
statistics::record_match(
|
||||||
pool,
|
pool,
|
||||||
profile_id,
|
profile_id,
|
||||||
@@ -170,10 +171,67 @@ pub async fn process_match(
|
|||||||
objective::increment_metric(pool, profile_id, obj_defs, "coinsearned", coins).await?;
|
objective::increment_metric(pool, profile_id, obj_defs, "coinsearned", coins).await?;
|
||||||
objectives_updated.append(&mut c);
|
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 {
|
Ok(MatchRewardResult {
|
||||||
match_record,
|
match_record,
|
||||||
coins_awarded: coins,
|
coins_awarded: coins,
|
||||||
xp_awarded: xp,
|
xp_awarded: xp,
|
||||||
objectives_updated,
|
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<Vec<String>> {
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|||||||
+42
-1
@@ -1,7 +1,7 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
db::Pool,
|
db::Pool,
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
models::profile::Profile,
|
models::profile::{coins_for_level, level_for_xp, pack_for_level, LevelUpEvent, Profile},
|
||||||
};
|
};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
|
||||||
@@ -50,3 +50,44 @@ pub async fn add_xp(pool: &Pool, profile_id: &str, xp: i64) -> AppResult<()> {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
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<Vec<LevelUpEvent>> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1449,3 +1449,67 @@ async fn test_pack_store_buy_then_opens() {
|
|||||||
assert_eq!(status, StatusCode::OK, "open failed: {json}");
|
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");
|
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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user