Merge pull request #11099 from Delete228/native-living-item-use-upstream

Native mods: item use after a mob passes interaction
This commit is contained in:
rdbt authored and GitHub committed 2026-11-06 21:18:53 +00:00
commit da4fb17df7
8 files changed
+465 -10

No files matched your search

+31 -1
View File
@@ -160,6 +160,8 @@ mod interaction_shapes;
mod interactions;
mod item_areas;
mod item_draw;
#[cfg(test)]
mod item_interact_mob_tests;
mod item_particles;
mod item_use;
mod jigsaw_blocks;
@@ -6704,7 +6706,35 @@ impl Game<'_> {
|| self.interact_cart(target)
|| self.interact_mob(target)?
// A guest's Interact naming a knot's id goes to `use_knot` before this.
|| target.is_none() && self.interact_knot())
|| target.is_none() && self.interact_knot()
|| self.item_interact_mob(target))
}
/// The `ItemStack.interactLivingEntity` fallback in `Player.interactOn`,
/// currently for native Mob entries after the entity's own action passed.
fn item_interact_mob(&mut self, target: Option<InteractTarget>) -> bool {
if self.spectator() {
return false;
}
let Some(index) = self.mob_interaction_target(target) else {
return false;
};
let mob = &self.mobs[index];
if mob.removed || !mob.alive() {
return false;
}
let (entity, kind) = (
mob.id as i32,
horizon_client::command::args::normalize(&mob.kind.id),
);
let Some(stack) = self.held().filter(|stack| stack.count > 0).cloned() else {
return false;
};
let mut swing = None;
self.native_item_interact_mob(entity, &kind, Some(&stack), &mut swing);
if swing == Some(true) {
self.swing_arm();
}
swing.is_some()
}
/// `ItemStack.applyAfterUseComponentSideEffects`: a used stack with a
/// `use_cooldown` component starts its entry (`UseCooldown.apply`). A
+15 -9
View File
@@ -5605,11 +5605,10 @@ impl Game<'_> {
};
self.audio.play(sound, Some(at), 1.0, 1.0);
}
/// Right click on a mob (Mob.interact / mobInteract), on the mob under the
/// crosshair or on the one a guest's interact packet named: returns true
/// when consumed.
pub(super) fn interact_mob(&mut self, target: Option<InteractTarget>) -> Result<bool> {
let Some((index, distance, _)) = (match target {
/// The mob a normal interaction can reach: a packet's exact validated ID,
/// or the crosshair's mob before its blocking shape.
pub(super) fn mob_interaction_target(&self, target: Option<InteractTarget>) -> Option<usize> {
let (index, distance, _) = (match target {
// The id the packet named, under the filters the crosshair pick
// applies (`mob_hit`): a mob in its death animation is not clicked
// by either path, so naming one in a packet must not reach the
@@ -5621,9 +5620,7 @@ impl Game<'_> {
})
.map(|i| (i, 0.0, [0.0; 3])),
None => self.mob_hit(),
}) else {
return Ok(false);
};
})?;
// Only a client's own pick is stopped by a block: the range check passed.
if target.is_none()
&& let Some(hit) = self.pick_unfiltered()
@@ -5634,9 +5631,18 @@ impl Game<'_> {
+ (hit.point[2] - eye[2]).powi(2))
.sqrt();
if d < distance {
return Ok(false);
return None;
}
}
Some(index)
}
/// Right click on a mob (Mob.interact / mobInteract), on the mob under the
/// crosshair or on the one a guest's interact packet named: returns true
/// when consumed.
pub(super) fn interact_mob(&mut self, target: Option<InteractTarget>) -> Result<bool> {
let Some(index) = self.mob_interaction_target(target) else {
return Ok(false);
};
// PlayerInteractTrigger: the name tag as it was before it named the mob.
let used = self.held().cloned();
if self.name_mob(index) {
@@ -0,0 +1,242 @@
//! Run alone with private prepared resources: installs process-wide trait mods.
use super::*;
use crate::mod_api::{self, Flow, Hooks, Host, InteractEntityEvent, ItemInteractMobEvent};
use horizon_client::{
chunk::Chunk,
merchant::{ItemCost, MerchantOffer, Trader},
mobs::Mob,
};
use std::sync::{
Arc, Mutex,
atomic::{AtomicBool, AtomicU8, Ordering},
};
#[derive(Default)]
struct Probe {
calls: Mutex<Vec<(i32, String, String)>>,
answer: AtomicU8,
claim_entity: AtomicBool,
discard: AtomicBool,
}
impl Hooks for Arc<Probe> {
fn interact_entity(&self, _: &mut dyn Host, _: &mut InteractEntityEvent<'_>) -> Flow {
if self.claim_entity.load(Ordering::Relaxed) {
Flow::Cancel
} else {
Flow::Continue
}
}
fn item_interact_mob(
&self,
host: &mut dyn Host,
event: &mut ItemInteractMobEvent<'_>,
) -> Option<bool> {
let stack = event.stack.expect("only a nonempty item has this fallback");
assert!(stack.count > 0);
self.calls
.lock()
.unwrap()
.push((event.entity, event.kind.into(), stack.item.to_string()));
let answer = self.answer.load(Ordering::Relaxed);
if answer != 0 && self.discard.load(Ordering::Relaxed) {
assert!(host.remove_mob("minecraft:overworld", event.entity as u32));
}
match answer {
0 => None,
1 => Some(false),
_ => Some(true),
}
}
}
fn setup(game: &mut Game<'_>, probe: &Probe, kind: &str, id: u32) {
game.mobs.clear();
let kind = game.res.mob_types().get(kind).unwrap().clone();
game.mobs
.push(Mob::new(id, kind, Vec3::new(0.5, 64.0, 2.5), 0.0));
game.station = None;
game.screen = Screen::Playing;
game.inventory[0] = Some(ItemStack::new("minecraft:stick", 2));
game.offhand = Some(ItemStack::new("minecraft:paper", 3));
game.swing = Default::default();
game.swings_started = 0;
game.tick_hit = None;
game.tick_mob = None;
game.riding = None;
probe.calls.lock().unwrap().clear();
probe.answer.store(1, Ordering::Relaxed);
probe.claim_entity.store(false, Ordering::Relaxed);
probe.discard.store(false, Ordering::Relaxed);
}
fn target(id: i32) -> Option<InteractTarget> {
Some(InteractTarget {
id,
location: [0.0, 1.0, 0.0],
})
}
#[test]
#[ignore = "private prepared resources; process-wide mods, run this test alone"]
fn actual_item_interact_mob_fallback_priority_and_filters() {
let root = PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
let probe = Arc::new(Probe::default());
assert!(
mod_api::MODS
.set(Arc::from(vec![Box::new(probe.clone()) as Box<dyn Hooks>]))
.is_ok()
);
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.world = World::new(game.res.facts().air, 1);
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(0.5, 64.0, 0.5)).unwrap());
game.yaw = 0.0;
game.pitch = 0.0;
game.selected = 0;
game.set_mode_id(0);
// A custom item willing to own every mob still cannot preempt trading.
setup(&mut game, &probe, "minecraft:villager", 42);
game.mobs[0].extra.trade = Some(Box::new(Trader {
offers: vec![MerchantOffer {
base_cost_a: ItemCost {
stack: ItemStack::new("minecraft:emerald", 1),
},
cost_b: None,
result: ItemStack::new("minecraft:bread", 1),
uses: 0,
max_uses: 12,
reward_exp: true,
special_price_diff: 0,
demand: 0,
price_multiplier: 0.0,
xp: 1,
}],
..Default::default()
}));
assert!(game.interact_entity(target(42)).unwrap());
assert!(probe.calls.lock().unwrap().is_empty());
assert!(!game.mobs[0].removed);
assert_eq!(
game.station
.as_ref()
.unwrap()
.merchant
.as_ref()
.unwrap()
.mob,
Some(42)
);
// Existing API 29 owns the earlier phase and retains its usual swing.
setup(&mut game, &probe, "minecraft:zombie", 42);
probe.claim_entity.store(true, Ordering::Relaxed);
assert!(game.interact_entity(target(42)).unwrap());
assert!(probe.calls.lock().unwrap().is_empty());
assert_eq!(game.swings_started, 1);
for (answer, consumed, swings) in [(0, false, 0), (1, true, 0), (2, true, 1)] {
setup(&mut game, &probe, "minecraft:zombie", 42);
probe.answer.store(answer, Ordering::Relaxed);
assert_eq!(game.interact_entity(target(42)).unwrap(), consumed);
assert_eq!(game.swings_started, swings);
assert_eq!(
*probe.calls.lock().unwrap(),
[(42, "minecraft:zombie".into(), "minecraft:stick".into())]
);
}
// An ordinary mob with no own item action reaches the fallback exactly
// once. The mod may discard it with no loot and end without an arm swing.
setup(&mut game, &probe, "minecraft:blaze", 42);
probe.discard.store(true, Ordering::Relaxed);
assert!(game.interact_entity(None).unwrap());
assert!(game.mobs[0].removed);
assert!(game.dropped.is_empty());
assert_eq!(game.swings_started, 0);
assert_eq!(probe.calls.lock().unwrap().len(), 1);
assert_eq!(game.inventory[0].as_ref().unwrap().count, 2);
// Panda's early false is still a normal pass into the outer fallback.
setup(&mut game, &probe, "minecraft:panda", 42);
assert!(game.interact_entity(target(42)).unwrap());
assert_eq!(probe.calls.lock().unwrap().len(), 1);
assert_eq!(game.swings_started, 0);
setup(&mut game, &probe, "minecraft:zombie", 42);
assert!(
game.with_hand(Hand::Off, |g| g.interact_entity(target(42)))
.unwrap()
);
assert_eq!(probe.calls.lock().unwrap()[0].2, "minecraft:paper");
assert_eq!(game.hand, Hand::Main);
assert_eq!(game.inventory[0].as_ref().unwrap().count, 2);
assert_eq!(game.offhand.as_ref().unwrap().count, 3);
assert_eq!(game.swings_started, 0);
for invalid in ["dead", "removed", "riding", "wrong_id", "empty", "zero"] {
setup(&mut game, &probe, "minecraft:zombie", 42);
match invalid {
"dead" => game.mobs[0].health = 0.0,
"removed" => game.mobs[0].removed = true,
"riding" => game.riding = Some(42),
"empty" => game.inventory[0] = None,
"zero" => game.inventory[0].as_mut().unwrap().count = 0,
_ => {}
}
assert!(
!game
.interact_entity(target(if invalid == "wrong_id" { 99 } else { 42 }))
.unwrap(),
"{invalid}"
);
assert!(probe.calls.lock().unwrap().is_empty(), "{invalid}");
}
setup(&mut game, &probe, "minecraft:zombie", 42);
game.mobs[0].health = 0.0;
assert!(!game.interact_entity(None).unwrap());
assert!(probe.calls.lock().unwrap().is_empty());
// Local selection is occluded; a validated server packet keeps its exact
// entity ID, independent of the acting player's camera and local ray.
setup(&mut game, &probe, "minecraft:blaze", 42);
let stone = game
.pack
.assets
.registry
.default_state("minecraft:stone")
.unwrap();
game.world.set(0, 65, 1, stone).unwrap();
assert!(!game.interact_entity(None).unwrap());
assert!(probe.calls.lock().unwrap().is_empty());
assert!(game.interact_entity(target(42)).unwrap());
assert_eq!(probe.calls.lock().unwrap().len(), 1);
setup(&mut game, &probe, "minecraft:zombie", 42);
game.set_mode_id(3);
assert_eq!(game.use_item_as_player().unwrap(), None);
assert!(probe.calls.lock().unwrap().is_empty());
}
@@ -2620,6 +2620,24 @@ impl Game<'_> {
) {
mod_api::dispatch_interact_entity(self, mod_api::mods(), entity, kind, stack, claimed);
}
/// An item's fallback on a living native Mob, after ordinary entity
/// interactions and `native_interact_entity` passed. The exact packet
/// target or unobstructed crosshair mob must still be alive; `stack` is
/// nonempty and belongs to the acting hand. First `Some` owns the turn:
/// true swings once, false ends it without a swing; None passes.
/// Server-side for every acting player. This covers native Mob entries,
/// not armor stands, other real players or dragon-part interaction tables.
/// `ItemStack.interactLivingEntity` in `Player.interactOn`.
/// API @API:item_interact_mob@.
pub(super) fn native_item_interact_mob(
&mut self,
entity: i32,
kind: &str,
stack: Option<&ItemStack>,
swing: &mut Option<bool>,
) {
mod_api::dispatch_item_interact_mob(self, mod_api::mods(), entity, kind, stack, swing);
}
/// A player left-clicks the block `state` at `pos`, `face` the face hit
/// (`[0, 1, 0]` is the top): where `ServerPlayerGameMode.handleBlockBreakAction`
/// takes a dig's start, after its reach, spawn protection and world border
+69
View File
@@ -1056,4 +1056,73 @@ mod tests {
let mut claimed = false;
dispatch_use_item_on(&mut host, &mods, &stick(), [0; 3], [0, 1, 0], &mut claimed);
}
struct ItemMobReply {
answer: Option<bool>,
calls: Arc<Mutex<Vec<Option<bool>>>>,
}
impl Hooks for ItemMobReply {
fn item_interact_mob(
&self,
_: &mut dyn Host,
event: &mut ItemInteractMobEvent<'_>,
) -> Option<bool> {
assert_eq!(event.entity, 17);
assert_eq!(event.kind, "minecraft:zombie");
assert_eq!(event.stack, Some(&stick()));
self.calls.lock().unwrap().push(self.answer);
self.answer
}
}
#[test]
fn item_interact_mob_first_response_controls_swing() {
for (answers, expected, expected_calls) in [
([None, Some(false), Some(true)], Some(false), 2),
([None, Some(true), Some(false)], Some(true), 2),
([None, None, None], None, 3),
] {
let calls = Arc::new(Mutex::new(Vec::new()));
let mods: Vec<Box<dyn Hooks>> = answers
.into_iter()
.map(|answer| {
Box::new(ItemMobReply {
answer,
calls: calls.clone(),
}) as Box<dyn Hooks>
})
.collect();
let mut host = Reader {
block: "minecraft:air",
lines: Vec::new(),
};
let mut swing = None;
dispatch_item_interact_mob(
&mut host,
&mods,
17,
"minecraft:zombie",
Some(&stick()),
&mut swing,
);
assert_eq!(swing, expected);
assert_eq!(calls.lock().unwrap().len(), expected_calls);
calls.lock().unwrap().clear();
swing = Some(false);
dispatch_item_interact_mob(
&mut host,
&mods,
17,
"minecraft:zombie",
Some(&stick()),
&mut swing,
);
assert_eq!(
swing,
Some(false),
"an earlier mixin already owned the result"
);
assert!(calls.lock().unwrap().is_empty());
}
}
}
+20
View File
@@ -3726,6 +3726,26 @@ dispatch_use_item_on(&mut Stone, &mods, &stack, [0, 64, 0], [0, 1, 0], &mut clai
assert!(claimed);
```
### Item fallback on native mobs
`native_item_interact_mob(entity, kind, stack, swing)` is a pending fragment for
an item's fallback after a native Mob's ordinary interaction passes. Existing
`native_interact_entity` (API 29), the entity's own handlers and built-in item
actions retain priority; errors propagate before the fallback. This is the
`ItemStack.interactLivingEntity` part of `Player.interactOn`, currently limited
to native Mob entries. Armor stands, other real players and dragon-part tables
are not covered by this hook.
The caller reuses normal mob selection: an already validated packet names its
exact entity ID; a local crosshair must reach the mob before a blocking shape.
The target must still be alive and not removed, and the acting hand must hold a
nonempty item. `stack` is that hand's item snapshot; a hook changes inventories
through ordinary Host helpers when needed. First `Some(true)` owns the turn and
swings once, `Some(false)` owns it without a swing, and `None` passes to the next
mod. An earlier mixin result also owns the output. No item count or world change
is performed merely because a hook answers. The fragment is unstamped; it adds
no manually assigned global API number.
### Adding a hook
A new hook needs its fragment in `tools/native-hook-fragments/` with its `mod_api` entry (for example
+58
View File
@@ -0,0 +1,58 @@
# Item fallback after a mob interaction
The pending `item_interact_mob` fragment adds a server hook after normal entity
interactions have passed. It covers native Mob entries, including an acting
guest's exact named target. Armor stands, other real players and dragon-part
interaction tables remain outside this bounded addition. Existing API 29 keeps
its earlier phase and ordinary swing behavior.
Minecraft 26.3 `Player.interactOn` calls the entity's `interact` first and returns
when it consumes the action, before the nonempty item's
`ItemStack.interactLivingEntity` fallback. The new hook follows that ordering.
Existing fallible ordinary handling retains `?`, so an error returns before the
fallback. No artificial error-producing interaction is introduced for testing.
`native_item_interact_mob(entity, kind, stack, swing)` uses the existing replace
contract: first `Some` owns the result, including an earlier mixin answer.
`Some(true)` ends the turn with the normal acting-hand swing; `Some(false)` ends
it without one; `None` passes. The caller reuses the ordinary mob selection and
block-occlusion checks, then requires an alive, nonremoved mob and a nonempty
acting-hand stack. Packet reach validation remains at the normal server entry;
the fallback does not repick another entity or impose a new line-of-sight rule
on an already validated named packet.
The actual prepared-resource Game test installs a process-local trait probe and
checks a real villager merchant menu before the fallback, earlier API 29
ownership, all three results, ordinary Blaze discard without loot or swing,
Panda's early pass, correct offhand stack, unchanged inventories, invalid/dead/
removed/ridden targets, empty and zero-count stacks, local block occlusion,
exact named-target handling and the normal spectator guard. A separate pure
dispatch test verifies first-response ownership and that later hooks never run
after either `Some(false)` or `Some(true)`.
Run with private prepared resources and fresh homes, no window or network
listener:
```sh
cargo test --offline -p horizon-client --lib item_interact_mob \
-- --include-ignored --nocapture
cargo test --offline -p horizon-mod-api-gen -p horizon-mod-api-gen-tests --lib
cargo clippy --offline -p horizon-client --lib --tests -- -D warnings
cargo fmt --all -- --check
python3 tools/check_public_tree.py
git diff --check
```
Set `HORIZON_TEST_RESOURCES` to private prepared 26.3 resources and set
`HORIZON_HOME`, `HORIZON_LAUNCHER_HOME`, `XDG_DATA_HOME`, `XDG_CONFIG_HOME`,
`HORIZON_NO_AUDIO=1` and `HORIZON_LAUNCHER_NO_SELF_UPDATE=1`. The ignored Game test
installs process-wide trait mods, so run its filter in a separate test process.
No Create bundle, third-party decoder, original asset bytes or installed player
world is involved. The hook fragment and generated dispatcher source were
processed with the official `cargo run -p xtask -- mod-api`; global catalog/API
history stamping is deliberately left to the normal upstream process.
Validation: both the actual Game and first-response dispatch tests passed;
the generator's 15 tests and generated-dispatch suite's 12 tests passed.
Full `horizon-client` library/test Clippy passed with warnings denied and no
exceptions, as did workspace formatting, public-tree and diff checks.
@@ -0,0 +1,12 @@
{
"hooks": [
{
"name": "item_interact_mob",
"target": "horizon_client::game::native_hooks::Game<'_>::native_item_interact_mob",
"side": "server",
"mod_api": {"contract": "replace", "out": "swing"}
}
],
"rust_summary": "an item fallback after a native mob's ordinary interaction passed.",
"doc_summary": "`native_item_interact_mob(entity, kind, stack, swing)` runs after ordinary entity interactions pass, for an alive native Mob selected by the exact packet ID or unobstructed crosshair. First `Some(true)` owns and swings once; `Some(false)` owns without a swing; `None` passes. The nonempty stack belongs to the acting hand. Armor stands, other real players and dragon-part tables are not covered. Bundles need \"hooks\": @API:item_interact_mob@."
}