mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Content mods: the state tables fan out per combination, and a declared
default no longer needs the template's properties
Both points arismoko raised by reading at e7f4440f0.
1. The NBT tables and the collision overrides carried one row per *template*
state, not per combination, so `facts.json` and the tables disagreed about
how many states a block has.
`nbt` (`states` lists, `cached_shapes` arrays) now emits a row per
combination in the order `states` numbers them in — a template state's
combinations are one run, and `Rename::id` maps the template state to the
first of it — with the declared properties on each row (the `properties`
compound `BlockState::to_nbt` writes; without them `BlockTable::check`
fails with "state identities differ" even once the lengths agree).
`collision_shapes` wrote `first + i`, so only the first combination of each
template state got a shape and the rest kept the table's -1. It now covers
every combination of every template state, all pointing at that state's one
shape: the geometry is the template's, the properties are the block's.
`structure-blocks.json` declared `states` as the template's states alone;
it now counts them times the fan-out, so the declared total and the lists
agree.
2. A `default_state` key the block declares itself was looked up on the
template anyway, so `{"template": "minecraft:stone",
"state_properties": {"lit": ...}, "default_state": {"lit": "true"}}` was
refused with "template default has no state properties" — stone has one
state and no properties at all. `combinations` already checks a declared
key's value, and only a key the template has narrows which of its states is
the default, so the lookup now happens only when such a key is present.
Three tests, each checked red before green: with the old `nbt` the table gets
4 rows where `facts.json` has 6; with the old `collision_shapes` the block's
last state maps to -1; with the old count `structure-blocks.json` says 4 where
its own lists hold 6; and with the old `default_state` guard the property-less
template is refused.
Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
This commit is contained in:
1 parent
e7f4440f05
commit
79ce64782a
2 files changed
+283
-15
No files matched your search
@@ -17,7 +17,7 @@
|
||||
use crate::{Result, blocks::MAP_COLORS};
|
||||
use horizon_core::geometry::{Aabb, Axis, ShapeOp, Vec3, VoxelShape};
|
||||
use horizon_mods::content::{Block, Content, CreativeTab, MobEffect, Rarity, StateProperty};
|
||||
use horizon_world::nbt::{self, JavaString, Limits, Tag};
|
||||
use horizon_world::nbt::{self, Compound, JavaString, Limits, Tag};
|
||||
use serde::{Deserialize, Serialize, de};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
@@ -313,6 +313,30 @@ fn set_state_properties(row: &mut J, combo: &[(String, String)]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The same for an NBT table's state, which spells a state the way
|
||||
/// `BlockState::to_nbt` does: `{id: "…", properties: {"<name>": "<value>"}}`,
|
||||
/// with the compound left out when there are no properties.
|
||||
fn set_nbt_properties(state: &mut Tag, combo: &[(String, String)]) {
|
||||
if combo.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Tag::Compound(c) = state else {
|
||||
return;
|
||||
};
|
||||
let properties = c
|
||||
.entry(JavaString::from("properties"))
|
||||
.or_insert_with(|| Tag::Compound(Compound::new()));
|
||||
let Tag::Compound(properties) = properties else {
|
||||
return;
|
||||
};
|
||||
for (name, value) in combo {
|
||||
properties.insert(
|
||||
JavaString::from(name.as_str()),
|
||||
Tag::String(JavaString::from(value.as_str())),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DiscreteVoxelShape.forAllEdges: merge consecutive boundary edges.
|
||||
fn shape_edges(shape: &VoxelShape) -> Vec<J> {
|
||||
let sizes = shape.sizes();
|
||||
@@ -599,7 +623,18 @@ impl<'a> Plan<'a> {
|
||||
}
|
||||
let combos = combinations(&decl.state_properties, &decl.default_state)
|
||||
.map_err(|e| format!("{}: {e}", decl.id))?;
|
||||
if !decl.default_state.is_empty() {
|
||||
// A `default_state` key the block itself declares is its own,
|
||||
// and `combinations` already checks that value against the
|
||||
// property. Only a key the template already has narrows which
|
||||
// of the template's states is the default, and only that needs
|
||||
// the template's own properties: a template with none (`stone`
|
||||
// has a single state) can still be given a property and have
|
||||
// it defaulted.
|
||||
let on_template = decl
|
||||
.default_state
|
||||
.keys()
|
||||
.any(|key| !decl.state_properties.contains_key(key));
|
||||
if on_template {
|
||||
let mut wanted = properties(default).cloned().ok_or_else(|| {
|
||||
format!("{}: template default has no state properties", decl.id)
|
||||
})?;
|
||||
@@ -1052,7 +1087,11 @@ impl<'a> Plan<'a> {
|
||||
for key in ["rotate", "mirror", "full_block"] {
|
||||
states(doc, key, true, false)?;
|
||||
}
|
||||
let added: usize = self.blocks.iter().map(|b| b.rename.states.len()).sum();
|
||||
let added: usize = self
|
||||
.blocks
|
||||
.iter()
|
||||
.map(|b| b.rename.states.len() * b.combos.len())
|
||||
.sum();
|
||||
doc.set("states", J::Num(((self.states + added) as u64).into()));
|
||||
}
|
||||
_ => return Err("no rule for this table".into()),
|
||||
@@ -1070,10 +1109,16 @@ impl<'a> Plan<'a> {
|
||||
};
|
||||
for block in &self.blocks {
|
||||
if let Some(shapes) = &block.shapes {
|
||||
let fanout = block.combos.len();
|
||||
for (i, shape) in shapes.iter().enumerate() {
|
||||
let id = entries.len() as i32;
|
||||
entries.push(encode_shape(shape));
|
||||
indices.push((block.rename.first as usize + i, id));
|
||||
// One shape per template state, shared by every combination
|
||||
// of it: the geometry is the template's, the properties are
|
||||
// the block's.
|
||||
for c in 0..fanout {
|
||||
indices.push((block.rename.first as usize + i * fanout + c, id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1162,19 +1207,32 @@ impl<'a> Plan<'a> {
|
||||
match (key.to_string_lossy().as_str(), value) {
|
||||
("states", Tag::List(list)) if list.len() == self.states => {
|
||||
for b in &self.blocks {
|
||||
// One row per template state per combination, the same
|
||||
// order `states` numbers them in (`Rename::id`): a
|
||||
// template state's combinations are one run, and the
|
||||
// first of the run is where the template state maps.
|
||||
for id in b.rename.states.clone() {
|
||||
let mut state = list[id as usize].clone();
|
||||
if let Some(slot) = state.get_mut("id") {
|
||||
*slot = Tag::String(b.decl.id.as_str().into());
|
||||
for combo in &b.combos {
|
||||
let mut state = list[id as usize].clone();
|
||||
if let Some(slot) = state.get_mut("id") {
|
||||
*slot = Tag::String(b.decl.id.as_str().into());
|
||||
}
|
||||
set_nbt_properties(&mut state, combo);
|
||||
list.push(state);
|
||||
}
|
||||
list.push(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
("cached_shapes", Tag::IntArray(a)) if a.len() == self.states => {
|
||||
for b in &self.blocks {
|
||||
let r = &b.rename.states;
|
||||
let copy = a[r.start as usize..r.end as usize].to_vec();
|
||||
// Parallel to `states`: every combination of a template
|
||||
// state keeps that state's shape.
|
||||
let mut copy = Vec::new();
|
||||
for id in b.rename.states.clone() {
|
||||
for _ in &b.combos {
|
||||
copy.push(a[id as usize]);
|
||||
}
|
||||
}
|
||||
a.extend(copy);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1890,18 +1890,33 @@ fn a_fan_out_rename_moves_a_template_state_to_its_first_state() {
|
||||
/// A base root of one template with two states, and a mod that adds a block
|
||||
/// to it. `declarations` go in as the block file.
|
||||
fn block_base(tag: &str, declarations: &[&str]) -> (PathBuf, Vec<ContentMod>) {
|
||||
block_base_of(
|
||||
tag,
|
||||
declarations,
|
||||
r#"{"states":[
|
||||
["minecraft:test",false,false,"",{"id":"minecraft:test","properties":{"axis":"x"}}],
|
||||
["minecraft:test",false,false,"",{"id":"minecraft:test","properties":{"axis":"y"}}]],
|
||||
"defaults":{"minecraft:test":0}}"#,
|
||||
)
|
||||
}
|
||||
|
||||
/// The same over a base root whose `facts.json` is `facts`: the other tables
|
||||
/// the plan reads are built from its state count, so a template can have one
|
||||
/// state and no properties of its own (a `stone`) as easily as two.
|
||||
fn block_base_of(tag: &str, declarations: &[&str], facts: &str) -> (PathBuf, Vec<ContentMod>) {
|
||||
let scratch = std::env::temp_dir().join(format!("horizon-mod-{tag}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&scratch);
|
||||
let base = scratch.join("base");
|
||||
fs::create_dir_all(&base).unwrap();
|
||||
let facts = r#"{"states":[
|
||||
["minecraft:test",false,false,"",{"id":"minecraft:test","properties":{"axis":"x"}}],
|
||||
["minecraft:test",false,false,"",{"id":"minecraft:test","properties":{"axis":"y"}}]],
|
||||
"defaults":{"minecraft:test":0}}"#;
|
||||
fs::write(base.join("facts.json"), facts).unwrap();
|
||||
let count = match serde_json::from_str::<J>(facts).unwrap().get("states") {
|
||||
Some(J::Arr(rows)) => rows.len(),
|
||||
_ => panic!("{tag}: facts.json has no states"),
|
||||
};
|
||||
let rows = vec![r#"{"item":"minecraft:test"}"#; count].join(",");
|
||||
fs::write(
|
||||
base.join("gameplay-facts.json"),
|
||||
r#"{"states":[{"item":"minecraft:test"},{"item":"minecraft:test"}]}"#,
|
||||
format!(r#"{{"states":[{rows}]}}"#),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(base.join("items.json"), r#"{"minecraft:test":{}}"#).unwrap();
|
||||
@@ -2096,3 +2111,198 @@ fn content_block_state_properties_are_validated() {
|
||||
fs::remove_dir_all(&base).unwrap();
|
||||
fs::remove_dir_all(base.parent().unwrap().join("mod0")).ok();
|
||||
}
|
||||
|
||||
/// A `default_state` that names only properties the block itself declares
|
||||
/// asks the template for nothing: `stone` has a single state and no properties
|
||||
/// at all, and a block that gives it `lit` can still default `lit`.
|
||||
#[test]
|
||||
fn a_declared_property_defaults_on_a_template_with_none() {
|
||||
let declaration = r#"{"template":"minecraft:test","item":false,
|
||||
"state_properties":{"lit":{"type":"boolean"}},
|
||||
"default_state":{"lit":"true"}}"#;
|
||||
let (base, mods) = block_base_of(
|
||||
"block-propertyless-default",
|
||||
&[declaration],
|
||||
r#"{"states":[["minecraft:test",true,true,"",{"id":"minecraft:test"}]],
|
||||
"defaults":{"minecraft:test":0}}"#,
|
||||
);
|
||||
let plan = Plan::new(&base, &mods).unwrap();
|
||||
let mut doc: J =
|
||||
serde_json::from_str(&fs::read_to_string(base.join("facts.json")).unwrap()).unwrap();
|
||||
plan.json("facts.json", &mut doc).unwrap();
|
||||
let Some(J::Arr(rows)) = doc.get("states") else {
|
||||
panic!("no states")
|
||||
};
|
||||
// One template state times the two combinations of `lit`.
|
||||
assert_eq!(rows.len(), 1 + 2);
|
||||
let lit = |i: usize| -> Option<String> {
|
||||
let J::Arr(row) = rows.get(i)? else {
|
||||
return None;
|
||||
};
|
||||
let J::Obj(state) = row.get(4)? else {
|
||||
return None;
|
||||
};
|
||||
let (_, properties) = state.iter().find(|(k, _)| k == "properties")?;
|
||||
match properties.get("lit") {
|
||||
Some(J::Str(s)) => Some(s.clone()),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
// The declared default opens the block's run, and the other value follows.
|
||||
assert_eq!(lit(1).as_deref(), Some("true"));
|
||||
assert_eq!(lit(2).as_deref(), Some("false"));
|
||||
// The block's default state is the first of its run.
|
||||
assert_eq!(
|
||||
doc.get("defaults").unwrap().get("testmod:test"),
|
||||
Some(&J::Num(1.into()))
|
||||
);
|
||||
fs::remove_dir_all(&base).unwrap();
|
||||
fs::remove_dir_all(base.parent().unwrap().join("mod0")).ok();
|
||||
}
|
||||
|
||||
/// An NBT state row for the two-state `minecraft:test` template, the shape
|
||||
/// `BlockState::to_nbt` writes.
|
||||
fn test_state_row(axis: &str) -> Tag {
|
||||
Tag::Compound(Compound::from([
|
||||
(
|
||||
JavaString::from("id"),
|
||||
Tag::String(JavaString::from("minecraft:test")),
|
||||
),
|
||||
(
|
||||
JavaString::from("properties"),
|
||||
Tag::Compound(Compound::from([(
|
||||
JavaString::from("axis"),
|
||||
Tag::String(JavaString::from(axis)),
|
||||
)])),
|
||||
),
|
||||
]))
|
||||
}
|
||||
|
||||
/// The NBT tables fan out like the JSON ones: `cached-collision.nbt`,
|
||||
/// `occlusion.nbt` and the rest get one row per combination, so `states` and
|
||||
/// `cached_shapes` stay the same length, and each row carries the properties
|
||||
/// its combination declares.
|
||||
#[test]
|
||||
fn the_nbt_tables_fan_out_with_the_states() {
|
||||
let declaration = r#"{"template":"minecraft:test","item":false,
|
||||
"state_properties":{"lit":{"type":"boolean"}}}"#;
|
||||
let (base, mods) = block_base("block-nbt-fanout", &[declaration]);
|
||||
let plan = Plan::new(&base, &mods).unwrap();
|
||||
let mut tag = Tag::Compound(Compound::from([
|
||||
(
|
||||
JavaString::from("states"),
|
||||
Tag::List(vec![test_state_row("x"), test_state_row("y")]),
|
||||
),
|
||||
(JavaString::from("cached_shapes"), Tag::IntArray(vec![7, 9])),
|
||||
]));
|
||||
plan.nbt(&mut tag);
|
||||
let Some(Tag::List(states)) = tag.get("states") else {
|
||||
panic!("no states")
|
||||
};
|
||||
let Some(Tag::IntArray(shapes)) = tag.get("cached_shapes") else {
|
||||
panic!("no cached_shapes")
|
||||
};
|
||||
// Two template states times the two combinations of `lit`, and the shapes
|
||||
// stay parallel to the states — that is what makes the file load.
|
||||
assert_eq!(states.len(), 2 + 4);
|
||||
assert_eq!(shapes.len(), states.len());
|
||||
// A combination keeps the shape of the template state it came from.
|
||||
assert_eq!(shapes, &vec![7, 9, 7, 7, 9, 9]);
|
||||
let properties = |i: usize| -> BTreeMap<String, String> {
|
||||
let Some(Tag::Compound(c)) = states.get(i) else {
|
||||
panic!("row {i} is not a compound")
|
||||
};
|
||||
let Some(Tag::Compound(p)) = c.get(&JavaString::from("properties")) else {
|
||||
panic!("row {i} has no properties")
|
||||
};
|
||||
p.iter()
|
||||
.filter_map(|(k, v)| match v {
|
||||
Tag::String(s) => Some((k.to_string_lossy(), s.to_string_lossy())),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let mut expected: Vec<String> = Vec::new();
|
||||
for axis in ["x", "y"] {
|
||||
for lit in ["false", "true"] {
|
||||
expected.push(format!("{axis}/{lit}"));
|
||||
}
|
||||
}
|
||||
let seen: Vec<String> = (2..states.len())
|
||||
.map(|i| {
|
||||
let p = properties(i);
|
||||
// The declared properties are the only ones on the row.
|
||||
assert_eq!(p.len(), 2, "row {i} is {p:?}");
|
||||
let axis = p.get("axis").cloned().unwrap_or_default();
|
||||
let lit = p.get("lit").cloned().unwrap_or_default();
|
||||
format!("{axis}/{lit}")
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(seen, expected);
|
||||
for i in 2..states.len() {
|
||||
let Some(Tag::String(id)) = states[i].get("id") else {
|
||||
panic!("row {i} has no id")
|
||||
};
|
||||
assert_eq!(id.to_string_lossy(), "testmod:test");
|
||||
}
|
||||
fs::remove_dir_all(&base).unwrap();
|
||||
fs::remove_dir_all(base.parent().unwrap().join("mod0")).ok();
|
||||
}
|
||||
|
||||
/// A block's shape and its count in `structure-blocks.json` cover every
|
||||
/// combination too: the table's declared total and the lengths of its lists
|
||||
/// grow by the fan-out, and every state of the block maps to a shape.
|
||||
#[test]
|
||||
fn a_shape_and_the_state_count_cover_every_combination() {
|
||||
let declaration = r#"{"template":"minecraft:test","item":false,
|
||||
"state_properties":{"lit":{"type":"boolean"}},
|
||||
"shapes":{"property":"axis","values":{"x":[[0,0,0,1,1,1]],"y":[[0,0,0,0.5,1,1]]}}}"#;
|
||||
let (base, mods) = block_base("block-shape-fanout", &[declaration]);
|
||||
let plan = Plan::new(&base, &mods).unwrap();
|
||||
// The template's two states, no shapes yet, every vanilla state unmapped.
|
||||
// `nbt` grows the arrays to the block's four states and `collision_shapes`
|
||||
// writes into them, in the order the real path applies them.
|
||||
let mut tag = Tag::Compound(Compound::from([
|
||||
(
|
||||
JavaString::from("states"),
|
||||
Tag::List(vec![test_state_row("x"), test_state_row("y")]),
|
||||
),
|
||||
(JavaString::from("shapes"), Tag::List(Vec::new())),
|
||||
(
|
||||
JavaString::from("cached_shapes"),
|
||||
Tag::IntArray(vec![-1, -1]),
|
||||
),
|
||||
]));
|
||||
plan.nbt(&mut tag);
|
||||
plan.collision_shapes(&mut tag).unwrap();
|
||||
let Some(Tag::List(entries)) = tag.get("shapes") else {
|
||||
panic!("no shapes")
|
||||
};
|
||||
let Some(Tag::IntArray(mapping)) = tag.get("cached_shapes") else {
|
||||
panic!("no cached_shapes")
|
||||
};
|
||||
// One shape per template state, shared by that state's combinations.
|
||||
assert_eq!(entries.len(), 2);
|
||||
assert_eq!(mapping.len(), 2 + 4);
|
||||
let added = &mapping[2..];
|
||||
assert!(added.iter().all(|id| *id >= 0), "{added:?}");
|
||||
assert_eq!((added[0], added[1]), (added[0], added[0]));
|
||||
assert_eq!((added[2], added[3]), (added[2], added[2]));
|
||||
assert_ne!(added[0], added[2]);
|
||||
|
||||
let rows = "[[false,false],[false,false]]";
|
||||
let text = format!(r#"{{"rotate":{rows},"mirror":{rows},"full_block":{rows},"states":2}}"#);
|
||||
let mut doc: J = serde_json::from_str(&text).unwrap();
|
||||
plan.json("worldgen/blocks/structure-blocks.json", &mut doc)
|
||||
.unwrap();
|
||||
for key in ["rotate", "mirror", "full_block"] {
|
||||
let Some(J::Arr(list)) = doc.get(key) else {
|
||||
panic!("{key} is not a list")
|
||||
};
|
||||
assert_eq!(list.len(), 2 + 4, "{key}");
|
||||
}
|
||||
// The declared total agrees with the lists, which is what a loader checks.
|
||||
assert_eq!(doc.get("states"), Some(&J::Num(6.into())));
|
||||
fs::remove_dir_all(&base).unwrap();
|
||||
fs::remove_dir_all(base.parent().unwrap().join("mod0")).ok();
|
||||
}
|
||||
Reference in new issue
Block a user