Fix the eight hook issues from Dyna's review of #6801

- A cancelled book edit resends the server's book to the client.
- Snow filling a cauldron reports level 1, the level it places.
- fluid_level_change also sees (and can cancel) a flow drying up to 0.
- A cost changed by prepare_item_enchant shows that cost's clue.
- raid_spawn_wave's doc says it fires after the wave, without mounts.
- A cancelled item cooldown ends the guest's predicted cooldown.
- Anvil, grindstone and smithing hooks run only when a click changed
  the inputs, the result or the cost.
- A crafting result set by prepare_item_craft is sent to the client.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-11-02 08:16:29 +01:00
1 parent 3552e9d1a4
commit 2e63a668e2
10 files changed
+106 -38

No files matched your search

+3
View File
@@ -6116,6 +6116,9 @@ impl Game<'_> {
}
if !cancel {
self.add_cooldown(group, component.ticks());
} else if self.lan_acting.is_some() {
// The guest's client started the cooldown itself; 0 ends it there.
self.guest_cooldowns.push((group, 0));
}
}
/// `ItemCooldowns.addCooldown` (a guest also gets the packet).
+16 -3
View File
@@ -334,7 +334,12 @@ impl Game<'_> {
{
return;
}
self.as_guest(host, conn, |g| {
let menu_slot = if p.slot == super::shield::OFFHAND_SLOT as i32 {
45
} else {
36 + p.slot as usize
};
let cancelled = self.as_guest(host, conn, |g| {
let mut cancel = false;
let writable = g
.stack_in(p.slot as usize)
@@ -351,10 +356,18 @@ impl Game<'_> {
);
}
if cancel {
return;
return true;
}
g.write_book(p.slot as usize, p.pages, p.title)
g.write_book(p.slot as usize, p.pages, p.title);
false
});
// A cancelled edit leaves the client's own copy of the book: forget
// what was sent so the next broadcast resends the server's stack.
if cancelled == Some(true)
&& let Some(g) = host.hub.guests.iter_mut().find(|g| g.conn == conn)
{
g.menu_sent[menu_slot] = None;
}
}
pub(super) fn book_key(&mut self, event: &KeyEvent) {
if event.state != ElementState::Pressed {
@@ -528,7 +528,7 @@ impl Game<'_> {
&& !host.hub.guests[gi].spectator()
&& self.lan_table_valid(&host.hub.guests[gi], pos)
{
let changed = self.guest_menu_op(host, gi, &mut menu, |menu, env| {
let mut changed = self.guest_menu_op(host, gi, &mut menu, |menu, env| {
let before: Vec<_> = (0..menu.slot_count())
.map(|s| menu.slot(s).cloned())
.collect();
@@ -540,7 +540,11 @@ impl Game<'_> {
let guest = &host.hub.guests[gi];
let (uuid, name) = (guest.uuid, guest.name.clone());
let mut result = menu.slot(0).cloned();
let vanilla = result.clone();
self.native_prepare_item_craft(uuid, &name, Some(pos), &mut result);
if result != vanilla && !changed.contains(&0) {
changed.push(0);
}
let env = self.menu_env();
menu.set_slot(&env, 0, result);
agreed = p.state == state
+38 -4
View File
@@ -125,8 +125,26 @@ impl Game<'_> {
if let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn)
&& !host.hub.guests[gi].spectator()
{
// createResult runs when the inputs change (or a rename): a click
// elsewhere must not hand the hooks their own result again.
let result_slot = match gs.kind {
StationKind::Anvil | StationKind::Grindstone => Some(2),
StationKind::Smithing => Some(3),
_ => None,
};
let before = result_slot.map(|r| {
let slots: Vec<_> = (0..=r).map(|s| gs.menu.slot(s).cloned()).collect();
(slots, gs.menu.data[0])
});
self.guest_menu_op(host, gi, &mut gs.menu, op);
if gs.kind == StationKind::Anvil {
let recomputed = before.is_some_and(|(slots, cost)| {
cost != gs.menu.data[0]
|| slots
.iter()
.enumerate()
.any(|(s, b)| gs.menu.slot(s) != b.as_ref())
});
if recomputed && gs.kind == StationKind::Anvil {
let guest = &host.hub.guests[gi];
let (uuid, name) = (guest.uuid, guest.name.clone());
let mut result = gs.menu.slot(2).cloned();
@@ -134,7 +152,7 @@ impl Game<'_> {
let env = self.menu_env();
gs.menu.set_slot(&env, 2, result);
}
if gs.kind == StationKind::Grindstone {
if recomputed && gs.kind == StationKind::Grindstone {
let guest = &host.hub.guests[gi];
let (uuid, name) = (guest.uuid, guest.name.clone());
let mut result = gs.menu.slot(2).cloned();
@@ -142,7 +160,7 @@ impl Game<'_> {
let env = self.menu_env();
gs.menu.set_slot(&env, 2, result);
}
if gs.kind == StationKind::Smithing {
if recomputed && gs.kind == StationKind::Smithing {
let guest = &host.hub.guests[gi];
let (uuid, name) = (guest.uuid, guest.name.clone());
let mut result = gs.menu.slot(3).cloned();
@@ -320,9 +338,25 @@ impl Game<'_> {
let guest = &host.hub.guests[gi];
let mut costs = offers.costs;
self.native_prepare_item_enchant(guest.uuid, &guest.name, gs.pos, &mut costs);
let mut clues = offers.clues;
for i in 0..3 {
// A cost a mod changed shows the clue of the list it will give.
if costs[i] != offers.costs[i]
&& let Some(stack) = gs.menu.slot(0)
{
clues[i] = horizon_client::stations::enchant_clue(
&self.stations().enchantments,
self.items(),
seed,
i,
costs[i],
stack,
);
}
}
for i in 0..3 {
gs.menu.data[i] = costs[i];
let clue = offers.clues[i].as_ref();
let clue = clues[i].as_ref();
gs.menu.data[4 + i] = clue
.and_then(|(e, _)| host.hub.registry_id("minecraft:enchantment", e))
.unwrap_or(-1);
@@ -4439,8 +4439,8 @@ impl Game<'_> {
mod_api::dispatch_portal_create(self, mod_api::mods(), dimension, pos);
}
/// A raid wave is about to be spawned at `pos`; `wave` is its one-based
/// wave number and `raiders` is the count Horizon will spawn. @API:native_raid_spawn_wave@.
/// A raid wave has been spawned at `pos`; `wave` is its one-based wave
/// number and `raiders` counts the raiders, not the mobs they ride. @API:native_raid_spawn_wave@.
pub(super) fn native_raid_spawn_wave(
&mut self,
raid: i32,
+2 -2
View File
@@ -3915,8 +3915,8 @@ pub trait Hooks: Send + Sync {
let _ = host;
let _ = event;
}
/// A raid wave is about to be spawned at `pos`; `wave` is its one-based
/// wave number and `raiders` is the count Horizon will spawn. @API:native_raid_spawn_wave@.
/// A raid wave has been spawned at `pos`; `wave` is its one-based wave
/// number and `raiders` counts the raiders, not the mobs they ride. @API:native_raid_spawn_wave@.
fn raid_spawn_wave(&self, host: &mut dyn Host, event: &mut RaidSpawnWaveEvent) {
let _ = host;
let _ = event;
+28 -14
View File
@@ -100,23 +100,37 @@ pub fn enchant_offers(
}
}
for i in 0..3 {
if offers.costs[i] > 0 {
// getEnchantmentList reseeds the menu random; the clue draws continue from it.
let mut list_random = LegacyRandom::new(i64::from(seed.wrapping_add(i as i32)));
let pool = e.tag("minecraft:in_enchanting_table").to_vec();
let mut list = e.select(items, &mut list_random, &stack.item, offers.costs[i], &pool);
if &*stack.item == "minecraft:book" && list.len() > 1 {
let r = list_random.next_int_bound(list.len() as i32).unwrap_or(0) as usize;
list.remove(r);
}
if !list.is_empty() {
let pick = list_random.next_int_bound(list.len() as i32).unwrap_or(0) as usize;
offers.clues[i] = Some(list[pick].clone());
}
}
offers.clues[i] = enchant_clue(e, items, seed, i, offers.costs[i], stack);
}
offers
}
/// `EnchantmentMenu.slotsChanged`'s clue for offer `slot` at `cost`: one
/// enchantment of the list that cost would give, or None for no offer.
pub fn enchant_clue(
e: &Enchantments,
items: &Items,
seed: i32,
slot: usize,
cost: i32,
stack: &ItemStack,
) -> Option<(Arc<str>, u32)> {
if cost <= 0 {
return None;
}
// getEnchantmentList reseeds the menu random; the clue draws continue from it.
let mut list_random = LegacyRandom::new(i64::from(seed.wrapping_add(slot as i32)));
let pool = e.tag("minecraft:in_enchanting_table").to_vec();
let mut list = e.select(items, &mut list_random, &stack.item, cost, &pool);
if &*stack.item == "minecraft:book" && list.len() > 1 {
let r = list_random.next_int_bound(list.len() as i32).unwrap_or(0) as usize;
list.remove(r);
}
if list.is_empty() {
return None;
}
let pick = list_random.next_int_bound(list.len() as i32).unwrap_or(0) as usize;
Some(list[pick].clone())
}
/// `EnchantmentMenu.clickMenuButton` result: the enchanted item, or None.
pub fn apply_enchantment(
e: &Enchantments,
+7 -5
View File
@@ -617,11 +617,7 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
if !fluid.is_source() {
let new_fluid = self.new_liquid(kind, pos, state);
let delay = self.spread_delay(kind, pos, fluid, new_fluid);
if new_fluid.kind() == FluidKind::Empty {
fluid = new_fluid;
state = self.b.air();
self.set_block_and_update(pos, state);
} else if new_fluid != fluid {
if new_fluid != fluid {
let name = match kind {
FluidKind::Water | FluidKind::FlowingWater => "water",
FluidKind::Lava | FluidKind::FlowingLava => "lava",
@@ -635,6 +631,12 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
) {
return;
}
}
if new_fluid.kind() == FluidKind::Empty {
fluid = new_fluid;
state = self.b.air();
self.set_block_and_update(pos, state);
} else if new_fluid != fluid {
fluid = new_fluid;
state = self.b.legacy_block(fluid);
self.set_block_and_update(pos, state);
+3 -5
View File
@@ -1488,11 +1488,9 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
};
if data.is(Class::Cauldron) {
if self.random().next_float() < chance {
let to = if info.kind == PrecipitationKind::Rain {
1
} else {
3
};
// Both place the cauldron's default state, level 1
// (CauldronBlock.handlePrecipitation).
let to = 1;
let reason = if info.kind == PrecipitationKind::Rain {
"rain"
} else {
@@ -10,6 +10,6 @@
}
}
],
"rust_summary": "A raid wave about to spawn (`native_raid_spawn_wave`).",
"doc_summary": "A raid wave is about to spawn at its position, with its raid id, wave number and raider count. Bundles need \"hooks\": @API:raid_spawn_wave@."
"rust_summary": "A raid wave that has spawned (`native_raid_spawn_wave`).",
"doc_summary": "A raid wave has spawned at its position, with its raid id, wave number and raider count (riders' mounts not counted). Bundles need \"hooks\": @API:raid_spawn_wave@."
}