//! The merchant bindings: the app pushes the open vendor ([`UiScript::set_merchant`]), and the //! Lua verbs queue intents it drains. A row is 1-based; the app maps it to the item entry //! `CMSG_BUY_ITEM` needs. //! //! `GetMerchantItemInfo(index)` answers `name, texture, price, quantity, numAvailable, isUsable` //! (`0x4fb150`), or `(nil, nil, 0, 1, 0, 1)` for an invalid index (`0x4fb2be`). Unlimited stock is //! `numAvailable` -1; `isUsable` is 1 or nil, the [`super::item_stats::item_usable`] gate, and a //! template still in flight reads usable (`0x4fb298`). use mlua::{Lua, MultiValue, Value}; use super::binding_abi::{flag, number_arg}; use super::container::UiCursorMode; use super::cursor::CursorPayload; use super::Model; /// One vendor row, resolved by the app; its 1-based index is its place in [`MerchantState::items`]. #[derive(Clone, Debug, Default, PartialEq)] pub struct MerchantItem { /// The item name; `None` while its template is in flight, answered as nil. pub name: Option, /// Icon path; `None` while the template is in flight. pub texture: Option, /// Buy price in copper, already reputation-discounted by the server. pub price: u32, /// Stack size per purchase (`item_template.buy_count`). pub quantity: u32, /// Remaining stock, -1 for unlimited (the wire's `0xFFFF_FFFF`). pub num_available: i32, /// The template entry `CMSG_BUY_ITEM` addresses; Lua never sees it. pub item_id: u32, /// The hover tooltip's stat head; `None` while the template is in flight. pub stats: Option, /// `GetMerchantItemLink`'s answer; `None` while in flight and on a buyback row, as 1.12 has no /// `GetBuybackItemLink` and the buyback click takes no modifier (`MerchantFrame.lua:358-361`). pub link: Option, /// The template's `stackable`, `GetMerchantItemMaxStack`'s answer for a row sold singly; /// `None` while in flight and on a buyback row. pub max_stack: Option, } /// An item template's tooltip stat head, resolved per row by the app, which `SetMerchantItem` and /// `SetBuybackItem` draw while the VM's template is in flight. The reference reads the template in /// C++ (`0x534080`); no 1.12 Lua API carries these. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct ItemStatsHead { /// 0 poor to 6 artifact; colours the tooltip's name line. pub quality: u32, /// `InventoryType`, the tooltip's slot line ("Main Hand", "Chest"); 0 for none. pub inventory_type: u32, /// Item class (2 weapon, 4 armor, 6 projectile); with `subclass`, the slot line's right side. pub class: u32, /// Item subclass within `class` (7 = sword, 1 = cloth, …). pub subclass: u32, /// Damage block 0's per-hit minimum, 0 for a non-weapon. pub dmg_min: f32, /// Damage block 0's per-hit maximum. pub dmg_max: f32, /// Damage block 0's school (0 physical, 1 Holy to 6 Arcane). pub dmg_type: u32, /// Attack delay in milliseconds; the tooltip's "Speed" is delay / 1000. pub delay_ms: u32, /// The armor line's value; 0 for none. pub armor: u32, /// A shield's block line; 0 for none. pub block: u32, /// What a vendor pays per unit; 0 shows the tooltip's "No sell price" line. pub sell_price: u32, } /// Each damaged item's repair cost in copper after the vendor's discount (`0x4faf30`). #[derive(Clone, Debug, Default, PartialEq)] pub struct RepairCosts { /// Equipped items by live inventory id. pub equipped: std::collections::HashMap, /// Container items by `(bag, slot)`: bags 0-4, the bank vault -1 and bank bags 5-10. pub bags: std::collections::HashMap<(i64, u32), u32>, } /// One open merchant window, pushed whole by the app; `None` means no vendor is open. #[derive(Clone, Debug, Default, PartialEq)] pub struct MerchantState { pub items: Vec, /// Buyback slots, oldest first; the last, the latest sale, is the one the merchant page shows /// (`MerchantFrame.lua:133`). From the player's VENDORBUYBACK fields: `price` is the buyback /// price, `quantity` the stored stack. pub buyback: Vec, /// Whether this vendor repairs (the `UNIT_NPC_FLAGS` repair bit). pub can_repair: bool, } impl super::UiScript { /// Push (or clear, with `None`) the open vendor. Clearing it leaves repair mode, as the /// merchant-close handler restores the Point base mode (`0x4fadf0`). pub fn set_merchant(&mut self, state: Option) { let closing = state.is_none(); self.model_mut().merchant = state; if closing { self.model_mut().repair_mode = false; } } /// Push `GetRepairAllCost`'s total in copper, swept by the app ahead of the events that read it. pub fn set_repair_all_cost(&mut self, copper: u32) { 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) } /// Drain the `(bag, slot)` of each cursor item dropped on the vendor; the app sells it. pub fn take_merchant_cursor_sells(&mut self) -> Vec<(i64, u32)> { std::mem::take(&mut self.model_mut().merchant_cursor_sells) } /// Drain the `(bag, slot, entry)` of held rows dropped into bags (`CMSG_BUY_ITEM_IN_SLOT`). pub fn take_merchant_slot_buys(&mut self) -> Vec<(i64, u32, u32)> { std::mem::take(&mut self.model_mut().merchant_slot_buys) } /// Whether `CloseMerchant` was called since the last drain; the close sends no packet. pub fn take_merchant_close(&mut self) -> bool { std::mem::take(&mut self.model_mut().merchant_close) } /// Drain the 1-based buyback slots `BuybackItem` queued. pub fn take_merchant_buybacks(&mut self) -> Vec { std::mem::take(&mut self.model_mut().merchant_buybacks) } /// Whether `RepairAllItems` was called since the last drain. pub fn take_repair_all(&mut self) -> bool { std::mem::take(&mut self.model_mut().repair_all) } /// The repair-mode latch: while set, a bag or equipment click repairs the item. pub fn repair_mode(&self) -> bool { self.model_ref().repair_mode } /// Leave repair mode, as `HideRepairCursor` does, for the world's right mouse-down, which /// resets the Repair base mode to Point (`0x492c68`). pub fn end_repair_mode(&mut self) { self.model_mut().repair_mode = false; } } /// 1 or nil, as the client pushes a usable flag (`pushnumber(1.0)` / `pushnil`). fn usable_value(usable: bool) -> Value { if usable { Value::Integer(1) } else { Value::Nil } } /// Both getters' invalid-index six-tuple (`0x4fb2be`, and the buyback one). fn invalid_tuple() -> Vec { vec![ Value::Nil, Value::Nil, Value::Integer(0), Value::Integer(1), Value::Integer(0), Value::Integer(1), ] } /// The row `PickupMerchantItem(index)` grabs, read before its clear: `None` for each silent /// refusal, a non-number (`0x4fb7cb`; a numeric string passes), `index < 1` (`0x4fb7e1`), /// `index > count` (`0x4fb7e9`), no merchant (`0x4fb7f7`), a dead row (`0x4fb80b`), or the held /// row again (`0x4fb818`, the toggle-off). Neither stock nor the item cache is tested: a sold-out /// row grabs, and the server refuses the buy. fn merchant_grab(model: &Model, index: Option) -> Option { let held = match &model.cursor { Some(CursorPayload::Merchant(m)) => Some(m.row), _ => None, }; // `_ftol` truncates toward zero, then the bound applies signed. The upper bound is // load-bearing: `2^32 + 1` would narrow to row 0, where the reference bounds against the // vendor count before any narrowing. let row = index?.trunc(); if row.is_nan() || row < 1.0 || row > f64::from(u32::MAX) { return None; } #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] let row0 = row as u32 - 1; let item = model.merchant.as_ref()?.items.get(row0 as usize)?; // The toggle-off: naming the held row puts nothing back. if held == Some(row0) { return None; } Some(CursorPayload::Merchant(super::cursor::CursorMerchantItem { item_id: item.item_id, row: row0, texture: item.texture.clone(), })) } /// Register the merchant globals. pub(super) fn install(lua: &Lua) -> mlua::Result<()> { let g = lua.globals(); g.set( "GetMerchantNumItems", lua.create_function(|lua, ()| { let model = lua.app_data_ref::().expect("model app_data"); Ok(model.merchant.as_ref().map_or(0, |m| m.items.len()) as i64) })?, )?; g.set( "GetMerchantItemInfo", lua.create_function(|lua, index: usize| { let (item, usable) = { let model = lua.app_data_ref::().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): nil out of range and while in flight; both stock row-click // arms accept a nil (`MerchantFrame.lua:303`, `:306`). g.set( "GetMerchantItemLink", lua.create_function(|lua, index: usize| { let link = { let model = lua.app_data_ref::().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), } })?, )?; // GetMerchantItemMaxStack(index) (`0x4fb670`): the template's stack size, or 1 for a row sold // in bundles (`[row+0x18] > 1`) and for every miss: out of range, no vendor, a template in // flight (`0x4fb739`). A right shift-click, or a left one with the chat box closed, asks it and // opens the stack-split spinner only above 1 (`MerchantFrame.lua:313`, `:340`). g.set( "GetMerchantItemMaxStack", lua.create_function(|lua, index: Value| { let index = number_arg(lua, index, "Usage: GetMerchantItemMaxStack(index)")?; let model = lua.app_data_ref::().expect("model app_data"); // Signed, as 1.12.1 reads both. let max_stack = usize::try_from(index) .ok() .and_then(|i| i.checked_sub(1)) .and_then(|n| model.merchant.as_ref()?.items.get(n)) .filter(|it| it.quantity as i32 <= 1) .and_then(|it| it.max_stack) .map_or(1, |n| n as i32); Ok(i64::from(max_stack)) })?, )?; g.set( "BuyMerchantItem", lua.create_function(|lua, (index, quantity): (u32, Option)| { let mut model = lua.app_data_mut::().expect("model app_data"); model.merchant_buys.push((index, quantity.unwrap_or(1))); Ok(()) })?, )?; // PickupMerchantItem(index): two verbs in one (`0x4fb760`), forked at `0x4fb787` on // `GetCursorItem 0x494c60`, which answers for mode 1 only; stock never checks // `CursorHasItem()` first (`MerchantFrame.lua:329`). A bag item on the cursor is sold, index // ignored and no merchant-open gate, which stock `MerchantFrame.xml:743`'s // `PickupMerchantItem(0)` relies on; otherwise row `index - 1` is grabbed as cursor mode 5. // Every refusal ([`merchant_grab`]) is silent and clears the cursor. g.set( "PickupMerchantItem", lua.create_function(|lua, index: Option| { // The sell fork comes first, before the argument is read. Its clear is the sale's, not // `ClearCursor`: `CURSOR_UPDATE` fires and the source slot stays greyed until the // server's inventory update (`0x494b60`). Every other leg reads the row, then clears. let grab = super::tick::verb(lua, |model| { if let Some(item) = crate::script::cursor::take_cursor_item_for_sale(model) { model.merchant_cursor_sells.push((item.bag, item.slot)); return None; } Some(merchant_grab(model, index)) }); let Some(grab) = grab else { return Ok(()) }; // Every other leg clears with the real `ClearCursor(1,1)` (`0x4fb82d`/`0x4fb83f` on a // refusal, `0x495112` in the grab), not a bare drop: a held spell fires `CURSOR_UPDATE` // and hides the bar grid, and an armed gift wrap is cancelled (`0x5edf10`). Its events // run before the grab sets the row, so that `CURSOR_UPDATE` reads an empty cursor. super::tick::verb(lua, crate::script::cursor::clear_cursor); if let Some(payload) = grab { // The grab's `SignalEvent(CURSOR_UPDATE)` (`0x49515e`); mode 5 skips the mode-7 // `ACTIONBAR_SHOWGRID` branch. super::tick::verb(lua, |model| { model.cursor = Some(payload); crate::script::cursor::raise_cursor_update(model); }); } Ok(()) })?, )?; g.set( "CloseMerchant", lua.create_function(|lua, ()| { let mut model = lua.app_data_mut::().expect("model app_data"); model.merchant_close = true; Ok(()) })?, )?; g.set( "GetNumBuybackItems", lua.create_function(|lua, ()| { let model = lua.app_data_ref::().expect("model app_data"); Ok(model.merchant.as_ref().map_or(0, |m| m.buyback.len()) as i64) })?, )?; // GetBuybackItemInfo(index): the same six-tuple (`0x4fb310`), oldest first. isUsable is the // same `0x5ea930` gate over the sold item (`0x4fb4f7`): one its seller cannot use reads red. g.set( "GetBuybackItemInfo", lua.create_function(|lua, index: usize| { let (item, usable) = { let model = lua.app_data_ref::().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), ])) })?, )?; g.set( "BuybackItem", lua.create_function(|lua, index: u32| { let mut model = lua.app_data_mut::().expect("model app_data"); model.merchant_buybacks.push(index); Ok(()) })?, )?; g.set( "CanMerchantRepair", lua.create_function(|lua, ()| { let can = { let model = lua.app_data_ref::().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: whether there is damage to pay for, which enables the // repair-all button (`MerchantFrame.lua:38`). `canRepair` is 1 or nil (`0x4fbfa8`-`0x4fbfce`). // With no open merchant that repairs (`0x4fadb0`) it answers the one value 0 (`0x4fbd76`). g.set( "GetRepairAllCost", lua.create_function(|lua, ()| { let model = lua.app_data_ref::().expect("model app_data"); if !model.merchant.as_ref().is_some_and(|m| m.can_repair) { return Ok(MultiValue::from_vec(vec![Value::Integer(0)])); } let cost = model.repair_all_cost; Ok(MultiValue::from_vec(vec![ Value::Integer(i64::from(cost)), flag(cost > 0), ])) })?, )?; g.set( "RepairAllItems", lua.create_function(|lua, ()| { let mut model = lua.app_data_mut::().expect("model app_data"); // `0x4fbff0`: nothing, not even the purse test, unless the open merchant repairs // (`0x4fadb0`, `UNIT_NPC_FLAGS` bit 14). if model.merchant.as_ref().is_some_and(|m| m.can_repair) { model.repair_all = true; } Ok(()) })?, )?; g.set( "ShowRepairCursor", lua.create_function(|lua, ()| { // `0x4fbcc0`: a targeting spell or a vendor without repair service leaves the // cursor untouched. `ClearCursor(1,1)` returns any held item before mode 0x11 arms, // its events running first. { let model = lua.app_data_ref::().expect("model app_data"); if model.spell_targeting || !model.merchant.as_ref().is_some_and(|m| m.can_repair) { return Ok(()); } } super::tick::verb(lua, crate::script::cursor::clear_cursor); let mut model = lua.app_data_mut::().expect("model app_data"); model.repair_mode = true; model.ui_cursor = None; model.ui_cursor_dirty = true; Ok(()) })?, )?; g.set( "HideRepairCursor", lua.create_function(|lua, ()| { lua.app_data_mut::() .expect("model app_data") .repair_mode = false; Ok(()) })?, )?; g.set( "InRepairMode", lua.create_function(|lua, ()| { let mode = lua .app_data_ref::() .expect("model app_data") .repair_mode; Ok(if mode { Value::Integer(1) } else { Value::Nil }) })?, )?; // ShowMerchantSellCursor(index) (`0x4fbab0`) and ShowBuybackSellCursor(index) (`0x4fbbb0`): // despite the names, the buy cursor, re-armed each frame an item is hovered // (`MerchantFrame.xml:726-733`). Every fail path returns without `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 hover cursor from a row's price: Buy(3) if the player can afford it, else the /// greyed UnableBuy(23); no price leaves the cursor untouched. fn arm_vendor_cursor(lua: &Lua, price_of: impl FnOnce(&MerchantState) -> Option) { let mut model = lua.app_data_mut::().expect("model app_data"); // The family bails on `IsTargeting` first (`0x6e48a0`): an armed spell keeps its 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::{ContainerSlot, ContainerState, 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: 5, num_available: -1, // unlimited item_id: 159, stats: Some(ItemStatsHead { quality: 1, ..Default::default() }), link: Some("|cffffffff|Hitem:159:0:0:0|h[Refreshing Spring Water]|h|r".into()), max_stack: Some(20), }, // In flight: the list arrived, its item template has not. MerchantItem { name: None, texture: None, price: 1500, quantity: 1, num_available: 3, item_id: 4540, stats: None, link: None, max_stack: None, // in flight, like `name` above }, ], ..Default::default() } } #[test] fn merchant_snapshot_reads() { let mut s = UiScript::new().unwrap(); assert_eq!(s.eval::("return GetMerchantNumItems()").unwrap(), 0); assert!(s .eval::("return GetMerchantItemInfo(1) == nil") .unwrap()); s.set_merchant(Some(stock())); assert_eq!(s.eval::("return GetMerchantNumItems()").unwrap(), 2); 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, 5, -1)); assert_eq!(usable, 1); // In flight: name and texture nil, and usable (the null-record skip). assert!(s .eval::( "local n, t, p, q, a, u = GetMerchantItemInfo(2)\n\ return n == nil and t == nil and p == 1500 and u == 1", ) .unwrap()); assert!(s .eval::( "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()); assert_eq!( s.eval::("return GetMerchantItemLink(1)").unwrap(), "|cffffffff|Hitem:159:0:0:0|h[Refreshing Spring Water]|h|r" ); assert!(s .eval::("return GetMerchantItemLink(2) == nil") .unwrap()); assert!(s .eval::("return GetMerchantItemLink(9) == nil") .unwrap()); } #[test] fn merchant_max_stack_is_one_for_bundles_and_misses() { let mut s = UiScript::new().unwrap(); let max = |s: &mut UiScript, arg: &str| { s.eval::(&format!("return GetMerchantItemMaxStack({arg})")) .unwrap() }; assert_eq!(max(&mut s, "1"), 1, "no vendor open"); let mut state = stock(); let single = |quantity: u32, max_stack: u32| MerchantItem { name: Some("Flask of Oil".into()), price: 100, quantity, num_available: -1, item_id: 814, max_stack: Some(max_stack), ..Default::default() }; state.items.push(single(1, 20)); state.items.push(single(1, 0)); state.items.push(single(0x8000_0000, u32::MAX)); s.set_merchant(Some(state)); assert_eq!(max(&mut s, "1"), 1, "a bundle row takes no split"); assert_eq!(max(&mut s, "2"), 1, "a template in flight"); assert_eq!(max(&mut s, "3"), 20, "a single row answers its stack size"); assert_eq!(max(&mut s, "4"), 0, "the stack size is not floored"); assert_eq!(max(&mut s, "5"), -1, "both fields read signed"); for out_of_range in ["0", "-1", "6", "9"] { assert_eq!(max(&mut s, out_of_range), 1, "index {out_of_range}"); } assert_eq!(max(&mut s, "\"3\""), 20); assert_eq!(max(&mut s, "3.9"), 20); for bad in ["", "nil", "\"x\"", "{}"] { let err = s .eval::(&format!("return GetMerchantItemMaxStack({bad})")) .unwrap_err() .to_string(); assert!( err.contains("Usage: GetMerchantItemMaxStack(index)"), "{bad:?}: {err}" ); } } #[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, 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::( "local n, t, p, q, a, u = GetMerchantItemInfo(1)\nreturn u == nil and n ~= nil", ) .unwrap()); assert!(s .eval::( "local n, t, p, q, a, u = GetBuybackItemInfo(1)\nreturn u == nil and n ~= nil", ) .unwrap()); // Row 2 has no template, so it is usable whatever the player state. assert!(s .eval::("local n, t, p, q, a, u = GetMerchantItemInfo(2)\nreturn u == 1") .unwrap()); s.set_player_req_state(req(5)); assert!(s .eval::("local n, t, p, q, a, u = GetMerchantItemInfo(1)\nreturn u == 1") .unwrap()); assert!(s .eval::("local n, t, p, q, a, u = GetBuybackItemInfo(1)\nreturn u == 1") .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"); } #[test] fn buyback_reads_and_intents() { let mut s = UiScript::new().unwrap(); assert_eq!(s.eval::("return GetNumBuybackItems()").unwrap(), 0); assert!(s .eval::( "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::("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)); s.run("BuybackItem(1)").unwrap(); assert_eq!(s.take_merchant_buybacks(), vec![1]); assert!(s.take_merchant_buybacks().is_empty(), "drained"); } /// Away from a vendor that repairs, the total reads 0, as `0x4fbd60` pushes there. #[test] fn the_repair_all_total_reads_zero_away_from_a_repairer() { let mut s = UiScript::new().unwrap(); s.set_repair_all_cost(1234); assert!( s.eval::("return GetRepairAllCost() == 0").unwrap(), "no vendor" ); assert_eq!( s.arity("GetRepairAllCost()").unwrap(), 1, "no vendor: `0x4fbd76`" ); s.set_merchant(Some(stock())); assert!( s.eval::("return GetRepairAllCost() == 0").unwrap(), "a vendor that does not repair" ); assert_eq!( s.arity("GetRepairAllCost()").unwrap(), 1, "one value, 0, when `0x4fadb0` fails (`0x4fbd76`)" ); } /// `RepairAllItems` returns before anything else unless the open merchant repairs /// (`0x4fadb0`, tested at `0x4fbfe9`–`0x4fbff0`): no vendor, or one without the repair flag, /// queues nothing. #[test] fn repair_all_queues_nothing_away_from_a_repairer() { let mut s = UiScript::new().unwrap(); s.run("RepairAllItems()").unwrap(); assert!(!s.take_repair_all(), "no vendor"); s.set_merchant(Some(stock())); s.run("RepairAllItems()").unwrap(); assert!(!s.take_repair_all(), "a vendor that does not repair"); } #[test] fn repair_reads_intents_and_mode_latch() { let mut s = UiScript::new().unwrap(); assert!(s.eval::("return CanMerchantRepair() == nil").unwrap()); let mut state = stock(); state.can_repair = true; s.set_merchant(Some(state)); s.set_repair_all_cost(1234); assert!(s.eval::("return CanMerchantRepair() == 1").unwrap()); assert!(s .eval::("local c, can = GetRepairAllCost()\nreturn c == 1234 and can == 1",) .unwrap()); // No damage: `canRepair` is nil, not false (`0x4fbfce`). s.set_repair_all_cost(0); assert!(s .eval::("local c, can = GetRepairAllCost()\nreturn c == 0 and can == nil",) .unwrap()); assert_eq!(s.arity("GetRepairAllCost()").unwrap(), 2); s.run("RepairAllItems()").unwrap(); assert!(s.take_repair_all()); assert!(!s.take_repair_all(), "drained"); assert!( s.take_sounds().is_empty(), "the stock button's OnClick plays ITEM_REPAIR" ); assert!(s.eval::("return InRepairMode() == nil").unwrap()); s.run("ShowRepairCursor()").unwrap(); assert!(s.repair_mode()); assert!(s.eval::("return InRepairMode() == 1").unwrap()); assert_eq!(s.ui_cursor(), None, "repair clears a stale hover override"); s.run("HideRepairCursor()").unwrap(); assert!(!s.repair_mode()); s.run("ShowRepairCursor()").unwrap(); s.set_merchant(None); assert!( !s.repair_mode(), "closing the merchant hides the repair cursor" ); } #[test] fn show_repair_cursor_checks_targeting_and_vendor_then_returns_held_item() { let mut s = UiScript::new().unwrap(); let mut bag = ContainerState { num_slots: 1, ..Default::default() }; bag.slots.insert( 1, ContainerSlot { item_id: 117, count: 1, ..Default::default() }, ); s.set_container(0, Some(bag)); s.run("PickupContainerItem(0, 1)").unwrap(); assert!(s.cursor_item().is_some()); s.run("ShowRepairCursor()").unwrap(); assert!(!s.repair_mode(), "no vendor cannot arm repair"); assert!(s.cursor_item().is_some(), "early return keeps held item"); s.set_merchant(Some(stock())); s.run("ShowRepairCursor()").unwrap(); assert!(!s.repair_mode(), "non-repair vendor cannot arm repair"); assert!(s.cursor_item().is_some()); let mut vendor = stock(); vendor.can_repair = true; s.set_merchant(Some(vendor)); s.set_spell_targeting(true); s.run("ShowRepairCursor()").unwrap(); assert!(!s.repair_mode(), "targeting spell wins over repair"); assert!(s.cursor_item().is_some()); s.set_spell_targeting(false); // The clear's `CURSOR_UPDATE` (`0x4fbcd9`) runs before repair mode arms. crate::script::tick::heard::listen(&s, &["CURSOR_UPDATE"], Some("InRepairMode()")); s.run("ShowRepairCursor()").unwrap(); assert_eq!(crate::script::tick::heard::take(&s), ["CURSOR_UPDATE nil"]); assert!(s.repair_mode()); assert!( s.cursor_item().is_none(), "ClearCursor returned the held item" ); assert!(s .eval::("local _, _, locked = GetContainerItemInfo(0, 1) return not locked") .unwrap()); assert!(s.take_container_moves().is_empty()); } /// The targeting arm writes the Cast base mode over Repair (`0x6e50b0`) and its end restores /// Point (`0x6e49f5`, `0x6e554c`), so arming ends repair mode for good. The idle feed pushes /// false every frame, which must leave repair mode standing. #[test] fn arming_spell_targeting_ends_repair_mode_and_disarming_does_not_restore_it() { let mut s = UiScript::new().unwrap(); let mut vendor = stock(); vendor.can_repair = true; s.set_merchant(Some(vendor)); s.run("ShowRepairCursor()").unwrap(); s.set_spell_targeting(false); assert!(s.repair_mode(), "the idle feed keeps repair mode"); s.set_spell_targeting(true); assert!(!s.repair_mode(), "the targeting arm ends repair mode"); assert!(s.eval::("return InRepairMode() == nil").unwrap()); s.set_spell_targeting(false); assert!( !s.repair_mode(), "ending the targeting restores Point, not Repair" ); assert!(s.eval::("return InRepairMode() == nil").unwrap()); } #[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::("return GetMerchantNumItems()").unwrap(), 0); assert!(s .eval::("return GetMerchantItemInfo(1) == nil") .unwrap()); } #[test] fn vendor_hover_cursor_gates_on_affordability() { use crate::script::UiCursorMode; let mut s = UiScript::new().unwrap(); 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) s.run("ShowMerchantSellCursor(1)").unwrap(); assert_eq!(s.ui_cursor(), Some(UiCursorMode::Buy)); s.run("ShowMerchantSellCursor(2)").unwrap(); assert_eq!(s.ui_cursor(), Some(UiCursorMode::UnableBuy)); s.run("ShowInspectCursor()").unwrap(); assert_eq!(s.ui_cursor(), Some(UiCursorMode::Inspect)); s.run("ResetCursor()").unwrap(); assert_eq!(s.ui_cursor(), None); s.run("ShowBuybackSellCursor(1)").unwrap(); assert_eq!(s.ui_cursor(), Some(UiCursorMode::Buy)); // Out of range leaves the armed cursor as it was. s.run("ShowMerchantSellCursor(99)").unwrap(); assert_eq!(s.ui_cursor(), Some(UiCursorMode::Buy)); } #[test] fn pickup_merchant_item_grabs_a_row_as_mode_5() { let mut s = UiScript::new().unwrap(); s.set_merchant(Some(stock())); s.run("PickupMerchantItem(1)").unwrap(); // Mode 5 is invisible to Lua: every stock `CursorHasItem()` gate stays closed. assert!( s.eval::("return not CursorHasItem()").unwrap(), "CursorHasItem is nil for mode 5" ); assert!(s.eval::("return not CursorHasSpell()").unwrap()); // Yet it is held: a second call toggles it off (`0x4fb818`), a third re-grabs. s.run("PickupMerchantItem(1)").unwrap(); s.run("PickupMerchantItem(1)").unwrap(); assert!(s.take_merchant_slot_buys().is_empty(), "no drop yet"); } #[test] fn pickup_merchant_item_refuses_silently_and_clears() { let mut s = UiScript::new().unwrap(); s.set_merchant(Some(stock())); for bad in [ "PickupMerchantItem(0)", "PickupMerchantItem(-1)", "PickupMerchantItem(99)", ] { s.run("PickupMerchantItem(1)").unwrap(); // hold something first s.run(bad) .unwrap_or_else(|e| panic!("{bad} must not raise: {e}")); assert!(s.cursor_payload().is_none(), "{bad} cleared the cursor"); } // A missing argument is the same silent clear. s.run("PickupMerchantItem(1)").unwrap(); s.run("PickupMerchantItem()").unwrap(); // `2^32 + 1` narrows to row 0 on a naive cast. s.run("PickupMerchantItem(4294967297)").unwrap(); assert!(s.cursor_payload().is_none()); s.run("PickupContainerItem(0, 3)").unwrap(); assert!( s.take_merchant_slot_buys().is_empty(), "the wrapping index grabbed nothing" ); // A numeric string is accepted, and 1.9 truncates to the held row 1, toggling it off. s.run(r#"PickupMerchantItem("1")"#).unwrap(); s.run("PickupMerchantItem(1.9)").unwrap(); assert!(s.cursor_payload().is_none()); } #[test] fn pickup_merchant_item_sells_what_the_cursor_holds() { use crate::script::cursor::{CursorItem, CursorPayload}; let mut s = UiScript::new().unwrap(); s.set_merchant(Some(stock())); s.model_mut().cursor = Some(CursorPayload::Item(CursorItem { bag: 0, slot: 7, item_id: 4540, texture: None, link: None, count: None, quality: None, equip_slots: Vec::new(), bar_placeable: false, })); s.run("PickupMerchantItem(0)").unwrap(); assert_eq!( s.take_merchant_cursor_sells(), vec![(0, 7)], "index 0 is ignored — the cursor decides" ); assert!(s.eval::("return not CursorHasItem()").unwrap()); assert!(s.take_merchant_cursor_sells().is_empty(), "drained"); } #[test] fn a_held_vendor_row_dropped_in_a_bag_is_a_buy() { let mut s = UiScript::new().unwrap(); s.set_merchant(Some(stock())); s.run("PickupMerchantItem(1)").unwrap(); s.run("PickupContainerItem(0, 3)").unwrap(); assert_eq!( s.take_merchant_slot_buys(), vec![(0, 3, 159)], "the row's item entry, aimed at the dropped-on slot" ); assert!( s.cursor_payload().is_none(), "the cursor clears on the drop" ); } /// Deviation: a stale row drops without buying on all three drop paths, because two of the /// reference's three dereference a null here (a fresh `SMSG_LIST_INVENTORY` rewrites the count /// without clearing the cursor); we take the third path's behaviour. #[test] fn a_stale_vendor_row_drops_without_buying() { let mut s = UiScript::new().unwrap(); s.set_merchant(Some(stock())); s.run("PickupMerchantItem(2)").unwrap(); // The list is replaced while the cursor holds row 2 of the old one. let mut shorter = stock(); shorter.items.truncate(1); s.set_merchant(Some(shorter)); s.run("PickupContainerItem(0, 3)").unwrap(); assert!( s.take_merchant_slot_buys().is_empty(), "no buy goes out for a row that no longer resolves" ); assert!(s.cursor_payload().is_none()); } #[test] fn the_action_bar_refuses_a_vendor_row_and_keeps_it_held() { let mut s = UiScript::new().unwrap(); s.set_merchant(Some(stock())); s.run("PickupMerchantItem(1)").unwrap(); s.run("PlaceAction(1)").unwrap(); // Still held: dropping it in a bag afterwards buys. s.run("PickupContainerItem(0, 5)").unwrap(); assert_eq!(s.take_merchant_slot_buys(), vec![(0, 5, 159)]); } /// Count the cursor's three events from Lua, the way a stock handler would see them. fn listen_cursor_events(s: &mut UiScript) { s.run( r#" updates, shows, hides = 0, 0, 0 local f = CreateFrame("Frame", "MerchantCursorListener") f:RegisterEvent("CURSOR_UPDATE") f:RegisterEvent("ACTIONBAR_SHOWGRID") f:RegisterEvent("ACTIONBAR_HIDEGRID") f:SetScript("OnEvent", function() if event == "CURSOR_UPDATE" then updates = updates + 1 end if event == "ACTIONBAR_SHOWGRID" then shows = shows + 1 end if event == "ACTIONBAR_HIDEGRID" then hides = hides + 1 end end) "#, ) .unwrap(); } /// `(CURSOR_UPDATE, ACTIONBAR_SHOWGRID, ACTIONBAR_HIDEGRID)` delivered so far. fn cursor_event_counts(s: &mut UiScript) -> (i64, i64, i64) { s.tick(0.01); let n = |g: &str| s.eval::(&format!("return {g}")).unwrap(); (n("updates"), n("shows"), n("hides")) } /// The grab goes through the cursor setter (`0x4950f0`): its `ClearCursor(1,1)` (`0x495112`) /// signals `CURSOR_UPDATE` from the clear's tail, the set signals it again (`0x495159`), and no /// `ACTIONBAR_SHOWGRID` since mode 5 is not mode 7. #[test] fn a_vendor_grab_fires_cursor_update_but_not_the_bar_grid() { let mut s = UiScript::new().unwrap(); s.set_merchant(Some(stock())); listen_cursor_events(&mut s); s.run("PickupMerchantItem(1)").unwrap(); assert_eq!( cursor_event_counts(&mut s), (2, 0, 0), "(CURSOR_UPDATE, ACTIONBAR_SHOWGRID, ACTIONBAR_HIDEGRID) after a vendor grab" ); // The toggle-off is a plain clear: one more CURSOR_UPDATE, no HIDEGRID. s.run("PickupMerchantItem(1)").unwrap(); assert_eq!(cursor_event_counts(&mut s), (3, 0, 0)); } #[test] fn a_spell_dropped_on_the_vendor_is_a_real_clear() { use crate::script::cursor::{self, CursorPayload, CursorSpell}; let mut s = UiScript::new().unwrap(); s.set_merchant(Some(stock())); listen_cursor_events(&mut s); s.set_cursor_for_test(CursorPayload::Spell(CursorSpell { book_slot: 1, book_type: "spell".into(), spell_id: 133, texture: None, passive: false, })); cursor::raise_cursor_update(&mut s.model_mut()); // the pickup's own transition assert_eq!(cursor_event_counts(&mut s), (1, 1, 0), "the spell pickup"); s.run("PickupMerchantItem(0)").unwrap(); assert!(s.cursor_payload().is_none()); assert_eq!( cursor_event_counts(&mut s), (2, 1, 1), "(CURSOR_UPDATE, ACTIONBAR_SHOWGRID, ACTIONBAR_HIDEGRID) after the vendor drop" ); } /// `ClearCursor 0x495190` opens with the gift-wrap cancel (`0x5edf10`), and the grab opens /// with that clear (`0x49510b`). #[test] fn a_vendor_grab_cancels_an_armed_gift_wrap() { let mut s = UiScript::new().unwrap(); s.set_merchant(Some(stock())); s.arm_gift_wrap(0, 1); s.run("PickupMerchantItem(1)").unwrap(); assert_eq!( s.gift_wrap_armed(), None, "the grab's ClearCursor cancels the wrap" ); } }