Repair costs and flight fares take 1.12.1's price discount: 10% at Honored, up to 10% more by honor rank at a PvP-flagged vendor (#439)
- Repair costs take the vendor's price discount, 10% off at Honored and up to 10% more by honor rank at a PvP-flagged vendor, rounded as 1.12.1 rounds them - Flight map fares take the flight master's price discount, as 1.12.1 shows them, while an express flight still sends the full fare Fixes #411 Fixes #434 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
2376fff914
commit
7c967eaf8d
@@ -34,6 +34,7 @@ pub(crate) fn hover_probe_armed() -> bool {
|
||||
hover_probe::armed()
|
||||
}
|
||||
pub(crate) mod lock;
|
||||
mod price_discount;
|
||||
mod relations;
|
||||
mod reticle;
|
||||
// `pub(crate)` for the hover inspector too: its faction catalog feeds `go_highlightable`.
|
||||
@@ -55,6 +56,10 @@ pub(crate) use cursor_mode::{
|
||||
pub(crate) use lock::GO_FLAG_LOCKED;
|
||||
// This frame's combat-flash verdict, for the ring's material and the nameplate colour gate.
|
||||
pub(crate) use flash::CombatFlash;
|
||||
// The discount `0x612b80` the repair cost and the taxi fare take off their DBC prices.
|
||||
pub(crate) use price_discount::vendor_price_discount;
|
||||
#[cfg(test)]
|
||||
pub(crate) use price_discount::{stormwind_fixture, HUMAN_WARRIOR};
|
||||
// The attack-with-no-target request, and the same nearest-enemy core called synchronously for the
|
||||
// pet bar's Attack, whose order must leave in the frame it was pressed.
|
||||
pub(crate) use relations::{can_assist, can_attack, can_interact};
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
//! The reference's price discount `0x612b80(vendor, player)`, which the client takes off the two
|
||||
//! prices it computes from its own DBCs: the repair cost (`0x4faf30`, caller `0x4faf8a`) and the
|
||||
//! taxi map's fare (`0x4dc3d0`, callers `0x4dc45d` and `0x4dc4a2`). The merchant's goods and the
|
||||
//! trainer's services are the server's prices, shown as sent.
|
||||
|
||||
use crate::net::{ObjectStore, Reputations};
|
||||
|
||||
use super::{ring_reaction, Factions};
|
||||
|
||||
/// `0x801620`, the f32 0.1 the reputation term loads (`0x612b9d`).
|
||||
const REPUTATION_TERM: f32 = 0.1;
|
||||
/// `0x8029c8`, the f32 0.05 each honor step adds (`0x612bcf`, `0x612bd7`).
|
||||
const HONOR_STEP: f32 = 0.05;
|
||||
/// `UNIT_FLAG_PVP`, bit 12 of the vendor's `UNIT_FIELD_FLAGS`, which the honor steps need
|
||||
/// (`0x612bb4 shr ecx,0xc`).
|
||||
const UNIT_FLAG_PVP: u32 = 0x1000;
|
||||
|
||||
/// `0x612b80`: 0.1 when the vendor's reaction to the player is at least 5, Honored (`0x612b98`,
|
||||
/// signed), then, only at a vendor flagged `UNIT_FLAG_PVP`, 0.05 at an honor rank byte of 6 and
|
||||
/// 0.05 more at 8 (`0x612bc9`, `0x612bcd`, unsigned), whatever the reaction. The f32 terms are
|
||||
/// summed in f64 and never narrowed, so 0.15 is no f32.
|
||||
fn price_discount(reaction: u8, vendor_unit_flags: u32, pvp_rank: u8) -> f64 {
|
||||
let mut d = if reaction >= 5 {
|
||||
f64::from(REPUTATION_TERM)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
if vendor_unit_flags & UNIT_FLAG_PVP != 0 && pvp_rank >= 6 {
|
||||
d += f64::from(HONOR_STEP);
|
||||
if pvp_rank >= 8 {
|
||||
d += f64::from(HONOR_STEP);
|
||||
}
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// [`price_discount`] for `vendor` and the player: the vendor's reaction toward the player
|
||||
/// ([`ring_reaction`], `vendor->UnitReaction(player)` at `0x612b93`), the vendor's own
|
||||
/// `UNIT_FIELD_FLAGS` (`[[vendor+0x110]+0xa0]`) and the player's current honor rank,
|
||||
/// `PLAYER_BYTES_3` byte 3 (`[[player+0xe68]+0x1f]`), not the highest rank held.
|
||||
pub(crate) fn vendor_price_discount(
|
||||
factions: Option<&Factions>,
|
||||
reputations: &Reputations,
|
||||
vendor: &ObjectStore,
|
||||
player: &ObjectStore,
|
||||
) -> f64 {
|
||||
price_discount(
|
||||
ring_reaction(factions, reputations, Some(vendor), Some(player)),
|
||||
vendor.0.unit_flags(),
|
||||
player.0.player_pvp_rank().unwrap_or(0),
|
||||
)
|
||||
}
|
||||
|
||||
/// A fixture off the install's `Faction.dbc`: the catalog, a `FactionTemplate` of Stormwind
|
||||
/// (faction 72, which has a reputation slot) and a reputation table putting a human warrior
|
||||
/// (`UNIT_FIELD_BYTES_0` = [`HUMAN_WARRIOR`]) at `total` standing with it.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn stormwind_fixture(
|
||||
chain: &mut benilla_formats::Chain,
|
||||
total: i32,
|
||||
) -> (Factions, u32, Reputations) {
|
||||
const STORMWIND: u32 = 72;
|
||||
let catalog = benilla_formats::load_faction_catalog(chain).expect("Faction.dbc");
|
||||
let template = (1u32..4096)
|
||||
.find(|&id| catalog.template(id).is_some_and(|t| t.faction == STORMWIND))
|
||||
.expect("a Stormwind template");
|
||||
let info = catalog
|
||||
.reputation_faction(STORMWIND)
|
||||
.expect("Stormwind has a reputation slot");
|
||||
let slot = usize::try_from(info.rep_index).expect("a slot index");
|
||||
let mut reps = vec![(0u8, 0i32); slot + 1];
|
||||
reps[slot].1 = total - info.base_for(1, 1);
|
||||
(Factions::from_catalog(catalog), template, Reputations(reps))
|
||||
}
|
||||
|
||||
/// `UNIT_FIELD_BYTES_0` of a human (race 1) warrior (class 1), for [`stormwind_fixture`].
|
||||
#[cfg(test)]
|
||||
pub(crate) const HUMAN_WARRIOR: u32 = 1 | (1 << 8);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use benilla_protocol::field::{FIELD_UNIT_FACTIONTEMPLATE, FIELD_UNIT_FLAGS};
|
||||
use benilla_protocol::ObjectFields;
|
||||
|
||||
/// The five values `0x612b80` can return, as the f64 bits its `st0` holds.
|
||||
const ZERO: u64 = 0;
|
||||
const D05: u64 = 0x3fa9_9999_a000_0000;
|
||||
const D10: u64 = 0x3fb9_9999_a000_0000;
|
||||
const D15: u64 = 0x3fc3_3333_3800_0000;
|
||||
const D20: u64 = 0x3fc9_9999_a000_0000;
|
||||
|
||||
/// Every branch of `0x612b80`: the reputation term at reaction 5 only, the honor steps only at a
|
||||
/// PvP-flagged vendor, rank 6 and 8 the thresholds, and a Friendly (4) player still earning them.
|
||||
#[test]
|
||||
fn the_discount_is_the_references_sum_bit_for_bit() {
|
||||
for (reaction, flags, rank, want) in [
|
||||
(4u8, 0u32, 0u8, ZERO),
|
||||
(4, 0, 5, ZERO),
|
||||
(4, 0, 6, ZERO),
|
||||
(4, 0, 7, ZERO),
|
||||
(4, 0, 8, ZERO),
|
||||
(4, 0, 19, ZERO),
|
||||
(4, UNIT_FLAG_PVP, 0, ZERO),
|
||||
(4, UNIT_FLAG_PVP, 5, ZERO),
|
||||
(4, UNIT_FLAG_PVP, 6, D05),
|
||||
(4, UNIT_FLAG_PVP, 7, D05),
|
||||
(4, UNIT_FLAG_PVP, 8, D10),
|
||||
(4, UNIT_FLAG_PVP, 19, D10),
|
||||
(5, 0, 0, D10),
|
||||
(5, 0, 5, D10),
|
||||
(5, 0, 6, D10),
|
||||
(5, 0, 7, D10),
|
||||
(5, 0, 8, D10),
|
||||
(5, 0, 19, D10),
|
||||
(5, UNIT_FLAG_PVP, 0, D10),
|
||||
(5, UNIT_FLAG_PVP, 5, D10),
|
||||
(5, UNIT_FLAG_PVP, 6, D15),
|
||||
(5, UNIT_FLAG_PVP, 7, D15),
|
||||
(5, UNIT_FLAG_PVP, 8, D20),
|
||||
(5, UNIT_FLAG_PVP, 19, D20),
|
||||
// Revered and up pass the same signed `cmp eax,5`; Neutral does not.
|
||||
(7, UNIT_FLAG_PVP, 19, D20),
|
||||
(3, UNIT_FLAG_PVP, 19, D10),
|
||||
// Every other flag bit set but the PvP one: no honor step.
|
||||
(5, !UNIT_FLAG_PVP, 19, D10),
|
||||
// The rank compares are unsigned: a byte past 19 takes both steps.
|
||||
(4, UNIT_FLAG_PVP, 255, D10),
|
||||
] {
|
||||
let got = price_discount(reaction, flags, rank);
|
||||
assert_eq!(
|
||||
got.to_bits(),
|
||||
want,
|
||||
"reaction {reaction}, flags {flags:#x}, rank {rank}: got {got:e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The 0.15 case is the f64 sum of the two f32 constants, which no f32 holds: narrowing it would
|
||||
/// move the taxi fare.
|
||||
#[test]
|
||||
fn the_fifteen_percent_discount_is_an_f64() {
|
||||
let d = price_discount(5, UNIT_FLAG_PVP, 6);
|
||||
assert_eq!(d.to_bits(), (0.1f32 as f64 + 0.05f32 as f64).to_bits());
|
||||
assert_ne!(f64::from(d as f32).to_bits(), d.to_bits());
|
||||
}
|
||||
|
||||
/// `PLAYER_BYTES_3`, `PLAYER_FIELD_BYTES`: absolute descriptor indices.
|
||||
const PLAYER_BYTES_3: u16 = 195;
|
||||
const PLAYER_FIELD_BYTES: u16 = 1222;
|
||||
|
||||
/// The flag is read off the vendor and the rank off the player's current-rank byte, never the
|
||||
/// highest-rank-held byte of `PLAYER_FIELD_BYTES`. With no faction catalog the reaction is
|
||||
/// Neutral, so only the honor steps can show.
|
||||
#[test]
|
||||
fn the_inputs_are_the_vendors_flag_and_the_players_current_rank() {
|
||||
let reps = Reputations::default();
|
||||
let store = |pairs: &[(u16, u32)]| ObjectStore(ObjectFields::from_pairs(pairs));
|
||||
let pvp_vendor = store(&[(FIELD_UNIT_FLAGS, UNIT_FLAG_PVP)]);
|
||||
let plain_vendor = store(&[(FIELD_UNIT_FLAGS, 0)]);
|
||||
let rank_8 = store(&[(PLAYER_BYTES_3, 8 << 24), (PLAYER_FIELD_BYTES, 0)]);
|
||||
let held_8 = store(&[(PLAYER_BYTES_3, 0), (PLAYER_FIELD_BYTES, 8 << 24)]);
|
||||
let flagged_player = store(&[(PLAYER_BYTES_3, 8 << 24), (FIELD_UNIT_FLAGS, UNIT_FLAG_PVP)]);
|
||||
let disc =
|
||||
|v: &ObjectStore, p: &ObjectStore| vendor_price_discount(None, &reps, v, p).to_bits();
|
||||
assert_eq!(disc(&pvp_vendor, &rank_8), D10, "two honor steps");
|
||||
assert_eq!(
|
||||
disc(&pvp_vendor, &held_8),
|
||||
ZERO,
|
||||
"the highest rank held is not the byte"
|
||||
);
|
||||
assert_eq!(
|
||||
disc(&plain_vendor, &flagged_player),
|
||||
ZERO,
|
||||
"the player's own PvP flag is not the vendor's"
|
||||
);
|
||||
}
|
||||
|
||||
/// `UNIT_FIELD_BYTES_0`, absolute descriptor index.
|
||||
const BYTES_0: u16 = 36;
|
||||
|
||||
/// The reputation term reads the vendor's reaction to the player, the standing rank: Friendly
|
||||
/// (8999) earns nothing, Honored (9000) the 0.1. The player's reaction to the vendor would be
|
||||
/// 4 at both (not at war), so the other direction never discounts.
|
||||
#[test]
|
||||
fn the_reputation_term_is_the_vendors_reaction_to_the_player() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
for (total, want) in [(8999, ZERO), (9000, D10), (42000, D10)] {
|
||||
let (factions, template, reps) = stormwind_fixture(&mut chain, total);
|
||||
let vendor = ObjectStore(ObjectFields::from_pairs(&[(
|
||||
FIELD_UNIT_FACTIONTEMPLATE,
|
||||
template,
|
||||
)]));
|
||||
let me = ObjectStore(ObjectFields::from_pairs(&[(BYTES_0, HUMAN_WARRIOR)]));
|
||||
let got = vendor_price_discount(Some(&factions), &reps, &vendor, &me);
|
||||
assert_eq!(got.to_bits(), want, "standing {total}: got {got:e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -271,13 +271,14 @@ fn resolve_buyback(
|
||||
}
|
||||
|
||||
/// One item's repair cost, by the reference's `0x4faf30` arithmetic
|
||||
/// ([`benilla_formats::DurabilityTables`]); its reputation discount is not applied (passed as 0).
|
||||
/// ([`benilla_formats::DurabilityTables`]), after the price `discount` (`0x4faf8a`).
|
||||
fn item_repair_cost(
|
||||
guid: u64,
|
||||
objects: &Objects,
|
||||
items: &Items,
|
||||
tables: &RepairTables,
|
||||
commands: &NetCommands,
|
||||
discount: f64,
|
||||
) -> u32 {
|
||||
let Some(obj) = objects.object(guid) else {
|
||||
return 0;
|
||||
@@ -295,22 +296,26 @@ fn item_repair_cost(
|
||||
let (level, quality, class, subclass) = (t.item_level, t.quality, t.class, t.subclass);
|
||||
tables
|
||||
.0
|
||||
.repair_cost(points, level, quality, class, subclass, 0.0)
|
||||
.repair_cost(points, level, quality, class, subclass, discount)
|
||||
}
|
||||
|
||||
/// The repair-all total over the reference's three sweeps (`0x4fbd60`): equipped slots 0-18, the
|
||||
/// backpack and the four bags' contents; never the bank, keyring or buyback.
|
||||
/// backpack and the four bags' contents; never the bank, keyring or buyback. It sums the
|
||||
/// discounted, rounded per-item costs (`0x4fbe0b`); the total itself is never discounted.
|
||||
fn repair_all_cost(
|
||||
store: &benilla_protocol::ObjectFields,
|
||||
objects: &Objects,
|
||||
items: &Items,
|
||||
tables: &RepairTables,
|
||||
commands: &NetCommands,
|
||||
discount: f64,
|
||||
) -> u32 {
|
||||
let mut total: u64 = 0;
|
||||
let mut add = |guid: u64, items: &Items| {
|
||||
if guid != 0 {
|
||||
total += u64::from(item_repair_cost(guid, objects, items, tables, commands));
|
||||
total += u64::from(item_repair_cost(
|
||||
guid, objects, items, tables, commands, discount,
|
||||
));
|
||||
}
|
||||
};
|
||||
for i in 0..19u8 {
|
||||
@@ -338,15 +343,22 @@ fn repair_all_cost(
|
||||
total.min(u64::from(u32::MAX)) as u32
|
||||
}
|
||||
|
||||
/// The open vendor's unit, `None` with none open or its unit not streamed.
|
||||
fn vendor_store<'a>(
|
||||
open: &MerchantOpen,
|
||||
units: &'a Query<(&Guid, &ObjectStore), Without<SelfPlayer>>,
|
||||
) -> Option<&'a ObjectStore> {
|
||||
open.vendor
|
||||
.and_then(|g| units.iter().find(|(guid, _)| guid.0 == g))
|
||||
.map(|(_, store)| store)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
vendor_store(open, units).map_or(0, |store| store.0.unit_npc_flags())
|
||||
}
|
||||
|
||||
/// Push `GetRepairAllCost`'s total, which the reference sweeps at the call (`0x4fbd60`): every
|
||||
@@ -359,6 +371,7 @@ fn feed_repair_all_cost(
|
||||
commands: Res<NetCommands>,
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
units: Query<(&Guid, &ObjectStore), Without<SelfPlayer>>,
|
||||
reactions: crate::target::ReactionInputs,
|
||||
tables: Option<Res<RepairTables>>,
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<u32>>>,
|
||||
) {
|
||||
@@ -366,9 +379,19 @@ fn feed_repair_all_cost(
|
||||
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),
|
||||
let vendor =
|
||||
vendor_store(&open, &units).filter(|store| store.0.unit_npc_flags() & NPC_FLAG_REPAIR != 0);
|
||||
let total = match (vendor, self_q.iter().next(), tables.as_deref()) {
|
||||
(Some(vendor), Some(store), Some(t)) => {
|
||||
// The open merchant's discount for the player (`0x4faf8a`).
|
||||
let discount = crate::target::vendor_price_discount(
|
||||
reactions.factions.as_deref(),
|
||||
&reactions.reputations,
|
||||
vendor,
|
||||
store,
|
||||
);
|
||||
repair_all_cost(&store.0, &objects, &items, t, &commands, discount)
|
||||
}
|
||||
_ => 0,
|
||||
};
|
||||
if *last != Some(total) {
|
||||
@@ -663,6 +686,101 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
/// `GetRepairAllCost` sums each item's cost after the open merchant's discount (`0x4faf8a`):
|
||||
/// at Honored with a PvP-flagged vendor and honor rank byte 8, 0.2 comes off each item before
|
||||
/// its rounding, and the undiscounted total is not what the VM reads.
|
||||
#[test]
|
||||
fn the_repair_all_total_takes_the_merchants_discount() {
|
||||
use benilla_protocol::field::{
|
||||
FIELD_PLAYER_INV_SLOT_HEAD, FIELD_UNIT_FACTIONTEMPLATE, FIELD_UNIT_FLAGS,
|
||||
FIELD_UNIT_NPC_FLAGS,
|
||||
};
|
||||
use benilla_protocol::ObjectFields;
|
||||
use bevy::ecs::system::RunSystemOnce;
|
||||
|
||||
/// Absolute descriptor indices.
|
||||
const OBJECT_ENTRY: u16 = 3;
|
||||
const ITEM_DURABILITY: u16 = 46;
|
||||
const ITEM_MAXDURABILITY: u16 = 47;
|
||||
const BYTES_0: u16 = 36;
|
||||
const PLAYER_BYTES_3: u16 = 195;
|
||||
const VENDOR: u64 = 0xF130_0000_0000_0042;
|
||||
const SWORD: u64 = 0x4000_0000_0000_0007;
|
||||
const SWORD_ENTRY: u32 = 2488;
|
||||
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
let tables = benilla_formats::load_durability_tables(&mut chain).expect("durability");
|
||||
let (factions, template, reps) = crate::target::stormwind_fixture(&mut chain, 9000);
|
||||
// A level 20 green sword (class 2, subclass 7) 40 points down.
|
||||
let disc = f64::from(0.1f32) + f64::from(0.05f32) + f64::from(0.05f32);
|
||||
let want = tables.repair_cost(40, 20, 2, 2, 7, disc);
|
||||
let full = tables.repair_cost(40, 20, 2, 2, 7, 0.0);
|
||||
assert!(
|
||||
want > 0 && want < full,
|
||||
"the case discriminates: {want} of {full}"
|
||||
);
|
||||
|
||||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||||
let mut app = App::new();
|
||||
app.init_resource::<Items>()
|
||||
.init_resource::<crate::net::GuidIndex>()
|
||||
.insert_resource(NetCommands(tx))
|
||||
.insert_resource(RepairTables(tables))
|
||||
.insert_resource(factions)
|
||||
.insert_resource(reps);
|
||||
let mut open = MerchantOpen::default();
|
||||
open.open(VENDOR, Vec::new());
|
||||
app.insert_resource(open);
|
||||
let mut sword = crate::items::test_template("Sword");
|
||||
(sword.class, sword.subclass, sword.quality, sword.item_level) = (2, 7, 2, 20);
|
||||
app.world_mut()
|
||||
.resource_mut::<Items>()
|
||||
.insert_template(SWORD_ENTRY, Some(sword));
|
||||
crate::items::test_spawn_item(
|
||||
app.world_mut(),
|
||||
SWORD,
|
||||
ObjectFields::from_pairs(&[
|
||||
(OBJECT_ENTRY, SWORD_ENTRY),
|
||||
(ITEM_DURABILITY, 35),
|
||||
(ITEM_MAXDURABILITY, 75),
|
||||
]),
|
||||
false,
|
||||
);
|
||||
// The sword in the main hand (slot 15); the current honor rank byte 8.
|
||||
let main_hand = FIELD_PLAYER_INV_SLOT_HEAD + 2 * 15;
|
||||
app.world_mut().spawn((
|
||||
SelfPlayer,
|
||||
ObjectStore(ObjectFields::from_pairs(&[
|
||||
(BYTES_0, crate::target::HUMAN_WARRIOR),
|
||||
(PLAYER_BYTES_3, 8 << 24),
|
||||
(main_hand, SWORD as u32),
|
||||
(main_hand + 1, (SWORD >> 32) as u32),
|
||||
])),
|
||||
));
|
||||
app.world_mut().spawn((
|
||||
Guid(VENDOR),
|
||||
ObjectStore(ObjectFields::from_pairs(&[
|
||||
(FIELD_UNIT_NPC_FLAGS, NPC_FLAG_REPAIR),
|
||||
(FIELD_UNIT_FACTIONTEMPLATE, template),
|
||||
(FIELD_UNIT_FLAGS, 0x1000),
|
||||
])),
|
||||
));
|
||||
let mut script = UiScript::new().unwrap();
|
||||
script.set_merchant(Some(MerchantState {
|
||||
can_repair: true,
|
||||
..Default::default()
|
||||
}));
|
||||
app.insert_non_send_resource(script);
|
||||
|
||||
app.world_mut()
|
||||
.run_system_once(feed_repair_all_cost)
|
||||
.unwrap();
|
||||
let script = app.world_mut().non_send_resource_mut::<UiScript>();
|
||||
let got = script.eval::<i64>("return (GetRepairAllCost())").unwrap();
|
||||
assert_eq!(got, i64::from(want), "the undiscounted total is {full}");
|
||||
}
|
||||
|
||||
fn row(entry: u32, slot: u32, count: u32) -> VendorItem {
|
||||
VendorItem {
|
||||
slot,
|
||||
|
||||
@@ -8,7 +8,7 @@ use bevy::prelude::*;
|
||||
use benilla_ui::script::{TaxiUiState, UiScript};
|
||||
|
||||
use crate::names::NameCache;
|
||||
use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfPlayer};
|
||||
use crate::net::{ClientCommand, GuidIndex, NetCommands, ObjectStore, SelfPlayer};
|
||||
use crate::player::UNIT_FLAG_TAXI_FLIGHT;
|
||||
use crate::ui_script::{UiFeed, UiInput};
|
||||
use crate::ui_session::{close_npc_session_out_of_range, NpcSession};
|
||||
@@ -89,6 +89,10 @@ fn feed_taxi(
|
||||
names: Res<NameCache>,
|
||||
commands: Res<NetCommands>,
|
||||
mut cache: ResMut<TaxiRouteCache>,
|
||||
index: Res<GuidIndex>,
|
||||
stores: Query<&ObjectStore>,
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
reactions: crate::target::ReactionInputs,
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<TaxiUiState>>>,
|
||||
mut last_name: Local<crate::ui_script::VmMemo<Option<String>>>,
|
||||
mut sink: crate::ui_action::MessageSink,
|
||||
@@ -145,9 +149,22 @@ fn feed_taxi(
|
||||
return;
|
||||
};
|
||||
|
||||
// The continent is the current node's own, as the packet left it (`build_nodes`).
|
||||
// The continent is the current node's own, as the packet left it (`build_nodes`). The fares
|
||||
// take the flight master's price discount for the player (`0x4dc45d`, `0x4dc4a2`).
|
||||
let fresh = state.open.as_ref().and_then(|open| {
|
||||
let (map_id, nodes, resolved) = build_nodes(open, &catalogs)?;
|
||||
let flightmaster = index
|
||||
.0
|
||||
.get(&open.flightmaster)
|
||||
.and_then(|&e| stores.get(e).ok());
|
||||
let discount = flightmaster.zip(self_q.iter().next()).map(|(master, me)| {
|
||||
crate::target::vendor_price_discount(
|
||||
reactions.factions.as_deref(),
|
||||
&reactions.reputations,
|
||||
master,
|
||||
me,
|
||||
)
|
||||
});
|
||||
let (map_id, nodes, resolved) = build_nodes(open, &catalogs, discount)?;
|
||||
cache.0 = resolved;
|
||||
Some(TaxiUiState {
|
||||
art: format!("Interface\\TaxiFrame\\TAXIMAP{map_id}"),
|
||||
@@ -334,6 +351,115 @@ mod tests {
|
||||
assert!(!on_taxi(&mut app), "landed: nil again");
|
||||
}
|
||||
|
||||
/// The map's fares take the flight master's discount and the express leg sends the raw route
|
||||
/// sum (`0x4dc3d0` against `0x4dc96f`). Honored, a PvP-flagged flight master and honor rank
|
||||
/// byte 6 make 0.15: Sentinel Hill's direct 110 shows 94, and Thelsamar's route through
|
||||
/// Ironforge, 50 + 110, shows 136 and sends 160. With the flight master unresolved the fares
|
||||
/// show 0 (`0x4dc423`).
|
||||
#[test]
|
||||
fn the_map_shows_discounted_fares_and_the_express_sends_the_raw_sum() {
|
||||
use benilla_protocol::field::FIELD_UNIT_FACTIONTEMPLATE;
|
||||
use bevy::ecs::system::RunSystemOnce;
|
||||
|
||||
/// `UNIT_FIELD_BYTES_0`, `PLAYER_BYTES_3`: absolute descriptor indices.
|
||||
const BYTES_0: u16 = 36;
|
||||
const PLAYER_BYTES_3: u16 = 195;
|
||||
const MASTER: u64 = 0xF130_0000_0000_0099;
|
||||
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
let catalogs = TaxiCatalogs::load(&mut chain).expect("taxi DBCs");
|
||||
let (factions, template, reps) = crate::target::stormwind_fixture(&mut chain, 9000);
|
||||
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
let mut app = App::new();
|
||||
app.init_resource::<TaxiState>()
|
||||
.init_resource::<TaxiRouteCache>()
|
||||
.init_resource::<GuidIndex>()
|
||||
.init_resource::<NameCache>()
|
||||
.init_resource::<crate::ui_chat::ChatLog>()
|
||||
.init_resource::<crate::sound::MessageSounds>()
|
||||
.insert_resource(NetCommands(tx))
|
||||
.insert_resource(catalogs)
|
||||
.insert_resource(factions)
|
||||
.insert_resource(reps)
|
||||
.insert_non_send_resource(UiScript::new().unwrap());
|
||||
app.world_mut().spawn((
|
||||
SelfPlayer,
|
||||
ObjectStore(ObjectFields::from_pairs(&[
|
||||
(BYTES_0, crate::target::HUMAN_WARRIOR),
|
||||
(PLAYER_BYTES_3, 6 << 24),
|
||||
])),
|
||||
));
|
||||
let master = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
crate::net::Guid(MASTER),
|
||||
ObjectStore(ObjectFields::from_pairs(&[
|
||||
(FIELD_UNIT_FACTIONTEMPLATE, template),
|
||||
(FIELD_UNIT_FLAGS, 0x1000),
|
||||
])),
|
||||
))
|
||||
.id();
|
||||
app.world_mut()
|
||||
.resource_mut::<GuidIndex>()
|
||||
.0
|
||||
.insert(MASTER, master);
|
||||
// At Stormwind (2), knowing Sentinel Hill (4), Ironforge (6) and Thelsamar (8).
|
||||
app.world_mut().resource_mut::<TaxiState>().open(
|
||||
MASTER,
|
||||
2,
|
||||
TaxiMask([0b1010_1010, 0, 0, 0, 0, 0, 0, 0]),
|
||||
);
|
||||
|
||||
let node = |app: &mut App, name: &str| -> (i64, i64) {
|
||||
app.world_mut()
|
||||
.non_send_resource_mut::<UiScript>()
|
||||
.eval(&format!(
|
||||
"for i = 1, NumTaxiNodes() do \
|
||||
if TaxiNodeName(i) == {name:?} then return i, TaxiNodeCost(i) end \
|
||||
end"
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
app.world_mut().run_system_once(feed_taxi).unwrap();
|
||||
assert_eq!(node(&mut app, "Sentinel Hill, Westfall").1, 94);
|
||||
assert_eq!(node(&mut app, "Ironforge, Dun Morogh").1, 42);
|
||||
let (thelsamar, shown) = node(&mut app, "Thelsamar, Loch Modan");
|
||||
assert_eq!(shown, 136);
|
||||
|
||||
app.world_mut()
|
||||
.non_send_resource_mut::<UiScript>()
|
||||
.run(&format!("TakeTaxiNode({thelsamar})"))
|
||||
.unwrap();
|
||||
app.world_mut().run_system_once(drain_taxi).unwrap();
|
||||
// The feed's name lookup queries the flight master; only the activates matter here.
|
||||
let sent: Vec<_> = rx
|
||||
.try_iter()
|
||||
.filter(|c| {
|
||||
matches!(
|
||||
c,
|
||||
ClientCommand::ActivateTaxi { .. } | ClientCommand::ActivateTaxiExpress { .. }
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
matches!(
|
||||
sent.as_slice(),
|
||||
[ClientCommand::ActivateTaxiExpress { total_cost: 160, nodes, .. }]
|
||||
if nodes == &[2, 6, 8]
|
||||
),
|
||||
"the express carries the undiscounted sum, got {sent:?}"
|
||||
);
|
||||
|
||||
app.world_mut()
|
||||
.resource_mut::<GuidIndex>()
|
||||
.0
|
||||
.remove(&MASTER);
|
||||
app.world_mut().run_system_once(feed_taxi).unwrap();
|
||||
assert_eq!(node(&mut app, "Sentinel Hill, Westfall").1, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_close_and_session_clear() {
|
||||
let mut state = TaxiState::default();
|
||||
|
||||
@@ -41,30 +41,33 @@ pub(super) fn load_taxi_catalogs(
|
||||
};
|
||||
*done = true;
|
||||
let mut chain = assets.chain.lock_recover();
|
||||
let loaded = load_taxi_nodes(&mut chain).and_then(|nodes| {
|
||||
let paths = load_taxi_paths(&mut chain)?;
|
||||
let continents = load_world_map_continent_catalog(&mut chain)?;
|
||||
Ok((nodes, paths, continents))
|
||||
});
|
||||
let loaded = TaxiCatalogs::load(&mut chain);
|
||||
drop(chain);
|
||||
match loaded {
|
||||
Ok((nodes, paths, continents)) => {
|
||||
Ok(cat) => {
|
||||
info!(
|
||||
"ui_taxi: catalogs loaded — {} nodes, {} paths, {} continents",
|
||||
nodes.len(),
|
||||
paths.len(),
|
||||
continents.len()
|
||||
cat.nodes.len(),
|
||||
cat.paths.len(),
|
||||
cat.continents.len()
|
||||
);
|
||||
commands.insert_resource(TaxiCatalogs {
|
||||
nodes,
|
||||
paths,
|
||||
continents,
|
||||
});
|
||||
commands.insert_resource(cat);
|
||||
}
|
||||
Err(e) => error!("ui_taxi: DBC catalogs failed to load, taxi map disabled: {e:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
impl TaxiCatalogs {
|
||||
/// The three tables off the patch chain.
|
||||
pub(super) fn load(chain: &mut benilla_formats::Chain) -> anyhow::Result<Self> {
|
||||
Ok(Self {
|
||||
nodes: load_taxi_nodes(chain)?,
|
||||
paths: load_taxi_paths(chain)?,
|
||||
continents: load_world_map_continent_catalog(chain)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A node's world `(x, y)` on the taxi map's 0..1 space, as the reference computes it
|
||||
/// (`0x4db958`), with the rect from `WorldMapContinent.dbc` fields 9-12 of the node's continent:
|
||||
///
|
||||
@@ -145,6 +148,14 @@ fn shortest_route(
|
||||
Some((chain, total_fare))
|
||||
}
|
||||
|
||||
/// `TaxiNodeCost`'s fare (`0x4dc3d0`): the price times `1 - discount`, both in f64 with nothing
|
||||
/// stored (`fsubr`, `fmul st,st(1)`), the product stored as an f32 and rounded half to even
|
||||
/// (`fstp dword`, `fistp dword`).
|
||||
fn shown_fare(price: u32, discount: f64) -> u32 {
|
||||
let product = (f64::from(price) * (1.0 - discount)) as f32;
|
||||
f64::from(product).round_ties_even() as u32
|
||||
}
|
||||
|
||||
/// A listed node's chain from the nearest node (the node alone for `Current`) and its fare, for
|
||||
/// `drain_taxi`.
|
||||
pub(super) struct ResolvedTaxiNode {
|
||||
@@ -161,9 +172,14 @@ pub(super) struct TaxiRouteCache(pub(super) Vec<ResolvedTaxiNode>);
|
||||
/// `Reachable` with its fare and route segments, and an unroutable one is left out, where the
|
||||
/// reference keeps it typed `NONE`, which stock `TaxiFrame.lua` hides; its `DISTANT` type is
|
||||
/// never produced. Returns the continent's map id, the Lua nodes and their routes, in one order.
|
||||
///
|
||||
/// A Lua node's fare takes the flight master's price `discount` ([`shown_fare`]), and is 0 with
|
||||
/// none, when the flight master or the player does not resolve (`0x4dc3fa`, `0x4dc423`); the
|
||||
/// route's fare stays the raw sum, which `CMSG_ACTIVATETAXIEXPRESS` carries (`0x4dc96f`).
|
||||
pub(super) fn build_nodes(
|
||||
open: &TaxiOpen,
|
||||
cat: &TaxiCatalogs,
|
||||
discount: Option<f64>,
|
||||
) -> Option<(u32, Vec<TaxiUiNode>, Vec<ResolvedTaxiNode>)> {
|
||||
let map_id = cat.nodes.get(open.nearest_node)?.map_id;
|
||||
let cont = cat.continents.get(map_id)?;
|
||||
@@ -230,7 +246,7 @@ pub(super) fn build_nodes(
|
||||
name: n.name.clone(),
|
||||
node_type: TaxiNodeType::Reachable,
|
||||
pos,
|
||||
cost,
|
||||
cost: discount.map_or(0, |d| shown_fare(cost, d)),
|
||||
routes,
|
||||
});
|
||||
resolved.push(ResolvedTaxiNode { chain, cost });
|
||||
@@ -351,6 +367,29 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `0x4dc3d0`'s rounding: `1 - discount` stays f64 and only the product is stored as f32, so a
|
||||
/// near-tie becomes an exact `.5` and rounds to even where the f64 shortcut rounds down.
|
||||
#[test]
|
||||
fn the_shown_fare_stores_only_the_product_as_f32() {
|
||||
let (d05, d10) = (f64::from(0.05f32), f64::from(0.1f32));
|
||||
let d15 = d10 + d05;
|
||||
// 0.1: 15 × 0.89999999850988388 = 13.4999999776, stored 13.5, to even 14; the f64
|
||||
// shortcut gives 13.
|
||||
assert_eq!(shown_fare(15, d10), 14);
|
||||
// 0.15: 30 × 0.84999999776482582 = 25.4999999329, stored 25.5, to even 26; and 110
|
||||
// (Stormwind to Sentinel Hill) = 93.4999997541, stored 93.5, to even 94.
|
||||
assert_eq!(shown_fare(30, d15), 26);
|
||||
assert_eq!(shown_fare(110, d15), 94);
|
||||
// 0.15 at 786437 stores 668471.4375, so 668471; repair's f32 multiplier gives 668472.
|
||||
assert_eq!(shown_fare(786_437, d15), 668_471);
|
||||
// 0.15 at 2097157 stores 1782583.5, to even 1782584; the discount narrowed to f32 first
|
||||
// (0.15000000596) would store 1782583.375, so 1782583.
|
||||
assert_eq!(shown_fare(2_097_157, d15), 1_782_584);
|
||||
// A plain case: 0.2 at 110 is 88, and no discount is the price.
|
||||
assert_eq!(shown_fare(110, d15 + d05), 88);
|
||||
assert_eq!(shown_fare(110, 0.0), 110);
|
||||
}
|
||||
|
||||
/// A non-square rect, where cross-axis and same-axis denominators differ.
|
||||
#[test]
|
||||
fn projection_uses_cross_axis_denominators() {
|
||||
@@ -439,7 +478,7 @@ mod tests {
|
||||
nearest_node: 2,
|
||||
known: mask_of(&[2, 4]),
|
||||
};
|
||||
let (map_id, ui, resolved) = build_nodes(&open, &cat).expect("map builds");
|
||||
let (map_id, ui, resolved) = build_nodes(&open, &cat, Some(0.0)).expect("map builds");
|
||||
assert_eq!(map_id, 0, "the continent is the nearest node's own map");
|
||||
assert_eq!(ui.len(), 2, "only the two known EK nodes are visible");
|
||||
|
||||
@@ -467,7 +506,7 @@ mod tests {
|
||||
nearest_node: 2,
|
||||
known: all_known,
|
||||
};
|
||||
let (_, ui, _) = build_nodes(&open, &cat).expect("map builds");
|
||||
let (_, ui, _) = build_nodes(&open, &cat, Some(0.0)).expect("map builds");
|
||||
let ek_known_total = cat.nodes.rows().filter(|n| n.map_id == 0).count();
|
||||
assert!(
|
||||
ui.len() < ek_known_total,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! `DurabilityCosts.dbc` and `DurabilityQuality.dbc`, behind the repair price the reference
|
||||
//! computes itself (`0x5da330`, wrapped by `0x4faf30`): points lost × the quality row
|
||||
//! `2·Quality + 1` × the item level's weapon or armor column by subclass, rounded half away from
|
||||
//! zero to at least 1, then × `(1 - discount)` rounded half to even. vmangos keys the quality row
|
||||
//! `(Quality + 1)·2` (`Player.cpp:4996`), so its charge may differ from the price shown.
|
||||
//! zero to at least 1, then × `f32(1 - discount)`, stored as f32 and rounded half to even. vmangos
|
||||
//! keys the quality row `(Quality + 1)·2` (`Player.cpp:4996`), so its charge may differ from the
|
||||
//! price shown.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -35,7 +36,8 @@ impl DurabilityTables {
|
||||
row.get(idx).copied()
|
||||
}
|
||||
|
||||
/// The displayed repair cost of one item in copper, after the vendor's reputation `discount`.
|
||||
/// The displayed repair cost of one item in copper, `0x4faf30`, after `discount`, the price
|
||||
/// discount `0x612b80` returns (0 with no merchant open), kept in f64 as the reference's `st0`.
|
||||
pub fn repair_cost(
|
||||
&self,
|
||||
points_lost: u32,
|
||||
@@ -43,7 +45,7 @@ impl DurabilityTables {
|
||||
quality: u32,
|
||||
class: u32,
|
||||
subclass: u32,
|
||||
discount: f32,
|
||||
discount: f64,
|
||||
) -> u32 {
|
||||
if points_lost == 0 {
|
||||
return 0;
|
||||
@@ -57,13 +59,11 @@ impl DurabilityTables {
|
||||
let base = f64::from(points_lost) * f64::from(mult) * f64::from(col);
|
||||
// Half away from zero, as the reference's ±0.5 then `_ftol`, then at least 1.
|
||||
let per = ((base + 0.5).floor() as i64).max(1);
|
||||
// The discount multiply rounds half to even.
|
||||
let discounted = per as f64 * f64::from(1.0 - discount);
|
||||
let f = discounted.floor();
|
||||
let frac = discounted - f;
|
||||
let up = frac > 0.5 || (frac == 0.5 && (f as i64) % 2 != 0);
|
||||
let rounded = if up { f + 1.0 } else { f };
|
||||
rounded.max(0.0) as u32
|
||||
// `1 - discount` stored as an f32 (`0x4faf8f fsubr`, `0x4faf95 fstp dword`).
|
||||
let factor = (1.0 - discount) as f32;
|
||||
// The product stored as an f32 (`0x4fafaf`), then `fistp` rounds half to even (`0x4fafb5`).
|
||||
let product = (per as f64 * f64::from(factor)) as f32;
|
||||
f64::from(product).round_ties_even() as u32
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,4 +155,31 @@ mod tests {
|
||||
assert_eq!(t.repair_cost(43, 23, 1, 2, 7, 0.5), 22);
|
||||
assert_eq!(t.repair_cost(42, 23, 1, 2, 7, 0.5), 21);
|
||||
}
|
||||
|
||||
/// `0x4faf30` at the discounts `0x612b80` returns: the f32 multiplier and the f32 product make
|
||||
/// a near-tie an exact `.5`, which then rounds to even where the f64 shortcut rounds down.
|
||||
#[test]
|
||||
fn the_discounted_cost_stores_the_multiplier_and_the_product_as_f32() {
|
||||
let mut t = tables();
|
||||
t.qualities.insert(3, 1.0);
|
||||
let mut row = vec![0u32; 29];
|
||||
row[7] = 1; // 1 copper/point so points == the base price
|
||||
t.costs.insert(23, row);
|
||||
let cost = |base: u32, disc: f64| t.repair_cost(base, 23, 1, 2, 7, disc);
|
||||
let (d05, d10) = (f64::from(0.05f32), f64::from(0.1f32));
|
||||
// 0.1: f32(0.9) = 0.89999997615814208984375; × 15 = 13.49999964237213134765625, stored
|
||||
// 13.5, to even 14. The f64 shortcut `rint(15 × 0.8999999985)` gives 13.
|
||||
assert_eq!(cost(15, d10), 14);
|
||||
// 0.05: f32(0.95) = 0.949999988079071044921875; × 10 = 9.49999988079071044921875, stored
|
||||
// 9.5, to even 10. The f64 shortcut gives 9.
|
||||
assert_eq!(cost(10, d05), 10);
|
||||
// 0.15, the f64 sum: f32(0.85) = 0.85000002384185791015625; × 30 =
|
||||
// 25.5000007152557373046875, stored 25.5, to even 26. The f64 shortcut gives 25.
|
||||
assert_eq!(cost(30, d10 + d05), 26);
|
||||
// 0.15 at 786437: × f32(0.85) = 668471.46875012, stored 668471.5, to even 668472; the
|
||||
// taxi's f64 multiplier stores 668471.4375 there, so 668471.
|
||||
assert_eq!(cost(786_437, d10 + d05), 668_472);
|
||||
// No discount: the base itself.
|
||||
assert_eq!(cost(15, 0.0), 15);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user