diff --git a/crates/benilla-app/src/ui_merchant/mod.rs b/crates/benilla-app/src/ui_merchant/mod.rs index 2b61a1eb..fa8207fe 100644 --- a/crates/benilla-app/src/ui_merchant/mod.rs +++ b/crates/benilla-app/src/ui_merchant/mod.rs @@ -1,20 +1,22 @@ //! 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 +//! [`feed_repair_costs`] the repair costs, 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; use bevy::prelude::*; -use benilla_ui::script::{ItemStatsHead, MerchantItem, MerchantState, ScriptValue, UiScript}; +use benilla_ui::script::{ + ItemStatsHead, MerchantItem, MerchantState, RepairCosts, ScriptValue, UiScript, +}; use crate::entities::ItemDisplays; use crate::items::Items; use crate::names::NameCache; use crate::net::{ClientCommand, Guid, NetCommands, ObjectStore, Objects, SelfPlayer}; use crate::ui_items::{item_link, slot_guid, wire_pos}; -use crate::ui_script::{UiFeed, UiInput}; +use crate::ui_script::{gate, UiFeed, UiInput}; use crate::ui_session::{close_npc_session_out_of_range, npc_switched, NpcSession}; use benilla_protocol::messages::BAG_PLAYER_INVENTORY; @@ -115,7 +117,7 @@ impl Plugin for UiMerchantPlugin { feed_merchant.after(crate::ui_unit::UnitFeed).in_set(UiFeed), // Before `feed_char`: the `UNIT_INVENTORY_CHANGED` a repair fires refreshes the // Repair All button from this total (`PaperDollFrame.lua:717-724`). - feed_repair_all_cost + feed_repair_costs .in_set(UiFeed) .before(crate::ui_char::feed_char), drain_merchant.after(UiInput), @@ -300,47 +302,78 @@ fn item_repair_cost( .repair_cost(points, level, quality, class, subclass, discount) } -/// The repair-all total over the reference's three sweeps (`0x4fbd60`): equipped slots 0-18, the -/// backpack and the four bags' contents; never the bank, keyring or buyback. It sums the -/// discounted, rounded per-item costs (`0x4fbe0b`); the total itself is never discounted. -fn repair_all_cost( +/// 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, commands: &NetCommands, discount: f64, -) -> u32 { - let mut total: u64 = 0; - let mut add = |guid: u64, items: &Items| { - if guid != 0 { - total += u64::from(item_repair_cost( - guid, objects, items, tables, commands, discount, - )); - } +) -> RepairCosts { + let cost = |guid: u64| { + (guid != 0) + .then(|| item_repair_cost(guid, objects, items, tables, commands, discount)) + .filter(|&c| c > 0) }; + let mut costs = RepairCosts::default(); for i in 0..19u8 { - add(store.player_inv_slot(i).unwrap_or(0), items); + 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 { - add(store.player_pack_slot(i).unwrap_or(0), items); + if let Some(c) = cost(store.player_pack_slot(i).unwrap_or(0)) { + costs.bags.insert((0, u32::from(i) + 1), c); + } } - for bag in 19..23u8 { - let bag_guid = store.player_inv_slot(bag).unwrap_or(0); + for i in 0..crate::ui_items::BANK_SLOTS { + if let Some(c) = cost(store.player_bank_slot(i).unwrap_or(0)) { + costs + .bags + .insert((crate::ui_items::BANK_CONTAINER, u32::from(i) + 1), c); + } + } + // 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 slots: Vec = objects - .object(bag_guid) - .map(|b| { - let n = b.container_num_slots().unwrap_or(0).min(36) as u8; - (0..n).filter_map(|i| b.container_slot(i)).collect() - }) - .unwrap_or_default(); - for guid in slots { - add(guid, items); + 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 { + if let Some(c) = b.container_slot(j).and_then(cost) { + costs.bags.insert((bag, u32::from(j) + 1), c); + } } } + costs +} + +/// `GetRepairAllCost`'s sum of the per-item costs (`0x4fbd60`): equipped and bags, never the bank. +fn repair_all_cost(costs: &RepairCosts) -> u32 { + let carried = costs + .bags + .iter() + .filter(|((bag, _), _)| (0..=4).contains(bag)) + .map(|(_, c)| c); + let total: u64 = costs + .equipped + .values() + .chain(carried) + .map(|&c| u64::from(c)) + .sum(); total.min(u64::from(u32::MAX)) as u32 } @@ -414,42 +447,89 @@ impl RepairPurse<'_, '_> { } } -/// 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( +/// What [`feed_repair_costs`] watches and last pushed. +#[derive(Default)] +struct RepairFeedMemo { + templates: gate::Watch, + discount: gate::Watch, + repairer: gate::Watch, + pushed: Option<(u32, RepairCosts)>, +} + +/// Push each item's repair cost, and `GetRepairAllCost`'s total while a repairer is open. +fn feed_repair_costs( script: Option>, open: Res, - objects: Objects, + mut inv: crate::items::Inventory, items: Res, commands: Res, - self_q: Query<&ObjectStore, With>, units: Query<(&Guid, &ObjectStore), Without>, reactions: crate::target::ReactionInputs, tables: Option>, - mut last: Local>>, + mut memo: Local>, ) { let Some(mut script) = script else { return; }; - let last = last.get(&script); - let vendor = - vendor_store(&open, &units).filter(|store| store.0.unit_npc_flags() & NPC_FLAG_REPAIR != 0); - let total = match (vendor, self_q.iter().next(), tables.as_deref()) { - (Some(vendor), Some(store), Some(t)) => { - // The open merchant's discount for the player (`0x4faf8a`). - let discount = crate::target::vendor_price_discount( - reactions.factions.as_deref(), - &reactions.reputations, - vendor, - store, - ); - repair_all_cost(&store.0, &objects, &items, t, &commands, discount) - } - _ => 0, + 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; }; - if *last != Some(total) { + let vendor = vendor_store(&open, &units); + // The open vendor's price discount (`0x4faf8a`), 0 with none open. + let discount = vendor.map_or(0.0, |vendor| { + crate::target::vendor_price_discount( + reactions.factions.as_deref(), + &reactions.reputations, + vendor, + player, + ) + }); + let repairer = vendor.is_some_and(|store| store.0.unit_npc_flags() & NPC_FLAG_REPAIR != 0); + 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 repairer_moved = memo.repairer.moved(u64::from(repairer)); + 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), + ("repairer", repairer_moved), + ("tables", tables_added), + ], + ); + let gate = gate::Gate::new( + vm_reset + || objects_moved + || self_changed + || templates_moved + || discount_moved + || repairer_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, &commands, discount) + }); + let total = if repairer { repair_all_cost(&costs) } else { 0 }; + if memo + .pushed + .as_ref() + .is_none_or(|(t, c)| *t != total || *c != costs) + { + gate.audit("feed_repair_costs", "the repair costs"); script.set_repair_all_cost(total); - *last = Some(total); + script.set_repair_costs(costs.clone()); + memo.pushed = Some((total, costs)); } } @@ -812,6 +892,54 @@ pub(crate) mod purse_fixture { ])) } + /// [`seat`] and the player with the sword at `slot_field`; returns the player's entity too. + pub(crate) fn repair_app(slot_field: u16) -> (App, Entity) { + let (tx, _rx) = crossbeam_channel::unbounded(); + let mut app = App::new(); + app.init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .insert_resource(NetCommands(tx)); + seat(app.world_mut()); + let me = app + .world_mut() + .spawn((SelfPlayer, Guid(0x5e1f), player(slot_field, 0))) + .id(); + (app, me) + } + + /// 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(); + let mut inv: benilla_ui::script::InventorySlots = Default::default(); + inv[16] = Some(benilla_ui::script::InvSlotView { + item_id: SWORD_ENTRY, + ..Default::default() + }); + script.set_inventory_slots(inv); + 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.clone())); + script.set_container(crate::ui_items::BANK_CONTAINER, Some(bag)); + script + .run( + r#"CreateFrame("GameTooltip", "TT"):SetOwner(CreateFrame("Frame"), "ANCHOR_NONE")"#, + ) + .unwrap(); + script + } + /// The message's string, and a recorder of the `UI_ERROR_MESSAGE` lines [`shown`] reads. pub(crate) fn record_errors(script: &UiScript) { script @@ -856,7 +984,7 @@ mod tests { let mut app = crate::game_plugins::schedule_tests::headless_client(); assert!(crate::test_support::runs_before( &mut app, - feed_repair_all_cost, + feed_repair_costs, crate::ui_char::feed_char )); } @@ -948,9 +1076,7 @@ mod tests { })); app.insert_non_send_resource(script); - app.world_mut() - .run_system_once(feed_repair_all_cost) - .unwrap(); + app.world_mut().run_system_once(feed_repair_costs).unwrap(); let script = app.world_mut().non_send_resource_mut::(); let got = script.eval::("return (GetRepairAllCost())").unwrap(); assert_eq!(got, i64::from(want), "the undiscounted total is {full}"); @@ -1015,6 +1141,141 @@ mod tests { assert!(lines.is_empty() && rows.is_empty()); } + /// An item's cost is discounted at the vendor and full once it closes (`0x4faf30`). + #[test] + fn each_items_cost_takes_the_open_vendors_discount() { + use benilla_protocol::field::FIELD_PLAYER_INV_SLOT_HEAD; + use bevy::ecs::system::RunSystemOnce; + use purse_fixture::*; + + let (full, discounted) = sword_prices(); + let priced = |slot_field: u16, read: &str| { + let (mut app, _) = repair_app(slot_field); + app.insert_non_send_resource(sword_vm()); + let read_cost = |app: &mut App| { + app.world_mut().run_system_once(feed_repair_costs).unwrap(); + let script = app.world().non_send_resource::(); + script.eval::(read).unwrap() + }; + let at_vendor = read_cost(&mut app); + app.world_mut().resource_mut::().clear(); + (at_vendor, read_cost(&mut app)) + }; + let want = (i64::from(discounted), i64::from(full)); + + // Main hand, inventory slot 15. + let worn = priced( + FIELD_PLAYER_INV_SLOT_HEAD + 2 * 15, + r#"local _, _, c = TT:SetInventoryItem("player", 16) return c"#, + ); + assert_eq!(worn, want, "worn: at the vendor, then with none open"); + // Backpack slot 1, inventory slot 23. + let carried = priced( + FIELD_PLAYER_INV_SLOT_HEAD + 2 * 23, + "local _, c = TT:SetBagItem(0, 1) return c", + ); + assert_eq!( + carried, want, + "in the backpack: at the vendor, then with none open" + ); + } + + /// Each gate input alone reopens the feed, through `App::update` so the memo persists; a bank + /// item is priced but left out of the total (`0x4fbd60`). + #[test] + fn each_input_alone_reopens_the_repair_cost_gate() { + use benilla_protocol::field::{FIELD_PLAYER_INV_SLOT_HEAD, FIELD_UNIT_FLAGS}; + use benilla_protocol::ObjectFields; + use purse_fixture::*; + + /// `ITEM_FIELD_DURABILITY`, absolute. + const ITEM_DURABILITY: u16 = 46; + let main_hand = FIELD_PLAYER_INV_SLOT_HEAD + 2 * 15; + let backpack_1 = FIELD_PLAYER_INV_SLOT_HEAD + 2 * 23; + // `PLAYER_FIELD_BANK_SLOT_1`, inventory slot 39. + let bank_1 = FIELD_PLAYER_INV_SLOT_HEAD + 2 * 39; + + let (mut app, me) = repair_app(main_hand); + app.add_systems(Update, feed_repair_costs); + let tables = app.world_mut().remove_resource::().unwrap(); + let mut script = sword_vm(); + script.set_merchant(Some(MerchantState { + can_repair: true, + ..Default::default() + })); + app.insert_non_send_resource(script); + + let entity = + |app: &App, guid: u64| app.world().resource::().0[&guid]; + let write = |app: &mut App, e: Entity, pairs: &[(u16, u32)]| { + let mut store = app.world_mut().get_mut::(e).unwrap(); + store.0.merge(ObjectFields::from_pairs(pairs)); + }; + // (worn, backpack, bank, `GetRepairAllCost`) after one frame. + let frame = |app: &mut App| { + app.update(); + app.world() + .non_send_resource::() + .eval::<(i64, 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, (GetRepairAllCost())"#, + ) + .unwrap() + }; + let vendor = app + .world_mut() + .query::<(Entity, &Guid)>() + .iter(app.world()) + .find(|(_, g)| g.0 == VENDOR) + .unwrap() + .0; + let hi = (SWORD >> 32) as u32; + let moved = + |from: u16, to: u16| [(from, 0), (from + 1, 0), (to, SWORD as u32), (to + 1, hi)]; + + assert_eq!(frame(&mut app), (0, 0, 0, 0), "no tables, no prices"); + // 1 copper a point, less 0.1: 40 points cost 36. + app.insert_resource(tables); + assert_eq!(frame(&mut app), (36, 0, 0, 36), "the tables arrive"); + let sword = entity(&app, SWORD); + write(&mut app, sword, &[(ITEM_DURABILITY, 55)]); + assert_eq!( + frame(&mut app), + (18, 0, 0, 18), + "a repair's write: 20 points" + ); + write(&mut app, me, &moved(main_hand, bank_1)); + assert_eq!( + frame(&mut app), + (0, 0, 18, 0), + "banked: priced, not in the total" + ); + write(&mut app, me, &moved(bank_1, backpack_1)); + assert_eq!(frame(&mut app), (0, 18, 0, 18), "the sword in the backpack"); + write(&mut app, vendor, &[(FIELD_UNIT_FLAGS, 0)]); + assert_eq!( + frame(&mut app), + (0, 20, 0, 20), + "no PvP flag, no honor discount" + ); + app.world_mut().resource_mut::().clear(); + assert_eq!( + frame(&mut app), + (0, 20, 0, 0), + "the repairer gone: no total" + ); + app.world_mut() + .resource_mut::() + .insert_template(SWORD_ENTRY, None); + assert_eq!( + frame(&mut app), + (0, 0, 0, 0), + "an unknown template prices nothing" + ); + } + fn row(entry: u32, slot: u32, count: u32) -> VendorItem { VendorItem { slot, diff --git a/crates/benilla-app/src/ui_script/tooltip_compare_tests.rs b/crates/benilla-app/src/ui_script/tooltip_compare_tests.rs index 2dd22f2a..e1a6f84f 100644 --- a/crates/benilla-app/src/ui_script/tooltip_compare_tests.rs +++ b/crates/benilla-app/src/ui_script/tooltip_compare_tests.rs @@ -248,6 +248,112 @@ fn doll_hover_renders_the_live_instance() { assert!(s.errors().is_empty(), "errors: {:?}", s.errors()); } +/// In repair mode a damaged item's hover adds `REPAIR_COST` and its coins, worn +/// (`PaperDollFrame.lua:757-760`) or bagged (`ContainerFrame.lua:274-277`); out of it, neither. +#[test] +fn repair_mode_hover_shows_the_items_repair_cost() { + let _data = benilla_formats::wow_data_or_skip!(); + let mut s = harness_with_bags(); + s.set_unit("player", Some(player())); + let mut inv: InventorySlots = Default::default(); + inv[16] = Some(InvSlotView { + item_id: 1300, + count: 1, + quality: 2, + name: Some("Worn Sword".into()), + link: Some("|cff1eff00|Hitem:1300:0:0:0|h[Worn Sword]|h|r".into()), + durability: Some((35, 75)), + equip_slots: vec![16], + ..Default::default() + }); + s.set_inventory_slots(inv); + let mut slots = std::collections::HashMap::new(); + slots.insert( + 1, + ContainerSlot { + item_id: 1301, + count: 1, + quality: Some(2), + link: Some("|cff1eff00|Hitem:1301:0:0:0|h[Worn Helm]|h|r".into()), + durability: Some((10, 40)), + equip_slots: vec![1], + ..Default::default() + }, + ); + s.set_container( + 0, + Some(ContainerState { + name: Some("Backpack".into()), + num_slots: 16, + slots, + }), + ); + s.set_item_template( + 1300, + ItemTemplateView { + class: 2, + subclass: 7, + max_durability: 75, + sell_price: 5, + ..armor_template("Worn Sword", 13) + }, + ); + s.set_item_template( + 1301, + ItemTemplateView { + max_durability: 40, + sell_price: 5, + ..armor_template("Worn Helm", 1) + }, + ); + let mut costs = benilla_ui::script::RepairCosts::default(); + costs.equipped.insert(16, 40); + costs.bags.insert((0, 1), 57); + s.set_repair_costs(costs); + s.set_merchant(Some(benilla_ui::script::MerchantState { + can_repair: true, + ..Default::default() + })); + // `MERCHANT_SHOW` opens the backpack. + s.fire_event("MERCHANT_SHOW", vec![]); + s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap(); + s.take_sounds(); + let repair_line = |s: &UiScript| -> Option { + s.eval::( + "for i = 1, GameTooltip:NumLines() do \ + if getglobal('GameTooltipTextLeft' .. i):GetText() == REPAIR_COST then \ + return true end \ + end return false", + ) + .unwrap() + .then(|| { + s.eval::("return GameTooltipMoneyFrameCopperButton:GetText()") + .unwrap() + }) + }; + let bag = bag_slot_button(&s, 0, 1); + + hover(&mut s, &bag); + assert_eq!(repair_line(&s), None, "a bag hover out of repair mode"); + hover(&mut s, "CharacterMainHandSlot"); + assert_eq!(repair_line(&s), None, "a doll hover out of repair mode"); + + s.run("ShowRepairCursor()").unwrap(); + hover(&mut s, &bag); + assert_eq!( + repair_line(&s).as_deref(), + Some("57"), + "the bag slot's cost" + ); + hover(&mut s, "CharacterMainHandSlot"); + assert_eq!( + repair_line(&s).as_deref(), + Some("40"), + "the worn sword's cost" + ); + assert!(s.errors().is_empty(), "errors: {:?}", s.errors()); +} + /// `MerchantItemButton`'s `OnEnter` (`MerchantFrame.xml:67`) seats plate 1 at the tooltip's /// `TOPRIGHT` (0, -10) and plate 2 off plate 1. The compare call passes p4 = 0, so a plate is the /// worn item's ordinary tooltip under a gray `Currently Equipped` line. diff --git a/crates/benilla-ui/src/script/char_stats.rs b/crates/benilla-ui/src/script/char_stats.rs index c3ddbe50..4a052283 100644 --- a/crates/benilla-ui/src/script/char_stats.rs +++ b/crates/benilla-ui/src/script/char_stats.rs @@ -413,6 +413,20 @@ impl Model { .clone() } + /// The player's repair cost at live id `slot`, the vault band by its container slot. + 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)) + } else { + u32::try_from(slot) + .ok() + .and_then(|s| costs.equipped.get(&s)) + }; + cost.copied().unwrap_or(0) + } + fn bank_inv_slot(&self, slot: usize) -> Option { if BANK_INV_SLOTS.contains(&slot) { let vault = self.containers.get(&BANK_CONTAINER)?; diff --git a/crates/benilla-ui/src/script/merchant.rs b/crates/benilla-ui/src/script/merchant.rs index 7593f704..6ab4dfe2 100644 --- a/crates/benilla-ui/src/script/merchant.rs +++ b/crates/benilla-ui/src/script/merchant.rs @@ -66,6 +66,15 @@ pub struct ItemStatsHead { pub sell_price: u32, } +/// Each damaged item's repair cost in copper after the vendor's discount (`0x4faf30`). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct RepairCosts { + /// Equipped items by live inventory id. + pub equipped: std::collections::HashMap, + /// Container items by `(bag, slot)`: bags 0-4, the bank vault -1 and bank bags 5-10. + pub bags: std::collections::HashMap<(i64, u32), u32>, +} + /// One open merchant window, pushed whole by the app; `None` means no vendor is open. #[derive(Clone, Debug, Default, PartialEq)] pub struct MerchantState { @@ -94,6 +103,11 @@ impl super::UiScript { self.model_mut().repair_all_cost = copper; } + /// Push the repair costs the item tooltip bindings return. + pub fn set_repair_costs(&mut self, costs: RepairCosts) { + self.model_mut().repair_costs = costs; + } + /// Drain the `(row, quantity)` buys `BuyMerchantItem` queued, the row 1-based. pub fn take_merchant_buys(&mut self) -> Vec<(u32, u32)> { std::mem::take(&mut self.model_mut().merchant_buys) diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index b86a1184..0330685c 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -208,7 +208,7 @@ pub use loot_roll::{LootRollEntry, LootRollsState}; pub use macros::{MacroState, MacroView, MAX_MACROS, MAX_MACRO_BODY, MAX_MACRO_NAME}; pub use mail::{MailInboxRow, MailInvoice, MailSendRequest, MailState, StationeryView}; pub use measure::TextMeasure; -pub use merchant::{ItemStatsHead, MerchantItem, MerchantState}; +pub use merchant::{ItemStatsHead, MerchantItem, MerchantState, RepairCosts}; pub(crate) use minimap::apply_model_attrs as apply_minimap_model_attrs; pub(crate) use model::Model; pub use model::{FontProbe, TextureProbe, TextureSizeProbe}; diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 14bb3ae3..421443fd 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -569,8 +569,10 @@ pub(crate) struct Model { /// The open vendor's stock, the `BuyMerchantItem` calls and whether `CloseMerchant` ran. pub(crate) merchant: Option, - /// `GetRepairAllCost`'s total, which the app pushes every frame a vendor is open. + /// `GetRepairAllCost`'s total; 0 unless a repairer is open. pub(crate) repair_all_cost: u32, + /// The repair costs `SetInventoryItem` and `SetBagItem` return. + pub(crate) repair_costs: merchant::RepairCosts, pub(crate) merchant_buys: Vec<(u32, u32)>, /// The held `(bag, slot)` when `PickupMerchantItem` sells, sent as `CMSG_SELL_ITEM`. pub(crate) merchant_cursor_sells: Vec<(i64, u32)>, @@ -1189,6 +1191,7 @@ impl Model { gossip_quest_selects: Vec::new(), merchant: None, repair_all_cost: 0, + repair_costs: Default::default(), merchant_buys: Vec::new(), merchant_cursor_sells: Vec::new(), merchant_slot_buys: Vec::new(), diff --git a/crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs b/crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs index 122cf20e..76224607 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs @@ -650,3 +650,102 @@ fn set_merchant_compare_item_answers_one_or_nil_per_candidate_slot() { "number" ); } + +/// Both bindings return the pushed cost, a vault item's through either; no cost, an empty slot +/// and an inspected unit's item answer 0. +#[test] +fn item_bindings_return_the_pushed_repair_cost() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + let mut inv: InventorySlots = Default::default(); + inv[16] = Some(InvSlotView { + item_id: 8200, + name: Some("Worn Blade".into()), + quality: 2, + ..Default::default() + }); + s.set_inventory_slots(inv.clone()); + s.set_inspect(Some(InspectView { + unit: "target".into(), + guid: 0x42, + slots: inv, + })); + let mut slots = HashMap::new(); + for (slot, id, name) in [(1, 8201, "Worn Helm"), (2, 8202, "Whole Helm")] { + slots.insert( + slot, + ContainerSlot { + item_id: id, + count: 1, + quality: Some(2), + link: Some(format!("|cff1eff00|Hitem:{id}:0:0:0|h[{name}]|h|r")), + ..Default::default() + }, + ); + } + let vault = HashMap::from([( + 3, + ContainerSlot { + item_id: 8201, + count: 1, + quality: Some(2), + link: Some("|cff1eff00|Hitem:8201:0:0:0|h[Worn Helm]|h|r".into()), + ..Default::default() + }, + )]); + for (bag, name, slots) in [(0, "Backpack", slots), (-1, "Bank", vault)] { + s.set_container( + bag, + Some(ContainerState { + name: Some(name.into()), + num_slots: 24, + slots, + }), + ); + } + for (id, name) in [ + (8200, "Worn Blade"), + (8201, "Worn Helm"), + (8202, "Whole Helm"), + ] { + s.set_item_template( + id, + ItemTemplateView { + name: name.into(), + quality: 2, + ..Default::default() + }, + ); + } + let mut costs = RepairCosts::default(); + costs.equipped.insert(16, 1234); + costs.bags.insert((0, 1), 567); + costs.bags.insert((-1, 3), 89); + s.set_repair_costs(costs); + s.run( + r#" + local a = CreateFrame("Button", "Slot"); a:SetPoint("CENTER", 0, 0) + a:SetWidth(10); a:SetHeight(10) + local tt = CreateFrame("GameTooltip", "TT") + tt:SetOwner(a, "ANCHOR_RIGHT") + local has, _, cost = tt:SetInventoryItem("player", 16) + assert(has == 1 and cost == 1234, "equipped: " .. tostring(cost)) + local _, _, upper = tt:SetInventoryItem("PLAYER", 16) + assert(upper == 1234, "the token matches case-blind: " .. tostring(upper)) + local _, _, empty = tt:SetInventoryItem("player", 15) + assert(empty == 0, "an empty slot still pushes 0") + local seen, _, theirs = tt:SetInventoryItem("target", 16) + assert(seen == 1 and theirs == 0, "an inspected item: " .. tostring(theirs)) + local _, _, vault = tt:SetInventoryItem("player", 42) + assert(vault == 89, "vault slot 3 by live id 42: " .. tostring(vault)) + local _, vault_bag = tt:SetBagItem(-1, 3) + assert(vault_bag == 89, "vault slot 3 as a bag: " .. tostring(vault_bag)) + local _, bag = tt:SetBagItem(0, 1) + assert(bag == 567, "bag: " .. tostring(bag)) + local _, whole = tt:SetBagItem(0, 2) + assert(whole == 0, "no cost: " .. tostring(whole)) + "#, + ) + .unwrap(); + assert!(s.take_errors().is_empty()); +} diff --git a/crates/benilla-ui/src/script/tooltip_item/mod.rs b/crates/benilla-ui/src/script/tooltip_item/mod.rs index bc0614e2..b56c8a26 100644 --- a/crates/benilla-ui/src/script/tooltip_item/mod.rs +++ b/crates/benilla-ui/src/script/tooltip_item/mod.rs @@ -338,7 +338,8 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { // not byte-read as `SetBagItem`'s two is (`0x534985`). Unit-keyed through `Model::inv_slot`: // an inspected item (`InspectPaperDollFrame.xml:20`) has no durability or creator, so those // lines do not show, as in the reference. An armed shopping tooltip renders the compare - // shape; the arm is consumed either way. repairCost is 0, as `SetBagItem`'s is. + // shape; the arm is consumed either way. repairCost is the item's cost (`0x5332fb`), 0 for an + // inspected unit's. m.set( "SetInventoryItem", lua.create_function( @@ -348,7 +349,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { // (`0x533027..0x53304c`); anything else builds the ordinary tooltip, without an // error. Three of the binding's four builder legs pass it on, not `0x533106`. let name_only = super::binding_abi::positive_number_flag(lua, name_only)?; - let (item_id, name, quality, inst, currently_equipped) = { + let (item_id, name, quality, inst, repair_cost, currently_equipped) = { let mut model = lua.app_data_mut::().expect("model app_data"); let armed = match super::tooltip::tip_mut(&mut model, h) { Ok(t) => std::mem::take(&mut t.equipped_header_armed), @@ -381,8 +382,13 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { }, ) }); + let cost = if unit.eq_ignore_ascii_case("player") { + model.inv_repair_cost(slot) + } else { + 0 + }; match view { - Some((id, name, q, inst)) => (id, name, q, inst, armed), + Some((id, name, q, inst)) => (id, name, q, inst, cost, armed), // An empty slot answers nil and still pushes the other two: callers // destructure all three first, and an addon may add up repairCost. None => { @@ -434,7 +440,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { } else { Value::Nil }, - Value::Integer(0), + Value::Integer(i64::from(repair_cost)), ])) }, )?, @@ -443,14 +449,14 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { // GameTooltip:SetBagItem(bag, slot) -> hasCooldown, repairCost: the real-instance hover and // the one money source: with the merchant open and repair off (`0x52e376`), the engine fires // `OnTooltipAddMoney(SellPrice × stack)`, or prints ITEM_UNSELLABLE at price 0 (`0x854a74`, - // pushed at `0x52e4a3`). repairCost is 0: the per-item repair cost is not fed. The reference - // always pushes a number there (`0x534975`), and stock guards on `> 0`. + // pushed at `0x52e4a3`). repairCost is the item's cost (`0x534975`), shown in repair mode. m.set( "SetBagItem", lua.create_function(|lua, (this, bag, slot): (Table, i64, u32)| { let h = frame_handle_of(lua, &this)?; - let (item_id, count, has_cd, link, quality, inst) = { + let (item_id, count, has_cd, link, quality, inst, repair_cost) = { let model = lua.app_data_mut::().expect("model app_data"); + let cost = model.repair_costs.bags.get(&(bag, slot)).copied(); match model .containers .get(&bag) @@ -485,6 +491,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { openable_source: !has_cd, duration_ms: s.duration_ms, }, + cost.unwrap_or(0), ) } None => return Ok(MultiValue::from_vec(vec![Value::Nil])), @@ -529,7 +536,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { Ok(MultiValue::from_vec(vec![ // The builder's `[ebp-0x38]`: a running cooldown or the item's duration line. Value::Boolean(has_cd || inst.duration_ms.is_some()), - Value::Integer(0), + Value::Integer(i64::from(repair_cost)), ])) })?, )?;