Files
benilla-octo/crates/benilla-app/src/ui_session.rs
T
SamandClaude Fable 5 3b6975fdef the pet gets its frame, flooded rooms fill, and macros really fire
- the pet gets its own unit frame beside yours, and an Attack order no longer latches as the standing command
- five rooms that always read bone dry hold their water now, the Deeprun Tram's flooded stretches among them
- macros fire through the client's own chat door, and the macro window fits its tabs and scrolls its icon chooser
- the options window grows a Nameplates page and its first dropdown, and the name-display switches apply live in the world

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 19:03:43 +07:00

208 lines
10 KiB
Rust

//! The **NPC-session range guard** — the one standardized rule for every UI window bound to a live
//! NPC (merchant stock, gossip menu, questgiver panel): when the player walks out of the
//! NPC-service range, or the NPC despawns, the window client-side-closes. That close is the exact
//! no-packet clear the window's own close button does (vanilla sends nothing for any of the three);
//! each window's feed then diffs the cleared state into its `*_CLOSED` event and the panel hides.
//!
//! The threshold is the byte-verified NPC-service gate the cursor grays at
//! ([`SERVICE_RANGE_SQ`], 5.5556 yd center-to-center — `crate::target::cursor_mode`), so a window
//! closes exactly where the cursor says its NPC is out of service — one law for "out of service".
//! Whether the real client's auto-close shares that exact gate is INFERRED (its close mechanism
//! isn't RE'd); the constant is the knob if it reads too eager or too lax in play.
use bevy::prelude::*;
use crate::net::{GuidIndex, SelfPlayer};
use crate::target::SERVICE_RANGE_SQ;
/// A UI session bound to a live NPC (or, for the mailbox, a GameObject): the shared face the range
/// guard closes through. Implemented by [`crate::ui_merchant::MerchantOpen`],
/// [`crate::ui_gossip::GossipState`], [`crate::ui_quest::QuestGiver`],
/// [`crate::ui_trainer::TrainerOpen`], [`crate::ui_taxi::TaxiState`],
/// [`crate::ui_mail::MailOpen`], and [`crate::ui_trade::TradeSession`]; each *NPC* window registers
/// [`close_npc_session_out_of_range::<T>`] ahead of its feed so the clear turns into the window's
/// `*_CLOSED` event the same frame — trade does **not** (its cancel is server-driven, decision 0592).
/// [`feed_interact_npc`] collapses the six portrait-bound sessions into the [`InteractNpc`] the
/// portrait booth reads for its `"npc"` slot; the mailbox is deliberately excluded from that
/// collapse (its window icon is art, not a unit-model bake — decision 0544). Trade's "npc" is a
/// live *player* (the partner), baked exactly like a vendor (decision 0592).
pub(crate) trait NpcSession: Resource {
/// The NPC this session is bound to; `None` = no window open.
fn npc(&self) -> Option<u64>;
/// The window's client-side close (no packet) — the same clear its close button does.
fn close(&mut self);
}
/// Did an NPC-bound window switch to a **different** NPC of the same kind while it was already open
/// (a `Some(a) → Some(b)`, `a != b`)? The real client can't just repaint: its `ShowUIPanel` early-
/// returns when the frame is already visible, so the only way MERCHANT_SHOW (etc.) re-plays the open
/// sound is if the frame is HIDDEN first. So the client fires `*_CLOSED` then `*_SHOW` on a
/// vendor→vendor (or gossip→gossip, quest→quest) change — a real close+open, both sounds. Each feed
/// reproduces that, then **consumes the close intent** its own `*_CLOSED`→OnHide→`CloseX` queued, so
/// the session it just re-opened to `b` is not wiped by the drain (decision 0096).
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 NPC-service range or the NPC despawns.
pub(crate) fn close_npc_session_out_of_range<T: NpcSession>(
mut session: ResMut<T>,
index: Res<GuidIndex>,
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;
};
let out = match index.0.get(&npc).and_then(|e| transforms.get(*e).ok()) {
Some(tf) => tf.translation.distance_squared(self_tf.translation) > SERVICE_RANGE_SQ,
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");
session.close();
}
}
/// The one unit an NPC-interaction window points its portrait at: whichever [`NpcSession`] is
/// currently open (gossip / quest / merchant / trainer / taxi — only one is ever open in play),
/// resolved through the [`GuidIndex`] to its live world entity. The portrait booth reads this for
/// the `"npc"` token exactly as it reads [`crate::target::Selection`] for `"target"` — the
/// decision-0105 face bake, wired to the interaction arc's NPC (decision 0081). `None` = no NPC
/// window open, so the booth empties; the ring is hidden with its window then, so the dark disc
/// never shows.
#[derive(Resource, Default)]
pub(crate) struct InteractNpc(pub(crate) Option<Entity>);
/// Collapse the six portrait-bound sessions into [`InteractNpc`] each frame: the open one's guid,
/// resolved to its entity. One deterministic writer (not one publish-system per session), so there is
/// no cross-system race over who owns the `"npc"` token — the sessions are mutually exclusive, and a
/// bare `.or` chain is the whole rule. `Option<Res<_>>` keeps it safe in a headless test that mounts
/// the portrait plugin without every window plugin.
#[allow(clippy::too_many_arguments)]
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 points the "npc" portrait at the partner (a live player) while its window is open — the
// same booth path, one more mutually-exclusive session in the chain (decision 0592 P1).
trade: Option<Res<crate::ui_trade::TradeSession>>,
// The banker's portrait while the vault is open (decision 0604) — same booth path.
bank: Option<Res<crate::ui_bank::BankOpen>>,
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()));
out.0 = guid.and_then(|g| index.and_then(|i| i.0.get(&g).copied()));
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ui_merchant::MerchantOpen;
/// Drive the guard in a minimal Bevy app over the merchant session (the trait's first
/// consumer): in range keeps the window, stepping past the service gate closes it, and a
/// despawned NPC closes it too.
#[test]
fn out_of_range_or_despawned_npc_closes_the_session() {
let mut app = App::new();
app.init_resource::<MerchantOpen>();
app.init_resource::<GuidIndex>();
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(0x42, vendor);
// 5.0 yd < the 5.5556 yd service gate: stays open.
app.world_mut()
.resource_mut::<MerchantOpen>()
.open(0x42, vec![]);
app.update();
assert!(app.world().resource::<MerchantOpen>().is_open());
// Step to 6 yd: past the gate — client-side close.
*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(0x42, vec![]);
app.world_mut().resource_mut::<GuidIndex>().0.remove(&0x42);
app.update();
assert!(!app.world().resource::<MerchantOpen>().is_open());
}
/// The `"npc"` portrait token's resolver ([`feed_interact_npc`]): the open NPC session's guid,
/// mapped through the [`GuidIndex`] to its world entity. No window → None; an unindexed
/// (not-yet-streamed) NPC → None, never a stale entity; the gossip session wins the `.or` chain
/// when more than one carries 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(0x42, npc);
// No window open → no npc.
app.update();
assert_eq!(app.world().resource::<InteractNpc>().0, None);
// A merchant opens on the indexed NPC → resolves to its entity.
app.world_mut()
.resource_mut::<MerchantOpen>()
.open(0x42, vec![]);
app.update();
assert_eq!(app.world().resource::<InteractNpc>().0, Some(npc));
// Gossip carries the same guid (a browse-goods hop) and wins the priority chain — still the
// one NPC's entity.
app.world_mut().resource_mut::<GossipState>().npc = Some(0x42);
app.update();
assert_eq!(app.world().resource::<InteractNpc>().0, Some(npc));
// Bound to a guid with no index entry (NPC not streamed) → None, not the last entity.
app.world_mut().resource_mut::<GossipState>().npc = Some(0xdead);
app.update();
assert_eq!(app.world().resource::<InteractNpc>().0, None);
// Both sessions close → None again.
app.world_mut().resource_mut::<GossipState>().close();
app.world_mut().resource_mut::<MerchantOpen>().close();
app.update();
assert_eq!(app.world().resource::<InteractNpc>().0, None);
}
}