- the auction house end to end: right-clicking an auctioneer opens Browse/Bids/Auctions on the server's reply — search, the category tree from your own DBCs, the reference's 50-a-page pager, multi-key sorting (directions corrected at the bytes), bid/buyout, the sell slot with deposit and duration, cancel with its fee popup; prices sit on the reference's own money-frame kit in lined-up columns, the twenty auction messages split refusals-red / outcomes-to-chat, and a won or sold auction reads as a proper mail receipt — the window's Lua re-worded in our own terms after the overlap ratchet caught the first cut leaning on the reference's text - the PvP honor system whole: the Honor tab and the inspect page over a real wire model, with current rank and highest-ever rank correctly two different fields - a day of reported fixes: WASD survives a /logout re-entry, addon settings save on a window close and Cmd+Q (exactly once — a mac close could fire the tail twice), clams open again, random-enchant items name themselves on every surface, the loot window's rows hold their seats with legible counts, idle players stop sinking through building floors, the shooter re-pulls between Auto Shots, portraits loosen ~19% to the client's own aspect-1.0 matrix, and big creatures thump the camera off the CameraShakes table - character select clicks like 1.12 — captions whiten on hover, buttons fire on release, Humans face straight ahead off the stage bone's real yaw, the facing is per-character, and hunter/warlock pets stand beside you at their family-ramp size with their own emitters (the imp's flames), the Voidwalker's sequence-hidden shackles culled - the smaller player-facing set: nameplate on/off rows in Options with the CanAttack category gate (emissaries stop plating), the bonus bar slides up with igBonusBarOpen, the camera's framing height glides on a shapeshift, the open letter evicts the centre seat, the far-band fog hull stops printing a pale horizon stripe at low view distance, the guide arrow is gold with per-source rim art, error text fades through its shadow too, and /convertraid converts the party - README: the auction house and PvP honor move out of "not built yet" Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
857 lines
36 KiB
Rust
857 lines
36 KiB
Rust
//! The merchant bindings (decision 0081 phase 4) — the Era-shaped vendor surface, the same two-way
|
|
//! seam as [`super::container`]/[`super::gossip`]: the app pushes a **merchant snapshot**
|
|
//! ([`UiScript::set_merchant`] — the vendor rows already resolved from the wire to name/icon/price
|
|
//! by the app's item stores), and the Lua `BuyMerchantItem`/`CloseMerchant` calls queue outbound
|
|
//! **intents** the app drains ([`UiScript::take_merchant_buys`] / [`UiScript::take_merchant_close`]).
|
|
//! The engine holds no vendor knowledge — a row is "a name, an icon path, a price, a stack size,
|
|
//! how many are left, and the tooltip's stat head" ([`ItemStatsHead`], read by
|
|
//! `BenillaGetMerchantItemStats`).
|
|
//!
|
|
//! ## The 5875 API shape
|
|
//!
|
|
//! 1.12's `GetMerchantItemInfo(index)` returns a flat **6-value** tuple the FrameXML reads
|
|
//! positionally — `name, texture, price, quantity, numAvailable, isUsable` (byte-verified,
|
|
//! `0x4fb150`; the ref's own `MerchantFrame_UpdateMerchantInfo` destructures exactly these six.
|
|
//! The `isPurchasable`/`extendedCost` extras an earlier draft carried are TBC-era, not 5875).
|
|
//! `index` is **1-based**; an invalid index answers the binding's fixed tuple
|
|
//! `(nil, nil, 0, 1, 0, 1)` — same shape as `GetBuybackItemInfo`'s (`0x4fb2be`). `numAvailable`
|
|
//! is `-1` for unlimited stock (the wire's `0xFFFF_FFFF` through `fild`, mapped by the app).
|
|
//! `isUsable` is `1`/`nil` (the client pushes a number or nil, never a boolean): the
|
|
//! [`super::item_stats::item_usable`] gate over the row's template — a template still in flight
|
|
//! reads usable, the getter's null-record skip (`0x4fb298`).
|
|
//!
|
|
//! Buying addresses the row by its **1-based list position** here (`BuyMerchantItem(index)`); the app
|
|
//! maps that position to the item *entry* the wire's `CMSG_BUY_ITEM` needs (buy is by entry, not the
|
|
//! vendor `muid` — decision 0081). vanilla's client-side `CloseMerchant()` sends no packet, so it
|
|
//! just flags the app to clear its local state (the gossip pattern).
|
|
|
|
use mlua::{Lua, MultiValue, Value};
|
|
|
|
use super::container::UiCursorMode;
|
|
use super::Model;
|
|
|
|
/// One vendor row, resolved by the app from the wire `VendorItem` (decision 0081). Plain data —
|
|
/// 1-based order in the window is its position in [`MerchantState::items`].
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub struct MerchantItem {
|
|
/// The item name (`GetMerchantItemInfo`'s first return); `None` while the ask-once item-template
|
|
/// query is still in flight (the API reports `nil`, the XML shows a placeholder).
|
|
pub name: Option<String>,
|
|
/// Icon texture path (`Interface\Icons\…`); `None` while the template answer is in flight.
|
|
pub texture: Option<String>,
|
|
/// Buy price in copper (already reputation-discounted server-side).
|
|
pub price: u32,
|
|
/// Stack size delivered per purchase (`item_template.buy_count`, the wire's `buy_count`).
|
|
pub quantity: u32,
|
|
/// Remaining stock, or `-1` for unlimited (the app maps the wire's `0xFFFF_FFFF`).
|
|
pub num_available: i32,
|
|
/// The item's template **entry** — what `CMSG_BUY_ITEM` addresses (the app maps the clicked
|
|
/// 1-based row to this; the Lua side never sees it).
|
|
pub item_id: u32,
|
|
/// The stat head the hover tooltip renders, from the same ask-once template answer as `name`
|
|
/// (`None` while it's in flight — the tooltip shows nothing it can't know yet). Still plain
|
|
/// data: the engine renders whatever numbers the app resolved, it holds no item model.
|
|
pub stats: Option<ItemStatsHead>,
|
|
/// The row's full escaped `|cff…|Hitem:…|h[Name]|h|r` link (`GetMerchantItemLink`, decision
|
|
/// 1059) — what the row click's ctrl/shift arms hand to `DressUpItemLink` /
|
|
/// `ChatFrameEditBox:Insert` (`MerchantFrame.lua:303`/`:306`). `None` while the template answer
|
|
/// is in flight (the link embeds the name), and `None` on a **buyback** row: 1.12 has no
|
|
/// `GetBuybackItemLink` — the reference's buyback arm is an unmodified `BuybackItem(this:GetID())`
|
|
/// with no ctrl/shift branch at all (`MerchantFrame.lua:358-361`), so nothing would read it.
|
|
pub link: Option<String>,
|
|
}
|
|
|
|
/// An item template's tooltip stat head (the app's `ItemInfo` view, resolved per row): what the
|
|
/// vendor hover tooltip needs beyond name/icon/price. The real client's `GameTooltip:SetMerchantItem`
|
|
/// reads the same template fields C++-side; benilla feeds them to Lua through
|
|
/// `BenillaGetMerchantItemStats(index)` (no 1.12 Lua API carries damage/armor — tooltip content was
|
|
/// C++'s alone — so the feed is benilla-named, not Era-shaped).
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
|
pub struct ItemStatsHead {
|
|
/// 0 poor … 6 artifact — colours the tooltip's name line.
|
|
pub quality: u32,
|
|
/// `InventoryType` — the tooltip's slot line ("Main Hand", "Chest", …); 0 = no slot line.
|
|
pub inventory_type: u32,
|
|
/// Item class (2 weapon, 4 armor, 6 projectile) — with `subclass`, the slot line's right column.
|
|
pub class: u32,
|
|
/// Item subclass within `class` (7 = sword, 1 = cloth, …).
|
|
pub subclass: u32,
|
|
/// Damage block 0 per-hit minimum (0 for non-weapons).
|
|
pub dmg_min: f32,
|
|
/// Damage block 0 per-hit maximum.
|
|
pub dmg_max: f32,
|
|
/// Damage block 0 school (0 physical, 1 Holy … 6 Arcane).
|
|
pub dmg_type: u32,
|
|
/// Attack delay in milliseconds ("Speed" = delay / 1000).
|
|
pub delay_ms: u32,
|
|
/// The armor line's value; 0 = no line.
|
|
pub armor: u32,
|
|
/// A shield's block line; 0 = no line.
|
|
pub block: u32,
|
|
/// `SellPrice` — what a vendor pays per unit (the bag tooltip's money row while a merchant is
|
|
/// open; 0 = the "No sell price" line).
|
|
pub sell_price: u32,
|
|
}
|
|
|
|
/// One open merchant window: the vendor's rows, the buyback slots, and the repair head. Pushed
|
|
/// whole by the app; `None` means no vendor is open (the window is closed).
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub struct MerchantState {
|
|
pub items: Vec<MerchantItem>,
|
|
/// The buyback slots, oldest-first (index 1 = the oldest; the LAST entry is the most recent
|
|
/// sale — the one the merchant page's single buyback slot shows, ref
|
|
/// `GetBuybackItemInfo(GetNumBuybackItems())`). Resolved by the app from the player's
|
|
/// VENDORBUYBACK descriptor fields + the item stores; `price` here is the buyback price
|
|
/// field, `quantity` the stored item's stack count.
|
|
pub buyback: Vec<MerchantItem>,
|
|
/// Whether this vendor repairs (UNIT_NPC_FLAGS repair bit) — shows the repair buttons.
|
|
pub can_repair: bool,
|
|
/// The repair-all cost in copper the app computed (0 = nothing to repair → the repair-all
|
|
/// button disables, ref MerchantFrame_OnShow).
|
|
pub repair_all_cost: u32,
|
|
}
|
|
|
|
impl super::UiScript {
|
|
/// Push (or clear, with `None`) the open merchant's stock snapshot.
|
|
pub fn set_merchant(&mut self, state: Option<MerchantState>) {
|
|
self.model_mut().merchant = state;
|
|
}
|
|
|
|
/// Drain the `(index, quantity)` buy intents queued by `BuyMerchantItem` since the last call.
|
|
/// `index` is the 1-based row position; the app maps it to the item entry the wire needs.
|
|
pub fn take_merchant_buys(&mut self) -> Vec<(u32, u32)> {
|
|
std::mem::take(&mut self.model_mut().merchant_buys)
|
|
}
|
|
|
|
/// Whether `CloseMerchant` was called since the last drain (and clear the flag). vanilla's
|
|
/// client-side close sends no packet — the app just clears its local merchant state.
|
|
pub fn take_merchant_close(&mut self) -> bool {
|
|
std::mem::take(&mut self.model_mut().merchant_close)
|
|
}
|
|
|
|
/// Drain the 1-based buyback-slot intents queued by `BuybackItem` since the last call. The app
|
|
/// maps each to the wire's absolute buyback inventory slot.
|
|
pub fn take_merchant_buybacks(&mut self) -> Vec<u32> {
|
|
std::mem::take(&mut self.model_mut().merchant_buybacks)
|
|
}
|
|
|
|
/// Whether `RepairAllItems` was called since the last drain (and clear the flag). The app
|
|
/// sends the repair-all wire message.
|
|
pub fn take_repair_all(&mut self) -> bool {
|
|
std::mem::take(&mut self.model_mut().repair_all)
|
|
}
|
|
|
|
/// The client-side repair-mode latch (`ShowRepairCursor`/`HideRepairCursor`/`InRepairMode`):
|
|
/// while set, a bag/equipment click means "repair this item" — the app reads this to route
|
|
/// clicks and swap the hardware cursor.
|
|
pub fn repair_mode(&self) -> bool {
|
|
self.model_ref().repair_mode
|
|
}
|
|
}
|
|
|
|
/// `1`/`nil` — how the client pushes a usable flag (`pushnumber(1.0)` / `pushnil`).
|
|
fn usable_value(usable: bool) -> Value {
|
|
if usable {
|
|
Value::Integer(1)
|
|
} else {
|
|
Value::Nil
|
|
}
|
|
}
|
|
|
|
/// The invalid-index tuple both getters answer (`0x4fb2be` / the buyback equivalent):
|
|
/// `(nil, nil, 0, 1, 0, 1)` — still six values.
|
|
fn invalid_tuple() -> Vec<Value> {
|
|
vec![
|
|
Value::Nil,
|
|
Value::Nil,
|
|
Value::Integer(0),
|
|
Value::Integer(1),
|
|
Value::Integer(0),
|
|
Value::Integer(1),
|
|
]
|
|
}
|
|
|
|
/// Register the merchant globals.
|
|
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
|
let g = lua.globals();
|
|
|
|
// → the number of rows the open vendor has (0 when no vendor is open).
|
|
g.set(
|
|
"GetMerchantNumItems",
|
|
lua.create_function(|lua, ()| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
Ok(model.merchant.as_ref().map_or(0, |m| m.items.len()) as i64)
|
|
})?,
|
|
)?;
|
|
|
|
// GetMerchantItemInfo(index) → name, texture, price, quantity, numAvailable, isUsable — the
|
|
// byte-verified 5875 6-tuple (0x4fb150; see the module doc). `index` is 1-based; an invalid
|
|
// index answers the fixed tuple (nil, nil, 0, 1, 0, 1).
|
|
g.set(
|
|
"GetMerchantItemInfo",
|
|
lua.create_function(|lua, index: usize| {
|
|
let (item, usable) = {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let item = model
|
|
.merchant
|
|
.as_ref()
|
|
.and_then(|m| index.checked_sub(1).and_then(|n| m.items.get(n)))
|
|
.cloned();
|
|
let usable = item
|
|
.as_ref()
|
|
.is_none_or(|it| super::item_stats::item_usable_by_id(&model, it.item_id));
|
|
(item, usable)
|
|
};
|
|
let Some(it) = item else {
|
|
return Ok(MultiValue::from_vec(invalid_tuple()));
|
|
};
|
|
let name = match &it.name {
|
|
Some(n) => Value::String(lua.create_string(n)?),
|
|
None => Value::Nil,
|
|
};
|
|
let texture = match &it.texture {
|
|
Some(t) => Value::String(lua.create_string(t)?),
|
|
None => Value::Nil,
|
|
};
|
|
Ok(MultiValue::from_vec(vec![
|
|
name,
|
|
texture,
|
|
Value::Integer(i64::from(it.price)),
|
|
Value::Integer(i64::from(it.quantity)),
|
|
Value::Integer(i64::from(it.num_available)),
|
|
usable_value(usable),
|
|
]))
|
|
})?,
|
|
)?;
|
|
|
|
// GetMerchantItemLink(index) → the row's full escaped `|cff…|Hitem:…|h[Name]|h|r` link | nil.
|
|
// 1-based like GetMerchantItemInfo; nil out of range and nil while the row's template answer is
|
|
// in flight (the link embeds the name). The reference's row click reads it for both LEFT-button
|
|
// modifier arms — `DressUpItemLink(GetMerchantItemLink(this:GetID()))` (`MerchantFrame.lua:303`)
|
|
// and `ChatFrameEditBox:Insert(...)` (`:306`); ours routes the second through
|
|
// `BenillaChatEdit_InsertLink`, whose whole job is the nil this getter can answer. Merchant rows
|
|
// only: see [`MerchantItem::link`] for why a buyback row carries none. Decision 1059.
|
|
g.set(
|
|
"GetMerchantItemLink",
|
|
lua.create_function(|lua, index: usize| {
|
|
let link = {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
model
|
|
.merchant
|
|
.as_ref()
|
|
.and_then(|m| index.checked_sub(1).and_then(|n| m.items.get(n)))
|
|
.and_then(|it| it.link.clone())
|
|
};
|
|
match link {
|
|
Some(link) => Ok(Value::String(lua.create_string(&link)?)),
|
|
None => Ok(Value::Nil),
|
|
}
|
|
})?,
|
|
)?;
|
|
|
|
// BenillaGetMerchantItemStats(index) → quality, invType, class, subclass, dmgMin, dmgMax,
|
|
// dmgType, delayMs, armor, block — the tooltip stat head ([`ItemStatsHead`]), or nil while
|
|
// the row's template answer is in flight / the index is out of range. Benilla-named: 1.12's Lua
|
|
// API never carried these (item tooltip content was C++'s alone — `SetMerchantItem 0x534080`).
|
|
g.set(
|
|
"BenillaGetMerchantItemStats",
|
|
lua.create_function(|lua, index: usize| {
|
|
let stats = {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
model
|
|
.merchant
|
|
.as_ref()
|
|
.and_then(|m| index.checked_sub(1).and_then(|n| m.items.get(n)))
|
|
.and_then(|it| it.stats)
|
|
};
|
|
let Some(s) = stats else {
|
|
return Ok(MultiValue::from_vec(vec![Value::Nil]));
|
|
};
|
|
Ok(MultiValue::from_vec(vec![
|
|
Value::Integer(i64::from(s.quality)),
|
|
Value::Integer(i64::from(s.inventory_type)),
|
|
Value::Integer(i64::from(s.class)),
|
|
Value::Integer(i64::from(s.subclass)),
|
|
Value::Number(f64::from(s.dmg_min)),
|
|
Value::Number(f64::from(s.dmg_max)),
|
|
Value::Integer(i64::from(s.dmg_type)),
|
|
Value::Integer(i64::from(s.delay_ms)),
|
|
Value::Integer(i64::from(s.armor)),
|
|
Value::Integer(i64::from(s.block)),
|
|
]))
|
|
})?,
|
|
)?;
|
|
|
|
// BuyMerchantItem(index [, quantity]) — queue the 1-based row + stack count (default 1); the app
|
|
// maps the row to the item entry the wire addresses.
|
|
g.set(
|
|
"BuyMerchantItem",
|
|
lua.create_function(|lua, (index, quantity): (u32, Option<u32>)| {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
model.merchant_buys.push((index, quantity.unwrap_or(1)));
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
// CloseMerchant() — client-side close (no packet, vanilla): flag it so the app clears its state.
|
|
g.set(
|
|
"CloseMerchant",
|
|
lua.create_function(|lua, ()| {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
model.merchant_close = true;
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
// → how many buyback slots are filled (ref GetNumBuybackItems; 0 with no vendor open).
|
|
g.set(
|
|
"GetNumBuybackItems",
|
|
lua.create_function(|lua, ()| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
Ok(model.merchant.as_ref().map_or(0, |m| m.buyback.len()) as i64)
|
|
})?,
|
|
)?;
|
|
|
|
// GetBuybackItemInfo(index) → name, texture, price, quantity, numAvailable, isUsable (the
|
|
// same 6-tuple; 0x4fb310, wow-re ui/scratch/buyback-data-path.md). `index` is 1-based
|
|
// oldest-first; an invalid index answers the fixed tuple (nil, nil, 0, 1, 0, 1). isUsable is
|
|
// the same 0x5ea930 gate over the sold item's template (0x4fb4f7) — yes, even a just-sold
|
|
// item reds if the seller can't use it (a mule selling a wrong-class drop).
|
|
g.set(
|
|
"GetBuybackItemInfo",
|
|
lua.create_function(|lua, index: usize| {
|
|
let (item, usable) = {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let item = model
|
|
.merchant
|
|
.as_ref()
|
|
.and_then(|m| index.checked_sub(1).and_then(|n| m.buyback.get(n)))
|
|
.cloned();
|
|
let usable = item
|
|
.as_ref()
|
|
.is_none_or(|it| super::item_stats::item_usable_by_id(&model, it.item_id));
|
|
(item, usable)
|
|
};
|
|
let Some(it) = item else {
|
|
return Ok(MultiValue::from_vec(invalid_tuple()));
|
|
};
|
|
let name = match &it.name {
|
|
Some(n) => Value::String(lua.create_string(n)?),
|
|
None => Value::Nil,
|
|
};
|
|
let texture = match &it.texture {
|
|
Some(t) => Value::String(lua.create_string(t)?),
|
|
None => Value::Nil,
|
|
};
|
|
Ok(MultiValue::from_vec(vec![
|
|
name,
|
|
texture,
|
|
Value::Integer(i64::from(it.price)),
|
|
Value::Integer(i64::from(it.quantity)),
|
|
Value::Integer(i64::from(it.num_available)),
|
|
usable_value(usable),
|
|
]))
|
|
})?,
|
|
)?;
|
|
|
|
// BenillaGetBuybackItemStats(index) — the buyback hover's tooltip stat head, same shape and
|
|
// reason as BenillaGetMerchantItemStats.
|
|
g.set(
|
|
"BenillaGetBuybackItemStats",
|
|
lua.create_function(|lua, index: usize| {
|
|
let stats = {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
model
|
|
.merchant
|
|
.as_ref()
|
|
.and_then(|m| index.checked_sub(1).and_then(|n| m.buyback.get(n)))
|
|
.and_then(|it| it.stats)
|
|
};
|
|
let Some(s) = stats else {
|
|
return Ok(MultiValue::from_vec(vec![Value::Nil]));
|
|
};
|
|
Ok(MultiValue::from_vec(vec![
|
|
Value::Integer(i64::from(s.quality)),
|
|
Value::Integer(i64::from(s.inventory_type)),
|
|
Value::Integer(i64::from(s.class)),
|
|
Value::Integer(i64::from(s.subclass)),
|
|
Value::Number(f64::from(s.dmg_min)),
|
|
Value::Number(f64::from(s.dmg_max)),
|
|
Value::Integer(i64::from(s.dmg_type)),
|
|
Value::Integer(i64::from(s.delay_ms)),
|
|
Value::Integer(i64::from(s.armor)),
|
|
Value::Integer(i64::from(s.block)),
|
|
]))
|
|
})?,
|
|
)?;
|
|
|
|
// BuybackItem(index) — queue the 1-based buyback slot; the app maps it to the wire's absolute
|
|
// inventory slot.
|
|
g.set(
|
|
"BuybackItem",
|
|
lua.create_function(|lua, index: u32| {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
model.merchant_buybacks.push(index);
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
// CanMerchantRepair() → 1/nil (the Era boolean shape) — whether the open vendor repairs.
|
|
g.set(
|
|
"CanMerchantRepair",
|
|
lua.create_function(|lua, ()| {
|
|
let can = {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
model.merchant.as_ref().is_some_and(|m| m.can_repair)
|
|
};
|
|
Ok(if can { Value::Integer(1) } else { Value::Nil })
|
|
})?,
|
|
)?;
|
|
|
|
// GetRepairAllCost() → cost, canRepair — the app-computed repair-all total; canRepair is
|
|
// "there is damage to pay for" (ref MerchantFrame_OnShow enables the button on it).
|
|
g.set(
|
|
"GetRepairAllCost",
|
|
lua.create_function(|lua, ()| {
|
|
let cost = {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
model.merchant.as_ref().map_or(0, |m| m.repair_all_cost)
|
|
};
|
|
Ok(MultiValue::from_vec(vec![
|
|
Value::Integer(i64::from(cost)),
|
|
Value::Boolean(cost > 0),
|
|
]))
|
|
})?,
|
|
)?;
|
|
|
|
// RepairAllItems() — flag the repair-all intent for the app to send.
|
|
g.set(
|
|
"RepairAllItems",
|
|
lua.create_function(|lua, ()| {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
model.repair_all = true;
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
// The repair-mode latch trio (ref MerchantRepairItemButton OnClick): ShowRepairCursor arms
|
|
// "next item click repairs", HideRepairCursor disarms, InRepairMode() → 1/nil reads it. The
|
|
// app reads UiScript::repair_mode to route clicks + swap the hardware cursor.
|
|
g.set(
|
|
"ShowRepairCursor",
|
|
lua.create_function(|lua, ()| {
|
|
lua.app_data_mut::<Model>()
|
|
.expect("model app_data")
|
|
.repair_mode = true;
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
g.set(
|
|
"HideRepairCursor",
|
|
lua.create_function(|lua, ()| {
|
|
lua.app_data_mut::<Model>()
|
|
.expect("model app_data")
|
|
.repair_mode = false;
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
g.set(
|
|
"InRepairMode",
|
|
lua.create_function(|lua, ()| {
|
|
let mode = lua
|
|
.app_data_ref::<Model>()
|
|
.expect("model app_data")
|
|
.repair_mode;
|
|
Ok(if mode { Value::Integer(1) } else { Value::Nil })
|
|
})?,
|
|
)?;
|
|
|
|
// ShowMerchantSellCursor(index) (5875 `0x4fbab0`, "buying from vendor") and
|
|
// ShowBuybackSellCursor(index) (`0x4fbbb0`, "re-buying") — the vendor-item hover cursor
|
|
// (wow-re cursor-system.md §7). Despite the "Sell" in the names, these arm the BUY cursor with
|
|
// an affordability gate: player coin vs the row's price → Buy(3) if `coin >= price`, else the
|
|
// grayed UnableBuy(23). The merchant frame's OnUpdate re-arms this every frame while an item is
|
|
// hovered (Ctrl-hover swaps to `ShowInspectCursor` instead); OnLeave `ResetCursor`s it. An
|
|
// unresolvable index leaves the cursor unchanged — the binary's every-fail-path `ret` with no
|
|
// `CursorSetMode` (the in-flight item lock and base-mode-Point gates live app-side).
|
|
g.set(
|
|
"ShowMerchantSellCursor",
|
|
lua.create_function(|lua, index: usize| {
|
|
arm_vendor_cursor(lua, |m| {
|
|
m.items.get(index.wrapping_sub(1)).map(|it| it.price)
|
|
});
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
g.set(
|
|
"ShowBuybackSellCursor",
|
|
lua.create_function(|lua, index: usize| {
|
|
arm_vendor_cursor(lua, |m| {
|
|
m.buyback.get(index.wrapping_sub(1)).map(|it| it.price)
|
|
});
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Arm the vendor-item hover cursor from a `price` picked out of the open merchant snapshot: Buy(3)
|
|
/// if the player can afford it, UnableBuy(23) otherwise. Shared by `ShowMerchantSellCursor` and
|
|
/// `ShowBuybackSellCursor` (which differ only in which price list they read). A `None` price (no
|
|
/// vendor open, or the 1-based index out of range) leaves the cursor untouched — the binary bails
|
|
/// without a `CursorSetMode` (wow-re cursor-system.md §7).
|
|
fn arm_vendor_cursor(lua: &Lua, price_of: impl FnOnce(&MerchantState) -> Option<u32>) {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
// The sell-cursor family bails on `IsTargeting` at its first instruction (wow-re
|
|
// `item-target-cursor-and-dropitemonunit.md`) — an armed spell keeps its cast cursor.
|
|
if model.spell_targeting {
|
|
return;
|
|
}
|
|
let Some(price) = model.merchant.as_ref().and_then(price_of) else {
|
|
return;
|
|
};
|
|
model.ui_cursor = Some(if model.money >= u64::from(price) {
|
|
UiCursorMode::Buy
|
|
} else {
|
|
UiCursorMode::UnableBuy
|
|
});
|
|
model.ui_cursor_dirty = true;
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{ItemStatsHead, MerchantItem, MerchantState};
|
|
use crate::script::UiScript;
|
|
|
|
fn stock() -> MerchantState {
|
|
MerchantState {
|
|
items: vec![
|
|
MerchantItem {
|
|
name: Some("Refreshing Spring Water".into()),
|
|
texture: Some("Interface\\Icons\\INV_Drink_18".into()),
|
|
price: 25,
|
|
quantity: 1,
|
|
num_available: -1, // unlimited
|
|
item_id: 159,
|
|
// A consumable's stat head: everything zero but the quality.
|
|
stats: Some(ItemStatsHead {
|
|
quality: 1,
|
|
..Default::default()
|
|
}),
|
|
link: Some("|cffffffff|Hitem:159:0:0:0|h[Refreshing Spring Water]|h|r".into()),
|
|
},
|
|
// An in-flight row: the vendor list arrived, the item-template answer hasn't.
|
|
MerchantItem {
|
|
name: None,
|
|
texture: None,
|
|
price: 1500,
|
|
quantity: 1,
|
|
num_available: 3,
|
|
item_id: 4540,
|
|
stats: None,
|
|
link: None,
|
|
},
|
|
],
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn merchant_snapshot_reads() {
|
|
let mut s = UiScript::new().unwrap();
|
|
// No vendor open: count 0, info nil.
|
|
assert_eq!(s.eval::<i64>("return GetMerchantNumItems()").unwrap(), 0);
|
|
assert!(s
|
|
.eval::<bool>("return GetMerchantItemInfo(1) == nil")
|
|
.unwrap());
|
|
|
|
s.set_merchant(Some(stock()));
|
|
assert_eq!(s.eval::<i64>("return GetMerchantNumItems()").unwrap(), 2);
|
|
// Row 1 (resolved): the byte-verified 6-tuple (0x4fb150) — isUsable is 1, not a boolean.
|
|
let (name, texture, price, quantity, num, usable) = s
|
|
.eval::<(String, String, i64, i64, i64, i64)>("return GetMerchantItemInfo(1)")
|
|
.unwrap();
|
|
assert_eq!(name, "Refreshing Spring Water");
|
|
assert_eq!(texture, "Interface\\Icons\\INV_Drink_18");
|
|
assert_eq!((price, quantity, num), (25, 1, -1));
|
|
assert_eq!(usable, 1);
|
|
|
|
// Row 2 (in flight): name + texture nil, the rest still present — and usable (the
|
|
// null-record skip: no template answer, nothing to judge).
|
|
assert!(s
|
|
.eval::<bool>(
|
|
"local n, t, p, q, a, u = GetMerchantItemInfo(2)\n\
|
|
return n == nil and t == nil and p == 1500 and u == 1",
|
|
)
|
|
.unwrap());
|
|
// An invalid index answers the fixed 6-tuple (nil, nil, 0, 1, 0, 1) — 0x4fb2be.
|
|
assert!(s
|
|
.eval::<bool>(
|
|
"local n, t, p, q, a, u = GetMerchantItemInfo(9)\n\
|
|
return n == nil and t == nil and p == 0 and q == 1 and a == 0 and u == 1",
|
|
)
|
|
.unwrap());
|
|
|
|
// GetMerchantItemLink: the resolved row's link; nil while the template is in flight and nil
|
|
// out of range (the row click's ctrl/shift arms hand this straight on — decision 1059).
|
|
assert_eq!(
|
|
s.eval::<String>("return GetMerchantItemLink(1)").unwrap(),
|
|
"|cffffffff|Hitem:159:0:0:0|h[Refreshing Spring Water]|h|r"
|
|
);
|
|
assert!(s
|
|
.eval::<bool>("return GetMerchantItemLink(2) == nil")
|
|
.unwrap());
|
|
assert!(s
|
|
.eval::<bool>("return GetMerchantItemLink(9) == nil")
|
|
.unwrap());
|
|
}
|
|
|
|
/// The isUsable leg end-to-end: the gate reads the shared template store + the player req
|
|
/// state, so a level-short player reds the row (nil), levelling past it whitens (1), and the
|
|
/// same flag drives the buyback tuple.
|
|
#[test]
|
|
fn merchant_usable_tracks_the_item_gate() {
|
|
use crate::script::{ItemTemplateView, PlayerReqState};
|
|
let mut s = UiScript::new().unwrap();
|
|
let mut state = stock();
|
|
state.buyback = vec![MerchantItem {
|
|
name: Some("Light Mail Gloves".into()),
|
|
texture: Some("Interface\\Icons\\INV_Gauntlets_05".into()),
|
|
price: 47,
|
|
quantity: 1,
|
|
item_id: 2418,
|
|
..Default::default()
|
|
}];
|
|
s.set_merchant(Some(state));
|
|
s.set_item_template(
|
|
159, // row 1: the spring water becomes level-gated for the test
|
|
ItemTemplateView {
|
|
name: "Refreshing Spring Water".into(),
|
|
required_level: 5,
|
|
allowable_class: -1,
|
|
allowable_race: -1,
|
|
..Default::default()
|
|
},
|
|
);
|
|
s.set_item_template(
|
|
2418,
|
|
ItemTemplateView {
|
|
name: "Light Mail Gloves".into(),
|
|
required_level: 5,
|
|
allowable_class: -1,
|
|
allowable_race: -1,
|
|
..Default::default()
|
|
},
|
|
);
|
|
let req = |level| PlayerReqState {
|
|
level,
|
|
class_id: 1,
|
|
race_id: 1,
|
|
..Default::default()
|
|
};
|
|
s.set_player_req_state(req(4));
|
|
assert!(s
|
|
.eval::<bool>(
|
|
"local n, t, p, q, a, u = GetMerchantItemInfo(1)\nreturn u == nil and n ~= nil",
|
|
)
|
|
.unwrap());
|
|
assert!(s
|
|
.eval::<bool>(
|
|
"local n, t, p, q, a, u = GetBuybackItemInfo(1)\nreturn u == nil and n ~= nil",
|
|
)
|
|
.unwrap());
|
|
// Row 2 has no template pushed → still usable, whatever the player state.
|
|
assert!(s
|
|
.eval::<bool>("local n, t, p, q, a, u = GetMerchantItemInfo(2)\nreturn u == 1")
|
|
.unwrap());
|
|
// Levelling past the requirement whitens both.
|
|
s.set_player_req_state(req(5));
|
|
assert!(s
|
|
.eval::<bool>("local n, t, p, q, a, u = GetMerchantItemInfo(1)\nreturn u == 1")
|
|
.unwrap());
|
|
assert!(s
|
|
.eval::<bool>("local n, t, p, q, a, u = GetBuybackItemInfo(1)\nreturn u == 1")
|
|
.unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn merchant_stats_feed_reads_the_tooltip_head() {
|
|
let mut s = UiScript::new().unwrap();
|
|
let mut stock = stock();
|
|
// Row 1 becomes a sword so every stat column is distinguishable.
|
|
stock.items[0].stats = Some(ItemStatsHead {
|
|
quality: 2,
|
|
inventory_type: 21,
|
|
class: 2,
|
|
subclass: 7,
|
|
dmg_min: 5.0,
|
|
dmg_max: 9.0,
|
|
dmg_type: 2,
|
|
delay_ms: 2600,
|
|
armor: 0,
|
|
block: 0,
|
|
sell_price: 0,
|
|
});
|
|
s.set_merchant(Some(stock));
|
|
let (quality, inv, class, sub, dmin, dmax, dtype, delay, armor, block) = s
|
|
.eval::<(i64, i64, i64, i64, f64, f64, i64, i64, i64, i64)>(
|
|
"return BenillaGetMerchantItemStats(1)",
|
|
)
|
|
.unwrap();
|
|
assert_eq!((quality, inv, class, sub), (2, 21, 2, 7));
|
|
assert_eq!((dmin, dmax, dtype, delay), (5.0, 9.0, 2, 2600));
|
|
assert_eq!((armor, block), (0, 0));
|
|
// In flight (row 2) and out of range: nil.
|
|
assert!(s
|
|
.eval::<bool>("return BenillaGetMerchantItemStats(2) == nil")
|
|
.unwrap());
|
|
assert!(s
|
|
.eval::<bool>("return BenillaGetMerchantItemStats(9) == nil")
|
|
.unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn buy_merchant_item_queues_intents() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_merchant(Some(stock()));
|
|
s.run("BuyMerchantItem(1)").unwrap(); // default quantity 1
|
|
s.run("BuyMerchantItem(2, 5)").unwrap();
|
|
assert_eq!(s.take_merchant_buys(), vec![(1, 1), (2, 5)]);
|
|
assert!(s.take_merchant_buys().is_empty(), "drained");
|
|
}
|
|
|
|
#[test]
|
|
fn close_merchant_flags_the_intent() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_merchant(Some(stock()));
|
|
assert!(!s.take_merchant_close());
|
|
s.run("CloseMerchant()").unwrap();
|
|
assert!(s.take_merchant_close());
|
|
assert!(!s.take_merchant_close(), "drained");
|
|
}
|
|
|
|
/// The buyback surface: count, the ref tuple (including the 5875 binding's exact empty tuple
|
|
/// `(nil,nil,0,1,0,1)` for an invalid index — 0x4fb310), and the BuybackItem intent drain.
|
|
#[test]
|
|
fn buyback_reads_and_intents() {
|
|
let mut s = UiScript::new().unwrap();
|
|
assert_eq!(s.eval::<i64>("return GetNumBuybackItems()").unwrap(), 0);
|
|
assert!(s
|
|
.eval::<bool>(
|
|
"local n, t, p, q, a, u = GetBuybackItemInfo(1)\n\
|
|
return n == nil and t == nil and p == 0 and q == 1 and a == 0 and u == 1",
|
|
)
|
|
.unwrap());
|
|
|
|
let mut state = stock();
|
|
state.buyback = vec![MerchantItem {
|
|
name: Some("Worn Dagger".into()),
|
|
texture: Some("Interface\\Icons\\INV_Weapon_ShortBlade_01".into()),
|
|
price: 47,
|
|
quantity: 1,
|
|
stats: Some(ItemStatsHead {
|
|
quality: 1,
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
}];
|
|
s.set_merchant(Some(state));
|
|
assert_eq!(s.eval::<i64>("return GetNumBuybackItems()").unwrap(), 1);
|
|
let (name, _tex, price): (String, String, i64) =
|
|
s.eval("return GetBuybackItemInfo(1)").unwrap();
|
|
assert_eq!((name.as_str(), price), ("Worn Dagger", 47));
|
|
assert!(s
|
|
.eval::<bool>("return BenillaGetBuybackItemStats(1) ~= nil")
|
|
.unwrap());
|
|
|
|
s.run("BuybackItem(1)").unwrap();
|
|
assert_eq!(s.take_merchant_buybacks(), vec![1]);
|
|
assert!(s.take_merchant_buybacks().is_empty(), "drained");
|
|
}
|
|
|
|
/// The repair surface: CanMerchantRepair off the snapshot, GetRepairAllCost's (cost, canRepair)
|
|
/// pair, the RepairAllItems intent, and the client-side repair-mode latch trio.
|
|
#[test]
|
|
fn repair_reads_intents_and_mode_latch() {
|
|
let mut s = UiScript::new().unwrap();
|
|
assert!(s.eval::<bool>("return CanMerchantRepair() == nil").unwrap());
|
|
|
|
let mut state = stock();
|
|
state.can_repair = true;
|
|
state.repair_all_cost = 1234;
|
|
s.set_merchant(Some(state));
|
|
assert!(s.eval::<bool>("return CanMerchantRepair() == 1").unwrap());
|
|
assert!(s
|
|
.eval::<bool>("local c, can = GetRepairAllCost()\nreturn c == 1234 and can == true",)
|
|
.unwrap());
|
|
|
|
s.run("RepairAllItems()").unwrap();
|
|
assert!(s.take_repair_all());
|
|
assert!(!s.take_repair_all(), "drained");
|
|
|
|
// ShowRepairCursor arms, InRepairMode reads (1/nil), HideRepairCursor disarms.
|
|
assert!(s.eval::<bool>("return InRepairMode() == nil").unwrap());
|
|
s.run("ShowRepairCursor()").unwrap();
|
|
assert!(s.repair_mode());
|
|
assert!(s.eval::<bool>("return InRepairMode() == 1").unwrap());
|
|
s.run("HideRepairCursor()").unwrap();
|
|
assert!(!s.repair_mode());
|
|
}
|
|
|
|
#[test]
|
|
fn clearing_the_merchant_empties_it() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_merchant(Some(stock()));
|
|
s.set_merchant(None);
|
|
assert_eq!(s.eval::<i64>("return GetMerchantNumItems()").unwrap(), 0);
|
|
assert!(s
|
|
.eval::<bool>("return GetMerchantItemInfo(1) == nil")
|
|
.unwrap());
|
|
}
|
|
|
|
/// The vendor-item hover cursor (`ShowMerchantSellCursor`/`ShowBuybackSellCursor`, `0x4fbab0`/
|
|
/// `0x4fbbb0`): Buy(3) when the player can afford the row, the grayed UnableBuy(23) when they
|
|
/// can't; `ShowInspectCursor` arms the magnifier; `ResetCursor` clears the whole override; an
|
|
/// unresolvable index (or no vendor open) leaves the cursor unchanged.
|
|
#[test]
|
|
fn vendor_hover_cursor_gates_on_affordability() {
|
|
use crate::script::UiCursorMode;
|
|
let mut s = UiScript::new().unwrap();
|
|
|
|
// No vendor open: the Show* calls resolve nothing and leave the cursor untouched.
|
|
s.run("ShowMerchantSellCursor(1)").unwrap();
|
|
assert_eq!(s.ui_cursor(), None);
|
|
|
|
let mut state = stock(); // row 1 price 25, row 2 price 1500
|
|
state.buyback = vec![MerchantItem {
|
|
price: 47,
|
|
..Default::default()
|
|
}];
|
|
s.set_merchant(Some(state));
|
|
s.set_money(100); // affords row 1 (25) + buyback (47), not row 2 (1500)
|
|
|
|
// Row 1: coin ≥ price → Buy(3).
|
|
s.run("ShowMerchantSellCursor(1)").unwrap();
|
|
assert_eq!(s.ui_cursor(), Some(UiCursorMode::Buy));
|
|
// Row 2: coin < price → the grayed UnableBuy(23).
|
|
s.run("ShowMerchantSellCursor(2)").unwrap();
|
|
assert_eq!(s.ui_cursor(), Some(UiCursorMode::UnableBuy));
|
|
|
|
// The Ctrl-hover magnifier, then ResetCursor back to the base mode.
|
|
s.run("ShowInspectCursor()").unwrap();
|
|
assert_eq!(s.ui_cursor(), Some(UiCursorMode::Inspect));
|
|
s.run("ResetCursor()").unwrap();
|
|
assert_eq!(s.ui_cursor(), None);
|
|
|
|
// Buyback affords too → Buy(3).
|
|
s.run("ShowBuybackSellCursor(1)").unwrap();
|
|
assert_eq!(s.ui_cursor(), Some(UiCursorMode::Buy));
|
|
|
|
// An out-of-range index bails without touching the armed cursor (the binary's no-CursorSetMode
|
|
// fail path) — the Buy from the buyback hover above survives.
|
|
s.run("ShowMerchantSellCursor(99)").unwrap();
|
|
assert_eq!(s.ui_cursor(), Some(UiCursorMode::Buy));
|
|
}
|
|
}
|