Files
benilla-octo/crates/benilla-app/src/ui_session.rs
T
Sam SungandClaude Opus 5.5 b50efc0a53 String literals and test names in benilla-app carry no record numbers (#368)
The strings pass for `benilla-app`, the last crate: string literals and test names lose the private record numbers and the workspace's words that a comment pass cannot touch.

Fixes #92. Fixes #36.

- **Messages.** About 230 assertion, panic, `expect` and log messages, printed tool labels, and comments inside embedded Lua fixtures drop their record numbers; the eight that named "the director" keep their fact in plain words. Stale claims in a few messages are corrected on the way (a cast-bar step, a font line, the every-shot recompute).
- **Names.** Seven test and helper names that carried a record number or "director" now say what they check. `git grep` found no other use of any of them.
- **Player files.** The key-bindings file's header and `config.toml`'s header each lose a record citation. Neither header is compared when the file is read back: the bindings reader skips every `#` line, and `config.toml`'s header is a TOML comment.
- **Fixtures.** Test fixtures use neutral character, account and frame names.
- **#36.** The quest-material test labels GameObject type 10 GOOBER (in 1.12, 3 is CHEST, 9 TEXT, 10 GOOBER); the code under test was already right, and the comments were fixed with the comment pass.
- **Verification.** With comments stripped and every literal's contents masked, each file is identical to main and keeps its literals' line counts, apart from the renamed functions and one rustfmt reflow. `cargo fmt --check` is clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 01:06:28 +07:00

507 lines
22 KiB
Rust

//! 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::<InteractNpc>().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::<T>`] 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<u64>;
/// 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<ClientCommand> {
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<u64>, now: Option<u64>) -> 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<T: NpcSession>(
mut session: ResMut<T>,
index: Res<GuidIndex>,
commands: Res<NetCommands>,
self_q: Query<&Transform, With<SelfPlayer>>,
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<Entity>, pub(crate) Option<u64>);
/// Collapse the portrait-bound sessions into [`InteractNpc`]: they are mutually exclusive, so the
/// first open one in the chain wins. `Option<Res<_>>` lets a test mount it without every window.
pub(crate) fn feed_interact_npc(
gossip: Option<Res<crate::ui_gossip::GossipState>>,
quest: Option<Res<crate::ui_quest::QuestGiver>>,
merchant: Option<Res<crate::ui_merchant::MerchantOpen>>,
trainer: Option<Res<crate::ui_trainer::TrainerOpen>>,
taxi: Option<Res<crate::ui_taxi::TaxiState>>,
// Trade's "npc" is the partner, a live player.
trade: Option<Res<crate::ui_trade::TradeSession>>,
bank: Option<Res<crate::ui_bank::BankOpen>>,
auction: Option<Res<crate::ui_auction::AuctionOpen>>,
// Not covered by the gossip arm: vmangos closes the gossip menu before it sends
// `SMSG_PETITION_SHOWLIST` (`Player.cpp:12428-12431`).
registrar: Option<Res<crate::ui_petition::GuildRegistrarState>>,
// 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<Res<crate::ui_stable::StableOpen>>,
tabard: Option<Res<crate::ui_tabard::TabardOpen>>,
index: Option<Res<GuidIndex>>,
mut out: ResMut<InteractNpc>,
) {
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::<MerchantOpen>();
app.init_resource::<GuidIndex>();
// 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::<MerchantOpen>);
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::<GuidIndex>()
.0
.insert(VENDOR, vendor);
// 5.0 yd < the 5.5556 yd service gate: stays open.
app.world_mut()
.resource_mut::<MerchantOpen>()
.open(VENDOR, vec![]);
app.update();
assert!(app.world().resource::<MerchantOpen>().is_open());
// 6 yd: past the gate, so it closes.
*app.world_mut()
.entity_mut(vendor)
.get_mut::<Transform>()
.unwrap() = Transform::from_xyz(6.0, 0.0, 0.0);
app.update();
assert!(!app.world().resource::<MerchantOpen>().is_open());
// Re-open, then the NPC despawns out from under the window: closes too.
app.world_mut()
.resource_mut::<MerchantOpen>()
.open(VENDOR, vec![]);
app.world_mut()
.resource_mut::<GuidIndex>()
.0
.remove(&VENDOR);
app.update();
assert!(!app.world().resource::<MerchantOpen>().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<ClientCommand>) {
let (tx, rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.insert_resource(NetCommands(tx));
app.init_resource::<QuestGiver>();
app.init_resource::<GuidIndex>();
app.add_systems(Update, close_npc_session_out_of_range::<QuestGiver>);
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::<GuidIndex>()
.0
.insert(giver, e);
app.world_mut()
.resource_mut::<QuestGiver>()
.open(giver, detail(giver));
app.update();
(
app.world().resource::<QuestGiver>().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::<GossipState>();
app.init_resource::<MerchantOpen>();
app.init_resource::<GuidIndex>();
app.init_resource::<InteractNpc>();
app.add_systems(Update, feed_interact_npc);
let npc = app.world_mut().spawn_empty().id();
app.world_mut()
.resource_mut::<GuidIndex>()
.0
.insert(VENDOR, npc);
app.update();
assert_eq!(app.world().resource::<InteractNpc>().0, None);
app.world_mut()
.resource_mut::<MerchantOpen>()
.open(VENDOR, vec![]);
app.update();
assert_eq!(app.world().resource::<InteractNpc>().0, Some(npc));
// A browse-goods hop: gossip holds the same guid and wins the chain.
app.world_mut().resource_mut::<GossipState>().npc = Some(VENDOR);
app.update();
assert_eq!(app.world().resource::<InteractNpc>().0, Some(npc));
// A guid with no index entry, an NPC not streamed.
app.world_mut().resource_mut::<GossipState>().npc = Some(0xdead);
app.update();
assert_eq!(app.world().resource::<InteractNpc>().0, None);
// A session missing from the chain renders a black disc where the NPC's face goes.
app.world_mut().resource_mut::<GossipState>().npc = None;
app.world_mut().resource_mut::<MerchantOpen>().close();
app.init_resource::<crate::ui_auction::AuctionOpen>();
app.world_mut()
.resource_mut::<crate::ui_auction::AuctionOpen>()
.open(VENDOR, 1);
app.update();
assert_eq!(
app.world().resource::<InteractNpc>().0,
Some(npc),
"the auctioneer's own portrait"
);
app.world_mut()
.resource_mut::<crate::ui_auction::AuctionOpen>()
.clear();
// The stable master: no gossip session carries it.
app.init_resource::<crate::ui_stable::StableOpen>();
app.world_mut()
.resource_mut::<crate::ui_stable::StableOpen>()
.open(VENDOR, 2, vec![]);
app.update();
assert_eq!(
app.world().resource::<InteractNpc>().0,
Some(npc),
"the stable master's own portrait"
);
app.world_mut().resource_mut::<GossipState>().close();
app.world_mut().resource_mut::<MerchantOpen>().close();
app.world_mut()
.resource_mut::<crate::ui_stable::StableOpen>()
.clear();
app.update();
assert_eq!(app.world().resource::<InteractNpc>().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.
let this_file = include_str!("ui_session.rs");
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:#?}"
);
}
/// Checked in the built schedule graph, by system identity: a `.after()` on a system missing
/// from the schedule is silently nothing, and debug names may be off.
#[test]
fn the_interact_npc_writer_is_seated_between_the_net_apply_and_the_facing_chain() {
use bevy::ecs::schedule::graph::Direction::{Incoming, Outgoing};
use bevy::ecs::schedule::{NodeId, Schedule};
use bevy::ecs::system::{IntoSystem, System};
use std::any::TypeId;
use std::collections::HashSet;
/// `a` runs before `b`: the dependency graph walked with the set hierarchy unfolded, so a
/// set `a` sits in precedes what that set precedes, and a successor set brings its members.
fn runs_before(schedule: &Schedule, a: NodeId, b: NodeId) -> bool {
let dep = schedule.graph().dependency().graph();
let hier = schedule.graph().hierarchy().graph();
let (mut after, mut containers) = (HashSet::new(), HashSet::new());
let mut work = vec![(a, false)];
while let Some((n, is_after)) = work.pop() {
let fresh = if is_after {
after.insert(n)
} else {
containers.insert(n)
};
if !fresh {
continue;
}
work.extend(dep.neighbors_directed(n, Outgoing).map(|m| (m, true)));
work.extend(hier.neighbors_directed(n, Incoming).map(|p| (p, false)));
if is_after {
work.extend(hier.neighbors_directed(n, Outgoing).map(|c| (c, true)));
}
}
after.contains(&b)
}
let apply = System::type_id(&IntoSystem::into_system(crate::net::apply_net_updates));
let writer = System::type_id(&IntoSystem::into_system(feed_interact_npc));
let facing = System::type_id(&IntoSystem::into_system(crate::net::drive_display_facing));
let mut app = App::new();
app.add_plugins((crate::net::NetPlugin { connect: false }, UiSessionPlugin));
// `schedule_scope`, not `resource_scope::<Schedules>`: initializing the schedule inserts
// `Schedules`, which a resource scope refuses.
app.world_mut().schedule_scope(Update, |world, schedule| {
schedule
.initialize(world)
.expect("the Update schedule builds");
let key = |id: TypeId| -> NodeId {
schedule
.systems()
.expect("initialized")
.find(|&(_, s)| System::type_id(&**s) == id)
.map(|(k, _)| NodeId::System(k))
.expect("the system is in Update")
};
let (apply, writer, facing) = (key(apply), key(writer), key(facing));
assert!(
runs_before(schedule, apply, writer),
"feed_interact_npc must run after apply_net_updates"
);
assert!(
runs_before(schedule, writer, facing),
"drive_display_facing must run after feed_interact_npc"
);
});
}
}