fix: show the repair cost on item tooltips in repair mode (#469)

- Item tooltips in repair mode show the repair cost: SetInventoryItem and SetBagItem return each item's cost after the open vendor's discount
- Bank items price in their tooltips too, the repair-cost gate is tested input by input, and logout keeps the last push
- The repair-cost tests share one fixture for the feed's world and the VM's sword
- The repair-cost comments say one fact each, and two that restated the code are gone

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mathih13
2026-09-28 06:54:16 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 655854c154
commit 3ed9c0b17e
8 changed files with 570 additions and 66 deletions
+317 -56
View File
@@ -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<u64> = 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<NonSendMut<UiScript>>,
open: Res<MerchantOpen>,
objects: Objects,
mut inv: crate::items::Inventory,
items: Res<Items>,
commands: Res<NetCommands>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
units: Query<(&Guid, &ObjectStore), Without<SelfPlayer>>,
reactions: crate::target::ReactionInputs,
tables: Option<Res<RepairTables>>,
mut last: Local<crate::ui_script::VmMemo<Option<u32>>>,
mut memo: Local<crate::ui_script::VmMemo<RepairFeedMemo>>,
) {
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::<Items>()
.init_resource::<crate::net::GuidIndex>()
.init_resource::<MerchantOpen>()
.init_resource::<crate::net::Reputations>()
.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::<UiScript>();
let got = script.eval::<i64>("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::<UiScript>();
script.eval::<i64>(read).unwrap()
};
let at_vendor = read_cost(&mut app);
app.world_mut().resource_mut::<MerchantOpen>().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::<RepairTables>().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::<crate::net::GuidIndex>().0[&guid];
let write = |app: &mut App, e: Entity, pairs: &[(u16, u32)]| {
let mut store = app.world_mut().get_mut::<ObjectStore>(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::<UiScript>()
.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::<MerchantOpen>().clear();
assert_eq!(
frame(&mut app),
(0, 20, 0, 0),
"the repairer gone: no total"
);
app.world_mut()
.resource_mut::<Items>()
.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,
@@ -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<String> {
s.eval::<bool>(
"for i = 1, GameTooltip:NumLines() do \
if getglobal('GameTooltipTextLeft' .. i):GetText() == REPAIR_COST then \
return true end \
end return false",
)
.unwrap()
.then(|| {
s.eval::<String>("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.
@@ -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<InvSlotView> {
if BANK_INV_SLOTS.contains(&slot) {
let vault = self.containers.get(&BANK_CONTAINER)?;
+14
View File
@@ -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<u32, u32>,
/// 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)
+1 -1
View File
@@ -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};
+4 -1
View File
@@ -569,8 +569,10 @@ pub(crate) struct Model {
/// The open vendor's stock, the `BuyMerchantItem` calls and whether `CloseMerchant` ran.
pub(crate) merchant: Option<merchant::MerchantState>,
/// `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(),
@@ -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());
}
@@ -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::<Model>().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::<Model>().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)),
]))
})?,
)?;