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/AGENTS.md b/AGENTS.md index 3804ebae8..e5561f7b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,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 | @@ -120,7 +121,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 e65b25dab..026eef014 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1385,6 +1385,7 @@ dependencies = [ "horizon-dualsense", "horizon-friends", "horizon-hdr", + "horizon-items", "horizon-mods", "horizon-protocol", "horizon-server-files", @@ -1469,6 +1470,17 @@ dependencies = [ "wgpu", ] +[[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 8f3153c62..908badb65 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crates/horizon-client", "crates/horizon-client-core", "crates/horizon-command", + "crates/horizon-items", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", diff --git a/crates/horizon-client-core/src/component.rs b/crates/horizon-client-core/src/component.rs index 7437e4ea8..248c78617 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 @@ -299,6 +300,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 7721668ca..d839feddf 100644 --- a/crates/horizon-client-core/src/lib.rs +++ b/crates/horizon-client-core/src/lib.rs @@ -14,6 +14,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 pattern; pub mod potions; 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 150433918..12b1ccfb5 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" } @@ -105,5 +106,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/examples/probe/mob_ai_probe.rs b/crates/horizon-client/examples/probe/mob_ai_probe.rs index 0e0813f4a..3aae26211 100644 --- a/crates/horizon-client/examples/probe/mob_ai_probe.rs +++ b/crates/horizon-client/examples/probe/mob_ai_probe.rs @@ -1077,6 +1077,8 @@ pub fn main() -> horizon_client::Result<()> { uuid: PLAYER_UUID, allies: Default::default(), remote: false, + sleeping: false, + sleep_timer: 0, passenger: false, } }); 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 a2d1bef82..0980da371 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}; @@ -874,14 +873,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; /// Mob.finalizeSpawn's follow range bonus in the saved attributes. pub(crate) fn spawn_bonus(tag: &Tag) -> Option { let Some(Tag::List(attrs)) = tag.get("attributes") else { diff --git a/crates/horizon-client/src/first_person.rs b/crates/horizon-client/src/first_person.rs index 12c97dfa0..b2f1ab331 100644 --- a/crates/horizon-client/src/first_person.rs +++ b/crates/horizon-client/src/first_person.rs @@ -443,18 +443,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/game.rs b/crates/horizon-client/src/game.rs index 6b4cd49c2..f234d4d72 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -107,6 +107,7 @@ mod maps; mod minecarts; mod mob_blocks; mod mob_gear; +mod mob_sounds; mod mod_browser; #[cfg(feature = "mods")] mod mod_browser_recipes; @@ -1525,6 +1526,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, @@ -7833,7 +7838,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], @@ -13653,6 +13660,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/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/creatures.rs b/crates/horizon-client/src/game/creatures.rs index a9bf19c07..e483afc94 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)>, /// Mace smash knockback on the other players from a guest's turn, waiting /// for it to end (game/mace.rs). pub smash_pushes: Vec, @@ -226,6 +230,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, passenger: self.riding.is_some(), }) } @@ -1356,6 +1362,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 { + // The ItemEntity constructor's random motion. + let r = &mut self.world.random; + let v = [ + r.next_double() * 0.2 - 0.1, + 0.2, + r.next_double() * 0.2 - 0.1, + ]; + self.spawn_item(stack, at, v, 10); + } + } // `Level.setBlockAndUpdate`: neighbour and shape updates, so a crop // above trampled farmland breaks and drops. MobEvent::SetBlock { pos, state } => self.level_set(pos, state)?, @@ -3356,6 +3378,7 @@ impl Game<'_> { .item_tags .get("minecraft:parrot_poisonous_food") .is_some_and(|t| t.contains(&*item)); + let player_at = self.eye_position(); let acting = self.acting_player_uuid(); let sneaking = self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft) || self.keys.contains(&winit::keyboard::KeyCode::ShiftRight); @@ -3688,6 +3711,29 @@ 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); + 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). Species::Parrot if !m.extra.tame && is_food => { diff --git a/crates/horizon-client/src/game/enchant_effects.rs b/crates/horizon-client/src/game/enchant_effects.rs index 6986c5627..806f193e5 100644 --- a/crates/horizon-client/src/game/enchant_effects.rs +++ b/crates/horizon-client/src/game/enchant_effects.rs @@ -215,6 +215,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")); + } + } + _ => {} + } + } /// `ApplyMobEffect.apply`: one of `to_apply` (a single id or a list) for a /// random duration and amplifier between the level-based bounds. fn apply_mob_effect(&mut self, mob: usize, effect: &serde_json::Value, level: u32) { diff --git a/crates/horizon-client/src/game/fishing.rs b/crates/horizon-client/src/game/fishing.rs index fa2627693..742d05c27 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::advancements::EntityView; use horizon_client::lan::{entity::States, hub::Hub}; use horizon_client::{ @@ -47,6 +47,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; @@ -168,6 +175,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))); } // CriteriaTriggers.FISHING_ROD_HOOKED with no items. let hooked = self @@ -476,6 +488,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; @@ -501,8 +522,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| { @@ -514,6 +535,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() @@ -528,12 +557,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() @@ -602,6 +644,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/hand.rs b/crates/horizon-client/src/game/hand.rs index e7bb1982f..40ca9d559 100644 --- a/crates/horizon-client/src/game/hand.rs +++ b/crates/horizon-client/src/game/hand.rs @@ -403,6 +403,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() @@ -470,18 +476,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/item_draw.rs b/crates/horizon-client/src/game/item_draw.rs index 99d67493b..be0ca56fb 100644 --- a/crates/horizon-client/src/game/item_draw.rs +++ b/crates/horizon-client/src/game/item_draw.rs @@ -165,13 +165,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:kinetic_weapon").is_some() { + if components.get("minecraft:blocks_attacks").is_some() + || components.get("minecraft:kinetic_weapon").is_some() + { return 72_000; } let item_model = components diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index ac5f8a13a..83fdaf4be 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::{BodyFlags, HostEvent, HostState, Hub, HubConfig, TickCtx}, playerdata, }, - mobs::{Mob, Who, leash::Holder}, + mobs::{Mob, Species, Who, leash::Holder}, text::Text, }; use horizon_core::geometry::Vec3; @@ -168,6 +168,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" @@ -324,9 +325,13 @@ fn mob_meta( defaults: &mut Vec<(u8, i32, Value)>, ) { let kind = m.kind.id.as_str(); - // Entity.DATA_SHARED_FLAGS_ID: on fire 0x01, glowing 0x40 - // (LivingEntity.updateGlowingStatus). - let shared = i8::from(m.fire_ticks > 0) | i8::from(m.is_currently_glowing()) << 6; + // Entity.DATA_SHARED_FLAGS_ID: on fire 0x01, swimming 0x10 (a drowned + // under water, `Drowned.setSwimming`), invisible 0x20 (the effect's flag) + // and glowing 0x40 (LivingEntity.updateGlowingStatus). + let shared = 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 + | i8::from(m.is_currently_glowing()) << 6; if shared != 0 { meta.push((0, serializer::BYTE, Value::Byte(shared))); } @@ -336,6 +341,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, @@ -430,8 +441,14 @@ fn mob_meta( } defaults.push((17, serializer::INT, Value::Int(0))); } - // Piglin.DATA_IS_DANCING (after the baby flag and the crossbow charge). + // AbstractPiglin.DATA_IMMUNE_TO_ZOMBIFICATION (16: with the dimension it + // makes it shake) and Piglin.DATA_IS_DANCING (after the baby flag and + // the crossbow charge). "minecraft:piglin" => { + if e.flag2 { + meta.push((16, serializer::BOOLEAN, Value::Bool(true))); + } + defaults.push((16, serializer::BOOLEAN, Value::Bool(false))); if e.scratch[0] > 0 { meta.push((19, serializer::BOOLEAN, Value::Bool(true))); } @@ -485,6 +502,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); @@ -509,10 +535,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 { @@ -545,6 +577,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)) = ( @@ -752,6 +793,60 @@ fn mob_meta( meta.push((17, serializer::BOOLEAN, Value::Bool(true))); } defaults.push((17, serializer::BOOLEAN, Value::Bool(false))); + 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 (the piglin's is + // in its own arm): with the dimension it makes them shake. + "minecraft:hoglin" | "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))); + } } _ => {} } @@ -769,6 +864,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))); +} + /// What a client shakes a mob by: Entity.DATA_TICKS_FROZEN (7), whose full /// count is `isFullyFrozen`, and Strider.DATA_SUFFOCATING (19, after /// AgeableMob's two and DATA_BOOST_TIME). @@ -1603,6 +1711,8 @@ impl Game<'_> { let _ = self.flush_wind_bursts(); // A sweep attack's reach to the other players from a guest's attack. self.flush_sweeps(); + // A reeled-in hook pulling a player. + self.flush_hook_pulls(); // Particles a guest's turn sent (sweep attack, damage indicator, a hook's splashes). self.flush_lan_particles(); } @@ -1743,6 +1853,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 @@ -2019,6 +2134,8 @@ impl Game<'_> { .get(g.selected) .and_then(|s| s.as_ref()) .is_some_and(|s| s.data().is_some_and(|d| !d.charged.is_empty())); + // 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() @@ -2043,6 +2160,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/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index c18abba36..69d158123 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -1133,6 +1133,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) => { @@ -1676,6 +1678,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; @@ -2081,6 +2114,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; @@ -3160,7 +3197,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/lan_mobs.rs b/crates/horizon-client/src/game/lan_mobs.rs index 8dd77dde7..48c6e2145 100644 --- a/crates/horizon-client/src/game/lan_mobs.rs +++ b/crates/horizon-client/src/game/lan_mobs.rs @@ -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; // A tame wolf or cat sees its owner (`TamableAnimal.getOwner`), while they @@ -399,6 +413,8 @@ mod tests { uuid: [0; 4], allies: Default::default(), remote: false, + sleeping: false, + sleep_timer: 0, passenger: false, } } 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 3191097e5..988fb438c 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -2097,6 +2097,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 @@ -2321,6 +2328,10 @@ impl Game<'_> { if p.event == 69 { self.remote_ravager_roar(p.id); } + // 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 @@ -2421,7 +2432,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 @@ -3928,6 +3939,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; }; @@ -4014,6 +4053,11 @@ impl Game<'_> { if m.species() == horizon_client::mobs::Species::Fox { horizon_client::mobs::fox::client_tick(m); } + // 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); @@ -4021,6 +4065,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 { @@ -4067,8 +4125,26 @@ impl Game<'_> { } // The animation states a server computes in its own `ai_step`. horizon_client::mobs::sync::client_tick(m, game_time); - if m.species() == horizon_client::mobs::Species::Panda { - horizon_client::mobs::panda::client_tick(m, thundering); + 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); + } + horizon_client::mobs::Species::Panda => { + horizon_client::mobs::panda::client_tick(m, thundering); + } + _ => {} } } for o in self.orbs.iter_mut().filter(|_| running) { @@ -4541,6 +4617,11 @@ impl Game<'_> { if reacts { self.predict_use(&changes, seq); self.open_command_block_if_editable((hit.pos[0], hit.pos[1], hit.pos[2])); + } 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 held.as_ref().is_some_and(|s| &*s.item == "minecraft:brush") { // BrushItem.useOn: brushing starts here (CONSUME, no swing); the // server brushes and the dust is this client's own. @@ -4727,7 +4808,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); @@ -5115,6 +5196,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). @@ -5165,7 +5268,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/remote_mob_data.rs b/crates/horizon-client/src/game/remote_mob_data.rs index a4080ac16..1e3f8887c 100644 --- a/crates/horizon-client/src/game/remote_mob_data.rs +++ b/crates/horizon-client/src/game/remote_mob_data.rs @@ -5,11 +5,13 @@ use super::Game; use horizon_client::mobs::render::{PROFESSIONS, VILLAGER_TYPES}; use horizon_client::mobs::{ - Mob, Species, Who, armadillo, bat, bee, camel, creaking, finalize, panda, sniffer, sync, tame, + Mob, Species, Who, armadillo, bat, bee, camel, creaking, equine, finalize, illusioner, panda, + sniffer, sync, tame, zombie_villager, }; use horizon_client::particles::{ParticleOptions, ParticleType}; use horizon_protocol::entity_data::{Particle, ParticleData, Value}; use horizon_protocol::registry::{Builtin, Dynamic}; +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). @@ -31,7 +33,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 @@ -112,6 +128,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; @@ -215,6 +234,42 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti (Species::CopperGolem, 16, Value::Id(state)) => { m.extra.variant = (*state).clamp(0, 3) as u8; } + // 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, @@ -357,9 +412,20 @@ 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); apply_registry_data(m, values, &r.registries, self.world.game_time); + if let Some(code) = bed + && m.species() == Species::Villager + { + m.extra.ints[1] = code; + } } } } 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/lib.rs b/crates/horizon-client/src/lib.rs index 5ff582455..9f3af3541 100644 --- a/crates/horizon-client/src/lib.rs +++ b/crates/horizon-client/src/lib.rs @@ -22,12 +22,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; @@ -43,7 +41,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; @@ -58,7 +55,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; @@ -94,11 +90,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; @@ -148,7 +142,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; @@ -210,3 +203,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/mobs.rs b/crates/horizon-client/src/mobs.rs index 29e9313f1..72c2a3d89 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -706,6 +706,8 @@ pub struct Extra { pub body_damage: i32, /// The components (dye, name...) of that armor stack. pub body_extra: Option>, + /// `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, @@ -963,7 +965,8 @@ pub enum MobEvent { mob: u32, at: [f64; 3], }, - /// `Level.broadcastEntityEvent` for a mob: guests run `sync::entity_event`. + /// `Level.broadcastEntityEvent` for a mob: guests run its + /// `handleEntityEvent`. EntityEvent { mob: u32, event: i8, @@ -1155,6 +1158,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, @@ -1483,6 +1491,8 @@ pub struct Seen { pub sitting: bool, /// `Entity.isSilent`. pub silent: bool, + /// `Cat.isLying` or `isRelaxStateOne`. + pub lying: bool, /// `Entity.isVehicle` or `isPassenger`. pub ridden: bool, /// `Attributes.KNOCKBACK_RESISTANCE`. @@ -1578,6 +1588,7 @@ impl Mob { ], sitting: m.extra.sitting, silent: m.silent, + 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(), knockback_resistance: m.attrs.knockback_resistance, effects: m @@ -1748,6 +1759,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, /// `isPassenger`. pub passenger: bool, } diff --git a/crates/horizon-client/src/mobs/ai.rs b/crates/horizon-client/src/mobs/ai.rs index 7d596f5ca..eaadf14a4 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)] @@ -540,18 +585,21 @@ fn effects_from_blocks(m: &mut Mob, w: &MobWorld) { } } } -/// 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; } /// `Entity.isInRain`: rain at the block position, or at the top of the box. pub(super) fn in_rain(m: &Mob, w: &MobWorld) -> bool { @@ -587,6 +635,9 @@ fn axolotl_air_supply(m: &mut Mob, w: &MobWorld) { 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 2eccd31f0..872944ac6 100644 --- a/crates/horizon-client/src/mobs/anim.rs +++ b/crates/horizon-client/src/mobs/anim.rs @@ -540,6 +540,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. @@ -697,6 +717,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 1155388fd..8b0463849 100644 --- a/crates/horizon-client/src/mobs/equine.rs +++ b/crates/horizon-client/src/mobs/equine.rs @@ -471,6 +471,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 { @@ -488,6 +493,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/guardian.rs b/crates/horizon-client/src/mobs/guardian.rs index 4bdd1831b..f86fd0bc4 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. @@ -476,3 +476,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/mobs/illusioner.rs b/crates/horizon-client/src/mobs/illusioner.rs index b98e56920..3ef6b0592 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/living.rs b/crates/horizon-client/src/mobs/living.rs index 2de7297ef..7e4058ec0 100644 --- a/crates/horizon-client/src/mobs/living.rs +++ b/crates/horizon-client/src/mobs/living.rs @@ -1118,17 +1118,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 { diff --git a/crates/horizon-client/src/mobs/piglin.rs b/crates/horizon-client/src/mobs/piglin.rs index ed219d312..aaaaf4dda 100644 --- a/crates/horizon-client/src/mobs/piglin.rs +++ b/crates/horizon-client/src/mobs/piglin.rs @@ -465,6 +465,13 @@ pub fn celebrate(m: &mut Mob, w: &MobWorld) { t.position.z.floor() as i32, ]); } +/// `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 d02c3a172..aa47b2a96 100644 --- a/crates/horizon-client/src/mobs/render.rs +++ b/crates/horizon-client/src/mobs/render.rs @@ -923,6 +923,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 16cd2b30c..fd45ebbb8 100644 --- a/crates/horizon-client/src/mobs/sync.rs +++ b/crates/horizon-client/src/mobs/sync.rs @@ -16,12 +16,13 @@ //! - 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, allay, armadillo, axolotl, breeze, camel, copper_golem, creaking, frog, goat, - golem, guardian, piglin, ravager, shulker, sniffer, warden, + Mob, Species, allay, armadillo, axolotl, breeze, camel, copper_golem, creaking, equine, frog, + goat, golem, guardian, piglin, ravager, 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 CROAKING: u8 = 8; pub const USING_TONGUE: u8 = 9; @@ -42,6 +43,8 @@ pub fn pose_id(m: &Mob) -> u8 { Species::Breeze => breeze::pose_id(m), Species::Camel | Species::CamelHusk if camel::sitting(m) => SITTING, Species::Frog => m.extra.pose, + // LivingEntity.startSleeping: the pose, with the bed in index 14. + Species::Villager if villager::sleeping(m) => SLEEPING, _ => STANDING, } } @@ -87,6 +90,8 @@ pub fn client_tick(m: &mut Mob, game_time: i64) { Species::Armadillo => armadillo::tick(m), Species::Frog => frog::tick(m), Species::Goat => goat::tick(m), + s if equine::grazes(s) => equine::client_tick(m), + Species::Drowned => undead::drowned_client_tick(m), _ => {} } } diff --git a/crates/horizon-client/src/mobs/tame.rs b/crates/horizon-client/src/mobs/tame.rs index faee3c4de..53d15dba1 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; @@ -768,6 +768,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, @@ -848,6 +849,202 @@ 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_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 +/// 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: 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 && is_owner_of(m, p)) + } +} +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| is_owner_of(m, 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); @@ -997,7 +1194,17 @@ 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; if lying { v[LIE] = (v[LIE] + 0.15).min(1.0); @@ -1006,11 +1213,11 @@ 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) { - 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)); - } + v[RELAX] = if relaxing { + (v[RELAX] + 0.1).min(1.0) + } else { + (v[RELAX] - 0.13).max(0.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 { diff --git a/crates/horizon-client/src/mobs/undead.rs b/crates/horizon-client/src/mobs/undead.rs index 0f446c87a..01e104001 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 0a429b135..d7fc5f093 100644 --- a/crates/horizon-client/src/mobs/wither.rs +++ b/crates/horizon-client/src/mobs/wither.rs @@ -170,9 +170,7 @@ fn target_pos(w: &MobWorld, id: i32) -> Option<([f64; 3], f64, bool)> { } } /// `WitherBoss.aiStep` on a client of a server: the smoke about each head -/// (`radius` 0.3 of the wither's scale, which is 1 here). The heads' turning -/// and the coloured particles need the server's targets and invulnerable -/// ticks, which a client is not sent. +/// (`radius` 0.3 of the wither's scale, which is 1 here). pub fn client_smoke(m: &mut Mob, events: &mut Vec) { for i in 0..3 { let h = head_pos(m, i); @@ -189,13 +187,12 @@ pub fn client_smoke(m: &mut Mob, events: &mut Vec) { }); } } -/// `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(); @@ -207,6 +204,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); @@ -284,11 +293,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() } 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 a9bed143a..7d8fcd5e6 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)] @@ -3838,6 +3839,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::*; @@ -4025,92 +4058,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 d304da145..3734da606 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)), @@ -412,62 +393,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-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); 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`. 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 97% rename from crates/horizon-client/src/bundle.rs rename to crates/horizon-items/src/bundle.rs index 545283b4c..f32d23a5d 100644 --- a/crates/horizon-client/src/bundle.rs +++ b/crates/horizon-items/src/bundle.rs @@ -375,14 +375,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/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 73b9bf090..169b0eade 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) { @@ -802,8 +802,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; @@ -1136,7 +1136,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() } @@ -1147,7 +1147,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 f5ff99939..cfcbae869 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`. @@ -109,7 +109,7 @@ impl StackData { /// `SetNameFunction.run`: a loot text component stored as `custom_name` /// (plain text, as modelled) or as the unmodelled `item_name`. pub fn set_loot_name(&mut self, id: &str, text: &serde_json::Value) { - let tag = crate::scoreboard::json_to_tag(text); + let tag = horizon_client_core::scoreboard::json_to_tag(text); if id == "minecraft:custom_name" { self.custom_name = read_text(&tag); } else { @@ -252,7 +252,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()) @@ -364,13 +364,15 @@ 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(crate::entity_nbt::name_text(t).plain()), + Tag::Compound(_) | Tag::List(_) => { + 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() { @@ -387,14 +389,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() @@ -442,7 +444,7 @@ fn template_from(tag: &Tag) -> Option { /// The stack a `show_item` hover event holds (`HoverEvent.ShowItem`): its /// `{id, count?, components?}` object. pub fn stack_from_hover(event: &serde_json::Value) -> Option { - template_from(&crate::scoreboard::json_to_tag(event)) + template_from(&horizon_client_core::scoreboard::json_to_tag(event)) } /// Sorted-key, type-annotated text of a tag (the differential harnesses /// print the same form from the vanilla side). @@ -508,7 +510,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 "-". @@ -517,7 +519,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 { @@ -566,7 +568,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 { @@ -669,7 +671,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 { @@ -752,7 +754,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") { @@ -1010,8 +1015,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)); @@ -1082,7 +1087,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 94% rename from crates/horizon-client/src/items.rs rename to crates/horizon-items/src/items.rs index bd818bd76..bfb261461 100644 --- a/crates/horizon-client/src/items.rs +++ b/crates/horizon-items/src/items.rs @@ -310,7 +310,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())) @@ -350,7 +350,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)?; @@ -779,7 +779,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(), @@ -1754,6 +1754,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::*; @@ -1866,14 +1882,9 @@ mod tests { /// `EnchantRandomlyFunction`) put their enchantments on the stack. #[test] fn loot_enchant_functions_enchant_the_rolled_item() { - let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../.local/horizon-client/26.3"); - if !root.join("items.json").exists() { + let Some(items) = super::prepared_items() else { 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 props = BTreeMap::new(); let mut random = LegacyRandom::new(11); // A sword at levels 20..39 comes out enchanted, as in an End city. @@ -1932,14 +1943,9 @@ mod tests { /// Mending book, then `set_count` uniform 1..64, is 46. #[test] fn a_single_level_enchantment_draws_no_level() { - let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../.local/horizon-client/26.3"); - if !root.join("items.json").exists() { + let Some(items) = super::prepared_items() else { 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 props = BTreeMap::new(); let table = json!({"pools":[{"rolls":1,"entries":[{"type":"minecraft:item", "name":"minecraft:book","modifier":[ @@ -1956,79 +1962,6 @@ mod tests { // 46 is vanilla's count for this seed; an extra level draw gives 28. assert_eq!(drops[0].count, 46); } - - #[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 3aec2338b..9659800b3 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/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) 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" 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_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__': 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', ],