Content mods: read the spawn biome modifiers (#7478)

Read NeoForge's `add_spawns`, `remove_spawns`, `add_spawn_costs` and
`remove_spawn_costs` biome modifiers alongside `add_features` and
`remove_features` when building the content root:

- `add_spawns` appends its spawners (a single spawner object or a list)
  to each entity type's spawn category in `spawns_by_category` under
  `attributes[minecraft:gameplay/natural_mob_spawns]`, converting
  `minCount` and `maxCount` to 26.3's `count` (an integer when equal,
  `minecraft:uniform` otherwise) and taking the category from
  `entity-types.json`;
- `remove_spawns` removes the resolved `entity_types` (an id, `#tag`,
  list, or `any`/`or`/`and`/`not` holder set) from every category;
- `add_spawn_costs` sets `spawn_costs[type]` (`energy_budget`,
  `charge`) and `remove_spawn_costs` removes them;
- additions run before removals in mod and file order, unknown entity
  types or tags are skipped with a warning, malformed modifiers fail the
  build naming the mod and file, and changed biomes also update
  `data/<ns>/worldgen/biome/<path>.json` in the overlay when present.

Fixes #7478

Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
GabrielNicholsandClaude Opus 4.6 committed 2026-11-02 22:27:07 -03:00
1 parent 31f83499b6
commit f5c34fc62e
4 files changed
+624 -100

No files matched your search

