From 27d750f1c9367ef8c66ca17752c1a21f284abdca Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:46:38 +0100 Subject: [PATCH 01/20] Split horizon-items out of horizon-client Second slice of the crate split, a pure move like the first: blocks (+ blocks/), items, item_data, enchant, cooldowns, bundle and random_sequences move to horizon-items. They are the first layer mobs/ needs, after horizon-client-core. Items moved out of lib modules that sit in the big cycle, unchanged: - firework shapes, explosions and dye colours (particles.rs) -> firework.rs; - BannerLayer and its NBT form (banner.rs) -> banner_layer.rs; - the ItemStack NBT codec (save.rs) -> item_nbt.rs; - consumable_animation (first_person.rs) -> use_animation.rs; - the content namespaces (mod_content.rs) -> content_namespaces.rs; - save.rs's NBT value helpers -> horizon-client-core's nbt_value.rs, and name_text (entity_nbt.rs) -> core's component.rs. horizon-client re-exports every module and item under its old path. Test helpers other client tests use (Items::for_tests, enchant::tests) are behind horizon-items' `test-support` feature, enabled by horizon-client's dev-dependency. The items test that loads the client's assets moved to the client: always_edible_and_consume_effects_loaded, to assets.rs. particles.rs' test module moved to the end of the file (clippy's items_after_test_module had been quiet only thanks to the derives that moved out). Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 5 +- Cargo.lock | 12 ++ Cargo.toml | 2 +- crates/horizon-client-core/src/component.rs | 10 ++ crates/horizon-client-core/src/lib.rs | 1 + crates/horizon-client-core/src/nbt_value.rs | 24 ++++ crates/horizon-client/Cargo.toml | 2 + .../examples/gameplay_decode_profile.rs | 2 +- crates/horizon-client/src/assets.rs | 78 +++++++++++ crates/horizon-client/src/banner.rs | 54 +------- crates/horizon-client/src/entity_nbt.rs | 10 +- crates/horizon-client/src/first_person.rs | 13 +- crates/horizon-client/src/lib.rs | 14 +- crates/horizon-client/src/mod_content.rs | 27 +--- crates/horizon-client/src/particles.rs | 122 +++++------------- crates/horizon-client/src/save.rs | 82 +----------- crates/horizon-items/Cargo.toml | 21 +++ crates/horizon-items/src/banner_layer.rs | 56 ++++++++ .../src/blocks.rs | 0 .../src/blocks/decode.rs | 0 .../src/blocks/decode_tests.rs | 0 .../src/bundle.rs | 0 .../horizon-items/src/content_namespaces.rs | 29 +++++ .../src/cooldowns.rs | 0 .../src/enchant.rs | 12 +- crates/horizon-items/src/firework.rs | 61 +++++++++ .../src/item_data.rs | 37 +++--- crates/horizon-items/src/item_nbt.rs | 62 +++++++++ .../src/items.rs | 83 +----------- crates/horizon-items/src/lib.rs | 19 +++ .../src/random_sequences.rs | 0 crates/horizon-items/src/use_animation.rs | 14 ++ docs/architecture.md | 7 +- tools/test_block_particles_parity.py | 2 +- tools/test_fire_starter_sound_parity.py | 2 +- tools/test_firework_tooltip_parity.py | 4 +- tools/test_lore_tooltip_parity.py | 2 +- 37 files changed, 484 insertions(+), 385 deletions(-) create mode 100644 crates/horizon-client-core/src/nbt_value.rs create mode 100644 crates/horizon-items/Cargo.toml create mode 100644 crates/horizon-items/src/banner_layer.rs rename crates/{horizon-client => horizon-items}/src/blocks.rs (100%) rename crates/{horizon-client => horizon-items}/src/blocks/decode.rs (100%) rename crates/{horizon-client => horizon-items}/src/blocks/decode_tests.rs (100%) rename crates/{horizon-client => horizon-items}/src/bundle.rs (100%) create mode 100644 crates/horizon-items/src/content_namespaces.rs rename crates/{horizon-client => horizon-items}/src/cooldowns.rs (100%) rename crates/{horizon-client => horizon-items}/src/enchant.rs (99%) create mode 100644 crates/horizon-items/src/firework.rs rename crates/{horizon-client => horizon-items}/src/item_data.rs (96%) create mode 100644 crates/horizon-items/src/item_nbt.rs rename crates/{horizon-client => horizon-items}/src/items.rs (95%) create mode 100644 crates/horizon-items/src/lib.rs rename crates/{horizon-client => horizon-items}/src/random_sequences.rs (100%) create mode 100644 crates/horizon-items/src/use_animation.rs diff --git a/AGENTS.md b/AGENTS.md index 24dc38b8e..a6406ebdc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,8 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se | Crate | Owns | |---|---| -| `horizon-client` | The game: a lib (`src/lib.rs`, ~180 flat modules) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) | +| `horizon-client` | The game: a lib (`src/lib.rs`, ~170 flat modules) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) | +| `horizon-items` | Blocks and items below gameplay: `blocks.rs` (block facts), `items.rs`, `item_data.rs` (components), `enchant.rs`, `cooldowns.rs`, `bundle.rs`, `item_nbt.rs`. Re-exported as `horizon_client::items`... | | `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` | | `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) | | `horizon-world` | Vanilla world data with no gameplay loop: NBT, regions, chunks, `datafix/`, `worldgen/`, `level/` (server-side block behaviour, ticks, redstone), entity physics, items, effects, food, damage | @@ -117,7 +118,7 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end | Block behaviour | `crates/horizon-world/src/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `redstone.rs`, `place.rs`, `interact.rs`); game.rs `fn tick_blocks`, `fn process_level_events` | | Mobs | L: `mobs.rs` (types), `mobs/` (`ai.rs`, `goals.rs`, `brain.rs`, `*_brain.rs`, `nav.rs`, `path.rs`, `species.rs`, `spawning.rs`, `render.rs`, `anim.rs`); G: `creatures.rs` (ticking, spawning, mob events), one file per mob family (`dragon.rs`, `wither.rs`, `villagers.rs`, `piglins.rs`, `golems.rs`...), `natural_spawning.rs` | | Other entities | L: `entities.rs`, `projectiles.rs`, `arrows.rs`; G: `boats.rs`, `minecarts.rs`, `mounts.rs`, `armor_stands.rs`, `paintings.rs`, `frames.rs`, `fireworks.rs`, `tnt.rs`, `explosion.rs` | -| Player actions | game.rs (`fn use_hand`, `fn use_item`, `fn attack`, `fn tick_mining`, `fn break_block`); G: `hand.rs`, `combat.rs`, `consume.rs`, `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`, `items.rs`, `item_data.rs` | +| Player actions | game.rs (`fn use_hand`, `fn use_item`, `fn attack`, `fn tick_mining`, `fn break_block`); G: `hand.rs`, `combat.rs`, `consume.rs`, `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`; crate `horizon-items`: `items.rs`, `item_data.rs`, `enchant.rs`, `blocks.rs` | | Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs` | | Commands | parse: crate `horizon-command` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`; `horizon-client-core`: `nbt_path.rs`, `scoreboard.rs`; G: `functions.rs`, `command_blocks.rs` | | UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs` | diff --git a/Cargo.lock b/Cargo.lock index 1d3b52a3b..400355a70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1370,6 +1370,7 @@ dependencies = [ "horizon-core", "horizon-dualsense", "horizon-friends", + "horizon-items", "horizon-mods", "horizon-protocol", "horizon-server-files", @@ -1445,6 +1446,17 @@ dependencies = [ "spake2", ] +[[package]] +name = "horizon-items" +version = "0.1.0" +dependencies = [ + "horizon-client-core", + "horizon-core", + "horizon-world", + "serde", + "serde_json", +] + [[package]] name = "horizon-mod-sdk" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 181ab1832..5af77974b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"] -members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-client-core", "crates/horizon-command", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/predator-missile", "crates/horizon-mod-sdk/examples/welcome", "mods/official/fps-overlay", "mods/official/keystrokes", "mods/official/minimap", "mods/official/zoom", "xtask"] +members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-client-core", "crates/horizon-command", "crates/horizon-items", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/predator-missile", "crates/horizon-mod-sdk/examples/welcome", "mods/official/fps-overlay", "mods/official/keystrokes", "mods/official/minimap", "mods/official/zoom", "xtask"] resolver = "2" [workspace.package] diff --git a/crates/horizon-client-core/src/component.rs b/crates/horizon-client-core/src/component.rs index 5b828b305..64ea6d75c 100644 --- a/crates/horizon-client-core/src/component.rs +++ b/crates/horizon-client-core/src/component.rs @@ -1,6 +1,7 @@ //! Text components from commands: `ComponentArgument` reads an SNBT (or JSON) //! value, `ComponentSerialization` turns it into styled text. use crate::text::{Style, Text, named_color}; +use horizon_world::nbt::Tag; use serde_json::{Map, Value}; /// `SnbtGrammar`, enough for components: compounds, lists, quoted and @@ -271,6 +272,15 @@ pub fn to_text_with( } } +/// `CustomName` as text: a string keeps its legacy formatting codes, anything +/// else is a component (`ComponentSerialization`). +pub fn name_text(tag: &Tag) -> Text { + match tag { + Tag::String(s) => Text::legacy(&s.to_string_lossy()), + t => crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t)), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/horizon-client-core/src/lib.rs b/crates/horizon-client-core/src/lib.rs index ca40141d1..77516add3 100644 --- a/crates/horizon-client-core/src/lib.rs +++ b/crates/horizon-client-core/src/lib.rs @@ -13,6 +13,7 @@ pub mod identifier; pub mod key_display; pub mod lang; pub mod nbt_path; +pub mod nbt_value; pub mod particle_type; pub mod registries; pub mod scoreboard; diff --git a/crates/horizon-client-core/src/nbt_value.rs b/crates/horizon-client-core/src/nbt_value.rs new file mode 100644 index 000000000..7f7d728b0 --- /dev/null +++ b/crates/horizon-client-core/src/nbt_value.rs @@ -0,0 +1,24 @@ +//! Reading and writing single NBT values the way vanilla's codecs accept them. +use horizon_world::nbt::Tag; + +pub fn string(s: &str) -> Tag { + Tag::String(s.into()) +} +pub fn text(tag: Option<&Tag>) -> Option { + match tag { + Some(Tag::String(s)) => Some(s.to_string_lossy()), + _ => None, + } +} +/// Any numeric tag as i64 (vanilla's `getIntOr` etc. accept every number type). +pub fn number(tag: Option<&Tag>) -> Option { + Some(match tag? { + Tag::Byte(v) => i64::from(*v), + Tag::Short(v) => i64::from(*v), + Tag::Int(v) => i64::from(*v), + Tag::Long(v) => *v, + Tag::Float(b) => f32::from_bits(*b) as i64, + Tag::Double(b) => f64::from_bits(*b) as i64, + _ => return None, + }) +} diff --git a/crates/horizon-client/Cargo.toml b/crates/horizon-client/Cargo.toml index 923a48a34..37a1560c0 100644 --- a/crates/horizon-client/Cargo.toml +++ b/crates/horizon-client/Cargo.toml @@ -11,6 +11,7 @@ description = "Horizon native development client" horizon-assets = { path = "../horizon-assets" } horizon-client-core = { path = "../horizon-client-core" } horizon-command = { path = "../horizon-command" } +horizon-items = { path = "../horizon-items" } horizon-core = { path = "../horizon-core" } # The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority). horizon-threads = { path = "../horizon-threads" } @@ -103,5 +104,6 @@ path = "examples/probe/main.rs" workspace = true [dev-dependencies] +horizon-items = { path = "../horizon-items", features = ["test-support"] } # Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests). naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] } diff --git a/crates/horizon-client/examples/gameplay_decode_profile.rs b/crates/horizon-client/examples/gameplay_decode_profile.rs index 60260452f..2731b9e88 100644 --- a/crates/horizon-client/examples/gameplay_decode_profile.rs +++ b/crates/horizon-client/examples/gameplay_decode_profile.rs @@ -2,7 +2,7 @@ //! Build with `cargo build --release -p horizon-client --example gameplay_decode_profile`. //! Each invocation runs one phase in a separate process for CPU/RSS measurements. #[allow(dead_code)] -#[path = "../src/blocks/decode.rs"] +#[path = "../../horizon-items/src/blocks/decode.rs"] mod decode; use std::{hint::black_box, path::Path, time::Instant}; diff --git a/crates/horizon-client/src/assets.rs b/crates/horizon-client/src/assets.rs index beafa96fd..99d169c93 100644 --- a/crates/horizon-client/src/assets.rs +++ b/crates/horizon-client/src/assets.rs @@ -234,3 +234,81 @@ impl RenderAssets { }) } } + +#[cfg(test)] +mod tests { + use crate::items::ConsumeEffect; + + #[test] + fn always_edible_and_consume_effects_loaded() { + let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../.local/horizon-client/26.3"); + if !root.join("items.json").exists() { + return; + } + let assets = crate::assets::Assets::load(&root).unwrap(); + let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap(); + let items = crate::items::Items::load(&root, assets.registry, &facts).unwrap(); + + let apple = items.get("minecraft:golden_apple").expect("golden apple"); + assert!(apple.can_always_eat); + assert!(apple.food.is_some()); + assert!(!apple.consumable.as_ref().unwrap().effects.is_empty()); + + let enchanted = items + .get("minecraft:enchanted_golden_apple") + .expect("enchanted golden apple"); + assert!(enchanted.can_always_eat); + assert!(enchanted.food.is_some()); + assert!(!enchanted.consumable.as_ref().unwrap().effects.is_empty()); + + let beef = items.get("minecraft:cooked_beef").expect("cooked beef"); + assert!(!beef.can_always_eat); + assert!(beef.food.is_some()); + + // Consumables without food, their style and their remainders. + use horizon_world::consumable::UseAnimation; + let milk = items.get("minecraft:milk_bucket").expect("milk bucket"); + let drink = milk.consumable.as_ref().unwrap(); + assert!(milk.food.is_none()); + assert_eq!(drink.effects, [ConsumeEffect::ClearAllEffects]); + assert_eq!((drink.ticks(), drink.animation), (32, UseAnimation::Drink)); + assert_eq!(drink.sound, "minecraft:entity.generic.drink"); + assert!(!drink.particles); + assert_eq!(milk.use_remainder.as_deref(), Some("minecraft:bucket")); + let honey = items.get("minecraft:honey_bottle").expect("honey bottle"); + let sip = honey.consumable.as_ref().unwrap(); + assert_eq!(sip.ticks(), 40); + assert_eq!(sip.sound, "minecraft:item.honey_bottle.drink"); + assert_eq!( + sip.effects, + [ConsumeEffect::RemoveEffects(vec![ + "minecraft:poison".into() + ])] + ); + assert_eq!( + honey.use_remainder.as_deref(), + Some("minecraft:glass_bottle") + ); + let kelp = items.get("minecraft:dried_kelp").unwrap(); + let bite = kelp.consumable.as_ref().unwrap(); + assert_eq!((bite.ticks(), bite.animation), (16, UseAnimation::Eat)); + assert_eq!(bite.sound, "minecraft:entity.generic.eat"); + assert!(bite.particles && kelp.use_remainder.is_none()); + let chorus = items.get("minecraft:chorus_fruit").unwrap(); + assert_eq!( + chorus.consumable.as_ref().unwrap().effects, + [ConsumeEffect::TeleportRandomly( + horizon_world::teleport_consume::TeleportConsume { + diameter: 16.0, + directional_particles: true, + } + )] + ); + let chicken = items.get("minecraft:chicken").unwrap(); + assert!(matches!( + chicken.consumable.as_ref().unwrap().effects[..], + [ConsumeEffect::ApplyEffects { probability, .. }] if probability == 0.3 + )); + } +} diff --git a/crates/horizon-client/src/banner.rs b/crates/horizon-client/src/banner.rs index aa470a552..a8ffe735b 100644 --- a/crates/horizon-client/src/banner.rs +++ b/crates/horizon-client/src/banner.rs @@ -3,24 +3,10 @@ //! banners, and the loom rules of `LoomMenu` (selectable patterns, the //! six-layer limit and the result stack). use crate::{block_entity_render::DYES, items::ItemStack, items::Items}; -use horizon_world::nbt::Tag; use serde_json::Value; use std::{collections::HashMap, fs, path::Path, sync::Arc}; -/// `BannerPatternLayers.Layer`: a pattern id and the dye name it is painted in. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct BannerLayer { - pub pattern: Arc, - pub color: Arc, -} -impl BannerLayer { - pub fn new(pattern: &str, color: &str) -> Self { - Self { - pattern: Arc::from(pattern), - color: Arc::from(color), - } - } -} +pub use horizon_items::banner_layer::{BannerLayer, layers_from_nbt, layers_to_nbt}; /// Layers a loom may stack on a banner (`LoomMenu`'s `hasMaxPatterns`). pub const MAX_LAYERS: usize = 6; @@ -224,44 +210,6 @@ pub fn has_max_layers(banner: &ItemStack) -> bool { layers(banner).len() >= MAX_LAYERS } -/// The `banner_patterns` NBT list of layers. -pub fn layers_to_nbt(layers: &[BannerLayer]) -> Tag { - Tag::List( - layers - .iter() - .map(|l| { - Tag::compound([ - ("pattern", Tag::String((&*l.pattern).into())), - ("color", Tag::String((&*l.color).into())), - ]) - }) - .collect(), - ) -} -pub fn layers_from_nbt(tag: Option<&Tag>) -> Vec { - let Some(Tag::List(list)) = tag else { - return Vec::new(); - }; - list.iter() - .filter_map(|e| { - let text = |k: &str| match e.get(k) { - Some(Tag::String(s)) => Some(s.to_string_lossy()), - _ => None, - }; - let pattern = text("pattern")?; - let color = text("color")?; - Some(BannerLayer::new( - &if pattern.contains(':') { - pattern - } else { - format!("minecraft:{pattern}") - }, - &color, - )) - }) - .collect() -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/horizon-client/src/entity_nbt.rs b/crates/horizon-client/src/entity_nbt.rs index 00576ea02..4fb579925 100644 --- a/crates/horizon-client/src/entity_nbt.rs +++ b/crates/horizon-client/src/entity_nbt.rs @@ -13,7 +13,6 @@ use crate::{ doubles, float, floats, item_from_nbt, item_to_nbt, number, put, read_doubles, text, uuid_from_tag, }, - text::Text, }; use horizon_core::geometry::Vec3; use horizon_world::nbt::{Compound, Tag}; @@ -600,14 +599,7 @@ fn custom_name(tag: &Tag) -> Option { _ => None, } } -/// `CustomName` as text: a string keeps its legacy formatting codes, anything -/// else is a component (`ComponentSerialization`). -pub fn name_text(tag: &Tag) -> Text { - match tag { - Tag::String(s) => Text::legacy(&s.to_string_lossy()), - t => crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t)), - } -} +pub use horizon_client_core::component::name_text; pub fn mob_from_nbt(tag: &Tag, types: &HashMap>, id: u32) -> Option { let kind = types.get(&self::kind(tag)?)?.clone(); let position = position(tag)?; diff --git a/crates/horizon-client/src/first_person.rs b/crates/horizon-client/src/first_person.rs index 0735ce2ba..816c1c0fd 100644 --- a/crates/horizon-client/src/first_person.rs +++ b/crates/horizon-client/src/first_person.rs @@ -426,18 +426,7 @@ impl UseResult { matches!(self, Self::Success { swing: true }) && before == after } } -pub fn consumable_animation(v: &serde_json::Value) -> horizon_world::consumable::UseAnimation { - use horizon_world::consumable::UseAnimation; - match v - .get("animation") - .and_then(serde_json::Value::as_str) - .unwrap_or("eat") - { - "eat" => UseAnimation::Eat, - "drink" => UseAnimation::Drink, - _ => UseAnimation::None, - } -} +pub use horizon_items::use_animation::consumable_animation; /// What the click handling reads and does in the world. pub trait HandWorld { fn hit(&mut self) -> Hit; diff --git a/crates/horizon-client/src/lib.rs b/crates/horizon-client/src/lib.rs index 232904b7a..d99066d1e 100644 --- a/crates/horizon-client/src/lib.rs +++ b/crates/horizon-client/src/lib.rs @@ -21,12 +21,10 @@ pub mod block_entity_anim; pub mod block_entity_item; pub mod block_entity_render; pub mod block_transform; -pub mod blocks; pub mod border_render; pub mod boss_overlay; pub mod breaking; pub mod brewing; -pub mod bundle; pub mod camera; pub mod cauldron; pub mod chat; @@ -42,7 +40,6 @@ pub mod confirm_screen; pub mod container_screen; pub mod containers; pub mod control; -pub mod cooldowns; pub mod crafting; pub mod crash; pub mod creative; @@ -56,7 +53,6 @@ pub mod dialog_screen; pub mod discord_presence; pub mod dynamic_light; pub mod effects; -pub mod enchant; pub mod end_crystal; pub mod entities; pub mod entity_culling; @@ -91,11 +87,9 @@ pub mod input; pub mod interaction_input; pub mod inv_sort; pub mod inventory_effects; -pub mod item_data; pub mod item_render; pub mod item_sync; pub mod item_tooltip; -pub mod items; pub mod java_text; pub mod key_names; pub mod keybinds; @@ -145,7 +139,6 @@ pub mod pose; pub mod pregen; pub mod projectiles; pub mod raid; -pub mod random_sequences; pub mod random_tick_sections; pub mod recipe_book; pub mod recipe_book_screen; @@ -206,3 +199,10 @@ pub use horizon_client_core::scoreboard; pub use horizon_client_core::snbt; pub use horizon_client_core::text; pub use horizon_command as command; +pub use horizon_items::blocks; +pub use horizon_items::bundle; +pub use horizon_items::cooldowns; +pub use horizon_items::enchant; +pub use horizon_items::item_data; +pub use horizon_items::items; +pub use horizon_items::random_sequences; diff --git a/crates/horizon-client/src/mod_content.rs b/crates/horizon-client/src/mod_content.rs index cb96fea06..2d8d2de3b 100644 --- a/crates/horizon-client/src/mod_content.rs +++ b/crates/horizon-client/src/mod_content.rs @@ -673,37 +673,14 @@ const NBT_TABLES: [&str; 6] = [ "worldgen/blocks/cached-support.nbt", "worldgen/blocks/cached-collision.nbt", ]; -/// In a root built here: the namespaces the content mods own (their ids and -/// listed namespaces), which loaders read besides `minecraft`. -const MARKER: &str = "horizon-mod-content.json"; +use horizon_items::content_namespaces::MARKER; +pub use horizon_items::content_namespaces::{namespaces, tag_namespaces}; /// Block states a world can hold. Chunk sections widen their global palette /// past vanilla's 16 bits to fit the registry (crate::chunk::set_block_states); /// this caps it at 20 bits, about 29 times vanilla's 35,723 states. Mods past /// it are refused, not wrapped onto other blocks. pub const MAX_STATES: u32 = 1 << 20; -/// The content mods' namespaces in a root built by [`build`]; none in any -/// other root, so vanilla's loaders read exactly what they did. -pub fn namespaces(root: &Path) -> Vec { - fs::read(root.join(MARKER)) - .ok() - .and_then(|b| serde_json::from_slice(&b).ok()) - .unwrap_or_default() -} - -/// The namespaces whose tags loaders read in `root`: `minecraft`, and in a -/// root built by [`build`] the convention tags (`c`, shared between mods as -/// on NeoForge and Fabric) and the content mods' own. -pub fn tag_namespaces(root: &Path) -> Vec { - let mods = namespaces(root); - let mut out = vec!["minecraft".to_owned()]; - if !mods.is_empty() { - out.push("c".to_owned()); - out.extend(mods); - } - out -} - /// Bundles of block tag files (tag id → the tag file's text). const TAG_BUNDLES: [&str; 2] = ["block-tags.json", "worldgen/block_tags.json"]; /// The worldgen loader's bundle of biome tags. diff --git a/crates/horizon-client/src/particles.rs b/crates/horizon-client/src/particles.rs index 17064c163..701422b02 100644 --- a/crates/horizon-client/src/particles.rs +++ b/crates/horizon-client/src/particles.rs @@ -24,6 +24,7 @@ use std::{ }; pub use horizon_client_core::particle_type::ParticleType; +pub use horizon_items::firework::{DYE_FIREWORK_COLORS, FireworkExplosion, FireworkShape}; /// `ParticleOptions` with the parameters of the option classes used here. #[derive(Clone, Debug)] @@ -3756,6 +3757,38 @@ fn lifetime_alpha(a: [f32; 4], age: i32, lifetime: i32, partial: f32) -> f32 { a[0] + (a[1] - a[0]) * t.clamp(0.0, 1.0) } +struct Starter { + at: [f64; 3], + v: [f64; 3], + explosions: Vec, + life: i32, + lifetime: i32, + twinkle_delay: bool, + far: bool, +} +const STAR: [[f64; 2]; 6] = [ + [0.0, 1.0], + [0.3455, 0.309], + [0.9511, 0.309], + [0.379_591_836_734_693_9, -0.126_530_612_244_897_95], + [0.612_244_897_959_183_7, -0.804_081_632_653_061_2], + [0.0, -0.359_183_673_469_387_73], +]; +const CREEPER: [[f64; 2]; 12] = [ + [0.0, 0.2], + [0.2, 0.2], + [0.2, 0.6], + [0.6, 0.6], + [0.6, 0.2], + [0.2, 0.2], + [0.2, 0.0], + [0.4, 0.0], + [0.4, -0.6], + [0.2, -0.6], + [0.2, -0.4], + [0.0, -0.4], +]; + #[cfg(test)] mod tests { use super::*; @@ -3942,92 +3975,3 @@ mod tests { assert!((lifetime_alpha(a, 40, 40, 0.0) - 0.6).abs() < 1e-6); } } -/// FireworkExplosion.Shape. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum FireworkShape { - #[default] - SmallBall, - LargeBall, - Star, - Creeper, - Burst, -} -impl FireworkShape { - pub fn from_name(name: &str) -> Self { - match name { - "large_ball" => Self::LargeBall, - "star" => Self::Star, - "creeper" => Self::Creeper, - "burst" => Self::Burst, - _ => Self::SmallBall, - } - } - pub fn name(self) -> &'static str { - match self { - Self::SmallBall => "small_ball", - Self::LargeBall => "large_ball", - Self::Star => "star", - Self::Creeper => "creeper", - Self::Burst => "burst", - } - } -} -/// `DyeColor` names and `getFireworkColor`, by dye id (white to black). -pub const DYE_FIREWORK_COLORS: [(&str, u32); 16] = [ - ("white", 0xF0F0F0), - ("orange", 0xEB8844), - ("magenta", 0xC354CD), - ("light_blue", 0x6689D3), - ("yellow", 0xDECF2A), - ("lime", 0x41CD34), - ("pink", 0xD88198), - ("gray", 0x434343), - ("light_gray", 0xABABAB), - ("cyan", 0x287697), - ("purple", 0x7B2FBE), - ("blue", 0x253192), - ("brown", 0x51301A), - ("green", 0x3B511A), - ("red", 0xB3312C), - ("black", 0x1E1B1B), -]; -/// The `firework_explosion` component. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct FireworkExplosion { - pub shape: FireworkShape, - pub colors: Vec, - pub fade: Vec, - pub trail: bool, - pub twinkle: bool, -} -struct Starter { - at: [f64; 3], - v: [f64; 3], - explosions: Vec, - life: i32, - lifetime: i32, - twinkle_delay: bool, - far: bool, -} -const STAR: [[f64; 2]; 6] = [ - [0.0, 1.0], - [0.3455, 0.309], - [0.9511, 0.309], - [0.379_591_836_734_693_9, -0.126_530_612_244_897_95], - [0.612_244_897_959_183_7, -0.804_081_632_653_061_2], - [0.0, -0.359_183_673_469_387_73], -]; -const CREEPER: [[f64; 2]; 12] = [ - [0.0, 0.2], - [0.2, 0.2], - [0.2, 0.6], - [0.6, 0.6], - [0.6, 0.2], - [0.2, 0.2], - [0.2, 0.0], - [0.4, 0.0], - [0.4, -0.6], - [0.2, -0.6], - [0.2, -0.4], - [0.0, -0.4], -]; diff --git a/crates/horizon-client/src/save.rs b/crates/horizon-client/src/save.rs index 6f4082ec3..3f45caddf 100644 --- a/crates/horizon-client/src/save.rs +++ b/crates/horizon-client/src/save.rs @@ -104,27 +104,8 @@ pub fn chunks_with_block_entities<'a>( .collect() } -fn string(s: &str) -> Tag { - Tag::String(s.into()) -} -pub fn text(tag: Option<&Tag>) -> Option { - match tag { - Some(Tag::String(s)) => Some(s.to_string_lossy()), - _ => None, - } -} -/// Any numeric tag as i64 (vanilla's `getIntOr` etc. accept every number type). -pub fn number(tag: Option<&Tag>) -> Option { - Some(match tag? { - Tag::Byte(v) => i64::from(*v), - Tag::Short(v) => i64::from(*v), - Tag::Int(v) => i64::from(*v), - Tag::Long(v) => *v, - Tag::Float(b) => f32::from_bits(*b) as i64, - Tag::Double(b) => f64::from_bits(*b) as i64, - _ => return None, - }) -} +use horizon_client_core::nbt_value::string; +pub use horizon_client_core::nbt_value::{number, text}; pub(crate) fn float(tag: Option<&Tag>) -> Option { Some(match tag? { Tag::Float(b) => f64::from(f32::from_bits(*b)), @@ -391,62 +372,9 @@ fn fluid_id(name: &str) -> Option { } as u32) } -fn encode_item(stack: &crate::items::ItemStack) -> Vec<(&'static str, Tag)> { - let mut entries = vec![ - ("id", string(&stack.item)), - ("count", Tag::Int(stack.count as i32)), - ]; - if let Some(components) = crate::item_data::encode_components(stack) { - entries.push(("components", components)); - } - entries -} -/// ItemStack.CODEC (id, count, components). -pub fn item_to_nbt(stack: &crate::items::ItemStack) -> Tag { - Tag::compound(encode_item(stack)) -} -pub fn item_from_nbt(tag: &Tag) -> Option { - let id = text(tag.get("id"))?; - if id == "minecraft:air" { - return None; - } - let count = number(tag.get("count")) - .or_else(|| number(tag.get("Count"))) - .unwrap_or(1) - .max(1) as u32; - let (damage, extra) = crate::item_data::decode_components(tag.get("components")); - Some(crate::items::ItemStack { - item: Arc::from(id.as_str()), - count, - damage, - extra, - }) -} -pub fn encode_items(slots: &[Option]) -> Tag { - Tag::List( - slots - .iter() - .enumerate() - .filter_map(|(i, s)| { - let mut entries = vec![("Slot", Tag::Byte(i as i8))]; - entries.extend(encode_item(s.as_ref()?)); - Some(Tag::compound(entries)) - }) - .collect(), - ) -} -pub fn decode_items(tag: Option<&Tag>, size: usize) -> Vec> { - let mut slots = vec![None; size]; - if let Some(Tag::List(items)) = tag { - for it in items { - let slot = number(it.get("Slot")).unwrap_or(0) as u8 as usize; - if let Some(cell) = slots.get_mut(slot) { - *cell = item_from_nbt(it); - } - } - } - slots -} +pub use horizon_items::item_nbt::{ + decode_items, encode_item, encode_items, item_from_nbt, item_to_nbt, +}; /// The block entity type ID for the block it sits in (a chest-like container /// is a chest, trapped chest or barrel). fn block_entity_id(e: &crate::containers::BlockEntity, block: &str) -> String { diff --git a/crates/horizon-items/Cargo.toml b/crates/horizon-items/Cargo.toml new file mode 100644 index 000000000..0662999bf --- /dev/null +++ b/crates/horizon-items/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "horizon-items" +version = "0.1.0" +edition.workspace = true +license.workspace = true +rust-version = "1.90" +description = "Blocks and items as the client knows them: block facts, item stacks and their data components, enchantments, cooldowns, bundles, and items in NBT" + +[dependencies] +horizon-client-core = { path = "../horizon-client-core" } +horizon-core = { path = "../horizon-core" } +horizon-world = { path = "../horizon-world" } +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1.0.151", features = ["raw_value"] } + +[features] +# Test helpers (`Items::for_tests`, `enchant::tests`) for horizon-client's unit tests. +test-support = [] + +[lints] +workspace = true diff --git a/crates/horizon-items/src/banner_layer.rs b/crates/horizon-items/src/banner_layer.rs new file mode 100644 index 000000000..5f76ad575 --- /dev/null +++ b/crates/horizon-items/src/banner_layer.rs @@ -0,0 +1,56 @@ +//! Banner pattern layers (`BannerPatternLayers`) and their NBT form. +use horizon_world::nbt::Tag; +use std::sync::Arc; + +/// `BannerPatternLayers.Layer`: a pattern id and the dye name it is painted in. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BannerLayer { + pub pattern: Arc, + pub color: Arc, +} +impl BannerLayer { + pub fn new(pattern: &str, color: &str) -> Self { + Self { + pattern: Arc::from(pattern), + color: Arc::from(color), + } + } +} + +/// The `banner_patterns` NBT list of layers. +pub fn layers_to_nbt(layers: &[BannerLayer]) -> Tag { + Tag::List( + layers + .iter() + .map(|l| { + Tag::compound([ + ("pattern", Tag::String((&*l.pattern).into())), + ("color", Tag::String((&*l.color).into())), + ]) + }) + .collect(), + ) +} +pub fn layers_from_nbt(tag: Option<&Tag>) -> Vec { + let Some(Tag::List(list)) = tag else { + return Vec::new(); + }; + list.iter() + .filter_map(|e| { + let text = |k: &str| match e.get(k) { + Some(Tag::String(s)) => Some(s.to_string_lossy()), + _ => None, + }; + let pattern = text("pattern")?; + let color = text("color")?; + Some(BannerLayer::new( + &if pattern.contains(':') { + pattern + } else { + format!("minecraft:{pattern}") + }, + &color, + )) + }) + .collect() +} diff --git a/crates/horizon-client/src/blocks.rs b/crates/horizon-items/src/blocks.rs similarity index 100% rename from crates/horizon-client/src/blocks.rs rename to crates/horizon-items/src/blocks.rs diff --git a/crates/horizon-client/src/blocks/decode.rs b/crates/horizon-items/src/blocks/decode.rs similarity index 100% rename from crates/horizon-client/src/blocks/decode.rs rename to crates/horizon-items/src/blocks/decode.rs diff --git a/crates/horizon-client/src/blocks/decode_tests.rs b/crates/horizon-items/src/blocks/decode_tests.rs similarity index 100% rename from crates/horizon-client/src/blocks/decode_tests.rs rename to crates/horizon-items/src/blocks/decode_tests.rs diff --git a/crates/horizon-client/src/bundle.rs b/crates/horizon-items/src/bundle.rs similarity index 100% rename from crates/horizon-client/src/bundle.rs rename to crates/horizon-items/src/bundle.rs diff --git a/crates/horizon-items/src/content_namespaces.rs b/crates/horizon-items/src/content_namespaces.rs new file mode 100644 index 000000000..07d4c5685 --- /dev/null +++ b/crates/horizon-items/src/content_namespaces.rs @@ -0,0 +1,29 @@ +//! The namespaces content mods add to a resource root, which loaders read +//! besides `minecraft`. +use std::{fs, path::Path}; + +/// In a root built here: the namespaces the content mods own (their ids and +/// listed namespaces), which loaders read besides `minecraft`. +pub const MARKER: &str = "horizon-mod-content.json"; + +/// The content mods' namespaces in a root built by `mod_content::build`; none in any +/// other root, so vanilla's loaders read exactly what they did. +pub fn namespaces(root: &Path) -> Vec { + fs::read(root.join(MARKER)) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()) + .unwrap_or_default() +} + +/// The namespaces whose tags loaders read in `root`: `minecraft`, and in a +/// root built by `mod_content::build` the convention tags (`c`, shared between mods as +/// on NeoForge and Fabric) and the content mods' own. +pub fn tag_namespaces(root: &Path) -> Vec { + let mods = namespaces(root); + let mut out = vec!["minecraft".to_owned()]; + if !mods.is_empty() { + out.push("c".to_owned()); + out.extend(mods); + } + out +} diff --git a/crates/horizon-client/src/cooldowns.rs b/crates/horizon-items/src/cooldowns.rs similarity index 100% rename from crates/horizon-client/src/cooldowns.rs rename to crates/horizon-items/src/cooldowns.rs diff --git a/crates/horizon-client/src/enchant.rs b/crates/horizon-items/src/enchant.rs similarity index 99% rename from crates/horizon-client/src/enchant.rs rename to crates/horizon-items/src/enchant.rs index 2f418ae82..60b5dbfbc 100644 --- a/crates/horizon-client/src/enchant.rs +++ b/crates/horizon-items/src/enchant.rs @@ -334,14 +334,14 @@ impl Enchantments { } /// `Enchantment.getFullname` as plain text and whether it is a curse (red). pub fn full_name(&self, id: &str, level: u32) -> (String, bool) { - let language = crate::lang::current(); + let language = horizon_client_core::lang::current(); self.full_name_in(language.as_deref(), id, level) } /// `full_name` against `language`; `None` (no language loaded: a /// dedicated server, the probes) keeps the English names loaded above. pub fn full_name_in( &self, - language: Option<&crate::lang::Language>, + language: Option<&horizon_client_core::lang::Language>, id: &str, level: u32, ) -> (String, bool) { @@ -776,8 +776,8 @@ pub fn apply_value_effect( } } -#[cfg(test)] -pub(crate) mod tests { +#[cfg(any(test, feature = "test-support"))] +pub mod tests { use super::*; use serde_json::json; @@ -1110,7 +1110,7 @@ pub(crate) mod tests { #[test] fn names_follow_the_language() { struct Mem(HashMap>); - impl crate::lang::ResourceSource for Mem { + impl horizon_client_core::lang::ResourceSource for Mem { fn read(&self, path: &str) -> Option> { self.0.get(path).cloned() } @@ -1121,7 +1121,7 @@ pub(crate) mod tests { .as_bytes() .to_vec(), )])); - let ru = crate::lang::Language::load(&[&ru], "ru_ru"); + let ru = horizon_client_core::lang::Language::load(&[&ru], "ru_ru"); let e = sample(); assert_eq!( e.full_name_in(Some(&ru), "minecraft:sharpness", 5).0, diff --git a/crates/horizon-items/src/firework.rs b/crates/horizon-items/src/firework.rs new file mode 100644 index 000000000..9e6668e5d --- /dev/null +++ b/crates/horizon-items/src/firework.rs @@ -0,0 +1,61 @@ +//! Firework explosions (the `firework_explosion` component and its shapes) and +//! the dye colours fireworks use. + +/// FireworkExplosion.Shape. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum FireworkShape { + #[default] + SmallBall, + LargeBall, + Star, + Creeper, + Burst, +} +impl FireworkShape { + pub fn from_name(name: &str) -> Self { + match name { + "large_ball" => Self::LargeBall, + "star" => Self::Star, + "creeper" => Self::Creeper, + "burst" => Self::Burst, + _ => Self::SmallBall, + } + } + pub fn name(self) -> &'static str { + match self { + Self::SmallBall => "small_ball", + Self::LargeBall => "large_ball", + Self::Star => "star", + Self::Creeper => "creeper", + Self::Burst => "burst", + } + } +} +/// `DyeColor` names and `getFireworkColor`, by dye id (white to black). +pub const DYE_FIREWORK_COLORS: [(&str, u32); 16] = [ + ("white", 0xF0F0F0), + ("orange", 0xEB8844), + ("magenta", 0xC354CD), + ("light_blue", 0x6689D3), + ("yellow", 0xDECF2A), + ("lime", 0x41CD34), + ("pink", 0xD88198), + ("gray", 0x434343), + ("light_gray", 0xABABAB), + ("cyan", 0x287697), + ("purple", 0x7B2FBE), + ("blue", 0x253192), + ("brown", 0x51301A), + ("green", 0x3B511A), + ("red", 0xB3312C), + ("black", 0x1E1B1B), +]; +/// The `firework_explosion` component. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct FireworkExplosion { + pub shape: FireworkShape, + pub colors: Vec, + pub fade: Vec, + pub trail: bool, + pub twinkle: bool, +} diff --git a/crates/horizon-client/src/item_data.rs b/crates/horizon-items/src/item_data.rs similarity index 96% rename from crates/horizon-client/src/item_data.rs rename to crates/horizon-items/src/item_data.rs index c07e2f352..0267e63f0 100644 --- a/crates/horizon-client/src/item_data.rs +++ b/crates/horizon-items/src/item_data.rs @@ -28,7 +28,7 @@ pub struct StackData { pub charged: Vec, /// `minecraft:fireworks`: flight duration and explosions (None: the /// item's default). - pub fireworks: Option<(u8, Vec)>, + pub fireworks: Option<(u8, Vec)>, /// `minecraft:map_id` (filled maps). pub map_id: Option, /// `minecraft:writable_book_content` pages. @@ -36,7 +36,7 @@ pub struct StackData { /// `minecraft:written_book_content`. pub written: Option, /// `minecraft:banner_patterns` (banners and shields). - pub banner_patterns: Vec, + pub banner_patterns: Vec, /// `minecraft:base_color` (a shield's dye). pub base_color: Option>, /// The raid captain's banner: `minecraft:banner_patterns` with the @@ -47,7 +47,7 @@ pub struct StackData { /// `minecraft:dyed_color` (leather armour, wolf armour, ... as RGB). pub dyed_color: Option, /// `minecraft:firework_explosion` (a firework star). - pub firework_explosion: Option, + pub firework_explosion: Option, /// `minecraft:pot_decorations`: back, left, right, front sherds. pub pot_decorations: Option; 4]>>, /// `minecraft:bundle_contents`. @@ -297,13 +297,13 @@ fn read_text(t: &Tag) -> Option { // A component's root `text` may be empty with the visible words in // `extra` (every legacy display name ViaVersion produces looks like // this), so flatten the whole component instead of reading one key. - Tag::Compound(_) => Some(crate::entity_nbt::name_text(t).plain()), + Tag::Compound(_) => Some(horizon_client_core::component::name_text(t).plain()), _ => None, } } /// A `FireworkExplosion` as vanilla's codec writes it (defaults omitted). -fn explosion_tag(e: &crate::particles::FireworkExplosion) -> Tag { +fn explosion_tag(e: &crate::firework::FireworkExplosion) -> Tag { let colors = |c: &[u32]| Tag::List(c.iter().map(|c| Tag::Int(*c as i32)).collect()); let mut fields = vec![("shape", Tag::String(e.shape.name().into()))]; if !e.colors.is_empty() { @@ -320,14 +320,14 @@ fn explosion_tag(e: &crate::particles::FireworkExplosion) -> Tag { } Tag::compound(fields) } -fn explosion_from(e: &Tag) -> crate::particles::FireworkExplosion { +fn explosion_from(e: &Tag) -> crate::firework::FireworkExplosion { let ints = |t: Option<&Tag>| match t { Some(Tag::IntArray(v)) => v.iter().map(|c| *c as u32).collect(), Some(Tag::List(v)) => v.iter().filter_map(int).map(|c| c as u32).collect(), _ => Vec::new(), }; - crate::particles::FireworkExplosion { - shape: crate::particles::FireworkShape::from_name( + crate::firework::FireworkExplosion { + shape: crate::firework::FireworkShape::from_name( e.get("shape") .and_then(string) .as_deref() @@ -436,7 +436,7 @@ pub fn canon_tag(tag: &Tag) -> String { pub fn canon_stack(stack: Option<&ItemStack>) -> String { stack.map_or_else( || "-".to_owned(), - |s| canon_tag(&crate::save::item_to_nbt(s)), + |s| canon_tag(&crate::item_nbt::item_to_nbt(s)), ) } /// The stack of an SNBT `ItemStack.CODEC` compound; None for "-". @@ -445,7 +445,7 @@ pub fn parse_stack(snbt: &str) -> Result, String> { return Ok(None); } let (tag, _) = horizon_world::nbt::parse_snbt(snbt)?; - Ok(crate::save::item_from_nbt(&tag)) + Ok(crate::item_nbt::item_from_nbt(&tag)) } /// The `components` compound for a stack, or None when every component is default. pub fn encode_components(stack: &ItemStack) -> Option { @@ -494,7 +494,7 @@ pub fn components_tag(damage: u32, data: Option<&StackData>) -> Option { if !d.banner_patterns.is_empty() { entries.push(( "minecraft:banner_patterns", - crate::banner::layers_to_nbt(&d.banner_patterns), + crate::banner_layer::layers_to_nbt(&d.banner_patterns), )); } if let Some(color) = &d.base_color { @@ -594,7 +594,7 @@ pub fn components_tag(damage: u32, data: Option<&StackData>) -> Option { if !d.charged.is_empty() { entries.push(( "minecraft:charged_projectiles", - Tag::List(d.charged.iter().map(crate::save::item_to_nbt).collect()), + Tag::List(d.charged.iter().map(crate::item_nbt::item_to_nbt).collect()), )); } if d.ominous_banner { @@ -677,7 +677,7 @@ pub fn decode_components(components: Option<&Tag>) -> (u32, Option) -> (u32, Option None, }, charged: match c.get("minecraft:charged_projectiles") { - Some(Tag::List(list)) => list.iter().filter_map(crate::save::item_from_nbt).collect(), + Some(Tag::List(list)) => list + .iter() + .filter_map(crate::item_nbt::item_from_nbt) + .collect(), _ => Vec::new(), }, container: match c.get("minecraft:container") { @@ -896,8 +899,8 @@ mod tests { let mut stack = ItemStack::new("minecraft:shield", 1); stack.data_mut().base_color = Some(Arc::from("red")); stack.data_mut().banner_patterns = vec![ - crate::banner::BannerLayer::new("minecraft:flower", "black"), - crate::banner::BannerLayer::new("minecraft:border", "white"), + crate::banner_layer::BannerLayer::new("minecraft:flower", "black"), + crate::banner_layer::BannerLayer::new("minecraft:border", "white"), ]; let tag = encode_components(&stack).expect("components"); let (_, extra) = decode_components(Some(&tag)); @@ -968,7 +971,7 @@ mod tests { assert_eq!(flight, 2); assert_eq!( explosions[0].shape, - crate::particles::FireworkShape::LargeBall + crate::firework::FireworkShape::LargeBall ); assert_eq!(explosions[0].colors, vec![0xFF0000]); assert!(explosions[0].twinkle && !explosions[0].trail); diff --git a/crates/horizon-items/src/item_nbt.rs b/crates/horizon-items/src/item_nbt.rs new file mode 100644 index 000000000..e0101228b --- /dev/null +++ b/crates/horizon-items/src/item_nbt.rs @@ -0,0 +1,62 @@ +//! Item stacks in NBT (`ItemStack.CODEC`): id, count and components, alone or in +//! slot lists. +use horizon_client_core::nbt_value::{number, string, text}; +use horizon_world::nbt::Tag; +use std::sync::Arc; + +pub fn encode_item(stack: &crate::items::ItemStack) -> Vec<(&'static str, Tag)> { + let mut entries = vec![ + ("id", string(&stack.item)), + ("count", Tag::Int(stack.count as i32)), + ]; + if let Some(components) = crate::item_data::encode_components(stack) { + entries.push(("components", components)); + } + entries +} +/// ItemStack.CODEC (id, count, components). +pub fn item_to_nbt(stack: &crate::items::ItemStack) -> Tag { + Tag::compound(encode_item(stack)) +} +pub fn item_from_nbt(tag: &Tag) -> Option { + let id = text(tag.get("id"))?; + if id == "minecraft:air" { + return None; + } + let count = number(tag.get("count")) + .or_else(|| number(tag.get("Count"))) + .unwrap_or(1) + .max(1) as u32; + let (damage, extra) = crate::item_data::decode_components(tag.get("components")); + Some(crate::items::ItemStack { + item: Arc::from(id.as_str()), + count, + damage, + extra, + }) +} +pub fn encode_items(slots: &[Option]) -> Tag { + Tag::List( + slots + .iter() + .enumerate() + .filter_map(|(i, s)| { + let mut entries = vec![("Slot", Tag::Byte(i as i8))]; + entries.extend(encode_item(s.as_ref()?)); + Some(Tag::compound(entries)) + }) + .collect(), + ) +} +pub fn decode_items(tag: Option<&Tag>, size: usize) -> Vec> { + let mut slots = vec![None; size]; + if let Some(Tag::List(items)) = tag { + for it in items { + let slot = number(it.get("Slot")).unwrap_or(0) as u8 as usize; + if let Some(cell) = slots.get_mut(slot) { + *cell = item_from_nbt(it); + } + } + } + slots +} diff --git a/crates/horizon-client/src/items.rs b/crates/horizon-items/src/items.rs similarity index 95% rename from crates/horizon-client/src/items.rs rename to crates/horizon-items/src/items.rs index 8ac450bcb..0efa2ff82 100644 --- a/crates/horizon-client/src/items.rs +++ b/crates/horizon-items/src/items.rs @@ -297,7 +297,7 @@ fn load_songs(root: &Path) -> HashMap { } songs } -pub(crate) fn load_tags(root: &Path, kind: &str) -> Result>> { +pub fn load_tags(root: &Path, kind: &str) -> Result>> { Ok(load_tag_lists(root, kind)? .into_iter() .map(|(k, v)| (k, v.into_iter().collect())) @@ -337,7 +337,7 @@ pub(crate) fn load_tag_lists(root: &Path, kind: &str) -> Result:blocks/...`. - for namespace in crate::mod_content::namespaces(root) { + for namespace in crate::content_namespaces::namespaces(root) { let dir = root.join(format!("data/{namespace}/loot_table")); if dir.is_dir() { walk_loot(&dir, &dir, &namespace, &mut loot)?; @@ -720,7 +720,7 @@ impl Items { }) } /// An item table with only tags, for unit tests of item rules. - #[cfg(test)] + #[cfg(any(test, feature = "test-support"))] pub fn for_tests(item_tags: HashMap>) -> Self { Self { items: HashMap::new(), @@ -1657,79 +1657,6 @@ mod tests { assert_eq!(&*drops[0].item, "minecraft:splash_potion"); assert_eq!(drops[0].potion(), Some("minecraft:fire_resistance")); } - - #[test] - fn always_edible_and_consume_effects_loaded() { - let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../.local/horizon-client/26.3"); - if !root.join("items.json").exists() { - return; - } - let assets = crate::assets::Assets::load(&root).unwrap(); - let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap(); - let items = Items::load(&root, assets.registry, &facts).unwrap(); - - let apple = items.get("minecraft:golden_apple").expect("golden apple"); - assert!(apple.can_always_eat); - assert!(apple.food.is_some()); - assert!(!apple.consumable.as_ref().unwrap().effects.is_empty()); - - let enchanted = items - .get("minecraft:enchanted_golden_apple") - .expect("enchanted golden apple"); - assert!(enchanted.can_always_eat); - assert!(enchanted.food.is_some()); - assert!(!enchanted.consumable.as_ref().unwrap().effects.is_empty()); - - let beef = items.get("minecraft:cooked_beef").expect("cooked beef"); - assert!(!beef.can_always_eat); - assert!(beef.food.is_some()); - - // Consumables without food, their style and their remainders. - use horizon_world::consumable::UseAnimation; - let milk = items.get("minecraft:milk_bucket").expect("milk bucket"); - let drink = milk.consumable.as_ref().unwrap(); - assert!(milk.food.is_none()); - assert_eq!(drink.effects, [ConsumeEffect::ClearAllEffects]); - assert_eq!((drink.ticks(), drink.animation), (32, UseAnimation::Drink)); - assert_eq!(drink.sound, "minecraft:entity.generic.drink"); - assert!(!drink.particles); - assert_eq!(milk.use_remainder.as_deref(), Some("minecraft:bucket")); - let honey = items.get("minecraft:honey_bottle").expect("honey bottle"); - let sip = honey.consumable.as_ref().unwrap(); - assert_eq!(sip.ticks(), 40); - assert_eq!(sip.sound, "minecraft:item.honey_bottle.drink"); - assert_eq!( - sip.effects, - [ConsumeEffect::RemoveEffects(vec![ - "minecraft:poison".into() - ])] - ); - assert_eq!( - honey.use_remainder.as_deref(), - Some("minecraft:glass_bottle") - ); - let kelp = items.get("minecraft:dried_kelp").unwrap(); - let bite = kelp.consumable.as_ref().unwrap(); - assert_eq!((bite.ticks(), bite.animation), (16, UseAnimation::Eat)); - assert_eq!(bite.sound, "minecraft:entity.generic.eat"); - assert!(bite.particles && kelp.use_remainder.is_none()); - let chorus = items.get("minecraft:chorus_fruit").unwrap(); - assert_eq!( - chorus.consumable.as_ref().unwrap().effects, - [ConsumeEffect::TeleportRandomly( - horizon_world::teleport_consume::TeleportConsume { - diameter: 16.0, - directional_particles: true, - } - )] - ); - let chicken = items.get("minecraft:chicken").unwrap(); - assert!(matches!( - chicken.consumable.as_ref().unwrap().effects[..], - [ConsumeEffect::ApplyEffects { probability, .. }] if probability == 0.3 - )); - } } /// Minecraft.handleKeybinds: spectators cannot drop from the selected slot. diff --git a/crates/horizon-items/src/lib.rs b/crates/horizon-items/src/lib.rs new file mode 100644 index 000000000..6e05a7072 --- /dev/null +++ b/crates/horizon-items/src/lib.rs @@ -0,0 +1,19 @@ +//! Blocks and items as the client knows them, below gameplay: block facts +//! (`blocks`), item stacks and definitions (`items`), their data components +//! (`item_data`), enchantments, cooldowns, bundles and item stacks in NBT. +//! horizon-client re-exports each module under its old path +//! (`horizon_client::items`, ...). +#![forbid(unsafe_code)] +pub mod banner_layer; +pub mod blocks; +pub mod bundle; +pub mod content_namespaces; +pub mod cooldowns; +pub mod enchant; +pub mod firework; +pub mod item_data; +pub mod item_nbt; +pub mod items; +pub mod random_sequences; +pub mod use_animation; +pub use horizon_client_core::Result; diff --git a/crates/horizon-client/src/random_sequences.rs b/crates/horizon-items/src/random_sequences.rs similarity index 100% rename from crates/horizon-client/src/random_sequences.rs rename to crates/horizon-items/src/random_sequences.rs diff --git a/crates/horizon-items/src/use_animation.rs b/crates/horizon-items/src/use_animation.rs new file mode 100644 index 000000000..af7f66817 --- /dev/null +++ b/crates/horizon-items/src/use_animation.rs @@ -0,0 +1,14 @@ +//! The use animation a `consumable` component names. + +pub fn consumable_animation(v: &serde_json::Value) -> horizon_world::consumable::UseAnimation { + use horizon_world::consumable::UseAnimation; + match v + .get("animation") + .and_then(serde_json::Value::as_str) + .unwrap_or("eat") + { + "eat" => UseAnimation::Eat, + "drink" => UseAnimation::Drink, + _ => UseAnimation::None, + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 7646ccc17..f48856b22 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -5,7 +5,8 @@ counted only for the game loop because nearly every gameplay change touches it). organised and how to build and test are in [AGENTS.md](../AGENTS.md). Paths: `L/` = `crates/horizon-client/src/`, `G/` = `crates/horizon-client/src/game/`, `W/` = -`crates/horizon-world/src/`, `K/` = `crates/horizon-client-core/src/`, `C/` = `crates/horizon-command/src/`. +`crates/horizon-world/src/`, `K/` = `crates/horizon-client-core/src/`, `C/` = `crates/horizon-command/src/`, +`I/` = `crates/horizon-items/src/`. Checks: a bare name is a harness (`python3 tools/parity.py `, script `tools/test__parity.py`); `lan_e2e ` is `python3 tools/lan_e2e.py --check `. @@ -30,7 +31,7 @@ file selects ~103. | 12 | Guests' actions on a server (98) | `G/lan_actions.rs` (`fn as_guest`, `fn tick_guests`), `G/lan_{attributes,pvp,chat,crafting,stations,trading,mount_menu,waypoints}.rs` | server_player_combat, server_player_damage, server_player_targets, lan_e2e (all) | | 13 | Modpack porting, mod content (97) | `crates/horizon-port-pack/`, `L/mod_content.rs`, `tools/port_*.py` | `cargo test -p horizon-port-pack`; `python3 tools/mod_port_harness.py` | | 14 | Mob AI (96) | `L/mobs/ai.rs`, `goals.rs`, `brain.rs`, `*_brain.rs`, `nav.rs`, `path.rs`, `control.rs` | mob_ai (probe `examples/mob_ai_probe.rs`; known failure on main: drowned_*, #290) | -| 15 | Items and item use (96) | `L/items.rs`, `L/item_data.rs`, `G/hand.rs`, `G/consume.rs`, `G/potions.rs`, `L/effects.rs`; game.rs `fn use_hand`, `fn use_item` | use_item_on, item_use, item_use_lifecycle, consumable, consume_effect, status_effect | +| 15 | Items and item use (96) | `I/items.rs`, `I/item_data.rs`, `G/hand.rs`, `G/consume.rs`, `G/potions.rs`, `L/effects.rs`; game.rs `fn use_hand`, `fn use_item` | use_item_on, item_use, item_use_lifecycle, consumable, consume_effect, status_effect | | 16 | Options and settings screens (93) | `L/options_ui.rs`, `G/options.rs`, `L/settings.rs`, `L/options_file.rs`, `L/graphics_ui.rs`, `L/graphics.rs` | options, toggle_keys | | 17 | HUD, chat, tab list (93) | `L/hud.rs`, `L/hud_layout.rs`, `L/hud_overlays.rs`, `L/chat.rs`, `G/chat.rs`, `L/tab_list.rs`, `L/locator_bar.rs`, `G/locator_bar.rs` | hud, tab_list, locator_bar, chat_screen, secure_chat | | 18 | Command execution (88) | `G/commands.rs`, `G/commands/*.rs`, `L/scoreboard_cmd.rs`, `L/data_cmd.rs`, `K/scoreboard.rs`, `G/functions.rs` | command_exec (corpus `tools/command_exec_corpus.py`), scoreboard, server_ops_feedback, lan_e2e commands | @@ -40,7 +41,7 @@ file selects ~103. | 22 | Player movement and collision (75) | `L/motion.rs` + `L/motion/`, `L/collisions.rs`, `W/entity_move.rs`, `W/player_travel.rs`, `W/collision.rs` | collision, entity_move, player_travel, living_motion, climbing, swimming; `python3 tools/test_native_scene_motion.py` | | 23 | Resource packs and models (65) | `L/packs.rs` + `L/packs/`, `L/pack_screen.rs`, `L/atlas_sources.rs`, `L/sprites.rs`, `L/model_library.rs`, `G/packs.rs`, `crates/horizon-assets/` | resource_pack, pack_selection, pack_screen, model_resolution, item_model, mipmap | | 24 | Container menus and inventory (61) | `L/menu.rs`, `L/container_screen.rs`, `L/containers.rs`, `L/creative.rs`; game.rs `fn menu_ops`, `fn click_inventory` | menu_click, container_screen, creative, lan_e2e containers clicks | -| 25 | Workstations (60) | `L/stations.rs`, `G/stations.rs`, `L/enchant.rs`, `L/brewing.rs` | enchanting, screen_layout, lan_e2e brewing loom stonecutter grindstone anvil smithing | +| 25 | Workstations (60) | `L/stations.rs`, `G/stations.rs`, `I/enchant.rs`, `L/brewing.rs` | enchanting, screen_layout, lan_e2e brewing loom stonecutter grindstone anvil smithing | | 26 | Crafting and the recipe book (59) | `L/crafting.rs`, `L/recipe_book.rs`, `L/recipe_book_screen.rs`, `G/recipe_book.rs`, `G/machines.rs`, `G/machines/crafter.rs` | recipe, recipe_book, recipe_book_view, place_recipe, ghost_recipe, crafter | | 27 | Vehicles and mounts (54) | `G/boats.rs`, `G/boat_passengers.rs`, `G/minecarts.rs`, `G/mounts.rs`, `G/mount_menu.rs`, `G/llamas.rs`, `G/passengers.rs` | server_entities, lan_e2e boats minecarts riding mounts | | 28 | Villagers, trading, raids (50) | `L/mobs/villager*.rs`, `L/merchant.rs`, `G/trading.rs`, `G/villagers.rs`, `L/raid.rs`, `G/raids.rs`, `L/gossip.rs` | trading, raid, mob_ai | diff --git a/tools/test_block_particles_parity.py b/tools/test_block_particles_parity.py index 7e08e98f8..ff50bd745 100644 --- a/tools/test_block_particles_parity.py +++ b/tools/test_block_particles_parity.py @@ -13,7 +13,7 @@ def main(): resources = lab.RESOURCES build = lab.LOCAL / "block-particles-parity" build.mkdir(exist_ok=True) - paths = [lab.ROOT / p for p in ["tools/test_block_particles_parity.py", "tools/test_breaking_model_parity.py", "tools/oracle_harness.py", "tools/lab.py", "crates/horizon-client/src/particles.rs", "crates/horizon-client/src/game.rs", "crates/horizon-client/src/blocks.rs", "crates/horizon-client/src/mesher.rs", "crates/horizon-client/examples/probe/block_particles_fixture.rs", "mods/horizon/src/test/java/dev/horizonlab/parity/VanillaBlockParticlesOracle.java", "crates/horizon-core/src/random.rs", "Cargo.lock", "mods/horizon/src/test/java/dev/horizonlab/parity/VanillaGameplayFactsExporter.java"]] + paths = [lab.ROOT / p for p in ["tools/test_block_particles_parity.py", "tools/test_breaking_model_parity.py", "tools/oracle_harness.py", "tools/lab.py", "crates/horizon-client/src/particles.rs", "crates/horizon-client/src/game.rs", "crates/horizon-items/src/blocks.rs", "crates/horizon-client/src/mesher.rs", "crates/horizon-client/examples/probe/block_particles_fixture.rs", "mods/horizon/src/test/java/dev/horizonlab/parity/VanillaBlockParticlesOracle.java", "crates/horizon-core/src/random.rs", "Cargo.lock", "mods/horizon/src/test/java/dev/horizonlab/parity/VanillaGameplayFactsExporter.java"]] tested = base.source_hashes(paths) rows = [dict(case=i, seed=(-1 if i % 2 else 1) * i * 987654321, **row) for i, row in enumerate(breaking.cases(resources))] rows[0]["seed"] = -(2**63) diff --git a/tools/test_fire_starter_sound_parity.py b/tools/test_fire_starter_sound_parity.py index bac04f4aa..b8b0696c1 100644 --- a/tools/test_fire_starter_sound_parity.py +++ b/tools/test_fire_starter_sound_parity.py @@ -25,7 +25,7 @@ def main(): 'FireChargeItem.useOn\'s choice between lighting and placing fire is not exercised.') harness.differential( 'rust-fire-starter-sound-parity', 'VanillaFireStarterSoundOracle', 'fire_starter_sound_probe', make_cases, - scope, sources=[lab.ROOT / 'crates/horizon-client/src/items.rs']) + scope, sources=[lab.ROOT / 'crates/horizon-items/src/items.rs']) if __name__ == '__main__': diff --git a/tools/test_firework_tooltip_parity.py b/tools/test_firework_tooltip_parity.py index 14a4138b5..5e971d8db 100644 --- a/tools/test_firework_tooltip_parity.py +++ b/tools/test_firework_tooltip_parity.py @@ -67,7 +67,7 @@ if __name__ == '__main__': 'firework-tooltip-parity', 'VanillaFireworkTooltipOracle', 'firework_tooltip_fixture', cases, 'Firework rocket and star tooltip lines: flight duration, explosions (counted runs), colours, fade, trail, ' 'twinkle, defaults and tooltip_display', - sources=[lab.ROOT / 'crates/horizon-client/src/enchant.rs', - lab.ROOT / 'crates/horizon-client/src/item_tooltip.rs', lab.ROOT / 'crates/horizon-client/src/item_data.rs', + sources=[lab.ROOT / 'crates/horizon-items/src/enchant.rs', + lab.ROOT / 'crates/horizon-client/src/item_tooltip.rs', lab.ROOT / 'crates/horizon-items/src/item_data.rs', lab.ROOT / 'crates/horizon-client/src/particles.rs'], expected_lines=COUNT) diff --git a/tools/test_lore_tooltip_parity.py b/tools/test_lore_tooltip_parity.py index e6869f7e9..63f746392 100644 --- a/tools/test_lore_tooltip_parity.py +++ b/tools/test_lore_tooltip_parity.py @@ -34,7 +34,7 @@ if __name__ == '__main__': 'Item lore tooltip lines: dark purple italic, child colours, explicit non-italic, and tooltip_display', sources=[ lab.ROOT / 'crates/horizon-client/src/item_tooltip.rs', - lab.ROOT / 'crates/horizon-client/src/item_data.rs', + lab.ROOT / 'crates/horizon-items/src/item_data.rs', lab.ROOT / 'crates/horizon-client-core/src/text.rs', lab.ROOT / 'crates/horizon-client-core/src/component.rs', ], From 775e5b19cb399f62c446745d22df950b2bf31f04 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:59:37 +0100 Subject: [PATCH 02/20] horizon-items: tests load prepared items without the client's Assets #2038's bundle tooltip tests and #2037's loot tests share one helper that reads the block registry and facts directly. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-items/src/bundle.rs | 9 +------- crates/horizon-items/src/items.rs | 36 +++++++++++++++--------------- 2 files changed, 19 insertions(+), 26 deletions(-) diff --git a/crates/horizon-items/src/bundle.rs b/crates/horizon-items/src/bundle.rs index 42473729f..efce3c822 100644 --- a/crates/horizon-items/src/bundle.rs +++ b/crates/horizon-items/src/bundle.rs @@ -322,14 +322,7 @@ mod tooltip_tests { /// The prepared resources, when they are on this machine: the tooltip /// asks the item registry whether a stack is a bundle. fn items() -> Option { - let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../.local/horizon-client/26.3"); - if !root.join("items.json").exists() { - return None; - } - let assets = crate::assets::Assets::load(&root).ok()?; - let facts = crate::blocks::BlockFacts::load(&root, assets.registry).ok()?; - Items::load(&root, assets.registry, &facts).ok() + crate::items::prepared_items() } fn bundle_with(counts: &[(&str, u32)]) -> ItemStack { diff --git a/crates/horizon-items/src/items.rs b/crates/horizon-items/src/items.rs index ecf3b9d63..ef8b9c810 100644 --- a/crates/horizon-items/src/items.rs +++ b/crates/horizon-items/src/items.rs @@ -1619,6 +1619,22 @@ mod bucket_use_tests { } } +/// The items of the prepared resources, when they are on this machine, for +/// tests: the block registry and facts as the client's `Assets::load` reads them. +#[cfg(test)] +pub(crate) fn prepared_items() -> Option { + let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../.local/horizon-client/26.3"); + if !root.join("items.json").exists() { + return None; + } + let (registry, _) = + BlockRegistry::from_facts_json_with_flags(fs::File::open(root.join("facts.json")).ok()?) + .ok()?; + let facts = BlockFacts::load(&root, ®istry).ok()?; + Items::load(&root, ®istry, &facts).ok() +} + #[cfg(test)] mod tests { use super::*; @@ -1641,22 +1657,6 @@ mod tests { } } - /// The items of the prepared resources, when they are on this machine - /// (the block registry and facts as the client's `Assets::load` reads them). - fn prepared_items() -> Option { - let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../.local/horizon-client/26.3"); - if !root.join("items.json").exists() { - return None; - } - let (registry, _) = BlockRegistry::from_facts_json_with_flags( - std::fs::File::open(root.join("facts.json")).ok()?, - ) - .ok()?; - let facts = BlockFacts::load(&root, ®istry).ok()?; - Items::load(&root, ®istry, &facts).ok() - } - #[test] fn fortune_silk_touch_and_looting() { let items = Items::for_tests(HashMap::new()); @@ -1746,7 +1746,7 @@ mod tests { /// `EnchantRandomlyFunction`) put their enchantments on the stack. #[test] fn loot_enchant_functions_enchant_the_rolled_item() { - let Some(items) = prepared_items() else { + let Some(items) = super::prepared_items() else { return; }; let props = BTreeMap::new(); @@ -1807,7 +1807,7 @@ mod tests { /// Mending book, then `set_count` uniform 1..64, is 46. #[test] fn a_single_level_enchantment_draws_no_level() { - let Some(items) = prepared_items() else { + let Some(items) = super::prepared_items() else { return; }; let props = BTreeMap::new(); From d18197a17645d076b4d61e715a2150e72f8b5c97 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 18:14:24 +0100 Subject: [PATCH 03/20] Shield: hold it up in first person and show the block pose on other players --- crates/horizon-client/src/game/item_draw.rs | 6 +++++- crates/horizon-client/src/game/lan.rs | 12 ++++++++++++ crates/horizon-client/src/game/remote.rs | 14 ++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game/item_draw.rs b/crates/horizon-client/src/game/item_draw.rs index b6b7e2fb7..b6c42f6e9 100644 --- a/crates/horizon-client/src/game/item_draw.rs +++ b/crates/horizon-client/src/game/item_draw.rs @@ -164,12 +164,16 @@ pub(super) fn owner_at(position: [f64; 3], yaw: f32, entity_type: &str) -> Owner ..Default::default() } } -/// `UseDuration` of a stack: 72000 for drawn items, the consume time for food. +/// `UseDuration` of a stack (`Item.getUseDuration`): 72000 for drawn items +/// and for `blocks_attacks` items (shields), the consume time for food. fn use_duration(components: &Components) -> i32 { if let Some(c) = components.get("minecraft:consumable") { let seconds = c["consume_seconds"].as_f64().unwrap_or(1.6); return (seconds * 20.0) as i32; } + if components.get("minecraft:blocks_attacks").is_some() { + return 72_000; + } let item_model = components .get("minecraft:item_model") .and_then(Value::as_str) diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 63aea7e9f..3ad66eaf8 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -1400,6 +1400,18 @@ impl Game<'_> { let using = host.play.get(&g.conn).and_then(|p| p.using()); m.extra.using_item = using.map_or(0, |(_, ticks)| ticks as i32 + 1); m.extra.using_off_hand = using.is_some_and(|(slot, _)| slot == OFFHAND_SLOT); + // AvatarRenderer.getArmPose: the used item's BLOCK animation (an + // item with `blocks_attacks`) raises the arm. + m.extra.blocking = using + .and_then(|(slot, _)| { + if slot == OFFHAND_SLOT { + m.extra.off_hand.as_ref() + } else { + m.extra.main_hand.as_ref() + } + }) + .and_then(|i| self.res.items().get(i)) + .is_some_and(|i| i.blocks_attacks.is_some()); } } diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index e9f30e84b..ea0927ead 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -3525,6 +3525,20 @@ impl Game<'_> { // LivingEntity.updatingUsingItem: the ticks the item has been // used before this one. Another player's use particles; a mob's // drawn bow only needs the count (its pull follows it). + if player { + // AvatarRenderer.getArmPose: the used item's BLOCK animation + // (an item with `blocks_attacks`) raises the arm. + let used = if m.extra.using_off_hand { + &m.extra.off_hand + } else { + &m.extra.main_hand + }; + m.extra.blocking = m.extra.using_item > 0 + && used + .as_ref() + .and_then(|i| self.res.items().get(i)) + .is_some_and(|i| i.blocks_attacks.is_some()); + } if m.extra.using_item > 0 { if player { let item = if m.extra.using_off_hand { From 5b89508fe92abd98e2035e95c08c0be6269dfc87 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 18:58:36 +0100 Subject: [PATCH 04/20] Predict putting out a lit campfire with a shovel on the client In single player and LAN the client never recognized the shovel on a lit campfire, so there was no smoke and a shield in the other hand was raised too. The client now shows the smoke, puts the fire out locally and ends the click after the hand that doused. --- .../horizon-client/src/game/campfire_douse.rs | 70 ++++++++++++++----- crates/horizon-client/src/game/remote.rs | 7 +- 2 files changed, 60 insertions(+), 17 deletions(-) diff --git a/crates/horizon-client/src/game/campfire_douse.rs b/crates/horizon-client/src/game/campfire_douse.rs index 04accb2d3..e3a990b27 100644 --- a/crates/horizon-client/src/game/campfire_douse.rs +++ b/crates/horizon-client/src/game/campfire_douse.rs @@ -2,8 +2,60 @@ //! shovels) put on a lit campfire puts it out. use super::Game; use horizon_client::{Result, animate_tick::make_campfire_smoke, items::ItemStack}; +use horizon_core::position::BlockPos; +use horizon_world::chunk::BlockState; +use winit::keyboard::KeyCode; impl Game<'_> { + /// The lit campfire at `pos` that `held` puts out, when the item is in + /// `#minecraft:douses_campfires` (`CampfireBlock.useItemOn`). + fn campfire_to_douse(&self, pos: [i32; 3], held: Option<&ItemStack>) -> Option { + held.filter(|s| { + self.res + .items() + .item_tags + .get("minecraft:douses_campfires") + .is_some_and(|members| members.contains(&*s.item)) + })?; + self.pack + .assets + .registry + .state(self.world.block(pos[0], pos[1], pos[2])) + .filter(|s| matches!(&*s.name, "minecraft:campfire" | "minecraft:soul_campfire")) + .filter(|s| s.properties.get("lit").map(String::as_str) == Some("true")) + .cloned() + } + + /// `CampfireBlock.useItemOn` as this client runs it before the server's + /// answer (`MultiPlayerGameMode.useItemOn`): the twenty smoke puffs of + /// `CampfireBlock.douse` and the campfire gone out, kept as predicted + /// under `seq`. The level event's sound comes from the server. True when + /// it doused (SUCCESS), which ends the click for both hands. + pub(super) fn predict_campfire_douse( + &mut self, + pos: [i32; 3], + held: Option<&ItemStack>, + seq: i32, + ) -> bool { + // Player.isSecondaryUseActive: a held item means both hands count. + if self.keys.contains(&KeyCode::ShiftLeft) { + return false; + } + let Some(mut state) = self.campfire_to_douse(pos, held) else { + return false; + }; + let signal = state.properties.get("signal_fire").map(String::as_str) == Some("true"); + for _ in 0..20 { + make_campfire_smoke(&mut self.particles, pos, signal, true); + } + state.properties.insert("lit".into(), "false".into()); + if let Ok(unlit) = self.pack.assets.registry.resolve(&state) { + let at = BlockPos::new(pos[0], pos[1], pos[2]); + self.predict_blocks(&vec![(at, unlit)], seq); + } + true + } + /// `CampfireBlock.useItemOn` with `held` in the hand that acts, the block /// at `pos` clicked: true when it doused a lit campfire (SUCCESS). pub(super) fn use_campfire_douse( @@ -11,24 +63,11 @@ impl Game<'_> { pos: [i32; 3], held: Option<&ItemStack>, ) -> Result { - let Some(tool) = held.filter(|s| { - self.res - .items() - .item_tags - .get("minecraft:douses_campfires") - .is_some_and(|members| members.contains(&*s.item)) - }) else { - return Ok(false); - }; - let registry = self.pack.assets.registry; - let Some(mut state) = registry - .state(self.world.block(pos[0], pos[1], pos[2])) - .filter(|s| matches!(&*s.name, "minecraft:campfire" | "minecraft:soul_campfire")) - .filter(|s| s.properties.get("lit").map(String::as_str) == Some("true")) - .cloned() + let (Some(used), Some(mut state)) = (held.cloned(), self.campfire_to_douse(pos, held)) else { return Ok(false); }; + let registry = self.pack.assets.registry; // Level event 1009 (SOUND_EXTINGUISH_FIRE) goes to every player. self.level_event_at(1009, pos, 0); // CampfireBlock.douse: the acting client's own smoke (twenty puffs), @@ -44,7 +83,6 @@ impl Game<'_> { if let Ok(unlit) = registry.resolve(&state) { self.level_set(pos, unlit)?; } - let used = tool.clone(); self.damage_held(1); // ServerPlayerGameMode.useItemOn: the action was consumed. self.trigger_block("minecraft:item_used_on_block", pos, &used); diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 3cfbd15e3..9a6d19b72 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -4063,6 +4063,11 @@ impl Game<'_> { } if reacts { self.predict_use(&changes, seq); + } else if self.predict_campfire_douse(hit.pos, held.as_ref(), seq) { + // CampfireBlock.useItemOn succeeded: this hand swings and the + // click ends, so a shield in the other hand is not raised. + self.with_hand(hand, Game::swing_arm); + return true; } else if let Some(stack) = held.as_ref() && self.predict_item_on_block(stack, &hit) { @@ -4230,7 +4235,7 @@ impl Game<'_> { /// predicted under the use's sequence until the server acknowledges it /// (`BlockStatePredictionHandler.retainKnownServerState`): a use the /// server refused goes back. - fn predict_blocks(&mut self, changes: &DryChanges, seq: i32) { + pub(super) fn predict_blocks(&mut self, changes: &DryChanges, seq: i32) { for &(p, state) in changes { let pos = [p.x, p.y, p.z]; let before = self.world.block(p.x, p.y, p.z); From a07c6d57dc5aac19f65730aa5b01579cf54f4e62 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:04:00 +0100 Subject: [PATCH 05/20] horizon-items: item_name reads name_text from horizon-client-core Main's item_name (d983ef89b) called crate::entity_nbt::name_text, which the client re-exports from horizon_client_core::component. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-items/src/item_data.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/horizon-items/src/item_data.rs b/crates/horizon-items/src/item_data.rs index 344dab6b7..52c0ccf9b 100644 --- a/crates/horizon-items/src/item_data.rs +++ b/crates/horizon-items/src/item_data.rs @@ -210,7 +210,7 @@ impl ItemStack { /// by `ominous_banner` instead. pub fn item_name(&self) -> Option { let name = self.data()?.other("minecraft:item_name")?; - Some(crate::entity_nbt::name_text(name).plain()) + Some(horizon_client_core::component::name_text(name).plain()) } pub fn potion(&self) -> Option<&str> { self.data().and_then(|d| d.potion.as_deref()) From 50fdcad10fed4dc812228d0cd24390d3ed326002 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 19:40:42 +0100 Subject: [PATCH 06/20] A cookie poisons and kills a parrot --- crates/horizon-client/src/game/creatures.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 845d2aee6..45568dc12 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -2560,6 +2560,7 @@ impl Game<'_> { .items() .get(&item) .is_some_and(|i| i.max_damage > 0); + let player_at = self.eye_position(); let m = &mut self.mobs[index]; if !m.alive() { return Ok(false); @@ -2816,6 +2817,24 @@ impl Game<'_> { consume = true; } } + // Parrot.mobInteract: #parrot_poisonous_food (a cookie) poisons the + // parrot for 45 s and then hurts it with a player attack of + // Float.MAX_VALUE, which an invulnerable one shrugs off. + Species::Parrot + if self + .res + .items() + .item_tags + .get("minecraft:parrot_poisonous_food") + .is_some_and(|t| t.contains(&*item)) => + { + consume = true; + m.add_effect("minecraft:poison", 900, 0); + m.damage( + f32::MAX, + horizon_client::mobs::Damage::player(player_at.unwrap_or(center)), + ); + } // Parrot.mobInteract: parrot food may tame a wild one (one time in // ten); a parrot never takes it as Animal food (isFood is false). Species::Parrot if !m.extra.tame && is_food => { From 23019a7bf999938e19c0d7c0de33f7a43f049485 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 19:48:21 +0100 Subject: [PATCH 07/20] Cats lie beside a sleeping owner and may leave a morning gift --- .../examples/probe/mob_ai_probe.rs | 2 + crates/horizon-client/src/game/creatures.rs | 18 ++ crates/horizon-client/src/game/lan_mobs.rs | 2 + crates/horizon-client/src/mobs.rs | 14 ++ crates/horizon-client/src/mobs/tame.rs | 198 +++++++++++++++++- 5 files changed, 231 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client/examples/probe/mob_ai_probe.rs b/crates/horizon-client/examples/probe/mob_ai_probe.rs index ecdf02901..e7c827541 100644 --- a/crates/horizon-client/examples/probe/mob_ai_probe.rs +++ b/crates/horizon-client/examples/probe/mob_ai_probe.rs @@ -1060,6 +1060,8 @@ pub fn main() -> horizon_client::Result<()> { uuid: PLAYER_UUID, allies: Default::default(), remote: false, + sleeping: false, + sleep_timer: 0, } }); let mut scratch = LegacyRandom::new(0); diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 845d2aee6..6374485d6 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -196,6 +196,8 @@ impl Game<'_> { uuid: self.level.player_uuid.unwrap_or_default(), allies: std::sync::Arc::new(self.player_allies()), remote: false, + sleeping: self.sleeping.is_some(), + sleep_timer: self.sleep_timer, }) } /// Biome facts at a position for finalizeSpawn and variant rules. @@ -1037,6 +1039,22 @@ impl Game<'_> { [0.0, 0.2, 0.0], 10, ), + MobEvent::DropGift { table, at } => { + let stacks = + self.res + .items() + .loot_table(table, false, None, &mut self.world.random); + for stack in stacks { + // ItemEntity's own random motion. + let r = &mut self.world.random; + let v = [ + f64::from(r.next_float()) * 0.2 - 0.1, + 0.2, + f64::from(r.next_float()) * 0.2 - 0.1, + ]; + self.spawn_item(stack, at, v, 10); + } + } MobEvent::SetBlock { pos, state } => { self.world.set(pos[0], pos[1], pos[2], state)? } diff --git a/crates/horizon-client/src/game/lan_mobs.rs b/crates/horizon-client/src/game/lan_mobs.rs index 4f53cec60..1d3b3d086 100644 --- a/crates/horizon-client/src/game/lan_mobs.rs +++ b/crates/horizon-client/src/game/lan_mobs.rs @@ -373,6 +373,8 @@ mod tests { uuid: [0; 4], allies: Default::default(), remote: false, + sleeping: false, + sleep_timer: 0, } } diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 6b1e16f5c..43da944d3 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -659,6 +659,8 @@ pub struct Extra { /// `Mob.canPickUpLoot` (also lets dispensers equip it). pub pick_up_loot: bool, pub saddled: bool, + /// `Cat.RELAX_STATE_ONE`: watching a sleeping owner before lying down. + pub relax_one: bool, /// The saddle's components (name, enchantments...) when it is not a bare /// one; `saddled` stays the gameplay flag. pub saddle_stack: Option, @@ -999,6 +1001,11 @@ pub enum MobEvent { count: u32, at: [f64; 3], }, + /// `Mob.dropFromGiftLootTable`: a gift loot table's items dropped at `at`. + DropGift { + table: &'static str, + at: [f64; 3], + }, SetBlock { pos: [i32; 3], state: horizon_world::registry::BlockStateId, @@ -1260,6 +1267,8 @@ pub struct Seen { pub stats: [f64; 3], /// `Wolf.isInSittingPose`. pub sitting: bool, + /// `Cat.isLying` or `isRelaxStateOne`. + pub lying: bool, /// `Entity.isVehicle` or `isPassenger`. pub ridden: bool, } @@ -1335,6 +1344,7 @@ impl Mob { m.attrs.speed, ], sitting: m.extra.sitting, + lying: m.species() == Species::Cat && (m.extra.flag || m.extra.relax_one), ridden: m.rider.is_some() || m.extra.rider.is_some() || m.extra.mount.is_some(), } } @@ -1488,6 +1498,10 @@ pub struct PlayerView { /// A LAN guest: its own client moves it, and touching mobs push it there /// (`EntitySelector.pushableBy` on the client). pub remote: bool, + /// `isSleeping` and `getSleepTimer` (100 once slept through, then the + /// ticks fading out after waking up). + pub sleeping: bool, + pub sleep_timer: u32, } #[derive(Clone)] pub struct MobWorld<'a> { diff --git a/crates/horizon-client/src/mobs/tame.rs b/crates/horizon-client/src/mobs/tame.rs index 1e725df80..7998b4763 100644 --- a/crates/horizon-client/src/mobs/tame.rs +++ b/crates/horizon-client/src/mobs/tame.rs @@ -12,7 +12,7 @@ use super::{ }; use horizon_core::geometry::trig::Mth; use horizon_core::{ - geometry::{Aabb, Vec3}, + geometry::{Aabb, Axis, Vec3}, random::RandomSource, }; use std::f32::consts::PI; @@ -719,6 +719,7 @@ pub fn register_cat(m: &mut Mob) { g.add(1, Float); g.add(1, TamePanic(Panic::new(1.5))); g.add(2, SitWhenOrdered); + g.add(3, CatRelaxOnOwner::default()); g.add(4, CatTempt(super::goals::Tempt::new(0.6, food, true))); g.add( 4, @@ -799,6 +800,192 @@ fn empty_above(w: &MobWorld, p: [i32; 3]) -> bool { .as_ref() == "minecraft:air" } +/// `Cat.CatRelaxOnOwnerGoal`: while the owner sleeps, the cat lies down beside +/// them, and may leave a morning gift when they wake up. +#[derive(Clone, Debug, Default)] +struct CatRelaxOnOwner { + goal: Option<[i32; 3]>, + on_bed_ticks: i32, +} +impl CatRelaxOnOwner { + /// `spaceIsOccupied`: another cat lying or relaxing within two blocks. + fn occupied(&self, m: &Mob, w: &MobWorld) -> bool { + let Some(g) = self.goal else { return true }; + let area = Aabb::new( + Vec3::new( + f64::from(g[0]) - 2.0, + f64::from(g[1]) - 2.0, + f64::from(g[2]) - 2.0, + ), + Vec3::new( + f64::from(g[0]) + 3.0, + f64::from(g[1]) + 3.0, + f64::from(g[2]) + 3.0, + ), + ); + w.others.iter().any(|o| { + o.species == Species::Cat && o.id != m.id && o.lying && o.bounds.intersects(area) + }) + } + /// The owner's sleeping state, when this cat may relax on them. + fn sleeping_owner<'w>(m: &Mob, w: &'w MobWorld) -> Option<&'w super::PlayerView> { + w.player + .as_ref() + .filter(|p| m.extra.tame && !m.extra.sitting && p.sleeping) + } +} +impl Goal for CatRelaxOnOwner { + goal_clone!(); + fn flags(&self) -> u8 { + 0 + } + fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { + let w = cx.w; + let Some(p) = Self::sleeping_owner(m, w) else { + return false; + }; + if m.distance_sqr(p.position) > 100.0 { + return false; + } + let at = [ + p.position.x.floor() as i32, + p.position.y.floor() as i32, + p.position.z.floor() as i32, + ]; + if !has_tag(w, at, "minecraft:cats_can_lie_on") { + return false; + } + // The bed's foot: against `BedBlock.FACING`. + let back = match w.prop(at[0], at[1], at[2], "facing") { + Some("north") => [0, 0, 1], + Some("south") => [0, 0, -1], + Some("east") => [-1, 0, 0], + Some("west") => [1, 0, 0], + Some("up") => [0, -1, 0], + Some("down") => [0, 1, 0], + _ => [0, 0, 0], + }; + self.goal = Some([at[0] + back[0], at[1] + back[1], at[2] + back[2]]); + !self.occupied(m, w) + } + fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { + Self::sleeping_owner(m, cx.w).is_some() && self.goal.is_some() && !self.occupied(m, cx.w) + } + fn start(&mut self, m: &mut Mob, cx: &mut Cx) { + if let Some(g) = self.goal { + m.extra.flag2 = false; + m.nav_move_to( + cx.w, + [f64::from(g[0]), f64::from(g[1]), f64::from(g[2])], + 1.1, + ); + } + } + fn stop(&mut self, m: &mut Mob, cx: &mut Cx) { + m.extra.flag = false; + // The `gameplay/cat_waking_up_gift_chance` of the `day` timeline: 0.7 + // from tick 23667 of the day to tick 362 of the next, 0 in between. + let day = cx.w.day_time.rem_euclid(24000); + let chance = if !cx.w.nether && !cx.w.end && !(362..23667).contains(&day) { + 0.7 + } else { + 0.0 + }; + if cx.w.player.as_ref().is_some_and(|p| p.sleep_timer >= 100) + && m.random.next_float() < chance + { + give_morning_gift(m, cx); + } + self.on_bed_ticks = 0; + m.extra.relax_one = false; + m.nav_stop(); + } + fn tick(&mut self, m: &mut Mob, cx: &mut Cx) { + let (Some(p), Some(g)) = (cx.w.player.as_ref(), self.goal) else { + return; + }; + m.extra.flag2 = false; + m.nav_move_to( + cx.w, + [f64::from(g[0]), f64::from(g[1]), f64::from(g[2])], + 1.1, + ); + if m.distance_sqr(p.position) < 2.5 { + self.on_bed_ticks += 1; + if self.on_bed_ticks > reduced(16) { + m.extra.flag = true; + m.extra.relax_one = false; + } else { + m.look_at_speed(p.eye, 45.0, 45.0); + m.extra.relax_one = true; + } + } else { + m.extra.flag = false; + } + } +} +/// `CatRelaxOnOwnerGoal.giveMorningGift`: hop to a spot within five blocks +/// (`randomTeleport`, avoiding `#dangerous_for_teleportation`) and drop a +/// `gameplay/cat_morning_gift` item at its feet. +fn give_morning_gift(m: &mut Mob, cx: &mut Cx) { + let w = cx.w; + let at = [ + m.position.x.floor() as i32, + m.position.y.floor() as i32, + m.position.z.floor() as i32, + ]; + let x = at[0] + m.random.next_int_bound(11).unwrap_or(5) - 5; + let y = at[1] + m.random.next_int_bound(5).unwrap_or(2) - 2; + let z = at[2] + m.random.next_int_bound(11).unwrap_or(5) - 5; + let tagged = |p: [i32; 3], tag| has_tag(w, p, tag); + let mut ground = y - 1; + while ground > crate::chunk::MIN_Y + && !tagged([x, ground, z], "minecraft:entities_can_teleport_to") + { + ground -= 1; + } + if w.world.loaded(x, z) && ground > crate::chunk::MIN_Y { + let feet = f64::from(ground + 1); + let half = f64::from(m.width()) / 2.0; + let b = Aabb::new( + Vec3::new(f64::from(x) - half, feet, f64::from(z) - half), + Vec3::new( + f64::from(x) + half, + feet + f64::from(m.height()), + f64::from(z) + half, + ), + ); + let dangerous = |p| tagged(p, "minecraft:dangerous_for_teleportation"); + let in_box = |f: &dyn Fn([i32; 3]) -> bool| { + (b.min(Axis::X).floor() as i32..=b.max(Axis::X).floor() as i32).any(|bx| { + (b.min(Axis::Y).floor() as i32..=b.max(Axis::Y).floor() as i32).any(|by| { + (b.min(Axis::Z).floor() as i32..=b.max(Axis::Z).floor() as i32) + .any(|bz| f([bx, by, bz])) + }) + }) + }; + let fluids = w.fluids(b, feet); + if !dangerous([x, ground, z]) + && w.no_collision(b) + && !(fluids.in_water || fluids.in_lava) + && !in_box(&dangerous) + { + m.position = Vec3::new(f64::from(x), feet, f64::from(z)); + m.previous = m.position; + m.velocity = Vec3::ZERO; + } + } + let yaw = m.body_yaw * (PI / 180.0); + let here = [ + m.position.x.floor() - f64::from(yaw.msin()), + m.position.y.floor(), + m.position.z.floor() + f64::from(yaw.mcos()), + ]; + cx.events.push(MobEvent::DropGift { + table: "minecraft:gameplay/cat_morning_gift", + at: here, + }); +} /// `CatLieOnBlockGoal`: tame cats lie on beds and other warm blocks. #[derive(Clone, Debug)] struct CatLieOnBlock(super::goals::MoveToBlock); @@ -941,6 +1128,7 @@ impl Goal for OcelotAttack { /// `Cat.tick`: lying and relaxing amounts and the purr. pub fn cat_ai_step(m: &mut Mob, events: &mut Vec) { let lying = m.extra.flag; + let relaxing = m.extra.relax_one; let v = &mut m.anim.v; if lying { v[LIE] = (v[LIE] + 0.15).min(1.0); @@ -949,8 +1137,12 @@ pub fn cat_ai_step(m: &mut Mob, events: &mut Vec) { v[LIE] = (v[LIE] - 0.22).max(0.0); v[LIE_TAIL] = (v[LIE_TAIL] - 0.13).max(0.0); } - v[RELAX] = (v[RELAX] - 0.13).max(0.0); - if lying && m.age.is_multiple_of(5) { + v[RELAX] = if relaxing { + (v[RELAX] + 0.1).min(1.0) + } else { + (v[RELAX] - 0.13).max(0.0) + }; + if (lying || relaxing) && m.age.is_multiple_of(5) { let volume = 0.6 + 0.4 * (m.random.next_float() - m.random.next_float()); events.push(MobEvent::Sound("entity.cat.purr", m.center(), volume, 1.0)); } From 2a297a43eb65807666978fd75128bb779e61b8f5 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 21:42:51 +0100 Subject: [PATCH 08/20] Credit the feeder with a parrot's cookie kill --- crates/horizon-client/src/game/creatures.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 45568dc12..069768cb8 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -2830,10 +2830,15 @@ impl Game<'_> { { consume = true; m.add_effect("minecraft:poison", 900, 0); - m.damage( + let id = m.id; + let hurt = m.damage( f32::MAX, horizon_client::mobs::Damage::player(player_at.unwrap_or(center)), ); + // The feeder gets the kill, as for a normal player attack. + if hurt { + self.last_hurt_by_player.insert(id, self.lan_acting); + } } // Parrot.mobInteract: parrot food may tame a wild one (one time in // ten); a parrot never takes it as Animal food (isFood is false). From b0ce58adeab076fe9bd0eb93ff3c985032be6267 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 21:48:18 +0100 Subject: [PATCH 09/20] Check the cat's owner and sync lying and relaxing to clients --- crates/horizon-client/src/game/lan.rs | 9 ++++++ crates/horizon-client/src/game/lan_mobs.rs | 16 +++++++++- crates/horizon-client/src/game/remote.rs | 5 ++++ .../src/game/remote_mob_data.rs | 3 ++ crates/horizon-client/src/mobs/tame.rs | 29 ++++++++++++++----- 5 files changed, 54 insertions(+), 8 deletions(-) diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 5292cd31b..44c669bd6 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -252,6 +252,15 @@ fn mob_meta( if let Some(v) = id(registry, name) { meta.push((index, serializer, Value::Id(v))); } + // Cat.IS_LYING and RELAX_STATE_ONE. + if kind == "minecraft:cat" { + for (index, on) in [(21, e.flag), (22, e.relax_one)] { + if on { + meta.push((index, serializer::BOOLEAN, Value::Bool(true))); + } + defaults.push((index, serializer::BOOLEAN, Value::Bool(false))); + } + } // Wolf.DATA_COLLAR_COLOR and Cat's, red by default. let collar = if kind == "minecraft:cat" { 23 } else { 21 }; let red = i32::from(horizon_client::mobs::tame::RED); diff --git a/crates/horizon-client/src/game/lan_mobs.rs b/crates/horizon-client/src/game/lan_mobs.rs index 1d3b3d086..0d37de086 100644 --- a/crates/horizon-client/src/game/lan_mobs.rs +++ b/crates/horizon-client/src/game/lan_mobs.rs @@ -5,7 +5,7 @@ //! (hits, pushes, fire, its projectiles) runs with that guest swapped in. use super::{Game, lan::LanHost}; use horizon_client::{ - mobs::{Mob, MobEvent, PlayerView, Who}, + mobs::{Mob, MobEvent, PlayerView, Species, Who}, projectiles::{Hit, Projectile}, }; use horizon_core::geometry::{Aabb, Vec3}; @@ -26,6 +26,20 @@ pub(super) fn player_for( guests: &GuestViews, bound: Option, ) -> Option { + // A tame cat reads its owner wherever they are while they sleep, or just + // woke up (`CatRelaxOnOwnerGoal` uses `getOwner`, not the nearest player). + if m.species() == Species::Cat + && m.extra.tame + && let Some(owner) = m.extra.owner + { + let asleep = |v: &PlayerView| v.uuid == owner && (v.sleeping || v.sleep_timer > 0); + if host.is_some_and(asleep) { + return None; + } + if let Some((conn, _)) = guests.iter().find(|(_, v)| asleep(v)) { + return Some(*conn); + } + } let targeting = m.ai.target == Some(Who::Player) || m.ai.last_hurt_by == Some(Who::Player) && m.age as i32 - m.ai.last_hurt_by_time <= 100; choose(m.position, targeting, host, guests, bound) diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 3cfbd15e3..3eb6b56db 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -3535,6 +3535,11 @@ impl Game<'_> { m.head_yaw += wrap_degrees(*yaw - m.head_yaw) / *steps as f32; *steps -= 1; } + // Cat.tick: the lying and relaxing amounts follow the synched flags. + if m.species() == horizon_client::mobs::Species::Cat { + m.anim.vo = m.anim.v; + horizon_client::mobs::tame::cat_amounts(m); + } // Chicken.aiStep: the wings follow the server's on-ground flag. if m.species() == horizon_client::mobs::Species::Chicken { horizon_client::mobs::farm::chicken_flap(m); diff --git a/crates/horizon-client/src/game/remote_mob_data.rs b/crates/horizon-client/src/game/remote_mob_data.rs index c6702dfb6..3d775d6dd 100644 --- a/crates/horizon-client/src/game/remote_mob_data.rs +++ b/crates/horizon-client/src/game/remote_mob_data.rs @@ -71,6 +71,9 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti m.extra.sitting = flags & 1 != 0; m.extra.tame = flags & 4 != 0; } + // Cat.IS_LYING and RELAX_STATE_ONE. + (Species::Cat, 21, Value::Bool(lying)) => m.extra.flag = *lying, + (Species::Cat, 22, Value::Bool(relaxing)) => m.extra.relax_one = *relaxing, // Wolf.DATA_COLLAR_COLOR and Cat's. (Species::Wolf, 21, Value::Int(c)) | (Species::Cat, 23, Value::Int(c)) => { m.extra.color = (*c & 15) as u8; diff --git a/crates/horizon-client/src/mobs/tame.rs b/crates/horizon-client/src/mobs/tame.rs index 7998b4763..4396c4487 100644 --- a/crates/horizon-client/src/mobs/tame.rs +++ b/crates/horizon-client/src/mobs/tame.rs @@ -800,6 +800,11 @@ fn empty_above(w: &MobWorld, p: [i32; 3]) -> bool { .as_ref() == "minecraft:air" } +/// Whether `p` is the player that owns `m` (a cat without a recorded owner +/// belongs to whoever it sees). +pub fn is_owner(m: &Mob, p: &super::PlayerView) -> bool { + m.extra.owner.is_none_or(|o| o == p.uuid) +} /// `Cat.CatRelaxOnOwnerGoal`: while the owner sleeps, the cat lies down beside /// them, and may leave a morning gift when they wake up. #[derive(Clone, Debug, Default)] @@ -827,11 +832,12 @@ impl CatRelaxOnOwner { o.species == Species::Cat && o.id != m.id && o.lying && o.bounds.intersects(area) }) } - /// The owner's sleeping state, when this cat may relax on them. + /// The owner's sleeping state, when this cat may relax on them: the + /// player it sees has to be its owner (`Cat.getOwner`). fn sleeping_owner<'w>(m: &Mob, w: &'w MobWorld) -> Option<&'w super::PlayerView> { w.player .as_ref() - .filter(|p| m.extra.tame && !m.extra.sitting && p.sleeping) + .filter(|p| m.extra.tame && !m.extra.sitting && p.sleeping && is_owner(m, p)) } } impl Goal for CatRelaxOnOwner { @@ -891,7 +897,11 @@ impl Goal for CatRelaxOnOwner { } else { 0.0 }; - if cx.w.player.as_ref().is_some_and(|p| p.sleep_timer >= 100) + if cx + .w + .player + .as_ref() + .is_some_and(|p| is_owner(m, p) && p.sleep_timer >= 100) && m.random.next_float() < chance { give_morning_gift(m, cx); @@ -1127,6 +1137,15 @@ impl Goal for OcelotAttack { } /// `Cat.tick`: lying and relaxing amounts and the purr. pub fn cat_ai_step(m: &mut Mob, events: &mut Vec) { + cat_amounts(m); + if (m.extra.flag || m.extra.relax_one) && m.age.is_multiple_of(5) { + let volume = 0.6 + 0.4 * (m.random.next_float() - m.random.next_float()); + events.push(MobEvent::Sound("entity.cat.purr", m.center(), volume, 1.0)); + } +} +/// The lying and relaxing amounts of `Cat.tick` (also a client's, from the +/// synched flags). +pub fn cat_amounts(m: &mut Mob) { let lying = m.extra.flag; let relaxing = m.extra.relax_one; let v = &mut m.anim.v; @@ -1142,10 +1161,6 @@ pub fn cat_ai_step(m: &mut Mob, events: &mut Vec) { } else { (v[RELAX] - 0.13).max(0.0) }; - if (lying || relaxing) && m.age.is_multiple_of(5) { - let volume = 0.6 + 0.4 * (m.random.next_float() - m.random.next_float()); - events.push(MobEvent::Sound("entity.cat.purr", m.center(), volume, 1.0)); - } } /// `Cat.finalizeSpawn` variant: any but all-black, which a full moon allows. pub fn cat_variant(m: &mut Mob, w_moon_full: bool) -> u8 { From 80e28dfe634a8c501c35c28d2ce27ab31d8adf81 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:49:17 +0100 Subject: [PATCH 10/20] creative_mining harness: items.rs now lives in horizon-items Co-Authored-By: Claude Opus 5.5 --- tools/test_creative_mining_parity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/test_creative_mining_parity.py b/tools/test_creative_mining_parity.py index 3e6bfed1a..03a3de34e 100644 --- a/tools/test_creative_mining_parity.py +++ b/tools/test_creative_mining_parity.py @@ -28,7 +28,7 @@ def main(): 'Item.canDestroyBlock with each vanilla item, empty stacks, Survival/Creative ' 'abilities, and explicit/implicit tool overrides. Block destruction and network ' 'prediction call this predicate; end-to-end packet handling is not covered here.', - sources=[lab.ROOT / 'crates/horizon-client/src/items.rs']) + sources=[lab.ROOT / 'crates/horizon-items/src/items.rs']) if __name__ == '__main__': From 2a1132387f318caf43e421ad4ba0094e8c00c661 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Tue, 27 Oct 2026 06:26:53 +0100 Subject: [PATCH 11/20] Swing the right arm and for the right time on other players --- crates/horizon-client/src/game/lan.rs | 2 + crates/horizon-client/src/game/lan_actions.rs | 3 +- crates/horizon-client/src/game/remote.rs | 9 ++- crates/horizon-client/src/mobs/ai.rs | 60 +++++++++++++++++-- 4 files changed, 66 insertions(+), 8 deletions(-) diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 28ec3fe8a..87ab2bfa4 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -1492,6 +1492,8 @@ impl Game<'_> { .map(|s| s.as_ref().map(|s| s.item.clone())); m.extra.crouching = g.sneaking; m.extra.sprinting = g.sprinting; + // ClientInformation.mainHand (HumanoidArm: 0 left, 1 right). + m.extra.main_hand_left = g.info.main_hand == 0; // LivingEntity.DATA_EFFECT_PARTICLES: the swirls of its visible effects. let visible = || g.effects.list.iter().filter(|e| e.visible); m.extra.effect_particles = visible() diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index 8c51a3f2a..badc9f82d 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -2872,7 +2872,8 @@ impl Game<'_> { let packet = swing_packet(id, c.hand, c.animation); host.hub.broadcast_near(at, 128.0, &packet, Some(conn)); if let Some(m) = host.models.get_mut(&conn) { - m.swing(); + // The duration already counts the guest's haste or fatigue. + m.swing_hand(c.hand == Hand::Off, c.duration_ticks); } } play.swing.tick(); diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 77e143f9a..96810acdf 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -1946,6 +1946,13 @@ impl Game<'_> { m.extra.ints[1] = code; } } + // Avatar.DATA_PLAYER_MAIN_HAND (HumanoidArm: 0 left, 1 + // right): the arm a swing of the main hand moves. + if let (15, Value::Id(arm)) = (index, value) + && let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) + { + m.extra.main_hand_left = *arm == 0; + } } } // LivingEntity.onSyncedDataUpdated(SLEEPING_POS_ID): this @@ -2187,7 +2194,7 @@ impl Game<'_> { if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) { self.swing_hand(hand, animation); } else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) { - m.swing(); + m.swing_hand_modified(hand == Hand::Off, animation.duration); } } // The player's own instances: its maximum health (the hearts the diff --git a/crates/horizon-client/src/mobs/ai.rs b/crates/horizon-client/src/mobs/ai.rs index 26b94eeac..978d4bd36 100644 --- a/crates/horizon-client/src/mobs/ai.rs +++ b/crates/horizon-client/src/mobs/ai.rs @@ -53,17 +53,62 @@ pub struct Ai { /// Swing animation progress (attackAnim) and ticks. pub swing_time: i32, pub swinging: bool, + /// The current swing's `SwingDescription.durationTicks`; 0 is the default 6. + pub swing_duration: i32, /// Walked distance and the next footstep. pub steps: super::living::Steps, } impl Mob { + /// `LivingEntity.getCurrentSwingDuration`: 6 ticks unless a swing set its own. + pub fn swing_duration(&self) -> i32 { + if self.ai.swing_duration > 0 { + self.ai.swing_duration + } else { + 6 + } + } + /// `SwingState.startIfAble`: a swing past the first half of the current + /// one restarts it. + fn start_swing(&mut self, duration: i32) -> bool { + let half = self.swing_duration() / 2; + if self.ai.swinging && self.ai.swing_time >= 0 && self.ai.swing_time < half { + return false; + } + self.ai.swing_time = -1; + self.ai.swinging = true; + self.ai.swing_duration = duration.max(1); + true + } /// LivingEntity.swing pub fn swing(&mut self) { - if !self.ai.swinging || self.ai.swing_time >= 3 || self.ai.swing_time < 0 { - self.ai.swing_time = -1; - self.ai.swinging = true; + if self.start_swing(6) { + self.extra.swing_right_arm = None; } } + /// `LivingEntity.swing(hand, animation, false)` for a swing of `duration` + /// ticks (`getModifiedSwingDuration`): the arm is `hand.asArm(mainArm)`. + pub fn swing_hand(&mut self, off_hand: bool, duration: i32) { + if self.start_swing(duration) { + self.extra.swing_right_arm = Some(!off_hand != self.main_arm_left()); + } + } + /// `swing_hand` for a swing of `base` ticks, lengthened or shortened by + /// this mob's haste, conduit power or mining fatigue + /// (`LivingEntity.getModifiedSwingDuration`). + pub fn swing_hand_modified(&mut self, off_hand: bool, base: i32) { + let amplifier = |id: &str| self.extra.effects.iter().find(|e| e.0 == id).map(|e| e.2); + let dig_speed = amplifier("minecraft:haste").max(amplifier("minecraft:conduit_power")); + let fatigue = amplifier("minecraft:mining_fatigue"); + let duration = crate::first_person::swing_duration( + crate::first_person::SwingAnimation { + kind: 1, + duration: base, + }, + dig_speed, + fatigue, + ); + self.swing_hand(off_hand, duration); + } } /// The module implementing a species' behaviour. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -435,18 +480,21 @@ pub fn tick( } true } -/// LivingEntity.updateSwingTime (6-tick swing duration). +/// LivingEntity.updateSwingTime. pub fn tick_swing(m: &mut Mob) { + let duration = m.swing_duration(); if m.ai.swinging { m.ai.swing_time += 1; - if m.ai.swing_time >= 6 { + if m.ai.swing_time >= duration { m.ai.swing_time = 0; m.ai.swinging = false; + m.ai.swing_duration = 0; + m.extra.swing_right_arm = None; } } else { m.ai.swing_time = 0; } - m.anim.attack_anim = m.ai.swing_time as f32 / 6.0; + m.anim.attack_anim = m.ai.swing_time as f32 / duration as f32; } /// `LivingEntity.baseTick` refills a breather's air by 4, then /// `Axolotl.handleAirSupply` (unless the mob has no AI): water or rain fills From eeceb9c2de52bee63cd84d2fdebd65df6ed27737 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Tue, 27 Oct 2026 06:27:40 +0100 Subject: [PATCH 12/20] Let a fishing hook catch and pull other players --- crates/horizon-client/src/game/creatures.rs | 4 + crates/horizon-client/src/game/fishing.rs | 103 +++++++++++++++++- crates/horizon-client/src/game/lan.rs | 2 + crates/horizon-client/src/game/lan_actions.rs | 4 + 4 files changed, 109 insertions(+), 4 deletions(-) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 2bc40a7af..281bf4d4e 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -46,6 +46,10 @@ pub(super) struct Creatures { pub projectiles: Vec, /// Wind bursts from a guest's turn, waiting for it to end (game/wind.rs). pub wind_bursts: Vec, + /// The players a fishing hook can catch, taken as the hooks tick, and + /// the pulls on them waiting for the hub (game/fishing.rs). + pub hook_targets: Vec, + pub hook_pulls: Vec<(u32, horizon_core::geometry::Vec3)>, /// Ticks left of the elder guardian's apparition (MobAppearanceParticle). pub elder_apparition: i32, /// The wandering trader spawner's clock. diff --git a/crates/horizon-client/src/game/fishing.rs b/crates/horizon-client/src/game/fishing.rs index 39f9e5ffe..dbf4c37b7 100644 --- a/crates/horizon-client/src/game/fishing.rs +++ b/crates/horizon-client/src/game/fishing.rs @@ -1,7 +1,7 @@ //! Fishing: `FishingRodItem.use` (cast / reel in), the `FishingHook` (flight, //! bobbing, lure and bite timers, open water, hooking mobs, retrieving the //! `gameplay/fishing` loot) and `FishingHookRenderer` (bobber and line). -use super::{Game, view_vector}; +use super::{Game, lan::LanHost, lan_mobs::guest_bounds, view_vector}; use horizon_client::lan::{entity::States, hub::Hub}; use horizon_client::{ blocks::Fluid, @@ -45,6 +45,13 @@ pub(super) struct Hook { luck: i32, lure: i32, } +/// A player a hook can catch (`canHitEntity`): their entity id, which +/// guest they are (none: the host) and their box. +pub(super) struct HookTarget { + id: u32, + conn: Option, + bounds: Aabb, +} const SIZE: f64 = 0.25; fn bounds(p: Vec3) -> Aabb { let h = SIZE / 2.0; @@ -166,6 +173,11 @@ impl Game<'_> { } else if let Some(item) = self.dropped.iter_mut().find(|i| i.id == id) { item.velocity = pulled(item.velocity); hooked_item = true; + } else if self.creatures.hook_targets.iter().any(|t| t.id == id) { + // A player: the hub moves them when the tick ends. + self.creatures + .hook_pulls + .push((id, Vec3::new(d.x * 0.1, d.y * 0.1, d.z * 0.1))); } // `hookedIn instanceof ItemEntity ? 3 : 5`. damage = if hooked_item { 3 } else { 5 }; @@ -449,6 +461,15 @@ impl Game<'_> { if !self.holds_rod() || d2 > 1024.0 { return; } + // The players it can catch, as they stand now (the host's own hook; + // guests' hooks use the ones taken as their turns start). + if self.lan_acting.is_none() + && matches!(h.state, State::Flying | State::Hooked(_)) + && let Some(host) = self.lan.take() + { + self.refresh_hook_targets(&host); + self.lan = Some(host); + } h.previous = h.position; if h.on_ground { h.life += 1; @@ -474,8 +495,8 @@ impl Game<'_> { self.hook = Some(h); return; } - // checkCollision: hook the first living mob it touches, or (via - // `canHitEntity`) a dropped item. + // checkCollision: hook the first living mob or other player it + // touches, or (via `canHitEntity`) a dropped item. let bb = bounds(h.position); let (lo, hi) = (bb.min_position(), bb.max_position()); let touches = |b: Aabb| { @@ -487,6 +508,14 @@ impl Game<'_> { .iter() .find(|m| m.alive() && touches(m.bounds())) .map(|m| m.id) + .or_else(|| { + let owner = self.lan_acting; + self.creatures + .hook_targets + .iter() + .find(|t| t.conn != owner && touches(t.bounds)) + .map(|t| t.id) + }) .or_else(|| { self.dropped .iter() @@ -501,12 +530,25 @@ impl Game<'_> { } } State::Hooked(id) => { - // updateHookedEntity: ride the hooked mob or item at 0.8 of its height. + // updateHookedEntity: ride the hooked mob, player or item at 0.8 of its height. let hooked = self .mobs .iter() .find(|m| m.id == id && m.alive()) .map(|m| (m.position, f64::from(m.height()))) + .or_else(|| { + self.creatures + .hook_targets + .iter() + .find(|t| t.id == id) + .map(|t| { + let (lo, hi) = (t.bounds.min_position(), t.bounds.max_position()); + ( + Vec3::new((lo.x + hi.x) / 2.0, lo.y, (lo.z + hi.z) / 2.0), + hi.y - lo.y, + ) + }) + }) .or_else(|| { self.dropped .iter() @@ -575,6 +617,59 @@ impl Game<'_> { ); self.hook = Some(h); } + /// The players a hook can catch: everyone but spectators and the dead + /// (`Projectile.canHitEntity`), the host and the guests in the level. + pub(super) fn refresh_hook_targets(&mut self, host: &LanHost) { + let mut targets = Vec::new(); + if self.options.dedicated.is_none() + && !self.spectator() + && let Some(p) = self.player.as_ref() + && !p.health.dead_or_dying() + { + targets.push(HookTarget { + id: host.host_entity as u32, + conn: None, + bounds: p.bounds(), + }); + } + for g in &host.hub.guests { + if host.hub.here(g) && g.loaded && !g.dead && !g.spectator() { + targets.push(HookTarget { + id: g.entity_id as u32, + conn: Some(g.conn), + bounds: guest_bounds(g), + }); + } + } + self.creatures.hook_targets = targets; + } + /// `FishingHook.pullEntity` on the players the hooks reeled in this tick + /// pulled: their velocity moves, and a guest's reaches their client. + pub(super) fn flush_hook_pulls(&mut self) { + let pulls = std::mem::take(&mut self.creatures.hook_pulls); + let Some(mut host) = self.lan.take() else { + return; + }; + for (id, delta) in pulls { + let push = |g: &mut Self| { + if let Some(p) = g.player.as_mut() { + p.velocity = p.velocity.add_vector(delta); + } + }; + if id == host.host_entity as u32 { + push(self); + } else if let Some(conn) = host + .hub + .guests + .iter() + .find(|g| g.entity_id as u32 == id) + .map(|g| g.conn) + { + self.guest_hit_by(&mut host, conn, push); + } + } + self.lan = Some(host); + } /// `FishingRodCast`: the stack in the arm holding the rod /// (`FishingHookRenderer.getHoldingArm`) while the hook is out. pub(super) fn cast_rod(&self) -> Option<&ItemStack> { diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 28ec3fe8a..cbe559578 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -1166,6 +1166,8 @@ impl Game<'_> { self.lan = Some(host); // A mace's wind burst from a guest's attack. let _ = self.flush_wind_bursts(); + // A reeled-in hook pulling a player. + self.flush_hook_pulls(); } fn lan_guest_joined( diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index 8c51a3f2a..0c6896daf 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -1899,6 +1899,10 @@ impl Game<'_> { .filter(|g| host.hub.here(g) && g.loaded) .map(|g| g.conn) .collect(); + // The players the guests' hooks can catch. + if host.play.values().any(|p| p.hook.is_some()) { + self.refresh_hook_targets(host); + } for conn in conns { let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else { continue; From 713de31f64aa701ce70805219a4cc8fb441266ea Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Tue, 27 Oct 2026 06:36:04 +0100 Subject: [PATCH 13/20] Slow mobs in cobwebs and make magma cubes jump out of lava --- crates/horizon-client/src/mobs/living.rs | 51 ++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/crates/horizon-client/src/mobs/living.rs b/crates/horizon-client/src/mobs/living.rs index e9cd42798..351524eb6 100644 --- a/crates/horizon-client/src/mobs/living.rs +++ b/crates/horizon-client/src/mobs/living.rs @@ -370,7 +370,14 @@ impl Mob { /// Entity.move with block collisions and step-up; updates collision flags /// and fall distance (Entity.checkFallDamage). pub fn do_move(&mut self, w: &MobWorld, events: &mut Vec) { - let wanted = self.velocity; + let mut wanted = self.velocity; + // Entity.move: standing in a cobweb (`WebBlock.entityInside` ends in + // `makeStuckInBlock`) scales the move, stops the mob and ends its fall. + if let Some(stuck) = self.web_multiplier(w) { + self.fall_distance = 0.0; + wanted = Vec3::new(wanted.x * stuck.x, wanted.y * stuck.y, wanted.z * stuck.z); + self.velocity = Vec3::ZERO; + } let step = self.kind.step_height as f32; let mut scene = SceneCollisions { world: w.world, @@ -429,6 +436,35 @@ impl Mob { self.supporting = self.supporting_block(w, moved); self.movement_emission(w, moved, events); } + /// `WebBlock.entityInside`: the speed multiplier of a mob whose box (shrunk + /// by `1.0E-5`, as in `checkInsideBlocks`) touches a cobweb, half as + /// strong with Weaving. Spiders' `makeStuckInBlock` ignores cobwebs and the + /// wither's ignores every block. + fn web_multiplier(&self, w: &MobWorld) -> Option { + use super::Species; + if matches!( + self.species(), + Species::Spider | Species::CaveSpider | Species::Wither + ) { + return None; + } + let b = self.bounds().deflate(Vec3::new(1e-5, 1e-5, 1e-5)); + let (min, max) = (b.min_position(), b.max_position()); + for x in min.x.floor() as i32..=max.x.floor() as i32 { + for y in min.y.floor() as i32..=max.y.floor() as i32 { + for z in min.z.floor() as i32..=max.z.floor() as i32 { + if &*w.facts.get(w.world.block(x, y, z)).name == "minecraft:cobweb" { + return Some(if self.has_effect("minecraft:weaving") { + Vec3::new(0.5, 0.25, 0.5) + } else { + Vec3::new(0.25, f64::from(0.05_f32), 0.25) + }); + } + } + } + } + None + } /// LivingEntity.checkFallDamage: the fluid state refreshed out of water /// (touching water resets the fall), then Entity.checkFallDamage. fn check_fall_damage(&mut self, w: &MobWorld, moved: Vec3, events: &mut Vec) { @@ -846,17 +882,24 @@ impl Mob { self.ai.no_jump_delay = 10; } } else { - self.jump_in_liquid(); + self.jump_in_liquid(true); } } else { - self.jump_in_liquid(); + self.jump_in_liquid(false); } } else { self.ai.no_jump_delay = 0; } } /// Mob.jumpInLiquid: floaters rise 0.04 per tick, others 0.3; bees 0.01. - fn jump_in_liquid(&mut self) { + /// A magma cube in lava rises to `0.22 + size * 0.05` instead + /// (`MagmaCube.jumpInLiquid`). + fn jump_in_liquid(&mut self, lava: bool) { + if lava && self.species() == super::Species::MagmaCube { + self.velocity.y = f64::from(0.22_f32 + self.extra.size as f32 * 0.05); + self.needs_sync = true; + return; + } self.velocity.y += if self.species() == super::Species::Bee { 0.01 } else if self.ai.nav.can_float { From cece5aca30e032ffb68897297bc2533459d99073 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Tue, 27 Oct 2026 06:36:13 +0100 Subject: [PATCH 14/20] Send more mob state to the single player window A client of a server only draws what the server sends. Send and read the synched data for sleeping villagers (pose and bed), horses grazing and rearing, invisible mobs, a carried enderman block, swimming drowned, zombie villager cures, the villager head shake, zombie drowning conversion, hoglin and piglin conversion, charged blazes, turtle eggs, baby zoglins and the wither's side head targets. A wither's side heads also need a line of sight to shoot, as in vanilla. --- crates/horizon-client/src/game.rs | 4 +- crates/horizon-client/src/game/lan.rs | 108 ++++++++++++++++-- crates/horizon-client/src/game/remote.rs | 46 ++++++++ .../src/game/remote_mob_data.rs | 68 ++++++++++- crates/horizon-client/src/mobs/ai.rs | 3 + crates/horizon-client/src/mobs/anim.rs | 23 ++++ crates/horizon-client/src/mobs/equine.rs | 21 ++++ crates/horizon-client/src/mobs/illusioner.rs | 9 ++ crates/horizon-client/src/mobs/piglin.rs | 7 ++ crates/horizon-client/src/mobs/render.rs | 11 ++ crates/horizon-client/src/mobs/sync.rs | 11 +- crates/horizon-client/src/mobs/undead.rs | 11 ++ crates/horizon-client/src/mobs/wither.rs | 34 ++++-- .../src/mobs/zombie_villager.rs | 8 ++ 14 files changed, 346 insertions(+), 18 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 7596ee871..d53aab8e2 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -7132,7 +7132,9 @@ impl Game<'_> { self.yaw }; for m in &self.mobs { - if m.fire_ticks > 0 && !m.removed { + // Blaze.isOnFire: a charged blaze burns. + let blazing = m.species() == horizon_client::mobs::Species::Blaze && m.extra.flag; + if (m.fire_ticks > 0 || blazing) && !m.removed { let at = m.previous.lerp(m.position, alpha); horizon_client::fire_render::flames( [at.x, at.y, at.z], diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 6e00df8fe..96b7c1ae7 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -14,7 +14,7 @@ use horizon_client::{ hub::{HostEvent, HostState, Hub, HubConfig, TickCtx}, playerdata, }, - mobs::{Mob, Who, leash::Holder}, + mobs::{Mob, Species, Who, leash::Holder}, text::Text, }; use horizon_core::geometry::Vec3; @@ -140,6 +140,7 @@ pub(super) fn baby_index(kind: &str) -> Option { | "minecraft:zombie_villager" | "minecraft:zombified_piglin" | "minecraft:hoglin" + | "minecraft:zoglin" | "minecraft:goat" | "minecraft:ocelot" | "minecraft:turtle" @@ -166,8 +167,13 @@ fn mob_meta( defaults: &mut Vec<(u8, i32, Value)>, ) { let kind = m.kind.id.as_str(); - if m.fire_ticks > 0 { - meta.push((0, serializer::BYTE, Value::Byte(1))); + // Entity.DATA_SHARED_FLAGS_ID: on fire 1, swimming 16 (a drowned under + // water, `Drowned.setSwimming`) and invisible 32 (the effect's flag). + let flags = i8::from(m.fire_ticks > 0) + | i8::from(m.species() == Species::Drowned && m.extra.flag) << 4 + | i8::from(m.invisible || horizon_client::mobs::trader::is_invisible(m)) << 5; + if flags != 0 { + meta.push((0, serializer::BYTE, Value::Byte(flags))); } // Entity.DATA_POSE: the pose the species keeps in its own fields (a // pose back at standing goes out by the entity's own default). @@ -175,6 +181,12 @@ fn mob_meta( if pose != 0 { meta.push((6, serializer::POSE, Value::Id(i32::from(pose)))); } + // LivingEntity.DATA_SLEEPING_POS_ID: the bed a sleeping villager lies in. + if horizon_client::mobs::villager::sleeping(m) + && let Some(bed) = m.extra.bed + { + meta.push((14, serializer::OPTIONAL_BLOCK_POS, Value::OptPos(Some(bed)))); + } if let Some(name) = &m.custom_name { meta.push(( 2, @@ -288,10 +300,16 @@ fn mob_meta( | "minecraft:skeleton_horse" | "minecraft:zombie_horse" | "minecraft:camel" => { - // AbstractHorse.DATA_ID_FLAGS: tame 2. - if e.tame { - meta.push((18, serializer::BYTE, Value::Byte(2))); + // AbstractHorse.DATA_ID_FLAGS: tame 2, eating 16 (grazing), standing + // 32 (rearing). + let grazes = horizon_client::mobs::equine::grazes(m.species()); + let flags = if e.tame { 2 } else { 0 } + | if grazes && e.flag { 16 } else { 0 } + | if grazes && e.counter > 0 { 32 } else { 0 }; + if flags != 0 { + meta.push((18, serializer::BYTE, Value::Byte(flags))); } + defaults.push((18, serializer::BYTE, Value::Byte(0))); if kind == "minecraft:horse" { let v = i32::from(e.variant) | i32::from(e.variant2) << 8; if v != 0 { @@ -316,6 +334,15 @@ fn mob_meta( // Villager.DATA_VILLAGER_DATA and ZombieVillager's (after Zombie's three). "minecraft:villager" | "minecraft:zombie_villager" => { use horizon_client::mobs::render::{PROFESSIONS, VILLAGER_TYPES}; + if kind == "minecraft:villager" { + unhappy_counter(m, meta, defaults); + } else { + // ZombieVillager.DATA_CONVERTING_ID: the cure's shaking. + if horizon_client::mobs::zombie_villager::is_converting(m) { + meta.push((19, serializer::BOOLEAN, Value::Bool(true))); + } + defaults.push((19, serializer::BOOLEAN, Value::Bool(false))); + } let villager_type = VILLAGER_TYPES[e.variant as usize % VILLAGER_TYPES.len()]; let profession = PROFESSIONS[e.variant2 as usize % PROFESSIONS.len()]; if let (Some(t), Some(p)) = ( @@ -399,7 +426,7 @@ fn mob_meta( } defaults.push((18, serializer::BYTE, Value::Byte(16))); } - // Enderman.DATA_CARRY_STATE. + // Enderman.DATA_CARRY_STATE (0 for none). "minecraft:enderman" => { if let Some(state) = e.carried { meta.push(( @@ -408,6 +435,60 @@ fn mob_meta( Value::Id(state.0 as i32), )); } + defaults.push((16, serializer::OPTIONAL_BLOCK_STATE, Value::Id(0))); + } + // Zombie.DATA_DROWNED_CONVERSION_ID (after DATA_BABY_ID and + // DATA_SPECIAL_TYPE_ID): the shaking of the conversion under water. + "minecraft:zombie" | "minecraft:husk" => { + if e.counter2 > 0 { + meta.push((18, serializer::BOOLEAN, Value::Bool(true))); + } + defaults.push((18, serializer::BOOLEAN, Value::Bool(false))); + } + // AbstractVillager.DATA_UNHAPPY_COUNTER (after AgeableMob's two). + "minecraft:wandering_trader" => unhappy_counter(m, meta, defaults), + // Hoglin.DATA_IMMUNE_TO_ZOMBIFICATION (after AgeableMob's two) and + // AbstractPiglin's, the first entry of its subclasses: with the + // dimension it makes them shake. + "minecraft:hoglin" | "minecraft:piglin" | "minecraft:piglin_brute" => { + let index = if kind == "minecraft:hoglin" { 18 } else { 16 }; + if e.flag2 { + meta.push((index, serializer::BOOLEAN, Value::Bool(true))); + } + defaults.push((index, serializer::BOOLEAN, Value::Bool(false))); + } + // Blaze.DATA_FLAGS_ID: charged 1, which makes it burn. + "minecraft:blaze" => { + if e.flag { + meta.push((16, serializer::BYTE, Value::Byte(1))); + } + defaults.push((16, serializer::BYTE, Value::Byte(0))); + } + // Turtle.HAS_EGG and LAYING_EGG (after AgeableMob's two). + "minecraft:turtle" => { + if e.flag { + meta.push((18, serializer::BOOLEAN, Value::Bool(true))); + } + if e.flag2 { + meta.push((19, serializer::BOOLEAN, Value::Bool(true))); + } + defaults.push((18, serializer::BOOLEAN, Value::Bool(false))); + defaults.push((19, serializer::BOOLEAN, Value::Bool(false))); + } + // WitherBoss.DATA_TARGET_B and DATA_TARGET_C (after DATA_TARGET_A, the + // main target): the entities the side heads look at, by id. + "minecraft:wither" => { + for (k, index) in [(0, 17), (1, 18)] { + let target = match e.ints[k] { + 0 => None, + -1 => player_entity, + id => Some(id), + }; + if let Some(target) = target { + meta.push((index, serializer::INT, Value::Int(target))); + } + defaults.push((index, serializer::INT, Value::Int(0))); + } } _ => {} } @@ -425,6 +506,19 @@ fn mob_meta( } } +/// AbstractVillager.DATA_UNHAPPY_COUNTER (after AgeableMob's two): the head +/// shake of a villager saying no counts it down from 40. +fn unhappy_counter( + m: &Mob, + meta: &mut Vec<(u8, i32, Value)>, + defaults: &mut Vec<(u8, i32, Value)>, +) { + if m.extra.ints[0] > 0 { + meta.push((18, serializer::INT, Value::Int(m.extra.ints[0]))); + } + defaults.push((18, serializer::INT, Value::Int(0))); +} + /// The equipment a mob shows (ClientboundSetEquipmentPacket slots: main hand, /// off hand, feet, legs, chest, head, body, saddle). fn mob_equipment( diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 5994af375..fef1ba398 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -3489,6 +3489,34 @@ impl Game<'_> { } } let game_time = self.world.game_time; + // `AbstractPiglin.isConverting`: the dimension is not piglin safe. + let nether = self.dim == super::Dim::Nether; + // What a wither's side heads can look at, by network entity id: the + // mobs and the player (position and eye height). + let wither_targets: Vec<(i32, [f64; 3], f64)> = if self + .mobs + .iter() + .any(|m| m.species() == horizon_client::mobs::Species::Wither) + { + let own = self + .remote + .as_ref() + .zip(self.eye_position()) + .map(|(r, eye)| (r.entity_id, eye, eye[1])); + self.mobs + .iter() + .map(|m| { + ( + m.id as i32, + [m.position.x, m.position.y, m.position.z], + m.eye_y(), + ) + }) + .chain(own) + .collect() + } else { + Vec::new() + }; let Some(r) = self.remote.as_mut() else { return; }; @@ -3596,6 +3624,24 @@ impl Game<'_> { } // The animation states a server computes in its own `ai_step`. horizon_client::mobs::sync::client_tick(m, game_time); + match m.species() { + // WitherBoss.aiStep: the side heads turn toward their targets. + horizon_client::mobs::Species::Wither => { + horizon_client::mobs::wither::client_tick(m, |id| { + wither_targets + .iter() + .find(|t| t.0 == id) + .map(|t| (t.1, t.2)) + }); + } + // Hoglin and piglin `isConverting` outside the Nether. + horizon_client::mobs::Species::Hoglin + | horizon_client::mobs::Species::Piglin + | horizon_client::mobs::Species::PiglinBrute => { + horizon_client::mobs::piglin::client_converting(m, nether); + } + _ => {} + } } for o in self.orbs.iter_mut().filter(|_| running) { o.previous = o.position; diff --git a/crates/horizon-client/src/game/remote_mob_data.rs b/crates/horizon-client/src/game/remote_mob_data.rs index bd452ba85..7ec09a3b2 100644 --- a/crates/horizon-client/src/game/remote_mob_data.rs +++ b/crates/horizon-client/src/game/remote_mob_data.rs @@ -4,9 +4,12 @@ //! own simulation, which a client of a server doesn't run. use super::Game; use horizon_client::mobs::render::{PROFESSIONS, VILLAGER_TYPES}; -use horizon_client::mobs::{Mob, Species, Who, bat, camel, sniffer, sync, tame}; +use horizon_client::mobs::{ + Mob, Species, Who, bat, camel, equine, illusioner, sniffer, sync, tame, zombie_villager, +}; use horizon_protocol::entity_data::Value; use horizon_protocol::registry::Builtin; +use horizon_world::registry::BlockStateId; /// The values `apply` takes from one SetEntityData packet, by vanilla's entry /// indices (Entity 0..=7, LivingEntity 8..=14, Mob 15, then the subclasses). @@ -19,7 +22,21 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti // Entity.DATA_SHARED_FLAGS_ID's FLAG_ONFIRE: Entity.isOnFire on a // client, which draws the flames. A client doesn't count the // fire down: the flag goes off when the server's fire is out. - (0, Value::Byte(flags)) => m.fire_ticks = i32::from(flags & 1), + (0, Value::Byte(flags)) => { + m.fire_ticks = i32::from(flags & 1); + // Drowned.setSwimming: the shared flag of swimming. + if species == Species::Drowned { + m.extra.flag = flags & 16 != 0; + } + // The shared flag of invisibility: Entity.isInvisible hides + // the model (an illusioner shows its mirror images). + let invisible = flags & 32 != 0; + if species == Species::Illusioner { + illusioner::set_invisible(m, invisible); + } else if &*m.kind.id != "minecraft:player" { + m.invisible = invisible; + } + } // Entity.DATA_POSE: the pose the species animates by. (6, Value::Id(pose)) => sync::set_pose(m, (*pose).clamp(0, 17) as u8), // LivingEntity.DATA_LIVING_ENTITY_FLAGS: using an item 1, in the @@ -104,6 +121,42 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti (Species::Camel | Species::CamelHusk, 20, Value::Long(tick)) => { camel::set_synced_pose_tick(m, *tick); } + // AbstractHorse.DATA_ID_FLAGS: eating 16 and standing 32 (the + // graze and the rear), which `sync::client_tick` animates. + (s, 18, Value::Byte(flags)) if equine::grazes(s) => equine::set_flags(m, *flags as u8), + // Enderman.DATA_CARRY_STATE (0 for none): the block it holds. + (Species::Enderman, 16, Value::Id(state)) => { + m.extra.carried = (*state > 0).then_some(BlockStateId(*state as u32)); + } + // Zombie.DATA_DROWNED_CONVERSION_ID (after DATA_BABY_ID and + // DATA_SPECIAL_TYPE_ID): the conversion's shaking. + (Species::Zombie | Species::Husk, 18, Value::Bool(converting)) => { + m.extra.counter2 = i32::from(*converting); + } + // ZombieVillager.DATA_CONVERTING_ID (before its villager data). + (Species::ZombieVillager, 19, Value::Bool(converting)) => { + zombie_villager::set_converting(m, *converting); + } + // AbstractVillager.DATA_UNHAPPY_COUNTER (after AgeableMob's two): + // the head shake of `no`, which the server counts down. + (Species::Villager | Species::WanderingTrader, 18, Value::Int(ticks)) => { + m.extra.ints[0] = *ticks; + } + // Hoglin.DATA_IMMUNE_TO_ZOMBIFICATION (after AgeableMob's two) and + // AbstractPiglin's: `sync`'s conversion shake reads it. + (Species::Hoglin, 18, Value::Bool(immune)) + | (Species::Piglin | Species::PiglinBrute, 16, Value::Bool(immune)) => { + m.extra.flag2 = *immune; + } + // Blaze.DATA_FLAGS_ID: charged 1, which makes it burn. + (Species::Blaze, 16, Value::Byte(flags)) => m.extra.flag = flags & 1 != 0, + // Turtle.HAS_EGG and LAYING_EGG (after AgeableMob's two). + (Species::Turtle, 18, Value::Bool(has_egg)) => m.extra.flag = *has_egg, + (Species::Turtle, 19, Value::Bool(laying)) => m.extra.flag2 = *laying, + // WitherBoss.DATA_TARGET_B and _C (after DATA_TARGET_A): the + // entities the side heads turn toward in `wither::client_tick`. + (Species::Wither, 17, Value::Int(id)) => m.extra.ints[0] = *id, + (Species::Wither, 18, Value::Int(id)) => m.extra.ints[1] = *id, // Pillager.IS_CHARGING_CROSSBOW: the charge pose (Raider's // IS_CELEBRATING takes 16). (Species::Pillager, 17, Value::Bool(charging)) => m.extra.flag = *charging, @@ -153,8 +206,19 @@ impl Game<'_> { let Some(r) = &self.remote else { return; }; + // LivingEntity.onSyncedDataUpdated(SLEEPING_POS_ID): a villager lies + // in its bed the way the bed faces, or gets up. + let bed = values.iter().find_map(|v| match v { + (14, _, Value::OptPos(bed)) => Some(bed.map_or(0, |b| self.bed_facing(b))), + _ => None, + }); if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) { apply(m, values, &r.builtin, r.entity_id); + if let Some(code) = bed + && m.species() == Species::Villager + { + m.extra.ints[1] = code; + } } } } diff --git a/crates/horizon-client/src/mobs/ai.rs b/crates/horizon-client/src/mobs/ai.rs index 258c1ccc7..b2e7a0128 100644 --- a/crates/horizon-client/src/mobs/ai.rs +++ b/crates/horizon-client/src/mobs/ai.rs @@ -448,6 +448,9 @@ pub fn tick_swing(m: &mut Mob) { fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec) { m.was_eye_in_water = m.fluids.eye_in_water; m.update_fluids(w, events); + if m.species() == Species::Drowned { + super::undead::drowned_update_swimming(m, w); + } let fire = crate::motion::environment::fire_contact(w.world, w.registry, m.bounds()); if fire > 0.0 { m.ticks_frozen = 0; diff --git a/crates/horizon-client/src/mobs/anim.rs b/crates/horizon-client/src/mobs/anim.rs index b81f224ae..122867121 100644 --- a/crates/horizon-client/src/mobs/anim.rs +++ b/crates/horizon-client/src/mobs/anim.rs @@ -501,6 +501,26 @@ fn rot_lerp_rad(a: f32, from: f32, to: f32) -> f32 { } from + a * diff } +/// `DrownedModel.setupSwimAnimation`: the arms reach up and sway, the legs +/// kick and the head lies straight. +fn drowned_swim(p: &mut Poses, s: &State, amount: f32) { + if amount <= 0.0 { + return; + } + let sway = amount * 0.35 * (0.1 * s.age).msin(); + let kick = amount * 0.55 * (0.1 * s.age).msin(); + let reach = -PI * 4.0 / 5.0; + let (right_x, left_x) = (p.get("right_arm")[3], p.get("left_arm")[3]); + let (right_z, left_z) = (p.get("right_arm")[5], p.get("left_arm")[5]); + p.xrot("right_arm", rot_lerp_rad(amount, right_x, reach) + sway); + p.xrot("left_arm", rot_lerp_rad(amount, left_x, reach) - sway); + p.zrot("right_arm", rot_lerp_rad(amount, right_z, -0.15)); + p.zrot("left_arm", rot_lerp_rad(amount, left_z, 0.15)); + let (left_leg, right_leg) = (p.get("left_leg")[3], p.get("right_leg")[3]); + p.xrot("left_leg", left_leg - kick); + p.xrot("right_leg", right_leg + kick); + p.xrot("head", 0.0); +} /// `HumanoidModel.setupSwimAnimation`: the stroke of both arms over 26 /// walk-animation units, the head down and a slow flutter kick. The left arm /// eases the short way round, the right one linearly, as in vanilla. @@ -657,6 +677,9 @@ pub fn setup( h.crouching = m.extra.crouching; h.passenger = m.extra.passenger; humanoid(p, s, &h); + if m.kind.anim == Anim::Drowned { + drowned_swim(p, s, m.extra.swim.amount(s.partial)); + } if m.kind.anim == Anim::ZombifiedPiglin { super::piglin::ears(p, s); } diff --git a/crates/horizon-client/src/mobs/equine.rs b/crates/horizon-client/src/mobs/equine.rs index 5bb5faf53..5f0a7d912 100644 --- a/crates/horizon-client/src/mobs/equine.rs +++ b/crates/horizon-client/src/mobs/equine.rs @@ -455,6 +455,11 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { if m.extra.counter > 0 { m.extra.counter -= 1; } + animate(m); +} +/// `AbstractHorse.tick`'s eat, stand and mouth animations, which follow the +/// eating and standing flags (`flag` and `counter > 0`). +pub fn animate(m: &mut Mob) { let (eating, standing) = (m.extra.flag, m.extra.counter > 0); let v = &mut m.anim.v; if standing { @@ -472,6 +477,22 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { } v[MOUTH] = (v[MOUTH] - v[MOUTH] * 0.7 - 0.05).max(0.0); } +/// `AbstractHorse.tick` on a client that is not the server: the animations +/// follow the flags of the synched data (`set_flags`). +pub fn client_tick(m: &mut Mob) { + m.anim.vo = m.anim.v; + animate(m); +} +/// `AbstractHorse.DATA_ID_FLAGS` on a client: eating 16 and standing 32. +pub fn set_flags(m: &mut Mob, flags: u8) { + m.extra.flag = flags & 16 != 0; + m.extra.counter = i32::from(flags & 32 != 0); +} +/// Whether `ai_step` above runs this species' grazing and rearing, so that +/// the server's flags 16 and 32 mean eating and standing. +pub fn grazes(s: Species) -> bool { + is_equine(s) && !super::camel::is_camel(s) && !super::llama::is_llama(s) +} /// Body texture and the markings overlay. pub fn texture(m: &Mob) -> String { let baby = if m.is_baby() { "_baby" } else { "" }; diff --git a/crates/horizon-client/src/mobs/illusioner.rs b/crates/horizon-client/src/mobs/illusioner.rs index ec17abad7..b32b15998 100644 --- a/crates/horizon-client/src/mobs/illusioner.rs +++ b/crates/horizon-client/src/mobs/illusioner.rs @@ -146,6 +146,15 @@ impl Goal for Spell { } } +/// `Entity.isInvisible` of an illusioner on a client: the shared flag stands +/// in for the effect the server holds, which draws the mirror images. +pub fn set_invisible(m: &mut Mob, on: bool) { + m.extra.effects.retain(|e| e.0 != INVISIBILITY); + if on { + m.extra.effects.push((INVISIBILITY, i32::MAX, 0)); + } +} + /// The client-side half of `Illusioner.aiStep`: new image offsets when /// hurt and once a minute, with a puff of cloud and a sound. pub fn tick(m: &mut Mob, events: &mut Vec) { diff --git a/crates/horizon-client/src/mobs/piglin.rs b/crates/horizon-client/src/mobs/piglin.rs index de9ba42dd..d7328cb9c 100644 --- a/crates/horizon-client/src/mobs/piglin.rs +++ b/crates/horizon-client/src/mobs/piglin.rs @@ -294,6 +294,13 @@ fn hoglin_repellent(m: &mut Mob, w: &MobWorld) { m.ai.target = None; } } +/// `Hoglin.isConverting` and `AbstractPiglin.isConverting` on a client: in a +/// dimension that is not piglin safe (`nether` is false), a mob that is not +/// immune (`flag2`) and has AI shakes. `counter2 > 0` draws the shake, as it +/// does on the server while the zombification counts up. +pub fn client_converting(m: &mut Mob, nether: bool) { + m.extra.counter2 = i32::from(!nether && !m.extra.flag2 && !m.no_ai); +} /// Per-tick piglin and hoglin state: admiring, anger, zombification and the /// hoglin attack animation. Returns a conversion when it happens. pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { diff --git a/crates/horizon-client/src/mobs/render.rs b/crates/horizon-client/src/mobs/render.rs index 077fd38a6..05bf4fd57 100644 --- a/crates/horizon-client/src/mobs/render.rs +++ b/crates/horizon-client/src/mobs/render.rs @@ -813,6 +813,17 @@ pub fn base_transform_at(m: &Mob, alpha: f32, origin: glam::Mat4) -> glam::Mat4 (sign * horizon_core::jmath::acos(dot.abs().min(1.0))) as f32, ); } + } else if m.species() == Species::Drowned { + // DrownedRenderer.setupRotations: tip toward the look pitch about + // the middle of the box while swimming. + let amount = m.extra.swim.amount(alpha); + if amount > 0.0 { + let pitch = m.previous_head_pitch + (m.head_pitch - m.previous_head_pitch) * alpha; + let middle = glam::Vec3::new(0.0, m.height() / 2.0, 0.0); + base *= glam::Mat4::from_translation(middle) + * glam::Mat4::from_rotation_x((amount * (-10.0 - pitch)).to_radians()) + * glam::Mat4::from_translation(-middle); + } } else if m.extra.swim.amount(alpha) > 0.0 { // AvatarRenderer.setupRotations: tip forward with the swim amount, // following the pitch in water, then lie along the swimming box. diff --git a/crates/horizon-client/src/mobs/sync.rs b/crates/horizon-client/src/mobs/sync.rs index d1c5b6609..7f806d5e2 100644 --- a/crates/horizon-client/src/mobs/sync.rs +++ b/crates/horizon-client/src/mobs/sync.rs @@ -15,10 +15,13 @@ //! them in `entity_event`; //! - run its client side animation in `client_tick`, sharing the function //! with the server's `ai_step` where the server computes the same. -use super::{Mob, Species, breeze, camel, shulker, sniffer, warden}; +use super::{ + Mob, Species, breeze, camel, equine, illusioner, shulker, sniffer, undead, villager, warden, +}; // `Pose.id` of the poses mobs take. pub const STANDING: u8 = 0; +pub const SLEEPING: u8 = 2; pub const LONG_JUMPING: u8 = 6; pub const SITTING: u8 = 10; pub const ROARING: u8 = 11; @@ -36,6 +39,8 @@ pub fn pose_id(m: &Mob) -> u8 { Species::Warden => warden::pose_id(m), Species::Breeze => breeze::pose_id(m), Species::Camel | Species::CamelHusk if camel::sitting(m) => SITTING, + // LivingEntity.startSleeping: the pose, with the bed in index 14. + Species::Villager if villager::sleeping(m) => SLEEPING, _ => STANDING, } } @@ -60,6 +65,10 @@ pub fn client_tick(m: &mut Mob, game_time: i64) { Species::Breeze => breeze::ai_step(m), Species::Camel | Species::CamelHusk => camel::client_tick(m, game_time), Species::Shulker => shulker::client_tick(m), + s if equine::grazes(s) => equine::client_tick(m), + Species::Drowned => undead::drowned_client_tick(m), + // Illusioner.aiStep runs on a client: the mirror images move there. + Species::Illusioner => illusioner::tick(m, &mut Vec::new()), _ => {} } } diff --git a/crates/horizon-client/src/mobs/undead.rs b/crates/horizon-client/src/mobs/undead.rs index 52efea3c4..926770e11 100644 --- a/crates/horizon-client/src/mobs/undead.rs +++ b/crates/horizon-client/src/mobs/undead.rs @@ -181,6 +181,17 @@ fn drowned_ok_target(m: &Mob, w: &super::MobWorld) -> bool { pub fn drowned_wants_to_swim(m: &Mob, w: &super::MobWorld) -> bool { m.extra.flag2 || m.target_info(w).is_some_and(|t| in_water_at(w, t.position)) } +/// `Drowned.updateSwimming`: swimming under water while it wants to. `flag` +/// holds `isSwimming`, which clients take from the entity's shared flags. +pub fn drowned_update_swimming(m: &mut Mob, w: &super::MobWorld) { + m.extra.flag = + !m.no_ai && m.was_eye_in_water && m.fluids.in_water && drowned_wants_to_swim(m, w); +} +/// A drowned on a client: `LivingEntity.updateSwimAmount` follows the +/// swimming flag (`Drowned.isVisuallySwimming`). +pub fn drowned_client_tick(m: &mut Mob) { + m.extra.swim.tick(m.extra.flag, m.fluids.in_water); +} /// Drowned.DrownedMoveControl pub fn drowned_move_control(m: &mut Mob, w: &super::MobWorld) { let target = m.target_info(w); diff --git a/crates/horizon-client/src/mobs/wither.rs b/crates/horizon-client/src/mobs/wither.rs index 6702686c5..165ba53b5 100644 --- a/crates/horizon-client/src/mobs/wither.rs +++ b/crates/horizon-client/src/mobs/wither.rs @@ -169,13 +169,12 @@ fn target_pos(w: &MobWorld, id: i32) -> Option<([f64; 3], f64, bool)> { .map(|s| ([s.position.x, s.position.y, s.position.z], s.eye_y, false)), } } -/// `WitherBoss.aiStep` after `super.aiStep()`: the side heads turn, smoke -/// rises from the heads (the particle offsets are drawn on the server too). -pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { - // Side heads turn toward their targets, else back to the body. +/// The side heads turn toward their targets, else back to the body. `find` +/// gives the position and eye height of the entity a head's target id names. +fn turn_heads(m: &mut Mob, find: impl Fn(i32) -> Option<([f64; 3], f64)>) { for i in 0..2 { - match target_pos(w, m.extra.ints[i]) { - Some((p, eye, _)) => { + match find(m.extra.ints[i]) { + Some((p, eye)) => { let h = head_pos(m, i + 1); let (xd, yd, zd) = (p[0] - h[0], eye - h[1], p[2] - h[2]); let sd = (xd * xd + zd * zd).sqrt(); @@ -187,6 +186,18 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { None => m.anim.v[i] = rotlerp(m.anim.v[i], m.body_yaw, 10.0), } } +} +/// `WitherBoss.aiStep` on a client that is not the server: the side heads +/// turn toward the targets of the synched data (`DATA_TARGET_B` and `_C`, +/// kept in `ints[0]` and `ints[1]` as network entity ids). +pub fn client_tick(m: &mut Mob, find: impl Fn(i32) -> Option<([f64; 3], f64)>) { + m.anim.vo = m.anim.v; + turn_heads(m, find); +} +/// `WitherBoss.aiStep` after `super.aiStep()`: the side heads turn, smoke +/// rises from the heads (the particle offsets are drawn on the server too). +pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { + turn_heads(m, |id| target_pos(w, id).map(|(p, eye, _)| (p, eye))); let powered = powered(m); for i in 0..3 { let h = head_pos(m, i); @@ -263,11 +274,20 @@ pub fn custom_server_ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec + <= 900.0 + && m.sees( + w, + if id == -1 { + Who::Player + } else { + Who::Mob(id as u32) + }, + ) => { let at = [p[0], p[1] + (eye - p[1]) * 0.5, p[2]]; shoot(m, i + 1, at, false, events); diff --git a/crates/horizon-client/src/mobs/zombie_villager.rs b/crates/horizon-client/src/mobs/zombie_villager.rs index 51fb33c6d..c5fae726a 100644 --- a/crates/horizon-client/src/mobs/zombie_villager.rs +++ b/crates/horizon-client/src/mobs/zombie_villager.rs @@ -24,6 +24,14 @@ pub struct Cure { pub fn cure(m: &Mob) -> Option<&Cure> { villager::ext(m).and_then(|e| e.cure.as_ref()) } +/// `ZombieVillager.DATA_CONVERTING_ID` on a client: only `is_converting` +/// (the cure's shaking) reads the cure, so its time does not matter. +pub fn set_converting(m: &mut Mob, on: bool) { + villager::ext_mut(m).cure = on.then_some(Cure { + time: 0, + starter: None, + }); +} pub fn is_converting(m: &Mob) -> bool { m.species() == Species::ZombieVillager && cure(m).is_some() } From 97d119b4e8995800f9e0e1de497af4c460758760 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Tue, 27 Oct 2026 06:39:31 +0100 Subject: [PATCH 15/20] Loop the bee buzz and the guardian attack hum like vanilla --- crates/horizon-client/src/game.rs | 7 ++ crates/horizon-client/src/game/creatures.rs | 3 + crates/horizon-client/src/game/lan.rs | 5 + crates/horizon-client/src/game/mob_sounds.rs | 82 +++++++++++++ crates/horizon-client/src/game/music.rs | 2 + crates/horizon-client/src/game/remote.rs | 4 + crates/horizon-client/src/mobs.rs | 7 ++ crates/horizon-client/src/mobs/guardian.rs | 25 ++-- crates/horizon-client/src/sound_engine.rs | 123 +++++++++++++++++++ 9 files changed, 250 insertions(+), 8 deletions(-) create mode 100644 crates/horizon-client/src/game/mob_sounds.rs diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 7699e83d7..8e1de77e4 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -103,6 +103,7 @@ mod maps; mod minecarts; mod mob_blocks; mod mob_gear; +mod mob_sounds; mod mod_browser; #[cfg(feature = "mods")] mod mod_browser_recipes; @@ -1379,6 +1380,10 @@ struct Game<'a> { cart_sounds: HashSet, /// The minecart the riding sounds were started for (`LocalPlayer.startRiding`). riding_cart_sounds: Option, + /// Bees whose buzz is playing, and whether it is the aggressive one. + bee_sounds: HashMap, + /// Guardians whose attack hum is playing. + guardian_sounds: HashSet, dropped: Vec, next_entity: u32, particles: horizon_client::particles::ParticleEngine, @@ -12908,6 +12913,8 @@ impl<'a> Game<'a> { sound_paused: false, cart_sounds: HashSet::new(), riding_cart_sounds: None, + bee_sounds: HashMap::new(), + guardian_sounds: HashSet::new(), dropped: Vec::new(), next_entity: 0, particles, diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 2bc40a7af..cf26174c5 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -933,6 +933,9 @@ impl Game<'_> { match e { MobEvent::SquidCycle { mob, at } => self.lan_entity_event(mob as i32, 19, at), MobEvent::RabbitHop { mob, at } => self.lan_entity_event(mob as i32, 1, at), + MobEvent::EntityEvent { mob, event, at } => { + self.lan_entity_event(mob as i32, event, at) + } MobEvent::Sound(name, at, volume, pitch) | MobEvent::LocalSound(name, at, volume, pitch) => { self.audio diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 28ec3fe8a..a29de78e6 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -1302,6 +1302,11 @@ impl Game<'_> { if self.audio.capture.is_some() { self.lan_entity_events.push((id, event, at)); } + // Event 21: a window that simulates the world itself is its own + // client, and hears the guardian's hum too. + if event == 21 && self.options.execution_role.is_client() { + self.start_guardian_sound(id as u32); + } } /// `ServerPlayer.crit` (or `magicCrit` when `magic`): this window's /// emitter, and an animation for the attacker's client and observers once diff --git a/crates/horizon-client/src/game/mob_sounds.rs b/crates/horizon-client/src/game/mob_sounds.rs new file mode 100644 index 000000000..50065d94f --- /dev/null +++ b/crates/horizon-client/src/game/mob_sounds.rs @@ -0,0 +1,82 @@ +//! The looping sound instances of mobs: a bee's buzz +//! (`ClientPacketListener.postAddEntitySoundInstance`) and a guardian's +//! attack hum (entity event 21). The sound engine ticks them with what +//! `start_mob_sounds` reads off the mobs. +use super::Game; +use horizon_client::{ + mobs::{Species, bee, guardian}, + sound_engine::{MobSound, SoundEngine, SoundInstance, Sounds}, +}; + +impl Game<'_> { + /// Start the buzz of each bee that has none (or whose anger changed, which + /// swaps the flying loop for the aggressive one and back), forget the mobs + /// that are gone, and read what the instances follow. + pub(super) fn start_mob_sounds(&mut self) -> Vec { + let game_time = self.world.game_time; + let mobs: Vec = self + .mobs + .iter() + .filter(|m| { + !m.removed + && matches!( + m.species(), + Species::Bee | Species::Guardian | Species::ElderGuardian + ) + }) + .map(|m| MobSound { + id: m.id, + position: [m.position.x, m.position.y, m.position.z], + // Vec3.horizontalDistance. + speed: m.velocity.x.hypot(m.velocity.z), + baby: m.is_baby(), + // NeutralMob.isAngry: the anger end time is ahead. + angry: m.species() == Species::Bee + && bee::get(m).is_some_and(|b| b.anger_end > 0 && b.anger_end - game_time > 0), + silent: m.silent, + attack_scale: guardian::attack_scale(m), + }) + .collect(); + self.guardian_sounds + .retain(|id| mobs.iter().any(|m| m.id == *id)); + self.bee_sounds + .retain(|id, _| mobs.iter().any(|m| m.id == *id)); + for sound in &mobs { + let is_bee = self + .mobs + .iter() + .any(|m| m.id == sound.id && m.species() == Species::Bee); + if is_bee && self.bee_sounds.get(&sound.id) != Some(&sound.angry) { + self.bee_sounds.insert(sound.id, sound.angry); + let instance = SoundInstance::bee(sound, sound.angry, SoundEngine::unseeded()); + self.audio.play_instance(instance, false); + } + } + mobs + } + + /// `ClientPacketListener.handleEntityEvent` 21: a guardian's attack + /// hum starts (one loop per guardian; the loops of its earlier beams are + /// the same loop, as each one follows the guardian's charge). + pub(super) fn start_guardian_sound(&mut self, id: u32) { + let Some(m) = self.mobs.iter().find(|m| { + m.id == id && matches!(m.species(), Species::Guardian | Species::ElderGuardian) + }) else { + return; + }; + if !self.guardian_sounds.insert(id) { + return; + } + let sound = MobSound { + id, + position: [m.position.x, m.position.y, m.position.z], + speed: 0.0, + baby: false, + angry: false, + silent: m.silent, + attack_scale: guardian::attack_scale(m), + }; + let instance = SoundInstance::guardian_attack(&sound, SoundEngine::unseeded()); + self.audio.play_instance(instance, false); + } +} diff --git a/crates/horizon-client/src/game/music.rs b/crates/horizon-client/src/game/music.rs index 97ca3763e..6e303e7d3 100644 --- a/crates/horizon-client/src/game/music.rs +++ b/crates/horizon-client/src/game/music.rs @@ -34,11 +34,13 @@ impl Game<'_> { self.sound_paused = self.pause; } let (vehicle, carts) = self.start_cart_sounds(); + let mobs = self.start_mob_sounds(); let ctx = TickContext { player_underwater: self.ambience.was_underwater(), player_removed: self.player.is_none(), vehicle, carts, + mobs, frozen: !self.clock.runs_normally(), }; self.audio.tick(self.pause, &ctx); diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 77e143f9a..9c0927d11 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -2130,6 +2130,10 @@ impl Game<'_> { self.ui.registries.permission = (p.event - 24) as u8; self.remote_world_options_permission(p.event >= 26); } + // ClientPacketListener.handleEntityEvent 21: a guardian's hum. + if p.event == 21 { + self.start_guardian_sound(p.id as u32); + } if p.event == 1 && let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) && m.species() == horizon_client::mobs::Species::Rabbit diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 2325c3f5b..1cb338bee 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -903,6 +903,13 @@ pub enum MobEvent { mob: u32, at: [f64; 3], }, + /// `Level.broadcastEntityEvent` for a mob: guests run its + /// `handleEntityEvent`. + EntityEvent { + mob: u32, + event: i8, + at: [f64; 3], + }, Sound(&'static str, [f64; 3], f32, f32), /// `ChestBlockEntity.startOpen` / `stopOpen` by a copper golem: the /// chest's opener count goes up or down (interaction range 3). diff --git a/crates/horizon-client/src/mobs/guardian.rs b/crates/horizon-client/src/mobs/guardian.rs index 880655361..fdd596b54 100644 --- a/crates/horizon-client/src/mobs/guardian.rs +++ b/crates/horizon-client/src/mobs/guardian.rs @@ -112,14 +112,14 @@ impl Goal for GuardianAttack { if self.attack_time == 0 { m.extra.flag = true; m.extra.counter2 = 0; - // Entity event 21: the attack sound loop starts. - let sound = "entity.guardian.attack"; - cx.events.push(MobEvent::Sound( - sound, - [m.position.x, m.position.y, m.position.z], - 1.0, - 1.0, - )); + // Entity event 21: the attack sound loop starts on the clients. + if !m.silent { + cx.events.push(MobEvent::EntityEvent { + mob: m.id, + event: 21, + at: [m.position.x, m.position.y, m.position.z], + }); + } } else if self.attack_time >= attack_duration(m) { // Indirect magic (1, +2 on Hard, +2 for the elder), then // doHurtTarget. @@ -459,3 +459,12 @@ pub fn anim(m: &Mob, s: &State, p: &mut Poses) { p.yrot("tail1", tail.msin() * PI * 0.1); p.yrot("tail2", tail.msin() * PI * 0.15); } +/// `Guardian.getAttackAnimationScale(0)`: how far the beam has charged. A +/// beam that ended counts from zero again. +pub fn attack_scale(m: &Mob) -> f32 { + if m.extra.flag { + m.extra.counter2 as f32 / attack_duration(m) as f32 + } else { + 0.0 + } +} diff --git a/crates/horizon-client/src/sound_engine.rs b/crates/horizon-client/src/sound_engine.rs index 562b14100..f3e2dd75d 100644 --- a/crates/horizon-client/src/sound_engine.rs +++ b/crates/horizon-client/src/sound_engine.rs @@ -53,6 +53,11 @@ pub enum Ticking { Minecart { cart: u32 }, /// `RidingMinecartSoundInstance` for the player riding a minecart. RidingMinecart { cart: u32, underwater: bool }, + /// `BeeFlyingSoundInstance`, or `BeeAggressiveSoundInstance` when + /// `aggressive`. + Bee { bee: u32, aggressive: bool }, + /// `GuardianAttackSoundInstance`. + GuardianAttack { guardian: u32 }, } /// What a minecart's sound instances read about it. #[derive(Clone, Copy, Debug)] @@ -62,6 +67,21 @@ pub struct CartSound { /// `getDeltaMovement().horizontalDistance()`. pub speed: f64, } +/// What a bee's or a guardian's sound instance reads about it. +#[derive(Clone, Copy, Debug)] +pub struct MobSound { + pub id: u32, + pub position: [f64; 3], + /// `getDeltaMovement().horizontalDistance()`. + pub speed: f64, + pub baby: bool, + /// `Bee.isAngry`. + pub angry: bool, + /// `isSilent`. + pub silent: bool, + /// `Guardian.getAttackAnimationScale(0)`. + pub attack_scale: f32, +} /// What tickable instances read about the player and the entities they follow. #[derive(Clone, Debug, Default)] pub struct TickContext { @@ -71,6 +91,8 @@ pub struct TickContext { pub vehicle: Option, /// The minecarts in the level (a missing one has been removed). pub carts: Vec, + /// The bees and guardians in the level (a missing one has been removed). + pub mobs: Vec, /// `!tickRateManager().runsNormally()`. pub frozen: bool, } @@ -315,6 +337,59 @@ impl SoundInstance { }; s } + /// `new BeeFlyingSoundInstance(bee)` (or `BeeAggressiveSoundInstance`): + /// silent until the bee moves. + pub fn bee(bee: &MobSound, aggressive: bool, random: LegacyRandom) -> Self { + let id = if aggressive { + "minecraft:entity.bee.loop_aggressive" + } else { + "minecraft:entity.bee.loop" + }; + let [x, y, z] = bee.position; + let mut s = Self::new( + id, + NEUTRAL, + 0.0, + 1.0, + random, + true, + 0, + Attenuation::Linear, + [ + f64::from(x as f32), + f64::from(y as f32), + f64::from(z as f32), + ], + false, + ); + s.can_start_silent = true; + s.ticking = Ticking::Bee { + bee: bee.id, + aggressive, + }; + s + } + /// `new GuardianAttackSoundInstance(guardian)`: the guardian's own volume + /// and pitch until its first tick, heard everywhere. + pub fn guardian_attack(guardian: &MobSound, random: LegacyRandom) -> Self { + let id = "minecraft:entity.guardian.attack"; + let mut s = Self::new( + id, + crate::settings::sound_source(id), + 1.0, + 1.0, + random, + true, + 0, + Attenuation::None, + guardian.position, + false, + ); + s.ticking = Ticking::GuardianAttack { + guardian: guardian.id, + }; + s + } pub fn tickable(&self) -> bool { self.ticking != Ticking::No } @@ -389,6 +464,54 @@ impl SoundInstance { 0.0 }; } + Ticking::Bee { bee, aggressive } => { + let (id, aggressive) = (*bee, *aggressive); + // canPlaySound, then shouldSwitchSounds: the other kind of + // instance is started in this one's place. + let Some(bee) = ctx + .mobs + .iter() + .find(|m| m.id == id) + .filter(|m| !m.silent && m.angry == aggressive) + else { + self.stop(); + return; + }; + let [x, y, z] = bee.position; + self.position = [ + f64::from(x as f32), + f64::from(y as f32), + f64::from(z as f32), + ]; + let (min, max) = if bee.baby { (1.1, 1.5) } else { (0.7, 1.1) }; + let speed = bee.speed as f32; + if speed >= 0.01 { + // Mth.lerp(Mth.clamp(speed, min, max), min, max). + self.pitch = min + speed.clamp(min, max) * (max - min); + self.volume = speed.clamp(0.0, 0.5) * 1.2; + } else { + self.pitch = 0.0; + self.volume = 0.0; + } + } + Ticking::GuardianAttack { guardian } => { + let id = *guardian; + // The client's guardian never has a target of its own, so + // only its removal or silence ends the loop. + let Some(guardian) = ctx.mobs.iter().find(|m| m.id == id && !m.silent) else { + self.stop(); + return; + }; + let [x, y, z] = guardian.position; + self.position = [ + f64::from(x as f32), + f64::from(y as f32), + f64::from(z as f32), + ]; + let scale = guardian.attack_scale; + self.volume = 0.0 + 1.0 * scale * scale; + self.pitch = 0.7 + 0.5 * scale; + } Ticking::RidingMinecart { cart, underwater } => { let (id, underwater) = (*cart, *underwater); let found = ctx.carts.iter().find(|c| c.id == id); From eb664d50c1272b93af67759bd547c2b8b907a48f Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:04:38 +0100 Subject: [PATCH 16/20] pr_ready: blocks.rs and items.rs live in horizon-items now Co-Authored-By: Claude Opus 5.5 --- tools/pr_ready.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tools/pr_ready.py b/tools/pr_ready.py index b741e475e..d4b97023f 100755 --- a/tools/pr_ready.py +++ b/tools/pr_ready.py @@ -292,8 +292,7 @@ def mod_features(crates): # What horizon-client's full content-root tests load (mod_content_tests.rs). They take minutes on prepared data, so # they are #[ignore]d and run here only when one of these changes. -CONTENT_ROOT = ("crates/horizon-client/src/mod_content", "crates/horizon-client/src/assets.rs", - "crates/horizon-client/src/blocks.rs", "crates/horizon-client/src/items.rs", "crates/horizon-items/", +CONTENT_ROOT = ("crates/horizon-client/src/mod_content", "crates/horizon-client/src/assets.rs", "crates/horizon-items/", "crates/horizon-mods/", "crates/horizon-assets/") CONTENT_ROOT_TESTS = "cargo test --locked -p horizon-client --features horizon-client/mods --lib mod_content::tests:: -- --ignored" From 8648470c475f409d171efc5b72a218f56aa58f94 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:06:54 +0100 Subject: [PATCH 17/20] rustfmt the merged item_data.rs line Co-Authored-By: Claude Opus 5.5 --- crates/horizon-items/src/item_data.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/horizon-items/src/item_data.rs b/crates/horizon-items/src/item_data.rs index c7a608d24..aefc5f23d 100644 --- a/crates/horizon-items/src/item_data.rs +++ b/crates/horizon-items/src/item_data.rs @@ -323,7 +323,9 @@ fn read_text(t: &Tag) -> Option { // this), so flatten the whole component instead of reading one key. // ComponentSerialization.CODEC also takes a list (its first element // styling the rest), as servers behind ViaVersion send book pages. - Tag::Compound(_) | Tag::List(_) => Some(horizon_client_core::component::name_text(t).plain()), + Tag::Compound(_) | Tag::List(_) => { + Some(horizon_client_core::component::name_text(t).plain()) + } _ => None, } } From e314c2fa81e3e2c76958611c473bccb1497585ea Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Tue, 27 Oct 2026 11:13:14 +0100 Subject: [PATCH 18/20] Implement the spear's left-click jab and the Lunge enchantment --- .../src/game/enchant_effects.rs | 100 ++++++++++++++++ crates/horizon-client/src/game/hand.rs | 23 +++- crates/horizon-client/src/game/lan_actions.rs | 33 ++++++ crates/horizon-client/src/game/remote.rs | 30 ++++- crates/horizon-client/src/game/spear.rs | 108 +++++++++++++++--- crates/horizon-client/src/spear.rs | 41 +++++-- 6 files changed, 301 insertions(+), 34 deletions(-) diff --git a/crates/horizon-client/src/game/enchant_effects.rs b/crates/horizon-client/src/game/enchant_effects.rs index 5f021a43f..2db9ba576 100644 --- a/crates/horizon-client/src/game/enchant_effects.rs +++ b/crates/horizon-client/src/game/enchant_effects.rs @@ -197,6 +197,106 @@ impl Game<'_> { } self.post_attack_bursts(&held, &kind); } + /// `EnchantmentHelper.doPostPiercingAttackEffects`: the + /// `minecraft:post_piercing_attack` effects of the weapon in hand after a + /// jab (Lunge's dash). + pub(super) fn post_piercing_attack(&mut self) { + let Some(held) = self.held().cloned() else { + return; + }; + let mut effects = Vec::new(); + for (id, level) in held.enchantments() { + let Some(def) = self.res.stations().enchantments.get(id) else { + continue; + }; + for entry in def.effects["minecraft:post_piercing_attack"] + .as_array() + .into_iter() + .flatten() + { + effects.push((*level, entry["effect"].clone())); + } + } + // Lunge's requirements: not riding, gliding or in water, and a + // player outside creative with at least 7 food. + let Some(p) = self.player.as_ref() else { + return; + }; + if effects.is_empty() + || self.riding.is_some() + || p.fall_flying + || p.fluids.was_touching_water + || !(self.creative || p.health.food.food >= 7) + { + return; + } + for (level, effect) in effects { + self.apply_piercing_effect(&effect, level); + } + } + /// One `post_piercing_attack` effect on the player: `AllOf`, + /// `ChangeItemDamage`, `ApplyExhaustion`, `ApplyEntityImpulse` and + /// `PlaySoundEffect`. + fn apply_piercing_effect(&mut self, effect: &serde_json::Value, level: u32) { + let amount = || level_value(&effect["amount"], level); + match effect["type"].as_str() { + Some("minecraft:all_of") => { + for e in effect["effects"].as_array().into_iter().flatten() { + self.apply_piercing_effect(e, level); + } + } + Some("minecraft:change_item_damage") => self.damage_held(amount() as u32), + Some("minecraft:apply_exhaustion") => { + if let Some(p) = self.player.as_mut() { + p.cause_food_exhaustion(amount()); + } + } + // The direction turned by the look (`Entity.getLookQuaternion`: + // pitch about x, then yaw about y), scaled by coordinate_scale + // and the magnitude, added to the velocity (the guest's client + // is told of it by `guest_hit_by`). + Some("minecraft:apply_impulse") => { + let vec3 = |key: &str| [0, 1, 2].map(|i| effect[key][i].as_f64().unwrap_or(0.0)); + let (d, scale) = (vec3("direction"), vec3("coordinate_scale")); + let magnitude = f64::from(level_value(&effect["magnitude"], level)); + let (yaw, pitch) = ( + f64::from(self.yaw).to_radians(), + f64::from(self.pitch).to_radians(), + ); + let (y1, z1) = ( + d[1] * pitch.cos() - d[2] * pitch.sin(), + d[1] * pitch.sin() + d[2] * pitch.cos(), + ); + let impulse = horizon_core::geometry::Vec3::new( + (d[0] * yaw.cos() - z1 * yaw.sin()) * scale[0] * magnitude, + y1 * scale[1] * magnitude, + (d[0] * yaw.sin() + z1 * yaw.cos()) * scale[2] * magnitude, + ); + if let Some(p) = self.player.as_mut() { + p.velocity = p.velocity.add_vector(impulse); + } + } + // The sound for the level (a list picks by level), heard by all. + Some("minecraft:play_sound") => { + let sounds: Vec<&str> = match &effect["sound"] { + serde_json::Value::Array(list) => { + list.iter().filter_map(|s| s.as_str()).collect() + } + one => one.as_str().into_iter().collect(), + }; + let index = (level as usize).saturating_sub(1); + if let Some(sound) = sounds.get(index.min(sounds.len().saturating_sub(1))) { + let float = |key: &str| effect[key].as_f64().unwrap_or(1.0) as f32; + let at = self + .player + .as_ref() + .map(|p| [p.position.x, p.position.y, p.position.z]); + self.audio.play(sound, at, float("volume"), float("pitch")); + } + } + _ => {} + } + } /// `minecraft:post_attack` explode effects on the attacker (wind burst): /// a burst at the player's feet that only knocks back and triggers /// blocks (`ExplodeEffect` with `block_interaction: trigger`). diff --git a/crates/horizon-client/src/game/hand.rs b/crates/horizon-client/src/game/hand.rs index d91d3b979..524db061f 100644 --- a/crates/horizon-client/src/game/hand.rs +++ b/crates/horizon-client/src/game/hand.rs @@ -331,6 +331,12 @@ struct GameHands<'g, 'a> { error: Option>, } impl GameHands<'_, '_> { + /// The main-hand item's `kinetic_weapon` and `piercing_weapon`, a spear's. + fn attack_kinetic(&self) -> Option { + self.game + .held() + .and_then(|s| horizon_client::spear::Kinetic::of(&s.item)) + } fn keep(&mut self, r: Result<()>) { if let Err(e) = r && self.error.is_none() @@ -398,18 +404,27 @@ impl HandWorld for GameHands<'_, '_> { self.game.creative } fn cannot_attack(&mut self) -> bool { - false + let ticker = self.game.hand_strength.ticker; + self.game.cannot_attack_with_item(ticker, 0) } fn piercing_weapon(&mut self) -> bool { - false + self.attack_kinetic().is_some() } fn attack_animation(&mut self) -> SwingAnimation { - SwingAnimation::DEFAULT + self.attack_kinetic() + .map_or(SwingAnimation::DEFAULT, |k| k.attack_animation()) } fn interact_animation(&mut self, _: Hand) -> SwingAnimation { SwingAnimation::DEFAULT } - fn piercing_attack(&mut self, _: SwingAnimation) {} + fn piercing_attack(&mut self, _: SwingAnimation) { + // The server's part (`PiercingWeapon.attack`), then the client's: + // the sound `MultiPlayerGameMode.piercingAttack` plays. + if let Some(sound) = self.game.jab() { + self.game.play_at_feet(sound); + } + self.game.hand_strength.reset_attack(); + } fn attack_entity(&mut self) -> bool { self.game.attack() } diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index 4aeb25563..8a518acde 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -1027,6 +1027,8 @@ impl Game<'_> { g.player_on_equip("mainhand", offhand.as_ref(), main.as_ref()); }); } + // STAB: `PiercingWeapon.attack` of a left click with a spear. + 8 if !spectator => self.guest_jab(host, conn), _ => {} }, S::SetCarriedItem(p) => { @@ -1514,6 +1516,37 @@ impl Game<'_> { Some((centre, eye)) } + /// `ServerGamePacketListenerImpl.handlePlayerAction` for STAB: the spear in + /// the guest's main hand jabs once its charge allows it (with five ticks + /// of tolerance). The guest's client is sent the dash Lunge gave them. + fn guest_jab(&mut self, host: &mut LanHost, conn: u32) { + let Some(g) = host.hub.guest(conn) else { + return; + }; + let at = g.position; + // ServerPlayer.getKnownMovement: the move their client just sent. + let movement = g + .moves + .last() + .map_or(Vec3::ZERO, |m| Vec3::new(m.0[0], m.0[1], m.0[2])); + let sound = self + .guest_hit_by(host, conn, |g| { + g.known_movement = movement; + if g.cannot_attack_with_item(g.attack_ticks as i32, 5) { + None + } else { + g.jab() + } + }) + .flatten(); + // PiercingWeapon.makeSound: `playSound(causer, ...)`, so the guest's + // own client plays it. + if let Some(name) = sound { + let source = horizon_client::settings::sound_source(name) as i32; + host.hub.sound(name, at, 1.0, 1.0, source, Some(conn)); + } + } + fn guest_attack(&mut self, host: &mut LanHost, conn: u32, id: i32) { let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else { return; diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 3707b9945..4871d4f8a 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -4564,6 +4564,28 @@ impl Game<'_> { if self.spectator() { return true; } + // A spear jabs (`MultiPlayerGameMode.piercingAttack`): the server + // stabs, this client swings, plays the sound and starts its charge + // again. + if let Some(kinetic) = self + .held() + .and_then(|s| horizon_client::spear::Kinetic::of(&s.item)) + { + if self.cannot_attack_with_item(self.hand_strength.ticker, 0) { + return false; + } + // ServerboundPlayerActionPacket.Action.STAB. + self.send(S::PlayerAction(play::PlayerAction { + action: 8, + pos: [0; 3], + direction: 0, + sequence: 0, + })); + self.hand_strength.reset_attack(); + self.swing_hand(Hand::Main, kinetic.attack_animation()); + self.play_at_feet(kinetic.attack_sound); + return true; + } let mut instant = false; // A ghast's fireball or a wind charge nearer than any mob: the // server turns it (game/deflect.rs). @@ -4614,7 +4636,13 @@ impl Game<'_> { if !down { self.clicks.miss_time = 0; } - if self.clicks.miss_time > 0 || self.using.is_some() { + // A piercing weapon neither breaks blocks nor swings on its own. + if self.clicks.miss_time > 0 + || self.using.is_some() + || self + .held() + .is_some_and(|s| horizon_client::spear::Kinetic::of(&s.item).is_some()) + { return; } let entity = self.entity_targeted(); diff --git a/crates/horizon-client/src/game/spear.rs b/crates/horizon-client/src/game/spear.rs index 13df3aed9..f7d1b5e4e 100644 --- a/crates/horizon-client/src/game/spear.rs +++ b/crates/horizon-client/src/game/spear.rs @@ -5,10 +5,13 @@ //! attacker's own speed, and each condition runs out after its time //! (`Item.use`, `ItemStack.onUseTick`, `KineticWeapon.damageEntities`, //! `Player.stabAttack`). The hit's feedback (`onKineticHit`) reaches the -//! players around as entity event 2. +//! players around as entity event 2. A left click jabs instead +//! (`PiercingWeapon.attack`), which Lunge turns into a dash. use super::{Game, view_vector}; use horizon_client::{ + attack_strength::delay, collisions::SceneCollisions, + first_person::Hand, items::ItemStack, mobs::Damage, spear::{HIT_FEEDBACK_TICKS, Kinetic, REACH}, @@ -115,6 +118,28 @@ impl Game<'_> { hits } + /// `ProjectileUtil.getHitEntitiesAlong` with the spear's `AttackRange`: the + /// mobs along the view from the minimum reach to the maximum plus the + /// attacker's own movement, none when a block is closer than the minimum. + fn stab_targets(&self, eye: Vec3, look: Vec3, movement: Vec3) -> Vec { + // AttackRange.effectiveMinRange and effectiveMaxRange for a player. + let (min, max) = if self.creative { + (REACH.min_creative, REACH.max_creative) + } else { + (REACH.min, REACH.max) + }; + let from = eye.add_vector(look.scale(f64::from(min))); + let mut to = eye.add_vector(look.scale(f64::from(max) + movement.dot(look).max(0.0))); + // A block closer than the minimum reach takes the whole stab. + if let Some(block) = self.clip_collider(eye, to) { + to = block; + if eye.distance_squared(to) < eye.distance_squared(from) { + return Vec::new(); + } + } + self.mobs_along(from, to, f64::from(REACH.hitbox_margin)) + } + /// `KineticWeapon.damageEntities`: one tick of the spear in use, `ticks` /// ticks into the use. pub(super) fn spear_tick(&mut self, stack: &ItemStack, ticks: u32) { @@ -136,25 +161,10 @@ impl Game<'_> { let look = Vec3::new(look[0], look[1], look[2]); let movement = self.known_player_movement(); let attacker_speed = look.dot(movement.scale(20.0)); - // AttackRange.effectiveMinRange and effectiveMaxRange for a player. - let (min, max) = if self.creative { - (REACH.min_creative, REACH.max_creative) - } else { - (REACH.min, REACH.max) - }; - let from = eye.add_vector(look.scale(f64::from(min))); - let mut to = eye.add_vector(look.scale(f64::from(max) + movement.dot(look).max(0.0))); - // A block closer than the minimum reach takes the whole stab. - if let Some(block) = self.clip_collider(eye, to) { - to = block; - if eye.distance_squared(to) < eye.distance_squared(from) { - return; - } - } let now = self.world.game_time; let actor = self.lan_acting; let mut affected = false; - for i in self.mobs_along(from, to, f64::from(REACH.hitbox_margin)) { + for i in self.stab_targets(eye, look, movement) { let key = (actor, self.mobs[i].id); let recent = self .stabbed @@ -204,9 +214,18 @@ impl Game<'_> { return false; }; let from = [p.position.x, p.position.y, p.position.z]; - // The enchantments' share, with no attack strength scaling for a - // weapon in use. + // The enchantments' share, scaled by the attack strength unless the + // weapon is in use. let magic_boost = self.enchant_damage_bonus(&kind); + let (base_damage, magic_boost) = if self.using.is_none() { + let scale = self.attack_strength_scale(); + ( + base_damage * (0.2 + scale * scale * 0.8), + magic_boost * scale, + ) + } else { + (base_damage, magic_boost) + }; let total = if deals_damage { base_damage + magic_boost } else { @@ -275,6 +294,57 @@ impl Game<'_> { true } + /// `Player.cannotAttackWithItem` for the held item: a spear needs its + /// `minimum_attack_charge` of 1, counting `tolerance` ticks more than the + /// `ticker` ticks charged. + pub(super) fn cannot_attack_with_item(&self, ticker: i32, tolerance: i32) -> bool { + self.held().is_some_and(|s| Kinetic::of(&s.item).is_some()) + && (ticker + tolerance) as f32 / delay(self.main_hand_attack_speed()) < 1.0 + } + + /// `PiercingWeapon.attack` of the spear in the main hand, for a left + /// click: every mob along the view is stabbed, then `postPiercingAttack` + /// (Lunge), the hit sound and the swing. Gives the sound of the jab + /// (`makeSound`), which everyone but the user hears from the server. + pub(super) fn jab(&mut self) -> Option<&'static str> { + let kinetic = self.held().and_then(|s| Kinetic::of(&s.item))?; + let eye = self.eye_position()?; + let eye = Vec3::new(eye[0], eye[1], eye[2]); + let look = view_vector(self.yaw, self.pitch); + let look = Vec3::new(look[0], look[1], look[2]); + // The attack damage attribute. + let damage = 1.0 + + self.attack_item_info().map_or(0.0, |i| i.attack_damage) + + self.effect_attack_bonus(); + let movement = self.known_player_movement(); + let mut hit = false; + for i in self.stab_targets(eye, look, movement) { + hit |= self.stab_mob(i, damage, true, true, false); + } + // Player.onAttack, then postPiercingAttack. + self.attack_ticks = 0; + self.post_piercing_attack(); + if hit { + self.play_at_feet(kinetic.hit_sound); + } + // swingAndResetAttackStrength (ServerPlayer): a swing that starts + // resets the attack strength. + let swings = self.swings_started; + self.swing_hand(Hand::Main, kinetic.attack_animation()); + if self.swings_started != swings { + self.reset_attack_strength(); + } + Some(kinetic.attack_sound) + } + + /// A sound at the player's feet. + pub(super) fn play_at_feet(&mut self, sound: &str) { + if let Some(p) = &self.player { + let at = [p.position.x, p.position.y, p.position.z]; + self.audio.play(sound, Some(at), 1.0, 1.0); + } + } + /// `LivingEntity.onKineticHit` for `entity`: the hit feels (the spear /// moves back, `kinetic_hit_ticks`) and sounds, at most once every ten /// ticks. diff --git a/crates/horizon-client/src/spear.rs b/crates/horizon-client/src/spear.rs index ae6d8f80d..ed32b269a 100644 --- a/crates/horizon-client/src/spear.rs +++ b/crates/horizon-client/src/spear.rs @@ -2,6 +2,7 @@ //! conditions of a stab, `Item.Properties.spear`'s numbers) and //! `SpearAnimations` (how the spear is held in first and third person while //! it charges). +use crate::first_person::SwingAnimation; use glam::{Mat4, Vec3}; use horizon_core::{geometry::trig::Mth, jmath::pow}; use std::f32::consts::PI; @@ -39,6 +40,10 @@ pub struct Kinetic { pub damage_multiplier: f32, pub sound: &'static str, pub hit_sound: &'static str, + /// The `attack_animation` duration, and the `piercing_weapon` sound of a + /// jab. + pub attack_ticks: i32, + pub attack_sound: &'static str, } /// `AttackRange` of a spear: the reach of its stabs. @@ -62,16 +67,17 @@ pub const REACH: Reach = Reach { impl Kinetic { /// The `kinetic_weapon` of a spear item (`Item.Properties.spear`). pub fn of(item: &str) -> Option { - // (damage multiplier, delay, dismount time and speed, knockback time - // and speed, damage time and speed): seconds and blocks per second. - let (multiplier, delay, dismount, knockback, damage) = match item { - "minecraft:wooden_spear" => (0.7, 0.75, (5.0, 14.0), (10.0, 5.1), (15.0, 4.6)), - "minecraft:stone_spear" => (0.82, 0.7, (4.5, 13.0), (9.0, 5.1), (13.75, 4.6)), - "minecraft:copper_spear" => (0.82, 0.65, (4.0, 12.0), (8.25, 5.1), (12.5, 4.6)), - "minecraft:iron_spear" => (0.95, 0.6, (2.5, 11.0), (6.75, 5.1), (11.25, 4.6)), - "minecraft:golden_spear" => (0.7, 0.7, (3.5, 13.0), (8.5, 5.1), (13.75, 4.6)), - "minecraft:diamond_spear" => (1.075, 0.5, (3.0, 10.0), (6.5, 5.1), (10.0, 4.6)), - "minecraft:netherite_spear" => (1.2, 0.4, (2.5, 9.0), (5.5, 5.1), (8.75, 4.6)), + // (attack duration, damage multiplier, delay, dismount time and + // speed, knockback time and speed, damage time and speed): seconds + // and blocks per second. + let (attack, multiplier, delay, dismount, knockback, damage) = match item { + "minecraft:wooden_spear" => (0.65, 0.7, 0.75, (5.0, 14.0), (10.0, 5.1), (15.0, 4.6)), + "minecraft:stone_spear" => (0.75, 0.82, 0.7, (4.5, 13.0), (9.0, 5.1), (13.75, 4.6)), + "minecraft:copper_spear" => (0.85, 0.82, 0.65, (4.0, 12.0), (8.25, 5.1), (12.5, 4.6)), + "minecraft:iron_spear" => (0.95, 0.95, 0.6, (2.5, 11.0), (6.75, 5.1), (11.25, 4.6)), + "minecraft:golden_spear" => (0.95, 0.7, 0.7, (3.5, 13.0), (8.5, 5.1), (13.75, 4.6)), + "minecraft:diamond_spear" => (1.05, 1.075, 0.5, (3.0, 10.0), (6.5, 5.1), (10.0, 4.6)), + "minecraft:netherite_spear" => (1.15, 1.2, 0.4, (2.5, 9.0), (5.5, 5.1), (8.75, 4.6)), _ => return None, }; let wood = item == "minecraft:wooden_spear"; @@ -107,8 +113,23 @@ impl Kinetic { } else { "minecraft:item.spear.hit" }, + attack_ticks: ticks(attack), + attack_sound: if wood { + "minecraft:item.spear_wood.attack" + } else { + "minecraft:item.spear.attack" + }, }) } + + /// The spear's `attack_animation`: a STAB (`SwingAnimationType` 2) as long + /// as its attack duration. + pub fn attack_animation(&self) -> SwingAnimation { + SwingAnimation { + kind: 2, + duration: self.attack_ticks, + } + } } /// `KineticWeapon.HIT_FEEDBACK_TICKS`. From d5aa3f6e4256e8631cd14a1ad651fab5074f9efe Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:11:34 +0100 Subject: [PATCH 19/20] Name the cat relax goal's owner check is_owner_of The tame mob goals (#2382, #2399) add an is_owner(m, w) to this module. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/mobs/tame.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client/src/mobs/tame.rs b/crates/horizon-client/src/mobs/tame.rs index 5ac5d72dc..bd846ebc2 100644 --- a/crates/horizon-client/src/mobs/tame.rs +++ b/crates/horizon-client/src/mobs/tame.rs @@ -795,7 +795,7 @@ fn empty_above(w: &MobWorld, p: [i32; 3]) -> bool { } /// Whether `p` is the player that owns `m` (a cat without a recorded owner /// belongs to whoever it sees). -pub fn is_owner(m: &Mob, p: &super::PlayerView) -> bool { +pub fn is_owner_of(m: &Mob, p: &super::PlayerView) -> bool { m.extra.owner.is_none_or(|o| o == p.uuid) } /// `Cat.CatRelaxOnOwnerGoal`: while the owner sleeps, the cat lies down beside @@ -830,7 +830,7 @@ impl CatRelaxOnOwner { fn sleeping_owner<'w>(m: &Mob, w: &'w MobWorld) -> Option<&'w super::PlayerView> { w.player .as_ref() - .filter(|p| m.extra.tame && !m.extra.sitting && p.sleeping && is_owner(m, p)) + .filter(|p| m.extra.tame && !m.extra.sitting && p.sleeping && is_owner_of(m, p)) } } impl Goal for CatRelaxOnOwner { @@ -894,7 +894,7 @@ impl Goal for CatRelaxOnOwner { .w .player .as_ref() - .is_some_and(|p| is_owner(m, p) && p.sleep_timer >= 100) + .is_some_and(|p| is_owner_of(m, p) && p.sleep_timer >= 100) && m.random.next_float() < chance { give_morning_gift(m, cx); From f6e96e484c5ba974b4d87e200c8be609e9d0a935 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:23:53 +0100 Subject: [PATCH 20/20] Nightly: the scheduled run deletes nightly/build-* branches older than 7 days Ludovic's decision (2026-10-27). `nightly.py prune-builds` runs in the scheduled `pick` only, with main's code, and lists each deletion in the run summary. It deletes only branches named nightly/build-- older than 7 days, never the newest build, and never one a published `nightly` or nightly- release points to. nightly-state and every other branch are left alone. tests/test_nightly.py pins it. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/nightly-build.yml | 8 +++++ docs/release-flow.md | 3 ++ tests/test_nightly.py | 36 ++++++++++++++++++++++ tools/ci/nightly.py | 48 +++++++++++++++++++++++++++++ 4 files changed, 95 insertions(+) diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index 24b6974a5..3fa440bd1 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -95,6 +95,14 @@ jobs: run: | python3 tools/ci/nightly.py pick ${FORCE:+--force} | tee -a "$GITHUB_STEP_SUMMARY" { echo; cat nightly-notes.md; } >> "$GITHUB_STEP_SUMMARY" + - name: Old nightly/build-* branches deleted (scheduled runs only) + # Ludovic, 2026-10-27: older than 7 days, but never the newest build or one a published nightly points to; + # nothing but nightly/build-* (nightly.py stale_builds, tests/test_nightly.py). + if: github.event_name == 'schedule' + env: + GH_TOKEN: ${{ github.token }} + run: | + { echo "### nightly/build-* branches"; python3 tools/ci/nightly.py prune-builds --days 7; } | tee -a "$GITHUB_STEP_SUMMARY" - uses: actions/upload-artifact@v4 with: name: nightly-notes diff --git a/docs/release-flow.md b/docs/release-flow.md index 5377b27bc..09565bd2e 100644 --- a/docs/release-flow.md +++ b/docs/release-flow.md @@ -447,6 +447,9 @@ comment on any issue or pull request (`.github/workflows/nightly-command.yml`, ` Equal inputs give the same commit, so an unchanged set and main publish nothing new. On a conflict the build stops and answers which two pull requests conflict and where; a reviewer can then open an integration pull request from their fork that combines them, and pick that one instead. +- **Old builds.** The scheduled run deletes the `nightly/build-*` branches older than 7 days (Ludovic, + 2026-10-27), and lists each in its summary. It never deletes the newest build, a build a published `nightly` or + `nightly-` release points to, or any other branch (`nightly.py prune-builds`). - **Refused.** A pull request that changes a path CODEOWNERS gives to Ludovic (`.github/`, `tools/ci/`, the signing and update code, `launcher/src/backend.rs`, `launcher/links.json`…), more than 30 in a set, and more than 6 builds a day (the scheduled one always runs). diff --git a/tests/test_nightly.py b/tests/test_nightly.py index c44416044..567b3d86e 100644 --- a/tests/test_nightly.py +++ b/tests/test_nightly.py @@ -120,3 +120,39 @@ class UntrustedBuildTests(unittest.TestCase): self.assertEqual(self.game.count("HORIZON_NO_SCCACHE: ${{ inputs.trusted && '1' || '' }}"), calls) env = (Path(__file__).resolve().parents[1] / "tools/ci/runner-env.sh").read_text() self.assertEqual(env.count('-z "${HORIZON_NO_SCCACHE:-}"'), 2) + + +class PruneBuildsTests(unittest.TestCase): + TODAY = dt.date(2026, 11, 10) + + def test_only_old_unpublished_builds_go(self): + branches = { + "nightly/build-2026-10-27-1": "a", # old: goes + "nightly/build-2026-10-27-2": "pub", # old, but a published nightly- points to it: stays + "nightly/build-2026-11-02-1": "c", # old enough by one day: goes + "nightly/build-2026-11-03-1": "d", # exactly 7 days: stays + "nightly/build-2026-11-09-3": "e", # recent: stays + } + self.assertEqual(nightly.stale_builds(branches, {"pub"}, self.TODAY, 7), + ["nightly/build-2026-10-27-1", "nightly/build-2026-11-02-1"]) + + def test_the_newest_build_always_stays(self): + branches = {"nightly/build-2026-10-01-1": "a", "nightly/build-2026-10-01-2": "b"} + self.assertEqual(nightly.stale_builds(branches, set(), self.TODAY, 7), ["nightly/build-2026-10-01-1"]) + self.assertEqual(nightly.stale_builds({"nightly/build-2026-10-01-1": "a"}, set(), self.TODAY, 7), []) + # the newest by number, not by text: -10 is newer than -9 + branches = {"nightly/build-2026-10-01-9": "a", "nightly/build-2026-10-01-10": "b"} + self.assertEqual(nightly.stale_builds(branches, set(), self.TODAY, 7), ["nightly/build-2026-10-01-9"]) + + def test_nothing_else_is_touched(self): + branches = {"nightly-state": "s", "nightly/build-x": "x", "main": "m", "nightly/build-2026-10-01-1-fix": "f", + "feature/nightly/build-2026-10-01-1": "g", "nightly/build-2026-10-02-1": "n"} + self.assertEqual(nightly.stale_builds(branches, set(), self.TODAY, 7), []) + + def test_the_workflow_prunes_on_schedule_only(self): + build = (WORKFLOWS / "nightly-build.yml").read_text() + step = build.split("Old nightly/build-* branches deleted", 1)[1].split("- uses:", 1)[0] + self.assertIn("if: github.event_name == 'schedule'", step) + self.assertIn("prune-builds --days 7", step) + self.assertIn("GITHUB_STEP_SUMMARY", step) + diff --git a/tools/ci/nightly.py b/tools/ci/nightly.py index 9a6dd8a3b..e04b99ddd 100644 --- a/tools/ci/nightly.py +++ b/tools/ci/nightly.py @@ -4,6 +4,8 @@ nightly.py pick [--force] which commit tonight's nightly builds: GitHub outputs and nightly-notes.md nightly.py rotate renames the current `nightly` pre-release to nightly-, frees the tag nightly.py prune [--days 7] deletes the nightly- pre-releases and tags older than that, nothing else + nightly.py prune-builds [--days 7] deletes the nightly/build-- branches older than that, but the + newest and any a published nightly points to (Ludovic, 2026-10-27) A nightly builds main plus the pull requests the trusted reviewers picked (the set: tools/ci/nightly_set.py), merged in their order and pushed as nightly/build--. With no set it builds the newest union branch on origin @@ -264,17 +266,63 @@ def prune(days: int) -> None: print(f"deleted {tag}") +BUILD = re.compile(r"^nightly/build-(\d{4}-\d{2}-\d{2})-(\d+)$") + + +def stale_builds(branches: dict[str, str], published: set[str], today: dt.date, days: int) -> list[str]: + """The nightly/build-- branches (name -> commit) to delete: older than `days`, never the newest build, + never one whose commit a published nightly release points to, never a branch not named like a build.""" + builds = {name: sha for name, sha in branches.items() if BUILD.match(name)} + if not builds: + return [] + order = lambda name: (BUILD.match(name).group(1), int(BUILD.match(name).group(2))) # noqa: E731 + newest = max(builds, key=order) + cutoff = today - dt.timedelta(days=days) + return sorted(name for name, sha in builds.items() if name != newest and sha not in published + and dt.date.fromisoformat(BUILD.match(name).group(1)) < cutoff) + + +def remote_refs(pattern: str) -> dict[str, str]: + """origin's refs matching `pattern`: short name -> commit (annotated tags peeled).""" + refs: dict[str, str] = {} + for line in git("ls-remote", "origin", pattern).splitlines(): + sha, ref = line.split("\t", 1) + name = ref.removeprefix("refs/heads/").removeprefix("refs/tags/") + if name.endswith("^{}"): + refs[name[:-3]] = sha # the peeled commit wins over the tag object + else: + refs.setdefault(name, sha) + return refs + + +def prune_builds(days: int) -> None: + """Deletes old nightly/build-* branches on origin through the API, one line per deletion (the run summary).""" + branches = remote_refs("refs/heads/nightly/build-*") + tags = {name: sha for name, sha in remote_refs("refs/tags/nightly*").items() + if name == "nightly" or DATED.match(name)} + today = dt.datetime.now(dt.timezone.utc).date() + doomed = stale_builds(branches, set(tags.values()), today, days) + for name in doomed: + gh("api", "-X", "DELETE", f"repos/{os.environ['GITHUB_REPOSITORY']}/git/refs/heads/{name}") + print(f"- deleted `{name}` ({branches[name][:7]})") + print(f"{len(doomed)} of {len(branches)} nightly/build-* branches deleted (kept: the newest, the published " + f"nightlies' and the last {days} days')") + + def main() -> None: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sub = ap.add_subparsers(dest="cmd", required=True) sub.add_parser("pick").add_argument("--force", action="store_true") sub.add_parser("rotate") sub.add_parser("prune").add_argument("--days", type=int, default=7) + sub.add_parser("prune-builds").add_argument("--days", type=int, default=7) args = ap.parse_args() if args.cmd == "pick": pick(args.force) elif args.cmd == "rotate": rotate() + elif args.cmd == "prune-builds": + prune_builds(args.days) else: prune(args.days)