NPC greetings from the latch; death and shapeshift close the window (#1320)
Two follow-ups on the interaction latch: - The NPC's hello and goodbye come from the latch: a window registering for a different non-player unit plays the hello (0x49318f), a release to nothing plays the goodbye (0x493210), and a swap or a walk-away (0x493305) plays none. Every window greets alike; a game object or a player says nothing. - Death and shapeshift close the window: the player's death releases with the goodbye (0x5e9a51), the latched unit's death releases without it (0x605950), and a shift into a form where IsShapeShifted holds and the row lacks flag 0x8 releases (0x5de07f). Tests pin the hello and goodbye per window, the swap and walk-away, and each release, and fail on a goodbye where none plays. Fixes #1287 Fixes #1288 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -22,12 +22,15 @@
|
||||
//!
|
||||
//! ## The window hello and goodbye (`0x60c3b0`, right-click)
|
||||
//!
|
||||
//! Opening any interaction window (`0x4930d0`, 14 call sites, none a mouseover) plays the NPC's
|
||||
//! hello once with a free-picked take (`0x60c464`), the same `+0x4` slot. Closing to nothing plays
|
||||
//! the goodbye (`+0x8`). A swap to another NPC's window plays the new hello and suppresses the old
|
||||
//! goodbye (`0x4930fd`: `dl = (new_guid != 0)`). The active NPC is the union of the window sessions
|
||||
//! ([`MerchantOpen`], [`GossipState`], [`QuestGiver`], [`TrainerOpen`]). Every such call also zeroes
|
||||
//! the select greeting's tracked NPC (`0x60c3d1`), so the next left-click starts its sequence over.
|
||||
//! The interaction latch speaks them ([`LatchVocal`]), not the windows: registering on it
|
||||
//! (`0x4930d0`, 14 call sites, none a mouseover) for a different unit plays the NPC's hello once
|
||||
//! with a free-picked take (`0x60c464`), the same `+0x4` slot, and releasing it to nothing
|
||||
//! through `0x4931c0` plays the goodbye (`+0x8`), so every window on the latch greets alike. A
|
||||
//! swap to another NPC's window plays the new hello and no goodbye for the old (`0x4930fd`:
|
||||
//! `dl = (new_guid != 0)`), and a walk-away close (`0x493305`) plays none. The gate is a
|
||||
//! non-player unit (`0x493159`, `0x493182`, `0x4931f8`-`0x493203`): a game object (a mailbox)
|
||||
//! or a player speaks nothing. Every such call also zeroes the select greeting's tracked NPC
|
||||
//! (`0x60c3d1`), so the next left-click starts its sequence over.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -36,10 +39,7 @@ use benilla_protocol::EntityKind;
|
||||
|
||||
use crate::net::{ActiveMover, GuidIndex, NetEntity, ObjectStore, Reputations, SelfPlayer};
|
||||
use crate::target::{both_reactions_at_least_neutral, Factions};
|
||||
use crate::ui_gossip::GossipState;
|
||||
use crate::ui_merchant::MerchantOpen;
|
||||
use crate::ui_quest::QuestGiver;
|
||||
use crate::ui_trainer::TrainerOpen;
|
||||
use crate::ui_session::LatchVocal;
|
||||
use benilla_assets::{AssetSet, LockRecover, WorldAssets};
|
||||
use benilla_world::schedule::WorldStage;
|
||||
use benilla_world::view::WorldCamera;
|
||||
@@ -93,40 +93,6 @@ fn greet_line(seq: usize, pissed_variations: usize) -> (GreetLine, usize) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The vocal an active-NPC change produces (`SetActiveNPC`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum WindowVocal {
|
||||
Hello(u64),
|
||||
Goodbye(u64),
|
||||
None,
|
||||
}
|
||||
|
||||
/// The active interaction NPC, the union of the window sessions. Every NPC-bound window must be
|
||||
/// in it: one left out makes a swap to it look like a close and plays a stray goodbye.
|
||||
fn active_interaction_npc(
|
||||
merchant: &MerchantOpen,
|
||||
gossip: &GossipState,
|
||||
quest: &QuestGiver,
|
||||
trainer: &TrainerOpen,
|
||||
) -> Option<u64> {
|
||||
merchant
|
||||
.vendor
|
||||
.or(gossip.npc)
|
||||
.or(quest.npc)
|
||||
.or(trainer.trainer)
|
||||
}
|
||||
|
||||
/// Open or swap plays the new NPC's hello with no goodbye for the old (`dl = new_guid != 0`);
|
||||
/// closing to nothing plays the goodbye.
|
||||
fn window_vocal(prev: Option<u64>, active: Option<u64>) -> WindowVocal {
|
||||
match (prev, active) {
|
||||
(p, a) if p == a => WindowVocal::None,
|
||||
(_, Some(new)) => WindowVocal::Hello(new),
|
||||
(Some(old), None) => WindowVocal::Goodbye(old),
|
||||
(None, None) => WindowVocal::None,
|
||||
}
|
||||
}
|
||||
|
||||
fn load_npc_greetings(mut commands: Commands, assets: Option<Res<WorldAssets>>) {
|
||||
let Some(assets) = assets else { return };
|
||||
let loaded = {
|
||||
@@ -142,6 +108,12 @@ fn load_npc_greetings(mut commands: Commands, assets: Option<Res<WorldAssets>>)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an object greets: a non-player unit (`0x493159`, `0x493182`, `0x4931f8`-`0x493203`).
|
||||
/// A player, a game object such as a mailbox and a book have no NPC to speak.
|
||||
fn speaks(kind: EntityKind) -> bool {
|
||||
matches!(kind, EntityKind::Unit)
|
||||
}
|
||||
|
||||
/// A live non-player unit (type-mask bit 3 set, bit 4 clear) whose display has an NPCSoundID row.
|
||||
fn resolve(
|
||||
npc: Entity,
|
||||
@@ -149,7 +121,7 @@ fn resolve(
|
||||
greetings: &NpcGreetings,
|
||||
) -> Option<(benilla_formats::NpcGreeting, Vec3)> {
|
||||
let (net, transform, store) = units.get(npc).ok()?;
|
||||
if !matches!(net.kind, EntityKind::Unit) {
|
||||
if !speaks(net.kind) {
|
||||
return None;
|
||||
}
|
||||
if store.is_some_and(|s| s.0.unit_is_dead()) {
|
||||
@@ -269,7 +241,7 @@ fn play_select_greetings(
|
||||
let Ok((net, transform, npc_store)) = units.get(req.npc) else {
|
||||
continue;
|
||||
};
|
||||
if !matches!(net.kind, EntityKind::Unit) {
|
||||
if !speaks(net.kind) {
|
||||
continue;
|
||||
}
|
||||
let allowed = select_greeting_allowed(
|
||||
@@ -312,14 +284,10 @@ fn play_select_greetings(
|
||||
}
|
||||
}
|
||||
|
||||
/// The window hello and goodbye, from the active NPC's change.
|
||||
/// The window hello and goodbye, from the latch's lines.
|
||||
fn play_window_greetings(
|
||||
merchant: Res<MerchantOpen>,
|
||||
gossip: Res<GossipState>,
|
||||
quest: Res<QuestGiver>,
|
||||
trainer: Res<TrainerOpen>,
|
||||
mut lines: MessageReader<LatchVocal>,
|
||||
index: Res<GuidIndex>,
|
||||
mut prev_active: Local<Option<u64>>,
|
||||
mut select_seq: ResMut<GreetSeq>,
|
||||
units: Query<(&NetEntity, &Transform, Option<&ObjectStore>)>,
|
||||
self_store: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
@@ -330,46 +298,41 @@ fn play_window_greetings(
|
||||
config: Res<SoundConfig>,
|
||||
cam: Query<&Transform, (With<WorldCamera>, Without<NetEntity>)>,
|
||||
) {
|
||||
let active = active_interaction_npc(&merchant, &gossip, &quest, &trainer);
|
||||
let vocal = window_vocal(*prev_active, active);
|
||||
if matches!(vocal, WindowVocal::None) {
|
||||
if lines.is_empty() {
|
||||
return;
|
||||
}
|
||||
*prev_active = active;
|
||||
|
||||
let (Some(greetings), Some(mut kits), Some(assets)) = (greetings, kits, assets) else {
|
||||
lines.clear();
|
||||
return;
|
||||
};
|
||||
let listener = cam.single().map(|t| t.translation).unwrap_or(Vec3::ZERO);
|
||||
|
||||
// A despawned NPC resolves nothing: the reference's teardown clear is silent (`0x605950`).
|
||||
let (guid, hello) = match vocal {
|
||||
WindowVocal::Hello(g) => (g, true),
|
||||
WindowVocal::Goodbye(g) => (g, false),
|
||||
WindowVocal::None => return,
|
||||
};
|
||||
let Some(&npc) = index.0.get(&guid) else {
|
||||
return;
|
||||
};
|
||||
// The call zeroes the select greeting's tracked NPC for any unit, before the gates below.
|
||||
if units
|
||||
.get(npc)
|
||||
.is_ok_and(|(net, ..)| matches!(net.kind, EntityKind::Unit))
|
||||
{
|
||||
forget_tracked(&mut select_seq);
|
||||
for &line in lines.read() {
|
||||
let (guid, hello) = match line {
|
||||
LatchVocal::Hello(g) => (g, true),
|
||||
LatchVocal::Goodbye(g) => (g, false),
|
||||
};
|
||||
// A despawned NPC resolves nothing: the reference's teardown clear is silent (`0x605950`).
|
||||
let Some(&npc) = index.0.get(&guid) else {
|
||||
continue;
|
||||
};
|
||||
// The call zeroes the select greeting's tracked NPC for any unit, before the gates below.
|
||||
if units.get(npc).is_ok_and(|(net, ..)| speaks(net.kind)) {
|
||||
forget_tracked(&mut select_seq);
|
||||
}
|
||||
// No reaction gate here (`0x60c3b0`), only the player's: dead or a ghost hears nothing.
|
||||
if !player_may_hear(self_store.single().ok()) {
|
||||
continue;
|
||||
}
|
||||
let Some((row, pos)) = resolve(npc, &units, &greetings) else {
|
||||
continue;
|
||||
};
|
||||
let kit_id = if hello { row.hello } else { row.goodbye };
|
||||
// Both window lines free-pick their take (`variation = -1`).
|
||||
play_line(
|
||||
npc, kit_id, None, pos, listener, &mut kits, &assets, &mut out, &config,
|
||||
);
|
||||
}
|
||||
// No reaction gate here (`0x60c3b0`), only the player's: dead or a ghost hears nothing.
|
||||
if !player_may_hear(self_store.single().ok()) {
|
||||
return;
|
||||
}
|
||||
let Some((row, pos)) = resolve(npc, &units, &greetings) else {
|
||||
return;
|
||||
};
|
||||
let kit_id = if hello { row.hello } else { row.goodbye };
|
||||
// Both window lines free-pick their take (`variation = -1`).
|
||||
play_line(
|
||||
npc, kit_id, None, pos, listener, &mut kits, &assets, &mut out, &config,
|
||||
);
|
||||
}
|
||||
|
||||
/// A despawning unit stops its greeting (`0x5fbb6c`).
|
||||
@@ -413,52 +376,6 @@ mod tests {
|
||||
assert_eq!(greet_line(5, 0), (GreetLine::Hello, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_vocal_opens_closes_and_suppresses_swap_goodbye() {
|
||||
assert_eq!(window_vocal(None, Some(0x1)), WindowVocal::Hello(0x1)); // open
|
||||
assert_eq!(window_vocal(Some(0x1), None), WindowVocal::Goodbye(0x1)); // close to nothing
|
||||
assert_eq!(window_vocal(Some(0x1), Some(0x2)), WindowVocal::Hello(0x2)); // swap: no goodbye
|
||||
assert_eq!(window_vocal(Some(0x1), Some(0x1)), WindowVocal::None); // no change
|
||||
assert_eq!(window_vocal(None, None), WindowVocal::None);
|
||||
}
|
||||
|
||||
/// A gossip→trainer handoff on one NPC is a silent swap, not a close with a goodbye.
|
||||
// The sessions have private fields, so they are built from `default()` and a field set.
|
||||
#[test]
|
||||
#[allow(clippy::field_reassign_with_default)]
|
||||
fn gossip_to_trainer_on_one_npc_is_a_silent_swap() {
|
||||
let npc = 0x1234_u64;
|
||||
let (empty_merchant, empty_gossip, empty_quest, empty_trainer) = (
|
||||
MerchantOpen::default(),
|
||||
GossipState::default(),
|
||||
QuestGiver::default(),
|
||||
TrainerOpen::default(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
active_interaction_npc(&empty_merchant, &empty_gossip, &empty_quest, &empty_trainer),
|
||||
None,
|
||||
);
|
||||
|
||||
// Frame 1: gossip open on `npc`.
|
||||
let mut gossip = GossipState::default();
|
||||
gossip.npc = Some(npc);
|
||||
let before = active_interaction_npc(&empty_merchant, &gossip, &empty_quest, &empty_trainer);
|
||||
|
||||
// Frame 2: gossip closed, the trainer open on the same NPC.
|
||||
let mut trainer = TrainerOpen::default();
|
||||
trainer.trainer = Some(npc);
|
||||
let after = active_interaction_npc(&empty_merchant, &empty_gossip, &empty_quest, &trainer);
|
||||
|
||||
assert_eq!(before, Some(npc), "gossip drove the active NPC");
|
||||
assert_eq!(after, Some(npc), "the trainer keeps the same NPC active");
|
||||
assert_eq!(
|
||||
window_vocal(before, after),
|
||||
WindowVocal::None,
|
||||
"gossip→trainer on one NPC is a silent swap — no goodbye",
|
||||
);
|
||||
}
|
||||
|
||||
use benilla_protocol::field::{
|
||||
FIELD_PLAYER_FLAGS, FIELD_UNIT_FACTIONTEMPLATE, FIELD_UNIT_FLAGS, FIELD_UNIT_HEALTH,
|
||||
FIELD_UNIT_MAXHEALTH,
|
||||
@@ -563,6 +480,21 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
/// Only a non-player unit has a hello and a goodbye: a mailbox, a player and the rest do not.
|
||||
#[test]
|
||||
fn only_a_non_player_unit_speaks() {
|
||||
assert!(speaks(EntityKind::Unit));
|
||||
for kind in [
|
||||
EntityKind::Player,
|
||||
EntityKind::GameObject,
|
||||
EntityKind::DynamicObject,
|
||||
EntityKind::Corpse,
|
||||
EntityKind::Other,
|
||||
] {
|
||||
assert!(!speaks(kind), "{kind:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn two_npcs() -> (Entity, Entity) {
|
||||
let mut world = World::new();
|
||||
(world.spawn_empty().id(), world.spawn_empty().id())
|
||||
|
||||
@@ -17,10 +17,13 @@ use benilla_world::schedule::WorldStage;
|
||||
mod latch;
|
||||
#[cfg(test)]
|
||||
mod latch_tests;
|
||||
mod release;
|
||||
#[cfg(test)]
|
||||
mod release_tests;
|
||||
|
||||
pub(crate) use latch::{
|
||||
dispatch, fire, note_closed, queue, register, release, with_script, CoreCloses, InteractLatch,
|
||||
LatchApp, LatchedWindow, NpcWindow,
|
||||
LatchApp, LatchVocal, LatchedWindow, NpcWindow,
|
||||
};
|
||||
|
||||
/// Owns [`InteractLatch`], its watchdog and [`InteractNpc`], read by the `"npc"` portrait and the
|
||||
@@ -30,6 +33,7 @@ pub(crate) struct UiSessionPlugin;
|
||||
impl Plugin for UiSessionPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<InteractLatch>()
|
||||
.add_message::<LatchVocal>()
|
||||
.init_resource::<InteractNpc>()
|
||||
.net_handler(
|
||||
SessionEventKind::Disconnected,
|
||||
@@ -43,6 +47,11 @@ impl Plugin for UiSessionPlugin {
|
||||
(
|
||||
// Before the feeds, so a walk-away's `*_CLOSED` lands the frame it happens.
|
||||
latch::watch_interaction_range.before(crate::ui_script::UiFeed),
|
||||
// The unit events that release the latch, off the apply pass's field edges.
|
||||
release::release_on_field_edges
|
||||
.in_set(WorldStage::Net)
|
||||
.after(crate::net::apply_net_updates)
|
||||
.before(feed_interact_npc),
|
||||
// Inside the net chain, after the apply pass that opens and closes windows
|
||||
// and before the facing chain: a `MERCHANT_SHOW` handler reads
|
||||
// `UnitName("NPC")` the frame it opens.
|
||||
|
||||
@@ -18,6 +18,11 @@
|
||||
//! `HideUIPanel` → `OnHide` does, re-enters that core in the reference before the fire returns;
|
||||
//! [`fire`] runs such a call before going on, as a core that fires before it clears its cell
|
||||
//! (trainer, bank, auction) is re-entered with the cell still set.
|
||||
//!
|
||||
//! The NPC's voice rides the same two calls ([`LatchVocal`]): `0x4930d0` greets a different
|
||||
//! non-player unit it latches, and `0x4931c0` says goodbye to the unit it releases, unless its
|
||||
//! caller's `dl` is set. A swap releases with `dl` set, and the watchdog and the leave-world sweep
|
||||
//! call `0x493310` directly, so neither says goodbye.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -79,6 +84,16 @@ impl NpcWindow {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the latch asks the latched NPC to say. The unit's kind and state gate it where it is
|
||||
/// played (`0x60c3b0`): a player, a game object or a book speaks nothing.
|
||||
#[derive(Message, Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum LatchVocal {
|
||||
/// A different unit latched (`0x4930d0` → `0x49318f`, `0x60c3b0` with 1).
|
||||
Hello(u64),
|
||||
/// The latch released to nothing with `dl` clear (`0x4931c0` → `0x493210`, `0x60c3b0` with 0).
|
||||
Goodbye(u64),
|
||||
}
|
||||
|
||||
/// How deep [`fire`]'s answers to close verbs may nest. The reference recurses without bound, so
|
||||
/// a handler that calls its own close verb on every fire overflows its stack; three levels cover
|
||||
/// every stock route.
|
||||
@@ -194,27 +209,59 @@ fn entries(world: &World) -> Vec<Entry> {
|
||||
}
|
||||
|
||||
/// `CGGameUI::SetInteractNPC` (`0x4930d0`), at a window's open: with anything latched, `0x4931c0`
|
||||
/// on the latch itself (`0x49310a`), which always matches and runs `0x493310`, so the holder
|
||||
/// closes first, even when it is this window on this same guid; then `guid` is latched
|
||||
/// (`0x493114`). The opener writes its cell after this, except the tabard designer's.
|
||||
/// on the latch itself with `dl` set (`0x49310a`, `dl = guid != 0`), which always matches and
|
||||
/// runs `0x493310` with no goodbye, so the holder closes first, even when it is this window on this
|
||||
/// same guid; then `guid` is latched (`0x493114`). A guid that differs from the one latched
|
||||
/// before is greeted (`0x49318f`, `bl` clear), so a second window on the same unit is silent. The
|
||||
/// opener writes its cell after this, except the tabard designer's.
|
||||
pub(crate) fn register(world: &mut World, guid: u64) {
|
||||
world.init_resource::<InteractLatch>();
|
||||
if world.resource::<InteractLatch>().guid.is_some() {
|
||||
let before = world.resource::<InteractLatch>().guid;
|
||||
if before.is_some() {
|
||||
dispatch(world);
|
||||
}
|
||||
world.resource_mut::<InteractLatch>().guid = Some(guid);
|
||||
if guid != 0 && before != Some(guid) {
|
||||
say(world, LatchVocal::Hello(guid));
|
||||
}
|
||||
}
|
||||
|
||||
/// `0x4931c0` on a core's own cell: while the latch names `cell`, `0x493310`.
|
||||
/// `0x4931c0` on a core's own cell with `dl` clear: while the latch names `cell`, the goodbye
|
||||
/// (`0x493210`), then `0x493310`.
|
||||
pub(crate) fn release(world: &mut World, cell: u64) {
|
||||
if world
|
||||
release_cell(world, cell, true);
|
||||
}
|
||||
|
||||
/// `0x4931c0(cell, dl = !goodbye)`: while the latch names `cell`, the unit's goodbye unless `dl`
|
||||
/// is set (`0x493208`), then `0x493310` whether or not the object is still there (`0x49321e`).
|
||||
pub(crate) fn release_cell(world: &mut World, cell: u64, goodbye: bool) {
|
||||
let latched = world
|
||||
.get_resource::<InteractLatch>()
|
||||
.is_some_and(|l| l.guid == Some(cell))
|
||||
{
|
||||
.and_then(|l| l.guid)
|
||||
.filter(|g| *g == cell);
|
||||
if latched.is_some() {
|
||||
if goodbye {
|
||||
say(world, LatchVocal::Goodbye(cell));
|
||||
}
|
||||
dispatch(world);
|
||||
}
|
||||
}
|
||||
|
||||
/// `0x4931c0` on the latch itself (`ecx = 0xb4e2d0`): whatever it holds is released, with the
|
||||
/// goodbye unless `goodbye` is false.
|
||||
pub(crate) fn release_latch(world: &mut World, goodbye: bool) {
|
||||
if let Some(guid) = world.get_resource::<InteractLatch>().and_then(|l| l.guid) {
|
||||
release_cell(world, guid, goodbye);
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue the NPC's line for whoever plays it; nothing reads it in a build with no sound.
|
||||
fn say(world: &mut World, vocal: LatchVocal) {
|
||||
if let Some(mut messages) = world.get_resource_mut::<Messages<LatchVocal>>() {
|
||||
messages.write(vocal);
|
||||
}
|
||||
}
|
||||
|
||||
/// `0x493310`: clear the latch (`0x49334b`), then run the close core of the first window whose
|
||||
/// cell holds the old guid. The watchdog (`0x493305`) and the leave-world sweep (`0x490b5f`)
|
||||
/// call it, and so does every core that finds its own guid latched.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::{dispatch, InteractLatch, LatchApp, LatchedWindow, NpcWindow as W};
|
||||
use super::{dispatch, InteractLatch, LatchApp, LatchVocal, LatchedWindow, NpcWindow as W};
|
||||
use crate::net::NetCommands;
|
||||
use crate::ui_auction::AuctionOpen;
|
||||
use crate::ui_bank::BankOpen;
|
||||
@@ -21,8 +21,8 @@ use crate::ui_taxi::TaxiState;
|
||||
use crate::ui_trade::TradeSession;
|
||||
use crate::ui_trainer::TrainerOpen;
|
||||
|
||||
const VENDOR: u64 = 0xF130_0000_0000_0001;
|
||||
const BANKER: u64 = 0xF130_0000_0000_0002;
|
||||
pub(super) const VENDOR: u64 = 0xF130_0000_0000_0001;
|
||||
pub(super) const BANKER: u64 = 0xF130_0000_0000_0002;
|
||||
const NPC: u64 = 0xF130_0000_0000_0003;
|
||||
|
||||
/// The windows this harness opens, in `0x493310`'s order: every window on the latch.
|
||||
@@ -63,7 +63,7 @@ fn event(window: W) -> &'static str {
|
||||
|
||||
/// The windows on the latch with their cells, a wire, and a VM that records their closes. Every
|
||||
/// unit guid's entry is 0, which a type-9 template makes a book, so any NPC here can be read.
|
||||
fn harness() -> App {
|
||||
pub(super) fn harness() -> App {
|
||||
harness_with_wire().0
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ fn harness_with_wire() -> (App, crossbeam_channel::Receiver<crate::net::ClientCo
|
||||
data[0] = 1;
|
||||
books.insert(0, 9, "Book".into(), &data);
|
||||
app.insert_resource(NetCommands(tx))
|
||||
.add_message::<LatchVocal>()
|
||||
.insert_resource(books)
|
||||
.init_resource::<crate::net::GuidIndex>()
|
||||
.init_resource::<crate::items::Items>()
|
||||
@@ -108,7 +109,7 @@ fn harness_with_wire() -> (App, crossbeam_channel::Receiver<crate::net::ClientCo
|
||||
(app, rx)
|
||||
}
|
||||
|
||||
fn latch(app: &App) -> Option<u64> {
|
||||
pub(super) fn latch(app: &App) -> Option<u64> {
|
||||
app.world().resource::<InteractLatch>().guid()
|
||||
}
|
||||
|
||||
@@ -148,7 +149,7 @@ fn greeting(npc: u64) -> QuestView {
|
||||
}
|
||||
|
||||
/// Each window's open as its packet handler or click runs it, on `npc`.
|
||||
fn open(app: &mut App, window: W, npc: u64) {
|
||||
pub(super) fn open(app: &mut App, window: W, npc: u64) {
|
||||
let world = app.world_mut();
|
||||
match window {
|
||||
W::Trade => {
|
||||
@@ -487,3 +488,71 @@ fn a_book_and_an_npc_window_close_each_other() {
|
||||
assert_eq!(seen_in(&app), ["MERCHANT_CLOSED", "ITEM_TEXT_CLOSED"]);
|
||||
assert_eq!(app.world().resource::<ItemTextOpen>().object(), None);
|
||||
}
|
||||
|
||||
/// The lines the latch asked for since the last call.
|
||||
pub(super) fn vocals(app: &mut App) -> Vec<LatchVocal> {
|
||||
app.world_mut()
|
||||
.resource_mut::<Messages<LatchVocal>>()
|
||||
.drain()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every window registers on the latch, so each greets the unit it latches (`0x4930d0` →
|
||||
/// `0x49318f`) and says goodbye when its close core releases the latch to nothing (`0x4931c0` →
|
||||
/// `0x493210`): a banker, a flight master or a stable master speaks as a vendor does.
|
||||
#[test]
|
||||
fn every_window_greets_on_open_and_says_goodbye_on_close() {
|
||||
for window in WINDOWS {
|
||||
let mut app = harness();
|
||||
open(&mut app, window, NPC);
|
||||
assert_eq!(
|
||||
vocals(&mut app),
|
||||
[LatchVocal::Hello(NPC)],
|
||||
"{window:?} open"
|
||||
);
|
||||
close(&mut app, window);
|
||||
assert_eq!(
|
||||
vocals(&mut app),
|
||||
[LatchVocal::Goodbye(NPC)],
|
||||
"{window:?} close"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A swap greets the new unit and says no goodbye for the old (`0x4930fd`, `dl = guid != 0`); a
|
||||
/// second window on the unit already latched greets nobody (`0x493187`, `bl` set).
|
||||
#[test]
|
||||
fn a_swap_greets_the_new_unit_with_no_goodbye_and_the_same_unit_is_silent() {
|
||||
let mut app = harness();
|
||||
crate::ui_merchant::open_merchant(app.world_mut(), VENDOR, Vec::new());
|
||||
crate::ui_bank::open_bank(app.world_mut(), BANKER);
|
||||
assert_eq!(
|
||||
vocals(&mut app),
|
||||
[LatchVocal::Hello(VENDOR), LatchVocal::Hello(BANKER)]
|
||||
);
|
||||
|
||||
crate::ui_gossip::open_menu(app.world_mut(), BANKER, 0, Vec::new(), Vec::new());
|
||||
assert_eq!(vocals(&mut app), [], "the same unit, another window");
|
||||
// Closing to nothing still says goodbye to the one latched.
|
||||
crate::ui_gossip::close_core(app.world_mut());
|
||||
assert_eq!(vocals(&mut app), [LatchVocal::Goodbye(BANKER)]);
|
||||
}
|
||||
|
||||
/// A walk-away close and the leave-world sweep run `0x493310` directly (`0x493305`, `0x490b5f`),
|
||||
/// skipping `0x4931c0`: no goodbye. A later open greets again.
|
||||
#[test]
|
||||
fn a_walk_away_says_no_goodbye() {
|
||||
for window in WINDOWS {
|
||||
let mut app = harness();
|
||||
open(&mut app, window, NPC);
|
||||
vocals(&mut app);
|
||||
dispatch(app.world_mut());
|
||||
assert_eq!(vocals(&mut app), [], "{window:?}");
|
||||
open(&mut app, window, NPC);
|
||||
assert_eq!(
|
||||
vocals(&mut app),
|
||||
[LatchVocal::Hello(NPC)],
|
||||
"{window:?} again"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
//! The unit events that release the interaction latch (`0x4931c0`), off the field edges the apply
|
||||
//! pass emits, as the reference's mirror handlers see them.
|
||||
//!
|
||||
//! A unit whose health falls to 0 runs `CGUnit_C::OnDied` (`0x605860`, from the health watcher
|
||||
//! `0x6046f0` at `0x6047a3`). For a player it calls `0x5e99b0` first, which for the active player
|
||||
//! releases the latch with the goodbye (`0x5e9a51`, `dl = 0`); then, for any unit, it releases the
|
||||
//! latch if it names the dying unit, without a goodbye (`0x605950`, `dl = 1`). A change of the
|
||||
//! active player's shapeshift form (`UNIT_FIELD_BYTES_1` byte 2, watcher `0x5de010`) into a form
|
||||
//! that is not a stance (`0x60e9f0`) and lacks flag `0x8` releases it with the goodbye
|
||||
//! (`0x5de07f`, `dl = 0`). The release closes the window through `0x493310`; the goodbye is
|
||||
//! [`super::LatchVocal`], which a dead player does not hear (`0x605f30`).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_formats::ShapeshiftForm;
|
||||
use benilla_protocol::field::{FIELD_UNIT_BYTES_1, FIELD_UNIT_HEALTH};
|
||||
use benilla_protocol::messages::ObjectType;
|
||||
|
||||
use super::latch::{release_cell, release_latch};
|
||||
use crate::net::{FieldChanged, SelfPlayer};
|
||||
use crate::ui_action::Spells;
|
||||
|
||||
/// `UNIT_FIELD_BYTES_1` byte 2, the shapeshift form (`[+0x110]+0x212`).
|
||||
fn form_of(bytes_1: u32) -> u8 {
|
||||
(bytes_1 >> 16) as u8
|
||||
}
|
||||
|
||||
/// Whether taking `form` closes the open window (`0x5de010`): a form other than none whose row is
|
||||
/// not a stance (`0x60e9f0`) and lacks the interact flag (`0x5de072`). A form with no row reads
|
||||
/// nothing here, where the reference faults.
|
||||
pub(super) fn form_releases(forms: &HashMap<u32, ShapeshiftForm>, form: u8) -> bool {
|
||||
form != 0
|
||||
&& forms
|
||||
.get(&u32::from(form))
|
||||
.is_some_and(|row| !row.is_stance() && !row.allows_interaction())
|
||||
}
|
||||
|
||||
/// The death of a unit and the active player's form change release the latch. The edges are read
|
||||
/// after the apply pass, and the releases run in order at the next sync point, before the `"npc"`
|
||||
/// feed.
|
||||
pub(super) fn release_on_field_edges(
|
||||
mut edges: MessageReader<FieldChanged>,
|
||||
me: Query<Entity, With<SelfPlayer>>,
|
||||
spells: Option<Res<Spells>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
let me = me.single().ok();
|
||||
for e in edges.read() {
|
||||
if e.unit_field(FIELD_UNIT_HEALTH) && e.old > 0 && e.new == 0 {
|
||||
// `0x605860` → `0x5e99b0` for the active player, then its own guid for any unit.
|
||||
if e.kind == ObjectType::Player && Some(e.entity) == me {
|
||||
commands.queue(|world: &mut World| release_latch(world, true));
|
||||
}
|
||||
let guid = e.guid;
|
||||
commands.queue(move |world: &mut World| release_cell(world, guid, false));
|
||||
} else if e.unit_field(FIELD_UNIT_BYTES_1)
|
||||
&& Some(e.entity) == me
|
||||
&& form_of(e.old) != form_of(e.new)
|
||||
&& spells
|
||||
.as_ref()
|
||||
.is_some_and(|s| form_releases(&s.forms, form_of(e.new)))
|
||||
{
|
||||
commands.queue(|world: &mut World| release_latch(world, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
//! The latch released by the death of the active player, a shapeshift into a form that cannot
|
||||
//! interact, and the death of the latched unit.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_formats::ShapeshiftForm;
|
||||
use benilla_protocol::field::{FIELD_UNIT_BYTES_1, FIELD_UNIT_HEALTH};
|
||||
use benilla_protocol::messages::ObjectType;
|
||||
|
||||
use super::latch_tests::{harness, latch, open, vocals, BANKER, VENDOR};
|
||||
use super::release::{form_releases, release_on_field_edges};
|
||||
use super::{LatchVocal, NpcWindow};
|
||||
use crate::net::{FieldChanged, SelfPlayer};
|
||||
use crate::ui_action::Spells;
|
||||
use crate::ui_script::sweep_test::seen_in;
|
||||
|
||||
const ME: u64 = 0x0000_0000_0000_0001;
|
||||
const OTHER: u64 = 0x0000_0000_0000_0009;
|
||||
|
||||
/// A form row with `flags1` set as given; nothing else of it is read here.
|
||||
fn form(flags: u32) -> ShapeshiftForm {
|
||||
ShapeshiftForm {
|
||||
bonus_bar: 0,
|
||||
name: String::new(),
|
||||
flags,
|
||||
creature_type: 0,
|
||||
attack_icon: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The latch harness with the release system, the active player and `SpellShapeshiftForm` rows: 1
|
||||
/// and 5 plain forms, 7 a stance (flag 0x1), 8 a form that may still interact (flag 0x8).
|
||||
fn app() -> (App, Entity) {
|
||||
let mut app = harness();
|
||||
let mut spells = Spells::empty_for_tests();
|
||||
spells.forms.insert(1, form(0));
|
||||
spells.forms.insert(5, form(0x2));
|
||||
spells.forms.insert(7, form(0x1 | 0x2));
|
||||
spells.forms.insert(8, form(0x8));
|
||||
app.insert_resource(spells)
|
||||
.add_message::<FieldChanged>()
|
||||
.add_systems(Update, release_on_field_edges);
|
||||
let me = app.world_mut().spawn(SelfPlayer).id();
|
||||
(app, me)
|
||||
}
|
||||
|
||||
fn edge(
|
||||
entity: Entity,
|
||||
guid: u64,
|
||||
kind: ObjectType,
|
||||
index: u16,
|
||||
old: u32,
|
||||
new: u32,
|
||||
) -> FieldChanged {
|
||||
FieldChanged {
|
||||
entity,
|
||||
guid,
|
||||
kind,
|
||||
index,
|
||||
old,
|
||||
new,
|
||||
}
|
||||
}
|
||||
|
||||
fn shift(app: &mut App, entity: Entity, old: u8, new: u8) {
|
||||
app.world_mut().write_message(edge(
|
||||
entity,
|
||||
ME,
|
||||
ObjectType::Player,
|
||||
FIELD_UNIT_BYTES_1,
|
||||
u32::from(old) << 16 | 0x0102,
|
||||
u32::from(new) << 16 | 0x0102,
|
||||
));
|
||||
app.update();
|
||||
}
|
||||
|
||||
/// `0x5e9a51`: the active player dying releases the latch with the goodbye (the player who is
|
||||
/// dead hears none, `0x605f30`), closing the window; another player dying does not.
|
||||
#[test]
|
||||
fn the_active_players_death_closes_the_window() {
|
||||
let (mut app, me) = app();
|
||||
let other = app.world_mut().spawn_empty().id();
|
||||
open(&mut app, NpcWindow::Merchant, VENDOR);
|
||||
vocals(&mut app);
|
||||
|
||||
// Health falls to 0 on another player, and rises on the active one: nothing.
|
||||
app.world_mut().write_message(edge(
|
||||
other,
|
||||
OTHER,
|
||||
ObjectType::Player,
|
||||
FIELD_UNIT_HEALTH,
|
||||
50,
|
||||
0,
|
||||
));
|
||||
app.world_mut()
|
||||
.write_message(edge(me, ME, ObjectType::Player, FIELD_UNIT_HEALTH, 0, 50));
|
||||
app.world_mut()
|
||||
.write_message(edge(me, ME, ObjectType::Player, FIELD_UNIT_HEALTH, 50, 20));
|
||||
app.update();
|
||||
assert_eq!(latch(&app), Some(VENDOR));
|
||||
assert!(seen_in(&app).is_empty());
|
||||
|
||||
app.world_mut()
|
||||
.write_message(edge(me, ME, ObjectType::Player, FIELD_UNIT_HEALTH, 20, 0));
|
||||
app.update();
|
||||
assert_eq!(seen_in(&app), ["MERCHANT_CLOSED"]);
|
||||
assert_eq!(latch(&app), None);
|
||||
assert_eq!(vocals(&mut app), [LatchVocal::Goodbye(VENDOR)]);
|
||||
}
|
||||
|
||||
/// `0x5de07f`: a form without flag 0x8 closes the window with the goodbye; none, a stance, a form
|
||||
/// that may interact, an unknown form, another unit's change and a move of another byte do not.
|
||||
#[test]
|
||||
fn a_shapeshift_into_a_form_that_cannot_interact_closes_the_window() {
|
||||
let (mut app, me) = app();
|
||||
let other = app.world_mut().spawn_empty().id();
|
||||
open(&mut app, NpcWindow::Bank, BANKER);
|
||||
vocals(&mut app);
|
||||
|
||||
shift(&mut app, me, 1, 0);
|
||||
shift(&mut app, me, 0, 7);
|
||||
shift(&mut app, me, 0, 8);
|
||||
shift(&mut app, me, 0, 99);
|
||||
shift(&mut app, other, 0, 1);
|
||||
// The form byte unchanged, the byte beside it moved.
|
||||
app.world_mut().write_message(edge(
|
||||
me,
|
||||
ME,
|
||||
ObjectType::Player,
|
||||
FIELD_UNIT_BYTES_1,
|
||||
1 << 16,
|
||||
1 << 16 | 0x0100,
|
||||
));
|
||||
app.update();
|
||||
assert_eq!(latch(&app), Some(BANKER));
|
||||
assert!(seen_in(&app).is_empty());
|
||||
|
||||
shift(&mut app, me, 0, 1);
|
||||
assert_eq!(seen_in(&app), ["BANKFRAME_CLOSED"]);
|
||||
assert_eq!(latch(&app), None);
|
||||
assert_eq!(vocals(&mut app), [LatchVocal::Goodbye(BANKER)]);
|
||||
|
||||
// A form on a plain, non-stance row with the interact flag clear, from another form.
|
||||
open(&mut app, NpcWindow::Merchant, VENDOR);
|
||||
shift(&mut app, me, 1, 5);
|
||||
assert_eq!(latch(&app), None);
|
||||
}
|
||||
|
||||
/// `0x605950`: the latched unit dying closes its window with no goodbye (`dl = 1`); another
|
||||
/// unit's death leaves it.
|
||||
#[test]
|
||||
fn the_latched_units_death_closes_its_window_without_a_goodbye() {
|
||||
let (mut app, _) = app();
|
||||
let vendor = app.world_mut().spawn_empty().id();
|
||||
let bystander = app.world_mut().spawn_empty().id();
|
||||
open(&mut app, NpcWindow::Merchant, VENDOR);
|
||||
vocals(&mut app);
|
||||
|
||||
app.world_mut().write_message(edge(
|
||||
bystander,
|
||||
OTHER,
|
||||
ObjectType::Unit,
|
||||
FIELD_UNIT_HEALTH,
|
||||
50,
|
||||
0,
|
||||
));
|
||||
app.update();
|
||||
assert_eq!(latch(&app), Some(VENDOR));
|
||||
|
||||
app.world_mut().write_message(edge(
|
||||
vendor,
|
||||
VENDOR,
|
||||
ObjectType::Unit,
|
||||
FIELD_UNIT_HEALTH,
|
||||
50,
|
||||
0,
|
||||
));
|
||||
app.update();
|
||||
assert_eq!(seen_in(&app), ["MERCHANT_CLOSED"]);
|
||||
assert_eq!(latch(&app), None);
|
||||
assert_eq!(vocals(&mut app), [], "dl = 1: the unit says no goodbye");
|
||||
}
|
||||
|
||||
/// A trade partner is a player: its death releases the latch through `0x605950` as any latched
|
||||
/// unit's does.
|
||||
#[test]
|
||||
fn a_latched_players_death_closes_the_window_too() {
|
||||
let (mut app, _) = app();
|
||||
let partner = app.world_mut().spawn_empty().id();
|
||||
open(&mut app, NpcWindow::Trade, OTHER);
|
||||
vocals(&mut app);
|
||||
app.world_mut().write_message(edge(
|
||||
partner,
|
||||
OTHER,
|
||||
ObjectType::Player,
|
||||
FIELD_UNIT_HEALTH,
|
||||
50,
|
||||
0,
|
||||
));
|
||||
app.update();
|
||||
assert_eq!(seen_in(&app), ["TRADE_CLOSED"]);
|
||||
assert_eq!(latch(&app), None);
|
||||
assert_eq!(vocals(&mut app), []);
|
||||
}
|
||||
|
||||
/// The shipped `SpellShapeshiftForm.dbc` through the gate: every druid form that is not a stance
|
||||
/// and Ghost Wolf close the window; the warrior stances, Stealth, Moonkin (all stances) and
|
||||
/// Shadowform (stance, and flag 0x8) do not.
|
||||
#[test]
|
||||
fn the_shipped_forms_split_into_those_that_close_the_window_and_those_that_do_not() {
|
||||
let Some(spells) = crate::ui_action::real_spells() else {
|
||||
return;
|
||||
};
|
||||
let closes = |id: u8| form_releases(&spells.forms, id);
|
||||
for (id, name) in [
|
||||
(1, "Cat"),
|
||||
(2, "Tree"),
|
||||
(3, "Travel"),
|
||||
(4, "Aquatic"),
|
||||
(5, "Bear"),
|
||||
(8, "Dire Bear"),
|
||||
(16, "Ghost Wolf"),
|
||||
] {
|
||||
assert!(closes(id), "{name} form {id}");
|
||||
}
|
||||
for (id, name) in [
|
||||
(0, "no form"),
|
||||
(17, "Battle Stance"),
|
||||
(18, "Defensive Stance"),
|
||||
(19, "Berserker Stance"),
|
||||
(28, "Shadowform"),
|
||||
(30, "Stealth"),
|
||||
(31, "Moonkin"),
|
||||
] {
|
||||
assert!(!closes(id), "{name} form {id}");
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,13 @@ impl ShapeshiftForm {
|
||||
self.flags & 1 != 0
|
||||
}
|
||||
|
||||
/// Flag bit `0x8` of `flags1`: the form may still talk to NPCs. Without it `0x5f0130` refuses
|
||||
/// the interaction (`ERR_CANT_INTERACT_SHAPESHIFTED`) and a change into the form closes the
|
||||
/// open window (`0x5de072`).
|
||||
pub fn allows_interaction(&self) -> bool {
|
||||
self.flags & 0x8 != 0
|
||||
}
|
||||
|
||||
/// Clicking the active form's button cancels its aura (`CMSG_CANCEL_AURA`) unless bit `0x2`
|
||||
/// makes it a silent no-op (`CastShapeshiftForm` `0x4b4810`, guard at `0x4b4963`); the
|
||||
/// warrior stances (`0x7`) set it on 5875.
|
||||
|
||||
Reference in New Issue
Block a user