A repair the purse cannot cover is refused with the not-enough-money error, as in 1.12.1 (#443)

- A repair the purse cannot cover is refused as in 1.12.1: the not-enough-money error and its voice line, no repair sound, nothing sent; Repair All tests the worn gear alone
- Tidy three comments: the gift-wrap getter's reader, the repair-reset test's claim, the merchant module doc's width

Fixes #412

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-26 18:01:39 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 7c967eaf8d
commit b17be2710f
5 changed files with 406 additions and 27 deletions
+1 -1
View File
@@ -1918,7 +1918,7 @@ mod tests {
);
assert!(
press(false, true),
"a press that drops a held payload is consumed before the hook"
"a press while a payload is held skips the repair reset"
);
}
+150 -17
View File
@@ -219,25 +219,24 @@ pub(super) fn drain_container_uses(
mut ladder: crate::spell::CastLadder,
mut ui_errors: ResMut<crate::ui_action::UiErrorKeys>,
mut gate: crate::ui_bind_confirm::BindGate,
mut purse: crate::ui_merchant::RepairPurse,
) {
let Some(mut script) = script else {
return;
};
// 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.
let me = self_q.iter().next();
// Repair-mode clicks (the reference's `0x4f9c7b` route) repair the one item: the purse test
// first (`0x4f9cc8`), then `ITEM_REPAIR` as it goes out (`0x4f9ce4`).
for (bag, slot) in script.take_container_repairs() {
let Some(vendor) = merchant.vendor else {
let (Some(vendor), Some(me)) = (merchant.vendor, me) else {
continue;
};
let slot0 = u8::try_from(slot.saturating_sub(1)).unwrap_or(0);
let item_guid = self_q
.iter()
.next()
.and_then(|store| slot_guid(&store.0, bag, slot0, &ladder.objects));
match item_guid {
match slot_guid(&me.0, bag, slot0, &ladder.objects) {
Some(guid) => {
if purse.refuses(&mut script, &merchant, me, [guid]) {
continue;
}
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 {
@@ -250,17 +249,17 @@ pub(super) fn drain_container_uses(
}
// 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 {
let (Some(vendor), Some(me)) = (merchant.vendor, me) 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))
});
let item_guid = u8::try_from(id.wrapping_sub(1))
.ok()
.and_then(|slot0| slot_guid(&me.0, EQUIPMENT_BAG, slot0, &ladder.objects));
match item_guid {
Some(guid) if (1..=19).contains(&id) => {
if purse.refuses(&mut script, &merchant, me, [guid]) {
continue;
}
debug!("ui_items: repair worn lua slot {id} (item {guid:#x})");
script.queue_sound_kit("ITEM_REPAIR");
let _ = ladder.commands.0.send(ClientCommand::RepairItem {
@@ -756,6 +755,8 @@ mod tests {
.init_resource::<crate::spell::SpellModifiers>()
.init_resource::<crate::ui_action::CastErrors>()
.init_resource::<crate::ui_action::UiErrorKeys>()
.init_resource::<crate::ui_chat::ChatLog>()
.init_resource::<crate::sound::MessageSounds>()
.init_resource::<crate::spell::AutoRepeatActive>()
.init_resource::<crate::ui_tradeskill::TradeSkillOpens>()
.init_resource::<crate::spell::targeting::SpellTargeting>()
@@ -937,6 +938,136 @@ mod tests {
);
}
/// What one repair click did: the packets, the VM's sounds, the `UI_ERROR_MESSAGE` lines and
/// the message rows queued for their sound.
struct RepairClick {
sent: Vec<ClientCommand>,
sounds: Vec<benilla_ui::script::SoundRequest>,
shown: Vec<String>,
sounded: Vec<&'static str>,
}
/// A repair-mode click on [`crate::ui_merchant::purse_fixture`]'s sword with `money` in the
/// purse: in backpack slot 1 through `UseContainerItem`, or `worn` in the main hand (slot 15)
/// through `PickupInventoryItem`.
fn click_to_repair_the_sword(money: u32, worn: bool) -> RepairClick {
use crate::ui_merchant::purse_fixture::{player, record_errors, seat, SWORD_ENTRY};
use 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
seat(app.world_mut());
let slot_field = if worn {
FIELD_PLAYER_INV_SLOT_HEAD + 2 * 15
} else {
F_PACK_SLOT_1
};
let me = app
.world_mut()
.query_filtered::<Entity, With<SelfPlayer>>()
.single(app.world())
.unwrap();
app.world_mut()
.entity_mut(me)
.insert(player(slot_field, money));
{
let mut script = app.world_mut().non_send_resource_mut::<UiScript>();
script.take_sounds();
record_errors(&script);
script.set_merchant(Some(benilla_ui::script::MerchantState {
can_repair: true,
..Default::default()
}));
let click = if worn {
let mut slots: benilla_ui::script::InventorySlots = Default::default();
slots[16] = Some(benilla_ui::script::InvSlotView {
item_id: SWORD_ENTRY,
..Default::default()
});
script.set_inventory_slots(slots);
"ShowRepairCursor() PickupInventoryItem(16)"
} else {
let mut bag = benilla_ui::script::ContainerState {
num_slots: 16,
..Default::default()
};
bag.slots.insert(
1,
benilla_ui::script::ContainerSlot {
item_id: SWORD_ENTRY,
count: 1,
..Default::default()
},
);
script.set_container(0, Some(bag));
"ShowRepairCursor() UseContainerItem(0, 1)"
};
script.run(click).unwrap();
}
app.world_mut()
.run_system_once(drain_container_uses)
.unwrap();
let sounded = crate::ui_merchant::purse_fixture::sounded(app.world());
let mut script = app.world_mut().non_send_resource_mut::<UiScript>();
RepairClick {
sent: rx.try_iter().collect(),
sounds: script.take_sounds(),
shown: crate::ui_merchant::purse_fixture::shown(&script),
sounded,
}
}
/// The repair arm's purse test, unsigned `cost > money` on the discounted cost: one copper
/// short shows `ERR_NOT_ENOUGH_MONEY` with its speech row, plays no `ITEM_REPAIR` and sends
/// nothing; exactly the cost, under the full price, sounds and sends.
fn the_purse_test_refuses_one_copper_short_and_pays_the_exact_cost(worn: bool) {
use crate::ui_merchant::purse_fixture::{sword_prices, NOT_ENOUGH_MONEY, SWORD, VENDOR};
let (full, discounted) = sword_prices();
let short = click_to_repair_the_sword(discounted - 1, worn);
assert!(
short.sent.is_empty(),
"short of {discounted}: nothing goes out"
);
assert!(short.sounds.is_empty(), "…no ITEM_REPAIR");
assert_eq!(short.shown, [NOT_ENOUGH_MONEY], "…the red line");
assert_eq!(
short.sounded,
["ERR_NOT_ENOUGH_MONEY"],
"…and its row, which speaks line 0x28"
);
let exact = click_to_repair_the_sword(discounted, worn);
assert!(
matches!(
exact.sent.as_slice(),
[ClientCommand::RepairItem {
vendor: VENDOR,
item_guid: SWORD
}]
),
"{discounted} in the purse pays the discounted {discounted}, short of the full {full}"
);
assert_eq!(
exact.sounds,
vec![benilla_ui::script::SoundRequest::KitName(
"ITEM_REPAIR".into()
)]
);
assert!(exact.shown.is_empty() && exact.sounded.is_empty());
}
/// `UseContainerItem`'s repair arm (`0x4fa22e`–`0x4fa236`, error `0x4fa23a`).
#[test]
fn a_bag_repair_the_purse_cannot_cover_is_refused_and_one_it_covers_is_sent() {
the_purse_test_refuses_one_copper_short_and_pays_the_exact_cost(false);
}
/// `PickupInventoryItem`'s repair arm (`0x4c775f`–`0x4c7766`, error `0x4c776a`).
#[test]
fn a_worn_repair_the_purse_cannot_cover_is_refused_and_one_it_covers_is_sent() {
the_purse_test_refuses_one_copper_short_and_pays_the_exact_cost(true);
}
/// 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() {
@@ -1578,6 +1709,8 @@ mod bind_confirm_tests {
.init_resource::<crate::spell::SpellModifiers>()
.init_resource::<crate::ui_action::CastErrors>()
.init_resource::<crate::ui_action::UiErrorKeys>()
.init_resource::<crate::ui_chat::ChatLog>()
.init_resource::<crate::sound::MessageSounds>()
.init_resource::<crate::spell::AutoRepeatActive>()
.init_resource::<crate::ui_tradeskill::TradeSkillOpens>()
.init_resource::<crate::spell::targeting::SpellTargeting>()
+242 -8
View File
@@ -1,6 +1,7 @@
//! The merchant window's app side: [`MerchantOpen`] holds the `SMSG_LIST_INVENTORY` rows,
//! [`feed_merchant`] pushes them with the buyback and repair rows, the purse and the refusals,
//! [`feed_repair_all_cost`] the repair-all total, and [`drain_merchant`] sends the Lua intents. A bag click sells through [`crate::ui_items`].
//! [`feed_repair_all_cost`] the repair-all total, and [`drain_merchant`] sends the Lua intents. A
//! bag click sells through [`crate::ui_items`].
use benilla_protocol::messages::{buy_result, sell_result, VendorItem};
use benilla_world::interact::WorldRightPress;
@@ -361,6 +362,58 @@ fn vendor_npc_flags(
vendor_store(open, units).map_or(0, |store| store.0.unit_npc_flags())
}
/// What the purse test before a repair's send reads: the items' costs, the open vendor for its
/// price discount, and the message path a refusal takes.
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct RepairPurse<'w, 's> {
objects: Objects<'w, 's>,
items: Res<'w, Items>,
commands: Res<'w, NetCommands>,
tables: Option<Res<'w, RepairTables>>,
units: Query<'w, 's, (&'static Guid, &'static ObjectStore), Without<SelfPlayer>>,
reactions: crate::target::ReactionInputs<'w>,
sink: crate::ui_action::MessageSink<'w>,
}
impl RepairPurse<'_, '_> {
/// The purse test of the four item-click repair arms (`0x4f9cc8`–`0x4f9ccf`) and of
/// `RepairAllItems` (`0x4fc08e`): the `0x4faf30` costs of `guids`, summed in a u32, above
/// `PLAYER_FIELD_COINAGE` (unsigned, so an equal cost passes) raise `ERR_NOT_ENOUGH_MONEY`
/// (`0x496720(0x25)`, speech line 0x28) and answer `true`, and the caller sends nothing.
pub(crate) fn refuses(
&mut self,
script: &mut UiScript,
open: &MerchantOpen,
player: &ObjectStore,
guids: impl IntoIterator<Item = u64>,
) -> bool {
// `0x4faf30` keeps the full price unless it resolves the vendor's unit.
let discount = vendor_store(open, &self.units).map_or(0.0, |vendor| {
crate::target::vendor_price_discount(
self.reactions.factions.as_deref(),
&self.reactions.reputations,
vendor,
player,
)
});
let cost = guids.into_iter().filter(|&g| g != 0).fold(0u32, |sum, g| {
let item = self.tables.as_deref().map_or(0, |t| {
item_repair_cost(g, &self.objects, &self.items, t, &self.commands, discount)
});
sum.wrapping_add(item)
});
let money = player.0.player_money().unwrap_or(0);
if cost <= money {
return false;
}
debug!("ui_merchant: repair of {cost} copper refused with {money} in the purse");
if let Some(line) = crate::ui_action::keyed_line(script, "ERR_NOT_ENOUGH_MONEY") {
crate::ui_action::show_messages(script, &mut self.sink, "ui_merchant", [line]);
}
true
}
}
/// Push `GetRepairAllCost`'s total, which the reference sweeps at the call (`0x4fbd60`): every
/// frame a repairing vendor is open, 0 otherwise.
fn feed_repair_all_cost(
@@ -541,13 +594,14 @@ impl NpcSession for MerchantOpen {
/// Drain the Lua intents: a buy sends `CMSG_BUY_ITEM` by item entry, a buyback
/// `CMSG_BUYBACK_ITEM` with the absolute slot (69-80), a repair-all `CMSG_REPAIR_ITEM` with guid
/// 0, and a close clears locally, as there is no close packet.
/// 0 when the purse covers the worn gear, and a close clears locally, as there is no close packet.
fn drain_merchant(
script: Option<NonSendMut<UiScript>>,
mut open: ResMut<MerchantOpen>,
commands: Res<NetCommands>,
self_q: Query<(&ObjectStore, &Guid), With<SelfPlayer>>,
objects: Objects,
mut purse: RepairPurse,
) {
let Some(mut script) = script else {
return;
@@ -574,12 +628,17 @@ fn drain_merchant(
}
}
if script.take_repair_all() {
if let Some(vendor) = open.vendor {
debug!("ui_merchant: repair all");
let _ = commands.0.send(ClientCommand::RepairItem {
vendor,
item_guid: 0,
});
if let (Some(vendor), Some(me)) = (open.vendor, self_store) {
// `RepairAllItems` (`0x4fbfe0`) prices only the equipped slots 0-18 for its purse test
// (`0x4fc026`–`0x4fc063`), not the bags `GetRepairAllCost` also sweeps.
let worn = (0..19u8).filter_map(|i| me.0.player_inv_slot(i));
if !purse.refuses(&mut script, &open, me, worn) {
debug!("ui_merchant: repair all");
let _ = commands.0.send(ClientCommand::RepairItem {
vendor,
item_guid: 0,
});
}
}
}
for (index, quantity) in script.take_merchant_buys() {
@@ -670,6 +729,122 @@ pub(crate) fn end_repair_mode_on_right_press(
}
}
/// A level-20 sword 40 points down at a repair vendor flagged `UNIT_FLAG_PVP`, for the purse tests
/// here and in [`crate::ui_items`].
#[cfg(test)]
pub(crate) mod purse_fixture {
use super::*;
use benilla_protocol::field::{
FIELD_PLAYER_FIELD_COINAGE, FIELD_UNIT_FLAGS, FIELD_UNIT_NPC_FLAGS,
};
use benilla_protocol::ObjectFields;
pub(crate) const VENDOR: u64 = 0xF130_0000_0000_0043;
pub(crate) const SWORD: u64 = 0x4000_0000_0000_0707;
pub(crate) const SWORD_ENTRY: u32 = 2488;
/// Absolute descriptor indices.
const OBJECT_ENTRY: u16 = 3;
const ITEM_DURABILITY: u16 = 46;
const ITEM_MAXDURABILITY: u16 = 47;
const PLAYER_BYTES_3: u16 = 195;
/// The string `ERR_NOT_ENOUGH_MONEY` holds in 1.12's `GlobalStrings.lua`.
pub(crate) const NOT_ENOUGH_MONEY: &str = "You don't have enough money.";
/// One level-20 row at 1 copper a point for a sword (weapon subclass 7), and quality 1's
/// multiplier (row `2·1 + 1`) at 1.
fn tables() -> benilla_formats::DurabilityTables {
let mut row = vec![0u32; 29];
row[7] = 1;
benilla_formats::DurabilityTables::from_rows([(20, row)], [(3, 1.0)])
}
/// The sword's repair in full, and after the two honor steps (`0x612bcf`, `0x612bd7`) an honor
/// rank byte of 8 earns at this vendor; a test at the discounted price discriminates the
/// discount only while the two differ.
pub(crate) fn sword_prices() -> (u32, u32) {
let t = tables();
let steps = f64::from(0.05f32) + f64::from(0.05f32);
let full = t.repair_cost(40, 20, 1, 2, 7, 0.0);
let discounted = t.repair_cost(40, 20, 1, 2, 7, steps);
assert!(discounted < full, "{discounted} of {full}");
(full, discounted)
}
/// The merchant open on the vendor, whose unit is streamed, the tables, and the sword's
/// template and object.
pub(crate) fn seat(world: &mut World) {
world.insert_resource(RepairTables(tables()));
world
.resource_mut::<MerchantOpen>()
.open(VENDOR, Vec::new());
world.spawn((
Guid(VENDOR),
ObjectStore(ObjectFields::from_pairs(&[
(FIELD_UNIT_NPC_FLAGS, NPC_FLAG_REPAIR),
(FIELD_UNIT_FLAGS, 0x1000),
])),
));
let mut sword = crate::items::test_template("Sword");
(sword.class, sword.subclass, sword.quality, sword.item_level) = (2, 7, 1, 20);
world
.resource_mut::<Items>()
.insert_template(SWORD_ENTRY, Some(sword));
crate::items::test_spawn_item(
world,
SWORD,
ObjectFields::from_pairs(&[
(OBJECT_ENTRY, SWORD_ENTRY),
(ITEM_DURABILITY, 35),
(ITEM_MAXDURABILITY, 75),
]),
false,
);
}
/// The player's fields: the sword in the guid pair at `slot_field`, `money` copper, and the
/// honor rank byte at 8.
pub(crate) fn player(slot_field: u16, money: u32) -> ObjectStore {
ObjectStore(ObjectFields::from_pairs(&[
(slot_field, SWORD as u32),
(slot_field + 1, (SWORD >> 32) as u32),
(FIELD_PLAYER_FIELD_COINAGE, money),
(PLAYER_BYTES_3, 8 << 24),
]))
}
/// The message's string, and a recorder of the `UI_ERROR_MESSAGE` lines [`shown`] reads.
pub(crate) fn record_errors(script: &UiScript) {
script
.run(&format!(
r#"
ERR_NOT_ENOUGH_MONEY = "{NOT_ENOUGH_MONEY}"
BENILLA_TEST_LINES = {{}}
local f = CreateFrame("Frame")
f:RegisterEvent("UI_ERROR_MESSAGE")
f:SetScript("OnEvent", function() table.insert(BENILLA_TEST_LINES, arg1) end)
"#
))
.unwrap();
}
/// The `UI_ERROR_MESSAGE` lines since [`record_errors`].
pub(crate) fn shown(script: &UiScript) -> Vec<String> {
script
.eval::<Vec<String>>("return BENILLA_TEST_LINES")
.unwrap()
}
/// The message rows queued for their sound.
pub(crate) fn sounded(world: &World) -> Vec<&'static str> {
world
.resource::<crate::sound::MessageSounds>()
.queued()
.iter()
.map(|r| r.key)
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -781,6 +956,65 @@ mod tests {
assert_eq!(got, i64::from(want), "the undiscounted total is {full}");
}
/// `RepairAllItems` prices only the equipped slots for its purse test (`0x4fc026`–`0x4fc063`,
/// `0x4fc08e`): the sword worn and over the purse is refused with `ERR_NOT_ENOUGH_MONEY` and
/// no send; the same sword in the backpack, which `GetRepairAllCost` would count, leaves a worn
/// total of 0 and the repair-all goes out.
#[test]
fn repair_all_tests_the_purse_against_the_worn_gear_alone() {
use benilla_protocol::field::FIELD_PLAYER_INV_SLOT_HEAD;
use bevy::ecs::system::RunSystemOnce;
use purse_fixture::*;
let (_, discounted) = sword_prices();
let repair_all = |slot_field: u16| {
let (tx, rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.init_resource::<Items>()
.init_resource::<crate::net::GuidIndex>()
.init_resource::<MerchantOpen>()
.init_resource::<crate::net::Reputations>()
.init_resource::<crate::ui_chat::ChatLog>()
.init_resource::<crate::sound::MessageSounds>()
.insert_resource(NetCommands(tx));
seat(app.world_mut());
app.world_mut()
.spawn((SelfPlayer, Guid(0x5e1f), player(slot_field, discounted - 1)));
let script = UiScript::new().unwrap();
record_errors(&script);
script.run("RepairAllItems()").unwrap();
app.insert_non_send_resource(script);
app.world_mut().run_system_once(drain_merchant).unwrap();
let sent: Vec<ClientCommand> = rx.try_iter().collect();
let lines = shown(app.world().non_send_resource::<UiScript>());
(sent, lines, sounded(app.world()))
};
// The main hand (slot 15).
let (sent, lines, rows) = repair_all(FIELD_PLAYER_INV_SLOT_HEAD + 2 * 15);
assert!(sent.is_empty(), "worn, one copper short: nothing goes out");
assert_eq!(lines, [NOT_ENOUGH_MONEY]);
assert_eq!(
rows,
["ERR_NOT_ENOUGH_MONEY"],
"the row that speaks line 0x28"
);
// Backpack slot 1, inventory slot 23.
let (sent, lines, rows) = repair_all(FIELD_PLAYER_INV_SLOT_HEAD + 2 * 23);
assert!(
matches!(
sent.as_slice(),
[ClientCommand::RepairItem {
vendor: VENDOR,
item_guid: 0
}]
),
"the bag's {discounted} is not in the purse test"
);
assert!(lines.is_empty() && rows.is_empty());
}
fn row(entry: u32, slot: u32, count: u32) -> VendorItem {
VendorItem {
slot,
+12
View File
@@ -25,6 +25,18 @@ pub struct DurabilityTables {
}
impl DurabilityTables {
/// Tables from explicit rows, for tests and tools: item level → the 29 cost columns, and
/// quality row id → multiplier.
pub fn from_rows(
costs: impl IntoIterator<Item = (u32, Vec<u32>)>,
qualities: impl IntoIterator<Item = (u32, f32)>,
) -> Self {
Self {
costs: costs.into_iter().collect(),
qualities: qualities.into_iter().collect(),
}
}
/// The per-point cost for a class and subclass at `item_level`, `None` when unrepairable.
fn column(&self, item_level: u32, class: u32, subclass: u32) -> Option<u32> {
let row = self.costs.get(&item_level)?;
+1 -1
View File
@@ -199,7 +199,7 @@ impl super::UiScript {
cursor::queue_lock_changed(&mut model, bag, slot);
}
/// The armed gift wrap, if any; the app reads it to tell whether a right-click cancels one.
/// The armed gift wrap, if any; the app reads it to hold the cursor's base at Cast.
pub fn gift_wrap_armed(&self) -> Option<PendingWrap> {
self.model_ref().pending_wrap
}