diff --git a/crates/horizon-port-pack/src/carry.rs b/crates/horizon-port-pack/src/carry.rs index 82dbf11af..a87f92ede 100644 --- a/crates/horizon-port-pack/src/carry.rs +++ b/crates/horizon-port-pack/src/carry.rs @@ -828,9 +828,9 @@ pub fn carry( // Additions to other sets and pools, as files the game merges. let modifier = serde_json::from_slice::(&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, diff --git a/crates/horizon-port-pack/src/legacy_structure_mods/ctov.rs b/crates/horizon-port-pack/src/legacy_structure_mods/ctov.rs index af46174ae..62c25739f 100644 --- a/crates/horizon-port-pack/src/legacy_structure_mods/ctov.rs +++ b/crates/horizon-port-pack/src/legacy_structure_mods/ctov.rs @@ -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": []}); diff --git a/crates/horizon-port-pack/src/legacy_structure_mods/lithostitched.rs b/crates/horizon-port-pack/src/legacy_structure_mods/lithostitched.rs deleted file mode 100644 index 54415a76a..000000000 --- a/crates/horizon-port-pack/src/legacy_structure_mods/lithostitched.rs +++ /dev/null @@ -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, -) -> Option { - 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//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> = 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, -) -> Option> { - 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, - data: &ProcessorData, - carried: &mut BTreeMap, -) -> (Vec, 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) -> 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, -) -> Option { - 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 = 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, -) -> Option { - 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, - data: &ProcessorData, - carried: &mut BTreeMap, - depth: usize, -) -> (Vec, 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); - } -} diff --git a/crates/horizon-port-pack/src/legacy_structure_mods/mod.rs b/crates/horizon-port-pack/src/legacy_structure_mods/mod.rs index 1faa1b39f..980798720 100644 --- a/crates/horizon-port-pack/src/legacy_structure_mods/mod.rs +++ b/crates/horizon-port-pack/src/legacy_structure_mods/mod.rs @@ -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( diff --git a/crates/horizon-port-pack/src/lib.rs b/crates/horizon-port-pack/src/lib.rs index 3b17f1c9c..b47c93754 100644 --- a/crates/horizon-port-pack/src/lib.rs +++ b/crates/horizon-port-pack/src/lib.rs @@ -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; diff --git a/crates/horizon-port-pack/src/lithostitched.rs b/crates/horizon-port-pack/src/lithostitched.rs new file mode 100644 index 000000000..e65efcf0e --- /dev/null +++ b/crates/horizon-port-pack/src/lithostitched.rs @@ -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, 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, +) -> Option { + 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, +) -> 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> { + 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, +) -> Option> { + 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, + data: &ProcessorData, + carried: &mut BTreeMap, +) -> (Vec, 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) { + 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), + ]) + ); + } + } +} diff --git a/crates/horizon-port-pack/src/processors.rs b/crates/horizon-port-pack/src/processors.rs index af1595f92..046eaa0bf 100644 --- a/crates/horizon-port-pack/src/processors.rs +++ b/crates/horizon-port-pack/src/processors.rs @@ -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(zip: &mut zip::ZipArchive) -> 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)]), diff --git a/crates/horizon-port-pack/src/worldgen.rs b/crates/horizon-port-pack/src/worldgen.rs index 298179acf..0e3d968e3 100644 --- a/crates/horizon-port-pack/src/worldgen.rs +++ b/crates/horizon-port-pack/src/worldgen.rs @@ -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) 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; } diff --git a/crates/horizon-world/src/worldgen/structure/processor.rs b/crates/horizon-world/src/worldgen/structure/processor.rs index f6f5d4508..b0a0622b7 100644 --- a/crates/horizon-world/src/worldgen/structure/processor.rs +++ b/crates/horizon-world/src/worldgen/structure/processor.rs @@ -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 { + 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), Pick(Box), CountCap(Box), - /// `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), - /// `horizon:apply_random` (Lithostitched's `apply_random`): a processor - /// list drawn by weight runs on the block. - ApplyRandom(Box), - /// `horizon:condition` (Lithostitched's `condition`): `then` or `else` + /// `lithostitched:apply_random`: a processor list drawn by weight runs + /// on the block. + ApplyRandom(Box), + /// `lithostitched:reference`: the named lists run on the block in turn. + Reference(Vec), + /// `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), - /// `horizon:set_block` (Lithostitched's `set_block`). - SetBlock(Box), + Condition(Box), + /// `lithostitched:set_block`: the block from a state provider. + SetBlock(Box), /// `horizon:pillar` (Repurposed Structures' and Moog's pillars): a /// trigger block starts a column down to the ground. Pillar(Box), @@ -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::>()?, ), "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( 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( 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( 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 diff --git a/crates/horizon-world/src/worldgen/structure/processor/litho.rs b/crates/horizon-world/src/worldgen/structure/processor/litho.rs new file mode 100644 index 000000000..6ba006fd6 --- /dev/null +++ b/crates/horizon-world/src/worldgen/structure/processor/litho.rs @@ -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 { + 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 { + 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, 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 { + 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::, 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), + AnyOf(Vec), + Not(Box), + Chance(f32), + /// `matching_blocks`: the template's block (`input`) or the world's + /// (`location`) is one of `blocks`, with `properties`. + Matching { + blocks: BTreeSet, + 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 { + let list = |v: &Value| -> Result, 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, 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 { + 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, + ) -> Result { + 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::>()?, + _ => 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 { + 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 = (0..8).map(|a| a.to_string()).collect(); + let ages: Vec<&str> = ages.iter().map(String::as_str).collect(); + let levels: Vec = (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 { + Ok(self.seed) + } + fn block_state(&mut self, _: [i32; 3]) -> Result { + Ok(self.block) + } + fn generation(&self) -> WorldGenerationContext { + WorldGenerationContext::new(-64, 384, 63) + } + fn min_y(&mut self) -> Result { + Ok(-64) + } + fn height(&mut self, _: HeightmapType, _: i32, _: i32) -> Result { + 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 { + 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 { + 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) -> String { + match info { + None => "none".into(), + Some(info) => { + let s = self.registry.state(info.state).unwrap(); + let props: Vec = 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::::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 { + (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 = (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 = (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() + ); + } +} diff --git a/crates/horizon-world/src/worldgen/structure/processor/lithostitched.rs b/crates/horizon-world/src/worldgen/structure/processor/lithostitched.rs deleted file mode 100644 index f0bb1c6ba..000000000 --- a/crates/horizon-world/src/worldgen/structure/processor/lithostitched.rs +++ /dev/null @@ -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 { - 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 { - 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, - ) -> Result { - 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::>()?, - 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::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 { - 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::, 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 { - // A processor alone or a list of them. - let processors = |key: &str| -> Result { - 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), - AnyOf(Vec), - Not(Box), - RandomChance(f32), - /// The block placed (`input`) or the one in the world (`location`) is - /// one of these, with these property values. - MatchingBlocks { - blocks: Arc>, - properties: Vec<(String, String)>, - location: bool, - }, -} -impl Condition { - fn decode(v: &Value, bindings: &FeatureBlockBindings<'_>) -> Result { - let all = |a: &Value| -> Result, 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::>()?, - }, - 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); - } - } -} diff --git a/docs/modpacks.md b/docs/modpacks.md index 13ad619ed..8e3980dd2 100644 --- a/docs/modpacks.md +++ b/docs/modpacks.md @@ -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 diff --git a/tools/test_structure_blocks_parity.py b/tools/test_structure_blocks_parity.py index 39ccb4e49..fc4151f8d 100644 --- a/tools/test_structure_blocks_parity.py +++ b/tools/test_structure_blocks_parity.py @@ -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', + }, }