mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Content mods: place mod features in biomes (biome modifiers)
A content mod's placed features now generate in vanilla's biomes through NeoForge's biome modifiers (data/<ns>/neoforge/biome_modifier/, and forge/biome_modifier/ for older mods): add_features, remove_features and none. When the content root builds they rewrite the biomes' feature lists, an added feature at the end of its step as NeoForge adds it; the generator's FeatureSorter then orders them as vanilla does. Biomes no modifier names keep their text, and a root with no modifiers keeps vanilla's bundle. An unknown biome, tag, feature, step or type fails the build with the mod and file named. Part of #4953, closes #5431. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NLEqWLKW2LHgtLer35pT8m
This commit is contained in:
4 files changed
+739
-1
No files matched your search
@@ -1424,6 +1424,68 @@ impl Addition {
|
||||
}
|
||||
}
|
||||
|
||||
/// The worldgen loader's bundles of biomes and of placed feature tags.
|
||||
const BIOMES: &str = "worldgen/biomes.json";
|
||||
const FEATURE_TAGS: &str = "worldgen/feature_tags.json";
|
||||
const PLACED_FEATURES: &str = "worldgen/placed_features.json";
|
||||
|
||||
/// Rewrites the biome bundle with the mods' biome modifiers. `biome_tags`
|
||||
/// and `placed` are the bundles as the mods left them (`None`: the base's).
|
||||
fn place_in_biomes(
|
||||
base: &Path,
|
||||
overlay: &Overlay<'_>,
|
||||
modifiers: &[biomes::Modifier],
|
||||
biome_tags: Option<J>,
|
||||
placed: Option<J>,
|
||||
) -> Result<()> {
|
||||
let bundle = |doc: Option<J>, name: &str| -> Result<BTreeMap<String, String>> {
|
||||
let doc = match doc {
|
||||
Some(doc) => doc,
|
||||
None if base.join(name).exists() => read_json(&base.join(name))?,
|
||||
None => return Ok(BTreeMap::new()),
|
||||
};
|
||||
let J::Obj(entries) = doc else {
|
||||
return Err(format!("{name}: not an object").into());
|
||||
};
|
||||
Ok(entries
|
||||
.into_iter()
|
||||
.filter_map(|(k, v)| match v {
|
||||
J::Str(text) => Some((k, text)),
|
||||
_ => None,
|
||||
})
|
||||
.collect())
|
||||
};
|
||||
if !base.join(BIOMES).exists() {
|
||||
return Err(format!("biome modifiers need {BIOMES}").into());
|
||||
}
|
||||
let mut doc = read_json(&base.join(BIOMES))?;
|
||||
let biome_ids: BTreeSet<String> = match &doc {
|
||||
J::Obj(o) => o.iter().map(|(k, _)| k.clone()).collect(),
|
||||
_ => return Err(format!("{BIOMES}: not an object").into()),
|
||||
};
|
||||
let biome_tags = bundle(biome_tags, BIOME_TAGS)?;
|
||||
let placed: BTreeSet<String> = bundle(placed, PLACED_FEATURES)?.into_keys().collect();
|
||||
let feature_tags = bundle(None, FEATURE_TAGS)?;
|
||||
biomes::apply(
|
||||
&mut doc,
|
||||
modifiers,
|
||||
&biomes::Registry {
|
||||
what: "biome",
|
||||
ids: &biome_ids,
|
||||
tags: &biome_tags,
|
||||
},
|
||||
&biomes::Registry {
|
||||
what: "placed feature",
|
||||
ids: &placed,
|
||||
tags: &feature_tags,
|
||||
},
|
||||
)?;
|
||||
overlay.write(BIOMES, &serde_json::to_vec(&doc)?)
|
||||
}
|
||||
|
||||
#[path = "mod_content_biomes.rs"]
|
||||
mod biomes;
|
||||
|
||||
/// The overlay being written: `out` mirrors `base` through links, and a
|
||||
/// directory becomes real only when something inside it is written.
|
||||
struct Overlay<'a> {
|
||||
@@ -1742,6 +1804,7 @@ pub fn build(base: &Path, mods: &[ContentMod], out: &Path) -> Result<Vec<String>
|
||||
let mut warnings = Vec::new();
|
||||
let mut worldgen: BTreeMap<&str, Vec<(String, String)>> = BTreeMap::new();
|
||||
let mut additions: BTreeMap<&str, Vec<Addition>> = BTreeMap::new();
|
||||
let mut modifiers = Vec::new();
|
||||
for m in mods {
|
||||
let declarations = format!("data/{}/horizon/", m.id);
|
||||
let mut reader = m.content.reader()?;
|
||||
@@ -1760,7 +1823,10 @@ pub fn build(base: &Path, mods: &[ContentMod], out: &Path) -> Result<Vec<String>
|
||||
warnings.extend(merge.add(&m.id, file, replace, shared, values));
|
||||
} else if !file.starts_with(&declarations) {
|
||||
overlay.write(file, &bytes)?;
|
||||
if let Some((bundle, id)) = worldgen_entry(file) {
|
||||
if biomes::is_modifier(file) {
|
||||
let text = String::from_utf8(bytes).map_err(|e| format!("{file}: {e}"))?;
|
||||
modifiers.extend(biomes::Modifier::read(&m.id, file, &text)?);
|
||||
} else if let Some((bundle, id)) = worldgen_entry(file) {
|
||||
let text = String::from_utf8(bytes).map_err(|e| format!("{file}: {e}"))?;
|
||||
let entries = worldgen.entry(bundle).or_default();
|
||||
match Addition::read(bundle, &text).map_err(|e| format!("{file}: {e}"))? {
|
||||
@@ -1831,6 +1897,7 @@ pub fn build(base: &Path, mods: &[ContentMod], out: &Path) -> Result<Vec<String>
|
||||
Some((format!("{ns}:{}", path.strip_suffix(".json")?), merge))
|
||||
})
|
||||
.collect();
|
||||
let mut biome_tag_doc = None;
|
||||
if !biome_tags.is_empty() && base.join(BIOME_TAGS).exists() {
|
||||
let mut doc = read_json(&base.join(BIOME_TAGS))?;
|
||||
for (id, merge) in biome_tags {
|
||||
@@ -1842,6 +1909,7 @@ pub fn build(base: &Path, mods: &[ContentMod], out: &Path) -> Result<Vec<String>
|
||||
doc.set(&id, J::Str(merged));
|
||||
}
|
||||
overlay.write(BIOME_TAGS, &serde_json::to_vec(&doc)?)?;
|
||||
biome_tag_doc = Some(doc);
|
||||
}
|
||||
// Structures and the pools, sets, processors and features they use, then
|
||||
// what mods add to sets, pools and lists, by priority, then in mod and
|
||||
@@ -1849,6 +1917,7 @@ pub fn build(base: &Path, mods: &[ContentMod], out: &Path) -> Result<Vec<String>
|
||||
for list in additions.values_mut() {
|
||||
list.sort_by_key(|a| a.priority);
|
||||
}
|
||||
let mut placed_doc = None;
|
||||
for (bundle, entries) in &worldgen {
|
||||
if !base.join(bundle).exists() {
|
||||
continue;
|
||||
@@ -1863,6 +1932,12 @@ pub fn build(base: &Path, mods: &[ContentMod], out: &Path) -> Result<Vec<String>
|
||||
.map_err(|e| format!("{bundle}: {e}"))?;
|
||||
}
|
||||
overlay.write(bundle, &serde_json::to_vec(&doc)?)?;
|
||||
if *bundle == PLACED_FEATURES {
|
||||
placed_doc = Some(doc);
|
||||
}
|
||||
}
|
||||
if !modifiers.is_empty() {
|
||||
place_in_biomes(base, &overlay, &modifiers, biome_tag_doc, placed_doc)?;
|
||||
}
|
||||
// The commands suggest ids from `command-registries.json`: add the
|
||||
// structures, pools, sets and features the mods bring.
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
//! Biome modifiers: a content mod's placed features in biomes it does not
|
||||
//! own (vanilla's), as NeoForge's `neoforge:add_features` and
|
||||
//! `neoforge:remove_features` biome modifiers do. Each is a file in
|
||||
//! `data/<ns>/neoforge/biome_modifier/` (older Forge mods:
|
||||
//! `data/<ns>/forge/biome_modifier/`, type `forge:...`).
|
||||
//!
|
||||
//! They rewrite the biomes' `features` lists when the content root builds:
|
||||
//! an added feature goes at the end of its step's list, as NeoForge adds it,
|
||||
//! and the generator orders every biome's lists as vanilla's `FeatureSorter`
|
||||
//! does. Biomes no modifier names keep their text byte for byte, and a root
|
||||
//! with no modifiers keeps vanilla's bundle, so vanilla features keep their
|
||||
//! indices and their random seeds.
|
||||
use super::{J, Result};
|
||||
use horizon_world::worldgen::structure::start::STEPS;
|
||||
use serde_json::Value;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
/// The folders a biome modifier sits in, under `data/<ns>`.
|
||||
const FOLDERS: [&str; 2] = ["/neoforge/biome_modifier/", "/forge/biome_modifier/"];
|
||||
|
||||
/// Whether a mod file is a biome modifier.
|
||||
pub(super) fn is_modifier(file: &str) -> bool {
|
||||
file.starts_with("data/") && file.ends_with(".json") && FOLDERS.iter().any(|f| file.contains(f))
|
||||
}
|
||||
|
||||
/// What a modifier does to the features of its biomes.
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum Action {
|
||||
/// Appends to one generation step.
|
||||
Add(usize),
|
||||
/// Takes out of these steps (every step when the file names none).
|
||||
Remove(Vec<usize>),
|
||||
}
|
||||
|
||||
/// One biome modifier file.
|
||||
#[derive(Debug)]
|
||||
pub(super) struct Modifier {
|
||||
/// `<mod>: <file>`, for errors.
|
||||
name: String,
|
||||
biomes: Value,
|
||||
features: Value,
|
||||
action: Action,
|
||||
}
|
||||
|
||||
/// A generation step's index from its name.
|
||||
fn step(v: &Value) -> std::result::Result<usize, String> {
|
||||
let name = v.as_str().ok_or("step: not a name")?;
|
||||
STEPS
|
||||
.iter()
|
||||
.position(|s| *s == name)
|
||||
.ok_or_else(|| format!("unknown generation step {name:?}"))
|
||||
}
|
||||
|
||||
impl Modifier {
|
||||
/// The modifier in `text`; `None` for `neoforge:none`, which does nothing.
|
||||
pub(super) fn read(mod_id: &str, file: &str, text: &str) -> Result<Option<Self>> {
|
||||
let name = format!("mod {mod_id}: {file}");
|
||||
let at = |e: String| format!("{name}: {e}");
|
||||
let doc: Value = serde_json::from_str(text).map_err(|e| at(e.to_string()))?;
|
||||
let kind = doc["type"].as_str().ok_or_else(|| at("no type".into()))?;
|
||||
let kind = kind
|
||||
.strip_prefix("neoforge:")
|
||||
.or_else(|| kind.strip_prefix("forge:"))
|
||||
.unwrap_or(kind);
|
||||
let field = |key: &str| -> std::result::Result<Value, String> {
|
||||
doc.get(key).cloned().ok_or_else(|| at(format!("no {key}")))
|
||||
};
|
||||
let action = match kind {
|
||||
"none" => return Ok(None),
|
||||
"add_features" => Action::Add(step(&field("step")?).map_err(at)?),
|
||||
"remove_features" => Action::Remove(match doc.get("steps") {
|
||||
None => (0..STEPS.len()).collect(),
|
||||
Some(Value::Array(steps)) => steps
|
||||
.iter()
|
||||
.map(step)
|
||||
.collect::<std::result::Result<_, _>>()
|
||||
.map_err(at)?,
|
||||
Some(one) => vec![step(one).map_err(at)?],
|
||||
}),
|
||||
other => {
|
||||
return Err(at(format!(
|
||||
"biome modifier type {other:?}: only add_features, remove_features and none \
|
||||
are read"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
};
|
||||
Ok(Some(Self {
|
||||
biomes: field("biomes")?,
|
||||
features: field("features")?,
|
||||
action,
|
||||
name,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// `ns:path` for an id that may leave out `minecraft:`.
|
||||
fn full(id: &str) -> String {
|
||||
match id.contains(':') {
|
||||
true => id.to_owned(),
|
||||
false => format!("minecraft:{id}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A registry the modifiers name entries of: its ids and its tags (tag id →
|
||||
/// the tag file's text), as the worldgen loader's bundles hold them.
|
||||
pub(super) struct Registry<'a> {
|
||||
pub what: &'static str,
|
||||
pub ids: &'a BTreeSet<String>,
|
||||
pub tags: &'a BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl Registry<'_> {
|
||||
/// The ids a holder set names, in its order without repeats: an id,
|
||||
/// `#tag`, a list of ids, or one of NeoForge's `any`, `or`, `and` and
|
||||
/// `not` objects. Unknown ids and tags are errors; inside a tag, entries
|
||||
/// nobody registers are left out, as the worldgen loader leaves them out.
|
||||
fn resolve(&self, set: &Value) -> std::result::Result<Vec<String>, String> {
|
||||
let mut out = Vec::new();
|
||||
match set {
|
||||
Value::String(s) => match s.strip_prefix('#') {
|
||||
Some(tag) => self.expand(&full(tag), &mut out, &mut BTreeSet::new())?,
|
||||
None => {
|
||||
let id = full(s);
|
||||
if !self.ids.contains(&id) {
|
||||
return Err(format!("unknown {} {id}", self.what));
|
||||
}
|
||||
out.push(id);
|
||||
}
|
||||
},
|
||||
Value::Array(list) => {
|
||||
for v in list {
|
||||
match v {
|
||||
Value::String(s) if !s.starts_with('#') => {
|
||||
extend(&mut out, self.resolve(v)?)
|
||||
}
|
||||
_ => return Err(format!("a {} list holds ids only", self.what)),
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Object(o) => {
|
||||
let kind = o.get("type").and_then(Value::as_str).unwrap_or_default();
|
||||
let kind = kind.strip_prefix("neoforge:").unwrap_or(kind);
|
||||
let sets = || -> std::result::Result<Vec<Vec<String>>, String> {
|
||||
o.get("values")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or(format!("{kind}: no values"))?
|
||||
.iter()
|
||||
.map(|v| self.resolve(v))
|
||||
.collect()
|
||||
};
|
||||
match kind {
|
||||
"any" => out.extend(self.ids.iter().cloned()),
|
||||
"or" => sets()?.into_iter().for_each(|s| extend(&mut out, s)),
|
||||
"and" => {
|
||||
let mut sets = sets()?.into_iter();
|
||||
out = sets.next().unwrap_or_default();
|
||||
for s in sets {
|
||||
out.retain(|id| s.contains(id));
|
||||
}
|
||||
}
|
||||
"not" => {
|
||||
let not = self.resolve(o.get("value").ok_or("not: no value")?)?;
|
||||
out.extend(self.ids.iter().filter(|id| !not.contains(id)).cloned());
|
||||
}
|
||||
_ => return Err(format!("{} set type {kind:?} is not read", self.what)),
|
||||
}
|
||||
}
|
||||
_ => return Err(format!("not a {} id, tag or list", self.what)),
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn expand(
|
||||
&self,
|
||||
tag: &str,
|
||||
out: &mut Vec<String>,
|
||||
seen: &mut BTreeSet<String>,
|
||||
) -> std::result::Result<(), String> {
|
||||
if !seen.insert(tag.to_owned()) {
|
||||
return Ok(());
|
||||
}
|
||||
let text = self
|
||||
.tags
|
||||
.get(tag)
|
||||
.ok_or_else(|| format!("unknown {} tag #{tag}", self.what))?;
|
||||
let doc: Value = serde_json::from_str(text).map_err(|e| format!("#{tag}: {e}"))?;
|
||||
for v in doc["values"].as_array().into_iter().flatten() {
|
||||
let (id, required) = match v {
|
||||
Value::String(s) => (s.as_str(), true),
|
||||
Value::Object(o) => match o.get("id").and_then(Value::as_str) {
|
||||
Some(id) => (id, o.get("required") != Some(&Value::Bool(false))),
|
||||
None => continue,
|
||||
},
|
||||
_ => continue,
|
||||
};
|
||||
match id.strip_prefix('#') {
|
||||
Some(inner) if required || self.tags.contains_key(&full(inner)) => {
|
||||
self.expand(&full(inner), out, seen)?
|
||||
}
|
||||
Some(_) => {}
|
||||
None if self.ids.contains(&full(id)) => extend(out, [full(id)]),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends the ids `out` lacks.
|
||||
fn extend(out: &mut Vec<String>, ids: impl IntoIterator<Item = String>) {
|
||||
for id in ids {
|
||||
if !out.contains(&id) {
|
||||
out.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies `modifiers` to the biome bundle `biomes` (id → the biome's text):
|
||||
/// every addition first, then every removal, each in mod and file order (as
|
||||
/// NeoForge's add phase runs before its remove phase).
|
||||
pub(super) fn apply(
|
||||
biomes: &mut J,
|
||||
modifiers: &[Modifier],
|
||||
biome_reg: &Registry<'_>,
|
||||
feature_reg: &Registry<'_>,
|
||||
) -> Result<()> {
|
||||
let adds = modifiers
|
||||
.iter()
|
||||
.filter(|m| matches!(m.action, Action::Add(_)));
|
||||
let removes = modifiers
|
||||
.iter()
|
||||
.filter(|m| matches!(m.action, Action::Remove(_)));
|
||||
// Each biome changed, parsed once, in the bundle's order.
|
||||
let mut changed: BTreeMap<String, J> = BTreeMap::new();
|
||||
for m in adds.chain(removes) {
|
||||
let at = |e: String| format!("{}: {e}", m.name);
|
||||
let targets = biome_reg.resolve(&m.biomes).map_err(at)?;
|
||||
let features = feature_reg.resolve(&m.features).map_err(at)?;
|
||||
for biome in targets {
|
||||
let doc = match changed.entry(biome.clone()) {
|
||||
std::collections::btree_map::Entry::Occupied(e) => e.into_mut(),
|
||||
std::collections::btree_map::Entry::Vacant(e) => {
|
||||
let Some(J::Str(text)) = biomes.get(&biome) else {
|
||||
continue;
|
||||
};
|
||||
e.insert(serde_json::from_str(text).map_err(|e| at(format!("{biome}: {e}")))?)
|
||||
}
|
||||
};
|
||||
edit(doc, &m.action, &features).map_err(|e| at(format!("{biome}: {e}")))?;
|
||||
}
|
||||
}
|
||||
for (id, doc) in changed {
|
||||
biomes.set(&id, J::Str(serde_json::to_string(&doc)?));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One modifier's change to one biome's `features` (a list per step).
|
||||
fn edit(biome: &mut J, action: &Action, features: &[String]) -> std::result::Result<(), String> {
|
||||
if biome.get("features").is_none() {
|
||||
biome.set("features", J::Arr(Vec::new()));
|
||||
}
|
||||
let Some(J::Arr(steps)) = biome.get_mut("features") else {
|
||||
return Err("features: not a list of steps".into());
|
||||
};
|
||||
let wanted: &[usize] = match action {
|
||||
Action::Add(step) => std::slice::from_ref(step),
|
||||
Action::Remove(steps) => steps,
|
||||
};
|
||||
if let Action::Add(step) = action {
|
||||
while steps.len() <= *step {
|
||||
steps.push(J::Arr(Vec::new()));
|
||||
}
|
||||
}
|
||||
for &i in wanted {
|
||||
let Some(list) = steps.get_mut(i) else {
|
||||
continue;
|
||||
};
|
||||
// A step may be one id; a tag cannot take more features.
|
||||
if let J::Str(one) = list {
|
||||
if one.starts_with('#') {
|
||||
return Err(format!("step {} is the feature tag {one}", STEPS[i]));
|
||||
}
|
||||
*list = J::Arr(vec![J::Str(one.clone())]);
|
||||
}
|
||||
let J::Arr(list) = list else {
|
||||
return Err(format!("step {}: not a list", STEPS[i]));
|
||||
};
|
||||
let held = |v: &J| match v {
|
||||
J::Str(s) => Some(full(s)),
|
||||
_ => None,
|
||||
};
|
||||
match action {
|
||||
Action::Add(_) => {
|
||||
for f in features {
|
||||
// Twice in one step would be a cycle for the sorter.
|
||||
if !list.iter().any(|v| held(v).as_ref() == Some(f)) {
|
||||
list.push(J::Str(f.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Action::Remove(_) => {
|
||||
list.retain(|v| held(v).is_none_or(|id| !features.contains(&id)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -2471,3 +2471,337 @@ fn a_shape_and_the_state_count_cover_every_combination() {
|
||||
fs::remove_dir_all(&base).unwrap();
|
||||
fs::remove_dir_all(base.parent().unwrap().join("mod0")).ok();
|
||||
}
|
||||
|
||||
/// A base root of one block with three biomes, two biome tags (one nesting
|
||||
/// the other) and three vanilla placed features: what biome modifiers read.
|
||||
fn biome_base(tag: &str) -> PathBuf {
|
||||
let (base, _) = block_base(tag, &[]);
|
||||
let worldgen = base.join("worldgen");
|
||||
fs::create_dir_all(&worldgen).unwrap();
|
||||
let bundle = |entries: &[(&str, &str)]| -> String {
|
||||
let map: BTreeMap<&str, &str> = entries.iter().copied().collect();
|
||||
serde_json::to_string(&map).unwrap()
|
||||
};
|
||||
let files = [
|
||||
(
|
||||
"biomes.json",
|
||||
bundle(&[
|
||||
(
|
||||
"minecraft:plains",
|
||||
r#"{"temperature":0.8,"features":[[],["minecraft:lake"],[],[],[],[],["minecraft:ore_coal"]]}"#,
|
||||
),
|
||||
("minecraft:desert", r#"{"features":[["minecraft:fossil"]]}"#),
|
||||
(
|
||||
"minecraft:nether_wastes",
|
||||
r#"{"temperature":2.0,"features":[[],[],[],[],[],[],["minecraft:ore_coal"]]}"#,
|
||||
),
|
||||
]),
|
||||
),
|
||||
(
|
||||
"biome_tags.json",
|
||||
bundle(&[
|
||||
(
|
||||
"minecraft:is_overworld",
|
||||
r##"{"values":["#minecraft:is_plains_like","minecraft:desert",{"id":"othermod:x","required":false}]}"##,
|
||||
),
|
||||
(
|
||||
"minecraft:is_plains_like",
|
||||
r#"{"values":["minecraft:plains"]}"#,
|
||||
),
|
||||
]),
|
||||
),
|
||||
(
|
||||
"placed_features.json",
|
||||
bundle(&[
|
||||
("minecraft:ore_coal", "{}"),
|
||||
("minecraft:lake", "{}"),
|
||||
("minecraft:fossil", "{}"),
|
||||
]),
|
||||
),
|
||||
("features.json", bundle(&[])),
|
||||
];
|
||||
for (name, text) in files {
|
||||
fs::write(worldgen.join(name), text).unwrap();
|
||||
}
|
||||
base
|
||||
}
|
||||
|
||||
/// A mod adding a zinc ore and its biome modifiers (`(file name, text)`).
|
||||
fn zinc_mod(dir: &Path, modifiers: &[(&str, &str)]) -> ContentMod {
|
||||
let mut files = vec![
|
||||
("horizon-mod.toml".to_owned(), manifest("zinc", "")),
|
||||
(
|
||||
"data/zinc/worldgen/configured_feature/ore_zinc.json".to_owned(),
|
||||
r#"{"type":"minecraft:ore","config":{}}"#.to_owned(),
|
||||
),
|
||||
(
|
||||
"data/zinc/worldgen/placed_feature/ore_zinc.json".to_owned(),
|
||||
r#"{"feature":"zinc:ore_zinc","placement":[]}"#.to_owned(),
|
||||
),
|
||||
];
|
||||
for (name, text) in modifiers {
|
||||
files.push((
|
||||
format!("data/zinc/neoforge/biome_modifier/{name}.json"),
|
||||
(*text).to_owned(),
|
||||
));
|
||||
}
|
||||
let files: Vec<(&str, &str)> = files
|
||||
.iter()
|
||||
.map(|(a, b)| (a.as_str(), b.as_str()))
|
||||
.collect();
|
||||
folder_mod(dir, &files)
|
||||
}
|
||||
|
||||
fn biome_features(root: &Path) -> BTreeMap<String, serde_json::Value> {
|
||||
let bundle: BTreeMap<String, String> =
|
||||
serde_json::from_slice(&fs::read(root.join("worldgen/biomes.json")).unwrap()).unwrap();
|
||||
bundle
|
||||
.into_iter()
|
||||
.map(|(id, text)| {
|
||||
let v: serde_json::Value = serde_json::from_str(&text).unwrap();
|
||||
(id, v["features"].clone())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `neoforge:add_features` puts a mod's ore at the end of its step in every
|
||||
/// biome of an overworld tag, `remove_features` takes a vanilla feature out,
|
||||
/// and the biomes no modifier names keep their text.
|
||||
#[test]
|
||||
fn biome_modifiers_place_mod_features_in_vanilla_biomes() {
|
||||
let base = biome_base("biome-modifiers");
|
||||
let scratch = base.parent().unwrap().to_owned();
|
||||
let m = zinc_mod(
|
||||
&scratch.join("zinc"),
|
||||
&[
|
||||
(
|
||||
"add_zinc",
|
||||
r##"{"type":"neoforge:add_features","biomes":"#minecraft:is_overworld",
|
||||
"features":"zinc:ore_zinc","step":"underground_ores"}"##,
|
||||
),
|
||||
// Adds before removes, whatever the file order.
|
||||
(
|
||||
"a_no_fossils",
|
||||
r#"{"type":"neoforge:remove_features","biomes":["minecraft:desert"],
|
||||
"features":["minecraft:fossil"],"steps":["raw_generation"]}"#,
|
||||
),
|
||||
("nothing", r#"{"type":"neoforge:none"}"#),
|
||||
],
|
||||
);
|
||||
let out = scratch.join("out");
|
||||
build(&base, &[m], &out).unwrap();
|
||||
let features = biome_features(&out);
|
||||
assert_eq!(
|
||||
features["minecraft:plains"],
|
||||
serde_json::json!([
|
||||
[],
|
||||
["minecraft:lake"],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
["minecraft:ore_coal", "zinc:ore_zinc"]
|
||||
])
|
||||
);
|
||||
// A biome with fewer steps gets the ones up to the modifier's.
|
||||
assert_eq!(
|
||||
features["minecraft:desert"],
|
||||
serde_json::json!([[], [], [], [], [], [], ["zinc:ore_zinc"]])
|
||||
);
|
||||
let text = |root: &Path| -> BTreeMap<String, String> {
|
||||
serde_json::from_slice(&fs::read(root.join("worldgen/biomes.json")).unwrap()).unwrap()
|
||||
};
|
||||
assert_eq!(
|
||||
text(&out)["minecraft:nether_wastes"],
|
||||
text(&base)["minecraft:nether_wastes"]
|
||||
);
|
||||
let plains: serde_json::Value = serde_json::from_str(&text(&out)["minecraft:plains"]).unwrap();
|
||||
assert_eq!(plains["temperature"], serde_json::json!(0.8));
|
||||
|
||||
// The generator's sorter places vanilla's features where they were and
|
||||
// the ore after the coal it follows: the order applyBiomeDecoration uses.
|
||||
use horizon_world::worldgen::feature_sort::{FeatureIdentity, build_features_per_step};
|
||||
let mut ids: Vec<String> = Vec::new();
|
||||
let mut plan = |biomes: &[&str], features: &BTreeMap<String, serde_json::Value>| {
|
||||
let lists: Vec<Vec<Vec<FeatureIdentity>>> = biomes
|
||||
.iter()
|
||||
.map(|b| {
|
||||
features[*b]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|step| {
|
||||
step.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let f = f.as_str().unwrap().to_owned();
|
||||
let i = ids.iter().position(|x| *x == f).unwrap_or_else(|| {
|
||||
ids.push(f);
|
||||
ids.len() - 1
|
||||
});
|
||||
FeatureIdentity(i as u32)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
build_features_per_step(&lists).unwrap()
|
||||
};
|
||||
let overworld = ["minecraft:plains", "minecraft:desert"];
|
||||
let steps = plan(&overworld, &features);
|
||||
let names = |s: usize, ids: &[String]| -> Vec<String> {
|
||||
steps[s]
|
||||
.features()
|
||||
.iter()
|
||||
.map(|f| ids[f.0 as usize].clone())
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(names(6, &ids), ["minecraft:ore_coal", "zinc:ore_zinc"]);
|
||||
assert_eq!(names(1, &ids), ["minecraft:lake"]);
|
||||
let _ = fs::remove_dir_all(&scratch);
|
||||
}
|
||||
|
||||
/// A mod with features but no modifier leaves vanilla's biome bundle alone.
|
||||
#[test]
|
||||
fn biome_modifiers_none_keep_the_vanilla_biomes() {
|
||||
let base = biome_base("biome-modifiers-none");
|
||||
let scratch = base.parent().unwrap().to_owned();
|
||||
let out = scratch.join("out");
|
||||
build(&base, &[zinc_mod(&scratch.join("zinc"), &[])], &out).unwrap();
|
||||
assert_eq!(
|
||||
fs::read(out.join("worldgen/biomes.json")).unwrap(),
|
||||
fs::read(base.join("worldgen/biomes.json")).unwrap()
|
||||
);
|
||||
let _ = fs::remove_dir_all(&scratch);
|
||||
}
|
||||
|
||||
/// An unknown biome, biome tag, feature, step or modifier type fails the
|
||||
/// build with the mod and file named.
|
||||
#[test]
|
||||
fn biome_modifiers_refuse_unknown_names() {
|
||||
let base = biome_base("biome-modifiers-errors");
|
||||
let scratch = base.parent().unwrap().to_owned();
|
||||
for (i, (modifier, error)) in [
|
||||
(
|
||||
r##"{"type":"neoforge:add_features","biomes":"#minecraft:is_moon",
|
||||
"features":"zinc:ore_zinc","step":"underground_ores"}"##,
|
||||
"unknown biome tag #minecraft:is_moon",
|
||||
),
|
||||
(
|
||||
r#"{"type":"neoforge:add_features","biomes":"minecraft:moon",
|
||||
"features":"zinc:ore_zinc","step":"underground_ores"}"#,
|
||||
"unknown biome minecraft:moon",
|
||||
),
|
||||
(
|
||||
r#"{"type":"neoforge:add_features","biomes":"minecraft:plains",
|
||||
"features":"zinc:ore_tin","step":"underground_ores"}"#,
|
||||
"unknown placed feature zinc:ore_tin",
|
||||
),
|
||||
(
|
||||
r#"{"type":"neoforge:add_features","biomes":"minecraft:plains",
|
||||
"features":"zinc:ore_zinc","step":"ores"}"#,
|
||||
"unknown generation step \"ores\"",
|
||||
),
|
||||
(
|
||||
r#"{"type":"neoforge:add_spawns","biomes":"minecraft:plains","spawners":[]}"#,
|
||||
"biome modifier type \"add_spawns\"",
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let m = zinc_mod(&scratch.join(format!("zinc{i}")), &[("bad", modifier)]);
|
||||
let e = build(&base, &[m], &scratch.join("out"))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(e.contains(error), "{e}");
|
||||
assert!(
|
||||
e.contains("mod zinc: data/zinc/neoforge/biome_modifier/bad.json"),
|
||||
"{e}"
|
||||
);
|
||||
}
|
||||
let _ = fs::remove_dir_all(&scratch);
|
||||
}
|
||||
|
||||
/// Over the real prepared resources: a mod's ore, added to
|
||||
/// `#minecraft:is_overworld` by a biome modifier, generates in a fresh
|
||||
/// world's chunk; without the modifier the same placed feature does not
|
||||
/// (its `biome` filter only passes in biomes that list it).
|
||||
#[test]
|
||||
#[ignore = "full content root: CI and pr_ready run it"]
|
||||
fn content_root_generates_a_biome_modifier_ore() {
|
||||
use crate::streaming::{ChunkGenerator, Proto};
|
||||
let Some(base) = prepared() else {
|
||||
return;
|
||||
};
|
||||
let scratch = std::env::temp_dir().join(format!("horizon-mod-ore-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&scratch);
|
||||
let ore = |modifier: bool| -> usize {
|
||||
let mut files = vec![
|
||||
("horizon-mod.toml", manifest("zinc", "")),
|
||||
(
|
||||
"data/zinc/horizon/block/zinc_ore.json",
|
||||
r#"{"template": "minecraft:iron_ore"}"#.to_owned(),
|
||||
),
|
||||
(
|
||||
"data/zinc/worldgen/configured_feature/ore_zinc.json",
|
||||
r##"{"type": "minecraft:ore", "config": {"size": 9, "discard_chance_on_air_exposure": 0.0,
|
||||
"targets": [{"target": {"predicate_type": "minecraft:tag_match",
|
||||
"tag": "minecraft:stone_ore_replaceables"},
|
||||
"state": {"Name": "zinc:zinc_ore"}}]}}"##
|
||||
.to_owned(),
|
||||
),
|
||||
(
|
||||
"data/zinc/worldgen/placed_feature/ore_zinc.json",
|
||||
r#"{"feature": "zinc:ore_zinc", "placement": [
|
||||
{"type": "minecraft:count", "count": 40}, {"type": "minecraft:in_square"},
|
||||
{"type": "minecraft:height_range", "height": {"type": "minecraft:uniform",
|
||||
"min_inclusive": {"absolute": -32}, "max_inclusive": {"absolute": 48}}},
|
||||
{"type": "minecraft:biome"}]}"#
|
||||
.to_owned(),
|
||||
),
|
||||
];
|
||||
if modifier {
|
||||
files.push((
|
||||
"data/zinc/neoforge/biome_modifier/add_zinc.json",
|
||||
r##"{"type": "neoforge:add_features", "biomes": "#minecraft:is_overworld",
|
||||
"features": "zinc:ore_zinc", "step": "underground_ores"}"##
|
||||
.to_owned(),
|
||||
));
|
||||
}
|
||||
let files: Vec<(&str, &str)> = files.iter().map(|(a, b)| (*a, b.as_str())).collect();
|
||||
let dir = scratch.join(format!("zinc-{modifier}"));
|
||||
let m = folder_mod(&dir, &files);
|
||||
let root = resource_root(&base, &[m], &dir.join("cache")).unwrap();
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let zinc = assets.registry.default_state("zinc:zinc_ore").unwrap().0;
|
||||
let biomes = crate::biome::Biomes::load(&root).unwrap();
|
||||
let terrain = crate::vanillagen::VanillaTerrain::new(
|
||||
&root.join("worldgen"),
|
||||
assets.registry,
|
||||
&biomes,
|
||||
1234,
|
||||
)
|
||||
.unwrap();
|
||||
let mut worker = terrain.worker();
|
||||
let mut region: [Proto; 9] =
|
||||
std::array::from_fn(|i| terrain.proto(&mut worker, i as i32 % 3 - 1, i as i32 / 3 - 1));
|
||||
terrain.decorate(&mut worker, &mut region);
|
||||
let [_, _, _, _, center, ..] = region;
|
||||
let chunk = terrain.finish(center);
|
||||
let mut count = 0;
|
||||
for y in chunk.min_y()..chunk.max_y() {
|
||||
for z in 0..16 {
|
||||
for x in 0..16 {
|
||||
count += usize::from(chunk.get(x, y, z) == zinc);
|
||||
}
|
||||
}
|
||||
}
|
||||
count
|
||||
};
|
||||
assert!(ore(true) > 0, "the modifier's ore generates");
|
||||
assert_eq!(ore(false), 0, "no modifier, no ore");
|
||||
let _ = fs::remove_dir_all(&scratch);
|
||||
}
|
||||
@@ -223,6 +223,26 @@ A pool addition holds `"elements"` as a pool does, a processor list addition `"p
|
||||
Lithostitched's `add_structure_set_entries`, `add_template_pool_elements` and `add_processor_list_processors` modifiers
|
||||
do on NeoForge and Fabric, and `horizon-port-pack carry` writes the last two from those.
|
||||
|
||||
A mod's own configured and placed features (`data/<ns>/worldgen/configured_feature/`, `placed_feature/`) generate in
|
||||
biomes it does not own (vanilla's) through NeoForge's biome modifiers, files in `data/<ns>/neoforge/biome_modifier/`
|
||||
(older Forge mods: `data/<ns>/forge/biome_modifier/`, `forge:` types). `add_features` appends its `features` to one
|
||||
generation `step` of every biome in `biomes`; `remove_features` takes them out of its `steps` (every step when it lists
|
||||
none); `none` does nothing. `biomes` and `features` are an id, a `#tag` or a list of ids; `biomes` may also be
|
||||
NeoForge's `any`, `or`, `and` or `not`. Every addition comes before every removal, each in mod and file order, and a
|
||||
feature already in the step is not added twice.
|
||||
|
||||
```json
|
||||
{ "type": "neoforge:add_features", "biomes": "#minecraft:is_overworld",
|
||||
"features": "zinc:ore_zinc", "step": "underground_ores" }
|
||||
```
|
||||
|
||||
The modifiers rewrite the biomes when the content root builds, and the generator orders every biome's features as
|
||||
vanilla's `FeatureSorter` does, so an added feature takes its place in that order (its random seed follows from it). A
|
||||
biome no modifier names keeps its text, and a world without mods keeps vanilla's biomes byte for byte. An unknown
|
||||
biome, biome tag, feature, feature tag, step or modifier type stops the build with the mod and file named, and the game
|
||||
starts without content mods ("content mods not loaded: ..." in the log). Feature tags are vanilla's; a world's data
|
||||
pack that replaces a biome replaces the mods' changes to it too. Spawns and climate (`add_spawns`, ...) are not read.
|
||||
|
||||
A pool element may be one of Lithostitched's: `lithostitched:limited` (`delegate`, `limit`, `min_depth`) places its
|
||||
`delegate` at most `limit` times in one structure start, and `lithostitched:guaranteed` (`delegate`, `count`,
|
||||
`min_depth`) is tried before its pool's other elements at each jigsaw that reaches that pool (as target or fallback)
|
||||
|
||||
Reference in new issue
Block a user