Repair All greys out after a repair: its total is swept before the inventory event that refreshes the button (#426)

Fixes #417

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-26 15:09:32 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 4ebc2a9f23
commit cf969431a3
4 changed files with 93 additions and 25 deletions
+61 -19
View File
@@ -1,6 +1,6 @@
//! 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, and
//! [`drain_merchant`] sends the Lua intents. A bag click sells through [`crate::ui_items`].
//! [`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`].
use benilla_protocol::messages::{buy_result, sell_result, VendorItem};
use bevy::prelude::*;
@@ -111,6 +111,11 @@ impl Plugin for UiMerchantPlugin {
// player-requirement stores for `GetMerchantItemInfo`'s `isUsable`.
close_npc_session_out_of_range::<MerchantOpen>.before(feed_merchant),
feed_merchant.after(crate::ui_unit::UnitFeed).in_set(UiFeed),
// Before `feed_char`: the `UNIT_INVENTORY_CHANGED` a repair fires refreshes the
// Repair All button from this total (`PaperDollFrame.lua:717-724`).
feed_repair_all_cost
.in_set(UiFeed)
.before(crate::ui_char::feed_char),
drain_merchant.after(UiInput),
),
);
@@ -332,6 +337,45 @@ fn repair_all_cost(
total.min(u64::from(u32::MAX)) as u32
}
/// The open vendor's `UNIT_NPC_FLAGS`, 0 with none open or its unit not streamed.
fn vendor_npc_flags(
open: &MerchantOpen,
units: &Query<(&Guid, &ObjectStore), Without<SelfPlayer>>,
) -> u32 {
open.vendor
.and_then(|g| units.iter().find(|(guid, _)| guid.0 == g))
.map(|(_, store)| store.0.unit_npc_flags())
.unwrap_or(0)
}
/// 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(
script: Option<NonSendMut<UiScript>>,
open: Res<MerchantOpen>,
objects: Objects,
items: Res<Items>,
commands: Res<NetCommands>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
units: Query<(&Guid, &ObjectStore), Without<SelfPlayer>>,
tables: Option<Res<RepairTables>>,
mut last: Local<crate::ui_script::VmMemo<Option<u32>>>,
) {
let Some(mut script) = script else {
return;
};
let last = last.get(&script);
let can_repair = vendor_npc_flags(&open, &units) & NPC_FLAG_REPAIR != 0;
let total = match (can_repair, self_q.iter().next(), tables.as_deref()) {
(true, Some(store), Some(t)) => repair_all_cost(&store.0, &objects, &items, t, &commands),
_ => 0,
};
if *last != Some(total) {
script.set_repair_all_cost(total);
*last = Some(total);
}
}
fn snapshot(
open: &MerchantOpen,
objects: &Objects,
@@ -340,7 +384,6 @@ fn snapshot(
commands: &NetCommands,
player: Option<&benilla_protocol::ObjectFields>,
vendor_npc_flags: u32,
tables: Option<&RepairTables>,
) -> Option<MerchantState> {
open.vendor?;
let buyback = player
@@ -351,11 +394,6 @@ fn snapshot(
.collect()
})
.unwrap_or_default();
let can_repair = vendor_npc_flags & NPC_FLAG_REPAIR != 0;
let repair_cost = match (can_repair, player, tables) {
(true, Some(store), Some(t)) => repair_all_cost(store, objects, items, t, commands),
_ => 0,
};
Some(MerchantState {
items: open
.items
@@ -363,8 +401,7 @@ fn snapshot(
.map(|it| resolve_item(it, items, icons, commands))
.collect(),
buyback,
can_repair,
repair_all_cost: repair_cost,
can_repair: vendor_npc_flags & NPC_FLAG_REPAIR != 0,
})
}
@@ -378,8 +415,7 @@ fn feed_merchant(
icons: Option<Res<ItemDisplays>>,
commands: Res<NetCommands>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
units: Query<(&crate::net::Guid, &ObjectStore), Without<SelfPlayer>>,
tables: Option<Res<RepairTables>>,
units: Query<(&Guid, &ObjectStore), Without<SelfPlayer>>,
names: Res<NameCache>,
mut errors: ResMut<MerchantErrors>,
mut last: Local<crate::ui_script::VmMemo<Option<MerchantState>>>,
@@ -422,11 +458,6 @@ fn feed_merchant(
}
// The vendor's service bits gate the repair pair; the player's descriptor carries buyback.
let vendor_npc_flags = open
.vendor
.and_then(|g| units.iter().find(|(guid, _)| guid.0 == g))
.map(|(_, store)| store.0.unit_npc_flags())
.unwrap_or(0);
let player = self_q.iter().next().map(|s| &s.0);
let fresh = snapshot(
&open,
@@ -435,8 +466,7 @@ fn feed_merchant(
icons.as_deref(),
&commands,
player,
vendor_npc_flags,
tables.as_deref(),
vendor_npc_flags(&open, &units),
);
// The vendor's name rides `MERCHANT_SHOW` and `MERCHANT_UPDATE` as arg1, and its landing alone
// re-fires `MERCHANT_UPDATE`; the reference fires both bare (`0x4fad92`, `0x4facaa`), and
@@ -604,6 +634,18 @@ fn drain_merchant(
mod tests {
use super::*;
/// A repair's `UNIT_INVENTORY_CHANGED` reads this frame's total: the stock handler refreshes
/// the Repair All button from `GetRepairAllCost` (`PaperDollFrame.lua:717-724`).
#[test]
fn the_repair_all_total_is_swept_before_the_inventory_event() {
let mut app = crate::game_plugins::schedule_tests::headless_client();
assert!(crate::test_support::runs_before(
&mut app,
feed_repair_all_cost,
crate::ui_char::feed_char
));
}
fn row(entry: u32, slot: u32, count: u32) -> VendorItem {
VendorItem {
slot,
@@ -732,8 +732,8 @@ fn merchant_tabs_drive_buyback_page_and_repair_pair() {
buyback_item("Cracked Sword", 20), // the latest sale: the merchant page's slot
],
can_repair: true,
repair_all_cost: 76,
}));
s.set_repair_all_cost(76);
s.fire_event(
"MERCHANT_SHOW",
vec![ScriptValue::Str("Kurdram Stonehammer".into())],
+28 -5
View File
@@ -76,8 +76,6 @@ pub struct MerchantState {
pub buyback: Vec<MerchantItem>,
/// Whether this vendor repairs (the `UNIT_NPC_FLAGS` repair bit).
pub can_repair: bool,
/// The repair-all cost in copper; 0 disables the button (`MerchantFrame_OnShow`).
pub repair_all_cost: u32,
}
impl super::UiScript {
@@ -91,6 +89,11 @@ impl super::UiScript {
}
}
/// Push `GetRepairAllCost`'s total in copper, swept by the app ahead of the events that read it.
pub fn set_repair_all_cost(&mut self, copper: u32) {
self.model_mut().repair_all_cost = copper;
}
/// Drain the `(row, quantity)` buys `BuyMerchantItem` queued, the row 1-based.
pub fn take_merchant_buys(&mut self) -> Vec<(u32, u32)> {
std::mem::take(&mut self.model_mut().merchant_buys)
@@ -446,13 +449,17 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
// GetRepairAllCost() → cost, canRepair: whether there is damage to pay for, which enables the
// repair-all button (`MerchantFrame.lua:38`).
// repair-all button (`MerchantFrame.lua:38`); 0 away from a vendor that repairs (`0x4fbd60`).
g.set(
"GetRepairAllCost",
lua.create_function(|lua, ()| {
let cost = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.merchant.as_ref().map_or(0, |m| m.repair_all_cost)
if model.merchant.as_ref().is_some_and(|m| m.can_repair) {
model.repair_all_cost
} else {
0
}
};
Ok(MultiValue::from_vec(vec![
Value::Integer(i64::from(cost)),
@@ -801,6 +808,22 @@ mod tests {
assert!(s.take_merchant_buybacks().is_empty(), "drained");
}
/// Away from a vendor that repairs, the total reads 0, as `0x4fbd60` pushes there.
#[test]
fn the_repair_all_total_reads_zero_away_from_a_repairer() {
let mut s = UiScript::new().unwrap();
s.set_repair_all_cost(1234);
assert!(
s.eval::<bool>("return GetRepairAllCost() == 0").unwrap(),
"no vendor"
);
s.set_merchant(Some(stock()));
assert!(
s.eval::<bool>("return GetRepairAllCost() == 0").unwrap(),
"a vendor that does not repair"
);
}
#[test]
fn repair_reads_intents_and_mode_latch() {
let mut s = UiScript::new().unwrap();
@@ -808,8 +831,8 @@ mod tests {
let mut state = stock();
state.can_repair = true;
state.repair_all_cost = 1234;
s.set_merchant(Some(state));
s.set_repair_all_cost(1234);
assert!(s.eval::<bool>("return CanMerchantRepair() == 1").unwrap());
assert!(s
.eval::<bool>("local c, can = GetRepairAllCost()\nreturn c == 1234 and can == true",)
+3
View File
@@ -592,6 +592,8 @@ pub(crate) struct Model {
/// The open vendor's stock, the `BuyMerchantItem` calls and whether `CloseMerchant` ran.
pub(crate) merchant: Option<merchant::MerchantState>,
/// `GetRepairAllCost`'s total, which the app pushes every frame a vendor is open.
pub(crate) repair_all_cost: u32,
pub(crate) merchant_buys: Vec<(u32, u32)>,
/// The held `(bag, slot)` when `PickupMerchantItem` sells, sent as `CMSG_SELL_ITEM`.
pub(crate) merchant_cursor_sells: Vec<(i64, u32)>,
@@ -1223,6 +1225,7 @@ impl Model {
gossip_close: false,
gossip_quest_selects: Vec::new(),
merchant: None,
repair_all_cost: 0,
merchant_buys: Vec::new(),
merchant_cursor_sells: Vec::new(),
merchant_slot_buys: Vec::new(),