From 695bc44af0551b26c6bf51068b32c8985619efa3 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Fri, 4 Sep 2026 22:47:49 +0200 Subject: [PATCH 1/2] fix(module): stop XP awards at the level cap --- module/src/combat/death.rs | 7 ++ module/src/creatures/pet_progression.rs | 4 +- module/src/xp.rs | 141 ++++++++++++++++++++++-- 3 files changed, 144 insertions(+), 8 deletions(-) diff --git a/module/src/combat/death.rs b/module/src/combat/death.rs index 54f06f0a1..93944d165 100644 --- a/module/src/combat/death.rs +++ b/module/src/combat/death.rs @@ -1319,6 +1319,13 @@ mod lethality_tests { assert!(death.contains("killer_guid: killer.unwrap_or(0)")); } + #[test] + fn a_capped_warlock_can_still_earn_a_soul_shard_from_a_non_grey_kill() { + assert!(crate::xp::xp_for_kill(60, 60) > 0); + let body = crate::test_scan::code_of(include_str!("death.rs"), "fn award_tag_rewards("); + assert!(body.contains("xp_for_kill(target.level, reward_level) > 0")); + } + #[test] fn active_duel_lethal_hits_bypass_the_player_death_chokepoint() { let body = crate::test_scan::code_of(include_str!("death.rs"), "pub(crate) fn apply_hit("); diff --git a/module/src/creatures/pet_progression.rs b/module/src/creatures/pet_progression.rs index d364660a6..8561c2c3f 100644 --- a/module/src/creatures/pet_progression.rs +++ b/module/src/creatures/pet_progression.rs @@ -326,7 +326,9 @@ mod tests { } #[test] - fn pet_award_is_gated_on_the_owner_gray_clamp() { + fn capped_owner_still_allows_an_under_cap_pet_to_earn_xp_from_a_non_grey_kill() { + assert!(crate::xp::xp_for_kill(60, 60) > 0); + assert!(crate::xp::xp_for_kill(60, 59) > 0); let body = crate::test_scan::code_of( include_str!("pet_progression.rs"), "pub(crate) fn award_hunter_pet_kill_progression(", diff --git a/module/src/xp.rs b/module/src/xp.rs index ac051b093..acab55e8e 100644 --- a/module/src/xp.rs +++ b/module/src/xp.rs @@ -13,6 +13,8 @@ use crate::game_config; use crate::game_world_entity; use crate::WorldEntity; +const LEVEL_CAP: u32 = 60; + // =========================================================================================== // XP / level-up event tables [event] — public, RLS-restricted to the recipient // =========================================================================================== @@ -79,7 +81,7 @@ pub fn xp_to_next_level(level: u32) -> u32 { 101000, 106300, 111800, 117500, 123200, 129100, 135100, 141200, 147500, 153900, 160400, 167100, 173900, 180800, 187900, 195000, 202300, 209800, ]; - if level == 0 || level >= 60 { + if level == 0 || level >= LEVEL_CAP { return 0; // level 0 / at the cap → no next-level threshold } XP_PER_LEVEL[(level - 1) as usize] @@ -109,6 +111,53 @@ pub(crate) fn xp_for_kill(mob_level: u32, player_level: u32) -> u32 { } } +#[derive(Debug, Eq, PartialEq)] +struct XpProgression { + level: u32, + xp: u32, + next_level_xp: u32, +} + +/// Spend one award across the level curve without narrowing the XP bank until it is below the next +/// threshold. Reaching the level cap discards the remainder because a capped Character has no XP bar. +fn progression_after_award( + level: u32, + current_xp: u32, + next_level_xp: u32, + amount: u64, +) -> XpProgression { + if level >= LEVEL_CAP { + return XpProgression { + level, + xp: 0, + next_level_xp: 0, + }; + } + + let mut level = level; + let mut bank = current_xp as u64 + amount; + let mut next = next_level_xp; + while next > 0 && bank >= next as u64 { + bank -= next as u64; + level += 1; + if level >= LEVEL_CAP { + return XpProgression { + level, + xp: 0, + next_level_xp: 0, + }; + } + next = xp_to_next_level(level); + } + + XpProgression { + level, + // The loop leaves less than one level threshold, whose largest value is well inside u32. + xp: bank.min(u32::MAX as u64) as u32, + next_level_xp: next, + } +} + /// Vanilla kill-XP multiplier by creature `rank` (cmangos classification: 0=normal, 1=elite, /// 2=rare-elite, 3=boss/world-boss, 4=rare). Elites (1/2/3) pay 2× — matching vanilla's `if IsElite() /// xp *= 2`; normal + rare pay 1×. An unknown rank → 1× (never amplify an unrecognized value). Pure. @@ -276,6 +325,12 @@ pub(crate) fn award_xp( let Some(mut p) = entities.guid().find(attacker_guid) else { return; }; + // A capped Character can still make a non-grey kill for rewards which share `xp_for_kill` + // (Hunter pet progression and Drain Soul shards). Stop only the Character XP path here, before + // rested XP is drained or an XP event is emitted. + if p.level >= LEVEL_CAP { + return; + } // Elite kills pay 2× (vanilla `if IsElite() xp *= 2`), then the realm xp_rate — applied HERE, before // the rested bonus below, so the pool drains by the same rated amount it grants (no double-dip). // GROUP SPLIT: the recipient's own level-based award divided evenly by the in-range member @@ -334,6 +389,11 @@ pub(crate) fn award_xp( /// drift. No-op for `amount == 0`. Does NOT emit a `game_xp_event` (the kill path owns the /// `SMSG_LOG_XPGAIN` source-guid line; quest XP has no killed unit). [entity] pub(crate) fn grant_xp(ctx: &ReducerContext, p: &mut WorldEntity, amount: u32) { + if p.level >= LEVEL_CAP { + p.xp = 0; + p.next_level_xp = 0; + return; + } if amount == 0 { return; } @@ -342,11 +402,14 @@ pub(crate) fn grant_xp(ctx: &ReducerContext, p: &mut WorldEntity, amount: u32) { // `award_xp`, quest XP in `quest.rs`). Applied HERE, the one chokepoint both sources share, so a // single multiply can never drift between them. u64 math to avoid a u32*u32 overflow. Missing // config row ⇒ 10000 (1×) — byte-identical to before this feature existed. - let amount = ((amount as u64 * crate::gm::xprate_bp(ctx) as u64) / 10_000) as u32; + let amount = (amount as u64 * crate::gm::xprate_bp(ctx) as u64) / 10_000; if amount == 0 { return; } - p.xp += amount; + let progression = progression_after_award(p.level, p.xp, p.next_level_xp, amount); + // Keep the in-hand entity coherent while synchronous level-up hooks run. The caller persists it + // after the ding loop, as before. + p.xp = progression.xp; // Race/class drive the real per-level curve (importer P3); they live packed in unit_bytes_0 // (race | class<<8 | gender<<16 | power<<24). max_health_for/max_power_for fall back to the flat // placeholder when the curve isn't loaded. @@ -354,9 +417,8 @@ pub(crate) fn grant_xp(ctx: &ReducerContext, p: &mut WorldEntity, amount: u32) { let class = p.class(); let mut leveled = false; // Ding loop: spend XP across as many thresholds as it crosses (a big award can be 2+ levels). - while p.next_level_xp > 0 && p.xp >= p.next_level_xp { + while p.level < progression.level { leveled = true; - p.xp -= p.next_level_xp; p.level += 1; // Lift every combat (weapon + Defense) skill line's cap to the new level*5: this // is the ONLY place the cap moves mid-session (login reconciles pre-existing rows separately). @@ -420,6 +482,7 @@ pub(crate) fn grant_xp(ctx: &ReducerContext, p: &mut WorldEntity, amount: u32) { }, ); } + p.next_level_xp = progression.next_level_xp; if leveled { // Sheet AP/damage-range are level-derived and only ever move via `recompute_sheet`, // which re-fetches the row by guid — so the ding's level/stat write must be PERSISTED first @@ -446,8 +509,8 @@ pub(crate) fn grant_xp(ctx: &ReducerContext, p: &mut WorldEntity, amount: u32) { mod tests { use super::{ accrue_rested_on_login, explore_base_xp, explore_xp, max_health_for_level, - rank_xp_multiplier, rest_bonus, rest_pool_after, split_kill_xp, xp_for_kill, - xp_to_next_level, REST_EIGHT_HOURS_MICROS, + progression_after_award, rank_xp_multiplier, rest_bonus, rest_pool_after, split_kill_xp, + xp_for_kill, xp_to_next_level, XpProgression, REST_EIGHT_HOURS_MICROS, }; #[test] @@ -471,6 +534,7 @@ mod tests { // Base branch (mob at or below the player's level): 5*mob + 45. assert_eq!(xp_for_kill(1, 1), 50); assert_eq!(xp_for_kill(10, 10), 95); + assert_eq!(xp_for_kill(60, 59), 414); assert_eq!(xp_for_kill(60, 60), 345); // GREY clamp boundary: exactly 5 levels below still pays; 6 below is grey (0). assert_eq!(xp_for_kill(5, 10), 70); // 5 below → 5*5+45, still pays @@ -495,6 +559,69 @@ mod tests { assert_eq!(split_kill_xp(100, 5), 20); } + #[test] + fn xp_progression_discards_awards_and_bank_at_the_level_cap() { + assert_eq!( + progression_after_award(60, u32::MAX, 0, u32::MAX as u64), + XpProgression { + level: 60, + xp: 0, + next_level_xp: 0, + } + ); + } + + #[test] + fn capped_kill_stops_before_rest_or_event_side_effects() { + let body = crate::test_scan::code_of(include_str!("xp.rs"), "pub(crate) fn award_xp("); + let cap = body + .find("if p.level >= LEVEL_CAP") + .expect("award_xp keeps its level-cap gate"); + let rested = body + .find("let chars = ctx.db.game_character()") + .expect("award_xp keeps its rested XP branch"); + let event = body + .find("ctx.db.game_xp_event().insert") + .expect("award_xp keeps its XP event"); + assert!(cap < rested && rested < event); + } + + #[test] + fn xp_progression_discards_a_large_remainder_when_the_award_reaches_the_cap() { + assert_eq!( + progression_after_award(59, 209_799, 209_800, u32::MAX as u64), + XpProgression { + level: 60, + xp: 0, + next_level_xp: 0, + } + ); + } + + #[test] + fn xp_progression_keeps_the_exact_remainder_across_multiple_levels() { + assert_eq!( + progression_after_award(1, 350, 400, 1_000), + XpProgression { + level: 3, + xp: 50, + next_level_xp: 1_400, + } + ); + } + + #[test] + fn zero_threshold_below_the_cap_remains_inert() { + assert_eq!( + progression_after_award(10, 5, 0, 10), + XpProgression { + level: 10, + xp: 15, + next_level_xp: 0, + } + ); + } + #[test] fn rest_bonus_doubles_until_the_pool_drains() { // Full bonus when the pool covers the kill: 50 XP kill, pool 1000 → 100 granted, 50 drained. From 9c701abd37db1d25fd10f3d0eb521f705ede0dd4 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Fri, 4 Sep 2026 23:12:20 +0200 Subject: [PATCH 2/2] test(xp): verify capped awards through the Module runtime --- module/src/combat/death.rs | 7 --- module/src/creatures/pet_progression.rs | 4 +- module/src/xp.rs | 26 ++------- module/tests/xp_cap.rs | 77 +++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 30 deletions(-) create mode 100644 module/tests/xp_cap.rs diff --git a/module/src/combat/death.rs b/module/src/combat/death.rs index 93944d165..54f06f0a1 100644 --- a/module/src/combat/death.rs +++ b/module/src/combat/death.rs @@ -1319,13 +1319,6 @@ mod lethality_tests { assert!(death.contains("killer_guid: killer.unwrap_or(0)")); } - #[test] - fn a_capped_warlock_can_still_earn_a_soul_shard_from_a_non_grey_kill() { - assert!(crate::xp::xp_for_kill(60, 60) > 0); - let body = crate::test_scan::code_of(include_str!("death.rs"), "fn award_tag_rewards("); - assert!(body.contains("xp_for_kill(target.level, reward_level) > 0")); - } - #[test] fn active_duel_lethal_hits_bypass_the_player_death_chokepoint() { let body = crate::test_scan::code_of(include_str!("death.rs"), "pub(crate) fn apply_hit("); diff --git a/module/src/creatures/pet_progression.rs b/module/src/creatures/pet_progression.rs index 8561c2c3f..d364660a6 100644 --- a/module/src/creatures/pet_progression.rs +++ b/module/src/creatures/pet_progression.rs @@ -326,9 +326,7 @@ mod tests { } #[test] - fn capped_owner_still_allows_an_under_cap_pet_to_earn_xp_from_a_non_grey_kill() { - assert!(crate::xp::xp_for_kill(60, 60) > 0); - assert!(crate::xp::xp_for_kill(60, 59) > 0); + fn pet_award_is_gated_on_the_owner_gray_clamp() { let body = crate::test_scan::code_of( include_str!("pet_progression.rs"), "pub(crate) fn award_hunter_pet_kill_progression(", diff --git a/module/src/xp.rs b/module/src/xp.rs index acab55e8e..ad54d9ae7 100644 --- a/module/src/xp.rs +++ b/module/src/xp.rs @@ -152,7 +152,7 @@ fn progression_after_award( XpProgression { level, - // The loop leaves less than one level threshold, whose largest value is well inside u32. + // A valid threshold leaves less than one level. A missing threshold keeps the bank bounded. xp: bank.min(u32::MAX as u64) as u32, next_level_xp: next, } @@ -310,9 +310,9 @@ pub(crate) fn accrue_rested_on_login( /// Award `attacker_guid` the XP for killing `killed_guid` (a `mob_level` creature), applying any /// level-ups (max-health recalc + full heal) and the rested-XP bonus (double kill XP while the -/// character's rested pool has XP, draining it). Emits a `game_xp_event` (skipped for a gray 0-XP kill — -/// vanilla shows nothing) and one `game_levelup_event` per level gained. No-op if the attacker has left -/// the world. Called from the combat killing-blow branch for player attackers. +/// character's rested pool has XP, draining it). Emits a `game_xp_event` and one `game_levelup_event` +/// per level gained. Grey kills, capped Characters and attackers who have left the world receive +/// no award or event. Called from the combat killing-blow branch for Character attackers. pub(crate) fn award_xp( ctx: &ReducerContext, attacker_guid: u64, @@ -386,7 +386,8 @@ pub(crate) fn award_xp( /// `game_levelup_event` per level gained). Mutates `p` in place — the CALLER persists it (so a single /// `update` covers the XP write plus any dings). This is the single home of the leveling math, shared /// by the kill award ([`award_xp`]) and quest turn-in rewards ([`crate::quest`]) so they can never -/// drift. No-op for `amount == 0`. Does NOT emit a `game_xp_event` (the kill path owns the +/// drift. Capped Characters retain zero XP. Below the cap, `amount == 0` is a no-op. +/// Does NOT emit a `game_xp_event` (the kill path owns the /// `SMSG_LOG_XPGAIN` source-guid line; quest XP has no killed unit). [entity] pub(crate) fn grant_xp(ctx: &ReducerContext, p: &mut WorldEntity, amount: u32) { if p.level >= LEVEL_CAP { @@ -571,21 +572,6 @@ mod tests { ); } - #[test] - fn capped_kill_stops_before_rest_or_event_side_effects() { - let body = crate::test_scan::code_of(include_str!("xp.rs"), "pub(crate) fn award_xp("); - let cap = body - .find("if p.level >= LEVEL_CAP") - .expect("award_xp keeps its level-cap gate"); - let rested = body - .find("let chars = ctx.db.game_character()") - .expect("award_xp keeps its rested XP branch"); - let event = body - .find("ctx.db.game_xp_event().insert") - .expect("award_xp keeps its XP event"); - assert!(cap < rested && rested < event); - } - #[test] fn xp_progression_discards_a_large_remainder_when_the_award_reaches_the_cap() { assert_eq!( diff --git a/module/tests/xp_cap.rs b/module/tests/xp_cap.rs new file mode 100644 index 000000000..f75a0d9fd --- /dev/null +++ b/module/tests/xp_cap.rs @@ -0,0 +1,77 @@ +//! Character XP awards through the Runtime Script Host and creature death. + +mod support; + +use support::Standalone; + +/// Builds and publishes only to the test's private Standalone process. +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn the_level_cap_discards_excess_xp_and_preserves_rested_xp_on_later_kills() { + let standalone = Standalone::start("xp-cap"); + standalone.publish_module(); + standalone.assert_call("claim_operator", &[]); + standalone.assert_call("debug_seed_scenario_fixtures", &[]); + standalone.assert_call("debug_spawn_player_entity", &["1"]); + standalone.assert_call("debug_set_level", &["1", "59"]); + standalone.assert_sql("UPDATE game_world_entity SET xp = 209799 WHERE guid = 1"); + + grant_xp(&standalone, 1_000_000); + assert_capped(&standalone); + let levels = standalone.query_rows("SELECT new_level FROM game_levelup_event"); + assert_eq!(levels.len(), 1); + assert_eq!(levels[0]["new_level"], "60"); + + grant_xp(&standalone, 25); + assert_capped(&standalone); + assert_eq!( + standalone + .query_rows("SELECT new_level FROM game_levelup_event") + .len(), + 1, + "a later award must not produce another level-up" + ); + + standalone.assert_sql("UPDATE game_character SET rested_xp = 1000 WHERE guid = 1"); + standalone.assert_call("debug_spawn_at_feet", &["1", "51000", "1.0"]); + let creature = standalone + .query_rows("SELECT guid FROM game_world_entity WHERE entry = 51000") + .pop() + .expect("the fixture creature exists")["guid"] + .clone(); + standalone.assert_sql(&format!( + "UPDATE game_world_entity SET level = 60 WHERE guid = {creature}" + )); + standalone.assert_sql(&format!( + "INSERT INTO game_creature_quest_tap (creature_guid, character_guid) \ + VALUES ({creature}, 1)" + )); + standalone.assert_call("debug_kill_nearest", &["1", "51000"]); + + assert_capped(&standalone); + let character = standalone.query_rows("SELECT rested_xp FROM game_character WHERE guid = 1"); + assert_eq!(character[0]["rested_xp"], "1000"); + assert!(standalone + .query_rows("SELECT id FROM game_xp_event") + .is_empty()); + let corpse = standalone.query_rows(&format!( + "SELECT dead FROM game_world_entity WHERE guid = {creature}" + )); + assert_eq!(corpse[0]["dead"], "true"); +} + +fn grant_xp(standalone: &Standalone, amount: u32) { + let source = serde_json::to_string(&format!("grant_xp(event.actor, {amount})")).unwrap(); + standalone.assert_call( + "debug_run_runtime_script", + &["\"fixture.xp-cap\"", "\"on_kill\"", &source, "1", "0"], + ); +} + +fn assert_capped(standalone: &Standalone) { + let entity = standalone + .query_rows("SELECT level, xp, next_level_xp FROM game_world_entity WHERE guid = 1"); + assert_eq!(entity[0]["level"], "60"); + assert_eq!(entity[0]["xp"], "0"); + assert_eq!(entity[0]["next_level_xp"], "0"); +}