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:
Claude committed 2026-10-31 20:45:38 +00:00
1 parent e31e3bd996
commit 1fedaa1c5a
4 files changed
+739 -1

No files matched your search

+76 -1
View File
@@ -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);
}
+20
View File
@@ -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)