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) <noreply@anthropic.com>
Co-Authored-By: Sam <samuv.eth@gmail.com>
This commit is contained in:
Juozas Polikevičius
2026-09-26 13:42:44 +07:00
committed by GitHub
co-authored by Claude Opus 5.5 Sam
parent 11e185fc09
commit 263ea062ab
7 changed files with 544 additions and 32 deletions
+143 -2
View File
@@ -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::<crate::ui_merchant::MerchantOpen>()
.vendor = Some(VENDOR);
{
let mut script = app.world_mut().non_send_resource_mut::<UiScript>();
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::<UiScript>();
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::<crate::ui_merchant::MerchantOpen>()
.vendor = Some(VENDOR);
let me = app
.world_mut()
.query_filtered::<Entity, With<SelfPlayer>>()
.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::<UiScript>();
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::<UiScript>();
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() {
@@ -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");
@@ -338,6 +338,11 @@ impl super::UiScript {
pub fn take_inventory_uses(&mut self) -> Vec<u32> {
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<u32> {
std::mem::take(&mut self.model_mut().inventory_repairs)
}
}
fn with_unit_stats<T>(
+205 -18
View File
@@ -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::<Model>().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::<Model>().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();
+106 -7
View File
@@ -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::<bool>("return PickupInventoryItem(1)").unwrap());
assert!(!s.eval::<bool>("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();
+78 -5
View File
@@ -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<MerchantState>) {
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::<Model>()
.expect("model app_data")
.repair_mode = true;
let mut model = lua.app_data_mut::<Model>().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::<bool>("return InRepairMode() == nil").unwrap());
s.run("ShowRepairCursor()").unwrap();
assert!(s.repair_mode());
assert!(s.eval::<bool>("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::<bool>("local _, _, locked = GetContainerItemInfo(0, 1) return not locked")
.unwrap());
assert!(s.take_container_moves().is_empty());
}
#[test]
+3
View File
@@ -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<u32>,
/// Equipped slot ids clicked while the merchant repair cursor is armed.
pub(crate) inventory_repairs: Vec<u32>,
/// Main- and off-hand temporary enchants in `GetWeaponEnchantInfo`'s order, pushed each frame.
pub(crate) weapon_enchants: [Option<weapon_enchant::WeaponEnchant>; 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(),