mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Content mods: a block declares its own state properties (#5290)
A content block's states were exactly its template's, copied row for row, and `default_state` could only overlay values on properties the template already had. So a pipe could not have a connection per side, and a machine could not have `facing` and `lit`. A block file may now declare `state_properties` — a boolean, or an enum of values — and the block's states become its template's states times their combinations, appended after the template's run so a block's states stay one contiguous run in registry order. The combination order puts every property at its declared default first, and `Rename` carries how many states a block gives each of its template's, so a template state still moves to exactly one state: the block's default. The `facts.json` defaults and `render-definitions.json` mappings are unchanged. Refused: a name its template already has, a bad name or value, a `default_state` value the property does not have, and more combinations than a world holds (2^20) — the last before the product is built. Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
This commit is contained in:
1 parent
b8cf85f8c3
commit
e7f4440f05
4 files changed
+472
-51
No files matched your search
@@ -16,7 +16,7 @@
|
||||
//! content mod the root is the base root itself.
|
||||
use crate::{Result, blocks::MAP_COLORS};
|
||||
use horizon_core::geometry::{Aabb, Axis, ShapeOp, Vec3, VoxelShape};
|
||||
use horizon_mods::content::{Block, Content, CreativeTab, MobEffect, Rarity};
|
||||
use horizon_mods::content::{Block, Content, CreativeTab, MobEffect, Rarity, StateProperty};
|
||||
use horizon_world::nbt::{self, JavaString, Limits, Tag};
|
||||
use serde::{Deserialize, Serialize, de};
|
||||
use std::{
|
||||
@@ -204,6 +204,11 @@ struct Rename {
|
||||
to: String,
|
||||
states: Range<u32>,
|
||||
first: u32,
|
||||
/// States this block gives each of its template's: one per combination
|
||||
/// of the state properties it declares (1 when it declares none). A
|
||||
/// template state moves to the first of them, which is the one every
|
||||
/// declared property is at its default for.
|
||||
fanout: u32,
|
||||
}
|
||||
impl Rename {
|
||||
fn new(from: &str, to: &str) -> Self {
|
||||
@@ -212,6 +217,7 @@ impl Rename {
|
||||
to: to.to_owned(),
|
||||
states: 0..0,
|
||||
first: 0,
|
||||
fanout: 1,
|
||||
}
|
||||
}
|
||||
/// The id itself, `Block{id}`, and `block.<ns>.<path>` / `item.<ns>.<path>` keys.
|
||||
@@ -233,7 +239,77 @@ impl Rename {
|
||||
fn id(&self, id: u32) -> Option<u32> {
|
||||
self.states
|
||||
.contains(&id)
|
||||
.then(|| self.first + (id - self.states.start))
|
||||
.then(|| self.first + (id - self.states.start) * self.fanout)
|
||||
}
|
||||
}
|
||||
|
||||
/// The combinations of a block's declared state properties, in a fixed
|
||||
/// order: by name (a `BTreeMap`'s), each one's declared default first, and
|
||||
/// the first name varying slowest. So the first combination is every
|
||||
/// property at its default, which is the one a template state maps to.
|
||||
fn combinations(
|
||||
declared: &BTreeMap<String, StateProperty>,
|
||||
default_state: &BTreeMap<String, String>,
|
||||
) -> Result<Vec<Vec<(String, String)>>> {
|
||||
let mut orders: Vec<(&str, Vec<&str>)> = Vec::new();
|
||||
for (name, property) in declared {
|
||||
let mut values = property.values();
|
||||
if let Some(default) = default_state.get(name) {
|
||||
let Some(at) = values.iter().position(|v| v == default) else {
|
||||
return Err(
|
||||
format!("{name}: default_state {default:?} is not one of its values").into(),
|
||||
);
|
||||
};
|
||||
values.rotate_left(at);
|
||||
}
|
||||
orders.push((name, values));
|
||||
}
|
||||
// Every combination is a state of each of the template's, so a block's run
|
||||
// is its template's states times this many: refused before it is built.
|
||||
let mut product: u64 = 1;
|
||||
for (_, values) in &orders {
|
||||
product = product.saturating_mul(values.len() as u64);
|
||||
}
|
||||
if product > MAX_STATES as u64 {
|
||||
return Err(format!(
|
||||
"{product} state property combinations, more than the {MAX_STATES} a world can hold"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let mut combos: Vec<Vec<(String, String)>> = vec![Vec::new()];
|
||||
for (name, values) in orders {
|
||||
let mut next = Vec::with_capacity(combos.len().saturating_mul(values.len()));
|
||||
for combo in &combos {
|
||||
for value in &values {
|
||||
let mut c = combo.clone();
|
||||
c.push((name.to_owned(), (*value).to_owned()));
|
||||
next.push(c);
|
||||
}
|
||||
}
|
||||
combos = next;
|
||||
}
|
||||
Ok(combos)
|
||||
}
|
||||
|
||||
/// Puts a declared state property's values on one of a block's state rows.
|
||||
/// Only `facts.json` keeps a state's own row, whose fifth field is
|
||||
/// `{"id": …, "properties": {…}}`; the other files' rows are per block, so
|
||||
/// they have no property map to extend.
|
||||
fn set_state_properties(row: &mut J, combo: &[(String, String)]) {
|
||||
let J::Arr(parts) = row else {
|
||||
return;
|
||||
};
|
||||
let Some(J::Obj(state)) = parts.get_mut(4) else {
|
||||
return;
|
||||
};
|
||||
if !state.iter().any(|(k, _)| k == "properties") {
|
||||
state.push(("properties".into(), J::Obj(Vec::new())));
|
||||
}
|
||||
let Some((_, properties)) = state.iter_mut().find(|(k, _)| k == "properties") else {
|
||||
return;
|
||||
};
|
||||
for (name, value) in combo {
|
||||
properties.set(name, J::Str(value.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,6 +406,10 @@ struct NewBlock<'a> {
|
||||
item: Option<String>,
|
||||
shapes: Option<Vec<VoxelShape>>,
|
||||
default: u32,
|
||||
/// The declared state properties' combinations, every property at its
|
||||
/// default first (one empty combination when the block declares none):
|
||||
/// the block's states are its template's states times these.
|
||||
combos: Vec<Vec<(String, String)>>,
|
||||
}
|
||||
/// An item being added (declared, or a block's).
|
||||
struct NewItem {
|
||||
@@ -499,20 +579,34 @@ impl<'a> Plan<'a> {
|
||||
decl.id, decl.template
|
||||
)
|
||||
})?;
|
||||
let Some(J::Arr(rows)) = facts.get("states") else {
|
||||
unreachable!()
|
||||
};
|
||||
let properties = |id: u32| -> Option<&J> {
|
||||
let J::Arr(row) = rows.get(id as usize)? else {
|
||||
return None;
|
||||
};
|
||||
row.get(4)?.get("properties")
|
||||
};
|
||||
for key in decl.state_properties.keys() {
|
||||
if properties(default).is_some_and(|p| p.get(key).is_some()) {
|
||||
return Err(format!(
|
||||
"{}: state property {key} is already on {}",
|
||||
decl.id, decl.template
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
let combos = combinations(&decl.state_properties, &decl.default_state)
|
||||
.map_err(|e| format!("{}: {e}", decl.id))?;
|
||||
if !decl.default_state.is_empty() {
|
||||
let Some(J::Arr(rows)) = facts.get("states") else {
|
||||
unreachable!()
|
||||
};
|
||||
let properties = |id: u32| -> Option<&J> {
|
||||
let J::Arr(row) = rows.get(id as usize)? else {
|
||||
return None;
|
||||
};
|
||||
row.get(4)?.get("properties")
|
||||
};
|
||||
let mut wanted = properties(default).cloned().ok_or_else(|| {
|
||||
format!("{}: template default has no state properties", decl.id)
|
||||
})?;
|
||||
for (key, value) in &decl.default_state {
|
||||
if decl.state_properties.contains_key(key) {
|
||||
continue;
|
||||
}
|
||||
if wanted.get(key).is_none() {
|
||||
return Err(format!(
|
||||
"{}: unknown state property {key} on {}",
|
||||
@@ -547,7 +641,10 @@ impl<'a> Plan<'a> {
|
||||
}
|
||||
let mut rename = Rename::new(&decl.template, &decl.id);
|
||||
rename.first = next;
|
||||
next += states.len() as u32;
|
||||
rename.fanout = combos.len() as u32;
|
||||
let count =
|
||||
u32::try_from(states.len() as u64 * combos.len() as u64).unwrap_or(u32::MAX);
|
||||
next = next.saturating_add(count);
|
||||
rename.states = states;
|
||||
if let Some(s) = &decl.properties.sounds {
|
||||
if !defaults.contains_key(s) {
|
||||
@@ -561,6 +658,7 @@ impl<'a> Plan<'a> {
|
||||
item: None,
|
||||
shapes,
|
||||
default,
|
||||
combos,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -569,7 +667,7 @@ impl<'a> Plan<'a> {
|
||||
for b in &blocks {
|
||||
*by_mod
|
||||
.entry(&b.decl.id[..b.decl.id.find(':').unwrap_or(0)])
|
||||
.or_default() += b.rename.states.len() as u32;
|
||||
.or_default() += (b.rename.states.len() as u32).saturating_mul(b.rename.fanout);
|
||||
}
|
||||
let list: Vec<String> = by_mod.iter().map(|(m, n)| format!("{m} {n}")).collect();
|
||||
return Err(format!(
|
||||
@@ -672,47 +770,58 @@ impl<'a> Plan<'a> {
|
||||
}
|
||||
for b in &self.blocks {
|
||||
let renames = self.renames(b);
|
||||
for (i, id) in b.rename.states.clone().enumerate() {
|
||||
let mut row = list[id as usize].clone();
|
||||
for r in &renames {
|
||||
row.rename(r, ids);
|
||||
}
|
||||
if gameplay {
|
||||
row.set("id", J::Num((b.rename.first + i as u32).into()));
|
||||
if !b.decl.item {
|
||||
row.set("item", J::Str("minecraft:air".into()));
|
||||
// One row per template state per combination the block declares,
|
||||
// so its states stay one run in registry order.
|
||||
let mut next = b.rename.first;
|
||||
for (t, id) in b.rename.states.clone().enumerate() {
|
||||
for combo in &b.combos {
|
||||
let mut row = list[id as usize].clone();
|
||||
for r in &renames {
|
||||
row.rename(r, ids);
|
||||
}
|
||||
self.override_gameplay(b, &mut row);
|
||||
if let Some(shapes) = &b.shapes {
|
||||
let shape = &shapes[i];
|
||||
let boxes = shape
|
||||
.boxes()
|
||||
.iter()
|
||||
.map(|b| {
|
||||
J::Arr(
|
||||
[
|
||||
b.min(Axis::X),
|
||||
b.min(Axis::Y),
|
||||
b.min(Axis::Z),
|
||||
b.max(Axis::X),
|
||||
b.max(Axis::Y),
|
||||
b.max(Axis::Z),
|
||||
]
|
||||
.into_iter()
|
||||
.map(J::f64)
|
||||
.collect(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
row.set("outline", J::Arr(boxes));
|
||||
row.set("outline_edges", J::Arr(shape_edges(shape)));
|
||||
row.set(
|
||||
"full_collision",
|
||||
J::Bool(!shape.join_is_not_empty(&VoxelShape::block(), ShapeOp::XOR)?),
|
||||
);
|
||||
if !combo.is_empty() {
|
||||
set_state_properties(&mut row, combo);
|
||||
}
|
||||
if gameplay {
|
||||
row.set("id", J::Num(next.into()));
|
||||
if !b.decl.item {
|
||||
row.set("item", J::Str("minecraft:air".into()));
|
||||
}
|
||||
self.override_gameplay(b, &mut row);
|
||||
if let Some(shapes) = &b.shapes {
|
||||
let shape = &shapes[t];
|
||||
let boxes = shape
|
||||
.boxes()
|
||||
.iter()
|
||||
.map(|b| {
|
||||
J::Arr(
|
||||
[
|
||||
b.min(Axis::X),
|
||||
b.min(Axis::Y),
|
||||
b.min(Axis::Z),
|
||||
b.max(Axis::X),
|
||||
b.max(Axis::Y),
|
||||
b.max(Axis::Z),
|
||||
]
|
||||
.into_iter()
|
||||
.map(J::f64)
|
||||
.collect(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
row.set("outline", J::Arr(boxes));
|
||||
row.set("outline_edges", J::Arr(shape_edges(shape)));
|
||||
row.set(
|
||||
"full_collision",
|
||||
J::Bool(
|
||||
!shape.join_is_not_empty(&VoxelShape::block(), ShapeOp::XOR)?,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
list.push(row);
|
||||
next += 1;
|
||||
}
|
||||
list.push(row);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -1871,3 +1871,228 @@ fn creative_tabs_come_on_their_own_page() {
|
||||
assert_eq!(creative.page, 0);
|
||||
fs::remove_dir_all(&scratch).unwrap();
|
||||
}
|
||||
|
||||
/// A block that gives each of its template's states a run moves a template
|
||||
/// state to the first of its own: the combination every declared property is
|
||||
/// at its default for, which is the one the id cursor points at first.
|
||||
#[test]
|
||||
fn a_fan_out_rename_moves_a_template_state_to_its_first_state() {
|
||||
let mut r = Rename::new("minecraft:test", "testmod:test");
|
||||
r.states = 10..12;
|
||||
r.first = 100;
|
||||
r.fanout = 6;
|
||||
assert_eq!(
|
||||
(r.id(9), r.id(10), r.id(11), r.id(12)),
|
||||
(None, Some(100), Some(106), None)
|
||||
);
|
||||
}
|
||||
|
||||
/// 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>) {
|
||||
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();
|
||||
fs::write(
|
||||
base.join("gameplay-facts.json"),
|
||||
r#"{"states":[{"item":"minecraft:test"},{"item":"minecraft:test"}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(base.join("items.json"), r#"{"minecraft:test":{}}"#).unwrap();
|
||||
fs::write(
|
||||
base.join("registries.json"),
|
||||
r#"{"minecraft:item":{"entries":{"minecraft:test":{"protocol_id":0}}},"minecraft:block":{"entries":{"minecraft:test":{"protocol_id":0}}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mods: Vec<ContentMod> = declarations
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, declaration)| {
|
||||
let dir = scratch.join(format!("mod{i}"));
|
||||
folder_mod(
|
||||
&dir,
|
||||
&[
|
||||
("horizon-mod.toml", &manifest("testmod", "")),
|
||||
("data/testmod/horizon/block/test.json", *declaration),
|
||||
],
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
(base, mods)
|
||||
}
|
||||
|
||||
/// A block's states are its template's times the combinations of the state
|
||||
/// properties it declares, in a fixed order with every default first.
|
||||
#[test]
|
||||
fn content_block_state_properties_fan_out_its_states() {
|
||||
let declaration = r#"{"template":"minecraft:test","item":false,
|
||||
"state_properties":{"lit":{"type":"boolean"},
|
||||
"facing":{"type":"enum","values":["north","south","east"]}}}"#;
|
||||
let (base, mods) = block_base("block-states", &[declaration]);
|
||||
let plan = Plan::new(&base, &mods).unwrap();
|
||||
let facts = fs::read_to_string(base.join("facts.json")).unwrap();
|
||||
let mut doc: J = serde_json::from_str(&facts).unwrap();
|
||||
plan.json("facts.json", &mut doc).unwrap();
|
||||
// Two template states times the six combinations, after the template's.
|
||||
let Some(J::Arr(rows)) = doc.get("states") else {
|
||||
panic!("no states")
|
||||
};
|
||||
assert_eq!(rows.len(), 2 + 12);
|
||||
// The default is the first combination of the template's default state.
|
||||
assert_eq!(
|
||||
doc.get("defaults").unwrap().get("testmod:test"),
|
||||
Some(&J::Num(2.into()))
|
||||
);
|
||||
let properties = |i: usize| -> BTreeMap<String, String> {
|
||||
let J::Arr(row) = &rows[i] else {
|
||||
panic!("row {i} is not a state")
|
||||
};
|
||||
let Some(J::Obj(state)) = row.get(4) else {
|
||||
panic!("row {i} has no state")
|
||||
};
|
||||
let Some((_, J::Obj(p))) = state.iter().find(|(k, _)| k == "properties") else {
|
||||
panic!("row {i} has no properties")
|
||||
};
|
||||
p.iter()
|
||||
.map(|(k, v)| match v {
|
||||
J::Str(s) => (k.clone(), s.clone()),
|
||||
_ => panic!("{k} is not a string"),
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
// By name `facing` varies slowest, and each one's default comes first.
|
||||
assert_eq!(
|
||||
properties(2),
|
||||
BTreeMap::from([
|
||||
("axis".to_owned(), "x".to_owned()),
|
||||
("facing".to_owned(), "north".to_owned()),
|
||||
("lit".to_owned(), "false".to_owned()),
|
||||
])
|
||||
);
|
||||
// And the last is the second template state and the last combination.
|
||||
assert_eq!(
|
||||
properties(13),
|
||||
BTreeMap::from([
|
||||
("axis".to_owned(), "y".to_owned()),
|
||||
("facing".to_owned(), "east".to_owned()),
|
||||
("lit".to_owned(), "true".to_owned()),
|
||||
])
|
||||
);
|
||||
// Every state of the block is a different one: the registry indexes
|
||||
// states by their name and properties, so a repeat would be a collision.
|
||||
let states: BTreeSet<BTreeMap<String, String>> = (2..14).map(properties).collect();
|
||||
assert_eq!(states.len(), 12);
|
||||
fs::remove_dir_all(&base).unwrap();
|
||||
fs::remove_dir_all(base.parent().unwrap().join("mod0")).ok();
|
||||
}
|
||||
|
||||
/// The declared default comes first in the run, so its id is the block's
|
||||
/// default with no arithmetic of its own.
|
||||
#[test]
|
||||
fn content_block_state_properties_put_the_declared_default_first() {
|
||||
let declaration = r#"{"template":"minecraft:test","item":false,
|
||||
"state_properties":{"facing":{"type":"enum","values":["north","south","east"]}},
|
||||
"default_state":{"facing":"south"}}"#;
|
||||
let (base, mods) = block_base("block-default-first", &[declaration]);
|
||||
let plan = Plan::new(&base, &mods).unwrap();
|
||||
let facts = fs::read_to_string(base.join("facts.json")).unwrap();
|
||||
let mut doc: J = serde_json::from_str(&facts).unwrap();
|
||||
plan.json("facts.json", &mut doc).unwrap();
|
||||
let Some(J::Arr(rows)) = doc.get("states") else {
|
||||
panic!("no states")
|
||||
};
|
||||
// Two template states times three values.
|
||||
assert_eq!(rows.len(), 2 + 6);
|
||||
let facing = |i: usize| -> String {
|
||||
let J::Arr(row) = &rows[i] else {
|
||||
panic!("row {i} is not a state")
|
||||
};
|
||||
let Some(J::Obj(state)) = row.get(4) else {
|
||||
panic!("row {i} has no state")
|
||||
};
|
||||
match state
|
||||
.iter()
|
||||
.find(|(k, _)| k == "properties")
|
||||
.map(|(_, p)| p)
|
||||
{
|
||||
Some(p) => match p.get("facing") {
|
||||
Some(J::Str(s)) => s.clone(),
|
||||
_ => panic!("row {i} has no facing"),
|
||||
},
|
||||
None => panic!("row {i} has no properties"),
|
||||
}
|
||||
};
|
||||
// The declared default is the block's default, and it comes first.
|
||||
assert_eq!(
|
||||
doc.get("defaults").unwrap().get("testmod:test"),
|
||||
Some(&J::Num(2.into()))
|
||||
);
|
||||
assert_eq!(facing(2), "south");
|
||||
// The rest keep their declared order, rotated to start at the default.
|
||||
assert_eq!(
|
||||
(facing(3), facing(4)),
|
||||
("east".to_owned(), "north".to_owned())
|
||||
);
|
||||
fs::remove_dir_all(&base).unwrap();
|
||||
fs::remove_dir_all(base.parent().unwrap().join("mod0")).ok();
|
||||
}
|
||||
|
||||
/// What a declaration may not do: shadow its template's property, name a
|
||||
/// value its own property does not have, or ask for more states than a world
|
||||
/// holds.
|
||||
#[test]
|
||||
fn content_block_state_properties_are_validated() {
|
||||
// The template's `axis` is not the mod's to redefine.
|
||||
let (base, mods) = block_base(
|
||||
"block-shadow",
|
||||
&[r#"{"template":"minecraft:test","state_properties":{"axis":{"type":"boolean"}}}"#],
|
||||
);
|
||||
let error = match Plan::new(&base, &mods) {
|
||||
Err(e) => e.to_string(),
|
||||
Ok(_) => panic!("a shadowed template property was accepted"),
|
||||
};
|
||||
assert!(error.contains("axis is already on"), "{error}");
|
||||
fs::remove_dir_all(&base).unwrap();
|
||||
fs::remove_dir_all(base.parent().unwrap().join("mod0")).ok();
|
||||
|
||||
// A default is one of the values the block declared.
|
||||
let (base, mods) = block_base(
|
||||
"block-bad-default",
|
||||
&[r#"{"template":"minecraft:test",
|
||||
"state_properties":{"lit":{"type":"boolean"}},"default_state":{"lit":"maybe"}}"#],
|
||||
);
|
||||
let error = match Plan::new(&base, &mods) {
|
||||
Err(e) => e.to_string(),
|
||||
Ok(_) => panic!("a default outside a property's values was accepted"),
|
||||
};
|
||||
assert!(error.contains("is not one of its values"), "{error}");
|
||||
fs::remove_dir_all(&base).unwrap();
|
||||
fs::remove_dir_all(base.parent().unwrap().join("mod0")).ok();
|
||||
|
||||
// Twenty-one booleans are more combinations than a world can hold, and
|
||||
// are refused before the product is built.
|
||||
let declared: Vec<String> = (0..21)
|
||||
.map(|i| format!(r#""p{i}":{{"type":"boolean"}}"#))
|
||||
.collect();
|
||||
let declaration = format!(
|
||||
r#"{{"template":"minecraft:test","item":false,"state_properties":{{{}}}}}"#,
|
||||
declared.join(",")
|
||||
);
|
||||
let (base, mods) = block_base("block-too-many", &[&declaration]);
|
||||
let error = match Plan::new(&base, &mods) {
|
||||
Err(e) => e.to_string(),
|
||||
Ok(_) => panic!("more states than a world holds were accepted"),
|
||||
};
|
||||
assert!(
|
||||
error.contains("more than the") && error.contains("world can hold"),
|
||||
"{error}"
|
||||
);
|
||||
fs::remove_dir_all(&base).unwrap();
|
||||
fs::remove_dir_all(base.parent().unwrap().join("mod0")).ok();
|
||||
}
|
||||
@@ -83,6 +83,37 @@ pub struct BlockShapes {
|
||||
pub values: BTreeMap<String, Vec<[f64; 6]>>,
|
||||
}
|
||||
|
||||
/// A state property a block declares on its own, beside its template's.
|
||||
/// A block's states become the template's states times these values, so a
|
||||
/// pipe can have a connection per side without a template that has one.
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "lowercase")]
|
||||
pub enum StateProperty {
|
||||
/// `false` and `true`, in vanilla's spelling.
|
||||
Boolean,
|
||||
/// A list of values, in the order the state space is built in.
|
||||
Enum { values: Vec<String> },
|
||||
}
|
||||
|
||||
impl StateProperty {
|
||||
/// Its values, in the order the state space is built in; a boolean's two
|
||||
/// come `false` first, as vanilla orders them.
|
||||
pub fn values(&self) -> Vec<&str> {
|
||||
match self {
|
||||
StateProperty::Boolean => vec!["false", "true"],
|
||||
StateProperty::Enum { values } => values.iter().map(String::as_str).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A vanilla state property's name, and a value's: lower case, digits and
|
||||
/// underscores (`minecraft:oak_log` is `axis`, a repeater is `delay`).
|
||||
fn state_name(s: &str) -> bool {
|
||||
!s.is_empty()
|
||||
&& s.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct BlockFile {
|
||||
@@ -91,6 +122,8 @@ struct BlockFile {
|
||||
properties: BlockProperties,
|
||||
shapes: Option<BlockShapes>,
|
||||
#[serde(default)]
|
||||
state_properties: BTreeMap<String, StateProperty>,
|
||||
#[serde(default)]
|
||||
default_state: BTreeMap<String, String>,
|
||||
#[serde(default = "yes")]
|
||||
item: bool,
|
||||
@@ -111,6 +144,8 @@ pub struct Block {
|
||||
pub template: String,
|
||||
pub properties: BlockProperties,
|
||||
pub shapes: Option<BlockShapes>,
|
||||
/// State properties of its own, which its template does not have.
|
||||
pub state_properties: BTreeMap<String, StateProperty>,
|
||||
/// State properties overlaid on the template default.
|
||||
pub default_state: BTreeMap<String, String>,
|
||||
/// Also add a block item with the same id.
|
||||
@@ -408,6 +443,7 @@ impl Content {
|
||||
template: file.template,
|
||||
properties: file.properties,
|
||||
shapes: file.shapes,
|
||||
state_properties: file.state_properties,
|
||||
default_state: file.default_state,
|
||||
item: file.item,
|
||||
components: file.components,
|
||||
@@ -537,6 +573,35 @@ fn check_block(file: &BlockFile) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
}
|
||||
for (name, property) in &file.state_properties {
|
||||
if !state_name(name) {
|
||||
return Err(format!(
|
||||
"state property {name:?} is lower case letters, digits and underscores"
|
||||
));
|
||||
}
|
||||
let values = property.values();
|
||||
if values.is_empty() {
|
||||
return Err(format!("state property {name:?} needs a value"));
|
||||
}
|
||||
let mut seen = BTreeSet::new();
|
||||
for value in values {
|
||||
if !state_name(value) {
|
||||
return Err(format!(
|
||||
"state property {name:?}: value {value:?} is lower case letters, digits and underscores"
|
||||
));
|
||||
}
|
||||
if !seen.insert(value) {
|
||||
return Err(format!("state property {name:?}: value {value:?} repeats"));
|
||||
}
|
||||
}
|
||||
}
|
||||
for key in file.default_state.keys() {
|
||||
if !state_name(key) {
|
||||
return Err(format!(
|
||||
"default_state key {key:?} is lower case letters, digits and underscores"
|
||||
));
|
||||
}
|
||||
}
|
||||
let p = &file.properties;
|
||||
let finite = |v: Option<f32>| v.is_none_or(f32::is_finite);
|
||||
if !finite(p.hardness) || !finite(p.explosion_resistance) || !finite(p.friction) {
|
||||
@@ -659,6 +724,7 @@ capabilities = ["registries"]
|
||||
id: "ruby:ruby_block".into(),
|
||||
template: "minecraft:iron_block".into(),
|
||||
shapes: None,
|
||||
state_properties: BTreeMap::new(),
|
||||
default_state: BTreeMap::new(),
|
||||
properties: BlockProperties {
|
||||
hardness: Some(4.0),
|
||||
|
||||
@@ -79,6 +79,27 @@ template ([modpacks.md](modpacks.md#registries-02-data-driven-blocks-items-and-m
|
||||
"item": true }
|
||||
```
|
||||
|
||||
A block copies its template's states. It can also declare state properties of
|
||||
its own, which its template does not have — how a pipe says which sides it
|
||||
connects to, or a machine whether it is running. Its states then become its
|
||||
template's states times the combinations of the ones it declares, in a fixed
|
||||
order with every declared default first, so ids do not move between loads:
|
||||
|
||||
```json
|
||||
{ "template": "minecraft:stone",
|
||||
"state_properties": {
|
||||
"facing": { "type": "enum", "values": ["north", "south", "east", "west"] },
|
||||
"lit": { "type": "boolean" } },
|
||||
"default_state": { "lit": "true" } }
|
||||
```
|
||||
|
||||
A `boolean` is `false` and `true`, as vanilla spells them; an `enum` is a list
|
||||
of values, and a name or value is lower case letters, digits and underscores.
|
||||
A declared name may not be one the template already has, and `default_state`
|
||||
takes one value per declared property too, so a block's default is exactly one
|
||||
state of its own. Every combination has to fit in the states a world holds
|
||||
(2^20), so a declaration that would not is refused when the mods load.
|
||||
|
||||
An item copies its template's data components, with `max_stack_size` and
|
||||
`rarity` changed, and any other 26.3 component set or removed in
|
||||
`components`, written as a data component patch is: `minecraft:<type>` sets
|
||||
|
||||
Reference in new issue
Block a user