diff --git a/crates/horizon-port-pack/src/carry.rs b/crates/horizon-port-pack/src/carry.rs index 42c0483b4..82dbf11af 100644 --- a/crates/horizon-port-pack/src/carry.rs +++ b/crates/horizon-port-pack/src/carry.rs @@ -814,7 +814,6 @@ pub fn carry( if kind == "structure" { let path = rel.trim_start_matches("worldgen/structure/"); let structure = format!("{ns}:{}", path.trim_end_matches(".json")); - changed |= crate::legacy_structure_mods::yung::disables(&structure, &mut v); changed |= crate::worldgen::add_piece_counts(&structure, &mut v, &piece_counts); } 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 49db376bc..1faa1b39f 100644 --- a/crates/horizon-port-pack/src/legacy_structure_mods/mod.rs +++ b/crates/horizon-port-pack/src/legacy_structure_mods/mod.rs @@ -15,7 +15,6 @@ //! conversion. pub(crate) mod ctov; pub(crate) mod lithostitched; -pub(crate) mod yung; #[cfg(test)] fn data() -> crate::processors::ProcessorData { diff --git a/crates/horizon-port-pack/src/legacy_structure_mods/yung.rs b/crates/horizon-port-pack/src/legacy_structure_mods/yung.rs deleted file mode 100644 index 26dd25923..000000000 --- a/crates/horizon-port-pack/src/legacy_structure_mods/yung.rs +++ /dev/null @@ -1,1055 +0,0 @@ -//! YUNG's API and the mods built on it: structures, placements, pool -//! elements and processors. -use crate::worldgen::{ENHANCED_TERRAIN, HORIZON_ENHANCED_TERRAIN, moogs_terrain, placement_rules}; -use serde_json::{Value, json}; -use std::collections::BTreeMap; - -/// YUNG's placements: vanilla's random spread with an enhanced exclusion -/// zone (several sets), plus, for the temples, no start near an ocean or a -/// river, and for strongholds, starts only in odd rings around the origin. -const YUNG_SPREADS: [&str; 4] = [ - "yungsapi:enhanced_random_spread", - "betterdeserttemples:desert_temple", - "betterjungletemples:jungle_temple", - "betterstrongholds:stronghold", -]; -/// YUNG's structures and the vanilla structure type each one's mod turns -/// off (its DisableVanilla*Mixin, on by default in each mod's config). -const YUNG_REPLACES: [(&str, &str); 7] = [ - ( - "betterdeserttemples:desert_temple", - "minecraft:desert_pyramid", - ), - ( - "betterjungletemples:jungle_temple", - "minecraft:jungle_temple", - ), - ("betterwitchhuts:witch_hut", "minecraft:swamp_hut"), - ( - "betteroceanmonuments:ocean_monument", - "minecraft:ocean_monument", - ), - ("betterstrongholds:stronghold", "minecraft:stronghold"), - ("betterfortresses:fortress", "minecraft:fortress"), - ("bettermineshafts:mineshaft_", "minecraft:mineshaft"), -]; - -/// A `structure` or `structure_set` file of YUNG's types as vanilla's: its -/// jigsaw, and its placements. `None` for any other file. -pub(crate) fn upgrade( - kind: &str, - v: &mut Value, - set_tags: &BTreeMap>, - lost: &mut BTreeMap, -) -> Option { - match kind { - "structure" => match v["type"].as_str() { - // Better Dungeons' small Nether dungeon is YUNG's jigsaw behind a - // config switch that ships on. - Some("yungsapi:yung_jigsaw" | "betterdungeons:small_nether_dungeon") => { - yung_structure(v, lost); - Some(true) - } - _ => None, - }, - "structure_set" => match v["placement"]["type"].as_str() { - Some(t) if YUNG_SPREADS.contains(&t) => { - let t = t.to_owned(); - yung_placement(&t, &mut v["placement"], set_tags, lost); - Some(true) - } - _ => None, - }, - _ => None, - } -} - -/// A pool element of YUNG's type as vanilla's; whether it was one. -pub(crate) fn upgrade_pool_element( - element: &mut Value, - lost: &mut BTreeMap, -) -> bool { - if element["element_type"] != "yungsapi:yung_single_element" { - return false; - } - yung_element(element, lost); - true -} - -/// YUNG's API's jigsaw as vanilla's, laid out by its own assembler in the -/// game (`horizon:yungsapi`, `horizon_world::worldgen::structure::yung`): -/// the heights pieces stay within move under the key, its terrain -/// adaptation under `horizon:enhanced_terrain_adaptation`. A start offset in -/// the chunk is lost. -fn yung_structure(v: &mut Value, lost: &mut BTreeMap) { - let Some(o) = v.as_object_mut() else { - return; - }; - o.insert("type".into(), "minecraft:jigsaw".into()); - let mut rules = serde_json::Map::new(); - for key in ["min_y", "max_y"] { - if let Some(y) = o.remove(key) { - rules.insert(key.into(), y); - } - } - // YungJigsawStructure's adaptation (NONE by default): every piece's - // junctions, and the start's box grown by its radius. - let own = o.remove(ENHANCED_TERRAIN); - let mut kernel = yung_terrain(own.as_ref().unwrap_or(&Value::Null), lost); - kernel["rigid_junctions"] = true.into(); - kernel["inflate"] = true.into(); - o.insert(HORIZON_ENHANCED_TERRAIN.into(), kernel); - for key in ["x_offset_in_chunk", "z_offset_in_chunk"] { - if o.remove(key).is_some_and(|n| n != 0) { - *lost.entry(key.into()).or_default() += 1; - } - } - o.insert("horizon:yungsapi".into(), rules.into()); -} - -/// YUNG's single pool element as vanilla's, its rules under -/// `horizon:yungsapi`: name and `max_count`, depth range, priority, -/// `ignore_bounds`, its condition, dead-end pool and modifiers; its terrain -/// adaptation under `horizon:enhanced_terrain_adaptation`. -fn yung_element(element: &mut Value, lost: &mut BTreeMap) { - let Some(o) = element.as_object_mut() else { - return; - }; - o.insert( - "element_type".into(), - "minecraft:single_pool_element".into(), - ); - let mut rules = serde_json::Map::new(); - for (from, to) in [ - ("name", "name"), - ("max_count", "max_count"), - ("min_required_depth", "min_depth"), - ("max_possible_depth", "max_depth"), - ("is_priority", "priority"), - ("ignore_bounds", "ignore_bounds"), - ] { - if let Some(x) = o.remove(from) { - rules.insert(to.into(), x); - } - } - if let Some(c) = o.remove("condition") { - rules.insert("condition".into(), yung_condition(&c, lost)); - } - // A piece's own adaptation, NONE included, replaces its structure's. - if let Some(a) = o.remove(ENHANCED_TERRAIN) { - o.insert(HORIZON_ENHANCED_TERRAIN.into(), yung_terrain(&a, lost)); - } - if let Some(pool) = o.remove("deadend_pool").filter(Value::is_string) { - rules.insert("deadend_pool".into(), pool); - } - let modifiers: Vec = o - .remove("modifiers") - .and_then(|m| m.as_array().cloned()) - .unwrap_or_default() - .iter() - .filter_map(|m| yung_modifier(m, lost)) - .collect(); - if !modifiers.is_empty() { - rules.insert("modifiers".into(), modifiers.into()); - } - o.insert("horizon:yungsapi".into(), rules.into()); -} - -/// One of YUNG's structure modifiers in the game's terms: its condition and -/// its transform and delay_generation actions, on the piece itself (`self`, -/// the API's only target). Another target loses the modifier, another action -/// just that action. -fn yung_modifier(m: &Value, lost: &mut BTreeMap) -> Option { - let target = m["target_selector"]["type"].as_str().unwrap_or_default(); - if target != "yungsapi:self" { - *lost - .entry(format!("yungsapi modifier target {target}")) - .or_default() += 1; - return None; - } - let mut actions = Vec::new(); - for a in m["actions"].as_array().into_iter().flatten() { - let int = |k: &str| a[k].as_i64().unwrap_or(0); - match a["type"].as_str().unwrap_or_default() { - "yungsapi:transform" => actions.push(serde_json::json!({"type": "transform", - "output": a["output"].clone(), - "offset": [int("x_offset"), int("y_offset"), int("z_offset")]})), - "yungsapi:delay_generation" => { - actions.push(serde_json::json!({"type": "delay_generation"})) - } - other => { - *lost - .entry(format!("yungsapi modifier action {other}")) - .or_default() += 1 - } - } - } - let condition = match m.get("condition") { - Some(c) => yung_condition(c, lost), - None => serde_json::json!({"type": "true"}), - }; - Some(serde_json::json!({"condition": condition, "actions": actions})) -} - -/// YUNG's API's terrain adaptation as the game's kernel: its two presets -/// spelled out, NONE (also the default) as size 0. An aquifer override -/// (none of ATM10's) is lost. -fn yung_terrain(a: &Value, lost: &mut BTreeMap) -> Value { - let kernel = |size: i32, distance: i32, top: &str, bottom: &str| { - serde_json::json!({"kernel_size": size, "kernel_distance": distance, - "top": top, "bottom": bottom}) - }; - match a["type"].as_str() { - Some("yungsapi:carved_top_no_beard_large") => kernel(24, 16, "carve", "none"), - Some("yungsapi:carved_top_no_beard_small") => kernel(12, 6, "carve", "none"), - Some("yungsapi:custom") => { - let mut out = serde_json::Map::new(); - for key in [ - "kernel_size", - "kernel_distance", - "top", - "bottom", - "bottom_offset", - "padding", - ] { - if let Some(x) = a.get(key) { - out.insert(key.into(), x.clone()); - } - } - if a.get("aquifer_override") - .is_some_and(|x| x["type"] != "yungsapi:none") - { - *lost.entry("yungsapi aquifer_override".into()).or_default() += 1; - } - moogs_terrain(&out.into(), lost).unwrap_or_else(|| kernel(0, 0, "none", "none")) - } - None | Some("yungsapi:none") => kernel(0, 0, "none", "none"), - Some(other) => { - *lost - .entry(format!("yungsapi terrain adaptation {other}")) - .or_default() += 1; - kernel(0, 0, "none", "none") - } - } -} - -/// One of YUNG's structure conditions in the game's terms. `mod_loaded` -/// holds for Minecraft alone: a mod that needs code is not ported, and the -/// pieces it guards would place its blocks. A condition the game does not -/// have does not hold, and is counted. -fn yung_condition(c: &Value, lost: &mut BTreeMap) -> Value { - let t = c["type"].as_str().unwrap_or_default(); - let list = |lost: &mut BTreeMap| -> Value { - let all = c["conditions"].as_array().into_iter().flatten(); - all.map(|x| yung_condition(x, lost)) - .collect::>() - .into() - }; - let opt = |k: &str| c.get(k).cloned().unwrap_or(Value::Null); - match t.trim_start_matches("yungsapi:") { - "always_true" => serde_json::json!({"type": "true"}), - "not" => { - serde_json::json!({"type": "not", "condition": yung_condition(&c["condition"], lost)}) - } - "all_of" => serde_json::json!({"type": "all_of", "conditions": list(lost)}), - "any_of" => serde_json::json!({"type": "any_of", "conditions": list(lost)}), - "depth" => serde_json::json!({"type": "depth", - "min_depth": opt("min_required_depth"), "max_depth": opt("max_possible_depth")}), - "altitude" => serde_json::json!({"type": "altitude", - "bottom": opt("bottom_cutoff_y"), "top": opt("top_cutoff_y")}), - "random_chance" => serde_json::json!({"type": "random_chance", "chance": opt("chance")}), - "rotation" => serde_json::json!({"type": "rotation", "rotations": opt("rotations")}), - "piece_in_horizontal_direction" => { - // "yungsapi:all" (or no list) matches any piece. - let pieces: Vec = c["pieces"].as_array().cloned().unwrap_or_default(); - let any = pieces.is_empty() || pieces.iter().any(|p| p == "yungsapi:all"); - serde_json::json!({"type": "piece_in_horizontal_direction", - "pieces": if any { Vec::new() } else { pieces }, - "range": opt("range"), "rotation": opt("rotation")}) - } - // The game reads "yungsapi:all" (or no list) as any piece. - "piece_in_range" => serde_json::json!({"type": "piece_in_range", - "pieces": c.get("pieces").cloned().unwrap_or_else(|| Vec::::new().into()), - "horizontal_range": opt("horizontal_range"), "above_range": opt("above_range"), - "below_range": opt("below_range")}), - "mod_loaded" if c["modid"] == "minecraft" => serde_json::json!({"type": "true"}), - "mod_loaded" => serde_json::json!({"type": "false"}), - _ => { - *lost.entry(format!("yungsapi condition {t}")).or_default() += 1; - serde_json::json!({"type": "false"}) - } - } -} - -/// One of YUNG's placements as vanilla's random spread with `horizon:` keys: -/// the enhanced exclusion zone as `horizon:exclusion_zone`; the temples' -/// ocean and river search (findBiomeHorizontal around the chunk's middle -/// block at y 120, 48 blocks, every 2 quarts) as `horizon:avoid_biomes`; the -/// strongholds' rings as `horizon:ring_sections`. -fn yung_placement( - t: &str, - p: &mut Value, - set_tags: &BTreeMap>, - lost: &mut BTreeMap, -) { - placement_rules(p, set_tags, lost); - p["type"] = "minecraft:random_spread".into(); - let Some(o) = p.as_object_mut() else { - return; - }; - match t { - "betterdeserttemples:desert_temple" | "betterjungletemples:jungle_temple" => { - o.insert( - "horizon:avoid_biomes".into(), - serde_json::json!({"biomes": ["#minecraft:is_river", "#minecraft:is_ocean"], - "radius": 48, "step": 2, "y": 120}), - ); - } - "betterstrongholds:stronghold" => { - let mut rings = serde_json::Map::new(); - for (from, to) in [ - ("chunk_distance_to_first_ring", "distance_to_first_ring"), - ("ring_chunk_thickness", "thickness"), - ("max_ring_section", "max_section"), - ] { - if let Some(n) = o.remove(from) { - rings.insert(to.into(), n); - } - } - o.insert("horizon:ring_sections".into(), rings.into()); - } - _ => {} - } -} - -/// Marks a YUNG's structure that the game can start (its type is vanilla's -/// after the upgrade) with the vanilla type its mod turns off, under -/// `horizon:disables`. One the game cannot start leaves the vanilla -/// structure in place, so the world keeps one of the two. -pub(crate) fn disables(id: &str, v: &mut Value) -> bool { - let Some((_, vanilla)) = YUNG_REPLACES.iter().find(|(s, _)| id.starts_with(s)) else { - return false; - }; - if !v["type"] - .as_str() - .is_some_and(|t| t.starts_with("minecraft:")) - { - return false; - } - v["horizon:disables"] = serde_json::json!([vanilla]); - true -} - -/// The game's type for the YUNG's processors that place blocks past their -/// template, which keep their fields (`horizon-world`'s `pillars.rs`). -pub(crate) fn pillar_type(kind: &str) -> Option<&'static str> { - Some(match kind { - "betterfortresses:pillar_processor" | "betterjungletemples:pillar_processor" => { - "horizon:yung_pillar" - } - _ => return None, - }) -} - -/// The data form of one of YUNG's processors: the processors to put in its -/// place, or `None` when it has none (or is not YUNG's). -pub(crate) fn processor(kind: &str, p: &Value) -> Option> { - let (ns, _) = kind.split_once(':')?; - Some(match kind { - // Better Fortresses' stair supports get the blocks its Java code - // names. - "betterfortresses:stair_pillar_processor" => { - vec![json!({"processor_type": "horizon:stair_pillar", - "blocks": {"minecraft:stone_brick_stairs": "minecraft:red_nether_brick_stairs", - "minecraft:purpur_stairs": "minecraft:red_nether_bricks"}, - "front": "minecraft:red_nether_brick_stairs", - "pillar": "minecraft:red_nether_bricks"})] - } - _ if ns == "betterdeserttemples" - && let Some(processor) = desert_temple(kind) => - { - vec![processor] - } - "betterdungeons:small_dungeon_chest_processor" => { - // Its config's defaults: one chest at least, two at most, a - // fifth of the others kept until then; ChestBlock covers both. - vec![json!({"processor_type": "horizon:count_cap", - "blocks": ["minecraft:chest", "minecraft:trapped_chest"], - "min": 1, "max": 2, "chance": 0.2f32 as f64, - "replacement": block("minecraft:cave_air")})] - } - "betterwitchhuts:leg_processor" => vec![yung_leg( - "minecraft:brown_stained_glass", - json!({"id": "minecraft:oak_log", "properties": {"axis": "y"}}), - false, - )], - "betterwitchhuts:fence_leg_processor" => vec![yung_leg( - "minecraft:crimson_fence", - block("minecraft:oak_fence"), - true, - )], - _ if ns == "betteroceanmonuments" => ocean_monument(kind)?, - _ if ns == "betterdungeons" || ns == "betterdeserttemples" => vec![yung_spawner(kind, p)?], - _ => return None, - }) -} - -/// YUNG's leg processors (Better Ocean Monuments, Better Witch Huts): a -/// placeholder block becomes `state` and a column of it runs down through -/// air and fluids: the game's `horizon:yung_pillar` with fixed states, and -/// the placeholder's properties kept for the fence legs. -fn yung_leg(target: &str, state: Value, keep_properties: bool) -> Value { - let fixed = json!({"entries": [], "defaultBlockState": state}); - json!({"processor_type": "horizon:yung_pillar", "target_block": block(target), - "target_block_output": fixed, "pillar_states": fixed, - "keep_properties": keep_properties}) -} - -/// Better Ocean Monuments' processors (no fields; what they do is in their -/// Java code): placeholder blocks as vanilla's `rule` or the game's -/// `horizon:pick` (a new `nextFloat` for each chance, as the mod draws), -/// and its sea-level ones as the game's `horizon:flood_air`, -/// `horizon:waterlog` and `horizon:keep_world_block`. -fn ocean_monument(kind: &str) -> Option> { - let rule = |rules: &[(&str, Value)]| { - let rules: Vec = rules - .iter() - .map(|(from, to)| { - json!({"input_predicate": {"predicate_type": "minecraft:block_match", "block": from}, - "location_predicate": {"predicate_type": "minecraft:always_true"}, - "output_state": to}) - }) - .collect(); - json!({"processor_type": "minecraft:rule", "rules": rules}) - }; - let state = |id: &str, props: Value| json!({"id": id, "properties": props}); - let pick = |from: &str, picks: Vec<(f32, Value)>, default: Value, keep: bool| { - let picks: Vec = picks - .into_iter() - .map(|(below, state)| json!({"below": below as f64, "state": state})) - .collect(); - json!({"processor_type": "horizon:pick", "block": from, "picks": picks, - "default": {"state": default}, "redraw": true, "keep_properties": keep}) - }; - let slabs = |from: &str, slab: &str, otherwise: &str| { - let wet = |t: &str| state(slab, json!({"type": t, "waterlogged": "true"})); - pick( - from, - vec![(0.4, wet("top")), (0.8, wet("bottom"))], - block(otherwise), - false, - ) - }; - // Exposed below 0.1, else weathered below a new 0.3, else oxidized, - // with the placeholder's properties for the stairs and slabs. - let copper = |kind: &str| { - let id = |age: &str| block(&format!("minecraft:{age}{kind}")); - pick( - &format!("minecraft:oxidized_{kind}"), - vec![(0.1, id("exposed_")), (0.3, id("weathered_"))], - id("oxidized_"), - true, - ) - }; - Some(match kind.strip_prefix("betteroceanmonuments:")? { - "random_prismarine_slab_decoration_processor" => vec![slabs( - "minecraft:lime_concrete", - "minecraft:prismarine_slab", - "minecraft:air", - )], - "random_dark_prismarine_slab_decoration_processor" => vec![slabs( - "minecraft:blue_concrete", - "minecraft:dark_prismarine_slab", - "minecraft:water", - )], - "sand_gravel_processor" => { - vec![rule(&[( - "minecraft:yellow_stained_glass", - block("minecraft:gravel"), - )])] - } - "random_oxidization_processor" => [ - "copper", - "cut_copper", - "cut_copper_stairs", - "cut_copper_slab", - ] - .into_iter() - .map(copper) - .collect(), - "structure_void_processor" => vec![json!({"processor_type": "horizon:keep_world_block", - "blocks": ["minecraft:purpur_slab", "minecraft:brown_wool"]})], - "air_processor" => vec![json!({"processor_type": "horizon:flood_air"})], - "waterlog_processor" => vec![json!({"processor_type": "horizon:waterlog"})], - "seagrass_processor" => vec![rule(&[ - ( - "minecraft:red_sandstone_slab", - state("minecraft:tall_seagrass", json!({"half": "lower"})), - ), - ( - "minecraft:end_stone_brick_slab", - state("minecraft:tall_seagrass", json!({"half": "upper"})), - ), - ("minecraft:brick_slab", block("minecraft:seagrass")), - ])], - "random_sponge_processor" => vec![pick( - "minecraft:orange_stained_glass", - vec![(0.75, block("minecraft:wet_sponge"))], - block("minecraft:water"), - false, - )], - "leg_processor" => vec![yung_leg( - "minecraft:blue_stained_glass", - block("minecraft:prismarine_bricks"), - false, - )], - _ => return None, - }) -} - -/// A block state as 26.3 writes it. -fn block(id: &str) -> Value { - json!({"id": id}) -} - -/// Better Desert Temples' placeholder blocks (glass, concrete and wool in -/// the templates) as the mod's processors turn them: the fixed ones as -/// vanilla's `rule`, the random ones as the game's `horizon:pick`, with the -/// mod's bounds on one `nextFloat` (YUNG's BlockStateRandomizer adds its -/// chances up in floats). The ones that read or change the world around -/// (pillars, Diorite's waterlogging) stay dropped. -fn desert_temple(kind: &str) -> Option { - let swap = |from: &str, to: &str| { - json!({"processor_type": "minecraft:rule", "rules": [{ - "input_predicate": {"predicate_type": "minecraft:block_match", "block": from}, - "location_predicate": {"predicate_type": "minecraft:always_true"}, - "output_state": block(to)}]}) - }; - let pick = |from: &str, picks: Vec, default: Value| json!({"processor_type": "horizon:pick", "block": from, "picks": picks, "default": default}); - // YUNG's BlockStateRandomizer: an entry wins below the sum of the - // chances so far, summed as floats. - let randomizer = |entries: &[(f32, &str)]| { - let mut bottom = 0f32; - entries - .iter() - .map(|(chance, id)| { - bottom += chance; - json!({"below": bottom as f64, "state": block(id)}) - }) - .collect::>() - }; - let at = |below: f32, id: &str| json!({"below": below as f64, "state": block(id)}); - // Sandstone stairs, upside down, facing Plane.HORIZONTAL.getRandomDirection. - let stairs = || { - let states: Vec = ["north", "east", "south", "west"] - .iter() - .map(|facing| { - json!({"id": "minecraft:sandstone_stairs", "properties": { - "facing": facing, "half": "top", "shape": "straight", "waterlogged": "false"}}) - }) - .collect(); - json!({"states": states}) - }; - Some(match kind.strip_prefix("betterdeserttemples:")? { - "blue_concrete_processor" => swap("minecraft:blue_concrete", "minecraft:blue_terracotta"), - "yellow_concrete_processor" => { - swap("minecraft:yellow_concrete", "minecraft:yellow_terracotta") - } - "red_wool_processor" => swap("minecraft:red_wool", "minecraft:tnt"), - "white_stained_glass_processor" => pick( - "minecraft:white_stained_glass", - randomizer(&[(0.4, "minecraft:cobweb")]), - json!({"state": block("minecraft:air")}), - ), - "polished_diorite_processor" => pick( - "minecraft:polished_diorite", - randomizer(&[(0.6, "minecraft:cut_sandstone")]), - json!({"state": block("minecraft:sandstone")}), - ), - "orange_stained_glass_processor" => { - let mut sus = at(0.01, "minecraft:suspicious_sand"); - sus["nbt"] = json!({"LootTable": "minecraft:archaeology/desert_pyramid"}); - let mut stairs = stairs(); - stairs["below"] = (0.45f32 as f64).into(); - pick( - "minecraft:orange_stained_glass", - vec![ - sus, - at(0.1, "minecraft:sand"), - at(0.2, "minecraft:sandstone"), - stairs, - ], - json!({"state": block("minecraft:cut_sandstone")}), - ) - } - "red_stained_glass_processor" => pick( - "minecraft:red_stained_glass", - vec![at(0.3, "minecraft:sand"), at(0.75, "minecraft:sandstone")], - stairs(), - ), - "pot_processor" => { - // Four sherds, one float each; the pot back to its default state. - let values: Vec = [ - (0.05f32, "archer"), - (0.1, "miner"), - (0.15, "prize"), - (0.2, "skull"), - ] - .iter() - .map(|(below, sherd)| { - json!({"below": *below as f64, "value": format!("minecraft:{sherd}_pottery_sherd")}) - }) - .collect(); - pick( - "minecraft:decorated_pot", - Vec::new(), - json!({"state": block("minecraft:decorated_pot"), "lists": [{ - "key": "sherds", "count": 4, "values": values, "default": "minecraft:brick"}]}), - ) - } - _ => return None, - }) -} - -/// An item as 26.3 saves it. -fn item(id: &str) -> Value { - json!({"id": format!("minecraft:{id}"), "count": 1}) -} - -/// YUNG's processors that make a spawner of a fixed mob (YUNG's API's -/// `MobSpawnerData`), as vanilla's `rule`: every `input` block becomes a -/// spawner with that NBT. -fn yung_spawner(kind: &str, p: &Value) -> Option { - const SPAWNER: &str = "minecraft:spawner"; - // (input block, mob, fields other than the defaults, whether the mob is - // also the only potential, its equipment) - let (input, mob, fields, potential, equipment): (_, _, &[(&str, i64)], _, _) = match kind { - "betterdungeons:mob_spawner_processor" => { - (SPAWNER, p["spawner_mob"].as_str()?, &[], true, None) - } - "betterdungeons:zombie_mob_spawner_processor" => ( - SPAWNER, - "minecraft:zombie", - &[("MaxNearbyEntities", 8)], - true, - None, - ), - "betterdungeons:skeleton_mob_spawner_processor" => ( - SPAWNER, - "minecraft:skeleton", - &[ - ("RequiredPlayerRange", 18), - ("MaxNearbyEntities", 8), - ("MaxSpawnDelay", 650), - ], - true, - None, - ), - "betterdungeons:small_nether_dungeon_mob_spawner_processor" => { - let mob = p["spawner_mob"].as_str()?; - // Wither skeletons get netherite armor that never drops and a - // stone sword (with the mod's default config, they and blazes - // keep their loot). - let armor = (mob == "minecraft:wither_skeleton").then(|| { - json!({ - "equipment": { - "feet": item("netherite_boots"), - "legs": item("netherite_leggings"), - "chest": item("netherite_chestplate"), - "head": item("netherite_helmet"), - "mainhand": item("stone_sword"), - }, - "drop_chances": {"feet": 0.0, "legs": 0.0, "chest": 0.0, "head": 0.0}, - }) - }); - (SPAWNER, mob, &[], true, armor) - } - "betterdungeons:zombie_tombstone_spawner_processor" => ( - "minecraft:black_stained_glass", - "minecraft:skeleton", - &[], - false, - Some(json!({"equipment": {"mainhand": item("iron_sword")}})), - ), - "betterdeserttemples:gravel_processor" => ( - "minecraft:gravel", - "minecraft:husk", - &[("MaxNearbyEntities", 8), ("RequiredPlayerRange", 24)], - false, - None, - ), - // An empty hand list in 1.21.1: the skeletons spawn without a bow, - // since a spawn entry with more than its id is not finalized. - "betterdeserttemples:bone_block_processor" => ( - "minecraft:bone_block", - "minecraft:skeleton", - &[("RequiredPlayerRange", 32)], - false, - Some(json!({"equipment": {}})), - ), - "betterdeserttemples:infested_cracked_stone_bricks_processor" => ( - "minecraft:infested_cracked_stone_bricks", - "minecraft:silverfish", - &[("RequiredPlayerRange", 24)], - false, - None, - ), - // 1.21.1 ignores the items' old `Count` and `tag` (the shield's - // pattern, the boots' dye): plain items. - "betterdeserttemples:yellow_wool_processor" => ( - "minecraft:yellow_wool", - "minecraft:husk", - &[("MaxNearbyEntities", 8), ("RequiredPlayerRange", 24)], - false, - Some(json!({"equipment": { - "mainhand": item("golden_sword"), - "offhand": item("shield"), - "feet": item("leather_boots"), - "legs": item("leather_leggings"), - "chest": item("golden_chestplate"), - "head": item("chainmail_helmet"), - }})), - ), - _ => return None, - }; - let mut entity = json!({"id": mob}); - if let Some(Value::Object(extra)) = equipment { - entity.as_object_mut()?.extend(extra); - } - let potentials = match potential { - true => json!([{"data": {"entity": entity.clone()}, "weight": 1}]), - false => json!([]), - }; - let mut nbt = json!({ - "Delay": 20, "MinSpawnDelay": 200, "MaxSpawnDelay": 800, "SpawnCount": 4, - "MaxNearbyEntities": 6, "RequiredPlayerRange": 16, "SpawnRange": 4, - "SpawnData": {"entity": entity}, - "SpawnPotentials": potentials, - }); - for (key, value) in fields { - nbt[*key] = (*value).into(); - } - Some(json!({ - "processor_type": "minecraft:rule", - "rules": [{ - "input_predicate": {"predicate_type": "minecraft:block_match", "block": input}, - "location_predicate": {"predicate_type": "minecraft:always_true"}, - "output_state": {"Name": SPAWNER}, - "block_entity_modifier": {"type": "minecraft:append_static", "data": nbt}, - }], - })) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::legacy_structure_mods::data; - use crate::processors::{ProcessorData, carry}; - use crate::worldgen::{Upgrade, upgrade}; - - const NO_TAGS: BTreeMap> = BTreeMap::new(); - - #[test] - fn yungs_jigsaws_keep_their_rules() { - let mut report = Upgrade::default(); - let mut s = json!({"type": "yungsapi:yung_jigsaw", "start_pool": "y:start", "size": 20, - "max_y": 60, "x_offset_in_chunk": 0, "max_distance_from_center": 80, - "enhanced_terrain_adaptation": {"type": "yungsapi:custom", "kernel_size": 24, - "kernel_distance": 24, "top": "carve", "bottom": "none"}}); - assert!(upgrade("structure", &mut s, &mut report, &NO_TAGS)); - assert_eq!(s["type"], "minecraft:jigsaw"); - assert_eq!(s["horizon:yungsapi"], json!({"max_y": 60})); - assert_eq!( - s["horizon:enhanced_terrain_adaptation"], - json!({"kernel_size": 24, "kernel_distance": 24, "top": "carve", "bottom": "none", - "rigid_junctions": true, "inflate": true}) - ); - for gone in ["max_y", "x_offset_in_chunk", "enhanced_terrain_adaptation"] { - assert!(s.get(gone).is_none(), "{gone}"); - } - // Better Dungeons' small Nether dungeon is the same jigsaw. - let mut nether = json!({"type": "betterdungeons:small_nether_dungeon", - "start_pool": "betterdungeons:small_nether_dungeon", "size": 10, - "start_jigsaw_name": "betterdungeons:small_nether_dungeon_anchor", - "max_distance_from_center": 80, "liquid_settings": "ignore_waterlogging"}); - assert!(upgrade("structure", &mut nether, &mut report, &NO_TAGS)); - assert_eq!(nether["type"], "minecraft:jigsaw"); - assert_eq!(nether["horizon:yungsapi"], json!({})); - assert_eq!( - nether["horizon:enhanced_terrain_adaptation"], - json!({"kernel_size": 0, "kernel_distance": 0, "top": "none", "bottom": "none", - "rigid_junctions": true, "inflate": true}) - ); - assert_eq!( - nether["start_jigsaw_name"], - "betterdungeons:small_nether_dungeon_anchor" - ); - assert!(report.unsupported.is_empty()); - let mut pool = json!({"elements": [{"weight": 3, "element": { - "element_type": "yungsapi:yung_single_element", "location": "y:room", - "processors": "minecraft:empty", "projection": "rigid", - "name": "room", "max_count": 2, "min_required_depth": 1, "is_priority": true, - "ignore_bounds": false, "deadend_pool": "y:end", - "modifiers": [ - {"condition": {"type": "yungsapi:piece_in_range", "pieces": ["y:pillar"], - "above_range": 40}, - "target_selector": {"type": "yungsapi:self"}, - "actions": [{"type": "yungsapi:transform", "output": ["y:covered"], "x_offset": -1}, - {"type": "yungsapi:delay_generation"}, {"type": "yungsapi:spin"}]}, - {"condition": {"type": "yungsapi:always_true"}, - "target_selector": {"type": "yungsapi:all"}, "actions": []}], - "enhanced_terrain_adaptation": {"type": "yungsapi:carved_top_no_beard_small"}, - "condition": {"type": "yungsapi:all_of", "conditions": [ - {"type": "yungsapi:piece_in_horizontal_direction", "pieces": ["yungsapi:all"], - "range": 5, "rotation": "none"}, - {"type": "yungsapi:not", "condition": {"type": "yungsapi:mod_loaded", "modid": "create"}}, - {"type": "yungsapi:biome", "biomes": []}]}}}]}); - assert!(upgrade("template_pool", &mut pool, &mut report, &NO_TAGS)); - let e = &pool["elements"][0]["element"]; - assert_eq!(e["element_type"], "minecraft:single_pool_element"); - assert_eq!(e["location"], "y:room"); - assert_eq!( - e["horizon:yungsapi"], - json!({"name": "room", "max_count": 2, "min_depth": 1, "priority": true, - "ignore_bounds": false, "condition": {"type": "all_of", "conditions": [ - {"type": "piece_in_horizontal_direction", "pieces": [], "range": 5, "rotation": "none"}, - {"type": "not", "condition": {"type": "false"}}, - {"type": "false"}]}, - "deadend_pool": "y:end", - "modifiers": [{"condition": {"type": "piece_in_range", "pieces": ["y:pillar"], - "horizontal_range": null, "above_range": 40, "below_range": null}, - "actions": [{"type": "transform", "output": ["y:covered"], "offset": [-1, 0, 0]}, - {"type": "delay_generation"}]}]}) - ); - assert!(e.get("deadend_pool").is_none() && e.get("modifiers").is_none()); - assert_eq!( - e["horizon:enhanced_terrain_adaptation"], - json!({"kernel_size": 12, "kernel_distance": 6, "top": "carve", "bottom": "none"}) - ); - assert_eq!( - report.rules_lost, - BTreeMap::from([ - ("yungsapi condition yungsapi:biome".into(), 1), - ("yungsapi modifier action yungsapi:spin".into(), 1), - ("yungsapi modifier target yungsapi:all".into(), 1), - ]) - ); - assert!(report.unsupported.is_empty()); - } - - #[test] - fn yungs_placements_keep_their_rules() { - let tags = BTreeMap::from([( - "bf:avoid".to_owned(), - vec!["minecraft:nether_complexes".to_owned()], - )]); - let mut report = Upgrade::default(); - let mut fortress = json!({"placement": {"type": "yungsapi:enhanced_random_spread", - "salt": 1, "spacing": 30, "separation": 20, - "enhanced_exclusion_zone": {"other_set": "#bf:avoid", "chunk_count": 10}}}); - assert!(upgrade("structure_set", &mut fortress, &mut report, &tags)); - assert_eq!( - fortress["placement"], - json!({"type": "minecraft:random_spread", "salt": 1, "spacing": 30, "separation": 20, - "horizon:exclusion_zone": {"other_sets": ["minecraft:nether_complexes"], "chunk_count": 10}}) - ); - let mut temple = json!({"placement": {"type": "betterdeserttemples:desert_temple", - "salt": 2, "spacing": 30, "separation": 20, - "exclusion_zone": {"other_set": "minecraft:villages", "chunk_count": 6}}}); - assert!(upgrade("structure_set", &mut temple, &mut report, &tags)); - assert_eq!(temple["placement"]["type"], "minecraft:random_spread"); - assert_eq!(temple["placement"]["exclusion_zone"]["chunk_count"], 6); - assert_eq!( - temple["placement"]["horizon:avoid_biomes"], - json!({"biomes": ["#minecraft:is_river", "#minecraft:is_ocean"], - "radius": 48, "step": 2, "y": 120}) - ); - let mut stronghold = json!({"placement": {"type": "betterstrongholds:stronghold", - "salt": 3, "spacing": 85, "separation": 50, - "chunk_distance_to_first_ring": 80, "ring_chunk_thickness": 96}}); - assert!(upgrade( - "structure_set", - &mut stronghold, - &mut report, - &tags - )); - assert_eq!( - stronghold["placement"], - json!({"type": "minecraft:random_spread", "salt": 3, "spacing": 85, "separation": 50, - "horizon:ring_sections": {"distance_to_first_ring": 80, "thickness": 96}}) - ); - assert_eq!(report.mapped, 3); - assert!(report.unsupported.is_empty()); - // The vanilla counterpart is turned off only for a structure the game starts. - let mut ported = json!({"type": "minecraft:jigsaw"}); - assert!(disables("betterstrongholds:stronghold", &mut ported)); - assert_eq!(ported["horizon:disables"], json!(["minecraft:stronghold"])); - let mut java = json!({"type": "bettermineshafts:mineshaft"}); - assert!(!disables("bettermineshafts:mineshaft_oak", &mut java)); - let mut circle = json!({"type": "minecraft:jigsaw"}); - assert!(!disables("betterwitchhuts:witch_circle", &mut circle)); - } - - #[test] - fn yungs_spawners_become_rules() { - let mut pool = json!({"elements": [{"element": {"processors": {"processors": [ - {"processor_type": "betterdungeons:skeleton_mob_spawner_processor"}, - {"processor_type": "betterdeserttemples:bone_block_processor"}, - {"processor_type": "betterdungeons:small_nether_dungeon_mob_spawner_processor", - "spawner_mob": "minecraft:wither_skeleton"}, - {"processor_type": "betterdungeons:candle_processor"}]}}}]}); - let mut carried = BTreeMap::new(); - assert!(carry("template_pool", &mut pool, &data(), &mut carried)); - let p = &pool["elements"][0]["element"]["processors"]["processors"]; - let rule = |i: usize| &p[i]["rules"][0]; - assert_eq!(rule(0)["input_predicate"]["block"], "minecraft:spawner"); - assert_eq!( - rule(0)["output_state"], - json!({"Name": "minecraft:spawner"}) - ); - let nbt = &rule(0)["block_entity_modifier"]["data"]; - assert_eq!( - ( - nbt["RequiredPlayerRange"].clone(), - nbt["MaxNearbyEntities"].clone() - ), - (json!(18), json!(8)) - ); - assert_eq!(nbt["MaxSpawnDelay"], 650); - assert_eq!( - nbt["SpawnPotentials"], - json!([{"data": {"entity": {"id": "minecraft:skeleton"}}, "weight": 1}]) - ); - // Bone blocks become bowless skeleton spawners with no potentials. - let bone = &rule(1)["block_entity_modifier"]["data"]; - assert_eq!(rule(1)["input_predicate"]["block"], "minecraft:bone_block"); - assert_eq!( - bone["SpawnData"], - json!({"entity": {"id": "minecraft:skeleton", "equipment": {}}}) - ); - assert_eq!(bone["SpawnPotentials"], json!([])); - let wither = &rule(2)["block_entity_modifier"]["data"]["SpawnData"]["entity"]; - assert_eq!( - wither["equipment"]["head"]["id"], - "minecraft:netherite_helmet" - ); - assert_eq!(wither["drop_chances"]["feet"], 0.0); - assert_eq!(p[3]["processor_type"], "betterdungeons:candle_processor"); - assert_eq!(carried.values().sum::(), 3); - } - - #[test] - fn desert_temple_placeholders_become_rules_and_picks() { - let mut list = json!({"processors": [ - {"processor_type": "betterdeserttemples:red_wool_processor"}, - {"processor_type": "betterdeserttemples:white_stained_glass_processor"}, - {"processor_type": "betterdeserttemples:orange_stained_glass_processor"}, - {"processor_type": "betterdeserttemples:pot_processor"}, - {"processor_type": "betterdeserttemples:quartz_pillar_processor"}]}); - 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]["rules"][0]["output_state"], - json!({"id": "minecraft:tnt"}) - ); - assert_eq!(p[1]["processor_type"], "horizon:pick"); - assert_eq!(p[1]["picks"][0]["below"], 0.4f32 as f64); - assert_eq!(p[1]["default"]["state"]["id"], "minecraft:air"); - let glass = &p[2]["picks"]; - assert_eq!(glass[0]["state"]["id"], "minecraft:suspicious_sand"); - assert_eq!( - glass[0]["nbt"]["LootTable"], - "minecraft:archaeology/desert_pyramid" - ); - assert_eq!(glass[3]["states"].as_array().unwrap().len(), 4); - assert_eq!(glass[3]["below"], 0.45f32 as f64); - assert_eq!(p[3]["default"]["lists"][0]["count"], 4); - // The pillar reads the world below: still Java, still dropped later. - assert_eq!( - p[4]["processor_type"], - "betterdeserttemples:quartz_pillar_processor" - ); - assert_eq!(carried.values().sum::(), 4); - } - - #[test] - fn small_dungeon_chests_are_capped() { - let mut list = json!({"processors": [ - {"processor_type": "betterdungeons:small_dungeon_chest_processor"}]}); - let mut carried = BTreeMap::new(); - assert!(carry("processor_list", &mut list, &data(), &mut carried)); - let cap = &list["processors"][0]; - assert_eq!(cap["processor_type"], "horizon:count_cap"); - assert_eq!( - (cap["min"].as_u64(), cap["max"].as_u64()), - (Some(1), Some(2)) - ); - assert_eq!(cap["replacement"], json!({"id": "minecraft:cave_air"})); - } - - #[test] - fn ocean_monuments_become_the_games() { - let names = [ - "random_prismarine_slab_decoration_processor", - "random_dark_prismarine_slab_decoration_processor", - "sand_gravel_processor", - "random_oxidization_processor", - "structure_void_processor", - "air_processor", - "waterlog_processor", - "seagrass_processor", - "random_sponge_processor", - "leg_processor", - ]; - let processors: Vec = names - .iter() - .map(|n| json!({"processor_type": format!("betteroceanmonuments:{n}")})) - .chain([json!({"processor_type": "betterwitchhuts:fence_leg_processor"})]) - .collect(); - let mut list = json!({"processors": processors}); - let mut carried = BTreeMap::new(); - assert!(carry( - "processor_list", - &mut list, - &ProcessorData::default(), - &mut carried - )); - let p = list["processors"].as_array().unwrap(); - let types: Vec<&str> = p - .iter() - .map(|p| p["processor_type"].as_str().unwrap()) - .collect(); - let mut want = vec!["horizon:pick", "horizon:pick", "minecraft:rule"]; - want.extend(["horizon:pick"; 4]); - want.extend([ - "horizon:keep_world_block", - "horizon:flood_air", - "horizon:waterlog", - "minecraft:rule", - "horizon:pick", - "horizon:yung_pillar", - "horizon:yung_pillar", - ]); - assert_eq!(types, want); - assert_eq!(carried.values().sum::(), 11); - // Copper stairs keep their properties, each chance on a new float. - let stairs = &p[5]; - assert_eq!(stairs["block"], "minecraft:oxidized_cut_copper_stairs"); - assert_eq!( - stairs["picks"][1]["state"]["id"], - "minecraft:weathered_cut_copper_stairs" - ); - assert_eq!( - (stairs["redraw"].clone(), stairs["keep_properties"].clone()), - (json!(true), json!(true)) - ); - assert_eq!(p[0]["picks"][0]["state"]["properties"]["type"], "top"); - assert_eq!(p[1]["default"]["state"]["id"], "minecraft:water"); - assert_eq!( - p[12]["target_block_output"]["defaultBlockState"]["id"], - "minecraft:prismarine_bricks" - ); - assert_eq!(p[13]["keep_properties"], true); - } -} diff --git a/crates/horizon-port-pack/src/lib.rs b/crates/horizon-port-pack/src/lib.rs index 950413341..3b17f1c9c 100644 --- a/crates/horizon-port-pack/src/lib.rs +++ b/crates/horizon-port-pack/src/lib.rs @@ -20,3 +20,4 @@ pub mod pipeline; pub mod processors; pub mod report; pub mod worldgen; +mod yung; diff --git a/crates/horizon-port-pack/src/processors.rs b/crates/horizon-port-pack/src/processors.rs index f182bee7b..af1595f92 100644 --- a/crates/horizon-port-pack/src/processors.rs +++ b/crates/horizon-port-pack/src/processors.rs @@ -16,7 +16,7 @@ //! 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, yung}; +use crate::legacy_structure_mods::{ctov, lithostitched}; use serde_json::{Value, json}; use std::{ collections::{BTreeMap, BTreeSet}, @@ -177,12 +177,12 @@ 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 and YUNG's processors are carried apart from this - // code (`legacy_structure_mods`). + // Lithostitched's processors are carried apart from this code + // (`legacy_structure_mods`). let replacement = lithostitched::processor(&kind, &p, data, carried).or_else(|| { - match pillar_type(&kind).or_else(|| yung::pillar_type(&kind)) { + match pillar_type(&kind) { Some(to) => Some(vec![pillar(&p, to, data, carried, depth)]), - None => as_data(&kind, &p, data).or_else(|| yung::processor(&kind, &p)), + None => as_data(&kind, &p, data), } }); match replacement { @@ -860,8 +860,10 @@ mod tests { "horizon:bottom_pillar", "horizon:close_off_fluid_sources", "horizon:close_off_air_sources", - "horizon:yung_pillar", - "horizon:stair_pillar" + // YUNG's processors stay Java (dropped later) until the + // specification covers them. + "betterfortresses:pillar_processor", + "betterfortresses:stair_pillar_processor" ] ); // The named list is written in once; its own pillar loses its list. @@ -878,8 +880,7 @@ mod tests { p[4]["weighted_list_of_replacement_blocks"], json!([{"block": "minecraft:prismarine", "weight": 1}]) ); - assert_eq!(p[6]["pillar"], "minecraft:red_nether_bricks"); assert_eq!(carried["repurposed_structures:pillar_processor"], 2); - assert_eq!(carried["betterfortresses:stair_pillar_processor"], 1); + assert!(!carried.contains_key("betterfortresses:stair_pillar_processor")); } } diff --git a/crates/horizon-port-pack/src/worldgen.rs b/crates/horizon-port-pack/src/worldgen.rs index 1070b2aad..298179acf 100644 --- a/crates/horizon-port-pack/src/worldgen.rs +++ b/crates/horizon-port-pack/src/worldgen.rs @@ -14,10 +14,11 @@ //! as it is is listed, so the coverage report can say what generates and how //! closely. //! -//! YUNG's, Lithostitched's and CTOV's worldgen files are converted by +//! 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}; +use crate::{legacy_structure_mods::lithostitched, yung}; use serde_json::Value; use std::{ collections::{BTreeMap, BTreeSet}, @@ -262,7 +263,7 @@ fn upgrade_types( let lost = &mut report.rules_lost; // YUNG's and Lithostitched's types are converted apart from this code. if let Some(changed) = - yung::upgrade(kind, v, set_tags, lost).or_else(|| lithostitched::upgrade(kind, v, lost)) + yung::upgrade(kind, v, lost).or_else(|| lithostitched::upgrade(kind, v, lost)) { report.mapped += usize::from(changed); return changed; diff --git a/crates/horizon-port-pack/src/yung.rs b/crates/horizon-port-pack/src/yung.rs new file mode 100644 index 000000000..1cb970846 --- /dev/null +++ b/crates/horizon-port-pack/src/yung.rs @@ -0,0 +1,107 @@ +//! YUNG's API's worldgen, carried as the mods write it (clean room: +//! `cleanroom/structures/spec-yungsapi.md`). +//! +//! The game reads `yungsapi:yung_jigsaw` structures and +//! `yungsapi:yung_single_element` pool elements as they ship +//! (`horizon_world::worldgen::structure::jigsaw::Draws::Yung`), so their files +//! pass through unchanged. What the game does not run yet is counted by name +//! in the carry report: the fields whose behaviour the specification leaves +//! open. YUNG's other structure, placement and processor types are left to the +//! general code, which lists them as unsupported or drops them. +use serde_json::Value; +use std::collections::BTreeMap; + +const JIGSAW: &str = "yungsapi:yung_jigsaw"; +const ELEMENT: &str = "yungsapi:yung_single_element"; +/// A terrain adaptation that adapts nothing. +const NO_ADAPTATION: &str = "yungsapi:none"; + +fn count(lost: &mut BTreeMap, rule: String) { + *lost.entry(rule).or_default() += 1; +} + +/// Whether a field changes generation in a way the game does not run yet: +/// present, and not the value that changes nothing. +fn active(v: &Value, field: &str) -> bool { + match &v[field] { + Value::Null | Value::Bool(false) => false, + Value::Object(o) => o.get("type").and_then(Value::as_str) != Some(NO_ADAPTATION), + _ => true, + } +} + +/// A `yung_jigsaw` structure: `Some(false)` (carried as it is), else `None`. +pub(crate) fn upgrade(kind: &str, v: &Value, lost: &mut BTreeMap) -> Option { + if kind != "structure" || v["type"] != JIGSAW { + return None; + } + // spec-yungsapi §2: OPEN-Y1, OPEN-Y2 and §5. + for field in [ + "adapt_noise", + "max_y", + "min_y", + "enhanced_terrain_adaptation", + ] { + if active(v, field) { + count(lost, format!("{JIGSAW} {field}")); + } + } + Some(false) +} + +/// A `yung_single_element` pool element, carried as it is; whether it was one. +pub(crate) fn upgrade_pool_element(element: &Value, lost: &mut BTreeMap) -> bool { + if element["element_type"] != ELEMENT { + return false; + } + // spec-yungsapi §3: OPEN-Y5 to OPEN-Y9 and §5. + for field in [ + "is_priority", + "min_required_depth", + "ignore_bounds", + "condition", + "enhanced_terrain_adaptation", + "modifiers", + ] { + if active(element, field) { + count(lost, format!("{ELEMENT} {field}")); + } + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn jigsaws_and_elements_are_carried_as_written() { + let mut lost = BTreeMap::new(); + let dungeon = + json!({"type": JIGSAW, "start_pool": "a:b", "size": 10, "adapt_noise": false}); + assert_eq!(upgrade("structure", &dungeon, &mut lost), Some(false)); + assert!(lost.is_empty()); + let stronghold = json!({"type": JIGSAW, "max_y": 0, + "enhanced_terrain_adaptation": {"type": "yungsapi:carved"}}); + assert_eq!(upgrade("structure", &stronghold, &mut lost), Some(false)); + assert_eq!(lost[&format!("{JIGSAW} max_y")], 1); + assert_eq!(lost[&format!("{JIGSAW} enhanced_terrain_adaptation")], 1); + assert_eq!( + upgrade("structure", &json!({"type": "minecraft:jigsaw"}), &mut lost), + None + ); + assert_eq!(upgrade("structure_set", &dungeon, &mut lost), None); + + let mut lost = BTreeMap::new(); + let pile = json!({"element_type": ELEMENT, "name": "loot_pile_0", "max_count": 1, + "enhanced_terrain_adaptation": {"type": NO_ADAPTATION}, "is_priority": false}); + assert!(upgrade_pool_element(&pile, &mut lost)); + assert!(lost.is_empty()); + let gated = json!({"element_type": ELEMENT, "condition": {"type": "yungsapi:not"}}); + assert!(upgrade_pool_element(&gated, &mut lost)); + assert_eq!(lost[&format!("{ELEMENT} condition")], 1); + let single = json!({"element_type": "minecraft:single_pool_element"}); + assert!(!upgrade_pool_element(&single, &mut lost)); + } +} diff --git a/crates/horizon-world/src/worldgen/structure.rs b/crates/horizon-world/src/worldgen/structure.rs index d9b037808..e0ac4d9e8 100644 --- a/crates/horizon-world/src/worldgen/structure.rs +++ b/crates/horizon-world/src/worldgen/structure.rs @@ -21,7 +21,6 @@ pub mod start_checks; pub mod stronghold; pub mod template; pub mod temple; -pub mod yung; use crate::{ nbt::{self, Limits}, diff --git a/crates/horizon-world/src/worldgen/structure/enhanced_terrain.rs b/crates/horizon-world/src/worldgen/structure/enhanced_terrain.rs index ac4d01e98..24c3b9a82 100644 --- a/crates/horizon-world/src/worldgen/structure/enhanced_terrain.rs +++ b/crates/horizon-world/src/worldgen/structure/enhanced_terrain.rs @@ -306,7 +306,6 @@ mod tests { legacy: false, projection, waterlogging: None, - yung: None, enhanced_terrain: own.map(Arc::new), ground_level: 1, }) diff --git a/crates/horizon-world/src/worldgen/structure/jigsaw.rs b/crates/horizon-world/src/worldgen/structure/jigsaw.rs index 87a551c05..6e1da3a4c 100644 --- a/crates/horizon-world/src/worldgen/structure/jigsaw.rs +++ b/crates/horizon-world/src/worldgen/structure/jigsaw.rs @@ -39,9 +39,6 @@ pub enum PoolElement { legacy: bool, projection: Projection, waterlogging: Option, - /// YUNG's API's rules on the piece (`"horizon:yungsapi"`), read only - /// by its assembler ([`super::yung`]). - yung: Option>, /// The piece's own terrain kernel in a structure that has one /// (`"horizon:enhanced_terrain_adaptation"`). enhanced_terrain: Option>, @@ -149,10 +146,6 @@ impl PoolElement { Some("ignore_waterlogging") => Some(false), _ => None, }, - yung: value - .get("horizon:yungsapi") - .map(|rules| super::yung::Rules::decode(rules, resources).map(Box::new)) - .transpose()?, enhanced_terrain: value .get("horizon:enhanced_terrain_adaptation") .map(|a| super::enhanced_terrain::Adaptation::decode(a).map(Arc::new)) @@ -248,13 +241,10 @@ impl PoolElement { .first() .map(|e| e.shuffled_jigsaws(resources, position, rotation, random)) .unwrap_or_default(), - Self::Single { template, yung, .. } => { + Self::Single { template, .. } => { let mut jigsaws = template_jigsaws(resources, template, position, rotation); shuffle(&mut jigsaws, random); - // YUNG's elements keep the shuffled order. - if yung.is_none() { - jigsaws.sort_by_key(|j| std::cmp::Reverse(j.selection_priority)); - } + jigsaws.sort_by_key(|j| std::cmp::Reverse(j.selection_priority)); jigsaws } } @@ -265,13 +255,6 @@ impl PoolElement { _ => 1, } } - /// YUNG's API's rules, on a piece of one of its structures. - pub fn yung(&self) -> Option<&super::yung::Rules> { - match self { - Self::Single { yung, .. } => yung.as_deref(), - _ => None, - } - } /// The piece's own terrain kernel, in place of its structure's. pub fn enhanced_terrain(&self) -> Option<&super::enhanced_terrain::Adaptation> { match self { diff --git a/crates/horizon-world/src/worldgen/structure/piece_limited.rs b/crates/horizon-world/src/worldgen/structure/piece_limited.rs index 12a6e3f17..6977ebc88 100644 --- a/crates/horizon-world/src/worldgen/structure/piece_limited.rs +++ b/crates/horizon-world/src/worldgen/structure/piece_limited.rs @@ -1025,7 +1025,6 @@ mod tests { legacy: false, projection: Projection::Rigid, waterlogging: None, - yung: None, enhanced_terrain: None, ground_level: 1, }); diff --git a/crates/horizon-world/src/worldgen/structure/start.rs b/crates/horizon-world/src/worldgen/structure/start.rs index 44f4b70f4..a98a9f88f 100644 --- a/crates/horizon-world/src/worldgen/structure/start.rs +++ b/crates/horizon-world/src/worldgen/structure/start.rs @@ -59,8 +59,6 @@ pub struct JigsawDef { waterlogging: bool, /// A carried mod's start rules (`"horizon:start_checks"`). checks: super::start_checks::StartChecks, - /// Laid out by YUNG's API's assembler (`"horizon:yungsapi"`). - yung: Option, /// Vanilla's draws, or YUNG's API's for `yungsapi:yung_jigsaw`. draws: super::jigsaw::Draws, /// Laid out by Repurposed Structures' and Moog's piece-limited @@ -111,9 +109,6 @@ pub struct StructureDef { /// A structure library's terrain kernel /// (`"horizon:enhanced_terrain_adaptation"`). pub enhanced_terrain: Option, - /// Another loaded structure turns this one's type off - /// (`"horizon:disables"`): it never starts. - pub disabled: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Reduction { @@ -311,19 +306,8 @@ impl StructureRegistry { biome_set: &dyn Fn(&Value) -> Result, DecodeError>, ) -> Result { let mut defs = Vec::new(); - let (mut types, mut disables) = (Vec::new(), BTreeSet::new()); for (name, json) in structures { let v: Value = serde_json::from_str(json)?; - types.push(v["type"].as_str().unwrap_or_default().to_owned()); - // YUNG's mods turn their vanilla counterparts off (the - // DisableVanilla*Mixin on ChunkGenerator.tryGenerateStructure). - disables.extend( - v["horizon:disables"] - .as_array() - .into_iter() - .flatten() - .filter_map(|t| Some(t.as_str()?.to_owned())), - ); let draws = match v["type"].as_str() { Some("yungsapi:yung_jigsaw") => super::jigsaw::Draws::Yung, _ => super::jigsaw::Draws::Vanilla, @@ -347,7 +331,6 @@ impl StructureRegistry { padding: pair(v.get("dimension_padding"), "bottom", "top", 0), waterlogging: v["liquid_settings"].as_str() != Some("ignore_waterlogging"), checks: super::start_checks::StartChecks::decode(v.get("horizon:start_checks")), - yung: v.get("horizon:yungsapi").map(super::yung::Config::decode), piece_limited: v .get("horizon:piece_limited") .map(super::piece_limited::Config::decode), @@ -408,12 +391,8 @@ impl StructureRegistry { .get("horizon:enhanced_terrain_adaptation") .map(super::enhanced_terrain::StructureAdaptation::decode) .transpose()?, - disabled: false, }); } - for (def, kind) in defs.iter_mut().zip(&types) { - def.disabled = disables.contains(kind); - } let index = |name: &str| defs.iter().position(|d| d.name == name); let mut out = Vec::new(); for (name, json) in sets { @@ -879,9 +858,6 @@ fn try_generate( chunk: ChunkPos, ) -> Option { let def = ®istry.structures[structure]; - if def.disabled { - return None; - } context.set_valid_biomes(&def.biomes); let seed = context.seed(); let mut random = WorldgenRandom::new(LegacyRandom::new(0)); @@ -911,8 +887,8 @@ fn try_generate( padding: jigsaw.padding, waterlogging: jigsaw.waterlogging, }; - let (position, mut pieces) = match (&jigsaw.yung, &jigsaw.piece_limited) { - (_, Some(limits)) => super::piece_limited::assemble( + let (position, mut pieces) = match &jigsaw.piece_limited { + Some(limits) => super::piece_limited::assemble( &config, limits, context, @@ -922,17 +898,7 @@ fn try_generate( chunk, start, )?, - (Some(yung), None) => super::yung::assemble( - &config, - yung, - context, - ®istry.pools, - resources, - bindings, - &mut random, - start, - )?, - (None, None) => assemble_drawing( + None => assemble_drawing( jigsaw.draws, &config, context, @@ -1437,37 +1403,6 @@ mod tests { .unwrap() } - #[test] - fn a_structure_turns_its_vanilla_counterpart_off() { - let r = registry( - &[ - ( - "minecraft:pyramid", - json!({"type": "minecraft:desert_pyramid"}), - ), - ("minecraft:igloo", json!({"type": "minecraft:igloo"})), - ( - "yung:temple", - json!({"type": "minecraft:swamp_hut", "horizon:disables": ["minecraft:desert_pyramid"]}), - ), - ], - &[], - ); - let disabled: Vec<_> = r - .structures - .iter() - .map(|d| (d.name.as_str(), d.disabled)) - .collect(); - assert_eq!( - disabled, - [ - ("minecraft:igloo", false), - ("minecraft:pyramid", true), - ("yung:temple", false) - ] - ); - } - #[test] fn exclusion_zones_may_skip_the_other_sets_own() { let set = |avoid: Value| { diff --git a/crates/horizon-world/src/worldgen/structure/yung.rs b/crates/horizon-world/src/worldgen/structure/yung.rs deleted file mode 100644 index efd66fdc3..000000000 --- a/crates/horizon-world/src/worldgen/structure/yung.rs +++ /dev/null @@ -1,1122 +0,0 @@ -//! YUNG's API's jigsaw assembler, for the structures of the "YUNG's Better" -//! mods carried over from a modpack (docs/modpacks.md). Carry maps -//! `yungsapi:yung_jigsaw` to `minecraft:jigsaw` with a `"horizon:yungsapi"` -//! key, and its pool elements to vanilla's single element with the piece's -//! rules under the same key; vanilla data never has it. -//! -//! The assembler differs from vanilla's JigsawPlacement: it picks the start -//! piece and every child by weight from the pool's weighted entries (a -//! priority piece first), lays pieces out breadth first, caps pieces by name -//! (`max_count`), limits them by depth, lets some ignore the bounds, checks -//! each piece's condition, and keeps every piece between `min_y` and -//! `max_y`. Pieces fit the same free space as vanilla's. A piece that ends -//! up with no child can be swapped for one of a dead-end pool, and once the -//! layout is done each piece's modifiers can turn it into another template -//! or place it last. -use super::{ - BoundingBox, Rotation, StructureResources, StructureTemplate, - jigsaw::{ - Free, JigsawConfig, JigsawContext, Junction, PoolElement, PoolPiece, Pools, Projection, - can_attach, shuffle, step, - }, -}; -use crate::{ - heightmap::HeightmapType, - worldgen::{DecodeError, block_feature::FeatureBlockBindings}, -}; -use horizon_core::random::{LegacyRandom, RandomSource, WorldgenRandom}; -use serde_json::Value; -use std::{ - cell::RefCell, - collections::{HashMap, VecDeque}, - rc::Rc, - sync::Arc, -}; - -/// A YUNG structure's own settings (`"horizon:yungsapi"` on the structure): -/// the heights every piece must stay within. -#[derive(Debug, Default)] -pub struct Config { - pub min_y: Option, - pub max_y: Option, -} -impl Config { - pub fn decode(v: &Value) -> Self { - let y = |k: &str| v[k].as_i64().map(|n| n as i32); - Self { - min_y: y("min_y"), - max_y: y("max_y"), - } - } -} - -/// A piece's rules (`"horizon:yungsapi"` on a pool element). -#[derive(Clone, Debug, Default)] -pub struct Rules { - /// Pieces sharing a name share the cap `max_count`. - name: Option, - max_count: Option, - min_depth: Option, - max_depth: Option, - /// Chosen before any other piece of its pool. - priority: bool, - /// Placed even where it overlaps other pieces or leaves the bounds. - ignore_bounds: bool, - condition: Condition, - /// The pool a piece that got no child is swapped for. - deadend_pool: Option, - /// Applied to the piece once the layout is done. - modifiers: Vec, -} -impl Rules { - pub fn decode(v: &Value, resources: &StructureResources) -> Result { - let int = |k: &str| v[k].as_i64().map(|n| n as i32); - Ok(Self { - name: v["name"].as_str().map(str::to_owned), - max_count: int("max_count"), - min_depth: int("min_depth"), - max_depth: int("max_depth"), - priority: v["priority"].as_bool().unwrap_or(false), - ignore_bounds: v["ignore_bounds"].as_bool().unwrap_or(false), - condition: match v.get("condition") { - Some(c) => Condition::decode(c, resources)?, - None => Condition::True, - }, - deadend_pool: v["deadend_pool"].as_str().map(str::to_owned), - modifiers: v["modifiers"] - .as_array() - .into_iter() - .flatten() - .map(|m| Modifier::decode(m, resources)) - .collect::>()?, - }) - } - /// YungJigsawPoolElement.isAtValidDepth. - fn at_valid_depth(&self, depth: i32) -> bool { - self.min_depth.is_none_or(|d| d <= depth) && self.max_depth.is_none_or(|d| d >= depth) - } - /// The name a capped piece counts under: `max_count` without a name is - /// ignored, as YUNG's does. - fn capped(&self) -> Option<(&str, i32)> { - Some((self.name.as_deref()?, self.max_count?)) - } -} - -/// A StructureModifier: when its condition holds for the piece, its actions -/// apply to the piece (the `self` target, the only one YUNG's API has). -#[derive(Clone, Debug)] -struct Modifier { - condition: Condition, - actions: Vec, -} -impl Modifier { - fn decode(v: &Value, resources: &StructureResources) -> Result { - Ok(Self { - condition: match v.get("condition") { - Some(c) => Condition::decode(c, resources)?, - None => Condition::True, - }, - actions: v["actions"] - .as_array() - .ok_or(DecodeError::Field("modifier actions"))? - .iter() - .map(|a| Action::decode(a, resources)) - .collect::>()?, - }) - } -} - -#[derive(Clone, Debug)] -enum Action { - /// TransformAction: the piece becomes one of these templates, moved by - /// `offset` turned with the piece. - Transform { - output: Vec>, - offset: [i32; 3], - }, - /// DelayGenerationAction: the piece is placed after every other. - DelayGeneration, -} -impl Action { - fn decode(v: &Value, resources: &StructureResources) -> Result { - Ok(match v["type"].as_str().unwrap_or_default() { - "transform" => { - let output = v["output"] - .as_array() - .into_iter() - .flatten() - .map(|t| { - let id = t.as_str().ok_or(DecodeError::Field("transform output"))?; - resources - .template(id) - .ok_or_else(|| DecodeError::Registry(format!("missing template {id}"))) - }) - .collect::, _>>()?; - if output.is_empty() { - return Err(DecodeError::Field("transform output")); - } - let offset = v["offset"].as_array(); - let at = |i: usize| { - offset - .and_then(|o| o.get(i)) - .and_then(Value::as_i64) - .unwrap_or(0) as i32 - }; - Self::Transform { - output, - offset: [at(0), at(1), at(2)], - } - } - "delay_generation" => Self::DelayGeneration, - _ => return Err(DecodeError::Field("YUNG modifier action type")), - }) - } -} - -/// YUNG's structure conditions, as carry writes them. -#[derive(Clone, Debug, Default)] -enum Condition { - #[default] - True, - False, - Not(Box), - AllOf(Vec), - AnyOf(Vec), - Depth { - min: Option, - max: Option, - }, - Altitude { - bottom: Option, - top: Option, - }, - RandomChance(f32), - Rotation(Vec), - /// Another piece (of these templates, or any) within `range` blocks in - /// the direction `rotation` turns the piece's own north. - PieceInDirection { - templates: Vec>, - range: i32, - rotation: Rotation, - }, - /// Another piece (of these templates, or any when None) within the - /// piece's box grown by these ranges, not overlapping the piece. - PieceInRange { - templates: Option>>, - horizontal: i32, - above: i32, - below: i32, - }, -} -impl Condition { - fn decode(v: &Value, resources: &StructureResources) -> Result { - let list = |k: &'static str| -> Result, DecodeError> { - v[k].as_array() - .ok_or(DecodeError::Field(k))? - .iter() - .map(|c| Self::decode(c, resources)) - .collect() - }; - let int = |k: &str| v[k].as_i64().map(|n| n as i32); - let rotation = |r: &Value| { - r.as_str() - .and_then(Rotation::from_name) - .ok_or(DecodeError::Field("condition rotation")) - }; - Ok(match v["type"].as_str().unwrap_or_default() { - "true" => Self::True, - "false" => Self::False, - "not" => Self::Not(Box::new(Self::decode(&v["condition"], resources)?)), - "all_of" => Self::AllOf(list("conditions")?), - "any_of" => Self::AnyOf(list("conditions")?), - "depth" => Self::Depth { - min: int("min_depth"), - max: int("max_depth"), - }, - "altitude" => Self::Altitude { - bottom: v["bottom"].as_f64(), - top: v["top"].as_f64(), - }, - "random_chance" => Self::RandomChance(v["chance"].as_f64().unwrap_or(0.) as f32), - "rotation" => Self::Rotation( - v["rotations"] - .as_array() - .into_iter() - .flatten() - .map(rotation) - .collect::>()?, - ), - "piece_in_horizontal_direction" => Self::PieceInDirection { - templates: v["pieces"] - .as_array() - .into_iter() - .flatten() - .filter_map(|p| resources.template(p.as_str()?)) - .collect(), - range: int("range").unwrap_or(0), - rotation: rotation(&v["rotation"])?, - }, - "piece_in_range" => { - let pieces = v["pieces"] - .as_array() - .map(Vec::as_slice) - .unwrap_or_default(); - let any = pieces.is_empty() || pieces.iter().any(|p| p == "yungsapi:all"); - Self::PieceInRange { - // An unknown template matches nothing, as YUNG's API's - // empty stand-in does. - templates: (!any).then(|| { - pieces - .iter() - .filter_map(|p| resources.template(p.as_str()?)) - .collect() - }), - horizontal: int("horizontal_range").unwrap_or(0), - above: int("above_range").unwrap_or(0), - below: int("below_range").unwrap_or(0), - } - } - _ => return Err(DecodeError::Field("YUNG condition type")), - }) - } - /// StructureCondition.passes. `at` is the piece being placed: its box, - /// rotation and depth; the start piece has no box yet. - fn passes(&self, at: &At<'_>, random: &mut impl RandomSource) -> bool { - match self { - Self::True => true, - Self::False => false, - Self::Not(c) => !c.passes(at, random), - Self::AllOf(all) => all.iter().all(|c| c.passes(at, random)), - Self::AnyOf(any) => any.iter().any(|c| c.passes(at, random)), - Self::Depth { min, max } => { - min.is_none_or(|d| d <= at.depth) && max.is_none_or(|d| d >= at.depth) - } - Self::Altitude { bottom, top } => { - let [low, high] = at.span; - bottom.is_none_or(|y| f64::from(low) >= y) - && top.is_none_or(|y| f64::from(high) <= y) - } - Self::RandomChance(chance) => random.next_float() < *chance, - Self::Rotation(valid) => valid.contains(&at.rotation), - Self::PieceInDirection { - templates, - range, - rotation, - } => { - let Some(piece) = at.piece else { - return false; - }; - let mut search = piece; - match at.rotation.get_rotated(*rotation) { - Rotation::None => search.min[2] -= range, - Rotation::Clockwise90 => search.max[0] += range, - Rotation::Clockwise180 => search.max[2] += range, - Rotation::Counterclockwise90 => search.min[0] -= range, - } - at.near(piece, &search, |template| { - templates.is_empty() || templates.iter().any(|t| Arc::ptr_eq(t, template)) - }) - } - Self::PieceInRange { - templates, - horizontal, - above, - below, - } => { - let Some(piece) = at.piece else { - return false; - }; - let search = BoundingBox::new( - [ - piece.min[0] - horizontal, - piece.min[1] - below, - piece.min[2] - horizontal, - ], - [ - piece.max[0] + horizontal, - piece.max[1] + above, - piece.max[2] + horizontal, - ], - ); - at.near(piece, &search, |template| { - templates - .as_ref() - .is_none_or(|ts| ts.iter().any(|t| Arc::ptr_eq(t, template))) - }) - } - } - } -} - -/// The surface height query, object-safe for the per-call cache. -trait JigsawContextDyn { - fn surface(&mut self, x: i32, z: i32) -> i32; -} -impl JigsawContextDyn for C { - fn surface(&mut self, x: i32, z: i32) -> i32 { - self.base_height(x, z, HeightmapType::WorldSurfaceWg) - } -} - -/// What a condition sees of the piece being placed (or modified). -struct At<'a> { - depth: i32, - rotation: Rotation, - piece: Option, - /// The piece's lowest and highest Y when the condition was set up. - span: [i32; 2], - pieces: &'a [PoolPiece], - /// Pieces taken out by a dead end, by index; the rest are kept. - gone: &'a [bool], -} -impl At<'_> { - /// Whether another single piece whose template matches lies in `search` - /// without overlapping `piece` (nor being exactly it). - fn near( - &self, - piece: BoundingBox, - search: &BoundingBox, - matches: impl Fn(&Arc) -> bool, - ) -> bool { - self.pieces.iter().enumerate().any(|(i, other)| { - let PoolElement::Single { template, .. } = other.element.as_ref() else { - return false; - }; - let b = other.bounding_box; - !self.gone.get(i).copied().unwrap_or(false) - && b != piece - && matches(template) - && b.intersects(search) - && !b.intersects(&piece) - }) - } -} - -/// The index of the entry to try: the first priority piece, else one drawn -/// by weight (`nextInt(total) + 1`, counted down). -fn pick( - candidates: &[(Arc, i32)], - total: i32, - random: &mut impl RandomSource, -) -> usize { - if let Some(i) = candidates - .iter() - .position(|(e, _)| e.yung().is_some_and(|r| r.priority)) - { - return i; - } - let mut chosen = random.next_int_bound(total).unwrap_or(0) + 1; - for (i, (_, weight)) in candidates.iter().enumerate() { - chosen -= weight; - if chosen <= 0 { - return i; - } - } - candidates.len() - 1 -} - -/// JigsawManager.assembleJigsawStructure: the start position and the -/// pieces, or None when no start piece fits. -#[allow(clippy::too_many_arguments)] -pub fn assemble( - config: &JigsawConfig<'_>, - yung: &Config, - context: &mut impl JigsawContext, - pools: &Pools, - resources: &StructureResources, - bindings: &FeatureBlockBindings<'_>, - random: &mut impl RandomSource, - position: [i32; 3], -) -> Option<([i32; 3], Vec)> { - let pool = pools.get(config.start_pool, resources, bindings)?; - let mut candidates = pool.raw.clone(); - shuffle(&mut candidates, random); - let rotation = Rotation::random(random); - let mut total: i32 = candidates.iter().map(|c| c.1).sum(); - let (element, adjusted, local) = loop { - if candidates.is_empty() || total <= 0 { - return None; - } - let index = pick(&candidates, total, random); - let (element, weight) = candidates[index].clone(); - if matches!(element.as_ref(), PoolElement::Empty) { - return None; - } - let anchored = match config.start_jigsaw { - Some(name) => { - element - .shuffled_jigsaws(resources, position, rotation, random) - .into_iter() - .find(|j| j.name.as_deref() == Some(name))? - .pos - } - None => position, - }; - let local: [i32; 3] = std::array::from_fn(|i| anchored[i] - position[i]); - let adjusted: [i32; 3] = std::array::from_fn(|i| position[i] - local[i]); - let at = At { - depth: 0, - rotation, - piece: None, - span: [0, 0], - pieces: &[], - gone: &[], - }; - if element - .yung() - .is_some_and(|r| !r.condition.passes(&at, random)) - { - total -= weight; - candidates.remove(index); - continue; - } - break (element, adjusted, local); - }; - let mut start = PoolPiece { - bounding_box: element.bounding_box(adjusted, rotation), - ground_level_delta: element.ground_level_delta(), - element, - position: adjusted, - rotation, - junctions: Vec::new(), - waterlogging: config.waterlogging, - }; - let b = start.bounding_box; - let (cx, cz) = ((b.max[0] + b.min[0]) / 2, (b.max[2] + b.min[2]) / 2); - let bottom = match config.project_to { - Some(kind) => position[1] + context.base_height(cx, cz, kind), - None => adjusted[1], - }; - start.moved(bottom - (b.min[1] + start.ground_level_delta)); - let cy = bottom + local[1]; - let d = config.max_distance[0]; - let free = Rc::new(RefCell::new(Free { - container: BoundingBox::new([cx - d, cy - d, cz - d], [cx + d, cy + d, cz + d]), - removed: vec![start.bounding_box], - })); - let mut assembler = Assembler { - config, - yung, - pools, - resources, - bindings, - pieces: vec![start], - states: vec![(free, 0)], - origins: vec![None], - gone: vec![false], - counts: HashMap::new(), - }; - if config.max_depth > 0 { - let mut queue = VecDeque::from([0]); - while let Some(piece) = queue.pop_front() { - assembler.children(piece, &mut queue, context, random); - } - } - Some(([cx, cy, cz], assembler.modified(random))) -} - -/// JigsawStructureAssembler. -struct Assembler<'a> { - config: &'a JigsawConfig<'a>, - yung: &'a Config, - pools: &'a Pools, - resources: &'a StructureResources, - bindings: &'a FeatureBlockBindings<'a>, - pieces: Vec, - /// Per piece: the free space it was placed in, and its depth. - states: Vec<(Rc>, i32)>, - /// Per piece: where it was placed and the junction it left on its - /// parent (none for the start piece), for a dead end to undo. - origins: Vec>, - /// Per piece: taken out by a dead end. - gone: Vec, - /// Pieces placed per capped name. - counts: HashMap, -} - -/// Where a child goes: the parent's jigsaw and what it needs. -#[derive(Clone)] -struct Slot { - source: usize, - jigsaw: super::jigsaw::Jigsaw, - target_pos: [i32; 3], - free: Rc>, - depth: i32, -} - -impl Assembler<'_> { - fn pool(&self, name: &str) -> Option> { - let name = self.config.aliases.get(name).map_or(name, String::as_str); - self.pools.get(name, self.resources, self.bindings) - } - /// JigsawStructureAssembler.Settings.isInBounds. - fn in_bounds(&self, y: i32, context: &impl JigsawContext) -> bool { - self.yung.max_y.is_none_or(|m| y <= m) - && self.yung.min_y.is_none_or(|m| y >= m) - && y >= context.min_y() + self.config.padding[0] - && y <= context.max_y() + 1 - self.config.padding[1] - } - /// addChildrenForPiece: one child per jigsaw where one fits, from the - /// target pool unless the piece is at the last depth, else its fallback. - fn children( - &mut self, - source: usize, - queue: &mut VecDeque, - context: &mut impl JigsawContext, - random: &mut impl RandomSource, - ) { - let (element, position, rotation, source_box) = { - let s = &self.pieces[source]; - (s.element.clone(), s.position, s.rotation, s.bounding_box) - }; - let (context_free, depth) = self.states[source].clone(); - let mut source_free: Option>> = None; - let jigsaws = element.shuffled_jigsaws(self.resources, position, rotation, random); - let count = jigsaws.len(); - let mut any = false; - for jigsaw in jigsaws { - let Some(target) = self.pool(&jigsaw.pool) else { - continue; - }; - if target.templates.is_empty() && jigsaw.pool != "minecraft:empty" { - continue; - } - let Some(fallback) = self.pool(&target.fallback) else { - continue; - }; - if fallback.templates.is_empty() && target.fallback != "minecraft:empty" { - continue; - } - let s = step(jigsaw.front); - let target_pos: [i32; 3] = std::array::from_fn(|i| jigsaw.pos[i] + s[i]); - let free = if source_box.is_inside(target_pos) { - source_free - .get_or_insert_with(|| { - Rc::new(RefCell::new(Free { - container: source_box, - removed: Vec::new(), - })) - }) - .clone() - } else { - context_free.clone() - }; - let slot = Slot { - source, - jigsaw, - target_pos, - free, - depth, - }; - let placed = depth != self.config.max_depth - && self.choose(&slot, target.raw.clone(), queue, context, random); - any |= placed || self.choose(&slot, fallback.raw.clone(), queue, context, random); - } - if let Some(pool) = element.yung().and_then(|r| r.deadend_pool.as_deref()) - && !any - && count > 1 - { - self.dead_end(source, pool, queue, context, random); - } - } - /// A piece with a dead-end pool that got no child though it has other - /// jigsaws is taken out, its parent's junction and its room freed and its - /// count given back, and a piece of that pool is chosen in its place for - /// the same parent jigsaw. The start piece stays. - fn dead_end( - &mut self, - piece: usize, - pool: &str, - queue: &mut VecDeque, - context: &mut impl JigsawContext, - random: &mut impl RandomSource, - ) { - let Some(pool) = self.pool(pool) else { - return; - }; - let Some((slot, junction)) = self.origins[piece].clone() else { - return; - }; - let parent = &mut self.pieces[slot.source].junctions; - if let Some(i) = parent.iter().position(|j| *j == junction) { - parent.remove(i); - } - let bounds = self.pieces[piece].bounding_box; - { - let mut free = slot.free.borrow_mut(); - if let Some(i) = free.removed.iter().position(|b| *b == bounds) { - free.removed.remove(i); - } - } - self.gone[piece] = true; - let element = self.pieces[piece].element.clone(); - if let Some((name, _)) = element.yung().and_then(Rules::capped) - && let Some(count) = self.counts.get_mut(name) - { - *count -= 1; - } - self.choose(&slot, pool.raw.clone(), queue, context, random); - } - /// applyModifications: each piece's modifiers, in piece order, then the - /// delayed pieces moved last. Returns the pieces kept. - fn modified(mut self, random: &mut impl RandomSource) -> Vec { - let mut delayed = vec![false; self.pieces.len()]; - for (i, delay) in delayed.iter_mut().enumerate() { - let element = self.pieces[i].element.clone(); - let Some(rules) = element - .yung() - .filter(|r| !self.gone[i] && !r.modifiers.is_empty()) - else { - continue; - }; - let b = self.pieces[i].bounding_box; - let (depth, rotation, span) = ( - self.states[i].1, - self.pieces[i].rotation, - [b.min[1], b.max[1]], - ); - for modifier in &rules.modifiers { - let at = At { - depth, - rotation, - piece: Some(self.pieces[i].bounding_box), - span, - pieces: &self.pieces, - gone: &self.gone, - }; - if !modifier.condition.passes(&at, random) { - continue; - } - for action in &modifier.actions { - match action { - Action::Transform { output, offset } => { - transform(&mut self.pieces[i], output, *offset); - } - Action::DelayGeneration => *delay = true, - } - } - } - } - let (mut kept, mut last) = (Vec::new(), Vec::new()); - for ((piece, gone), delay) in self.pieces.into_iter().zip(self.gone).zip(delayed) { - if !gone { - if delay { &mut last } else { &mut kept }.push(piece); - } - } - kept.extend(last); - kept - } - /// chooseCandidateFromPool: whether a piece was placed. - fn choose( - &mut self, - slot: &Slot, - mut candidates: Vec<(Arc, i32)>, - queue: &mut VecDeque, - context: &mut impl JigsawContext, - random: &mut impl RandomSource, - ) -> bool { - let jigsaw = &slot.jigsaw; - let (source_rigid, source_y, source_ground) = { - let s = &self.pieces[slot.source]; - ( - s.element.projection() == Projection::Rigid, - s.bounding_box.min[1], - s.ground_level_delta, - ) - }; - let local_y = jigsaw.pos[1] - source_y; - // The surface at the jigsaw, read once per call. - let mut surface = None; - let (x, z) = (jigsaw.pos[0], jigsaw.pos[2]); - let mut base = |context: &mut dyn JigsawContextDyn| { - *surface.get_or_insert_with(|| context.surface(x, z)) - }; - shuffle(&mut candidates, random); - let mut total: i32 = candidates.iter().map(|c| c.1).sum(); - while !candidates.is_empty() && total > 0 { - let index = pick(&candidates, total, random); - let (target, weight) = candidates[index].clone(); - if matches!(target.as_ref(), PoolElement::Empty) { - return false; - } - let rules = target.yung(); - let capped = rules.and_then(Rules::capped); - let full = capped - .is_some_and(|(name, max)| self.counts.get(name).copied().unwrap_or(0) >= max); - if full || rules.is_some_and(|r| !r.at_valid_depth(slot.depth)) { - total -= weight; - candidates.remove(index); - continue; - } - let mut rotations = Rotation::ALL; - shuffle(&mut rotations, random); - for rotation in rotations { - let target_jigsaws = - target.shuffled_jigsaws(self.resources, [0; 3], rotation, random); - let hack_box = target.bounding_box([0; 3], rotation); - let expand_to = if self.config.use_expansion_hack && hack_box.span()[1] <= 16 { - target_jigsaws - .iter() - .map(|tj| { - let st = step(tj.front); - if !hack_box.is_inside(std::array::from_fn(|i| tj.pos[i] + st[i])) { - return 0; - } - let child = self.pool(&tj.pool); - let child_size = child.as_ref().map_or(0, |p| p.max_size()); - let fallback_size = child - .and_then(|p| self.pool(&p.fallback)) - .map_or(0, |p| p.max_size()); - child_size.max(fallback_size) - }) - .max() - .unwrap_or(0) - } else { - 0 - }; - for target_jigsaw in &target_jigsaws { - if !can_attach(jigsaw, target_jigsaw) { - continue; - } - let target_local = target_jigsaw.pos; - let raw_position: [i32; 3] = - std::array::from_fn(|i| slot.target_pos[i] - target_local[i]); - let raw_box = target.bounding_box(raw_position, rotation); - let target_rigid = target.projection() == Projection::Rigid; - let delta_y = local_y - target_local[1] + step(jigsaw.front)[1]; - let target_y = if source_rigid && target_rigid { - source_y + delta_y - } else { - base(context) - target_local[1] - }; - let offset = target_y - raw_box.min[1]; - let mut target_box = raw_box.moved([0, offset, 0]); - let target_position = - [raw_position[0], raw_position[1] + offset, raw_position[2]]; - if expand_to > 0 { - let size = (expand_to + 1).max(target_box.max[1] - target_box.min[1]); - target_box.max[1] = target_box.min[1] + size; - } - if !self.in_bounds(target_box.max[1], context) - || !self.in_bounds(target_box.min[1], context) - { - continue; - } - if !rules.is_some_and(|r| r.ignore_bounds) - && !slot.free.borrow().fits(&target_box) - { - continue; - } - let target_ground = if target_rigid { - source_ground - delta_y - } else { - target.ground_level_delta() - }; - let junction_y = if source_rigid { - source_y + local_y - } else if target_rigid { - target_y + target_local[1] - } else { - base(context) + delta_y / 2 - }; - let at = At { - depth: slot.depth + 1, - rotation, - piece: Some(target_box), - span: [target_box.min[1], target_box.max[1]], - pieces: &self.pieces, - gone: &self.gone, - }; - if rules.is_some_and(|r| !r.condition.passes(&at, random)) { - continue; - } - let on_parent = Junction { - source_x: slot.target_pos[0], - source_ground_y: junction_y - local_y + source_ground, - source_z: slot.target_pos[2], - }; - self.pieces[slot.source].junctions.push(on_parent); - slot.free.borrow_mut().removed.push(target_box); - self.pieces.push(PoolPiece { - element: target.clone(), - position: target_position, - ground_level_delta: target_ground, - rotation, - bounding_box: target_box, - junctions: vec![Junction { - source_x: jigsaw.pos[0], - source_ground_y: junction_y - target_local[1] + target_ground, - source_z: jigsaw.pos[2], - }], - waterlogging: self.config.waterlogging, - }); - self.states.push((slot.free.clone(), slot.depth + 1)); - self.origins.push(Some((slot.clone(), on_parent))); - self.gone.push(false); - if slot.depth < self.config.max_depth { - queue.push_back(self.pieces.len() - 1); - } - if let Some((name, _)) = capped { - *self.counts.entry(name.to_owned()).or_default() += 1; - } - return true; - } - } - total -= weight; - candidates.remove(index); - } - false - } -} - -/// TransformAction: the piece takes one of `output`'s templates, picked by a -/// random seeded with setFeatureSeed(x, y, x) of its position, and moves by -/// `offset` turned with it. Like YUNG's API's new piece, it loses its -/// junctions and takes its own liquid setting (else waterlogging). -fn transform(piece: &mut PoolPiece, output: &[Arc], offset: [i32; 3]) { - let [x, y, _] = piece.position; - let mut random = WorldgenRandom::new(LegacyRandom::new(0)); - random.set_feature_seed(i64::from(x), y, x); - let chosen = random.next_int_bound(output.len() as i32).unwrap_or(0) as usize; - let mut element = (*piece.element).clone(); - let PoolElement::Single { - template, - waterlogging, - .. - } = &mut element - else { - return; - }; - *template = output[chosen].clone(); - piece.waterlogging = waterlogging.unwrap_or(true); - let [dx, dy, dz] = offset; - let [dx, dz] = match piece.rotation { - Rotation::None => [dx, dz], - Rotation::Clockwise90 => [-dz, dx], - Rotation::Clockwise180 => [-dx, -dz], - Rotation::Counterclockwise90 => [dz, -dx], - }; - piece.position = [x + dx, y + dy, piece.position[2] + dz]; - piece.bounding_box = element.bounding_box(piece.position, piece.rotation); - piece.element = Arc::new(element); - piece.junctions.clear(); -} - -#[cfg(test)] -mod tests { - use super::*; - use horizon_core::random::LegacyRandom; - - fn single(template: &Arc, rules: Option) -> Arc { - Arc::new(PoolElement::Single { - template: template.clone(), - processors: Default::default(), - legacy: false, - projection: Projection::Rigid, - waterlogging: None, - yung: rules.map(Box::new), - enhanced_terrain: None, - ground_level: 1, - }) - } - - #[test] - fn picks_priority_pieces_then_by_weight() { - let t = Arc::new(StructureTemplate::empty()); - let plain = single(&t, None); - let first = single( - &t, - Some(Rules { - priority: true, - ..Rules::default() - }), - ); - let mut random = LegacyRandom::new(7); - let list = vec![(plain.clone(), 3), (first, 1)]; - assert_eq!(pick(&list, 4, &mut random), 1); - // By weight: nextInt(total) + 1, counted down; the first entry - // takes draws 1..=3 of 4. - let list = vec![(plain.clone(), 3), (plain, 1)]; - let mut ours = LegacyRandom::new(42); - let mut theirs = LegacyRandom::new(42); - for _ in 0..32 { - let draw = theirs.next_int_bound(4).unwrap() + 1; - assert_eq!(pick(&list, 4, &mut ours), usize::from(draw > 3)); - } - } - - #[test] - fn rules_cap_by_name_and_depth() { - let rules = Rules { - name: Some("room".into()), - max_count: Some(2), - min_depth: Some(1), - max_depth: Some(3), - ..Rules::default() - }; - assert_eq!(rules.capped(), Some(("room", 2))); - assert!(!rules.at_valid_depth(0) && rules.at_valid_depth(1) && !rules.at_valid_depth(4)); - // max_count without a name is ignored, as YUNG's does. - let unnamed = Rules { - max_count: Some(1), - ..Rules::default() - }; - assert_eq!(unnamed.capped(), None); - } - - #[test] - fn conditions() { - let t = Arc::new(StructureTemplate::empty()); - let other = Arc::new(StructureTemplate::empty()); - let neighbour = PoolPiece { - element: single(&t, None), - position: [0; 3], - ground_level_delta: 1, - rotation: Rotation::None, - bounding_box: BoundingBox::new([0, 0, -6], [4, 4, -1]), - junctions: Vec::new(), - waterlogging: true, - }; - let pieces = [neighbour]; - let at = |rotation, depth| At { - depth, - rotation, - piece: Some(BoundingBox::new([0, 10, 0], [4, 14, 4])), - span: [10, 14], - pieces: &pieces, - gone: &[], - }; - let mut random = LegacyRandom::new(1); - let depth = Condition::Depth { - min: Some(2), - max: None, - }; - assert!(!depth.passes(&at(Rotation::None, 1), &mut random)); - assert!(depth.passes(&at(Rotation::None, 2), &mut random)); - let altitude = |bottom, top| Condition::Altitude { bottom, top }; - assert!(altitude(Some(10.), Some(14.)).passes(&at(Rotation::None, 0), &mut random)); - assert!(!altitude(Some(10.5), None).passes(&at(Rotation::None, 0), &mut random)); - assert!(!altitude(None, Some(13.9)).passes(&at(Rotation::None, 0), &mut random)); - // North (-Z) of an unrotated piece, within 5 blocks; the neighbour - // stays below it in Y, but only X and Z are searched, and it must - // not overlap the piece itself. - let north = |templates, rotation| Condition::PieceInDirection { - templates, - range: 5, - rotation, - }; - assert!(!north(Vec::new(), Rotation::None).passes(&at(Rotation::None, 1), &mut random)); - let low = At { - piece: Some(BoundingBox::new([0, 0, 0], [4, 4, 4])), - span: [0, 4], - ..at(Rotation::None, 1) - }; - assert!(north(Vec::new(), Rotation::None).passes(&low, &mut random)); - assert!(north(vec![t.clone()], Rotation::None).passes(&low, &mut random)); - assert!(!north(vec![other], Rotation::None).passes(&low, &mut random)); - // The piece turned 180°: its north is south. - let turned = At { - rotation: Rotation::Clockwise180, - ..low - }; - assert!(!north(Vec::new(), Rotation::None).passes(&turned, &mut random)); - assert!(north(Vec::new(), Rotation::Clockwise180).passes(&turned, &mut random)); - let all = Condition::AllOf(vec![ - Condition::True, - Condition::Not(Box::new(Condition::False)), - Condition::Rotation(vec![Rotation::Clockwise180]), - ]); - assert!(all.passes(&turned, &mut random)); - assert!(!Condition::AnyOf(vec![Condition::False]).passes(&turned, &mut random)); - } - - #[test] - fn piece_in_range_searches_a_grown_box() { - let t = Arc::new(StructureTemplate::empty()); - let other = Arc::new(StructureTemplate::empty()); - let piece = BoundingBox::new([0, 0, 0], [4, 4, 4]); - let above = |gone: &'static [bool]| { - let pieces = vec![PoolPiece { - element: single(&t, None), - position: [0, 30, 0], - ground_level_delta: 1, - rotation: Rotation::None, - bounding_box: BoundingBox::new([0, 30, 0], [4, 34, 4]), - junctions: Vec::new(), - waterlogging: true, - }]; - (pieces, gone) - }; - let range = |templates, above| Condition::PieceInRange { - templates, - horizontal: 0, - above, - below: 0, - }; - let mut random = LegacyRandom::new(1); - let (pieces, gone) = above(&[]); - let at = At { - depth: 1, - rotation: Rotation::None, - piece: Some(piece), - span: [0, 4], - pieces: &pieces, - gone, - }; - // 26 blocks above the piece's top: in a 40-block search, not 20. - assert!(range(None, 40).passes(&at, &mut random)); - assert!(!range(None, 20).passes(&at, &mut random)); - assert!(range(Some(vec![t.clone()]), 40).passes(&at, &mut random)); - assert!(!range(Some(vec![other]), 40).passes(&at, &mut random)); - // An unknown template matches nothing; a piece taken out by a dead - // end is not seen. - assert!(!range(Some(Vec::new()), 40).passes(&at, &mut random)); - let (pieces, gone) = above(&[true]); - let at = At { - pieces: &pieces, - gone, - ..at - }; - assert!(!range(None, 40).passes(&at, &mut random)); - } - - #[test] - fn transform_picks_by_position_and_turns_its_offset() { - let (a, b) = ( - Arc::new(StructureTemplate::empty()), - Arc::new(StructureTemplate::empty()), - ); - let rules = Rules { - name: Some("pillar".into()), - ..Rules::default() - }; - let mut piece = PoolPiece { - element: single(&a, Some(rules)), - position: [100, 40, -7], - ground_level_delta: 1, - rotation: Rotation::Clockwise90, - bounding_box: BoundingBox::new([100, 40, -7], [104, 44, -3]), - junctions: vec![Junction { - source_x: 0, - source_ground_y: 0, - source_z: 0, - }], - waterlogging: false, - }; - transform(&mut piece, &[a.clone(), b.clone()], [-1, 2, 0]); - // setFeatureSeed(x, y, x): seed x + y + 10000 * x, then nextInt(2). - let mut java = LegacyRandom::new(100 + 40 + 10000 * 100); - let chosen = [&a, &b][java.next_int_bound(2).unwrap() as usize]; - let PoolElement::Single { template, yung, .. } = piece.element.as_ref() else { - panic!("not a single element"); - }; - assert!(Arc::ptr_eq(template, chosen)); - assert_eq!( - yung.as_ref().and_then(|r| r.name.as_deref()), - Some("pillar") - ); - // (-1, 2, 0) turned 90° clockwise is (0, 2, -1). - assert_eq!(piece.position, [100, 42, -8]); - assert!(piece.junctions.is_empty() && piece.waterlogging); - } -} diff --git a/docs/mods.md b/docs/mods.md index 6f3216d38..d17b15d65 100644 --- a/docs/mods.md +++ b/docs/mods.md @@ -350,14 +350,14 @@ A pool addition holds `"elements"` as a pool does. This is what lithostitched's NeoForge and Fabric, and `horizon-port-pack carry` writes them from those. A structure (and any of its pool elements, in place of the structure's) may -hold `horizon:enhanced_terrain_adaptation`: YUNG's API's and -MoogsStructureLib's terrain kernel, which carves or buries terrain around the +hold `horizon:enhanced_terrain_adaptation`: MoogsStructureLib's terrain +kernel, which carves or buries terrain around the pieces on top of vanilla's `terrain_adaptation`. `kernel_size` and `kernel_distance` shape the kernel; `top` and `bottom` (`carve`, `bury` or `none`) act above and below a piece's floor; `bottom_offset`, `padding` (`x`, `top`, `bottom`, `z`) and MoogsStructureLib's `band` (`bottom`, `top`, `piece_heights`) move the box it measures from. On a structure, -`rigid_junctions` (YUNG's API) adds rigid pieces' junctions as well and +`rigid_junctions` adds rigid pieces' junctions as well and `inflate` grows the start's box by the kernel radius. `kernel_size` 0 is none. diff --git a/tools/test_structure_layout_parity.py b/tools/test_structure_layout_parity.py index 185dde578..d16002424 100644 --- a/tools/test_structure_layout_parity.py +++ b/tools/test_structure_layout_parity.py @@ -17,12 +17,11 @@ The oracle's game is 1.21.1 and Horizon's 26.3. KNOWN lists the starts where 26. 1.21.1, each checked against 26.3 vanilla (VanillaChunkGenerationOracle's `scan`, HORIZON_PIECES=1), whose pieces equal Horizon's there; they are reported, not failed. -Mod profiles compare the mod world's `pieces` instead. Horizon then reads the mod's own data from its jar -(`.local/structure-oracle/jars/`, or HORIZON_STRUCTURE_JARS): an overlay of the 26.3 resources adds the -jar's structures, sets, pools, processor lists, biome tags and templates, as they are written. The mod's -own processors are left out of the overlay's lists: they change blocks, never which pieces are placed. -Block states are upgraded to 26.3's JSON shape, as the port does. -The jars are the player's; nothing of them enters the repository. +Mod profiles compare the mod world's `pieces` instead, through the path players' packs take: the mod jars +(`.local/structure-oracle/jars/`, or HORIZON_STRUCTURE_JARS) are carried by `horizon-port-pack carry` into +a fresh work folder, and an overlay of the 26.3 resources adds the carried structures, sets, pools, +processor lists, biome tags and templates. The jars are the player's; nothing of them enters the +repository. usage: test_structure_layout_parity.py [--profile lithostitched|betterdungeons] [--filter REGEX] [--verbose] """ @@ -34,7 +33,6 @@ import re import shutil import subprocess import sys -import zipfile from pathlib import Path try: @@ -44,12 +42,13 @@ except ImportError: ORACLE = Path(os.environ.get('HORIZON_STRUCTURE_ORACLE', lab.LOCAL / 'structure-oracle')) JARS = Path(os.environ.get('HORIZON_STRUCTURE_JARS', ORACLE / 'jars')) -# Per profile: which saved pieces are expected, the mod jars whose data Horizon reads, and the structures -# compared (a regex; None: every start that has the pieces). +# Per profile: which saved pieces are expected, the mods in the pack (id: jar), those carried, and the +# structures compared (a regex; None: every start that has the pieces). PROFILES = { - 'lithostitched': {'side': 'base_pieces', 'jars': [], 'structures': None}, - 'betterdungeons': {'side': 'pieces', 'jars': ['YungsApi-1.21.1-NeoForge-5.1.9.jar', 'YungsBetterDungeons-1.21.1-NeoForge-5.1.4.jar'], - 'structures': r'^betterdungeons:(small|skeleton|zombie)_dungeon$'}, + 'lithostitched': {'side': 'base_pieces', 'mods': {}, 'carry': [], 'structures': None}, + 'betterdungeons': {'side': 'pieces', 'mods': {'yungsapi': 'YungsApi-1.21.1-NeoForge-5.1.9.jar', + 'betterdungeons': 'YungsBetterDungeons-1.21.1-NeoForge-5.1.4.jar'}, + 'carry': ['betterdungeons'], 'structures': r'^betterdungeons:(small|skeleton|zombie)_dungeon$'}, } BUNDLES = {'structure': 'structures', 'structure_set': 'structure_sets', 'template_pool': 'template_pools', 'processor_list': 'processor_lists'} @@ -61,7 +60,7 @@ KNOWN = { ('lithostitched', 1234, 'minecraft:village_plains', -24, 47): 'no start in 26.3 vanilla (nor in Horizon)', ('lithostitched', 1234, 'minecraft:ruined_portal', -16, 51): RUINED_PORTAL, ('lithostitched', 42, 'minecraft:ruined_portal', -19, -38): RUINED_PORTAL, - ('betterdungeons', 1234, 'betterdungeons:small_dungeon', 162, 10): SULFUR_CAVES, + ('betterdungeons', 1234, 'betterdungeons:small_dungeon', 162, 10): SULFUR_CAVES, ('betterdungeons', 42, 'betterdungeons:small_dungeon', -29, -28): SULFUR_CAVES, **{('lithostitched', seed, 'minecraft:trial_chambers', x, z): TRIAL_CHAMBERS for seed, x, z in [(1234, 13, 21), (2, -28, 13), (2, 11, 16), (2, 146, 44), (42, 17, 7), (42, 38, -25)]}, @@ -91,20 +90,34 @@ def expected_piece(saved): return piece -def upgrade_states(value): - """1.21.1's block states (`{"Name", "Properties"}`) in 26.3's shape (`{"id", "properties"}`).""" - if isinstance(value, list): - return [upgrade_states(v) for v in value] - if not isinstance(value, dict): - return value - if 'Name' in value and set(value) <= {'Name', 'Properties'}: - return {'id': value['Name'], **({'properties': value['Properties']} if 'Properties' in value else {})} - return {k: upgrade_states(v) for k, v in value.items()} +# horizon-port-pack's jar census (inventory.json); the carry does not read it. +CENSUS = ['blockstates', 'item_models', 'recipes', 'loot_tables', 'tags', 'worldgen', 'structures', 'languages', + 'textures', 'sounds', 'classes', 'mixin_configs'] -def overlay(profile, jars): - """The 26.3 resources plus the jars' structure data, in a folder the probe reads as its resources.""" - root = lab.LOCAL / 'structure-layout' / profile +def carry(profile, mods, carried): + """`horizon-port-pack carry` of the profile's mods; the carried mods' data folders.""" + work = lab.LOCAL / 'structure-layout' / profile / 'work' + shutil.rmtree(work, ignore_errors=True) + work.mkdir(parents=True) + records = [{'key': key, 'name': key, 'version': '', 'path': jar, 'tier': 'data', 'confidence': 'known', + 'class': 'content' if key in carried else 'library', 'source': 'file', 'license': '', + 'client': '', 'server': '', 'depends': [], 'layer': 0, 'jar': str(JARS / jar), + 'info': {'mod_id': key, 'name': key, 'version': '', 'license': '', 'depends': [], + 'census': dict.fromkeys(CENSUS, 0)}} + for key, jar in mods.items()] + (work / 'inventory.json').write_text(json.dumps(records)) + subprocess.run(['cargo', 'run', '--release', '-q', '-p', 'horizon-port-pack', '--', 'carry', str(work), + '--resources', str(lab.RESOURCES), *carried], check=True, cwd=lab.ROOT, stdout=subprocess.DEVNULL) + data = [work / 'mods' / key / 'carry' / 'data' for key in carried] + if not all(d.is_dir() for d in data): + raise SystemExit(f'horizon-port-pack carried nothing into {work / "mods"}') + return data + + +def overlay(profile, data_dirs): + """The 26.3 resources plus the carried structure data, in a folder the probe reads as its resources.""" + root = lab.LOCAL / 'structure-layout' / profile / 'resources' shutil.rmtree(root, ignore_errors=True) worldgen = root / 'worldgen' (worldgen / 'structure').mkdir(parents=True) @@ -116,28 +129,24 @@ def overlay(profile, jars): (worldgen / entry.name).symlink_to(entry) for namespace in (source / 'structure').iterdir(): (worldgen / 'structure' / namespace.name).symlink_to(namespace) - for jar in jars: - with zipfile.ZipFile(JARS / jar) as archive: - for name in archive.namelist(): - parts = name.split('/') - if parts[0] != 'data' or len(parts) < 4 or name.endswith('/'): - continue - namespace, kind = parts[1], parts[2] - if kind == 'structure' and name.endswith('.nbt'): - target = worldgen / 'structure' / namespace / '/'.join(parts[3:]) - target.parent.mkdir(parents=True, exist_ok=True) - target.write_bytes(archive.read(name)) - elif name.endswith('.json') and kind == 'worldgen' and parts[3] in BUNDLES: - value = upgrade_states(json.loads(archive.read(name))) - if parts[3] == 'processor_list': - value['processors'] = [p for p in value['processors'] if p['processor_type'].startswith('minecraft:')] - merged[BUNDLES[parts[3]]][f'{namespace}:' + '/'.join(parts[4:])[:-5]] = json.dumps(value) - elif name.endswith('.json') and parts[2:5] == ['tags', 'worldgen', 'biome']: - tag = f'{namespace}:' + '/'.join(parts[5:])[:-5] - value = json.loads(archive.read(name)) - if tag in merged['biome_tags'] and not value.get('replace'): - value['values'] = json.loads(merged['biome_tags'][tag])['values'] + value['values'] - merged['biome_tags'][tag] = json.dumps(value) + for data in data_dirs: + for path in sorted(p for p in data.rglob('*') if p.is_file()): + parts = path.relative_to(data).parts + if len(parts) < 3: + continue + namespace, kind = parts[0], parts[1] + if kind == 'structure' and path.suffix == '.nbt': + target = worldgen / 'structure' / namespace / Path(*parts[2:]) + target.parent.mkdir(parents=True, exist_ok=True) + target.symlink_to(path) + elif path.suffix == '.json' and kind == 'worldgen' and parts[2] in BUNDLES: + merged[BUNDLES[parts[2]]][f'{namespace}:' + '/'.join(parts[3:])[:-5]] = path.read_text() + elif path.suffix == '.json' and parts[1:4] == ('tags', 'worldgen', 'biome'): + tag = f'{namespace}:' + '/'.join(parts[4:])[:-5] + value = json.loads(path.read_text()) + if tag in merged['biome_tags'] and not value.get('replace'): + value['values'] = json.loads(merged['biome_tags'][tag])['values'] + value['values'] + merged['biome_tags'][tag] = json.dumps(value) for name, bundle in merged.items(): (worldgen / f'{name}.json').write_text(json.dumps(bundle)) return root @@ -190,11 +199,13 @@ def main(): print(f'SKIP: no oracle results in {ORACLE / args.profile} (see this file\'s docstring)') return 0 profile = PROFILES[args.profile] - missing = [jar for jar in profile['jars'] if not (JARS / jar).exists()] + missing = [jar for jar in profile['mods'].values() if not (JARS / jar).exists()] if missing: print(f'SKIP: mod jars not in {JARS}: {", ".join(missing)}') return 0 - resources = overlay(args.profile, profile['jars']) if profile['jars'] else lab.RESOURCES + resources = lab.RESOURCES + if profile['carry']: + resources = overlay(args.profile, carry(args.profile, profile['mods'], profile['carry'])) binary = probe_binary() matched, known, failed = 0, 0, [] for path in results: