diff --git a/crates/benilla-app/src/capture/fixtures.rs b/crates/benilla-app/src/capture/fixtures.rs index cc89eeda9..92637c5a2 100644 --- a/crates/benilla-app/src/capture/fixtures.rs +++ b/crates/benilla-app/src/capture/fixtures.rs @@ -102,7 +102,6 @@ pub(super) fn seed_ui_fixture( ), ( ResMut<'static, crate::ui_action::PlayerActions>, - ResMut<'static, crate::ui_bank::BankOpen>, MessageWriter<'static, AppExit>, ResMut<'static, crate::loading_screen::LoadingScreen>, ResMut<'static, crate::ui_gossip::GossipState>, @@ -127,7 +126,7 @@ pub(super) fn seed_ui_fixture( vm.turn(|script, parts| { let (mut loot, mut names, mut selection, mut player, store, rest, set_parts) = parts; let (mut item_sets, mut item_names) = set_parts; - let (mut actions, mut bank, mut exit, mut loading, mut gossip, mut quest) = rest; + let (mut actions, mut exit, mut loading, mut gossip, mut quest) = rest; let (mut items, mut index, mut home_bind) = store; // A glue-screen capture has no world scenario, and no glue screen opens a UI fixture. let Some(scenario) = ctx.scenario else { @@ -392,17 +391,21 @@ pub(super) fn seed_ui_fixture( display_id: 0, }), ); - gossip.npc = Some(NPC_GUID); - gossip.text_id = 1; // A greeting long enough that the wrapped options below run past the parchment. - gossip.greeting = Some( - "A man has been caught stealing corn from the fields of a noble, a lord known \ - for his harsh taxes throughout the land.$B$BMake your choice!" - .into(), + let greeting = "A man has been caught stealing corn from the fields of a noble, \ + a lord known for his harsh taxes throughout the land.$B$BMake \ + your choice!"; + gossip.remember_record( + 1, + vec![benilla_protocol::messages::NpcTextBlock { + probability: 1.0, + male: greeting.into(), + female: greeting.into(), + }], ); // An available quest row above the options, for the quest-row icon and text // seating. - gossip.quests = vec![(783, 0, 5, "Eagan Peltskinner".into())]; + let quests = vec![(783, 0, 5, "Eagan Peltskinner".into())]; // One short option and four that wrap, taller than the parchment, so the capture // covers the stock `GossipResize` per-row height, the scroll frame and its // scrollbar. @@ -416,26 +419,45 @@ pub(super) fn seed_ui_fixture( "I allow the man to take enough corn to feed his family for a couple of days, \ encouraging him to leave the land.", ]; - gossip.options = vec![benilla_protocol::messages::GossipOption { + let mut options = vec![benilla_protocol::messages::GossipOption { index: 0, icon: 1, coded: false, message: "Let me browse your goods.".into(), }]; - gossip - .options - .extend(judgement.iter().enumerate().map(|(i, message)| { - benilla_protocol::messages::GossipOption { - index: i as u32 + 1, - icon: 0, - coded: false, - message: (*message).into(), - } - })); + options.extend(judgement.iter().enumerate().map(|(i, message)| { + benilla_protocol::messages::GossipOption { + index: i as u32 + 1, + icon: 0, + coded: false, + message: (*message).into(), + } + })); + // The draw needs the active player and the NPC's object (`0x4e20b3`, `0x4e20bb`). + let npc = commands + .spawn(( + crate::net::Guid(NPC_GUID), + crate::net::ObjectStore( + benilla_protocol::messages::ObjectFields::from_pairs(&[(2, 9)]), + ), + )) + .id(); + index.0.insert(NPC_GUID, npc); + commands.spawn(( + crate::net::ObjectStore(benilla_protocol::messages::ObjectFields::from_pairs( + &[(2, 0x19)], + )), + crate::net::SelfPlayer, + crate::net::Guid(0x51), + )); + // The message's own handler, which queues `GOSSIP_SHOW` as a live one does. + commands.queue(move |world: &mut World| { + crate::ui_gossip::net::gossip_menu(world, NPC_GUID, 1, options, quests) + }); } UiFixture::Bank => { // A pure banker and the vault, fed through the descriptor and caches that - // `feed_bank` and `ui_items` push into the VM as live wire data would. + // `ui_items` pushes into the VM as live wire data would. use benilla_protocol::messages::ObjectFields; names.insert_creature( NPC_ENTRY, @@ -519,8 +541,8 @@ pub(super) fn seed_ui_fixture( crate::net::SelfPlayer, crate::net::Guid(PLAYER_GUID), )); - // `feed_bank` then fires BANKFRAME_OPENED as a live SMSG_SHOW_BANK would. - bank.open(NPC_GUID); + // The open's own core, which queues BANKFRAME_OPENED as a live SMSG_SHOW_BANK does. + commands.queue(|world: &mut World| crate::ui_bank::open_bank(world, NPC_GUID)); // Opens bank bag 1 once its container feed lands (`GetContainerNumSlots(5) > 0`); // `BankFrameBag1`'s `GetID()` is 5, the container id its handler passes to // `ToggleBag`. diff --git a/crates/benilla-app/src/field_watch.rs b/crates/benilla-app/src/field_watch.rs index a7bc7e7c2..68935467b 100644 --- a/crates/benilla-app/src/field_watch.rs +++ b/crates/benilla-app/src/field_watch.rs @@ -219,6 +219,7 @@ pub(crate) mod id { pub(crate) const UNIT_INVENTORY_CHANGED: u16 = 186; pub(crate) const PLAYER_MONEY: u16 = 200; pub(crate) const PLAYERBANKSLOTS_CHANGED: u16 = 347; + pub(crate) const PLAYERBANKBAGSLOTS_CHANGED: u16 = 350; pub(crate) const UNIT_CLASSIFICATION_CHANGED: u16 = 187; pub(crate) const PLAYER_FLAGS_CHANGED: u16 = 407; pub(crate) const UNIT_QUEST_LOG_CHANGED: u16 = 522; @@ -381,6 +382,11 @@ const COINAGE: (u16, u16) = (0xf70, 4); /// `0x5de4e0`'s range, `PLAYER_BYTES_2` byte 3 (`0x5ddb0b`). const REST_STATE: u16 = 0x1b; +/// The active player's bank-slot watch `0x4f8490`, `PLAYER_BYTES_2` byte 2, the bought bank bag +/// slots, registered at world entry (`0x4f8460`, from `0x4909ba`), behind `0x5de4e0` on the same +/// dword. +const BANK_BAG_SLOTS: (u16, u16) = (0x1a, 1); + /// The skill block: entry `k` at `0x848 + 12k` of the player section, 128 entries (`0x5dda54` to /// `0x5ddaf0`). Each entry's four watches cover two bytes each: the value at `+4`, the maximum at /// `+6`, the temporary bonus at `+8` and the permanent bonus at `+0xa`. @@ -954,6 +960,11 @@ pub(crate) fn placed_values( ); } } + // `0x4f8490`: `PLAYERBANKBAGSLOTS_CHANGED` bare (`0x4f8495`), a banker open or not. + if let Some(at) = at(PLAYER_SECTION, BANK_BAG_SLOTS, List::Object) { + let id = id::PLAYERBANKBAGSLOTS_CHANGED; + pass.push(at, FieldNotice::Global { id }); + } for (offset, len, id) in PLAYER_EVENT_WATCHES { if let Some(at) = at(PLAYER_SECTION, (offset, len), List::Object) { pass.push(at, FieldNotice::Player { id }); @@ -1224,8 +1235,9 @@ mod tests { ); } - /// The rank bar watch is one byte of `PLAYER_FIELD_BYTES2`, the rest-state watch one byte of - /// `PLAYER_BYTES_2`: the other bytes of their dwords run neither. + /// The rank bar watch is one byte of `PLAYER_FIELD_BYTES2`, the rest-state watch and the + /// bank-slot watch one byte each of `PLAYER_BYTES_2`: the other bytes of their dwords run + /// neither, and on one dword the rest state's runs first (`0x5ddb0b`, then `0x4f8460`). #[test] fn a_byte_watch_reads_its_own_byte_only() { let mut me = player(); @@ -1236,10 +1248,14 @@ mod tests { [FieldNotice::Player { id: 524 }] ); let rest = pf(0x1b); - assert!(block(&mut me, Some(ME), ME, &[(rest, 0x00ff_ffff)]).is_empty()); + assert!(block(&mut me, Some(ME), ME, &[(rest, 0x0000_ffff)]).is_empty()); + let bank = FieldNotice::Global { + id: id::PLAYERBANKBAGSLOTS_CHANGED, + }; + assert_eq!(block(&mut me, Some(ME), ME, &[(rest, 0x0002_ffff)]), [bank]); assert_eq!( - block(&mut me, Some(ME), ME, &[(rest, 0x02ff_ffff)]), - [FieldNotice::RestState { old: 0, new: 2 }] + block(&mut me, Some(ME), ME, &[(rest, 0x0203_ffff)]), + [FieldNotice::RestState { old: 0, new: 2 }, bank] ); // The whole `PLAYER_BYTES_3` is the rank watch: its gender byte runs it. assert_eq!( diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index a4802785b..b99bd7a59 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -1862,9 +1862,7 @@ pub(crate) mod schedule_tests { ); let seated = after(units); let stale: Vec<&str> = [ - "::ui_gossip::feed_gossip", "::ui_quest::feed_quest", - "::ui_bank::feed_bank", "::ui_auction::feed_auction", "::ui_trade::feed_trade", ] @@ -2066,8 +2064,6 @@ pub(crate) mod schedule_tests { "filled only by a world right-click on a GameObject or a body; the drain sends casts to the wire"), ("ui_action/recount.rs", "recount", Because::Deliberate, "no system: the item lane's firing calls it per run (`item_lane::fire_run`), inside the lanes' drain, which runs only with the interface up (`ingame_ui_up`)"), - ("ui_bank/mod.rs", "feed_bank", Because::PlayerRoundTrip, - "`BankErrors` answers a `BuyBankSlot` click; the OPENED edge rides `VmMemo`s"), ("ui_char.rs", "feed_char", Because::FilledByVm, "its take is the ammo getters' hits, which only a getter's call leaves, and a frameless VM \ calls none; the doll's own pushes ride `VmMemo`s"), diff --git a/crates/benilla-app/src/lanes.rs b/crates/benilla-app/src/lanes.rs index e7d56f623..e97f7302c 100644 --- a/crates/benilla-app/src/lanes.rs +++ b/crates/benilla-app/src/lanes.rs @@ -482,6 +482,8 @@ mod aura_view_tests; #[cfg(test)] mod bag_tests; #[cfg(test)] +mod bank_live_tests; +#[cfg(test)] mod battlefield_live_tests; #[cfg(test)] mod book_live_tests; @@ -494,6 +496,8 @@ mod dialog_live_tests; #[cfg(test)] mod duration_live_tests; #[cfg(test)] +mod gossip_live_tests; +#[cfg(test)] mod group_view_tests; #[cfg(test)] mod guard_tests; diff --git a/crates/benilla-app/src/lanes/bank_live_tests.rs b/crates/benilla-app/src/lanes/bank_live_tests.rs new file mode 100644 index 000000000..6904a4ac7 --- /dev/null +++ b/crates/benilla-app/src/lanes/bank_live_tests.rs @@ -0,0 +1,231 @@ +//! The bank getters as a listener reads them, and the bank's events at their packets. The getters +//! read the active player's bought bag slots and the next price at the call (`0x4f85b0`, +//! `0x4f8630`), no banker tested; `SMSG_SHOW_BANK`'s open signals `BANKFRAME_OPENED` inside it +//! (`0x4f8522`), a refusal shows its line inside `SMSG_BUY_BANK_SLOT_RESULT`'s handler +//! (`0x5e3f8d`), and the bought count's watch signals `PLAYERBANKBAGSLOTS_CHANGED` at its packet +//! (`0x4f8490`). + +use benilla_protocol::{EntityKind, SessionEvent}; +use benilla_ui::script::UiScript; +use bevy::prelude::*; +use crossbeam_channel::Sender; + +use super::unit_view_tests::{created, values, wire_packets}; + +const ME: u64 = 0x7; +const BANKER: u64 = 0xF130_0000_0000_0043; +/// `PLAYER_BYTES_2`, absolute: byte 2 is the bought bank bag slots. +const PLAYER_BYTES_2: u16 = 194; + +/// The whole client, headless, in the world. +struct Client { + app: App, + inbound: Sender, +} + +impl Client { + /// One frame of the client's, with `packets` in the inbound channel, each one packet. + fn frame(&mut self, packets: Vec>) { + for ev in wire_packets(packets) { + self.inbound.send(ev).expect("the inbound channel"); + } + self.app.update(); + } + + /// `chunk`'s answer through the lent host when `live`, else off the VM's copy alone. + fn eval(&mut self, live: bool, chunk: &str) -> T { + if live { + crate::script_calls::with_hosted_vm(self.app.world_mut(), |s| { + s.expect("a VM").eval::(chunk).expect("runs") + }) + } else { + crate::script_calls::eval_in_vm(self.app.world(), chunk) + } + } + + /// What the spy heard since the last call. + fn seen(&mut self) -> Vec { + self.eval(false, "local s = Seen Seen = {} return s") + } + + /// `packets` run through their handlers with no frame: the world as the handlers leave it. + fn handle(&mut self, packets: Vec) { + crate::net::handlers::dispatch(self.app.world_mut(), packets); + } +} + +/// The client in the world with a banker beside us, two bag slots bought, the price table's first +/// rows, and a spy whose line for each bank event and message line carries its name and first +/// argument. +fn client() -> Client { + let mut app = crate::game_plugins::schedule_tests::headless_client(); + app.insert_resource(bevy::ecs::error::DefaultErrorHandler( + bevy::ecs::error::ignore, + )); + let (tx, _sent) = crossbeam_channel::unbounded(); + app.insert_resource(crate::net::NetCommands(tx)); + let (inbound, rx) = crossbeam_channel::unbounded(); + app.insert_resource(crate::net::NetEvents::for_test(rx)); + app.insert_resource(crate::ui_bank::BankPrices( + benilla_formats::BankBagSlotPrices::from_rows([(1, 1_000), (2, 10_000), (3, 100_000)]), + )); + app.update(); + let script = UiScript::new().expect("a VM"); + script + .run( + r#" + Seen = {} + ERR_BANKSLOT_INSUFFICIENT_FUNDS = "too poor" + local f = CreateFrame("Frame") + for _, e in ipairs({"BANKFRAME_OPENED", "BANKFRAME_CLOSED", + "PLAYERBANKBAGSLOTS_CHANGED", "UI_ERROR_MESSAGE", "CHAT_MSG_MONSTER_SAY"}) do + f:RegisterEvent(e) + end + f:SetScript("OnEvent", function() + table.insert(Seen, event .. ":" .. tostring(arg1)) + end) + "#, + ) + .unwrap(); + app.insert_non_send_resource(script); + app.insert_resource(State::new(crate::client_state::ClientState::InWorld)); + benilla_world::world_point::init_world_point_resources(app.world_mut()); + app.world_mut().resource_mut::().0 = Some(ME); + let mut c = Client { app, inbound }; + c.frame(vec![vec![ + created(ME, EntityKind::Player, &[(PLAYER_BYTES_2, 2 << 16)]), + created(BANKER, EntityKind::Unit, &[]), + ]]); + c.frame(vec![]); + c.seen(); + c +} + +/// A monster's line, which shows inside its own packet's handler on the chat lane. +fn boar_says(text: &str) -> SessionEvent { + SessionEvent::Chat(benilla_protocol::messages::ChatMessage { + chat_type: benilla_protocol::messages::CHAT_MSG_MONSTER_SAY, + language: 0, + sender_guid: 0xF130_0000_0045_0003, + target_guid: 0, + sender_name: Some("Boar".into()), + channel: None, + text: text.into(), + chat_tag: 0, + }) +} + +const SLOTS: &str = "local n, full = GetNumBankSlots() return n, full == 1, GetBankSlotCost()"; + +/// The purchase row reads the player at the call, no banker open, with no push between; with no +/// host the VM's copy, which nothing pushes, holds none. +#[test] +fn the_bank_getters_read_the_player_at_the_call() { + let mut c = client(); + assert_eq!(c.eval::<(i64, bool, i64)>(true, SLOTS), (2, false, 100_000)); + assert_eq!(c.eval::<(i64, bool, i64)>(false, SLOTS), (0, false, 0)); + c.handle(vec![ + values(ME, &[(PLAYER_BYTES_2, 6 << 16)]), + SessionEvent::ObjectUpdateEnd, + ]); + assert_eq!( + c.eval::<(i64, bool, i64)>(true, SLOTS), + (6, true, 0), + "six bought: full, and no seventh row" + ); +} + +/// The open's `BANKFRAME_OPENED` sits on the bank lane at its handler's stamp, between the stamps +/// of the handlers before and after it. +#[test] +fn the_open_is_stamped_at_its_handler() { + let mut c = client(); + let before = crate::lanes::stamp(); + c.handle(vec![SessionEvent::ShowBank { banker: BANKER }]); + let after = crate::lanes::stamp(); + let stamped = c + .app + .world() + .resource::() + .stamped(); + assert_eq!(stamped.len(), 1); + let (at, event) = stamped[0]; + assert_eq!(event, crate::ui_bank::BankEvent::Opened); + assert!(before < at && at < after, "stamped inside the handler"); +} + +/// The open, a refusal's line and a bought slot's watch each fire at their packet's place around +/// a chat line of a packet between them; the watch fires with no banker open too. +#[test] +fn the_bank_events_fire_at_their_packets() { + let mut c = client(); + c.frame(vec![ + vec![boar_says("a")], + vec![SessionEvent::ShowBank { banker: BANKER }], + vec![boar_says("b")], + vec![SessionEvent::BuyBankSlotResult { + result: benilla_protocol::messages::bank_slot_result::INSUFFICIENT_FUNDS, + }], + vec![boar_says("c")], + vec![values(ME, &[(PLAYER_BYTES_2, 3 << 16)])], + ]); + assert_eq!( + c.seen(), + [ + "CHAT_MSG_MONSTER_SAY:a", + "BANKFRAME_OPENED:nil", + "CHAT_MSG_MONSTER_SAY:b", + "UI_ERROR_MESSAGE:too poor", + "CHAT_MSG_MONSTER_SAY:c", + "PLAYERBANKBAGSLOTS_CHANGED:nil", + ] + ); + crate::ui_bank::close_core(c.app.world_mut()); + c.seen(); + c.frame(vec![vec![values(ME, &[(PLAYER_BYTES_2, 4 << 16)])]]); + assert_eq!(c.seen(), ["PLAYERBANKBAGSLOTS_CHANGED:nil"]); +} + +/// A close in the open's frame fires after the open, which a close core run ahead of the drain +/// would otherwise beat. The close signals first and then releases the latch, which re-enters it +/// with the cell still set (`0x4f84df`, `0x4f84eb` → `0x493310` → `0x4f84d0`), so +/// `BANKFRAME_CLOSED` signals twice. +#[test] +fn a_close_fires_after_the_open() { + let mut c = client(); + c.handle(vec![SessionEvent::ShowBank { banker: BANKER }]); + crate::ui_bank::close_core(c.app.world_mut()); + c.frame(vec![]); + assert_eq!( + c.seen(), + [ + "BANKFRAME_OPENED:nil", + "BANKFRAME_CLOSED:nil", + "BANKFRAME_CLOSED:nil" + ] + ); + assert!(!c + .app + .world() + .resource::() + .is_open()); +} + +/// Two bought slots in one frame, each its own packet: the watch's listener reads the count its +/// own packet left (`0x4f8490` signals inside the packet, `0x4f85dd` reads the field at the call), +/// not the frame's last. +#[test] +fn the_bank_slots_read_as_of_each_packet() { + let mut c = client(); + c.eval::<()>( + false, + "Slots = {} local f = CreateFrame('Frame') \ + f:RegisterEvent('PLAYERBANKBAGSLOTS_CHANGED') \ + f:SetScript('OnEvent', function() table.insert(Slots, (GetNumBankSlots())) end)", + ); + c.frame(vec![ + vec![values(ME, &[(PLAYER_BYTES_2, 3 << 16)])], + vec![values(ME, &[(PLAYER_BYTES_2, 4 << 16)])], + ]); + assert_eq!(c.eval::>(false, "return Slots"), [3, 4]); +} diff --git a/crates/benilla-app/src/lanes/gossip_live_tests.rs b/crates/benilla-app/src/lanes/gossip_live_tests.rs new file mode 100644 index 000000000..253ff5b7c --- /dev/null +++ b/crates/benilla-app/src/lanes/gossip_live_tests.rs @@ -0,0 +1,412 @@ +//! The gossip getters as a listener reads them, and `GOSSIP_SHOW` at its packet. The getters read +//! the message's slots (`0xbbbe90`, `0x4e28d0`), its quest rows (`0xbb74c0`) and the text buffer +//! (`0xbbb678`, `0x4e28c0`) at the call; the show fires inside `0x4e2010` (`0x4e22b5`), right after +//! the text's write (`0x4e229a`), which the message runs (`0x4e27df`) and so does its text's +//! arrival (`0x4e22f2`). + +use benilla_protocol::field::FIELD_UNIT_HEALTH; +use benilla_protocol::messages::{GossipOption, NpcTextBlock}; +use benilla_protocol::{EntityKind, SessionEvent}; +use benilla_ui::script::UiScript; +use bevy::prelude::*; +use crossbeam_channel::{Receiver, Sender}; + +use super::unit_view_tests::{created, values, wire_packets}; +use crate::net::ClientCommand; + +const ME: u64 = 0x7; +const NPC: u64 = 0xF130_0000_0000_0043; +/// `OBJECT_FIELD_ENTRY`, absolute, and the NPC's creature record. +const OBJECT_ENTRY: u16 = 3; +const NPC_ENTRY: u32 = 0x45; + +/// The whole client, headless, in the world. +struct Client { + app: App, + inbound: Sender, + sent: Receiver, +} + +impl Client { + /// One frame of the client's, with `packets` in the inbound channel, each one packet. + fn frame(&mut self, packets: Vec>) { + for ev in wire_packets(packets) { + self.inbound.send(ev).expect("the inbound channel"); + } + self.app.update(); + } + + /// `chunk`'s answer through the lent host when `live`, else off the VM's copy alone. + fn eval(&mut self, live: bool, chunk: &str) -> T { + if live { + crate::script_calls::with_hosted_vm(self.app.world_mut(), |s| { + s.expect("a VM").eval::(chunk).expect("runs") + }) + } else { + crate::script_calls::eval_in_vm(self.app.world(), chunk) + } + } + + /// What the spy heard since the last call. + fn seen(&mut self) -> Vec { + self.eval(false, "local s = Seen Seen = {} return s") + } + + /// The pet-name queries sent since the last call, by pet number. + fn pet_name_queries(&mut self) -> Vec { + crate::query_cache::queries(&self.sent) + .into_iter() + .filter_map(|c| match c { + ClientCommand::PetNameQuery { pet_number, .. } => Some(pet_number), + _ => None, + }) + .collect() + } + + /// `packets` run through their handlers with no frame: the world as the handlers leave it. + fn handle(&mut self, packets: Vec) { + crate::net::handlers::dispatch(self.app.world_mut(), packets); + } +} + +/// The client in the world with an NPC beside us, a VM `prepare` seeds, and a spy whose line for +/// each gossip event carries its name and first argument. +fn client_with(prepare: impl FnOnce(&mut UiScript)) -> Client { + let mut app = crate::game_plugins::schedule_tests::headless_client(); + app.insert_resource(bevy::ecs::error::DefaultErrorHandler( + bevy::ecs::error::ignore, + )); + let (tx, sent) = crossbeam_channel::unbounded(); + app.insert_resource(crate::net::NetCommands(tx)); + let (inbound, rx) = crossbeam_channel::unbounded(); + app.insert_resource(crate::net::NetEvents::for_test(rx)); + app.update(); + let mut script = UiScript::new().expect("a VM"); + prepare(&mut script); + script + .run( + r#" + Seen = {} + local f = CreateFrame("Frame") + for _, e in ipairs({"GOSSIP_SHOW", "GOSSIP_CLOSED", "CHAT_MSG_MONSTER_SAY"}) do + f:RegisterEvent(e) + end + f:SetScript("OnEvent", function() + table.insert(Seen, event .. ":" .. tostring(arg1)) + end) + "#, + ) + .unwrap(); + app.insert_non_send_resource(script); + app.insert_resource(State::new(crate::client_state::ClientState::InWorld)); + benilla_world::world_point::init_world_point_resources(app.world_mut()); + app.world_mut().resource_mut::().0 = Some(ME); + let mut c = Client { app, inbound, sent }; + c.frame(vec![vec![ + // Alive, so a panel opens (`ShowUIPanel`'s `UnitIsDead` test, `UIParent.lua`). + created(ME, EntityKind::Player, &[(FIELD_UNIT_HEALTH, 100)]), + created(NPC, EntityKind::Unit, &[(OBJECT_ENTRY, NPC_ENTRY)]), + ]]); + c.frame(vec![]); + c.seen(); + c +} + +fn client() -> Client { + client_with(|_| {}) +} + +fn option(label: &str) -> GossipOption { + GossipOption { + index: 0, + icon: 1, + coded: false, + message: label.into(), + } +} + +/// `NPC`'s message on text `text_id` with one option labelled `label`, and one available quest. +fn menu(text_id: u32, label: &str) -> SessionEvent { + SessionEvent::GossipMenu { + npc: NPC, + text_id, + options: vec![option(label)], + quests: vec![(783, 2, 5, "A Threat Within".into())], + } +} + +/// Text `text_id`'s record, `line` in both columns. +fn greeting(text_id: u32, line: &str) -> SessionEvent { + SessionEvent::NpcGreeting { + text_id, + blocks: vec![NpcTextBlock { + probability: 1.0, + male: line.into(), + female: line.into(), + }], + } +} + +/// A monster's line, which shows inside its own packet's handler on the chat lane. +fn boar_says(text: &str) -> SessionEvent { + SessionEvent::Chat(benilla_protocol::messages::ChatMessage { + chat_type: benilla_protocol::messages::CHAT_MSG_MONSTER_SAY, + language: 0, + sender_guid: 0xF130_0000_0045_0003, + target_guid: 0, + sender_name: Some("Boar".into()), + channel: None, + text: text.into(), + chat_tag: 0, + }) +} + +const STORE: &str = "local function n(...) return arg.n end \ + return GetGossipText() .. ',' .. (GetGossipOptions()) .. ',' .. n(GetGossipOptions()) \ + .. ',' .. (GetGossipAvailableQuests())"; + +/// Each getter reads what the handlers wrote at the call, with no push between: the drawn text, +/// the slots and the quest rows; with no host the VM's copy, which nothing pushes, holds none. +#[test] +fn the_gossip_getters_read_the_world_at_the_call() { + let mut c = client(); + c.handle(vec![menu(7, "Browse"), greeting(7, "Hello")]); + assert_eq!( + c.eval::(true, STORE), + "Hello,Browse,2,A Threat Within" + ); + assert_eq!( + c.eval::>(false, "return GetGossipText()"), + None + ); + // The next message's slots read at once; its text, still out, leaves the last. + c.handle(vec![menu(8, "Fly")]); + assert_eq!(c.eval::(true, STORE), "Hello,Fly,2,A Threat Within"); + crate::ui_gossip::close_core(c.app.world_mut()); + assert_eq!( + c.eval::(true, STORE), + "Hello,Fly,2,A Threat Within", + "a close leaves the store (`0x4e1fa0`)" + ); +} + +/// The draw's `GOSSIP_SHOW` sits on the gossip lane at the text's handler, between the stamps of +/// the handlers before and after it. +#[test] +fn the_show_is_stamped_at_its_handler() { + let mut c = client(); + c.handle(vec![menu(7, "Browse")]); + assert!(c + .app + .world() + .resource::() + .stamped() + .is_empty()); + let before = crate::lanes::stamp(); + c.handle(vec![greeting(7, "Hello")]); + let after = crate::lanes::stamp(); + let stamped = c + .app + .world() + .resource::() + .stamped(); + assert_eq!(stamped.len(), 1); + let (at, event) = stamped[0]; + assert_eq!(event, crate::ui_gossip::GossipEvent::Show); + assert!(before < at && at < after, "stamped inside the handler"); +} + +/// A cached text shows at its message and a first visit at its text's arrival, each at its +/// packet's place around a chat line of a packet between; the round trip fires nothing. +#[test] +fn the_show_fires_at_its_packet() { + let mut c = client(); + c.handle(vec![greeting(7, "Hello")]); + c.frame(vec![ + vec![boar_says("a")], + vec![menu(7, "Browse")], + vec![boar_says("b")], + vec![menu(8, "Fly")], + vec![boar_says("c")], + vec![greeting(8, "Safe travels")], + ]); + assert_eq!( + c.seen(), + [ + "CHAT_MSG_MONSTER_SAY:a", + "GOSSIP_SHOW:nil", + "CHAT_MSG_MONSTER_SAY:b", + "CHAT_MSG_MONSTER_SAY:c", + "GOSSIP_SHOW:nil", + ] + ); + assert_eq!( + c.eval::(true, "return GetGossipText()"), + "Safe travels" + ); +} + +/// A close in the show's frame fires after it. A text landing after a close draws nothing: the +/// close cancels no wait (`0x4e1fa0`), but the draw finds no object under the zeroed cell and +/// bails (`0x4e20bb` → `0x4e22ba`), with no text write and no signal. The close's latch release +/// re-enters it with the cell still set (`0x4e1fc5` → `0x493310` → `0x4e1fa0`), so +/// `GOSSIP_CLOSED` signals twice (`0x4e1fdc`). +#[test] +fn a_close_fires_after_the_show_and_a_late_text_draws_nothing() { + let mut c = client(); + c.handle(vec![greeting(7, "Hello"), menu(7, "Browse")]); + crate::ui_gossip::close_core(c.app.world_mut()); + c.frame(vec![]); + assert_eq!( + c.seen(), + ["GOSSIP_SHOW:nil", "GOSSIP_CLOSED:nil", "GOSSIP_CLOSED:nil"] + ); + c.frame(vec![vec![menu(9, "Fly")]]); + crate::ui_gossip::close_core(c.app.world_mut()); + c.frame(vec![vec![greeting(9, "Too late")]]); + assert_eq!(c.seen(), ["GOSSIP_CLOSED:nil", "GOSSIP_CLOSED:nil"]); + assert_eq!(c.eval::(true, "return GetGossipText()"), "Hello"); +} + +/// A message whose NPC is no object here draws nothing either (`0x4e20bb`). +#[test] +fn a_menu_of_an_npc_not_held_draws_nothing() { + let mut c = client(); + c.handle(vec![greeting(7, "Hello")]); + c.frame(vec![vec![SessionEvent::GossipMenu { + npc: 0xF130_0000_0000_0099, + text_id: 7, + options: vec![option("Browse")], + quests: Vec::new(), + }]]); + assert!(!c.seen().iter().any(|e| e == "GOSSIP_SHOW:nil")); + assert_eq!(c.eval::(true, "return GetGossipText()"), ""); +} + +/// The show asks the NPC's name as the stock window's `UnitName("npc")` does there +/// (`GossipFrame.lua:33`, `0x5171b8`), at the draw that signals it, a first visit's text; never +/// the round trip before it. A held creature's name is its record, which its create asked for, so +/// the ask is seen on an NPC that names itself by its pet number (`0x609210`). +#[test] +fn the_show_asks_the_npcs_name() { + /// `UNIT_FIELD_PETNUMBER`, absolute. + const PET_NUMBER: u16 = 139; + let mut c = client(); + c.handle(vec![ + values(NPC, &[(PET_NUMBER, 5)]), + SessionEvent::ObjectUpdateEnd, + ]); + c.app + .world_mut() + .resource_mut::() + .forget_pet(5); + c.pet_name_queries(); + c.handle(vec![menu(8, "Fly")]); + assert!(c.pet_name_queries().is_empty(), "no show, no ask"); + c.handle(vec![greeting(8, "Safe travels")]); + assert_eq!(c.pet_name_queries(), [5], "asked at the draw"); +} + +/// The greeting's macros expand once per message, as `0x4e2010` writes the expanded text to +/// `0xbbb678` at its draw (`0x4e2274`, `0x4e229a`): a later change to the player's name leaves it +/// until the next message. +#[test] +fn the_greeting_expands_once_per_message() { + let mut c = client(); + let named = |c: &mut Client, name: &str| { + c.app + .world_mut() + .resource_mut::() + .insert_player(ME, name.into(), None); + }; + named(&mut c, "Ann"); + c.handle(vec![greeting(7, "Well met, $N."), menu(7, "Browse")]); + assert_eq!( + c.eval::(true, "return GetGossipText()"), + "Well met, Ann." + ); + named(&mut c, "Bea"); + assert_eq!( + c.eval::(true, "return GetGossipText()"), + "Well met, Ann.", + "held until the next message" + ); + c.handle(vec![menu(7, "Browse")]); + assert_eq!( + c.eval::(true, "return GetGossipText()"), + "Well met, Bea." + ); +} + +/// Stock `GossipFrame.xml` through a first-visit sub-menu: the window stays up on the first menu +/// while the text is out, with nothing signalled, and repaints in place once it lands. +#[test] +fn stock_gossip_frame_stays_visible_through_a_first_visit_submenu() { + let _data = benilla_formats::wow_data_or_skip!(); + let mut c = client_with(|s| { + s.set_screen_size(1024.0, 768.0); + s.set_text_measurer(Box::new(crate::ui_script::FixedWidthFont(6.0))); + for f in crate::ui_script::test_ui::production_order(&[ + crate::ui_script::test_ui::GOSSIP_UI, + &[r"Interface\FrameXML\GossipFrame.xml"], + ]) { + crate::ui_script::test_ui::load_ui(s, f); + } + }); + // What the window shows: visible?, the greeting, the first row's label. + let window = |c: &mut Client| -> (bool, String, String) { + let s = c.app.world().non_send_resource::(); + ( + s.eval::("return GossipFrame:IsVisible()").unwrap(), + s.eval::("return GossipGreetingText:GetText() or ''") + .unwrap(), + s.eval::("return GossipTitleButton1:GetText() or ''") + .unwrap(), + ) + }; + // A message with one option and no quest row. + let menu = |text_id: u32, label: &str| SessionEvent::GossipMenu { + npc: NPC, + text_id, + options: vec![option(label)], + quests: Vec::new(), + }; + c.frame(vec![ + vec![greeting(50, "What are you looking for?")], + vec![menu(50, "The bank")], + ]); + assert_eq!(c.seen(), ["GOSSIP_SHOW:nil"]); + assert_eq!( + window(&mut c), + ( + true, + "What are you looking for?".to_string(), + "The bank".to_string(), + ), + "the greeting menu opens" + ); + for _ in 0..3 { + c.frame(vec![vec![menu(51, "Goodbye")]]); + assert!(c.seen().is_empty(), "nothing fires during the hold"); + assert_eq!( + window(&mut c), + ( + true, + "What are you looking for?".to_string(), + "The bank".to_string(), + ), + "the window stays up, unchanged, for the round trip" + ); + } + c.frame(vec![vec![greeting(51, "The bank is north of here.")]]); + assert_eq!(c.seen(), ["GOSSIP_SHOW:nil"]); + assert_eq!( + window(&mut c), + ( + true, + "The bank is north of here.".to_string(), + "Goodbye".to_string(), + ), + "an in-place repaint" + ); +} diff --git a/crates/benilla-app/src/lanes/guard_tests.rs b/crates/benilla-app/src/lanes/guard_tests.rs index 43ec86e50..f8f4249ba 100644 --- a/crates/benilla-app/src/lanes/guard_tests.rs +++ b/crates/benilla-app/src/lanes/guard_tests.rs @@ -102,7 +102,6 @@ const NOT_YET_ON_A_LANE: &[(Row, &str, &str)] = &[ "::player::net::SkinnedFeed", "the skinned state", ), - (Row::Queue, "::ui_bank::BankErrors", "the bank errors"), ( Row::Queue, "::player::view_subject::FarSight", @@ -154,12 +153,6 @@ const NOT_YET_ON_A_LANE: &[(Row, &str, &str)] = &[ "::ui_quest::lines::QuestLines", "the quest lines", ), - ( - Row::HostHeld, - "::ui_gossip::GossipState", - "the gossip message's slots, which `feed_gossip` fires from; the gossip verbs, acting in \ - their calls, hold it in the host", - ), ( Row::HostHeld, "::ui_quest::QuestGiver", @@ -185,11 +178,6 @@ const NOT_YET_ON_A_LANE: &[(Row, &str, &str)] = &[ "::death::DeathNet", "the death stores, which `feed_death` fires the offers and the corpse range from; the death verbs, acting in their calls, hold them in the host", ), - ( - Row::HostHeld, - "::ui_bank::BankOpen", - "the banker's cell, which `feed_bank` fires from; `PurchaseSlot` and `CloseBankFrame`, acting in their calls, hold it in the host", - ), ( Row::HostHeld, "::ui_craft::CraftOpen", @@ -267,6 +255,9 @@ const HOST_HELD_UNREAD: &[&str] = &[ "::target::Selection", "::ui_action::PlayerActions", "::ui_auction::AuctionOpen", + // Read through the host by the bank getters, and quiet: its events fire on the bank lane at + // their handlers, and `PurchaseSlot` and `CloseBankFrame` hold it in their calls. + "::ui_bank::BankOpen", // Read, and quiet: the buff timers read the stamps through the host and no feed fires off // them; the aura fields' watcher raises `PLAYER_AURAS_CHANGED`. "::ui_aura::AuraDurations", @@ -293,6 +284,9 @@ const HOST_HELD_UNREAD: &[&str] = &[ // through the host, and no feed fires off it, as the reply's `INSPECT_HONOR_UPDATE` goes on // the notice lane at its handler and a re-target's fires in its call. "::ui_honor::InspectHonor", + // Read through the host by the gossip getters, and quiet: the draw's `GOSSIP_SHOW` fires on the + // gossip lane at its handler, and the gossip verbs hold it in their calls. + "::ui_gossip::GossipState", "::ui_loot::AutoLootByte", "::ui_loot::LootLatch", // Read through the host by the merchant getters, and quiet: its events fire on the merchant @@ -440,17 +434,9 @@ pub(super) const READS_FRAME_FINAL: &[(&str, &[&str])] = &[ "ui_petition::PetitionRecords"]), ("::ui_items::feed::feed_item_stats", &["items::Items", "net::HomeBind", "net::ObjectStore", "spell::mods::SpellModifiers"]), - ("::ui_gossip::feed_gossip", &["items::Countdowns", "names::NameCache", "net::GuidIndex", - "net::ObjectStore", "ui_gossip::GossipState", "world_state::WorldStates"]), - // The purse the trade and mail verbs read off the VM; `GetMoney` reads it through the host. - ("::ui_merchant::feed_purse", &["net::ObjectStore"]), - ("::ui_merchant::feed_repair_costs", &["items::Countdowns", "items::Items", "names::NameCache", - "net::GuidIndex", "net::ObjectStore", "net::SelfGuid", "net::reputations::Reputations", - "ui_merchant::MerchantOpen"]), ("::ui_auction::feed_auction", &["items::Items", "names::NameCache", "ui_auction::AuctionOpen"]), ("::ui_auction::reset_auction_on_world_enter", &["ui_auction::AuctionOpen"]), - ("::ui_bank::feed_bank", &["net::ObjectStore", "ui_bank::BankErrors", "ui_bank::BankOpen"]), ("::ui_mail::feed_mail", &["go_templates::GameObjectTemplates", "items::Countdowns", "items::Items", "names::NameCache", "net::GuidIndex", "net::ObjectStore", "ui_mail::MailOpen", "ui_mail::pending::MailPending", "world_state::WorldStates"]), @@ -647,7 +633,7 @@ const READ_ONLY_TO_HANDLERS: &[&str] = &[ ]; /// Every lane's queue, by type: what a handler fills there fires in the packets' order. -fn lane_types() -> [TypeId; 31] { +fn lane_types() -> [TypeId; 33] { [ TypeId::of::(), // The views lane: what each packet changed in the stores. @@ -705,12 +691,16 @@ fn lane_types() -> [TypeId; 31] { TypeId::of::(), // The trainer lane: the list's and the state passes' pushes and signals. TypeId::of::(), + // The bank lane: the open's signal and the refusals' lines. + TypeId::of::(), + // The gossip lane: the greeting's draw. + TypeId::of::(), ] } /// [`lane_types`] by the lane that drains each: a queue is stripped only from its own lane's row, /// so another lane's read of the same type shows as the state it is. -fn lane_queue_owners() -> [(&'static str, TypeId); 31] { +fn lane_queue_owners() -> [(&'static str, TypeId); 33] { [ ("bag view", TypeId::of::()), ("unit views", TypeId::of::()), @@ -764,6 +754,8 @@ fn lane_queue_owners() -> [(&'static str, TypeId); 31] { ("stable", TypeId::of::()), ("charter", TypeId::of::()), ("trainer", TypeId::of::()), + ("bank", TypeId::of::()), + ("gossip", TypeId::of::()), ] } @@ -917,6 +909,7 @@ fn the_drain_runs_after_the_feeds_and_before_the_tick() { [ "auction house", "bag view", + "bank", "battlefield", "cast reports", "cast views", @@ -928,6 +921,7 @@ fn the_drain_runs_after_the_feeds_and_before_the_tick() { "dialogs", "equip errors", "field notices", + "gossip", "guild", "item text", "item watchers", diff --git a/crates/benilla-app/src/lanes/host_read_tests.rs b/crates/benilla-app/src/lanes/host_read_tests.rs index 9759600f2..53b5c9704 100644 --- a/crates/benilla-app/src/lanes/host_read_tests.rs +++ b/crates/benilla-app/src/lanes/host_read_tests.rs @@ -24,8 +24,8 @@ use crate::game_plugins::schedule_tests::{census, type_names_available, SyncPoin /// The most rows [`super::guard_tests::READS_FRAME_FINAL`] may hold, and the most inputs across /// them: each only falls, so lower it when a row or an input goes. -const FRAME_FINAL_FEEDS: usize = 56; -const FRAME_FINAL_INPUTS: usize = 295; +const FRAME_FINAL_FEEDS: usize = 52; +const FRAME_FINAL_INPUTS: usize = 277; /// The systems that may run after the dispatch (`apply_net_updates`) and run ahead of the drain /// (`fire_in_order`), by a suffix of the system's name, with what each writes of the host's live diff --git a/crates/benilla-app/src/lanes/item_live_tests.rs b/crates/benilla-app/src/lanes/item_live_tests.rs index 99a0f73f1..288c3555c 100644 --- a/crates/benilla-app/src/lanes/item_live_tests.rs +++ b/crates/benilla-app/src/lanes/item_live_tests.rs @@ -431,6 +431,11 @@ fn client(window: Window) -> Client { world .resource_mut::() .open(BANKER); + // The banker's cell in the VM, which the open writes beside it (`0x4f850e`), for the vault. + crate::script_calls::with_hosted_vm(world, |s| { + s.expect("a VM") + .set_bank(Some(benilla_ui::script::BankState::default())) + }); match window { Window::Trade => { world diff --git a/crates/benilla-app/src/lanes/merchant_live_tests.rs b/crates/benilla-app/src/lanes/merchant_live_tests.rs index 3ea7f1ba4..9331447c4 100644 --- a/crates/benilla-app/src/lanes/merchant_live_tests.rs +++ b/crates/benilla-app/src/lanes/merchant_live_tests.rs @@ -458,3 +458,104 @@ fn a_close_in_the_lists_frame_fires_after_its_show() { c.frame(vec![vec![list(vec![row(1, 25)])], vec![trainer]]); assert_eq!(c.seen(), ["MERCHANT_SHOW:nil", "MERCHANT_CLOSED:nil"]); } + +/// The verbs that test the purse read the coinage at the call, as `GetMoney` does (`0x518160`, +/// `+0xf70`): `PickupPlayerMoney`'s gate (`0x494cc0`) passes on a coinage its packet wrote, with no +/// push between, and refuses once a later packet lowers it. +#[test] +fn the_purse_tests_read_the_coinage_at_the_call() { + use benilla_protocol::field::FIELD_PLAYER_FIELD_COINAGE; + let mut c = client(); + let pickup = "ClearCursor() PickupPlayerMoney(400) return GetCursorMoney()"; + c.handle(vec![values(ME, &[(FIELD_PLAYER_FIELD_COINAGE, 500)])]); + assert_eq!(c.eval::(true, pickup), 400); + c.handle(vec![values(ME, &[(FIELD_PLAYER_FIELD_COINAGE, 300)])]); + assert_eq!(c.eval::(true, pickup), 0, "the purse falls short"); +} + +/// A bag item's repair cost read in a listener of each of two packets in one frame, each packet +/// moving the open vendor's PvP flag and the purse: `SetBagItem` prices the object at the call +/// after the vendor's discount (`0x534948` → `0x4faf30`, `0x4faf8a`), as stock +/// `ContainerFrame.lua:267` reads it in its listeners, so each listener reads the vendor its own +/// packet left, not the frame's last. +#[test] +fn a_repair_cost_reads_as_of_each_packet() { + use benilla_protocol::field::{ + FIELD_PLAYER_FIELD_COINAGE, FIELD_PLAYER_INV_SLOT_HEAD, FIELD_UNIT_FLAGS, + }; + /// `OBJECT_FIELD_ENTRY`, `ITEM_FIELD_STACK_COUNT`, `ITEM_FIELD_DURABILITY` and its maximum. + const ENTRY: u16 = 3; + const STACK: u16 = 14; + const DURABILITY: u16 = 46; + const MAX_DURABILITY: u16 = 47; + /// `PLAYER_BYTES_3`, absolute: byte 3 is the honor rank the discount steps by. + const PLAYER_BYTES_3: u16 = 195; + const SWORD: u64 = 0x4000_0000_0000_0707; + const SWORD_ENTRY: u32 = 2488; + let (full, discounted) = crate::ui_merchant::purse_fixture::sword_prices(); + let mut c = client(); + // One level-20 row at 1 copper a point for a sword (weapon subclass 7), quality 1 at 1. + let mut costs = vec![0u32; 29]; + costs[7] = 1; + c.app.insert_resource(crate::ui_merchant::RepairTables( + benilla_formats::DurabilityTables::from_rows([(20, costs)], [(3, 1.0)]), + )); + let mut sword = crate::items::test_template("Sword"); + (sword.class, sword.subclass, sword.quality, sword.item_level) = (2, 7, 1, 20); + c.app + .world_mut() + .resource_mut::() + .insert_template(SWORD_ENTRY, Some(sword)); + let pack_1 = FIELD_PLAYER_INV_SLOT_HEAD + 2 * 23; + c.frame(vec![ + vec![ + SessionEvent::ItemCreate { + guid: SWORD, + container: false, + fields: benilla_protocol::ObjectFields::from_pairs(&[ + (2, 0x3), + (ENTRY, SWORD_ENTRY), + (STACK, 1), + (DURABILITY, 35), + (MAX_DURABILITY, 75), + ]) + .into_created(benilla_protocol::messages::ObjectType::Item), + }, + values( + ME, + &[ + (pack_1, SWORD as u32), + (pack_1 + 1, (SWORD >> 32) as u32), + (PLAYER_BYTES_3, 8 << 24), + ], + ), + ], + vec![list(vec![row(1, 159)])], + ]); + c.frame(vec![]); + { + let script = c.app.world().non_send_resource::(); + crate::ui_script::test_ui::load_tooltip_template(script); + } + c.eval::<()>( + false, + "Costs = {} \ + CreateFrame('GameTooltip', 'TT', nil, 'TestTooltipTemplate'):SetOwner(CreateFrame('Frame'), 'ANCHOR_NONE') \ + local f = CreateFrame('Frame') f:RegisterEvent('PLAYER_MONEY') \ + f:SetScript('OnEvent', function() local _, cost = TT:SetBagItem(0, 1) table.insert(Costs, cost) end)", + ); + c.frame(vec![ + vec![ + values(VENDOR, &[(FIELD_UNIT_FLAGS, 0x1000)]), + values(ME, &[(FIELD_PLAYER_FIELD_COINAGE, 1)]), + ], + vec![ + values(VENDOR, &[(FIELD_UNIT_FLAGS, 0)]), + values(ME, &[(FIELD_PLAYER_FIELD_COINAGE, 2)]), + ], + ]); + assert_eq!( + c.eval::>(false, "return Costs"), + [i64::from(discounted), i64::from(full)] + ); +} diff --git a/crates/benilla-app/src/lanes/stat_live_tests.rs b/crates/benilla-app/src/lanes/stat_live_tests.rs index 76ff0a081..e95a7c00d 100644 --- a/crates/benilla-app/src/lanes/stat_live_tests.rs +++ b/crates/benilla-app/src/lanes/stat_live_tests.rs @@ -406,6 +406,8 @@ fn client() -> Client { c.frame(vec![]); c.frame(vec![]); c.eval::<()>(false, "Seen = {}"); + // The purse's VM copy, which no feed pushes: `GetMoney` reads the coinage through the host. + crate::script_calls::with_hosted_vm(c.app.world_mut(), |s| s.expect("a VM").set_money(123_456)); c } diff --git a/crates/benilla-app/src/script_calls/live.rs b/crates/benilla-app/src/script_calls/live.rs index 462ec9217..cde60b064 100644 --- a/crates/benilla-app/src/script_calls/live.rs +++ b/crates/benilla-app/src/script_calls/live.rs @@ -102,8 +102,12 @@ live_reads! { /// Another player's visible items, the active player's honor block and the inspect-honor /// slot. inspect: crate::ui_inspect::InspectLive<'static, 'static>, - /// The vendor window's rows, the buy-back rows and the repair total. + /// The vendor window's rows, the buy-back rows, the repair total and each item's cost. merchant: crate::ui_merchant::MerchantLive<'static, 'static>, + /// The bank's purchase row. + bank: crate::ui_bank::BankLive<'static, 'static>, + /// The gossip message's slots and drawn text. + gossip: crate::ui_gossip::GossipLive<'static>, /// The taxi map's slots and fares. taxi: crate::ui_taxi::TaxiLive<'static, 'static>, /// The dialogs' latches, clocks, meeting stone, summon and instance terms. diff --git a/crates/benilla-app/src/target/died.rs b/crates/benilla-app/src/target/died.rs index 21424956a..29576151a 100644 --- a/crates/benilla-app/src/target/died.rs +++ b/crates/benilla-app/src/target/died.rs @@ -816,7 +816,10 @@ mod tests { #[test] fn our_own_death_clears_the_cursor_after_player_dead() { let mut c = dying_client(); - crate::script_calls::with_hosted_vm(c.world(), |s| s.expect("a VM").set_money(500)); + c.values( + ME, + &[(benilla_protocol::field::FIELD_PLAYER_FIELD_COINAGE, 500)], + ); c.lua( r#"SEEN = {} local f = CreateFrame("Frame") f:RegisterEvent("PLAYER_DEAD") f:RegisterEvent("CURSOR_UPDATE") diff --git a/crates/benilla-app/src/ui_bank/lane.rs b/crates/benilla-app/src/ui_bank/lane.rs new file mode 100644 index 000000000..5e0f61c89 --- /dev/null +++ b/crates/benilla-app/src/ui_bank/lane.rs @@ -0,0 +1,132 @@ +//! The bank lane: what the bank's packet handlers signal, each fired at its packet's place among +//! the other lanes' items ([`crate::lanes`]). In the reference each signals inside itself: +//! `SMSG_SHOW_BANK`'s open writes the banker's cell and signals `BANKFRAME_OPENED` (`0x5e3f78` → +//! `0x4f850e`, `0x4f8522`), and `SMSG_BUY_BANK_SLOT_RESULT`'s refusal shows its line through +//! `DisplayError` (`0x5e3f8d`). A bought slot's `PLAYERBANKBAGSLOTS_CHANGED` is the field watch's, +//! on the notice lane ([`crate::field_watch`]). + +use std::collections::VecDeque; + +use bevy::prelude::*; + +use benilla_ui::script::UiScript; + +use crate::lanes::{LaneApp, Stamp, Until}; +use crate::ui_action::MessageSink; + +/// What an item signals. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum BankEvent { + /// `BANKFRAME_OPENED` (`0x4f8522`), behind the open's cell write (`0x4f850e`). + Opened, + /// A refusal's message line, by its `GlobalStrings` key. + Line(&'static str), + /// What a close core run outside a call signalled while this lane held items, parked in the + /// VM of the session under the key ([`crate::script_calls::park_since`]). + Parked { session: u64, key: u64 }, +} + +/// The bank lane's queue. +#[derive(Resource, Default)] +pub(crate) struct BankLane { + events: VecDeque<(Stamp, BankEvent)>, +} + +impl BankLane { + /// Queue `event` stamped now, behind everything stamped before it. + pub(crate) fn push(&mut self, event: BankEvent) { + let stamp = crate::lanes::stamp(); + let at = self.events.partition_point(|(s, _)| *s < stamp); + self.events.insert(at, (stamp, event)); + } + + fn head(&self) -> Option { + self.events.front().map(|(s, _)| *s) + } + + /// The queued items with their stamps, in order. + #[cfg(test)] + pub(crate) fn stamped(&self) -> Vec<(Stamp, BankEvent)> { + self.events.iter().copied().collect() + } +} + +/// The lane, and its drop while no in-game interface is up to hear it: a signal is for the +/// interface that was up. +pub(super) fn add_bank_lane(app: &mut App) { + app.init_resource::() + .add_lane( + "bank", + |world| world.get_resource::()?.head(), + fire_bank, + None, + ) + .add_systems( + Update, + drop_bank_events + .in_set(crate::ui_script::UiFeed) + .run_if(not(crate::ui_script::ingame_ui_up)), + ); +} + +fn drop_bank_events(mut lane: ResMut) { + lane.events.clear(); +} + +/// The lane's firing: each item the drain admits, signalled with the host lent. It stops after an +/// item whose firing stamped anything, so what that pushed fires next. +fn fire_bank( + In(until): In, + mut vm: crate::script_calls::HostedVm, + mut lane: ResMut, + mut sink: MessageSink, +) { + if !vm.run(|script| script.is_some()) { + return; + } + while let Some(at) = lane.head().filter(|&s| until.admits(s)) { + let Some((_, event)) = lane.events.pop_front() else { + return; + }; + let ((), moved) = crate::lanes::firing(at, || { + vm.run(|script| { + if let Some(script) = script { + fire(script, &mut sink, event); + } + }) + }); + if moved { + return; + } + } +} + +/// One item's signal: `BANKFRAME_OPENED` carries no argument (`0x4f8522`), and stock +/// `BankFrame.lua:127` titles with `UnitName("npc")`. +fn fire(script: &mut UiScript, sink: &mut MessageSink, event: BankEvent) { + match event { + BankEvent::Opened => script.fire_event("BANKFRAME_OPENED", vec![]), + // Shown, and spoken, as its message row says: insufficient funds is speech `0x16`. + BankEvent::Line(key) => { + if let Some(line) = crate::ui_action::keyed_line(script, key) { + crate::ui_action::show_now(script, sink, "ui_bank", line); + } + } + BankEvent::Parked { session, key } => { + if script.session() == session { + script.run_parked(key); + } + } + } +} + +/// Run the close `core` behind the lane ([`crate::lanes::behind`]), so a close never fires ahead +/// of the open it closes. +pub(super) fn behind_lane(world: &mut World, core: impl FnOnce(&mut World)) { + let head = world.get_resource::().and_then(BankLane::head); + crate::lanes::behind(world, head, core, |world, session, key| { + world + .resource_mut::() + .push(BankEvent::Parked { session, key }); + }); +} diff --git a/crates/benilla-app/src/ui_bank/live.rs b/crates/benilla-app/src/ui_bank/live.rs new file mode 100644 index 000000000..f36c94f8b --- /dev/null +++ b/crates/benilla-app/src/ui_bank/live.rs @@ -0,0 +1,48 @@ +//! The bank getters' reads at the call, the call host's half of +//! [`benilla_ui::script::BankSlotsQuery`]: the active player's bought bag slots and the price of +//! the next, as `GetNumBankSlots` and `GetBankSlotCost` read them (`0x4f85b0`, `0x4f8630`), the +//! player's store as of the drain's place ([`crate::ui_unit::views`]). + +use bevy::ecs::system::SystemParam; +use bevy::prelude::*; + +use benilla_ui::script::live::LiveAsk; +use benilla_ui::script::{BankSlotsQuery, BankState}; + +use super::BankPrices; +use crate::net::Objects; + +/// What the bank getters read as they run; the price table absent without client data, where +/// every price reads 0. +#[derive(SystemParam)] +pub(crate) struct BankLive<'w, 's> { + objects: Objects<'w, 's>, + stores: crate::ui_unit::views::ViewStores<'w, 's>, + prices: Option>, +} + +impl BankLive<'_, '_> { + /// `PLAYER_BYTES_2` byte 2 of the active player as of the drain's place (`0x4f85dd`) and the + /// price row after it (`0x4f8690`), both 0 with no player (`0x4f85e3`, `0x4f866a`); no banker + /// is tested. + fn slots(&self) -> BankState { + let Some(player) = self.objects.player().and_then(|e| self.stores.get(e).ok()) else { + return BankState::default(); + }; + let bought = player.0.player_bank_bag_slots_purchased().unwrap_or(0); + BankState { + num_purchased: u32::from(bought), + next_cost: self + .prices + .as_deref() + .and_then(|p| p.0.next_slot_price(bought)) + .unwrap_or(0), + } + } +} + +impl crate::script_calls::live::LiveFamily for BankLive<'_, '_> { + fn answer(&mut self, ask: &mut LiveAsk<'_>) -> bool { + ask.answer(|_: &BankSlotsQuery| Some(self.slots())) + } +} diff --git a/crates/benilla-app/src/ui_bank/mod.rs b/crates/benilla-app/src/ui_bank/mod.rs index da78d227e..e87e95682 100644 --- a/crates/benilla-app/src/ui_bank/mod.rs +++ b/crates/benilla-app/src/ui_bank/mod.rs @@ -1,7 +1,8 @@ -//! The bank window's app side: the open banker, the purchase row, the bank events and the bank -//! verbs' engine half ([`BankSteps`]). The vault itself is descriptor fields that -//! [`crate::ui_items`] feeds as containers `-1` and `5..=10`, and moves items in and out of on a -//! right-click. +//! The bank window's app side: the open banker, whose cell the handlers and the close write into +//! the VM as they write it here, the purchase row the getters read through the host ([`live`]), the +//! bank lane the handlers signal on ([`lane`]) and the bank verbs' engine half ([`BankSteps`]). +//! The vault itself is descriptor fields that [`crate::ui_items`] feeds as containers `-1` and +//! `5..=10`, and moves items in and out of on a right-click. use benilla_protocol::messages::bank_slot_result; use bevy::ecs::system::SystemParam; @@ -10,8 +11,7 @@ use bevy::prelude::*; use benilla_ui::script::{BankState, UiScript}; use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfPlayer}; -use crate::ui_script::UiFeed; -use crate::ui_session::{CoreCloses, LatchApp, LatchedWindow, NpcWindow}; +use crate::ui_session::{LatchApp, LatchedWindow, NpcWindow}; /// `BankBagSlotPrices.dbc`, the client's own slot prices; absent, the purchase row shows 0 and /// the server still charges its own price. @@ -60,50 +60,54 @@ impl LatchedWindow for BankOpen { /// The close core, `0x4f84d0` with a zero guid, as `0x493310` enters it: `BANKFRAME_CLOSED` first /// and with no test of the cell (`0x4f84df`); then the latch release on the cell as it stands -/// after the fire (`0x4f84eb`), and the cell cleared (`0x4f84f2`). The fire comes before the -/// clear, so `OnHide`'s `CloseBankFrame()` re-enters with the cell set. +/// after the fire (`0x4f84eb`), and the cell cleared (`0x4f84f2`), in the VM too. The fire comes +/// before the clear, so `OnHide`'s `CloseBankFrame()` re-enters with the cell set. It runs behind +/// the bank lane, so it never fires ahead of an open still queued there. pub(crate) fn close_core(world: &mut World) { - crate::ui_session::fire(world, |script| { - script.fire_event("BANKFRAME_CLOSED", vec![]); + lane::behind_lane(world, |world| { + crate::ui_session::fire(world, |script| { + script.fire_event("BANKFRAME_CLOSED", vec![]); + }); + if let Some(banker) = world.resource::().banker { + crate::ui_session::release(world, banker); + } + if world.resource::().is_open() { + world.resource_mut::().clear(); + crate::ui_session::note_closed(world, NpcWindow::Bank); + crate::ui_session::with_script(world, |script| script.set_bank(None)); + } }); - if let Some(banker) = world.resource::().banker { - crate::ui_session::release(world, banker); - } - if world.resource::().is_open() { - world.resource_mut::().clear(); - crate::ui_session::note_closed(world, NpcWindow::Bank); - crate::ui_session::with_script(world, |script| script.set_bank(None)); - } } /// `SMSG_SHOW_BANK` (`0x5e3f78` → `0x4f84d0(banker)`): the latch first, with no test of the cell -/// (`0x4f8507`), then the cell (`0x4f850e`); the feed fires `BANKFRAME_OPENED` (`0x4f8522`). +/// (`0x4f8507`), then the cell (`0x4f850e`), in the VM too, then `BANKFRAME_OPENED` (`0x4f8522`) +/// on the bank lane at the packet's place. pub(crate) fn open_bank(world: &mut World, banker: u64) { crate::ui_session::register(world, banker); world.resource_mut::().open(banker); + crate::ui_session::with_script(world, |script| { + script.set_bank(Some(BankState::default())); + }); + if let Some(mut lane) = world.get_resource_mut::() { + lane.push(BankEvent::Opened); + } } -/// `SMSG_BUY_BANK_SLOT_RESULT` codes waiting for the error line; vmangos sends one only on failure. -#[derive(Resource, Default)] -pub(crate) struct BankErrors(pub Vec); - +pub(crate) mod lane; +pub(crate) mod live; mod net; +pub(crate) use lane::{BankEvent, BankLane}; +pub(crate) use live::BankLive; pub(crate) struct UiBankPlugin; impl Plugin for UiBankPlugin { fn build(&self, app: &mut App) { net::register(app); + lane::add_bank_lane(app); app.init_resource::() - .init_resource::() // No leg of the sweep closes the bank: its interaction close (`0x490b5f`) does. - .add_latched_window::() - .add_systems( - Update, - (feed_bank - .in_set(UiFeed) - .in_set(crate::ui_session::NpcWindowFeed),), - ); + .add_latched_window::(); } } @@ -119,60 +123,6 @@ fn bank_slot_error_key(result: u32) -> Option<&'static str> { }) } -/// Push the purchase row and fire the bank events but the close, the core's ([`close_core`]). -/// `BANKFRAME_OPENED` has no arguments: the reference fires it bare (`0x4f8522`), and stock -/// `BankFrame.lua:127` titles with `UnitName("npc")`. -fn feed_bank( - mut vm: crate::script_calls::VmWith>, - self_q: Query<&ObjectStore, With>, - prices: Option>, - mut errors: ResMut, - mut last: crate::ui_script::WindowMemo>, - mut closes: CoreCloses, - mut sink: crate::ui_action::MessageSink, -) { - let banker = vm.own().banker; - vm.run(|script| { - let Some(script) = script else { - return; - }; - let last = last.get(script); - if closes.since_last(script, NpcWindow::Bank) { - *last = None; - } - // Refusals show, and speak, as their message rows say: insufficient funds is speech `0x16`. - let lines: Vec<_> = errors - .0 - .drain(..) - .filter_map(|result| crate::ui_action::keyed_line(script, bank_slot_error_key(result)?)) - .collect(); - crate::ui_action::show_messages(script, &mut sink, "ui_bank", lines); - let store = self_q.iter().next(); - let purchased = store - .and_then(|s| s.0.player_bank_bag_slots_purchased()) - .unwrap_or(0); - let fresh = banker.map(|_| BankState { - num_purchased: u32::from(purchased), - next_cost: prices - .as_ref() - .and_then(|p| p.0.next_slot_price(purchased)) - .unwrap_or(0), - }); - if fresh == *last { - return; - } - script.set_bank(fresh.clone()); - match (&*last, &fresh) { - (None, Some(_)) => script.fire_event("BANKFRAME_OPENED", vec![]), - // The bought count moved: a bought slot has no reply packet, so this confirms it. - (Some(_), Some(_)) => script.fire_event("PLAYERBANKBAGSLOTS_CHANGED", vec![]), - // The core told the VM; a disconnect's clear tells it nothing. - (Some(_), None) | (None, None) => {} - } - *last = fresh; - }) -} - /// The bank verbs' engine half, which the call host applies inside each call /// ([`crate::script_calls::ScriptCallHost`]): the banker's cell, and what `PurchaseSlot` reads and /// sends through. diff --git a/crates/benilla-app/src/ui_bank/net.rs b/crates/benilla-app/src/ui_bank/net.rs index e8376f654..2b211131f 100644 --- a/crates/benilla-app/src/ui_bank/net.rs +++ b/crates/benilla-app/src/ui_bank/net.rs @@ -1,9 +1,10 @@ -//! The bank's packet handlers: they fill [`BankOpen`] and [`BankErrors`] for the bank feed. +//! The bank's packet handlers: the open writes [`BankOpen`], and each signals on the bank lane +//! at its packet ([`super::lane`]). use benilla_protocol::{SessionEvent, SessionEventKind}; use bevy::prelude::*; -use super::{BankErrors, BankOpen}; +use super::{BankEvent, BankLane, BankOpen}; use crate::net::NetHandlerApp; /// Register the bank handlers and the session-end listener. @@ -23,9 +24,14 @@ fn on_show_bank(In(ev): In, mut commands: Commands) { } } -fn on_buy_slot_result(In(ev): In, mut errors: ResMut) { +/// `SMSG_BUY_BANK_SLOT_RESULT` (`0x5e3f8d`), a refusal: vmangos sends it only on failure. Its +/// line shows inside the handler, on the bank lane. +fn on_buy_slot_result(In(ev): In, mut lane: ResMut) { if let SessionEvent::BuyBankSlotResult { result } = ev { - bank_buy_slot_result(result, &mut errors); + debug!("net: bank slot purchase refused (code {result})"); + if let Some(key) = super::bank_slot_error_key(result) { + lane.push(BankEvent::Line(key)); + } } } @@ -33,9 +39,3 @@ fn on_buy_slot_result(In(ev): In, mut errors: ResMut) fn on_session_end(In(_): In, mut bank: ResMut) { bank.clear_session(); } - -/// `SMSG_BUY_BANK_SLOT_RESULT`, a refusal: vmangos sends it only on failure. -fn bank_buy_slot_result(result: u32, errors: &mut BankErrors) { - debug!("net: bank slot purchase refused (code {result})"); - errors.0.push(result); -} diff --git a/crates/benilla-app/src/ui_gossip/lane.rs b/crates/benilla-app/src/ui_gossip/lane.rs new file mode 100644 index 000000000..cdd6c2624 --- /dev/null +++ b/crates/benilla-app/src/ui_gossip/lane.rs @@ -0,0 +1,121 @@ +//! The gossip lane: the greeting's `GOSSIP_SHOW`, fired at its packet's place among the other +//! lanes' items ([`crate::lanes`]). In the reference it signals inside `0x4e2010` (`0x4e22b5`), +//! right after the text's write (`0x4e229a`), which a message runs (`0x4e27df`) and so does its +//! text's arrival (`0x4e22f2`); the getters its listeners call read the store through the host +//! ([`super::live`]). + +use std::collections::VecDeque; + +use bevy::prelude::*; + +use benilla_ui::script::UiScript; + +use crate::lanes::{LaneApp, Stamp, Until}; + +/// What an item signals. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum GossipEvent { + /// `GOSSIP_SHOW` (`0x4e22b5`), with no argument. + Show, + /// What a close core run outside a call signalled while this lane held items, parked in the + /// VM of the session under the key ([`crate::script_calls::park_since`]). + Parked { session: u64, key: u64 }, +} + +/// The gossip lane's queue. +#[derive(Resource, Default)] +pub(crate) struct GossipLane { + events: VecDeque<(Stamp, GossipEvent)>, +} + +impl GossipLane { + /// Queue `event` stamped now, behind everything stamped before it. + pub(crate) fn push(&mut self, event: GossipEvent) { + let stamp = crate::lanes::stamp(); + let at = self.events.partition_point(|(s, _)| *s < stamp); + self.events.insert(at, (stamp, event)); + } + + fn head(&self) -> Option { + self.events.front().map(|(s, _)| *s) + } + + /// The queued items with their stamps, in order. + #[cfg(test)] + pub(crate) fn stamped(&self) -> Vec<(Stamp, GossipEvent)> { + self.events.iter().copied().collect() + } +} + +/// The lane, and its drop while no in-game interface is up to hear it: a signal is for the +/// interface that was up. +pub(super) fn add_gossip_lane(app: &mut App) { + app.init_resource::() + .add_lane( + "gossip", + |world| world.get_resource::()?.head(), + fire_gossip, + None, + ) + .add_systems( + Update, + drop_gossip_events + .in_set(crate::ui_script::UiFeed) + .run_if(not(crate::ui_script::ingame_ui_up)), + ); +} + +fn drop_gossip_events(mut lane: ResMut) { + lane.events.clear(); +} + +/// The lane's firing: each item the drain admits, signalled with the host lent. It stops after an +/// item whose firing stamped anything, so what that pushed fires next. +fn fire_gossip( + In(until): In, + mut vm: crate::script_calls::HostedVm, + mut lane: ResMut, +) { + if !vm.run(|script| script.is_some()) { + return; + } + while let Some(at) = lane.head().filter(|&s| until.admits(s)) { + let Some((_, event)) = lane.events.pop_front() else { + return; + }; + let ((), moved) = crate::lanes::firing(at, || { + vm.run(|script| { + if let Some(script) = script { + fire(script, event); + } + }) + }); + if moved { + return; + } + } +} + +fn fire(script: &mut UiScript, event: GossipEvent) { + match event { + GossipEvent::Show => script.fire_event("GOSSIP_SHOW", vec![]), + GossipEvent::Parked { session, key } => { + if script.session() == session { + script.run_parked(key); + } + } + } +} + +/// Run the close `core` behind the lane ([`crate::lanes::behind`]), so a close never fires ahead +/// of the show it closes. +pub(super) fn behind_lane(world: &mut World, core: impl FnOnce(&mut World)) { + let head = world + .get_resource::() + .and_then(GossipLane::head); + crate::lanes::behind(world, head, core, |world, session, key| { + world + .resource_mut::() + .push(GossipEvent::Parked { session, key }); + }); +} diff --git a/crates/benilla-app/src/ui_gossip/live.rs b/crates/benilla-app/src/ui_gossip/live.rs new file mode 100644 index 000000000..61969e8c6 --- /dev/null +++ b/crates/benilla-app/src/ui_gossip/live.rs @@ -0,0 +1,25 @@ +//! The gossip getters' reads at the call, the call host's half of +//! [`benilla_ui::script::GossipQuery`]: the message's slots and the drawn text as the handlers and +//! the selects left them (`0xbbbe90`, `0xbb74c0`, `0xbbb678`), which a close leaves for the getters +//! (`0x4e1fa0`). + +use bevy::ecs::system::SystemParam; +use bevy::prelude::*; + +use benilla_ui::script::live::LiveAsk; +use benilla_ui::script::GossipQuery; + +use super::GossipState; + +/// What the gossip getters read as they run; the store absent in a harness without the gossip +/// window, where the getters read the VM's copy. +#[derive(SystemParam)] +pub(crate) struct GossipLive<'w> { + state: Option>, +} + +impl crate::script_calls::live::LiveFamily for GossipLive<'_> { + fn answer(&mut self, ask: &mut LiveAsk<'_>) -> bool { + ask.answer(|_: &GossipQuery| self.state.as_deref().map(super::menu)) + } +} diff --git a/crates/benilla-app/src/ui_gossip/mod.rs b/crates/benilla-app/src/ui_gossip/mod.rs index 7f4cf41ca..072238d27 100644 --- a/crates/benilla-app/src/ui_gossip/mod.rs +++ b/crates/benilla-app/src/ui_gossip/mod.rs @@ -1,18 +1,17 @@ -//! The gossip window's app side: [`GossipState`] holds the menu and the NPC-text records, -//! [`feed_gossip`] pushes the menu and fires its events, the selects apply in their calls over its -//! slots ([`GossipVerbs`]), and so does `CloseGossip`'s close core. -//! As in the reference's `0x4e2010`, a menu opens only once its greeting is drawn. +//! The gossip window's app side: [`GossipState`] holds the menu and the NPC-text records, which +//! the getters read through the host at the call ([`live`]); the greeting's draw signals on the +//! gossip lane at its packet ([`lane`]), the selects apply in their calls over its slots +//! ([`GossipVerbs`]), and so does `CloseGossip`'s close core. +//! As in the reference's `0x4e2010`, a menu shows only once its greeting is drawn. use benilla_protocol::messages::{select_greeting, GossipOption, NpcTextBlock}; use bevy::prelude::*; use benilla_ui::script::{GossipMenu, GossipOptionView, GossipQuestRow}; -use crate::names::NameCache; -use crate::net::{Guid, NetCommands, ObjectStore, SelfPlayer}; -use crate::query_cache::{Ask, QueryCache}; +use crate::query_cache::QueryCache; use crate::ui_quest::row_is_active; -use crate::ui_script::{SweepApp, SweepLeg, UiFeed}; +use crate::ui_script::{SweepApp, SweepLeg}; use crate::ui_session::{LatchApp, LatchedWindow, NpcWindow}; /// The open gossip menu, plus the NPC-text records by id, which outlive it. The cache holds whole @@ -21,8 +20,11 @@ use crate::ui_session::{LatchApp, LatchedWindow, NpcWindow}; pub(crate) struct GossipState { pub(crate) npc: Option, pub(crate) text_id: u32, - /// This open's drawn greeting; `None` while its text query is out. + /// This open's drawn greeting, unexpanded; `None` while its text query is out. pub(crate) greeting: Option, + /// The text buffer `0xbbb678`: the last drawn greeting, expanded at its draw (`0x4e2274`, + /// `0x4e229a`), which `GetGossipText` returns (`0x4e28c0`). No close or message clears it. + pub(crate) text: String, pub(crate) options: Vec, /// The packet's quest rows, `(quest_id, dialog-status icon, level, title)`. The level is kept /// because `GetGossipAvailableQuests` and `GetGossipActiveQuests` return `(title, level)` @@ -35,8 +37,8 @@ pub(crate) struct GossipState { /// The reference's text-pending latch `[0xbbb670]`: set on the text's cache miss /// (`0x4e2050`), cleared once the record is found (`0x4e206e`); no close touches it. pending: bool, - /// The messages latched so far, which the feed shows each of once. - generation: u64, + /// A greeting was drawn: the caller owes its expansion into [`Self::text`] and `GOSSIP_SHOW`. + drawn: bool, } impl GossipState { @@ -84,7 +86,6 @@ impl GossipState { self.options = options; self.quests = quests; self.greeting = None; - self.generation += 1; // A miss, id 0 included (`0x55d530`), holds the latch until the record lands. self.pending = true; if text_id == 0 { @@ -115,7 +116,10 @@ impl GossipState { fn resolve_greeting(&mut self, npc_gender: u8) { self.pending = false; match self.draw_greeting(self.text_id, npc_gender) { - Some(line) => self.greeting = Some(line), + Some(line) => { + self.greeting = Some(line); + self.drawn = true; + } None => { debug!( "ui_gossip: missing gossip text (id {}) — ending the interaction", @@ -130,6 +134,14 @@ impl GossipState { pub(crate) fn take_missing_text(&mut self) -> bool { std::mem::take(&mut self.missing_text) } + + /// The greeting drawn since the last take, unexpanded: its expansion and `GOSSIP_SHOW` are + /// owed to the caller, which holds the world the expansion reads. + pub(crate) fn take_drawn(&mut self) -> Option { + std::mem::take(&mut self.drawn) + .then(|| self.greeting.clone()) + .flatten() + } } /// The greeting draw's roll in `[1.0, 2.0)`, built as the reference builds it: random bits in the @@ -148,6 +160,8 @@ impl GossipState { self.quests.clear(); self.missing_text = false; self.pending = false; + self.drawn = false; + self.text.clear(); } /// The disconnect clear; NPC text is static per id, so the records stay. @@ -156,8 +170,12 @@ impl GossipState { } } -mod net; +pub(crate) mod lane; +pub(crate) mod live; +pub(crate) mod net; mod verbs; +pub(crate) use lane::{GossipEvent, GossipLane}; +pub(crate) use live::GossipLive; pub(crate) use verbs::GossipVerbs; #[cfg(test)] mod verb_tests; @@ -165,22 +183,17 @@ mod verb_tests; #[cfg(test)] pub(crate) use net::gossip_menu as open_menu; -/// The gossip window's handlers and feed. +/// The gossip window's handlers and lane. pub(crate) struct UiGossipPlugin; impl Plugin for UiGossipPlugin { fn build(&self, app: &mut App) { net::register(app); + lane::add_gossip_lane(app); app.init_resource::() .add_latched_window::() // `0x490ac1` → `0x4e1f60` → `0x4e1fa0(0)`, the core. - .add_sweep_leg(SweepLeg::Gossip, close_core) - .add_systems( - Update, - (feed_gossip - .in_set(UiFeed) - .in_set(crate::ui_session::NpcWindowFeed),), - ); + .add_sweep_leg(SweepLeg::Gossip, close_core); } } @@ -204,15 +217,18 @@ impl LatchedWindow for GossipState { /// them still. A menu still waiting on its text was latched at its open, so its close fires too. /// `SMSG_GOSSIP_COMPLETE` (`0x4e2824`), the missing-text path (`0x4e2197`), `0x493310` and the /// leave-world sweep's leg (`0x490ac1` → `0x4e1f60`) call it as it is. +/// It runs behind the gossip lane, so it never fires ahead of a show still queued there. pub(crate) fn close_core(world: &mut World) { let Some(npc) = world.resource::().npc else { return; }; - crate::ui_session::release(world, npc); - world.resource_mut::().npc = None; - crate::ui_session::note_closed(world, NpcWindow::Gossip); - crate::ui_session::fire(world, |script| { - script.fire_event("GOSSIP_CLOSED", vec![]); + lane::behind_lane(world, |world| { + crate::ui_session::release(world, npc); + world.resource_mut::().npc = None; + crate::ui_session::note_closed(world, NpcWindow::Gossip); + crate::ui_session::fire(world, |script| { + script.fire_event("GOSSIP_CLOSED", vec![]); + }); }); } @@ -247,13 +263,13 @@ const GOSSIP_ICON_TYPES: [&str; 14] = [ "gossip", ]; -/// The Lua-facing menu, the slots and the text as the getters read them, which a close leaves; -/// `None` before any text. [`feed_gossip`] never calls it while the text is pending. -fn snapshot(state: &GossipState) -> Option { - Some(GossipMenu { - greeting: state.greeting.clone()?, - // Active or available by the wire icon: the reference's `{3,4}` test in `0x4e2430` and - // `0x4e2580`, behind `GetGossipAvailableQuests` and `GetGossipActiveQuests`. +/// The store as the getters read it at the call, which a close leaves: the text buffer, the used +/// option slots in slot order, an index with its top bit set skipped as unused (`0x4e28ec`), and +/// the quest rows, active or available by the wire icon (the `{3,4}` test of `0x4e2430` and +/// `0x4e2580`). +pub(crate) fn menu(state: &GossipState) -> GossipMenu { + GossipMenu { + greeting: state.text.clone(), quests: state .quests .iter() @@ -266,170 +282,70 @@ fn snapshot(state: &GossipState) -> Option { options: state .options .iter() + .filter(|o| (o.index as i32) >= 0) .map(|o| GossipOptionView { label: o.message.clone(), icon_type: gossip_icon_type(o.icon).into(), }) .collect(), - }) -} - -/// Push the menu into the VM and, with the window open, fire `GOSSIP_SHOW` once for each message -/// as its greeting is drawn; the close is the core's ([`close_core`]). The menu the VM holds is the -/// memory: `SelectGossipOption`'s reset writes both sides, so it reads as no change. The slots wait -/// outside the VM's turn, since the gossip verbs write them in the host. -fn feed_gossip( - mut vm: crate::script_calls::VmWith>, - self_q: Query<(&ObjectStore, &Guid), With>, - names: Res, - commands: Res, - objects: crate::net::Objects, - unit_names: crate::ui_unit::NameTables, - states: Res, - mut shown_message: crate::ui_script::WindowMemo, -) { - vm.turn(|script, state| { - let Some(script) = script else { - return; - }; - let shown_message = shown_message.get(script); - let shown = script.gossip(); - // The stock window titles with `UnitName("npc")` (`GossipFrame.lua:33`), which looks the - // name up with a callback (`0x5171b8`) at the show a gossip packet brings; its landing - // fires nothing. Asked before the hold, so the name query goes out beside the text query. - let ask = Ask::at(shown.is_none() || state.is_changed(), &commands); - if let Some(g) = state.npc { - let _ = names.resolve(&objects, g, ask); - } - // While the text query is out, fire nothing and keep the last snapshot: the reference - // returns at `0x4e2068` with no write and no event, so its frame keeps what it last showed. - // Reading the hold as a close would flash an open window. - if state.text_pending() { - return; - } - let mut fresh = snapshot(&state); - let new_message = std::mem::replace(shown_message, state.generation) != state.generation; - // The greeting's `$N`/`$B`/`$G`/`$w` macros expand once per message, at its draw: the - // reference writes the expanded text to `0xbbb678` there (`0x4e2274`, `0x4e229a`), which - // `GetGossipText` returns (`0x4e28c0`) until the next message draws. - if let Some(greeting) = fresh.as_mut().map(|m| &mut m.greeting) { - match shown.as_ref().filter(|_| !new_message) { - Some(drawn) => greeting.clone_from(&drawn.greeting), - None => { - let player = - crate::npc_text::player_identity(&self_q, &names, unit_names.get()); - *greeting = crate::npc_text::substitute( - greeting, - &crate::npc_text::MacroContext { - subject: player.as_ref(), - states: &states, - }, - ); - } - } - } - if fresh != shown { - script.set_gossip(fresh.clone()); - } - // `GOSSIP_SHOW` has one producer, `0x4e2010` (`0x4e22b5`, no argument), run by a message - // (`0x4e27df`) and by its text's arrival (`0x4e22f2`), which the hold above waits on. A - // close is the core's, and a disconnect's clear tells the VM nothing. - if new_message && fresh.is_some() && state.npc.is_some() { - script.fire_event("GOSSIP_SHOW", vec![]); - } - }) + } } #[cfg(test)] mod tests { use super::*; - use crate::net::ClientCommand; - use benilla_ui::script::UiScript; - use bevy::ecs::system::RunSystemOnce; /// The one type in [`GOSSIP_ICON_TYPES`] with no art in the 1.12 install. const GOSSIP_ICON_TYPE_UNSHIPPED: &str = "auctioneer"; - /// The stock window titles itself with `UnitName("npc")`, which looks the name up with a - /// callback (`0x5171b8`), at the `GOSSIP_SHOW` a menu brings; the frames between read. A - /// refused name is asked by no frame, and again by the next menu. - #[test] - fn a_refused_npc_name_is_asked_at_the_next_menu_never_by_a_frame() { - let (tx, rx) = crossbeam_channel::unbounded(); - let mut app = App::new(); - app.insert_resource(NetCommands(tx)) - .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() - .add_systems(Update, feed_gossip); - app.insert_non_send_resource(UiScript::new().unwrap()); - // A creature not streamed: its name is asked by the guid's entry bits. - const NPC: u64 = 0xF130_0000_4500_0001; - let menu = |app: &mut App| { - let mut state = app.world_mut().resource_mut::(); - state.remember_record( - 7, - vec![NpcTextBlock { - probability: 1.0, - male: "Hello".into(), - female: "Hello".into(), - }], - ); - state.open_menu(NPC, 7, Vec::new(), Vec::new(), 0); - }; - menu(&mut app); - app.update(); - assert_eq!(crate::query_cache::queries(&rx).len(), 1); - app.world_mut() - .resource_mut::() - .insert_creature(0x45, None); - for _ in 0..600 { - app.update(); + fn option(index: u32, icon: u8, label: &str) -> GossipOption { + GossipOption { + index, + icon, + coded: false, + message: label.into(), } - assert!(crate::query_cache::queries(&rx).is_empty()); - menu(&mut app); - app.update(); - assert_eq!(crate::query_cache::queries(&rx).len(), 1); } + /// The getters' store: the text buffer, empty before any draw, and the used slots in slot + /// order, an index with its top bit set skipped (`0x4e28ec`). #[test] - fn snapshot_is_none_until_a_menu_opens() { + fn the_menu_reads_the_text_buffer_and_the_used_slots() { let mut state = GossipState::default(); - assert!(snapshot(&state).is_none()); - state.npc = Some(0x42); - state.greeting = Some("Hello".into()); - state.options = vec![GossipOption { - index: 3, - icon: 1, - coded: false, - message: "Browse".into(), - }]; - let menu = snapshot(&state).expect("open"); - assert_eq!(menu.greeting, "Hello"); - assert_eq!(menu.options.len(), 1); - assert_eq!(menu.options[0].icon_type, "vendor"); + assert_eq!(menu(&state), GossipMenu::default()); + state.text = "Hello".into(); + state.options = vec![ + option(3, 1, "Browse"), + option(0x8000_0000, 0, "Hidden"), + option(4, 2, "Fly"), + ]; + let read = menu(&state); + assert_eq!(read.greeting, "Hello"); + let rows: Vec<_> = read + .options + .iter() + .map(|o| (o.label.as_str(), o.icon_type.as_str())) + .collect(); + assert_eq!(rows, [("Browse", "vendor"), ("Fly", "taxi")]); } - /// A first visit keeps the menu closed until its text answers, then opens it complete: the - /// reference's greeting write and `GOSSIP_SHOW` sit together on its one success path - /// (`0x4e229a`, `0x4e22b0`), and its cache-miss exit fires nothing. + /// A first visit draws nothing until its text answers, then draws once: the reference's + /// greeting write and `GOSSIP_SHOW` sit together on its one success path (`0x4e229a`, + /// `0x4e22b0`), and its cache-miss exit draws nothing. #[test] - fn first_visit_holds_the_menu_until_the_text_answers() { + fn first_visit_draws_once_its_text_answers() { let mut state = GossipState::default(); - let options = vec![GossipOption { - index: 0, - icon: 9, - coded: false, - message: "I wish to join the battle!".into(), - }]; - let wants_query = state.open_menu(0x42, 50, options, Vec::new(), 0); - assert!(wants_query, "first visit asks for the text"); + let options = vec![option(0, 9, "I wish to join the battle!")]; assert!( - snapshot(&state).is_none(), - "menu held closed while the query is in flight" + state.open_menu(0x42, 50, options, Vec::new(), 0), + "first visit asks for the text" ); - + assert_eq!( + state.take_drawn(), + None, + "nothing drawn while the query is out" + ); + assert_eq!(menu(&state).options.len(), 1, "the slots are the message's"); state.text_arrived( 50, vec![NpcTextBlock { @@ -439,325 +355,11 @@ mod tests { }], 0, ); - let menu = snapshot(&state).expect("text landed — the menu opens now, complete"); - assert_eq!(menu.greeting, "The Alliance needs you!"); - assert_eq!(menu.options.len(), 1, "options open WITH the greeting"); - } - - // ── The hold, through the real feed ───────────────────────────────────────── - - /// Records the two gossip events the VM sees, in order. - const RECORDER: &str = r#" - SEEN = {} - local f = CreateFrame("Frame") - f:RegisterEvent("GOSSIP_SHOW") - f:RegisterEvent("GOSSIP_CLOSED") - f:SetScript("OnEvent", function() table.insert(SEEN, event) end) - "#; - - /// The real [`feed_gossip`] over a VM; `prepare` seeds the VM, and the receiver gets what - /// went out. - fn feed_app( - prepare: impl FnOnce(&mut UiScript), - ) -> (App, crossbeam_channel::Receiver) { - let (tx, rx) = crossbeam_channel::unbounded(); - let mut app = App::new(); - app.insert_resource(NetCommands(tx)) - .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() - .add_systems(Update, feed_gossip); - let mut script = UiScript::new().unwrap(); - prepare(&mut script); - app.insert_non_send_resource(script); - (app, rx) - } - - fn state(app: &mut App) -> Mut<'_, GossipState> { - app.world_mut().resource_mut::() - } - - /// One frame, then the events the VM saw and `GetGossipText()` (`""` for nil). - fn tick(app: &mut App) -> (Vec, String) { - app.update(); - let s = app.world_mut().non_send_resource_mut::(); - assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); - let seen = s.eval::>("return SEEN").unwrap(); - s.run("SEEN = {}").unwrap(); - let text = s.eval::("return GetGossipText() or ''").unwrap(); - (seen, text) - } - - fn record(line: &str) -> Vec { - vec![NpcTextBlock { - probability: 0.0, - male: line.into(), - female: String::new(), - }] - } - - fn option(label: &str) -> Vec { - vec![GossipOption { - index: 0, - icon: 0, - coded: false, - message: label.into(), - }] - } - - /// A host that applies the gossip calls alone, as the whole client's applies them. - struct GossipHost<'a, 'w, 's>(&'a mut super::verbs::GossipVerbs<'w, 's>); - - impl benilla_ui::script::CallHost for GossipHost<'_, '_, '_> { - fn apply( - &mut self, - script: &mut UiScript, - call: benilla_ui::script::ScriptCall, - ) -> Option { - if let benilla_ui::script::ScriptCall::Gossip(call) = call { - self.0.apply(script, call); - } - None - } - } - - /// Run `lua` with the gossip host lent. - fn select(app: &mut App, lua: &'static str) { - app.world_mut() - .run_system_once( - move |mut script: NonSendMut, mut verbs: super::verbs::GossipVerbs| { - script - .hosted(&mut GossipHost(&mut verbs), |s| s.run(lua)) - .expect("the chunk runs"); - }, - ) - .expect("the hosted chunk runs as a one-shot system"); - } - - /// The selects sent; the channel also carries the feed's name query. - fn selects_sent(rx: &crossbeam_channel::Receiver) -> usize { - rx.try_iter() - .filter(|c| matches!(c, ClientCommand::GossipSelectOption { .. })) - .count() - } - - const GUARD: u64 = 0xF130_0000_0000_0007; // HIGHGUID_UNIT - - /// A guard's sub-menu on a first-visit text: nothing fires while the query is out, the old - /// menu stays with its selects refused, and the text repaints in place, as the reference's - /// exit at `0x4e2068` leaves it. A close fired at the pending step would flash the window. - #[test] - fn the_hold_fires_nothing_a_pending_submenu_keeps_the_frame_painted() { - let (mut app, rx) = feed_app(|s| s.run(RECORDER).unwrap()); - - // The greeting menu, its text already cached: opens at once. - { - let mut st = state(&mut app); - st.remember_record(50, record("What are you looking for?")); - assert!(!st.open_menu(GUARD, 50, option("The bank"), Vec::new(), 0)); - } assert_eq!( - tick(&mut app), - ( - vec!["GOSSIP_SHOW".to_string()], - "What are you looking for?".to_string() - ) + state.take_drawn().as_deref(), + Some("The Alliance needs you!") ); - - // "The bank" is clicked: a new menu on the same NPC with an unseen text id. Nothing fires - // for the round trip, and a click on the old menu's row is refused. - assert!( - state(&mut app).open_menu(GUARD, 51, option("Goodbye"), Vec::new(), 0), - "first visit to the sub-menu's text: the query is owed" - ); - for _ in 0..3 { - assert_eq!( - tick(&mut app), - (Vec::new(), "What are you looking for?".to_string()), - "the hold fires nothing — the previous menu stays painted" - ); - } - select(&mut app, "SelectGossipOption(1)"); - let _ = tick(&mut app); - assert_eq!( - selects_sent(&rx), - 0, - "a select during the hold is refused (the latch)" - ); - - // The text lands: one in-place repaint, and selects work again. - state(&mut app).text_arrived(51, record("The bank is north of here."), 0); - assert_eq!( - tick(&mut app), - ( - vec!["GOSSIP_SHOW".to_string()], - "The bank is north of here.".to_string() - ) - ); - select(&mut app, "SelectGossipOption(1)"); - let _ = tick(&mut app); - assert_eq!(selects_sent(&rx), 1); - - // Controls: a real end still closes, and a first visit from a closed frame stays shut - // until its text answers. - // The close leaves the text and the slots, which the getters still read (`0x4e1fa0`). - close_core(app.world_mut()); - assert_eq!( - tick(&mut app), - ( - vec!["GOSSIP_CLOSED".to_string()], - "The bank is north of here.".to_string() - ) - ); - assert!(state(&mut app).open_menu(GUARD, 52, option("Goodbye"), Vec::new(), 0)); - assert_eq!( - tick(&mut app), - (Vec::new(), "The bank is north of here.".to_string()), - "hidden stays hidden through the hold" - ); - state(&mut app).text_arrived(52, record("Yes?"), 0); - assert_eq!( - tick(&mut app), - (vec!["GOSSIP_SHOW".to_string()], "Yes?".to_string()) - ); - } - - /// `GOSSIP_SHOW` fires only in `0x4e2010`, which a message (`0x4e27df`) or its text's arrival - /// (`0x4e22f2`) runs: the NPC's name landing fires nothing, and the next message fires again. - #[test] - fn the_npc_name_landing_fires_no_second_gossip_show() { - const SPEAKER: u64 = 0x10; - let (mut app, _rx) = feed_app(|s| s.run(RECORDER).unwrap()); - let menu = |app: &mut App| { - let mut st = state(app); - st.remember_record(50, record("Well met.")); - st.open_menu(SPEAKER, 50, option("Goodbye"), Vec::new(), 0); - }; - menu(&mut app); - assert_eq!(tick(&mut app).0, ["GOSSIP_SHOW"]); - app.world_mut() - .resource_mut::() - .insert_player(SPEAKER, "Gossiper".into(), None); - assert!(tick(&mut app).0.is_empty(), "a landed name fires nothing"); - menu(&mut app); - assert_eq!(tick(&mut app).0, ["GOSSIP_SHOW"], "the next message does"); - } - - /// The same sequence through stock `GossipFrame.xml`: the window stays up, silent, on the - /// first menu, and the repaint plays no open kit, as `ShowUIPanel` returns early on a visible - /// frame. - #[test] - fn stock_gossip_frame_stays_visible_through_a_first_visit_submenu() { - use benilla_ui::script::SoundRequest; - let _data = benilla_formats::wow_data_or_skip!(); - let (mut app, _rx) = feed_app(|s| { - s.set_screen_size(1024.0, 768.0); - s.set_text_measurer(Box::new(crate::ui_script::FixedWidthFont(6.0))); - for f in crate::ui_script::test_ui::production_order(&[ - crate::ui_script::test_ui::GOSSIP_UI, - &[r"Interface\FrameXML\GossipFrame.xml"], - ]) { - crate::ui_script::test_ui::load_ui(s, f); - } - s.run(RECORDER).unwrap(); - }); - // What the window shows: visible?, the greeting, the first row's label, the kits played. - let window = |app: &mut App| -> (bool, String, String, Vec) { - let mut s = app.world_mut().non_send_resource_mut::(); - ( - s.eval::("return GossipFrame:IsVisible()").unwrap(), - s.eval::("return GossipGreetingText:GetText() or ''") - .unwrap(), - s.eval::("return GossipTitleButton1:GetText() or ''") - .unwrap(), - s.take_sounds(), - ) - }; - - { - let mut st = state(&mut app); - st.remember_record(50, record("What are you looking for?")); - st.open_menu(GUARD, 50, option("The bank"), Vec::new(), 0); - } - let _ = tick(&mut app); - assert_eq!( - window(&mut app), - ( - true, - "What are you looking for?".to_string(), - "The bank".to_string(), - vec![SoundRequest::KitName("igQuestListOpen".into())] - ), - "the greeting menu opens with its kit" - ); - - // The click's answer holds on its text: the window must not blink. - assert!(state(&mut app).open_menu(GUARD, 51, option("Goodbye"), Vec::new(), 0)); - for _ in 0..3 { - let (seen, _) = tick(&mut app); - assert!(seen.is_empty(), "nothing fires during the hold: {seen:?}"); - assert_eq!( - window(&mut app), - ( - true, - "What are you looking for?".to_string(), - "The bank".to_string(), - Vec::new() - ), - "the window stays up, unchanged and silent, for the round trip — a hide here IS the flash" - ); - } - - // The text lands: repainted in place, with no kit. - state(&mut app).text_arrived(51, record("The bank is north of here."), 0); - let (seen, _) = tick(&mut app); - assert_eq!(seen, vec!["GOSSIP_SHOW".to_string()]); - assert_eq!( - window(&mut app), - ( - true, - "The bank is north of here.".to_string(), - "Goodbye".to_string(), - Vec::new() - ), - "an in-place repaint: ShowUIPanel on a visible frame is a no-op, no kit" - ); - } - - /// The greeting's macros expand once per message, as `0x4e2010` writes the expanded text to - /// `0xbbb678` at its draw (`0x4e2274`, `0x4e229a`) and `GetGossipText` returns that buffer - /// (`0x4e28c0`): a later change to the player's name leaves it until the next message. - #[test] - fn the_greeting_expands_once_per_message() { - const ME: u64 = 0x10; - let (mut app, _rx) = feed_app(|s| s.run(RECORDER).unwrap()); - app.world_mut().spawn(( - SelfPlayer, - Guid(ME), - ObjectStore(benilla_protocol::ObjectFields::default()), - )); - let named = |app: &mut App, name: &str| { - app.world_mut() - .resource_mut::() - .insert_player(ME, name.into(), None); - }; - named(&mut app, "Ann"); - let menu = |app: &mut App| { - let mut st = state(app); - st.remember_record(50, record("Well met, $N.")); - st.open_menu(GUARD, 50, option("Goodbye"), Vec::new(), 0); - }; - menu(&mut app); - assert_eq!(tick(&mut app).1, "Well met, Ann."); - named(&mut app, "Bea"); - assert_eq!( - tick(&mut app).1, - "Well met, Ann.", - "held until the next message" - ); - menu(&mut app); - assert_eq!(tick(&mut app).1, "Well met, Bea."); + assert_eq!(state.take_drawn(), None, "drawn once"); } #[test] @@ -774,8 +376,9 @@ mod tests { let wants_query = state.open_menu(0x42, 50, Vec::new(), Vec::new(), 0); assert!(!wants_query, "cached — no re-query"); assert_eq!( - snapshot(&state).expect("open at once").greeting, - "Back again?" + state.take_drawn().as_deref(), + Some("Back again?"), + "drawn at once" ); } @@ -799,12 +402,12 @@ mod tests { state.take_missing_text(), "missing gossip text — the close core is owed" ); - assert!(snapshot(&state).is_none()); + assert_eq!(state.take_drawn(), None); - // Id 0: no query wanted, and the menu never opens. + // Id 0: no query wanted, and nothing is ever drawn. let mut state = GossipState::default(); assert!(!state.open_menu(0x42, 0, Vec::new(), Vec::new(), 0)); - assert!(snapshot(&state).is_none()); + assert_eq!(state.take_drawn(), None); } #[test] @@ -820,7 +423,7 @@ mod tests { let mut state = GossipState::default(); assert!(state.open_menu(0x42, 70, Vec::new(), Vec::new(), 0)); state.text_arrived(50, blocks(), 0); - assert!(snapshot(&state).is_none(), "still waiting on id 70"); + assert_eq!(state.take_drawn(), None, "still waiting on id 70"); assert!( state.cached_record(50).is_some(), "the answer seeded the cache" @@ -828,9 +431,10 @@ mod tests { // Already open: a duplicate answer leaves the drawn line alone. state.text_arrived(70, blocks(), 0); - assert_eq!(snapshot(&state).expect("open").greeting, "Hail."); + assert_eq!(state.take_drawn().as_deref(), Some("Hail.")); state.greeting = Some("The drawn line".into()); state.text_arrived(70, blocks(), 0); + assert_eq!(state.take_drawn(), None, "no second draw"); assert_eq!( state.greeting.as_deref(), Some("The drawn line"), diff --git a/crates/benilla-app/src/ui_gossip/net.rs b/crates/benilla-app/src/ui_gossip/net.rs index 175c699ae..ebfc0c2a8 100644 --- a/crates/benilla-app/src/ui_gossip/net.rs +++ b/crates/benilla-app/src/ui_gossip/net.rs @@ -1,12 +1,15 @@ -//! The gossip window's packet handlers: they fill [`GossipState`], on the latch, and touch the VM -//! only through the close core. +//! The gossip window's packet handlers: they fill [`GossipState`], on the latch, signal the drawn +//! greeting on the gossip lane ([`super::lane`]), and touch the VM only through the close core. use benilla_protocol::messages::{GossipOption, NpcTextBlock}; use benilla_protocol::{SessionEvent, SessionEventKind}; use bevy::prelude::*; -use super::GossipState; -use crate::net::{ClientCommand, GuidIndex, NetCommands, NetHandlerApp, ObjectStore}; +use super::{GossipEvent, GossipLane, GossipState}; +use crate::names::NameCache; +use crate::net::{ + ClientCommand, Guid, GuidIndex, NetCommands, NetHandlerApp, ObjectStore, SelfPlayer, +}; /// Register the gossip handlers and the session-end listener. pub(super) fn register(app: &mut App) { @@ -91,6 +94,7 @@ pub(crate) fn gossip_menu( .send(ClientCommand::NpcTextQuery { text_id, guid: npc }); } } + show_drawn(world); close_on_missing_text(world); } @@ -104,9 +108,79 @@ fn npc_greeting(world: &mut World, text_id: u32, blocks: Vec) { world .resource_mut::() .text_arrived(text_id, blocks, gender); + show_drawn(world); close_on_missing_text(world); } +/// The draw's tail in `0x4e2010`, once the record is found: with no active player or no object +/// under the cell `[0xbc3f58]` it bails (`0x4e20b3`, `0x4e20bb` → `0x4e22ba`), so a close's zeroed +/// cell writes no text and signals nothing, nor takes the missing-text close. Else the greeting +/// expanded for the player (`0x4e2274`), written to the text buffer (`0x4e229a`), then +/// `GOSSIP_SHOW` (`0x4e22b5`) on the gossip lane, with the NPC's name asked as its listener's +/// `UnitName("npc")` asks it there (`GossipFrame.lua:33`, `0x5171b8`). +fn show_drawn(world: &mut World) { + let player = world + .query_filtered::<(), (With, With)>() + .iter(world) + .next() + .is_some(); + let npc = world.resource::().npc; + let held = |guid: u64| { + world + .get_resource::() + .and_then(|index| index.0.get(&guid).copied()) + .is_some_and(|e| world.get::(e).is_some()) + }; + let Some(npc) = npc.filter(|&g| player && held(g)) else { + let mut state = world.resource_mut::(); + state.take_drawn(); + state.take_missing_text(); + return; + }; + let Some(raw) = world.resource_mut::().take_drawn() else { + return; + }; + let text = world + .run_system_cached_with(expand_greeting, raw.clone()) + .unwrap_or(raw); + world.resource_mut::().text = text; + if let Some(mut lane) = world.get_resource_mut::() { + lane.push(GossipEvent::Show); + } + let _ = world.run_system_cached_with(ask_npc_name, npc); +} + +/// The NPC's name looked up with a callback, as `UnitName("npc")` does at the show; its landing +/// fires nothing. +fn ask_npc_name( + In(npc): In, + names: Res, + commands: Res, + index: Res, + stores: Query<&ObjectStore>, +) { + let store = index.0.get(&npc).and_then(|&e| stores.get(e).ok()); + let _ = names.resolve_unit(npc, store, crate::query_cache::Ask::Event(&commands)); +} + +/// The greeting's `$N`/`$B`/`$G`/`$w` macros for the active player (`0x506f70`). +fn expand_greeting( + In(raw): In, + self_q: Query<(&ObjectStore, &Guid), With>, + names: Res, + unit_names: crate::ui_unit::NameTables, + states: Res, +) -> String { + let player = crate::npc_text::player_identity(&self_q, &names, unit_names.get()); + crate::npc_text::substitute( + &raw, + &crate::npc_text::MacroContext { + subject: player.as_ref(), + states: &states, + }, + ) +} + /// The missing-text path's close (`0x4e2197` → `0x4e1fa0(0)`). fn close_on_missing_text(world: &mut World) { if world.resource_mut::().take_missing_text() { diff --git a/crates/benilla-app/src/ui_gossip/verb_tests.rs b/crates/benilla-app/src/ui_gossip/verb_tests.rs index ea895cff5..e3245ac72 100644 --- a/crates/benilla-app/src/ui_gossip/verb_tests.rs +++ b/crates/benilla-app/src/ui_gossip/verb_tests.rs @@ -1,11 +1,9 @@ //! The gossip verbs inside their calls, through the whole client's call host: each packet goes //! out before the verb returns, so a chunk's packets leave in call order. -use benilla_protocol::messages::{GossipOption, NpcTextBlock}; -use bevy::prelude::*; - use super::GossipState; use crate::ui_quest::verb_tests::Client; +use benilla_protocol::messages::{GossipOption, NpcTextBlock}; const NPC: u64 = 0xF130_0000_0000_0007; const ROLL: &str = "RandomRoll { min: 1, max: 100 }"; @@ -13,33 +11,25 @@ const ROLL: &str = "RandomRoll { min: 1, max: 100 }"; /// `NPC`'s menu, its greeting drawn: option 1 is wire index 3, then active quest 5 (icon 3) and /// available quest 6 (icon 5). fn menu(c: &mut Client) { - let world = c.app.world_mut(); - { - let mut state = world.resource_mut::(); - state.remember_record( - 7, - vec![NpcTextBlock { - probability: 1.0, - male: "Hello".into(), - female: "Hello".into(), - }], - ); - state.open_menu( - NPC, - 7, - vec![GossipOption { - index: 3, - icon: 1, - coded: false, - message: "Browse".into(), - }], - vec![(5, 3, 1, "Active".into()), (6, 5, 1, "Available".into())], - 0, - ); - } - world - .run_system_cached(super::feed_gossip) - .expect("the feed runs"); + menu_of( + c, + vec![GossipOption { + index: 3, + icon: 1, + coded: false, + message: "Browse".into(), + }], + true, + ); +} + +/// `lua`'s string value, read with the host lent, as the getters read the store at the call. +fn live_eval(c: &mut Client, lua: &str) -> String { + crate::script_calls::with_hosted_vm(c.app.world_mut(), |s| { + s.expect("a VM") + .eval::(lua) + .expect("the chunk runs") + }) } /// `SelectGossipOption` (`0x4e2a30` → `0x4e2320`, the send at `0x4e23ef`) and the two quest-row @@ -74,32 +64,28 @@ const RECORDER: &str = "SEEN = {} local f = CreateFrame('Frame') \ f:RegisterEvent('GOSSIP_SHOW') f:RegisterEvent('GOSSIP_ENTER_CODE') \ f:SetScript('OnEvent', function() table.insert(SEEN, event .. ':' .. tostring(arg1)) end)"; -/// `NPC`'s menu with `options` and the two quest rows, its text cached when `drawn`. +/// `NPC`'s menu with `options` and the two quest rows through the message's handler, its text +/// cached when `drawn`, and the lanes drained, so its `GOSSIP_SHOW` has fired. fn menu_of(c: &mut Client, options: Vec, drawn: bool) { let world = c.app.world_mut(); - { - let mut state = world.resource_mut::(); - if drawn { - state.remember_record( - 7, - vec![NpcTextBlock { - probability: 1.0, - male: "Hello".into(), - female: "Hello".into(), - }], - ); - } - state.open_menu( - NPC, + if drawn { + world.resource_mut::().remember_record( 7, - options, - vec![(5, 3, 1, "Active".into()), (6, 5, 1, "Available".into())], - 0, + vec![NpcTextBlock { + probability: 1.0, + male: "Hello".into(), + female: "Hello".into(), + }], ); } - world - .run_system_cached(super::feed_gossip) - .expect("the feed runs"); + super::net::gossip_menu( + world, + NPC, + 7, + options, + vec![(5, 3, 1, "Active".into()), (6, 5, 1, "Available".into())], + ); + crate::lanes::fire_in_order(world); } fn option(index: u32, coded: bool) -> GossipOption { @@ -129,17 +115,15 @@ fn an_option_select_resets_the_menus_slots() { )] ); assert_eq!( - c.eval( + live_eval( + &mut c, "local function n(...) return arg.n end \ return n(GetGossipOptions()) .. ',' .. n(GetGossipAvailableQuests()) .. ',' .. \ n(GetGossipActiveQuests()) .. ',' .. GetGossipText()" ), "0,0,0,Hello" ); - c.app - .world_mut() - .run_system_cached(super::feed_gossip) - .expect("the feed runs"); + crate::lanes::fire_in_order(c.app.world_mut()); assert_eq!(c.eval("return table.concat(SEEN, ',')"), ""); } @@ -243,7 +227,8 @@ fn a_code_keeps_its_bytes_up_to_its_nul() { /// `CloseGossip` (`0x4e2b20` → `0x4e1fa0`) zeroes the cell inside its call (`0x4e1fcc`) and /// leaves the slots: a select on the chunk's next line sends for guid 0, and so does one after -/// the drain's `GOSSIP_CLOSED`, whose getters still read the slots. +/// the close's `GOSSIP_CLOSED`, whose getters still read the slots. The close's latch release +/// re-enters it with the cell still set (`0x4e1fc5` → `0x493310`), so it signals twice. #[test] fn close_gossip_zeroes_the_cell_and_keeps_the_slots() { let mut c = Client::new(0); @@ -264,13 +249,10 @@ fn close_gossip_zeroes_the_cell_and_keeps_the_slots() { .unwrap(); c.sent(); c.run("CloseGossip()").unwrap(); - c.app - .world_mut() - .run_system_cached(super::feed_gossip) - .expect("the feed runs"); + crate::lanes::fire_in_order(c.app.world_mut()); assert_eq!( - c.eval("local function n(...) return arg.n end return n(GetGossipOptions()) .. ',' .. table.concat(SEEN, ',')"), - "2,GOSSIP_CLOSED" + live_eval(&mut c, "local function n(...) return arg.n end return n(GetGossipOptions()) .. ',' .. table.concat(SEEN, ',')"), + "2,GOSSIP_CLOSED,GOSSIP_CLOSED" ); c.run("SelectGossipOption(1)").unwrap(); assert_eq!( diff --git a/crates/benilla-app/src/ui_items/mod.rs b/crates/benilla-app/src/ui_items/mod.rs index 1c9542dcf..4ef8c4855 100644 --- a/crates/benilla-app/src/ui_items/mod.rs +++ b/crates/benilla-app/src/ui_items/mod.rs @@ -120,7 +120,7 @@ pub(crate) fn slot_guid( store: &ObjectFields, bag: i64, slot0: u8, - objects: &Objects, + objects: &(impl ItemObjects + ?Sized), ) -> Option { match bag { 0 => store.player_pack_slot(slot0).filter(|g| *g != 0), diff --git a/crates/benilla-app/src/ui_merchant/call_tests.rs b/crates/benilla-app/src/ui_merchant/call_tests.rs index e986c8124..4625d4aea 100644 --- a/crates/benilla-app/src/ui_merchant/call_tests.rs +++ b/crates/benilla-app/src/ui_merchant/call_tests.rs @@ -9,7 +9,7 @@ use benilla_ui::script::{ContainerSlot, ContainerState, MerchantState}; use crossbeam_channel::Receiver; use super::*; -use crate::net::{ClientCommand, Guid, NetCommands}; +use crate::net::{ClientCommand, Guid, NetCommands, SelfPlayer}; use crate::script_calls::with_hosted_vm; const ME: u64 = 0x10; diff --git a/crates/benilla-app/src/ui_merchant/live.rs b/crates/benilla-app/src/ui_merchant/live.rs index 78bb80e64..56e7ab38e 100644 --- a/crates/benilla-app/src/ui_merchant/live.rs +++ b/crates/benilla-app/src/ui_merchant/live.rs @@ -2,20 +2,21 @@ //! [`benilla_ui::script::MerchantHeadQuery`] and its kin: the window's cell and rows //! (`0xbddfa0`, `0xbdd118`), the player's buy-back fields, items and the open vendor's unit as of //! the drain's place, each row resolved off the item cache as the reference's getter reads it at -//! the call (`0x4fb150`, `0x4fb310`, `0x4fadb0`, `0x4fbd60`). +//! the call (`0x4fb150`, `0x4fb310`, `0x4fadb0`, `0x4fbd60`), and each item's repair cost the +//! tooltips read at theirs (`0x4faf30`, `0x5da330`). use bevy::ecs::system::SystemParam; use bevy::prelude::*; use benilla_ui::script::live::LiveAsk; use benilla_ui::script::{ - BuybackRowQuery, MerchantHead, MerchantHeadQuery, MerchantItem, MerchantRowQuery, - RepairAllCostQuery, + BuybackRowQuery, ItemRepairCostQuery, MerchantHead, MerchantHeadQuery, MerchantItem, + MerchantRowQuery, RepairAllCostQuery, RepairCostQuery, }; use super::{ - buyback_order, open_vendor_discount, repair_all, resolve_buyback, resolve_item, vendor_repairs, - MerchantOpen, RepairTables, + buyback_order, item_repair_cost, open_vendor_discount, repair_all, resolve_buyback, + resolve_item, vendor_repairs, MerchantOpen, RepairTables, }; use crate::entities::ItemDisplays; use crate::items::Items; @@ -115,6 +116,41 @@ impl MerchantLive<'_, '_> { discount, ))) } + + /// The item at the player's `bag` slot `slot` (1-based; the doll's live id with + /// [`benilla_ui::script::EQUIPMENT_BAG`]) priced as `0x4faf30` prices it at the call: the + /// object's own cost (`0x5da330`) after the open vendor's discount, the full price with none + /// open (`0x4faf8a`); 0 for no object, no player or no tables. + pub(super) fn repair_cost(&self, bag: i64, slot: u32) -> Option { + let items = self.items.as_deref()?; + let Some(player) = self.player() else { + return Some(0); + }; + let (Some(slot0), Some(tables)) = ( + slot.checked_sub(1).and_then(|s| u8::try_from(s).ok()), + self.tables.as_deref(), + ) else { + return Some(0); + }; + let objects = self.stores.item_objects(&self.objects); + let Some(guid) = crate::ui_items::slot_guid(&player.0, bag, slot0, &objects) else { + return Some(0); + }; + let discount = self.open.as_deref().map_or(0.0, |open| { + open_vendor_discount(self.vendor(open), &self.relations, player) + }); + Some(item_repair_cost(guid, &objects, items, tables, discount)) + } + + /// The item object `guid`'s own cost before any discount (`0x5da330`), as of the drain's + /// place. + pub(super) fn own_repair_cost(&self, guid: u64) -> Option { + let items = self.items.as_deref()?; + let objects = self.stores.item_objects(&self.objects); + Some(self.tables.as_deref().map_or(0, |tables| { + item_repair_cost(guid, &objects, items, tables, 0.0) + })) + } } impl crate::script_calls::live::LiveFamily for MerchantLive<'_, '_> { @@ -123,5 +159,7 @@ impl crate::script_calls::live::LiveFamily for MerchantLive<'_, '_> { || ask.answer(|q: &MerchantRowQuery| self.row(q.0)) || ask.answer(|q: &BuybackRowQuery| self.buyback(q.0)) || ask.answer(|_: &RepairAllCostQuery| self.repair_all()) + || ask.answer(|q: &RepairCostQuery| self.repair_cost(q.bag, q.slot)) + || ask.answer(|q: &ItemRepairCostQuery| self.own_repair_cost(q.0)) } } diff --git a/crates/benilla-app/src/ui_merchant/mod.rs b/crates/benilla-app/src/ui_merchant/mod.rs index 6fea50f3a..57b5b6348 100644 --- a/crates/benilla-app/src/ui_merchant/mod.rs +++ b/crates/benilla-app/src/ui_merchant/mod.rs @@ -1,21 +1,21 @@ //! The merchant window's app side: [`MerchantOpen`] holds the `SMSG_LIST_INVENTORY` rows, which //! the getters read with the buy-back rows and the repair total through the host as they run -//! ([`live`]); the handlers signal on the merchant lane ([`lane`]); [`feed_repair_costs`] pushes -//! the items' repair costs, and [`MerchantSteps`] applies the verbs, the close among them, inside +//! ([`live`]), the items' repair costs among them; the handlers signal on the merchant lane +//! ([`lane`]), and [`MerchantSteps`] applies the verbs, the close among them, inside //! their calls. A bag click sells through [`crate::ui_items`]. use benilla_protocol::messages::{buy_result, sell_result, VendorItem}; use benilla_world::interact::WorldRightPress; use bevy::prelude::*; -use benilla_ui::script::{ItemStatsHead, MerchantItem, RepairCosts, UiScript}; +use benilla_ui::script::{ItemStatsHead, MerchantItem, UiScript}; use crate::entities::ItemDisplays; use crate::items::Items; -use crate::net::{ObjectStore, Objects, SelfPlayer}; +use crate::net::{ObjectStore, Objects}; use crate::query_cache::Ask; use crate::ui_items::{item_link, wire_pos}; -use crate::ui_script::{gate, SweepApp, SweepLeg, UiFeed}; +use crate::ui_script::{SweepApp, SweepLeg}; use crate::ui_session::{LatchApp, LatchedWindow, NpcWindow}; /// A vendor row's unlimited `current_count` (vmangos `ItemHandler.cpp:795`), `-1` to Lua. @@ -117,19 +117,7 @@ impl Plugin for UiMerchantPlugin { app.init_resource::() .add_latched_window::() // `0x490ada` → `0x4facc0` → `0x4fadf0`, the core itself. - .add_sweep_leg(SweepLeg::Merchant, close_core) - .add_systems( - Update, - ( - feed_purse.in_set(UiFeed), - // Before `feed_char`, whose doll push repaints each slot's tooltip cost. - // After the roster publish, whose party slots the vendor's reaction reads. - feed_repair_costs - .in_set(UiFeed) - .after(crate::spell::group_relation::publish_group_roster) - .before(crate::ui_char::feed_char), - ), - ); + .add_sweep_leg(SweepLeg::Merchant, close_core); } } @@ -317,77 +305,6 @@ fn item_repair_cost( .repair_cost(points, level, quality, class, subclass, discount) } -/// Each damaged carried or banked item's cost (`0x4faf30`), keyed as the bindings take it. -fn repair_costs( - store: &benilla_protocol::ObjectFields, - objects: &Objects, - items: &Items, - tables: &RepairTables, - discount: f64, -) -> RepairCosts { - let cost = |guid: u64| { - (guid != 0) - .then(|| item_repair_cost(guid, objects, items, tables, discount)) - .filter(|&c| c > 0) - }; - // The cost before the discount (`0x5da330`), which the merchant price takes off (`0x52e44a`). - let own = |guid: u64| { - (guid != 0) - .then(|| item_repair_cost(guid, objects, items, tables, 0.0)) - .filter(|&c| c > 0) - .map(|c| (guid, c)) - }; - let mut costs = RepairCosts::default(); - for i in 0..19u8 { - if let Some(c) = cost(store.player_inv_slot(i).unwrap_or(0)) { - costs.equipped.insert(u32::from(i) + 1, c); - } - } - for i in 0..16u8 { - let guid = store.player_pack_slot(i).unwrap_or(0); - if let Some(c) = cost(guid) { - costs.bags.insert((0, u32::from(i) + 1), c); - } - costs.own.extend(own(guid)); - } - for i in 0..crate::ui_items::BANK_SLOTS { - let guid = store.player_bank_slot(i).unwrap_or(0); - if let Some(c) = cost(guid) { - costs - .bags - .insert((crate::ui_items::BANK_CONTAINER, u32::from(i) + 1), c); - } - costs.own.extend(own(guid)); - } - // Bags 1-4 are inventory slots 19-22, bank bags 5-10 the bank bag slots. - let bags = (1..=4u8) - .map(|b| (i64::from(b), store.player_inv_slot(18 + b))) - .chain((0..crate::ui_items::BANK_BAGS).map(|i| { - ( - crate::ui_items::BANK_BAG_ID_FIRST + i64::from(i), - store.player_bank_bag_slot(i), - ) - })); - for (bag, bag_guid) in bags { - let bag_guid = bag_guid.unwrap_or(0); - if bag_guid == 0 { - continue; - } - let Some(b) = objects.object(bag_guid) else { - continue; - }; - let n = b.container_num_slots().unwrap_or(0).min(36) as u8; - for j in 0..n { - let guid = b.container_slot(j).unwrap_or(0); - if let Some(c) = cost(guid) { - costs.bags.insert((bag, u32::from(j) + 1), c); - } - costs.own.extend(own(guid)); - } - } - costs -} - /// `GetRepairAllCost`'s walk (`0x4fbd60`): the equipped slots 0 to 18, the backpack's 23 to 38 /// and each item inside the four equipped bags (19 to 22), never the bags themselves, the bank, /// the keyring or the buy-back slots. An item counts when its flags lack the wrap bit @@ -505,99 +422,6 @@ impl RepairPurse<'_, '_> { } } -/// What [`feed_repair_costs`] watches and last pushed. -#[derive(Default)] -struct RepairFeedMemo { - templates: gate::Watch, - discount: gate::Watch, - pushed: Option, -} - -/// Push each item's repair cost, which the item tooltips and the paper doll read off the VM. -/// `GetRepairAllCost` reads its total at the call ([`live`]). -fn feed_repair_costs( - mut vm: crate::script_calls::VmWith<( - crate::target::RelationWorld<'static, 'static>, - Res<'static, MerchantOpen>, - )>, - mut inv: crate::items::Inventory, - items: Res, - tables: Option>, - mut memo: Local>, -) { - vm.turn(|script, (relations, open)| { - let Some(script) = script else { - return; - }; - let (memo, vm_reset) = memo.get_reset(script); - // No self store at logout: keep the last push, as `feed_char` does. - let Some(player) = inv.self_store.iter().next() else { - return; - }; - // The open vendor's price discount (`0x4faf8a`), 0 with none open. - let discount = open_vendor_discount(vendor_unit(&open, &inv.objects), &relations, player); - let objects_moved = inv.changes.moved(); - let self_changed = !inv.self_changed.is_empty(); - let templates_moved = memo.templates.moved(items.template_epoch()); - let discount_moved = memo.discount.moved(discount.to_bits()); - let tables_added = tables.as_ref().is_some_and(|t| t.is_added()); - gate::trace( - "feed_repair_costs", - &[ - ("vm_reset", vm_reset), - ("objects", objects_moved), - ("self", self_changed), - ("templates", templates_moved), - ("discount", discount_moved), - ("tables", tables_added), - ], - ); - let gate = gate::Gate::new( - vm_reset - || objects_moved - || self_changed - || templates_moved - || discount_moved - || tables_added, - ); - if gate.skip() { - return; - } - let costs = tables.as_deref().map_or_else(RepairCosts::default, |t| { - repair_costs(&player.0, &inv.objects, &items, t, discount) - }); - if memo.pushed.as_ref() != Some(&costs) { - gate.audit("feed_repair_costs", "the repair costs"); - script.set_repair_costs(costs.clone()); - memo.pushed = Some(costs); - } - }) -} - -/// Push the purse (`PLAYER_FIELD_COINAGE`) on a change: the trade and mail verbs read it off the -/// VM, and it is `GetMoney`'s copy with no host to ask. Its `PLAYER_MONEY` is the coinage -/// watcher's (`0x5ddf4b`, [`crate::field_watch`]). -fn feed_purse( - mut hosted_vm: crate::script_calls::HostedVm, - self_q: Query<&ObjectStore, With>, - mut last_money: Local>>, -) { - hosted_vm.run(|script| { - let Some(script) = script else { - return; - }; - let last_money = last_money.get(script); - let Some(copper) = self_q.iter().next().and_then(|s| s.0.player_money()) else { - return; - }; - let copper = u64::from(copper); - if *last_money != Some(copper) { - script.set_money(copper); - *last_money = Some(copper); - } - }) -} - /// The vendor's cell `[0xbddfa0]`; `CloseMerchant` (`0x4fb120`) is the core itself, run inside /// its call ([`crate::script_calls::ScriptCallHost`]). impl LatchedWindow for MerchantOpen { @@ -688,7 +512,7 @@ pub(crate) fn end_repair_mode_on_right_press( #[cfg(test)] pub(crate) mod purse_fixture { use super::*; - use crate::net::{Guid, NetCommands}; + use crate::net::{Guid, NetCommands, SelfPlayer}; use benilla_protocol::field::{ FIELD_PLAYER_FIELD_COINAGE, FIELD_UNIT_FLAGS, FIELD_UNIT_NPC_FLAGS, }; @@ -790,6 +614,33 @@ pub(crate) mod purse_fixture { (app, me) } + /// `chunk`'s answer with a host lent whose one family is the merchant's ([`live`]): the + /// tooltips' costs read the world at the call. + pub(crate) fn live_eval(app: &mut App, chunk: &str) -> T { + struct Host<'a, 'w, 's>(&'a mut live::MerchantLive<'w, 's>); + impl benilla_ui::script::CallHost for Host<'_, '_, '_> { + fn apply( + &mut self, + _: &mut UiScript, + _: benilla_ui::script::ScriptCall, + ) -> Option { + None + } + fn read_live(&mut self, ask: &mut benilla_ui::script::live::LiveAsk<'_>) -> bool { + crate::script_calls::live::LiveFamily::answer(self.0, ask) + } + } + let world = app.world_mut(); + let mut script = world.remove_non_send_resource::().expect("a VM"); + let mut state = bevy::ecs::system::SystemState::::new(world); + let mut family = state.get_mut(world); + let r = script.hosted(&mut Host(&mut family), |s| { + s.eval::(chunk).expect("runs") + }); + world.insert_non_send_resource(script); + r + } + /// A VM with the sword at live id 16, backpack slot 1 and bank slot 1, and a tooltip `TT`. pub(crate) fn sword_vm() -> UiScript { let mut script = UiScript::new().unwrap(); @@ -861,7 +712,7 @@ mod call_tests; #[cfg(test)] mod tests { use super::*; - use crate::net::{ClientCommand, Guid, NetCommands}; + use crate::net::{ClientCommand, Guid, NetCommands, SelfPlayer}; use benilla_ui::script::MerchantState; /// `GetRepairAllCost` sums each item's cost after the open merchant's discount (`0x4faf8a`): @@ -972,28 +823,19 @@ mod tests { /// merchant price takes off the cost before the discount (`0x52e44a`). #[test] fn a_carried_items_own_cost_rides_beside_the_discounted_one() { + use benilla_protocol::field::FIELD_PLAYER_INV_SLOT_HEAD; use bevy::ecs::system::RunSystemOnce; use purse_fixture::*; - const PACK_SLOT_1: u16 = 532; let (full, discounted) = sword_prices(); - let mut app = App::new(); - app.init_resource::() - .init_resource::() - .init_resource::(); - seat(app.world_mut()); - let store = player(PACK_SLOT_1, 0).0; - let steps = f64::from(0.05f32) + f64::from(0.05f32); + let (mut app, _) = repair_app(FIELD_PLAYER_INV_SLOT_HEAD + 2 * 23); let costs = app .world_mut() - .run_system_once( - move |objects: Objects, items: Res, tables: Res| { - repair_costs(&store, &objects, &items, &tables, steps) - }, - ) + .run_system_once(|live: live::MerchantLive| { + (live.repair_cost(0, 1), live.own_repair_cost(SWORD)) + }) .unwrap(); - assert_eq!(costs.bags.get(&(0, 1)), Some(&discounted)); - assert_eq!(costs.own.get(&SWORD), Some(&full)); + assert_eq!(costs, (Some(discounted), Some(full))); } /// `RepairAllItems()` through the feed and the host, at [`purse_fixture`]'s vendor carrying @@ -1128,18 +970,13 @@ mod tests { #[test] fn each_items_cost_takes_the_open_vendors_discount() { use benilla_protocol::field::FIELD_PLAYER_INV_SLOT_HEAD; - use bevy::ecs::system::RunSystemOnce; use purse_fixture::*; let (full, discounted) = sword_prices(); let priced = |slot_field: u16, read: &str| { let (mut app, _) = repair_app(slot_field); app.insert_non_send_resource(sword_vm()); - let read_cost = |app: &mut App| { - app.world_mut().run_system_once(feed_repair_costs).unwrap(); - let script = app.world().non_send_resource::(); - script.eval::(read).unwrap() - }; + let read_cost = |app: &mut App| live_eval::(app, read); let at_vendor = read_cost(&mut app); app.world_mut().resource_mut::().clear(); (at_vendor, read_cost(&mut app)) @@ -1168,7 +1005,6 @@ mod tests { #[test] fn a_wrapped_items_repair_costs_nothing() { use benilla_protocol::field::FIELD_PLAYER_INV_SLOT_HEAD; - use bevy::ecs::system::RunSystemOnce; use purse_fixture::*; /// `ITEM_FIELD_FLAGS`, and its wrapped bit. const ITEM_FLAGS: u16 = 21; @@ -1176,11 +1012,10 @@ mod tests { let (mut app, _) = repair_app(FIELD_PLAYER_INV_SLOT_HEAD + 2 * 15); app.insert_non_send_resource(sword_vm()); let cost = |app: &mut App| { - app.world_mut().run_system_once(feed_repair_costs).unwrap(); - let script = app.world().non_send_resource::(); - script - .eval::(r#"local _, _, c = TT:SetInventoryItem("player", 16) return c"#) - .unwrap() + live_eval::( + app, + r#"local _, _, c = TT:SetInventoryItem("player", 16) return c"#, + ) }; assert!(cost(&mut app) > 0, "the damaged sword costs"); let sword = app.world().resource::().0[&SWORD]; @@ -1194,10 +1029,11 @@ mod tests { assert_eq!(cost(&mut app), 0, "wrapped, it costs nothing"); } - /// Each gate input alone reopens the feed, through `App::update` so the memo persists; a bank - /// item is priced too. + /// Each cost reads the world at the call: the tables, the durability, the slot, the vendor's + /// discount and the record, each written alone with no push between; a bank item is priced + /// too. #[test] - fn each_input_alone_reopens_the_repair_cost_gate() { + fn each_cost_reads_the_world_at_the_call() { use benilla_protocol::field::{FIELD_PLAYER_INV_SLOT_HEAD, FIELD_UNIT_FLAGS}; use benilla_protocol::ObjectFields; use purse_fixture::*; @@ -1210,7 +1046,6 @@ mod tests { let bank_1 = FIELD_PLAYER_INV_SLOT_HEAD + 2 * 39; let (mut app, me) = repair_app(main_hand); - app.add_systems(Update, feed_repair_costs); let tables = app.world_mut().remove_resource::().unwrap(); let mut script = sword_vm(); script.set_merchant(Some(MerchantState { @@ -1228,18 +1063,15 @@ mod tests { let mut store = app.world_mut().get_mut::(e).unwrap(); store.0.merge(ObjectFields::from_pairs(pairs)); }; - // (worn, backpack, bank) after one frame. + // (worn, backpack, bank) at the call. let frame = |app: &mut App| { - app.update(); - app.world() - .non_send_resource::() - .eval::<(i64, i64, i64)>( - r#"local _, _, worn = TT:SetInventoryItem("player", 16) - local _, carried = TT:SetBagItem(0, 1) - local _, banked = TT:SetBagItem(-1, 1) - return worn, carried, banked"#, - ) - .unwrap() + live_eval::<(i64, i64, i64)>( + app, + r#"local _, _, worn = TT:SetInventoryItem("player", 16) + local _, carried = TT:SetBagItem(0, 1) + local _, banked = TT:SetBagItem(-1, 1) + return worn, carried, banked"#, + ) }; let vendor = app .world_mut() diff --git a/crates/benilla-app/src/ui_script/sweep_tests.rs b/crates/benilla-app/src/ui_script/sweep_tests.rs index ac37a8d11..2b2594565 100644 --- a/crates/benilla-app/src/ui_script/sweep_tests.rs +++ b/crates/benilla-app/src/ui_script/sweep_tests.rs @@ -121,7 +121,12 @@ fn open_everything(world: &mut World) { .open(STABLE_MASTER, 0, Vec::new()); // The bank last, so it holds the latch: every leg above releases its own, and the interaction // close finds the banker. - crate::ui_bank::open_bank(world, BANKER); + // The open's latch and cell, its `BANKFRAME_OPENED` long fired (`0x4f8507`, `0x4f850e`), so + // the sweep's close finds no open still queued on the bank lane ahead of it. + crate::ui_session::register(world, BANKER); + world + .resource_mut::() + .open(BANKER); world.insert_resource(crate::cinematic::Cinematic::playing_for_test()); } diff --git a/crates/benilla-app/src/ui_script/test_ui.rs b/crates/benilla-app/src/ui_script/test_ui.rs index 1d3cfb347..50c855ed5 100644 --- a/crates/benilla-app/src/ui_script/test_ui.rs +++ b/crates/benilla-app/src/ui_script/test_ui.rs @@ -509,9 +509,9 @@ pub(super) const MERCHANT_UI: &[&str] = &[ "ScrollTemplates.xml", ]; -/// Stock `GossipFrame.xml`'s dependencies in the production order, shared with `ui_gossip`'s feed -/// tests. The greeting pane inherits `UIPanelScrollFrameTemplate`, and a missing template only -/// warns, losing the scrollbar silently. Needs client data. +/// Stock `GossipFrame.xml`'s dependencies in the production order, for the gossip lane's tests. +/// The greeting pane inherits `UIPanelScrollFrameTemplate`, and a missing template only warns, +/// losing the scrollbar silently. Needs client data. pub(crate) const GOSSIP_UI: &[&str] = &[ r"Interface\FrameXML\GlobalStrings.lua", r"Interface\FrameXML\Fonts.xml", diff --git a/crates/benilla-app/src/ui_stable/call_tests.rs b/crates/benilla-app/src/ui_stable/call_tests.rs index 5fb220a2d..8f3f54e99 100644 --- a/crates/benilla-app/src/ui_stable/call_tests.rs +++ b/crates/benilla-app/src/ui_stable/call_tests.rs @@ -29,6 +29,8 @@ fn client(summon: u64) -> Stable { let world = app.world_mut(); let me = ObjectFields::from_pairs(&[ (2, 0x19), + // The purse the buy's gate reads (`0x4cb122`). + (benilla_protocol::field::FIELD_PLAYER_FIELD_COINAGE, 10_000), (SUMMON, summon as u32), (SUMMON + 1, (summon >> 32) as u32), ]) @@ -95,8 +97,7 @@ fn client(summon: u64) -> Stable { world.insert_resource(super::StableSlotPrices( benilla_formats::StableSlotPrices::from_rows([(2, 500)]), )); - let mut script = UiScript::new().expect("a VM"); - script.set_money(10_000); + let script = UiScript::new().expect("a VM"); app.world_mut().insert_non_send_resource(script); // The list's signal, which leaves the VM its copy. app.world_mut() diff --git a/crates/benilla-app/src/ui_trade/call_tests.rs b/crates/benilla-app/src/ui_trade/call_tests.rs index 7f5af94c2..5d5025a11 100644 --- a/crates/benilla-app/src/ui_trade/call_tests.rs +++ b/crates/benilla-app/src/ui_trade/call_tests.rs @@ -40,6 +40,8 @@ fn me_fields(extra: &[(u16, u32)]) -> ObjectFields { (22, 100), (35, fixture::HUMAN), (46, fixture::PC), + // The purse the money verbs test against (`PLAYER_FIELD_COINAGE`). + (benilla_protocol::field::FIELD_PLAYER_FIELD_COINAGE, 10_000), (532, CLOTH as u32), (533, (CLOTH >> 32) as u32), (534, JERKY as u32), @@ -87,7 +89,6 @@ fn client() -> Trade { .insert_player(BOB, "Bob".to_string(), None); world.resource_mut::().guid = Some(BOB); let mut script = UiScript::new().expect("a VM"); - script.set_money(10_000); script.set_unit_guids(&UnitGuids { player: ME, target: BOB, diff --git a/crates/benilla-app/src/ui_trade/mod.rs b/crates/benilla-app/src/ui_trade/mod.rs index b278f84fb..612c3a440 100644 --- a/crates/benilla-app/src/ui_trade/mod.rs +++ b/crates/benilla-app/src/ui_trade/mod.rs @@ -1289,7 +1289,12 @@ fn feed_trade( // `SetTradePartner`'s open leg (`0x4bf5f1`): only a trade we started by dropping // something on the partner moves what the cursor holds into the offer (`0x5d3f80`). if trade.opens_with_held() { - if let Some(offer) = script.trade_open_money() { + // The coinage `0x5d4570` tests the total against, as the handler finds it. + let purse = objects + .player_fields() + .and_then(|p| p.player_money()) + .map_or(0, u64::from); + if let Some(offer) = script.trade_open_money(purse) { // Coins (`0x4bf5fe`-`0x4bf644`): `0x5d4500` sends the new offer, then the // cursor clears (`0x4bf61c`), the cache of our money `[0xb71740]` takes it // (`0x4bf62b`), and `PLAYER_TRADE_MONEY`, `PLAYER_MONEY` and `TRADE_SHOW` fire diff --git a/crates/benilla-app/src/ui_unit/field_events.rs b/crates/benilla-app/src/ui_unit/field_events.rs index 2e5471206..cb8067770 100644 --- a/crates/benilla-app/src/ui_unit/field_events.rs +++ b/crates/benilla-app/src/ui_unit/field_events.rs @@ -679,6 +679,7 @@ fn global_event(id: u16) -> Option<&'static str> { id::AREA_SPIRIT_HEALER_IN_RANGE => "AREA_SPIRIT_HEALER_IN_RANGE", id::AREA_SPIRIT_HEALER_OUT_OF_RANGE => "AREA_SPIRIT_HEALER_OUT_OF_RANGE", id::INSPECT_HONOR_UPDATE => "INSPECT_HONOR_UPDATE", + id::PLAYERBANKBAGSLOTS_CHANGED => "PLAYERBANKBAGSLOTS_CHANGED", _ => return None, }) } diff --git a/crates/benilla-ui/src/script/bank.rs b/crates/benilla-ui/src/script/bank.rs index 6a6b4bfed..64f07dad6 100644 --- a/crates/benilla-ui/src/script/bank.rs +++ b/crates/benilla-ui/src/script/bank.rs @@ -1,5 +1,7 @@ -//! The bank bindings: the app pushes the open bank's snapshot ([`UiScript::set_bank`]), and -//! `PurchaseSlot` and `CloseBankFrame` hand the host their calls ([`BankCall`]). +//! The bank bindings: the purchase row reads the active player through the host at the call +//! ([`BankSlotsQuery`]), the app writes the banker's cell into the VM at the open and the close +//! ([`UiScript::set_bank`]), and `PurchaseSlot` and `CloseBankFrame` hand the host their calls +//! ([`BankCall`]). //! //! The vault is container `-1` (24 slots) and containers 5..=10 (the six bags), the reference's //! own ids (`BankFrame.lua:1-4`), so the container verbs work on it unchanged. The bag items @@ -8,9 +10,11 @@ use mlua::{Lua, MultiValue, Value}; +use super::live::{ask, LiveQuery}; use super::Model; -/// The open bank's purchase row, pushed whole by the app. +/// The bank's purchase row: `Some` in the VM while a banker's cell is set, which the container +/// getters' bank gate reads; its two numbers are the getters' answer with no host to ask. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct BankState { /// Purchased bag slots, 0..=6: `PLAYER_BYTES_2` byte 2. @@ -20,8 +24,27 @@ pub struct BankState { pub next_cost: u32, } +/// The question `GetNumBankSlots` (`0x4f85b0`) and `GetBankSlotCost` (`0x4f8630`) ask at the +/// call: the active player's `PLAYER_BYTES_2` byte 2 (`+0x1a`, `0x4f85dd`) and the price of the +/// row after it (`0x4f8663` → `0x4f8690`), both 0 with no player (`0x4f85e3`, `0x4f866a`). Neither +/// tests the banker. +pub struct BankSlotsQuery; + +impl LiveQuery for BankSlotsQuery { + type Answer = BankState; +} + +/// The purchase row now: the host's, else the VM's copy, which only a test sets. +fn slots(lua: &Lua) -> BankState { + ask(lua, &BankSlotsQuery).unwrap_or_else(|| { + let model = lua.app_data_ref::().expect("model app_data"); + model.bank.clone().unwrap_or_default() + }) +} + impl super::UiScript { - /// Push (or clear, with `None`) the open bank's snapshot. + /// Write (or clear, with `None`) the banker's cell `[0xbdd038]` into the VM, at the open's + /// signal and the close's clear. pub fn set_bank(&mut self, state: Option) { self.model_mut().bank = state; } @@ -54,10 +77,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { g.set( "GetNumBankSlots", lua.create_function(|lua, ()| { - let n = { - let model = lua.app_data_ref::().expect("model app_data"); - model.bank.as_ref().map_or(0, |b| b.num_purchased) - }; + let n = slots(lua).num_purchased; let full = if n >= 6 { Value::Integer(1) } else { @@ -70,14 +90,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { })?, )?; - // GetBankSlotCost(numSlots): the argument is unread; the pushed state names the next slot. + // GetBankSlotCost(numSlots): the argument is unread; the next slot's price. g.set( "GetBankSlotCost", // Reads no argument at all (`0x4f8630`): any call shape answers. - lua.create_function(|lua, _args: mlua::MultiValue| { - let model = lua.app_data_ref::().expect("model app_data"); - Ok(i64::from(model.bank.as_ref().map_or(0, |b| b.next_cost))) - })?, + lua.create_function(|lua, _args: mlua::MultiValue| Ok(i64::from(slots(lua).next_cost)))?, )?; // PurchaseSlot(): the `CONFIRM_BUY_BANK_SLOT` popup's accept (`StaticPopup.lua:52`). diff --git a/crates/benilla-ui/src/script/char_stats.rs b/crates/benilla-ui/src/script/char_stats.rs index c0fd706d2..f7f979019 100644 --- a/crates/benilla-ui/src/script/char_stats.rs +++ b/crates/benilla-ui/src/script/char_stats.rs @@ -600,22 +600,20 @@ impl Model { self.containers.get(&bag)?.slots.get(&n) } - /// The player's repair cost at live id `slot`, the vault and keyring bands by their container - /// slots. - pub(super) fn inv_repair_cost(&self, slot: usize) -> u32 { - let costs = &self.repair_costs; - let cost = if BANK_INV_SLOTS.contains(&slot) { - let n = (slot - BANK_INV_SLOTS.start() + 1) as u32; - costs.bags.get(&(BANK_CONTAINER, n)) + /// The player's repair cost at live id `slot` now (`0x5332e1`), the vault and keyring bands by + /// their container slots. + pub(super) fn inv_repair_cost(&self, lua: &mlua::Lua, slot: usize) -> u32 { + let (bag, n) = if BANK_INV_SLOTS.contains(&slot) { + (BANK_CONTAINER, slot - BANK_INV_SLOTS.start() + 1) } else if KEYRING_INV_SLOTS.contains(&slot) { - let n = (slot - KEYRING_INV_SLOTS.start() + 1) as u32; - costs.bags.get(&(KEYRING_CONTAINER, n)) + (KEYRING_CONTAINER, slot - KEYRING_INV_SLOTS.start() + 1) } else { - u32::try_from(slot) - .ok() - .and_then(|s| costs.equipped.get(&s)) + (super::EQUIPMENT_BAG, slot) }; - cost.copied().unwrap_or(0) + let Ok(n) = u32::try_from(n) else { + return 0; + }; + super::merchant::repair_cost_at(lua, self, bag, n) } /// The use cooldown `(start, duration, enabled)` in `GetTime` seconds of the player's item at diff --git a/crates/benilla-ui/src/script/cursor/money.rs b/crates/benilla-ui/src/script/cursor/money.rs index 81ff1299b..7a31d3ccf 100644 --- a/crates/benilla-ui/src/script/cursor/money.rs +++ b/crates/benilla-ui/src/script/cursor/money.rs @@ -52,7 +52,9 @@ fn install_money(lua: &Lua, copper: u32) { pub(crate) fn pickup_player_money(lua: &Lua, copper: u32) -> bool { let allowed = { let model = lua.app_data_ref::().expect("model app_data"); - copper != 0 && model.player_control && u64::from(copper) <= model.money + copper != 0 + && model.player_control + && u64::from(copper) <= crate::script::stat_read::purse(lua, &model) }; if allowed { install_money(lua, copper); @@ -70,16 +72,16 @@ pub(crate) fn drop_cursor_money(model: &mut Model) -> bool { true } -/// The new offer `0x5d4500` sends for the held coins: offer plus cursor, when the purse covers it -/// (`0x5d4570`); `None` with no coins held. -pub(crate) fn trade_money_total(model: &Model) -> Option { +/// The new offer `0x5d4500` sends for the held coins: offer plus cursor, when `purse`, the coinage, +/// covers it (`0x5d4570`); `None` with no coins held. +pub(crate) fn trade_money_total(model: &Model, purse: u64) -> Option { let copper = cursor_money(model); if copper == 0 { return None; } let offer = model.trade.as_ref().map_or(0, |t| t.player.gold); let total = u64::from(offer) + u64::from(copper); - (total <= model.money).then_some(total as u32) + (total <= purse).then_some(total as u32) } /// Our money offer `[0xc4be70]` as a sender stores it after its send. @@ -97,7 +99,8 @@ pub(crate) fn set_own_offer(model: &mut Model, copper: u32) { pub(crate) fn add_trade_money(lua: &Lua) -> mlua::Result { let (held, total) = { let model = lua.app_data_ref::().expect("model app_data"); - (cursor_money(&model) != 0, trade_money_total(&model)) + let purse = crate::script::stat_read::purse(lua, &model); + (cursor_money(&model) != 0, trade_money_total(&model, purse)) }; let Some(total) = total else { return Ok(held); diff --git a/crates/benilla-ui/src/script/gossip.rs b/crates/benilla-ui/src/script/gossip.rs index 4e81d7264..9c7690b8a 100644 --- a/crates/benilla-ui/src/script/gossip.rs +++ b/crates/benilla-ui/src/script/gossip.rs @@ -1,20 +1,21 @@ -//! The gossip bindings: the app pushes the open menu ([`UiScript::set_gossip`]), each select hands -//! the host its call ([`GossipCall`]), which reads the message's slots and sends before the verb -//! returns, and `CloseGossip` leaves its teardown to the app. +//! The gossip bindings: the getters read the message's slots and the drawn text through the host +//! at the call ([`GossipQuery`]), each select hands the host its call ([`GossipCall`]), which +//! reads the slots and sends before the verb returns, and `CloseGossip` leaves its teardown to the +//! app. //! //! `GetGossipOptions()` returns flat `(text, type)` pairs, `type` the lowercase icon name the app //! maps from the wire `GOSSIP_ICON`. A coded option selected with no code fires //! `GOSSIP_ENTER_CODE`, whose stock popup (`StaticPopup.lua:1383`) calls //! `SelectGossipOption(index, code)`. //! -//! A menu is pushed only once its greeting (`SMSG_NPC_TEXT_UPDATE`) has arrived; until then the -//! VM keeps its last menu, as the reference's frame keeps its last paint: its handler returns on a -//! cache miss without an event, and the greeting write and `GOSSIP_SHOW` share one success path -//! (`0x4e2010`, `0x4e22b0`). +//! The message writes the slots at once (`0x4e26e0`), and the text only once its greeting is +//! drawn (`0x4e229a`), right before `GOSSIP_SHOW`; while the text query is out the getters read +//! the new slots beside the last text, and nothing signals, so the frame keeps its last paint. use mlua::{Lua, MultiValue, Value}; use super::binding_abi; +use super::live::{ask, LiveQuery}; use super::Model; /// One gossip option; its 1-based index is its place in [`GossipMenu::options`]. @@ -38,10 +39,11 @@ pub struct GossipQuestRow { pub active: bool, } -/// One open gossip menu, pushed whole by the app. +/// The gossip store as the getters read it: the host's answer at the call, else the VM's copy, +/// which only a test sets. #[derive(Clone, Debug, Default, PartialEq)] pub struct GossipMenu { - /// The NPC greeting, always resolved. + /// The drawn text `0xbbb678`, expanded at its draw, which `GetGossipText` returns (`0x4e28c0`). pub greeting: String, /// Quest rows from the same packet. pub quests: Vec, @@ -63,22 +65,36 @@ pub enum GossipCall { Close, } +/// The question the gossip getters ask at the call: the text buffer (`0xbbb678`), the used option +/// slots in slot order (`0xbbbe90`, `0x4e28d0`) and the quest rows (`0xbb74c0`). +pub struct GossipQuery; + +impl LiveQuery for GossipQuery { + type Answer = GossipMenu; +} + +/// `f` over the store now: the host's, else the VM's copy; `None` with neither. +fn read(lua: &Lua, f: impl FnOnce(&GossipMenu) -> T) -> Option { + match ask(lua, &GossipQuery) { + Some(menu) => Some(f(&menu)), + None => { + let model = lua.app_data_ref::().expect("model app_data"); + model.gossip.as_ref().map(f) + } + } +} + /// Hand `call` to the host, which applies it before this returns ([`super::calls::submit`]). fn submit(lua: &Lua, call: GossipCall) -> mlua::Result<()> { super::calls::submit(lua, super::ScriptCall::Gossip(call)) } impl super::UiScript { - /// Push (or clear, with `None`) the open gossip menu. + /// Set (or clear, with `None`) the VM's copy of the store, for a test. pub fn set_gossip(&mut self, menu: Option) { self.model_mut().gossip = menu; } - /// The menu the VM holds, as last pushed. - pub fn gossip(&self) -> Option { - self.model_ref().gossip.clone() - } - /// `ResetGossipOptions` (`0x4e26a0`) as the getters see it: no option and no quest row; the /// text stays. pub fn reset_gossip_slots(&mut self) { @@ -114,15 +130,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { g.set( "GetGossipText", - lua.create_function(|lua, ()| { - let text = { - let model = lua.app_data_ref::().expect("model app_data"); - model.gossip.as_ref().map(|m| m.greeting.clone()) - }; - match text { - Some(t) => Ok(Value::String(lua.create_string(&t)?)), - None => Ok(Value::Nil), - } + lua.create_function(|lua, ()| match read(lua, |m| m.greeting.clone()) { + Some(t) => Ok(Value::String(lua.create_string(&t)?)), + None => Ok(Value::Nil), })?, )?; @@ -130,15 +140,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { "GetGossipOptions", lua.create_function(|lua, ()| { // Copy out under one short borrow; build the Lua values with none held. - let pairs: Vec<(String, String)> = { - let model = lua.app_data_ref::().expect("model app_data"); - model.gossip.as_ref().map_or_else(Vec::new, |m| { - m.options - .iter() - .map(|o| (o.label.clone(), o.icon_type.clone())) - .collect() - }) - }; + let pairs: Vec<(String, String)> = read(lua, |m| { + m.options + .iter() + .map(|o| (o.label.clone(), o.icon_type.clone())) + .collect() + }) + .unwrap_or_default(); let mut out: Vec = Vec::with_capacity(pairs.len() * 2); for (label, icon) in pairs { out.push(Value::String(lua.create_string(&label)?)); @@ -180,16 +188,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { // The reference splits the quest rows by wire icon, 3 and 4 active and the rest available, // behind two vararg verbs (`0x4e2430`, `0x4e2580`); the app splits them at parse time. fn quest_rows(lua: &Lua, active: bool) -> mlua::Result { - let rows: Vec<(String, u32)> = { - let model = lua.app_data_ref::().expect("model app_data"); - model.gossip.as_ref().map_or_else(Vec::new, |m| { - m.quests - .iter() - .filter(|q| q.active == active) - .map(|q| (q.title.clone(), q.level)) - .collect() - }) - }; + let rows: Vec<(String, u32)> = read(lua, |m| { + m.quests + .iter() + .filter(|q| q.active == active) + .map(|q| (q.title.clone(), q.level)) + .collect() + }) + .unwrap_or_default(); let mut out = Vec::with_capacity(rows.len() * 2); for (title, level) in rows { out.push(Value::String(lua.create_string(&title)?)); diff --git a/crates/benilla-ui/src/script/mail.rs b/crates/benilla-ui/src/script/mail.rs index 9ce57c1a5..1e7092709 100644 --- a/crates/benilla-ui/src/script/mail.rs +++ b/crates/benilla-ui/src/script/mail.rs @@ -382,7 +382,12 @@ pub enum MailRowVerb { impl MailRowVerb { /// `Ok` when the reference sends for `row`, else the refusal's message key, if it shows one. - fn sends(self, model: &Model, row: &MailInboxRow) -> Result<(), Option<&'static str>> { + fn sends( + self, + lua: &Lua, + model: &Model, + row: &MailInboxRow, + ) -> Result<(), Option<&'static str>> { // An item (`[rec+0x120]`, `0x4af96d`), then the player's object (`0x4af992`) and the purse // to pay its COD (`[rec+0x144]` against the coinage, `0x4af9c5`); either miss shows // `ERR_NOT_ENOUGH_MONEY` (`0x4afa4c`, `push 0x25`). @@ -391,7 +396,7 @@ impl MailRowVerb { return Err(None); } let player = model.units_by_lower.contains_key("player"); - if !player || model.money < u64::from(row.cod) { + if !player || super::stat_read::purse(lua, model) < u64::from(row.cod) { return Err(Some("ERR_NOT_ENOUGH_MONEY")); } return Ok(()); @@ -679,7 +684,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { // the lock or the mailbox. let model = lua.app_data_ref::().expect("model app_data"); let can = row_at(&model, index) - .is_some_and(|r| MailRowVerb::Delete.sends(&model, &r).is_ok()); + .is_some_and(|r| MailRowVerb::Delete.sends(lua, &model, &r).is_ok()); Ok(flag(can)) })?, )?; @@ -764,7 +769,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { let Some(row) = row_at(&model, index as usize) else { return Ok(()); }; - if let Err(refusal) = verb.sends(&model, &row) { + if let Err(refusal) = verb.sends(lua, &model, &row) { drop(model); return match refusal { Some(key) => super::calls::display_error(lua, key), @@ -874,7 +879,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { copper, "Usage: SetSendMailMoney(amount)", )? as u32; - let money = lua.app_data_ref::().expect("model app_data").money; + let money = { + let model = lua.app_data_ref::().expect("model app_data"); + super::stat_read::purse(lua, &model) + }; if u64::from(n) > money { super::calls::display_error(lua, "ERR_NOT_ENOUGH_MONEY")?; return Ok(Value::Nil); diff --git a/crates/benilla-ui/src/script/merchant.rs b/crates/benilla-ui/src/script/merchant.rs index 373e34972..4c6611be1 100644 --- a/crates/benilla-ui/src/script/merchant.rs +++ b/crates/benilla-ui/src/script/merchant.rs @@ -72,7 +72,8 @@ pub struct ItemStatsHead { pub sell_price: u32, } -/// Each damaged item's repair cost in copper after the vendor's discount (`0x4faf30`). +/// Each damaged item's repair cost in copper after the vendor's discount (`0x4faf30`): the VM's +/// copy the tooltips read with no host to ask ([`RepairCostQuery`]), which only a test sets. #[derive(Clone, Debug, Default, PartialEq)] pub struct RepairCosts { /// Equipped items by live inventory id. @@ -138,6 +139,45 @@ impl LiveQuery for RepairAllCostQuery { type Answer = Option<(u32, bool)>; } +/// The repair cost the item tooltips return for the item a slot holds, read at the call +/// (`0x534948`, `0x5332e1`): `0x4faf30`, the object's own cost after the open vendor's discount, +/// 0 for no object. `bag` is the Lua container id, or [`super::EQUIPMENT_BAG`] with a doll live id. +pub struct RepairCostQuery { + pub bag: i64, + pub slot: u32, +} + +impl LiveQuery for RepairCostQuery { + type Answer = u32; +} + +/// An item object's own repair cost before any discount (`0x5da330`), which the merchant price +/// takes off (`0x52e44a`); 0 for no such object. +pub struct ItemRepairCostQuery(pub u64); + +impl LiveQuery for ItemRepairCostQuery { + type Answer = u32; +} + +/// The repair cost of the item at `bag`'s 1-based `slot` now, as [`RepairCostQuery`] reads it: +/// the host's, else the VM's copy, which only a test sets. +pub(crate) fn repair_cost_at(lua: &Lua, model: &Model, bag: i64, slot: u32) -> u32 { + ask(lua, &RepairCostQuery { bag, slot }).unwrap_or_else(|| { + let costs = &model.repair_costs; + let cost = match bag { + super::EQUIPMENT_BAG => costs.equipped.get(&slot), + _ => costs.bags.get(&(bag, slot)), + }; + cost.copied().unwrap_or(0) + }) +} + +/// The item object `guid`'s own repair cost now, as [`ItemRepairCostQuery`] reads it. +pub(crate) fn item_repair_cost(lua: &Lua, model: &Model, guid: u64) -> u32 { + ask(lua, &ItemRepairCostQuery(guid)) + .unwrap_or_else(|| model.repair_costs.own.get(&guid).copied().unwrap_or(0)) +} + /// The window's head now: the host's, else the VM's copy, which only a test sets (every /// production entry that runs Lua lends the host). pub(crate) fn head(lua: &Lua, model: &Model) -> MerchantHead { @@ -198,7 +238,7 @@ impl super::UiScript { self.model_mut().merchant = state; } - /// Push the repair costs the item tooltip bindings return. + /// Set the repair costs the item tooltip bindings return with no host to ask, for a test. pub fn set_repair_costs(&mut self, costs: RepairCosts) { self.model_mut().repair_costs = costs; } diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 72a8506ed..c5d318540 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -241,7 +241,7 @@ pub use aura::{ AuraCall, AuraListRead, AuraRead, AuraState, PlayerBuffExpiry, TrackingState, UnitAuraList, }; pub use backdrop::{Backdrop, Insets}; -pub use bank::{BankCall, BankState}; +pub use bank::{BankCall, BankSlotsQuery, BankState}; pub use battlefield_live::{ BattlefieldFlagQuery, BattlefieldIconScaleQuery, BattlefieldListQuery, BattlefieldPositionCountQuery, BattlefieldPositionQuery, BattlefieldScoreRead, @@ -317,7 +317,7 @@ pub use duel::DuelRequest; pub use follow::FollowRequest; pub use gm_survey::{GmSurveyTables, SurveyAnswerSend}; pub use gm_ticket::{GmTicketCall, GmTicketIntent, GmTicketWrite}; -pub use gossip::{GossipCall, GossipMenu, GossipOptionView, GossipQuestRow}; +pub use gossip::{GossipCall, GossipMenu, GossipOptionView, GossipQuery, GossipQuestRow}; pub use guild::{ GuildCall, GuildCells, GuildMemberInfo, GuildRankEdit, GuildRankInfo, GuildState, LastOnline, UnitGuild, MAX_RANKS, MIN_RANKS, RANK_RIGHT_BITS, @@ -376,8 +376,9 @@ pub use mail::{ }; pub use measure::TextMeasure; pub use merchant::{ - BuybackRowQuery, ItemStatsHead, MerchantCall, MerchantHead, MerchantHeadQuery, MerchantItem, - MerchantRowQuery, MerchantState, RepairAllCostQuery, RepairCosts, + BuybackRowQuery, ItemRepairCostQuery, ItemStatsHead, MerchantCall, MerchantHead, + MerchantHeadQuery, MerchantItem, MerchantRowQuery, MerchantState, RepairAllCostQuery, + RepairCostQuery, RepairCosts, }; pub(crate) use minimap::apply_model_attrs as apply_minimap_model_attrs; pub use minimap::{ diff --git a/crates/benilla-ui/src/script/stable.rs b/crates/benilla-ui/src/script/stable.rs index a0ae69030..f207525ca 100644 --- a/crates/benilla-ui/src/script/stable.rs +++ b/crates/benilla-ui/src/script/stable.rs @@ -665,8 +665,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { lua.create_function(|lua, ()| { let allowed = { let model = lua.app_data_ref::().expect("model app_data"); - // Affordability reads `model.money`, the purse `GetMoney()` answers. - let money = model.money; + // Affordability reads the purse `GetMoney()` answers (`0x4cb122`). + let money = super::stat_read::purse(lua, &model); view(lua, &model).state.as_ref().is_some_and(|s| { s.num_stable_slots as usize != NUM_STABLE_SLOTS - 1 && s.next_slot_cost != 0 diff --git a/crates/benilla-ui/src/script/stat_read.rs b/crates/benilla-ui/src/script/stat_read.rs index 9ca4d8ce4..d0e944850 100644 --- a/crates/benilla-ui/src/script/stat_read.rs +++ b/crates/benilla-ui/src/script/stat_read.rs @@ -348,3 +348,12 @@ pub(crate) fn read_self(lua: &Lua, f: impl FnOnce(&dyn SelfRead) -> T) -> T { } } } + +/// The purse now, `PLAYER_FIELD_COINAGE` (`+0xf70`), as `GetMoney` reads it (`0x518160`): the +/// active player's through the lent host, 0 while it holds none (`0x5181db`), else `model`'s +/// copy. For a caller that holds the model already. +pub(crate) fn purse(lua: &Lua, model: &Model) -> u64 { + let mut out = None; + super::host::read_host(lua, |h| h.read_self(&mut |own| out = Some(own.money()))); + out.unwrap_or(model.money) +} diff --git a/crates/benilla-ui/src/script/tooltip_item/build.rs b/crates/benilla-ui/src/script/tooltip_item/build.rs index 050225158..94369be4c 100644 --- a/crates/benilla-ui/src/script/tooltip_item/build.rs +++ b/crates/benilla-ui/src/script/tooltip_item/build.rs @@ -318,10 +318,9 @@ pub(in crate::script) fn build_item( // An item object's charges and repair cost (`0x52e426` tests the object). let object = build.inst.as_ref().filter(|i| i.object); let charges = object.map(|i| i.object_charges[0]); - let cost = object - .and_then(|i| model.repair_costs.own.get(&i.guid)) - .copied() - .unwrap_or(0); + let cost = object.map_or(0, |i| { + crate::script::merchant::item_repair_cost(lua, &model, i.guid) + }); let amount = sell_amount(v.sell_price, v.spell_charges_0, charges, cost, count); (v.sell_price, amount) }); diff --git a/crates/benilla-ui/src/script/tooltip_item/mod.rs b/crates/benilla-ui/src/script/tooltip_item/mod.rs index 505d99d4d..17c79bb4f 100644 --- a/crates/benilla-ui/src/script/tooltip_item/mod.rs +++ b/crates/benilla-ui/src/script/tooltip_item/mod.rs @@ -545,7 +545,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { }) }); let cost = if player { - model.inv_repair_cost(slot) + model.inv_repair_cost(lua, slot) } else { 0 }; @@ -633,7 +633,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { } let (item_id, count, link, quality, inst, repair_cost) = { let model = lua.app_data_ref::().expect("model app_data"); - let cost = model.repair_costs.bags.get(&(bag, slot)).copied(); + let cost = crate::script::merchant::repair_cost_at(lua, &model, bag, slot); match model.bag_slot(bag, slot).filter(|s| s.item_id != 0) { // `0x534620` takes the p6=1 leg iff the cooldown query `0x6e2ed0` reports // enable, start and duration all non-zero; that boolean is the inverse of the @@ -658,7 +658,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { // p6 = 0 iff no cooldown runs; mid-cooldown ITEM_COOLDOWN_TIME takes // the openable line's place. inst, - cost.unwrap_or(0), + cost, ) } None => return Ok(MultiValue::from_vec(vec![Value::Nil])), diff --git a/crates/benilla-ui/src/script/trade.rs b/crates/benilla-ui/src/script/trade.rs index 7596cbb29..da8f5233a 100644 --- a/crates/benilla-ui/src/script/trade.rs +++ b/crates/benilla-ui/src/script/trade.rs @@ -233,13 +233,13 @@ impl super::UiScript { } /// `SetTradePartner`'s open leg for a trade we started by dropping coins (`0x4bf5fe`- - /// `0x4bf61c`): the offer becomes offer plus the held coins through `0x5d4500` when the purse - /// covers it. The new offer, for the app's send, the coins still held for + /// `0x4bf61c`): the offer becomes offer plus the held coins through `0x5d4500` when `purse`, + /// the coinage the app reads at the handler, covers it. The new offer, for the app's send, the coins still held for /// [`Self::trade_open_money_clear`]; `None` with no coins held or a purse short of the total, /// which leaves the cursor as it is. - pub fn trade_open_money(&mut self) -> Option { + pub fn trade_open_money(&mut self, purse: u64) -> Option { super::tick::verb(&self.lua, |model| { - let total = cursor::money::trade_money_total(model)?; + let total = cursor::money::trade_money_total(model, purse)?; cursor::money::set_own_offer(model, total); Some(total) }) @@ -1129,7 +1129,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { copper, "Usage: SetTradeMoney(amount)", )? as u32; - if u64::from(n) > lua.app_data_ref::().expect("model app_data").money { + let money = { + let model = lua.app_data_ref::().expect("model app_data"); + crate::script::stat_read::purse(lua, &model) + }; + if u64::from(n) > money { return Ok(()); } submit(lua, TradeCall::Gold(n))?; diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md index a61ac3b1f..c971f9491 100644 --- a/docs/DEVIATIONS.md +++ b/docs/DEVIATIONS.md @@ -345,7 +345,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it #### ui_gossip -- 14 (a NULL, pushed as `nil`) and 15 up (a read past the table's end) give `"gossip"`, because the reference's behaviour there is a missing bounds check. ([`src/ui_gossip/mod.rs:222`](../crates/benilla-app/src/ui_gossip/mod.rs#L222)) +- 14 (a NULL, pushed as `nil`) and 15 up (a read past the table's end) give `"gossip"`, because the reference's behaviour there is a missing bounds check. ([`src/ui_gossip/mod.rs:238`](../crates/benilla-app/src/ui_gossip/mod.rs#L238)) #### ui_guild @@ -767,8 +767,8 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it - A listing that finds no icon stays empty, to be listed again on the next ask, where the reference's dedup then reads below its null list (`0x4f0134`-`0x4f0145`) and faults. ([`src/script/macros.rs:547`](../crates/benilla-ui/src/script/macros.rs#L547)) - Each line is trimmed, because the reference sends an indented `" /say hi"` as plain chat, never as the command meant. ([`src/script/macros/bind.rs:30`](../crates/benilla-ui/src/script/macros/bind.rs#L30)) - The `/cast` argument is trimmed, because the reference binds `/cast Fireball` (two spaces) to a name that never resolves. ([`src/script/macros/bind.rs:50`](../crates/benilla-ui/src/script/macros/bind.rs#L50)) -- The client's Lua has no such bound, as nothing sets a hook (`lua_sethook`, `0x6fba40`, has no caller and no pointer to it), so a load that never ends hangs it. ([`src/script/mod.rs:975`](../crates/benilla-ui/src/script/mod.rs#L975)) -- The reference sets the cells only when glue or the interface is built, `0x41ad10` from `0x46a828` (`0x46a7b0`) and `0x48fe2b` (`0x48fbf0`), and a resize reaches them only through the rebuild it asks for. ([`src/script/mod.rs:1037`](../crates/benilla-ui/src/script/mod.rs#L1037)) +- The client's Lua has no such bound, as nothing sets a hook (`lua_sethook`, `0x6fba40`, has no caller and no pointer to it), so a load that never ends hangs it. ([`src/script/mod.rs:976`](../crates/benilla-ui/src/script/mod.rs#L976)) +- The reference sets the cells only when glue or the interface is built, `0x41ad10` from `0x46a828` (`0x46a7b0`) and `0x48fe2b` (`0x48fbf0`), and a resize reaches them only through the rebuild it asks for. ([`src/script/mod.rs:1038`](../crates/benilla-ui/src/script/mod.rs#L1038)) - A path that does not resolve is stored, where the reference raises `Invalid model file: %s` (`0x878b44`), because the file loads asynchronously here and the call cannot know yet. ([`src/script/modelframe.rs:339`](../crates/benilla-ui/src/script/modelframe.rs#L339)) - The reference's free list is FIFO; either keeps every index stable, all an addon can observe. ([`src/script/nameplate.rs:455`](../crates/benilla-ui/src/script/nameplate.rs#L455)) - The reference adds this rim over the bar; we brighten the bar (`LIT_BOOST`), because the rim reads as hard edge lines in our linear-blending pipeline. ([`src/script/nameplate.rs:911`](../crates/benilla-ui/src/script/nameplate.rs#L911))