diff --git a/README.md b/README.md index 384566d2a..ad7133e48 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ round-trip harness has not been rerun. | Lighting | 1 | None: saved and relit light matches vanilla, slabs and stairs included | | World generation | 1 | None: 2,352 whole chunks over 225 structure sites, in all three dimensions, identical to vanilla | | Region files and saves | 0.75 | Saves round-trip with vanilla, and worlds from 1.18.2 to 26.2 upgrade as vanilla's DataFixers do over 22 sample worlds; worlds older than 1.18.2 are not upgraded | -| Entities and physics | 0.75 | Attributes and dimensions match; AI, loot and spawn rules not compared; drowned, villagers, traders and the wither unverified | +| Entities and physics | 0.75 | Attributes, dimensions, death loot, spawn rules and natural spawning (15,500 NaturalSpawner cases) match; AI not compared; drowned, villagers, traders and the wither unverified | | Interaction and inventory | 1 | None: menu clicks match over 6,000 click sequences | | Menus, HUD and chat | 0.75 | Commands parse as vanilla over 39,685 inputs, but /item modify, /spectate, /waypoint and /dialog do not run; no tab list or world border rendering | | Audio | 0.75 | Sound engine, music and ambience not compared with vanilla | @@ -49,7 +49,7 @@ round-trip harness has not been rerun. 1. Upgrade saves older than 1.18.2 (the DataFixers before DataVersion 2970). 2. Draw every item through the item model pipeline, and add the resource pack screen. 3. An audio harness: sound engine attenuation, weighted sound events, music and ambience. -4. Mob AI, loot and spawn-rule harnesses, and an oracle for drowned, villagers, traders and the wither. +4. A mob AI harness, and an oracle for drowned, villagers, traders and the wither. 5. The missing commands and dispenser behaviours, the tab list and world border rendering. 6. Test LAN hosting with a vanilla client, and add a dedicated server mode. diff --git a/crates/horizon-client/examples/natural_spawn_fixture.rs b/crates/horizon-client/examples/natural_spawn_fixture.rs new file mode 100644 index 000000000..b2f0a8900 --- /dev/null +++ b/crates/horizon-client/examples/natural_spawn_fixture.rs @@ -0,0 +1,688 @@ +//! Natural spawning decisions for differential tests against vanilla +//! (tools/test_natural_spawn_parity.py): reads the box-world cases of +//! `VanillaNaturalSpawnOracle` and prints what `mobs::spawn_selection` does in +//! the same order and format. +//! +//! The oracle runs `finalizeSpawn` with a fixed random (nextInt 0, nextFloat +//! 0.75); `neutral_box` is the box that leaves a mob with: polar bears, +//! ocelots and rabbits after the first of their group are babies (chance +//! 1.0), foxes and axolotls from the third, sulfur cubes have size 2, salmon +//! are small, slimes and magma cubes keep size 1, tropical fish school. +//! +//! usage: natural_spawn_fixture RESOURCES INPUT OUTPUT +use horizon_client::{ + assets::Assets, + biome::{Biomes, SpawnEntry}, + blocks::BlockFacts, + collisions::{self, LookupCollisions, no_collision_empty}, + items::Items, + mobs::{ + MobTypes, Species, + data::MobData, + spawn_rules::{Dimension, SpawnLevel}, + spawn_selection::{ + self, Category, Counted, Event, GenEvent, Heightmap, NaturalLevel, SpawnCost, + SpawnPlayer, SpawnRequest, SpawnState, Spawned, StructureAt, StructureSpawns, + spawn_box, + }, + species::{self, Baby}, + }, +}; +use horizon_core::{ + geometry::Aabb, + random::{LegacyRandom, RandomSource}, +}; +use horizon_world::{ + block_collision::BlockCollisionRegistry, chunk::BlockState, registry::BlockStateId, +}; +use serde_json::Value; +use std::{ + cell::RefCell, + collections::{BTreeMap, HashMap, HashSet}, + fmt::Write as _, + fs, + path::Path, +}; + +#[derive(Clone, Copy)] +struct Bx([i32; 6]); +impl Bx { + fn parse(s: &str) -> Self { + let v: Vec = s.split(',').map(|n| n.parse().unwrap_or(0)).collect(); + Self([v[0], v[1], v[2], v[3], v[4], v[5]]) + } + fn has(&self, x: i32, y: i32, z: i32) -> bool { + let b = self.0; + x >= b[0] && x <= b[3] && y >= b[1] && y <= b[4] && z >= b[2] && z <= b[5] + } +} +struct Start { + id: String, + pieces: Vec, + /// The start's bounding box (pieces encapsulated, inflated by 12 for + /// terrain adaptation). + bounds: Bx, +} + +struct Shared<'a> { + facts: &'a BlockFacts, + data: &'a MobData, + block_tags: &'a HashMap>, + types: &'a MobTypes, + biomes: &'a Biomes, + structures: &'a StructureSpawns, + collision: &'a BlockCollisionRegistry<'a>, + air: BlockStateId, +} +struct World<'a> { + s: &'a Shared<'a>, + blocks: Vec<(Bx, BlockStateId)>, + lights: Vec<(Bx, i32, i32)>, + biomes: Vec<(Bx, String)>, + biome: String, + sky: i32, + light: i32, + darken: i32, + thunder: bool, + sea: i32, + seed: i64, + peaceful: bool, + dimension: Dimension, + dimension_id: String, + slime: f32, + starts: Vec, + players: Vec<([f64; 3], String)>, + respawn: Option<[i32; 3]>, + unloaded: HashSet<(i32, i32)>, + spawn_mobs: bool, + /// Boxes of living entities (case entities, then spawned mobs). + entities: RefCell>, +} +impl World<'_> { + fn biome_at(&self, x: i32, y: i32, z: i32) -> &str { + self.biomes + .iter() + .rev() + .find(|(b, _)| b.has(x, y, z)) + .map_or(&self.biome, |(_, n)| n) + } + fn biome_info(&self, x: i32, y: i32, z: i32) -> Option<&horizon_client::biome::BiomeInfo> { + let i = *self.s.biomes.index.get(self.biome_at(x, y, z))?; + Some(self.s.biomes.get(i)) + } + fn lights_at(&self, x: i32, y: i32, z: i32) -> (i32, i32) { + self.lights + .iter() + .rev() + .find(|(b, ..)| b.has(x, y, z)) + .map_or((self.sky, self.light), |(_, s, b)| (*s, *b)) + } +} +impl SpawnLevel for World<'_> { + fn state(&self, x: i32, y: i32, z: i32) -> BlockStateId { + self.blocks + .iter() + .rev() + .find(|(b, _)| b.has(x, y, z)) + .map_or(self.s.air, |(_, s)| *s) + } + fn facts(&self) -> &BlockFacts { + self.s.facts + } + fn mob_data(&self) -> &MobData { + self.s.data + } + fn block_tag(&self, tag: &str, block: &str) -> bool { + self.s + .block_tags + .get(tag) + .is_some_and(|s| s.contains(block)) + } + fn biome_tag(&self, tag: &str, x: i32, y: i32, z: i32) -> bool { + self.s + .data + .biome_tags + .get(tag) + .is_some_and(|s| s.contains(self.biome_at(x, y, z))) + } + fn sky_light(&self, x: i32, y: i32, z: i32) -> i32 { + self.lights_at(x, y, z).0 + } + fn block_light(&self, x: i32, y: i32, z: i32) -> i32 { + self.lights_at(x, y, z).1 + } + fn sky_darken(&self) -> i32 { + self.darken + } + fn thundering(&self) -> bool { + self.thunder + } + fn sea_level(&self) -> i32 { + self.sea + } + fn world_surface(&self, x: i32, z: i32) -> i32 { + self.heightmap(Heightmap::WorldSurface, x, z) + } + fn peaceful(&self) -> bool { + self.peaceful + } + fn dimension(&self) -> Dimension { + self.dimension + } + fn surface_slime_chance(&self, _: i32, _: i32, _: i32) -> f32 { + self.slime + } + fn world_seed(&self) -> i64 { + self.seed + } + fn player_near(&self, x: f64, y: f64, z: f64, distance: f64) -> bool { + self.players.iter().any(|(p, mode)| { + let d = (p[0] - x).powi(2) + (p[1] - y).powi(2) + (p[2] - z).powi(2); + (mode == "survival" || mode == "adventure") && d < distance * distance + }) + } +} +impl NaturalLevel for World<'_> { + fn min_y(&self) -> i32 { + if self.dimension == Dimension::Overworld { + -64 + } else { + 0 + } + } + fn max_y(&self) -> i32 { + if self.dimension == Dimension::Overworld { + 319 + } else { + 255 + } + } + fn ambient_light(&self) -> f32 { + match self.dimension { + Dimension::Overworld => 0.0, + Dimension::Nether => 0.1, + Dimension::End => 0.25, + } + } + fn has_ceiling(&self) -> bool { + self.dimension == Dimension::Nether + } + fn mob_type(&self, entity: &str) -> Option<(Species, f32, f32, Category)> { + let t = self.s.types.types.get(entity)?; + Some(( + t.species, + t.width as f32, + t.height as f32, + Category::from_name(&t.category), + )) + } + fn biome_spawns(&self, x: i32, y: i32, z: i32) -> Option<&HashMap>> { + self.biome_info(x, y, z).map(|b| &b.spawns) + } + fn spawn_cost(&self, entity: &str, x: i32, y: i32, z: i32) -> Option { + self.biome_info(x, y, z)?.spawn_costs.get(entity).copied() + } + fn creature_probability(&self, x: i32, y: i32, z: i32) -> f32 { + self.biome_info(x, y, z) + .map_or(0.1, |b| b.creature_probability) + } + fn structures_at(&self, x: i32, y: i32, z: i32) -> Vec { + let (cx, cz) = (x >> 4, z >> 4); + let mut out: Vec = Vec::new(); + for start in &self.starts { + let b = start.bounds.0; + // Referenced: the start's box reaches the chunk's columns. + if b[3] < cx * 16 || b[0] > cx * 16 + 15 || b[5] < cz * 16 || b[2] > cz * 16 + 15 { + continue; + } + let in_piece = start.pieces.iter().any(|p| p.has(x, y, z)); + let in_bounds = start.bounds.has(x, y, z); + match out.iter_mut().find(|s| s.id == start.id) { + Some(s) => { + s.in_piece |= in_piece; + s.in_bounds |= in_bounds; + } + None => out.push(StructureAt { + id: start.id.clone(), + in_piece, + in_bounds, + }), + } + } + out + } + fn structure_spawns(&self) -> &StructureSpawns { + self.s.structures + } + fn nearest_player_distance_sqr(&self, x: f64, y: f64, z: f64) -> Option { + let mut best: Option = None; + for (p, mode) in &self.players { + if mode == "spectator" { + continue; + } + let d = [p[0] - x, p[1] - y, p[2] - z]; + let d = d[0] * d[0] + d[1] * d[1] + d[2] * d[2]; + if best.is_none_or(|b| d < b) { + best = Some(d); + } + } + best + } + fn respawn(&self) -> Option<[i32; 3]> { + self.respawn + } + fn can_spawn_in_chunk(&self, cx: i32, cz: i32) -> bool { + !self.unloaded.contains(&(cx, cz)) + } + fn no_collision(&self, bounds: Aabb) -> bool { + let lookup = |x: i32, y: i32, z: i32| self.state(x, y, z); + let mut world = LookupCollisions { + block: &lookup, + binding: self.s.collision, + }; + no_collision_empty(&mut world, bounds) + } + fn unobstructed(&self, bounds: Aabb) -> bool { + !self.entities.borrow().iter().any(|b| b.intersects(bounds)) + } + fn spawn_mobs(&self) -> bool { + self.spawn_mobs + } + fn heightmap(&self, kind: Heightmap, x: i32, z: i32) -> i32 { + (self.min_y()..=self.max_y()) + .rev() + .find(|&y| spawn_selection::heightmap_blocks(self, kind, x, y, z)) + .map_or(self.min_y(), |y| y + 1) + } + fn pathfindable_land(&self, x: i32, y: i32, z: i32) -> bool { + self.s.data.state(self.state(x, y, z)).flags & horizon_client::mobs::data::LAND != 0 + } +} + +/// The box a mob is left with by `finalizeSpawn` under the oracle's fixed random. +fn neutral_box(l: &World, r: &SpawnRequest) -> Aabb { + let t = &l.s.types.types[r.entity]; + let (mut w, mut h) = (t.width as f32, t.height as f32); + let baby = match r.species { + Species::PolarBear | Species::Ocelot | Species::Rabbit => r.group_index > 0, + Species::Fox | Species::Axolotl => r.group_index >= 2, + _ => false, + }; + if baby { + match species::def(r.species).baby { + Baby::Abs(bw, bh, _) => (w, h) = (bw as f32, bh as f32), + Baby::Scale(s, _) => (w, h) = (w * s as f32, h * s as f32), + Baby::None => {} + } + } + match r.species { + Species::SulfurCube => (w, h) = (w * 2.0, h * 2.0), + Species::Salmon => (w, h) = (w * 0.5, h * 0.5), + _ => {} + } + let [x, y, z] = r.position; + spawn_box(x, y, z, w, h) +} + +fn parse_state(spec: &str) -> BlockState { + let (name, props) = match spec.split_once('[') { + Some((n, rest)) => (n, rest.trim_end_matches(']')), + None => (spec, ""), + }; + BlockState { + name: name.to_owned(), + properties: props + .split(',') + .filter(|p| !p.is_empty()) + .filter_map(|p| p.split_once('=')) + .map(|(k, v)| (k.to_owned(), v.to_owned())) + .collect(), + } +} +fn bits(v: f64) -> String { + format!("{:x}", v.to_bits()) +} +/// The yaw as the oracle prints it: a cube mob grown by `finalizeSpawn` +/// (sulfur cubes, size 2 under the fixed random) takes its constructor's +/// head rotation from the entity's unseeded random. +fn yaw_text(entity: &str, v: f32) -> String { + if entity == "minecraft:sulfur_cube" { + "head".to_owned() + } else { + format!("{:x}", v.to_bits()) + } +} + +fn main() -> horizon_client::Result<()> { + let args: Vec = std::env::args().collect(); + let root = Path::new( + args.get(1) + .ok_or("usage: natural_spawn_fixture RESOURCES INPUT OUTPUT")?, + ); + let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?; + let assets = Assets::load(root)?; + let registry = assets.registry; + let facts = BlockFacts::load(root, registry)?; + let data = MobData::load(root, &facts); + let items = Items::tags_only(root)?; + let types = MobTypes::load(root)?; + let biomes = Biomes::load(root)?; + let structures = StructureSpawns::load(root); + let collision = collisions::load(root, registry)?; + let shared = Shared { + facts: &facts, + data: &data, + block_tags: &items.block_tags, + types: &types, + biomes: &biomes, + structures: &structures, + collision: &collision, + air: facts.air, + }; + let mut adaptation: HashMap = HashMap::new(); + let mut out = String::new(); + for raw in input.lines() { + if raw.trim().is_empty() { + continue; + } + let mut c = BTreeMap::new(); + let mut name = ""; + for token in raw.split_whitespace() { + match token.split_once('=') { + Some((k, v)) => { + c.insert(k, v); + } + None => name = token, + } + } + let get = + |k: &str, d: &'static str| -> String { c.get(k).copied().unwrap_or(d).to_owned() }; + let indexed = |prefix: &str| -> Vec<&str> { + (0..) + .map_while(|i| c.get(format!("{prefix}{i}").as_str()).copied()) + .collect() + }; + let mut blocks = Vec::new(); + for k in indexed("k") { + let (b, s) = k.split_once('/').ok_or("bad block box")?; + blocks.push((Bx::parse(b), registry.resolve(&parse_state(s))?)); + } + let lights = indexed("l") + .into_iter() + .filter_map(|l| { + let (b, v) = l.split_once('/')?; + let (s, k) = v.split_once(',')?; + Some((Bx::parse(b), s.parse().ok()?, k.parse().ok()?)) + }) + .collect(); + let biome_boxes = indexed("b") + .into_iter() + .filter_map(|l| { + let (b, n) = l.split_once('/')?; + Some((Bx::parse(b), n.to_owned())) + }) + .collect(); + let mut starts = Vec::new(); + for s in indexed("s") { + let (id, pieces) = s.split_once('/').ok_or("bad structure")?; + let pieces: Vec = pieces.split(';').map(Bx::parse).collect(); + let mut b = pieces[0].0; + for p in &pieces[1..] { + for i in 0..3 { + b[i] = b[i].min(p.0[i]); + b[i + 3] = b[i + 3].max(p.0[i + 3]); + } + } + let adapts = *adaptation.entry(id.to_owned()).or_insert_with(|| { + let path = root.join(format!( + "data/minecraft/worldgen/structure/{}.json", + id.trim_start_matches("minecraft:") + )); + fs::read(path) + .ok() + .and_then(|b| serde_json::from_slice::(&b).ok()) + .and_then(|v| v["terrain_adaptation"].as_str().map(|a| a != "none")) + .unwrap_or(false) + }); + if adapts { + for i in 0..3 { + b[i] -= 12; + b[i + 3] += 12; + } + } + starts.push(Start { + id: id.to_owned(), + pieces, + bounds: Bx(b), + }); + } + let players = indexed("p") + .into_iter() + .map(|p| { + let mut parts = p.split('/'); + let v: Vec = parts + .next() + .unwrap_or("") + .split(',') + .map(|n| n.parse().unwrap_or(0.0)) + .collect(); + ( + [v[0], v[1], v[2]], + parts.next().unwrap_or("survival").to_owned(), + ) + }) + .collect::>(); + let dimension_id = get("dim", "minecraft:overworld"); + let dimension = match dimension_id.as_str() { + "minecraft:the_nether" => Dimension::Nether, + "minecraft:the_end" => Dimension::End, + _ => Dimension::Overworld, + }; + let respawn = { + let r = get("respawn", "0,-1000,0/minecraft:overworld"); + let (p, d) = r.split_once('/').ok_or("bad respawn")?; + let v: Vec = p.split(',').map(|n| n.parse().unwrap_or(0)).collect(); + (d == dimension_id).then_some([v[0], v[1], v[2]]) + }; + let unloaded = c + .get("unloaded") + .map(|u| { + u.split(';') + .filter_map(|p| { + let (x, z) = p.split_once(',')?; + Some((x.parse().ok()?, z.parse().ok()?)) + }) + .collect() + }) + .unwrap_or_default(); + // Case entities: (type, position, persistent). + let mut case_entities = Vec::new(); + for e in indexed("e") { + let parts: Vec<&str> = e.split('/').collect(); + let v: Vec = parts[1] + .split(',') + .map(|n| n.parse().unwrap_or(0.0)) + .collect(); + // EntityType.create makes no monsters in peaceful. + if get("difficulty", "normal") == "peaceful" + && types.types.get(parts[0]).is_some_and(|t| { + !horizon_client::mobs::spawn_rules::allowed_in_peaceful(t.species) + }) + { + continue; + } + case_entities.push(( + parts[0].to_owned(), + [v[0], v[1], v[2]], + parts.get(2) == Some(&"p"), + )); + } + let world = World { + s: &shared, + blocks, + lights, + biomes: biome_boxes, + biome: get("biome", "minecraft:plains"), + sky: get("sky", "15").parse()?, + light: get("light", "0").parse()?, + darken: get("darken", "0").parse()?, + thunder: get("thunder", "0") == "1", + sea: get("sea", "63").parse()?, + seed: get("wseed", "0").parse()?, + peaceful: get("difficulty", "normal") == "peaceful", + dimension, + dimension_id: dimension_id.clone(), + slime: get("slime", "0.0").parse()?, + starts, + players, + respawn, + unloaded, + spawn_mobs: get("rules", "1") != "0", + entities: RefCell::new( + case_entities + .iter() + .filter_map(|(t, p, _)| { + let t = types.types.get(t)?; + Some(spawn_box(p[0], p[1], p[2], t.width as f32, t.height as f32)) + }) + .collect(), + ), + }; + let _ = &world.dimension_id; + let chunk = { + let v: Vec = get("chunk", "0,0") + .split(',') + .map(|n| n.parse().unwrap_or(0)) + .collect(); + (v[0], v[1]) + }; + let mut random = LegacyRandom::new(get("seed", "0").parse()?); + let mut log: Vec = Vec::new(); + let mut spawn = |l: &World, r: &SpawnRequest| -> Option { + let bounds = neutral_box(l, r); + l.entities.borrow_mut().push(bounds); + Some(Spawned { + bounds, + school: true, + }) + }; + let mode = get("mode", "chunk"); + if mode == "gen" { + let mut gen_random = LegacyRandom::new(get("gseed", "0").parse()?); + // The level random of the spawn predicates: no creature's predicate + // draws from it (the oracle's answers fixed values). + let mut rules = LegacyRandom::new(0); + let mut spawns = Vec::new(); + let source = [chunk.0 * 16, world.max_y(), chunk.1 * 16]; + spawn_selection::spawn_mobs_for_chunk_generation( + &world, + source, + chunk, + &mut gen_random, + &mut rules, + &mut spawn, + &mut |e| match e { + GenEvent::Column(x, z) => log.push(format!("col {x} {z}")), + GenEvent::Spawn(t, p, yaw) => spawns.push(format!( + "spawn {t} {} {} {} {}", + bits(p[0]), + bits(p[1]), + bits(p[2]), + yaw_text(&t, yaw) + )), + }, + ); + log.extend(spawns); + log.push(format!( + "next {}", + gen_random.next_int_bound(1000).unwrap_or(0) + )); + } else { + let counted: Vec = case_entities + .iter() + .filter_map(|(t, p, persistent)| { + let kind = types.types.get(t)?; + Some(Counted { + entity: t, + category: Category::from_name(&kind.category), + block: [ + p[0].floor() as i32, + p[1].floor() as i32, + p[2].floor() as i32, + ], + is_mob: true, + persistent: *persistent, + }) + }) + .collect(); + let players = world + .players + .iter() + .map(|(p, mode)| SpawnPlayer { + position: *p, + spectator: mode == "spectator", + }) + .collect(); + let mut state = SpawnState::new( + &world, + get("chunkcount", "289").parse()?, + players, + &counted, + |cx, cz| !world.unloaded.contains(&(cx, cz)), + ); + let mut trace = |e: Event| { + log.push(match e { + Event::Categories(list) => format!( + "cats {}", + list.iter().map(|c| c.name()).collect::>().join(",") + ), + Event::Local(c, ok) => format!("local {} {ok}", c.name()), + Event::Start(c, p) => format!("start {} {} {} {}", c.name(), p[0], p[1], p[2]), + Event::Try(x, z) => format!("try {x} {z}"), + Event::Test(t, p, ok) => format!("test {t} {} {} {} {ok}", p[0], p[1], p[2]), + Event::Spawn(t, p, yaw) => format!( + "spawn {t} {} {} {} {}", + bits(p[0]), + bits(p[1]), + bits(p[2]), + yaw_text(&t, yaw) + ), + }) + }; + if mode == "pos" { + let category = Category::from_name(&get("cat", "monster")); + let v: Vec = get("start", "0,0,0") + .split(',') + .map(|n| n.parse().unwrap_or(0)) + .collect(); + spawn_selection::spawn_category_for_position( + &world, + &mut random, + &mut state, + category, + chunk, + [v[0], v[1], v[2]], + &mut spawn, + &mut trace, + ); + } else { + let categories = state + .filtered_categories(get("enemies", "1") == "1", get("persistent", "1") == "1"); + trace(Event::Categories(categories.clone())); + spawn_selection::spawn_for_chunk( + &world, + &mut random, + &mut state, + &categories, + chunk, + &mut spawn, + &mut trace, + ); + } + log.push(format!("next {}", random.next_int_bound(1000).unwrap_or(0))); + } + let _ = writeln!(out, "{name} {}", log.join(" ; ")); + } + fs::write(args.get(3).ok_or("missing OUTPUT")?, out)?; + Ok(()) +} diff --git a/crates/horizon-client/src/biome.rs b/crates/horizon-client/src/biome.rs index 1be7a4ce9..e0c6900cf 100644 --- a/crates/horizon-client/src/biome.rs +++ b/crates/horizon-client/src/biome.rs @@ -17,11 +17,16 @@ pub struct SpawnEntry { pub weight: u32, pub min: u32, pub max: u32, + /// A `ConstantInt` count (sampling draws nothing). + pub constant: bool, } #[derive(Clone, Debug)] pub struct BiomeInfo { /// `natural_mob_spawns` by category (creature, monster, ...). pub spawns: HashMap>, + /// `natural_mob_spawns` spawn costs by entity type. + pub spawn_costs: HashMap, + /// The `gameplay/creature_world_gen_spawn_probability` attribute. pub creature_probability: f32, pub name: String, pub temperature: f32, @@ -103,37 +108,26 @@ impl Biomes { _ => GrassModifier::None, }; let mut spawns: HashMap> = HashMap::new(); - let categories = &attributes["minecraft:gameplay/natural_mob_spawns"]["argument"]["spawns_by_category"]; + let natural = &attributes["minecraft:gameplay/natural_mob_spawns"]; + // A modifier object ({argument, modifier: overlay}) or the plain value; + // the overlay over the dimension's empty default is the biome's value. + let natural = if natural["argument"].is_object() { + &natural["argument"] + } else { + natural + }; + let categories = &natural["spawns_by_category"]; for (category, entries) in categories.as_object().into_iter().flatten() { - let list = entries - .as_array() - .into_iter() - .flatten() - .filter_map(|e| { - let (min, max) = match &e["count"] { - Value::Number(n) => { - let n = n.as_u64().unwrap_or(1) as u32; - (n, n) - } - c => ( - c["min_inclusive"].as_u64().unwrap_or(1) as u32, - c["max_inclusive"].as_u64().unwrap_or(1) as u32, - ), - }; - Some(SpawnEntry { - entity: e["type"].as_str()?.to_owned(), - weight: e["weight"].as_u64().unwrap_or(1) as u32, - min, - max, - }) - }) - .collect(); + let list = crate::mobs::spawn_selection::parse_entries(entries); spawns.insert(category.clone(), list); } list.push(BiomeInfo { spawns, - creature_probability: v["creature_spawn_probability"].as_f64().unwrap_or(0.1) - as f32, + spawn_costs: crate::mobs::spawn_selection::parse_costs(&natural["spawn_costs"]), + creature_probability: + attributes["minecraft:gameplay/creature_world_gen_spawn_probability"] + .as_f64() + .unwrap_or(0.1) as f32, temperature, downfall, precipitation: v["has_precipitation"].as_bool().unwrap_or(true), diff --git a/crates/horizon-client/src/collisions.rs b/crates/horizon-client/src/collisions.rs index 6b06c5384..47b5d7981 100644 --- a/crates/horizon-client/src/collisions.rs +++ b/crates/horizon-client/src/collisions.rs @@ -78,6 +78,23 @@ impl SceneCollisions<'_, '_> { )?) } } +/// `noCollision(null, box)`: block collisions under the empty collision +/// context, as entity-less queries such as spawn checks use. +pub fn no_collision_empty(world: &mut W, bounds: Aabb) -> bool +where + W: BlockCollisionWorld + EntityMovementWorld, +{ + no_world_collision( + world, + CollisionCheckRequest { + entity: EntityIdentity(0), + context: CollisionContext::empty(false), + bounds, + candidate_budget: 1_000_000, + }, + ) + .unwrap_or(false) +} impl BlockCollisionWorld for SceneCollisions<'_, '_> { type Error = io::Error; type Chunk = ChunkPos; @@ -166,3 +183,91 @@ impl PowderSnowWorld for SceneCollisions<'_, '_> { Ok(false) } } + +/// Collision queries over any block lookup (test worlds without chunks): +/// every chunk is present, nothing is suffocating, no entities collide. +pub struct LookupCollisions<'w, 'r> { + pub block: &'w dyn Fn(i32, i32, i32) -> horizon_world::registry::BlockStateId, + pub binding: &'w BlockCollisionRegistry<'r>, +} +impl BlockCollisionWorld for LookupCollisions<'_, '_> { + type Error = io::Error; + type Chunk = ChunkPos; + fn collision_chunk(&mut self, p: ChunkPos) -> io::Result> { + Ok(Some(p)) + } + fn collision_block(&mut self, _: ChunkPos, p: BlockPos) -> io::Result { + self.binding.collision_block((self.block)(p.x, p.y, p.z)) + } + fn is_suffocating(&mut self, _: ChunkPos, _: CollisionBlock, _: BlockPos) -> io::Result { + Ok(false) + } + fn context_shape( + &mut self, + block: CollisionBlock, + p: BlockPos, + context: &CollisionContext<'_>, + ) -> io::Result { + self.binding + .collision_shape_with_context(self, block.state, p, context) + .map_err(io::Error::other) + } +} +impl EntityMovementWorld for LookupCollisions<'_, '_> { + type Error = io::Error; + fn entity_collisions(&mut self, _: EntityIdentity, _: Aabb) -> io::Result> { + Ok(Vec::new()) + } + fn movement_border(&mut self, _: EntityIdentity, _: Aabb) -> io::Result> { + Ok(None) + } +} +impl DynamicSupportWorld for LookupCollisions<'_, '_> { + type Error = io::Error; + fn shulker_is_closed(&mut self, _: [i32; 3]) -> io::Result> { + Ok(None) + } + fn moving_piston_shape(&mut self, _: [i32; 3]) -> io::Result> { + Ok(None) + } +} +impl DynamicCollisionWorld for LookupCollisions<'_, '_> { + fn shulker_collision_state( + &mut self, + _: [i32; 3], + ) -> io::Result> { + Ok(None) + } +} +impl LiquidCollisionWorld for LookupCollisions<'_, '_> { + type Error = io::Error; + fn entity_liquid_shape(&mut self, _: EntityIdentity) -> io::Result { + Ok(VoxelShape::empty()) + } + fn fluid(&mut self, p: BlockPos) -> io::Result { + FluidState::from_block( + self.binding + .registry() + .state((self.block)(p.x, p.y, p.z)) + .ok_or_else(|| io::Error::other("fluid state outside registry"))?, + ) + } + fn entity_can_stand(&mut self, _: EntityIdentity, _: FluidState) -> io::Result { + Ok(false) + } +} +impl PowderSnowWorld for LookupCollisions<'_, '_> { + type Error = io::Error; + fn powder_snow_entity(&mut self, _: EntityIdentity) -> io::Result { + Ok(PowderSnowEntity { + fall_distance: 0.0, + falling_block: false, + }) + } + fn powder_snow_walkable_tag(&mut self, _: EntityIdentity) -> io::Result { + Ok(false) + } + fn feet_are_leather_boots(&mut self, _: EntityIdentity) -> io::Result { + Ok(false) + } +} diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 0c13caf7f..62c9f30eb 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -52,6 +52,7 @@ mod mount_menu; mod mounts; mod music; mod names; +mod natural_spawning; mod nautili; mod options; mod packs; @@ -755,9 +756,6 @@ struct Game<'a> { adv_ui: advancements_screen::AdvUi, camera_type: perspective::CameraType, fangs: fangs::FangState, - /// Structure `spawn_overrides`: structure -> category -> (piece only, spawns). - spawn_overrides: - HashMap)>>, /// The local player as a model (third person, the inventory). player_model: Option, } @@ -8133,7 +8131,6 @@ pub fn run() -> Result<()> { adv_ui: Default::default(), camera_type: Default::default(), fangs: Default::default(), - spawn_overrides: HashMap::new(), player_model: None, }; game.ui.vanilla_root = vanilla_root; diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 50d0d1aab..0d7e34572 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -37,17 +37,6 @@ pub(super) struct Creatures { /// The wandering trader spawner's clock. pub trader: super::wandering_trader::Spawner, } -/// MobCategory caps (maxInstancesPerChunk with the 17x17 spawnable chunk area). -const CATEGORIES: [(&str, usize); 7] = [ - ("monster", 70), - ("creature", 10), - ("ambient", 15), - ("axolotls", 5), - ("underground_water_creature", 5), - ("water_creature", 5), - ("water_ambient", 20), -]; - struct ProjWorld<'a, 'w> { w: &'a MobWorld<'w>, targets: Vec<(Who, Aabb)>, @@ -355,99 +344,6 @@ impl Game<'_> { } horizon_client::chunk::MIN_Y } - fn weighted_entry( - &mut self, - entries: &[horizon_client::biome::SpawnEntry], - ) -> Option { - let total: u32 = entries.iter().map(|e| e.weight).sum(); - if total == 0 { - return None; - } - let mut r = self.world.random.next_int_bound(total as i32).ok()? as u32; - for e in entries { - if r < e.weight { - return Some(e.clone()); - } - r -= e.weight; - } - None - } - /// NaturalSpawner.spawnMobsForChunkGeneration (creature packs at generation). - pub(super) fn spawn_generation_creatures(&mut self, cx: i32, cz: i32) { - let Some(chunk) = self.world.chunks.get(&(cx, cz)).cloned() else { - return; - }; - let biome = self.res.biomes.get(chunk.biome(8, 64, 8)).clone(); - let Some(entries) = biome.spawns.get("creature").cloned() else { - return; - }; - let (xo, zo) = (cx * 16, cz * 16); - while self.world.random.next_float() < biome.creature_probability { - let Some(entry) = self.weighted_entry(&entries) else { - return; - }; - let count = entry.min - + self - .world - .random - .next_int_bound((entry.max - entry.min + 1) as i32) - .unwrap_or(0) as u32; - let mut x = xo + self.world.random.next_int_bound(16).unwrap_or(0); - let mut z = zo + self.world.random.next_int_bound(16).unwrap_or(0); - let (sx, sz) = (x, z); - let Some(kind) = self.res.mob_types.get(&entry.entity).cloned() else { - continue; - }; - for _ in 0..count { - let mut success = false; - let mut attempts = 0; - while !success && attempts < 4 { - attempts += 1; - let mut y = self.motion_top(x, z); - let placement = self.res.mob_data.placement(&entry.entity); - if placement == horizon_client::mobs::data::Placement::OnGround { - let below = self.world.block(x, y - 1, z); - if self.res.mob_data.state(below).flags & horizon_client::mobs::data::LAND - != 0 - { - y -= 1; - } - } - let ok = { - let w = self.mob_world_base(&[]); - spawning::placement_ok(kind.species, &w, x, y, z) && { - let mut r = self.world.random.clone(); - - spawning::spawn_rules_ok(kind.species, &w, &mut r, x, y, z) - } - }; - if ok { - let width = kind.width; - let fx = - f64::from(x).clamp(f64::from(xo) + width, f64::from(xo) + 16.0 - width); - let fz = - f64::from(z).clamp(f64::from(zo) + width, f64::from(zo) + 16.0 - width); - if self - .spawn_mob(&entry.entity, [fx, f64::from(y), fz], true) - .is_some() - { - success = true; - } - } - x += self.world.random.next_int_bound(5).unwrap_or(0) - - self.world.random.next_int_bound(5).unwrap_or(0); - z += self.world.random.next_int_bound(5).unwrap_or(0) - - self.world.random.next_int_bound(5).unwrap_or(0); - while x < xo || x >= xo + 16 || z < zo || z >= zo + 16 { - x = sx + self.world.random.next_int_bound(5).unwrap_or(0) - - self.world.random.next_int_bound(5).unwrap_or(0); - z = sz + self.world.random.next_int_bound(5).unwrap_or(0) - - self.world.random.next_int_bound(5).unwrap_or(0); - } - } - } - } - } pub(super) fn mob_world_base<'s>(&'s self, others: &'s [Seen]) -> MobWorld<'s> { let (rain, thunder) = self.world.weather.levels(1.0); MobWorld { @@ -516,171 +412,6 @@ impl Game<'_> { } } } - /// NaturalSpawner.spawnForChunk over the chunks within 8 of the player. - fn spawn_natural(&mut self) { - let Some(p) = self.player.as_ref().map(|p| p.position) else { - return; - }; - let (pcx, pcz) = (p.x.floor() as i32 >> 4, p.z.floor() as i32 >> 4); - let mut counts = [0usize; 7]; - for m in &self.mobs { - if let Some(i) = CATEGORIES.iter().position(|c| c.0 == m.kind.category) { - counts[i] += 1; - } - } - let creature_tick = self.world.game_time % 400 == 0; - let mut chunks: Vec<(i32, i32)> = (-8..=8) - .flat_map(|dx| (-8..=8).map(move |dz| (pcx + dx, pcz + dz))) - .filter(|c| self.world.chunks.contains_key(c)) - .collect(); - // ChunkMap: shuffled each tick. - for i in (1..chunks.len()).rev() { - let j = self.world.random.next_int_bound(i as i32 + 1).unwrap_or(0) as usize; - chunks.swap(i, j); - } - // ServerChunkCache.tickChunks: the chunks are shuffled every tick, but - // no category spawns without spawn_mobs, and only friendly ones - // unless the level is spawning monsters (ServerLevel.isSpawningMonsters). - let spawn_mobs = self.ui.rules.bool("spawn_mobs"); - let spawn_enemies = spawn_mobs && self.ui.rules.bool("spawn_monsters"); - for (cx, cz) in chunks { - for (ci, (category, cap)) in CATEGORIES.iter().enumerate() { - if !spawn_mobs - || counts[ci] >= *cap - || *category == "creature" && !creature_tick - || *category == "monster" && !spawn_enemies - { - continue; - } - counts[ci] += self.spawn_category_for_chunk(category, cx, cz, p); - } - } - } - /// NaturalSpawner.spawnCategoryForPosition at a random point in the chunk. - fn spawn_category_for_chunk( - &mut self, - category: &str, - cx: i32, - cz: i32, - player: Vec3, - ) -> usize { - let x0 = cx * 16 + self.world.random.next_int_bound(16).unwrap_or(0); - let z0 = cz * 16 + self.world.random.next_int_bound(16).unwrap_or(0); - let top = self.motion_top(x0, z0) + 1; - let min = horizon_client::chunk::MIN_Y; - let y = min + self.world.random.next_int_bound(top - min + 1).unwrap_or(0); - if y < min + 1 { - return 0; - } - let start = self.res.facts.get(self.world.block(x0, y, z0)); - if start.full_collision { - return 0; - } - let mut spawned = 0; - for _ in 0..3 { - let (mut x, mut z) = (x0, z0); - let mut entry: Option = None; - let mut group = (self.world.random.next_float() * 4.0).ceil() as u32; - let mut cluster = 0; - let mut i = 0; - while i < group { - i += 1; - x += self.world.random.next_int_bound(6).unwrap_or(0) - - self.world.random.next_int_bound(6).unwrap_or(0); - z += self.world.random.next_int_bound(6).unwrap_or(0) - - self.world.random.next_int_bound(6).unwrap_or(0); - let (fx, fz) = (f64::from(x) + 0.5, f64::from(z) + 0.5); - let d2 = (fx - player.x).powi(2) - + (f64::from(y) - player.y).powi(2) - + (fz - player.z).powi(2); - if d2 <= 576.0 { - continue; - } - if entry.is_none() { - let Some(chunk) = self.world.chunks.get(&(x >> 4, z >> 4)) else { - break; - }; - let biome = self.res.biomes.get(chunk.biome(x, y, z)).clone(); - let list = match self.structure_spawns(category, [x, y, z]) { - Some(list) => list, - None => match biome.spawns.get(category).cloned() { - Some(list) => list, - None => break, - }, - }; - let list: Vec<_> = list - .into_iter() - .filter(|e| self.res.mob_types.contains_key(&e.entity)) - .collect(); - let Some(e) = self.weighted_entry(&list) else { - break; - }; - group = e.min - + self - .world - .random - .next_int_bound((e.max - e.min + 1) as i32) - .unwrap_or(0) as u32; - entry = Some(e); - } - let e = entry.clone().unwrap_or_else(|| unreachable!()); - let despawn = if category == "water_ambient" { - 64.0 - } else { - 128.0 - }; - if d2 > despawn * despawn { - continue; - } - let Some(kind) = self.res.mob_types.get(&e.entity).cloned() else { - break; - }; - let ok = { - let w = self.mob_world_base(&[]); - let mut r = self.world.random.clone(); - let placed = spawning::placement_ok(kind.species, &w, x, y, z) - && spawning::spawn_rules_ok(kind.species, &w, &mut r, x, y, z); - let half = kind.width / 2.0; - let room = w.no_collision(Aabb::new( - Vec3::new(fx - half, f64::from(y), fz - half), - Vec3::new(fx + half, f64::from(y) + kind.height, fz + half), - )); - // PathfinderMob.checkSpawnRules: walk target value >= 0 (monsters need darkness). - let light_ok = !kind.category.eq("monster") - || matches!( - kind.species, - Species::Slime - | Species::SulfurCube - | Species::Ghast - | Species::MagmaCube - | Species::Guardian - | Species::ElderGuardian - ) - || w.brightness(x, y, z) <= 0.5; - let consumed = r; - (placed && room && light_ok, consumed) - }; - self.world.random = ok.1; - if !ok.0 { - continue; - } - if d2 > 16384.0 && kind.category != "creature" { - continue; - } - if self - .spawn_mob(&e.entity, [fx, f64::from(y), fz], false) - .is_some() - { - cluster += 1; - spawned += 1; - if spawned >= 4 || cluster >= group.max(1) { - return spawned; - } - } - } - } - spawned - } /// Advance every mob one tick, then apply their events. pub(super) fn tick_creatures(&mut self) -> Result<()> { self.attack_ticks = self.attack_ticks.saturating_add(1); diff --git a/crates/horizon-client/src/game/natural_spawning.rs b/crates/horizon-client/src/game/natural_spawning.rs new file mode 100644 index 000000000..7d07f148b --- /dev/null +++ b/crates/horizon-client/src/game/natural_spawning.rs @@ -0,0 +1,355 @@ +//! The game's side of `NaturalSpawner` (`mobs::spawn_selection`): the level +//! it reads, the spawning chunks and caps of `ServerChunkCache.tickChunks`, +//! and the mobs it adds (finalized with the game's `finalizeSpawn`). +use super::{Dim, Game}; +use horizon_client::{ + biome::{BiomeInfo, SpawnEntry}, + blocks::BlockFacts, + collisions::{SceneCollisions, no_collision_empty}, + mobs::{ + Mob, MobWorld, Species, + data::{self, MobData}, + spawn_rules::{Dimension, SpawnLevel}, + spawn_selection::{ + self, Category, Counted, Heightmap, NaturalLevel, SpawnCost, SpawnPlayer, SpawnRequest, + SpawnState, Spawned, StructureAt, StructureSpawns, + }, + }, +}; +use horizon_core::{ + geometry::{Aabb, Vec3}, + random::{LegacyRandom, RandomSource, WorldgenRandom}, +}; +use horizon_world::registry::BlockStateId; +use std::collections::HashMap; + +/// The game level as natural spawning reads it. +struct GameLevel<'g, 'a> { + game: &'g Game<'a>, + w: MobWorld<'g>, + /// Boxes of the mobs this pass created, not yet in the game's list. + pending: std::cell::RefCell>, +} +impl GameLevel<'_, '_> { + fn biome(&self, x: i32, y: i32, z: i32) -> Option<&BiomeInfo> { + let chunk = self.game.world.chunks.get(&(x >> 4, z >> 4))?; + Some(self.game.res.biomes.get(chunk.biome(x, y, z))) + } +} +impl SpawnLevel for GameLevel<'_, '_> { + fn state(&self, x: i32, y: i32, z: i32) -> BlockStateId { + self.w.state(x, y, z) + } + fn facts(&self) -> &BlockFacts { + self.w.facts + } + fn mob_data(&self) -> &MobData { + self.w.data + } + fn block_tag(&self, tag: &str, block: &str) -> bool { + self.w.block_tag(tag, block) + } + fn biome_tag(&self, tag: &str, x: i32, y: i32, z: i32) -> bool { + self.w.biome_tag(tag, x, y, z) + } + fn sky_light(&self, x: i32, y: i32, z: i32) -> i32 { + self.w.sky_light(x, y, z) + } + fn block_light(&self, x: i32, y: i32, z: i32) -> i32 { + self.w.block_light(x, y, z) + } + fn sky_darken(&self) -> i32 { + SpawnLevel::sky_darken(&self.w) + } + fn thundering(&self) -> bool { + self.w.thundering + } + fn sea_level(&self) -> i32 { + self.w.sea_level() + } + fn world_surface(&self, x: i32, z: i32) -> i32 { + self.w.world_surface(x, z) + } + fn peaceful(&self) -> bool { + self.w.peaceful() + } + fn dimension(&self) -> Dimension { + self.w.dimension() + } + fn surface_slime_chance(&self, x: i32, y: i32, z: i32) -> f32 { + self.w.surface_slime_chance(x, y, z) + } + fn world_seed(&self) -> i64 { + self.w.world_seed + } + fn player_near(&self, x: f64, y: f64, z: f64, distance: f64) -> bool { + self.w.player_near(x, y, z, distance) + } +} +impl NaturalLevel for GameLevel<'_, '_> { + fn min_y(&self) -> i32 { + horizon_client::chunk::MIN_Y + } + fn max_y(&self) -> i32 { + horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT - 1 + } + fn ambient_light(&self) -> f32 { + match self.game.dim { + Dim::Nether => 0.1, + Dim::End => 0.25, + _ => 0.0, + } + } + fn has_ceiling(&self) -> bool { + self.game.dim == Dim::Nether + } + fn mob_type(&self, entity: &str) -> Option<(Species, f32, f32, Category)> { + let t = self.game.res.mob_types.get(entity)?; + Some(( + t.species, + t.width as f32, + t.height as f32, + Category::from_name(&t.category), + )) + } + fn biome_spawns(&self, x: i32, y: i32, z: i32) -> Option<&HashMap>> { + self.biome(x, y, z).map(|b| &b.spawns) + } + fn spawn_cost(&self, entity: &str, x: i32, y: i32, z: i32) -> Option { + self.biome(x, y, z)?.spawn_costs.get(entity).copied() + } + fn creature_probability(&self, x: i32, y: i32, z: i32) -> f32 { + self.biome(x, y, z).map_or(0.1, |b| b.creature_probability) + } + fn structures_at(&self, x: i32, y: i32, z: i32) -> Vec { + self.game + .structures_reaching([x, y, z]) + .into_iter() + .map(|(id, in_piece)| StructureAt { + id, + in_piece, + in_bounds: true, + }) + .collect() + } + fn structure_spawns(&self) -> &StructureSpawns { + StructureSpawns::shared(&self.game.options.resources) + } + fn nearest_player_distance_sqr(&self, x: f64, y: f64, z: f64) -> Option { + let p = self.game.player.as_ref()?; + if self.game.spectator() { + return None; + } + let d = [p.position.x - x, p.position.y - y, p.position.z - z]; + Some(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]) + } + fn respawn(&self) -> Option<[i32; 3]> { + // The world spawn point lives in the overworld. + if self.game.dim != Dim::Overworld { + return None; + } + self.game.level.spawn.map(|s| { + [ + s[0].floor() as i32, + s[1].floor() as i32, + s[2].floor() as i32, + ] + }) + } + fn can_spawn_in_chunk(&self, cx: i32, cz: i32) -> bool { + self.game.world.chunks.contains_key(&(cx, cz)) + } + fn no_collision(&self, bounds: Aabb) -> bool { + let mut scene = SceneCollisions { + world: &self.game.world, + binding: self.w.collision, + }; + no_collision_empty(&mut scene, bounds) + } + fn unobstructed(&self, bounds: Aabb) -> bool { + let player = self + .game + .player_view() + .is_some_and(|p| p.alive && !p.spectator && p.bounds.intersects(bounds)); + !player + && !self.pending.borrow().iter().any(|b| b.intersects(bounds)) + && !self + .game + .mobs + .iter() + .any(|m| m.alive() && !m.removed && m.bounds().intersects(bounds)) + } + fn spawn_mobs(&self) -> bool { + self.game.ui.rules.bool("spawn_mobs") + } + fn heightmap(&self, kind: Heightmap, x: i32, z: i32) -> i32 { + let top = horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT; + (horizon_client::chunk::MIN_Y..top) + .rev() + .find(|&y| spawn_selection::heightmap_blocks(self, kind, x, y, z)) + .map_or(horizon_client::chunk::MIN_Y, |y| y + 1) + } + fn pathfindable_land(&self, x: i32, y: i32, z: i32) -> bool { + self.w.data.state(self.state(x, y, z)).flags & data::LAND != 0 + } +} +impl<'a> Game<'a> { + fn natural_level(&self) -> GameLevel<'_, 'a> { + GameLevel { + game: self, + w: self.mob_world_base(&[]), + pending: Default::default(), + } + } + /// Creates a mob the spawner accepted, finalized the game's way; the + /// caller adds it with [`Game::add_spawned`]. + fn finalize_spawned(&self, r: &SpawnRequest, pending: &mut Vec) -> Option { + let kind = self.res.mob_types.get(r.entity)?.clone(); + let [x, y, z] = r.position; + let mut mob = Mob::new(0, kind, Vec3::new(x, y, z), r.yaw); + let facts = self.biome_facts(x.floor() as i32, y.floor() as i32, z.floor() as i32); + let difficulty = self.local_difficulty(r.position); + horizon_client::mobs::spawning::finalize(&mut mob, &facts, &difficulty); + horizon_client::mobs::raider::finalize( + &mut mob, + horizon_client::mobs::raider::Reason::Natural, + ); + let bounds = mob.bounds(); + pending.push(mob); + Some(Spawned { + bounds, + school: true, + }) + } + /// Adds the mobs a spawn pass created: ids, enchanted gear and jockeys. + fn add_spawned(&mut self, pending: Vec, persistent: bool) { + for mut mob in pending { + self.next_entity += 1; + mob.id = self.next_entity; + mob.persistent = persistent; + let id = mob.id; + self.mobs.push(mob); + self.enchant_spawn_equipment(id); + if !persistent { + self.spawn_jockeys(id, true); + } + } + } + /// `ServerChunkCache.tickChunks`: the spawn state over the loaded mobs, + /// the categories under their global caps (creatures every 400 ticks), + /// then `spawnForChunk` in the shuffled chunks within 128 blocks of the + /// player. + pub(super) fn spawn_natural(&mut self) { + let Some(p) = self.player.as_ref().map(|p| p.position) else { + return; + }; + let (pcx, pcz) = (p.x.floor() as i32 >> 4, p.z.floor() as i32 >> 4); + // ChunkMap.collectSpawningChunks: tracked chunks whose centre is + // within 128 blocks of a non-spectator. + let spectator = self.spectator(); + let mut chunks: Vec<(i32, i32)> = (-8..=8) + .flat_map(|dx| (-8..=8).map(move |dz| (pcx + dx, pcz + dz))) + .filter(|&(cx, cz)| { + let (x, z) = (f64::from(cx * 16 + 8) - p.x, f64::from(cz * 16 + 8) - p.z); + !spectator && x * x + z * z < 16384.0 && self.world.chunks.contains_key(&(cx, cz)) + }) + .collect(); + let spawn_mobs = self.ui.rules.bool("spawn_mobs"); + let spawn_enemies = self.ui.rules.bool("spawn_monsters"); + let mut random = std::mem::replace(&mut self.world.random, LegacyRandom::new(0)); + let mut pending = Vec::new(); + { + let level = self.natural_level(); + let counted: Vec = self + .mobs + .iter() + .map(|m| Counted { + entity: &m.kind.id, + category: Category::from_name(&m.kind.category), + block: [ + m.position.x.floor() as i32, + m.position.y.floor() as i32, + m.position.z.floor() as i32, + ], + is_mob: true, + persistent: m.persistent, + }) + .collect(); + let players = vec![SpawnPlayer { + position: [p.x, p.y, p.z], + spectator, + }]; + // A single player's natural spawn chunk tracker covers 17 x 17 chunks. + let mut state = SpawnState::new(&level, 289, players, &counted, |cx, cz| { + self.world.chunks.contains_key(&(cx, cz)) + }); + let categories = if spawn_mobs { + state.filtered_categories(spawn_enemies, self.world.game_time % 400 == 0) + } else { + Vec::new() + }; + // Util.shuffle. + for i in (1..chunks.len()).rev() { + let j = random.next_int_bound(i as i32 + 1).unwrap_or(0) as usize; + chunks.swap(i, j); + } + if !categories.is_empty() { + for chunk in chunks { + let mut spawn = |l: &GameLevel, r: &SpawnRequest| { + let spawned = l.game.finalize_spawned(r, &mut pending); + if let Some(s) = &spawned { + l.pending.borrow_mut().push(s.bounds); + } + spawned + }; + spawn_selection::spawn_for_chunk( + &level, + &mut random, + &mut state, + &categories, + chunk, + &mut spawn, + &mut |_| {}, + ); + } + } + } + self.world.random = random; + self.add_spawned(pending, false); + } + /// `NoiseBasedChunkGenerator.spawnOriginalMobs`: creature packs for a + /// freshly generated chunk, from the biome at its corner column's top, + /// with the chunk's decoration-seeded random (mobs are not generated in + /// the End). + pub(super) fn spawn_generation_creatures(&mut self, cx: i32, cz: i32) { + if !self.world.chunks.contains_key(&(cx, cz)) || self.dim == Dim::End { + return; + } + let mut random = WorldgenRandom::new(LegacyRandom::new(0)); + random.set_decoration_seed(self.level.seed, cx * 16, cz * 16); + // The region's own random: no creature's spawn predicate draws from it. + let mut rules = LegacyRandom::new(self.level.seed ^ (i64::from(cx) << 32 | i64::from(cz))); + let mut pending = Vec::new(); + { + let level = self.natural_level(); + let source = [cx * 16, level.max_y(), cz * 16]; + let mut spawn = |l: &GameLevel, r: &SpawnRequest| { + let spawned = l.game.finalize_spawned(r, &mut pending); + if let Some(s) = &spawned { + l.pending.borrow_mut().push(s.bounds); + } + spawned + }; + spawn_selection::spawn_mobs_for_chunk_generation( + &level, + source, + (cx, cz), + &mut random, + &mut rules, + &mut spawn, + &mut |_| {}, + ); + } + self.add_spawned(pending, true); + } +} diff --git a/crates/horizon-client/src/game/structures.rs b/crates/horizon-client/src/game/structures.rs index 116f2100e..e941f2fd9 100644 --- a/crates/horizon-client/src/game/structures.rs +++ b/crates/horizon-client/src/game/structures.rs @@ -6,7 +6,7 @@ use super::Game; impl Game<'_> { /// Structures reaching `pos`: (id, inside a piece). `getStructureAt` /// tests the start's whole box, `getStructureWithPieceAt` a piece's. - fn structures_reaching(&self, pos: [i32; 3]) -> Vec<(String, bool)> { + pub(super) fn structures_reaching(&self, pos: [i32; 3]) -> Vec<(String, bool)> { let Some(chunk) = self.world.chunks.get(&(pos[0] >> 4, pos[2] >> 4)) else { return Vec::new(); }; @@ -39,83 +39,4 @@ impl Game<'_> { .map(|(n, _)| n) .collect() } - /// `ChunkGenerator.getMobsAt` / `NaturalSpawner.mobsAt`: a structure's - /// `spawn_overrides` for the category replace the biome's list where its - /// box (or piece) holds `pos`; fortress spawns on nether bricks. - pub(super) fn structure_spawns( - &mut self, - category: &str, - pos: [i32; 3], - ) -> Option> { - let reaching = self.structures_reaching(pos); - if reaching.is_empty() { - return None; - } - let below = self.world.block(pos[0], pos[1] - 1, pos[2]); - let bricks = &*self.res.facts.get(below).name == "minecraft:nether_bricks"; - for (id, piece) in &reaching { - let Some(o) = self.spawn_override(id, category) else { - continue; - }; - if !o.0 || *piece { - return Some(o.1); - } - } - if category == "monster" - && bricks - && reaching.iter().any(|(n, _)| n == "minecraft:fortress") - { - return self - .spawn_override("minecraft:fortress", "monster") - .map(|o| o.1); - } - None - } - /// A structure's override for a category: (piece-only, spawns). - fn spawn_override( - &mut self, - structure: &str, - category: &str, - ) -> Option<(bool, Vec)> { - if !self.spawn_overrides.contains_key(structure) { - let (ns, path) = structure - .split_once(':') - .unwrap_or(("minecraft", structure)); - let file = self - .options - .resources - .join(format!("data/{ns}/worldgen/structure/{path}.json")); - let v: serde_json::Value = std::fs::read(file) - .ok() - .and_then(|b| serde_json::from_slice(&b).ok()) - .unwrap_or_default(); - let mut map = std::collections::HashMap::new(); - for (cat, o) in v["spawn_overrides"].as_object().into_iter().flatten() { - let piece = o["bounding_box"].as_str() == Some("piece"); - let spawns = o["spawns"] - .as_array() - .into_iter() - .flatten() - .map(|e| { - let count = &e["count"]; - horizon_client::biome::SpawnEntry { - entity: e["type"].as_str().unwrap_or_default().to_owned(), - weight: e["weight"].as_u64().unwrap_or(1) as u32, - min: count["min_inclusive"] - .as_u64() - .or_else(|| e["minCount"].as_u64()) - .unwrap_or(1) as u32, - max: count["max_inclusive"] - .as_u64() - .or_else(|| e["maxCount"].as_u64()) - .unwrap_or(1) as u32, - } - }) - .collect(); - map.insert(cat.clone(), (piece, spawns)); - } - self.spawn_overrides.insert(structure.to_owned(), map); - } - self.spawn_overrides.get(structure)?.get(category).cloned() - } } diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 1f139a11a..a80d29b83 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -47,6 +47,7 @@ pub mod riders; pub mod shulker; pub mod sniffer; pub mod spawn_rules; +pub mod spawn_selection; pub mod spawning; pub mod species; pub mod sulfur_cube; diff --git a/crates/horizon-client/src/mobs/data.rs b/crates/horizon-client/src/mobs/data.rs index dc9e391aa..48fc4234f 100644 --- a/crates/horizon-client/src/mobs/data.rs +++ b/crates/horizon-client/src/mobs/data.rs @@ -43,6 +43,8 @@ pub enum Placement { pub struct MobData { pub states: Vec, pub placements: HashMap, + /// Each entity type's `SpawnPlacements.getHeightmapType`. + pub heightmaps: HashMap, pub animations: HashMap, /// Biome tags (spawns_warm_variant_farm_animals, is_ocean...) by tag id. pub biome_tags: HashMap>, @@ -109,6 +111,12 @@ impl MobData { (k.clone(), placement) }) .collect(); + let heightmaps = v["placements"] + .as_object() + .into_iter() + .flatten() + .filter_map(|(k, p)| Some((k.clone(), p["heightmap"].as_str()?.to_owned()))) + .collect(); let animations = v["animations"] .as_object() .into_iter() @@ -118,6 +126,7 @@ impl MobData { Self { states, placements, + heightmaps, animations, biome_tags: HashMap::new(), entity_tags: HashMap::new(), @@ -171,6 +180,7 @@ impl MobData { Self { states, placements: HashMap::new(), + heightmaps: HashMap::new(), animations: HashMap::new(), biome_tags: HashMap::new(), entity_tags: HashMap::new(), @@ -186,6 +196,12 @@ impl MobData { top: 1.0, }) } + /// `SpawnPlacements.getHeightmapType` (MOTION_BLOCKING_NO_LEAVES unless registered). + pub fn heightmap(&self, entity: &str) -> &str { + self.heightmaps + .get(entity) + .map_or("MOTION_BLOCKING_NO_LEAVES", String::as_str) + } pub fn placement(&self, entity: &str) -> Placement { self.placements .get(entity) diff --git a/crates/horizon-client/src/mobs/spawn_selection.rs b/crates/horizon-client/src/mobs/spawn_selection.rs new file mode 100644 index 000000000..74b371230 --- /dev/null +++ b/crates/horizon-client/src/mobs/spawn_selection.rs @@ -0,0 +1,1348 @@ +//! `NaturalSpawner`: which mob spawns where and when. The spawn state +//! (`createState`: category counts, the spawn potential's charges, the +//! per-player local caps), the filtered categories and their global and local +//! caps, `spawnForChunk` and `spawnCategoryForPosition` (the three packs of up +//! to four attempts, the distance and respawn checks, `getRandomSpawnMobAt` +//! over `mobsAt`: the nether fortress bounds, structure spawn overrides by +//! piece or bounding box, then the biome's natural mob spawns), the per-type +//! checks of `isValidSpawnPostitionForType` and `isValidPositionForMob` +//! (each class's `checkSpawnRules` walk target value, `checkSpawnObstruction`, +//! `removeWhenFarAway`, cluster and group sizes), and +//! `spawnMobsForChunkGeneration`. Everything reads the level through +//! [`NaturalLevel`] so the game and the differential test world +//! (tools/test_natural_spawn_parity.py) run the same code; random draws follow +//! vanilla's order. +use super::Species; +use super::spawn_rules::{self, Reason, SpawnLevel}; +use crate::biome::SpawnEntry; +use horizon_core::{ + geometry::{Aabb, Vec3}, + random::RandomSource, +}; +use serde_json::Value; +use std::{collections::HashMap, fs, path::Path}; + +/// A `natural_mob_spawns` / `spawn_overrides` entry list. +pub fn parse_entries(list: &Value) -> Vec { + list.as_array() + .into_iter() + .flatten() + .filter_map(|e| { + let count = &e["count"]; + let (min, max, constant) = match count { + Value::Number(n) => { + let n = n.as_u64().unwrap_or(1) as u32; + (n, n, true) + } + c if c["type"].as_str() == Some("minecraft:constant") => { + let n = c["value"].as_u64().unwrap_or(1) as u32; + (n, n, true) + } + c => ( + c["min_inclusive"].as_u64().unwrap_or(1) as u32, + c["max_inclusive"].as_u64().unwrap_or(1) as u32, + false, + ), + }; + Some(SpawnEntry { + entity: e["type"].as_str()?.to_owned(), + weight: e["weight"].as_u64().unwrap_or(1) as u32, + min, + max, + constant, + }) + }) + .collect() +} +/// `MobSpawnSettings.MobSpawnCost`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SpawnCost { + pub energy_budget: f64, + pub charge: f64, +} +/// The `spawn_costs` of a `natural_mob_spawns` value. +pub fn parse_costs(costs: &Value) -> HashMap { + costs + .as_object() + .into_iter() + .flatten() + .map(|(k, c)| { + ( + k.clone(), + SpawnCost { + energy_budget: c["energy_budget"].as_f64().unwrap_or(0.0), + charge: c["charge"].as_f64().unwrap_or(0.0), + }, + ) + }) + .collect() +} + +/// `MobCategory`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Category { + Monster, + Creature, + Ambient, + Axolotls, + UndergroundWaterCreature, + WaterCreature, + WaterAmbient, + Misc, +} +impl Category { + pub const ALL: [Self; 8] = [ + Self::Monster, + Self::Creature, + Self::Ambient, + Self::Axolotls, + Self::UndergroundWaterCreature, + Self::WaterCreature, + Self::WaterAmbient, + Self::Misc, + ]; + /// `NaturalSpawner.SPAWNING_CATEGORIES`: all but MISC, in enum order. + pub const SPAWNING: [Self; 7] = [ + Self::Monster, + Self::Creature, + Self::Ambient, + Self::Axolotls, + Self::UndergroundWaterCreature, + Self::WaterCreature, + Self::WaterAmbient, + ]; + pub fn name(self) -> &'static str { + match self { + Self::Monster => "monster", + Self::Creature => "creature", + Self::Ambient => "ambient", + Self::Axolotls => "axolotls", + Self::UndergroundWaterCreature => "underground_water_creature", + Self::WaterCreature => "water_creature", + Self::WaterAmbient => "water_ambient", + Self::Misc => "misc", + } + } + pub fn from_name(name: &str) -> Self { + Self::ALL + .into_iter() + .find(|c| c.name() == name) + .unwrap_or(Self::Misc) + } + fn index(self) -> usize { + self as usize + } + /// `getMaxInstancesPerChunk`. + pub fn max(self) -> i32 { + match self { + Self::Monster => 70, + Self::Creature => 10, + Self::Ambient => 15, + Self::Axolotls | Self::UndergroundWaterCreature | Self::WaterCreature => 5, + Self::WaterAmbient => 20, + Self::Misc => -1, + } + } + pub fn friendly(self) -> bool { + self != Self::Monster + } + pub fn persistent(self) -> bool { + matches!(self, Self::Creature | Self::Misc) + } + pub fn despawn_distance(self) -> i32 { + if self == Self::WaterAmbient { 64 } else { 128 } + } +} + +/// `PathfinderMob.checkSpawnRules`: the class's `getWalkTargetValue` at the +/// spawn position must not be negative (plain `Mob`s accept any position). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum WalkTarget { + NotPathfinder, + Zero, + Animal, + Mooshroom, + Turtle, + Monster, + Giant, + Guardian, + Silverfish, + Strider, + Hoglin, + Bee, + HappyGhast, +} +fn walk_target(s: Species) -> WalkTarget { + use Species as S; + use WalkTarget as W; + match s { + S::Bat | S::Slime | S::MagmaCube | S::SulfurCube | S::Ghast | S::Phantom => { + W::NotPathfinder + } + // Monster subclasses that override it with 0. + S::Enderman | S::Creaking | S::Pillager | S::Warden => W::Zero, + S::Giant => W::Giant, + S::Guardian | S::ElderGuardian => W::Guardian, + S::Silverfish => W::Silverfish, + S::Zombie + | S::Husk + | S::Drowned + | S::ZombieVillager + | S::ZombifiedPiglin + | S::Skeleton + | S::Stray + | S::Bogged + | S::Parched + | S::WitherSkeleton + | S::Creeper + | S::Spider + | S::CaveSpider + | S::Endermite + | S::Witch + | S::Vindicator + | S::Evoker + | S::Illusioner + | S::Ravager + | S::Piglin + | S::PiglinBrute + | S::Zoglin + | S::Blaze + | S::Vex + | S::Breeze + | S::Wither => W::Monster, + S::Mooshroom => W::Mooshroom, + S::Turtle => W::Turtle, + S::Strider => W::Strider, + S::Hoglin => W::Hoglin, + S::Bee => W::Bee, + S::HappyGhast => W::HappyGhast, + // Animal subclasses with their own value. + S::Axolotl | S::Nautilus | S::ZombieNautilus => W::Zero, + S::Pig + | S::Cow + | S::Sheep + | S::Chicken + | S::Rabbit + | S::Horse + | S::Donkey + | S::Mule + | S::Llama + | S::TraderLlama + | S::SkeletonHorse + | S::ZombieHorse + | S::Camel + | S::CamelHusk + | S::Goat + | S::Panda + | S::PolarBear + | S::Fox + | S::Wolf + | S::Cat + | S::Ocelot + | S::Parrot + | S::Armadillo + | S::Frog + | S::Sniffer => W::Animal, + // Other PathfinderMobs: water animals, golems, villagers, allays... + _ => W::Zero, + } +} +/// Each class's `checkSpawnObstruction`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Obstruction { + /// `Mob`: no liquid in the box and no entity blocking it. + NoLiquidUnobstructed, + Unobstructed, + Ocelot, + NoLiquid, +} +fn obstruction(s: Species) -> Obstruction { + use Species as S; + match s { + S::Squid + | S::GlowSquid + | S::Dolphin + | S::Axolotl + | S::Cod + | S::Salmon + | S::TropicalFish + | S::Pufferfish + | S::Tadpole + | S::Nautilus + | S::ZombieNautilus + | S::Guardian + | S::ElderGuardian + | S::Strider + | S::Drowned => Obstruction::Unobstructed, + S::Ocelot => Obstruction::Ocelot, + S::Ravager => Obstruction::NoLiquid, + // ZombifiedPiglin's override is the Mob default's two tests; iron + // golems and wardens never spawn naturally. + _ => Obstruction::NoLiquidUnobstructed, + } +} +/// `Mob.removeWhenFarAway` of a freshly created mob. +fn removed_when_far(s: Species) -> bool { + use Species as S; + match s { + // Animal, golems, villagers, traders, allays and wardens never; + // cats and ocelots only past 2400 ticks; chickens as jockeys. + S::Axolotl + | S::Cod + | S::Salmon + | S::TropicalFish + | S::Pufferfish + | S::Tadpole + | S::CamelHusk + | S::ZombieHorse + | S::Hoglin + | S::Nautilus + | S::ZombieNautilus => true, + S::Villager + | S::WanderingTrader + | S::IronGolem + | S::SnowGolem + | S::Shulker + | S::Allay + | S::Warden => false, + _ => !is_animal(s), + } +} +fn is_animal(s: Species) -> bool { + use Species as S; + matches!(walk_target(s), WalkTarget::Animal) + || matches!( + s, + S::Axolotl + | S::Nautilus + | S::ZombieNautilus + | S::Mooshroom + | S::Turtle + | S::Strider + | S::Hoglin + | S::Bee + | S::HappyGhast + ) +} +/// `Mob.getMaxSpawnClusterSize`. +pub fn max_cluster_size(s: Species) -> u32 { + use Species as S; + match s { + S::Horse + | S::Donkey + | S::Mule + | S::Llama + | S::TraderLlama + | S::SkeletonHorse + | S::ZombieHorse + | S::Camel + | S::CamelHusk => 6, + S::Cod | S::TropicalFish | S::Pufferfish | S::Tadpole | S::Wolf => 8, + S::Salmon => 5, + S::Ghast | S::HappyGhast | S::Pillager => 1, + _ => 4, + } +} +/// `EntityType.canSpawnFarFromPlayer`: creatures, misc, pillagers, shulkers. +fn spawns_far_from_player(s: Species, category: Category) -> bool { + matches!(category, Category::Creature | Category::Misc) + || matches!(s, Species::Pillager | Species::Shulker) +} + +/// What natural spawning reads from a level beyond the spawn rules. +pub trait NaturalLevel: SpawnLevel { + fn min_y(&self) -> i32; + /// `getMaxY()`: the highest buildable block. + fn max_y(&self) -> i32; + /// `DimensionType.ambientLight`. + fn ambient_light(&self) -> f32; + fn has_ceiling(&self) -> bool; + /// The entity type's species, width, height and category. + fn mob_type(&self, entity: &str) -> Option<(Species, f32, f32, Category)>; + /// The `natural_mob_spawns` lists by category at a position. + fn biome_spawns(&self, x: i32, y: i32, z: i32) -> Option<&HashMap>>; + /// The `natural_mob_spawns` spawn cost of a type at a position. + fn spawn_cost(&self, entity: &str, x: i32, y: i32, z: i32) -> Option; + /// The `creature_world_gen_spawn_probability` attribute at a position. + fn creature_probability(&self, x: i32, y: i32, z: i32) -> f32; + /// Every structure whose references reach the position's chunk, in the + /// chunk's reference order. + fn structures_at(&self, x: i32, y: i32, z: i32) -> Vec; + fn structure_spawns(&self) -> &StructureSpawns; + /// `getNearestPlayer(x, y, z, -1, false)`: the squared distance to the + /// nearest player that is not a spectator. + fn nearest_player_distance_sqr(&self, x: f64, y: f64, z: f64) -> Option; + /// The world spawn point, when it is in this dimension. + fn respawn(&self) -> Option<[i32; 3]>; + /// `ServerLevel.canSpawnEntitiesInChunk`. + fn can_spawn_in_chunk(&self, cx: i32, cz: i32) -> bool; + /// `noCollision(null, box)`: blocks under an empty collision context and + /// entities that can be collided with. + fn no_collision(&self, bounds: Aabb) -> bool; + /// `isUnobstructed(entity)`: no entity blocking building overlaps. + fn unobstructed(&self, bounds: Aabb) -> bool; + /// The `spawn_mobs` game rule. + fn spawn_mobs(&self) -> bool; + /// `getHeight(heightmap, x, z)`. + fn heightmap(&self, kind: Heightmap, x: i32, z: i32) -> i32; + /// `isPathfindable(PathComputationType.LAND)` of the block. + fn pathfindable_land(&self, x: i32, y: i32, z: i32) -> bool; +} + +/// The heightmap types `SpawnPlacements` registers. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Heightmap { + WorldSurface, + OceanFloor, + MotionBlocking, + MotionBlockingNoLeaves, +} +impl Heightmap { + pub fn from_name(name: &str) -> Self { + match name { + "WORLD_SURFACE" => Self::WorldSurface, + "OCEAN_FLOOR" => Self::OceanFloor, + "MOTION_BLOCKING" => Self::MotionBlocking, + _ => Self::MotionBlockingNoLeaves, + } + } +} + +/// `Heightmap.Types`' predicates: WORLD_SURFACE (not air), OCEAN_FLOOR +/// (`#blocks_motion_in_heightmap`), MOTION_BLOCKING (that or a fluid) and +/// MOTION_BLOCKING_NO_LEAVES (`#blocks_motion_in_heightmap_no_leaves` or a fluid). +pub fn heightmap_blocks(l: &L, kind: Heightmap, x: i32, y: i32, z: i32) -> bool { + let state = l.state(x, y, z); + let f = l.facts().get(state); + let fluid = f.fluid != crate::blocks::Fluid::None; + match kind { + Heightmap::WorldSurface => !l.facts().is_air(state), + Heightmap::OceanFloor => l.block_tag("minecraft:blocks_motion_in_heightmap", &f.name), + Heightmap::MotionBlocking => { + fluid || l.block_tag("minecraft:blocks_motion_in_heightmap", &f.name) + } + Heightmap::MotionBlockingNoLeaves => { + fluid || l.block_tag("minecraft:blocks_motion_in_heightmap_no_leaves", &f.name) + } + } +} +/// `LevelReader.getLightLevelDependentMagicValue`. +fn magic_light(l: &L, x: i32, y: i32, z: i32) -> f32 { + let raw = l + .block_light(x, y, z) + .max(l.sky_light(x, y, z) - l.sky_darken()); + let v = raw as f32 / 15.0; + let curved = v / (4.0 - 3.0 * v); + curved + l.ambient_light() * (1.0 - curved) +} +fn cost_from_light(l: &L, x: i32, y: i32, z: i32) -> f32 { + magic_light(l, x, y, z) - 0.5 +} +fn name_at(l: &L, x: i32, y: i32, z: i32) -> &str { + &l.facts().get(l.state(x, y, z)).name +} +fn fluid_at(l: &L, x: i32, y: i32, z: i32) -> crate::blocks::Fluid { + l.facts().get(l.state(x, y, z)).fluid +} +/// `checkSpawnRules(level, NATURAL)` of a freshly created mob at a block. +fn mob_spawn_rules(s: Species, l: &L, x: i32, y: i32, z: i32) -> bool { + use crate::blocks::Fluid; + let value = match walk_target(s) { + WalkTarget::NotPathfinder | WalkTarget::Zero => 0.0, + WalkTarget::Animal => { + if name_at(l, x, y - 1, z) == "minecraft:grass_block" { + 10.0 + } else { + cost_from_light(l, x, y, z) + } + } + WalkTarget::Mooshroom => { + if name_at(l, x, y - 1, z) == "minecraft:mycelium" { + 10.0 + } else { + cost_from_light(l, x, y, z) + } + } + WalkTarget::Turtle => { + if fluid_at(l, x, y, z) == Fluid::Water + || l.block_tag("minecraft:sand", name_at(l, x, y - 1, z)) + { + 10.0 + } else { + cost_from_light(l, x, y, z) + } + } + WalkTarget::Monster => -cost_from_light(l, x, y, z), + WalkTarget::Giant => cost_from_light(l, x, y, z), + WalkTarget::Guardian => { + if fluid_at(l, x, y, z) == Fluid::Water { + 10.0 + cost_from_light(l, x, y, z) + } else { + -cost_from_light(l, x, y, z) + } + } + WalkTarget::Silverfish => { + if infested_host(name_at(l, x, y - 1, z)) { + 10.0 + } else { + -cost_from_light(l, x, y, z) + } + } + // A new strider is not in lava yet. + WalkTarget::Strider => { + if fluid_at(l, x, y, z) == Fluid::Lava { + 10.0 + } else { + 0.0 + } + } + // A new hoglin remembers no repellent. + WalkTarget::Hoglin => { + if name_at(l, x, y - 1, z) == "minecraft:crimson_nylium" { + 10.0 + } else { + 0.0 + } + } + WalkTarget::Bee => { + if l.facts().is_air(l.state(x, y, z)) { + 10.0 + } else { + 0.0 + } + } + WalkTarget::HappyGhast => { + let air = |dy| l.facts().is_air(l.state(x, y + dy, z)); + if !air(0) { + 0.0 + } else if air(-1) && !air(-2) { + 10.0 + } else { + 5.0 + } + } + }; + value >= 0.0 +} +/// `InfestedBlock.isCompatibleHostBlock`. +fn infested_host(block: &str) -> bool { + matches!( + block, + "minecraft:stone" + | "minecraft:cobblestone" + | "minecraft:stone_bricks" + | "minecraft:mossy_stone_bricks" + | "minecraft:cracked_stone_bricks" + | "minecraft:chiseled_stone_bricks" + | "minecraft:deepslate" + ) +} +/// `LevelReader.containsAnyLiquid`: the blocks from the box's minimum corner +/// to the block holding its maximum corner. +pub fn contains_any_liquid(l: &L, b: Aabb) -> bool { + let (lo, hi) = (b.min_position(), b.max_position()); + let (x0, y0, z0) = ( + lo.x.floor() as i32, + lo.y.floor() as i32, + lo.z.floor() as i32, + ); + let (x1, y1, z1) = ( + hi.x.floor() as i32, + hi.y.floor() as i32, + hi.z.floor() as i32, + ); + (x0..=x1).any(|x| { + (y0..=y1).any(|y| { + (z0..=z1).any(|z| l.facts().get(l.state(x, y, z)).fluid != crate::blocks::Fluid::None) + }) + }) +} +/// `EntityDimensions.makeBoundingBox`: the half width is a float. +pub fn spawn_box(x: f64, y: f64, z: f64, width: f32, height: f32) -> Aabb { + let half = f64::from(width / 2.0); + Aabb::new( + Vec3::new(x - half, y, z - half), + Vec3::new(x + half, y + f64::from(height), z + half), + ) +} +/// `EntityType.spawnDimensionsScale`: cube mobs are checked for room at +/// their largest spawn size. +fn spawn_dimensions_scale(s: Species) -> f32 { + match s { + Species::Slime | Species::MagmaCube => 4.0, + Species::SulfurCube => 2.0, + _ => 1.0, + } +} +/// `EntityType.getSpawnAABB`. +pub fn spawn_aabb(s: Species, x: f64, y: f64, z: f64, width: f32, height: f32) -> Aabb { + let scale = spawn_dimensions_scale(s); + let half = f64::from(scale * width / 2.0); + Aabb::new( + Vec3::new(x - half, y, z - half), + Vec3::new(x + half, y + f64::from(scale * height), z + half), + ) +} +/// `checkSpawnObstruction` of a freshly created mob with the given box. +fn spawn_obstruction( + s: Species, + l: &L, + bounds: Aabb, + spawned: &[Aabb], + x: i32, + y: i32, + z: i32, +) -> bool { + let unobstructed = + |l: &L| l.unobstructed(bounds) && !spawned.iter().any(|b| b.intersects(bounds)); + match obstruction(s) { + Obstruction::NoLiquidUnobstructed => !contains_any_liquid(l, bounds) && unobstructed(l), + Obstruction::Unobstructed => unobstructed(l), + Obstruction::NoLiquid => !contains_any_liquid(l, bounds), + Obstruction::Ocelot => { + if unobstructed(l) && !contains_any_liquid(l, bounds) { + if y < l.sea_level() { + return false; + } + let below = name_at(l, x, y - 1, z); + if below == "minecraft:grass_block" || l.block_tag("minecraft:leaves", below) { + return true; + } + } + false + } + } +} + +/// A structure's spawn override for one category. +#[derive(Clone, Debug)] +pub struct Override { + /// `bounding_box: piece` (else the whole structure's box). + pub piece: bool, + pub spawns: Vec, +} +/// Every structure's `spawn_overrides`, by structure id then category. +#[derive(Clone, Debug, Default)] +pub struct StructureSpawns { + pub overrides: HashMap>, +} +impl StructureSpawns { + /// The overrides of the resource root first asked for, loaded once. + pub fn shared(root: &Path) -> &'static Self { + static SHARED: std::sync::OnceLock = std::sync::OnceLock::new(); + SHARED.get_or_init(|| Self::load(root)) + } + pub fn load(root: &Path) -> Self { + let mut overrides = HashMap::new(); + let dir = root.join("data/minecraft/worldgen/structure"); + for entry in fs::read_dir(dir).into_iter().flatten().flatten() { + let path = entry.path(); + let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { + continue; + }; + let Some(v) = fs::read(&path) + .ok() + .and_then(|b| serde_json::from_slice::(&b).ok()) + else { + continue; + }; + let map: HashMap = v["spawn_overrides"] + .as_object() + .into_iter() + .flatten() + .map(|(category, o)| { + ( + category.clone(), + Override { + piece: o["bounding_box"].as_str() == Some("piece"), + spawns: parse_entries(&o["spawns"]), + }, + ) + }) + .collect(); + if !map.is_empty() { + overrides.insert(format!("minecraft:{stem}"), map); + } + } + Self { overrides } + } + pub fn get(&self, structure: &str, category: &str) -> Option<&Override> { + self.overrides.get(structure)?.get(category) + } +} + +/// A structure whose references reach a position's chunk. +#[derive(Clone, Debug)] +pub struct StructureAt { + pub id: String, + /// A piece of one of its starts holds the position. + pub in_piece: bool, + /// One of its starts' bounding boxes holds the position. + pub in_bounds: bool, +} + +/// `NetherFortressStructure.FORTRESS_ENEMIES`. +pub fn fortress_enemies() -> Vec { + let e = |entity: &str, weight, min, max, constant| SpawnEntry { + entity: entity.to_owned(), + weight, + min, + max, + constant, + }; + vec![ + e("minecraft:blaze", 10, 2, 3, false), + e("minecraft:zombified_piglin", 5, 4, 4, true), + e("minecraft:wither_skeleton", 8, 5, 5, true), + e("minecraft:skeleton", 2, 5, 5, true), + e("minecraft:magma_cube", 3, 4, 4, true), + ] +} + +/// `NaturalSpawner.mobsAt`: the fortress bounds on nether bricks, then +/// `ChunkGenerator.getMobsAt` (the first referenced structure whose override +/// for the category holds the position), else the biome's list. +pub fn mobs_at( + l: &L, + category: Category, + x: i32, + y: i32, + z: i32, +) -> Vec { + let structures = l.structures_at(x, y, z); + if category == Category::Monster + && name_at(l, x, y - 1, z) == "minecraft:nether_bricks" + && structures + .iter() + .any(|s| s.id == "minecraft:fortress" && s.in_bounds) + { + return fortress_enemies(); + } + for s in &structures { + if let Some(o) = l.structure_spawns().get(&s.id, category.name()) + && (if o.piece { s.in_piece } else { s.in_bounds }) + { + return o.spawns.clone(); + } + } + l.biome_spawns(x, y, z) + .and_then(|b| b.get(category.name())) + .cloned() + .unwrap_or_default() +} + +/// `WeightedList.getRandom`. +pub fn weighted_pick(list: &[SpawnEntry], random: &mut impl RandomSource) -> Option { + let total: i64 = list.iter().map(|e| i64::from(e.weight)).sum(); + if total <= 0 { + return None; + } + let mut selection = i64::from(random.next_int_bound(total as i32).ok()?); + for e in list { + selection -= i64::from(e.weight); + if selection < 0 { + return Some(e.clone()); + } + } + None +} + +/// `NaturalSpawner.getRandomSpawnMobAt`. +pub fn random_spawn_mob_at( + list: &[SpawnEntry], + category: &str, + reduced_water_ambient: bool, + random: &mut impl RandomSource, +) -> Option { + if category == "water_ambient" && reduced_water_ambient && random.next_float() < 0.98 { + return None; + } + weighted_pick(list, random) +} + +/// `SpawnerData.count().sample(random)`: a constant draws nothing. +pub fn sample_count(entry: &SpawnEntry, random: &mut impl RandomSource) -> u32 { + if entry.constant { + entry.min + } else { + let span = entry.max.saturating_sub(entry.min) as i32 + 1; + entry.min + random.next_int_bound(span).unwrap_or(0) as u32 + } +} +/// `SpawnerData.equals`: the type and the count provider (the weight is not +/// part of it; a constant and a one-value uniform differ). +fn same_spawner(a: &SpawnEntry, b: &SpawnEntry) -> bool { + a.entity == b.entity && a.constant == b.constant && a.min == b.min && a.max == b.max +} + +/// An entity counted by `NaturalSpawner.createState`. +#[derive(Clone, Debug)] +pub struct Counted<'a> { + pub entity: &'a str, + pub category: Category, + /// `blockPosition()`. + pub block: [i32; 3], + pub is_mob: bool, + /// `isPersistenceRequired() || requiresCustomPersistence()`. + pub persistent: bool, +} +/// A player as the spawn caps see it. +#[derive(Clone, Copy, Debug)] +pub struct SpawnPlayer { + pub position: [f64; 3], + pub spectator: bool, +} + +/// `NaturalSpawner.SpawnState` with its `PotentialCalculator` and +/// `LocalMobCapCalculator`. +#[derive(Clone, Debug)] +pub struct SpawnState { + spawnable_chunks: i32, + counts: [i32; 8], + charges: Vec<([i32; 3], f64)>, + players: Vec, + players_near: HashMap<(i32, i32), Vec>, + player_counts: HashMap, + last_checked: Option<([i32; 3], String, f64)>, +} +impl SpawnState { + /// `NaturalSpawner.createState`: every non-persistent entity of a + /// spawning category in a loaded chunk adds its biome's spawn charge, its + /// category count and (for mobs) the local caps of the players near it. + pub fn new( + l: &L, + spawnable_chunks: i32, + players: Vec, + entities: &[Counted], + chunk_loaded: impl Fn(i32, i32) -> bool, + ) -> Self { + let mut state = Self { + spawnable_chunks, + counts: [0; 8], + charges: Vec::new(), + players, + players_near: HashMap::new(), + player_counts: HashMap::new(), + last_checked: None, + }; + for e in entities { + if e.is_mob && e.persistent || e.category == Category::Misc { + continue; + } + let [x, y, z] = e.block; + if !chunk_loaded(x >> 4, z >> 4) { + continue; + } + if let Some(cost) = l.spawn_cost(e.entity, x, y, z) { + state.add_charge(e.block, cost.charge); + } + if e.is_mob { + state.add_local(x >> 4, z >> 4, e.category); + } + state.counts[e.category.index()] += 1; + } + state + } + fn add_charge(&mut self, pos: [i32; 3], charge: f64) { + if charge != 0.0 { + self.charges.push((pos, charge)); + } + } + /// `ChunkMap.getPlayersCloseForSpawning`: none past the natural spawn + /// chunk tracker's range of 8 chunks from every player, else the + /// non-spectators within 128 blocks of the chunk's centre. + fn players_near(&mut self, cx: i32, cz: i32) -> &[usize] { + let players = &self.players; + self.players_near.entry((cx, cz)).or_insert_with(|| { + let tracked = players.iter().any(|p| { + let (px, pz) = ( + (p.position[0].floor() as i32) >> 4, + (p.position[2].floor() as i32) >> 4, + ); + (px - cx).abs().max((pz - cz).abs()) <= 8 + }); + if !tracked { + return Vec::new(); + } + let (x, z) = (f64::from(cx * 16 + 8), f64::from(cz * 16 + 8)); + players + .iter() + .enumerate() + .filter(|(_, p)| { + let (dx, dz) = (x - p.position[0], z - p.position[2]); + !p.spectator && dx * dx + dz * dz < 16384.0 + }) + .map(|(i, _)| i) + .collect() + }) + } + fn add_local(&mut self, cx: i32, cz: i32, category: Category) { + let near = self.players_near(cx, cz).to_vec(); + for p in near { + self.player_counts.entry(p).or_insert([0; 8])[category.index()] += 1; + } + } + /// `canSpawnForCategoryGlobal`. + pub fn can_spawn_global(&self, category: Category) -> bool { + let max = category.max() * self.spawnable_chunks / 289; + self.counts[category.index()] < max + } + /// `canSpawnForCategoryLocal` (`LocalMobCapCalculator.canSpawn`). + pub fn can_spawn_local(&mut self, category: Category, cx: i32, cz: i32) -> bool { + let near = self.players_near(cx, cz).to_vec(); + near.into_iter().any(|p| { + self.player_counts + .get(&p) + .is_none_or(|c| c[category.index()] < category.max()) + }) + } + /// `getFilteredSpawningCategories`. + pub fn filtered_categories( + &self, + spawn_enemies: bool, + spawn_persistent: bool, + ) -> Vec { + Category::SPAWNING + .into_iter() + .filter(|&c| { + (spawn_enemies || c.friendly()) + && (spawn_persistent || !c.persistent()) + && self.can_spawn_global(c) + }) + .collect() + } + /// `SpawnState.canSpawn`: the spawn potential's energy change must stay + /// within the type's budget where it has a spawn cost. + fn can_spawn(&mut self, l: &L, entity: &str, pos: [i32; 3]) -> bool { + match l.spawn_cost(entity, pos[0], pos[1], pos[2]) { + None => { + self.last_checked = Some((pos, entity.to_owned(), 0.0)); + true + } + Some(cost) => { + self.last_checked = Some((pos, entity.to_owned(), cost.charge)); + self.energy_change(pos, cost.charge) <= cost.energy_budget + } + } + } + /// `PotentialCalculator.getPotentialEnergyChange`. + fn energy_change(&self, pos: [i32; 3], charge: f64) -> f64 { + if charge == 0.0 { + return 0.0; + } + let mut change = 0.0; + for (p, c) in &self.charges { + let d = [ + f64::from(p[0] - pos[0]), + f64::from(p[1] - pos[1]), + f64::from(p[2] - pos[2]), + ]; + let dist = d[0] * d[0] + d[1] * d[1] + d[2] * d[2]; + change += if dist == 0.0 { + f64::INFINITY + } else { + c / dist.sqrt() + }; + } + change * charge + } + /// `SpawnState.afterSpawn`. + fn after_spawn( + &mut self, + l: &L, + entity: &str, + category: Category, + pos: [i32; 3], + ) { + let charge = match &self.last_checked { + Some((p, t, c)) if *p == pos && t == entity => *c, + _ => l + .spawn_cost(entity, pos[0], pos[1], pos[2]) + .map_or(0.0, |c| c.charge), + }; + self.add_charge(pos, charge); + self.counts[category.index()] += 1; + self.add_local(pos[0] >> 4, pos[2] >> 4, category); + } +} + +/// What a spawn pass did, in vanilla's order (for differential tests). +#[derive(Clone, Debug, PartialEq)] +pub enum Event { + /// `getFilteredSpawningCategories`. + Categories(Vec), + /// `canSpawnForCategoryLocal` for the chunk. + Local(Category, bool), + /// `getRandomPosWithin`'s start. + Start(Category, [i32; 3]), + /// An attempt at a position (the nearest player lookup). + Try(i32, i32), + /// The type passed `isValidSpawnPostitionForType`; the spawn state's + /// potential test answered. + Test(String, [i32; 3], bool), + /// A mob spawned. + Spawn(String, [f64; 3], f32), +} +/// A mob the spawner is about to add: the level runs `finalizeSpawn` and +/// adds it, returning its finalized box and whether a tropical fish schools. +#[derive(Clone, Debug)] +pub struct SpawnRequest<'a> { + pub entity: &'a str, + pub species: Species, + pub position: [f64; 3], + pub yaw: f32, + /// Mobs of this group spawned before (the `SpawnGroupData`'s size). + pub group_index: u32, + pub reason: Reason, +} +#[derive(Clone, Copy, Debug)] +pub struct Spawned { + pub bounds: Aabb, + /// `TropicalFish.isSchool`. + pub school: bool, +} + +/// `NaturalSpawner.spawnForChunk` for the categories already filtered. +#[allow(clippy::too_many_arguments)] +pub fn spawn_for_chunk( + l: &L, + random: &mut R, + state: &mut SpawnState, + categories: &[Category], + chunk: (i32, i32), + spawn: &mut dyn FnMut(&L, &SpawnRequest) -> Option, + trace: &mut dyn FnMut(Event), +) { + for &category in categories { + let local = state.can_spawn_local(category, chunk.0, chunk.1); + trace(Event::Local(category, local)); + if local { + spawn_category_for_chunk(l, random, state, category, chunk, spawn, trace); + } + } +} +/// `NaturalSpawner.spawnCategoryForChunk`: `getRandomPosWithin` (a column of +/// the chunk, a height up to one above the world surface). +pub fn spawn_category_for_chunk( + l: &L, + random: &mut R, + state: &mut SpawnState, + category: Category, + chunk: (i32, i32), + spawn: &mut dyn FnMut(&L, &SpawnRequest) -> Option, + trace: &mut dyn FnMut(Event), +) { + let x = chunk.0 * 16 + random.next_int_bound(16).unwrap_or(0); + let z = chunk.1 * 16 + random.next_int_bound(16).unwrap_or(0); + let top = l.heightmap(Heightmap::WorldSurface, x, z) + 1; + let min = l.min_y(); + let y = min + random.next_int_bound(top - min + 1).unwrap_or(0); + trace(Event::Start(category, [x, y, z])); + if y > min { + spawn_category_for_position(l, random, state, category, chunk, [x, y, z], spawn, trace); + } +} +/// `NaturalSpawner.spawnCategoryForPosition` with the spawn state's +/// `canSpawn` and `afterSpawn`. +#[allow(clippy::too_many_arguments)] +pub fn spawn_category_for_position( + l: &L, + random: &mut R, + state: &mut SpawnState, + category: Category, + chunk: (i32, i32), + start: [i32; 3], + spawn: &mut dyn FnMut(&L, &SpawnRequest) -> Option, + trace: &mut dyn FnMut(Event), +) { + let [x0, y, z0] = start; + if l.mob_data().state(l.state(x0, y, z0)).flags & super::data::REDSTONE_CONDUCTOR != 0 { + return; + } + let mut spawned: Vec = Vec::new(); + let mut cluster = 0u32; + for _ in 0..3 { + let (mut x, mut z) = (x0, z0); + let mut current: Option = None; + let mut group_index = 0u32; + let mut max = (random.next_float() * 4.0).ceil() as i32; + let mut ll = 0; + while ll < max { + ll += 1; + x += random.next_int_bound(6).unwrap_or(0) - random.next_int_bound(6).unwrap_or(0); + z += random.next_int_bound(6).unwrap_or(0) - random.next_int_bound(6).unwrap_or(0); + let (xx, zz) = (f64::from(x) + 0.5, f64::from(z) + 0.5); + trace(Event::Try(x, z)); + let Some(distance) = l.nearest_player_distance_sqr(xx, f64::from(y), zz) else { + continue; + }; + if !right_distance(l, chunk, [x, y, z], distance) { + continue; + } + if current.is_none() { + let reduced = l.biome_tag("minecraft:reduce_water_ambient_spawns", x, y, z); + let list = if category == Category::WaterAmbient + && reduced + && random.next_float() < 0.98 + { + None + } else { + weighted_pick(&mobs_at(l, category, x, y, z), random) + }; + let Some(entry) = list else { + break; + }; + max = sample_count(&entry, random) as i32; + current = Some(entry); + } + let entry = current.as_ref().unwrap_or_else(|| unreachable!()); + if !valid_position_for_type(l, random, category, entry, [x, y, z], distance) { + continue; + } + let passed = state.can_spawn(l, &entry.entity, [x, y, z]); + trace(Event::Test(entry.entity.clone(), [x, y, z], passed)); + if !passed { + continue; + } + // getMobForSpawn: a type the game cannot create ends the pass. + let Some((species, width, height, type_category)) = l.mob_type(&entry.entity) else { + return; + }; + let yaw = random.next_float() * 360.0; + let bounds = spawn_box(xx, f64::from(y), zz, width, height); + let despawn = f64::from(type_category.despawn_distance().pow(2)); + let far = distance > despawn && removed_when_far(species); + if far + || !mob_spawn_rules(species, l, x, y, z) + || !spawn_obstruction(species, l, bounds, &spawned, x, y, z) + { + continue; + } + let request = SpawnRequest { + entity: &entry.entity, + species, + position: [xx, f64::from(y), zz], + yaw, + group_index, + reason: Reason::Natural, + }; + let Some(result) = spawn(l, &request) else { + continue; + }; + group_index += 1; + spawned.push(result.bounds); + cluster += 1; + state.after_spawn(l, &entry.entity, type_category, [x, y, z]); + trace(Event::Spawn( + entry.entity.clone(), + [xx, f64::from(y), zz], + yaw, + )); + if cluster >= max_cluster_size(species) { + return; + } + // TropicalFish.isMaxGroupSizeReached. + if species == Species::TropicalFish && !result.school { + break; + } + } + } +} +/// `isRightDistanceToPlayerAndSpawnPoint`. +fn right_distance(l: &L, chunk: (i32, i32), pos: [i32; 3], distance: f64) -> bool { + if distance <= 576.0 { + return false; + } + if let Some(r) = l.respawn() { + let d = [ + f64::from(r[0]) + 0.5 - (f64::from(pos[0]) + 0.5), + f64::from(r[1]) + 0.5 - f64::from(pos[1]), + f64::from(r[2]) + 0.5 - (f64::from(pos[2]) + 0.5), + ]; + if d[0] * d[0] + d[1] * d[1] + d[2] * d[2] < 24.0 * 24.0 { + return false; + } + } + let (cx, cz) = (pos[0] >> 4, pos[2] >> 4); + (cx, cz) == chunk || l.can_spawn_in_chunk(cx, cz) +} +/// `isValidSpawnPostitionForType`. +fn valid_position_for_type( + l: &L, + random: &mut R, + category: Category, + entry: &SpawnEntry, + pos: [i32; 3], + distance: f64, +) -> bool { + let Some((species, width, height, type_category)) = l.mob_type(&entry.entity) else { + return false; + }; + if type_category == Category::Misc { + return false; + } + let despawn = f64::from(type_category.despawn_distance().pow(2)); + if !spawns_far_from_player(species, type_category) && distance > despawn { + return false; + } + let [x, y, z] = pos; + if !mobs_at(l, category, x, y, z) + .iter() + .any(|e| same_spawner(e, entry)) + { + return false; + } + if !spawn_rules::is_spawn_position_ok(species, l, x, y, z) { + return false; + } + if !spawn_rules::check_spawn_rules(species, l, Reason::Natural, random, x, y, z) { + return false; + } + l.no_collision(spawn_aabb( + species, + f64::from(x) + 0.5, + f64::from(y), + f64::from(z) + 0.5, + width, + height, + )) +} + +/// What chunk generation spawning did (for differential tests). +#[derive(Clone, Debug, PartialEq)] +pub enum GenEvent { + /// An attempt at a column (the heightmap lookup). + Column(i32, i32), + Spawn(String, [f64; 3], f32), +} +/// `NaturalSpawner.spawnMobsForChunkGeneration`: creature packs placed on +/// the chunk's surface. `rules` is the level's own random, which the spawn +/// predicates draw from. +#[allow(clippy::too_many_arguments)] +pub fn spawn_mobs_for_chunk_generation( + l: &L, + source: [i32; 3], + chunk: (i32, i32), + random: &mut R, + rules: &mut Q, + spawn: &mut dyn FnMut(&L, &SpawnRequest) -> Option, + trace: &mut dyn FnMut(GenEvent), +) { + if !l.spawn_mobs() { + return; + } + let [sx, sy, sz] = source; + let mobs = l + .biome_spawns(sx, sy, sz) + .and_then(|b| b.get("creature")) + .cloned() + .unwrap_or_default(); + if mobs.iter().map(|e| e.weight).sum::() == 0 { + return; + } + let probability = l.creature_probability(sx, sy, sz); + let (xo, zo) = (chunk.0 * 16, chunk.1 * 16); + while random.next_float() < probability { + let Some(entry) = weighted_pick(&mobs, random) else { + continue; + }; + let count = sample_count(&entry, random); + let mut group_index = 0u32; + let mut spawned: Vec = Vec::new(); + let mut x = xo + random.next_int_bound(16).unwrap_or(0); + let mut z = zo + random.next_int_bound(16).unwrap_or(0); + let (start_x, start_z) = (x, z); + let kind = l.mob_type(&entry.entity); + for _ in 0..count { + let mut success = false; + let mut attempts = 0; + while !success && attempts < 4 { + attempts += 1; + let Some((species, width, height, _)) = kind else { + break; + }; + let pos = top_non_colliding(l, &entry.entity, x, z); + trace(GenEvent::Column(x, z)); + if spawn_rules::is_spawn_position_ok(species, l, pos[0], pos[1], pos[2]) { + let fx = f64::from(x).clamp( + f64::from(xo) + f64::from(width), + f64::from(xo) + 16.0 - f64::from(width), + ); + let fz = f64::from(z).clamp( + f64::from(zo) + f64::from(width), + f64::from(zo) + 16.0 - f64::from(width), + ); + let fy = f64::from(pos[1]); + let (bx, bz) = (fx.floor() as i32, fz.floor() as i32); + if !l.no_collision(spawn_aabb(species, fx, fy, fz, width, height)) + || !spawn_rules::check_spawn_rules( + species, + l, + Reason::ChunkGeneration, + rules, + bx, + pos[1], + bz, + ) + { + continue; + } + let yaw = random.next_float() * 360.0; + let bounds = spawn_box(fx, fy, fz, width, height); + if mob_spawn_rules(species, l, bx, pos[1], bz) + && spawn_obstruction(species, l, bounds, &spawned, bx, pos[1], bz) + { + let request = SpawnRequest { + entity: &entry.entity, + species, + position: [fx, fy, fz], + yaw, + group_index, + reason: Reason::ChunkGeneration, + }; + if let Some(result) = spawn(l, &request) { + group_index += 1; + spawned.push(result.bounds); + trace(GenEvent::Spawn(entry.entity.clone(), [fx, fy, fz], yaw)); + success = true; + } + } + } + x += random.next_int_bound(5).unwrap_or(0) - random.next_int_bound(5).unwrap_or(0); + z += random.next_int_bound(5).unwrap_or(0) - random.next_int_bound(5).unwrap_or(0); + while x < xo || x >= xo + 16 || z < zo || z >= zo + 16 { + x = start_x + random.next_int_bound(5).unwrap_or(0) + - random.next_int_bound(5).unwrap_or(0); + z = start_z + random.next_int_bound(5).unwrap_or(0) + - random.next_int_bound(5).unwrap_or(0); + } + } + } + } +} +/// `getTopNonCollidingPos`: the type's heightmap, under the ceiling in +/// dimensions that have one, then the placement type's adjustment. +fn top_non_colliding(l: &L, entity: &str, x: i32, z: i32) -> [i32; 3] { + let heightmap = Heightmap::from_name(l.mob_data().heightmap(entity)); + let mut y = l.heightmap(heightmap, x, z); + if l.has_ceiling() { + loop { + y -= 1; + if l.facts().is_air(l.state(x, y, z)) { + break; + } + } + loop { + y -= 1; + if !(l.facts().is_air(l.state(x, y, z)) && y > l.min_y()) { + break; + } + } + } + if l.mob_data().placement(entity) == super::data::Placement::OnGround + && l.pathfindable_land(x, y - 1, z) + { + y -= 1; + } + [x, y, z] +} diff --git a/crates/horizon-protocol/src/connection.rs b/crates/horizon-protocol/src/connection.rs index 5c0bba188..cac0ab60f 100644 --- a/crates/horizon-protocol/src/connection.rs +++ b/crates/horizon-protocol/src/connection.rs @@ -82,7 +82,10 @@ impl Connection { } /// Connection.setEncryptionKey: both directions keyed with the shared secret. pub fn enable_encryption(&mut self, secret: &[u8; 16]) { - self.reader.decrypt = Some(Cfb8::new(secret, secret)); + let mut decrypt = Cfb8::new(secret, secret); + // Bytes already read past the last packet were sent encrypted. + decrypt.decrypt(&mut self.reader.buffer); + self.reader.decrypt = Some(decrypt); self.writer.encrypt = Some(Cfb8::new(secret, secret)); } pub fn send(&mut self, body: &[u8]) -> Result<()> { @@ -180,4 +183,27 @@ mod tests { assert_eq!((id, payload.as_slice()), (9, &b"reply"[..])); server.join().unwrap(); } + + /// Encrypted bytes that arrive in the same read as the last plain packet + /// are decrypted once encryption is enabled. + #[test] + fn encrypted_bytes_read_before_enabling_encryption() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let (sent, both_sent) = std::sync::mpsc::channel(); + let client = std::thread::spawn(move || { + let mut c = Connection::connect("127.0.0.1", port, Duration::from_secs(5)).unwrap(); + c.send(&[0, 1, 2]).unwrap(); + c.enable_encryption(&[7; 16]); + c.send(&[5, 4, 3]).unwrap(); + sent.send(c).unwrap(); + }); + let (stream, _) = listener.accept().unwrap(); + let mut c = Connection::from_stream(stream).unwrap(); + let _keep_open = both_sent.recv().unwrap(); + assert_eq!(c.recv().unwrap(), (0, vec![1, 2])); + c.enable_encryption(&[7; 16]); + assert_eq!(c.recv().unwrap(), (5, vec![4, 3])); + client.join().unwrap(); + } } diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaNaturalSpawnOracle.java b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaNaturalSpawnOracle.java new file mode 100644 index 000000000..7f1420eba --- /dev/null +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaNaturalSpawnOracle.java @@ -0,0 +1,550 @@ +package dev.horizonlab.parity; + +import it.unimi.dsi.fastutil.longs.LongOpenHashSet; +import it.unimi.dsi.fastutil.longs.LongSet; +import java.io.PrintWriter; +import java.lang.reflect.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import java.util.function.Consumer; +import java.util.function.Predicate; +import net.minecraft.SharedConstants; +import net.minecraft.core.*; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.core.registries.Registries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.RegistryDataLoader; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.Bootstrap; +import net.minecraft.server.RegistryLayer; +import net.minecraft.server.level.ChunkMap; +import net.minecraft.server.level.ServerChunkCache; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.packs.PackType; +import net.minecraft.server.packs.repository.ServerPacksSource; +import net.minecraft.server.packs.resources.MultiPackResourceManager; +import net.minecraft.tags.TagLoader; +import net.minecraft.util.RandomSource; +import net.minecraft.world.Difficulty; +import net.minecraft.world.DifficultyInstance; +import net.minecraft.world.entity.*; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.*; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.biome.BiomeManager; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.border.WorldBorder; +import net.minecraft.world.level.chunk.ChunkGenerator; +import net.minecraft.world.level.chunk.LevelChunk; +import net.minecraft.world.level.dimension.DimensionType; +import net.minecraft.world.level.entity.EntityTypeTest; +import net.minecraft.world.level.gamerules.GameRules; +import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.level.levelgen.FlatLevelSource; +import net.minecraft.util.Mth; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.level.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.StructurePiece; +import net.minecraft.world.level.levelgen.structure.StructureStart; +import net.minecraft.world.level.levelgen.structure.pieces.PiecesContainer; +import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext; +import net.minecraft.world.level.material.FluidState; +import net.minecraft.world.level.storage.LevelData; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; + +/** + * Original oracle: natural spawning as NaturalSpawner decides it, on a + * stand-in ServerLevel built from boxes of blocks, light and biomes, with + * structure starts (pieces), existing entities, players, the world spawn + * point and unloaded chunks. Three modes: + * + * - chunk: createState over the entities, getFilteredSpawningCategories, then + * spawnForChunk's loop (canSpawnForCategoryLocal, getRandomPosWithin) and + * vanilla's spawnCategoryForPosition with the state's canSpawn and + * afterSpawn; + * - pos: spawnCategoryForPosition from a given start; + * - gen: spawnMobsForChunkGeneration. + * + * The log records each attempt (the nearest player lookup), each type that + * passed isValidSpawnPostitionForType with the potential test's answer, each + * spawn (position and yaw bits), and the next draw of the level random, which + * pins every draw in between. finalizeSpawn reads level.getRandom(), which + * here is a fixed "neutral" source (nextInt 0, nextFloat and nextDouble 0.75), + * so finalizeSpawn's own outcomes stay out of scope while its effect on later + * attempts (the finalized mob's box obstructs them) is deterministic. + */ +public final class VanillaNaturalSpawnOracle { + static Object unsafe; + static Method allocate, putObject, objectFieldOffset; + + static Object alloc(Class c) throws Exception { return allocate.invoke(unsafe, c); } + + static void put(Object target, Class owner, String field, Object value) throws Exception { + Field f = owner.getDeclaredField(field); + putObject.invoke(unsafe, target, (long) objectFieldOffset.invoke(unsafe, f), value); + } + + record Box(int x0, int y0, int z0, int x1, int y1, int z1) { + boolean has(int x, int y, int z) { return x >= x0 && x <= x1 && y >= y0 && y <= y1 && z >= z0 && z <= z1; } + static Box parse(String s) { + String[] p = s.split(","); + return new Box(Integer.parseInt(p[0]), Integer.parseInt(p[1]), Integer.parseInt(p[2]), + Integer.parseInt(p[3]), Integer.parseInt(p[4]), Integer.parseInt(p[5])); + } + } + record Start(Structure structure, StructureStart start) {} + + /** finalizeSpawn's random: the same answer every time. */ + static final class Neutral implements RandomSource { + @Override public RandomSource fork() { return this; } + @Override public net.minecraft.world.level.levelgen.PositionalRandomFactory forkPositional() { throw new UnsupportedOperationException(); } + @Override public void setSeed(long seed) {} + @Override public int nextInt() { return 0; } + @Override public int nextInt(int bound) { return 0; } + @Override public long nextLong() { return 0L; } + @Override public boolean nextBoolean() { return false; } + @Override public float nextFloat() { return 0.75F; } + @Override public double nextDouble() { return 0.75; } + @Override public double nextGaussian() { return 0.0; } + } + + static final class Lvl extends ServerLevel { + Lvl() { super(null, null, null, null, null, null, false, 0L, List.of(), false); } + RegistryAccess registries; + net.minecraft.server.MinecraftServer fake; + @Override public net.minecraft.server.MinecraftServer getServer() { return fake; } + List> blocks = new ArrayList<>(); + List> lights = new ArrayList<>(); + List>> biomes = new ArrayList<>(); + Holder biome; + int sky, block, darken, sea; + boolean thunder; + long seed; + Difficulty difficulty; + DimensionType dimensionType; + ResourceKey dimensionKey; + LevelData.RespawnData respawn; + Set unloaded = new HashSet<>(); + List starts = new ArrayList<>(); + List entities = new ArrayList<>(); + List playerList = new ArrayList<>(); + net.minecraft.world.attribute.EnvironmentAttributeSystem attributes; + BiomeManager biomeManager; + WorldBorder border = new WorldBorder(); + GameRules rules; + ServerChunkCache chunkSource; + StructureManager structures; + RandomSource neutral = new Neutral(); + List log; + boolean logColumns; + int next = 1; + @Override public RegistryAccess registryAccess() { return registries; } + @Override public Difficulty getDifficulty() { return difficulty; } + @Override public long getGameTime() { return 0L; } + @Override public int getNextEntityId() { return next++; } + @Override public net.minecraft.world.flag.FeatureFlagSet enabledFeatures() { return net.minecraft.world.flag.FeatureFlags.REGISTRY.allFlags(); } + @Override public BlockState getBlockState(BlockPos pos) { + for (int i = blocks.size() - 1; i >= 0; i--) + if (blocks.get(i).getKey().has(pos.getX(), pos.getY(), pos.getZ())) return blocks.get(i).getValue(); + return Blocks.AIR.defaultBlockState(); + } + @Override public FluidState getFluidState(BlockPos pos) { return getBlockState(pos).getFluidState(); } + int[] light(BlockPos pos) { + for (int i = lights.size() - 1; i >= 0; i--) + if (lights.get(i).getKey().has(pos.getX(), pos.getY(), pos.getZ())) return lights.get(i).getValue(); + return new int[] {sky, block}; + } + @Override public int getBrightness(LightLayer layer, BlockPos pos) { int[] l = light(pos); return layer == LightLayer.SKY ? l[0] : l[1]; } + @Override public int getRawBrightness(BlockPos pos, int darkening) { int[] l = light(pos); return Math.max(l[1], l[0] - darkening); } + @Override public int getSkyDarken() { return darken; } + @Override public boolean isThundering() { return thunder; } + @Override public int getSeaLevel() { return sea; } + @Override public int getHeight(Heightmap.Types type, int x, int z) { + if (logColumns) log.add("col " + x + " " + z); + for (int y = getMaxY(); y >= getMinY(); y--) + if (type.isOpaque().test(getBlockState(new BlockPos(x, y, z)))) return y + 1; + return getMinY(); + } + @Override public long getSeed() { return seed; } + @Override public DimensionType dimensionType() { return dimensionType; } + @Override public ResourceKey dimension() { return dimensionKey; } + Holder biomeAt(int x, int y, int z) { + for (int i = biomes.size() - 1; i >= 0; i--) + if (biomes.get(i).getKey().has(x, y, z)) return biomes.get(i).getValue(); + return biome; + } + @Override public Holder getBiome(BlockPos pos) { return biomeAt(pos.getX(), pos.getY(), pos.getZ()); } + @Override public BiomeManager getBiomeManager() { return biomeManager; } + @Override public net.minecraft.world.attribute.EnvironmentAttributeSystem environmentAttributes() { return attributes; } + @Override public WorldBorder getWorldBorder() { return border; } + @Override public GameRules getGameRules() { return rules; } + @Override public RandomSource getRandom() { return neutral; } + @Override public ServerChunkCache getChunkSource() { return chunkSource; } + @Override public StructureManager structureManager() { return structures; } + @Override public LevelData.RespawnData getRespawnData() { return respawn; } + @Override public boolean canSpawnEntitiesInChunk(ChunkPos pos) { return !unloaded.contains(pos.pack()); } + @Override public DifficultyInstance getCurrentDifficultyAt(BlockPos pos) { return new DifficultyInstance(difficulty, 0L, 0L, 0.0F); } + @Override public BlockGetter getChunkForCollisions(int chunkX, int chunkZ) { return this; } + @Override public boolean containsAnyLiquid(AABB box) { + BlockPos lo = BlockPos.containing(box.minX, box.minY, box.minZ), hi = BlockPos.containing(box.maxX, box.maxY, box.maxZ); + for (BlockPos p : BlockPos.betweenClosed(lo, hi)) if (!getBlockState(p).getFluidState().isEmpty()) return true; + return false; + } + @SuppressWarnings({"unchecked", "rawtypes"}) + @Override public List players() { return playerList; } + @Override public Iterable getAllEntities() { return entities; } + @Override public List getEntities(Entity except, AABB bb, Predicate selector) { + List out = new ArrayList<>(); + for (Entity e : entities) if (e != except && e.getBoundingBox().intersects(bb) && selector.test(e)) out.add(e); + return out; + } + @Override public List getEntities(EntityTypeTest type, AABB bb, Predicate selector) { + List out = new ArrayList<>(); + for (Entity e : entities) { + T t = type.tryCast(e); + if (t != null && e.getBoundingBox().intersects(bb) && selector.test(t)) out.add(t); + } + return out; + } + @Override public boolean addFreshEntity(Entity entity) { entities.add(entity); return true; } + @Override public void addFreshEntityWithPassengers(Entity entity) { entity.getSelfAndPassengers().forEach(this::addFreshEntity); } + @Override public Player getNearestPlayer(double x, double y, double z, double maxDist, boolean filter) { + if (maxDist == -1.0 && !filter) log.add("try " + (int) Math.floor(x) + " " + (int) Math.floor(z)); + return super.getNearestPlayer(x, y, z, maxDist, filter); + } + } + + static final class Chunk extends LevelChunk { + Chunk() { super((Level) null, (ChunkPos) null); } + Lvl level; + ChunkPos at; + @Override public ChunkPos getPos() { return at; } + @Override public BlockState getBlockState(BlockPos pos) { return level.getBlockState(pos); } + @Override public FluidState getFluidState(BlockPos pos) { return level.getFluidState(pos); } + @Override public int getHeight(Heightmap.Types type, int x, int z) { return level.getHeight(type, x, z); } + } + + static final class Source extends ServerChunkCache { + Source() { super(null, null, null, null, null, null, 0, 0, false, null); } + ChunkGenerator generator; + @Override public ChunkGenerator getGenerator() { return generator; } + } + + /** getPlayersCloseForSpawning: none past the natural spawn chunk tracker's 8 chunks, else non-spectators within 128 blocks of the chunk centre. */ + static final class Map2 extends ChunkMap { + Map2() { super(null, null, null, null, null, null, null, null, null, null, 0, false); } + Lvl level; + @Override public List getPlayersCloseForSpawning(ChunkPos pos) { + boolean tracked = false; + for (ServerPlayer p : level.playerList) { + int px = Mth.floor(p.getX()) >> 4, pz = Mth.floor(p.getZ()) >> 4; + if (Math.max(Math.abs(px - pos.x()), Math.abs(pz - pos.z())) <= 8) tracked = true; + } + if (!tracked) return List.of(); + List out = new ArrayList<>(); + for (ServerPlayer p : level.playerList) { + double xd = pos.x() * 16 + 8 - p.getX(), zd = pos.z() * 16 + 8 - p.getZ(); + if (!p.isSpectator() && xd * xd + zd * zd < 16384.0) out.add(p); + } + return out; + } + } + + static final class Structures extends StructureManager { + Structures() { super(null, null, null); } + Lvl level; + List referenced(int x, int z) { + int cx = x >> 4, cz = z >> 4; + List out = new ArrayList<>(); + for (Start s : level.starts) { + BoundingBox b = s.start().getBoundingBox(); + if (b.intersects(cx * 16, cz * 16, cx * 16 + 15, cz * 16 + 15)) out.add(s); + } + return out; + } + @Override public Map getAllStructuresAt(int x, int y, int z) { + Map out = new LinkedHashMap<>(); + for (Start s : referenced(x, z)) out.computeIfAbsent(s.structure(), k -> new LongOpenHashSet()).add(level.starts.indexOf(s)); + return out; + } + @Override public void fillStartsForStructure(Structure structure, LongSet references, Consumer consumer) { + for (long i : references) { + StructureStart start = level.starts.get((int) i).start(); + if (start.isValid()) consumer.accept(start); + } + } + @Override public List startsForStructure(int sectionX, int sectionZ, Structure structure) { + List out = new ArrayList<>(); + for (Start s : referenced(sectionX << 4, sectionZ << 4)) if (s.structure() == structure) out.add(s.start()); + return out; + } + @Override public RegistryAccess registryAccess() { return level.registries; } + } + + static final class Piece extends StructurePiece { + Piece(BoundingBox box) { super(null, 0, box); } + @Override protected void addAdditionalSaveData(StructurePieceSerializationContext context, CompoundTag tag) {} + @Override public void postProcess(WorldGenLevel level, StructureManager structureManager, ChunkGenerator generator, RandomSource random, + BoundingBox chunkBB, ChunkPos chunkPos, BlockPos referencePos) {} + } + + static final class OraclePlayer extends ServerPlayer { + OraclePlayer() { super(null, null, null, null); } + GameType mode; + @Override public boolean isSpectator() { return mode == GameType.SPECTATOR; } + @Override public boolean isCreative() { return mode == GameType.CREATIVE; } + @Override public GameType gameMode() { return mode; } + } + + static String bits(double v) { return Long.toHexString(Double.doubleToRawLongBits(v)); } + /** + * A spawned mob's yaw. A cube mob whose size finalizeSpawn changed took its + * constructor's head rotation (AbstractCubeMob.onSyncedDataUpdated), drawn + * from the entity's own unseeded random: not reproducible, so not compared. + */ + static String yaw(Entity e) { + if (e instanceof net.minecraft.world.entity.monster.cubemob.AbstractCubeMob cube && cube.getSize() != 1) return "head"; + return bits(e.getYRot()); + } + static String bits(float v) { return Integer.toHexString(Float.floatToRawIntBits(v)); } + + public static void main(String[] args) throws Exception { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + var unsafeField = Class.forName("sun.misc.Unsafe").getDeclaredField("theUnsafe"); + unsafeField.setAccessible(true); + unsafe = unsafeField.get(null); + allocate = unsafe.getClass().getMethod("allocateInstance", Class.class); + putObject = unsafe.getClass().getMethod("putObject", Object.class, long.class, Object.class); + objectFieldOffset = unsafe.getClass().getMethod("objectFieldOffset", Field.class); + var repo = ServerPacksSource.createVanillaTrustedRepository(); + repo.reload(); + repo.setSelected(List.of("vanilla")); + var resources = new MultiPackResourceManager(PackType.SERVER_DATA, repo.openAllSelected()); + var layers = RegistryLayer.createRegistryAccess(); + var staticTags = TagLoader.loadTagsForExistingRegistries(resources, layers.getLayer(RegistryLayer.STATIC)); + var context = TagLoader.buildUpdatedLookups(layers.getAccessForLoading(RegistryLayer.WORLD), staticTags); + RegistryAccess.Frozen world = RegistryDataLoader.load(resources, context, RegistryDataLoader.WORLD_REGISTRIES, Runnable::run).join(); + var worldLayers = layers.replaceFrom(RegistryLayer.WORLD, world); + var loaded = net.minecraft.server.ReloadableServerRegistries.reload(worldLayers, staticTags, resources, Runnable::run).join(); + HolderLookup.Provider lookup = loaded.lookupWithUpdatedTags(); + var components = BuiltInRegistries.DATA_COMPONENT_INITIALIZERS.build(lookup); + staticTags.forEach(Registry.PendingTags::apply); + components.forEach(net.minecraft.core.component.DataComponentInitializers.PendingComponents::apply); + RegistryAccess access = loaded.layers().compositeAccess(); + var biomes = access.lookupOrThrow(Registries.BIOME); + var dimensions = access.lookupOrThrow(Registries.DIMENSION_TYPE); + var blockLookup = access.lookupOrThrow(Registries.BLOCK); + var structureLookup = access.lookupOrThrow(Registries.STRUCTURE); + + // A server that only answers debug subscribers (path navigation asks). + var server = (net.minecraft.server.MinecraftServer) alloc(net.minecraft.server.dedicated.DedicatedServer.class); + put(server, net.minecraft.server.MinecraftServer.class, "debugSubscribers", alloc(net.minecraft.util.debug.ServerDebugSubscribers.class)); + Lvl level = (Lvl) alloc(Lvl.class); + level.fake = server; + put(level, ServerLevel.class, "server", server); + level.registries = access; + level.blocks = new ArrayList<>(); + level.lights = new ArrayList<>(); + level.biomes = new ArrayList<>(); + level.unloaded = new HashSet<>(); + level.starts = new ArrayList<>(); + level.entities = new ArrayList<>(); + level.playerList = new ArrayList<>(); + level.border = new WorldBorder(); + level.neutral = new Neutral(); + level.rules = new GameRules(net.minecraft.world.flag.FeatureFlags.REGISTRY.allFlags()); + level.biomeManager = new BiomeManager((x, y, z) -> level.biome, 0L) { + @Override public Holder getBiome(BlockPos pos) { return level.biomeAt(pos.getX(), pos.getY(), pos.getZ()); } + @Override public Holder getBiome(int x, int y, int z) { return level.biomeAt(x, y, z); } + }; + Source source = (Source) alloc(Source.class); + source.generator = (ChunkGenerator) alloc(FlatLevelSource.class); + level.chunkSource = source; + Structures structures = (Structures) alloc(Structures.class); + structures.level = level; + level.structures = structures; + Map2 chunkMap = (Map2) alloc(Map2.class); + chunkMap.level = level; + + Method createState = NaturalSpawner.class.getMethod("createState", int.class, ServerLevel.class, NaturalSpawner.ChunkGetter.class, LocalMobCapCalculator.class); + Method canSpawn = NaturalSpawner.SpawnState.class.getDeclaredMethod("canSpawn", EntityType.class, Level.class, BlockPos.class, net.minecraft.world.level.chunk.ChunkAccess.class); + canSpawn.setAccessible(true); + Method afterSpawn = NaturalSpawner.SpawnState.class.getDeclaredMethod("afterSpawn", Mob.class, net.minecraft.world.level.chunk.ChunkAccess.class); + afterSpawn.setAccessible(true); + Method local = NaturalSpawner.SpawnState.class.getDeclaredMethod("canSpawnForCategoryLocal", MobCategory.class, ChunkPos.class); + local.setAccessible(true); + Method randomPos = NaturalSpawner.class.getDeclaredMethod("getRandomPosWithin", Level.class, LevelChunk.class); + randomPos.setAccessible(true); + Field entityId = Entity.class.getDeclaredField("id"); + entityId.setAccessible(true); + + List lines = Files.readAllLines(Path.of(args[0])); + try (var w = new PrintWriter(Files.newBufferedWriter(Path.of(args[1]), StandardCharsets.UTF_8))) { + for (String raw : lines) { + if (raw.isBlank()) continue; + Map c = new LinkedHashMap<>(); + for (String token : raw.trim().split("\\s+")) { + int eq = token.indexOf('='); + if (eq < 0) c.put("name", token); else c.put(token.substring(0, eq), token.substring(eq + 1)); + } + List log = new ArrayList<>(); + level.log = log; + level.logColumns = false; + try { + level.dimensionKey = ResourceKey.create(Registries.DIMENSION, Identifier.parse(c.getOrDefault("dim", "minecraft:overworld"))); + level.dimensionType = dimensions.getOrThrow(ResourceKey.create(Registries.DIMENSION_TYPE, level.dimensionKey.identifier())).value(); + level.blocks.clear(); + for (int i = 0; c.containsKey("k" + i); i++) { + String[] p = c.get("k" + i).split("/", 2); + level.blocks.add(Map.entry(Box.parse(p[0]), net.minecraft.commands.arguments.blocks.BlockStateParser.parseForBlock(blockLookup, p[1], false).blockState())); + } + level.lights.clear(); + for (int i = 0; c.containsKey("l" + i); i++) { + String[] p = c.get("l" + i).split("/", 2); + String[] v = p[1].split(","); + level.lights.add(Map.entry(Box.parse(p[0]), new int[] {Integer.parseInt(v[0]), Integer.parseInt(v[1])})); + } + level.biome = biomes.getOrThrow(ResourceKey.create(Registries.BIOME, Identifier.parse(c.getOrDefault("biome", "minecraft:plains")))); + level.biomes.clear(); + for (int i = 0; c.containsKey("b" + i); i++) { + String[] p = c.get("b" + i).split("/", 2); + level.biomes.add(Map.entry(Box.parse(p[0]), biomes.getOrThrow(ResourceKey.create(Registries.BIOME, Identifier.parse(p[1]))))); + } + level.sky = Integer.parseInt(c.getOrDefault("sky", "15")); + level.block = Integer.parseInt(c.getOrDefault("light", "0")); + level.darken = Integer.parseInt(c.getOrDefault("darken", "0")); + level.thunder = c.getOrDefault("thunder", "0").equals("1"); + level.sea = Integer.parseInt(c.getOrDefault("sea", "63")); + level.seed = Long.parseLong(c.getOrDefault("wseed", "0")); + level.difficulty = Difficulty.byName(c.getOrDefault("difficulty", "normal")); + level.rules.set(net.minecraft.world.level.gamerules.GameRules.SPAWN_MOBS, !c.getOrDefault("rules", "1").equals("0"), null); + // The dimension and biome layers; the moon timeline's surface slime chance as a constant. + level.attributes = net.minecraft.world.attribute.EnvironmentAttributeSystem.builder().addStaticLayers(level) + .addConstantLayer(net.minecraft.world.attribute.EnvironmentAttributeMap.builder() + .set(net.minecraft.world.attribute.EnvironmentAttributes.SURFACE_SLIME_SPAWN_CHANCE, Float.parseFloat(c.getOrDefault("slime", "0.0"))).build()) + .build(); + level.unloaded.clear(); + if (c.containsKey("unloaded")) for (String u : c.get("unloaded").split(";")) { + String[] p = u.split(","); + level.unloaded.add(ChunkPos.pack(Integer.parseInt(p[0]), Integer.parseInt(p[1]))); + } + level.starts.clear(); + for (int i = 0; c.containsKey("s" + i); i++) { + String[] p = c.get("s" + i).split("/", 2); + Structure structure = structureLookup.getValue(Identifier.parse(p[0])); + List pieces = new ArrayList<>(); + for (String b : p[1].split(";")) { + Box bx = Box.parse(b); + pieces.add(new Piece(new BoundingBox(bx.x0(), bx.y0(), bx.z0(), bx.x1(), bx.y1(), bx.z1()))); + } + level.starts.add(new Start(structure, new StructureStart(structure, new ChunkPos(0, 0), 0, new PiecesContainer(pieces)))); + } + String[] r = c.getOrDefault("respawn", "0,-1000,0/minecraft:overworld").split("/"); + String[] rp = r[0].split(","); + level.respawn = new LevelData.RespawnData(GlobalPos.of(ResourceKey.create(Registries.DIMENSION, Identifier.parse(r[1])), + new BlockPos(Integer.parseInt(rp[0]), Integer.parseInt(rp[1]), Integer.parseInt(rp[2]))), 0.0F, 0.0F); + level.playerList.clear(); + for (int i = 0; c.containsKey("p" + i); i++) { + String[] p = c.get("p" + i).split("/"); + String[] v = p[0].split(","); + OraclePlayer player = (OraclePlayer) alloc(OraclePlayer.class); + player.mode = GameType.byName(p.length > 1 ? p[1] : "survival"); + entityId.setInt(player, 100000 + i); + put(player, Entity.class, "position", new Vec3(Double.parseDouble(v[0]), Double.parseDouble(v[1]), Double.parseDouble(v[2]))); + level.playerList.add(player); + } + level.entities.clear(); + for (int i = 0; c.containsKey("e" + i); i++) { + String[] p = c.get("e" + i).split("/"); + String[] v = p[1].split(","); + Entity e = BuiltInRegistries.ENTITY_TYPE.getValue(Identifier.parse(p[0])).create(level, EntitySpawnReason.LOAD); + if (e == null) continue; // monsters do not exist in peaceful + e.setPos(Double.parseDouble(v[0]), Double.parseDouble(v[1]), Double.parseDouble(v[2])); + if (p.length > 2 && p[2].equals("p") && e instanceof Mob mob) mob.setPersistenceRequired(); + level.entities.add(e); + } + RandomSource mainRandom = RandomSource.create(Long.parseLong(c.getOrDefault("seed", "0"))); + put(level, Level.class, "random", mainRandom); + String mode = c.getOrDefault("mode", "chunk"); + String[] cp = c.getOrDefault("chunk", "0,0").split(","); + Chunk chunk = (Chunk) alloc(Chunk.class); + chunk.level = level; + chunk.at = new ChunkPos(Integer.parseInt(cp[0]), Integer.parseInt(cp[1])); + if (mode.equals("gen")) { + level.logColumns = true; + int n = level.entities.size(); + RandomSource random = RandomSource.create(Long.parseLong(c.getOrDefault("gseed", "0"))); + BlockPos sourcePos = new BlockPos(chunk.at.getMinBlockX(), level.getMaxY(), chunk.at.getMinBlockZ()); + NaturalSpawner.spawnMobsForChunkGeneration(level, sourcePos, chunk.at, random); + level.logColumns = false; + for (Entity e : level.entities.subList(n, level.entities.size())) + if (e.getVehicle() == null) + log.add("spawn " + BuiltInRegistries.ENTITY_TYPE.getKey(e.getType()) + " " + bits(e.getX()) + " " + bits(e.getY()) + " " + bits(e.getZ()) + " " + yaw(e)); + log.add("next " + random.nextInt(1000)); + } else { + Map chunks = new HashMap<>(); + NaturalSpawner.ChunkGetter getter = (key, consumer) -> { + if (level.unloaded.contains(key)) return; + Chunk k = chunks.computeIfAbsent(key, kk -> { + try { + Chunk made = (Chunk) alloc(Chunk.class); + made.level = level; + made.at = ChunkPos.unpack(kk); + return made; + } catch (Exception ex) { throw new RuntimeException(ex); } + }); + consumer.accept(k); + }; + var state = (NaturalSpawner.SpawnState) createState.invoke(null, Integer.parseInt(c.getOrDefault("chunkcount", "289")), level, getter, new LocalMobCapCalculator(chunkMap)); + NaturalSpawner.SpawnPredicate extra = (type, lvl, pos, ch) -> { + try { + boolean ok = (boolean) canSpawn.invoke(state, type, lvl, pos, ch); + log.add("test " + BuiltInRegistries.ENTITY_TYPE.getKey(type) + " " + pos.getX() + " " + pos.getY() + " " + pos.getZ() + " " + ok); + return ok; + } catch (Exception ex) { throw new RuntimeException(ex); } + }; + NaturalSpawner.AfterSpawnCallback after = (mob, ch) -> { + log.add("spawn " + BuiltInRegistries.ENTITY_TYPE.getKey(mob.getType()) + " " + bits(mob.getX()) + " " + bits(mob.getY()) + " " + bits(mob.getZ()) + " " + yaw(mob)); + try { afterSpawn.invoke(state, mob, ch); } catch (Exception ex) { throw new RuntimeException(ex); } + }; + if (mode.equals("pos")) { + MobCategory category = MobCategory.valueOf(c.get("cat").toUpperCase(Locale.ROOT)); + String[] sp = c.get("start").split(","); + BlockPos start = new BlockPos(Integer.parseInt(sp[0]), Integer.parseInt(sp[1]), Integer.parseInt(sp[2])); + NaturalSpawner.spawnCategoryForPosition(category, level, chunk, start, extra, after); + } else { + List categories = NaturalSpawner.getFilteredSpawningCategories(state, c.getOrDefault("enemies", "1").equals("1"), c.getOrDefault("persistent", "1").equals("1")); + StringBuilder names = new StringBuilder(); + for (MobCategory m : categories) names.append(names.isEmpty() ? "" : ",").append(m.getSerializedName()); + log.add("cats " + names); + // spawnForChunk, then spawnCategoryForChunk with its start logged. + for (MobCategory category : categories) { + boolean ok = (boolean) local.invoke(state, category, chunk.at); + log.add("local " + category.getSerializedName() + " " + ok); + if (!ok) continue; + BlockPos start = (BlockPos) randomPos.invoke(null, level, chunk); + log.add("start " + category.getSerializedName() + " " + start.getX() + " " + start.getY() + " " + start.getZ()); + if (start.getY() >= level.getMinY() + 1) NaturalSpawner.spawnCategoryForPosition(category, level, chunk, start, extra, after); + } + } + log.add("next " + mainRandom.nextInt(1000)); + } + w.println(c.get("name") + " " + String.join(" ; ", log)); + } catch (Throwable t) { + Throwable cause = t; + while ((cause instanceof InvocationTargetException || cause instanceof RuntimeException && cause.getCause() != null) && cause.getCause() != null) cause = cause.getCause(); + StringBuilder line = new StringBuilder(c.get("name") + " error " + cause); + if (System.getenv("ORACLE_TRACE") != null) for (var frame : cause.getStackTrace()) line.append("\n at ").append(frame); + w.println(line); + } + } + } + System.exit(0); + } +} diff --git a/tools/test_natural_spawn_parity.py b/tools/test_natural_spawn_parity.py new file mode 100644 index 000000000..d6db51fba --- /dev/null +++ b/tools/test_natural_spawn_parity.py @@ -0,0 +1,602 @@ +#!/usr/bin/env python3 +"""Compare natural spawning (NaturalSpawner) with vanilla. + +Java (VanillaNaturalSpawnOracle) runs vanilla's NaturalSpawner on a stand-in +ServerLevel built from each case: boxes of blocks, light and biomes, structure +starts with their pieces, existing mobs, players, the world spawn point and +unloaded chunks. Rust (natural_spawn_fixture) runs +horizon_client::mobs::spawn_selection on the same world. Three modes: + +- chunk: createState over the case's mobs (category counts, spawn potential + charges, local caps), getFilteredSpawningCategories under the global caps, + then spawnForChunk (local caps, getRandomPosWithin, spawnCategoryForPosition); +- pos: spawnCategoryForPosition from a chosen start (surfaces, caves, water, + lava, structures); +- gen: spawnMobsForChunkGeneration. + +Each case's log lists every attempt (the nearest player lookup), each type that +passed isValidSpawnPostitionForType with the spawn potential's answer, each +spawn with its exact position and yaw bits, the heightmap columns of +chunk-generation attempts, and the next draw of the spawner's random, which +pins every draw in between. Worlds cover the overworld, nether and end, their +biomes, caves, water and lava, block and sky light, day, night and thunder, +peaceful to hard, spawn costs (soul sand valley, warped forest), structure +spawn overrides (fortress, swamp hut, monument, outpost, trial chambers, +ancient city), mob caps, players near and far, spectators and the world spawn. +""" +import argparse +import json +import os +from pathlib import Path +import platform +import random +import subprocess +try: + from . import lab, test_rust_parity as base +except ImportError: + import lab + import test_rust_parity as base + +R = 48 # world boxes span [-R, 16 + R) around chunk (0, 0) + +OVERWORLD = { + # biome: (top block, filler) + 'minecraft:plains': ('minecraft:grass_block', 'minecraft:dirt'), + 'minecraft:forest': ('minecraft:grass_block', 'minecraft:dirt'), + 'minecraft:birch_forest': ('minecraft:grass_block', 'minecraft:dirt'), + 'minecraft:dark_forest': ('minecraft:grass_block', 'minecraft:dirt'), + 'minecraft:flower_forest': ('minecraft:grass_block', 'minecraft:dirt'), + 'minecraft:sunflower_plains': ('minecraft:grass_block', 'minecraft:dirt'), + 'minecraft:meadow': ('minecraft:grass_block', 'minecraft:dirt'), + 'minecraft:cherry_grove': ('minecraft:grass_block', 'minecraft:dirt'), + 'minecraft:taiga': ('minecraft:grass_block', 'minecraft:dirt'), + 'minecraft:old_growth_pine_taiga': ('minecraft:podzol', 'minecraft:dirt'), + 'minecraft:snowy_taiga': ('minecraft:grass_block[snowy=true]', 'minecraft:dirt'), + 'minecraft:snowy_plains': ('minecraft:snow_block', 'minecraft:dirt'), + 'minecraft:ice_spikes': ('minecraft:snow_block', 'minecraft:packed_ice'), + 'minecraft:frozen_peaks': ('minecraft:snow_block', 'minecraft:stone'), + 'minecraft:jagged_peaks': ('minecraft:snow_block', 'minecraft:stone'), + 'minecraft:stony_peaks': ('minecraft:stone', 'minecraft:stone'), + 'minecraft:windswept_hills': ('minecraft:grass_block', 'minecraft:stone'), + 'minecraft:desert': ('minecraft:sand', 'minecraft:sandstone'), + 'minecraft:badlands': ('minecraft:red_sand', 'minecraft:terracotta'), + 'minecraft:wooded_badlands': ('minecraft:coarse_dirt', 'minecraft:terracotta'), + 'minecraft:savanna': ('minecraft:grass_block', 'minecraft:dirt'), + 'minecraft:jungle': ('minecraft:grass_block', 'minecraft:dirt'), + 'minecraft:bamboo_jungle': ('minecraft:grass_block', 'minecraft:dirt'), + 'minecraft:sparse_jungle': ('minecraft:grass_block', 'minecraft:dirt'), + 'minecraft:swamp': ('minecraft:grass_block', 'minecraft:dirt'), + 'minecraft:mangrove_swamp': ('minecraft:mud', 'minecraft:mud'), + 'minecraft:mushroom_fields': ('minecraft:mycelium', 'minecraft:dirt'), + 'minecraft:beach': ('minecraft:sand', 'minecraft:sand'), + 'minecraft:snowy_beach': ('minecraft:sand', 'minecraft:sand'), + 'minecraft:stony_shore': ('minecraft:stone', 'minecraft:stone'), + 'minecraft:river': ('minecraft:sand', 'minecraft:dirt'), + 'minecraft:pale_garden': ('minecraft:grass_block', 'minecraft:dirt'), + 'minecraft:grove': ('minecraft:snow_block', 'minecraft:dirt'), + 'minecraft:snowy_slopes': ('minecraft:snow_block', 'minecraft:stone'), + 'minecraft:savanna_plateau': ('minecraft:grass_block', 'minecraft:dirt'), + 'minecraft:eroded_badlands': ('minecraft:red_sand', 'minecraft:terracotta'), +} +OCEANS = ['minecraft:ocean', 'minecraft:deep_ocean', 'minecraft:warm_ocean', 'minecraft:lukewarm_ocean', + 'minecraft:deep_lukewarm_ocean', 'minecraft:cold_ocean', 'minecraft:deep_cold_ocean', + 'minecraft:frozen_ocean', 'minecraft:deep_frozen_ocean', 'minecraft:river', 'minecraft:frozen_river'] +CAVES = ['minecraft:lush_caves', 'minecraft:dripstone_caves', 'minecraft:deep_dark', 'minecraft:sulfur_caves'] +NETHER = { + 'minecraft:nether_wastes': 'minecraft:netherrack', + 'minecraft:crimson_forest': 'minecraft:crimson_nylium', + 'minecraft:warped_forest': 'minecraft:warped_nylium', + 'minecraft:soul_sand_valley': 'minecraft:soul_sand', + 'minecraft:basalt_deltas': 'minecraft:basalt', +} +END = ['minecraft:the_end', 'minecraft:end_highlands', 'minecraft:end_midlands', 'minecraft:small_end_islands', + 'minecraft:end_barrens'] +MOBS = { + 'monster': ['zombie', 'skeleton', 'creeper', 'spider', 'enderman', 'witch', 'slime', 'drowned', 'husk', 'stray', + 'zombie_villager', 'cave_spider', 'bogged', 'parched'], + 'creature': ['cow', 'pig', 'sheep', 'chicken', 'horse', 'rabbit', 'wolf', 'fox', 'llama', 'polar_bear', 'goat', + 'strider'], + 'ambient': ['bat'], + 'water_creature': ['squid', 'dolphin'], + 'water_ambient': ['cod', 'salmon', 'tropical_fish', 'pufferfish'], + 'axolotls': ['axolotl'], + 'underground_water_creature': ['glow_squid'], + 'nether': ['zombified_piglin', 'piglin', 'hoglin', 'ghast', 'magma_cube', 'skeleton', 'enderman', 'strider', + 'blaze', 'wither_skeleton'], +} +CATEGORIES = ['monster', 'creature', 'ambient', 'axolotls', 'underground_water_creature', 'water_creature', + 'water_ambient'] +TORCH_LIGHT = [0, 0, 0, 1, 4, 7, 8, 9, 12, 14] + + +def java_next_int(seed, bound): + """java.util.Random(seed).nextInt(bound).""" + mask = (1 << 48) - 1 + state = (seed ^ 0x5DEECE66D) & mask + while True: + state = (state * 0x5DEECE66D + 0xB) & mask + bits = state >> 17 + value = bits % bound + if bits - value + bound - 1 < 2**31: + return value + + +def slime_chunk(world_seed, cx, cz): + """WorldgenRandom.seedSlimeChunk(cx, cz, seed, 987234911).nextInt(10) == 0.""" + def i32(v): + return (v + 2**31) % 2**32 - 2**31 + seed = (world_seed + i32(cx * cx * 4987142) + i32(cx * 5947611) + i32(cz * cz) * 4392871 + i32(cz * 389711)) ^ 987234911 + return java_next_int(seed & (2**64 - 1), 10) == 0 + + +def box(x0, y0, z0, x1, y1, z1): + return f'{x0},{y0},{z0},{x1},{y1},{z1}' + + +class World: + """One case's world, written as the oracle's key=value tokens.""" + + def __init__(self, rng): + self.rng = rng + self.kv = {} + self.blocks, self.lights, self.biomes, self.starts, self.entities, self.players = [], [], [], [], [], [] + self.sites = [] # (x, y, z, kind): open positions above a floor, in water or in lava + self.dim = 'minecraft:overworld' + self.surface = 64 + self.min_y = -64 + + def block(self, b, state): + self.blocks.append(f'{b}/{state}') + + def light(self, b, sky, block): + self.lights.append(f'{b}/{sky},{block}') + + def tokens(self): + out = dict(self.kv) + for prefix, items in (('k', self.blocks), ('l', self.lights), ('b', self.biomes), ('s', self.starts), + ('e', self.entities), ('p', self.players)): + for i, v in enumerate(items): + out[f'{prefix}{i}'] = v + return ' '.join(f'{k}={v}' for k, v in out.items()) + + +def overworld(w, rng, biome=None, ocean=None): + lo, hi = -R, 16 + R - 1 + w.kv['dim'] = 'minecraft:overworld' + w.kv['sea'] = 63 + ocean = rng.random() < 0.2 if ocean is None else ocean + if ocean: + biome = biome or rng.choice(OCEANS) + floor = rng.randrange(28, 58) + w.kv['biome'] = biome + w.block(box(lo, -64, lo, hi, floor - 1, hi), 'minecraft:stone') + w.block(box(lo, floor - 1, lo, hi, floor - 1, hi), rng.choice(['minecraft:sand', 'minecraft:gravel', 'minecraft:clay'])) + top = 62 + water = 'minecraft:ice' if 'frozen' in biome and rng.random() < 0.5 else 'minecraft:water' + w.block(box(lo, floor, lo, hi, 62, hi), 'minecraft:water') + if water == 'minecraft:ice': + w.block(box(lo, 62, lo, hi, 62, hi), 'minecraft:ice') + for _ in range(6): + w.sites.append((rng.randrange(-4, 20), rng.randrange(floor, 62), rng.randrange(-4, 20), 'water')) + w.surface = 63 + depth = 62 - floor + w.light(box(lo, -64, lo, hi, 62, hi), max(0, 15 - depth // 2) if rng.random() < 0.5 else 15, 0) + w.light(box(lo, -64, lo, hi, floor - 1, hi), 0, 0) + # An island. + if rng.random() < 0.3: + x, z = rng.randrange(-8, 20), rng.randrange(-8, 20) + w.block(box(x, floor, z, x + rng.randrange(3, 12), 64, z + rng.randrange(3, 12)), 'minecraft:sand') + return + biome = biome or rng.choice(list(OVERWORLD)) + top, filler = OVERWORLD[biome] + surface = rng.randrange(58, 90) + w.surface = surface + 1 + w.kv['biome'] = biome + w.block(box(lo, -64, lo, hi, surface - 4, hi), rng.choice(['minecraft:stone', 'minecraft:stone', 'minecraft:deepslate'])) + w.block(box(lo, surface - 3, lo, hi, surface - 1, hi), filler) + w.block(box(lo, surface, lo, hi, surface, hi), top) + w.light(box(lo, -64, lo, hi, surface, hi), 0, 0) + for _ in range(4): + w.sites.append((rng.randrange(-2, 18), surface + 1, rng.randrange(-2, 18), 'surface')) + # Terraces and hills at other heights. + for _ in range(rng.randrange(0, 4)): + x, z, dy = rng.randrange(lo, hi), rng.randrange(lo, hi), rng.choice([-3, -2, -1, 1, 2, 3, 5]) + sx, sz = rng.randrange(3, 20), rng.randrange(3, 20) + if dy > 0: + w.block(box(x, surface, z, x + sx, surface + dy - 1, z + sz), filler) + w.block(box(x, surface + dy, z, x + sx, surface + dy, z + sz), top) + else: + w.block(box(x, surface + dy + 1, z, x + sx, surface, z + sz), 'minecraft:air') + w.block(box(x, surface + dy, z, x + sx, surface + dy, z + sz), top) + w.light(box(x, surface + dy + 1, z, x + sx, surface, z + sz), 15, 0) + # Ponds, lava, trees and features on the surface. + for _ in range(rng.randrange(0, 3)): + x, z = rng.randrange(lo, hi), rng.randrange(lo, hi) + fluid = 'minecraft:water' if rng.random() < 0.8 else 'minecraft:lava' + d = rng.randrange(1, 4) + w.block(box(x, surface - d + 1, z, x + rng.randrange(2, 9), surface, z + rng.randrange(2, 9)), fluid) + w.sites.append((x, surface, z, 'water' if fluid == 'minecraft:water' else 'lava')) + for _ in range(rng.randrange(0, 4)): + x, z = rng.randrange(lo, hi), rng.randrange(lo, hi) + h = rng.randrange(4, 7) + leaves = rng.choice(['minecraft:oak_leaves', 'minecraft:spruce_leaves', 'minecraft:jungle_leaves', 'minecraft:birch_leaves']) + w.block(box(x - 2, surface + h - 2, z - 2, x + 2, surface + h + 1, z + 2), leaves) + w.block(box(x, surface + 1, z, x, surface + h, z), 'minecraft:oak_log') + w.light(box(x - 2, surface + 1, z - 2, x + 2, surface + h - 3, z + 2), 14, 0) + for _ in range(rng.randrange(0, 6)): + x, z = rng.randrange(lo, hi), rng.randrange(lo, hi) + thing = rng.choice(['minecraft:short_grass', 'minecraft:tall_grass[half=lower]', 'minecraft:snow[layers=1]', + 'minecraft:oak_slab[type=bottom]', 'minecraft:glass', 'minecraft:rail', 'minecraft:torch', + 'minecraft:sweet_berry_bush', 'minecraft:powder_snow', 'minecraft:cobweb', + 'minecraft:white_carpet', 'minecraft:oak_fence', 'minecraft:redstone_block', 'minecraft:magma_block']) + w.block(box(x, surface + 1, z, x + rng.randrange(0, 5), surface + 1, z + rng.randrange(0, 5)), thing) + # Caves: lit or dark rooms underground, some with water, a cave biome. + for _ in range(rng.choice([0, 1, 2, 2, 3, 4, 5])): + x, z = rng.randrange(-20, 14), rng.randrange(-20, 14) + y = rng.randrange(-60, surface - 6) + sx, sy, sz = rng.randrange(4, 24), rng.randrange(2, 8), rng.randrange(4, 24) + y1 = min(y + sy, surface - 2) + w.block(box(x, y, z, x + sx, y1, z + sz), 'minecraft:cave_air' if rng.random() < 0.5 else 'minecraft:air') + w.light(box(x, y, z, x + sx, y1, z + sz), 0, rng.choice(TORCH_LIGHT)) + wet = rng.random() < 0.35 + if wet: + w.block(box(x, y, z, x + sx, y + rng.randrange(1, 4), z + sz), 'minecraft:water') + if rng.random() < 0.3: + w.block(box(x, y - 1, z, x + sx, y - 1, z + sz), rng.choice(['minecraft:clay', 'minecraft:moss_block', 'minecraft:sculk', 'minecraft:dripstone_block', 'minecraft:clay'])) + if rng.random() < 0.45: + w.biomes.append(f'{box(x - 2, y - 2, z - 2, x + sx + 2, y1 + 2, z + sz + 2)}/{rng.choice(CAVES)}') + for _ in range(3): + w.sites.append((rng.randrange(x, x + sx + 1), y + (rng.randrange(0, 2) if wet else 0), rng.randrange(z, z + sz + 1), 'water' if wet else 'cave')) + if rng.random() < 0.2: + # An opening to the sky. + w.block(box(x + 1, y1, z + 1, x + 3, surface, z + 3), 'minecraft:air') + w.light(box(x + 1, y, z + 1, x + 3, surface, z + 3), 15, 0) + + +def nether(w, rng, biome=None): + lo, hi = -R, 16 + R - 1 + biome = biome or rng.choice(list(NETHER)) + w.kv['dim'] = 'minecraft:the_nether' + w.kv['sea'] = 32 + w.kv['biome'] = biome + w.kv['sky'] = 0 + floor = rng.randrange(28, 80) + w.surface = 128 + w.block(box(lo, 0, lo, hi, 0, hi), 'minecraft:bedrock') + w.block(box(lo, 1, lo, hi, floor - 1, hi), 'minecraft:netherrack') + w.block(box(lo, floor, lo, hi, floor, hi), NETHER[biome]) + w.block(box(lo, rng.randrange(100, 120), lo, hi, 126, hi), 'minecraft:netherrack') + w.block(box(lo, 127, lo, hi, 127, hi), 'minecraft:bedrock') + w.light(box(lo, 0, lo, hi, 127, hi), 0, rng.choice([0, 0, 3, 7, 11])) + for _ in range(4): + w.sites.append((rng.randrange(-2, 18), floor + 1, rng.randrange(-2, 18), 'surface')) + if floor <= 34 or rng.random() < 0.4: + x, z = rng.randrange(lo, hi), rng.randrange(lo, hi) + w.block(box(x, min(floor, 20), z, x + rng.randrange(6, 30), 31, z + rng.randrange(6, 30)), 'minecraft:lava') + w.sites.append((x + 2, 31, z + 2, 'lava')) + w.light(box(x, 20, z, x + 30, 40, z + 30), 0, 15) + for _ in range(rng.randrange(0, 4)): + x, z = rng.randrange(lo, hi), rng.randrange(lo, hi) + y = rng.randrange(floor - 10, floor + 20) + w.block(box(x, y, z, x + rng.randrange(3, 14), y, z + rng.randrange(3, 14)), + rng.choice(['minecraft:netherrack', 'minecraft:nether_wart_block', 'minecraft:soul_soil', 'minecraft:magma_block', + 'minecraft:glowstone', 'minecraft:blackstone', 'minecraft:nether_bricks', 'minecraft:fire'])) + if rng.random() < 0.3: + x, z = rng.randrange(lo, hi), rng.randrange(lo, hi) + w.biomes.append(f'{box(x, 0, z, x + rng.randrange(8, 40), 127, z + rng.randrange(8, 40))}/{rng.choice(list(NETHER))}') + return floor + + +def end(w, rng): + lo, hi = -R, 16 + R - 1 + w.kv['dim'] = 'minecraft:the_end' + w.kv['sea'] = 0 + w.kv['sky'] = 0 + w.kv['biome'] = rng.choice(END) + top = rng.randrange(40, 70) + w.surface = top + 1 + w.block(box(lo, top - rng.randrange(5, 30), lo, hi, top, hi), 'minecraft:end_stone') + w.light(box(lo, 0, lo, hi, 255, hi), 0, rng.choice([0, 0, 5, 14])) + for _ in range(4): + w.sites.append((rng.randrange(-2, 18), top + 1, rng.randrange(-2, 18), 'surface')) + + +def structures(w, rng): + dim = w.kv['dim'] + s = w.surface + if dim == 'minecraft:the_nether': + if rng.random() < 0.6: + # Fortress corridors on nether bricks (FORTRESS_ENEMIES and the piece override). + y = rng.randrange(40, 80) + pieces = [] + for _ in range(rng.randrange(1, 4)): + x, z = rng.randrange(-30, 30), rng.randrange(-30, 30) + along = rng.random() < 0.5 + x1, z1 = (x + rng.randrange(10, 40), z + 4) if along else (x + 4, z + rng.randrange(10, 40)) + pieces.append(box(x, y, z, x1, y + 6, z1)) + w.block(box(x, y, z, x1, y, z1), 'minecraft:nether_bricks') + w.block(box(x, y + 1, z, x1, y + 5, z1), 'minecraft:air') + w.light(box(x, y + 1, z, x1, y + 5, z1), 0, rng.choice([0, 7, 11])) + w.block(box(x, y + 6, z, x1, y + 6, z1), 'minecraft:nether_bricks') + w.sites.append((rng.randrange(x, x1 + 1), y + 1, rng.randrange(z, z1 + 1), 'structure')) + w.starts.append('minecraft:fortress/' + ';'.join(pieces)) + if rng.random() < 0.2: + x, z = rng.randrange(-20, 20), rng.randrange(-20, 20) + w.starts.append(f'minecraft:bastion_remnant/{box(x, 30, z, x + 30, 80, z + 30)}') + return + if dim != 'minecraft:overworld': + return + biome = w.kv['biome'] + roll = rng.random() + x, z = rng.randrange(-24, 24), rng.randrange(-24, 24) + if biome in ('minecraft:swamp', 'minecraft:mangrove_swamp') and roll < 0.7: + # Swamp hut: witches and cats only in its piece. + y = s + 1 + w.block(box(x, y, z, x + 6, y, z + 8), 'minecraft:spruce_planks') + w.block(box(x + 1, y + 1, z + 1, x + 5, y + 4, z + 7), 'minecraft:air') + w.light(box(x + 1, y + 1, z + 1, x + 5, y + 4, z + 7), rng.choice([0, 3, 9]), 0) + w.block(box(x, y + 5, z, x + 6, y + 5, z + 8), 'minecraft:spruce_planks') + w.starts.append(f'minecraft:swamp_hut/{box(x, y, z, x + 6, y + 6, z + 8)}') + w.sites.append((x + 3, y + 1, z + 4, 'structure')) + elif 'ocean' in biome and roll < 0.6: + w.starts.append(f'minecraft:monument/{box(x, 39, z, x + 57, 61, z + 57)}') + w.sites.append((x + 8, rng.randrange(40, 61), z + 8, 'structure')) + elif roll < 0.15: + w.starts.append(f'minecraft:pillager_outpost/{box(x, s, z, x + 8, s + 20, z + 8)};{box(x + 10, s, z + 2, x + 14, s + 5, z + 6)}') + w.sites.append((x + 4, s, z + 4, 'structure')) + elif roll < 0.25: + y = rng.randrange(-40, -10) + w.block(box(x, y, z, x + 20, y + 8, z + 20), 'minecraft:air') + w.block(box(x, y - 1, z, x + 20, y - 1, z + 20), 'minecraft:tuff_bricks') + w.light(box(x, y, z, x + 20, y + 8, z + 20), 0, 0) + w.starts.append(f'minecraft:trial_chambers/{box(x, y - 1, z, x + 20, y + 9, z + 20)}') + w.sites.append((x + 5, y, z + 5, 'structure')) + elif roll < 0.32: + y = rng.randrange(-52, -30) + w.block(box(x, y, z, x + 30, y + 10, z + 30), 'minecraft:air') + w.block(box(x, y - 1, z, x + 30, y - 1, z + 30), 'minecraft:deepslate_tiles') + w.light(box(x, y, z, x + 30, y + 10, z + 30), 0, 0) + w.biomes.append(f'{box(x - 4, y - 4, z - 4, x + 34, y + 14, z + 34)}/minecraft:deep_dark') + w.starts.append(f'minecraft:ancient_city/{box(x, y - 1, z, x + 30, y + 11, z + 30)}') + w.sites.append((x + 5, y, z + 5, 'structure')) + elif roll < 0.4: + w.starts.append(f'minecraft:village_plains/{box(x, s - 2, z, x + 12, s + 8, z + 12)}') + + +def mob_entities(w, rng): + dim = w.kv['dim'] + pool = MOBS['nether'] if dim == 'minecraft:the_nether' else sum((MOBS[c] for c in CATEGORIES if c in MOBS), []) + n = rng.choice([0, 0, 1, 3, 6, 12, 25, 80]) + for _ in range(n): + t = rng.choice(pool) + x, z = rng.uniform(-R + 2, 16 + R - 2), rng.uniform(-R + 2, 16 + R - 2) + y = rng.choice([w.surface, w.surface, rng.randrange(w.min_y + 2, w.surface + 1)]) + persistent = '/p' if rng.random() < 0.2 else '' + w.entities.append(f'minecraft:{t}/{x:.3f},{y},{z:.3f}{persistent}') + if w.kv.get('biome') in ('minecraft:soul_sand_valley', 'minecraft:warped_forest') or any('soul_sand_valley' in b or 'warped' in b for b in w.biomes): + for _ in range(rng.randrange(0, 10)): + t = rng.choice(['skeleton', 'ghast', 'enderman', 'strider']) + w.entities.append(f'minecraft:{t}/{rng.uniform(-10, 26):.3f},{w.sites[0][1]},{rng.uniform(-10, 26):.3f}') + # Clusters right where spawns happen (obstruction and spawn potential). + if rng.random() < 0.3: + t = rng.choice(pool) + for _ in range(rng.randrange(1, 8)): + w.entities.append(f'minecraft:{t}/{rng.randrange(0, 16) + 0.5},{w.surface},{rng.randrange(0, 16) + 0.5}') + + +def place_players(w, rng): + count = rng.choice([1, 1, 1, 1, 2, 3]) + for _ in range(count): + kind = rng.random() + if kind < 0.75: + # 24 to ~100 blocks from the chunk: most attempts are allowed. + ang, dist = rng.uniform(0, 6.283), rng.uniform(26, 100) + elif kind < 0.87: + ang, dist = rng.uniform(0, 6.283), rng.uniform(0, 30) + else: + ang, dist = rng.uniform(0, 6.283), rng.uniform(100, 200) + import math + x, z = 8 + dist * math.cos(ang), 8 + dist * math.sin(ang) + y = w.surface + rng.choice([0, 0, 5, 30, -20]) + mode = rng.choice(['survival'] * 8 + ['creative', 'adventure', 'spectator']) + w.players.append(f'{x:.3f},{y},{z:.3f}/{mode}') + + +def common(w, rng, seed): + w.kv['seed'] = seed + w.kv['wseed'] = rng.randrange(-2**63, 2**63) + if w.kv['dim'] == 'minecraft:overworld': + w.kv['darken'] = rng.choice([0, 0, 2, 4, 7, 11, 11, 11, 11, 11]) + if rng.random() < 0.08: + w.kv['thunder'] = 1 + if rng.random() < 0.3: + w.kv['slime'] = rng.choice([0.0, 0.125, 0.25, 0.5]) + w.kv['difficulty'] = rng.choice(['normal', 'normal', 'hard', 'easy', 'peaceful']) + if rng.random() < 0.15: + w.kv['respawn'] = f'{rng.randrange(-20, 36)},{w.surface},{rng.randrange(-20, 36)}/{w.kv["dim"]}' + if rng.random() < 0.2: + w.kv['unloaded'] = ';'.join(f'{rng.choice([-1, 0, 1])},{rng.choice([-1, 0, 1])}' for _ in range(rng.randrange(1, 5))) + + +def make_world(rng, seed, dim=None, biome=None, ocean=None): + w = World(rng) + dim = dim or rng.choices(['overworld', 'nether', 'end'], [70, 22, 8])[0] + if dim == 'overworld': + overworld(w, rng, biome=biome, ocean=ocean) + elif dim == 'nether': + w.min_y = 0 + nether(w, rng, biome=biome) + else: + w.min_y = 0 + end(w, rng) + structures(w, rng) + mob_entities(w, rng) + place_players(w, rng) + common(w, rng, seed) + return w + + +def corpus(): + rng = random.Random(0x4E5350) + cases = [] + # Whole chunk ticks. + for k in range(3000): + w = make_world(rng, rng.randrange(-2**47, 2**47)) + if rng.random() < 0.2: + w.kv['chunkcount'] = rng.choice([0, 1, 4, 17, 40, 100, 200, 289, 578]) + w.kv['enemies'] = 1 if rng.random() < 0.9 else 0 + w.kv['persistent'] = 1 if rng.random() < 0.5 else 0 + cases.append(f'chunk/{k} mode=chunk {w.tokens()}') + # Chosen starts at sites: surfaces, cave floors, water, lava, structure floors. + for k in range(7000): + w = make_world(rng, rng.randrange(-2**47, 2**47)) + dim = w.kv['dim'] + x, y, z, kind = rng.choice(w.sites) + if kind == 'structure' and rng.random() < 0.5: + x, z = x + rng.randrange(-3, 4), z + rng.randrange(-3, 4) + if rng.random() < 0.1: + y = rng.randrange(w.min_y + 1, w.surface + 2) + if dim == 'minecraft:the_nether': + category = 'creature' if kind == 'lava' or rng.random() < 0.2 else 'monster' + elif dim == 'minecraft:the_end': + category = 'monster' + elif kind == 'water': + category = rng.choice(['water_creature', 'water_ambient', 'axolotls', 'underground_water_creature', 'monster']) + elif kind == 'cave': + category = rng.choice(['monster', 'monster', 'ambient', 'ambient', 'creature']) + elif kind == 'structure': + category = rng.choice(['monster', 'monster', 'creature', 'ambient', 'water_ambient']) + else: + category = rng.choice(['monster', 'creature', 'creature', 'ambient']) + if rng.random() < 0.05: + category = rng.choice(CATEGORIES) + cases.append(f'pos/{k} mode=pos cat={category} start={x},{y},{z} {w.tokens()}') + # Targeted nights and places for the rarer types. + targets = [ + ('minecraft:desert', False, 'surface', 'monster'), ('minecraft:desert', False, 'surface', 'creature'), + ('minecraft:snowy_plains', False, 'surface', 'monster'), ('minecraft:ice_spikes', False, 'surface', 'creature'), + ('minecraft:swamp', False, 'surface', 'monster'), ('minecraft:mangrove_swamp', False, 'surface', 'monster'), + ('minecraft:jungle', False, 'surface', 'monster'), ('minecraft:bamboo_jungle', False, 'surface', 'monster'), + ('minecraft:plains', False, 'surface', 'monster'), ('minecraft:savanna', False, 'surface', 'creature'), + ('minecraft:badlands', False, 'surface', 'creature'), ('minecraft:warm_ocean', True, 'water', 'water_ambient'), + ('minecraft:lukewarm_ocean', True, 'water', 'water_creature'), ('minecraft:deep_ocean', True, 'water', 'monster'), + ('minecraft:frozen_ocean', True, 'water', 'creature'), ('minecraft:lush_caves', None, 'cave', 'axolotls'), + ('minecraft:sulfur_caves', None, 'cave', 'monster'), ('minecraft:dripstone_caves', None, 'cave', 'monster'), + ('minecraft:crimson_forest', 'nether', 'surface', 'monster'), ('slime', None, 'cave', 'monster'), + ('minecraft:river', True, 'water', 'monster'), + ] + for k in range(4000): + biome, ocean, kind, category = rng.choice(targets) + if ocean == 'nether': + w = make_world(rng, rng.randrange(-2**47, 2**47), dim='nether', biome=biome) + elif kind == 'cave': + w = make_world(rng, rng.randrange(-2**47, 2**47), dim='overworld', ocean=False) + else: + w = make_world(rng, rng.randrange(-2**47, 2**47), dim='overworld', biome=biome, ocean=ocean) + w.kv['darken'] = 11 if category == 'monster' else rng.choice([0, 0, 4, 11]) + w.kv.pop('thunder', None) + sites = [t for t in w.sites if t[3] == kind] or w.sites + x, y, z, _ = rng.choice(sites) + if kind == 'cave': + # A dark room under the start, in the cave biome (or a slime chunk). + w.block(box(x - 6, y, z - 6, x + 6, y + 4, z + 6), 'minecraft:cave_air') + floor = 'minecraft:clay' if category == 'axolotls' else rng.choice(['minecraft:stone', 'minecraft:deepslate']) + w.block(box(x - 6, y - 1, z - 6, x + 6, y - 1, z + 6), floor) + if category == 'axolotls' or biome == 'minecraft:dripstone_caves' and rng.random() < 0.5: + w.block(box(x - 6, y, z - 6, x + 6, y + 2, z + 6), 'minecraft:water') + w.light(box(x - 6, y, z - 6, x + 6, y + 4, z + 6), 0, rng.choice([0, 0, 0, 3])) + if biome == 'slime': + y = min(y, rng.randrange(-50, 38)) + w.block(box(x - 6, y, z - 6, x + 6, y + 4, z + 6), 'minecraft:cave_air') + w.block(box(x - 6, y - 1, z - 6, x + 6, y - 1, z + 6), 'minecraft:stone') + w.light(box(x - 6, y, z - 6, x + 6, y + 4, z + 6), 0, 0) + while not slime_chunk(w.kv['wseed'], 0, 0): + w.kv['wseed'] = rng.randrange(-2**63, 2**63) + else: + w.biomes.append(f'{box(x - 8, y - 3, z - 8, x + 8, y + 6, z + 8)}/{biome}') + if biome in ('minecraft:swamp', 'minecraft:mangrove_swamp'): + w.kv['slime'] = rng.choice([0.0, 0.25, 0.5]) + cases.append(f'target/{k} mode=pos cat={category} start={x},{y},{z} {w.tokens()}') + # Chunk generation: creature packs placed on the surface. + for k in range(1500): + w = World(rng) + dim = rng.choices(['overworld', 'nether'], [85, 15])[0] + if dim == 'overworld': + biome = rng.choice(list(OVERWORLD) + ['minecraft:frozen_ocean', 'minecraft:beach'] * 2) + if biome in OVERWORLD: + overworld(w, rng, biome=biome, ocean=False) + else: + overworld(w, rng, biome=biome, ocean=True) + else: + w.min_y = 0 + nether(w, rng) + common(w, rng, rng.randrange(-2**47, 2**47)) + w.kv['gseed'] = rng.randrange(-2**47, 2**47) + if rng.random() < 0.05: + w.kv['rules'] = 0 + w.kv['chunk'] = f'{rng.randrange(-3, 3)},{rng.randrange(-3, 3)}' + cases.append(f'gen/{k} mode=gen {w.tokens()}') + return cases + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--prepare', action='store_true') + args = parser.parse_args() + build = lab.LOCAL / 'rust-natural-spawn-parity' + build.mkdir(parents=True, exist_ok=True) + if args.prepare: + lab.prepare() + lock, jars, libraries = base.verify_artifacts() + resources = lab.LOCAL / 'horizon-client/26.3' + oracle = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaNaturalSpawnOracle.java' + sources = [oracle, Path(__file__), lab.ROOT / 'crates/horizon-client/examples/natural_spawn_fixture.rs', + lab.ROOT / 'crates/horizon-client/src/mobs/spawn_selection.rs', + lab.ROOT / 'crates/horizon-client/src/mobs/spawn_rules.rs'] + tested = base.source_hashes(sources) + inputs = build / 'inputs.txt' + cases = corpus() + inputs.write_text(''.join(line + '\n' for line in cases), encoding='utf-8') + classes = build / 'classes' + classes.mkdir(exist_ok=True) + deps = os.pathsep.join(map(str, libraries)) + + def run(command, name): + with (build / (name + '.log')).open('w', encoding='utf-8') as log: + base.run(list(map(str, command)), stdout=log, stderr=subprocess.STDOUT) + run(['javac', '--release', lock['minecraft']['java_major'], '-proc:none', '-sourcepath', str(lab.ROOT / 'mods/horizon/src/test/java'), + '-cp', str(jars['server']) + os.pathsep + deps, '-d', classes, oracle], 'compile-java') + run(['java', '-Xmx3G', '-cp', os.pathsep.join([str(classes), str(jars['server']), deps]), + 'dev.horizonlab.parity.VanillaNaturalSpawnOracle', inputs, build / 'server.txt'], 'server') + run(['cargo', 'build', '--release', '-p', 'horizon-client', '--example', 'natural_spawn_fixture'], 'compile-rust') + metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--no-deps', '--format-version', '1'], cwd=lab.ROOT, text=True)) + binary = Path(metadata['target_directory']) / 'release/examples/natural_spawn_fixture' + run([binary, resources, inputs, build / 'rust.txt'], 'rust') + vanilla = (build / 'server.txt').read_text().splitlines() + errors = [l for l in vanilla if ' error ' in l] + if errors: + raise SystemExit(f'oracle errors ({len(errors)}): {errors[:5]}') + rust = (build / 'rust.txt').read_text().splitlines() + bad = [(a, b) for a, b in zip(vanilla, rust) if a != b] + if bad or len(vanilla) != len(rust): + report = '\n'.join(f' vanilla {a[:1500]}\n rust {b[:1500]}' for a, b in bad[:20]) + raise SystemExit(f'{len(bad)} of {len(vanilla)} cases differ (rust {len(rust)} lines):\n{report}') + base.ensure_sources_unchanged(sources, tested) + spawns = sum(line.count(' ; spawn ') for line in vanilla) + attempts = sum(line.count(' ; try ') + line.count(' ; col ') for line in vanilla) + tests = sum(line.count(' ; test ') for line in vanilla) + lab.write_json(build / 'report.json', { + 'schema': 1, 'minecraft': lock['minecraft']['version'], 'platform': platform.platform(), 'cases': len(cases), + 'attempts': attempts, 'type_checks_passed': tests, 'spawns': spawns, 'sources_sha256': tested, + 'scope': 'NaturalSpawner: createState (counts, spawn potential, local caps), getFilteredSpawningCategories, ' + 'spawnForChunk, spawnCategoryForPosition (distance and respawn checks, getRandomSpawnMobAt over mobsAt: ' + 'fortress bounds, structure spawn overrides, biome spawns; isValidSpawnPostitionForType with ' + 'SpawnPlacements; isValidPositionForMob with each class\'s checkSpawnRules, checkSpawnObstruction and ' + 'removeWhenFarAway; cluster and group sizes) and spawnMobsForChunkGeneration, with every random draw. ' + 'finalizeSpawn runs with a fixed random in the oracle; its own outcomes are not compared.'}) + print(f'{len(cases):,} natural spawn cases match vanilla ({attempts:,} attempts, {tests:,} type checks passed, ' + f'{spawns:,} spawns)') + + +if __name__ == '__main__': + main()