mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
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:
1 parent
31f83499b6
commit
f5c34fc62e
4 files changed
+624
-100
No files matched your search
@@ -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,
|
||||
)?;
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
@@ -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`,
|
||||
|
||||
Reference in new issue
Block a user