Merge pull request #11725 from MSedgeMC/test/spawn-obstruction

test: compare mob spawn obstruction
This commit is contained in:
rdbt authored and GitHub committed 2026-11-07 12:32:13 +00:00
commit 8331bedf2b
4 files changed
+158 -5

No files matched your search

@@ -11,6 +11,10 @@ use horizon_client::{
MobTypes,
data::MobData,
spawn_rules::{self, Dimension, Reason, SpawnLevel},
spawn_selection::{
self, Heightmap, MobType as SelectionMobType, NaturalLevel, SpawnCost, StructureAt,
StructureSpawns, spawn_aabb,
},
},
};
use horizon_core::random::{LegacyRandom, RandomSource};
@@ -44,6 +48,9 @@ struct Column<'a> {
slime: f32,
/// A player position and whether it is survival or adventure.
player: Option<([f64; 3], bool)>,
/// `LevelReader.isUnobstructed` for the obstruction family.
unobstructed: bool,
structure_spawns: StructureSpawns,
}
impl SpawnLevel for Column<'_> {
fn state(&self, x: i32, y: i32, z: i32) -> BlockStateId {
@@ -104,6 +111,73 @@ impl SpawnLevel for Column<'_> {
})
}
}
impl NaturalLevel for Column<'_> {
fn min_y(&self) -> i32 {
-64
}
fn max_y(&self) -> i32 {
320
}
fn ambient_light(&self) -> f32 {
0.0
}
fn has_ceiling(&self) -> bool {
false
}
fn mob_type(&self, _entity: &str) -> Option<SelectionMobType> {
None
}
fn biome_spawns(
&self,
_x: i32,
_y: i32,
_z: i32,
) -> Option<&HashMap<String, Vec<horizon_client::biome::SpawnEntry>>> {
None
}
fn spawn_cost(&self, _entity: &str, _x: i32, _y: i32, _z: i32) -> Option<SpawnCost> {
None
}
fn creature_probability(&self, _x: i32, _y: i32, _z: i32) -> f32 {
0.0
}
fn structures_at(
&self,
_x: i32,
_y: i32,
_z: i32,
_wanted: &dyn Fn(&str) -> bool,
) -> Vec<StructureAt> {
Vec::new()
}
fn structure_spawns(&self) -> &StructureSpawns {
&self.structure_spawns
}
fn nearest_player_positions(&self) -> &[[f64; 3]] {
&[]
}
fn respawn(&self) -> Option<[i32; 3]> {
None
}
fn can_spawn_in_chunk(&self, _cx: i32, _cz: i32) -> bool {
true
}
fn no_collision(&self, _bounds: horizon_core::geometry::Aabb) -> bool {
true
}
fn unobstructed(&self, _bounds: horizon_core::geometry::Aabb) -> bool {
self.unobstructed
}
fn spawn_mobs(&self) -> bool {
true
}
fn heightmap(&self, _kind: Heightmap, _x: i32, _z: i32) -> i32 {
self.surface
}
fn pathfindable_land(&self, _x: i32, _y: i32, _z: i32) -> bool {
false
}
}
/// `name[key=value,...]`.
fn parse_state(spec: &str) -> BlockState {
@@ -213,7 +287,24 @@ pub fn main() -> horizon_client::Result<()> {
biome: get("biome", "minecraft:plains"),
slime: get("slime", "0.0").parse()?,
player,
unobstructed: !get("unobstructed", "1").eq("0"),
structure_spawns: StructureSpawns::default(),
};
if c.get("mode") == Some(&"obstruction") {
let s = kind.species;
let bounds = spawn_aabb(
s,
f64::from(x0) + 0.5,
f64::from(y),
f64::from(z0) + 0.5,
kind.width as f32,
kind.height as f32,
);
let obstruction =
spawn_selection::spawn_obstruction(s, &level, bounds, Some(&[]), x0, y, z0);
let _ = writeln!(out, "{name} {obstruction}");
continue;
}
let mut random = LegacyRandom::new(get("seed", "0").parse()?);
let s = kind.species;
let ok = spawn_rules::is_spawn_position_ok(s, &level, x0, y, z0);
@@ -578,7 +578,7 @@ pub fn spawn_aabb(s: Species, x: f64, y: f64, z: f64, width: f32, height: f32) -
/// `checkSpawnObstruction` of a freshly created mob with the given box, with
/// the mobs already `spawned` in the way; None in a generation region, which
/// has no entities (`WorldGenRegion.getEntities`).
pub(crate) fn spawn_obstruction<L: NaturalLevel>(
pub fn spawn_obstruction<L: NaturalLevel>(
s: Species,
l: &L,
bounds: Aabb,
@@ -14,6 +14,7 @@ import net.minecraft.resources.Identifier;
import net.minecraft.resources.RegistryDataLoader;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.Bootstrap;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.RegistryLayer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.packs.PackType;
@@ -21,6 +22,7 @@ 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.util.debug.ServerDebugSubscribers;
import net.minecraft.world.Difficulty;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.player.Player;
@@ -61,6 +63,9 @@ public final class VanillaSpawnRulesOracle {
Map<Integer, BlockState> column = new HashMap<>();
int x0, z0, sky, block, darken, sea, surface;
boolean thunder;
boolean unobstructed = true;
boolean liquid;
MinecraftServer server;
long seed;
Difficulty difficulty;
DimensionType dimension;
@@ -70,6 +75,7 @@ public final class VanillaSpawnRulesOracle {
WorldBorder border = new WorldBorder();
int next = 1;
@Override public RegistryAccess registryAccess() { return registries; }
@Override public MinecraftServer getServer() { return server; }
@Override public Difficulty getDifficulty() { return difficulty; }
@Override public long getGameTime() { return 0L; }
@Override public int getNextEntityId() { return next++; }
@@ -79,6 +85,8 @@ public final class VanillaSpawnRulesOracle {
return column.getOrDefault(pos.getY(), Blocks.AIR.defaultBlockState());
}
@Override public FluidState getFluidState(BlockPos pos) { return getBlockState(pos).getFluidState(); }
@Override public boolean isUnobstructed(net.minecraft.world.entity.Entity entity) { return unobstructed; }
@Override public boolean containsAnyLiquid(net.minecraft.world.phys.AABB box) { return liquid; }
@Override public int getBrightness(LightLayer layer, BlockPos pos) { return layer == LightLayer.SKY ? sky : block; }
@Override public int getRawBrightness(BlockPos pos, int darkening) { return Math.max(block, sky - darkening); }
@Override public int getSkyDarken() { return darken; }
@@ -125,6 +133,8 @@ public final class VanillaSpawnRulesOracle {
Lvl level = (Lvl) alloc(Lvl.class);
level.registries = access;
level.server = (MinecraftServer) alloc(VanillaBlockUpdateOracle.Server.class);
put(level.server, MinecraftServer.class, "debugSubscribers", new ServerDebugSubscribers(level.server));
level.column = new HashMap<>();
level.playerList = new ArrayList<>();
level.border = new WorldBorder();
@@ -162,6 +172,7 @@ public final class VanillaSpawnRulesOracle {
level.column.put(y + offset, net.minecraft.commands.arguments.blocks.BlockStateParser.parseForBlock(blocks, e.getValue(), false).blockState());
}
}
level.liquid = level.column.values().stream().anyMatch(state -> !state.getFluidState().isEmpty());
level.sky = Integer.parseInt(c.getOrDefault("sky", "15"));
level.block = Integer.parseInt(c.getOrDefault("light", "0"));
level.darken = Integer.parseInt(c.getOrDefault("darken", "0"));
@@ -187,10 +198,18 @@ public final class VanillaSpawnRulesOracle {
EntityType<?> type = BuiltInRegistries.ENTITY_TYPE.getValue(Identifier.parse(c.get("type")));
EntitySpawnReason reason = EntitySpawnReason.valueOf(c.getOrDefault("reason", "natural").toUpperCase(Locale.ROOT));
BlockPos pos = new BlockPos(level.x0, y, level.z0);
RandomSource random = RandomSource.create(Long.parseLong(c.getOrDefault("seed", "0")));
boolean ok = SpawnPlacements.isSpawnPositionOk(type, level, pos);
boolean rules = SpawnPlacements.checkSpawnRules(type, level, reason, pos, random);
w.println(c.get("name") + " " + ok + " " + rules + " " + random.nextInt(1000));
if (c.getOrDefault("mode", "").equals("obstruction")) {
level.unobstructed = !c.getOrDefault("unobstructed", "1").equals("0");
var entity = type.create(level, EntitySpawnReason.LOAD);
if (entity != null) entity.setPosRaw(level.x0 + 0.5, y, level.z0 + 0.5);
boolean obstruction = entity instanceof Mob mob && mob.checkSpawnObstruction(level);
w.println(c.get("name") + " " + obstruction);
} else {
RandomSource random = RandomSource.create(Long.parseLong(c.getOrDefault("seed", "0")));
boolean ok = SpawnPlacements.isSpawnPositionOk(type, level, pos);
boolean rules = SpawnPlacements.checkSpawnRules(type, level, reason, pos, random);
w.println(c.get("name") + " " + ok + " " + rules + " " + random.nextInt(1000));
}
} catch (Throwable t) {
Throwable cause = t instanceof InvocationTargetException ite ? ite.getCause() : t;
StringBuilder line = new StringBuilder(c.get("name") + " error " + cause);
+43
View File
@@ -125,9 +125,50 @@ def corpus(types):
return cases
def obstruction_cases(types):
"""`Mob.checkSpawnObstruction` on the same column worlds: the default
liquid/unobstructed answer and each class's override, with water and lava
in the feet or head cell, Ocelot's sea-level and grass/leaves base, and
the level's unobstructed flag both ways."""
rng = random.Random(0x4F425354)
cases = []
scenarios = [
dict(at0='minecraft:air', at1='minecraft:air', unobstructed=1),
dict(at0='minecraft:water', at1='minecraft:water', unobstructed=1),
dict(at0='minecraft:lava', at1='minecraft:lava', unobstructed=1),
dict(at0='minecraft:water', at1='minecraft:air', unobstructed=1),
dict(at0='minecraft:air', at1='minecraft:air', unobstructed=0),
dict(at0='minecraft:water', at1='minecraft:water', unobstructed=0),
dict(at0='minecraft:air', at1='minecraft:air', y=50, sea=63, **{'at-1': 'minecraft:grass_block'}, unobstructed=1),
dict(at0='minecraft:air', at1='minecraft:air', y=50, sea=63, **{'at-1': 'minecraft:stone'}, unobstructed=1),
dict(at0='minecraft:air', at1='minecraft:air', y=70, sea=63, **{'at-1': 'minecraft:grass_block'}, unobstructed=1),
dict(at0='minecraft:air', at1='minecraft:air', y=70, sea=63, **{'at-1': 'minecraft:oak_leaves'}, unobstructed=1),
]
for t in types:
n = t.split(':')[1]
# These three need data components that a bare EntityType.create lacks.
if n in ('wandering_trader', 'warden', 'wither', 'iron_golem'):
continue
for k, scenario in enumerate(scenarios):
y = scenario.get('y', 64)
fields = {key: value for key, value in scenario.items() if key.startswith('at')}
fields['unobstructed'] = scenario['unobstructed']
fields['sea'] = scenario.get('sea', 63)
cases.append(f'{n}/obstruction/{k} type={t} mode=obstruction y={y} '
+ ' '.join(f'{key}={value}' for key, value in fields.items()))
# The Ocelot override's y >= sea path with both base block tags.
for k, (y, below) in enumerate(((64, 'minecraft:grass_block'), (64, 'minecraft:oak_leaves'),
(62, 'minecraft:grass_block'), (63, 'minecraft:stone'))):
cases.append(f'{n}/ocelot/{k} type={t} mode=obstruction y={y} at-1={below} '
f'at0=minecraft:air at1=minecraft:air unobstructed=1 sea=63')
return cases
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--prepare', action='store_true')
parser.add_argument('--no-obstruction', action='store_true',
help='leave out the Mob.checkSpawnObstruction family, reproducing the previous corpus')
args = parser.parse_args()
build = lab.LOCAL / 'rust-spawn-rules-parity'
build.mkdir(parents=True, exist_ok=True)
@@ -143,6 +184,8 @@ def main():
tested = base.source_hashes(sources)
inputs = build / 'inputs.txt'
cases = corpus(types)
if not args.no_obstruction:
cases += obstruction_cases(types)
inputs.write_text(''.join(line + '\n' for line in cases), encoding='utf-8')
classes = build / 'classes'
classes.mkdir(exist_ok=True)