Phase 17: profile level-up system
CI / Build, lint & test (push) Failing after 57s

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:
funman300
2026-06-25 17:41:13 -07:00
parent 3d50da3589
commit d97695d414
6 changed files with 257 additions and 6 deletions
+60 -2
View File
@@ -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<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
View File
@@ -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<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)
}