From 36b1c93fcdbb35f3f3958ad3ddac1d9fb01adeee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juozas=20Polikevi=C4=8Dius?= Date: Sat, 26 Sep 2026 09:42:44 +0300 Subject: [PATCH] Repair mode: bag and worn-item clicks in 1.12.1's order, right-clicks clearing the cursor first (#378) - fix: repair mode - repair mode: a right-click puts a held payload back, then repairs the item - repair clicks sound ITEM_REPAIR as they are sent, under the kit's no-duplicates gate - the repair-all button is no longer driven from Rust - repair mode: the click bindings' order, for every cursor payload - worn-item repair clicks drain beside the bag's, in no system of their own Fixes #147 Fixes #148 Co-Authored-By: Claude Opus 5.5 (1M context) Co-Authored-By: Sam --- crates/benilla-app/src/ui_items/drain.rs | 145 ++++++++++++- crates/benilla-formats/src/sound_entries.rs | 4 + crates/benilla-ui/src/script/char_stats.rs | 5 + crates/benilla-ui/src/script/container.rs | 223 ++++++++++++++++++-- crates/benilla-ui/src/script/cursor/doll.rs | 113 +++++++++- crates/benilla-ui/src/script/merchant.rs | 83 +++++++- crates/benilla-ui/src/script/model.rs | 3 + 7 files changed, 544 insertions(+), 32 deletions(-) diff --git a/crates/benilla-app/src/ui_items/drain.rs b/crates/benilla-app/src/ui_items/drain.rs index 4d4cc069..6bb50950 100644 --- a/crates/benilla-app/src/ui_items/drain.rs +++ b/crates/benilla-app/src/ui_items/drain.rs @@ -223,8 +223,10 @@ pub(super) fn drain_container_uses( let Some(mut script) = script else { return; }; - // Repair-mode clicks (the reference's `0x4f9c7b` route) repair the one item. The reference's - // affordability check (error 0x25) is not built; the server refuses instead. + // Repair-mode clicks (the reference's `0x4f9c7b` route) repair the one item, sounding + // `ITEM_REPAIR` as it goes out (`0x4f9ce4`). The reference's affordability check before that + // (error 0x25, silent) is not built: our costs lack its reputation discount, so the server + // refuses instead. for (bag, slot) in script.take_container_repairs() { let Some(vendor) = merchant.vendor else { continue; @@ -237,6 +239,7 @@ pub(super) fn drain_container_uses( match item_guid { Some(guid) => { debug!("ui_items: repair lua bag {bag} slot {slot} (item {guid:#x})"); + script.queue_sound_kit("ITEM_REPAIR"); let _ = ladder.commands.0.send(ClientCommand::RepairItem { vendor, item_guid: guid, @@ -245,6 +248,29 @@ pub(super) fn drain_container_uses( None => debug!("ui_items: repair on empty slot (bag {bag} slot {slot}) — ignored"), } } + // The paper doll's repair clicks (`0x4c7714`, `0x4c79c4`), the same arm on a worn item. + for id in script.take_inventory_repairs() { + let Some(vendor) = merchant.vendor else { + continue; + }; + let item_guid = u8::try_from(id.wrapping_sub(1)).ok().and_then(|slot0| { + self_q + .iter() + .next() + .and_then(|store| slot_guid(&store.0, EQUIPMENT_BAG, slot0, &ladder.objects)) + }); + match item_guid { + Some(guid) if (1..=19).contains(&id) => { + debug!("ui_items: repair worn lua slot {id} (item {guid:#x})"); + script.queue_sound_kit("ITEM_REPAIR"); + let _ = ladder.commands.0.send(ClientCommand::RepairItem { + vendor, + item_guid: guid, + }); + } + _ => debug!("ui_items: repair on worn lua slot {id}, empty or out of range — ignored"), + } + } for (bag, slot) in script.take_container_uses() { // Any right-click, a sell or a deposit included, cancels an armed gift wrap: the // reference's use path clears the cursor first (`0x4fa198`). @@ -804,6 +830,121 @@ mod tests { ); } + /// A repair-mode right-click on a bag item: `ITEM_REPAIR` as it goes out (`0x4f9ce4`), then + /// `CMSG_REPAIR_ITEM` with the vendor's and the item's guid, and no use or sale. + #[test] + fn a_repair_click_sounds_and_ships_the_items_guid() { + const VENDOR: u64 = 0xF130_0000_0000_0042; + let (mut app, rx) = open_the_clam(); + while rx.try_recv().is_ok() {} // drain the clam's own send + app.world_mut() + .resource_mut::() + .vendor = Some(VENDOR); + { + let mut script = app.world_mut().non_send_resource_mut::(); + script.take_sounds(); + let mut bag = benilla_ui::script::ContainerState { + num_slots: 16, + ..Default::default() + }; + bag.slots.insert( + 1, + benilla_ui::script::ContainerSlot { + item_id: CLAM_ENTRY, + count: 1, + ..Default::default() + }, + ); + script.set_container(0, Some(bag)); + script.set_merchant(Some(benilla_ui::script::MerchantState { + can_repair: true, + ..Default::default() + })); + script + .run("ShowRepairCursor() UseContainerItem(0, 1)") + .unwrap(); + } + app.world_mut() + .run_system_once(drain_container_uses) + .unwrap(); + + assert!(matches!( + rx.try_recv(), + Ok(ClientCommand::RepairItem { + vendor: VENDOR, + item_guid: CLAM + }) + )); + assert!(rx.try_recv().is_err(), "the repair alone: no use or sale"); + let mut script = app.world_mut().non_send_resource_mut::(); + assert_eq!( + script.take_sounds(), + vec![benilla_ui::script::SoundRequest::KitName( + "ITEM_REPAIR".into() + )] + ); + } + + /// A repair-mode click on a worn item (`0x4c7714`): `ITEM_REPAIR` as it goes out (`0x4c7780`), + /// then `CMSG_REPAIR_ITEM` with the vendor's and the worn item's guid. + #[test] + fn a_worn_repair_click_sounds_and_ships_the_items_guid() { + const VENDOR: u64 = 0xF130_0000_0000_0042; + const HELM: u64 = 0x4000_0000_0000_0777; + let head = benilla_protocol::field::FIELD_PLAYER_INV_SLOT_HEAD; + let (mut app, rx) = open_the_clam(); + while rx.try_recv().is_ok() {} // drain the clam's own send + app.world_mut() + .resource_mut::() + .vendor = Some(VENDOR); + let me = app + .world_mut() + .query_filtered::>() + .single(app.world()) + .unwrap(); + app.world_mut() + .entity_mut(me) + .insert(ObjectStore(ObjectFields::from_pairs(&[ + (head, HELM as u32), + (head + 1, (HELM >> 32) as u32), + ]))); + { + let mut script = app.world_mut().non_send_resource_mut::(); + script.take_sounds(); + let mut slots: benilla_ui::script::InventorySlots = Default::default(); + slots[1] = Some(benilla_ui::script::InvSlotView { + item_id: 7, + ..Default::default() + }); + script.set_inventory_slots(slots); + script.set_merchant(Some(benilla_ui::script::MerchantState { + can_repair: true, + ..Default::default() + })); + script + .run("ShowRepairCursor() PickupInventoryItem(1)") + .unwrap(); + } + app.world_mut() + .run_system_once(drain_container_uses) + .unwrap(); + + assert!(matches!( + rx.try_recv(), + Ok(ClientCommand::RepairItem { + vendor: VENDOR, + item_guid: HELM + }) + )); + let mut script = app.world_mut().non_send_resource_mut::(); + assert_eq!( + script.take_sounds(), + vec![benilla_ui::script::SoundRequest::KitName( + "ITEM_REPAIR".into() + )] + ); + } + /// The unwrap arm's other side: wrapping paper arms the wrap cursor and sends nothing. #[test] fn a_wrapper_right_click_arms_the_cursor_and_ships_no_packet() { diff --git a/crates/benilla-formats/src/sound_entries.rs b/crates/benilla-formats/src/sound_entries.rs index affa4b27..df1a691b 100644 --- a/crates/benilla-formats/src/sound_entries.rs +++ b/crates/benilla-formats/src/sound_entries.rs @@ -196,6 +196,10 @@ mod tests { assert_eq!(ui.id, 823); assert_eq!(ui.sound_type, 2, "type 2 = UI"); + let repair = cat.by_name("ITEM_REPAIR").expect("repair UI kit by name"); + assert_eq!(repair.id, 7994); + assert_eq!(repair.flags & sound_kit_flags::NO_DUPLICATES, 0x20); + // The joined path of a UI kit resolves to real bytes on the chain. let (path, _) = &ui.files[0]; let bytes = chain.read(path).expect("kit file readable off the chain"); diff --git a/crates/benilla-ui/src/script/char_stats.rs b/crates/benilla-ui/src/script/char_stats.rs index a05fb8f3..b4c9d708 100644 --- a/crates/benilla-ui/src/script/char_stats.rs +++ b/crates/benilla-ui/src/script/char_stats.rs @@ -338,6 +338,11 @@ impl super::UiScript { pub fn take_inventory_uses(&mut self) -> Vec { std::mem::take(&mut self.model_mut().inventory_uses) } + + /// Drain equipped slots clicked in repair mode, for `CMSG_REPAIR_ITEM`. + pub fn take_inventory_repairs(&mut self) -> Vec { + std::mem::take(&mut self.model_mut().inventory_repairs) + } } fn with_unit_stats( diff --git a/crates/benilla-ui/src/script/container.rs b/crates/benilla-ui/src/script/container.rs index e0d457d4..9eb98008 100644 --- a/crates/benilla-ui/src/script/container.rs +++ b/crates/benilla-ui/src/script/container.rs @@ -308,6 +308,27 @@ fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) -> bool model.ui_cursor_dirty = true; return true; } + // Only a held item (`0x4f9c38`) or vendor row (`0x4f9c43`) takes the click first. Past them + // an empty slot does nothing (`0x4f9c4e`), an armed item-targeting spell binds the item + // (`0x4f9c54`) and repair mode repairs it (`0x4f9c7b`), any other payload staying held; the + // app's repair drain runs the arm's `ITEM_REPAIR` and send. + let placing = matches!( + model.cursor, + Some(CursorPayload::Item(_) | CursorPayload::Merchant(_)) + ); + if !placing && (model.item_pick_armed || model.repair_mode) { + let occupied = model + .containers + .get(&bag) + .and_then(|c| c.slots.get(&slot)) + .is_some_and(|s| s.item_id != 0); + if occupied && model.item_pick_armed { + model.item_picks.push((bag, slot)); + } else if occupied { + model.container_repairs.push((bag, slot)); + } + return false; + } match model.cursor.take() { None => { let picked = model @@ -602,7 +623,21 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { "UseContainerItem", lua.create_function(|lua, (bag, slot, _rest): (i64, u32, mlua::MultiValue)| { let mut model = lua.app_data_mut::().expect("model app_data"); - model.container_uses.push((bag, slot)); + if model.repair_mode { + // The right-click clears the cursor first (`0x4fa198`), then repairs the item in + // the slot (`0x4fa1a6`, `0x4fa1da`): a held payload goes back, never placed. + cursor::clear_cursor(&mut model); + let occupied = model + .containers + .get(&bag) + .and_then(|c| c.slots.get(&slot)) + .is_some_and(|s| s.item_id != 0); + if occupied { + model.container_repairs.push((bag, slot)); + } + } else { + model.container_uses.push((bag, slot)); + } Ok(()) })?, )?; @@ -613,19 +648,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { "PickupContainerItem", lua.create_function(|lua, (bag, slot): (i64, u32)| { let mut model = lua.app_data_mut::().expect("model app_data"); - // The reference tests a held payload first (`0x4f9c38`), then an armed item-targeting - // spell, which takes the item (`0x4f9c54`, `0x495d60`), then repair mode (`0x4f9c7b`), - // so poisoning at a repair vendor binds the poison. - if model.item_pick_armed && model.cursor.is_none() { - model.item_picks.push((bag, slot)); - return Ok(false); - } - // Repair mode, until `HideRepairCursor`, queues a repair and picks nothing up. Unlike - // the reference, it does not first check for a held item. - if model.repair_mode { - model.container_repairs.push((bag, slot)); - return Ok(false); - } Ok(pickup_container_item(&mut model, bag, slot)) })?, )?; @@ -641,9 +663,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { .get(&bag) .and_then(|c| c.slots.get(&slot)) .is_some(); - // An armed spell suppresses it first (`0x4fa469`); the mode is sticky, so a Buy here - // would stamp out the cast cursor for good. - if occupied && !model.spell_targeting { + // An armed spell or repair cursor suppresses it first; each is sticky, so a Buy here + // would stamp out the active base cursor. + if occupied && !model.spell_targeting && !model.repair_mode { model.ui_cursor = Some(UiCursorMode::Buy); model.ui_cursor_dirty = true; } @@ -712,7 +734,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { #[cfg(test)] mod tests { use super::{ContainerMove, ContainerSlot, ContainerState, PendingWrap, UiCursorMode}; - use crate::script::UiScript; + use crate::script::{MerchantItem, MerchantState, UiScript}; fn backpack() -> ContainerState { let mut slots = std::collections::HashMap::new(); @@ -1132,6 +1154,171 @@ mod tests { assert!(s.take_container_moves().is_empty()); } + #[test] + fn repair_cursor_wins_over_a_sellable_bag_hover() { + let mut s = UiScript::new().unwrap(); + s.set_container(0, Some(backpack())); + s.set_merchant(Some(MerchantState { + can_repair: true, + ..Default::default() + })); + + s.run("ShowContainerSellCursor(0, 1)").unwrap(); + assert_eq!(s.ui_cursor(), Some(UiCursorMode::Buy)); + + s.run("ShowRepairCursor()").unwrap(); + assert_eq!( + s.ui_cursor(), + None, + "repair replaces the existing Buy hover" + ); + s.run("ShowContainerSellCursor(0, 1)").unwrap(); + assert_eq!( + s.ui_cursor(), + None, + "a bag hover keeps the repair base cursor" + ); + s.run("PickupContainerItem(0, 1)").unwrap(); + s.run("PickupContainerItem(0, 1)").unwrap(); + assert_eq!( + s.take_container_repairs(), + vec![(0, 1), (0, 1)], + "repair mode sticks across clicks" + ); + } + + #[test] + fn right_click_repairs_bag_item_instead_of_selling_it() { + let mut s = UiScript::new().unwrap(); + s.set_container(0, Some(backpack())); + s.set_merchant(Some(MerchantState { + can_repair: true, + ..Default::default() + })); + s.run("ShowRepairCursor() UseContainerItem(0, 1) UseContainerItem(0, 1)") + .unwrap(); + + assert_eq!(s.take_container_repairs(), vec![(0, 1), (0, 1)]); + assert!(s.take_container_uses().is_empty(), "no sell/use intent"); + } + + #[test] + fn right_click_in_repair_mode_puts_a_held_item_back_then_repairs() { + let mut s = UiScript::new().unwrap(); + s.set_container(0, Some(backpack())); + s.set_merchant(Some(MerchantState { + can_repair: true, + ..Default::default() + })); + s.run("PickupContainerItem(0, 1)").unwrap(); + assert!(s.cursor_item().is_some()); + // A held item and repair mode together, as after a cursor-mode change. + s.model_mut().repair_mode = true; + + s.run("UseContainerItem(0, 4)").unwrap(); + assert!( + s.cursor_payload().is_none(), + "the held item went back (`0x4fa198`)" + ); + assert!(s.take_container_moves().is_empty(), "never placed"); + assert_eq!(s.take_container_repairs(), vec![(0, 4)]); + + s.model_mut().repair_mode = false; + s.run("PickupContainerItem(0, 1)").unwrap(); + s.model_mut().repair_mode = true; + s.run("UseContainerItem(0, 2)").unwrap(); + assert!( + s.cursor_payload().is_none(), + "an empty slot still clears the cursor" + ); + assert!( + s.take_container_repairs().is_empty(), + "and repairs nothing (`0x4fa1a6`)" + ); + } + + #[test] + fn held_vendor_row_buys_into_bag_before_repair_mode() { + let mut s = UiScript::new().unwrap(); + s.set_container(0, Some(backpack())); + s.set_merchant(Some(MerchantState { + items: vec![MerchantItem { + item_id: 159, + ..Default::default() + }], + can_repair: true, + ..Default::default() + })); + s.run("ShowRepairCursor() PickupMerchantItem(1)").unwrap(); + assert!( + s.repair_mode(), + "vendor grab preserves the repair base mode" + ); + assert!(s.cursor_payload().is_some()); + + s.run("PickupContainerItem(0, 1)").unwrap(); + assert_eq!(s.take_merchant_slot_buys(), vec![(0, 1, 159)]); + assert!(s.cursor_payload().is_none()); + assert!(s.take_container_repairs().is_empty()); + assert!(s.take_sounds().is_empty()); + + // The right-click clears the vendor row with no packet and repairs instead. + s.run("PickupMerchantItem(1) UseContainerItem(0, 1)") + .unwrap(); + assert!(s.take_merchant_slot_buys().is_empty()); + assert!(s.cursor_payload().is_none()); + assert_eq!(s.take_container_repairs(), vec![(0, 1)]); + assert!(s.take_container_uses().is_empty()); + } + + #[test] + fn held_item_places_before_repair_mode() { + let mut s = UiScript::new().unwrap(); + s.set_container(0, Some(backpack())); + s.run("PickupContainerItem(0, 1)").unwrap(); + assert!(s.cursor_item().is_some()); + // A held item and repair base mode can coexist after a cursor-mode change. + s.model_mut().repair_mode = true; + + s.run("PickupContainerItem(0, 2)").unwrap(); + assert_eq!( + s.take_container_moves(), + vec![ContainerMove { + src_bag: 0, + src_slot: 1, + dst_bag: 0, + dst_slot: 2, + count: None, + }] + ); + assert!(s.cursor_payload().is_none()); + assert!(s.take_container_repairs().is_empty()); + assert!(s.take_sounds().is_empty()); + } + + #[test] + fn repair_mode_repairs_under_held_coins_and_keeps_them() { + use crate::script::cursor::{CursorMoney, CursorPayload}; + let mut s = UiScript::new().unwrap(); + s.set_container(0, Some(backpack())); + s.model_mut().repair_mode = true; + let coins = CursorPayload::Money(CursorMoney { copper: 50 }); + s.model_mut().cursor = Some(coins.clone()); + + s.run("PickupContainerItem(0, 1)").unwrap(); + assert_eq!( + s.take_container_repairs(), + vec![(0, 1)], + "coins pass both payload tests (`0x4f9c38`, `0x4f9c43`)" + ); + assert_eq!(s.cursor_payload(), Some(coins), "and stay held"); + s.run("PickupContainerItem(0, 2)").unwrap(); + assert!( + s.take_container_repairs().is_empty(), + "an empty slot does nothing (`0x4f9c4e`)" + ); + } + #[test] fn removing_a_bag_empties_it() { let mut s = UiScript::new().unwrap(); diff --git a/crates/benilla-ui/src/script/cursor/doll.rs b/crates/benilla-ui/src/script/cursor/doll.rs index 477488e7..bf824fb7 100644 --- a/crates/benilla-ui/src/script/cursor/doll.rs +++ b/crates/benilla-ui/src/script/cursor/doll.rs @@ -18,11 +18,24 @@ pub(super) fn pickup_inventory_item(model: &mut Model, id: u32) -> bool { if !(1..=23).contains(&id) && !super::bag_verbs::BANK_BAG_INV_SLOTS.contains(&id) { return false; } - // An item-targeting spell binds this slot instead of picking it up, as the bag click does - // (`0x4c76df`: `0x6e48a0` IsTargeting, `0x6e6330` TargetingWantsItem, `0x495d60` bind), so a - // worn weapon can be poisoned. A held payload wins: `0x4c73af` tests it first. - if model.item_pick_armed && model.cursor.is_none() { - model.item_picks.push((EQUIPMENT_BAG, id)); + // Only a held item (`0x4c769a`) or vendor row (`0x4c76a5`) takes the click first. Past them + // an empty slot or a locked item does nothing (`0x4c76af`, `0x4c76d2`), an item-targeting + // spell binds the item, so a worn weapon can be poisoned (`0x4c76df`: `0x6e48a0` IsTargeting, + // `0x6e6330` TargetingWantsItem, `0x495d60` bind), and repair mode repairs a worn item + // (`0x4c7714`), any other payload staying held. + let placing = matches!( + model.cursor, + Some(CursorPayload::Item(_) | CursorPayload::Merchant(_)) + ); + if !placing && (model.item_pick_armed || model.repair_mode) { + let usable = model + .inv_slot("player", id as usize) + .is_some_and(|s| s.item_id != 0 && !s.locked); + if usable && model.item_pick_armed { + model.item_picks.push((EQUIPMENT_BAG, id)); + } else if usable && (1..=19).contains(&id) { + model.inventory_repairs.push(id); + } return false; } match model.cursor.take() { @@ -143,9 +156,22 @@ pub(super) fn auto_equip_cursor_item(model: &mut Model) -> bool { } /// `UseInventoryItem(id)`, the doll slot's right-click (`PaperDollFrame.lua:658-659`): the app -/// sends `CMSG_USE_ITEM` with bag 255 and the 0-based slot, and drops an empty slot unsent. +/// sends `CMSG_USE_ITEM` with bag 255 and the 0-based slot, dropping an empty slot unsent. In +/// repair mode the cursor clears first (`0x4c79a9`) and a worn item is repaired, locked or not +/// (`0x4c79c4`): a held payload goes back, never placed. pub(super) fn use_inventory_item(model: &mut Model, id: u32) { - model.inventory_uses.push(id); + if !model.repair_mode { + model.inventory_uses.push(id); + return; + } + super::clear_cursor(model); + if (1..=19).contains(&id) + && model + .inv_slot("player", id as usize) + .is_some_and(|s| s.item_id != 0) + { + model.inventory_repairs.push(id); + } } /// `IsInventoryItemLocked(id)`: true while `id` is the held item's source or the app reports a @@ -576,6 +602,79 @@ mod tests { assert!(s.take_inventory_uses().is_empty(), "drained"); } + #[test] + fn repair_mode_queues_an_equipped_slot_without_picking_it_up() { + let mut s = UiScript::new().unwrap(); + s.set_inventory_slots(doll_slots()); + s.set_merchant(Some(crate::script::MerchantState { + can_repair: true, + ..Default::default() + })); + s.run("ShowRepairCursor()").unwrap(); + + assert!(!s.eval::("return PickupInventoryItem(1)").unwrap()); + assert!(!s.eval::("return PickupInventoryItem(1)").unwrap()); + s.run("UseInventoryItem(1)").unwrap(); + assert!(s.cursor_item().is_none()); + assert_eq!(s.take_inventory_repairs(), vec![1, 1, 1]); + assert!(s.take_inventory_uses().is_empty(), "no item-use intent"); + assert!(s.take_inventory_repairs().is_empty(), "drained"); + } + + #[test] + fn repair_mode_follows_the_doll_clicks_order() { + use crate::script::cursor::CursorMoney; + let mut s = UiScript::new().unwrap(); + let mut slots = doll_slots(); + if let Some(head) = slots[1].as_mut() { + head.locked = true; + } + s.set_inventory_slots(slots); + s.model_mut().repair_mode = true; + + s.run("PickupInventoryItem(1)").unwrap(); + assert!( + s.take_inventory_repairs().is_empty(), + "a locked item does nothing (`0x4c76d2`)" + ); + let coins = CursorPayload::Money(CursorMoney { copper: 50 }); + s.model_mut().cursor = Some(coins.clone()); + s.run("PickupInventoryItem(11)").unwrap(); + assert_eq!( + s.take_inventory_repairs(), + vec![11], + "coins do not block it" + ); + assert_eq!(s.cursor_payload(), Some(coins), "and stay held"); + + // A held item takes the click first (`0x4c769a`): the ring goes back, nothing repairs. + s.model_mut().cursor = None; + s.model_mut().repair_mode = false; + s.run("PickupInventoryItem(11)").unwrap(); + s.model_mut().repair_mode = true; + s.run("PickupInventoryItem(11)").unwrap(); + assert!(s.cursor_payload().is_none()); + assert!(s.take_inventory_repairs().is_empty()); + } + + #[test] + fn repair_mode_right_click_puts_a_held_item_back_then_repairs() { + let mut s = UiScript::new().unwrap(); + s.set_inventory_slots(doll_slots()); + s.run("PickupInventoryItem(11)").unwrap(); + assert!(s.cursor_item().is_some()); + s.model_mut().repair_mode = true; + + s.run("UseInventoryItem(19)").unwrap(); + assert!( + s.cursor_payload().is_none(), + "the ring went back (`0x4c79a9`)" + ); + assert!(s.take_container_moves().is_empty(), "never placed"); + assert_eq!(s.take_inventory_repairs(), vec![19]); + assert!(s.take_inventory_uses().is_empty(), "no use either"); + } + #[test] fn is_inventory_item_locked_reads_held_or_fed() { let mut s = UiScript::new().unwrap(); diff --git a/crates/benilla-ui/src/script/merchant.rs b/crates/benilla-ui/src/script/merchant.rs index 0768e037..d657af4a 100644 --- a/crates/benilla-ui/src/script/merchant.rs +++ b/crates/benilla-ui/src/script/merchant.rs @@ -81,9 +81,14 @@ pub struct MerchantState { } impl super::UiScript { - /// Push (or clear, with `None`) the open vendor. + /// 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; + } } /// Drain the `(row, quantity)` buys `BuyMerchantItem` queued, the row 1-based. @@ -468,9 +473,16 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { g.set( "ShowRepairCursor", lua.create_function(|lua, ()| { - lua.app_data_mut::() - .expect("model app_data") - .repair_mode = true; + let mut model = lua.app_data_mut::().expect("model app_data"); + // `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. + if model.spell_targeting || !model.merchant.as_ref().is_some_and(|m| m.can_repair) { + return Ok(()); + } + crate::script::cursor::clear_cursor(&mut model); + model.repair_mode = true; + model.ui_cursor = None; + model.ui_cursor_dirty = true; Ok(()) })?, )?; @@ -542,7 +554,7 @@ fn arm_vendor_cursor(lua: &Lua, price_of: impl FnOnce(&MerchantState) -> Option< #[cfg(test)] mod tests { use super::{ItemStatsHead, MerchantItem, MerchantState}; - use crate::script::UiScript; + use crate::script::{ContainerSlot, ContainerState, UiScript}; fn stock() -> MerchantState { MerchantState { @@ -806,13 +818,74 @@ mod tests { 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); + s.run("ShowRepairCursor()").unwrap(); + 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()); } #[test] diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 0ffc0597..eaa49e8f 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -830,6 +830,8 @@ pub(crate) struct Model { pub(crate) inventory_alerts: [u8; 12], /// `UseInventoryItem` slot ids, sent as `CMSG_USE_ITEM` on the equipped item. pub(crate) inventory_uses: Vec, + /// Equipped slot ids clicked while the merchant repair cursor is armed. + pub(crate) inventory_repairs: Vec, /// Main- and off-hand temporary enchants in `GetWeaponEnchantInfo`'s order, pushed each frame. pub(crate) weapon_enchants: [Option; 2], @@ -1381,6 +1383,7 @@ impl Model { bank_bag_slots: Default::default(), inventory_alerts: [0; 12], inventory_uses: Vec::new(), + inventory_repairs: Vec::new(), weapon_enchants: [None; 2], inspect: None, inspect_notifies: Vec::new(),