diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index 48f438f6..759dcbd1 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -546,7 +546,7 @@ pub(crate) mod schedule_tests { /// Does this build carry type names? Bevy has them only under its `debug` feature, which /// rides with `benilla-world`'s `dev`. Probed off the census, not a `cfg`, which belongs in /// `run_mode` alone; name-dependent tests skip without them. - fn type_names_available(c: &Census) -> bool { + pub(crate) fn type_names_available(c: &Census) -> bool { c.systems.values().any(|s| s.name.contains("benilla_app::")) } diff --git a/crates/benilla-app/src/ui_bank/mod.rs b/crates/benilla-app/src/ui_bank/mod.rs index 878df4cf..de467af4 100644 --- a/crates/benilla-app/src/ui_bank/mod.rs +++ b/crates/benilla-app/src/ui_bank/mod.rs @@ -72,7 +72,10 @@ impl Plugin for UiBankPlugin { ( // Range-close first so the clear fires `BANKFRAME_CLOSED` the same frame. close_npc_session_out_of_range::.before(feed_bank), - feed_bank.in_set(UiFeed), + // After the unit feed: `BANKFRAME_OPENED` titles the frame from + // `UnitName("npc")` and bakes the portrait off the same token + // (`BankFrame.lua:127-128`). + feed_bank.in_set(UiFeed).after(crate::ui_unit::UnitFeed), drain_bank.after(UiInput), ), ); diff --git a/crates/benilla-app/src/ui_gossip/mod.rs b/crates/benilla-app/src/ui_gossip/mod.rs index a4488ddd..a59d1a03 100644 --- a/crates/benilla-app/src/ui_gossip/mod.rs +++ b/crates/benilla-app/src/ui_gossip/mod.rs @@ -142,7 +142,12 @@ impl Plugin for UiGossipPlugin { ( // Range-close first so the clear fires `GOSSIP_CLOSED` the same frame. close_npc_session_out_of_range::.before(feed_gossip), - feed_gossip.in_set(UiFeed), + // After the unit feed: `GossipFrame_OnEvent` reads `UnitName("npc")` and + // `UnitExists("npc")` for the title and portrait (`GossipFrame.lua:33-35`), and + // the chain's own windows gate on the same token — `Turtle_ArenaUI.lua:285` + // refuses to open unless `UnitName("npc")` answers, `Turtle_GuildBankUI.lua:378` + // on `UnitExists("npc")`. + feed_gossip.in_set(UiFeed).after(crate::ui_unit::UnitFeed), drain_gossip.after(UiInput), ), ); diff --git a/crates/benilla-app/src/ui_quest/mod.rs b/crates/benilla-app/src/ui_quest/mod.rs index a38145eb..ece064b4 100644 --- a/crates/benilla-app/src/ui_quest/mod.rs +++ b/crates/benilla-app/src/ui_quest/mod.rs @@ -224,7 +224,13 @@ impl Plugin for UiQuestPlugin { // Range-close before the feed so the close fires the same frame; the feed before // the input pass and the drain after it, so a click goes out the same frame. close_npc_session_out_of_range::.before(feed_quest), - feed_quest.in_set(UiFeed), + // After the unit feed: `QUEST_DETAIL` reaches `QuestFrame_OnEvent`, whose + // `QuestFrame_SetPortrait` reads `UnitName("npc")` and `UnitExists("npc")` + // (`QuestFrame.lua:62-66`), and the `"npc"` token is pushed in `feed_units`. The + // reference has the pointer before the signal by construction: + // `CGGameUI::SetInteractNPC` writes `[0xb4e2d0]` on the interact, the window's + // event is signalled after. + feed_quest.in_set(UiFeed).after(crate::ui_unit::UnitFeed), drain_quest.after(UiInput), ), ); diff --git a/crates/benilla-app/src/ui_session.rs b/crates/benilla-app/src/ui_session.rs index 8521d819..d1b74f72 100644 --- a/crates/benilla-app/src/ui_session.rs +++ b/crates/benilla-app/src/ui_session.rs @@ -456,4 +456,73 @@ mod tests { "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:?}" + ); + } } diff --git a/crates/benilla-app/src/ui_tabard.rs b/crates/benilla-app/src/ui_tabard.rs index 4a29e0c9..0c6435b6 100644 --- a/crates/benilla-app/src/ui_tabard.rs +++ b/crates/benilla-app/src/ui_tabard.rs @@ -306,7 +306,9 @@ impl Plugin for TabardUiPlugin { ( reset_on_world_enter.before(feed_tabard), close_npc_session_out_of_range::.before(feed_tabard), - feed_tabard.in_set(UiFeed), + // After the unit feed: `TABARDFRAME_OPENED` names the vendor from + // `UnitName("npc")` and bakes its portrait (`TabardFrame.lua:27-28`). + feed_tabard.in_set(UiFeed).after(crate::ui_unit::UnitFeed), drain_tabard.after(UiInput), ), ); diff --git a/crates/benilla-app/src/ui_taxi/mod.rs b/crates/benilla-app/src/ui_taxi/mod.rs index 5e71d788..8903a84a 100644 --- a/crates/benilla-app/src/ui_taxi/mod.rs +++ b/crates/benilla-app/src/ui_taxi/mod.rs @@ -298,7 +298,9 @@ impl Plugin for UiTaxiPlugin { load_taxi_catalogs, // Range-close first so the clear fires `TAXIMAP_CLOSED` the same frame. close_npc_session_out_of_range::.before(feed_taxi), - feed_taxi.in_set(UiFeed), + // After the unit feed: `TAXIMAP_OPENED` names the flight master from + // `UnitName("npc")` and bakes its portrait (`TaxiFrame.lua:27-28`). + feed_taxi.in_set(UiFeed).after(crate::ui_unit::UnitFeed), feed_on_taxi.in_set(UiFeed), drain_taxi.after(UiInput), ),