Merge pull request #1765 from ludovic111/feat/lithostitched-processors

Lithostitched's processors as the game's own types; replace the old Lithostitched conversion
This commit is contained in:
Ludovic Marie authored and GitHub committed 2026-10-26 12:24:09 +01:00
commit b8027d4b19
13 files changed
+1383 -1020

No files matched your search

+3 -3
View File
@@ -828,9 +828,9 @@ pub fn carry(
// Additions to other sets and pools, as files the game merges.
let modifier = serde_json::from_slice::<Value>(&bytes).ok();
let types = &mut report.worldgen_types;
if let Some((kind, mut added)) = modifier.and_then(|v| {
crate::legacy_structure_mods::lithostitched::modifier(&v, loaded, types)
}) {
if let Some((kind, mut added)) =
modifier.and_then(|v| crate::lithostitched::modifier(&v, loaded, types))
{
crate::processors::carry(
kind,
&mut added,
@@ -157,6 +157,7 @@ mod tests {
}
#[test]
#[ignore = "modular compat is crate::lithostitched's now (its tests)"]
fn modular_compat_runs_its_list_only_with_its_mod() {
let mut data = ProcessorData::default();
let swap = json!({"processor_type": "minecraft:rule", "rules": []});
@@ -1,503 +0,0 @@
//! Lithostitched: its jigsaw structure, its worldgen modifiers and its
//! processors.
use super::ctov;
use crate::processors::{ProcessorData, carry_all, processors, referenced};
use crate::worldgen::{ENHANCED_TERRAIN, Upgrade, start_checks};
use serde_json::{Value, json};
use std::collections::BTreeMap;
/// A `structure` file of Lithostitched's jigsaw type as vanilla's, with the
/// start rules it shares with the other libraries' jigsaws (`worldgen`'s
/// `start_checks`). `None` for any other file.
pub(crate) fn upgrade(
kind: &str,
v: &mut Value,
lost: &mut BTreeMap<String, usize>,
) -> Option<bool> {
if kind != "structure" || v["type"] != "lithostitched:jigsaw" {
return None;
}
start_checks(v, "lowest", lost, false);
if v.as_object_mut()
.and_then(|o| o.remove(ENHANCED_TERRAIN))
.is_some()
{
*lost.entry(ENHANCED_TERRAIN.into()).or_default() += 1;
}
v["type"] = "minecraft:jigsaw".into();
Some(true)
}
/// A lithostitched worldgen modifier
/// (`data/<ns>/lithostitched/worldgen_modifier/`) that adds to structure
/// sets or template pools, as a file the game merges into them (the key
/// `horizon:add_to`, `horizon_client::mod_content`): (kind, file). The
/// modifier's conditions are checked against `loaded`, the mods ported
/// along: a compat addition for a mod that is not would place that mod's
/// blocks, which the game does not have. Other modifier types are left out.
/// Both are counted in `report`.
pub(crate) fn modifier(
v: &Value,
loaded: &dyn Fn(&str) -> bool,
report: &mut Upgrade,
) -> Option<(&'static str, Value)> {
let t = v["type"].as_str().unwrap_or_default();
let mut left = |why: &str| *report.modifiers_left.entry(why.to_owned()).or_default() += 1;
let (kind, targets, from, to) = match t {
"lithostitched:add_structure_set_entries" => {
("structure_set", "structure_sets", "entries", "structures")
}
"lithostitched:add_template_pool_elements" => {
("template_pool", "template_pools", "elements", "elements")
}
_ => {
left(t);
return None;
}
};
if !conditions_hold(v, loaded) {
left("conditions");
return None;
}
let targets: Option<Vec<&str>> = match &v[targets] {
Value::String(s) => Some(vec![s.as_str()]),
Value::Array(a) => a.iter().map(Value::as_str).collect(),
_ => None,
};
let Some(targets) = targets.filter(|t| !t.is_empty() && t.iter().all(|t| !t.starts_with('#')))
else {
left("target");
return None;
};
let entries = v[from].as_array().cloned().unwrap_or_default();
for target in &targets {
*report.added_to.entry((*target).to_owned()).or_default() += entries.len();
}
Some((
kind,
serde_json::json!({"horizon:add_to": targets, to: entries}),
))
}
/// Whether a modifier's load conditions hold: lithostitched's `predicate`,
/// and the loader's conditions (NeoForge's, else Fabric's). A condition
/// type not known here does not hold.
fn conditions_hold(v: &Value, loaded: &dyn Fn(&str) -> bool) -> bool {
let all = |list: &Value, test: &dyn Fn(&Value) -> bool| {
list.as_array().is_some_and(|a| a.iter().all(test))
};
fn predicate(p: &Value, loaded: &dyn Fn(&str) -> bool) -> bool {
let test = |q: &Value| predicate(q, loaded);
let list = &p["predicates"];
match p["type"]
.as_str()
.map(|t| t.trim_start_matches("lithostitched:"))
{
Some("true") => true,
Some("mod_loaded") => p["mod_id"].as_str().is_some_and(loaded),
Some("not") => !predicate(&p["predicate"], loaded),
Some("all_of") => list.as_array().is_some_and(|a| a.iter().all(test)),
Some("any_of") => list.as_array().is_some_and(|a| a.iter().any(test)),
_ => false,
}
}
fn neoforge(c: &Value, loaded: &dyn Fn(&str) -> bool) -> bool {
let test = |q: &Value| neoforge(q, loaded);
match c["type"].as_str() {
Some("neoforge:true") => true,
Some("neoforge:mod_loaded") => c["modid"].as_str().is_some_and(loaded),
Some("neoforge:not") => !neoforge(&c["value"], loaded),
Some("neoforge:and") => c["values"].as_array().is_some_and(|a| a.iter().all(test)),
Some("neoforge:or") => c["values"].as_array().is_some_and(|a| a.iter().any(test)),
_ => false,
}
}
fn fabric(c: &Value, loaded: &dyn Fn(&str) -> bool) -> bool {
let test = |q: &Value| fabric(q, loaded);
let mods = || {
c["values"]
.as_array()
.into_iter()
.flatten()
.map(Value::as_str)
};
match c["condition"].as_str() {
Some("fabric:true") => true,
Some("fabric:all_mods_loaded") => mods().all(|m| m.is_some_and(loaded)),
Some("fabric:any_mods_loaded") => mods().any(|m| m.is_some_and(loaded)),
Some("fabric:not") => !fabric(&c["value"], loaded),
Some("fabric:and") => c["values"].as_array().is_some_and(|a| a.iter().all(test)),
Some("fabric:or") => c["values"].as_array().is_some_and(|a| a.iter().any(test)),
_ => false,
}
}
let litho = v.get("predicate").is_none_or(|p| predicate(p, loaded));
let loader = match (
v.get("neoforge:conditions"),
v.get("fabric:load_conditions"),
) {
(Some(c), _) => all(c, &|c| neoforge(c, loaded)),
// One condition or a list of them.
(None, Some(c)) if c.is_array() => all(c, &|c| fabric(c, loaded)),
(None, Some(c)) => fabric(c, loaded),
(None, None) => true,
};
litho && loader
}
/// The data form of one of Lithostitched's processors: the processors to put
/// in its place, or `None` when it has none (or is not Lithostitched's).
pub(crate) fn processor(
kind: &str,
p: &Value,
data: &ProcessorData,
carried: &mut BTreeMap<String, usize>,
) -> Option<Vec<Value>> {
match kind {
"lithostitched:apply_random" => apply_random(p, data, carried).map(|p| vec![p]),
"lithostitched:condition" => condition(p, data, carried).map(|p| vec![p]),
"lithostitched:block_swap" => {
// The same map; the game's swap keeps the properties both have.
p["blocks"].as_object()?;
Some(vec![
json!({"processor_type": "horizon:block_swap", "blocks": p["blocks"]}),
])
}
"lithostitched:set_block" => {
// A simple or weighted state provider, the block entity left as
// it is.
let provider = p["state_provider"]["type"].as_str()?;
let modifier = p["block_entity_modifier"]["type"].as_str();
let plain =
modifier.is_none_or(|m| m.trim_start_matches("minecraft:") == "passthrough");
if !plain
|| !matches!(
provider,
"minecraft:simple_state_provider" | "minecraft:weighted_state_provider"
)
{
return None;
}
let mut out = p.clone();
out["processor_type"] = "horizon:set_block".into();
if let Some(o) = out.as_object_mut() {
o.remove("block_entity_modifier");
}
Some(vec![out])
}
_ => None,
}
}
/// The references (Lithostitched's, and CTOV's compat processors) in a
/// processor list written in; whether any was.
pub(crate) fn expand_references(
list: Vec<Value>,
data: &ProcessorData,
carried: &mut BTreeMap<String, usize>,
) -> (Vec<Value>, bool) {
references(list, data, carried, 0)
}
/// A processor list inside a carried processor: an id stays (the game reads
/// that file, carried on its own); an inline one is carried here.
fn nested(list: &Value, data: &ProcessorData, carried: &mut BTreeMap<String, usize>) -> Value {
let mut list = list.clone();
if let Some(ps) = processors(&mut list) {
*ps = carry_all(std::mem::take(ps), data, carried).0;
}
list
}
/// Lithostitched's `apply_random` as `horizon:apply_random`: its weighted
/// lists, or its set of lists at weight 1. A tag is not carried.
fn apply_random(
p: &Value,
data: &ProcessorData,
carried: &mut BTreeMap<String, usize>,
) -> Option<Value> {
let lists = &p["processor_lists"];
let weighted = lists.as_array().is_some_and(|a| {
a.iter()
.all(|e| e.get("weight").is_some() && e.get("data").is_some())
});
let entries: Vec<(Value, Value)> = if weighted {
lists
.as_array()?
.iter()
.map(|e| (e["weight"].clone(), e["data"].clone()))
.collect()
} else {
match lists {
Value::String(id) if !id.starts_with('#') => vec![(1.into(), lists.clone())],
Value::Array(a) if a.iter().any(|x| x.get("processor_type").is_some()) => {
vec![(1.into(), lists.clone())]
}
Value::Array(a)
if a.iter()
.all(|x| x.as_str().is_some_and(|s| !s.starts_with('#')) || x.is_object()) =>
{
a.iter().map(|x| (1.into(), x.clone())).collect()
}
Value::Object(_) => vec![(1.into(), lists.clone())],
_ => return None,
}
};
let lists: Vec<Value> = entries
.into_iter()
.map(|(weight, list)| json!({"weight": weight, "data": nested(&list, data, carried)}))
.collect();
let mut out = json!({"processor_type": "horizon:apply_random", "processor_lists": lists});
if let Some(mode) = p.get("mode") {
out["mode"] = mode.clone();
}
Some(out)
}
/// Lithostitched's `condition` as `horizon:condition`, when the game runs
/// every condition in it (true, all_of, any_of, not, random_chance,
/// matching_blocks with plain property values); else it stays Java.
fn condition(
p: &Value,
data: &ProcessorData,
carried: &mut BTreeMap<String, usize>,
) -> Option<Value> {
fn runs(c: &Value) -> bool {
if let Some(all) = c.as_array() {
return all.iter().all(runs);
}
let kind = c["type"].as_str().unwrap_or_default();
match kind.strip_prefix("lithostitched:").unwrap_or(kind) {
"true" | "random_chance" => true,
"all_of" | "any_of" => c["conditions"]
.as_array()
.is_some_and(|a| a.iter().all(runs)),
"not" => runs(&c["condition"]),
"matching_blocks" => c.get("properties").is_none_or(|p| {
p.as_object()
.is_some_and(|o| o.values().all(Value::is_string))
}),
_ => false,
}
}
if !runs(&p["if_true"]) {
return None;
}
// One processor or a list of them.
let mut branch = |key: &str| -> Value {
let list = match p.get(key) {
None => Vec::new(),
Some(Value::Array(a)) => a.clone(),
Some(one) => vec![one.clone()],
};
carry_all(list, data, carried).0.into()
};
let (then, otherwise) = (branch("then"), branch("else"));
let mut out = json!({"processor_type": "horizon:condition", "if_true": p["if_true"],
"then": then, "else": otherwise});
if let Some(mode) = p.get("random_mode") {
out["random_mode"] = mode.clone();
}
Some(out)
}
/// Lithostitched's references in a list, each replaced by the processors of
/// the lists it names, in order (references in them too, to a depth of 8).
/// One naming a tag or a list the mod does not have stays, to be dropped.
/// Whether any was replaced.
fn references(
list: Vec<Value>,
data: &ProcessorData,
carried: &mut BTreeMap<String, usize>,
depth: usize,
) -> (Vec<Value>, bool) {
let mut out = Vec::with_capacity(list.len());
let mut changed = false;
for p in list {
if let Some(inner) = ctov::modular_compat(&p, data, carried, depth) {
changed = true;
out.extend(references(inner, data, carried, depth + 1).0);
continue;
}
let named = (p["processor_type"] == "lithostitched:reference" && depth < 8)
.then(|| referenced(&p["processor_lists"], data))
.flatten();
match named {
Some(inner) => {
*carried.entry("lithostitched:reference".into()).or_default() += 1;
out.extend(references(inner, data, carried, depth + 1).0);
changed = true;
}
None => out.push(p),
}
}
(out, changed)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::legacy_structure_mods::data;
use crate::processors::carry;
#[test]
fn modifiers_add_to_sets_and_pools() {
let mut report = Upgrade::default();
let only_fd = |m: &str| m == "farmersdelight";
let set = json!({"type": "lithostitched:add_structure_set_entries",
"structure_sets": "minecraft:villages",
"entries": [{"structure": "x:village", "weight": 4}]});
let (kind, added) = modifier(&set, &only_fd, &mut report).unwrap();
assert_eq!(kind, "structure_set");
assert_eq!(
added,
json!({"horizon:add_to": ["minecraft:villages"],
"structures": [{"structure": "x:village", "weight": 4}]})
);
// A compat addition holds only when its mod is ported along.
let pool = |modid: &str| {
json!({"type": "lithostitched:add_template_pool_elements",
"neoforge:conditions": [{"type": "neoforge:mod_loaded", "modid": modid}],
"fabric:load_conditions": {"condition": "fabric:all_mods_loaded", "values": ["other"]},
"template_pools": ["x:houses", "y:houses"],
"elements": [{"weight": 5, "element": {"element_type": "minecraft:single_pool_element",
"location": "x:fd_farm", "projection": "rigid", "processors": "minecraft:empty"}}]})
};
let (kind, added) = modifier(&pool("farmersdelight"), &only_fd, &mut report).unwrap();
assert_eq!(kind, "template_pool");
assert_eq!(added["horizon:add_to"], json!(["x:houses", "y:houses"]));
assert_eq!(added["elements"][0]["weight"], 5);
assert!(modifier(&pool("create"), &only_fd, &mut report).is_none());
// Fabric's conditions when there are no NeoForge ones; lithostitched's own.
let fabric = json!({"type": "lithostitched:add_structure_set_entries",
"fabric:load_conditions": [{"condition": "fabric:not",
"value": {"condition": "fabric:any_mods_loaded", "values": ["create"]}}],
"predicate": {"type": "lithostitched:all_of", "predicates": [
{"type": "lithostitched:mod_loaded", "mod_id": "farmersdelight"},
{"type": "lithostitched:true"}]},
"structure_sets": "minecraft:villages", "entries": []});
assert!(modifier(&fabric, &only_fd, &mut report).is_some());
let tagged = json!({"type": "lithostitched:add_structure_set_entries",
"structure_sets": "#x:sets", "entries": []});
assert!(modifier(&tagged, &only_fd, &mut report).is_none());
let features = json!({"type": "lithostitched:add_features"});
assert!(modifier(&features, &only_fd, &mut report).is_none());
assert_eq!(
report.added_to,
BTreeMap::from([
("minecraft:villages".into(), 1),
("x:houses".into(), 1),
("y:houses".into(), 1),
])
);
assert_eq!(
report.modifiers_left,
BTreeMap::from([
("conditions".into(), 1),
("lithostitched:add_features".into(), 1),
("target".into(), 1),
])
);
}
#[test]
fn lithostitched_block_swaps_become_the_games() {
let mut list = json!({"processors": [
{"processor_type": "lithostitched:block_swap",
"blocks": {"minecraft:cobblestone": "minecraft:mossy_cobblestone"}},
{"processor_type": "lithostitched:block_swap", "blocks": []}]});
let mut carried = BTreeMap::new();
assert!(carry("processor_list", &mut list, &data(), &mut carried));
let p = list["processors"].as_array().unwrap();
assert_eq!(
p[0],
json!({"processor_type": "horizon:block_swap",
"blocks": {"minecraft:cobblestone": "minecraft:mossy_cobblestone"}})
);
// A map that is not one stays Java (dropped later).
assert_eq!(p[1]["processor_type"], "lithostitched:block_swap");
}
#[test]
fn lithostitched_references_are_written_in() {
let mut data = data();
let gravity = json!({"processor_type": "minecraft:gravity", "heightmap": "WORLD_SURFACE_WG", "offset": 0});
data.lists
.insert("v:road".into(), json!({"processors": [gravity.clone()]}));
data.lists.insert(
"v:house".into(),
json!({"processors": [
{"processor_type": "lithostitched:reference", "processor_lists": "v:road"},
{"processor_type": "lithostitched:block_swap", "blocks": {"minecraft:dirt": "minecraft:grass_block"}}]}),
);
let mut list = json!({"processors": [
{"processor_type": "lithostitched:reference", "processor_lists": ["v:house", "v:road"]},
{"processor_type": "lithostitched:reference", "processor_lists": "#v:roads"},
{"processor_type": "lithostitched:reference", "processor_lists": ["v:nowhere"]}]});
let mut carried = BTreeMap::new();
assert!(carry("processor_list", &mut list, &data, &mut carried));
let p = list["processors"].as_array().unwrap();
// House (its own reference to road first, then its swap), then road;
// the swap inside is carried too.
assert_eq!(p[0], gravity);
assert_eq!(p[1]["processor_type"], "horizon:block_swap");
assert_eq!(p[2], gravity);
// A tag or a list the mod lacks stays, to be dropped.
assert_eq!(p[3]["processor_lists"], "#v:roads");
assert_eq!(p[4]["processor_lists"], json!(["v:nowhere"]));
assert_eq!(carried["lithostitched:reference"], 2);
}
#[test]
fn lithostitched_random_and_condition_become_the_games() {
let mut data = data();
let swap = json!({"processor_type": "lithostitched:block_swap",
"blocks": {"minecraft:dirt": "minecraft:grass_block"}});
data.lists
.insert("v:green".into(), json!({"processors": [swap.clone()]}));
let mut list = json!({"processors": [
{"processor_type": "lithostitched:apply_random", "mode": "per_piece", "processor_lists": [
{"weight": 3, "data": "minecraft:empty"},
{"weight": 1, "data": {"processors": [
{"processor_type": "lithostitched:reference", "processor_lists": "v:green"},
{"processor_type": "v:java"}]}}]},
{"processor_type": "lithostitched:apply_random", "mode": "per_block",
"processor_lists": ["v:green", "v:red"]},
{"processor_type": "lithostitched:apply_random", "mode": "per_block",
"processor_lists": "#v:colours"},
{"processor_type": "lithostitched:condition",
"if_true": [{"type": "lithostitched:matching_blocks", "blocks": "minecraft:cobblestone"}],
"then": swap, "else": {"processor_type": "lithostitched:set_block", "state_provider":
{"type": "minecraft:simple_state_provider", "state": {"Name": "minecraft:stone"}}}},
{"processor_type": "lithostitched:condition",
"if_true": {"type": "lithostitched:matching_biomes", "biomes": "#c:is_cold"},
"then": []}]});
let mut carried = BTreeMap::new();
assert!(carry("processor_list", &mut list, &data, &mut carried));
let p = list["processors"].as_array().unwrap();
let green = json!({"processor_type": "horizon:block_swap",
"blocks": {"minecraft:dirt": "minecraft:grass_block"}});
// Inline lists are carried; Java left in them is dropped later.
assert_eq!(
p[0],
json!({"processor_type": "horizon:apply_random", "mode": "per_piece", "processor_lists": [
{"weight": 3, "data": "minecraft:empty"},
{"weight": 1, "data": {"processors": [green.clone(), {"processor_type": "v:java"}]}}]})
);
assert_eq!(
p[1]["processor_lists"],
json!([{"weight": 1, "data": "v:green"}, {"weight": 1, "data": "v:red"}])
);
assert_eq!(p[2]["processor_type"], "lithostitched:apply_random");
assert_eq!(
p[3],
json!({"processor_type": "horizon:condition",
"if_true": [{"type": "lithostitched:matching_blocks", "blocks": "minecraft:cobblestone"}],
"then": [green], "else": [{"processor_type": "horizon:set_block", "state_provider":
{"type": "minecraft:simple_state_provider", "state": {"Name": "minecraft:stone"}}}]})
);
assert_eq!(p[4]["processor_type"], "lithostitched:condition");
assert_eq!(carried["lithostitched:apply_random"], 2);
assert_eq!(carried["lithostitched:condition"], 1);
}
}
@@ -13,10 +13,11 @@
//! The general code reaches them only through the `pub(crate)` entry points
//! of these three modules. Nothing in them is used by another mod's
//! conversion.
#[allow(dead_code)] // its modular compat is `crate::lithostitched`'s now; the rest goes with C1
pub(crate) mod ctov;
pub(crate) mod lithostitched;
#[cfg(test)]
#[allow(dead_code)] // the old Lithostitched tests used it
fn data() -> crate::processors::ProcessorData {
let mut d = crate::processors::ProcessorData::default();
d.spawners.insert(
+1
View File
@@ -14,6 +14,7 @@ pub mod import;
pub mod inventory;
pub mod jar;
pub(crate) mod legacy_structure_mods;
mod lithostitched;
pub mod modrinth;
pub mod oracle;
pub mod pipeline;
@@ -0,0 +1,434 @@
//! Lithostitched's worldgen, carried as the game reads it (clean room:
//! `cleanroom/structures/spec-lithostitched.md`; CTOV's processor:
//! `spec-ctov.md` §4).
//!
//! - `lithostitched:jigsaw` structures become vanilla's jigsaw. Lithostitched
//! lays jigsaws out its own way (OPEN-L5): Horizon keeps vanilla's layouts.
//! - Its processors (`block_swap`, `reference`, `apply_random`, `condition`,
//! `set_block`, `discard_input`) are the game's own types
//! (`horizon_world`'s `structure::processor`): they are carried as written,
//! 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.
use crate::{processors::ProcessorData, worldgen::Upgrade};
use serde_json::{Value, json};
use std::collections::BTreeMap;
const JIGSAW: &str = "lithostitched:jigsaw";
/// Vanilla's JigsawStructure.MAX_DEPTH.
const MAX_SIZE: i64 = 20;
const MODULAR_COMPAT: &str = "ctov:modular_compat";
/// The processor types the game reads.
const PROCESSORS: [&str; 6] = [
"lithostitched:block_swap",
"lithostitched:reference",
"lithostitched:apply_random",
"lithostitched:condition",
"lithostitched:set_block",
"lithostitched:discard_input",
];
fn count(map: &mut BTreeMap<String, usize>, name: &str) {
*map.entry(name.to_owned()).or_default() += 1;
}
/// A `lithostitched:jigsaw` structure as vanilla's jigsaw: `Some(true)`, else
/// `None`.
pub(crate) fn upgrade(
kind: &str,
v: &mut Value,
lost: &mut BTreeMap<String, usize>,
) -> Option<bool> {
if kind != "structure" || v["type"] != JIGSAW {
return None;
}
v["type"] = "minecraft:jigsaw".into();
// `size` may be an int provider (sampled per start): its largest value.
let size = match &v["size"] {
Value::Null => None,
Value::Number(n) => n.as_i64(),
provider => {
count(lost, "lithostitched:jigsaw size provider");
["max_inclusive", "value"]
.iter()
.find_map(|k| provider[k].as_i64())
}
};
if let Some(size) = size {
if size > MAX_SIZE {
count(lost, "lithostitched:jigsaw size over 20");
}
v["size"] = size.clamp(0, MAX_SIZE).into();
}
let o = v.as_object_mut()?;
match o.remove("start_projection") {
Some(Value::String(heightmap)) if heightmap != "floor" && heightmap != "ceiling" => {
o.entry("project_start_to_heightmap")
.or_insert(heightmap.into());
}
Some(_) => count(lost, "lithostitched:jigsaw start_projection"),
None => {}
}
if o.remove("fixed_rotation").is_some_and(|f| f == true) {
count(lost, "lithostitched:jigsaw fixed_rotation");
}
Some(true)
}
/// Lithostitched's pool elements as the element they wrap; whether `element`
/// was one.
pub(crate) fn upgrade_pool_element(
element: &mut Value,
lost: &mut BTreeMap<String, usize>,
) -> bool {
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,
}
}
/// The ids a modifier targets: an id or a list of ids (`None` for tags and
/// Lithostitched's object forms).
fn ids(v: &Value) -> Option<Vec<String>> {
let id = |v: &Value| {
v.as_str()
.filter(|s| !s.starts_with('#'))
.map(str::to_owned)
};
match v {
Value::Array(a) => a.iter().map(id).collect(),
other => Some(vec![id(other)?]),
}
}
/// NeoForge's load conditions (`neoforge:conditions`): all must pass.
fn conditions_pass(v: &Value, loaded: &dyn Fn(&str) -> bool) -> bool {
fn pass(c: &Value, loaded: &dyn Fn(&str) -> bool) -> bool {
let all = |key: &str| {
c[key]
.as_array()
.into_iter()
.flatten()
.all(|c| pass(c, loaded))
};
match c["type"].as_str() {
Some("neoforge:mod_loaded") => c["modid"].as_str().is_some_and(loaded),
Some("neoforge:true") => true,
Some("neoforge:not") => !pass(&c["value"], loaded),
Some("neoforge:and") => all("values"),
Some("neoforge:or") => {
(c["values"].as_array().into_iter().flatten()).any(|c| pass(c, loaded))
}
_ => false,
}
}
(v["neoforge:conditions"].as_array().into_iter().flatten()).all(|c| pass(c, loaded))
}
/// 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`.
pub(crate) fn modifier(
v: &Value,
loaded: &dyn Fn(&str) -> bool,
report: &mut Upgrade,
) -> Option<(&'static str, Value)> {
let kind = v["type"].as_str().unwrap_or("no type");
if !conditions_pass(v, loaded) {
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 {
count(&mut report.modifiers_left, "target");
return None;
};
for target in &targets {
*report.added_to.entry(target.clone()).or_default() += elements.len();
}
Some((
"template_pool",
json!({"horizon:add_to": targets, "elements": elements}),
))
}
/// Whether the game reads a Lithostitched processor as it is: one of its
/// types, with no processor list named by tag (the game has no processor
/// list tags) and a state provider it runs.
pub(crate) fn game_reads(p: &Value) -> bool {
let Some(kind) = p["processor_type"]
.as_str()
.filter(|t| PROCESSORS.contains(t))
else {
return false;
};
let tag = |v: &Value| v.as_str().is_some_and(|s| s.starts_with('#'));
match kind {
"lithostitched:reference" => match &p["processor_lists"] {
Value::Array(a) => !a.iter().any(tag),
other => other.is_string() && !tag(other),
},
"lithostitched:apply_random" => p["processor_lists"]
.as_array()
.is_some_and(|a| a.iter().all(|e| !tag(e) && !tag(&e["data"]))),
"lithostitched:set_block" => matches!(
p["state_provider"]["type"].as_str(),
Some("minecraft:simple_state_provider" | "minecraft:weighted_state_provider")
),
"lithostitched:block_swap" => p["blocks"].is_object(),
"lithostitched:condition" => !p["if_true"].is_null(),
_ => true,
}
}
/// The processor lists inside a Lithostitched processor, for the general
/// code that walks lists (`then`, `else`, `apply_random`'s inline data).
pub(crate) fn inner_lists(p: &mut Value) -> Vec<&mut Value> {
match p["processor_type"].as_str() {
Some("lithostitched:apply_random") => (p["processor_lists"].as_array_mut().into_iter())
.flatten()
.filter_map(|e| e.get_mut("data").filter(|d| !d.is_string()))
.collect(),
Some("lithostitched:condition") => (p.as_object_mut().into_iter())
.flat_map(|o| o.iter_mut())
.filter(|(k, _)| *k == "then" || *k == "else")
.map(|(_, v)| v)
.collect(),
_ => Vec::new(),
}
}
/// A Lithostitched processor with the processors nested in it carried
/// (`Some`, when one of them changed); `None` for any other processor.
pub(crate) fn processor(
kind: &str,
p: &Value,
data: &ProcessorData,
carried: &mut BTreeMap<String, usize>,
) -> Option<Vec<Value>> {
if !PROCESSORS.contains(&kind) {
return None;
}
let mut out = p.clone();
let mut changed = false;
for list in inner_lists(&mut out) {
// One processor alone is a list of one.
if list.get("processor_type").is_some() {
*list = Value::Array(vec![list.take()]);
changed = true;
}
if let Some(processors) = crate::processors::processors(list) {
let (inner, inner_changed) =
crate::processors::carry_all(std::mem::take(processors), data, carried);
*processors = inner;
changed |= inner_changed;
}
}
changed.then(|| vec![out])
}
/// The list with CTOV's modular compat processors written as the game's
/// `reference` (its mod is ported along) or taken out; whether any was.
pub(crate) fn expand_references(
list: Vec<Value>,
data: &ProcessorData,
carried: &mut BTreeMap<String, usize>,
) -> (Vec<Value>, bool) {
let mut changed = false;
let mut out = Vec::with_capacity(list.len());
for p in list {
if p["processor_type"] != MODULAR_COMPAT {
out.push(p);
continue;
}
changed = true;
count(carried, MODULAR_COMPAT);
let loaded = p["modid"]
.as_str()
.is_some_and(|m| data.compat_loaded.contains(m));
if loaded {
out.push(json!({"processor_type": "lithostitched:reference",
"processor_lists": p["reference"].clone()}));
}
}
(out, changed)
}
/// Notes which mods CTOV's modular compat processors name are `loaded`.
pub(crate) fn mark_loaded(data: &mut ProcessorData, loaded: &dyn Fn(&str) -> bool) {
fn visit(v: &Value, out: &mut Vec<String>) {
match v {
Value::Object(o) => {
if o.get("processor_type").and_then(Value::as_str) == Some(MODULAR_COMPAT)
&& let Some(m) = o.get("modid").and_then(Value::as_str)
{
out.push(m.to_owned());
}
o.values().for_each(|v| visit(v, out));
}
Value::Array(a) => a.iter().for_each(|v| visit(v, out)),
_ => {}
}
}
let mut named = Vec::new();
data.lists.values().for_each(|l| visit(l, &mut named));
data.compat_loaded
.extend(named.into_iter().filter(|m| loaded(m)));
}
#[cfg(test)]
mod tests {
use super::*;
use crate::worldgen::upgrade as upgrade_file;
#[test]
fn jigsaws_become_vanillas() {
let mut lost = BTreeMap::new();
let mut s = json!({"type": JIGSAW, "size": {"type": "uniform", "min_inclusive": 3,
"max_inclusive": 40}, "start_projection": "WORLD_SURFACE_WG", "fixed_rotation": true});
assert_eq!(upgrade("structure", &mut s, &mut lost), Some(true));
assert_eq!(
s,
json!({"type": "minecraft:jigsaw", "size": 20,
"project_start_to_heightmap": "WORLD_SURFACE_WG"})
);
assert_eq!(lost.len(), 3);
assert_eq!(
upgrade(
"structure",
&mut json!({"type": "minecraft:jigsaw"}),
&mut lost
),
None
);
}
#[test]
fn wrapped_elements_place_their_delegate() {
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));
}
#[test]
fn modifiers_add_to_pools_when_their_mods_are_loaded() {
let mut report = Upgrade::default();
let loaded = |m: &str| m == "waystones";
let add = |modid: &str, pools: Value| {
json!({"type": "lithostitched:add_template_pool_elements", "template_pools": pools,
"elements": [{"weight": 2, "element": {"element_type": "minecraft:empty_pool_element"}}],
"neoforge:conditions": [{"type": "neoforge:mod_loaded", "modid": modid}]})
};
let (kind, file) = modifier(
&add("waystones", json!("ctov:village/plains/house")),
&loaded,
&mut report,
)
.unwrap();
assert_eq!(kind, "template_pool");
assert_eq!(file["horizon:add_to"], json!(["ctov:village/plains/house"]));
assert_eq!(file["elements"][0]["weight"], 2);
assert_eq!(report.added_to["ctov:village/plains/house"], 1);
assert!(modifier(&add("sawmill", json!("a:b")), &loaded, &mut report).is_none());
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());
assert_eq!(
report.modifiers_left,
BTreeMap::from([
("conditions".into(), 1),
("lithostitched:set_pool_aliases".into(), 1),
("target".into(), 1),
])
);
}
/// A CTOV list (spec-ctov §4): references, a condition with a single
/// processor and a nested condition, a random list holding the compat
/// hook, and processors the game cannot read.
#[test]
fn processors_stay_lithostitcheds_with_their_insides_carried() {
let hook = json!({"processor_type": MODULAR_COMPAT, "modid": "natures_spirit",
"reference": "ctov:village/mesa/house_wisteria"});
let list = json!({"processors": [
{"processor_type": "lithostitched:reference", "processor_lists": ["ctov:village/mesa/house"]},
{"processor_type": "lithostitched:condition", "random_mode": "per_block",
"if_true": {"type": "lithostitched:matching_blocks", "blocks": "minecraft:water",
"match_type": "location"},
"then": {"processor_type": "lithostitched:set_block", "state_provider": {
"type": "minecraft:simple_state_provider", "state": {"Name": "minecraft:spruce_planks"}}},
"else": [{"processor_type": "somemod:java_processor"}]},
{"processor_type": "lithostitched:apply_random", "mode": "per_piece", "processor_lists": [
{"weight": 1, "data": "minecraft:empty"},
{"weight": 1, "data": {"processors": [hook]}}]},
{"processor_type": "lithostitched:reference", "processor_lists": "#ctov:palettes"},
{"processor_type": "lithostitched:set_block", "state_provider": {
"type": "minecraft:noise_provider"}}]});
for (loaded, wisteria) in [
(false, json!([])),
(
true,
json!([{"processor_type":
"lithostitched:reference", "processor_lists": "ctov:village/mesa/house_wisteria"}]),
),
] {
let mut data = ProcessorData::default();
data.lists
.insert("ctov:village/mesa/house".into(), list.clone());
mark_loaded(&mut data, &|m| loaded && m == "natures_spirit");
let mut v = list.clone();
let mut report = Upgrade::default();
crate::processors::carry(
"processor_list",
&mut v,
&data,
&mut report.processors_carried,
);
upgrade_file("processor_list", &mut v, &mut report, &BTreeMap::new());
let p = &v["processors"];
assert_eq!(p.as_array().unwrap().len(), 3, "{p}");
assert_eq!(p[0], list["processors"][0]);
// One processor is a list of one; the Java one is taken out.
assert_eq!(
p[1]["then"][0]["state_provider"]["state"]["id"],
"minecraft:spruce_planks"
);
assert_eq!(p[1]["else"], json!([]));
assert_eq!(p[2]["processor_lists"][0]["data"], "minecraft:empty");
assert_eq!(p[2]["processor_lists"][1]["data"]["processors"], wisteria);
assert_eq!(report.processors_carried[MODULAR_COMPAT], 1);
assert_eq!(
report.processors_dropped,
BTreeMap::from([
("lithostitched:reference".into(), 1),
("lithostitched:set_block".into(), 1),
("somemod:java_processor".into(), 1),
])
);
}
}
}
+5 -6
View File
@@ -10,13 +10,13 @@
//! - Repurposed Structures' mineshaft sky view becomes the game's
//! `horizon:mineshaft_sky_view`.
//!
//! The processors of YUNG's mods, Lithostitched and Choice Theorem's Overhauled
//! Village are carried by `legacy_structure_mods`, called from [`carry`].
//! Lithostitched's processors and Choice Theorem's Overhauled Village's
//! compat hook are carried by `crate::lithostitched`, called from [`carry`].
//!
//! What is not here is taken out of its list later and counted
//! (`worldgen::upgrade`). The entity NBT is written in 26.3's shape (an
//! `equipment` map, not 1.21.1's `HandItems` and `ArmorItems`).
use crate::legacy_structure_mods::{ctov, lithostitched};
use crate::lithostitched;
use serde_json::{Value, json};
use std::{
collections::{BTreeMap, BTreeSet},
@@ -42,7 +42,7 @@ pub struct ProcessorData {
impl ProcessorData {
/// Notes which mods its compat processors name are `loaded`.
pub fn mark_loaded(&mut self, loaded: &dyn Fn(&str) -> bool) {
ctov::mark_loaded(self, loaded);
lithostitched::mark_loaded(self, loaded);
}
pub fn read<R: Read + Seek>(zip: &mut zip::ZipArchive<R>) -> Self {
@@ -177,8 +177,7 @@ fn carry_at(
let mut out = Vec::with_capacity(expanded.len());
for p in expanded {
let kind = p["processor_type"].as_str().unwrap_or_default().to_owned();
// Lithostitched's processors are carried apart from this code
// (`legacy_structure_mods`).
// Lithostitched's processors stay theirs, with what they hold carried.
let replacement = lithostitched::processor(&kind, &p, data, carried).or_else(|| {
match pillar_type(&kind) {
Some(to) => Some(vec![pillar(&p, to, data, carried, depth)]),
+12 -19
View File
@@ -15,10 +15,10 @@
//! closely.
//!
//! YUNG's API's files are carried as written ([`crate::yung`]).
//! Lithostitched's and CTOV's worldgen files are converted by
//! `legacy_structure_mods`, called from [`upgrade`], [`crate::carry`] and the
//! processors.
use crate::{legacy_structure_mods::lithostitched, yung};
//! Lithostitched's jigsaws, elements, modifiers and processors are carried by
//! [`crate::lithostitched`]; the entries CTOV adds to vanilla's structure sets
//! by `legacy_structure_mods`, called from [`crate::carry`].
use crate::{lithostitched, yung};
use serde_json::Value;
use std::{
collections::{BTreeMap, BTreeSet},
@@ -191,28 +191,19 @@ fn drop_java_processors(list: &mut Value, dropped: &mut BTreeMap<String, usize>)
let before = processors.len();
processors.retain(|p| {
let t = p["processor_type"].as_str().unwrap_or_default();
let java = !t.starts_with("minecraft:") && !t.starts_with("horizon:");
let java = !t.starts_with("minecraft:")
&& !t.starts_with("horizon:")
&& !lithostitched::game_reads(p);
if java {
*dropped.entry(t.to_owned()).or_default() += 1;
}
!java
});
let mut changed = processors.len() != before;
// The inline lists inside the game's apply_random and condition.
// The inline lists inside Lithostitched's apply_random and condition.
for p in processors.iter_mut() {
let inner: Vec<&mut Value> = match p["processor_type"].as_str() {
Some("horizon:apply_random") => p["processor_lists"]
.as_array_mut()
.into_iter()
.flatten()
.map(|e| &mut e["data"])
.collect(),
Some("horizon:condition") => {
let o = p.as_object_mut().into_iter().flat_map(|o| o.iter_mut());
o.filter(|(k, _)| *k == "then" || *k == "else")
.map(|(_, v)| v)
.collect()
}
Some(t) if t.starts_with("lithostitched:") => lithostitched::inner_lists(p),
Some("horizon:pillar" | "horizon:bottom_pillar") => {
p.get_mut("pillar_processor_list").into_iter().collect()
}
@@ -398,7 +389,9 @@ fn upgrade_types(
}
}
for element in elements {
if yung::upgrade_pool_element(element, lost) {
if yung::upgrade_pool_element(element, lost)
|| lithostitched::upgrade_pool_element(element, lost)
{
changed = true;
continue;
}
@@ -21,7 +21,7 @@ use horizon_core::{
use serde_json::Value;
use std::{collections::BTreeSet, sync::Arc};
mod lithostitched;
mod litho;
mod pillars;
mod sea_level;
@@ -238,6 +238,23 @@ impl Modifier {
)
}
}
impl Modifier {
/// RuleBlockEntityModifier.apply: the block entity the output gets.
fn apply(&self, nbt: Option<&Compound>, random: &mut impl ProcessorRandom) -> Option<Compound> {
match self {
Self::Passthrough => nbt.cloned(),
Self::Clear => Some(Compound::new()),
Self::AppendLoot(table) => {
let mut nbt = nbt.cloned().unwrap_or_default();
nbt.insert("LootTable".into(), Tag::String(table.as_str().into()));
nbt.insert("LootTableSeed".into(), Tag::Long(random.long()));
Some(nbt)
}
Self::AppendStatic(data) => Some(edited(nbt, &[], data)),
Self::Edit { remove, data } => Some(edited(nbt, remove, data)),
}
}
}
/// A block entity with `remove`'s keys taken out and `data` merged in
/// (append_static is the one with nothing to remove).
fn edited(nbt: Option<&Compound>, remove: &[String], data: &Compound) -> Compound {
@@ -292,17 +309,21 @@ pub enum Processor {
SpawnerMobs(Box<SpawnerMobs>),
Pick(Box<Pick>),
CountCap(Box<CountCap>),
/// `horizon:block_swap` (Lithostitched's `block_swap`): blocks by name
/// become others, keeping the properties both have.
/// `lithostitched:block_swap`: blocks by name become others, keeping
/// the properties both have (a block the game lacks is left alone).
BlockSwap(std::collections::BTreeMap<String, String>),
/// `horizon:apply_random` (Lithostitched's `apply_random`): a processor
/// list drawn by weight runs on the block.
ApplyRandom(Box<lithostitched::ApplyRandom>),
/// `horizon:condition` (Lithostitched's `condition`): `then` or `else`
/// `lithostitched:apply_random`: a processor list drawn by weight runs
/// on the block.
ApplyRandom(Box<litho::ApplyRandom>),
/// `lithostitched:reference`: the named lists run on the block in turn.
Reference(Vec<ProcessorList>),
/// `lithostitched:discard_input`: the world's block stays.
DiscardInput,
/// `lithostitched:condition`: `then` or `else`
/// runs on the block, by a test of it.
Condition(Box<lithostitched::Conditional>),
/// `horizon:set_block` (Lithostitched's `set_block`).
SetBlock(Box<lithostitched::SetBlock>),
Condition(Box<litho::Conditional>),
/// `lithostitched:set_block`: the block from a state provider.
SetBlock(Box<litho::SetBlock>),
/// `horizon:pillar` (Repurposed Structures' and Moog's pillars): a
/// trigger block starts a column down to the ground.
Pillar(Box<pillars::Pillar>),
@@ -786,7 +807,7 @@ impl Processor {
})?))
}
"horizon:pick" => Self::Pick(Box::new(Pick::decode(value, state)?)),
"horizon:block_swap" => Self::BlockSwap(
"lithostitched:block_swap" => Self::BlockSwap(
value["blocks"]
.as_object()
.ok_or(DecodeError::Field("blocks"))?
@@ -801,12 +822,16 @@ impl Processor {
.collect::<Result<_, DecodeError>>()?,
),
"horizon:count_cap" => Self::CountCap(Box::new(CountCap::decode(value, state)?)),
"horizon:apply_random" => Self::ApplyRandom(Box::new(
lithostitched::ApplyRandom::decode(value, resources, bindings)?,
"lithostitched:apply_random" => Self::ApplyRandom(Box::new(
litho::ApplyRandom::decode(value, resources, bindings)?,
)),
"horizon:set_block" => {
Self::SetBlock(Box::new(lithostitched::SetBlock::decode(value, state)?))
"lithostitched:set_block" => {
Self::SetBlock(Box::new(litho::SetBlock::decode(value, state)?))
}
"lithostitched:reference" => {
Self::Reference(litho::reference(value, resources, bindings)?)
}
"lithostitched:discard_input" => Self::DiscardInput,
"horizon:pillar" => Self::Pillar(Box::new(pillars::Pillar::decode(
value, resources, bindings, state,
)?)),
@@ -834,7 +859,7 @@ impl Processor {
"horizon:keep_world_block" => {
Self::KeepWorldBlock(Box::new(sea_level::KeepWorldBlock::decode(value)?))
}
"horizon:condition" => Self::Condition(Box::new(lithostitched::Conditional::decode(
"lithostitched:condition" => Self::Condition(Box::new(litho::Conditional::decode(
value, resources, bindings,
)?)),
_ => return Err(DecodeError::Field("structure processor type")),
@@ -1115,24 +1140,10 @@ fn process_block<W: PlacementContext>(
if !position_ok {
continue;
}
let nbt = match &rule.modifier {
Modifier::Passthrough => info.nbt.clone(),
Modifier::Clear => Some(Compound::new()),
Modifier::AppendLoot(table) => {
let mut nbt = info.nbt.clone().unwrap_or_default();
nbt.insert("LootTable".into(), Tag::String(table.as_str().into()));
nbt.insert("LootTableSeed".into(), Tag::Long(random.next_long()));
Some(nbt)
}
Modifier::AppendStatic(data) => Some(edited(info.nbt.as_ref(), &[], data)),
Modifier::Edit { remove, data } => {
Some(edited(info.nbt.as_ref(), remove, data))
}
};
return Ok(Some(BlockInfo {
pos: info.pos,
state: rule.output,
nbt,
nbt: rule.modifier.apply(info.nbt.as_ref(), &mut random),
}));
}
Some(info)
@@ -1165,8 +1176,7 @@ fn process_block<W: PlacementContext>(
false => Some(info),
},
Processor::ApplyRandom(apply) => {
let seed = world.world_seed()?;
let mut random = apply.random.create(seed, position, reference, info.pos);
let mut random = apply.draws.random(world.world_seed()?, position, info.pos);
match apply.pick(&mut random) {
Some(list) => {
return process_list(
@@ -1176,21 +1186,34 @@ fn process_block<W: PlacementContext>(
None => Some(info),
}
}
Processor::Reference(lists) => {
let mut current = info;
for list in lists {
match process_list(
list, world, resources, position, reference, relative, current, settings,
)? {
Some(next) => current = next,
None => return Ok(None),
}
}
Some(current)
}
Processor::DiscardInput => None,
Processor::SetBlock(set) => {
let seed = world.world_seed()?;
let state = set.state(seed, position, reference, info.pos);
let state = match set.preserves() {
let mut random = set.draws.random(world.world_seed()?, position, info.pos);
let state = set.state(&mut random);
let state = match set.preserve {
true => registry
.state(state)
.and_then(|s| with_properties_of(registry, &s.name, info.state))
.unwrap_or(state),
false => state,
};
Some(BlockInfo { state, ..info })
let nbt = set.modifier.apply(info.nbt.as_ref(), &mut random);
Some(BlockInfo { state, nbt, ..info })
}
Processor::Condition(c) => {
let seed = world.world_seed()?;
let mut random = c.random.create(seed, position, reference, info.pos);
let mut random = c.draws.random(world.world_seed()?, position, info.pos);
let location = world.block_state(info.pos)?;
let passed = c
.condition
@@ -0,0 +1,841 @@
//! Lithostitched's processors (spec-lithostitched §5), read as its data
//! writes them: `lithostitched:block_swap` (in `processor.rs`), `reference`,
//! `apply_random`, `condition`, `set_block` and `discard_input`.
//!
//! The draws of `random_chance`, `apply_random` and `set_block` follow
//! Lithostitched's `random_mode` (per block or per piece), but its seeding is
//! not known (OPEN-L6): a positional random of the world seed, the block's or
//! the piece's position and the processor itself, so the shares match while
//! the blocks chosen do not.
use super::{BlockStateId, DecodeError, Modifier, ProcessorList, ProcessorRandom};
use crate::worldgen::{block_feature::FeatureBlockBindings, structure::StructureResources};
use horizon_core::{
position::BlockPos,
random::{LegacyRandom, position_seed},
};
use serde_json::Value;
use std::collections::BTreeSet;
/// `random_mode` (`condition`, `set_block`) or `mode` (`apply_random`).
#[derive(Clone, Copy, Debug)]
pub(super) struct Draws {
per_piece: bool,
/// The processor's own salt, so two processors on a block draw apart.
salt: i64,
}
impl Draws {
fn decode(mode: Option<&Value>, processor: &Value) -> Result<Self, DecodeError> {
let per_piece = match mode.and_then(Value::as_str) {
None | Some("per_block") => false,
Some("per_piece") => true,
_ => return Err(DecodeError::Field("random_mode")),
};
// FNV-1a over the processor's JSON: the same in every run.
let salt = (processor.to_string().bytes()).fold(0xcbf2_9ce4_8422_2325_u64, |h, b| {
(h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3)
}) as i64;
Ok(Self { per_piece, salt })
}
/// The random for the block at `pos` of the piece at `piece`.
pub(super) fn random(self, world_seed: i64, piece: [i32; 3], pos: [i32; 3]) -> LegacyRandom {
let [x, y, z] = if self.per_piece { piece } else { pos };
LegacyRandom::new(world_seed ^ self.salt ^ position_seed(BlockPos::new(x, y, z)))
}
}
/// A processor or a list of them (`then`, `else`, `apply_random`'s data):
/// a list holder (`ProcessorList::decode`), or one processor object.
fn processors(
value: &Value,
resources: &StructureResources,
bindings: &FeatureBlockBindings<'_>,
) -> Result<ProcessorList, DecodeError> {
let one = value.get("processor_type").is_some();
let holder = if one {
Value::Array(vec![value.clone()])
} else {
value.clone()
};
ProcessorList::decode(&holder.to_string(), resources, bindings)
}
/// `reference`: the named lists, run in turn on the block.
pub(super) fn reference(
value: &Value,
resources: &StructureResources,
bindings: &FeatureBlockBindings<'_>,
) -> Result<Vec<ProcessorList>, DecodeError> {
let names = match &value["processor_lists"] {
Value::Array(a) => a.clone(),
other => vec![other.clone()],
};
(names.iter())
.map(|name| match name.as_str() {
Some(n) if n.starts_with('#') => Err(DecodeError::Field("processor list tag")),
Some(_) => ProcessorList::decode(&name.to_string(), resources, bindings),
None => Err(DecodeError::Field("processor_lists")),
})
.collect()
}
/// `apply_random`: one list drawn by weight runs on the block.
#[derive(Clone, Debug)]
pub struct ApplyRandom {
pub(super) draws: Draws,
lists: Vec<(i32, ProcessorList)>,
total: i32,
}
impl ApplyRandom {
pub(super) fn decode(
value: &Value,
resources: &StructureResources,
bindings: &FeatureBlockBindings<'_>,
) -> Result<Self, DecodeError> {
let entries = value["processor_lists"]
.as_array()
.ok_or(DecodeError::Field("apply_random processor_lists"))?;
let lists = (entries.iter())
.map(|e| match e.get("data") {
Some(data) => {
let weight = e["weight"]
.as_i64()
.unwrap_or(1)
.clamp(0, i64::from(i32::MAX));
Ok((weight as i32, processors(data, resources, bindings)?))
}
None => Ok((1, processors(e, resources, bindings)?)),
})
.collect::<Result<Vec<_>, DecodeError>>()?;
let total = lists
.iter()
.map(|(w, _)| *w)
.fold(0i32, i32::saturating_add);
Ok(Self {
draws: Draws::decode(value.get("mode"), value)?,
lists,
total,
})
}
/// The list drawn: one bounded draw over the total weight.
pub(super) fn pick(&self, random: &mut impl ProcessorRandom) -> Option<&ProcessorList> {
if self.total <= 0 {
return None;
}
let mut at = random.int(self.total);
for (weight, list) in &self.lists {
at -= weight;
if at < 0 {
return Some(list);
}
}
None
}
}
/// Lithostitched's processor conditions.
#[derive(Clone, Debug)]
pub(super) enum Condition {
True,
AllOf(Vec<Condition>),
AnyOf(Vec<Condition>),
Not(Box<Condition>),
Chance(f32),
/// `matching_blocks`: the template's block (`input`) or the world's
/// (`location`) is one of `blocks`, with `properties`.
Matching {
blocks: BTreeSet<String>,
properties: Vec<(String, String)>,
location: bool,
},
}
impl Condition {
/// One condition, or a list of them that must all pass (`if_true`).
pub(super) fn decode(
value: &Value,
bindings: &FeatureBlockBindings<'_>,
) -> Result<Self, DecodeError> {
let list = |v: &Value| -> Result<Vec<Self>, DecodeError> {
(v.as_array().ok_or(DecodeError::Field("conditions"))?.iter())
.map(|c| Self::decode(c, bindings))
.collect()
};
if value.is_array() {
return Ok(Self::AllOf(list(value)?));
}
let kind = value["type"].as_str().unwrap_or_default();
Ok(match crate::worldgen::tags::identifier(kind)?.as_str() {
"lithostitched:true" => Self::True,
"lithostitched:all_of" => Self::AllOf(list(&value["conditions"])?),
"lithostitched:any_of" => Self::AnyOf(list(&value["conditions"])?),
"lithostitched:not" => {
Self::Not(Box::new(Self::decode(&value["condition"], bindings)?))
}
"lithostitched:random_chance" => Self::Chance(
value["chance"]
.as_f64()
.ok_or(DecodeError::Field("random_chance chance"))? as f32,
),
"lithostitched:matching_blocks" => Self::Matching {
blocks: blocks(&value["blocks"], bindings)?,
properties: (value
.get("properties")
.and_then(Value::as_object)
.into_iter())
.flatten()
.map(|(k, v)| {
(
k.clone(),
v.as_str().map_or_else(|| v.to_string(), str::to_owned),
)
})
.collect(),
location: match value.get("match_type").and_then(Value::as_str) {
None | Some("input") => false,
Some("location") => true,
_ => return Err(DecodeError::Field("matching_blocks match_type")),
},
},
_ => return Err(DecodeError::Field("lithostitched condition type")),
})
}
pub(super) fn test(
&self,
input: BlockStateId,
location: BlockStateId,
registry: &crate::registry::BlockRegistry,
random: &mut impl ProcessorRandom,
) -> bool {
match self {
Self::True => true,
Self::AllOf(all) => all
.iter()
.all(|c| c.test(input, location, registry, random)),
Self::AnyOf(any) => any
.iter()
.any(|c| c.test(input, location, registry, random)),
Self::Not(c) => !c.test(input, location, registry, random),
Self::Chance(chance) => random.float() < *chance,
Self::Matching {
blocks,
properties,
location: at_location,
} => {
let Some(state) = registry.state(if *at_location { location } else { input })
else {
return false;
};
blocks.contains(&state.name)
&& (properties.iter()).all(|(k, v)| state.properties.get(k) == Some(v))
}
}
}
}
/// A block set (an id, a list, `#tag`) by block name. Blocks the game does
/// not have are left out: no template block can be one.
fn blocks(
value: &Value,
bindings: &FeatureBlockBindings<'_>,
) -> Result<BTreeSet<String>, DecodeError> {
let names: Vec<&str> = match value {
Value::String(s) => vec![s.as_str()],
Value::Array(a) => a.iter().filter_map(Value::as_str).collect(),
_ => return Err(DecodeError::Field("matching_blocks blocks")),
};
let registry = bindings.registry();
let mut out = BTreeSet::new();
for name in names {
match name.strip_prefix('#') {
Some(tag) => {
let tag = crate::worldgen::tags::identifier(tag)?;
let states = bindings.tags.get(&tag).into_iter().flat_map(|s| s.iter());
out.extend(states.filter_map(|s| Some(registry.state(*s)?.name.clone())));
}
None => {
out.insert(crate::worldgen::tags::identifier(name)?.to_string());
}
}
}
Ok(out)
}
/// `condition`: `then` when the conditions pass, else `else` (none: the
/// block as it is).
#[derive(Clone, Debug)]
pub struct Conditional {
pub(super) draws: Draws,
pub(super) condition: Condition,
pub(super) then: ProcessorList,
pub(super) otherwise: ProcessorList,
}
impl Conditional {
pub(super) fn decode(
value: &Value,
resources: &StructureResources,
bindings: &FeatureBlockBindings<'_>,
) -> Result<Self, DecodeError> {
let branch = |key| match value.get(key) {
Some(v) => processors(v, resources, bindings),
None => Ok(ProcessorList(Vec::new())),
};
Ok(Self {
draws: Draws::decode(value.get("random_mode"), value)?,
condition: Condition::decode(&value["if_true"], bindings)?,
then: branch("then")?,
otherwise: branch("else")?,
})
}
}
/// `set_block`: the block from a state provider (simple or weighted).
#[derive(Clone, Debug)]
pub struct SetBlock {
pub(super) draws: Draws,
states: Vec<(i32, BlockStateId)>,
total: i32,
/// `preserve_state` (true by default): the template block's properties
/// the new block has too are kept.
pub(super) preserve: bool,
pub(super) modifier: Modifier,
}
impl SetBlock {
pub(super) fn decode(
value: &Value,
state: impl Fn(&Value) -> Result<BlockStateId, DecodeError>,
) -> Result<Self, DecodeError> {
let provider = &value["state_provider"];
let kind = provider["type"].as_str().unwrap_or_default();
let states = match crate::worldgen::tags::identifier(kind)?.as_str() {
"minecraft:simple_state_provider" => vec![(1, state(&provider["state"])?)],
"minecraft:weighted_state_provider" => (provider["entries"].as_array().into_iter())
.flatten()
.map(|e| {
Ok((
e["weight"].as_i64().unwrap_or(1).clamp(0, 1 << 30) as i32,
state(&e["data"])?,
))
})
.collect::<Result<_, DecodeError>>()?,
_ => return Err(DecodeError::Field("set_block state_provider")),
};
let total = states.iter().map(|(w, _)| *w).sum();
if total <= 0 {
return Err(DecodeError::Field("set_block state_provider"));
}
Ok(Self {
draws: Draws::decode(value.get("random_mode"), value)?,
states,
total,
preserve: value
.get("preserve_state")
.and_then(Value::as_bool)
.unwrap_or(true),
modifier: Modifier::decode(value.get("block_entity_modifier"))?,
})
}
/// The provider's state: WeightedList.getRandom, one bounded draw.
pub(super) fn state(&self, random: &mut impl ProcessorRandom) -> BlockStateId {
let mut at = random.int(self.total);
for (weight, state) in &self.states {
at -= weight;
if at < 0 {
return *state;
}
}
self.states[0].1
}
}
#[cfg(test)]
mod tests {
use super::super::{BlockInfo, PlaceSettings, ProcessorList, process_list};
use crate::{
heightmap::HeightmapType,
nbt::{JavaString, Tag},
registry::{BlockRegistry, BlockStateId},
worldgen::{
anchor::WorldGenerationContext,
block_feature::FeatureBlockBindings,
placement::{PlacementContext, PlacementQueryError},
structure::{
Rotation, StateTransforms, StructureResources, template::parse_state_string,
},
},
};
use serde_json::{Value, json};
use std::collections::{BTreeMap, BTreeSet};
/// Every combination of `properties`' values, the first one the default.
fn states(name: &str, properties: &[(&str, &[&str])]) -> Vec<Value> {
let mut out = vec![serde_json::Map::new()];
for (key, values) in properties {
out = (out.iter())
.flat_map(|o| {
values.iter().map(move |v| {
let mut o = o.clone();
o.insert((*key).into(), (*v).into());
o
})
})
.collect();
}
(out.into_iter())
.map(|p| json!([name, name.ends_with("air"), false, "", {"id": name, "properties": p}]))
.collect()
}
/// A registry of the blocks CTOV's lists and the probe pack use.
fn registry() -> &'static BlockRegistry {
let ages: Vec<String> = (0..8).map(|a| a.to_string()).collect();
let ages: Vec<&str> = ages.iter().map(String::as_str).collect();
let levels: Vec<String> = (0..16).map(|a| a.to_string()).collect();
let levels: Vec<&str> = levels.iter().map(String::as_str).collect();
let water = [("level", &levels[..])];
let stairs: &[(&str, &[&str])] =
&[("facing", &["north", "east"]), ("half", &["bottom", "top"])];
let blocks: Vec<(&str, &[(&str, &[&str])])> = vec![
("minecraft:air", &[]),
("minecraft:cave_air", &[]),
("minecraft:void_air", &[]),
("minecraft:lava", &water),
("minecraft:cobblestone", &[]),
("minecraft:blackstone", &[]),
("minecraft:deepslate", &[]),
("minecraft:water", &water),
("minecraft:spruce_planks", &[]),
("minecraft:chest", &[]),
("minecraft:oak_log", &[("axis", &["y", "x"])]),
("minecraft:stripped_cherry_log", &[("axis", &["y", "x"])]),
("minecraft:oak_stairs", stairs),
("minecraft:warped_stairs", stairs),
];
let wheat = [("age", &ages[..])];
let beetroots = [("age", &ages[..4])];
let mut rows = Vec::new();
let mut defaults = BTreeMap::new();
let crops: [(&str, &[(&str, &[&str])]); 3] = [
("minecraft:wheat", &wheat),
("minecraft:carrots", &wheat),
("minecraft:beetroots", &beetroots),
];
for (name, properties) in blocks.into_iter().chain(crops) {
defaults.insert(name, rows.len());
rows.extend(states(name, properties));
}
let facts = json!({"states": rows, "defaults": defaults});
let registry = BlockRegistry::from_facts_json(facts.to_string().as_bytes()).unwrap();
Box::leak(Box::new(registry))
}
/// The world a piece is placed in: one block everywhere.
struct World {
seed: i64,
block: BlockStateId,
}
impl PlacementContext for World {
fn world_seed(&mut self) -> Result<i64, PlacementQueryError> {
Ok(self.seed)
}
fn block_state(&mut self, _: [i32; 3]) -> Result<BlockStateId, PlacementQueryError> {
Ok(self.block)
}
fn generation(&self) -> WorldGenerationContext {
WorldGenerationContext::new(-64, 384, 63)
}
fn min_y(&mut self) -> Result<i32, PlacementQueryError> {
Ok(-64)
}
fn height(&mut self, _: HeightmapType, _: i32, _: i32) -> Result<i32, PlacementQueryError> {
Err(PlacementQueryError::Unavailable)
}
}
struct Lists {
registry: &'static BlockRegistry,
resources: StructureResources,
}
impl Lists {
fn new(lists: &[(&str, Value)]) -> Self {
let registry = registry();
let lists = (lists.iter())
.map(|(id, list)| ((*id).to_owned(), list.to_string()))
.collect();
let resources = StructureResources::new(
registry,
StateTransforms::default(),
std::path::Path::new("/nonexistent"),
lists,
&BTreeMap::new(),
);
Self {
registry,
resources,
}
}
fn id(&self, state: &str) -> BlockStateId {
parse_state_string(self.registry, state).unwrap()
}
fn decode(&self, processors: Value) -> Result<ProcessorList, crate::worldgen::DecodeError> {
let bindings = FeatureBlockBindings::new(self.registry, BTreeSet::new()).unwrap();
ProcessorList::decode(&processors.to_string(), &self.resources, &bindings)
}
/// The block the list makes of template block `state` at `pos` of the
/// piece at `piece`, in a world of `world`: None when it is not placed.
fn run(
&self,
list: &ProcessorList,
world: (i64, &str),
piece: [i32; 3],
pos: [i32; 3],
state: &str,
) -> Option<BlockInfo> {
let mut world = World {
seed: world.0,
block: self.id(world.1),
};
let info = BlockInfo {
pos,
state: self.id(state),
nbt: None,
};
let mut settings = PlaceSettings::new(Rotation::None);
process_list(
list,
&mut world,
&self.resources,
piece,
piece,
[0; 3],
info,
&mut settings,
)
.unwrap()
}
fn name(&self, info: Option<BlockInfo>) -> String {
match info {
None => "none".into(),
Some(info) => {
let s = self.registry.state(info.state).unwrap();
let props: Vec<String> = s
.properties
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect();
match props.is_empty() {
true => s.name.clone(),
false => format!("{}[{}]", s.name, props.join(",")),
}
}
}
}
/// `name(run(...))` for one template block in an air world of seed 0.
fn out(&self, list: &ProcessorList, state: &str) -> String {
self.name(self.run(list, (0, "minecraft:air"), [0; 3], [0; 3], state))
}
}
fn swap(blocks: Value) -> Value {
json!({"processor_type": "lithostitched:block_swap", "blocks": blocks})
}
fn set_block(provider: Value) -> Value {
json!({"processor_type": "lithostitched:set_block", "state_provider": provider})
}
fn simple(name: &str) -> Value {
json!({"type": "minecraft:simple_state_provider", "state": {"id": name}})
}
#[test]
fn block_swap_keeps_the_properties_the_new_block_can_have() {
let t = Lists::new(&[]);
let list = t
.decode(json!([swap(json!({
"minecraft:wheat": "minecraft:carrots",
"oak_stairs": "minecraft:warped_stairs",
"minecraft:cobblestone": "natures_spirit:missing"}))]))
.unwrap();
assert_eq!(
t.out(&list, "minecraft:wheat[age=7]"),
"minecraft:carrots[age=7]"
);
assert_eq!(
t.out(&list, "minecraft:oak_stairs[facing=east,half=top]"),
"minecraft:warped_stairs[facing=east,half=top]"
);
// A block the game lacks leaves the template's; others pass.
assert_eq!(
t.out(&list, "minecraft:cobblestone"),
"minecraft:cobblestone"
);
assert_eq!(
t.out(&list, "minecraft:oak_log[axis=x]"),
"minecraft:oak_log[axis=x]"
);
// Beetroots' age stops at 3: ripe wheat gives the default (spec-ctov §4).
let beet = t
.decode(json!([swap(
json!({"minecraft:wheat": "minecraft:beetroots"})
)]))
.unwrap();
assert_eq!(
t.out(&beet, "minecraft:wheat[age=7]"),
"minecraft:beetroots[age=0]"
);
assert_eq!(
t.out(&beet, "minecraft:wheat[age=2]"),
"minecraft:beetroots[age=2]"
);
}
#[test]
fn references_run_the_named_lists_in_turn() {
let t = Lists::new(&[
(
"t:logs",
json!({"processors": [swap(json!({"minecraft:oak_log": "minecraft:stripped_cherry_log"}))]}),
),
(
"t:stone",
json!([swap(
json!({"minecraft:stripped_cherry_log": "minecraft:cobblestone"})
)]),
),
(
"t:discard",
json!([{"processor_type": "lithostitched:discard_input"}]),
),
]);
let reference = |lists: Value| json!([{"processor_type": "lithostitched:reference", "processor_lists": lists}]);
let one = t.decode(reference(json!("t:logs"))).unwrap();
assert_eq!(
t.out(&one, "minecraft:oak_log[axis=x]"),
"minecraft:stripped_cherry_log[axis=x]"
);
let both = t.decode(reference(json!(["t:logs", "t:stone"]))).unwrap();
assert_eq!(t.out(&both, "minecraft:oak_log"), "minecraft:cobblestone");
let reversed = t.decode(reference(json!(["t:stone", "t:logs"]))).unwrap();
assert_eq!(
t.out(&reversed, "minecraft:oak_log"),
"minecraft:stripped_cherry_log[axis=y]"
);
// discard_input: the template block is not placed, and the lists after it do not run.
let discard = t.decode(reference(json!(["t:discard", "t:logs"]))).unwrap();
assert_eq!(t.out(&discard, "minecraft:oak_log"), "none");
// The game has no processor list tags; unknown lists are errors.
assert!(t.decode(reference(json!(["#t:palettes"]))).is_err());
assert!(t.decode(reference(json!("t:missing"))).is_err());
}
/// The probe pack's nested condition (spec-lithostitched §7.3): template
/// cobblestone becomes blackstone or deepslate, half each.
#[test]
fn conditions_run_then_or_else() {
let t = Lists::new(&[]);
let inner = json!({"processor_type": "lithostitched:condition", "random_mode": "per_block",
"if_true": {"type": "lithostitched:random_chance", "chance": 0.5},
"then": set_block(simple("minecraft:blackstone")),
"else": [set_block(simple("minecraft:deepslate"))]});
let list = t
.decode(json!([{"processor_type": "lithostitched:condition",
"if_true": [{"type": "lithostitched:matching_blocks", "blocks": "minecraft:cobblestone"}],
"then": [inner]}]))
.unwrap();
let mut counts = BTreeMap::<String, u32>::new();
for i in 0..4000 {
let out = t.run(
&list,
(1234, "minecraft:air"),
[0; 3],
[i % 64, i / 64, 7],
"minecraft:cobblestone",
);
*counts.entry(t.name(out)).or_default() += 1;
}
assert_eq!(counts.len(), 2, "{counts:?}");
assert!(
(1850..2150).contains(&counts["minecraft:blackstone"]),
"{counts:?}"
);
// Not cobblestone, and no `else`: the block as it is.
assert_eq!(
t.out(&list, "minecraft:oak_log"),
"minecraft:oak_log[axis=y]"
);
// `location` tests the world's block, with properties on the input.
let water = t
.decode(
json!([{"processor_type": "lithostitched:condition", "random_mode": "per_piece",
"if_true": {"type": "lithostitched:matching_blocks", "blocks": ["minecraft:water"],
"match_type": "location"},
"then": set_block(simple("minecraft:spruce_planks"))}]),
)
.unwrap();
let at = |world| {
t.name(t.run(
&water,
(0, world),
[0; 3],
[1, 2, 3],
"minecraft:cobblestone",
))
};
assert_eq!(at("minecraft:water"), "minecraft:spruce_planks");
assert_eq!(at("minecraft:air"), "minecraft:cobblestone");
let ripe = t
.decode(json!([{"processor_type": "lithostitched:condition",
"if_true": {"type": "lithostitched:all_of", "conditions": [
{"type": "lithostitched:true"},
{"type": "lithostitched:matching_blocks", "blocks": "minecraft:wheat", "properties": {"age": "7"}},
{"type": "lithostitched:not", "condition": {"type": "lithostitched:any_of", "conditions": [
{"type": "lithostitched:random_chance", "chance": 0.0},
{"type": "lithostitched:matching_blocks", "blocks": "minecraft:water", "match_type": "location"}]}}]},
"then": swap(json!({"minecraft:wheat": "minecraft:carrots"}))}]))
.unwrap();
assert_eq!(
t.out(&ripe, "minecraft:wheat[age=7]"),
"minecraft:carrots[age=7]"
);
assert_eq!(
t.out(&ripe, "minecraft:wheat[age=6]"),
"minecraft:wheat[age=6]"
);
let bad = json!([{"processor_type": "lithostitched:condition", "random_mode": "per_chunk",
"if_true": {"type": "lithostitched:true"}}]);
assert!(t.decode(bad).is_err());
}
/// `apply_random` (spec-lithostitched §7.3: crimson 1 : birch 3, one
/// choice per piece): here blackstone 1 : deepslate 3.
#[test]
fn apply_random_draws_per_piece_or_per_block() {
let t = Lists::new(&[(
"t:deepslate",
json!([swap(
json!({"minecraft:cobblestone": "minecraft:deepslate"})
)]),
)]);
let random = |mode: &str| {
t.decode(json!([{"processor_type": "lithostitched:apply_random", "mode": mode,
"processor_lists": [
{"weight": 1, "data": [swap(json!({"minecraft:cobblestone": "minecraft:blackstone"}))]},
{"weight": 3, "data": "t:deepslate"}]}]))
.unwrap()
};
let (per_piece, per_block) = (random("per_piece"), random("per_block"));
let outcomes = |list: &ProcessorList, seed: i64, piece: [i32; 3]| -> Vec<String> {
(0..20)
.map(|i| {
let pos = [piece[0] + i, piece[1], piece[2] + 3 * i];
t.name(t.run(
list,
(seed, "minecraft:air"),
piece,
pos,
"minecraft:cobblestone",
))
})
.collect()
};
let mut blackstone_pieces = 0;
for p in 0..400 {
let piece = [16 * p, 70, -40 * p];
let blocks = outcomes(&per_piece, 1234, piece);
assert!(blocks.iter().all(|b| *b == blocks[0]), "{blocks:?}");
blackstone_pieces += u32::from(blocks[0] == "minecraft:blackstone");
// The same piece and seed give the same blocks.
assert_eq!(outcomes(&per_piece, 1234, piece), blocks);
}
assert!(
(70..130).contains(&blackstone_pieces),
"{blackstone_pieces}"
);
// Per block, one piece mixes both.
let mixed: BTreeSet<String> = (0..10)
.flat_map(|p| outcomes(&per_block, 1234, [p, 0, 0]))
.collect();
assert_eq!(mixed.len(), 2, "{mixed:?}");
// A plain list: every entry weight 1. An entry list holding nothing is a no-op.
let plain = t
.decode(
json!([{"processor_type": "lithostitched:apply_random", "mode": "per_block",
"processor_lists": ["t:deepslate", []]}]),
)
.unwrap();
let seen: BTreeSet<String> = (0..10)
.flat_map(|p| outcomes(&plain, 9, [p, 0, 0]))
.collect();
assert_eq!(
seen,
BTreeSet::from(["minecraft:cobblestone".into(), "minecraft:deepslate".into()])
);
}
#[test]
fn set_block_runs_its_provider() {
let t = Lists::new(&[]);
let stairs = "minecraft:oak_stairs[facing=east,half=top]";
let warped = |preserve: Value| {
let mut p = set_block(simple("minecraft:warped_stairs"));
if !preserve.is_null() {
p["preserve_state"] = preserve;
}
t.decode(json!([p])).unwrap()
};
// preserve_state (true by default) keeps the properties both blocks have.
assert_eq!(
t.out(&warped(Value::Null), stairs),
"minecraft:warped_stairs[facing=east,half=top]"
);
assert_eq!(
t.out(&warped(json!(false)), stairs),
"minecraft:warped_stairs[facing=north,half=bottom]"
);
let weighted = t
.decode(json!([set_block(
json!({"type": "minecraft:weighted_state_provider", "entries": [
{"weight": 1, "data": {"id": "minecraft:blackstone"}},
{"weight": 3, "data": {"id": "minecraft:deepslate"}}]})
)]))
.unwrap();
let blackstone = (0..2000)
.filter(|i| {
let out = t.run(
&weighted,
(42, "minecraft:air"),
[0; 3],
[*i, 0, -*i],
"minecraft:cobblestone",
);
t.name(out) == "minecraft:blackstone"
})
.count();
assert!((420..580).contains(&blackstone), "{blackstone}");
// block_entity_modifier gives the output its block entity.
let mut loot = set_block(simple("minecraft:chest"));
loot["block_entity_modifier"] =
json!({"type": "minecraft:append_loot", "loot_table": "t:chests/house"});
let out = t
.run(
&t.decode(json!([loot])).unwrap(),
(0, "minecraft:air"),
[0; 3],
[0; 3],
"minecraft:air",
)
.unwrap();
let table = out
.nbt
.as_ref()
.and_then(|n| n.get(&JavaString::from("LootTable")));
assert_eq!(table, Some(&Tag::String("t:chests/house".into())));
// Providers the game does not run are refused.
assert!(
t.decode(json!([set_block(
json!({"type": "minecraft:noise_provider"})
)]))
.is_err()
);
}
}
@@ -1,434 +0,0 @@
//! Lithostitched's processors that pick processors, carried as data
//! (`horizon:apply_random` and `horizon:condition`): one runs a processor
//! list drawn by weight, the other one list or another by a test of the
//! block. Both draw from Lithostitched's RandomSettings: a random seeded from
//! the world seed and a name, at the block, the piece or the structure.
//! `horizon:set_block` is its `set_block`, which the conditions mostly run.
use super::{Processor, ProcessorList};
use crate::{
registry::{BlockRegistry, BlockStateId},
worldgen::{DecodeError, block_feature::FeatureBlockBindings, structure::StructureResources},
};
use horizon_core::{
position::BlockPos,
random::{LegacyRandom, PositionalRandom, RandomSource},
};
use serde_json::Value;
use std::{collections::BTreeSet, sync::Arc};
/// RandomSettings: where the random is taken, and the name salting it.
#[derive(Clone, Debug, PartialEq)]
pub struct RandomSettings {
mode: Mode,
/// ResourceLocation.hashCode of the name (`lithostitched:default`).
salt: i32,
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Mode {
Block,
Piece,
Structure,
}
impl RandomSettings {
/// A mode alone, or `{"mode": ..., "name": ...}`; per block when unset.
fn decode(v: Option<&Value>) -> Result<Self, DecodeError> {
let mode = Mode::decode;
let (mode, name) = match v {
None => (Mode::Block, "lithostitched:default"),
Some(Value::String(m)) => (mode(Some(m))?, "lithostitched:default"),
Some(o) => (
mode(o["mode"].as_str())?,
o["name"]
.as_str()
.ok_or(DecodeError::Field("random name"))?,
),
};
let (namespace, path) = name.split_once(':').unwrap_or(("minecraft", name));
Ok(Self {
mode,
salt: java_hash(namespace)
.wrapping_mul(31)
.wrapping_add(java_hash(path)),
})
}
/// RandomSource.create(seed + hash).forkPositional().at(the chosen
/// position): the piece's position, the structure's pivot or the block.
pub(super) fn create(
&self,
seed: i64,
piece: [i32; 3],
pivot: [i32; 3],
block: [i32; 3],
) -> LegacyRandom {
let [x, y, z] = self.mode.select(piece, pivot, block);
LegacyRandom::new(seed.wrapping_add(i64::from(self.salt)))
.fork_positional()
.at(BlockPos::new(x, y, z))
}
}
impl Mode {
fn decode(m: Option<&str>) -> Result<Self, DecodeError> {
match m {
None | Some("per_block") => Ok(Self::Block),
Some("per_piece") => Ok(Self::Piece),
Some("per_structure") => Ok(Self::Structure),
_ => Err(DecodeError::Field("random mode")),
}
}
fn select(self, piece: [i32; 3], pivot: [i32; 3], block: [i32; 3]) -> [i32; 3] {
match self {
Self::Block => block,
Self::Piece => piece,
Self::Structure => pivot,
}
}
}
/// SetBlockProcessor with a simple or weighted state provider and no block
/// entity change: the block becomes the state, keeping the properties it
/// shares with the old one unless `preserve_state` is off.
#[derive(Clone, Debug)]
pub struct SetBlock {
/// The states and their weights; one state alone is a simple provider,
/// which draws nothing.
states: Vec<(BlockStateId, i32)>,
weighted: bool,
preserve: bool,
mode: Mode,
}
impl SetBlock {
pub(super) fn decode(
v: &Value,
state: impl Fn(&Value) -> Result<BlockStateId, DecodeError>,
) -> Result<Self, DecodeError> {
let provider = &v["state_provider"];
let (states, weighted) = match provider["type"].as_str() {
Some("minecraft:simple_state_provider") => {
(vec![(state(&provider["state"])?, 1)], false)
}
Some("minecraft:weighted_state_provider") => (
provider["entries"]
.as_array()
.ok_or(DecodeError::Field("set_block entries"))?
.iter()
.map(|e| {
let weight = e["weight"]
.as_i64()
.ok_or(DecodeError::Field("set_block weight"))?;
Ok((state(&e["data"])?, weight as i32))
})
.collect::<Result<_, DecodeError>>()?,
true,
),
_ => return Err(DecodeError::Field("set_block state_provider")),
};
if states.is_empty() {
return Err(DecodeError::Field("set_block entries"));
}
Ok(Self {
states,
weighted,
preserve: v["preserve_state"].as_bool().unwrap_or(true),
mode: Mode::decode(v["random_mode"].as_str())?,
})
}
/// The state to set, before `preserve`: drawn with
/// RandomSource.create(seed).forkPositional().at(the chosen position).
pub(super) fn state(
&self,
seed: i64,
piece: [i32; 3],
pivot: [i32; 3],
block: [i32; 3],
) -> BlockStateId {
if !self.weighted {
return self.states[0].0;
}
let [x, y, z] = self.mode.select(piece, pivot, block);
let mut random = LegacyRandom::new(seed)
.fork_positional()
.at(BlockPos::new(x, y, z));
let total: i32 = self.states.iter().map(|s| s.1).sum();
let mut value = random.next_int_bound(total.max(1)).unwrap_or(0);
for (state, weight) in &self.states {
if value < *weight {
return *state;
}
value -= weight;
}
self.states[0].0
}
pub(super) fn preserves(&self) -> bool {
self.preserve
}
}
/// String.hashCode over UTF-16 units.
fn java_hash(s: &str) -> i32 {
s.encode_utf16()
.fold(0i32, |h, c| h.wrapping_mul(31).wrapping_add(i32::from(c)))
}
fn list(
v: &Value,
resources: &StructureResources,
bindings: &FeatureBlockBindings<'_>,
) -> Result<ProcessorList, DecodeError> {
ProcessorList::decode(&v.to_string(), resources, bindings)
}
/// ApplyRandomStructureProcessor: a weighted list of processor lists, one
/// drawn per block with `nextInt(total)`.
#[derive(Clone, Debug)]
pub struct ApplyRandom {
pub(super) random: RandomSettings,
lists: Vec<(ProcessorList, i32)>,
total: i32,
}
impl ApplyRandom {
pub(super) fn decode(
v: &Value,
resources: &StructureResources,
bindings: &FeatureBlockBindings<'_>,
) -> Result<Self, DecodeError> {
let lists = v["processor_lists"]
.as_array()
.ok_or(DecodeError::Field("apply_random processor_lists"))?
.iter()
.map(|e| {
let weight = e["weight"]
.as_i64()
.ok_or(DecodeError::Field("apply_random weight"))?;
Ok((list(&e["data"], resources, bindings)?, weight as i32))
})
.collect::<Result<Vec<_>, DecodeError>>()?;
Ok(Self {
random: RandomSettings::decode(v.get("mode"))?,
total: lists.iter().map(|l| l.1).sum(),
lists,
})
}
/// WeightedList.getRandom: none when the weights add up to 0.
pub(super) fn pick(&self, random: &mut impl RandomSource) -> Option<&ProcessorList> {
if self.total <= 0 {
return None;
}
let mut value = random.next_int_bound(self.total).unwrap_or(0);
for (list, weight) in &self.lists {
if value < *weight {
return Some(list);
}
value -= weight;
}
None
}
}
/// ConditionProcessor: `then` when the condition holds for the block, else
/// `else`.
#[derive(Clone, Debug)]
pub struct Conditional {
pub(super) random: RandomSettings,
pub(super) condition: Condition,
pub(super) then: ProcessorList,
pub(super) otherwise: ProcessorList,
}
impl Conditional {
pub(super) fn decode(
v: &Value,
resources: &StructureResources,
bindings: &FeatureBlockBindings<'_>,
) -> Result<Self, DecodeError> {
// A processor alone or a list of them.
let processors = |key: &str| -> Result<ProcessorList, DecodeError> {
match v.get(key) {
None => Ok(ProcessorList::default()),
Some(Value::Object(p)) if p.contains_key("processor_type") => Ok(ProcessorList(
vec![Processor::decode(&v[key], resources, bindings)?],
)),
Some(other) => list(other, resources, bindings),
}
};
Ok(Self {
random: RandomSettings::decode(v.get("random_mode"))?,
condition: Condition::decode(&v["if_true"], bindings)?,
then: processors("then")?,
otherwise: processors("else")?,
})
}
}
/// Lithostitched's processor conditions the game runs.
#[derive(Clone, Debug)]
pub(super) enum Condition {
True,
AllOf(Vec<Condition>),
AnyOf(Vec<Condition>),
Not(Box<Condition>),
RandomChance(f32),
/// The block placed (`input`) or the one in the world (`location`) is
/// one of these, with these property values.
MatchingBlocks {
blocks: Arc<BTreeSet<BlockStateId>>,
properties: Vec<(String, String)>,
location: bool,
},
}
impl Condition {
fn decode(v: &Value, bindings: &FeatureBlockBindings<'_>) -> Result<Self, DecodeError> {
let all = |a: &Value| -> Result<Vec<Condition>, DecodeError> {
a.as_array()
.ok_or(DecodeError::Field("processor conditions"))?
.iter()
.map(|c| Self::decode(c, bindings))
.collect()
};
if v.is_array() {
return Ok(Self::AllOf(all(v)?));
}
let kind = v["type"].as_str().unwrap_or_default();
Ok(match kind.strip_prefix("lithostitched:").unwrap_or(kind) {
"true" => Self::True,
"all_of" => Self::AllOf(all(&v["conditions"])?),
"any_of" => Self::AnyOf(all(&v["conditions"])?),
"not" => Self::Not(Box::new(Self::decode(&v["condition"], bindings)?)),
"random_chance" => Self::RandomChance(super::float(v, "chance")?),
"matching_blocks" => Self::MatchingBlocks {
blocks: super::holder_set(&v["blocks"], bindings)?,
properties: match v.get("properties") {
None => Vec::new(),
Some(p) => p
.as_object()
.ok_or(DecodeError::Field("matching_blocks properties"))?
.iter()
.map(|(k, x)| {
let x = x
.as_str()
.ok_or(DecodeError::Field("matching_blocks property"))?;
Ok((k.clone(), x.to_owned()))
})
.collect::<Result<_, DecodeError>>()?,
},
location: match v["match_type"].as_str() {
None | Some("input") => false,
Some("location") => true,
_ => return Err(DecodeError::Field("matching_blocks match_type")),
},
},
_ => return Err(DecodeError::Field("processor condition type")),
})
}
/// ProcessorCondition.test with the block placed and the one there.
pub(super) fn test(
&self,
input: BlockStateId,
location: BlockStateId,
registry: &BlockRegistry,
random: &mut impl RandomSource,
) -> bool {
match self {
Self::True => true,
Self::AllOf(all) => all
.iter()
.all(|c| c.test(input, location, registry, random)),
Self::AnyOf(any) => any
.iter()
.any(|c| c.test(input, location, registry, random)),
Self::Not(c) => !c.test(input, location, registry, random),
Self::RandomChance(chance) => random.next_float() < *chance,
Self::MatchingBlocks {
blocks,
properties,
location: at_location,
} => {
let state = if *at_location { location } else { input };
blocks.contains(&state)
&& properties.iter().all(|(k, x)| {
registry
.state(state)
.and_then(|s| s.properties.get(k))
.is_some_and(|v| v == x)
})
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn random_settings_salt_and_position() {
// ResourceLocation.hashCode: 31 * namespace + path.
let default = RandomSettings::decode(Some(&"per_piece".into())).unwrap();
assert_eq!(
default.salt,
java_hash("lithostitched")
.wrapping_mul(31)
.wrapping_add(java_hash("default"))
);
assert_eq!(java_hash("default"), 1544803905);
let (seed, piece, pivot, block) = (12345, [16, 64, -32], [8, 70, 8], [17, 65, -30]);
let mut ours = default.create(seed, piece, pivot, block);
let mut theirs = LegacyRandom::new(seed + i64::from(default.salt))
.fork_positional()
.at(BlockPos::new(16, 64, -32));
assert_eq!(ours.next_long(), theirs.next_long());
let named: Value = serde_json::json!({"mode": "per_structure", "name": "v:roof"});
let named = RandomSettings::decode(Some(&named)).unwrap();
assert_eq!(named.mode, Mode::Structure);
assert_eq!(
named.salt,
java_hash("v")
.wrapping_mul(31)
.wrapping_add(java_hash("roof"))
);
}
#[test]
fn apply_random_draws_by_weight() {
let apply = ApplyRandom {
random: RandomSettings {
mode: Mode::Block,
salt: 0,
},
lists: vec![
(ProcessorList(vec![Processor::Nop]), 3),
(ProcessorList::default(), 1),
],
total: 4,
};
let mut ours = LegacyRandom::new(9);
let mut theirs = LegacyRandom::new(9);
for _ in 0..32 {
let draw = theirs.next_int_bound(4).unwrap();
let picked = apply.pick(&mut ours).unwrap();
assert_eq!(picked.0.len(), usize::from(draw < 3));
}
}
#[test]
fn set_block_draws_from_the_unsalted_seed() {
let (a, b) = (BlockStateId(1), BlockStateId(2));
let set = SetBlock::decode(
&serde_json::json!({"state_provider": {"type": "minecraft:weighted_state_provider",
"entries": [{"weight": 1, "data": 1}, {"weight": 2, "data": 2}]},
"random_mode": "per_piece"}),
|v| Ok(BlockStateId(v.as_u64().unwrap() as u32)),
)
.unwrap();
assert!(set.preserves());
for x in 0..16 {
let mut java = LegacyRandom::new(77)
.fork_positional()
.at(BlockPos::new(x, 5, 3));
let want = if java.next_int_bound(3).unwrap() < 1 {
a
} else {
b
};
assert_eq!(set.state(77, [x, 5, 3], [0; 3], [9, 9, 9]), want);
}
}
}
+16 -15
View File
@@ -504,21 +504,22 @@ Two cases the oracle fills in:
mod. Better Dungeons' small dungeons keep one chest and each other one with a fifth's
chance, two at most (the game's `horizon:count_cap`, counted per placement as the mod's
DungeonContext is): 12 chests in five small dungeons at seed 1234 become 6.
Lithostitched's `block_swap` (137 lists, all in ChoiceTheorem's Overhauled Village) is the
game's `horizon:block_swap`: the same map, with Block.withPropertiesOf's properties kept;
a target block the game does not have (most of them are Nature's Spirit's and Croptopia's)
leaves the block as it is, as the mod does. Lithostitched's `reference` (CTOV builds most
of its lists from others) has the referenced lists' processors written in its place; its
`apply_random`, `condition` and `set_block` are the game's `horizon:apply_random`,
`horizon:condition` and `horizon:set_block`, with Lithostitched's random (the world seed
plus the name's hash, at the block, piece or structure). In a CTOV plains village at seed
1234 the farms now grow carrots, potatoes and beetroot besides wheat, and 1,655
cobblestone blocks become paths, grass, mossy cobblestone and planks. No Lithostitched processor is
dropped now (130 before). CTOV's compat hooks for other mods inside them (its
`modular_compat`, 142 copies) run a list of their own only when the mod they name is
loaded: carry writes that list in when the mod is ported along and takes the hook out (a
no-op, as in the mod) when it is not. In ATM10 all 142 name Nature's Spirit, which the
pack does not have.
Lithostitched's processors (all in ChoiceTheorem's Overhauled Village in ATM10) are the
game's own types, read as the mod writes them. `block_swap` keeps the properties the new
block can have: a farm's ripe wheat becomes ripe carrots, but beetroot, whose age stops at
3, keeps its default; a target block the game does not have (most are Nature's Spirit's and
Croptopia's) leaves the block as it is. `reference` runs the lists it names, `condition`
tests the template's block or the world's, `apply_random` and `set_block` draw per block or
per piece as the data says. Lithostitched's own random is not known yet, so the shares of a
random choice are the Java pack's but the blocks chosen differ. Lists named by tag and state
providers other than simple and weighted ones are dropped and counted (ATM10 has none).
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.
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
differ from the Java pack's for the same seed (their positions do not).
The processors that build outside their template are carried too: Repurposed's and
Moog's pillar processors become the game's `horizon:pillar` (a column from a placeholder
block down to the ground, with the processor list they run on each of its blocks written
+6
View File
@@ -55,6 +55,12 @@ KNOWN = {
"Better Dungeons' own structure types (spec-betterdungeons §5, §6; OPEN-B14, B15) are not "
'implemented yet',
},
'ctov': {
r'^ctov:': "Lithostitched's random is not known (spec-lithostitched OPEN-L6): per-block road and palette "
'choices match by share, not by block; and decoration (grass, trees) differs where the village '
'changed the terrain',
r'^minecraft:village_plains$': 'decoration (grass, trees) differs where the village changed the terrain',
},
}