From 9c85ce9dda489f617998a901a55c2d1c7c4d2c7f Mon Sep 17 00:00:00 2001 From: T3 Code Date: Mon, 7 Sep 2026 17:59:55 +0000 Subject: [PATCH 1/4] feat(items): apply remaining random property stats --- gateway/src/codec/entity.rs | 8 + gateway/src/codec/tests.rs | 37 ++- gateway/src/codec/values.rs | 45 ++-- gateway/src/stdb/armor.rs | 181 ++++++++----- gateway/src/stdb/reads/account.rs | 5 + gateway/src/stdb/subscriptions.rs | 28 +- gateway/src/stdb/views.rs | 1 + gateway/src/stdb/world_store.rs | 4 + gateway/src/world/handlers/char.rs | 1 + gateway/src/world/store.rs | 4 + gateway/src/world/tests.rs | 1 + module/src/combat/folds.rs | 47 +++- module/src/combat/tables.rs | 119 +++++++-- module/src/creatures/cycle/ctx.rs | 16 +- module/src/creatures/cycle/harness.rs | 9 +- module/src/items/ops.rs | 82 ++++-- module/src/items/properties.rs | 45 +++- module/src/items/rules.rs | 87 ++++++- module/src/skill.rs | 10 +- module/src/spell/cast/targeting.rs | 2 +- module/src/spell/effects.rs | 46 ++-- module/src/spell/math.rs | 64 +++-- module/src/spell/tests.rs | 7 + module/src/tripwires.rs | 4 +- module/tests/random_property_stats.rs | 359 ++++++++++++++++++++++++++ 25 files changed, 994 insertions(+), 218 deletions(-) create mode 100644 module/tests/random_property_stats.rs diff --git a/gateway/src/codec/entity.rs b/gateway/src/codec/entity.rs index 6b1c9b810..67be5fb46 100644 --- a/gateway/src/codec/entity.rs +++ b/gateway/src/codec/entity.rs @@ -89,6 +89,8 @@ pub struct EntityView { /// gear-folded value so the operator sees real worn armor on relog (auras self-correct via the on_aura /// relay). Matches the module's combat `effective_armor`, so the sheet equals the mitigation value. pub effective_armor: u32, + /// Holy, Fire, Nature, Frost, Shadow and Arcane resistance in descriptor order. + pub magic_resistances: [u32; 6], /// Persisted hearthstone / bind-point from `game_character.home_*`. Zero-initialised for /// creature rows (which never build a `SMSG_BINDPOINTUPDATE`). pub home_map: u32, @@ -362,6 +364,12 @@ pub fn build_create_object( // them the instant they insert. Part of the full CREATE mask (OBJECT_FIELD_TYPE belongs here), // so no dirty_reset concern. .set_unit_normal_resistance(entity.effective_armor as i32) + .set_unit_holy_resistance(entity.magic_resistances[0] as i32) + .set_unit_fire_resistance(entity.magic_resistances[1] as i32) + .set_unit_nature_resistance(entity.magic_resistances[2] as i32) + .set_unit_frost_resistance(entity.magic_resistances[3] as i32) + .set_unit_shadow_resistance(entity.magic_resistances[4] as i32) + .set_unit_arcane_resistance(entity.magic_resistances[5] as i32) // Weapon damage for the character sheet's Main-Hand panel. gtker's UpdatePlayer has no // default for these float/int fields, so leaving them unset makes the 5875 client read // uninitialized floats → "-1.#IND" (NaN) in the damage/DPS tooltip. Send the derived values. diff --git a/gateway/src/codec/tests.rs b/gateway/src/codec/tests.rs index c42d1de4c..fdb56088d 100644 --- a/gateway/src/codec/tests.rs +++ b/gateway/src/codec/tests.rs @@ -231,13 +231,13 @@ fn health_values_update_is_health_only_no_object_type() { #[test] fn resistance_values_is_unit_only_no_object_type() { - // Live armor (Demon Skin / gear): the EFFECTIVE-armor relay must be a Unit VALUES mask carrying ONLY - // UNIT_FIELD_RESISTANCES[0] (descriptor index 155 → bit 27 of mask block 4 → word 0x08000000), never + // Live armor and magic resistance must use a Unit VALUES mask carrying only + // UNIT_FIELD_RESISTANCES[0..=6], never // re-sending OBJECT_FIELD_TYPE (bit 2, value 0x09 = OBJECT|UNIT) whose re-send strips the PLAYER bit // → 5875 null+0x110 crash. Same dirty_reset discipline as build_health_values. let guid = (0xF130u64 << 48) | (620u64 << 24) | 1; - let armor = 0x1234u32; // 4660 — a recognizable LE pattern distinct from any mask word - let msg = build_resistance_values(guid, armor); + let resistances = [0x1234u32, 1, 2, 3, 4, 5, 6]; + let msg = build_resistance_values(guid, resistances); match &msg.objects[0] { Object::Values { guid1, mask1 } => { assert_eq!(guid1.guid(), guid); @@ -250,23 +250,28 @@ fn resistance_values_is_unit_only_no_object_type() { } let mut buf = Vec::new(); msg.write_unencrypted_server(&mut buf).unwrap(); - // The update mask is the LAST thing written: block_count=5 (descriptor 155 lives in block 4), then 5 - // mask words (only block 4 set = 0x08000000 = bit 27), then the armor value LE. A leaked + // The update mask is the last thing written: descriptors 155..=161 span blocks 4 and 5. A leaked // OBJECT_FIELD_TYPE would set bit 2 in block 0 and add a 0x09 value word ahead of the armor word, so // this exact-tail assertion fails in that case (guarding the crash regression). assert!( buf.ends_with(&[ - 0x05, // block_count = 5 (the mask spans blocks 0..=4) + 0x06, // block_count = 6 (the mask spans blocks 0..=5) 0, 0, 0, 0, // block 0 (NO OBJECT_FIELD_TYPE bit 2) 0, 0, 0, 0, // block 1 0, 0, 0, 0, // block 2 0, 0, 0, 0, // block 3 - 0x00, 0x00, 0x00, - 0x08, // block 4 = bit 27 only (descriptor 155 = UNIT_FIELD_RESISTANCES[0]) + 0x00, 0x00, 0x00, 0xf8, // block 4 = descriptors 155..=159 + 0x03, 0x00, 0x00, 0x00, // block 5 = descriptors 160..=161 0x34, 0x12, 0x00, 0x00, // armor value 0x1234 LE + 1, 0, 0, 0, // holy + 2, 0, 0, 0, // fire + 3, 0, 0, 0, // nature + 4, 0, 0, 0, // frost + 5, 0, 0, 0, // shadow + 6, 0, 0, 0, // arcane ]), - "resistance VALUES must carry only index 155 (no OBJECT_FIELD_TYPE); tail was {:02x?}", - &buf[buf.len().saturating_sub(28)..] + "resistance VALUES must carry only indices 155..=161; tail was {:02x?}", + &buf[buf.len().saturating_sub(56)..] ); // Belt-and-suspenders: the OBJECT_FIELD_TYPE value word (0x09 = OBJECT|UNIT) must be absent anywhere. let contains = |needle: &[u8]| buf.windows(needle.len()).any(|w| w == needle); @@ -1098,7 +1103,9 @@ fn warrior_create_carries_skills_and_serializes() { // without Fury the client throws SpellBookFrame.lua:342 "invalid spell slot". The existing // CREATE tests only pattern-match, so SERIALIZE here to actually run the writer over the new // PLAYER_SKILL_INFO words (a malformed skill mask would otherwise slip past the suite). - let msg = build_create_object(&warrior_entity(), CreateKind::SelfPlayer, &[], &[]).unwrap(); + let mut entity = warrior_entity(); + entity.magic_resistances = [1, 2, 3, 4, 5, 6]; + let msg = build_create_object(&entity, CreateKind::SelfPlayer, &[], &[]).unwrap(); let mut buf = Vec::new(); msg.write_unencrypted_server(&mut buf).unwrap(); assert!(!buf.is_empty()); @@ -1123,6 +1130,12 @@ fn warrior_create_carries_skills_and_serializes() { // EFFECTIVE armor (UNIT_FIELD_RESISTANCES[0]) is read from `entity.effective_armor` (base + // worn gear, folded at the self-login call site; the fixture has no gear → effective == base). assert_eq!(p.unit_normal_resistance(), Some(e.effective_armor as i32)); + assert_eq!(p.unit_holy_resistance(), Some(1)); + assert_eq!(p.unit_fire_resistance(), Some(2)); + assert_eq!(p.unit_nature_resistance(), Some(3)); + assert_eq!(p.unit_frost_resistance(), Some(4)); + assert_eq!(p.unit_shadow_resistance(), Some(5)); + assert_eq!(p.unit_arcane_resistance(), Some(6)); } other => panic!("expected a Player CreateObject2, got {other:?}"), } diff --git a/gateway/src/codec/values.rs b/gateway/src/codec/values.rs index 1fd9963ce..9be38966e 100644 --- a/gateway/src/codec/values.rs +++ b/gateway/src/codec/values.rs @@ -303,24 +303,18 @@ pub fn build_max_vitals_values( }) } -/// Build a VALUES partial-update carrying `UNIT_FIELD_RESISTANCES[0]` — the "Armor" slot (descriptor -/// index 155, read by the paperdoll via `UnitArmor`) — so the character sheet's Armor readout moves LIVE -/// when an `A_MOD_RESISTANCE(armor)` aura applies/expires (e.g. Demon Skin) or armor gear is -/// equipped/unequipped. `armor` is the gateway-computed EFFECTIVE armor (base + armor auras + gear; see -/// `stdb::armor::effective_armor`), which equals the module's combat `effective_armor` — so the sheet -/// shows exactly what physical mitigation uses. UNIT mask (armor is a UNIT field, like max-vitals) + the -/// same `dirty_reset` discipline as `build_health_values` so the wire carries ONLY index 155 and never -/// re-sends OBJECT_FIELD_TYPE (the 5875 null+0x110 crash trap). -/// Build a VALUES partial-update carrying armor WITH its green "(+N)" split: -/// `UNIT_FIELD_RESISTANCES[0]` = the effective TOTAL and `PLAYER_FIELD_RESISTANCEBUFFMODSPOSITIVE[0]` -/// (index 1187, PLAYER block) = the positive AURA portion the paperdoll colors green. -/// TWO wrong guesses preceded this (live-found white armor both rounds): the buff-mods fields are -/// NOT unit fields in 1.12 — unit-space 162 is UNIT_FIELD_BASE_MANA — they live in the PLAYER -/// block, where gtker HAS a typed int setter. Player mask (self-only push; only your own sheet -/// reads it), same dirty_reset discipline as every partial VALUES. -pub fn build_armor_values(guid: u64, total: u32, pos_buff: u32) -> SMSG_UPDATE_OBJECT { +/// Build a self-only VALUES update for all seven effective resistances. Armor also carries its positive +/// aura split in `PLAYER_FIELD_RESISTANCEBUFFMODSPOSITIVE[0]` so the paperdoll colors that term green. +/// This uses the same `dirty_reset` path as every partial VALUES update. +pub fn build_armor_values(guid: u64, total: [u32; 7], pos_buff: u32) -> SMSG_UPDATE_OBJECT { player_values(guid, |p| { - p.set_unit_normal_resistance(total as i32); + p.set_unit_normal_resistance(total[0] as i32); + p.set_unit_holy_resistance(total[1] as i32); + p.set_unit_fire_resistance(total[2] as i32); + p.set_unit_nature_resistance(total[3] as i32); + p.set_unit_frost_resistance(total[4] as i32); + p.set_unit_shadow_resistance(total[5] as i32); + p.set_unit_arcane_resistance(total[6] as i32); p.set_player_field_resistancebuffmodspositive(pos_buff as i32); }) } @@ -396,9 +390,15 @@ pub fn build_sheet_stats_values(guid: u64, s: &SheetStatsValues) -> SMSG_UPDATE_ }) } -pub fn build_resistance_values(guid: u64, armor: u32) -> SMSG_UPDATE_OBJECT { +pub fn build_resistance_values(guid: u64, total: [u32; 7]) -> SMSG_UPDATE_OBJECT { unit_values(guid, |unit| { - unit.set_unit_normal_resistance(armor as i32); + unit.set_unit_normal_resistance(total[0] as i32); + unit.set_unit_holy_resistance(total[1] as i32); + unit.set_unit_fire_resistance(total[2] as i32); + unit.set_unit_nature_resistance(total[3] as i32); + unit.set_unit_frost_resistance(total[4] as i32); + unit.set_unit_shadow_resistance(total[5] as i32); + unit.set_unit_arcane_resistance(total[6] as i32); }) } @@ -640,8 +640,11 @@ mod lint_tests { ("power", build_power_values(g, 0, 55)), ("target", build_target_values(g, 0xF130_0000_0000_0001)), ("max_vitals", build_max_vitals_values(g, 100, 0, 200)), - ("armor", build_armor_values(g, 60, 20)), - ("resistance", build_resistance_values(g, 40)), + ("armor", build_armor_values(g, [60, 0, 0, 0, 0, 0, 0], 20)), + ( + "resistance", + build_resistance_values(g, [40, 0, 0, 0, 0, 0, 0]), + ), ("coinage", build_coinage_values(g, 900)), ("ammo", build_player_ammo_id_values(g, 2512)), ( diff --git a/gateway/src/stdb/armor.rs b/gateway/src/stdb/armor.rs index 8d09145f2..4dc7f7699 100644 --- a/gateway/src/stdb/armor.rs +++ b/gateway/src/stdb/armor.rs @@ -1,5 +1,5 @@ -//! Character-sheet armor from the coordinator's entity, aura, item and catalogue caches. -//! The fold mirrors Module combat armor, including applied enchantments and Random Properties. +//! Character-sheet resistances from the coordinator's entity, aura, item and catalogue caches. +//! The fold mirrors Module combat resistance, including applied enchantments and Random Properties. //! Other character-sheet values come directly from the Module's derived entity fields. use super::bindings::*; @@ -9,9 +9,16 @@ use std::collections::HashMap; /// Aura kind `A_MOD_RESISTANCE` — a direct resistance/armor bonus (e.g. Demon Skin). Mirrors /// `module::spell::taxonomy::A_MOD_RESISTANCE = 0xA1`. const A_MOD_RESISTANCE: u8 = 0xA1; -/// `RESIST_ARMOR` school bit. The aura's `eff_p0` is a school MASK, so test `& bit != 0` (NOT `== 1`), -/// matching `module::spell::math::resistance_bonus`. Mirrors `module::spell::taxonomy::RESIST_ARMOR = 0x01`. -const RESIST_ARMOR_MASK: u32 = 0x01; +const RESISTANCE_BITS: [u32; 7] = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40]; +const RESISTANCE_KINDS: [u8; 7] = [ + lyracore_shared::item_property::ARMOR, + lyracore_shared::item_property::HOLY_RESISTANCE, + lyracore_shared::item_property::FIRE_RESISTANCE, + lyracore_shared::item_property::NATURE_RESISTANCE, + lyracore_shared::item_property::FROST_RESISTANCE, + lyracore_shared::item_property::SHADOW_RESISTANCE, + lyracore_shared::item_property::ARCANE_RESISTANCE, +]; /// Inclusive upper bound of the equipment region (`equip_slot::END == TABARD == 18`). Only worn pieces in /// slots 0..=18 carry armor; bags/backpack/bank don't count. Mirrors `items::equipped_stat_bonus`. const EQUIP_REGION_END: u8 = 18; @@ -20,14 +27,24 @@ const EQUIP_REGION_END: u8 = 18; /// frozen `eff_p0` school mask overlaps `RESIST_ARMOR` (bit 0); else 0. Mirrors the module's /// `resistance_bonus` predicate + `stacked_amount`: `stacks` is floored at 1 so a legacy 0-stack row still /// counts ONCE, and a negative `amount × stacks` (Sunder Armor) subtracts. Pure → unit-tested. -fn aura_armor_contribution(eff_kind: u8, eff_p0: i32, amount: i32, stacks: u8) -> i32 { - if eff_kind == A_MOD_RESISTANCE && (eff_p0 as u32 & RESIST_ARMOR_MASK) != 0 { +fn aura_resistance_contribution( + eff_kind: u8, + eff_p0: i32, + amount: i32, + stacks: u8, + school_bit: u32, +) -> i32 { + if eff_kind == A_MOD_RESISTANCE && (eff_p0 as u32 & school_bit) != 0 { amount * (stacks.max(1) as i32) } else { 0 } } +fn aura_armor_contribution(eff_kind: u8, eff_p0: i32, amount: i32, stacks: u8) -> i32 { + aura_resistance_contribution(eff_kind, eff_p0, amount, stacks, RESISTANCE_BITS[0]) +} + /// One equipped item's armor contribution: its template `stat_armor`, or 0 if the item is outside the /// equipment region (`slot > 18`) or BROKEN (`max_durability > 0 && durability == 0`). Mirrors the /// module's `equipped_stat_bonus(Armor)` (slot gate + `item_is_broken`). A no-durability item @@ -98,71 +115,102 @@ pub(crate) fn aura_armor_positive(db: &RemoteTables, guid: u64) -> u32 { .sum::() as u32 } -/// The EFFECTIVE armor for `guid`, folded from the connection's subscription cache `db`: -/// `max(0, base + Σ armor-aura(amount×stacks) + Σ equipped-gear stat_armor)`. Reproduces -/// `module::combat::effective_armor` term-for-term, so the character-sheet readout EQUALS the armor -/// combat mitigates with. The aura term is naturally 0 on a cache not subscribed to `game_aura` (the -/// coordinator's, used at CREATE — the on_aura relay re-pushes the moment an aura inserts); the -/// per-player cache (relays) carries all three tables. A guid with no entity row → base 0 → returns 0. -pub(crate) fn effective_armor(db: &RemoteTables, guid: u64) -> u32 { - let base = db +/// Effective armor followed by Holy, Fire, Nature, Frost, Shadow and Arcane resistance. +/// The order matches `UNIT_FIELD_RESISTANCES[0..=6]`. +pub(crate) fn effective_resistances(db: &RemoteTables, guid: u64) -> [u32; 7] { + let base_armor = db .game_world_entity() .guid() .find(&guid) .map(|e| e.armor as i64) .unwrap_or(0); - let aura_sum: i64 = db - .game_aura() - .iter() - .filter(|a| a.target_guid == guid) - .map(|a| aura_armor_contribution(a.eff_kind, a.eff_p0, a.amount, a.stacks) as i64) - .sum(); + let mut aura = [0i64; 7]; + for row in db.game_aura().iter().filter(|a| a.target_guid == guid) { + for (index, school_bit) in RESISTANCE_BITS.into_iter().enumerate() { + aura[index] += i64::from(aura_resistance_contribution( + row.eff_kind, + row.eff_p0, + row.amount, + row.stacks, + school_bit, + )); + } + } // The SDK exposes no non-unique enchantment index. Read the catalogue once per projection. - let mut enchantments = HashMap::::new(); - for effect in db.game_item_enchantment().iter().filter(|effect| { - effect.enchant_id != 0 && effect.kind == lyracore_shared::item_property::ARMOR - }) { - let total = enchantments.entry(effect.enchant_id).or_default(); + let mut enchantments = HashMap::<(u32, u8), i32>::new(); + for effect in db + .game_item_enchantment() + .iter() + .filter(|effect| effect.enchant_id != 0 && RESISTANCE_KINDS.contains(&effect.kind)) + { + let total = enchantments + .entry((effect.enchant_id, effect.kind)) + .or_default(); *total = total.saturating_add(effect.amount); } - let enchant_armor = |id| enchantments.get(&id).copied().unwrap_or(0); + let enchant_stat = |id, kind| enchantments.get(&(id, kind)).copied().unwrap_or(0); let templates = db.game_item_template(); - let gear_sum: i32 = db + let mut gear = [0i32; 7]; + for item in db .game_item_instance() .iter() .filter(|i| i.owner_guid == guid && i.slot <= EQUIP_REGION_END) - .map(|i| { - templates - .entry() - .find(&i.entry) - .map(|t| { - if t.max_durability > 0 && i.durability == 0 { - return 0; - } - let mut armor = t.stat_armor.saturating_add(enchant_armor(i.enchant_id)); - if i.random_property_id != 0 { - if let Some(property) = db - .game_item_random_property() - .property_id() - .find(&i.random_property_id) - { - let bonus = [ - property.enchant_id_1, - property.enchant_id_2, - property.enchant_id_3, - ] - .into_iter() - .map(&enchant_armor) - .fold(0i32, i32::saturating_add); - armor = armor.saturating_add(bonus); - } - } - gear_armor_contribution(i.slot, armor, t.max_durability, i.durability) - }) - .unwrap_or(0) // a missing template join never poisons the sum (matches the module) - }) - .fold(0i32, i32::saturating_add); - (base + aura_sum + i64::from(gear_sum)).max(0) as u32 + { + let Some(tmpl) = templates.entry().find(&item.entry) else { + continue; + }; + if tmpl.max_durability > 0 && item.durability == 0 { + continue; + } + let template = [ + tmpl.stat_armor, + tmpl.holy_res, + tmpl.fire_res, + tmpl.nature_res, + tmpl.frost_res, + tmpl.shadow_res, + tmpl.arcane_res, + ]; + let property = db + .game_item_random_property() + .property_id() + .find(&item.random_property_id); + for index in 0..RESISTANCE_KINDS.len() { + let kind = RESISTANCE_KINDS[index]; + let mut amount = template[index].saturating_add(enchant_stat(item.enchant_id, kind)); + if let Some(property) = &property { + let property_amount = [ + property.enchant_id_1, + property.enchant_id_2, + property.enchant_id_3, + ] + .into_iter() + .map(|id| enchant_stat(id, kind)) + .fold(0i32, i32::saturating_add); + amount = amount.saturating_add(property_amount); + } + let amount = if index == 0 { + gear_armor_contribution(item.slot, amount, tmpl.max_durability, item.durability) + } else { + amount + }; + gear[index] = gear[index].saturating_add(amount); + } + } + std::array::from_fn(|index| { + let base = if index == 0 { base_armor } else { 0 }; + (base + aura[index] + i64::from(gear[index])).clamp(0, i64::from(u32::MAX)) as u32 + }) +} + +pub(crate) fn effective_armor(db: &RemoteTables, guid: u64) -> u32 { + effective_resistances(db, guid)[0] +} + +pub(crate) fn effective_magic_resistances(db: &RemoteTables, guid: u64) -> [u32; 6] { + effective_resistances(db, guid)[1..] + .try_into() + .expect("the magic resistance slice has six schools") } #[cfg(test)] @@ -171,7 +219,7 @@ mod tests { #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] - fn armor_projection_adds_property_effects_only_while_the_item_is_worn_and_unbroken() { + fn resistance_projection_adds_property_effects_only_while_the_item_is_worn_and_unbroken() { use crate::accept::BlockingTaskCapacity; use crate::config::GatewayConfig; use crate::durable_test_support::{poll_until, Standalone, POLL_TIMEOUT}; @@ -183,9 +231,9 @@ mod tests { shard.assert_call("debug_seed_scenario_fixtures", &[]); shard.assert_call("debug_spawn_player_entity", &["1"]); shard.assert_sql("DELETE FROM game_item_instance WHERE owner_guid = 1"); - shard.assert_sql("UPDATE game_item_template SET stat_armor = 19 WHERE entry = 5090050"); + shard.assert_sql("UPDATE game_item_template SET stat_armor = 19, holy_res = 1, fire_res = 2, nature_res = 3, frost_res = 4, shadow_res = 5, arcane_res = 6 WHERE entry = 5090050"); shard.assert_sql("INSERT INTO game_item_random_property (property_id,enchant_id_1,enchant_id_2,enchant_id_3,suffix) VALUES (5090101,5090103,0,0,'of the Fixture')"); - shard.assert_sql("INSERT INTO game_item_enchantment (id,enchant_id,effect_index,kind,amount,spell_id,school_mask) VALUES (1303066368,5090103,0,14,2,0,0),(1303066369,5090103,1,14,3,0,0),(1303066624,5090104,0,14,11,0,0)"); + shard.assert_sql("INSERT INTO game_item_enchantment (id,enchant_id,effect_index,kind,amount,spell_id,school_mask) VALUES (1303066368,5090103,0,14,2,0,0),(1303066369,5090103,1,14,3,0,0),(1303066370,5090103,2,8,10,0,2),(1303066371,5090103,3,9,20,0,4),(1303066372,5090103,4,10,30,0,8),(1303066373,5090103,5,11,40,0,16),(1303066374,5090103,6,12,50,0,32),(1303066375,5090103,7,13,60,0,64),(1303066624,5090104,0,14,11,0,0),(1303066625,5090104,1,9,7,0,4)"); shard.assert_call("debug_grant_item", &["1", "5090050", "1"]); shard.assert_sql("UPDATE game_item_instance SET random_property_id = 5090101, enchant_id = 5090104 WHERE owner_guid = 1"); let base: u32 = shard.query_rows("SELECT armor FROM game_world_entity WHERE guid = 1")[0] @@ -204,10 +252,17 @@ mod tests { let runtime = tokio::runtime::Runtime::new().unwrap(); let coordinator = runtime.block_on(Coordinator::connect(&cfg)).unwrap(); assert_eq!(coordinator.effective_armor(1), base); + assert_eq!(coordinator.effective_magic_resistances(1), [0; 6]); shard.assert_call("debug_equip_item", &["1", "23"]); assert!(poll_until(POLL_TIMEOUT, || coordinator.effective_armor(1) == base + 35)); + assert!(poll_until(POLL_TIMEOUT, || { + coordinator.effective_magic_resistances(1) == [11, 29, 33, 44, 55, 66] + })); shard.assert_sql("UPDATE game_item_instance SET durability = 0 WHERE owner_guid = 1"); assert!(poll_until(POLL_TIMEOUT, || coordinator.effective_armor(1) == base)); + assert!(poll_until(POLL_TIMEOUT, || { + coordinator.effective_magic_resistances(1) == [0; 6] + })); } #[test] diff --git a/gateway/src/stdb/reads/account.rs b/gateway/src/stdb/reads/account.rs index b80f5ec57..f125dc52d 100644 --- a/gateway/src/stdb/reads/account.rs +++ b/gateway/src/stdb/reads/account.rs @@ -196,6 +196,11 @@ impl Coordinator { super::super::armor::effective_armor(&guard.conn.db, guid) } + pub fn effective_magic_resistances(&self, guid: u64) -> [u32; 6] { + let guard = self.0.coord(); + super::super::armor::effective_magic_resistances(&guard.conn.db, guid) + } + /// The live entity's `max_health` from the privileged cache — 0 if not in world. Feeds the /// fall-damage flavor line; the module applies the authoritative damage itself. pub fn entity_max_health(&self, guid: u64) -> u32 { diff --git a/gateway/src/stdb/subscriptions.rs b/gateway/src/stdb/subscriptions.rs index c2ba200d7..afff5e2c9 100644 --- a/gateway/src/stdb/subscriptions.rs +++ b/gateway/src/stdb/subscriptions.rs @@ -1449,35 +1449,25 @@ pub(crate) fn aura_sync( let (opcode, body) = codec::build_values_update_raw(target_guid, &mask); Outbound::Raw { opcode, body } } -// Live ARMOR on the character sheet (the operator's Demon Skin bug): a player's own -// `A_MOD_RESISTANCE(armor)` aura applying or expiring must push `UNIT_FIELD_RESISTANCES[0]` — the -// module keeps `e.armor` at BASE (combat folds the effective value on demand), so the sheet never -// moves without this relay. Recompute the EFFECTIVE armor from this connection's CURRENT cache -// (base + armor auras + gear — `effective_armor` reads the post-change set, so apply AND expire -// both land the right value, exactly like `run_speed_packet`). Returns `None` unless `changed` is a -// self armor aura, so ordinary buffs/debuffs don't spam the opcode. Self-scoped: the sheet shows -// only your own armor, so no peer relay is needed. +// A self resistance aura applies or expires after the entity CREATE. Recompute all seven effective +// resistances from the coordinator's post-change cache and push the complete paperdoll group. pub(crate) fn armor_packet( coord: &Coordinator, changed: &Aura, self_guid: u64, ) -> Option { const A_MOD_RESISTANCE: u8 = 0xA1; // taxonomy A_MOD_RESISTANCE - const RESIST_ARMOR_MASK: u32 = 0x01; // taxonomy RESIST_ARMOR bit (eff_p0 is a school MASK) + const RESISTANCE_MASK: u32 = 0x7f; if changed.target_guid != self_guid || changed.eff_kind != A_MOD_RESISTANCE - || (changed.eff_p0 as u32 & RESIST_ARMOR_MASK) == 0 + || (changed.eff_p0 as u32 & RESISTANCE_MASK) == 0 { return None; } - // The entity row (the BASE armor term) lives only on the coordinator now — the - // per-player connection no longer subscribes `game_world_entity` at all. The - // coordinator's cache also carries the auras, the item instances and the item - // templates, so this fold is complete there in a way it never was on the player - // connection (which lost `game_item_template` to the connection reclaim). + // The coordinator owns the entity, aura, item-instance and item-catalogue caches needed by this fold. let guard = coord.0.coord(); let db = &guard.conn.db; - let eff = super::armor::effective_armor(db, self_guid); + let eff = super::armor::effective_resistances(db, self_guid); // Carry the positive AURA portion alongside the total so the paperdoll renders the // green "(+N)" (Devotion Aura showed as plain white armor). Raw path — the positive // field has no gtker setter. Login self-corrects through this same relay (the SDK @@ -3333,9 +3323,9 @@ fn append_final_item_slots( } fn append_item_armor_and_sheet(db: &RemoteTables, self_guid: u64, out: &mut Vec) { - let armor = super::armor::effective_armor(db, self_guid); + let resistances = super::armor::effective_resistances(db, self_guid); out.push(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( - Box::new(codec::build_resistance_values(self_guid, armor)), + Box::new(codec::build_resistance_values(self_guid, resistances)), ))); if let Some(stats) = super::armor::sheet_stats(db, self_guid) { out.push(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( @@ -3520,7 +3510,7 @@ impl Coordinator { { let guard = self.0.coord(); let db = &guard.conn.db; - let eff = super::armor::effective_armor(db, self_guid); + let eff = super::armor::effective_resistances(db, self_guid); let pos = super::armor::aura_armor_positive(db, self_guid); let sheet = super::armor::sheet_stats(db, self_guid); drop(guard); diff --git a/gateway/src/stdb/views.rs b/gateway/src/stdb/views.rs index f041a8c9b..2355427dc 100644 --- a/gateway/src/stdb/views.rs +++ b/gateway/src/stdb/views.rs @@ -173,6 +173,7 @@ pub(crate) fn entity_view(e: WorldEntity, durable_zone: u32) -> crate::codec::En // your sheet only shows your own armor). The self-login CREATE overrides this with the gear-folded // value (`Coordinator::effective_armor`); the relays then keep it live. effective_armor: e.armor, + magic_resistances: [0; 6], owner_guid: e.owner_guid, // Home fields are not on the world-entity row; caller fills them in from game_character when // building the self-login sequence. Peer/creature views never need these, so 0 is fine. diff --git a/gateway/src/stdb/world_store.rs b/gateway/src/stdb/world_store.rs index 55e54dbca..3e97ef08c 100644 --- a/gateway/src/stdb/world_store.rs +++ b/gateway/src/stdb/world_store.rs @@ -492,6 +492,10 @@ impl WorldStore for Coordinator { self.effective_armor(guid) } + fn effective_magic_resistances(&self, guid: u64) -> [u32; 6] { + self.effective_magic_resistances(guid) + } + fn pending_system_messages(&self, self_guid: u64) -> Vec { self.system_messages_for(self_guid) } diff --git a/gateway/src/world/handlers/char.rs b/gateway/src/world/handlers/char.rs index ead23a66f..b79171b3f 100644 --- a/gateway/src/world/handlers/char.rs +++ b/gateway/src/world/handlers/char.rs @@ -102,6 +102,7 @@ fn enter_world( // self-correct via the on_aura relay; combat mitigation is unchanged (the module still folds its // own effective_armor on demand — this only feeds the display descriptor). entity.effective_armor = store.effective_armor(character_guid); + entity.magic_resistances = store.effective_magic_resistances(character_guid); log::info!( "world: entering world guid={character_guid} -> entity at map {} ({:.1},{:.1},{:.1}); subscribing + sending login sequence + self-spawn", entity.map_id, entity.x, entity.y, entity.z diff --git a/gateway/src/world/store.rs b/gateway/src/world/store.rs index 7f0daa2b6..ac0c3ef4a 100644 --- a/gateway/src/world/store.rs +++ b/gateway/src/world/store.rs @@ -487,6 +487,10 @@ pub trait WorldStore: /// folded here (they self-correct via the on_aura relay). Mirrors the module's combat `effective_armor`. fn effective_armor(&self, guid: u64) -> u32; + fn effective_magic_resistances(&self, _guid: u64) -> [u32; 6] { + [0; 6] + } + /// Standing-derived reaction gate: does this NPC refuse `player_guid` its /// interaction WINDOW? Rep-bar factions refuse at Unfriendly-or-below standing; bar-less /// factions fall back to the FactionTemplate hostility masks. Fail-open on missing data. diff --git a/gateway/src/world/tests.rs b/gateway/src/world/tests.rs index 259335df5..80dc2bf15 100644 --- a/gateway/src/world/tests.rs +++ b/gateway/src/world/tests.rs @@ -4292,6 +4292,7 @@ fn warrior_entity() -> codec::EntityView { npc_flags: 0, // a player is not an NPC owner_guid: 0, // not a summon effective_armor: 40, // agility 20 * 2 (base; no gear in the fixture → effective == base) + magic_resistances: [0; 6], // No hearthstone bind recorded for the test entity; fall back to login position. home_map: 0, home_zone: 0, diff --git a/module/src/combat/folds.rs b/module/src/combat/folds.rs index 4dcbc5278..d65410b37 100644 --- a/module/src/combat/folds.rs +++ b/module/src/combat/folds.rs @@ -20,14 +20,18 @@ use crate::{ // folds::*`/`death::*`/`engage::*`/`swing::*`) so every symbol resolves the same as before the split. use super::*; -/// Fold a defender combat-rating aura (`A_MOD_COMBAT(field)`, e.g. a talent) onto an attack-table band: -/// `base + combat_field_bonus(target, field)`, clamped to `[0, 10000]` so the band never goes negative or -/// past the full roll line. `bonus == 0` (no such aura) → exactly `base` (baseline-safe). NOTE: unlike the -/// attacker AP/crit/hit folds (which sum aura + EQUIPPED-gear ratings), these defender ratings are -/// aura-only for now — no gear sources parry/dodge/block rating yet, so only `combat_field_bonus`. [entity] -fn fold_band(ctx: &ReducerContext, target_guid: u64, base: u32, field: u8) -> u32 { - (base as i32 + crate::spell::combat_field_bonus(ctx, target_guid, field)).clamp(0, 10_000) - as u32 +/// Fold aura and equipped-item rating onto one attack-table band. +fn fold_band( + ctx: &ReducerContext, + target_guid: u64, + base: u32, + field: u8, + gear: crate::items::EquipStat, +) -> u32 { + (i64::from(base) + + i64::from(crate::spell::combat_field_bonus(ctx, target_guid, field)) + + i64::from(crate::items::equipped_stat_bonus(ctx, target_guid, gear))) + .clamp(0, 10_000) as u32 } /// `target`'s EFFECTIVE dodge chance (bp) vs a swing with skill difference `sd`: the agility-derived @@ -39,6 +43,7 @@ pub fn effective_dodge_bp(ctx: &ReducerContext, target: &WorldEntity, sd: u32) - target.guid, dodge_chance_bp(effective_agility(ctx, target), sd), crate::spell::COMBAT_DODGE, + crate::items::EquipStat::Dodge, ) } @@ -51,6 +56,7 @@ pub fn effective_parry_bp(ctx: &ReducerContext, target: &WorldEntity, sd: u32) - target.guid, parry_chance_bp(sd), crate::spell::COMBAT_PARRY, + crate::items::EquipStat::Parry, ) } @@ -71,6 +77,7 @@ pub fn effective_block_bp( target.guid, block_chance_bp(sd), crate::spell::COMBAT_BLOCK, + crate::items::EquipStat::Block, ) } else { 0 // no shield → no block band (a block aura is inert without a shield) @@ -231,7 +238,7 @@ pub fn effective_armor(ctx: &ReducerContext, e: &WorldEntity) -> u32 { effective_stat( e.armor as i32, crate::spell::resistance_bonus(ctx, e.guid, crate::spell::RESIST_ARMOR), - crate::items::equipped_stat_bonus(ctx, e.guid, crate::items::EquipStat::Armor), + 0, ) } @@ -353,14 +360,20 @@ fn weapon_profile_in_slot( if crate::items::item_is_broken(&tmpl, &inst) { return None; // a BROKEN weapon (has durability and it hit 0) gives no damage } + let bonus = + crate::items::item_stat_bonus(ctx, &inst, &tmpl, crate::items::EquipStat::WeaponDamage); Some(( - tmpl.damage_min.round() as u32, - tmpl.damage_max.round() as u32, + add_weapon_damage(tmpl.damage_min, bonus), + add_weapon_damage(tmpl.damage_max, bonus), tmpl.delay_ms, tmpl.subclass, )) } +fn add_weapon_damage(base: f32, bonus: i32) -> u32 { + (base.round() as i64 + i64::from(bonus)).clamp(0, i64::from(u32::MAX)) as u32 +} + /// The player's equipped main-hand WEAPON damage profile `(damage_min, damage_max, delay_ms)`, or /// `None` when unarmed (see [`weapon_profile_in_slot`] for the full "no weapon here" rule) → the /// swing falls back to the unarmed range. [entity] @@ -721,6 +734,18 @@ pub(crate) fn roll_money(ctx: &ReducerContext, min: u32, max: u32) -> u32 { min + ctx.random::() % (max - min + 1) } +#[cfg(test)] +mod weapon_damage_tests { + use super::add_weapon_damage; + + #[test] + fn flat_weapon_damage_applies_after_template_rounding_and_clamps() { + assert_eq!(add_weapon_damage(10.6, 20), 31); + assert_eq!(add_weapon_damage(10.4, -20), 0); + assert_eq!(add_weapon_damage(u32::MAX as f32, i32::MAX), u32::MAX); + } +} + #[cfg(test)] mod seal_tests { use super::seal_swing_holy; diff --git a/module/src/combat/tables.rs b/module/src/combat/tables.rs index 9cd750a6b..7512958d1 100644 --- a/module/src/combat/tables.rs +++ b/module/src/combat/tables.rs @@ -291,7 +291,7 @@ pub fn apply_offhand_penalty(min: u32, max: u32) -> (u32, u32) { /// from the unit, which are semantically distinct) so the "base + aura + gear, floored at 0" shape lives /// in ONE place. Pure — unit-tested. pub(crate) fn effective_stat(base: i32, aura: i32, gear: i32) -> u32 { - (base + aura + gear).max(0) as u32 + (i64::from(base) + i64::from(aura) + i64::from(gear)).clamp(0, i64::from(u32::MAX)) as u32 } /// Apply a crit-rating bonus to the flat `CRIT_BP` base: `CRIT_BP + bonus`, clamped to `[0, 10000]` @@ -545,6 +545,34 @@ pub fn regen_power( level: u32, in_combat: bool, mana_paused: bool, +) -> u32 { + regen_power_with_flat( + power_type, + current, + max, + spirit, + level, + PowerRegenTick { + in_combat, + mana_paused, + mana_per_five: 0, + }, + ) +} + +struct PowerRegenTick { + in_combat: bool, + mana_paused: bool, + mana_per_five: i32, +} + +fn regen_power_with_flat( + power_type: u8, + current: u32, + max: u32, + spirit: u32, + level: u32, + tick: PowerRegenTick, ) -> u32 { use lyracore_shared::packing::power_type as pt; if max == 0 { @@ -552,15 +580,20 @@ pub fn regen_power( } match power_type { pt::MANA => { - if mana_paused { + if tick.mana_paused { current } else { - (current + mana_regen_per_tick(spirit, level)).min(max) + apply_regen( + current, + max, + mana_regen_per_tick(spirit, level), + tick.mana_per_five, + ) } } - pt::ENERGY => (current + ENERGY_TICK).min(max), + pt::ENERGY => current.saturating_add(ENERGY_TICK).min(max), pt::RAGE => { - if in_combat { + if tick.in_combat { current } else { current.saturating_sub(RAGE_DECAY_PER_TICK) @@ -576,10 +609,32 @@ pub fn regen_power( /// `max == 0` yields no change. A spirit-0 unit (every creature today) still heals `level + 1` per /// tick — a small, sane amount that keeps the prior "recovers a bit out of combat" behavior. pub fn regen_health(current: u32, max: u32, spirit: u32, level: u32) -> u32 { + regen_health_with_flat(current, max, spirit, level, 0) +} + +fn regen_health_with_flat( + current: u32, + max: u32, + spirit: u32, + level: u32, + health_per_five: i32, +) -> u32 { if max == 0 { return current; } - (current + health_regen_per_tick(spirit, level)).min(max) + apply_regen( + current, + max, + health_regen_per_tick(spirit, level), + health_per_five, + ) +} + +/// Add a points-per-five-seconds item contribution to this four-second regeneration tick. +fn apply_regen(current: u32, max: u32, base_tick: u32, per_five: i32) -> u32 { + let flat_tick = i128::from(per_five) * 4 / 5; + let tick = (i128::from(base_tick) + flat_tick).clamp(0, i128::from(u32::MAX)) as u32; + current.saturating_add(tick).min(max) } /// Entity-aware POWER-regen wrapper: reads `spirit`/`level`/power bar off the row so the `tick_creatures` @@ -587,25 +642,33 @@ pub fn regen_health(current: u32, max: u32, spirit: u32, level: u32) -> u32 { /// and FSR derivation live here, not inline in the tick). Power type is byte 3 of `unit_bytes_0`. /// FSR gate: mana regen is paused when `now_ms < e.mana_regen_paused_until_ms` (stamped on every /// mana-spend in the cast path). Returns the new power value (the caller writes it back only if it changed). -pub fn regen_entity_power(e: &WorldEntity, in_combat: bool, now_ms: u64) -> u32 { +pub fn regen_entity_power( + e: &WorldEntity, + in_combat: bool, + now_ms: u64, + mana_per_five: i32, +) -> u32 { let power_type = (e.unit_bytes_0 >> 24) as u8; let mana_paused = now_ms < e.mana_regen_paused_until_ms; - regen_power( + regen_power_with_flat( power_type, e.power, e.max_power, e.spirit, e.level, - in_combat, - mana_paused, + PowerRegenTick { + in_combat, + mana_paused, + mana_per_five, + }, ) } /// Entity-aware HEALTH-regen wrapper: reads `spirit`/`level` off the row so the `tick_creatures` health /// pass passes ONE entity. Returns the new health value (the caller writes it back). Callers gate on /// `health < max_health && !in_combat` before calling, as before. -pub fn regen_entity_health(e: &WorldEntity) -> u32 { - regen_health(e.health, e.max_health, e.spirit, e.level) +pub fn regen_entity_health(e: &WorldEntity, health_per_five: i32) -> u32 { + regen_health_with_flat(e.health, e.max_health, e.spirit, e.level, health_per_five) } /// Per-tick PARTIAL health regen DURING COMBAT: `combat_regen_pct%` of the normal out-of-combat @@ -1262,11 +1325,11 @@ mod tests { 5_000, ); // Strictly inside the window → paused, no regen. - assert_eq!(regen_entity_power(&mage, false, 4_999), 100); + assert_eq!(regen_entity_power(&mage, false, 4_999, 0), 100); // Straddling the boundary: `now_ms == paused_until_ms` is NOT `<`, so the window has just cleared. - assert_eq!(regen_entity_power(&mage, false, 5_000), 100 + 26); + assert_eq!(regen_entity_power(&mage, false, 5_000, 0), 100 + 26); // Past the window, mid-combat → still regens (the FSR gate, not combat state, controls mana). - assert_eq!(regen_entity_power(&mage, true, 6_000), 100 + 26); + assert_eq!(regen_entity_power(&mage, true, 6_000, 0), 100 + 26); // A rage (warrior) entity ignores the FSR field entirely — reads its own power-type byte. let warrior = entity_for_regen( @@ -1279,14 +1342,34 @@ mod tests { 10, 0, ); - assert_eq!(regen_entity_power(&warrior, false, 0), 1000 - 50); // out of combat → decays - assert_eq!(regen_entity_power(&warrior, true, 0), 1000); // in combat → holds + assert_eq!(regen_entity_power(&warrior, false, 0, 0), 1000 - 50); // out of combat → decays + assert_eq!(regen_entity_power(&warrior, true, 0, 0), 1000); // in combat → holds } #[test] fn regen_entity_health_wrapper_reads_spirit_and_level_off_the_row() { let e = entity_for_regen(0, 100, 1000, 0, 0, 30, 10, 0); - assert_eq!(regen_entity_health(&e), 100 + (30 + 10 + 1)); // spirit + level + 1 per tick + assert_eq!(regen_entity_health(&e, 0), 100 + (30 + 10 + 1)); // spirit + level + 1 per tick + } + + #[test] + fn points_per_five_are_converted_to_the_four_second_regen_tick() { + use lyracore_shared::packing::{power_type, unit_bytes_0}; + + let mage = entity_for_regen( + unit_bytes_0(1, 8, 0, power_type::MANA), + 100, + 1000, + 100, + 1000, + 40, + 10, + 0, + ); + assert_eq!(regen_entity_power(&mage, false, 0, 10), 134); + assert_eq!(regen_entity_health(&mage, 15), 163); + assert_eq!(regen_entity_power(&mage, false, 0, -40), 100); + assert_eq!(regen_entity_health(&mage, -100), 100); } #[test] diff --git a/module/src/creatures/cycle/ctx.rs b/module/src/creatures/cycle/ctx.rs index 7ba7dc18c..a781ece3c 100644 --- a/module/src/creatures/cycle/ctx.rs +++ b/module/src/creatures/cycle/ctx.rs @@ -544,7 +544,14 @@ impl RegenSink for CtxWorld<'_> { .game_world_entity() .guid() .find(u.guid) - .map_or(u.health, |e| crate::combat::regen_entity_health(&e)) + .map_or(u.health, |e| { + let health_per_five = crate::items::equipped_stat_bonus( + self.ctx, + e.guid, + crate::items::EquipStat::HealthPerFive, + ); + crate::combat::regen_entity_health(&e, health_per_five) + }) } fn combat_healed_to(&self, u: &Recovering) -> Option { let pct = u32::try_from(crate::spell::combat_health_regen_pct(self.ctx, u.guid)) @@ -567,7 +574,12 @@ impl RegenSink for CtxWorld<'_> { .guid() .find(u.guid) .map_or(u.power, |e| { - crate::combat::regen_entity_power(&e, u.in_combat, now_ms) + let mana_per_five = crate::items::equipped_stat_bonus( + self.ctx, + e.guid, + crate::items::EquipStat::ManaPerFive, + ); + crate::combat::regen_entity_power(&e, u.in_combat, now_ms, mana_per_five) }) } // ponytail: one row write per unit, where the old pass wrote health and power in two separate diff --git a/module/src/creatures/cycle/harness.rs b/module/src/creatures/cycle/harness.rs index 9ac47be4a..76b2a36af 100644 --- a/module/src/creatures/cycle/harness.rs +++ b/module/src/creatures/cycle/harness.rs @@ -5973,7 +5973,10 @@ fn the_production_adapter_is_the_pass_through_the_harness_assumes() { "e.max_health, power: e.power, max_power: e.max_power, in_combat: ", "in_combat.contains(&e.guid), }) .collect() } fn healed_to(&self, u: ", "&Recovering) -> u32 { self.ctx .db .game_world_entity() .guid() .find(u.guid) ", - ".map_or(u.health, |e| crate::combat::regen_entity_health(&e)) } fn ", + ".map_or(u.health, |e| { let health_per_five = ", + "crate::items::equipped_stat_bonus( self.ctx, e.guid, ", + "crate::items::EquipStat::HealthPerFive, ); ", + "crate::combat::regen_entity_health(&e, health_per_five) }) } fn ", "combat_healed_to(&self, u: &Recovering) -> Option { let pct = ", "u32::try_from(crate::spell::combat_health_regen_pct(self.ctx, u.guid)) .ok() ", ".filter(|pct| *pct > 0)?; let e = ", @@ -5982,7 +5985,9 @@ fn the_production_adapter_is_the_pass_through_the_harness_assumes() { "e.level, pct, )) } fn powered_to(&self, u: &Recovering) -> u32 { let now_ms = ", "(self.ctx.timestamp.to_micros_since_unix_epoch() / 1000) as u64; self.ctx .db ", ".game_world_entity() .guid() .find(u.guid) .map_or(u.power, |e| { ", - "crate::combat::regen_entity_power(&e, u.in_combat, now_ms) }) } fn ", + "let mana_per_five = crate::items::equipped_stat_bonus( self.ctx, e.guid, ", + "crate::items::EquipStat::ManaPerFive, ); ", + "crate::combat::regen_entity_power(&e, u.in_combat, now_ms, mana_per_five) }) } fn ", "restore(&mut self, guid: u64, health: Option, power: Option) { let ", "entities = self.ctx.db.game_world_entity(); let Some(mut live) = ", "entities.guid().find(guid) else { return; }; let (next_health, next_power) = ", diff --git a/module/src/items/ops.rs b/module/src/items/ops.rs index 85200368a..8ef9b55eb 100644 --- a/module/src/items/ops.rs +++ b/module/src/items/ops.rs @@ -9,7 +9,7 @@ use spacetimedb::{Identity, ReducerContext, Table}; use lyracore_shared::constants::starter_item; use lyracore_shared::item::ItemRefusal; -use super::{enchant_stat, property_stat, refuse, select_property}; +use super::{refuse, select_property}; use crate::game_character; // the durable char holds `class` (the live WorldEntity does not) use crate::game_corpse_loot; // the loot.rs accessor trait — re-exported at crate root (`pub use loot::*`) use crate::game_gameobject; @@ -582,15 +582,50 @@ pub(crate) fn apply_item_use( .map_err(|detail| refuse(ItemRefusal::NotRightNow, detail)) } -/// The total `which` bonus from every piece of gear `owner_guid` has EQUIPPED — the sum of -/// `template_stat(.., which)` over the owner's item instances in the equipment region (slots 0..=18), -/// each joined to its `game_item_template`. The gear twin of the spell module's `stat_bonus`/`combat_field_bonus` -/// (which sum the matching auras): the combat module folds THIS alongside those aura sums into the same -/// effective-* helper, so equipping a +stat piece is mechanically real (it moves the swing/dodge/ -/// mitigation/crit/hit readout) without any new readback. A CREATURE (or an unequipped player) has no -/// equipped item rows → the sum is 0, so its readout is byte-identical to before (baseline-safe). An item -/// whose template isn't loaded contributes 0 (a missing join never poisons the sum). [entity] -pub(crate) fn equipped_stat_bonus(ctx: &ReducerContext, owner_guid: u64, which: EquipStat) -> i32 { +/// One item's template, applied enchantment, and Random Property contribution to a Stat Kind. +fn item_stat_bonus_for_school( + ctx: &ReducerContext, + item: &ItemInstance, + tmpl: &ItemTemplate, + which: EquipStat, + school_mask: u32, +) -> i32 { + let (enchant, property) = if school_mask == u32::MAX { + ( + super::enchant_stat(ctx, item.enchant_id, which.kind()), + super::property_stat(ctx, item.random_property_id, which.kind()), + ) + } else { + ( + super::enchant_stat_for_school(ctx, item.enchant_id, which.kind(), school_mask), + super::property_stat_for_school( + ctx, + item.random_property_id, + which.kind(), + school_mask, + ), + ) + }; + template_stat(tmpl, which) + .saturating_add(enchant) + .saturating_add(property) +} + +pub(crate) fn item_stat_bonus( + ctx: &ReducerContext, + item: &ItemInstance, + tmpl: &ItemTemplate, + which: EquipStat, +) -> i32 { + item_stat_bonus_for_school(ctx, item, tmpl, which, u32::MAX) +} + +fn equipped_stat_bonus_for_school( + ctx: &ReducerContext, + owner_guid: u64, + which: EquipStat, + school_mask: u32, +) -> i32 { let templates = ctx.db.game_item_template(); ctx.db .game_item_instance() @@ -606,15 +641,32 @@ pub(crate) fn equipped_stat_bonus(ctx: &ReducerContext, owner_guid: u64, which: // the 13). `enchant_stat` is 0 for an unenchanted item (enchant_id 0) → byte-identical readout // for every existing/unenchanted piece (baseline-safe). A broken item already returned above, so // a broken-but-enchanted piece grants neither — the enchant rides the item's working state. - Some( - template_stat(&tmpl, which) - .saturating_add(enchant_stat(ctx, i.enchant_id, which.kind())) - .saturating_add(property_stat(ctx, i.random_property_id, which.kind())), - ) + Some(item_stat_bonus_for_school( + ctx, + &i, + &tmpl, + which, + school_mask, + )) }) .fold(0i32, i32::saturating_add) } +/// Sum one Stat Kind across working items in the equipment region. +pub(crate) fn equipped_stat_bonus(ctx: &ReducerContext, owner_guid: u64, which: EquipStat) -> i32 { + equipped_stat_bonus_for_school(ctx, owner_guid, which, u32::MAX) +} + +/// The school-filtered form used by spell power. Effects with a zero school mask apply to every school. +pub(crate) fn equipped_school_stat_bonus( + ctx: &ReducerContext, + owner_guid: u64, + which: EquipStat, + school_mask: u8, +) -> i32 { + equipped_stat_bonus_for_school(ctx, owner_guid, which, u32::from(school_mask)) +} + /// The pure per-item durability-loss formula `apply_death_durability_loss` applies to each equipped /// piece: 10% of MAX durability (rounded down, at least 1), saturating so it never wraps below 0. /// Returns the item UNCHANGED (no loss) when it has no durability concept (`max_durability == 0`) or is diff --git a/module/src/items/properties.rs b/module/src/items/properties.rs index 2935dd321..4f82aae14 100644 --- a/module/src/items/properties.rs +++ b/module/src/items/properties.rs @@ -101,6 +101,17 @@ pub(crate) fn select_loot_property(ctx: &ReducerContext, entry: u32) -> Result i32 { + enchant_stat_for_school(ctx, enchant_id, stat_kind, u32::MAX) +} + +/// Sum one Stat Kind from an enchantment when its school applies to the requested spell. A zero +/// `school_mask` is universal. Imported school-specific spell power carries its exact school bits. +pub(crate) fn enchant_stat_for_school( + ctx: &ReducerContext, + enchant_id: u32, + stat_kind: u8, + school_mask: u32, +) -> i32 { if enchant_id == 0 { return 0; } @@ -108,11 +119,24 @@ pub(crate) fn enchant_stat(ctx: &ReducerContext, enchant_id: u32, stat_kind: u8) .game_item_enchantment() .by_enchant() .filter(enchant_id) - .filter(|row| row.kind != kind::UNKNOWN && row.kind == stat_kind) + .filter(|row| { + row.kind != kind::UNKNOWN + && row.kind == stat_kind + && (row.school_mask == 0 || row.school_mask & school_mask != 0) + }) .fold(0i32, |sum, row| sum.saturating_add(row.amount)) } pub(crate) fn property_stat(ctx: &ReducerContext, property_id: u32, stat_kind: u8) -> i32 { + property_stat_for_school(ctx, property_id, stat_kind, u32::MAX) +} + +pub(crate) fn property_stat_for_school( + ctx: &ReducerContext, + property_id: u32, + stat_kind: u8, + school_mask: u32, +) -> i32 { if property_id == 0 { return 0; } @@ -130,7 +154,7 @@ pub(crate) fn property_stat(ctx: &ReducerContext, property_id: u32, stat_kind: u property.enchant_id_3, ] .into_iter() - .map(|id| enchant_stat(ctx, id, stat_kind)) + .map(|id| enchant_stat_for_school(ctx, id, stat_kind, school_mask)) .fold(0i32, i32::saturating_add) } @@ -152,9 +176,26 @@ impl EquipStat { Self::Stamina => kind::STAMINA, Self::Intellect => kind::INTELLECT, Self::Spirit => kind::SPIRIT, + Self::Health => kind::HEALTH, + Self::Mana => kind::MANA, + Self::HolyResistance => kind::HOLY_RESISTANCE, + Self::FireResistance => kind::FIRE_RESISTANCE, + Self::NatureResistance => kind::NATURE_RESISTANCE, + Self::FrostResistance => kind::FROST_RESISTANCE, + Self::ShadowResistance => kind::SHADOW_RESISTANCE, + Self::ArcaneResistance => kind::ARCANE_RESISTANCE, Self::Crit => kind::CRIT, Self::Hit => kind::HIT, Self::Armor => kind::ARMOR, + Self::WeaponDamage => kind::WEAPON_DAMAGE, + Self::SpellPower => kind::SPELL_POWER, + Self::HealingPower => kind::HEALING_POWER, + Self::ManaPerFive => kind::MANA_PER_FIVE, + Self::HealthPerFive => kind::HEALTH_PER_FIVE, + Self::Defense => kind::DEFENSE, + Self::Dodge => kind::DODGE, + Self::Parry => kind::PARRY, + Self::Block => kind::BLOCK, } } } diff --git a/module/src/items/rules.rs b/module/src/items/rules.rs index 6afc32204..078c96b77 100644 --- a/module/src/items/rules.rs +++ b/module/src/items/rules.rs @@ -263,16 +263,8 @@ pub fn meets_required_level(player_level: u32, required_level: u8) -> bool { player_level >= required_level as u32 } -/// Which equip-stat an `equipped_stat_bonus` query sums across a unit's worn gear. A small typed enum -/// (vs. raw stat ids) so the call site reads self-documentingly and the column pick is exhaustive-checked -/// by the compiler. The five base attributes mirror the `UNIT_FIELD_STAT` order / the spell module's `STAT_*`; the -/// combat ratings (`Crit`/`Hit`) mirror the `COMBAT_*` attack-table fields; `Armor` mirrors the -/// `A_MOD_RESISTANCE(armor)` school. The variants compose with the aura sums in combat/ — each gear -/// total is added ALONGSIDE the matching aura total into the same effective-* helper. `Stamina` and -/// `Intellect` feed `recompute_vitals`'s max-health/max-mana derivation; `Spirit` is summable but not -/// folded into any pool (no Spirit-driven derive exists yet), so it's kept for symmetry with the other -/// four base attributes — hence `#[allow(dead_code)]` on the otherwise-unconstructed variant. [reference] -#[allow(dead_code)] +/// A Stat Kind summed across a unit's working equipped items. The typed projection keeps callers from +/// passing raw imported codes and makes every supported family explicit. [reference] #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub(crate) enum EquipStat { Strength, @@ -280,9 +272,41 @@ pub(crate) enum EquipStat { Stamina, Intellect, Spirit, + Health, + Mana, + HolyResistance, + FireResistance, + NatureResistance, + FrostResistance, + ShadowResistance, + ArcaneResistance, Crit, Hit, Armor, + WeaponDamage, + SpellPower, + HealingPower, + ManaPerFive, + HealthPerFive, + Defense, + Dodge, + Parry, + Block, +} + +impl EquipStat { + pub(crate) fn resistance_for_school(school_mask: u8) -> Option { + match school_mask.trailing_zeros() { + 0 if school_mask != 0 => Some(Self::Armor), + 1 => Some(Self::HolyResistance), + 2 => Some(Self::FireResistance), + 3 => Some(Self::NatureResistance), + 4 => Some(Self::FrostResistance), + 5 => Some(Self::ShadowResistance), + 6 => Some(Self::ArcaneResistance), + _ => None, + } + } } /// This item template's contribution to one `EquipStat` — the single typed column the variant names. @@ -297,9 +321,26 @@ pub(crate) fn template_stat(tmpl: &ItemTemplate, which: EquipStat) -> i32 { EquipStat::Stamina => tmpl.stat_stamina, EquipStat::Intellect => tmpl.stat_intellect, EquipStat::Spirit => tmpl.stat_spirit, + EquipStat::HolyResistance => tmpl.holy_res, + EquipStat::FireResistance => tmpl.fire_res, + EquipStat::NatureResistance => tmpl.nature_res, + EquipStat::FrostResistance => tmpl.frost_res, + EquipStat::ShadowResistance => tmpl.shadow_res, + EquipStat::ArcaneResistance => tmpl.arcane_res, EquipStat::Crit => tmpl.stat_crit, EquipStat::Hit => tmpl.stat_hit, EquipStat::Armor => tmpl.stat_armor, + EquipStat::Health + | EquipStat::Mana + | EquipStat::WeaponDamage + | EquipStat::SpellPower + | EquipStat::HealingPower + | EquipStat::ManaPerFive + | EquipStat::HealthPerFive + | EquipStat::Defense + | EquipStat::Dodge + | EquipStat::Parry + | EquipStat::Block => 0, } } @@ -641,16 +682,42 @@ pub(crate) mod tests { t.stat_armor = 50; t.stat_crit = 100; t.stat_hit = 30; + t.holy_res = 1; + t.fire_res = 2; + t.nature_res = 3; + t.frost_res = 4; + t.shadow_res = 5; + t.arcane_res = 6; assert_eq!(template_stat(&t, EquipStat::Strength), 10); assert_eq!(template_stat(&t, EquipStat::Agility), 20); assert_eq!(template_stat(&t, EquipStat::Armor), 50); assert_eq!(template_stat(&t, EquipStat::Crit), 100); assert_eq!(template_stat(&t, EquipStat::Hit), 30); + assert_eq!(template_stat(&t, EquipStat::HolyResistance), 1); + assert_eq!(template_stat(&t, EquipStat::FireResistance), 2); + assert_eq!(template_stat(&t, EquipStat::NatureResistance), 3); + assert_eq!(template_stat(&t, EquipStat::FrostResistance), 4); + assert_eq!(template_stat(&t, EquipStat::ShadowResistance), 5); + assert_eq!(template_stat(&t, EquipStat::ArcaneResistance), 6); // An untouched column is 0 — the starter/loadout templates carry no stats, so they contribute 0. assert_eq!(template_stat(&t, EquipStat::Stamina), 0); assert_eq!(template_stat(&blank_template(25), EquipStat::Strength), 0); } + #[test] + fn resistance_kind_uses_the_lowest_school_bit() { + assert_eq!( + EquipStat::resistance_for_school(1 << 2), + Some(EquipStat::FireResistance) + ); + assert_eq!( + EquipStat::resistance_for_school((1 << 4) | (1 << 6)), + Some(EquipStat::FrostResistance) + ); + assert_eq!(EquipStat::resistance_for_school(0), None); + assert_eq!(EquipStat::resistance_for_school(1 << 7), None); + } + /// Re-scoped (was `equipped_stat_sum_adds_across_pieces_and_is_zero_for_no_gear`): the original name /// oversold this as covering `equipped_stat_bonus`, but that fn needs a live `ReducerContext` (it /// joins the owner's equipped `ItemInstance` rows through `game_item_template`, filters to the diff --git a/module/src/skill.rs b/module/src/skill.rs index 3220b8772..495889419 100644 --- a/module/src/skill.rs +++ b/module/src/skill.rs @@ -400,13 +400,15 @@ pub fn effective_weapon_skill(ctx: &ReducerContext, attacker: &WorldEntity) -> u } /// A unit's EFFECTIVE defense skill: the trained Defense value (else the `level*5` baseline) PLUS any -/// `A_MOD_COMBAT(COMBAT_DEFENSE)` aura (e.g. the Anticipation talent). More defense raises +/// `A_MOD_COMBAT(COMBAT_DEFENSE)` aura and Defense from working equipped items. More defense raises /// `skill_diff` against an attacker, so it tightens the attacker's miss/dodge/parry/block bands — a -/// defender's avoidance goes up. No defense aura → exactly the trained/baseline value (baseline-safe). [entity] +/// defender's avoidance. With neither source, this is exactly the trained or baseline value. [entity] pub fn effective_defense_skill(ctx: &ReducerContext, target: &WorldEntity) -> u32 { let base = skill_value_or_cap(ctx, target.guid, skill_line::DEFENSE, target.level); - let bonus = crate::spell::combat_field_bonus(ctx, target.guid, crate::spell::COMBAT_DEFENSE); - (base as i32 + bonus).max(0) as u32 + let aura = crate::spell::combat_field_bonus(ctx, target.guid, crate::spell::COMBAT_DEFENSE); + let gear = + crate::items::equipped_stat_bonus(ctx, target.guid, crate::items::EquipStat::Defense); + (i64::from(base) + i64::from(aura) + i64::from(gear)).clamp(0, i64::from(u32::MAX)) as u32 } /// The attack-table skill difference for a swing of `attacker` at `target`: the defender's defense skill diff --git a/module/src/spell/cast/targeting.rs b/module/src/spell/cast/targeting.rs index e9cd1c35b..9dcfc6592 100644 --- a/module/src/spell/cast/targeting.rs +++ b/module/src/spell/cast/targeting.rs @@ -1585,7 +1585,7 @@ fn apply_heal( target_guid: u64, points: i32, ) -> u32 { - let sp = spell_power(ctx, caster_guid, hdr.school_mask); + let sp = healing_power(ctx, caster_guid, hdr.school_mask); let scaled = compose_magnitude(points, sp, SPELL_POWER_COEFF_PCT); apply_direct_heal(ctx, caster_guid, target_guid, scaled) } diff --git a/module/src/spell/effects.rs b/module/src/spell/effects.rs index 063ea84a5..3f288bd6b 100644 --- a/module/src/spell/effects.rs +++ b/module/src/spell/effects.rs @@ -204,13 +204,17 @@ pub(crate) fn recompute_vitals(ctx: &ReducerContext, unit_guid: u64) { // — so a +Stamina chest/ring actually grows the health bar, not just a Mark-of-the-Wild // aura. Mirrors how combat's effective_strength folds aura + gear. No gear and no aura → 0, so the // pool stays byte-identical to the bare level curve (baseline-safe). - let sta_bonus = stat_bonus(ctx, unit_guid, STAT_STA) - + crate::items::equipped_stat_bonus(ctx, unit_guid, crate::items::EquipStat::Stamina); - let new_max_health = rederive_pool( - login_max_health, - e.stamina, - sta_bonus, - crate::stats::hp_from_stamina, + let sta_bonus = stat_bonus(ctx, unit_guid, STAT_STA).saturating_add( + crate::items::equipped_stat_bonus(ctx, unit_guid, crate::items::EquipStat::Stamina), + ); + let new_max_health = add_flat_pool_bonus( + rederive_pool( + login_max_health, + e.stamina, + sta_bonus, + crate::stats::hp_from_stamina, + ), + crate::items::equipped_stat_bonus(ctx, unit_guid, crate::items::EquipStat::Health), ); // --- max_power from effective intellect (MANA classes only — rage/energy don't scale off INT) --- @@ -219,13 +223,17 @@ pub(crate) fn recompute_vitals(ctx: &ReducerContext, unit_guid: u64) { let new_max_power = if is_mana { let login_max_power = crate::stats::max_power_for(ctx, race, class, e.level); // Aura INT + equipped INT, the mana twin of the stamina fold above. - let int_bonus = stat_bonus(ctx, unit_guid, STAT_INT) - + crate::items::equipped_stat_bonus(ctx, unit_guid, crate::items::EquipStat::Intellect); - rederive_pool( - login_max_power, - e.intellect, - int_bonus, - crate::stats::mana_from_intellect, + let int_bonus = stat_bonus(ctx, unit_guid, STAT_INT).saturating_add( + crate::items::equipped_stat_bonus(ctx, unit_guid, crate::items::EquipStat::Intellect), + ); + add_flat_pool_bonus( + rederive_pool( + login_max_power, + e.intellect, + int_bonus, + crate::stats::mana_from_intellect, + ), + crate::items::equipped_stat_bonus(ctx, unit_guid, crate::items::EquipStat::Mana), ) } else { e.max_power // unchanged for rage/energy pools @@ -292,10 +300,12 @@ pub(crate) fn recompute_sheet(ctx: &ReducerContext, unit_guid: u64) { let eff_agi = crate::combat::effective_agility(ctx, &e); let str_bonus = eff_str as i32 - e.strength as i32; let agi_bonus = eff_agi as i32 - e.agility as i32; - let sta_bonus = stat_bonus(ctx, unit_guid, STAT_STA) - + crate::items::equipped_stat_bonus(ctx, unit_guid, crate::items::EquipStat::Stamina); - let int_bonus = stat_bonus(ctx, unit_guid, STAT_INT) - + crate::items::equipped_stat_bonus(ctx, unit_guid, crate::items::EquipStat::Intellect); + let sta_bonus = stat_bonus(ctx, unit_guid, STAT_STA).saturating_add( + crate::items::equipped_stat_bonus(ctx, unit_guid, crate::items::EquipStat::Stamina), + ); + let int_bonus = stat_bonus(ctx, unit_guid, STAT_INT).saturating_add( + crate::items::equipped_stat_bonus(ctx, unit_guid, crate::items::EquipStat::Intellect), + ); // `recompute_vitals` (called first at every real call site) already folds Spirit's base+aura into // `e.spirit` — re-derive the SAME base curve so the sheet's bonus is `e.spirit - base`, never a // second copy of the aura/pct math. diff --git a/module/src/spell/math.rs b/module/src/spell/math.rs index 1abb8f329..18726fb30 100644 --- a/module/src/spell/math.rs +++ b/module/src/spell/math.rs @@ -658,23 +658,37 @@ fn sum_active_auras(ctx: &ReducerContext, unit_guid: u64, pred: impl Fn(&Aura) - .sum() } -/// The caster's **spell power** (a.k.a. healing power) — the caster-side scaling number folded into -/// E_DAMAGE / E_HEAL magnitudes via `compose_magnitude`. Two additive sources: +/// The caster's spell power, folded into E_DAMAGE magnitudes by `compose_magnitude`. It combines: /// 1. **INT ramp** — `int_spell_power(effective_int)`, where effective INT = the stored base /// (`caster.intellect`) + every `A_MOD_STAT(INT)` aura (`stat_bonus(.., STAT_INT)`) + the INT summed /// across EQUIPPED gear (`items::equipped_stat_bonus(.., Intellect)`). The ramp is 0 at/below the /// starter floor (`INT_SPELL_POWER_BASE`), so the L2 player and every creature contribute 0. -/// 2. **Spell-power auras** — every `A_MOD_COMBAT(COMBAT_SPELL_POWER)` aura (`combat_field_bonus(.., -/// COMBAT_SPELL_POWER)`), e.g. a seeded "Spell Power" buff. None present → 0. +/// 2. Every `A_MOD_COMBAT(COMBAT_SPELL_POWER)` aura. +/// 3. Spell Power from working equipped items whose school mask is universal or overlaps the spell. /// -/// A caster that is not in the world returns 0. `school_mask` is RESERVED for later school-specific power -/// (fire vs frost vs holy) — accepted now, school-agnostic for the slice, so the signature is stable when -/// that lands. A caster with the starter INT and no spell-power aura sums to 0 → magnitudes are -/// unchanged from the baseline (the explicit baseline gate). [entity] +/// A caster that is not in the world returns 0. A caster with starter INT and no spell-power source +/// keeps the baseline magnitude. [entity] pub(crate) fn spell_power(ctx: &ReducerContext, caster_guid: u64, school_mask: u8) -> i32 { - let _ = school_mask; // reserved: school-agnostic for the slice (later: per-school power) int_spell_power(effective_intellect(ctx, caster_guid)) - + combat_field_bonus(ctx, caster_guid, COMBAT_SPELL_POWER) + .saturating_add(combat_field_bonus(ctx, caster_guid, COMBAT_SPELL_POWER)) + .saturating_add(crate::items::equipped_school_stat_bonus( + ctx, + caster_guid, + crate::items::EquipStat::SpellPower, + school_mask, + )) +} + +/// Spell power for E_HEAL. General Spell Power applies, then school-matching Healing Power is added once. +pub(crate) fn healing_power(ctx: &ReducerContext, caster_guid: u64, school_mask: u8) -> i32 { + spell_power(ctx, caster_guid, school_mask).saturating_add( + crate::items::equipped_school_stat_bonus( + ctx, + caster_guid, + crate::items::EquipStat::HealingPower, + school_mask, + ), + ) } /// The caster's EFFECTIVE Intellect: stored base (`caster.intellect`) + every `A_MOD_STAT(INT)` aura @@ -689,9 +703,14 @@ pub(crate) fn effective_intellect(ctx: &ReducerContext, caster_guid: u64) -> i32 .find(caster_guid) .map(|c| c.intellect as i32) .unwrap_or(0); - base_int - + stat_bonus(ctx, caster_guid, STAT_INT) - + crate::items::equipped_stat_bonus(ctx, caster_guid, crate::items::EquipStat::Intellect) + (i64::from(base_int) + + i64::from(stat_bonus(ctx, caster_guid, STAT_INT)) + + i64::from(crate::items::equipped_stat_bonus( + ctx, + caster_guid, + crate::items::EquipStat::Intellect, + ))) + .clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32 } /// Sum a base-attribute bonus from active auras on `unit_guid`: every `A_MOD_STAT` aura whose frozen @@ -719,12 +738,16 @@ pub(crate) fn stat_pct_bonus(ctx: &ReducerContext, unit_guid: u64, stat: u8) -> /// Sum a resistance/armor bonus from active auras on `unit_guid`: every `A_MOD_RESISTANCE` aura whose /// frozen `eff_p0` school MASK overlaps `school_mask` (e.g. `RESIST_ARMOR` bit 0) contributes /// `amount × stacks`. The resistance twin of `stat_bonus` — the combat module folds this into a unit's -/// EFFECTIVE armor before computing damage mitigation, so an armor buff actually softens incoming hits, -/// not just a stored aura. Returns 0 for an un-buffed unit, so mitigation is unchanged without it. [entity] +/// effective resistance before computing damage mitigation. The matching equipped resistance Stat Kind +/// is added once; a multi-school spell uses its lowest school bit, matching the mitigation caller. [entity] pub(crate) fn resistance_bonus(ctx: &ReducerContext, unit_guid: u64, school_mask: u8) -> i32 { - sum_active_auras(ctx, unit_guid, |a| { + let aura = sum_active_auras(ctx, unit_guid, |a| { a.eff_kind == A_MOD_RESISTANCE && (a.eff_p0 as u32 & school_mask as u32) != 0 - }) + }); + let gear = crate::items::EquipStat::resistance_for_school(school_mask) + .map(|which| crate::items::equipped_stat_bonus(ctx, unit_guid, which)) + .unwrap_or(0); + aura.saturating_add(gear) } // =========================================================================================== @@ -752,7 +775,12 @@ pub(crate) fn rederive_pool( // Swap the base-attribute contribution for the effective one via SIGNED arithmetic so the result is a // TRUE no-op at bonus == 0 (from_eff == from_base) for ALL inputs — even a synthetic login_max smaller // than from_base (which a saturating_sub would wrongly floor to 0 and then inflate). Floored at 0. - (login_max as i64 - from_base as i64 + from_eff as i64).max(0) as u32 + (i128::from(login_max) - i128::from(from_base) + i128::from(from_eff)) + .clamp(0, i128::from(u32::MAX)) as u32 +} + +pub(crate) fn add_flat_pool_bonus(pool: u32, bonus: i32) -> u32 { + (i128::from(pool) + i128::from(bonus)).clamp(0, i128::from(u32::MAX)) as u32 } /// True iff `effect` is an `A_MOD_STAT` aura that touches STAMINA or INTELLECT — the only stat changes diff --git a/module/src/spell/tests.rs b/module/src/spell/tests.rs index 3e5c26f15..1629922bd 100644 --- a/module/src/spell/tests.rs +++ b/module/src/spell/tests.rs @@ -682,6 +682,13 @@ fn rederive_pool_is_noop_at_zero_and_curve_exact() { assert_eq!(rederive_pool(120, 20, 0, mana_from_intellect), 120); // no-op } +#[test] +fn direct_pool_bonus_clamps_at_imported_integer_boundaries() { + assert_eq!(add_flat_pool_bonus(100, 25), 125); + assert_eq!(add_flat_pool_bonus(100, -150), 0); + assert_eq!(add_flat_pool_bonus(u32::MAX, i32::MAX), u32::MAX); +} + /// The vitals-recompute gate: ONLY an A_MOD_STAT aura touching STA / INT / ALL moves a max pool. /// A +STR/+AGI/+SPI A_MOD_STAT, or any non-stat aura kind, must NOT trip the recompute (no work → /// the un-buffed unit keeps its login max). `STAT_ALL` (Mark of the Wild) counts (it includes STA+INT). diff --git a/module/src/tripwires.rs b/module/src/tripwires.rs index badea83ad..6faf894d6 100644 --- a/module/src/tripwires.rs +++ b/module/src/tripwires.rs @@ -2085,7 +2085,7 @@ pub(crate) mod issue_reference_tripwire { /// deliberate residues. A conditional `cfg_attr` allowance does not count here. #[cfg(test)] pub(crate) mod dead_code_allowance_tripwire { - const EXPECTED_UNCONDITIONAL_ALLOWANCES: usize = 5; + const EXPECTED_UNCONDITIONAL_ALLOWANCES: usize = 4; fn unconditional_dead_code_allows(content: &str) -> Vec { let mut lines = Vec::new(); @@ -2105,7 +2105,7 @@ pub(crate) mod dead_code_allowance_tripwire { } #[test] - fn module_has_exactly_five_unconditional_dead_code_allowances() { + fn module_has_exactly_four_unconditional_dead_code_allowances() { let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); let mut files = Vec::new(); super::character_owned_tripwire::collect_rs_files(&root, &mut files); diff --git a/module/tests/random_property_stats.rs b/module/tests/random_property_stats.rs new file mode 100644 index 000000000..26c82f861 --- /dev/null +++ b/module/tests/random_property_stats.rs @@ -0,0 +1,359 @@ +mod support; + +use std::collections::BTreeMap; + +use support::{poll_until, Standalone, POLL_TIMEOUT}; + +const ITEM: &str = "5090050"; +const POOL: u32 = 5090200; +const PROPERTY: u32 = 5090201; +const ENCHANT: u32 = 5090202; + +fn fixture(name: &str, class: u8) -> Standalone { + let mut shard = Standalone::start(name); + shard.publish_module(); + shard.assert_call("claim_operator", &[]); + shard.assert_call("debug_seed_scenario_fixtures", &[]); + shard.assert_sql("DELETE FROM game_item_instance WHERE owner_guid = 1"); + shard.assert_sql(&format!( + "UPDATE game_character SET class = {class} WHERE guid = 1" + )); + shard.assert_call("debug_spawn_player_entity", &["1"]); + shard.assert_sql(&format!( + "INSERT INTO game_item_random_property (property_id,enchant_id_1,enchant_id_2,enchant_id_3,suffix) VALUES ({PROPERTY},{ENCHANT},0,0,'of the Stat Fixture')" + )); + shard.assert_sql(&format!( + "INSERT INTO game_item_property_weight (id,pool_id,property_id,weight) VALUES ({POOL},{POOL},{PROPERTY},10000)" + )); + shard +} + +fn enchantments(shard: &Standalone, effects: &[(u8, i32, u32)]) { + let values = effects + .iter() + .enumerate() + .map(|(index, (kind, amount, school_mask))| { + format!( + "({},{ENCHANT},{index},{kind},{amount},0,{school_mask})", + u64::from(ENCHANT) * 256 + index as u64 + ) + }) + .collect::>() + .join(","); + shard.assert_sql(&format!( + "INSERT INTO game_item_enchantment (id,enchant_id,effect_index,kind,amount,spell_id,school_mask) VALUES {values}" + )); +} + +fn grant_and_equip(shard: &Standalone) { + shard.assert_call("debug_grant_item", &["1", ITEM, "1"]); + shard.assert_call("debug_equip_item", &["1", "23"]); +} + +fn world_row(shard: &Standalone) -> BTreeMap { + shard + .query_rows("SELECT * FROM game_world_entity WHERE guid = 1") + .pop() + .expect("the fixture player must be live") +} + +fn readout(shard: &Standalone, key: &str) -> BTreeMap { + shard + .query_rows(&format!( + "SELECT * FROM game_debug_readout WHERE key = '{key}'" + )) + .pop() + .expect("the debug readout must exist") +} + +fn create_damage_spell(shard: &Standalone, spell_id: u32, school_mask: u8) { + shard.assert_sql(&format!( + "INSERT INTO game_spell (spell_id,name,power_type,cost,cast_time_ms,gcd_ms,cooldown_ms,range_yd,duration_ms,school_mask,dispel_type,mechanic,max_stacks,aura_interrupt,attributes,spell_level,max_level,is_negative,cast_flags,stances,family_name,family_flags,proc_flags,proc_chance,proc_charges) VALUES ({spell_id},'Stat fixture damage',0,0,0,0,0,40,0,{school_mask},0,0,0,0,0,1,0,true,0,0,0,0,0,0,0)" + )); + shard.assert_sql(&format!( + "INSERT INTO game_spell_effect (id,spell_id,effect_index,kind,base_points,die_sides,per_level,period_ms,target,radius_yd,chain_targets,trigger_spell,effect_mechanic,p0,p0_kind,p1,script_id,enters_combat) VALUES ({},{spell_id},0,1,100,0,0.0,0,1,0.0,0,0,0,0,0,0,0,false)", + u64::from(spell_id) * 4 + )); +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn direct_pools_and_regeneration_follow_working_equipment() { + let shard = fixture("property-stat-vitals", 8); + enchantments(&shard, &[(6, 25, 0), (7, 40, 0), (18, 10, 0), (19, 15, 0)]); + shard.assert_sql(&format!( + "UPDATE game_item_template SET random_property = {POOL}, stat_stamina = 0, stat_intellect = 0 WHERE entry = {ITEM}" + )); + let bare = world_row(&shard); + let bare_health: u32 = bare["max_health"].parse().unwrap(); + let bare_mana: u32 = bare["max_power"].parse().unwrap(); + + grant_and_equip(&shard); + let equipped = world_row(&shard); + assert_eq!( + equipped["max_health"].parse::().unwrap(), + bare_health + 25 + ); + assert_eq!( + equipped["max_power"].parse::().unwrap(), + bare_mana + 40 + ); + + shard.assert_call("debug_unequip_item", &["1", "15"]); + let unequipped = world_row(&shard); + assert_eq!( + unequipped["max_health"].parse::().unwrap(), + bare_health + ); + assert_eq!(unequipped["max_power"].parse::().unwrap(), bare_mana); + + shard.assert_sql( + "UPDATE game_item_instance SET durability = 0 WHERE owner_guid = 1 AND slot = 23", + ); + shard.assert_call("debug_equip_item", &["1", "23"]); + let broken = world_row(&shard); + assert_eq!(broken["max_health"].parse::().unwrap(), bare_health); + assert_eq!(broken["max_power"].parse::().unwrap(), bare_mana); + + shard.assert_sql( + "UPDATE game_item_instance SET durability = 70 WHERE owner_guid = 1 AND slot = 15", + ); + shard.assert_sql("UPDATE game_world_entity SET health = 100, max_health = 100000, power = 100, max_power = 100000, spirit = 30, mana_regen_paused_until_ms = 0 WHERE guid = 1"); + assert!( + poll_until(POLL_TIMEOUT, || { + let row = world_row(&shard); + row["health"] != "100" || row["power"] != "100" + }), + "the scheduled regeneration pass did not run" + ); + let regenerated = world_row(&shard); + assert_eq!(regenerated["health"], "144"); + assert_eq!(regenerated["power"], "124"); +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn defenses_and_each_magic_resistance_reach_combat_callers() { + let shard = fixture("property-stat-defense", 1); + enchantments( + &shard, + &[ + (8, 6, 2), + (9, 8, 4), + (10, 10, 8), + (11, 12, 16), + (12, 14, 32), + (13, 16, 64), + (22, 4, 0), + (23, 200, 0), + (24, 300, 0), + (25, 400, 0), + ], + ); + shard.assert_sql(&format!( + "UPDATE game_item_template SET random_property = {POOL}, holy_res = 1, fire_res = 2, nature_res = 3, frost_res = 4, shadow_res = 5, arcane_res = 6 WHERE entry = 50053" + )); + shard.assert_call("debug_spawn_at_feet", &["1", "51000", "1"]); + let wolf = shard + .query_rows("SELECT guid FROM game_world_entity WHERE entry = 51000 AND dead = false") + .pop() + .unwrap()["guid"] + .clone(); + + shard.assert_call("debug_compute_swing", &[&wolf, "1"]); + let bare = readout(&shard, "swing"); + shard.assert_call("debug_equip_offhand", &["1", "50053"]); + shard.assert_call("debug_compute_swing", &[&wolf, "1"]); + let equipped = readout(&shard, "swing"); + assert_eq!( + equipped["defender_defense_skill"].parse::().unwrap(), + bare["defender_defense_skill"].parse::().unwrap() + 4 + ); + assert_eq!( + equipped["dodge_bp"].parse::().unwrap(), + bare["dodge_bp"].parse::().unwrap() + 240 + ); + assert_eq!( + equipped["parry_bp"].parse::().unwrap(), + bare["parry_bp"].parse::().unwrap() + 340 + ); + assert_eq!(equipped["block_bp"], "940"); + + shard.assert_sql(&format!( + "UPDATE game_world_entity SET level = 10 WHERE guid = {wolf}" + )); + for (index, (school, expected)) in + [(2u8, 90u32), (4, 85), (8, 81), (16, 76), (32, 72), (64, 67)] + .into_iter() + .enumerate() + { + let spell = 5090210 + index as u32; + create_damage_spell(&shard, spell, school); + shard.assert_call("debug_compute_spell", &[&wolf, "1", &spell.to_string()]); + assert_eq!( + readout(&shard, "spell")["spell_hit_normal"] + .parse::() + .unwrap(), + expected, + "school mask {school} used the wrong resistance" + ); + } + + shard.assert_sql( + "UPDATE game_item_instance SET durability = 0 WHERE owner_guid = 1 AND slot = 16", + ); + shard.assert_call("debug_compute_swing", &[&wolf, "1"]); + let broken = readout(&shard, "swing"); + assert_eq!( + broken["defender_defense_skill"], + bare["defender_defense_skill"] + ); + assert_eq!(broken["dodge_bp"], bare["dodge_bp"]); + assert_eq!(broken["parry_bp"], bare["parry_bp"]); + assert_eq!(broken["block_bp"], "0"); + shard.assert_call("debug_compute_spell", &[&wolf, "1", "5090211"]); + assert_eq!(readout(&shard, "spell")["spell_hit_normal"], "100"); +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn spell_power_selects_school_and_healing_power_only_affects_heals() { + let shard = fixture("property-stat-spells", 8); + enchantments( + &shard, + &[(16, 20, 4), (16, 30, 16), (17, 40, 2), (17, 60, 4)], + ); + shard.assert_sql(&format!( + "UPDATE game_item_template SET random_property = {POOL} WHERE entry = {ITEM}" + )); + shard.assert_call("debug_spawn_at_feet", &["1", "51000", "1"]); + let wolf = shard + .query_rows("SELECT guid FROM game_world_entity WHERE entry = 51000 AND dead = false") + .pop() + .unwrap()["guid"] + .clone(); + for (spell, school) in [(5090230, 4u8), (5090231, 16), (5090232, 64)] { + create_damage_spell(&shard, spell, school); + } + let baseline = [5090230u32, 5090231, 5090232].map(|spell| { + shard.assert_call("debug_compute_spell", &["1", &wolf, &spell.to_string()]); + readout(&shard, "spell")["spell_hit_normal"] + .parse::() + .unwrap() + }); + + grant_and_equip(&shard); + for (index, expected_bonus) in [20u32, 30, 0].into_iter().enumerate() { + let spell = 5090230 + index as u32; + shard.assert_call("debug_compute_spell", &["1", &wolf, &spell.to_string()]); + assert_eq!( + readout(&shard, "spell")["spell_hit_normal"] + .parse::() + .unwrap(), + baseline[index] + expected_bonus + ); + } + + shard.assert_sql("INSERT INTO game_spell (spell_id,name,power_type,cost,cast_time_ms,gcd_ms,cooldown_ms,range_yd,duration_ms,school_mask,dispel_type,mechanic,max_stacks,aura_interrupt,attributes,spell_level,max_level,is_negative,cast_flags,stances,family_name,family_flags,proc_flags,proc_chance,proc_charges) VALUES (5090233,'Stat fixture heal',0,0,0,0,0,40,0,2,0,0,0,0,0,1,0,false,0,0,0,0,0,0,0)"); + shard.assert_sql("INSERT INTO game_spell_effect (id,spell_id,effect_index,kind,base_points,die_sides,per_level,period_ms,target,radius_yd,chain_targets,trigger_spell,effect_mechanic,p0,p0_kind,p1,script_id,enters_combat) VALUES (20360932,5090233,0,2,100,0,0.0,0,0,0.0,0,0,0,0,0,0,0,false)"); + shard + .assert_sql("UPDATE game_world_entity SET health = 100, max_health = 10000 WHERE guid = 1"); + shard.assert_call("debug_cast_at", &["1", "5090233", "1"]); + let healed: u32 = world_row(&shard)["health"].parse().unwrap(); + assert_eq!( + healed, 240, + "healing power adds forty to the 100-point heal" + ); + + shard.assert_sql( + "UPDATE game_item_instance SET durability = 0 WHERE owner_guid = 1 AND slot = 15", + ); + shard.assert_call("debug_compute_spell", &["1", &wolf, "5090230"]); + assert_eq!( + readout(&shard, "spell")["spell_hit_normal"] + .parse::() + .unwrap(), + baseline[0] + ); +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn weapon_damage_uses_the_selected_main_off_and_ranged_item() { + let shard = fixture("property-stat-weapons", 8); + enchantments(&shard, &[(15, 20, 0)]); + shard.assert_sql(&format!( + "UPDATE game_item_template SET random_property = {POOL}, damage_min = 8.0, damage_max = 12.0, delay_ms = 2600 WHERE entry = {ITEM}" + )); + shard.assert_sql("UPDATE game_world_entity SET strength = 0, agility = 0 WHERE guid = 1"); + shard.assert_call("debug_equip_weapon", &["1", ITEM]); + shard.assert_call("debug_spawn_at_feet", &["1", "51000", "1"]); + let wolf = shard + .query_rows("SELECT guid FROM game_world_entity WHERE entry = 51000 AND dead = false") + .pop() + .unwrap()["guid"] + .clone(); + shard.assert_sql(&format!( + "UPDATE game_world_entity SET armor = 0, health = 100000, max_health = 100000 WHERE guid = {wolf}" + )); + shard.assert_call("debug_compute_swing", &["1", &wolf]); + let main = readout(&shard, "swing"); + assert_eq!(main["base_min"], "28"); + assert_eq!(main["base_max"], "32"); + + shard.assert_sql(&format!( + "UPDATE game_item_template SET inventory_type = 26, subclass = 19 WHERE entry = {ITEM}" + )); + shard.assert_sql("UPDATE game_item_instance SET slot = 17 WHERE owner_guid = 1 AND slot = 15"); + shard.assert_call("debug_set_level", &["1", "1"]); + let ranged = world_row(&shard); + assert_eq!(ranged["sheet_ranged_dmg_min"], "28"); + assert_eq!(ranged["sheet_ranged_dmg_max"], "32"); + shard.assert_sql( + "UPDATE game_item_instance SET durability = 0 WHERE owner_guid = 1 AND slot = 17", + ); + shard.assert_call("debug_set_level", &["1", "1"]); + let broken_ranged = world_row(&shard); + assert_eq!(broken_ranged["sheet_ranged_dmg_min"], "0"); + assert_eq!(broken_ranged["sheet_ranged_dmg_max"], "0"); + + shard.assert_sql(&format!( + "UPDATE game_item_template SET inventory_type = 22, subclass = 7 WHERE entry = {ITEM}" + )); + shard.assert_sql("UPDATE game_item_instance SET slot = 16, durability = 70 WHERE owner_guid = 1 AND slot = 17"); + shard.assert_sql("UPDATE game_world_entity SET strength = 0, agility = 0, health = 100000, max_health = 100000 WHERE guid = 1"); + shard.assert_sql("DELETE FROM game_combat_event"); + shard.assert_call("debug_engage", &["1", &wolf]); + assert!( + poll_until(POLL_TIMEOUT, || { + shard + .query_rows("SELECT damage FROM game_combat_event WHERE attacker_guid = 1") + .iter() + .any(|row| row["damage"].parse::().unwrap() >= 14) + }), + "the off-hand stream never used its 14..16 property-adjusted range" + ); + + let before = shard + .query_rows("SELECT last_offhand_swing_ms FROM game_melee_attack WHERE attacker_guid = 1") + [0]["last_offhand_swing_ms"] + .clone(); + shard.assert_sql( + "UPDATE game_item_instance SET durability = 0 WHERE owner_guid = 1 AND slot = 16", + ); + shard.assert_sql("DELETE FROM game_combat_event"); + shard.assert_sql("UPDATE game_melee_attack SET last_swing_ms = 1 WHERE attacker_guid = 1"); + assert!(poll_until(POLL_TIMEOUT, || { + !shard + .query_rows("SELECT id FROM game_combat_event WHERE attacker_guid = 1") + .is_empty() + })); + let after = shard + .query_rows("SELECT last_offhand_swing_ms FROM game_melee_attack WHERE attacker_guid = 1") + [0]["last_offhand_swing_ms"] + .clone(); + assert_eq!( + after, before, + "a broken off-hand must not advance its swing clock" + ); +} From f249dd9de4182abaa3f587f575995952be90acb0 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Mon, 7 Sep 2026 18:50:48 +0000 Subject: [PATCH 2/4] fix(items): preserve flat regeneration cadence --- gateway/src/stdb/armor.rs | 10 +- module/src/combat/folds.rs | 12 +- module/src/combat/tables.rs | 189 ++++++++++++++++++++------ module/src/creatures/cycle/ctx.rs | 37 +++-- module/src/creatures/cycle/harness.rs | 32 +++-- module/src/creatures/cycle/mod.rs | 9 +- module/tests/random_property_stats.rs | 33 ++++- 7 files changed, 240 insertions(+), 82 deletions(-) diff --git a/gateway/src/stdb/armor.rs b/gateway/src/stdb/armor.rs index 4dc7f7699..b33a334fb 100644 --- a/gateway/src/stdb/armor.rs +++ b/gateway/src/stdb/armor.rs @@ -35,7 +35,7 @@ fn aura_resistance_contribution( school_bit: u32, ) -> i32 { if eff_kind == A_MOD_RESISTANCE && (eff_p0 as u32 & school_bit) != 0 { - amount * (stacks.max(1) as i32) + amount.saturating_mul(i32::from(stacks.max(1))) } else { 0 } @@ -273,6 +273,14 @@ mod tests { assert_eq!(aura_armor_contribution(0xA1, 0x01, 160, 0), 160); // Stacking, negative (Sunder Armor-style): amount × stacks subtracts. assert_eq!(aura_armor_contribution(0xA1, 0x01, -90, 5), -450); + assert_eq!( + aura_armor_contribution(0xA1, 0x01, i32::MAX, u8::MAX), + i32::MAX + ); + assert_eq!( + aura_armor_contribution(0xA1, 0x01, i32::MIN, u8::MAX), + i32::MIN + ); // A school MASK that merely INCLUDES the armor bit still counts (mask test, not ==). assert_eq!(aura_armor_contribution(0xA1, 0x03, 50, 1), 50); // A non-armor school (eff_p0 == 0x02, e.g. holy) does NOT touch armor. diff --git a/module/src/combat/folds.rs b/module/src/combat/folds.rs index d65410b37..49900f49c 100644 --- a/module/src/combat/folds.rs +++ b/module/src/combat/folds.rs @@ -235,11 +235,13 @@ pub(crate) fn effective_agility(ctx: &ReducerContext, e: &WorldEntity) -> u32 { /// incoming hits. Clamped ≥0; no aura and no armor gear → the base value (mitigation byte-identical to /// before — baseline-safe). [entity] pub fn effective_armor(ctx: &ReducerContext, e: &WorldEntity) -> u32 { - effective_stat( - e.armor as i32, - crate::spell::resistance_bonus(ctx, e.guid, crate::spell::RESIST_ARMOR), - 0, - ) + (i64::from(e.armor) + + i64::from(crate::spell::resistance_bonus( + ctx, + e.guid, + crate::spell::RESIST_ARMOR, + ))) + .clamp(0, i64::from(u32::MAX)) as u32 } /// A unit's EFFECTIVE shield BLOCK VALUE — the flat physical damage a blocked swing absorbs: the diff --git a/module/src/combat/tables.rs b/module/src/combat/tables.rs index 7512958d1..234fd3edd 100644 --- a/module/src/combat/tables.rs +++ b/module/src/combat/tables.rs @@ -531,9 +531,9 @@ fn health_regen_per_tick(spirit: u32, level: u32) -> u32 { /// `max == 0` (creatures, which carry no power bar) yields no change. Power never exceeds `max` (the /// `.min(max)` cap) and never underflows (the `saturating_sub` on rage). /// -/// FSR (Five-Second Rule): `mana_paused` is true for 5s after the caster last spent mana — regen is -/// suppressed during that window IN OR OUT OF COMBAT (vanilla parity). After the window, mana regens -/// regardless of combat state, matching vanilla's "wait 5s and regen mid-fight" mechanic. +/// FSR (Five-Second Rule): `mana_paused` is true for 5s after the caster last spent mana. It pauses +/// the spirit-derived term in or out of combat. Flat mana-per-five continues during the window. +/// After the window, the spirit term resumes regardless of combat state. /// /// BASELINE-SAFETY: with `spirit == 0` a mana unit still regens `level/2 + 1` per tick when not /// paused; energy is unchanged by spirit; rage decays at the vanilla-approximate rate. @@ -555,7 +555,7 @@ pub fn regen_power( PowerRegenTick { in_combat, mana_paused, - mana_per_five: 0, + mana_flat_tick: 0, }, ) } @@ -563,7 +563,7 @@ pub fn regen_power( struct PowerRegenTick { in_combat: bool, mana_paused: bool, - mana_per_five: i32, + mana_flat_tick: i32, } fn regen_power_with_flat( @@ -579,18 +579,16 @@ fn regen_power_with_flat( return current; } match power_type { - pt::MANA => { + pt::MANA => apply_regen( + current, + max, if tick.mana_paused { - current + 0 } else { - apply_regen( - current, - max, - mana_regen_per_tick(spirit, level), - tick.mana_per_five, - ) - } - } + mana_regen_per_tick(spirit, level) + }, + tick.mana_flat_tick, + ), pt::ENERGY => current.saturating_add(ENERGY_TICK).min(max), pt::RAGE => { if tick.in_combat { @@ -617,7 +615,7 @@ fn regen_health_with_flat( max: u32, spirit: u32, level: u32, - health_per_five: i32, + health_flat_tick: i32, ) -> u32 { if max == 0 { return current; @@ -626,14 +624,35 @@ fn regen_health_with_flat( current, max, health_regen_per_tick(spirit, level), - health_per_five, + health_flat_tick, ) } -/// Add a points-per-five-seconds item contribution to this four-second regeneration tick. -fn apply_regen(current: u32, max: u32, base_tick: u32, per_five: i32) -> u32 { - let flat_tick = i128::from(per_five) * 4 / 5; - let tick = (i128::from(base_tick) + flat_tick).clamp(0, i128::from(u32::MAX)) as u32; +/// The scheduled window covered by one regeneration pass. +#[derive(Clone, Copy)] +pub(crate) struct RegenWindow { + now_ms: u64, + elapsed_ms: u64, +} + +impl RegenWindow { + pub(crate) fn new(now_ms: u64, elapsed_ms: u64) -> Self { + Self { now_ms, elapsed_ms } + } +} + +/// Convert a points-per-five-seconds rate without losing low rates to per-tick truncation. The +/// difference between cumulative quotients assigns each whole point to one deterministic window. +fn per_five_in_window(per_five: i32, window: RegenWindow) -> i32 { + let previous_ms = window.now_ms.saturating_sub(window.elapsed_ms); + let accrued = |at_ms: u64| (i128::from(per_five) * i128::from(at_ms)).div_euclid(5_000); + let points = accrued(window.now_ms) - accrued(previous_ms); + points.clamp(i128::from(i32::MIN), i128::from(i32::MAX)) as i32 +} + +fn apply_regen(current: u32, max: u32, base_tick: u32, flat_tick: i32) -> u32 { + let tick = + (i128::from(base_tick) + i128::from(flat_tick)).clamp(0, i128::from(u32::MAX)) as u32; current.saturating_add(tick).min(max) } @@ -642,14 +661,14 @@ fn apply_regen(current: u32, max: u32, base_tick: u32, per_five: i32) -> u32 { /// and FSR derivation live here, not inline in the tick). Power type is byte 3 of `unit_bytes_0`. /// FSR gate: mana regen is paused when `now_ms < e.mana_regen_paused_until_ms` (stamped on every /// mana-spend in the cast path). Returns the new power value (the caller writes it back only if it changed). -pub fn regen_entity_power( +pub(crate) fn regen_entity_power( e: &WorldEntity, in_combat: bool, - now_ms: u64, + window: RegenWindow, mana_per_five: i32, ) -> u32 { let power_type = (e.unit_bytes_0 >> 24) as u8; - let mana_paused = now_ms < e.mana_regen_paused_until_ms; + let mana_paused = window.now_ms < e.mana_regen_paused_until_ms; regen_power_with_flat( power_type, e.power, @@ -659,16 +678,25 @@ pub fn regen_entity_power( PowerRegenTick { in_combat, mana_paused, - mana_per_five, + mana_flat_tick: per_five_in_window(mana_per_five, window), }, ) } /// Entity-aware HEALTH-regen wrapper: reads `spirit`/`level` off the row so the `tick_creatures` health -/// pass passes ONE entity. Returns the new health value (the caller writes it back). Callers gate on -/// `health < max_health && !in_combat` before calling, as before. -pub fn regen_entity_health(e: &WorldEntity, health_per_five: i32) -> u32 { - regen_health_with_flat(e.health, e.max_health, e.spirit, e.level, health_per_five) +/// pass passes one entity. Returns the new health value for an out-of-combat unit. +pub(crate) fn regen_entity_health( + e: &WorldEntity, + health_per_five: i32, + window: RegenWindow, +) -> u32 { + regen_health_with_flat( + e.health, + e.max_health, + e.spirit, + e.level, + per_five_in_window(health_per_five, window), + ) } /// Per-tick PARTIAL health regen DURING COMBAT: `combat_regen_pct%` of the normal out-of-combat @@ -688,12 +716,29 @@ pub fn regen_health_in_combat( if max == 0 || combat_regen_pct == 0 { return current; } - let full_tick = health_regen_per_tick(spirit, level); - // Integer multiply then divide; the full_tick is already small (spirit+level+1 at low levels), - // so no overflow risk. A fractional result floors toward 0 (a 10% tick at `full_tick == 6` → 0 - // rather than rounding up — that is expected: low-level troll regen is very slow in combat). - let partial = full_tick * combat_regen_pct / 100; - (current + partial).min(max) + let partial = (i128::from(health_regen_per_tick(spirit, level)) * i128::from(combat_regen_pct) + / 100) + .clamp(0, i128::from(u32::MAX)) as u32; + apply_regen(current, max, partial, 0) +} + +/// Entity-aware in-combat health regeneration. Aura percentage applies only to natural recovery; +/// item health-per-five remains active independently. +pub(crate) fn regen_entity_health_in_combat( + e: &WorldEntity, + combat_regen_pct: u32, + health_per_five: i32, + window: RegenWindow, +) -> u32 { + let partial = + (i128::from(health_regen_per_tick(e.spirit, e.level)) * i128::from(combat_regen_pct) / 100) + .clamp(0, i128::from(u32::MAX)) as u32; + apply_regen( + e.health, + e.max_health, + partial, + per_five_in_window(health_per_five, window), + ) } #[cfg(test)] @@ -1325,11 +1370,20 @@ mod tests { 5_000, ); // Strictly inside the window → paused, no regen. - assert_eq!(regen_entity_power(&mage, false, 4_999, 0), 100); + assert_eq!( + regen_entity_power(&mage, false, RegenWindow::new(4_999, 4_000), 0), + 100 + ); // Straddling the boundary: `now_ms == paused_until_ms` is NOT `<`, so the window has just cleared. - assert_eq!(regen_entity_power(&mage, false, 5_000, 0), 100 + 26); + assert_eq!( + regen_entity_power(&mage, false, RegenWindow::new(5_000, 4_000), 0), + 100 + 26 + ); // Past the window, mid-combat → still regens (the FSR gate, not combat state, controls mana). - assert_eq!(regen_entity_power(&mage, true, 6_000, 0), 100 + 26); + assert_eq!( + regen_entity_power(&mage, true, RegenWindow::new(6_000, 4_000), 0), + 100 + 26 + ); // A rage (warrior) entity ignores the FSR field entirely — reads its own power-type byte. let warrior = entity_for_regen( @@ -1342,14 +1396,23 @@ mod tests { 10, 0, ); - assert_eq!(regen_entity_power(&warrior, false, 0, 0), 1000 - 50); // out of combat → decays - assert_eq!(regen_entity_power(&warrior, true, 0, 0), 1000); // in combat → holds + assert_eq!( + regen_entity_power(&warrior, false, RegenWindow::new(0, 4_000), 0), + 1000 - 50 + ); // out of combat → decays + assert_eq!( + regen_entity_power(&warrior, true, RegenWindow::new(0, 4_000), 0), + 1000 + ); // in combat → holds } #[test] fn regen_entity_health_wrapper_reads_spirit_and_level_off_the_row() { let e = entity_for_regen(0, 100, 1000, 0, 0, 30, 10, 0); - assert_eq!(regen_entity_health(&e, 0), 100 + (30 + 10 + 1)); // spirit + level + 1 per tick + assert_eq!( + regen_entity_health(&e, 0, RegenWindow::new(4_000, 4_000)), + 100 + (30 + 10 + 1) + ); // spirit + level + 1 per tick } #[test] @@ -1366,10 +1429,37 @@ mod tests { 10, 0, ); - assert_eq!(regen_entity_power(&mage, false, 0, 10), 134); - assert_eq!(regen_entity_health(&mage, 15), 163); - assert_eq!(regen_entity_power(&mage, false, 0, -40), 100); - assert_eq!(regen_entity_health(&mage, -100), 100); + let window = RegenWindow::new(8_000, 4_000); + assert_eq!(regen_entity_power(&mage, false, window, 10), 134); + assert_eq!(regen_entity_health(&mage, 15, window), 163); + assert_eq!(regen_entity_power(&mage, false, window, -40), 100); + assert_eq!(regen_entity_health(&mage, -100, window), 100); + + let low_rate_total: i32 = (4_000..=20_000) + .step_by(4_000) + .map(|now_ms| per_five_in_window(1, RegenWindow::new(now_ms, 4_000))) + .sum(); + assert_eq!(low_rate_total, 4); + } + + #[test] + fn flat_mana_regen_continues_during_the_five_second_rule() { + use lyracore_shared::packing::{power_type, unit_bytes_0}; + + let mage = entity_for_regen( + unit_bytes_0(1, 8, 0, power_type::MANA), + 100, + 1000, + 100, + 1000, + 40, + 10, + 10_000, + ); + assert_eq!( + regen_entity_power(&mage, false, RegenWindow::new(8_000, 4_000), 10), + 108 + ); } #[test] @@ -1540,6 +1630,15 @@ mod tests { assert_eq!(regen_health_in_combat(0, 0, 30, 10, 10), 0); } + #[test] + fn flat_health_regen_continues_in_combat_without_a_percentage_aura() { + let e = entity_for_regen(0, 100, 1000, 0, 0, 30, 10, 0); + assert_eq!( + regen_entity_health_in_combat(&e, 0, 15, RegenWindow::new(8_000, 4_000)), + 112 + ); + } + #[test] fn armor_mitigation_follows_the_vanilla_curve() { // 0 armor → no reduction (every creature target today). diff --git a/module/src/creatures/cycle/ctx.rs b/module/src/creatures/cycle/ctx.rs index a781ece3c..f28384ae9 100644 --- a/module/src/creatures/cycle/ctx.rs +++ b/module/src/creatures/cycle/ctx.rs @@ -32,11 +32,16 @@ use crate::{ /// `tick_creatures`' one call into the cycle. The adapter never leaves this module. pub(crate) fn run(ctx: &ReducerContext, tick: TickContext) -> CycleOutcome { - run_cycle(&mut CtxWorld { ctx }, tick) + let regen_window = crate::combat::RegenWindow::new( + tick.now_micros / 1_000, + (f64::from(tick.sense_secs) * 1_000.0).round() as u64, + ); + run_cycle(&mut CtxWorld { ctx, regen_window }, tick) } struct CtxWorld<'a> { ctx: &'a ReducerContext, + regen_window: crate::combat::RegenWindow, } impl CtxWorld<'_> { @@ -550,24 +555,28 @@ impl RegenSink for CtxWorld<'_> { e.guid, crate::items::EquipStat::HealthPerFive, ); - crate::combat::regen_entity_health(&e, health_per_five) + crate::combat::regen_entity_health(&e, health_per_five, self.regen_window) }) } fn combat_healed_to(&self, u: &Recovering) -> Option { - let pct = u32::try_from(crate::spell::combat_health_regen_pct(self.ctx, u.guid)) - .ok() - .filter(|pct| *pct > 0)?; + let pct = + u32::try_from(crate::spell::combat_health_regen_pct(self.ctx, u.guid)).unwrap_or(0); let e = self.ctx.db.game_world_entity().guid().find(u.guid)?; - Some(crate::combat::regen_health_in_combat( - e.health, - e.max_health, - e.spirit, - e.level, - pct, - )) + let health_per_five = crate::items::equipped_stat_bonus( + self.ctx, + e.guid, + crate::items::EquipStat::HealthPerFive, + ); + (pct > 0 || health_per_five != 0).then(|| { + crate::combat::regen_entity_health_in_combat( + &e, + pct, + health_per_five, + self.regen_window, + ) + }) } fn powered_to(&self, u: &Recovering) -> u32 { - let now_ms = (self.ctx.timestamp.to_micros_since_unix_epoch() / 1000) as u64; self.ctx .db .game_world_entity() @@ -579,7 +588,7 @@ impl RegenSink for CtxWorld<'_> { e.guid, crate::items::EquipStat::ManaPerFive, ); - crate::combat::regen_entity_power(&e, u.in_combat, now_ms, mana_per_five) + crate::combat::regen_entity_power(&e, u.in_combat, self.regen_window, mana_per_five) }) } // ponytail: one row write per unit, where the old pass wrote health and power in two separate diff --git a/module/src/creatures/cycle/harness.rs b/module/src/creatures/cycle/harness.rs index 76b2a36af..160a26d3f 100644 --- a/module/src/creatures/cycle/harness.rs +++ b/module/src/creatures/cycle/harness.rs @@ -5814,9 +5814,16 @@ fn the_production_adapter_is_the_pass_through_the_harness_assumes() { for (signature, want) in [ ( "pub(crate) fn run(ctx: &ReducerContext, tick: TickContext) -> CycleOutcome {", - "{ run_cycle(&mut CtxWorld { ctx }, tick) }", + concat!( + "{ let regen_window = crate::combat::RegenWindow::new( tick.now_micros / 1_000, ", + "(f64::from(tick.sense_secs) * 1_000.0).round() as u64, ); run_cycle(&mut ", + "CtxWorld { ctx, regen_window }, tick) }", + ), + ), + ( + "struct CtxWorld<'a> {", + "{ ctx: &'a ReducerContext, regen_window: crate::combat::RegenWindow, }", ), - ("struct CtxWorld<'a> {", "{ ctx: &'a ReducerContext, }"), ( "impl CtxWorld<'_> {", concat!( @@ -5976,18 +5983,21 @@ fn the_production_adapter_is_the_pass_through_the_harness_assumes() { ".map_or(u.health, |e| { let health_per_five = ", "crate::items::equipped_stat_bonus( self.ctx, e.guid, ", "crate::items::EquipStat::HealthPerFive, ); ", - "crate::combat::regen_entity_health(&e, health_per_five) }) } fn ", + "crate::combat::regen_entity_health(&e, health_per_five, self.regen_window) ", + "}) } fn ", "combat_healed_to(&self, u: &Recovering) -> Option { let pct = ", - "u32::try_from(crate::spell::combat_health_regen_pct(self.ctx, u.guid)) .ok() ", - ".filter(|pct| *pct > 0)?; let e = ", - "self.ctx.db.game_world_entity().guid().find(u.guid)?; ", - "Some(crate::combat::regen_health_in_combat( e.health, e.max_health, e.spirit, ", - "e.level, pct, )) } fn powered_to(&self, u: &Recovering) -> u32 { let now_ms = ", - "(self.ctx.timestamp.to_micros_since_unix_epoch() / 1000) as u64; self.ctx .db ", - ".game_world_entity() .guid() .find(u.guid) .map_or(u.power, |e| { ", + "u32::try_from(crate::spell::combat_health_regen_pct(self.ctx, u.guid))", + ".unwrap_or(0); let e = self.ctx.db.game_world_entity().guid().find(u.guid)?; ", + "let health_per_five = crate::items::equipped_stat_bonus( self.ctx, e.guid, ", + "crate::items::EquipStat::HealthPerFive, ); (pct > 0 || health_per_five != 0)", + ".then(|| { crate::combat::regen_entity_health_in_combat( &e, pct, ", + "health_per_five, self.regen_window, ) }) } fn powered_to(&self, u: ", + "&Recovering) -> u32 { self.ctx .db .game_world_entity() .guid() .find(u.guid) ", + ".map_or(u.power, |e| { ", "let mana_per_five = crate::items::equipped_stat_bonus( self.ctx, e.guid, ", "crate::items::EquipStat::ManaPerFive, ); ", - "crate::combat::regen_entity_power(&e, u.in_combat, now_ms, mana_per_five) }) } fn ", + "crate::combat::regen_entity_power(&e, u.in_combat, self.regen_window, ", + "mana_per_five) }) } fn ", "restore(&mut self, guid: u64, health: Option, power: Option) { let ", "entities = self.ctx.db.game_world_entity(); let Some(mut live) = ", "entities.guid().find(guid) else { return; }; let (next_health, next_power) = ", diff --git a/module/src/creatures/cycle/mod.rs b/module/src/creatures/cycle/mod.rs index e07c70552..6e925187b 100644 --- a/module/src/creatures/cycle/mod.rs +++ b/module/src/creatures/cycle/mod.rs @@ -333,8 +333,8 @@ pub(crate) trait RegenSink { fn recovering(&self, flagged: &[u64]) -> Vec; /// The health this unit recovers to out of combat, at its own spirit- and level-scaled rate. fn healed_to(&self, u: &Recovering) -> u32; - /// The health it recovers to WHILE FIGHTING, which only a combat-regen aura grants at all. - /// `None` is the ordinary answer: a unit in a fight heals nothing. + /// The health it recovers to while fighting through a combat-regen aura or flat item HP5. + /// `None` means the unit has neither source. fn combat_healed_to(&self, u: &Recovering) -> Option; /// The power it ticks to, up or down: mana once the five-second rule lapses, energy always, /// rage decaying out of combat. @@ -1726,9 +1726,8 @@ fn loiter(w: &mut W, tick: &TickContext, c: &IdleCreature, home: Ho } /// REGENERATION — every unit recovers health and power once per sense firing. Out of combat a hurt -/// unit heals freely; in a fight it heals only through a combat-regen aura, because fighting is what -/// stops regeneration. That gate is the whole of the cycle's rule here — the RATES are -/// `combat::tables`', and PLAYERS are candidates too, since nothing else ticks their bars. +/// unit heals freely; in a fight only combat-regen auras and flat item HP5 remain active. The rates +/// are `combat::tables`', and players are candidates too, since nothing else ticks their bars. /// /// It runs AFTER chase, so the gate sees a creature that closed on its victim this very firing and /// leaves it unhealed, and BEFORE rout and fear, whose legs it must not revert: only the two bars diff --git a/module/tests/random_property_stats.rs b/module/tests/random_property_stats.rs index 26c82f861..8322e0283 100644 --- a/module/tests/random_property_stats.rs +++ b/module/tests/random_property_stats.rs @@ -129,6 +129,29 @@ fn direct_pools_and_regeneration_follow_working_equipment() { let regenerated = world_row(&shard); assert_eq!(regenerated["health"], "144"); assert_eq!(regenerated["power"], "124"); + + shard.assert_sql("UPDATE game_world_entity SET power = 100, mana_regen_paused_until_ms = 9999999999999 WHERE guid = 1"); + assert!( + poll_until(POLL_TIMEOUT, || world_row(&shard)["power"] != "100"), + "flat mana regeneration stopped during the five-second rule" + ); + assert_eq!(world_row(&shard)["power"], "108"); + + shard.assert_call("debug_spawn_at_feet", &["1", "51000", "1"]); + let wolf = shard + .query_rows("SELECT guid FROM game_world_entity WHERE entry = 51000 AND dead = false") + .pop() + .unwrap()["guid"] + .clone(); + shard.assert_sql( + "UPDATE game_world_entity SET health = 100, power = 100000, godmode = true WHERE guid = 1", + ); + shard.assert_call("debug_engage", &[&wolf, "1"]); + assert!( + poll_until(POLL_TIMEOUT, || world_row(&shard)["health"] != "100"), + "flat health regeneration stopped in combat" + ); + assert_eq!(world_row(&shard)["health"], "112"); } #[test] @@ -144,6 +167,7 @@ fn defenses_and_each_magic_resistance_reach_combat_callers() { (11, 12, 16), (12, 14, 32), (13, 16, 64), + (14, 80, 0), (22, 4, 0), (23, 200, 0), (24, 300, 0), @@ -151,7 +175,7 @@ fn defenses_and_each_magic_resistance_reach_combat_callers() { ], ); shard.assert_sql(&format!( - "UPDATE game_item_template SET random_property = {POOL}, holy_res = 1, fire_res = 2, nature_res = 3, frost_res = 4, shadow_res = 5, arcane_res = 6 WHERE entry = 50053" + "UPDATE game_item_template SET random_property = {POOL}, stat_armor = 920, holy_res = 1, fire_res = 2, nature_res = 3, frost_res = 4, shadow_res = 5, arcane_res = 6 WHERE entry = 50053" )); shard.assert_call("debug_spawn_at_feet", &["1", "51000", "1"]); let wolf = shard @@ -160,6 +184,7 @@ fn defenses_and_each_magic_resistance_reach_combat_callers() { .unwrap()["guid"] .clone(); + shard.assert_sql("UPDATE game_world_entity SET armor = 40 WHERE guid = 1"); shard.assert_call("debug_compute_swing", &[&wolf, "1"]); let bare = readout(&shard, "swing"); shard.assert_call("debug_equip_offhand", &["1", "50053"]); @@ -178,6 +203,11 @@ fn defenses_and_each_magic_resistance_reach_combat_callers() { bare["parry_bp"].parse::().unwrap() + 340 ); assert_eq!(equipped["block_bp"], "940"); + assert_eq!(bare["mitigation_pct"], "7"); + assert_eq!( + equipped["mitigation_pct"], "68", + "40 base + 920 template + 80 property armor must mitigate once" + ); shard.assert_sql(&format!( "UPDATE game_world_entity SET level = 10 WHERE guid = {wolf}" @@ -211,6 +241,7 @@ fn defenses_and_each_magic_resistance_reach_combat_callers() { assert_eq!(broken["dodge_bp"], bare["dodge_bp"]); assert_eq!(broken["parry_bp"], bare["parry_bp"]); assert_eq!(broken["block_bp"], "0"); + assert_eq!(broken["mitigation_pct"], "3"); shard.assert_call("debug_compute_spell", &[&wolf, "1", "5090211"]); assert_eq!(readout(&shard, "spell")["spell_hit_normal"], "100"); } From 7eb7046016db14e76f89b3974b5cadfe19977d76 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Mon, 7 Sep 2026 19:49:15 +0000 Subject: [PATCH 3/4] test(items): check regeneration in the sampled row --- module/tests/random_property_stats.rs | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/module/tests/random_property_stats.rs b/module/tests/random_property_stats.rs index 8322e0283..4c2d807fd 100644 --- a/module/tests/random_property_stats.rs +++ b/module/tests/random_property_stats.rs @@ -122,20 +122,16 @@ fn direct_pools_and_regeneration_follow_working_equipment() { assert!( poll_until(POLL_TIMEOUT, || { let row = world_row(&shard); - row["health"] != "100" || row["power"] != "100" + row["health"] == "144" && row["power"] == "124" }), - "the scheduled regeneration pass did not run" + "the scheduled regeneration pass did not apply the expected health and mana" ); - let regenerated = world_row(&shard); - assert_eq!(regenerated["health"], "144"); - assert_eq!(regenerated["power"], "124"); shard.assert_sql("UPDATE game_world_entity SET power = 100, mana_regen_paused_until_ms = 9999999999999 WHERE guid = 1"); assert!( - poll_until(POLL_TIMEOUT, || world_row(&shard)["power"] != "100"), + poll_until(POLL_TIMEOUT, || world_row(&shard)["power"] == "108"), "flat mana regeneration stopped during the five-second rule" ); - assert_eq!(world_row(&shard)["power"], "108"); shard.assert_call("debug_spawn_at_feet", &["1", "51000", "1"]); let wolf = shard @@ -148,10 +144,9 @@ fn direct_pools_and_regeneration_follow_working_equipment() { ); shard.assert_call("debug_engage", &[&wolf, "1"]); assert!( - poll_until(POLL_TIMEOUT, || world_row(&shard)["health"] != "100"), + poll_until(POLL_TIMEOUT, || world_row(&shard)["health"] == "112"), "flat health regeneration stopped in combat" ); - assert_eq!(world_row(&shard)["health"], "112"); } #[test] From 0e9e855a8a2aa8b7c008aa4e98e0e0418d8af50a Mon Sep 17 00:00:00 2001 From: T3 Code Date: Mon, 7 Sep 2026 21:09:50 +0000 Subject: [PATCH 4/4] fix(gateway): saturate positive armor aura totals --- gateway/src/stdb/armor.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/gateway/src/stdb/armor.rs b/gateway/src/stdb/armor.rs index b33a334fb..e130537d2 100644 --- a/gateway/src/stdb/armor.rs +++ b/gateway/src/stdb/armor.rs @@ -112,7 +112,7 @@ pub(crate) fn aura_armor_positive(db: &RemoteTables, guid: u64) -> u32 { .filter(|a| a.target_guid == guid) .map(|a| aura_armor_contribution(a.eff_kind, a.eff_p0, a.amount, a.stacks)) .filter(|&c| c > 0) - .sum::() as u32 + .fold(0u32, |total, amount| total.saturating_add(amount as u32)) } /// Effective armor followed by Holy, Fire, Nature, Frost, Shadow and Arcane resistance. @@ -263,6 +263,12 @@ mod tests { assert!(poll_until(POLL_TIMEOUT, || { coordinator.effective_magic_resistances(1) == [0; 6] })); + shard.assert_call("debug_fill_aura_slots", &["1", "1", "false", "3"]); + shard.assert_sql("UPDATE game_aura SET eff_kind = 161, eff_p0 = 1, amount = 2147483647, stacks = 255 WHERE target_guid = 1"); + assert!(poll_until(POLL_TIMEOUT, || { + let guard = coordinator.0.coord(); + aura_armor_positive(&guard.conn.db, 1) == u32::MAX + })); } #[test]