diff --git a/crates/benilla-app/src/addon_harness/tick_bench_tests.rs b/crates/benilla-app/src/addon_harness/tick_bench_tests.rs index e5741cd1..95808877 100644 --- a/crates/benilla-app/src/addon_harness/tick_bench_tests.rs +++ b/crates/benilla-app/src/addon_harness/tick_bench_tests.rs @@ -1525,6 +1525,275 @@ fn a_cursor_seated_sign_plate_prices_its_own_re_anchor() { } } +/// **grom's whole macro line**, driven the way the game drives it, on his own interface: +/// +/// ```text +/// #showtooltip +/// /cast [noslamclip, stance:1/3] Slam; [slamclip, stance:1/3] Heroic Strike; [stance:2] Shield Slam +/// ``` +/// +/// The sibling test below drives the *conditional*. This one drives the **path**, because the +/// conditional answering `true` in isolation is what let two sessions of chasing this go past the +/// real question — which is whether that answer survives the eight links between a key press and a +/// spell id on the wire: +/// +/// `UseAction` on a macro slot → `ui_macro::run_macro` fires `EXECUTE_CHAT_LINE` per line +/// (`0x4f14e0`, reached only from `UseAction` at `0x4e6098`) → the stock `ChatFrame_OnEvent` arm +/// sends the line through its edit box (`ChatFrame.lua:1343`) → `ChatEdit_ParseText` finds +/// `SlashCmdList["CAST"]` → **CleveRoids' hook** → `GetParsedMsg` splits the three clauses → +/// `DoWithConditionals` walks the conditionals through `CleveRoids.Keywords` → `noslamclip` → +/// `ValidateNoSlamClip` → `CastSpellByName`. +/// +/// Both arms are asserted, and the second is the one that makes the first mean anything: at the +/// top of a swing the line casts **Slam**, and 1.6 s into a 2.70 s swing — 59% elapsed, past the +/// 44% window — it casts **Heroic Strike**. A chain that always answered one or the other would +/// pass half of this. +#[test] +fn the_whole_slam_macro_line_picks_slam_early_and_heroic_strike_late() { + let _data = benilla_formats::wow_data_or_skip!(); + let Some(root) = crate::ui_script::addons::root() else { + eprintln!("slam-macro probe: no benilla-config/AddOns — nothing to ask"); + return; + }; + let mut s = load_the_players_ui(&root); + seat_a_warrior_mid_fight(&mut s, false); + + let ask = |s: &mut UiScript, chunk: &str| -> String { + s.eval::(&format!( + "local ok, v = pcall(function() return {chunk} end) return tostring(v)" + )) + .unwrap_or_else(|e| format!("<{e}>")) + }; + + // The three inputs the window is computed from, each named so a regression says which moved. + assert_eq!( + ask(&mut s, "CleveRoids.GetCurrentShapeshiftIndex()"), + "1", + "Battle Stance, which is the `stance:1/3` half of every clause" + ); + assert_eq!( + ask(&mut s, "CleveRoids.GetSlamCastTime()"), + "1.5", + "the cast time came off the scanning tooltip, not the addon's 2.5 s fallback — against a \ + 2.70 s swing the fallback leaves a 7% window instead of 44%, which in play reads as \ + `[noslamclip]` never being true at all" + ); + + // **Before the first swing of the session**, and this is the state a player meets when he + // opens on a mob with this macro. `st_timerMax` is SP_SwingTimer's `ADDON_LOADED` latch, which + // is whatever `UnitAttackSpeed` answered before `feed_char`'s first push — the + // `VANILLA_SWING_MS` stand-in — and `st_timer` is still 0, so the whole swing reads as + // elapsed: + // + // window = (2.0 - 1.5) / 2.0 = 25%, elapsed = 100% -> `[noslamclip]` false -> Heroic Strike. + // + // That is correct of the numbers it has and wrong about the weapon, and it heals on the first + // auto-attack that lands (below). It is also why the latch is worth a log line + // (`char_stats::with_unit_stats`): if the sheet never arrives, this *is* the session. + assert_eq!(ask(&mut s, "st_timerMax"), "2"); + assert_eq!(ask(&mut s, "CleveRoids.GetSwingPercentElapsed()"), "100"); + assert_eq!(ask(&mut s, "CleveRoids.GetSlamWindowPercent()"), "25"); + assert_eq!( + ask(&mut s, "CleveRoids.ValidateNoSlamClip()"), + "false", + "with no swing timer running yet the macro picks Heroic Strike, and nothing is raised \ + about it" + ); + + const LINE: &str = "/cast [noslamclip, stance:1/3] Slam; [slamclip, stance:1/3] Heroic Strike; [stance:2] Shield Slam"; + /// Slam, Heroic Strike: the two ids this line can resolve to. + const SLAM: u32 = 1464; + const HEROIC_STRIKE: u32 = 78; + + // One auto-attack lands: `st_timer` is full, so none of the swing has elapsed. + s.fire_event( + "CHAT_MSG_COMBAT_SELF_HITS", + vec![benilla_ui::script::ScriptValue::Str( + "You hit Apprentice Training Dummy for 46.".into(), + )], + ); + assert_eq!( + ask(&mut s, "st_timerMax"), + "2.7", + "the swing reached `SP_ST_OnEvent` with a parsable `arg1` and `ResetTimer` replaced the \ + load-time latch with the real weapon speed" + ); + assert_eq!(ask(&mut s, "CleveRoids.GetSwingPercentElapsed()"), "0"); + assert_eq!( + ask(&mut s, "CleveRoids.GetSlamWindowPercent()"), + "44.444444444444", + "(2.70 - 1.50) / 2.70, off the real weapon this time" + ); + let _ = s.take_spell_casts(); + s.fire_event( + "EXECUTE_CHAT_LINE", + vec![benilla_ui::script::ScriptValue::Str(LINE.into())], + ); + assert_eq!( + s.take_spell_casts(), + vec![SLAM], + "at the top of a swing the line has to reach Slam — if this is Heroic Strike, read \ + `GetSwingPercentElapsed` and `GetSlamWindowPercent` above before anything else" + ); + + // 1.6 s later the swing is 59% elapsed, past the window: the second clause takes it. + s.tick(1.6); + assert_eq!( + ask(&mut s, "CleveRoids.ValidateNoSlamClip()"), + "false", + "59% elapsed is past the 44% window, so casting Slam now would clip the auto-attack" + ); + let _ = s.take_spell_casts(); + s.fire_event( + "EXECUTE_CHAT_LINE", + vec![benilla_ui::script::ScriptValue::Str(LINE.into())], + ); + assert_eq!( + s.take_spell_casts(), + vec![HEROIC_STRIKE], + "late in the swing the line has to fall through to Heroic Strike" + ); +} + +/// The same line with an **off-hand weapon**, which takes a different road through +/// SP_SwingTimer and is the one shape that can strand the main-hand timer. +/// +/// `CheckDamageSource` returns `"MAIN"` on its first line for a two-hander +/// (`SP_SwingTimer.lua:339`), so none of its damage arithmetic runs. Dual-wielding, it instead +/// weighs the hit against `UnitDamage`'s two ranges scaled by the target's armor — and +/// `UnitArmor("target")` answers 0 on any 1.12 client, ours included, because another unit's +/// resistances are not in the public descriptor (the addon says as much at +/// `SP_SwingTimer.lua:299`). So every hit reads `"UNKNOWN"`, and the fall-through is +/// `ClosestSwing == true or HeroicStrikeQueued()` — the first of which is a comparison against a +/// *function* and can never be true, and the second of which is true whenever a Heroic Strike is +/// queued, which this very macro leaves queued. `ResetTimer(true)` then refills the **off-hand** +/// timer and leaves `st_timer` running down to 0, where `GetSwingPercentElapsed` reads 100 and +/// `[noslamclip]` is false for good. +/// +/// That loop is the addon's own and reproduces on any client; what this test pins is that we do +/// not *enter* it when the player is two-handed. `has_offhand` is the only input that decides +/// (`ui_char::combat_stats` reads the off-hand slot's template class, so a shield is not a +/// weapon), and `UnitAttackSpeed`'s second return is `nil` on it — which is the whole of +/// `isDualWield()`. +#[test] +fn the_slam_macro_line_holds_up_with_an_off_hand_weapon() { + let _data = benilla_formats::wow_data_or_skip!(); + let Some(root) = crate::ui_script::addons::root() else { + return; + }; + let mut s = load_the_players_ui(&root); + seat_a_warrior_mid_fight(&mut s, true); + let ask = |s: &mut UiScript, chunk: &str| -> String { + s.eval::(&format!( + "local ok, v = pcall(function() return {chunk} end) return tostring(v)" + )) + .unwrap_or_else(|e| format!("<{e}>")) + }; + assert_eq!( + ask(&mut s, "select(2, UnitAttackSpeed('player'))"), + "1.8", + "an off-hand weapon answers a second speed, and that *is* `isDualWield()`" + ); + s.fire_event( + "CHAT_MSG_COMBAT_SELF_HITS", + vec![benilla_ui::script::ScriptValue::Str( + "You hit Apprentice Training Dummy for 46.".into(), + )], + ); + assert_eq!( + ask(&mut s, "st_timerMax"), + "2.6", + "the main-hand timer still armed: with nothing in an action slot to answer \ + `HeroicStrikeQueued()`, the UNKNOWN branch falls through to `ResetTimer(false)`" + ); + let _ = s.take_spell_casts(); + s.fire_event( + "EXECUTE_CHAT_LINE", + vec![benilla_ui::script::ScriptValue::Str( + "/cast [noslamclip, stance:1/3] Slam; [slamclip, stance:1/3] Heroic Strike".into(), + )], + ); + assert_eq!(s.take_spell_casts(), vec![1464]); +} + +/// A warrior in Battle Stance with a target, Slam/Heroic Strike/Shield Slam known and tooltipped, +/// and his own 2.70 s main hand — what the live feeds push and `load_the_players_ui` does not. +/// `offhand` adds a 1.80 s off-hand weapon, which is what `isDualWield()` reads. +fn seat_a_warrior_mid_fight(s: &mut UiScript, offhand: bool) { + s.set_player_combat_stats(Some(benilla_ui::script::UnitCombatStats { + main_attack_time_ms: if offhand { 2600 } else { 2700 }, + offhand_attack_time_ms: 1800, + has_offhand: offhand, + min_damage: 41.0, + max_damage: 51.0, + min_offhand_damage: 20.0, + max_offhand_damage: 30.0, + ..Default::default() + })); + for (id, name, cast) in [ + (1464u32, "Slam", Some("1.5 sec cast".to_string())), + (78, "Heroic Strike", None), + (23922, "Shield Slam", None), + ] { + s.set_spell_tooltip( + id, + benilla_ui::script::SpellTooltipView { + name: name.into(), + rank: Some("Rank 1".into()), + cost: Some("15 Rage".into()), + range: Some("Melee Range".into()), + cast_time: cast, + description: "Strikes the opponent.".into(), + ..Default::default() + }, + ); + } + s.set_spellbook(benilla_ui::script::SpellBookState { + tabs: Vec::new(), + slots: [ + (1464u32, "Slam"), + (78, "Heroic Strike"), + (23922, "Shield Slam"), + ] + .iter() + .map(|(id, name)| benilla_ui::script::SpellSlotView { + spell_id: *id, + name: (*name).into(), + ..Default::default() + }) + .collect(), + }); + // The three warrior stances, Battle active: `GetCurrentShapeshiftIndex` walks + // `GetShapeshiftFormInfo` for the active one, and every clause of his macro gates on it. + s.set_shapeshift_forms( + [(2457u32, true), (71, false), (2458, false)] + .iter() + .map(|(id, active)| benilla_ui::script::ShapeshiftFormView { + spell_id: *id, + texture: Some(format!("Interface\\Icons\\Stance_{id}")), + name: format!("Stance {id}"), + active: *active, + castable: true, + cooldown: None, + }) + .collect(), + ); + s.fire_event("UPDATE_SHAPESHIFT_FORMS", vec![]); + s.set_unit( + "target", + Some(UnitState { + exists: true, + has_object: true, + name: Some("Apprentice Training Dummy".into()), + health: 100, + max_health: 100, + level: 14, + guid: 0x7001, + ..Default::default() + }), + ); +} + /// SuperCleveRoidMacros' `[noslamclip]` conditional, end to end, on grom's own interface. /// /// He reports that `/cast [noslamclip, stance:1/3] Slam; [slamclip, stance:1/3] Heroic Strike` diff --git a/crates/benilla-app/src/ui_char.rs b/crates/benilla-app/src/ui_char.rs index bfbd1d96..db90cd34 100644 --- a/crates/benilla-app/src/ui_char.rs +++ b/crates/benilla-app/src/ui_char.rs @@ -1004,6 +1004,40 @@ pub(crate) fn fire_stat_transitions( } } +/// Push the player's own combat sheet into a freshly built VM, **before its addon walk**. +/// +/// `load_ingame_ui_on_world_entry` seeds the player's roster record and unit snapshot for exactly +/// this reason — an addon reads them at file scope — and the sheet belongs in the same seat, +/// because the one number everything melee divides by is latched there. SP_SwingTimer caches +/// `UnitAttackSpeed("player")` into `st_timerMax` in its `ADDON_LOADED` arm behind an +/// `if not st_timerMax`, and CleveRoids computes its `[noslamclip]` window from that cache rather +/// than from the verb, so a stand-in read at load is the window for the whole first fight: +/// (2.0 - 1.5)/2.0 = 25% where the player's 2.70 s weapon gives 44%, with the swing reading 100% +/// elapsed until the first auto-attack lands and `ResetTimer` replaces it. +/// +/// A fresh login has no self store yet — the create block has not arrived, which is why the record +/// comes off the roster — and there is nothing to seed; `feed_char` pushes a frame or two later +/// and the latch heals on the first swing. A `/reload`, which is the common case for an addon +/// author and for anyone mid-fight, has the store and gets the real weapon. +pub(crate) fn seed_combat_stats(world: &mut World, script: &mut UiScript) { + // Every resource optional and read before the `SystemState` is built: a world-entry test + // world holds neither `Items` nor `NetCommands`, and a `SystemState` over a missing `Res` + // panics on `get` rather than answering `None`. + if !world.contains_resource::() || !world.contains_resource::() { + return; + } + let mut state = bevy::ecs::system::SystemState::<( + crate::items::Inventory, + Res, + Res, + )>::new(world); + let (inv, items, commands) = state.get(world); + let Some(store) = inv.self_store.iter().next() else { + return; + }; + script.set_player_combat_stats(Some(combat_stats(store, &inv.objects, &items, &commands))); +} + pub(crate) fn feed_char( // `ChrClasses.dbc` field 16, the relic flag `find_equip_slot` reads; absent, no class has one. classes: Option>, @@ -1435,6 +1469,8 @@ mod tests { /// `PLAYER_FIELD_INV_SLOT_HEAD` plus the main hand's two words (`EQUIPMENT_SLOT_MAINHAND` is /// 0-based 15, live id 16). const F_MAIN_HAND: u16 = benilla_protocol::field::FIELD_PLAYER_INV_SLOT_HEAD + 2 * 15; + /// `UNIT_FIELD_BASEATTACKTIME` (`OBJECT_END+0x78`), the main hand's slot. + const F_BASE_ATTACK_TIME: u16 = benilla_protocol::field::FIELD_UNIT_BASEATTACKTIME; /// `ITEM_FIELD_DURABILITY` / `ITEM_FIELD_MAXDURABILITY`, absolute descriptor indices. const F_ITEM_DURABILITY: u16 = 46; const F_ITEM_MAXDURABILITY: u16 = 47; @@ -1475,6 +1511,10 @@ mod tests { (F_MAIN_HAND, SWORD as u32), (F_MAIN_HAND + 1, (SWORD >> 32) as u32), (F_PLAYER_BYTES_3, 8 << 24), + // A 2.70 s main hand and no off hand, which + // `the_players_swing_speed_reaches_the_verb_through_the_feed` reads back through + // the verb. The other tests here price repairs and do not look at it. + (F_BASE_ATTACK_TIME, 2700), ])), )); let mut sword = crate::items::test_template("Sword"); @@ -1612,6 +1652,39 @@ mod tests { ); } + /// `UnitAttackSpeed("player")` answers the store's own `UNIT_FIELD_BASEATTACKTIME`, through + /// the real feed, on the frame the store arrives. + /// + /// This is the one assertion the VM-side tests could not make, and the gap cost two sessions: + /// every swing-timer test seats the sheet with `set_player_combat_stats` and then asks, which + /// proves the *verb* and says nothing about whether anything ever calls the setter. The whole + /// `[noslamclip]` chain divides by this number — SP_SwingTimer latches it into `st_timerMax` + /// at `ADDON_LOADED` and CleveRoids computes its window from that — so a feed that never + /// pushed would leave the stand-in in place for the session and the macro's window wrong, with + /// nothing raised anywhere. + #[test] + fn the_players_swing_speed_reaches_the_verb_through_the_feed() { + // The store carries the wire's own `UNIT_FIELD_BASEATTACKTIME`: 2700 ms, no off hand. + let mut app = world_wearing_a_damaged_sword(); + app.world_mut().run_system_once(feed_char).unwrap(); + let script = app.world().non_send_resource::(); + assert_eq!( + script + .eval::(r#"return (UnitAttackSpeed("player"))"#) + .expect("UnitAttackSpeed answers"), + 2.7, + "the store's own base attack time has to reach Lua, not the `VANILLA_SWING_MS` \ + stand-in `UnitCombatStats::default()` holds before the first push" + ); + assert!( + script + .eval::(r#"return select(2, UnitAttackSpeed("player")) == nil"#) + .expect("the second return"), + "no off-hand weapon is `nil`, not a number — SP_SwingTimer's `isDualWield()` is \ + exactly this read, and a false positive sends its timer resets to the wrong hand" + ); + } + /// The equipped band's repair quote is `0x4faf30`'s own number, not 0 — which is what the /// stock paper-doll tooltip's "Repair Cost" line and pfUI's durability panel both read. With /// no vendor open it is the full list price, because `0x4faf30` takes a discount only once it diff --git a/crates/benilla-app/src/ui_script/lifecycle.rs b/crates/benilla-app/src/ui_script/lifecycle.rs index 3ce02e6b..9da3c2a9 100644 --- a/crates/benilla-app/src/ui_script/lifecycle.rs +++ b/crates/benilla-app/src/ui_script/lifecycle.rs @@ -815,6 +815,9 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) { { script.set_unit("player", Some(seat)); } + // …and his combat sheet, whose swing speed an addon latches at `ADDON_LOADED` and never + // re-reads. Absent on a fresh login, where there is no self store yet. + crate::ui_char::seed_combat_stats(world, &mut script); // The addon version gate, read from the persisted base the fold above just made current, so a // character-screen "Load out of date AddOns" click counts. A bare test world is check on. let version_check = world diff --git a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs index 29afbe5e..137b2b81 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs @@ -111,7 +111,7 @@ const FIELD_UNIT_VIRTUAL_ITEM_INFO: u16 = 40; /// Not `FIELD_PLAYER_BYTES_2`. const FIELD_UNIT_BYTES_2: u16 = 164; // UNIT combat and stat block: offset from OBJECT_END, then the wire type. -const FIELD_UNIT_BASEATTACKTIME: u16 = 126; // OBJECT_END+0x78; 2 slots [main, offhand], ms, INT +pub const FIELD_UNIT_BASEATTACKTIME: u16 = 126; // OBJECT_END+0x78; 2 slots [main, offhand], ms, INT const FIELD_UNIT_RANGEDATTACKTIME: u16 = 128; // +0x7A, INT const FIELD_UNIT_MINDAMAGE: u16 = 134; // +0x80, FLOAT const FIELD_UNIT_MAXDAMAGE: u16 = 135; // +0x81, FLOAT @@ -188,10 +188,10 @@ pub mod field { FIELD_CORPSE_DYNAMIC_FLAGS, FIELD_GAMEOBJECT_DYN_FLAGS, FIELD_GAMEOBJECT_STATE, FIELD_PLAYER_FIELD_COINAGE, FIELD_PLAYER_FLAGS, FIELD_PLAYER_INV_SLOT_HEAD, FIELD_PLAYER_QUEST_LOG_1_1, FIELD_PLAYER_SKILL_INFO_1_1, FIELD_UNIT_AURA, - FIELD_UNIT_AURAAPPLICATIONS, FIELD_UNIT_AURAFLAGS, FIELD_UNIT_BYTES_1, - FIELD_UNIT_CHANNEL_SPELL, FIELD_UNIT_DYNAMIC_FLAGS, FIELD_UNIT_FACTIONTEMPLATE, - FIELD_UNIT_FLAGS, FIELD_UNIT_HEALTH, FIELD_UNIT_LEVEL, FIELD_UNIT_MAXHEALTH, - FIELD_UNIT_MOUNTDISPLAYID, FIELD_UNIT_NPC_FLAGS, + FIELD_UNIT_AURAAPPLICATIONS, FIELD_UNIT_AURAFLAGS, FIELD_UNIT_BASEATTACKTIME, + FIELD_UNIT_BYTES_1, FIELD_UNIT_CHANNEL_SPELL, FIELD_UNIT_DYNAMIC_FLAGS, + FIELD_UNIT_FACTIONTEMPLATE, FIELD_UNIT_FLAGS, FIELD_UNIT_HEALTH, FIELD_UNIT_LEVEL, + FIELD_UNIT_MAXHEALTH, FIELD_UNIT_MOUNTDISPLAYID, FIELD_UNIT_NPC_FLAGS, }; } diff --git a/crates/benilla-ui/src/script/char_stats.rs b/crates/benilla-ui/src/script/char_stats.rs index 1cc2b174..9d292b6b 100644 --- a/crates/benilla-ui/src/script/char_stats.rs +++ b/crates/benilla-ui/src/script/char_stats.rs @@ -427,14 +427,52 @@ fn with_unit_stats( token: &Option, f: impl FnOnce(&UnitCombatStats) -> T, ) -> T { - let model = lua.app_data_ref::().expect("model app_data"); let absent = UnitCombatStats::default(); - let pushed = match token.as_deref() { - Some("player") => model.player_combat_stats.as_ref(), - Some("pet") => model.pet_combat_stats.as_ref(), - _ => None, - }; - f(pushed.unwrap_or(&absent)) + { + let model = lua.app_data_ref::().expect("model app_data"); + let pushed = match token.as_deref() { + Some("player") => model.player_combat_stats.as_ref(), + Some("pet") => model.pet_combat_stats.as_ref(), + _ => None, + }; + if let Some(stats) = pushed { + return f(stats); + } + } + // No sheet for this token: the answer is the stand-in, and for `"player"` that is worth one + // line in the log. + // + // Every addon that reads a swing speed, a damage range or an armour value divides by it, and + // several latch it: SP_SwingTimer caches `UnitAttackSpeed("player")` into `st_timerMax` at + // `ADDON_LOADED` behind an `if not st_timerMax` that a number satisfies, and CleveRoids + // computes its whole `[noslamclip]` window from that cache. A stand-in read there is wrong by + // the ratio of the real weapon to the vanilla 2000 — (2.0 - 1.5)/2.0 = 25% where the player's + // 2.70 s weapon gives 44% — and nothing anywhere raises. The window between a VM's birth and + // the first `feed_char` push always contains the addon walk, so one such read is *expected*; + // what this line is here to catch is the case where it never stops, which no test of the verb + // can see because every one of them seats the sheet first. + // + // Once per VM per token, through the same channel `/errors` lists: a per-call warning on a + // verb pfUI polls four times a second would be the log's whole content. + if let Some(token) = token.as_deref().filter(|t| *t == "player" || *t == "pet") { + let first = { + let mut model = lua.app_data_mut::().expect("model app_data"); + model.stats_stand_in_warned.insert(token.to_string()) + }; + if first { + crate::script::diagnostics::record_warning( + lua, + &format!( + "a combat-stats verb answered for \"{token}\" before anything pushed that \ + unit's sheet, so it answered the stand-in ({} ms swing, zero damage). \ + Expected once at load; if it is the last word for the session, `feed_char` \ + never pushed and every addon dividing by a swing speed is wrong.", + VANILLA_SWING_MS + ), + ); + } + } + f(&absent) } /// The clamp `UnitDefense` (`0x519298`) and each hand of `UnitAttackBothHands` (`0x5188a7`) apply: diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 363100ec..9c33baa2 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -1449,6 +1449,9 @@ pub(crate) struct Model { pub(crate) player_combat_stats: Option, /// The pet's combat stats, which the reference's pet sheet reads as `UnitStat("pet", …)`. pub(crate) pet_combat_stats: Option, + /// The tokens a combat-stats verb has already answered the stand-in for, so the warning it + /// records is one line per VM rather than one per call (`char_stats::with_unit_stats`). + pub(crate) stats_stand_in_warned: std::collections::HashSet, pub(crate) inventory_slots: char_stats::InventorySlots, /// The six bank-bag slots, ids 64..=69, fed from the player descriptor's guids. pub(crate) bank_bag_slots: char_stats::BankBagSlots, @@ -2343,6 +2346,7 @@ impl Model { .collect(), player_combat_stats: None, pet_combat_stats: None, + stats_stand_in_warned: Default::default(), inventory_slots: Default::default(), bank_bag_slots: Default::default(), inventory_alerts: [0; 12],