From b17be2710ffab0fc631af7bc8b93c189eead271e Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Sat, 26 Sep 2026 18:01:39 +0700 Subject: [PATCH] A repair the purse cannot cover is refused with the not-enough-money error, as in 1.12.1 (#443) - A repair the purse cannot cover is refused as in 1.12.1: the not-enough-money error and its voice line, no repair sound, nothing sent; Repair All tests the worn gear alone - Tidy three comments: the gift-wrap getter's reader, the repair-reset test's claim, the merchant module doc's width Fixes #412 Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/benilla-app/src/player/camera.rs | 2 +- crates/benilla-app/src/ui_items/drain.rs | 167 +++++++++++++-- crates/benilla-app/src/ui_merchant/mod.rs | 250 +++++++++++++++++++++- crates/benilla-formats/src/durability.rs | 12 ++ crates/benilla-ui/src/script/container.rs | 2 +- 5 files changed, 406 insertions(+), 27 deletions(-) diff --git a/crates/benilla-app/src/player/camera.rs b/crates/benilla-app/src/player/camera.rs index 2df4f9e9..1e54b39e 100644 --- a/crates/benilla-app/src/player/camera.rs +++ b/crates/benilla-app/src/player/camera.rs @@ -1918,7 +1918,7 @@ mod tests { ); assert!( press(false, true), - "a press that drops a held payload is consumed before the hook" + "a press while a payload is held skips the repair reset" ); } diff --git a/crates/benilla-app/src/ui_items/drain.rs b/crates/benilla-app/src/ui_items/drain.rs index 96f2d1aa..39a5d3ec 100644 --- a/crates/benilla-app/src/ui_items/drain.rs +++ b/crates/benilla-app/src/ui_items/drain.rs @@ -219,25 +219,24 @@ pub(super) fn drain_container_uses( mut ladder: crate::spell::CastLadder, mut ui_errors: ResMut, mut gate: crate::ui_bind_confirm::BindGate, + mut purse: crate::ui_merchant::RepairPurse, ) { let Some(mut script) = script else { return; }; - // Repair-mode clicks (the reference's `0x4f9c7b` route) repair the one item, sounding - // `ITEM_REPAIR` as it goes out (`0x4f9ce4`). The reference's affordability check before that - // (error 0x25, silent) is not built: our costs lack its reputation discount, so the server - // refuses instead. + let me = self_q.iter().next(); + // Repair-mode clicks (the reference's `0x4f9c7b` route) repair the one item: the purse test + // first (`0x4f9cc8`), then `ITEM_REPAIR` as it goes out (`0x4f9ce4`). for (bag, slot) in script.take_container_repairs() { - let Some(vendor) = merchant.vendor else { + let (Some(vendor), Some(me)) = (merchant.vendor, me) else { continue; }; let slot0 = u8::try_from(slot.saturating_sub(1)).unwrap_or(0); - let item_guid = self_q - .iter() - .next() - .and_then(|store| slot_guid(&store.0, bag, slot0, &ladder.objects)); - match item_guid { + match slot_guid(&me.0, bag, slot0, &ladder.objects) { Some(guid) => { + if purse.refuses(&mut script, &merchant, me, [guid]) { + continue; + } debug!("ui_items: repair lua bag {bag} slot {slot} (item {guid:#x})"); script.queue_sound_kit("ITEM_REPAIR"); let _ = ladder.commands.0.send(ClientCommand::RepairItem { @@ -250,17 +249,17 @@ pub(super) fn drain_container_uses( } // The paper doll's repair clicks (`0x4c7714`, `0x4c79c4`), the same arm on a worn item. for id in script.take_inventory_repairs() { - let Some(vendor) = merchant.vendor else { + let (Some(vendor), Some(me)) = (merchant.vendor, me) else { continue; }; - let item_guid = u8::try_from(id.wrapping_sub(1)).ok().and_then(|slot0| { - self_q - .iter() - .next() - .and_then(|store| slot_guid(&store.0, EQUIPMENT_BAG, slot0, &ladder.objects)) - }); + let item_guid = u8::try_from(id.wrapping_sub(1)) + .ok() + .and_then(|slot0| slot_guid(&me.0, EQUIPMENT_BAG, slot0, &ladder.objects)); match item_guid { Some(guid) if (1..=19).contains(&id) => { + if purse.refuses(&mut script, &merchant, me, [guid]) { + continue; + } debug!("ui_items: repair worn lua slot {id} (item {guid:#x})"); script.queue_sound_kit("ITEM_REPAIR"); let _ = ladder.commands.0.send(ClientCommand::RepairItem { @@ -756,6 +755,8 @@ mod tests { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -937,6 +938,136 @@ mod tests { ); } + /// What one repair click did: the packets, the VM's sounds, the `UI_ERROR_MESSAGE` lines and + /// the message rows queued for their sound. + struct RepairClick { + sent: Vec, + sounds: Vec, + shown: Vec, + sounded: Vec<&'static str>, + } + + /// A repair-mode click on [`crate::ui_merchant::purse_fixture`]'s sword with `money` in the + /// purse: in backpack slot 1 through `UseContainerItem`, or `worn` in the main hand (slot 15) + /// through `PickupInventoryItem`. + fn click_to_repair_the_sword(money: u32, worn: bool) -> RepairClick { + use crate::ui_merchant::purse_fixture::{player, record_errors, seat, SWORD_ENTRY}; + use benilla_protocol::field::FIELD_PLAYER_INV_SLOT_HEAD; + let (mut app, rx) = open_the_clam(); + while rx.try_recv().is_ok() {} // drain the clam's own send + seat(app.world_mut()); + let slot_field = if worn { + FIELD_PLAYER_INV_SLOT_HEAD + 2 * 15 + } else { + F_PACK_SLOT_1 + }; + let me = app + .world_mut() + .query_filtered::>() + .single(app.world()) + .unwrap(); + app.world_mut() + .entity_mut(me) + .insert(player(slot_field, money)); + { + let mut script = app.world_mut().non_send_resource_mut::(); + script.take_sounds(); + record_errors(&script); + script.set_merchant(Some(benilla_ui::script::MerchantState { + can_repair: true, + ..Default::default() + })); + let click = if worn { + let mut slots: benilla_ui::script::InventorySlots = Default::default(); + slots[16] = Some(benilla_ui::script::InvSlotView { + item_id: SWORD_ENTRY, + ..Default::default() + }); + script.set_inventory_slots(slots); + "ShowRepairCursor() PickupInventoryItem(16)" + } else { + let mut bag = benilla_ui::script::ContainerState { + num_slots: 16, + ..Default::default() + }; + bag.slots.insert( + 1, + benilla_ui::script::ContainerSlot { + item_id: SWORD_ENTRY, + count: 1, + ..Default::default() + }, + ); + script.set_container(0, Some(bag)); + "ShowRepairCursor() UseContainerItem(0, 1)" + }; + script.run(click).unwrap(); + } + app.world_mut() + .run_system_once(drain_container_uses) + .unwrap(); + let sounded = crate::ui_merchant::purse_fixture::sounded(app.world()); + let mut script = app.world_mut().non_send_resource_mut::(); + RepairClick { + sent: rx.try_iter().collect(), + sounds: script.take_sounds(), + shown: crate::ui_merchant::purse_fixture::shown(&script), + sounded, + } + } + + /// The repair arm's purse test, unsigned `cost > money` on the discounted cost: one copper + /// short shows `ERR_NOT_ENOUGH_MONEY` with its speech row, plays no `ITEM_REPAIR` and sends + /// nothing; exactly the cost, under the full price, sounds and sends. + fn the_purse_test_refuses_one_copper_short_and_pays_the_exact_cost(worn: bool) { + use crate::ui_merchant::purse_fixture::{sword_prices, NOT_ENOUGH_MONEY, SWORD, VENDOR}; + let (full, discounted) = sword_prices(); + + let short = click_to_repair_the_sword(discounted - 1, worn); + assert!( + short.sent.is_empty(), + "short of {discounted}: nothing goes out" + ); + assert!(short.sounds.is_empty(), "…no ITEM_REPAIR"); + assert_eq!(short.shown, [NOT_ENOUGH_MONEY], "…the red line"); + assert_eq!( + short.sounded, + ["ERR_NOT_ENOUGH_MONEY"], + "…and its row, which speaks line 0x28" + ); + + let exact = click_to_repair_the_sword(discounted, worn); + assert!( + matches!( + exact.sent.as_slice(), + [ClientCommand::RepairItem { + vendor: VENDOR, + item_guid: SWORD + }] + ), + "{discounted} in the purse pays the discounted {discounted}, short of the full {full}" + ); + assert_eq!( + exact.sounds, + vec![benilla_ui::script::SoundRequest::KitName( + "ITEM_REPAIR".into() + )] + ); + assert!(exact.shown.is_empty() && exact.sounded.is_empty()); + } + + /// `UseContainerItem`'s repair arm (`0x4fa22e`–`0x4fa236`, error `0x4fa23a`). + #[test] + fn a_bag_repair_the_purse_cannot_cover_is_refused_and_one_it_covers_is_sent() { + the_purse_test_refuses_one_copper_short_and_pays_the_exact_cost(false); + } + + /// `PickupInventoryItem`'s repair arm (`0x4c775f`–`0x4c7766`, error `0x4c776a`). + #[test] + fn a_worn_repair_the_purse_cannot_cover_is_refused_and_one_it_covers_is_sent() { + the_purse_test_refuses_one_copper_short_and_pays_the_exact_cost(true); + } + /// The unwrap arm's other side: wrapping paper arms the wrap cursor and sends nothing. #[test] fn a_wrapper_right_click_arms_the_cursor_and_ships_no_packet() { @@ -1578,6 +1709,8 @@ mod bind_confirm_tests { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() diff --git a/crates/benilla-app/src/ui_merchant/mod.rs b/crates/benilla-app/src/ui_merchant/mod.rs index de508c35..2b61a1eb 100644 --- a/crates/benilla-app/src/ui_merchant/mod.rs +++ b/crates/benilla-app/src/ui_merchant/mod.rs @@ -1,6 +1,7 @@ //! The merchant window's app side: [`MerchantOpen`] holds the `SMSG_LIST_INVENTORY` rows, //! [`feed_merchant`] pushes them with the buyback and repair rows, the purse and the refusals, -//! [`feed_repair_all_cost`] the repair-all total, and [`drain_merchant`] sends the Lua intents. A bag click sells through [`crate::ui_items`]. +//! [`feed_repair_all_cost`] the repair-all total, and [`drain_merchant`] sends the Lua intents. A +//! bag click sells through [`crate::ui_items`]. use benilla_protocol::messages::{buy_result, sell_result, VendorItem}; use benilla_world::interact::WorldRightPress; @@ -361,6 +362,58 @@ fn vendor_npc_flags( vendor_store(open, units).map_or(0, |store| store.0.unit_npc_flags()) } +/// What the purse test before a repair's send reads: the items' costs, the open vendor for its +/// price discount, and the message path a refusal takes. +#[derive(bevy::ecs::system::SystemParam)] +pub(crate) struct RepairPurse<'w, 's> { + objects: Objects<'w, 's>, + items: Res<'w, Items>, + commands: Res<'w, NetCommands>, + tables: Option>, + units: Query<'w, 's, (&'static Guid, &'static ObjectStore), Without>, + reactions: crate::target::ReactionInputs<'w>, + sink: crate::ui_action::MessageSink<'w>, +} + +impl RepairPurse<'_, '_> { + /// The purse test of the four item-click repair arms (`0x4f9cc8`–`0x4f9ccf`) and of + /// `RepairAllItems` (`0x4fc08e`): the `0x4faf30` costs of `guids`, summed in a u32, above + /// `PLAYER_FIELD_COINAGE` (unsigned, so an equal cost passes) raise `ERR_NOT_ENOUGH_MONEY` + /// (`0x496720(0x25)`, speech line 0x28) and answer `true`, and the caller sends nothing. + pub(crate) fn refuses( + &mut self, + script: &mut UiScript, + open: &MerchantOpen, + player: &ObjectStore, + guids: impl IntoIterator, + ) -> bool { + // `0x4faf30` keeps the full price unless it resolves the vendor's unit. + let discount = vendor_store(open, &self.units).map_or(0.0, |vendor| { + crate::target::vendor_price_discount( + self.reactions.factions.as_deref(), + &self.reactions.reputations, + vendor, + player, + ) + }); + let cost = guids.into_iter().filter(|&g| g != 0).fold(0u32, |sum, g| { + let item = self.tables.as_deref().map_or(0, |t| { + item_repair_cost(g, &self.objects, &self.items, t, &self.commands, discount) + }); + sum.wrapping_add(item) + }); + let money = player.0.player_money().unwrap_or(0); + if cost <= money { + return false; + } + debug!("ui_merchant: repair of {cost} copper refused with {money} in the purse"); + if let Some(line) = crate::ui_action::keyed_line(script, "ERR_NOT_ENOUGH_MONEY") { + crate::ui_action::show_messages(script, &mut self.sink, "ui_merchant", [line]); + } + true + } +} + /// Push `GetRepairAllCost`'s total, which the reference sweeps at the call (`0x4fbd60`): every /// frame a repairing vendor is open, 0 otherwise. fn feed_repair_all_cost( @@ -541,13 +594,14 @@ impl NpcSession for MerchantOpen { /// Drain the Lua intents: a buy sends `CMSG_BUY_ITEM` by item entry, a buyback /// `CMSG_BUYBACK_ITEM` with the absolute slot (69-80), a repair-all `CMSG_REPAIR_ITEM` with guid -/// 0, and a close clears locally, as there is no close packet. +/// 0 when the purse covers the worn gear, and a close clears locally, as there is no close packet. fn drain_merchant( script: Option>, mut open: ResMut, commands: Res, self_q: Query<(&ObjectStore, &Guid), With>, objects: Objects, + mut purse: RepairPurse, ) { let Some(mut script) = script else { return; @@ -574,12 +628,17 @@ fn drain_merchant( } } if script.take_repair_all() { - if let Some(vendor) = open.vendor { - debug!("ui_merchant: repair all"); - let _ = commands.0.send(ClientCommand::RepairItem { - vendor, - item_guid: 0, - }); + if let (Some(vendor), Some(me)) = (open.vendor, self_store) { + // `RepairAllItems` (`0x4fbfe0`) prices only the equipped slots 0-18 for its purse test + // (`0x4fc026`–`0x4fc063`), not the bags `GetRepairAllCost` also sweeps. + let worn = (0..19u8).filter_map(|i| me.0.player_inv_slot(i)); + if !purse.refuses(&mut script, &open, me, worn) { + debug!("ui_merchant: repair all"); + let _ = commands.0.send(ClientCommand::RepairItem { + vendor, + item_guid: 0, + }); + } } } for (index, quantity) in script.take_merchant_buys() { @@ -670,6 +729,122 @@ pub(crate) fn end_repair_mode_on_right_press( } } +/// A level-20 sword 40 points down at a repair vendor flagged `UNIT_FLAG_PVP`, for the purse tests +/// here and in [`crate::ui_items`]. +#[cfg(test)] +pub(crate) mod purse_fixture { + use super::*; + use benilla_protocol::field::{ + FIELD_PLAYER_FIELD_COINAGE, FIELD_UNIT_FLAGS, FIELD_UNIT_NPC_FLAGS, + }; + use benilla_protocol::ObjectFields; + + pub(crate) const VENDOR: u64 = 0xF130_0000_0000_0043; + pub(crate) const SWORD: u64 = 0x4000_0000_0000_0707; + pub(crate) const SWORD_ENTRY: u32 = 2488; + /// Absolute descriptor indices. + const OBJECT_ENTRY: u16 = 3; + const ITEM_DURABILITY: u16 = 46; + const ITEM_MAXDURABILITY: u16 = 47; + const PLAYER_BYTES_3: u16 = 195; + /// The string `ERR_NOT_ENOUGH_MONEY` holds in 1.12's `GlobalStrings.lua`. + pub(crate) const NOT_ENOUGH_MONEY: &str = "You don't have enough money."; + + /// One level-20 row at 1 copper a point for a sword (weapon subclass 7), and quality 1's + /// multiplier (row `2·1 + 1`) at 1. + fn tables() -> benilla_formats::DurabilityTables { + let mut row = vec![0u32; 29]; + row[7] = 1; + benilla_formats::DurabilityTables::from_rows([(20, row)], [(3, 1.0)]) + } + + /// The sword's repair in full, and after the two honor steps (`0x612bcf`, `0x612bd7`) an honor + /// rank byte of 8 earns at this vendor; a test at the discounted price discriminates the + /// discount only while the two differ. + pub(crate) fn sword_prices() -> (u32, u32) { + let t = tables(); + let steps = f64::from(0.05f32) + f64::from(0.05f32); + let full = t.repair_cost(40, 20, 1, 2, 7, 0.0); + let discounted = t.repair_cost(40, 20, 1, 2, 7, steps); + assert!(discounted < full, "{discounted} of {full}"); + (full, discounted) + } + + /// The merchant open on the vendor, whose unit is streamed, the tables, and the sword's + /// template and object. + pub(crate) fn seat(world: &mut World) { + world.insert_resource(RepairTables(tables())); + world + .resource_mut::() + .open(VENDOR, Vec::new()); + world.spawn(( + Guid(VENDOR), + ObjectStore(ObjectFields::from_pairs(&[ + (FIELD_UNIT_NPC_FLAGS, NPC_FLAG_REPAIR), + (FIELD_UNIT_FLAGS, 0x1000), + ])), + )); + let mut sword = crate::items::test_template("Sword"); + (sword.class, sword.subclass, sword.quality, sword.item_level) = (2, 7, 1, 20); + world + .resource_mut::() + .insert_template(SWORD_ENTRY, Some(sword)); + crate::items::test_spawn_item( + world, + SWORD, + ObjectFields::from_pairs(&[ + (OBJECT_ENTRY, SWORD_ENTRY), + (ITEM_DURABILITY, 35), + (ITEM_MAXDURABILITY, 75), + ]), + false, + ); + } + + /// The player's fields: the sword in the guid pair at `slot_field`, `money` copper, and the + /// honor rank byte at 8. + pub(crate) fn player(slot_field: u16, money: u32) -> ObjectStore { + ObjectStore(ObjectFields::from_pairs(&[ + (slot_field, SWORD as u32), + (slot_field + 1, (SWORD >> 32) as u32), + (FIELD_PLAYER_FIELD_COINAGE, money), + (PLAYER_BYTES_3, 8 << 24), + ])) + } + + /// The message's string, and a recorder of the `UI_ERROR_MESSAGE` lines [`shown`] reads. + pub(crate) fn record_errors(script: &UiScript) { + script + .run(&format!( + r#" + ERR_NOT_ENOUGH_MONEY = "{NOT_ENOUGH_MONEY}" + BENILLA_TEST_LINES = {{}} + local f = CreateFrame("Frame") + f:RegisterEvent("UI_ERROR_MESSAGE") + f:SetScript("OnEvent", function() table.insert(BENILLA_TEST_LINES, arg1) end) + "# + )) + .unwrap(); + } + + /// The `UI_ERROR_MESSAGE` lines since [`record_errors`]. + pub(crate) fn shown(script: &UiScript) -> Vec { + script + .eval::>("return BENILLA_TEST_LINES") + .unwrap() + } + + /// The message rows queued for their sound. + pub(crate) fn sounded(world: &World) -> Vec<&'static str> { + world + .resource::() + .queued() + .iter() + .map(|r| r.key) + .collect() + } +} + #[cfg(test)] mod tests { use super::*; @@ -781,6 +956,65 @@ mod tests { assert_eq!(got, i64::from(want), "the undiscounted total is {full}"); } + /// `RepairAllItems` prices only the equipped slots for its purse test (`0x4fc026`–`0x4fc063`, + /// `0x4fc08e`): the sword worn and over the purse is refused with `ERR_NOT_ENOUGH_MONEY` and + /// no send; the same sword in the backpack, which `GetRepairAllCost` would count, leaves a worn + /// total of 0 and the repair-all goes out. + #[test] + fn repair_all_tests_the_purse_against_the_worn_gear_alone() { + use benilla_protocol::field::FIELD_PLAYER_INV_SLOT_HEAD; + use bevy::ecs::system::RunSystemOnce; + use purse_fixture::*; + + let (_, discounted) = sword_prices(); + let repair_all = |slot_field: u16| { + let (tx, rx) = crossbeam_channel::unbounded(); + let mut app = App::new(); + app.init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .insert_resource(NetCommands(tx)); + seat(app.world_mut()); + app.world_mut() + .spawn((SelfPlayer, Guid(0x5e1f), player(slot_field, discounted - 1))); + let script = UiScript::new().unwrap(); + record_errors(&script); + script.run("RepairAllItems()").unwrap(); + app.insert_non_send_resource(script); + app.world_mut().run_system_once(drain_merchant).unwrap(); + let sent: Vec = rx.try_iter().collect(); + let lines = shown(app.world().non_send_resource::()); + (sent, lines, sounded(app.world())) + }; + + // The main hand (slot 15). + let (sent, lines, rows) = repair_all(FIELD_PLAYER_INV_SLOT_HEAD + 2 * 15); + assert!(sent.is_empty(), "worn, one copper short: nothing goes out"); + assert_eq!(lines, [NOT_ENOUGH_MONEY]); + assert_eq!( + rows, + ["ERR_NOT_ENOUGH_MONEY"], + "the row that speaks line 0x28" + ); + + // Backpack slot 1, inventory slot 23. + let (sent, lines, rows) = repair_all(FIELD_PLAYER_INV_SLOT_HEAD + 2 * 23); + assert!( + matches!( + sent.as_slice(), + [ClientCommand::RepairItem { + vendor: VENDOR, + item_guid: 0 + }] + ), + "the bag's {discounted} is not in the purse test" + ); + assert!(lines.is_empty() && rows.is_empty()); + } + fn row(entry: u32, slot: u32, count: u32) -> VendorItem { VendorItem { slot, diff --git a/crates/benilla-formats/src/durability.rs b/crates/benilla-formats/src/durability.rs index 939dc8ed..a51aa3ef 100644 --- a/crates/benilla-formats/src/durability.rs +++ b/crates/benilla-formats/src/durability.rs @@ -25,6 +25,18 @@ pub struct DurabilityTables { } impl DurabilityTables { + /// Tables from explicit rows, for tests and tools: item level → the 29 cost columns, and + /// quality row id → multiplier. + pub fn from_rows( + costs: impl IntoIterator)>, + qualities: impl IntoIterator, + ) -> Self { + Self { + costs: costs.into_iter().collect(), + qualities: qualities.into_iter().collect(), + } + } + /// The per-point cost for a class and subclass at `item_level`, `None` when unrepairable. fn column(&self, item_level: u32, class: u32, subclass: u32) -> Option { let row = self.costs.get(&item_level)?; diff --git a/crates/benilla-ui/src/script/container.rs b/crates/benilla-ui/src/script/container.rs index 4985fc02..31e13582 100644 --- a/crates/benilla-ui/src/script/container.rs +++ b/crates/benilla-ui/src/script/container.rs @@ -199,7 +199,7 @@ impl super::UiScript { cursor::queue_lock_changed(&mut model, bag, slot); } - /// The armed gift wrap, if any; the app reads it to tell whether a right-click cancels one. + /// The armed gift wrap, if any; the app reads it to hold the cursor's base at Cast. pub fn gift_wrap_armed(&self) -> Option { self.model_ref().pending_wrap }