Split horizon-items out of horizon-client

Second slice of the crate split, a pure move like the first: blocks (+ blocks/), items, item_data, enchant,
cooldowns, bundle and random_sequences move to horizon-items. They are the first layer mobs/ needs, after
horizon-client-core.

Items moved out of lib modules that sit in the big cycle, unchanged:
- firework shapes, explosions and dye colours (particles.rs) -> firework.rs;
- BannerLayer and its NBT form (banner.rs) -> banner_layer.rs;
- the ItemStack NBT codec (save.rs) -> item_nbt.rs;
- consumable_animation (first_person.rs) -> use_animation.rs;
- the content namespaces (mod_content.rs) -> content_namespaces.rs;
- save.rs's NBT value helpers -> horizon-client-core's nbt_value.rs, and name_text (entity_nbt.rs) -> core's
  component.rs.

horizon-client re-exports every module and item under its old path. Test helpers other client tests use
(Items::for_tests, enchant::tests) are behind horizon-items' `test-support` feature, enabled by
horizon-client's dev-dependency. The items test that loads the client's assets moved to the client:
always_edible_and_consume_effects_loaded, to assets.rs. particles.rs' test module moved to the end of the file
(clippy's items_after_test_module had been quiet only thanks to the derives that moved out).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ludovic Marie
2026-10-26 14:46:38 +01:00
parent 0a116d9523
commit 27d750f1c9
37 changed files with 484 additions and 385 deletions
+3 -2
View File
@@ -34,7 +34,8 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| Crate | Owns |
|---|---|
| `horizon-client` | The game: a lib (`src/lib.rs`, ~180 flat modules) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
| `horizon-client` | The game: a lib (`src/lib.rs`, ~170 flat modules) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
| `horizon-items` | Blocks and items below gameplay: `blocks.rs` (block facts), `items.rs`, `item_data.rs` (components), `enchant.rs`, `cooldowns.rs`, `bundle.rs`, `item_nbt.rs`. Re-exported as `horizon_client::items`... |
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) |
| `horizon-world` | Vanilla world data with no gameplay loop: NBT, regions, chunks, `datafix/`, `worldgen/`, `level/` (server-side block behaviour, ticks, redstone), entity physics, items, effects, food, damage |
@@ -117,7 +118,7 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
| Block behaviour | `crates/horizon-world/src/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `redstone.rs`, `place.rs`, `interact.rs`); game.rs `fn tick_blocks`, `fn process_level_events` |
| Mobs | L: `mobs.rs` (types), `mobs/` (`ai.rs`, `goals.rs`, `brain.rs`, `*_brain.rs`, `nav.rs`, `path.rs`, `species.rs`, `spawning.rs`, `render.rs`, `anim.rs`); G: `creatures.rs` (ticking, spawning, mob events), one file per mob family (`dragon.rs`, `wither.rs`, `villagers.rs`, `piglins.rs`, `golems.rs`...), `natural_spawning.rs` |
| Other entities | L: `entities.rs`, `projectiles.rs`, `arrows.rs`; G: `boats.rs`, `minecarts.rs`, `mounts.rs`, `armor_stands.rs`, `paintings.rs`, `frames.rs`, `fireworks.rs`, `tnt.rs`, `explosion.rs` |
| Player actions | game.rs (`fn use_hand`, `fn use_item`, `fn attack`, `fn tick_mining`, `fn break_block`); G: `hand.rs`, `combat.rs`, `consume.rs`, `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`, `items.rs`, `item_data.rs` |
| Player actions | game.rs (`fn use_hand`, `fn use_item`, `fn attack`, `fn tick_mining`, `fn break_block`); G: `hand.rs`, `combat.rs`, `consume.rs`, `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`; crate `horizon-items`: `items.rs`, `item_data.rs`, `enchant.rs`, `blocks.rs` |
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs` |
| Commands | parse: crate `horizon-command` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`; `horizon-client-core`: `nbt_path.rs`, `scoreboard.rs`; G: `functions.rs`, `command_blocks.rs` |
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs` |
Generated
+12
View File
@@ -1370,6 +1370,7 @@ dependencies = [
"horizon-core",
"horizon-dualsense",
"horizon-friends",
"horizon-items",
"horizon-mods",
"horizon-protocol",
"horizon-server-files",
@@ -1445,6 +1446,17 @@ dependencies = [
"spake2",
]
[[package]]
name = "horizon-items"
version = "0.1.0"
dependencies = [
"horizon-client-core",
"horizon-core",
"horizon-world",
"serde",
"serde_json",
]
[[package]]
name = "horizon-mod-sdk"
version = "0.1.0"
+1 -1
View File
@@ -1,6 +1,6 @@
[workspace]
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"]
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-client-core", "crates/horizon-command", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/predator-missile", "crates/horizon-mod-sdk/examples/welcome", "mods/official/fps-overlay", "mods/official/keystrokes", "mods/official/minimap", "mods/official/zoom", "xtask"]
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-client-core", "crates/horizon-command", "crates/horizon-items", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/predator-missile", "crates/horizon-mod-sdk/examples/welcome", "mods/official/fps-overlay", "mods/official/keystrokes", "mods/official/minimap", "mods/official/zoom", "xtask"]
resolver = "2"
[workspace.package]
@@ -1,6 +1,7 @@
//! Text components from commands: `ComponentArgument` reads an SNBT (or JSON)
//! value, `ComponentSerialization` turns it into styled text.
use crate::text::{Style, Text, named_color};
use horizon_world::nbt::Tag;
use serde_json::{Map, Value};
/// `SnbtGrammar`, enough for components: compounds, lists, quoted and
@@ -271,6 +272,15 @@ pub fn to_text_with(
}
}
/// `CustomName` as text: a string keeps its legacy formatting codes, anything
/// else is a component (`ComponentSerialization`).
pub fn name_text(tag: &Tag) -> Text {
match tag {
Tag::String(s) => Text::legacy(&s.to_string_lossy()),
t => crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t)),
}
}
#[cfg(test)]
mod tests {
use super::*;
+1
View File
@@ -13,6 +13,7 @@ pub mod identifier;
pub mod key_display;
pub mod lang;
pub mod nbt_path;
pub mod nbt_value;
pub mod particle_type;
pub mod registries;
pub mod scoreboard;
@@ -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<String> {
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<i64> {
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,
})
}
+2
View File
@@ -11,6 +11,7 @@ description = "Horizon native development client"
horizon-assets = { path = "../horizon-assets" }
horizon-client-core = { path = "../horizon-client-core" }
horizon-command = { path = "../horizon-command" }
horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-threads = { path = "../horizon-threads" }
@@ -103,5 +104,6 @@ path = "examples/probe/main.rs"
workspace = true
[dev-dependencies]
horizon-items = { path = "../horizon-items", features = ["test-support"] }
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
@@ -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};
+78
View File
@@ -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
));
}
}
+1 -53
View File
@@ -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<str>,
pub color: Arc<str>,
}
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<BannerLayer> {
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::*;
+1 -9
View File
@@ -13,7 +13,6 @@ use crate::{
doubles, float, floats, item_from_nbt, item_to_nbt, number, put, read_doubles, text,
uuid_from_tag,
},
text::Text,
};
use horizon_core::geometry::Vec3;
use horizon_world::nbt::{Compound, Tag};
@@ -600,14 +599,7 @@ fn custom_name(tag: &Tag) -> Option<String> {
_ => None,
}
}
/// `CustomName` as text: a string keeps its legacy formatting codes, anything
/// else is a component (`ComponentSerialization`).
pub fn name_text(tag: &Tag) -> Text {
match tag {
Tag::String(s) => Text::legacy(&s.to_string_lossy()),
t => crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t)),
}
}
pub use horizon_client_core::component::name_text;
pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -> Option<Mob> {
let kind = types.get(&self::kind(tag)?)?.clone();
let position = position(tag)?;
+1 -12
View File
@@ -426,18 +426,7 @@ impl UseResult {
matches!(self, Self::Success { swing: true }) && before == after
}
}
pub fn consumable_animation(v: &serde_json::Value) -> horizon_world::consumable::UseAnimation {
use horizon_world::consumable::UseAnimation;
match v
.get("animation")
.and_then(serde_json::Value::as_str)
.unwrap_or("eat")
{
"eat" => UseAnimation::Eat,
"drink" => UseAnimation::Drink,
_ => UseAnimation::None,
}
}
pub use horizon_items::use_animation::consumable_animation;
/// What the click handling reads and does in the world.
pub trait HandWorld {
fn hit(&mut self) -> Hit;
+7 -7
View File
@@ -21,12 +21,10 @@ pub mod block_entity_anim;
pub mod block_entity_item;
pub mod block_entity_render;
pub mod block_transform;
pub mod blocks;
pub mod border_render;
pub mod boss_overlay;
pub mod breaking;
pub mod brewing;
pub mod bundle;
pub mod camera;
pub mod cauldron;
pub mod chat;
@@ -42,7 +40,6 @@ pub mod confirm_screen;
pub mod container_screen;
pub mod containers;
pub mod control;
pub mod cooldowns;
pub mod crafting;
pub mod crash;
pub mod creative;
@@ -56,7 +53,6 @@ pub mod dialog_screen;
pub mod discord_presence;
pub mod dynamic_light;
pub mod effects;
pub mod enchant;
pub mod end_crystal;
pub mod entities;
pub mod entity_culling;
@@ -91,11 +87,9 @@ pub mod input;
pub mod interaction_input;
pub mod inv_sort;
pub mod inventory_effects;
pub mod item_data;
pub mod item_render;
pub mod item_sync;
pub mod item_tooltip;
pub mod items;
pub mod java_text;
pub mod key_names;
pub mod keybinds;
@@ -145,7 +139,6 @@ pub mod pose;
pub mod pregen;
pub mod projectiles;
pub mod raid;
pub mod random_sequences;
pub mod random_tick_sections;
pub mod recipe_book;
pub mod recipe_book_screen;
@@ -206,3 +199,10 @@ pub use horizon_client_core::scoreboard;
pub use horizon_client_core::snbt;
pub use horizon_client_core::text;
pub use horizon_command as command;
pub use horizon_items::blocks;
pub use horizon_items::bundle;
pub use horizon_items::cooldowns;
pub use horizon_items::enchant;
pub use horizon_items::item_data;
pub use horizon_items::items;
pub use horizon_items::random_sequences;
+2 -25
View File
@@ -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<String> {
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<String> {
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.
+33 -89
View File
@@ -24,6 +24,7 @@ use std::{
};
pub use horizon_client_core::particle_type::ParticleType;
pub use horizon_items::firework::{DYE_FIREWORK_COLORS, FireworkExplosion, FireworkShape};
/// `ParticleOptions` with the parameters of the option classes used here.
#[derive(Clone, Debug)]
@@ -3756,6 +3757,38 @@ fn lifetime_alpha(a: [f32; 4], age: i32, lifetime: i32, partial: f32) -> f32 {
a[0] + (a[1] - a[0]) * t.clamp(0.0, 1.0)
}
struct Starter {
at: [f64; 3],
v: [f64; 3],
explosions: Vec<FireworkExplosion>,
life: i32,
lifetime: i32,
twinkle_delay: bool,
far: bool,
}
const STAR: [[f64; 2]; 6] = [
[0.0, 1.0],
[0.3455, 0.309],
[0.9511, 0.309],
[0.379_591_836_734_693_9, -0.126_530_612_244_897_95],
[0.612_244_897_959_183_7, -0.804_081_632_653_061_2],
[0.0, -0.359_183_673_469_387_73],
];
const CREEPER: [[f64; 2]; 12] = [
[0.0, 0.2],
[0.2, 0.2],
[0.2, 0.6],
[0.6, 0.6],
[0.6, 0.2],
[0.2, 0.2],
[0.2, 0.0],
[0.4, 0.0],
[0.4, -0.6],
[0.2, -0.6],
[0.2, -0.4],
[0.0, -0.4],
];
#[cfg(test)]
mod tests {
use super::*;
@@ -3942,92 +3975,3 @@ mod tests {
assert!((lifetime_alpha(a, 40, 40, 0.0) - 0.6).abs() < 1e-6);
}
}
/// FireworkExplosion.Shape.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum FireworkShape {
#[default]
SmallBall,
LargeBall,
Star,
Creeper,
Burst,
}
impl FireworkShape {
pub fn from_name(name: &str) -> Self {
match name {
"large_ball" => Self::LargeBall,
"star" => Self::Star,
"creeper" => Self::Creeper,
"burst" => Self::Burst,
_ => Self::SmallBall,
}
}
pub fn name(self) -> &'static str {
match self {
Self::SmallBall => "small_ball",
Self::LargeBall => "large_ball",
Self::Star => "star",
Self::Creeper => "creeper",
Self::Burst => "burst",
}
}
}
/// `DyeColor` names and `getFireworkColor`, by dye id (white to black).
pub const DYE_FIREWORK_COLORS: [(&str, u32); 16] = [
("white", 0xF0F0F0),
("orange", 0xEB8844),
("magenta", 0xC354CD),
("light_blue", 0x6689D3),
("yellow", 0xDECF2A),
("lime", 0x41CD34),
("pink", 0xD88198),
("gray", 0x434343),
("light_gray", 0xABABAB),
("cyan", 0x287697),
("purple", 0x7B2FBE),
("blue", 0x253192),
("brown", 0x51301A),
("green", 0x3B511A),
("red", 0xB3312C),
("black", 0x1E1B1B),
];
/// The `firework_explosion` component.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct FireworkExplosion {
pub shape: FireworkShape,
pub colors: Vec<u32>,
pub fade: Vec<u32>,
pub trail: bool,
pub twinkle: bool,
}
struct Starter {
at: [f64; 3],
v: [f64; 3],
explosions: Vec<FireworkExplosion>,
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],
];
+5 -77
View File
@@ -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<String> {
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<i64> {
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<f64> {
Some(match tag? {
Tag::Float(b) => f64::from(f32::from_bits(*b)),
@@ -391,62 +372,9 @@ fn fluid_id(name: &str) -> Option<u32> {
} 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<crate::items::ItemStack> {
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<crate::items::ItemStack>]) -> 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<Option<crate::items::ItemStack>> {
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 {
+21
View File
@@ -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
+56
View File
@@ -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<str>,
pub color: Arc<str>,
}
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<BannerLayer> {
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()
}
@@ -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<String> {
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<String> {
let mods = namespaces(root);
let mut out = vec!["minecraft".to_owned()];
if !mods.is_empty() {
out.push("c".to_owned());
out.extend(mods);
}
out
}
@@ -334,14 +334,14 @@ impl Enchantments {
}
/// `Enchantment.getFullname` as plain text and whether it is a curse (red).
pub fn full_name(&self, id: &str, level: u32) -> (String, bool) {
let language = crate::lang::current();
let language = horizon_client_core::lang::current();
self.full_name_in(language.as_deref(), id, level)
}
/// `full_name` against `language`; `None` (no language loaded: a
/// dedicated server, the probes) keeps the English names loaded above.
pub fn full_name_in(
&self,
language: Option<&crate::lang::Language>,
language: Option<&horizon_client_core::lang::Language>,
id: &str,
level: u32,
) -> (String, bool) {
@@ -776,8 +776,8 @@ pub fn apply_value_effect(
}
}
#[cfg(test)]
pub(crate) mod tests {
#[cfg(any(test, feature = "test-support"))]
pub mod tests {
use super::*;
use serde_json::json;
@@ -1110,7 +1110,7 @@ pub(crate) mod tests {
#[test]
fn names_follow_the_language() {
struct Mem(HashMap<String, Vec<u8>>);
impl crate::lang::ResourceSource for Mem {
impl horizon_client_core::lang::ResourceSource for Mem {
fn read(&self, path: &str) -> Option<Vec<u8>> {
self.0.get(path).cloned()
}
@@ -1121,7 +1121,7 @@ pub(crate) mod tests {
.as_bytes()
.to_vec(),
)]));
let ru = crate::lang::Language::load(&[&ru], "ru_ru");
let ru = horizon_client_core::lang::Language::load(&[&ru], "ru_ru");
let e = sample();
assert_eq!(
e.full_name_in(Some(&ru), "minecraft:sharpness", 5).0,
+61
View File
@@ -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<u32>,
pub fade: Vec<u32>,
pub trail: bool,
pub twinkle: bool,
}
@@ -28,7 +28,7 @@ pub struct StackData {
pub charged: Vec<ItemStack>,
/// `minecraft:fireworks`: flight duration and explosions (None: the
/// item's default).
pub fireworks: Option<(u8, Vec<crate::particles::FireworkExplosion>)>,
pub fireworks: Option<(u8, Vec<crate::firework::FireworkExplosion>)>,
/// `minecraft:map_id` (filled maps).
pub map_id: Option<i32>,
/// `minecraft:writable_book_content` pages.
@@ -36,7 +36,7 @@ pub struct StackData {
/// `minecraft:written_book_content`.
pub written: Option<WrittenBook>,
/// `minecraft:banner_patterns` (banners and shields).
pub banner_patterns: Vec<crate::banner::BannerLayer>,
pub banner_patterns: Vec<crate::banner_layer::BannerLayer>,
/// `minecraft:base_color` (a shield's dye).
pub base_color: Option<Arc<str>>,
/// 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<i32>,
/// `minecraft:firework_explosion` (a firework star).
pub firework_explosion: Option<crate::particles::FireworkExplosion>,
pub firework_explosion: Option<crate::firework::FireworkExplosion>,
/// `minecraft:pot_decorations`: back, left, right, front sherds.
pub pot_decorations: Option<Box<[Option<ItemStack>; 4]>>,
/// `minecraft:bundle_contents`.
@@ -297,13 +297,13 @@ fn read_text(t: &Tag) -> Option<String> {
// A component's root `text` may be empty with the visible words in
// `extra` (every legacy display name ViaVersion produces looks like
// this), so flatten the whole component instead of reading one key.
Tag::Compound(_) => Some(crate::entity_nbt::name_text(t).plain()),
Tag::Compound(_) => Some(horizon_client_core::component::name_text(t).plain()),
_ => None,
}
}
/// A `FireworkExplosion` as vanilla's codec writes it (defaults omitted).
fn explosion_tag(e: &crate::particles::FireworkExplosion) -> Tag {
fn explosion_tag(e: &crate::firework::FireworkExplosion) -> Tag {
let colors = |c: &[u32]| Tag::List(c.iter().map(|c| Tag::Int(*c as i32)).collect());
let mut fields = vec![("shape", Tag::String(e.shape.name().into()))];
if !e.colors.is_empty() {
@@ -320,14 +320,14 @@ fn explosion_tag(e: &crate::particles::FireworkExplosion) -> Tag {
}
Tag::compound(fields)
}
fn explosion_from(e: &Tag) -> crate::particles::FireworkExplosion {
fn explosion_from(e: &Tag) -> crate::firework::FireworkExplosion {
let ints = |t: Option<&Tag>| match t {
Some(Tag::IntArray(v)) => v.iter().map(|c| *c as u32).collect(),
Some(Tag::List(v)) => v.iter().filter_map(int).map(|c| c as u32).collect(),
_ => Vec::new(),
};
crate::particles::FireworkExplosion {
shape: crate::particles::FireworkShape::from_name(
crate::firework::FireworkExplosion {
shape: crate::firework::FireworkShape::from_name(
e.get("shape")
.and_then(string)
.as_deref()
@@ -436,7 +436,7 @@ pub fn canon_tag(tag: &Tag) -> String {
pub fn canon_stack(stack: Option<&ItemStack>) -> String {
stack.map_or_else(
|| "-".to_owned(),
|s| canon_tag(&crate::save::item_to_nbt(s)),
|s| canon_tag(&crate::item_nbt::item_to_nbt(s)),
)
}
/// The stack of an SNBT `ItemStack.CODEC` compound; None for "-".
@@ -445,7 +445,7 @@ pub fn parse_stack(snbt: &str) -> Result<Option<ItemStack>, 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<Tag> {
@@ -494,7 +494,7 @@ pub fn components_tag(damage: u32, data: Option<&StackData>) -> Option<Tag> {
if !d.banner_patterns.is_empty() {
entries.push((
"minecraft:banner_patterns",
crate::banner::layers_to_nbt(&d.banner_patterns),
crate::banner_layer::layers_to_nbt(&d.banner_patterns),
));
}
if let Some(color) = &d.base_color {
@@ -594,7 +594,7 @@ pub fn components_tag(damage: u32, data: Option<&StackData>) -> Option<Tag> {
if !d.charged.is_empty() {
entries.push((
"minecraft:charged_projectiles",
Tag::List(d.charged.iter().map(crate::save::item_to_nbt).collect()),
Tag::List(d.charged.iter().map(crate::item_nbt::item_to_nbt).collect()),
));
}
if d.ominous_banner {
@@ -677,7 +677,7 @@ pub fn decode_components(components: Option<&Tag>) -> (u32, Option<Box<StackData
custom_name: c.get("minecraft:custom_name").and_then(read_text),
potion,
map_id: c.get("minecraft:map_id").and_then(int).map(|v| v as i32),
banner_patterns: crate::banner::layers_from_nbt(c.get("minecraft:banner_patterns")),
banner_patterns: crate::banner_layer::layers_from_nbt(c.get("minecraft:banner_patterns")),
base_color: c
.get("minecraft:base_color")
.and_then(string)
@@ -749,7 +749,10 @@ pub fn decode_components(components: Option<&Tag>) -> (u32, Option<Box<StackData
None => None,
},
charged: match c.get("minecraft:charged_projectiles") {
Some(Tag::List(list)) => list.iter().filter_map(crate::save::item_from_nbt).collect(),
Some(Tag::List(list)) => list
.iter()
.filter_map(crate::item_nbt::item_from_nbt)
.collect(),
_ => Vec::new(),
},
container: match c.get("minecraft:container") {
@@ -896,8 +899,8 @@ mod tests {
let mut stack = ItemStack::new("minecraft:shield", 1);
stack.data_mut().base_color = Some(Arc::from("red"));
stack.data_mut().banner_patterns = vec![
crate::banner::BannerLayer::new("minecraft:flower", "black"),
crate::banner::BannerLayer::new("minecraft:border", "white"),
crate::banner_layer::BannerLayer::new("minecraft:flower", "black"),
crate::banner_layer::BannerLayer::new("minecraft:border", "white"),
];
let tag = encode_components(&stack).expect("components");
let (_, extra) = decode_components(Some(&tag));
@@ -968,7 +971,7 @@ mod tests {
assert_eq!(flight, 2);
assert_eq!(
explosions[0].shape,
crate::particles::FireworkShape::LargeBall
crate::firework::FireworkShape::LargeBall
);
assert_eq!(explosions[0].colors, vec![0xFF0000]);
assert!(explosions[0].twinkle && !explosions[0].trail);
+62
View File
@@ -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<crate::items::ItemStack> {
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<crate::items::ItemStack>]) -> 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<Option<crate::items::ItemStack>> {
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
}
@@ -297,7 +297,7 @@ fn load_songs(root: &Path) -> HashMap<String, Song> {
}
songs
}
pub(crate) fn load_tags(root: &Path, kind: &str) -> Result<HashMap<String, HashSet<String>>> {
pub fn load_tags(root: &Path, kind: &str) -> Result<HashMap<String, HashSet<String>>> {
Ok(load_tag_lists(root, kind)?
.into_iter()
.map(|(k, v)| (k, v.into_iter().collect()))
@@ -337,7 +337,7 @@ pub(crate) fn load_tag_lists(root: &Path, kind: &str) -> Result<HashMap<String,
Ok(())
}
// Vanilla's tags; with content mods also the convention (`c`) tags and the mods' own.
for namespace in crate::mod_content::tag_namespaces(root) {
for namespace in crate::content_namespaces::tag_namespaces(root) {
let dir = root.join(format!("data/{namespace}/tags/{kind}"));
if dir.exists() {
walk(&dir, &dir, &namespace, &mut raw)?;
@@ -558,7 +558,7 @@ impl Items {
consumable: c.get("minecraft:consumable").map(|cons| {
horizon_world::consumable::Consumable {
seconds: cons["consume_seconds"].as_f64().map_or(1.6, |s| s as f32),
animation: crate::first_person::consumable_animation(cons),
animation: crate::use_animation::consumable_animation(cons),
sound: cons["sound"]
.as_str()
.unwrap_or("minecraft:entity.generic.eat")
@@ -685,7 +685,7 @@ impl Items {
let loot_root = root.join("data/minecraft/loot_table");
walk_loot(&loot_root, &loot_root, "minecraft", &mut loot)?;
// Content mods' tables (docs/modpacks.md): `<ns>:blocks/...`.
for namespace in crate::mod_content::namespaces(root) {
for namespace in crate::content_namespaces::namespaces(root) {
let dir = root.join(format!("data/{namespace}/loot_table"));
if dir.is_dir() {
walk_loot(&dir, &dir, &namespace, &mut loot)?;
@@ -720,7 +720,7 @@ impl Items {
})
}
/// An item table with only tags, for unit tests of item rules.
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
pub fn for_tests(item_tags: HashMap<String, HashSet<String>>) -> Self {
Self {
items: HashMap::new(),
@@ -1657,79 +1657,6 @@ mod tests {
assert_eq!(&*drops[0].item, "minecraft:splash_potion");
assert_eq!(drops[0].potion(), Some("minecraft:fire_resistance"));
}
#[test]
fn always_edible_and_consume_effects_loaded() {
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3");
if !root.join("items.json").exists() {
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
let items = Items::load(&root, assets.registry, &facts).unwrap();
let apple = items.get("minecraft:golden_apple").expect("golden apple");
assert!(apple.can_always_eat);
assert!(apple.food.is_some());
assert!(!apple.consumable.as_ref().unwrap().effects.is_empty());
let enchanted = items
.get("minecraft:enchanted_golden_apple")
.expect("enchanted golden apple");
assert!(enchanted.can_always_eat);
assert!(enchanted.food.is_some());
assert!(!enchanted.consumable.as_ref().unwrap().effects.is_empty());
let beef = items.get("minecraft:cooked_beef").expect("cooked beef");
assert!(!beef.can_always_eat);
assert!(beef.food.is_some());
// Consumables without food, their style and their remainders.
use horizon_world::consumable::UseAnimation;
let milk = items.get("minecraft:milk_bucket").expect("milk bucket");
let drink = milk.consumable.as_ref().unwrap();
assert!(milk.food.is_none());
assert_eq!(drink.effects, [ConsumeEffect::ClearAllEffects]);
assert_eq!((drink.ticks(), drink.animation), (32, UseAnimation::Drink));
assert_eq!(drink.sound, "minecraft:entity.generic.drink");
assert!(!drink.particles);
assert_eq!(milk.use_remainder.as_deref(), Some("minecraft:bucket"));
let honey = items.get("minecraft:honey_bottle").expect("honey bottle");
let sip = honey.consumable.as_ref().unwrap();
assert_eq!(sip.ticks(), 40);
assert_eq!(sip.sound, "minecraft:item.honey_bottle.drink");
assert_eq!(
sip.effects,
[ConsumeEffect::RemoveEffects(vec![
"minecraft:poison".into()
])]
);
assert_eq!(
honey.use_remainder.as_deref(),
Some("minecraft:glass_bottle")
);
let kelp = items.get("minecraft:dried_kelp").unwrap();
let bite = kelp.consumable.as_ref().unwrap();
assert_eq!((bite.ticks(), bite.animation), (16, UseAnimation::Eat));
assert_eq!(bite.sound, "minecraft:entity.generic.eat");
assert!(bite.particles && kelp.use_remainder.is_none());
let chorus = items.get("minecraft:chorus_fruit").unwrap();
assert_eq!(
chorus.consumable.as_ref().unwrap().effects,
[ConsumeEffect::TeleportRandomly(
horizon_world::teleport_consume::TeleportConsume {
diameter: 16.0,
directional_particles: true,
}
)]
);
let chicken = items.get("minecraft:chicken").unwrap();
assert!(matches!(
chicken.consumable.as_ref().unwrap().effects[..],
[ConsumeEffect::ApplyEffects { probability, .. }] if probability == 0.3
));
}
}
/// Minecraft.handleKeybinds: spectators cannot drop from the selected slot.
+19
View File
@@ -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;
+14
View File
@@ -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,
}
}
+4 -3
View File
@@ -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 <name>`, script `tools/test_<name>_parity.py`);
`lan_e2e <check>` is `python3 tools/lan_e2e.py --check <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 |
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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__':
+2 -2
View File
@@ -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)
+1 -1
View File
@@ -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',
],