Lithostitched's limited and guaranteed elements and its processor-list modifiers

The game reads lithostitched:limited and lithostitched:guaranteed pool
elements as written (spec-lithostitched §3): a limited element places its
delegate at most `limit` times per start, a guaranteed one is tried first
at each jigsaw of its pool until `count` are placed, and neither goes below
a parent shallower than `min_depth` (IQ-10). Layouts stay vanilla's (L5);
with no such element vanilla's draws are unchanged. Saved pieces of these
types read as their delegate.

The carry keeps them (delegating still places its delegate), writes
add_processor_list_processors as a processor-list addition (§2.2, 1.8.1's
`processor_lists` key) and both modifier kinds with their priority; the
game merges additions by `horizon:priority`, then mod and file order.

Harnesses: the layout harness checks the probe pack's limits on Horizon's
own probe villages, and the blocks harness gets a lithostitched-probe
profile; the probe pack is built into a jar from the oracle's profile.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-10-25 09:56:59 +01:00
1 parent eb6c81945a
commit cc2a2a8741
8 files changed
+461 -113

No files matched your search

+23 -8
View File
@@ -729,24 +729,29 @@ fn worldgen_entry(file: &str) -> Option<(&'static str, String)> {
Some((bundle, format!("{ns}:{}", path.strip_suffix(".json")?)))
}
/// The key of a mod's structure set or template pool file that adds its
/// entries to other sets or pools (vanilla's, another mod's) instead of
/// being one of its own. Vanilla data never has it.
/// The key of a mod's structure set, template pool or processor list file
/// that adds its entries to other sets, pools or lists (vanilla's, another
/// mod's) instead of being one of its own. Vanilla data never has it.
const ADD_TO: &str = "horizon:add_to";
/// An addition's place among the others (lower first; 1000 by default), as
/// Lithostitched's modifiers' `priority`.
const PRIORITY: &str = "horizon:priority";
/// The bundles a mod may add to, and the list each entry goes into.
const ADDABLE: [(&str, &str); 2] = [
const ADDABLE: [(&str, &str); 3] = [
("worldgen/structure_sets.json", "structures"),
("worldgen/template_pools.json", "elements"),
("worldgen/processor_lists.json", "processors"),
];
/// A mod file's additions (`horizon:add_to`): its entries appended to the
/// list of each set or pool it names, as lithostitched's
/// `add_structure_set_entries` and `add_template_pool_elements` do for
/// NeoForge and Fabric packs.
/// list of each set, pool or processor list it names, as Lithostitched's
/// `add_structure_set_entries`, `add_template_pool_elements` and
/// `add_processor_list_processors` do for NeoForge and Fabric packs.
struct Addition {
targets: Vec<String>,
list: &'static str,
entries: Vec<J>,
priority: i64,
}
impl Addition {
/// What a mod file of `bundle` adds, if it is an addition file.
@@ -776,10 +781,16 @@ impl Addition {
let Some(J::Arr(entries)) = doc.get(list) else {
return Err(format!("{ADD_TO} without a {list} list").into());
};
let priority = match doc.get(PRIORITY) {
None => 1000,
Some(J::Num(n)) => n.as_i64().ok_or(format!("{PRIORITY}: not an integer"))?,
Some(_) => return Err(format!("{PRIORITY}: not an integer").into()),
};
Ok(Some(Self {
targets,
list,
entries: entries.clone(),
priority,
}))
}
/// Appends the entries to each target in the bundle `doc` (id → the
@@ -1215,7 +1226,11 @@ pub fn build(base: &Path, mods: &[ContentMod], out: &Path) -> Result<Vec<String>
overlay.write(BIOME_TAGS, &serde_json::to_vec(&doc)?)?;
}
// Structures and the pools, sets, processors and features they use, then
// what mods add to sets and pools, in mod and file order.
// what mods add to sets, pools and lists, by priority, then in mod and
// file order.
for list in additions.values_mut() {
list.sort_by_key(|a| a.priority);
}
for (bundle, entries) in &worldgen {
if !base.join(bundle).exists() {
continue;
+24 -4
View File
@@ -605,7 +605,8 @@ fn content_root_carries_a_structure_mod() {
"data/huts/tags/worldgen/biome/has_hut.json",
r##"{"values": ["#minecraft:is_overworld", "#c:is_plains", "othermod:glade", "minecraft:plains"]}"##,
),
// Additions to vanilla's set and pool; a target nobody defines is skipped.
// Additions to vanilla's set, pool and processor list; a target nobody
// defines is skipped. A lower priority goes first.
(
"data/huts/worldgen/structure_set/added/villages.json",
r#"{"horizon:add_to": "minecraft:villages",
@@ -617,6 +618,16 @@ fn content_root_carries_a_structure_mod() {
"elements": [{"weight": 2, "element": {"element_type": "minecraft:single_pool_element",
"location": "huts:hut", "projection": "rigid", "processors": "minecraft:empty"}}]}"#,
),
(
"data/huts/worldgen/template_pool/added/more_houses.json",
r#"{"horizon:add_to": "minecraft:village/plains/houses", "horizon:priority": 10,
"elements": [{"weight": 1, "element": {"element_type": "minecraft:empty_pool_element"}}]}"#,
),
(
"data/huts/worldgen/processor_list/added/mossy.json",
r#"{"horizon:add_to": "minecraft:mossify_10_percent",
"processors": [{"processor_type": "minecraft:nop"}]}"#,
),
];
for (name, text) in files {
let path = dir.join(name);
@@ -652,14 +663,23 @@ fn content_root_carries_a_structure_mod() {
"structure_sets.json",
"minecraft:villages",
"structures",
serde_json::json!({"structure": "huts:hut", "weight": 3}),
vec![serde_json::json!({"structure": "huts:hut", "weight": 3})],
),
(
"template_pools.json",
"minecraft:village/plains/houses",
"elements",
serde_json::json!({"weight": 2, "element": {"element_type": "minecraft:single_pool_element",
vec![
serde_json::json!({"weight": 1, "element": {"element_type": "minecraft:empty_pool_element"}}),
serde_json::json!({"weight": 2, "element": {"element_type": "minecraft:single_pool_element",
"location": "huts:hut", "projection": "rigid", "processors": "minecraft:empty"}}),
],
),
(
"processor_lists.json",
"minecraft:mossify_10_percent",
"processors",
vec![serde_json::json!({"processor_type": "minecraft:nop"})],
),
] {
let (ours, vanilla) = (bundle(name, &root), bundle(name, &base));
@@ -668,7 +688,7 @@ fn content_root_carries_a_structure_mod() {
v[list].as_array().unwrap().clone()
};
let mut expected = entries(&vanilla[id]);
expected.push(added);
expected.extend(added);
assert_eq!(entries(&ours[id]), expected, "{id}");
assert!(!ours.keys().any(|k| k.starts_with("huts:added/")), "{name}");
assert!(!ours.contains_key("othermod:houses"));
+78 -35
View File
@@ -10,10 +10,12 @@
//! the processors nested in them carried in turn. CTOV's
//! `ctov:modular_compat` (a reference that runs only when its mod is loaded)
//! becomes a `reference` when the mod is ported along, else nothing.
//! - `add_template_pool_elements` modifiers become `horizon:add_to` pool files
//! (`docs/mods.md`), when their load conditions pass. Lithostitched's own
//! pool elements (`limited`, `guaranteed`, `delegating`) place their
//! `delegate`; what they add to it is counted as lost.
//! - `add_template_pool_elements` and `add_processor_list_processors`
//! modifiers become `horizon:add_to` pool and processor list files
//! (`docs/mods.md`), with their `priority`, when their load conditions pass.
//! - Its `limited` and `guaranteed` pool elements are the game's own
//! (`horizon_world`'s `structure::jigsaw`); `delegating` places its
//! `delegate`, and what it adds is counted as lost.
use crate::{processors::ProcessorData, worldgen::Upgrade};
use serde_json::{Value, json};
use std::collections::BTreeMap;
@@ -88,17 +90,21 @@ pub(crate) fn upgrade_pool_element(
let Some(kind) = element["element_type"].as_str() else {
return false;
};
match kind {
"lithostitched:limited" | "lithostitched:guaranteed" | "lithostitched:delegating" => {
count(lost, kind);
let delegate = element["delegate"].take();
*element = delegate;
// A delegate wrapping another (only `delegating` allows it).
upgrade_pool_element(element, lost);
true
}
_ => false,
let unwrap = match kind {
"lithostitched:guaranteed" => false,
// A `limit` is what it adds (spec-lithostitched §3).
"lithostitched:limited" => element["limit"].as_i64().is_none(),
"lithostitched:delegating" => true,
_ => return false,
};
if unwrap {
count(lost, kind);
let delegate = element["delegate"].take();
*element = delegate;
// A delegate wrapping another (only `delegating` allows it).
upgrade_pool_element(element, lost);
}
true
}
/// The ids a modifier targets: an id or a list of ids (`None` for tags and
@@ -140,8 +146,9 @@ fn conditions_pass(v: &Value, loaded: &dyn Fn(&str) -> bool) -> bool {
}
/// A worldgen modifier as a file the game merges: `("template_pool", file)`
/// for `add_template_pool_elements`; the others are counted in
/// `report.modifiers_left`.
/// for `add_template_pool_elements`, `("processor_list", file)` for
/// `add_processor_list_processors` (spec-lithostitched §2.1, §2.2); the
/// others are counted in `report.modifiers_left`.
pub(crate) fn modifier(
v: &Value,
loaded: &dyn Fn(&str) -> bool,
@@ -152,22 +159,31 @@ pub(crate) fn modifier(
count(&mut report.modifiers_left, "conditions");
return None;
}
if kind != "lithostitched:add_template_pool_elements" {
count(&mut report.modifiers_left, kind);
return None;
}
let (Some(targets), Some(elements)) = (ids(&v["template_pools"]), v["elements"].as_array())
else {
// 1.8.1 reads `processor_lists`, not the wiki's `processor_list`.
let (to, targets, list) = match kind {
"lithostitched:add_template_pool_elements" => {
("template_pool", "template_pools", "elements")
}
"lithostitched:add_processor_list_processors" => {
("processor_list", "processor_lists", "processors")
}
_ => {
count(&mut report.modifiers_left, kind);
return None;
}
};
let (Some(targets), Some(entries)) = (ids(&v[targets]), v[list].as_array()) else {
count(&mut report.modifiers_left, "target");
return None;
};
for target in &targets {
*report.added_to.entry(target.clone()).or_default() += elements.len();
*report.added_to.entry(target.clone()).or_default() += entries.len();
}
Some((
"template_pool",
json!({"horizon:add_to": targets, "elements": elements}),
))
let mut file = json!({"horizon:add_to": targets, list: entries});
if let Some(priority) = v["priority"].as_i64() {
file["horizon:priority"] = priority.into();
}
Some((to, file))
}
/// Whether the game reads a Lithostitched processor as it is: one of its
@@ -322,16 +338,28 @@ mod tests {
}
#[test]
fn wrapped_elements_place_their_delegate() {
fn limited_and_guaranteed_elements_stay_theirs() {
let mut lost = BTreeMap::new();
let delegate = json!({"element_type": "minecraft:single_pool_element", "location": "a:b",
"projection": "rigid", "processors": "minecraft:empty"});
let mut e =
json!({"element_type": "lithostitched:limited", "limit": 1, "delegate": delegate});
assert!(upgrade_pool_element(&mut e, &mut lost));
assert_eq!(e, delegate);
assert_eq!(lost["lithostitched:limited"], 1);
assert!(!upgrade_pool_element(&mut e, &mut lost));
for kept in [
json!({"element_type": "lithostitched:limited", "limit": 1, "delegate": delegate}),
json!({"element_type": "lithostitched:guaranteed", "count": 1, "min_depth": 3,
"delegate": delegate}),
] {
let mut e = kept.clone();
assert!(upgrade_pool_element(&mut e, &mut lost));
assert_eq!(e, kept);
}
assert!(lost.is_empty());
// Without a limit, and `delegating`: the delegate, the rest counted.
for kind in ["lithostitched:limited", "lithostitched:delegating"] {
let mut e = json!({"element_type": kind, "delegate": delegate});
assert!(upgrade_pool_element(&mut e, &mut lost));
assert_eq!(e, delegate);
assert_eq!(lost[kind], 1);
}
assert!(!upgrade_pool_element(&mut delegate.clone(), &mut lost));
}
#[test]
@@ -357,12 +385,27 @@ mod tests {
assert!(modifier(&add("waystones", json!("#a:pools")), &loaded, &mut report).is_none());
let aliases = json!({"type": "lithostitched:set_pool_aliases"});
assert!(modifier(&aliases, &loaded, &mut report).is_none());
// Processors appended to lists, with the modifier's priority; 1.8.1
// reads `processor_lists` (spec-lithostitched §2.2).
let nop = json!([{"processor_type": "minecraft:nop"}]);
let processors = json!({"type": "lithostitched:add_processor_list_processors",
"priority": 10, "processor_lists": ["minecraft:mossify_10_percent"], "processors": nop});
let (kind, file) = modifier(&processors, &loaded, &mut report).unwrap();
assert_eq!(kind, "processor_list");
assert_eq!(
file,
json!({"horizon:add_to": ["minecraft:mossify_10_percent"], "processors": nop,
"horizon:priority": 10})
);
let wiki = json!({"type": "lithostitched:add_processor_list_processors",
"processor_list": "minecraft:mossify_10_percent", "processors": nop});
assert!(modifier(&wiki, &loaded, &mut report).is_none());
assert_eq!(
report.modifiers_left,
BTreeMap::from([
("conditions".into(), 1),
("lithostitched:set_pool_aliases".into(), 1),
("target".into(), 1),
("target".into(), 2),
])
);
}
@@ -119,6 +119,11 @@ impl PoolElement {
};
Ok(match kind {
"minecraft:empty_pool_element" => Self::Empty,
// Lithostitched's elements place what their `delegate` places;
// their limits are the pool entry's (`Pool::limits`).
"lithostitched:limited" | "lithostitched:guaranteed" | "lithostitched:delegating" => {
Self::decode(&value["delegate"], resources, bindings, inherited)?
}
// YUNG's API's element places what a single element places; its
// `name` and `max_count` are the pool entry's (`Pool::limits`).
"minecraft:single_pool_element"
@@ -321,11 +326,14 @@ pub(super) fn can_attach(source: &Jigsaw, target: &Jigsaw) -> bool {
pub struct Pool {
/// Each element repeated by its weight.
pub(super) templates: Vec<Arc<PoolElement>>,
/// The entry of `raw` each of `templates` repeats.
entries: Vec<usize>,
/// The elements with their weights, in file order (`rawTemplates`).
pub(super) raw: Vec<(Arc<PoolElement>, i32)>,
/// Per entry of `raw`: a YUNG element's `name` and `max_count`, the most
/// pieces of that name in one start.
limits: Vec<Option<(Arc<str>, i32)>>,
/// Per entry of `raw`: how many pieces of it one start may hold.
limits: Vec<Option<Limit>>,
/// The entries of `raw` that are Lithostitched's `guaranteed` elements.
guaranteed: Vec<usize>,
pub(super) fallback: String,
max_size: OnceLock<i32>,
}
@@ -339,10 +347,11 @@ impl Pool {
.unwrap_or(0) as usize]
.clone()
}
fn shuffled(&self, random: &mut impl RandomSource) -> Vec<Arc<PoolElement>> {
let mut copy = self.templates.clone();
shuffle(&mut copy, random);
copy
/// The entries of `templates`, shuffled.
fn shuffled(&self, random: &mut impl RandomSource) -> Vec<usize> {
let mut order: Vec<usize> = (0..self.templates.len()).collect();
shuffle(&mut order, random);
order.into_iter().map(|i| self.entries[i]).collect()
}
/// YUNG's API's pick: `nextInt` of the entries' total weight, walked in
/// `order` (indices into `raw`); the entry that takes it below zero.
@@ -365,6 +374,52 @@ impl Pool {
})
}
}
/// How many pieces of a pool entry one start may hold, and where.
#[derive(Clone, Debug)]
struct Limit {
/// The pieces counted together: a YUNG element's `name`; a Lithostitched
/// element itself (its JSON), wherever it is listed.
key: Arc<str>,
max: i32,
/// The parent piece's least depth (the start is at 0).
min_depth: i32,
/// `lithostitched:guaranteed`: tried before the pool's other entries.
guaranteed: bool,
}
impl Limit {
/// A pool entry's element's limit: YUNG's `max_count` (spec-yungsapi §3),
/// Lithostitched's `limited` and `guaranteed` (spec-lithostitched §3).
fn of(element: &Value) -> Option<Self> {
let int = |key: &str| {
element[key]
.as_i64()
.map(|v| v.clamp(0, i32::MAX.into()) as i32)
};
let litho = |max: Option<i32>, guaranteed| {
Some(Self {
key: element.to_string().into(),
max: max?,
min_depth: int("min_depth").unwrap_or(0),
guaranteed,
})
};
match element["element_type"].as_str()? {
"yungsapi:yung_single_element" => Some(Self {
key: element["name"].as_str()?.into(),
max: int("max_count")?,
min_depth: 0,
guaranteed: false,
}),
"lithostitched:limited" => litho(int("limit"), false),
"lithostitched:guaranteed" => litho(Some(int("count").unwrap_or(1)), true),
_ => None,
}
}
/// Whether one more piece may go below a parent at `depth`.
fn allows(&self, counts: &HashMap<Arc<str>, i32>, depth: i32) -> bool {
depth >= self.min_depth && counts.get(&self.key).copied().unwrap_or(0) < self.max
}
}
/// Lazily decoded template pools.
pub struct Pools {
raw: BTreeMap<String, String>,
@@ -393,7 +448,8 @@ impl Pools {
}
let pool = self.raw.get(name).and_then(|json| {
let value: Value = serde_json::from_str(json).ok()?;
let (mut templates, mut raw, mut limits) = (Vec::new(), Vec::new(), Vec::new());
let (mut templates, mut entries, mut raw, mut limits) =
(Vec::new(), Vec::new(), Vec::new(), Vec::new());
for entry in value["elements"].as_array()? {
let element =
match PoolElement::decode(&entry["element"], resources, bindings, None) {
@@ -408,24 +464,21 @@ impl Pools {
let weight = entry["weight"].as_i64().unwrap_or(1);
for _ in 0..weight {
templates.push(element.clone());
entries.push(raw.len());
}
raw.push((element, weight as i32));
let fields = &entry["element"];
limits.push(
match (fields["name"].as_str(), fields["max_count"].as_i64()) {
(Some(name), Some(max))
if fields["element_type"] == "yungsapi:yung_single_element" =>
{
Some((name.into(), max as i32))
}
_ => None,
},
);
limits.push(Limit::of(&entry["element"]));
}
let guaranteed = (limits.iter().enumerate())
.filter(|(_, l)| l.as_ref().is_some_and(|l| l.guaranteed))
.map(|(i, _)| i)
.collect();
Some(Arc::new(Pool {
templates,
entries,
raw,
limits,
guaranteed,
fallback: value["fallback"]
.as_str()
.unwrap_or("minecraft:empty")
@@ -669,9 +722,11 @@ pub enum Draws {
/// The elements a jigsaw tries, in order.
enum Candidates {
/// Vanilla: the target pool's weight-expanded elements, then the
/// fallback's, each shuffled up front.
Listed(std::vec::IntoIter<Arc<PoolElement>>),
/// Vanilla: the target pool's weight-expanded entries, then the
/// fallback's, each shuffled up front. Lithostitched's `guaranteed`
/// entries of the target pool come first, in file order, and are not
/// drawn again (spec-lithostitched §3, OPEN-L4).
Listed(std::vec::IntoIter<(Arc<Pool>, usize)>),
/// YUNG's: weighted picks from each pool in turn (the fallback's only
/// once the target's are spent), each pool's entries shuffled when it
/// is reached.
@@ -694,27 +749,36 @@ impl Candidates {
vec![fallback.clone()]
};
match draws {
Draws::Vanilla => Self::Listed(
pools
.iter()
.flat_map(|pool| pool.shuffled(random))
.collect::<Vec<_>>()
.into_iter(),
),
Draws::Vanilla => {
let first = (target.guaranteed.iter()).filter(|_| below_max_depth);
let mut listed: Vec<_> = first.map(|&e| (target.clone(), e)).collect();
for pool in &pools {
let drawn = pool.shuffled(random).into_iter();
let drawn = drawn.filter(|&e| !pool.guaranteed.contains(&e));
listed.extend(drawn.map(|e| (pool.clone(), e)));
}
Self::Listed(listed.into_iter())
}
Draws::Yung => Self::Weighted {
pools: pools.into_iter().rev().collect(),
current: None,
},
}
}
/// The next element to try, with its YUNG name and limit.
/// The next element to try below a parent at `depth`, with the key
/// its pieces are counted under.
fn next(
&mut self,
counts: &HashMap<Arc<str>, i32>,
depth: i32,
random: &mut impl RandomSource,
) -> Option<(Arc<PoolElement>, Option<Arc<str>>)> {
match self {
Self::Listed(elements) => elements.next().map(|e| (e, None)),
Self::Listed(entries) => entries.find_map(|(pool, entry)| {
let limit = pool.limits[entry].as_ref();
(limit.is_none_or(|l| l.allows(counts, depth)))
.then(|| (pool.raw[entry].0.clone(), limit.map(|l| l.key.clone())))
}),
Self::Weighted { pools, current } => loop {
let (pool, order) = match current {
Some((pool, order)) if !order.is_empty() => (pool, order),
@@ -731,13 +795,11 @@ impl Candidates {
continue;
};
let entry = order.remove(at);
let limit = pool.limits[entry].clone();
if let Some((name, max)) = &limit
&& counts.get(name).copied().unwrap_or(0) >= *max
{
let limit = pool.limits[entry].as_ref();
if limit.is_some_and(|l| !l.allows(counts, depth)) {
continue;
}
return Some((pool.raw[entry].0.clone(), limit.map(|(name, _)| name)));
return Some((pool.raw[entry].0.clone(), limit.map(|l| l.key.clone())));
},
}
}
@@ -830,7 +892,7 @@ pub fn assemble_drawing(
let rotation = Rotation::random(random);
let entry = order[center_pool.weighted(&order, random)?];
let (element, _) = &center_pool.raw[entry];
let limit = center_pool.limits[entry].clone().map(|(name, _)| name);
let limit = center_pool.limits[entry].as_ref().map(|l| l.key.clone());
(rotation, element.clone(), limit)
}
};
@@ -972,7 +1034,7 @@ struct Placer<'a, 'p> {
/// SequencedPriorityIterator: highest priority first, FIFO within one.
queue: BTreeMap<i32, VecDeque<PieceState>>,
draws: Draws,
/// Pieces placed per YUNG element name.
/// Pieces placed per limited element (`Limit::key`).
counts: HashMap<Arc<str>, i32>,
}
impl Placer<'_, '_> {
@@ -1052,7 +1114,7 @@ impl Placer<'_, '_> {
depth != self.config.max_depth,
random,
);
while let Some((target, name)) = candidates.next(&self.counts, random) {
while let Some((target, name)) = candidates.next(&self.counts, depth, random) {
if matches!(target.as_ref(), PoolElement::Empty) {
break;
}
@@ -1175,3 +1237,117 @@ impl Placer<'_, '_> {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
/// A pool of empty elements, told apart by identity, with these limits.
fn pool(entries: &[(i32, Value)]) -> Arc<Pool> {
let (mut templates, mut indices, mut raw, mut limits) =
(Vec::new(), Vec::new(), Vec::new(), Vec::new());
for (i, (weight, element)) in entries.iter().enumerate() {
let e = Arc::new(PoolElement::Empty);
for _ in 0..*weight {
templates.push(e.clone());
indices.push(i);
}
raw.push((e, *weight));
limits.push(Limit::of(element));
}
let guaranteed = (0..entries.len())
.filter(|&i| limits[i].as_ref().is_some_and(|l: &Limit| l.guaranteed))
.collect();
Arc::new(Pool {
templates,
entries: indices,
raw,
limits,
guaranteed,
fallback: "minecraft:empty".into(),
max_size: OnceLock::new(),
})
}
fn delegate() -> Value {
json!({"element_type": "minecraft:single_pool_element", "location": "a:b"})
}
/// The entries a vanilla jigsaw tries below a parent at `depth`, by index.
fn tried(
pool: &Arc<Pool>,
counts: &HashMap<Arc<str>, i32>,
depth: i32,
below_max: bool,
) -> Vec<usize> {
let empty = self::pool(&[]);
let mut random = LegacyRandom::new(7);
let mut candidates = Candidates::new(Draws::Vanilla, pool, &empty, below_max, &mut random);
let mut out = Vec::new();
while let Some((element, _)) = candidates.next(counts, depth, &mut random) {
out.push(
pool.raw
.iter()
.position(|(e, _)| Arc::ptr_eq(e, &element))
.unwrap(),
);
}
out
}
#[test]
fn limits_read_yungs_and_lithostitcheds_fields() {
let yung =
json!({"element_type": "yungsapi:yung_single_element", "name": "pile", "max_count": 2});
let yung = Limit::of(&yung).unwrap();
assert_eq!(
(&*yung.key, yung.max, yung.min_depth, yung.guaranteed),
("pile", 2, 0, false)
);
let limited =
json!({"element_type": "lithostitched:limited", "limit": 1, "delegate": delegate()});
let l = Limit::of(&limited).unwrap();
assert_eq!((l.max, l.min_depth, l.guaranteed), (1, 0, false));
assert_eq!(*l.key, *limited.to_string());
let guaranteed = json!({"element_type": "lithostitched:guaranteed", "count": 2, "min_depth": 3,
"delegate": delegate()});
let g = Limit::of(&guaranteed).unwrap();
assert_eq!((g.max, g.min_depth, g.guaranteed), (2, 3, true));
// No limit: none to keep.
for none in [
json!({"element_type": "lithostitched:limited", "delegate": delegate()}),
json!({"element_type": "yungsapi:yung_single_element", "name": "pile"}),
delegate(),
] {
assert!(Limit::of(&none).is_none(), "{none}");
}
}
#[test]
fn vanilla_jigsaws_keep_lithostitcheds_limits() {
let limited =
json!({"element_type": "lithostitched:limited", "limit": 1, "delegate": delegate()});
let guaranteed = json!({"element_type": "lithostitched:guaranteed", "count": 1, "min_depth": 3,
"delegate": delegate()});
let p = pool(&[
(3, delegate()),
(2, limited.clone()),
(1, guaranteed.clone()),
]);
let mut counts = HashMap::new();
// Too shallow for the guaranteed entry: the weight-expanded list without it.
let mut shallow = tried(&p, &counts, 2, true);
shallow.sort();
assert_eq!(shallow, [0, 0, 0, 1, 1]);
// Deep enough: it comes first, and is not drawn again.
let deep = tried(&p, &counts, 3, true);
assert_eq!(deep[0], 2);
assert_eq!(deep[1..].iter().filter(|&&e| e == 2).count(), 0);
// At the most depth only the fallback is tried.
assert!(tried(&p, &counts, 3, false).is_empty());
// Entries at their limit are skipped.
counts.insert(Arc::from(limited.to_string()), 1);
counts.insert(Arc::from(guaranteed.to_string()), 1);
let full = tried(&p, &counts, 3, true);
assert_eq!(full, [0, 0, 0]);
}
}
+8 -1
View File
@@ -449,7 +449,9 @@ Two cases the oracle fills in:
- `lithostitched:jigsaw`, MoogsStructureLib's and Repurposed Structures' generic jigsaws
become `minecraft:jigsaw`;
- their `advanced_random_spread` becomes `minecraft:random_spread`;
- their versioned, legacy and empty pool elements become vanilla's.
- their versioned, legacy and empty pool elements become vanilla's; Lithostitched's
`limited` and `guaranteed` elements are the game's own (its `delegating` places its
`delegate`).
Three more rewrites make the mapped structures decode and start where their mod would:
- **Block states.** 1.21.1 writes a block or fluid state as `{"Name", "Properties"}`, 26.3
@@ -516,6 +518,11 @@ Two cases the oracle fills in:
CTOV's compat hooks for other mods (its `modular_compat`, 30 of them in 17 lists) become a
`reference` when the mod they name is ported along, and are taken out (a no-op, as in the
mod) when it is not. In ATM10 they all name Nature's Spirit, which the pack does not have.
Lithostitched's `add_template_pool_elements` and `add_processor_list_processors` modifiers
(CTOV's houses for 11 of ATM10's mods) become the game's additions, when the mods their
load conditions name are ported along. The houses are `limited` (at most once in a
village) or `guaranteed` (tried first, from the depth they name, until placed): the game
keeps both.
In Java, Lithostitched also lays out every jigsaw structure its own way (villages,
outposts, trial chambers; CTOV's villages are `lithostitched:jigsaw`, carried as vanilla's
jigsaw). Horizon keeps vanilla's layouts, so with Lithostitched in a pack, structure layouts
+19 -9
View File
@@ -333,21 +333,31 @@ vanilla's entries and those of adds are left out, ...". Tags are the only
`minecraft` files a mod may hold: its other content stays in its own
namespaces.
A mod adds to structure sets and template pools that are not its own
(vanilla's villages, another mod's houses) with a set or pool file in its own
namespace holding `horizon:add_to`, a key vanilla data never has. Its entries
are appended to each set or pool named, after every mod's own files, in mod
and file order; the file is no set or pool itself. A name nobody defines is
skipped.
A mod adds to structure sets, template pools and processor lists that are not
its own (vanilla's villages, another mod's houses) with a set, pool or
processor list file in its own namespace holding `horizon:add_to`, a key
vanilla data never has. Its entries are appended to each set, pool or list
named, after every mod's own files, by `horizon:priority` (lower first, 1000
when it has none), then in mod and file order; the file is no set, pool or
list itself. A name nobody defines is skipped.
```json
{ "horizon:add_to": "minecraft:villages",
"structures": [ { "structure": "mymod:village_beach", "weight": 10 } ] }
```
A pool addition holds `"elements"` as a pool does. This is what lithostitched's
`add_structure_set_entries` and `add_template_pool_elements` modifiers do on
NeoForge and Fabric, and `horizon-port-pack carry` writes them from those.
A pool addition holds `"elements"` as a pool does, a processor list addition
`"processors"`. This is what Lithostitched's `add_structure_set_entries`,
`add_template_pool_elements` and `add_processor_list_processors` modifiers do
on NeoForge and Fabric, and `horizon-port-pack carry` writes the last two from
those.
A pool element may be one of Lithostitched's: `lithostitched:limited`
(`delegate`, `limit`, `min_depth`) places its `delegate` at most `limit` times
in one structure start, and `lithostitched:guaranteed` (`delegate`, `count`,
`min_depth`) is tried before the pool's other elements at each jigsaw of that
pool until `count` are placed. Neither goes below a piece shallower than
`min_depth` (the start is at depth 0).
A structure (and any of its pool elements, in place of the structure's) may
hold `horizon:enhanced_terrain_adaptation`: MoogsStructureLib's terrain
+14 -6
View File
@@ -21,7 +21,8 @@ A mismatch is counted by the oracle's no-mod block, the expected block and Horiz
missing rule reads as one line. KNOWN names the mismatch classes the implementation does not cover yet, with
the reason; they are reported, not failed.
usage: test_structure_blocks_parity.py [--profile betterdungeons|ctov] [--filter REGEX] [--limit N]
usage: test_structure_blocks_parity.py [--profile betterdungeons|ctov|lithostitched-probe] [--filter REGEX]
[--limit N]
"""
import argparse
import gzip
@@ -61,6 +62,13 @@ KNOWN = {
'changed the terrain',
r'^minecraft:village_plains$': 'decoration (grass, trees) differs where the village changed the terrain',
},
# The probe pack's swaps and references match block for block; its random choices do not (OPEN-L6).
'lithostitched-probe': {
r'^minecraft:village_plains$': "Lithostitched's random is not known (spec-lithostitched OPEN-L6): the "
'probe\'s blackstone/deepslate and per-piece palettes match by share, not '
'by block; and decoration (grass, trees) differs where the village changed '
'the terrain',
},
}
@@ -81,11 +89,9 @@ def chunk_rect(box):
def saved_element(element):
"""A saved pool element as the game reads it: Lithostitched's wrappers place their `delegate`
(spec-lithostitched §3), and 1.21.1's `{"Name", "Properties"}` states are 26.3's `{"id", "properties"}`."""
"""A saved pool element as the game reads it: 1.21.1's `{"Name", "Properties"}` states are 26.3's
`{"id", "properties"}` (Lithostitched's wrapped elements are read as saved, spec-lithostitched §7.3)."""
if isinstance(element, dict):
if 'delegate' in element and str(element.get('element_type', '')).startswith('lithostitched:'):
return saved_element(element['delegate'])
if 'Name' in element and set(element) <= {'Name', 'Properties'}:
return {'id': element['Name'], **({'properties': element['Properties']} if 'Properties' in element else {})}
return {k: saved_element(v) for k, v in element.items()}
@@ -193,10 +199,12 @@ def main():
profile = PROFILES[args.profile]
results = sorted((layout.ORACLE / args.profile).glob('seed-*.json.gz'))
missing = [jar for jar in profile['mods'].values() if not layout.jar_path(jar).exists()]
missing += [n for n in profile.get('packs', {}).values() if not (layout.ORACLE / 'profiles' / f'{n}.json').exists()]
if not results or missing:
print(f'SKIP: oracle results or mod jars missing under {layout.ORACLE} (see test_structure_layout_parity.py)')
return 0
resources = layout.overlay(args.profile, layout.carry(args.profile, profile['mods'], profile['carry']))
data = layout.carry(args.profile, profile['mods'], profile['carry'], profile.get('packs'))
resources = layout.overlay(args.profile, data)
binary = layout.probe_binary()
names = state_names(lab.RESOURCES)
# structure -> [starts, compared by region, mismatches by region]
+80 -11
View File
@@ -21,9 +21,17 @@ Mod profiles compare the mod world's `pieces` instead, through the path players'
(`.local/structure-oracle/jars/`, or HORIZON_STRUCTURE_JARS) are carried by `horizon-port-pack carry` into
a fresh work folder, and an overlay of the 26.3 resources adds the carried structures, sets, pools,
processor lists, biome tags and templates. The jars are the player's; nothing of them enters the
repository.
repository. A profile whose oracle run added a data pack of the spec team's own (`lithostitched-probe`)
carries that pack as one more mod, built at run time from the oracle's profile file
(`.local/structure-oracle/profiles/<profile>.json`).
usage: test_structure_layout_parity.py [--profile lithostitched|betterdungeons] [--filter REGEX] [--verbose]
Lithostitched lays jigsaws out its own way (spec-lithostitched OPEN-L5) and Horizon keeps vanilla's
layouts, so the `lithostitched-probe` profile checks its pool elements' limits instead of the layout: in
every start Horizon builds, no `limited` or `guaranteed` element's template appears more often than its
limit, and how many starts hold one is printed beside the oracle's count.
usage: test_structure_layout_parity.py [--profile lithostitched|betterdungeons|lithostitched-probe]
[--filter REGEX] [--verbose]
"""
import argparse
import gzip
@@ -33,6 +41,7 @@ import re
import shutil
import subprocess
import sys
import zipfile
from pathlib import Path
try:
@@ -55,6 +64,12 @@ PROFILES = {
'betterdungeons': {'side': 'pieces', 'mods': {'yungsapi': 'YungsApi-1.21.1-NeoForge-5.1.9.jar',
'betterdungeons': 'YungsBetterDungeons-1.21.1-NeoForge-5.1.4.jar'},
'carry': ['betterdungeons'], 'structures': r'^betterdungeons:(small|skeleton|zombie)_dungeon$'},
# The probe pack's limited (limit 1) and guaranteed (count 1) elements on vanilla's plains houses.
'lithostitched-probe': {'side': 'pieces', 'mods': {'lithostitched': 'lithostitched-1.8.1-neoforge-21.1.jar'},
'packs': {'probe': 'lithostitched-probe'}, 'carry': ['lithostitched', 'probe'],
'structures': r'^minecraft:village_plains$',
'limits': {'minecraft:village/taiga/houses/taiga_small_house_1': 1,
'minecraft:village/snowy/houses/snowy_library_1': 1}},
}
BUNDLES = {'structure': 'structures', 'structure_set': 'structure_sets', 'template_pool': 'template_pools',
'processor_list': 'processor_lists'}
@@ -101,14 +116,28 @@ CENSUS = ['blockstates', 'item_models', 'recipes', 'loot_tables', 'tags', 'world
'textures', 'sounds', 'classes', 'mixin_configs']
def carry(profile, mods, carried):
def pack_jar(work, mod, profile):
"""The data pack an oracle profile added to its world, as a jar of mod `mod`."""
files = json.loads((ORACLE / 'profiles' / f'{profile}.json').read_text())['config_files']
jar = work / f'{mod}.jar'
with zipfile.ZipFile(jar, 'w') as z:
for path, text in files.items():
parts = Path(path).parts
if parts[:2] == ('world', 'datapacks') and len(parts) > 3:
z.writestr(str(Path(*parts[3:])), text)
return jar
def carry(profile, mods, carried, packs=None):
"""`horizon-port-pack carry` of the profile's mods; the carried mods' data folders."""
work = lab.LOCAL / 'structure-layout' / profile / 'work'
shutil.rmtree(work, ignore_errors=True)
work.mkdir(parents=True)
mods = {key: str(jar_path(jar)) for key, jar in mods.items()}
mods.update({key: str(pack_jar(work, key, name)) for key, name in (packs or {}).items()})
records = [{'key': key, 'name': key, 'version': '', 'path': jar, 'tier': 'data', 'confidence': 'known',
'class': 'content' if key in carried else 'library', 'source': 'file', 'license': '',
'client': '', 'server': '', 'depends': [], 'layer': 0, 'jar': str(jar_path(jar)),
'client': '', 'server': '', 'depends': [], 'layer': 0, 'jar': jar,
'info': {'mod_id': key, 'name': key, 'version': '', 'license': '', 'depends': [],
'census': dict.fromkeys(CENSUS, 0)}}
for key, jar in mods.items()]
@@ -149,7 +178,8 @@ def overlay(profile, data_dirs):
elif path.suffix == '.json' and kind == 'worldgen' and parts[2] in BUNDLES:
text = path.read_text()
if 'horizon:add_to' in text:
additions.append((BUNDLES[parts[2]], json.loads(text)))
value = json.loads(text)
additions.append((value.get('horizon:priority', 1000), BUNDLES[parts[2]], value))
else:
merged[BUNDLES[parts[2]]][f'{namespace}:' + '/'.join(parts[3:])[:-5]] = text
elif path.suffix == '.json' and parts[1:4] == ('tags', 'worldgen', 'biome'):
@@ -158,11 +188,12 @@ def overlay(profile, data_dirs):
if tag in merged['biome_tags'] and not value.get('replace'):
value['values'] = json.loads(merged['biome_tags'][tag])['values'] + value['values']
merged['biome_tags'][tag] = json.dumps(value)
# A carried file with `horizon:add_to` adds its entries to other sets or pools, as the game's
# mod_content.rs merges them.
for bundle, addition in additions:
# A carried file with `horizon:add_to` adds its entries to other sets, pools or processor lists, by
# priority, as the game's mod_content.rs merges them.
lists = {'structure_sets': 'structures', 'template_pools': 'elements', 'processor_lists': 'processors'}
for _, bundle, addition in sorted(additions, key=lambda a: a[0]):
targets = addition['horizon:add_to']
key = 'structures' if bundle == 'structure_sets' else 'elements'
key = lists[bundle]
for target in [targets] if isinstance(targets, str) else targets:
if target in merged[bundle]:
value = json.loads(merged[bundle][target])
@@ -209,6 +240,40 @@ def compare(expected, actual):
return None
def check_limits(binary, resources, results, profile, pattern):
"""Each limited template at most its limit in every start Horizon builds; starts holding it, Horizon
beside the oracle."""
limits, failed = profile['limits'], []
holding = {template: [0, 0] for template in limits}
starts = 0
for path in results:
oracle = json.load(gzip.open(path))
seed = oracle['seed']
wanted = [s for s in oracle['structures'] if s.get('pieces') and re.search(pattern, s['id'])
and re.search(profile['structures'], s['id'])]
chunks = sorted({tuple(s['start_chunk']) for s in wanted})
actual = horizon_starts(binary, resources, seed, 'overworld', chunks)
for start in wanted:
got = actual.get((start['id'], *start['start_chunk']))
if got is None:
print(f'skip seed {seed} {start["id"]} chunk {start["start_chunk"]}: no start in Horizon')
continue
starts += 1
for template, limit in limits.items():
have = sum(p.get('element') == template for p in got)
want = sum(element(p['pool_element'].get('delegate', p['pool_element'])) == template
for p in start['pieces'] if p['id'] == 'minecraft:jigsaw')
holding[template][0] += have > 0
holding[template][1] += want > 0
if have > limit:
failed.append(f'seed {seed} {start["id"]} chunk {start["start_chunk"]}: {have} {template}')
for line in failed:
print(f'FAIL {line} (over its limit)')
for template, (ours, theirs) in holding.items():
print(f'{template} (limit {limits[template]}): in {ours} of {starts} Horizon starts; the oracle: {theirs}')
return 1 if failed or not starts else 0
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--profile', default='lithostitched', choices=sorted(PROFILES))
@@ -221,13 +286,17 @@ def main():
return 0
profile = PROFILES[args.profile]
missing = [jar for jar in profile['mods'].values() if not jar_path(jar).exists()]
missing += [f'profiles/{n}.json' for n in profile.get('packs', {}).values() if not (ORACLE / 'profiles' / f'{n}.json').exists()]
if missing:
print(f'SKIP: mod jars not in {JARS}: {", ".join(missing)}')
print(f'SKIP: not in {ORACLE}: {", ".join(missing)}')
return 0
resources = lab.RESOURCES
if profile['carry']:
resources = overlay(args.profile, carry(args.profile, profile['mods'], profile['carry']))
data = carry(args.profile, profile['mods'], profile['carry'], profile.get('packs'))
resources = overlay(args.profile, data)
binary = probe_binary()
if 'limits' in profile:
return check_limits(binary, resources, results, profile, args.filter)
matched, known, failed = 0, 0, []
for path in results:
oracle = json.load(gzip.open(path))