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
+7
View File
@@ -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<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>,
}
+56
View File
@@ -30,3 +30,59 @@ impl Profile {
pub struct CreateProfileRequest {
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>,
}