Bank, gossip, purse and repair costs read live; events at packets (#2542)
The bank, gossip, purse and repair-cost getters read frame-final copies, and their events fired from feeds after the drain. Now they read live as of each packet: - the purse, as GetMoney does (`0x518160`) - the repair costs per slot (`0x4faf30`) - GetNumBankSlots and GetBankSlotCost, from the player's own field with no banker test (`0x4f85b0`, `0x4f8630`) - gossip's slots and text (`0x4e28d0`, `0x4e28c0`) A bank lane carries OPENED (`0x4f8522`) and the refusal lines. PLAYERBANKBAGSLOTS_CHANGED fires at its field (`0x4f8460`). A gossip lane pushes SHOW at the draw (`0x4e22b5`), and asks the NPC's name there. A gossip draw with no player or no NPC held writes nothing and fires nothing (`0x4e20bb`), so a late greeting no longer reopens a closed window. Four feeds are gone. Tests in `bank_live_tests.rs` and `gossip_live_tests.rs` fail on main. READS_FRAME_FINAL 56 → 52. Part of #2217 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<SessionEvent>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// One frame of the client's, with `packets` in the inbound channel, each one packet.
|
||||
fn frame(&mut self, packets: Vec<Vec<SessionEvent>>) {
|
||||
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<T: benilla_ui::mlua::FromLuaMulti>(&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::<T>(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<String> {
|
||||
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<SessionEvent>) {
|
||||
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::<crate::net::SelfGuid>().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::<crate::ui_bank::BankLane>()
|
||||
.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::<crate::ui_bank::BankOpen>()
|
||||
.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::<Vec<i64>>(false, "return Slots"), [3, 4]);
|
||||
}
|
||||
@@ -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<SessionEvent>,
|
||||
sent: Receiver<ClientCommand>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// One frame of the client's, with `packets` in the inbound channel, each one packet.
|
||||
fn frame(&mut self, packets: Vec<Vec<SessionEvent>>) {
|
||||
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<T: benilla_ui::mlua::FromLuaMulti>(&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::<T>(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<String> {
|
||||
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<u32> {
|
||||
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<SessionEvent>) {
|
||||
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::<crate::net::SelfGuid>().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::<String>(true, STORE),
|
||||
"Hello,Browse,2,A Threat Within"
|
||||
);
|
||||
assert_eq!(
|
||||
c.eval::<Option<String>>(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::<String>(true, STORE), "Hello,Fly,2,A Threat Within");
|
||||
crate::ui_gossip::close_core(c.app.world_mut());
|
||||
assert_eq!(
|
||||
c.eval::<String>(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::<crate::ui_gossip::GossipLane>()
|
||||
.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::<crate::ui_gossip::GossipLane>()
|
||||
.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::<String>(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::<String>(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::<String>(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::<crate::names::NameCache>()
|
||||
.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::<crate::names::NameCache>()
|
||||
.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::<String>(true, "return GetGossipText()"),
|
||||
"Well met, Ann."
|
||||
);
|
||||
named(&mut c, "Bea");
|
||||
assert_eq!(
|
||||
c.eval::<String>(true, "return GetGossipText()"),
|
||||
"Well met, Ann.",
|
||||
"held until the next message"
|
||||
);
|
||||
c.handle(vec![menu(7, "Browse")]);
|
||||
assert_eq!(
|
||||
c.eval::<String>(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::<UiScript>();
|
||||
(
|
||||
s.eval::<bool>("return GossipFrame:IsVisible()").unwrap(),
|
||||
s.eval::<String>("return GossipGreetingText:GetText() or ''")
|
||||
.unwrap(),
|
||||
s.eval::<String>("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"
|
||||
);
|
||||
}
|
||||
@@ -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::<crate::ui_items::feed::BagView>(),
|
||||
// The views lane: what each packet changed in the stores.
|
||||
@@ -705,12 +691,16 @@ fn lane_types() -> [TypeId; 31] {
|
||||
TypeId::of::<crate::ui_petition::PetitionLane>(),
|
||||
// The trainer lane: the list's and the state passes' pushes and signals.
|
||||
TypeId::of::<crate::ui_trainer::TrainerLane>(),
|
||||
// The bank lane: the open's signal and the refusals' lines.
|
||||
TypeId::of::<crate::ui_bank::BankLane>(),
|
||||
// The gossip lane: the greeting's draw.
|
||||
TypeId::of::<crate::ui_gossip::GossipLane>(),
|
||||
]
|
||||
}
|
||||
|
||||
/// [`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::<crate::ui_items::feed::BagView>()),
|
||||
("unit views", TypeId::of::<crate::ui_unit::views::ViewLog>()),
|
||||
@@ -764,6 +754,8 @@ fn lane_queue_owners() -> [(&'static str, TypeId); 31] {
|
||||
("stable", TypeId::of::<crate::ui_stable::StableLane>()),
|
||||
("charter", TypeId::of::<crate::ui_petition::PetitionLane>()),
|
||||
("trainer", TypeId::of::<crate::ui_trainer::TrainerLane>()),
|
||||
("bank", TypeId::of::<crate::ui_bank::BankLane>()),
|
||||
("gossip", TypeId::of::<crate::ui_gossip::GossipLane>()),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -431,6 +431,11 @@ fn client(window: Window) -> Client {
|
||||
world
|
||||
.resource_mut::<crate::ui_bank::BankOpen>()
|
||||
.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
|
||||
|
||||
@@ -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::<i64>(true, pickup), 400);
|
||||
c.handle(vec![values(ME, &[(FIELD_PLAYER_FIELD_COINAGE, 300)])]);
|
||||
assert_eq!(c.eval::<i64>(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::<crate::items::Items>()
|
||||
.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::<UiScript>();
|
||||
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::<Vec<i64>>(false, "return Costs"),
|
||||
[i64::from(discounted), i64::from(full)]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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<Stamp> {
|
||||
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::<BankLane>()
|
||||
.add_lane(
|
||||
"bank",
|
||||
|world| world.get_resource::<BankLane>()?.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<BankLane>) {
|
||||
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<Until>,
|
||||
mut vm: crate::script_calls::HostedVm,
|
||||
mut lane: ResMut<BankLane>,
|
||||
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::<BankLane>().and_then(BankLane::head);
|
||||
crate::lanes::behind(world, head, core, |world, session, key| {
|
||||
world
|
||||
.resource_mut::<BankLane>()
|
||||
.push(BankEvent::Parked { session, key });
|
||||
});
|
||||
}
|
||||
@@ -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<Res<'w, BankPrices>>,
|
||||
}
|
||||
|
||||
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()))
|
||||
}
|
||||
}
|
||||
@@ -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::<BankOpen>().banker {
|
||||
crate::ui_session::release(world, banker);
|
||||
}
|
||||
if world.resource::<BankOpen>().is_open() {
|
||||
world.resource_mut::<BankOpen>().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::<BankOpen>().banker {
|
||||
crate::ui_session::release(world, banker);
|
||||
}
|
||||
if world.resource::<BankOpen>().is_open() {
|
||||
world.resource_mut::<BankOpen>().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::<BankOpen>().open(banker);
|
||||
crate::ui_session::with_script(world, |script| {
|
||||
script.set_bank(Some(BankState::default()));
|
||||
});
|
||||
if let Some(mut lane) = world.get_resource_mut::<BankLane>() {
|
||||
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<u32>);
|
||||
|
||||
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::<BankOpen>()
|
||||
.init_resource::<BankErrors>()
|
||||
// No leg of the sweep closes the bank: its interaction close (`0x490b5f`) does.
|
||||
.add_latched_window::<BankOpen>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(feed_bank
|
||||
.in_set(UiFeed)
|
||||
.in_set(crate::ui_session::NpcWindowFeed),),
|
||||
);
|
||||
.add_latched_window::<BankOpen>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Res<'static, BankOpen>>,
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
prices: Option<Res<BankPrices>>,
|
||||
mut errors: ResMut<BankErrors>,
|
||||
mut last: crate::ui_script::WindowMemo<Option<BankState>>,
|
||||
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.
|
||||
|
||||
@@ -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<SessionEvent>, mut commands: Commands) {
|
||||
}
|
||||
}
|
||||
|
||||
fn on_buy_slot_result(In(ev): In<SessionEvent>, mut errors: ResMut<BankErrors>) {
|
||||
/// `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<SessionEvent>, mut lane: ResMut<BankLane>) {
|
||||
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<SessionEvent>, mut errors: ResMut<BankErrors>)
|
||||
fn on_session_end(In(_): In<SessionEvent>, mut bank: ResMut<BankOpen>) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<Stamp> {
|
||||
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::<GossipLane>()
|
||||
.add_lane(
|
||||
"gossip",
|
||||
|world| world.get_resource::<GossipLane>()?.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<GossipLane>) {
|
||||
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<Until>,
|
||||
mut vm: crate::script_calls::HostedVm,
|
||||
mut lane: ResMut<GossipLane>,
|
||||
) {
|
||||
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::<GossipLane>()
|
||||
.and_then(GossipLane::head);
|
||||
crate::lanes::behind(world, head, core, |world, session, key| {
|
||||
world
|
||||
.resource_mut::<GossipLane>()
|
||||
.push(GossipEvent::Parked { session, key });
|
||||
});
|
||||
}
|
||||
@@ -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<Res<'w, GossipState>>,
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -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<u64>,
|
||||
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<String>,
|
||||
/// 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<GossipOption>,
|
||||
/// 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<String> {
|
||||
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::<GossipState>()
|
||||
.add_latched_window::<GossipState>()
|
||||
// `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::<GossipState>().npc else {
|
||||
return;
|
||||
};
|
||||
crate::ui_session::release(world, npc);
|
||||
world.resource_mut::<GossipState>().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::<GossipState>().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<GossipMenu> {
|
||||
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<GossipMenu> {
|
||||
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<Res<'static, GossipState>>,
|
||||
self_q: Query<(&ObjectStore, &Guid), With<SelfPlayer>>,
|
||||
names: Res<NameCache>,
|
||||
commands: Res<NetCommands>,
|
||||
objects: crate::net::Objects,
|
||||
unit_names: crate::ui_unit::NameTables,
|
||||
states: Res<crate::world_state::WorldStates>,
|
||||
mut shown_message: crate::ui_script::WindowMemo<u64>,
|
||||
) {
|
||||
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`/`$<n>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::<GossipState>()
|
||||
.init_resource::<NameCache>()
|
||||
.init_resource::<crate::net::GuidIndex>()
|
||||
.init_resource::<crate::world_state::WorldStates>()
|
||||
.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::<GossipState>();
|
||||
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::<NameCache>()
|
||||
.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<ClientCommand>) {
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
let mut app = App::new();
|
||||
app.insert_resource(NetCommands(tx))
|
||||
.init_resource::<GossipState>()
|
||||
.init_resource::<NameCache>()
|
||||
.init_resource::<crate::net::GuidIndex>()
|
||||
.init_resource::<crate::world_state::WorldStates>()
|
||||
.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::<GossipState>()
|
||||
}
|
||||
|
||||
/// One frame, then the events the VM saw and `GetGossipText()` (`""` for nil).
|
||||
fn tick(app: &mut App) -> (Vec<String>, String) {
|
||||
app.update();
|
||||
let s = app.world_mut().non_send_resource_mut::<UiScript>();
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
let seen = s.eval::<Vec<String>>("return SEEN").unwrap();
|
||||
s.run("SEEN = {}").unwrap();
|
||||
let text = s.eval::<String>("return GetGossipText() or ''").unwrap();
|
||||
(seen, text)
|
||||
}
|
||||
|
||||
fn record(line: &str) -> Vec<NpcTextBlock> {
|
||||
vec![NpcTextBlock {
|
||||
probability: 0.0,
|
||||
male: line.into(),
|
||||
female: String::new(),
|
||||
}]
|
||||
}
|
||||
|
||||
fn option(label: &str) -> Vec<GossipOption> {
|
||||
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<benilla_ui::script::CallTail> {
|
||||
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<UiScript>, 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<ClientCommand>) -> 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::<NameCache>()
|
||||
.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<SoundRequest>) {
|
||||
let mut s = app.world_mut().non_send_resource_mut::<UiScript>();
|
||||
(
|
||||
s.eval::<bool>("return GossipFrame:IsVisible()").unwrap(),
|
||||
s.eval::<String>("return GossipGreetingText:GetText() or ''")
|
||||
.unwrap(),
|
||||
s.eval::<String>("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::<NameCache>()
|
||||
.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"),
|
||||
|
||||
@@ -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<NpcTextBlock>) {
|
||||
world
|
||||
.resource_mut::<GossipState>()
|
||||
.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<SelfPlayer>, With<ObjectStore>)>()
|
||||
.iter(world)
|
||||
.next()
|
||||
.is_some();
|
||||
let npc = world.resource::<GossipState>().npc;
|
||||
let held = |guid: u64| {
|
||||
world
|
||||
.get_resource::<GuidIndex>()
|
||||
.and_then(|index| index.0.get(&guid).copied())
|
||||
.is_some_and(|e| world.get::<ObjectStore>(e).is_some())
|
||||
};
|
||||
let Some(npc) = npc.filter(|&g| player && held(g)) else {
|
||||
let mut state = world.resource_mut::<GossipState>();
|
||||
state.take_drawn();
|
||||
state.take_missing_text();
|
||||
return;
|
||||
};
|
||||
let Some(raw) = world.resource_mut::<GossipState>().take_drawn() else {
|
||||
return;
|
||||
};
|
||||
let text = world
|
||||
.run_system_cached_with(expand_greeting, raw.clone())
|
||||
.unwrap_or(raw);
|
||||
world.resource_mut::<GossipState>().text = text;
|
||||
if let Some(mut lane) = world.get_resource_mut::<GossipLane>() {
|
||||
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<u64>,
|
||||
names: Res<NameCache>,
|
||||
commands: Res<NetCommands>,
|
||||
index: Res<GuidIndex>,
|
||||
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`/`$<n>w` macros for the active player (`0x506f70`).
|
||||
fn expand_greeting(
|
||||
In(raw): In<String>,
|
||||
self_q: Query<(&ObjectStore, &Guid), With<SelfPlayer>>,
|
||||
names: Res<NameCache>,
|
||||
unit_names: crate::ui_unit::NameTables,
|
||||
states: Res<crate::world_state::WorldStates>,
|
||||
) -> 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::<GossipState>().take_missing_text() {
|
||||
|
||||
@@ -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::<GossipState>();
|
||||
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::<String>(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<GossipOption>, drawn: bool) {
|
||||
let world = c.app.world_mut();
|
||||
{
|
||||
let mut state = world.resource_mut::<GossipState>();
|
||||
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::<GossipState>().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!(
|
||||
|
||||
@@ -120,7 +120,7 @@ pub(crate) fn slot_guid(
|
||||
store: &ObjectFields,
|
||||
bag: i64,
|
||||
slot0: u8,
|
||||
objects: &Objects,
|
||||
objects: &(impl ItemObjects + ?Sized),
|
||||
) -> Option<u64> {
|
||||
match bag {
|
||||
0 => store.player_pack_slot(slot0).filter(|g| *g != 0),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<u32> {
|
||||
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<u32> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<MerchantOpen>()
|
||||
.add_latched_window::<MerchantOpen>()
|
||||
// `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<RepairCosts>,
|
||||
}
|
||||
|
||||
/// 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<Items>,
|
||||
tables: Option<Res<RepairTables>>,
|
||||
mut memo: Local<crate::ui_script::VmMemo<RepairFeedMemo>>,
|
||||
) {
|
||||
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<SelfPlayer>>,
|
||||
mut last_money: Local<crate::ui_script::VmMemo<Option<u64>>>,
|
||||
) {
|
||||
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<T: benilla_ui::mlua::FromLuaMulti>(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<benilla_ui::script::CallTail> {
|
||||
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::<UiScript>().expect("a VM");
|
||||
let mut state = bevy::ecs::system::SystemState::<live::MerchantLive>::new(world);
|
||||
let mut family = state.get_mut(world);
|
||||
let r = script.hosted(&mut Host(&mut family), |s| {
|
||||
s.eval::<T>(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::<Items>()
|
||||
.init_resource::<crate::net::GuidIndex>()
|
||||
.init_resource::<MerchantOpen>();
|
||||
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<Items>, tables: Res<RepairTables>| {
|
||||
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::<UiScript>();
|
||||
script.eval::<i64>(read).unwrap()
|
||||
};
|
||||
let read_cost = |app: &mut App| live_eval::<i64>(app, read);
|
||||
let at_vendor = read_cost(&mut app);
|
||||
app.world_mut().resource_mut::<MerchantOpen>().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::<UiScript>();
|
||||
script
|
||||
.eval::<i64>(r#"local _, _, c = TT:SetInventoryItem("player", 16) return c"#)
|
||||
.unwrap()
|
||||
live_eval::<i64>(
|
||||
app,
|
||||
r#"local _, _, c = TT:SetInventoryItem("player", 16) return c"#,
|
||||
)
|
||||
};
|
||||
assert!(cost(&mut app) > 0, "the damaged sword costs");
|
||||
let sword = app.world().resource::<crate::net::GuidIndex>().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::<RepairTables>().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::<ObjectStore>(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::<UiScript>()
|
||||
.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()
|
||||
|
||||
@@ -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::<crate::ui_bank::BankOpen>()
|
||||
.open(BANKER);
|
||||
world.insert_resource(crate::cinematic::Cinematic::playing_for_test());
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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::<crate::target::Selection>().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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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::<Model>().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<BankState>) {
|
||||
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::<Model>().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::<Model>().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`).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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::<Model>().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<u32> {
|
||||
/// 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<u32> {
|
||||
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<bool> {
|
||||
let (held, total) = {
|
||||
let model = lua.app_data_ref::<Model>().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);
|
||||
|
||||
@@ -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<GossipQuestRow>,
|
||||
@@ -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<T>(lua: &Lua, f: impl FnOnce(&GossipMenu) -> T) -> Option<T> {
|
||||
match ask(lua, &GossipQuery) {
|
||||
Some(menu) => Some(f(&menu)),
|
||||
None => {
|
||||
let model = lua.app_data_ref::<Model>().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<GossipMenu>) {
|
||||
self.model_mut().gossip = menu;
|
||||
}
|
||||
|
||||
/// The menu the VM holds, as last pushed.
|
||||
pub fn gossip(&self) -> Option<GossipMenu> {
|
||||
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::<Model>().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::<Model>().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<Value> = 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<MultiValue> {
|
||||
let rows: Vec<(String, u32)> = {
|
||||
let model = lua.app_data_ref::<Model>().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)?));
|
||||
|
||||
@@ -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::<Model>().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::<Model>().expect("model app_data").money;
|
||||
let money = {
|
||||
let model = lua.app_data_ref::<Model>().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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -665,8 +665,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
lua.create_function(|lua, ()| {
|
||||
let allowed = {
|
||||
let model = lua.app_data_ref::<Model>().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
|
||||
|
||||
@@ -348,3 +348,12 @@ pub(crate) fn read_self<T>(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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
|
||||
@@ -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::<Model>().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])),
|
||||
|
||||
@@ -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<u32> {
|
||||
pub fn trade_open_money(&mut self, purse: u64) -> Option<u32> {
|
||||
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::<Model>().expect("model app_data").money {
|
||||
let money = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
crate::script::stat_read::purse(lua, &model)
|
||||
};
|
||||
if u64::from(n) > money {
|
||||
return Ok(());
|
||||
}
|
||||
submit(lua, TradeCall::Gold(n))?;
|
||||
|
||||
+3
-3
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user