Compare commits

...
Author SHA1 Message Date
Ludovic Marie 1086c2831a Merge branch 'tools/bots-abusive-client' into bench/sn-abuse 2026-10-30 05:58:37 +01:00
Ludovic MarieandClaude Opus 5.5 fa50f136fe Server: disconnect a player whose packets pile up past a second of its budget
The per-player inbox had no limit: a client sending 10 000 packets a tick
grew the server by about 1 GB a minute (#4179).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 05:38:59 +01:00
Ludovic Marie 061751a023 Merge pull request #4095 from horizon-rust/carry/fusseltv-idle-spawn-area
🤖 Agent: pr-triage-1 (Ludovic's AI agent, Claude), not Ludovic himself. Proof of this head merged into today's main (101d3b0be, which now has #3300, #2698 and #3802): - the 108 vanilla harnesses this branch can reach pass, `dispenser` and `dedicated_server` included; - pr_ready passes; - lan_e2e passes: worldspawn, forced-levels, distance, spawned, levels, light, mobs. Queued for merge.
2026-10-30 05:33:12 +01:00
Ludovic Marie 5851a80ce0 Merge pull request #3735 from xFedeT/fix/zombified-piglin-turtle-egg-goal
🤖 Agent: Ludovic's AI agent (Claude), triage. #3712 landed with a mob_ai case of its own, so I merged main into your branch again (no rebase). The only conflict was the case list in `tools/test_mob_ai_parity.py`, and both sides are kept. `parity.py mob_ai` passes on main with this and #3721/#3735 merged. Requeued.
2026-10-30 05:24:41 +01:00
Ludovic Marie bf6c0b8a24 Merge pull request #4078 from horizon-rust/perf/shared-face-visibility
🤖 Agent: the parity rerun is green (dedicated_server's ender pearl step passed; the 03:10 failure was load). Requeued, same head.
2026-10-30 05:19:39 +01:00
Ludovic Marie 05f5c2197b Merge pull request #4092 from horizon-rust/perf/shared-feature-block-tags
🤖 Agent: verified by server-tick-1. Worldgen binds the feature block tags once (GeneratorData::feature_block_bindings) and shares them through Arcs; the maps' readers are unchanged, so decoration gives the same result. −11.6 MB allocated on the dedicated server. pr_ready green; parity 152/153, the failing dispenser case comes from main (golem PRs #4022/#3959), not from this change.
2026-10-30 05:17:46 +01:00
Ludovic Marie 05114a3673 Merge remote-tracking branch 'origin/main' into upd-3735
# Conflicts:
#	tools/test_mob_ai_parity.py
2026-10-30 05:14:03 +01:00
Ludovic Marie df583d34af Merge pull request #4056 from DUzzL/fix/issue-4037-armadillo-campfire
🤖 Agent: merging DUzzL's campfire contact damage for mobs (tested in game on both single-player paths: armadillos, cows and sheep burn on lit campfires, Infested spawns silverfish). Thanks!
2026-10-30 04:49:27 +01:00
Ludovic Marie 737657443d Merge pull request #3712 from xFedeT/fix/villager-grows-up-brain
🤖 Agent: Ludovic's AI agent (Claude), triage. #3729 landed with mob_ai cases of its own. I merged main into your branch (no rebase). The only conflict was where the new cases go in `tools/test_mob_ai_parity.py`, and both sets are kept. The Rust merged clean, so the proof above still holds. Requeued at the merged head.
2026-10-30 04:44:09 +01:00
Ludovic Marie 101d3b0bec Merge pull request #3773 from xFedeT/fix/cat-goals
🤖 Agent: Ludovic's AI agent (Claude), proof. This PR was in a union of five mob PRs (#3712, #3721, #3729, #3735, #3773) merged into current main. `parity.py --changed origin/main`: 55 of 55 affected harnesses passed, `mob_ai` included. `horizon-client` lib tests: 726 passed. clippy `-D warnings` is clean. The only conflicts were the new cases in `tools/test_mob_ai_parity.py`, as resolved on your branch. Queued.
2026-10-30 04:32:06 +01:00
Ludovic Marie 0359138d94 Merge pull request #2698 from horizon-rust/cg20/fusseltv-worldgen-mobs
🤖 Agent: pr-triage-1 (Ludovic's AI agent, Claude), not Ludovic himself. Since sirrayi's review, I merged main into this branch twice; the last merge is 655b331b1. In the merges: - `VanillaTerrain` keeps main's world presets (2607d06f9). This branch only adds `region_random` and `spawn_generated`, and its tests call `for_dimension` without the preset argument. - Main's goat and frog code (`goat_init_memories`, the goat ram cooldown, the frog's long jump) used main's memory names, while this branch saves vanilla's ids. Both now use vanilla's: `long_jump_cooling_down` and `ram_cooldown_ticks`. - main's mooshroom kind test sits beside #2544's baby villager test. Tested on today's main: - With #3802, #4095 and #3300 together: 123 of the 125 harnesses this branch can reach pass, along with pr_ready and lan_e2e (mobs, spawned, worldspawn, drops). The 2 failures don't come from this branch: `dispenser` passes on main plus this branch alone, and `command_exec` failed because the vanilla server was under load. - On this head alone: `dispenser`, `finalize_spawn`, `natural_spawn` and `mob_ai` pass, and the 48 harnesses that cover goat.rs, frog.rs and finalize.rs pass 48/48. Queued for merge. When it merges, GitHub marks @FusselTV's #2544 and #2586 as merged.
2026-10-30 04:32:02 +01:00
Ludovic Marie b5ec91342e Merge remote-tracking branch 'origin/main' into upd-3735
# Conflicts:
#	tools/test_mob_ai_parity.py
2026-10-30 04:32:00 +01:00
Ludovic Marie 6883b74274 Merge remote-tracking branch 'origin/main' into upd-3712
# Conflicts:
#	tools/test_mob_ai_parity.py
2026-10-30 04:31:58 +01:00
Ludovic MarieandClaude Opus 5.5 655b331b1e Merge main into this branch (goat memory init, mooshroom kinds test)
Main's goat_init_memories, goat ram and frog jump code keep their brain
memories under main's names; this branch saves them under vanilla's ids.
Both now use vanilla's: long_jump_cooling_down and ram_cooldown_ticks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 03:53:15 +01:00
levzzzandClaude Sonnet 5.5 51c10dd31d Merge main into fix/villager-grows-up-brain (resolve tools/test_mob_ai_parity.py)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 05:20:20 +05:00
levzzzandClaude Sonnet 5.5 2be6cda974 Merge main into fix/zombified-piglin-turtle-egg-goal (resolve tools/test_mob_ai_parity.py)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 05:19:36 +05:00
Ludovic MarieandClaude Opus 5.5 4c802c1342 Merge main into this branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:08:59 +01:00
Ludovic MarieandClaude Opus 5.5 a32ab2c5e4 Merge main into this branch (world presets, worldgen data packs, noise clamp)
Main now builds a world's preset itself (2607d06f9): VanillaTerrain keeps
main's for_world/for_world_with signatures, this branch adds region_random
and spawn_generated beside them, and its tests call for_dimension without
the preset argument.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:06:35 +01:00
Federico Tagliani 5f5d0434cb Merge remote-tracking branch 'origin/main' into fix/cat-goals 2026-10-30 01:05:14 +01:00
Federico TaglianiandClaude Sonnet 5.5 11159a1ed5 Merge fork/fix/cat-goals (maintainer's merge of main)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 01:05:13 +01:00
Federico TaglianiandClaude Sonnet 5.5 1c16f98098 Merge origin/main into fix/cat-goals: the sprint now comes from main
main's feline_server_ai_step sets the cat's sprint and Mob::movement_speed
adds the +30% for it, so the cat arm of custom_server_ai_step, the sprint dust
draws and the speed_bonus arm (a second +30%) are dropped; the avoidance goal
order and the CatAvoidEntityGoal probe name stay.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 01:00:39 +01:00
Ludovic MarieandClaude Opus 5.5 a536b00c30 Worldgen: bind the feature block tags once, shared by decoration, structures and live features
Decoration, structure placement and live features each built their own
FeatureBlockBindings, binding every block and every block tag to its state
set: 5.5 MB each, the same three times. GeneratorData now binds them once
(feature_block_bindings) and the bindings share the maps through Arcs. Each
decorated region also cloned the whole tag map; it now shares it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 00:48:07 +01:00
levzzzandClaude Sonnet 5.5 e4d8570fbc Merge main into fix/villager-grows-up-brain (resolve tools/test_mob_ai_parity.py)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 04:42:41 +05:00
levzzzandClaude Sonnet 5.5 30da32b83c Merge main into fix/zombified-piglin-turtle-egg-goal (resolve tools/test_mob_ai_parity.py)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 04:40:41 +05:00
DUzzL 084394a849 Keep campfire damage from making mobs panic 2026-10-30 00:32:02 +01:00
Ludovic MarieandClaude Opus 5.5 23c5335910 Block entity rendering: reuse the meshes' face visibility instead of loading it again
BlockEntityRenderer loaded occlusion.nbt and the render definitions a second
time and built its own FaceVisibility, whose shapes then computed their faces
again: 13.6 MB of face shapes, twice. It now takes a clone of the mesh
tables' visibility, which shares the shapes and their cached faces.
Probes and tests that load the renderer alone still load their own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 00:18:41 +01:00
DUzzL e93427276f Hurt mobs standing in a lit campfire (#4037)
CampfireBlock.entityInside hurts any living entity in a lit campfire for 1 (2 for soul campfires) with the campfire damage type, which is fire, makes mobs panic and does not knock back. Mobs ignored campfires entirely.
2026-10-30 00:11:15 +01:00
Ludovic Marie 4464c5d64a Merge remote-tracking branch 'origin/main' into upd-3735
# Conflicts:
#	tools/test_mob_ai_parity.py
2026-10-29 23:52:44 +01:00
Ludovic Marie c41e95bf53 Merge remote-tracking branch 'origin/main' into upd-3712
# Conflicts:
#	tools/test_mob_ai_parity.py
2026-10-29 23:52:37 +01:00
Ludovic Marie 3c8a73f86f Merge remote-tracking branch 'origin/main' into upd-3773
# Conflicts:
#	tools/test_mob_ai_parity.py
2026-10-29 23:46:15 +01:00
Federico TaglianiandClaude Sonnet 5.5 7703ebcf95 Cat: avoidance goal last, sprint while dashing
Cat.reassessTameGoals adds the player-avoidance goal after registerGoals, so
it is evaluated last. Cat.customServerAiStep sets sprinting while the move
control's speed modifier is 1.33: +30% movement speed, and Entity.baseTick
draws twice from the mob's random for the sprint dust. The probe names the
goal CatAvoidEntityGoal as vanilla's subclass.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 16:42:19 +01:00
Federico TaglianiandClaude Sonnet 5.5 0b6c48be77 Zombified piglin: it runs Zombie.registerGoals too, with the turtle egg goal
ZombifiedPiglin extends Zombie and only overrides addBehaviourGoals, so it
keeps the turtle egg goal (priority 4) that Zombie.registerGoals adds. Horizon
left it out, so the goal never drew its start delay and the mob's random
stream drifted from vanilla's on its second tick.

Adds a zombified_piglin_player scenario to the mob_ai harness.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 15:23:54 +01:00
Federico TaglianiandClaude Sonnet 5.5 136349ae95 Villagers: a baby that grows up gets the adult brain (Villager.ageBoundaryReached)
Horizon built a villager's brain once, with its baby flag, so a villager
that grew up kept the baby schedule (play) and never went to work. At the
age boundary the brain is now rebuilt as Villager.refreshBrain does: running
behaviours stop, the packed memories carry over and the sensors' first scans
are delayed by draws from the mob's random.

Adds villager_grows_up scenarios to the mob_ai harness.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 14:22:45 +01:00
Ludovic MarieandClaude Opus 5.5 21773ca7f5 Merge main into this branch
streaming.rs moved to horizon-client-render (#3186) and main now sweeps far
columns only on some updates (if sweep); this branch's changes (no area at a
negative radius, rank_full_task, its test) are applied there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:33:55 +01:00
FusselTVandClaude Opus 5.5 8401cde519 Dedicated server: nothing kept around the spawn while nobody is on
While nobody was online, the dedicated server behaved as if a player
stood at the world spawn: at startup it streamed a view-distance area
there and placed a stand-in player, and afterwards it kept that area
loaded, generated and ticked. Vanilla only loads the chunks its spawn
spiral tests (MinecraftServer.setInitialSpawn: getChunk(FULL), whose
3x3 is decorated in one generation task), has no spawn ticket, and finds
a player's spawn when one joins. In a pre-generated world the startup
area decorated in another order made ore blobs and a mineshaft start's
saved flags near spawn differ from vanilla's.

A streaming radius below 0 now means no area around the centre. The
dedicated server only finds the world spawn, loading each tested chunk
as an extra area of radius -1 ranked as one FULL task (x-outer,
z-inner), and keeps the radius at -1 while no guest is online.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 09:36:12 +01:00
Ludovic Marie 9bb3fc077e Merge #2570 2026-10-27 06:43:30 +01:00
Ludovic MarieandClaude Opus 5.5 2aa9056259 Structure mobs test: pass the world preset for_dimension now takes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:43:30 +01:00
Ludovic Marie 8117e2767f Merge main 2026-10-27 06:43:23 +01:00
Ludovic MarieandClaude Opus 5.5 bc5ec6fb46 Structure mobs test: pass the world preset #2570 added to for_dimension
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:42:39 +01:00
Ludovic Marie e5ebeddb90 Merge #2585
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-10-27 06:42:33 +01:00
Ludovic Marie d0b42fb88e Merge #2570 2026-10-27 06:42:19 +01:00
Ludovic Marie bca77d1ac4 Merge origin/main 2026-10-27 06:42:18 +01:00
Ludovic MarieandClaude Opus 5.5 815c09b1f8 Merge #2214 (world presets in the create-world screen)
The superflat screen and the agent's state read the world preset that
#2214 adds instead of the old flat flag; drop a stray parenthesis in
the horizon-prepare hint.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:40:03 +01:00
Claude Codeandminimax-m3.1-flash 0104eeaf33 Format the world preset test
rustfmt keeps the registry read on one line.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 05:06:02 +05:00
Claude Codeandminimax-m3.1-flash 729a224eef Read the test registry without the explicit deref
clippy's explicit_auto_deref asks for the LazyLock itself, which
derefs to the registry the generator data keeps.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 04:52:31 +05:00
FusselTVandClaude Opus 5.5 22054eee90 Worldgen: chunk generation spawns its animals as vanilla, ticking or not
NaturalSpawner.spawnMobsForChunkGeneration runs in the generation
pipeline (spawnOriginalMobs), with the WorldGenRegion's random for the
spawn rules and finalizeSpawn. Horizon ran it from the game tick for
chunks that were fresh and lit, so:

- a world generated with ticks frozen, as a seed generator does, got no
  animals at all, and a chunk saved before a tick saw it lost them;
- the rules and finalizeSpawn drew from other randoms, and the reduced
  spawning::finalize gave no variants, babies or group data;
- the spawn list came from the biome's noise cell, not the zoomed biome
  (fullResolutionBiomes) vanilla reads;
- members of a pack were kept apart, though a WorldGenRegion has no
  entities in the way.

A chunk now gets its animals as soon as it is lit, whatever the tick
state. The positions come from the same selection, the rules and the
finalize from the chunk's region random, and each mob is made with its
constructor's state, finalized like a structure mob
(Reason::ChunkGeneration, group data per pack) and loaded from the NBT
generation would save. The natural spawn oracle's generation mode sees
no entities, as a WorldGenRegion.

Goats and frogs keep their long jump and ram cooldowns under vanilla's
memory names (long_jump_cooling_down, ram_cooldown_ticks), and rabbits
save the attack damage attribute Rabbit.setVariant makes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 00:37:18 +01:00
Claude Codeandminimax-m3.1-flash 52f0e38d41 Add the amplified, large-biome and single-biome world types
The create screen only offered Default and Superflat, so the amplified
world type of #1873 could not be chosen at all, and the generator
always built the overworld's noise settings.

The world-type button now cycles through the presets vanilla's
WorldPresetTags.NORMAL tag holds (normal, flat, large_biomes, amplified,
single_biome_surface), the chosen preset is written into the new world's
world_gen_settings.dat with the generator
worldgen/world_preset/<preset>.json has, and the generator is built from
that preset, so an amplified world gets the amplified noise settings.
A world opened again reads its preset back from its generator.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 04:20:01 +05:00
FusselTVandClaude Opus 5.5 1a80673d50 Worldgen: structure mobs save the fields vanilla's chunk keeps
ProtoChunk.addEntity saves a structure's entity as soon as it is placed,
and the chunk loads it again when it becomes full, so vanilla keeps what
each entity's own save writes. Horizon kept the template's NBT with its
model on top, and its model left out some fields:

- the keys in vanilla's templates that no entity reads (food_level,
  lastRestock, CatType, OwnerUUID, ...) are dropped;
- villagers save VillagerDataFinalized and their age (AgeableMob writes
  Age, ForcedAge and AgeLocked whenever canBeABaby, not only for
  creatures; never for a frog, a parrot or a zombie nautilus), and the
  age is read back for the same mobs (a baby villager or hoglin no
  longer grows up on load);
- iron golems and the other neutral mobs save anger_end_time;
- zombies, drowned and piglins save the movement speed attribute that
  their setBaby creates on load;
- a drowned with a nautilus shell keeps drop_chances (offhand 2.0), and
  saved drop chances are read back;
- the rider of a new chicken jockey is saved as it was when the chicken
  was added, before its equipment and attributes, as vanilla's chunk
  saves the chicken with its passenger at that point.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 23:12:29 +01:00
38 changed files with 677 additions and 169 deletions

No files matched your search

@@ -865,13 +865,23 @@ const VIEW_DISTANCE: f64 = 64.0;
impl BlockEntityRenderer {
pub fn load(root: &Path, registry: &BlockRegistry) -> crate::Result<Self> {
let visibility = crate::models::load_visibility(root, registry)?;
Self::load_with_visibility(root, registry, visibility)
}
/// [`BlockEntityRenderer::load`] over the face visibility the meshes use
/// (`MeshTables::visibility`): a clone shares its shapes and their faces.
pub fn load_with_visibility(
root: &Path,
registry: &BlockRegistry,
visibility: FaceVisibility,
) -> crate::Result<Self> {
let all: Value = serde_json::from_slice(&std::fs::read(root.join("entity-models.json"))?)?;
let models = MODELS
.iter()
.filter_map(|(key, layer)| all.get(*layer).map(|v| (*key, ModelPart::parse(v))))
.collect();
let mut renderer = Self::new(models, registry);
renderer.visibility = Some(Mutex::new(crate::models::load_visibility(root, registry)?));
renderer.visibility = Some(Mutex::new(visibility));
// Sherd items: provides_pottery_pattern -> decorated_pot_pattern asset id.
if let Ok(bytes) = std::fs::read(root.join("items.json")) {
let items: Value = serde_json::from_slice(&bytes)?;
+45 -3
View File
@@ -310,6 +310,8 @@ pub struct Streaming {
deferred: Vec<Done>,
sequence: u64,
pub center: (i32, i32),
/// The view distance around `center`; below 0, no area there (a dedicated
/// server nobody is on).
pub radius: i32,
/// `/forceload`ed chunks: kept loaded whatever the distance.
pub forced: HashSet<(i32, i32)>,
@@ -1105,7 +1107,9 @@ impl Streaming {
.iter()
.filter(|&(&(x, z), p)| {
p.is_some()
&& i64::from(x - center.0).pow(2) + i64::from(z - center.1).pow(2) > r4 * r4
&& (self.radius < 0
|| i64::from(x - center.0).pow(2) + i64::from(z - center.1).pow(2)
> r4 * r4)
&& !ranges.in_extra(x, z, 4)
&& !ranges.in_band(x, z, 4)
&& !ranges.forced.iter().any(|f| {
@@ -1556,6 +1560,16 @@ impl Streaming {
fn in_range(&self, x: i32, z: i32, margin: i32) -> bool {
self.ranges().in_range(x, z, margin)
}
/// Rank the 3x3 a chunk loaded to FULL decorates, in one generation
/// task's order (`ChunkGenerationTask`: x-outer, z-inner), after every
/// chunk ranked before.
pub fn rank_full_task(&mut self, x: i32, z: i32) {
for dx in -1..=1 {
for dz in -1..=1 {
self.decoration_order.rank_or_append(x + dx, z + dz);
}
}
}
pub fn band(&self) -> Option<Band> {
self.band
}
@@ -1861,7 +1875,7 @@ impl Ranges<'_> {
let (dx, dz) = (gap(x - self.center.0), gap(z - self.center.1));
let r = i64::from(self.radius + 1);
// The extra areas are squares: their finished square grown by `reach`.
dx * dx + dz * dz <= r * r
(self.radius >= 0 && dx * dx + dz * dz <= r * r)
|| self.in_extra(x, z, 1 + reach)
|| self.in_band(x, z, 1 + reach)
}
@@ -1894,7 +1908,7 @@ impl Ranges<'_> {
fn in_main_range(&self, x: i32, z: i32, margin: i32) -> bool {
let r = i64::from(self.radius + margin);
let (dx, dz) = (i64::from(x - self.center.0), i64::from(z - self.center.1));
if dx * dx + dz * dz <= r * r {
if self.radius >= 0 && dx * dx + dz * dz <= r * r {
return true;
}
// A forced chunk holds a FORCED ticket at level 31
@@ -2529,6 +2543,34 @@ mod tests {
}
}
/// An idle dedicated server keeps no area around its centre: only a spawn
/// search's chunk (FULL alone: its 3x3 decorated, 5x5 generated) and the
/// forced chunks.
#[test]
fn no_area_around_an_idle_servers_centre() {
let forced = HashSet::from([(30, 30)]);
let extra = [((5, -3), -1)];
let r = Ranges {
center: (5, -3),
radius: -1,
extra: &extra,
forced: &forced,
cover: None,
band: None,
};
for x in -5i32..=15 {
for z in -13i32..=7 {
let d = (x - 5).abs().max((z + 3).abs());
assert_eq!(r.in_range(x, z, 1), d == 0, "({x}, {z})");
assert_eq!(r.chunk_kept(x, z, true), d <= 2, "({x}, {z})");
assert_eq!(r.requested(x, z, true), d <= 2, "({x}, {z})");
}
}
assert!(r.in_range(30, 30, 1));
let idle = Ranges { extra: &[], ..r };
assert!(!idle.requested(5, -3, true) && idle.unloaded(5, -3, true));
}
/// A server player's area (`lan_update_streaming`: the server view
/// distance + 1) finishes vanilla's FULL square: PLAYER_LOADING tickets at
/// level 31 out to the view distance, FULL (33) two chunks past it. Every
@@ -143,8 +143,8 @@ const GENES: [&str; 7] = [
"aggressive",
];
const MEMORIES: [&str; 7] = [
"long_jump_cooldown",
"ram_cooldown",
"long_jump_cooling_down",
"ram_cooldown_ticks",
"attack_target_cooldown",
"hunted_recently",
"dig_cooldown",
@@ -507,6 +507,7 @@ pub fn main() -> horizon_client::Result<()> {
difficulty,
data: &data,
created: Vec::new(),
jockey_rider: None,
};
group = finalize::finalize(&mut cx, &mut mob, reason, group);
let created = std::mem::take(&mut cx.created);
@@ -225,6 +225,8 @@ fn goal_name(m: &Mob, name: &str) -> String {
"MoveThroughVillage" => "MoveThroughVillageGoal",
"RestrictSun" => "RestrictSunGoal",
"FleeSun" => "FleeSunGoal",
// `Cat.CatAvoidEntityGoal`, `Fox.FoxAvoidEntityGoal`... name their subclass.
"AvoidEntity" if m.species() == Species::Cat => "CatAvoidEntityGoal",
"AvoidEntity" => "AvoidEntityGoal",
"BowAttack" => "RangedBowAttackGoal",
"Swell" => "SwellGoal",
@@ -796,7 +796,7 @@ pub fn main() -> horizon_client::Result<()> {
chunk,
&mut gen_random,
&mut rules,
&mut spawn,
&mut |l, r, _| spawn(l, r).is_some(),
&mut |e| match e {
GenEvent::Column(x, z) => log.push(format!("col {x} {z}")),
GenEvent::Spawn(t, p, yaw) => spawns.push(format!(
+105 -12
View File
@@ -87,6 +87,11 @@ impl Base<'_> {
}
}
/// The mobs that save `Age` and `AgeLocked`.
fn saves_age(s: crate::mobs::Species) -> bool {
use crate::mobs::{Species, drops::can_be_baby, finalize::ageable};
s == Species::Tadpole || (ageable(s) && can_be_baby(s))
}
/// `LivingEntity` + `Mob` (+ `AgeableMob`, creeper and skeleton specifics).
/// The mobs whose class extends vanilla's `Animal` (and so save `InLove`):
/// not allays, villagers, golems, dolphins or fish.
@@ -409,19 +414,14 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
map.entry(key.into()).or_insert(value);
}
let id = m.kind.id.as_str();
// Only breedable animals have an age; a loaded creature that was saved without
// one (a frog) stays without.
let aged = raw.is_none_or(|r| r.get("Age").is_some());
if (m.kind.category == "creature" || m.species() == crate::mobs::Species::Hoglin) && aged {
// AgeableMob.addAdditionalSaveData, only if canBeABaby (not a frog or a
// parrot); a tadpole saves its own age.
let species = m.species();
if saves_age(species) {
map.insert("Age".into(), Tag::Int(m.extra.age));
map.insert("AgeLocked".into(), Tag::Byte(i8::from(m.extra.age_locked)));
map.insert("ForcedAge".into(), Tag::Int(m.extra.forced_age));
for (key, value) in [
("Age", Tag::Int(0)),
("ForcedAge", Tag::Int(0)),
("AgeLocked", Tag::Byte(0)),
] {
map.entry(key.into()).or_insert(value);
if species != crate::mobs::Species::Tadpole {
map.insert("ForcedAge".into(), Tag::Int(m.extra.forced_age));
}
}
// Animal.addAdditionalSaveData: the love ticks left, whatever the save had.
@@ -660,6 +660,8 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
}
"minecraft:villager" => {
map.insert("VillagerData".into(), villager_data(m));
// A loaded villager with VillagerData is finalized.
map.insert("VillagerDataFinalized".into(), Tag::Byte(1));
let t = m.extra.trade.as_deref().cloned().unwrap_or_default();
map.insert("Xp".into(), Tag::Int(t.xp));
map.insert("LastRestock".into(), Tag::Long(t.last_restock));
@@ -865,9 +867,64 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
) {
map.insert("IsBaby".into(), Tag::Byte(i8::from(m.is_baby())));
}
// NeutralMob.addPersistentAngerSaveData; the bee saves its own.
if matches!(
id,
"minecraft:iron_golem"
| "minecraft:wolf"
| "minecraft:polar_bear"
| "minecraft:zombified_piglin"
| "minecraft:enderman"
) {
map.entry("anger_end_time".into()).or_insert(Tag::Long(-1));
}
if let Some(a) = m.extra.attributes.as_deref() {
put_attributes(map, m, a);
}
// The attribute their load makes, which AttributeMap.pack then saves:
// Zombie and Piglin.setBaby (even for an adult) the speed, Rabbit.setVariant
// the attack damage.
use crate::mobs::finalize::Attr;
let made = if crate::mobs::doors::is_zombie(species) || species == crate::mobs::Species::Piglin
{
Some(Attr::MovementSpeed)
} else if species == crate::mobs::Species::Rabbit {
Some(Attr::AttackDamage)
} else {
None
};
if let Some(attr) = made {
let base = crate::mobs::finalize::base_value(m, attr);
let mut list = match map.get(&"attributes".into()) {
Some(Tag::List(l)) => l.clone(),
_ => Vec::new(),
};
let attr_id = format!("minecraft:{}", attr.name());
if !list
.iter()
.any(|t| text(t.get("id")).as_deref() == Some(attr_id.as_str()))
{
list.push(Tag::compound([
("id", string(&attr_id)),
("base", Tag::Double(base.to_bits())),
]));
map.insert("attributes".into(), Tag::List(list));
}
}
// Mob.addAdditionalSaveData: the chances that are not the default.
if let Some(chances) = &m.extra.drop_chances {
let set: Vec<(&str, Tag)> = crate::mobs::drops::SLOTS
.iter()
.zip(chances.iter())
.filter(|(_, c)| **c != crate::mobs::drops::DEFAULT_DROP_CHANCE)
.map(|(slot, c)| (*slot, Tag::Float(c.to_bits())))
.collect();
if set.is_empty() {
map.remove(&"drop_chances".into());
} else {
map.insert("drop_chances".into(), Tag::compound(set));
}
}
crate::mobs::raider::write_nbt(m, map);
crate::mobs::riders::write_nbt(m, map);
// LivingEntity equipment: what the mob holds now.
@@ -1191,7 +1248,16 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.custom_name = custom_name(tag);
m.custom_name_text = tag.get("CustomName").map(name_text);
m.extra.left_handed = flag(tag, "LeftHanded");
if m.kind.category == "creature" || m.species() == crate::mobs::Species::Hoglin {
if let Some(Tag::Compound(c)) = tag.get("drop_chances") {
let mut chances = [crate::mobs::drops::DEFAULT_DROP_CHANCE; 8];
for (chance, slot) in chances.iter_mut().zip(crate::mobs::drops::SLOTS) {
if let Some(Tag::Float(v)) = c.get(&slot.into()) {
*chance = f32::from_bits(*v);
}
}
m.extra.drop_chances = Some(Box::new(chances));
}
if saves_age(m.species()) {
m.extra.age = number(tag.get("Age")).unwrap_or(0) as i32;
m.extra.forced_age = number(tag.get("ForcedAge")).unwrap_or(0) as i32;
m.extra.age_locked = flag(tag, "AgeLocked");
@@ -3170,6 +3236,33 @@ mod sheep_nbt_tests {
}
}
#[test]
fn a_baby_villager_keeps_its_age() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
let data = crate::mobs::MobTypes::load(&root).unwrap();
let types: HashMap<String, Arc<MobType>> = data
.types
.iter()
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
.collect();
let saved = Tag::compound([
("id", string("minecraft:villager")),
("Pos", doubles(&[0.5, 64.0, 0.5])),
("Age", Tag::Int(-21359)),
]);
let m = mob_from_nbt(&saved, &types, 1).unwrap();
assert_eq!(m.extra.age, -21359);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
assert_eq!(number(tag.get("Age")), Some(-21359));
assert_eq!(number(tag.get("AgeLocked")), Some(0));
assert_eq!(number(tag.get("VillagerDataFinalized")), Some(1));
}
#[test]
fn foxes_mooshrooms_rabbits_and_goats_keep_their_kind() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
+13 -4
View File
@@ -13130,6 +13130,7 @@ impl Game<'_> {
let (uploads, unloaded) =
horizon_client::spans::time("streaming", || self.streaming.update(&mut self.world));
self.streaming.extra.truncate(areas);
self.spawn_fresh_chunks();
if let Some(lod) = &mut self.lod {
self.lod_observe
.extend(uploads.iter().map(|(key, _)| (key.0, key.2)));
@@ -15387,6 +15388,9 @@ fn load_resources(
prepared_at.elapsed().as_millis()
);
}
// The block entity renderer's face visibility: the meshes' own, whose
// shapes already hold their faces, not a second load of the same tables.
let visibility = pack.context.tables.visibility.clone();
let rest_loader = if menu {
let resources = resources.clone();
let root = root.clone();
@@ -15401,6 +15405,7 @@ fn load_resources(
&assets,
&behaviour_json,
&gameplay_projection,
visibility,
&progress,
started,
)
@@ -15428,6 +15433,7 @@ fn load_resources(
&assets,
&behaviour_json,
&gameplay_projection,
visibility,
progress,
started,
)?;
@@ -15455,6 +15461,7 @@ fn load_world_rest(
assets: &Assets,
behaviour_json: &serde_json::Value,
gameplay_projection: &[horizon_client::blocks::GameplayProjection],
visibility: horizon_world::block_render::FaceVisibility,
progress: &Progress,
started: Instant,
) -> Result<WorldRest> {
@@ -15499,10 +15506,12 @@ fn load_world_rest(
recipes,
transformers,
providers: Providers::load(root),
block_entity_render: horizon_client::block_entity_render::BlockEntityRenderer::load(
root,
registry_owner.registry,
)?,
block_entity_render:
horizon_client::block_entity_render::BlockEntityRenderer::load_with_visibility(
root,
registry_owner.registry,
visibility,
)?,
animate,
fog,
music: horizon_client::music::MusicTables::load(root),
@@ -113,7 +113,8 @@ impl Game<'_> {
"explosion" | "player_explosion" | "bad_respawn_point" => DamageKind::Explosion,
"fall" | "ender_pearl" | "fly_into_wall" => DamageKind::Fall,
"on_fire" => DamageKind::OnFire,
"in_fire" | "campfire" | "hot_floor" => DamageKind::InFire,
"in_fire" | "hot_floor" => DamageKind::InFire,
"campfire" => DamageKind::Campfire,
"lava" => DamageKind::Lava,
"drown" => DamageKind::Drown,
"dry_out" => DamageKind::DryOut,
+8 -3
View File
@@ -536,9 +536,9 @@ impl Game<'_> {
};
spawning::LocalDifficulty::new(base, self.world.day_time, inhabited, moon)
}
/// 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);
/// Generation's animals for the chunks generated since, once lit: chunk
/// generation places them whether the level ticks or not.
pub(super) fn spawn_fresh_chunks(&mut self) {
let fresh: Vec<(i32, i32)> = self
.world
.chunks
@@ -552,6 +552,10 @@ impl Game<'_> {
}
self.spawn_generation_creatures(key.0, key.1);
}
}
/// 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);
self.tick_leads();
self.spawn_natural();
// ServerChunkCache.tickChunks: the custom spawners tick only with spawn_mobs.
@@ -5073,6 +5077,7 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool)
K::FallingStalactite => "minecraft:falling_stalactite",
K::OnFire => "minecraft:on_fire",
K::InFire => "minecraft:in_fire",
K::Campfire => "minecraft:campfire",
K::Lava => "minecraft:lava",
K::Drown => "minecraft:drown",
K::DryOut => "minecraft:dry_out",
+53 -19
View File
@@ -672,7 +672,8 @@ impl Game<'_> {
}
self.settings.render_distance = props.view_distance.clamp(2, 32);
self.settings.simulation_distance = props.simulation_distance.clamp(2, 32);
self.streaming.radius = self.settings.render_distance;
// Nothing is kept around the spawn while nobody is on.
self.streaming.radius = -1;
self.settle_spawn()?;
let gate_lists = lists.clone();
let gate: LoginGate = Arc::new(move |address, uuid, name| {
@@ -846,10 +847,39 @@ impl Game<'_> {
self.stop_dedicated()
}
/// A dedicated server of its own, not single player's server thread
/// (which runs with `options.dedicated` too).
pub(super) fn standalone_server(&self) -> bool {
self.options.dedicated.is_some() && self.serving.is_none()
}
/// Load the spawn area and find the world spawn as single player does,
/// without keeping a player there.
/// without keeping a player there. A dedicated server only finds the world
/// spawn (MinecraftServer.setInitialSpawn): a player's spawn is found as
/// one joins.
pub(super) fn settle_spawn(&mut self) -> Result<()> {
let deadline = Instant::now() + Duration::from_secs(600);
if self.standalone_server() {
if self.level.spawn.is_none() {
let [x, y, z] = loop {
self.update_world();
if let Some(pos) = self.find_world_spawn()? {
break pos;
}
if Instant::now() > deadline {
return Err("the spawn area did not load".into());
}
std::thread::sleep(Duration::from_millis(2));
};
self.level.spawn = Some([f64::from(x), f64::from(y), f64::from(z)]);
self.level.world_spawn.get_or_insert([x, y, z]);
self.streaming.extra.clear();
}
self.spawn = self
.level
.spawn
.map(|s| horizon_core::geometry::Vec3::new(s[0] + 0.5, s[1], s[2] + 0.5));
return Ok(());
}
while self.player.is_none() {
self.update_world();
self.spawn_player()?;
@@ -862,24 +892,28 @@ impl Game<'_> {
Ok(())
}
/// The chunks to keep loaded: the players' (lan_update_streaming), or
/// the spawn chunk while nobody is online.
/// The chunks to keep loaded: the players' (lan_update_streaming). While
/// nobody is online a dedicated server keeps none (vanilla has no spawn
/// ticket), an integrated one the spawn chunk's.
pub(super) fn update_dedicated_streaming(&mut self) {
let center = self
.lan
.as_ref()
.and_then(|h| {
h.hub
.guests
.iter()
.find(|g| h.hub.here(g))
.map(|g| g.chunk())
})
.or_else(|| {
self.spawn
.map(|s| ((s.x.floor() as i32) >> 4, (s.z.floor() as i32) >> 4))
});
if let Some(c) = center {
let guest = self.lan.as_ref().and_then(|h| {
h.hub
.guests
.iter()
.find(|g| h.hub.here(g))
.map(|g| g.chunk())
});
if self.standalone_server() {
self.streaming.radius = if guest.is_some() {
self.settings.render_distance
} else {
-1
};
}
let spawn = self
.spawn
.map(|s| ((s.x.floor() as i32) >> 4, (s.z.floor() as i32) >> 4));
if let Some(c) = guest.or(spawn) {
self.streaming.center = c;
}
}
@@ -110,7 +110,17 @@ impl Game<'_> {
while let Some((dx, dz)) = self.spawn_spiral.current() {
if self.spawn_spiral.tested() {
let (cx, cz) = (ox + dx, oz + dz);
if !self.streaming.is_lit(cx, cz) {
if self.standalone_server() {
// getChunk(FULL) of the tested chunk alone: its 3x3 is decorated.
if !self.world.chunks.contains_key(&(cx, cz)) {
let area = [((cx, cz), -1)];
if self.streaming.extra != area {
self.streaming.extra = area.to_vec();
self.streaming.rank_full_task(cx, cz);
}
return Ok(None);
}
} else if !self.streaming.is_lit(cx, cz) {
self.streaming.center = (cx, cz);
return Ok(None);
}
@@ -2,6 +2,7 @@
//! 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::vanillagen::VanillaTerrain;
use horizon_client::{
biome::{BiomeInfo, SpawnEntry},
blocks::BlockFacts,
@@ -19,9 +20,12 @@ use horizon_client::{
};
use horizon_core::{
geometry::{Aabb, Vec3},
random::{LegacyRandom, RandomSource, WorldgenRandom},
random::{LegacyRandom, RandomSource, WorldgenRandom, XoroshiroRandom},
};
use horizon_world::{
nbt::{Compound, Tag},
registry::BlockStateId,
};
use horizon_world::registry::BlockStateId;
use std::collections::HashMap;
/// Reused buffers for one pass over the spawning chunks: the candidate chunks
@@ -162,6 +166,16 @@ impl GameLevel<'_, '_> {
let chunk = self.game.world.chunks.get(&(x >> 4, z >> 4))?;
Some(self.game.res.biomes().get(chunk.biome(x, y, z)))
}
/// The block whose noise cell `BiomeManager.getBiome` reads.
fn zoomed(&self, x: i32, y: i32, z: i32) -> [i32; 3] {
let zoom = self.game.streaming.biome_zoom;
let [qx, qy, qz] = zoom.quart_at_block(horizon_core::position::BlockPos::new(x, y, z));
[qx << 2, qy << 2, qz << 2]
}
fn zoomed_biome(&self, x: i32, y: i32, z: i32) -> Option<&BiomeInfo> {
let [x, y, z] = self.zoomed(x, y, z);
self.biome(x, y, z)
}
}
impl SpawnLevel for GameLevel<'_, '_> {
fn state(&self, x: i32, y: i32, z: i32) -> BlockStateId {
@@ -176,7 +190,9 @@ impl SpawnLevel for GameLevel<'_, '_> {
fn block_tag(&self, tag: &str, block: &str) -> bool {
self.w.block_tag(tag, block)
}
// Level.getBiome: the zoomed biome.
fn biome_tag(&self, tag: &str, x: i32, y: i32, z: i32) -> bool {
let [x, y, z] = self.zoomed(x, y, z);
self.w.biome_tag(tag, x, y, z)
}
fn sky_light(&self, x: i32, y: i32, z: i32) -> i32 {
@@ -242,11 +258,13 @@ impl NaturalLevel for GameLevel<'_, '_> {
Category::from_name(&t.category),
))
}
// The `natural_mob_spawns` attribute reads the zoomed biome
// (fullResolutionBiomes), the spawn probability the noise cell.
fn biome_spawns(&self, x: i32, y: i32, z: i32) -> Option<&HashMap<String, Vec<SpawnEntry>>> {
self.biome(x, y, z).map(|b| &b.spawns)
self.zoomed_biome(x, y, z).map(|b| &b.spawns)
}
fn spawn_cost(&self, entity: &str, x: i32, y: i32, z: i32) -> Option<SpawnCost> {
self.biome(x, y, z)?.spawn_costs.get(entity).copied()
self.zoomed_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)
@@ -580,37 +598,68 @@ impl<'a> Game<'a> {
}
/// `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
/// with the chunk's decoration-seeded random, finalized with the
/// region's random as generation adds them (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 Some(terrain) = self.end.locator() else {
return;
};
let mut region = terrain.region_random(cx, cz);
let any = region.as_any();
let added = if let Some(r) = any.downcast_mut::<LegacyRandom>() {
self.generation_creatures(cx, cz, &terrain, r)
} else if let Some(r) = any.downcast_mut::<XoroshiroRandom>() {
self.generation_creatures(cx, cz, &terrain, r)
} else {
return;
};
for nbt in added {
self.load_mob(Tag::Compound(nbt), None);
}
}
fn generation_creatures<Q: RandomSource + 'static>(
&self,
cx: i32,
cz: i32,
terrain: &VanillaTerrain,
region: &mut Q,
) -> Vec<Compound> {
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);
let mut added = Vec::new();
let mut group = None;
let level = self.natural_level();
let source = [cx * 16, level.max_y(), cz * 16];
let mut spawn = |l: &GameLevel, r: &SpawnRequest, region: &mut Q| {
// Each pack starts without group data.
if r.group_index == 0 {
group = None;
}
let [x, y, z] = r.position;
let (bx, by, bz) = (x.floor() as i32, y.floor() as i32, z.floor() as i32);
let biome = l.zoomed_biome(bx, by, bz).map_or("", |b| b.name.as_str());
let at = Vec3::new(x, y, z);
match terrain.spawn_generated(r.entity, at, r.yaw, biome, region, &mut group) {
Some(nbt) => {
added.push(nbt);
true
}
spawned
};
spawn_selection::spawn_mobs_for_chunk_generation(
&level,
source,
(cx, cz),
&mut random,
&mut rules,
&mut spawn,
&mut |_| {},
);
}
self.add_spawned(pending, true);
None => false,
}
};
spawn_selection::spawn_mobs_for_chunk_generation(
&level,
source,
(cx, cz),
&mut random,
region,
&mut spawn,
&mut |_| {},
);
added
}
}
+24 -3
View File
@@ -1172,8 +1172,23 @@ impl Hub {
}
}
NetEvent::Packet(conn, packet) => {
if let Some(g) = self.guest_mut(conn) {
g.inbox.push_back(*packet);
let Some(g) = self.guest_mut(conn) else {
continue;
};
// A kicked player's packets wait for nothing.
if !g.left_reason.is_empty() {
continue;
}
g.inbox.push_back(*packet);
// A client sending faster than its budget is handled
// would grow the inbox until the server runs out of memory.
if g.inbox.len() > INBOX_LIMIT {
g.inbox = Default::default();
events.push(HostEvent::Warn(format!(
"{} sent more packets than the server handles ({INBOX_LIMIT} waiting)",
g.name
)));
self.kick(conn, translatable("disconnect.exceeded_packet_rate", &[]));
}
}
NetEvent::Duplicate(uuid) => {
@@ -2126,7 +2141,7 @@ impl Hub {
let g = &mut self.guests[i];
g.first_good = g.position;
g.known_moves = g.received_moves;
let mut budget = 400;
let mut budget = PACKETS_PER_TICK;
while budget > 0 {
let Some(packet) = self.guests[i].inbox.pop_front() else {
break;
@@ -2833,6 +2848,12 @@ fn wrap_degrees(degrees: f32) -> f32 {
wrapped
}
/// The packets of one player handled in a tick; the rest wait for the next.
const PACKETS_PER_TICK: usize = 400;
/// The packets a player may have waiting (a second of its budget) before it is
/// disconnected, as vanilla's rate-limit does: no real client gets near it.
const INBOX_LIMIT: usize = 20 * PACKETS_PER_TICK;
fn invalid_movement() -> Tag {
translatable("multiplayer.disconnect.invalid_player_movement", &[])
}
+5
View File
@@ -525,6 +525,8 @@ pub enum DamageKind {
FallingStalactite,
OnFire,
InFire,
/// `campfire`: standing in a lit campfire (`CampfireBlock.entityInside`).
Campfire,
Lava,
Drown,
/// `dry_out`: an axolotl out of water for too long.
@@ -657,6 +659,7 @@ impl Damage {
| DamageKind::Spit
| DamageKind::Stalagmite
| DamageKind::Starve
| DamageKind::Campfire
)
}
/// DamageTypeTags.BYPASSES_ARMOR
@@ -686,6 +689,7 @@ impl Damage {
self.kind,
DamageKind::OnFire
| DamageKind::InFire
| DamageKind::Campfire
| DamageKind::Lava
| DamageKind::Fireball
| DamageKind::SulfurCubeHot
@@ -699,6 +703,7 @@ impl Damage {
| DamageKind::Fall
| DamageKind::OnFire
| DamageKind::InFire
| DamageKind::Campfire
| DamageKind::Lava
| DamageKind::Drown
| DamageKind::DryOut
+15
View File
@@ -668,6 +668,18 @@ fn effects_from_blocks(m: &mut Mob, w: &MobWorld) {
"minecraft:cactus" => {
m.damage(1.0, Damage::of(DamageKind::Cactus));
}
// CampfireBlock.entityInside: a lit campfire hurts a
// living entity by its fire damage (1, soul 2).
name @ ("minecraft:campfire" | "minecraft:soul_campfire")
if w.prop(x, y, z, "lit") == Some("true") =>
{
let damage = if name == "minecraft:campfire" {
1.0
} else {
2.0
};
m.damage(damage, Damage::of(DamageKind::Campfire));
}
// WebBlock.entityInside -> makeStuckInBlock, which a
// spider ignores for cobwebs and a wither always.
"minecraft:cobweb"
@@ -1130,6 +1142,9 @@ fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: &
// setAge at the age boundary: refreshDimensions.
if m.extra.age >= 0 {
m.fudge_position_after_size_change(w, bw, bh);
if m.species() == Species::Villager {
super::villager_brain::age_boundary_reached(m, w, level_random, events);
}
// Turtle.ageBoundaryReached: a grown turtle drops a scute.
if m.species() == Species::Turtle {
events.push(MobEvent::GiftLoot {
@@ -444,4 +444,15 @@ mod tests {
}
assert!(hiding_in_shell(&m), "the model shows the shell");
}
#[test]
fn campfire_damage_is_fire_and_has_no_knockback_but_does_not_cause_panic() {
// `campfire` is in #is_fire and #no_knockback, but in neither
// #panic_causes nor #panic_environmental_causes (#4037).
let source = Damage::of(DamageKind::Campfire);
assert!(source.is_fire());
assert!(source.no_knockback());
assert!(!source.causes_panic());
assert!(!environmental(DamageKind::Campfire));
}
}
+10 -5
View File
@@ -389,6 +389,10 @@ pub struct Ctx<'a, L: FinalizeLevel + ?Sized, R: RandomSource> {
/// Jockeys and mounts made along the way, finalized, with their riding
/// links set (`Extra::mount` / `Extra::rider`).
pub created: Vec<Mob>,
/// The rider as it was when its new chicken was added to the level, before
/// its equipment and attributes: what a generated chunk keeps of it
/// (`ProtoChunk.addEntity` saves the chicken with its passenger then).
pub jockey_rider: Option<Mob>,
}
// ---- attributes -----------------------------------------------------------
@@ -835,12 +839,12 @@ pub fn goat_spawn(m: &mut Mob) {
.random
.next_int_between_inclusive(600, 1200)
.unwrap_or(600);
set_memory(m, "long_jump_cooldown", i64::from(jump), i64::MAX);
set_memory(m, "long_jump_cooling_down", i64::from(jump), i64::MAX);
let ram = m
.random
.next_int_between_inclusive(600, 6000)
.unwrap_or(600);
set_memory(m, "ram_cooldown", i64::from(ram), i64::MAX);
set_memory(m, "ram_cooldown_ticks", i64::from(ram), i64::MAX);
m.extra.screaming = m.random.next_double() < 0.02;
age_boundary_reached(m);
if !m.is_baby() && m.random.next_float() < 0.1 {
@@ -1290,6 +1294,7 @@ impl<L: FinalizeLevel + ?Sized, R: RandomSource> Ctx<'_, L, R> {
self.finalize(&mut chicken, Reason::Jockey, None);
chicken.extra.jockey = true;
self.ride(m, &mut chicken);
self.jockey_rider = Some(m.clone());
self.created.push(chicken);
}
}
@@ -1641,7 +1646,7 @@ impl<L: FinalizeLevel + ?Sized, R: RandomSource> Ctx<'_, L, R> {
S::Frog => {
self.variant(m, |r| &r.frog);
let jump = self.uniform(100, 140);
set_memory(m, "long_jump_cooldown", i64::from(jump), i64::MAX);
set_memory(m, "long_jump_cooling_down", i64::from(jump), i64::MAX);
self.ageable(m, g)
}
S::ZombieNautilus => {
@@ -2024,9 +2029,9 @@ fn set_memory(m: &mut Mob, name: &'static str, value: i64, ttl: i64) {
/// cooldown (600 to 6000), drawn from `random`.
pub fn goat_init_memories(m: &mut Mob, random: &mut impl RandomSource) {
let jump = random.next_int_between_inclusive(600, 1200).unwrap_or(600);
set_memory(m, "long_jump_cooldown", i64::from(jump), i64::MAX);
set_memory(m, "long_jump_cooling_down", i64::from(jump), i64::MAX);
let ram = random.next_int_between_inclusive(600, 6000).unwrap_or(600);
set_memory(m, "ram_cooldown", i64::from(ram), i64::MAX);
set_memory(m, "ram_cooldown_ticks", i64::from(ram), i64::MAX);
}
/// `Panda.Gene.getRandom` (enum order: normal, lazy, worried, playful,
/// brown, weak, aggressive).
+1 -1
View File
@@ -36,7 +36,7 @@ const SCAN_RATE: i32 = 20;
/// `FrogAi.TIME_BETWEEN_LONG_JUMPS` and the brain memory
/// `LONG_JUMP_COOLDOWN_TICKS` (saved with the others, as `finalize` sets it).
const TIME_BETWEEN_LONG_JUMPS: (i32, i32) = (100, 140);
const LONG_JUMP_COOLDOWN: &str = "long_jump_cooldown";
const LONG_JUMP_COOLDOWN: &str = "long_jump_cooling_down";
/// `FrogAi`'s `LongJumpToPreferredBlock`: `MAX_LONG_JUMP_WIDTH`,
/// `MAX_LONG_JUMP_HEIGHT`, `MAX_JUMP_VELOCITY_MULTIPLIER` and the chance to
/// look for a lily pad or big dripleaf first.
+1
View File
@@ -181,6 +181,7 @@ pub fn protection_points(g: &Gear, kind: DamageKind) -> f32 {
kind,
DamageKind::OnFire
| DamageKind::InFire
| DamageKind::Campfire
| DamageKind::Lava
| DamageKind::Fireball
| DamageKind::SulfurCubeHot
+2 -2
View File
@@ -39,7 +39,7 @@ struct Candidate {
}
/// The lowered head and the ram behaviours' state. `RAM_COOLDOWN_TICKS` is a
/// brain memory (`ram_cooldown`), saved with the others.
/// brain memory (`ram_cooldown_ticks`), saved with the others.
#[derive(Clone, Debug, Default)]
pub struct State {
lower_head_tick: i32,
@@ -96,7 +96,7 @@ fn set_ram_cooldown(m: &mut Mob, ticks: i32) {
None => {}
}
}
const RAM_COOLDOWN: &str = "ram_cooldown";
const RAM_COOLDOWN: &str = "ram_cooldown_ticks";
/// `Goat.getRammingXHeadRot`.
pub fn ramming_x_head_rot(m: &Mob) -> f32 {
let max = if m.is_baby() { 52.5_f32 } else { 30.0 };
@@ -535,11 +535,6 @@ pub fn to_nbt(m: &Mob, map: &mut horizon_world::nbt::Compound) {
map.remove(&"Owner".into());
}
map.insert("Sitting".into(), Tag::Byte(0));
// AgeableMob and Animal (a water creature is not saved with them above).
map.insert("Age".into(), Tag::Int(m.extra.age));
map.insert("AgeLocked".into(), Tag::Byte(i8::from(m.extra.age_locked)));
map.insert("InLove".into(), Tag::Int(m.extra.in_love.max(0)));
map.entry("ForcedAge".into()).or_insert(Tag::Int(0));
if zombie(m) {
let v = VARIANTS[usize::from(m.extra.variant).min(1)];
map.insert(
@@ -573,14 +568,6 @@ pub fn to_nbt(m: &Mob, map: &mut horizon_world::nbt::Compound) {
}
pub fn from_nbt(m: &mut Mob, tag: &horizon_world::nbt::Tag) {
use horizon_world::nbt::Tag;
let int = |k: &str| match tag.get(k) {
Some(Tag::Int(v)) => Some(*v),
Some(Tag::Byte(v)) => Some(i32::from(*v)),
_ => None,
};
m.extra.age = int("Age").unwrap_or(0);
m.extra.age_locked = int("AgeLocked").unwrap_or(0) != 0;
m.extra.in_love = int("InLove").unwrap_or(0);
m.extra.owner = crate::save::uuid_from_tag(tag.get("Owner"));
m.extra.tame = m.extra.owner.is_some();
if let Some(Tag::String(v)) = tag.get("variant") {
@@ -539,18 +539,21 @@ pub fn spawn_aabb(s: Species, x: f64, y: f64, z: f64, width: f32, height: f32) -
Vec3::new(x + half, y + f64::from(scale * height), z + half),
)
}
/// `checkSpawnObstruction` of a freshly created mob with the given box.
/// `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>(
s: Species,
l: &L,
bounds: Aabb,
spawned: &[Aabb],
spawned: Option<&[Aabb]>,
x: i32,
y: i32,
z: i32,
) -> bool {
let unobstructed =
|l: &L| l.unobstructed(bounds) && !spawned.iter().any(|b| b.intersects(bounds));
let unobstructed = |l: &L| {
spawned.is_none_or(|s| l.unobstructed(bounds) && !s.iter().any(|b| b.intersects(bounds)))
};
match obstruction(s) {
Obstruction::NoLiquidUnobstructed => !contains_any_liquid(l, bounds) && unobstructed(l),
Obstruction::Unobstructed => unobstructed(l),
@@ -1084,7 +1087,7 @@ pub fn spawn_category_for_position<L: NaturalLevel, R: RandomSource>(
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)
|| !spawn_obstruction(species, l, bounds, Some(&spawned), x, y, z)
{
continue;
}
@@ -1237,8 +1240,8 @@ pub enum GenEvent {
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.
/// the chunk's surface. `rules` is the region's random, which the spawn
/// predicates and `spawn` (finalizeSpawn) draw from.
#[allow(clippy::too_many_arguments)]
pub fn spawn_mobs_for_chunk_generation<L: NaturalLevel, R: RandomSource, Q: RandomSource>(
l: &L,
@@ -1246,7 +1249,7 @@ pub fn spawn_mobs_for_chunk_generation<L: NaturalLevel, R: RandomSource, Q: Rand
chunk: (i32, i32),
random: &mut R,
rules: &mut Q,
spawn: &mut dyn FnMut(&L, &SpawnRequest) -> Option<Spawned>,
spawn: &mut dyn FnMut(&L, &SpawnRequest, &mut Q) -> bool,
trace: &mut dyn FnMut(GenEvent),
) {
if !l.spawn_mobs() {
@@ -1269,7 +1272,6 @@ pub fn spawn_mobs_for_chunk_generation<L: NaturalLevel, R: RandomSource, Q: Rand
};
let count = sample_count(&entry, random);
let mut group_index = 0u32;
let mut spawned: Vec<Aabb> = 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);
@@ -1311,7 +1313,7 @@ pub fn spawn_mobs_for_chunk_generation<L: NaturalLevel, R: RandomSource, Q: Rand
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)
&& spawn_obstruction(species, l, bounds, None, bx, pos[1], bz)
{
let request = SpawnRequest {
entity: &entry.entity,
@@ -1321,9 +1323,8 @@ pub fn spawn_mobs_for_chunk_generation<L: NaturalLevel, R: RandomSource, Q: Rand
group_index,
reason: Reason::ChunkGeneration,
};
if let Some(result) = spawn(l, &request) {
if spawn(l, &request, rules) {
group_index += 1;
spawned.push(result.bounds);
trace(GenEvent::Spawn(entry.entity.clone(), [fx, fy, fz], yaw));
success = true;
}
+9 -1
View File
@@ -584,7 +584,15 @@ impl SpawnerExt for Spawner {
let rules_ok = data.custom_spawn_rules.is_some()
|| spawn_selection::mob_spawn_rules(s, l, own[0], own[1], own[2]);
if !rules_ok
|| !spawn_selection::spawn_obstruction(s, l, bounds, &added, own[0], own[1], own[2])
|| !spawn_selection::spawn_obstruction(
s,
l,
bounds,
Some(&added),
own[0],
own[1],
own[2],
)
{
continue;
}
+7 -5
View File
@@ -839,11 +839,6 @@ pub fn register_cat(m: &mut Mob) {
g.add(2, SitWhenOrdered);
g.add(3, CatRelaxOnOwner::default());
g.add(4, CatTempt(super::goals::Tempt::new(0.6, food, true)));
g.add(
4,
super::goals::AvoidEntity::new(super::goals::Avoid::Player, 16.0, 0.8, 1.33)
.when(|m| !m.extra.tame),
);
g.add(
5,
CatLieOnBlock(super::goals::MoveToBlock {
@@ -858,6 +853,13 @@ pub fn register_cat(m: &mut Mob) {
g.add(10, Breed::new(0.8));
g.add(11, Stroll::avoiding_water(0.8));
g.add(12, LookAtPlayer::new(10.0));
// `reassessTameGoals` adds the avoidance after `registerGoals`: goals run
// in insertion order, so it comes last.
g.add(
4,
super::goals::AvoidEntity::new(super::goals::Avoid::Player, 16.0, 0.8, 1.33)
.when(|m| !m.extra.tame),
);
m.ai.targets.add(
1,
NearestTarget::species(&[Species::Rabbit], false).when(|m, _| !m.extra.tame),
@@ -1081,7 +1081,15 @@ impl TrialSpawnerExt for TrialSpawner {
width * scale,
height * scale,
);
if !spawn_selection::spawn_obstruction(species, l, bounds, &[], own[0], own[1], own[2]) {
if !spawn_selection::spawn_obstruction(
species,
l,
bounds,
Some(&[]),
own[0],
own[1],
own[2],
) {
return None;
}
let request = SpawnerSpawn {
+2
View File
@@ -55,6 +55,8 @@ pub fn register(m: &mut Mob) {
// ZombifiedPiglin.addBehaviourGoals: neutral until provoked.
let g = &mut m.ai.goals;
g.add(1, BreakDoor::zombie());
// `Zombie.registerGoals`, which `ZombifiedPiglin` extends.
g.add(4, AttackTurtleEgg::default());
g.add(8, LookAtPlayer::new(8.0));
g.add(8, RandomLookAround::default());
g.add(2, MeleeAttack::new(1.0, false).zombie());
@@ -503,6 +503,28 @@ pub fn tick(m: &mut Mob, w: &MobWorld, lr: &mut LegacyRandom, events: &mut Vec<M
sync(m, w.game_time);
}
/// `Villager.ageBoundaryReached`: a villager that grew up gets a new brain
/// (the schedule it follows is the adult's), as `refreshBrain` builds it.
pub fn age_boundary_reached(
m: &mut Mob,
w: &MobWorld,
lr: &mut LegacyRandom,
events: &mut Vec<MobEvent>,
) {
if brain(m).is_none() {
return;
}
let mut list = std::mem::take(&mut brain_mut(m).list);
let mut cx = Bx {
w,
lr,
events,
now: w.game_time,
refresh: false,
};
refresh(m, &mut list, usize::MAX, &mut cx);
}
/// `Villager.refreshBrain`: stop every running behaviour, then rebuild the
/// brain from the remembered memories.
fn refresh(m: &mut Mob, list: &mut [Entry], current: usize, cx: &mut Bx) {
+19
View File
@@ -21,6 +21,7 @@ use horizon_world::{
GeneratorData, GeneratorWorker, OverworldGenerator, ProtoChunk, SurfaceSample,
},
overrides::WorldgenOverrides,
structure::legacy::PieceRandom,
terrain::PreLightChunkStage,
},
};
@@ -252,6 +253,24 @@ impl VanillaTerrain {
pub fn set_spawn_conditions(&self, difficulty: u8, day_time: i64) {
self.structure_mobs.set_conditions(difficulty, day_time);
}
/// The random a chunk's generation steps draw from (`WorldGenRegion`).
pub fn region_random(&self, cx: i32, cz: i32) -> Box<dyn PieceRandom> {
self.generator.region_random(ChunkPos::new(cx, cz))
}
/// A mob chunk generation spawns, as the chunk saves it
/// (`StructureMobFinalizer::spawn_generated`).
pub fn spawn_generated(
&self,
kind: &str,
position: horizon_core::geometry::Vec3,
yaw: f32,
biome: &str,
random: &mut dyn PieceRandom,
group: &mut Option<crate::mobs::finalize::Group>,
) -> Option<horizon_world::nbt::Compound> {
self.structure_mobs
.spawn_generated(kind, position, yaw, biome, random, group)
}
/// A proto chunk's blocks, biomes, scheduled ticks and post-processing
/// marks as a column.
fn column(&self, chunk: &ProtoChunk) -> Chunk {
@@ -8,7 +8,7 @@ use crate::{
items::Items,
mobs::{
Mob, MobType, MobTypes,
finalize::{self, Ctx, FinalizeLevel, Reason, SpawnData, SpawnRegistries},
finalize::{self, Ctx, FinalizeLevel, Group, Reason, SpawnData, SpawnRegistries},
spawning::{LocalDifficulty, MOON_BRIGHTNESS},
},
};
@@ -140,6 +140,8 @@ fn run<R: RandomSource>(
difficulty: LocalDifficulty,
data: &SpawnData<'_>,
m: &mut Mob,
reason: Reason,
group: &mut Option<Group>,
) -> Vec<Mob> {
let mut cx = Ctx {
level,
@@ -147,8 +149,13 @@ fn run<R: RandomSource>(
difficulty,
data,
created: Vec::new(),
jockey_rider: None,
};
cx.finalize(m, Reason::Structure, None);
*group = cx.finalize(m, reason, group.take());
if let Some(rider) = cx.jockey_rider {
debug_assert_eq!(rider.id, m.id);
*m = rider;
}
cx.created
}
@@ -159,6 +166,18 @@ fn tree(mobs: &[(Mob, Option<Tag>)], i: usize, dimension: &str) -> Compound {
return Compound::new();
};
c.remove(&"UUID".into());
// Keys in vanilla's templates that no entity reads: the chunk keeps the
// entity as its save writes it (ProtoChunk.addEntity), without them.
for key in [
"CatType",
"OwnerUUID",
"Riches",
"Willing",
"food_level",
"lastRestock",
] {
c.remove(&key.into());
}
entity_nbt::put_brain(&mut c, m, dimension);
let riders: Vec<Tag> = (0..mobs.len())
.filter(|&j| mobs[j].0.extra.mount == Some(m.id))
@@ -178,23 +197,78 @@ impl StructureMobs for StructureMobFinalizer {
structures: &[String],
random: &mut dyn PieceRandom,
) -> bool {
let Some(data) = self.data.get_or_init(|| {
let data = load(&self.root);
if data.is_none() {
log::error!("Horizon: no mob data, structure mobs are finalized when loaded");
}
data
}) else {
let Some(data) = self.data() else {
return false;
};
let mut raw = Tag::Compound(nbt.clone());
if let Tag::Compound(c) = &mut raw {
c.remove(&"horizon:finalize".into());
}
let Some(mut m) = entity_nbt::mob_from_nbt(&raw, &data.types, 1) else {
let Some(m) = entity_nbt::mob_from_nbt(&raw, &data.types, 1) else {
// Not a mob: vanilla finalizes only Mob instances.
return true;
};
let reason = Reason::Structure;
match self.finish(
data,
m,
Some(raw),
biome,
structures,
random,
reason,
&mut None,
) {
Some(saved) => {
*nbt = saved;
true
}
None => false,
}
}
}
impl StructureMobFinalizer {
fn data(&self) -> Option<&Data> {
self.data
.get_or_init(|| {
let data = load(&self.root);
if data.is_none() {
log::error!("Horizon: no mob data, structure mobs are finalized when loaded");
}
data
})
.as_ref()
}
/// A mob chunk generation spawns (`spawnOriginalMobs`): a new one of
/// `kind` finalized as a structure mob is, as the chunk saves it; `group`
/// carries a pack's group data from one member to the next.
pub fn spawn_generated(
&self,
kind: &str,
position: Vec3,
yaw: f32,
biome: &str,
random: &mut dyn PieceRandom,
group: &mut Option<Group>,
) -> Option<Compound> {
let data = self.data()?;
let m = Mob::new(1, data.types.get(kind)?.clone(), position, yaw);
let reason = Reason::ChunkGeneration;
self.finish(data, m, None, biome, &[], random, reason, group)
}
#[allow(clippy::too_many_arguments)]
fn finish(
&self,
data: &Data,
mut m: Mob,
raw: Option<Tag>,
biome: &str,
structures: &[String],
random: &mut dyn PieceRandom,
reason: Reason,
group: &mut Option<Group>,
) -> Option<Compound> {
let base = self.difficulty.load(Ordering::Relaxed);
let day_time = self.day_time.load(Ordering::Relaxed);
let mut level = Level {
@@ -216,21 +290,20 @@ impl StructureMobs for StructureMobFinalizer {
};
let any = random.as_any();
let created = if let Some(r) = any.downcast_mut::<LegacyRandom>() {
run(&mut level, r, difficulty, &spawn, &mut m)
run(&mut level, r, difficulty, &spawn, &mut m, reason, group)
} else if let Some(r) = any.downcast_mut::<XoroshiroRandom>() {
run(&mut level, r, difficulty, &spawn, &mut m)
run(&mut level, r, difficulty, &spawn, &mut m, reason, group)
} else {
log::error!("Horizon: unknown region random, structure mobs are finalized when loaded");
return false;
return None;
};
let mut mobs = vec![(m, Some(raw))];
let mut mobs = vec![(m, raw)];
mobs.extend(created.into_iter().map(|c| (c, None)));
let root = mobs
.iter()
.position(|(m, _)| m.extra.mount.is_none())
.unwrap_or(0);
*nbt = tree(&mobs, root, &self.dimension);
true
Some(tree(&mobs, root, &self.dimension))
}
}
@@ -14,6 +14,11 @@ use std::{
sync::Arc,
};
/// Block states by block name and by tag name ([`FeatureBlockBindings::bind_tags`]).
pub type BoundBlockTags = (
Arc<BTreeMap<String, Arc<BTreeSet<BlockStateId>>>>,
Arc<BTreeMap<String, Arc<BTreeSet<BlockStateId>>>>,
);
pub struct FeatureBlockBindings<'r> {
registry: &'r BlockRegistry,
pub(crate) simple_blocks: Option<(
@@ -27,8 +32,8 @@ pub struct FeatureBlockBindings<'r> {
biomes: Option<Arc<super::biome_predicate::BiomePredicateBindings>>,
pub(crate) fluids: super::fluid_predicate::FluidPredicateBindings,
pub(crate) codec: BlockStateJsonCodec<'r>,
pub(crate) blocks: BTreeMap<String, Arc<BTreeSet<BlockStateId>>>,
pub(crate) tags: BTreeMap<String, Arc<BTreeSet<BlockStateId>>>,
pub(crate) blocks: Arc<BTreeMap<String, Arc<BTreeSet<BlockStateId>>>>,
pub(crate) tags: Arc<BTreeMap<String, Arc<BTreeSet<BlockStateId>>>>,
pub(crate) air: [BlockStateId; 3],
tree_blocks: std::sync::OnceLock<Arc<super::tree_blocks::TreeBlocks>>,
pub(crate) structures: Option<Arc<super::structure::StructureResources>>,
@@ -126,8 +131,15 @@ impl<'r> FeatureBlockBindings<'r> {
integers: BTreeSet<(String, String)>,
tags: &BTreeMap<String, Vec<String>>,
) -> Result<Self, DecodeError> {
let codec = BlockStateJsonCodec::new(registry, integers)
.map_err(|e| DecodeError::Registry(e.to_string()))?;
let bound = Self::bind_tags(registry, tags)?;
Self::with_bound_tags(registry, integers, bound)
}
/// Every block's states and every tag's, by name: what `with_tags` binds,
/// to share between the bindings made over the same registry and tags.
pub fn bind_tags(
registry: &BlockRegistry,
tags: &BTreeMap<String, Vec<String>>,
) -> Result<BoundBlockTags, DecodeError> {
let mut blocks = BTreeMap::<String, BTreeSet<BlockStateId>>::new();
for id in 0..registry.len() {
let id = BlockStateId(id as u32);
@@ -153,6 +165,25 @@ impl<'r> FeatureBlockBindings<'r> {
return Err(DecodeError::Field("duplicate block tag"));
}
}
Ok((
Arc::new(
blocks
.into_iter()
.map(|(name, states)| (name, Arc::new(states)))
.collect(),
),
Arc::new(bound_tags),
))
}
/// [`FeatureBlockBindings::with_tags`] over tags already bound by
/// [`FeatureBlockBindings::bind_tags`].
pub fn with_bound_tags(
registry: &'r BlockRegistry,
integers: BTreeSet<(String, String)>,
(blocks, tags): BoundBlockTags,
) -> Result<Self, DecodeError> {
let codec = BlockStateJsonCodec::new(registry, integers)
.map_err(|e| DecodeError::Registry(e.to_string()))?;
let get = |name| {
registry
.default_state(name)
@@ -167,11 +198,8 @@ impl<'r> FeatureBlockBindings<'r> {
occlusion_faces: None,
can_occlude: None,
fluids: super::fluid_predicate::FluidPredicateBindings::new(registry)?,
blocks: blocks
.into_iter()
.map(|(name, states)| (name, Arc::new(states)))
.collect(),
tags: bound_tags,
blocks,
tags,
tree_blocks: std::sync::OnceLock::new(),
structures: None,
air: [
@@ -28,7 +28,7 @@ use std::{
struct RegionBlockQueries {
fluids: super::fluid_predicate::FluidPredicateBindings,
tags: BTreeMap<String, Arc<BTreeSet<BlockStateId>>>,
tags: Arc<BTreeMap<String, Arc<BTreeSet<BlockStateId>>>>,
}
#[derive(Debug)]
+43 -11
View File
@@ -184,6 +184,9 @@ pub struct GeneratorData {
biome_carvers: Vec<Vec<Arc<WorldCarver>>>,
carving_blocks: CarvingBlocks,
pub(super) block_tags: BTreeMap<String, Vec<String>>,
/// `block_tags` bound to block states, once for the feature bindings of
/// decoration, structures and live features (`feature_block_bindings`).
bound_block_tags: std::sync::OnceLock<super::block_feature::BoundBlockTags>,
pub(super) biome_tags: BTreeMap<String, Vec<String>>,
pub(super) feature_resources: Option<FeatureResources>,
/// Templates, processor lists and per-state template transforms.
@@ -557,6 +560,7 @@ impl GeneratorData {
feature_resources,
ring_cache: Mutex::new(Vec::new()),
decorations: Mutex::new(Vec::new()),
bound_block_tags: std::sync::OnceLock::new(),
}))
}
/// The concentric ring positions (strongholds) of the generator for
@@ -676,6 +680,21 @@ impl GeneratorData {
pub(super) fn integers(&self) -> &BTreeSet<(String, String)> {
&self.integers
}
/// Feature block bindings over this data's blocks and block tags. The tags'
/// state sets are bound once and shared: each binding held 5 MB of them.
pub(super) fn feature_block_bindings(
&self,
) -> Result<super::block_feature::FeatureBlockBindings<'static>, DecodeError> {
use super::block_feature::FeatureBlockBindings;
let bound = match self.bound_block_tags.get() {
Some(bound) => bound.clone(),
None => {
let bound = FeatureBlockBindings::bind_tags(self.registry, &self.block_tags)?;
self.bound_block_tags.get_or_init(|| bound).clone()
}
};
FeatureBlockBindings::with_bound_tags(self.registry, self.integers.clone(), bound)
}
}
/// A generated chunk with its quart biomes. Blocks and heightmaps live in
@@ -1454,6 +1473,17 @@ impl<R: RandomSource> Pipeline<R>
where
R::Positional: Clone + Send + Sync,
{
/// WorldGenRegion's level random, positioned at the center chunk.
fn region_random_at(&self, center: ChunkPos) -> R {
self.root
.from_hash_of("minecraft:worldgen_region_random")
.fork_positional()
.at(BlockPos::new(
center.x.wrapping_shl(4),
0,
center.z.wrapping_shl(4),
))
}
/// ChunkGenerator.getBaseHeight: the first free Y above the noise column
/// (with aquifers, without terrain adaptation) for a heightmap type.
fn base_height(&self, x: i32, z: i32, kind: HeightmapType) -> i32 {
@@ -2032,6 +2062,7 @@ trait SeededPipeline: Send + Sync {
fn forget_structure_starts(&self);
fn ring_positions(&self, worker: &mut WorkerState, set: &str) -> Option<Vec<ChunkPos>>;
fn spawn_position(&self) -> Result<[i32; 2], GeneratorError>;
fn region_random(&self, center: ChunkPos) -> Box<dyn super::structure::legacy::PieceRandom>;
fn locate(
&self,
worker: &mut WorkerState,
@@ -2070,17 +2101,7 @@ where
) -> Result<DecorateStats, GeneratorError> {
match &self.decoration {
Some(decoration) => {
let center = region[4].position();
// WorldGenRegion's level random, positioned at the center chunk.
let mut random = self
.root
.from_hash_of("minecraft:worldgen_region_random")
.fork_positional()
.at(BlockPos::new(
center.x.wrapping_shl(4),
0,
center.z.wrapping_shl(4),
));
let mut random = self.region_random_at(region[4].position());
decoration.decorate(
&self.data,
self.seed,
@@ -2169,6 +2190,9 @@ where
set,
)
}
fn region_random(&self, center: ChunkPos) -> Box<dyn super::structure::legacy::PieceRandom> {
Box::new(self.region_random_at(center))
}
fn spawn_position(&self) -> Result<[i32; 2], GeneratorError> {
if let Some(position) = self.spawn_position.get() {
return Ok(*position);
@@ -2918,6 +2942,14 @@ impl OverworldGenerator {
pub fn spawn_position(&self) -> Result<[i32; 2], GeneratorError> {
self.pipeline.spawn_position()
}
/// A new `WorldGenRegion` random centred on the chunk: what its generation
/// steps draw from (`level.getRandom()`).
pub fn region_random(
&self,
center: ChunkPos,
) -> Box<dyn super::structure::legacy::PieceRandom> {
self.pipeline.region_random(center)
}
/// Create per-thread state. Workers are cheap; keep one per thread.
pub fn worker(&self) -> GeneratorWorker {
let data = self.pipeline.data();
@@ -134,10 +134,10 @@ impl Decoration {
.collect(),
&data.biome_tags,
)?);
let mut bindings =
FeatureBlockBindings::with_tags(registry, data.integers().clone(), &data.block_tags)?
.with_fluid_tags(&resources.fluid_tags)?
.with_biomes(biomes.clone());
let mut bindings = data
.feature_block_bindings()?
.with_fluid_tags(&resources.fluid_tags)?
.with_biomes(biomes.clone());
if let Some(structures) = &data.structures {
bindings = bindings.with_structures(structures.clone());
}
@@ -203,11 +203,7 @@ impl StructureState {
for (i, def) in registry.structures.iter().enumerate() {
by_step[def.step].push(i);
}
let mut bindings = FeatureBlockBindings::with_tags(
data.registry(),
data.integers().clone(),
&data.block_tags,
)?;
let mut bindings = data.feature_block_bindings()?;
// Processors that test what a block is made of read canOcclude.
if let Some(blocks) = data
.feature_resources
+4 -4
View File
@@ -103,10 +103,10 @@ impl LiveFeatures {
.collect(),
&data.biome_tags,
)?);
let mut bindings =
FeatureBlockBindings::with_tags(registry, data.integers().clone(), &data.block_tags)?
.with_fluid_tags(&resources.fluid_tags)?
.with_biomes(biomes.clone());
let mut bindings = data
.feature_block_bindings()?
.with_fluid_tags(&resources.fluid_tags)?
.with_biomes(biomes.clone());
if let Some(structures) = &data.structures {
bindings = bindings.with_structures(structures.clone());
}
@@ -246,7 +246,7 @@ public final class VanillaFinalizeSpawnOracle {
Map.entry("jump_strength", Attributes.JUMP_STRENGTH));
static final List<Map.Entry<String, MemoryModuleType<?>>> MEMORIES = List.of(
Map.entry("long_jump_cooldown", MemoryModuleType.LONG_JUMP_COOLDOWN_TICKS), Map.entry("ram_cooldown", MemoryModuleType.RAM_COOLDOWN_TICKS),
Map.entry("long_jump_cooling_down", MemoryModuleType.LONG_JUMP_COOLDOWN_TICKS), Map.entry("ram_cooldown_ticks", MemoryModuleType.RAM_COOLDOWN_TICKS),
Map.entry("attack_target_cooldown", MemoryModuleType.ATTACK_TARGET_COOLDOWN), Map.entry("hunted_recently", MemoryModuleType.HUNTED_RECENTLY),
Map.entry("dig_cooldown", MemoryModuleType.DIG_COOLDOWN), Map.entry("is_emerging", MemoryModuleType.IS_EMERGING),
Map.entry("home", MemoryModuleType.HOME));
@@ -171,6 +171,8 @@ public final class VanillaNaturalSpawnOracle {
int tick = -1;
List<String> log;
boolean logColumns;
// A WorldGenRegion sees no entities (getEntities is empty).
boolean generationRegion;
int next = 1;
@Override public RegistryAccess registryAccess() { return registries; }
@Override public Difficulty getDifficulty() { return difficulty; }
@@ -281,11 +283,13 @@ public final class VanillaNaturalSpawnOracle {
@Override public Iterable<Entity> getAllEntities() { return entities; }
@Override public List<Entity> getEntities(Entity except, AABB bb, Predicate<? super Entity> selector) {
List<Entity> out = new ArrayList<>();
if (generationRegion) return out;
for (Entity e : entities) if (e != except && e.getBoundingBox().intersects(bb) && selector.test(e)) out.add(e);
return out;
}
@Override public <T extends Entity> List<T> getEntities(EntityTypeTest<Entity, T> type, AABB bb, Predicate<? super T> selector) {
List<T> out = new ArrayList<>();
if (generationRegion) return out;
for (Entity e : entities) {
T t = type.tryCast(e);
if (t != null && e.getBoundingBox().intersects(bb) && selector.test(t)) out.add(t);
@@ -811,11 +815,13 @@ public final class VanillaNaturalSpawnOracle {
vaultCase(level, c, access, blockLookup, mainRandom, log);
} else if (mode.equals("gen")) {
level.logColumns = true;
level.generationRegion = 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;
level.generationRegion = 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));
+10
View File
@@ -327,6 +327,13 @@ def scenarios():
# Constructor path maluses: an Animal avoids fire (FIRE -1, FIRE_IN_NEIGHBOR 16).
add('pig_tempt_fire', 100, ['player 9.5 -60 0.5 hand=minecraft:carrot', 'fill 3 -61 -3 3 -61 3 minecraft:netherrack',
'fill 3 -60 -3 3 -60 3 minecraft:fire', 'mob minecraft:pig 0.5 -60 0.5 0 221 persistent'])
# A baby villager grows up: Villager.ageBoundaryReached rebuilds its brain, so the adult schedule
# (work, meet) replaces the baby's (play) at once.
for seed in (201, 202):
add(f'villager_grows_up_{seed}', 120, ['player 8.5 -60 0.5',
f'mob minecraft:villager 0.5 -60 0.5 0 {seed} persistent age=-30'], 'time=2000')
# A zombified piglin runs Zombie.registerGoals too (the turtle egg goal draws its start delay).
add('zombified_piglin_player', 120, ['player 7.5 -60 0.5', 'mob minecraft:zombified_piglin 0.5 -60 0.5 0 241 persistent'])
add('wither_skeleton_player', 100, ['player 9.5 -60 0.5', 'mob minecraft:wither_skeleton 0.5 -60 0.5 0 222 persistent'])
# Chickens fleeing a hit (PanicGoal) beside a pool.
for seed in range(191, 193):
@@ -335,6 +342,9 @@ def scenarios():
+ [f'hurt {t} 0' for t in range(5, 60, 30)])
add('chicken_panic_dry', 60, ['player 12.5 -60 0.5', 'mob minecraft:chicken 0.5 -60 0.5 0 195 persistent', 'hurt 5 0'])
add('pig_panic_dry', 60, ['player 12.5 -60 0.5', 'mob minecraft:pig 0.5 -60 0.5 0 196 persistent', 'hurt 5 0'])
# A wild cat avoids the player it sees (Cat.reassessTameGoals adds that goal last) and sprints while
# it dashes at 1.33 (Cat.customServerAiStep).
add('cat_player', 120, ['player 6.5 -60 0.5', 'mob minecraft:cat 0.5 -60 0.5 0 261 persistent'])
# Axolotls (#64): idle swimming, hunting a cod, and playing dead after hits, in a pool sunk into
# the ground so no water flows out of it (the probe doesn't tick fluids).
POOL = 'fill -5 -63 -5 5 -61 5 minecraft:water'