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:
co-authored by
Claude Opus 5.5
parent
7c967eaf8d
commit
b17be2710f
@@ -1918,7 +1918,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
press(false, true),
|
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"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -219,25 +219,24 @@ pub(super) fn drain_container_uses(
|
|||||||
mut ladder: crate::spell::CastLadder,
|
mut ladder: crate::spell::CastLadder,
|
||||||
mut ui_errors: ResMut<crate::ui_action::UiErrorKeys>,
|
mut ui_errors: ResMut<crate::ui_action::UiErrorKeys>,
|
||||||
mut gate: crate::ui_bind_confirm::BindGate,
|
mut gate: crate::ui_bind_confirm::BindGate,
|
||||||
|
mut purse: crate::ui_merchant::RepairPurse,
|
||||||
) {
|
) {
|
||||||
let Some(mut script) = script else {
|
let Some(mut script) = script else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
// Repair-mode clicks (the reference's `0x4f9c7b` route) repair the one item, sounding
|
let me = self_q.iter().next();
|
||||||
// `ITEM_REPAIR` as it goes out (`0x4f9ce4`). The reference's affordability check before that
|
// Repair-mode clicks (the reference's `0x4f9c7b` route) repair the one item: the purse test
|
||||||
// (error 0x25, silent) is not built: our costs lack its reputation discount, so the server
|
// first (`0x4f9cc8`), then `ITEM_REPAIR` as it goes out (`0x4f9ce4`).
|
||||||
// refuses instead.
|
|
||||||
for (bag, slot) in script.take_container_repairs() {
|
for (bag, slot) in script.take_container_repairs() {
|
||||||
let Some(vendor) = merchant.vendor else {
|
let (Some(vendor), Some(me)) = (merchant.vendor, me) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let slot0 = u8::try_from(slot.saturating_sub(1)).unwrap_or(0);
|
let slot0 = u8::try_from(slot.saturating_sub(1)).unwrap_or(0);
|
||||||
let item_guid = self_q
|
match slot_guid(&me.0, bag, slot0, &ladder.objects) {
|
||||||
.iter()
|
|
||||||
.next()
|
|
||||||
.and_then(|store| slot_guid(&store.0, bag, slot0, &ladder.objects));
|
|
||||||
match item_guid {
|
|
||||||
Some(guid) => {
|
Some(guid) => {
|
||||||
|
if purse.refuses(&mut script, &merchant, me, [guid]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
debug!("ui_items: repair lua bag {bag} slot {slot} (item {guid:#x})");
|
debug!("ui_items: repair lua bag {bag} slot {slot} (item {guid:#x})");
|
||||||
script.queue_sound_kit("ITEM_REPAIR");
|
script.queue_sound_kit("ITEM_REPAIR");
|
||||||
let _ = ladder.commands.0.send(ClientCommand::RepairItem {
|
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.
|
// The paper doll's repair clicks (`0x4c7714`, `0x4c79c4`), the same arm on a worn item.
|
||||||
for id in script.take_inventory_repairs() {
|
for id in script.take_inventory_repairs() {
|
||||||
let Some(vendor) = merchant.vendor else {
|
let (Some(vendor), Some(me)) = (merchant.vendor, me) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let item_guid = u8::try_from(id.wrapping_sub(1)).ok().and_then(|slot0| {
|
let item_guid = u8::try_from(id.wrapping_sub(1))
|
||||||
self_q
|
.ok()
|
||||||
.iter()
|
.and_then(|slot0| slot_guid(&me.0, EQUIPMENT_BAG, slot0, &ladder.objects));
|
||||||
.next()
|
|
||||||
.and_then(|store| slot_guid(&store.0, EQUIPMENT_BAG, slot0, &ladder.objects))
|
|
||||||
});
|
|
||||||
match item_guid {
|
match item_guid {
|
||||||
Some(guid) if (1..=19).contains(&id) => {
|
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})");
|
debug!("ui_items: repair worn lua slot {id} (item {guid:#x})");
|
||||||
script.queue_sound_kit("ITEM_REPAIR");
|
script.queue_sound_kit("ITEM_REPAIR");
|
||||||
let _ = ladder.commands.0.send(ClientCommand::RepairItem {
|
let _ = ladder.commands.0.send(ClientCommand::RepairItem {
|
||||||
@@ -756,6 +755,8 @@ mod tests {
|
|||||||
.init_resource::<crate::spell::SpellModifiers>()
|
.init_resource::<crate::spell::SpellModifiers>()
|
||||||
.init_resource::<crate::ui_action::CastErrors>()
|
.init_resource::<crate::ui_action::CastErrors>()
|
||||||
.init_resource::<crate::ui_action::UiErrorKeys>()
|
.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::spell::AutoRepeatActive>()
|
||||||
.init_resource::<crate::ui_tradeskill::TradeSkillOpens>()
|
.init_resource::<crate::ui_tradeskill::TradeSkillOpens>()
|
||||||
.init_resource::<crate::spell::targeting::SpellTargeting>()
|
.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.
|
/// The unwrap arm's other side: wrapping paper arms the wrap cursor and sends nothing.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_wrapper_right_click_arms_the_cursor_and_ships_no_packet() {
|
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::spell::SpellModifiers>()
|
||||||
.init_resource::<crate::ui_action::CastErrors>()
|
.init_resource::<crate::ui_action::CastErrors>()
|
||||||
.init_resource::<crate::ui_action::UiErrorKeys>()
|
.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::spell::AutoRepeatActive>()
|
||||||
.init_resource::<crate::ui_tradeskill::TradeSkillOpens>()
|
.init_resource::<crate::ui_tradeskill::TradeSkillOpens>()
|
||||||
.init_resource::<crate::spell::targeting::SpellTargeting>()
|
.init_resource::<crate::spell::targeting::SpellTargeting>()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
//! The merchant window's app side: [`MerchantOpen`] holds the `SMSG_LIST_INVENTORY` rows,
|
//! 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_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_protocol::messages::{buy_result, sell_result, VendorItem};
|
||||||
use benilla_world::interact::WorldRightPress;
|
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())
|
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
|
/// Push `GetRepairAllCost`'s total, which the reference sweeps at the call (`0x4fbd60`): every
|
||||||
/// frame a repairing vendor is open, 0 otherwise.
|
/// frame a repairing vendor is open, 0 otherwise.
|
||||||
fn feed_repair_all_cost(
|
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
|
/// 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
|
/// `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(
|
fn drain_merchant(
|
||||||
script: Option<NonSendMut<UiScript>>,
|
script: Option<NonSendMut<UiScript>>,
|
||||||
mut open: ResMut<MerchantOpen>,
|
mut open: ResMut<MerchantOpen>,
|
||||||
commands: Res<NetCommands>,
|
commands: Res<NetCommands>,
|
||||||
self_q: Query<(&ObjectStore, &Guid), With<SelfPlayer>>,
|
self_q: Query<(&ObjectStore, &Guid), With<SelfPlayer>>,
|
||||||
objects: Objects,
|
objects: Objects,
|
||||||
|
mut purse: RepairPurse,
|
||||||
) {
|
) {
|
||||||
let Some(mut script) = script else {
|
let Some(mut script) = script else {
|
||||||
return;
|
return;
|
||||||
@@ -574,12 +628,17 @@ fn drain_merchant(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if script.take_repair_all() {
|
if script.take_repair_all() {
|
||||||
if let Some(vendor) = open.vendor {
|
if let (Some(vendor), Some(me)) = (open.vendor, self_store) {
|
||||||
debug!("ui_merchant: repair all");
|
// `RepairAllItems` (`0x4fbfe0`) prices only the equipped slots 0-18 for its purse test
|
||||||
let _ = commands.0.send(ClientCommand::RepairItem {
|
// (`0x4fc026`–`0x4fc063`), not the bags `GetRepairAllCost` also sweeps.
|
||||||
vendor,
|
let worn = (0..19u8).filter_map(|i| me.0.player_inv_slot(i));
|
||||||
item_guid: 0,
|
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() {
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -781,6 +956,65 @@ mod tests {
|
|||||||
assert_eq!(got, i64::from(want), "the undiscounted total is {full}");
|
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 {
|
fn row(entry: u32, slot: u32, count: u32) -> VendorItem {
|
||||||
VendorItem {
|
VendorItem {
|
||||||
slot,
|
slot,
|
||||||
|
|||||||
@@ -25,6 +25,18 @@ pub struct DurabilityTables {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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.
|
/// 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> {
|
fn column(&self, item_level: u32, class: u32, subclass: u32) -> Option<u32> {
|
||||||
let row = self.costs.get(&item_level)?;
|
let row = self.costs.get(&item_level)?;
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ impl super::UiScript {
|
|||||||
cursor::queue_lock_changed(&mut model, bag, slot);
|
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> {
|
pub fn gift_wrap_armed(&self) -> Option<PendingWrap> {
|
||||||
self.model_ref().pending_wrap
|
self.model_ref().pending_wrap
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user