//! The NPC-session range guard: a window bound to a live NPC (merchant, gossip, questgiver and the //! rest) closes client-side, sending nothing as 1.12 does, when the player walks out of range or //! the NPC despawns; the window's feed then fires its `*_CLOSED`. //! //! The reference's watchdog is `0x493230`, run every frame from `CGWorldFrame`'s layer update. The //! NPC range is the cursor's own 50/9 yd service gate ([`SERVICE_RANGE_SQ`], 5.5556 yd centre to //! centre), and the leash belongs to the latched object, 14 yd for a player ([`leash_sq`]). use bevy::prelude::*; use crate::net::{ClientCommand, GuidIndex, NetCommands, SelfPlayer}; use crate::target::SERVICE_RANGE_SQ; use benilla_world::schedule::WorldStage; /// Owns [`InteractNpc`] and its feed, read by the `"npc"` portrait and the interaction face-me. pub(crate) struct UiSessionPlugin; impl Plugin for UiSessionPlugin { fn build(&self, app: &mut App) { // Inside the net chain, after the apply pass that opens and swaps sessions and before the // facing chain: a `MERCHANT_SHOW` handler reads `UnitName("NPC")` the frame it opens. app.init_resource::().add_systems( Update, feed_interact_npc .in_set(WorldStage::Net) .after(crate::net::apply_net_updates) .before(crate::net::drive_display_facing), ); } } /// A UI session bound to a live NPC, or a GameObject for the mailbox: what the range guard closes. /// The binder and talent-wipe questions are sessions too, as the reference re-runs its /// interact-range test against a latched guid. Each NPC window registers /// [`close_npc_session_out_of_range::`] ahead of its feed so the close fires its `*_CLOSED` the /// same frame; trade does not, as its cancel is server-driven. pub(crate) trait NpcSession: Resource { /// The bound NPC's guid; `None` when no window is open. fn npc(&self) -> Option; /// The window's client-side close, with no packet: the same clear its close button does. fn close(&mut self); /// What a walk-away close sends first, if anything. The reference's watchdog (`0x4933da`) /// calls the quest session's end (`0x501130`) directly, not `DeclineQuest`, so walking away /// from an NPC's quest panel sends nothing, while a shared quest still owes the sharer /// `MSG_QUEST_PUSH_RESULT{DECLINE_QUEST}`. fn walk_away_send(&self, _npc: u64) -> Option { None } } /// The walk-away leash, set by the latched object's type, not the window: the reference's /// `0x4930d0` stores the opener's range and overwrites it for an object with the PLAYER typemask /// bit (`0x493156`). Thirteen of the client's fourteen arming sites push 50/9 yd /// ([`SERVICE_RANGE_SQ`]); a player gets `10.0 + 4.0 = 14.0` yd (`[0x8044b0] + [0x80306c]`, /// squared at `0x49316d`), which here means a shared quest's sharer. fn leash_sq(npc: u64) -> f32 { if benilla_protocol::guid::is_player(npc) { PLAYER_LEASH_SQ } else { SERVICE_RANGE_SQ } } /// `14.0²`, the leash for a session latched onto a player. const PLAYER_LEASH_SQ: f32 = 196.0; /// Whether an open NPC window switched to a different NPC (`Some(a)` to `Some(b)`). Stock /// `ShowUIPanel` returns early for a visible frame (`UIParent.lua:650`), so the client fires /// `*_CLOSED` then `*_SHOW`, both sounds; each feed does the same, then consumes the close intent /// its own `*_CLOSED` queued so the drain does not wipe the new session. pub(crate) fn npc_switched(prev: Option, now: Option) -> bool { matches!((prev, now), (Some(a), Some(b)) if a != b) } /// Close `T`'s open session when the player leaves the leash or the NPC despawns. pub(crate) fn close_npc_session_out_of_range( mut session: ResMut, index: Res, commands: Res, self_q: Query<&Transform, With>, transforms: Query<&Transform>, ) { let Some(npc) = session.npc() else { return }; let Some(self_tf) = self_q.iter().next() else { return; }; // Centre to centre with no bounding radii, and `dist² <= leash²` keeps: the reference's // compare (`0x4932f2`, `0x4932fe`). let out = match index.0.get(&npc).and_then(|e| transforms.get(*e).ok()) { Some(tf) => tf.translation.distance_squared(self_tf.translation) > leash_sq(npc), None => true, // the NPC despawned out from under the window }; if out { debug!("ui_session: NPC {npc:#x} out of range/gone — client-side close"); if let Some(cmd) = session.walk_away_send(npc) { let _ = commands.0.send(cmd); } session.close(); } } /// The `"npc"` unit: the open [`NpcSession`]'s world entity and guid, read by the portrait booth /// as it reads [`crate::target::Selection`] for `"target"`. Only one session is open in play. #[derive(Resource, Default, PartialEq, Eq)] pub(crate) struct InteractNpc(pub(crate) Option, pub(crate) Option); /// Collapse the portrait-bound sessions into [`InteractNpc`]: they are mutually exclusive, so the /// first open one in the chain wins. `Option>` lets a test mount it without every window. pub(crate) fn feed_interact_npc( gossip: Option>, quest: Option>, merchant: Option>, trainer: Option>, taxi: Option>, // Trade's "npc" is the partner, a live player. trade: Option>, bank: Option>, auction: Option>, // Not covered by the gossip arm: vmangos closes the gossip menu before it sends // `SMSG_PETITION_SHOWLIST` (`Player.cpp:12428-12431`). registrar: Option>, // A menuless stable master is asked for its list straight from the interact, the reference's // `0x5f05bc` path, with no gossip session behind it. stable: Option>, tabard: Option>, index: Option>, mut out: ResMut, ) { let guid = gossip .and_then(|s| s.npc()) .or_else(|| quest.and_then(|s| s.npc())) .or_else(|| merchant.and_then(|s| s.npc())) .or_else(|| trainer.and_then(|s| s.npc())) .or_else(|| taxi.and_then(|s| s.npc())) .or_else(|| trade.and_then(|s| s.npc())) .or_else(|| bank.and_then(|s| s.npc())) .or_else(|| auction.and_then(|s| s.npc())) .or_else(|| registrar.and_then(|s| s.npc())) .or_else(|| stable.and_then(|s| s.npc())) .or_else(|| tabard.and_then(|s| s.npc())); // Field 0 is the entity the booth bakes and the facing chain steers by; field 1 is the guid // `crate::ui_unit` names the token by, which holds even when the entity is not streamed. // Written only on a change: the unit feed's dirty gate reads `is_changed()` on it. let next = InteractNpc( guid.and_then(|g| index.and_then(|i| i.0.get(&g).copied())), guid, ); out.set_if_neq(next); } #[cfg(test)] mod tests { use super::*; use crate::ui_merchant::MerchantOpen; /// A `HIGHGUID_UNIT` guid: a bare `0x42` would be a player's (`guid::is_player`) and get the /// 14 yd leash. const VENDOR: u64 = 0xF130_0000_0000_0042; #[test] fn out_of_range_or_despawned_npc_closes_the_session() { let mut app = App::new(); app.init_resource::(); app.init_resource::(); // The merchant owes no walk-away send, but the system needs the channel. let (tx, _rx) = crossbeam_channel::unbounded(); app.insert_resource(NetCommands(tx)); app.add_systems(Update, close_npc_session_out_of_range::); app.world_mut() .spawn((SelfPlayer, Transform::from_xyz(0.0, 0.0, 0.0))); let vendor = app .world_mut() .spawn(Transform::from_xyz(5.0, 0.0, 0.0)) .id(); app.world_mut() .resource_mut::() .0 .insert(VENDOR, vendor); // 5.0 yd < the 5.5556 yd service gate: stays open. app.world_mut() .resource_mut::() .open(VENDOR, vec![]); app.update(); assert!(app.world().resource::().is_open()); // 6 yd: past the gate, so it closes. *app.world_mut() .entity_mut(vendor) .get_mut::() .unwrap() = Transform::from_xyz(6.0, 0.0, 0.0); app.update(); assert!(!app.world().resource::().is_open()); // Re-open, then the NPC despawns out from under the window: closes too. app.world_mut() .resource_mut::() .open(VENDOR, vec![]); app.world_mut() .resource_mut::() .0 .remove(&VENDOR); app.update(); assert!(!app.world().resource::().is_open()); } /// A player-latched quest panel is leashed at 14 yd and an NPC-latched one at 5.56 yd; walking /// away from a share still declines it, from an NPC's panel it sends nothing. #[test] fn the_walk_away_leash_is_the_givers_own_and_a_share_still_answers() { use crate::ui_quest::{QuestGiver, QuestView}; fn detail(npc: u64) -> QuestView { QuestView::Detail(benilla_protocol::messages::QuestDetails { npc, quest_id: 1, title: "A Threat Within".into(), details: String::new(), objectives: String::new(), auto_finish: 0, choices: Vec::new(), rewards: Vec::new(), money: 0, reward_spell: 0, }) } const SHARER: u64 = 0x0000_0000_0000_002A; // HIGHGUID_PLAYER: a zero high word const NPC: u64 = 0xF130_0000_0000_0007; // HIGHGUID_UNIT /// One guard pass with the panel on `giver` at `dist` yd: whether it stays, what is sent. fn walk(giver: u64, dist: f32) -> (bool, Vec) { let (tx, rx) = crossbeam_channel::unbounded(); let mut app = App::new(); app.insert_resource(NetCommands(tx)); app.init_resource::(); app.init_resource::(); app.add_systems(Update, close_npc_session_out_of_range::); app.world_mut() .spawn((SelfPlayer, Transform::from_xyz(0.0, 0.0, 0.0))); let e = app .world_mut() .spawn(Transform::from_xyz(dist, 0.0, 0.0)) .id(); app.world_mut() .resource_mut::() .0 .insert(giver, e); app.world_mut() .resource_mut::() .open(giver, detail(giver)); app.update(); ( app.world().resource::().is_open(), rx.try_iter().collect(), ) } // A share at 10 yd: past the NPC gate, inside the player leash. let (open, sent) = walk(SHARER, 10.0); assert!(open, "a share is leashed at 14 yd, not 5.56"); assert!(sent.is_empty(), "still in range, nothing owed: {sent:?}"); // Past 14 yd it closes and answers the sharer. let (open, sent) = walk(SHARER, 15.0); assert!(!open, "past the player leash the panel closes"); assert!( matches!( sent.as_slice(), [ClientCommand::QuestPushResult { sharer: SHARER, msg: benilla_protocol::messages::QuestShareMsg::DECLINE_QUEST, }] ), "walking away from a share still declines it: {sent:?}" ); // An NPC giver at 10 yd: closed, silently. let (open, sent) = walk(NPC, 10.0); assert!(!open, "an NPC giver is still leashed at 5.56 yd"); assert!( sent.is_empty(), "the walk-away skips the giver re-open the button does: {sent:?}" ); // Boundary-inclusive: exactly on the leash keeps the window (`dist² <= leash²`). assert!(walk(SHARER, 14.0).0, "14.0 yd exactly is still in range"); } /// No window is `None`, an unstreamed NPC is `None` and never a stale entity, and gossip wins /// the chain when two sessions hold a guid (the same NPC on a browse-goods hop). #[test] fn interact_npc_resolves_the_open_session_to_its_entity() { use crate::ui_gossip::GossipState; let mut app = App::new(); app.init_resource::(); app.init_resource::(); app.init_resource::(); app.init_resource::(); app.add_systems(Update, feed_interact_npc); let npc = app.world_mut().spawn_empty().id(); app.world_mut() .resource_mut::() .0 .insert(VENDOR, npc); app.update(); assert_eq!(app.world().resource::().0, None); app.world_mut() .resource_mut::() .open(VENDOR, vec![]); app.update(); assert_eq!(app.world().resource::().0, Some(npc)); // A browse-goods hop: gossip holds the same guid and wins the chain. app.world_mut().resource_mut::().npc = Some(VENDOR); app.update(); assert_eq!(app.world().resource::().0, Some(npc)); // A guid with no index entry, an NPC not streamed. app.world_mut().resource_mut::().npc = Some(0xdead); app.update(); assert_eq!(app.world().resource::().0, None); // A session missing from the chain renders a black disc where the NPC's face goes. app.world_mut().resource_mut::().npc = None; app.world_mut().resource_mut::().close(); app.init_resource::(); app.world_mut() .resource_mut::() .open(VENDOR, 1); app.update(); assert_eq!( app.world().resource::().0, Some(npc), "the auctioneer's own portrait" ); app.world_mut() .resource_mut::() .clear(); // The stable master: no gossip session carries it. app.init_resource::(); app.world_mut() .resource_mut::() .open(VENDOR, 2, vec![]); app.update(); assert_eq!( app.world().resource::().0, Some(npc), "the stable master's own portrait" ); app.world_mut().resource_mut::().close(); app.world_mut().resource_mut::().close(); app.world_mut() .resource_mut::() .clear(); app.update(); assert_eq!(app.world().resource::().0, None); } /// Every `NpcSession` implementor in the app's sources must be in [`feed_interact_npc`]'s /// chain or on the exclusion list with a reason; one missing renders a black portrait disc. #[test] fn every_npc_session_is_portrait_bound_or_explicitly_excluded() { use std::path::Path; /// Sessions that do not own the `"npc"` portrait token, each with its reason. const EXCLUDED: &[(&str, &str)] = &[ // Its icon is art, not a unit bake, and must not take a portrait window's token. ("MailOpen", "its window icon is art, not a unit bake"), // Reached from an open gossip menu, which stays open behind it and heads the chain; // unlike the registrar's, the server does not close it first. ("TalentWipeState", "rides the still-open gossip session"), ("BinderState", "rides the still-open gossip session"), // The pet trainer's question, reached the same way. ("PetUnlearnState", "rides the still-open gossip session"), ]; // The chain's own source says what is wired. // MONKEY (integration): a CRLF checkout must still find the function's closing brace. let this_file = include_str!("ui_session.rs").replace("\r\n", "\n"); let chain = this_file .split_once("pub(crate) fn feed_interact_npc") .expect("feed_interact_npc") .1 .split_once("\n}\n") .expect("end of feed_interact_npc") .0; let mut missing = Vec::new(); let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); let mut stack = vec![root]; while let Some(dir) = stack.pop() { for entry in std::fs::read_dir(&dir).expect("read_dir") { let path = entry.expect("dir entry").path(); if path.is_dir() { stack.push(path); continue; } if path.extension().is_none_or(|e| e != "rs") { continue; } // This file defines the trait and no session, but its own text spells the pattern. if path.file_name().is_some_and(|f| f == "ui_session.rs") { continue; } let src = std::fs::read_to_string(&path).expect("read source"); for (_, rest) in src .match_indices("impl NpcSession for ") .map(|(i, _)| (i, &src[i + "impl NpcSession for ".len()..])) { let ty: String = rest .chars() .take_while(|c| c.is_alphanumeric() || *c == '_') .collect(); if ty.is_empty() || EXCLUDED.iter().any(|(name, _)| *name == ty) { continue; } // Wired means the chain's text names the type, as its `Res<...>` parameter // does; a comment naming it counts too. if !chain.contains(&ty) { missing.push(format!("{ty} (in {})", path.display())); } } } } assert!( missing.is_empty(), "these NpcSession windows own no `\"npc\"` portrait and are not on the exclusion \ list, so each renders a BLACK DISC where the NPC's face goes — wire them into \ `feed_interact_npc`'s chain, or add them to EXCLUDED with a reason: {missing:#?}" ); } #[test] fn the_interact_npc_writer_is_seated_between_the_net_apply_and_the_facing_chain() { use crate::test_support::runs_before; let mut app = App::new(); app.add_plugins((crate::net::NetPlugin { connect: false }, UiSessionPlugin)); assert!( runs_before(&mut app, crate::net::apply_net_updates, feed_interact_npc), "feed_interact_npc must run after apply_net_updates" ); assert!( runs_before( &mut app, feed_interact_npc, crate::net::drive_display_facing ), "drive_display_facing must run after feed_interact_npc" ); } /// Every window whose open event's stock handler reads the `"npc"` token runs **after** the /// unit feed, which is what pushes that token ([`crate::ui_unit::feed_units`]'s `"npc"` block). /// /// [`feed_interact_npc`] above seats the resource before both, but the token only reaches the /// VM in the unit feed, and two systems in one set with no edge between them are ordered by /// nothing: the event can be signalled into a VM whose `"npc"` is still last window's, or /// nil. The symptom is not a missing name alone — `UnitName("npc")` nil makes /// `QuestFrameNpcNameText:SetText(nil)` leave the string empty, and HCQuest, which reads that /// string back on `QUEST_DETAIL`, raises `bad argument #1 to 'strupper'` and loses its PvP /// warning for the quest (`HCQuest.lua:16`, from the player's 2026-10-16 session). /// /// The reference needs no such edge: `CGGameUI::SetInteractNPC` writes `[0xb4e2d0]` on the /// interact itself, so the pointer is live before any window's `SignalEvent`. #[test] fn every_window_that_reads_the_npc_token_is_fed_after_the_unit_feed() { use crate::game_plugins::schedule_tests::{census, SyncPoints}; /// Each window's feed and the stock line that reads the token. const READERS: &[(&str, &str)] = &[ ("ui_quest::feed_quest", "QuestFrame.lua:63"), ("ui_gossip::feed_gossip", "GossipFrame.lua:33"), ("ui_bank::feed_bank", "BankFrame.lua:127"), ("ui_taxi::feed_taxi", "TaxiFrame.lua:27"), ("ui_tabard::feed_tabard", "TabardFrame.lua:28"), ("ui_auction::feed_auction", "AuctionFrame.lua:29"), ("ui_trainer::feed_trainer", "ClassTrainerFrame.lua:59"), ]; const PUSHER: &str = "ui_unit::feed_units"; let mut app = crate::game_plugins::schedule_tests::headless_client(); let c = census(&mut app, bevy::app::Update, SyncPoints::Declared); if !crate::game_plugins::schedule_tests::type_names_available(&c) { eprintln!("skipped: this build carries no type names"); return; } let one = |suffix: &str| { let mut hits = c.systems.iter().filter(|(_, s)| s.name.ends_with(suffix)); match (hits.next(), hits.next()) { (Some((k, _)), None) => *k, _ => panic!("exactly one system named `…{suffix}` in Update"), } }; // Reachability over declared edges: a path means somebody declared the order. let mut succ: std::collections::HashMap<_, Vec<_>> = std::collections::HashMap::new(); for (a, b) in &c.dependencies { succ.entry(*a).or_default().push(*b); } let pusher = one(PUSHER); let mut seen = std::collections::HashSet::new(); let mut work = vec![pusher]; while let Some(n) = work.pop() { if !seen.insert(n) { continue; } work.extend(succ.get(&n).into_iter().flatten().copied()); } let missing: Vec = READERS .iter() .filter(|(feed, _)| !seen.contains(&one(feed))) .map(|(feed, site)| format!("{feed} (reads it at {site})")) .collect(); assert!( missing.is_empty(), "these window feeds fire an open event whose stock handler reads the `\"npc\"` token, \ but nothing orders them after `{PUSHER}`, which pushes it — \ `.after(crate::ui_unit::UnitFeed)` at the feed's own `add_systems`: {missing:?}" ); } }