+63 -1
View File
@@ -227,6 +227,12 @@ impl J {
_ => None,
}
}
fn as_str(&self) -> Option<&str> {
match self {
J::Str(s) => Some(s),
_ => None,
}
}
/// Renames strings and, with `ids`, moves state ids, everywhere inside.
fn rename(&mut self, r: &Rename, ids: bool) {
match self {
@@ -1940,6 +1946,7 @@ fn place_in_biomes(
modifiers: &[biomes::Modifier],
biome_tags: Option<J>,
placed: Option<J>,
tags: &BTreeMap<String, TagMerge>,
warnings: &mut Vec<String>,
) -> Result<()> {
let bundle = |doc: Option<J>, name: &str| -> Result<BTreeMap<String, String>> {
@@ -1970,7 +1977,47 @@ fn place_in_biomes(
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(
let entities_path = out_path(overlay, "entity-types.json");
let entity_categories: BTreeMap<String, String> = match entities_path.exists() {
true => match read_json(&entities_path)? {
J::Obj(entries) => entries
.into_iter()
.map(|(k, v)| {
let cat = v
.get("category")
.and_then(J::as_str)
.unwrap_or("misc")
.to_owned();
(k, cat)
})
.collect(),
_ => return Err("entity-types.json: not an object".into()),
},
false => BTreeMap::new(),
};
let entity_ids: BTreeSet<String> = entity_categories.keys().cloned().collect();
let mut entity_tags = BTreeMap::new();
let vanilla_entity_tags = base.join("data/minecraft/tags/entity_type");
if vanilla_entity_tags.is_dir() {
for file in &crate::json_folder::read(&vanilla_entity_tags)?.files {
let text = std::str::from_utf8(file.bytes()?)
.map_err(|e| format!("entity_type/{}: {e}", file.name))?;
entity_tags.insert(format!("minecraft:{}", file.name), text.to_owned());
}
}
for file in tags.keys() {
if let Some((ns, path)) = file
.strip_prefix("data/")
.and_then(|r| r.split_once("/tags/entity_type/"))
.and_then(|(ns, p)| Some((ns, p.strip_suffix(".json")?)))
{
entity_tags.insert(
format!("{ns}:{path}"),
fs::read_to_string(out_path(overlay, file))?,
);
}
}
let changed = biomes::apply(
&mut doc,
modifiers,
&biomes::Registry {
@@ -1983,8 +2030,22 @@ fn place_in_biomes(
ids: &placed,
tags: &feature_tags,
},
&biomes::Registry {
what: "entity type",
ids: &entity_ids,
tags: &entity_tags,
},
&entity_categories,
warnings,
)?;
for (id, text) in changed {
if let Some((ns, path)) = id.split_once(':') {
let file = format!("data/{ns}/worldgen/biome/{path}.json");
if out_path(overlay, &file).exists() {
overlay.write(&file, text.as_bytes())?;
}
}
}
overlay.write(BIOMES, &serde_json::to_vec(&doc)?)
}
@@ -2473,6 +2534,7 @@ pub fn build(base: &Path, mods: &[ContentMod], out: &Path) -> Result<Vec<String>
&modifiers,
biome_tag_doc,
placed_doc,
&tags,
&mut warnings,
)?;
}
+375 -67
View File
@@ -1,18 +1,22 @@
//! 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
//! Biome modifiers: a content mod's placed features and mob spawns in biomes
//! it does not own (vanilla's), as NeoForge's `add_features`,
//! `remove_features`, `add_spawns`, `remove_spawns`, `add_spawn_costs` and
//! `remove_spawn_costs` 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.
//! They rewrite the biomes' `features` lists and
//! `minecraft:gameplay/natural_mob_spawns` attributes 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; an added spawner goes at the end of its entity type's
//! spawn category with `minCount` and `maxCount` converted to 26.3's `count`.
//! 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 serde_json::{Number, Value};
use std::collections::{BTreeMap, BTreeSet};
/// The folders a biome modifier sits in, under `data/<ns>`.
@@ -23,13 +27,43 @@ 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.
/// One mob spawner entry in an `add_spawns` modifier.
#[derive(Debug, PartialEq)]
struct Spawner {
entity: String,
weight: i64,
min_count: i64,
max_count: i64,
}
/// What a modifier does to 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>),
/// Appends features to one generation step.
AddFeatures { features: Value, step: usize },
/// Takes features out of these steps (every step when the file names none).
RemoveFeatures { features: Value, steps: Vec<usize> },
/// Appends spawners to their entity types' spawn categories.
AddSpawns(Vec<Spawner>),
/// Takes these entity types out of every spawn category.
RemoveSpawns(Value),
/// Sets `spawn_costs` for these entity types.
AddSpawnCosts {
entity_types: Value,
energy_budget: Number,
charge: Number,
},
/// Takes these entity types out of `spawn_costs`.
RemoveSpawnCosts(Value),
}
impl Action {
fn is_add(&self) -> bool {
matches!(
self,
Self::AddFeatures { .. } | Self::AddSpawns(_) | Self::AddSpawnCosts { .. }
)
}
}
/// One biome modifier file.
@@ -38,7 +72,6 @@ pub(super) struct Modifier {
/// `<mod>: <file>`, for errors.
name: String,
biomes: Value,
features: Value,
action: Action,
}
@@ -51,11 +84,45 @@ fn step(v: &Value) -> std::result::Result<usize, String> {
.ok_or_else(|| format!("unknown generation step {name:?}"))
}
/// One NeoForge `SpawnerData` object from `spawners`.
fn spawner(v: &Value) -> std::result::Result<Spawner, String> {
let o = v.as_object().ok_or("spawner: not an object")?;
let entity = o
.get("type")
.and_then(Value::as_str)
.ok_or("spawner: no type")?;
let int = |k: &str| {
o.get(k)
.and_then(Value::as_i64)
.ok_or_else(|| format!("spawner: no {k}"))
};
let weight = int("weight")?;
let min_count = int("minCount")?;
let max_count = int("maxCount")?;
if weight < 0 {
return Err(format!("spawner: weight ({weight}) < 0"));
}
if min_count <= 0 {
return Err(format!("spawner: minCount ({min_count}) must be positive"));
}
if min_count > max_count {
return Err(format!(
"spawner: minCount ({min_count}) > maxCount ({max_count})"
));
}
Ok(Spawner {
entity: full(entity),
weight,
min_count,
max_count,
})
}
impl Modifier {
/// The modifier in `text`; `None` for `neoforge:none`, which does nothing,
/// and for a type Horizon doesn't read yet (`add_spawns`, `add_carvers`, a
/// mod's own serializer), which is skipped with a line in `warnings`: real
/// mods ship those beside their feature modifiers, and one must not drop
/// and for a type Horizon doesn't read yet (`add_carvers`, a mod's own
/// serializer), which is skipped with a line in `warnings`: real mods ship
/// those beside their feature and spawn modifiers, and one must not drop
/// every content mod.
pub(super) fn read(
mod_id: &str,
@@ -76,27 +143,62 @@ impl Modifier {
};
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)?],
}),
"add_features" => Action::AddFeatures {
features: field("features")?,
step: step(&field("step")?).map_err(at)?,
},
"remove_features" => Action::RemoveFeatures {
features: field("features")?,
steps: 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)?],
},
},
"add_spawns" => {
let raw = field("spawners")?;
let spawners = match &raw {
Value::Array(list) => list
.iter()
.map(spawner)
.collect::<std::result::Result<_, _>>()
.map_err(at)?,
one @ Value::Object(_) => vec![spawner(one).map_err(at)?],
_ => return Err(at("spawners: not a spawner or list".into()).into()),
};
Action::AddSpawns(spawners)
}
"remove_spawns" => Action::RemoveSpawns(field("entity_types")?),
"add_spawn_costs" => {
let entity_types = field("entity_types")?;
let cost = field("spawn_cost")?;
let num = |k: &str| {
cost.get(k)
.and_then(Value::as_number)
.cloned()
.ok_or_else(|| at(format!("spawn_cost: no {k}")))
};
Action::AddSpawnCosts {
entity_types,
energy_budget: num("energy_budget")?,
charge: num("charge")?,
}
}
"remove_spawn_costs" => Action::RemoveSpawnCosts(field("entity_types")?),
other => {
warnings.push(at(format!(
"biome modifier type {other:?} skipped: only add_features, remove_features \
and none are read"
"biome modifier type {other:?} skipped: only feature, spawn and none \
modifiers are read"
)));
return Ok(None);
}
};
Ok(Some(Self {
biomes: field("biomes")?,
features: field("features")?,
action,
name,
}))
@@ -228,36 +330,54 @@ fn extend(out: &mut Vec<String>, ids: impl IntoIterator<Item = String>) {
/// 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). A modifier naming a
/// biome, feature or tag the root doesn't have (another mod's, a `#c:`
/// convention tag) is skipped with a line in `warnings`, as NeoForge skips a
/// holder set it can't resolve, instead of dropping every content mod.
/// biome, feature, entity type or tag the root doesn't have (another mod's, a
/// `#c:` convention tag) is skipped with a line in `warnings`, as NeoForge
/// skips a holder set it can't resolve, instead of dropping every content mod.
pub(super) fn apply(
biomes: &mut J,
modifiers: &[Modifier],
biome_reg: &Registry<'_>,
feature_reg: &Registry<'_>,
entity_reg: &Registry<'_>,
entity_categories: &BTreeMap<String, String>,
warnings: &mut Vec<String>,
) -> Result<()> {
let adds = modifiers
.iter()
.filter(|m| matches!(m.action, Action::Add(_)));
let removes = modifiers
.iter()
.filter(|m| matches!(m.action, Action::Remove(_)));
) -> Result<Vec<(String, String)>> {
let adds = modifiers.iter().filter(|m| m.action.is_add());
let removes = modifiers.iter().filter(|m| !m.action.is_add());
// 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 resolved = biome_reg
.resolve(&m.biomes)
.and_then(|targets| Ok((targets, feature_reg.resolve(&m.features)?)));
let (targets, features) = match resolved {
let resolved: std::result::Result<(Vec<String>, Vec<String>), String> =
biome_reg.resolve(&m.biomes).and_then(|targets| {
let ids = match &m.action {
Action::AddFeatures { features, .. }
| Action::RemoveFeatures { features, .. } => feature_reg.resolve(features)?,
Action::AddSpawns(spawners) => {
if let Some(s) = spawners
.iter()
.find(|s| !entity_reg.ids.contains(&s.entity))
{
return Err(format!("unknown {} {}", entity_reg.what, s.entity));
}
Vec::new()
}
Action::RemoveSpawns(entity_types)
| Action::AddSpawnCosts { entity_types, .. }
| Action::RemoveSpawnCosts(entity_types) => entity_reg.resolve(entity_types)?,
};
Ok((targets, ids))
});
let (targets, ids) = match resolved {
Ok(both) => both,
Err(e) => {
warnings.push(at(format!("{e}: skipped")));
continue;
}
};
if matches!(&m.action, Action::AddSpawns(s) if s.is_empty()) {
continue;
}
for biome in targets {
let doc = match changed.entry(biome.clone()) {
std::collections::btree_map::Entry::Occupied(e) => e.into_mut(),
@@ -268,29 +388,57 @@ pub(super) fn apply(
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}")))?;
edit(doc, &m.action, &ids, entity_categories)
.map_err(|e| at(format!("{biome}: {e}")))?;
}
}
let mut out = Vec::with_capacity(changed.len());
for (id, doc) in changed {
biomes.set(&id, J::Str(serde_json::to_string(&doc)?));
let text = serde_json::to_string(&doc)?;
biomes.set(&id, J::Str(text.clone()));
out.push((id, text));
}
Ok(())
Ok(out)
}
/// 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> {
/// One modifier's change to one biome.
fn edit(
biome: &mut J,
action: &Action,
ids: &[String],
entity_categories: &BTreeMap<String, String>,
) -> std::result::Result<(), String> {
match action {
Action::AddFeatures { step, .. } => {
edit_features(biome, std::slice::from_ref(step), true, ids)
}
Action::RemoveFeatures { steps, .. } => edit_features(biome, steps, false, ids),
Action::AddSpawns(spawners) => add_spawns(biome, spawners, entity_categories),
Action::RemoveSpawns(_) => remove_spawns(biome, ids),
Action::AddSpawnCosts {
energy_budget,
charge,
..
} => add_spawn_costs(biome, ids, energy_budget, charge),
Action::RemoveSpawnCosts(_) => remove_spawn_costs(biome, ids),
}
}
/// One feature modifier's change to one biome's `features` (a list per step).
fn edit_features(
biome: &mut J,
wanted: &[usize],
add: bool,
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 {
if add && let Some(&step) = wanted.first() {
while steps.len() <= step {
steps.push(J::Arr(Vec::new()));
}
}
@@ -312,19 +460,179 @@ fn edit(biome: &mut J, action: &Action, features: &[String]) -> std::result::Res
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()));
}
if 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)));
}
} else {
list.retain(|v| held(v).is_none_or(|id| !features.contains(&id)));
}
}
Ok(())
}
/// The `minecraft:gameplay/natural_mob_spawns` argument object on `biome`,
/// creating it when the biome does not yet have one.
fn natural_spawns(biome: &mut J) -> std::result::Result<&mut J, String> {
if biome.get("attributes").is_none() {
biome.set("attributes", J::Obj(Vec::new()));
}
let Some(attrs @ J::Obj(_)) = biome.get_mut("attributes") else {
return Err("attributes: not an object".into());
};
const KEY: &str = "minecraft:gameplay/natural_mob_spawns";
if attrs.get(KEY).is_none() {
attrs.set(
KEY,
J::Obj(vec![
(
"argument".into(),
J::Obj(vec![
("spawn_costs".into(), J::Obj(Vec::new())),
("spawns_by_category".into(), J::Obj(Vec::new())),
]),
),
("modifier".into(), J::Str("overlay".into())),
]),
);
}
let Some(nat) = attrs.get_mut(KEY) else {
unreachable!()
};
if nat.get("argument").is_some() {
match nat.get_mut("argument") {
Some(arg @ J::Obj(_)) => Ok(arg),
_ => Err(format!("{KEY}.argument: not an object")),
}
} else {
match nat {
J::Obj(_) => Ok(nat),
_ => Err(format!("{KEY}: not an object")),
}
}
}
fn add_spawns(
biome: &mut J,
spawners: &[Spawner],
entity_categories: &BTreeMap<String, String>,
) -> std::result::Result<(), String> {
let nat = natural_spawns(biome)?;
if nat.get("spawns_by_category").is_none() {
nat.set("spawns_by_category", J::Obj(Vec::new()));
}
let Some(by_cat @ J::Obj(_)) = nat.get_mut("spawns_by_category") else {
return Err("spawns_by_category: not an object".into());
};
for s in spawners {
let cat = entity_categories
.get(&s.entity)
.map_or("misc", String::as_str);
if by_cat.get(cat).is_none() {
by_cat.set(cat, J::Arr(Vec::new()));
}
let Some(J::Arr(list)) = by_cat.get_mut(cat) else {
return Err(format!("spawns_by_category.{cat}: not a list"));
};
let count = if s.min_count == s.max_count {
J::Num(s.min_count.into())
} else {
J::Obj(vec![
("type".into(), J::Str("minecraft:uniform".into())),
("min_inclusive".into(), J::Num(s.min_count.into())),
("max_inclusive".into(), J::Num(s.max_count.into())),
])
};
list.push(J::Obj(vec![
("type".into(), J::Str(s.entity.clone())),
("count".into(), count),
("weight".into(), J::Num(s.weight.into())),
]));
}
Ok(())
}
fn remove_spawns(biome: &mut J, entities: &[String]) -> std::result::Result<(), String> {
let Some(nat) = biome
.get_mut("attributes")
.and_then(|a| a.get_mut("minecraft:gameplay/natural_mob_spawns"))
else {
return Ok(());
};
let nat = if nat.get("argument").is_some() {
nat.get_mut("argument").unwrap()
} else {
nat
};
let Some(by_cat) = nat.get_mut("spawns_by_category") else {
return Ok(());
};
let J::Obj(cats) = by_cat else {
return Err("spawns_by_category: not an object".into());
};
for (cat, list) in cats {
let J::Arr(entries) = list else {
return Err(format!("spawns_by_category.{cat}: not a list"));
};
entries.retain(|entry| {
entry
.get("type")
.and_then(J::as_str)
.is_none_or(|t| !entities.contains(&full(t)))
});
}
Ok(())
}
fn add_spawn_costs(
biome: &mut J,
entities: &[String],
energy_budget: &Number,
charge: &Number,
) -> std::result::Result<(), String> {
let nat = natural_spawns(biome)?;
if nat.get("spawn_costs").is_none() {
nat.set("spawn_costs", J::Obj(Vec::new()));
}
let Some(costs @ J::Obj(_)) = nat.get_mut("spawn_costs") else {
return Err("spawn_costs: not an object".into());
};
for entity in entities {
if let J::Obj(entries) = costs {
entries.retain(|(k, _)| k == entity || full(k) != *entity);
}
costs.set(
entity,
J::Obj(vec![
("energy_budget".into(), J::Num(energy_budget.clone())),
("charge".into(), J::Num(charge.clone())),
]),
);
}
Ok(())
}
fn remove_spawn_costs(biome: &mut J, entities: &[String]) -> std::result::Result<(), String> {
let Some(nat) = biome
.get_mut("attributes")
.and_then(|a| a.get_mut("minecraft:gameplay/natural_mob_spawns"))
else {
return Ok(());
};
let nat = if nat.get("argument").is_some() {
nat.get_mut("argument").unwrap()
} else {
nat
};
let Some(costs) = nat.get_mut("spawn_costs") else {
return Ok(());
};
let J::Obj(entries) = costs else {
return Err("spawn_costs: not an object".into());
};
entries.retain(|(k, _)| !entities.contains(&full(k)));
Ok(())
}
+168 -19
View File
@@ -3165,7 +3165,8 @@ fn a_shape_and_the_state_count_cover_every_combination() {
}
/// 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.
/// the other), three vanilla placed features and entity types: what biome
/// modifiers read.
fn biome_base(tag: &str) -> PathBuf {
let (base, _) = block_base(tag, &[]);
let worldgen = base.join("worldgen");
@@ -3180,7 +3181,7 @@ fn biome_base(tag: &str) -> PathBuf {
bundle(&[
(
"minecraft:plains",
r#"{"temperature":0.8,"features":[[],["minecraft:lake"],[],[],[],[],["minecraft:ore_coal"]]}"#,
r#"{"temperature":0.8,"features":[[],["minecraft:lake"],[],[],[],[],["minecraft:ore_coal"]],"attributes":{"minecraft:gameplay/natural_mob_spawns":{"argument":{"spawn_costs":{"minecraft:skeleton":{"energy_budget":0.7,"charge":0.15}},"spawns_by_category":{"monster":[{"type":"minecraft:zombie","count":4,"weight":95},{"type":"minecraft:skeleton","count":4,"weight":100}]}},"modifier":"overlay"}}}"#,
),
("minecraft:desert", r#"{"features":[["minecraft:fossil"]]}"#),
(
@@ -3215,6 +3216,18 @@ fn biome_base(tag: &str) -> PathBuf {
for (name, text) in files {
fs::write(worldgen.join(name), text).unwrap();
}
fs::write(
base.join("entity-types.json"),
r#"{"minecraft:zombie":{"category":"monster"},"minecraft:skeleton":{"category":"monster"},"minecraft:stray":{"category":"monster"},"minecraft:rabbit":{"category":"creature"}}"#,
)
.unwrap();
let entity_tags = base.join("data/minecraft/tags/entity_type");
fs::create_dir_all(&entity_tags).unwrap();
fs::write(
entity_tags.join("skeletons.json"),
r#"{"values":["minecraft:skeleton","minecraft:stray",{"id":"othermod:bone","required":false}]}"#,
)
.unwrap();
base
}
@@ -3369,14 +3382,14 @@ fn biome_modifiers_none_keep_the_vanilla_biomes() {
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.
/// An unknown biome, biome tag, feature, entity type, step or modifier type is
/// skipped with a warning or 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();
// A name the root doesn't have, or a type not read yet: that modifier is
// skipped with a warning (another mod's ids, `#c:` tags and `add_spawns`
// skipped with a warning (another mod's ids, `#c:` tags and `add_carvers`
// are common in real mods), and the mods still load.
for (i, (modifier, warning)) in [
(
@@ -3395,8 +3408,18 @@ fn biome_modifiers_refuse_unknown_names() {
"unknown placed feature zinc:ore_tin",
),
(
r#"{"type":"neoforge:add_spawns","biomes":"minecraft:plains","spawners":[]}"#,
"biome modifier type \"add_spawns\" skipped",
r#"{"type":"neoforge:add_spawns","biomes":"minecraft:plains",
"spawners":{"type":"zinc:goblin","weight":10,"minCount":1,"maxCount":2}}"#,
"unknown entity type zinc:goblin",
),
(
r##"{"type":"neoforge:remove_spawns","biomes":"minecraft:plains",
"entity_types":"#minecraft:is_goblin"}"##,
"unknown entity type tag #minecraft:is_goblin",
),
(
r#"{"type":"neoforge:add_carvers","biomes":"minecraft:plains","carvers":[]}"#,
"biome modifier type \"add_carvers\" skipped",
),
]
.into_iter()
@@ -3411,21 +3434,147 @@ fn biome_modifiers_refuse_unknown_names() {
);
}
// A malformed modifier of a type Horizon reads is the mod's error.
let m = zinc_mod(
&scratch.join("zinc-step"),
&[(
"bad",
for (i, (modifier, err)) in [
(
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":{"type":"minecraft:zombie","weight":10,"minCount":5,"maxCount":2}}"#,
"minCount (5) > maxCount (2)",
),
(
r#"{"type":"neoforge:add_spawn_costs","biomes":"minecraft:plains",
"entity_types":"minecraft:zombie","spawn_cost":{"energy_budget":0.7}}"#,
"spawn_cost: no charge",
),
]
.into_iter()
.enumerate()
{
let m = zinc_mod(&scratch.join(format!("zinc-err{i}")), &[("bad", modifier)]);
let e = build(&base, &[m], &scratch.join(format!("out-err{i}")))
.unwrap_err()
.to_string();
assert!(e.contains(err), "{e}");
assert!(
e.contains("mod zinc: data/zinc/neoforge/biome_modifier/bad.json"),
"{e}"
);
}
let _ = fs::remove_dir_all(&scratch);
}
/// `add_spawns`, `remove_spawns`, `add_spawn_costs` and `remove_spawn_costs`
/// rewrite `minecraft:gameplay/natural_mob_spawns` in the biomes they name:
/// additions before removals regardless of file order, spawners placed under
/// their entity type's category with `minCount`/`maxCount` converted to
/// `count`, and untouched biomes left byte for byte as they were.
#[test]
fn biome_modifiers_change_mob_spawns_and_spawn_costs() {
let base = biome_base("biome-modifiers-spawns");
let scratch = base.parent().unwrap().to_owned();
let m = zinc_mod(
&scratch.join("zinc"),
&[
// Listed before `add_spawns`, but removals run after additions:
// takes out vanilla's `minecraft:skeleton` and the added
// `minecraft:stray` via `#minecraft:skeletons`.
(
"a_no_skeletons",
r##"{"type":"neoforge:remove_spawns","biomes":"#minecraft:is_overworld",
"entity_types":"#minecraft:skeletons"}"##,
),
(
"a_no_skeleton_cost",
r##"{"type":"neoforge:remove_spawn_costs","biomes":"minecraft:plains",
"entity_types":"#minecraft:skeletons"}"##,
),
// Single spawner object (`minCount == maxCount` -> integer `count`)
// on a biome that had no `attributes` at all (`desert`), and on `plains`.
(
"add_rabbits",
r##"{"type":"neoforge:add_spawns","biomes":"#minecraft:is_overworld",
"spawners":{"type":"minecraft:rabbit","weight":12,"minCount":3,"maxCount":3}}"##,
),
// Array of spawners (`minCount < maxCount` -> `minecraft:uniform`).
(
"add_monsters",
r#"{"type":"forge:add_spawns","biomes":"plains",
"spawners":[
{"type":"stray","weight":80,"minCount":1,"maxCount":4},
{"type":"minecraft:zombie","weight":20,"minCount":2,"maxCount":5}
]}"#,
),
(
"add_zombie_cost",
r#"{"type":"neoforge:add_spawn_costs","biomes":["minecraft:plains","minecraft:desert"],
"entity_types":["minecraft:zombie","minecraft:stray"],
"spawn_cost":{"energy_budget":0.85,"charge":0.2}}"#,
),
],
);
let e = build(&base, &[m], &scratch.join("out-step"))
.unwrap_err()
.to_string();
assert!(e.contains("unknown generation step \"ores\""), "{e}");
assert!(
e.contains("mod zinc: data/zinc/neoforge/biome_modifier/bad.json"),
"{e}"
let out = scratch.join("out");
let warnings = build(&base, &[m], &out).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
let text = |root: &Path| -> BTreeMap<String, String> {
serde_json::from_slice(&fs::read(root.join("worldgen/biomes.json")).unwrap()).unwrap()
};
let out_biomes = text(&out);
// Untouched biome keeps its exact bytes.
assert_eq!(
out_biomes["minecraft:nether_wastes"],
text(&base)["minecraft:nether_wastes"]
);
let plains: serde_json::Value = serde_json::from_str(&out_biomes["minecraft:plains"]).unwrap();
let plains_spawns = &plains["attributes"]["minecraft:gameplay/natural_mob_spawns"]["argument"];
// `minecraft:skeleton` and the added `minecraft:stray` were removed by
// `remove_spawns`, while vanilla's zombie, the second zombie spawner, and
// the rabbit in `creature` remain.
assert_eq!(
plains_spawns["spawns_by_category"],
serde_json::json!({
"monster": [
{"type": "minecraft:zombie", "count": 4, "weight": 95},
{
"type": "minecraft:zombie",
"count": {"type": "minecraft:uniform", "min_inclusive": 2, "max_inclusive": 5},
"weight": 20
}
],
"creature": [
{"type": "minecraft:rabbit", "count": 3, "weight": 12}
]
})
);
// `skeleton` and `stray` costs were removed on `plains`; `zombie` cost remains.
assert_eq!(
plains_spawns["spawn_costs"],
serde_json::json!({
"minecraft:zombie": {"energy_budget": 0.85, "charge": 0.2}
})
);
let desert: serde_json::Value = serde_json::from_str(&out_biomes["minecraft:desert"]).unwrap();
let desert_spawns = &desert["attributes"]["minecraft:gameplay/natural_mob_spawns"]["argument"];
assert_eq!(
desert_spawns["spawns_by_category"],
serde_json::json!({
"creature": [
{"type": "minecraft:rabbit", "count": 3, "weight": 12}
]
})
);
assert_eq!(
desert_spawns["spawn_costs"],
serde_json::json!({
"minecraft:zombie": {"energy_budget": 0.85, "charge": 0.2},
"minecraft:stray": {"energy_budget": 0.85, "charge": 0.2}
})
);
let _ = fs::remove_dir_all(&scratch);
}
+18 -13
View File
@@ -376,13 +376,17 @@ 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.
A mod's own configured and placed features (`data/<ns>/worldgen/configured_feature/`, `placed_feature/`) and mob spawn
changes reach 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); `add_spawns` appends its `spawners` (one `{ "type", "weight",
"minCount", "maxCount" }` object or a list of them) to each entity type's category in
`minecraft:gameplay/natural_mob_spawns`, turning `minCount` and `maxCount` into 26.3's `count`; `remove_spawns` takes its
`entity_types` out of every category; `add_spawn_costs` sets `spawn_costs` (`{ "energy_budget", "charge" }`) for its
`entity_types`, and `remove_spawn_costs` removes them; `none` does nothing. `biomes`, `features` and `entity_types` are
an id, a `#tag`, a list of ids, or 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",
@@ -392,12 +396,13 @@ feature already in the step is not added twice.
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. A modifier
naming a biome, biome tag, feature or feature tag the game doesn't have (another mod's, a `#c:` convention tag), or of a
type not read yet (spawns and climate: `add_spawns`, `add_carvers`, a mod's own serializer), is skipped with a warning
naming the mod and file, as NeoForge skips what it can't resolve; the mods still load. A malformed modifier of a type
that is read (an unknown step, a missing field) stops the build, and the game starts without content mods ("content
mods not loaded: ..." in the log). Feature tags are vanilla's for now (`#tag` in `features` doesn't read a mod's
`placed_feature` tags yet); a world's data pack that replaces a biome replaces the mods' changes to it too.
naming a biome, biome tag, feature, feature tag, entity type or entity type tag the game doesn't have (another mod's, a
`#c:` convention tag), or of a type not read yet (`add_carvers`, climate, a mod's own serializer), is skipped with a
warning naming the mod and file, as NeoForge skips what it can't resolve; the mods still load. A malformed modifier of a
type that is read (an unknown step, a missing field, `minCount > maxCount`) stops the build, and the game starts without
content mods ("content mods not loaded: ..." in the log). Feature tags are vanilla's for now (`#tag` in `features`
doesn't read a mod's `placed_feature` tags yet); a world's data pack that replaces a biome replaces the mods' changes to
it too.
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`,