Native trade hook: each trade's own offer snapshot; xtask stub; docs

Event::Traded now carries the offer as notifyTrade left it, so a
shift-click whose trades level the villager up reports each trade's own
prices. xtask's mod API check gets a MerchantOffer stand-in.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
levzzzandClaude Opus 5.5 committed 2026-11-02 13:37:30 +05:00
1 parent 0dd9865328
commit 60191c6b70
4 files changed
+37 -86

No files matched your search

+6 -81
View File
@@ -423,7 +423,7 @@ impl Game<'_> {
) -> (Box<merchant::Session>, Vec<MerchantOffer>) {
let mut session = session;
let events = std::mem::take(&mut session.events);
let traded = traded_offers(&events, &session.offers);
let mut traded = Vec::new();
let (villager, origin) = (session.villager, session.origin);
if let Some(m) = mob.and_then(|id| self.mobs.iter_mut().find(|m| m.id == id)) {
m.random = session.random.clone();
@@ -451,7 +451,7 @@ impl Game<'_> {
};
self.audio.play(sound, Some(origin), 1.0, pitch);
}
merchant::Event::Traded(_) => {}
merchant::Event::Traded(_, offer) => traded.push(*offer),
merchant::Event::TradeSound => {
let sound = if villager {
"minecraft:entity.villager.yes"
@@ -464,10 +464,10 @@ impl Game<'_> {
}
(session, traded)
}
/// `MerchantResultSlot.onTake` for each trade made (`offers`, as they
/// are after it): `AbstractVillager.notifyTrade`'s `villager_trade`
/// trigger, the statistic, then the mods' `native_trade_with_villager`
/// for a server's player.
/// `MerchantResultSlot.onTake` for each trade made (`offers`, as each
/// was right after its trade): the statistic, `AbstractVillager.notifyTrade`'s
/// `villager_trade` trigger, then the mods' `native_trade_with_villager`
/// for a server's player (when the trader is still in the level).
pub(super) fn merchant_traded(&mut self, mob: Option<u32>, offers: &[MerchantOffer]) {
for offer in offers {
self.stats.custom("traded_with_villager", 1);
@@ -935,33 +935,6 @@ fn drag_scroll(y: f32, offers: usize) -> usize {
((offs * max as f32 + 0.5) as i32).clamp(0, max) as usize
}
/// Each trade a click made, as its offer was right after it: `offers` are
/// as they are after all of the click's trades (a shift-click trades
/// several times), so each copy counts only the uses up to its own trade.
fn traded_offers(events: &[merchant::Event], offers: &[MerchantOffer]) -> Vec<MerchantOffer> {
let mut later = vec![0; offers.len()];
for e in events {
if let merchant::Event::Traded(index) = e {
if let Some(n) = later.get_mut(*index) {
*n += 1;
}
}
}
let mut traded = Vec::new();
for e in events {
if let merchant::Event::Traded(index) = *e {
if let (Some(offer), Some(n)) = (offers.get(index), later.get_mut(index)) {
*n -= 1;
traded.push(MerchantOffer {
uses: offer.uses - *n,
..offer.clone()
});
}
}
}
traded
}
/// `MerchantScreen.mouseScrolled`: `Mth.clamp((int)(scrollOff - scrollY), 0, max)`,
/// so a fractional wheel step that truncates to zero does not move the list.
fn scrolled(scroll: usize, max: usize, d: f32) -> usize {
@@ -1007,52 +980,4 @@ mod tests {
assert_eq!(drag_scroll(31.5 + 112.0, 10), 3);
assert_eq!(drag_scroll(500.0, 10), 3);
}
fn offer(result: &str, uses: i32) -> MerchantOffer {
MerchantOffer {
base_cost_a: merchant::ItemCost {
stack: ItemStack::new("minecraft:emerald", 1),
},
cost_b: None,
result: ItemStack::new(result, 1),
uses,
max_uses: 12,
reward_exp: true,
special_price_diff: 0,
demand: 0,
price_multiplier: 0.05,
xp: 1,
}
}
/// A shift-click that trades offer 1 three times and offer 0 once: each
/// trade is heard with its offer's uses as they were right after it
/// (MerchantResultSlot.onTake runs per trade), not the click's final count.
#[test]
fn each_trade_of_a_click_sees_its_own_use_count() {
use merchant::Event::{Orb, TradeSound, Traded};
// After the click: offer 0 was at 4 uses, offer 1 at 2.
let offers = [offer("minecraft:bread", 5), offer("minecraft:arrow", 5)];
let events = [
Traded(1),
Orb(3),
Traded(1),
TradeSound,
Traded(0),
Traded(1),
];
let traded = traded_offers(&events, &offers);
let seen: Vec<_> = traded.iter().map(|o| (&*o.result.item, o.uses)).collect();
assert_eq!(
seen,
[
("minecraft:arrow", 3),
("minecraft:arrow", 4),
("minecraft:bread", 5),
("minecraft:arrow", 5),
]
);
// A trade of an offer that is gone is not heard.
assert!(traded_offers(&[Traded(7)], &offers).is_empty());
}
}
+27 -3
View File
@@ -278,8 +278,9 @@ pub enum Event {
TradeUpdated(bool, f32),
/// `MerchantMenu.playTradeSound` (a shift-clicked trade).
TradeSound,
/// A trade of this offer was made (`notifyTrade`).
Traded(usize),
/// A trade of this offer was made (`notifyTrade`), with the offer as
/// it was right after that trade's use (before a level-up's prices).
Traded(usize, Box<MerchantOffer>),
}
/// What the world lends a trading session for offers made while it is open.
pub struct TradeEnv<'a> {
@@ -373,7 +374,8 @@ impl Session {
offer.uses += 1;
let offer = offer.clone();
self.ambient_sound_time = -80;
self.events.push(Event::Traded(index));
self.events
.push(Event::Traded(index, Box::new(offer.clone())));
let mut prices = false;
if self.villager {
let mut pop = 3 + self.random.next_int_bound(4).unwrap_or(0);
@@ -1016,6 +1018,28 @@ mod tests {
}
}
/// A shift-click trades several times in one session: each `Traded`
/// event keeps the offer as it was right after its own trade, which is
/// what the mods hear (`TradeWithVillagerEvent` per `onTake`).
#[test]
fn each_trade_event_keeps_its_own_offer() {
let items = items();
let mut s = Session::new(vec![offer(4, None), offer(2, None)], false);
s.notify_trade(1, None, &items);
s.notify_trade(1, None, &items);
s.notify_trade(0, None, &items);
let seen: Vec<_> = s
.events
.iter()
.filter_map(|e| match e {
Event::Traded(i, o) => Some((*i, o.uses, o.base_cost_a.stack.count)),
_ => None,
})
.collect();
assert_eq!(seen, [(1, 1, 2), (1, 2, 2), (0, 1, 4)]);
assert_eq!((s.offers[0].uses, s.offers[1].uses), (1, 2));
}
#[test]
fn demand_raises_prices() {
let items = items();
+3 -2
View File
@@ -1490,8 +1490,9 @@ For mods that run where the world is simulated: a dedicated server, a LAN host,
- **`native_trade_with_villager(merchant, kind, offer)`**: NeoForge's `TradeWithVillagerEvent`, heard once per trade a
server player makes with a villager or a wandering trader (a shift-click that trades several times is heard for each),
after the trade's statistic and `villager_trade` trigger. `merchant` is the trader's entity id, `kind` its type
(`minecraft:villager`), and `offer` the `MerchantOffer` as it is after the trade: its costs, `result`, `uses` (this
trade counted), `max_uses` and `xp`. Listen only. The player's state is swapped in, so `native_acting_player` names
(`minecraft:villager` or `minecraft:wandering_trader`), and `offer` the `MerchantOffer` as it was right after the
trade: its costs, `result`, `uses` (this trade counted), `max_uses` and `xp`. Listen only, heard once the whole click
is done (its payments taken; NeoForge fires during each take). The player's state is swapped in, so `native_acting_player` names
them. Unlike `native_player_trade`, which is heard once per click with no offer.
- **`native_player_shield_disable(player, name, ticks)`**: a blocked melee hit disabled the server player’s shield for `ticks` ticks. It fires just before the cooldown is set; guest lookup helpers may not find the guest during packet handling.
- **`native_player_loom_pattern_select(player, name, pattern)`**: a server player selected loom pattern button `pattern`, after the menu accepted it. Guest lookup helpers may not find the guest during packet handling.
+1
View File
@@ -1045,6 +1045,7 @@ mod tests {
pub(super) struct NativeScreen;
pub(super) struct ModBlockEntity;
pub(super) struct ModContainer;
pub(super) struct MerchantOffer;
pub(super) struct ModFluidTanks;
pub(super) struct ModEnergyBuffer;
pub(super) struct LootStack;