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 98b0733013
commit 36b1c93fcd
7 changed files with 544 additions and 32 deletions
+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();