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:
committed by
GitHub
co-authored by
Claude Opus 5.5
Sam
parent
11e185fc09
commit
263ea062ab
@@ -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>(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user