diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 87490faacf..ba3774bb3b 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -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) -> 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 diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index b8ee16d772..8363235677 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -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) -> Result { - 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) -> Option { + 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) -> Result { + 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) { diff --git a/crates/horizon-client/src/game/item_interact_mob_tests.rs b/crates/horizon-client/src/game/item_interact_mob_tests.rs new file mode 100644 index 0000000000..13d7753acd --- /dev/null +++ b/crates/horizon-client/src/game/item_interact_mob_tests.rs @@ -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>, + answer: AtomicU8, + claim_entity: AtomicBool, + discard: AtomicBool, +} +impl Hooks for Arc { + 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 { + 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 { + 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])) + .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()); +} diff --git a/crates/horizon-client/src/game/native_hooks.rs b/crates/horizon-client/src/game/native_hooks.rs index f5c8dc3582..8262c37872 100644 --- a/crates/horizon-client/src/game/native_hooks.rs +++ b/crates/horizon-client/src/game/native_hooks.rs @@ -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, + ) { + 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 diff --git a/crates/horizon-client/src/mod_api.rs b/crates/horizon-client/src/mod_api.rs index 56b2f5f10b..578c1fefde 100644 --- a/crates/horizon-client/src/mod_api.rs +++ b/crates/horizon-client/src/mod_api.rs @@ -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, + calls: Arc>>>, + } + impl Hooks for ItemMobReply { + fn item_interact_mob( + &self, + _: &mut dyn Host, + event: &mut ItemInteractMobEvent<'_>, + ) -> Option { + 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> = answers + .into_iter() + .map(|answer| { + Box::new(ItemMobReply { + answer, + calls: calls.clone(), + }) as Box + }) + .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()); + } + } } diff --git a/docs/native-mods.md b/docs/native-mods.md index db226a9189..f19b47bffd 100644 --- a/docs/native-mods.md +++ b/docs/native-mods.md @@ -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 diff --git a/docs/validation/item-interact-mob.md b/docs/validation/item-interact-mob.md new file mode 100644 index 0000000000..65ebe6bd9b --- /dev/null +++ b/docs/validation/item-interact-mob.md @@ -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. diff --git a/tools/native-hook-fragments/item_interact_mob.json b/tools/native-hook-fragments/item_interact_mob.json new file mode 100644 index 0000000000..ab9a0d1b76 --- /dev/null +++ b/tools/native-hook-fragments/item_interact_mob.json @@ -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@." +}