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:
co-authored by
Claude Opus 5.5
parent
655854c154
commit
3ed9c0b17e
@@ -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)?;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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)),
|
||||
]))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
Reference in New Issue
Block a user