WIP: tick orbs, primed TNT and falling blocks in join order between the other kinds

Not ready: the wind burst cells got worse (see the proof runs). Not pushed.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
HrafnaValkyrjaandClaude Sonnet 5.5 committed 2026-11-03 05:21:17 -05:00
1 parent f42ef42ef6
commit 3b7fe12fe2
8 files changed
+256 -142

No files matched your search

+44 -70
View File
@@ -50,6 +50,7 @@ fn pack_files(world_tables: &Option<WorldTables>) -> horizon_world::loot::PackOv
}
mod advancements;
mod ordered_tick;
mod advancements_screen;
mod agent;
mod agent_settings;
@@ -2677,6 +2678,7 @@ struct Game<'a> {
experience: Experience,
orbs: horizon_client::join_seq::JoinList<ExperienceOrb>,
xp_delay: u32,
ordered: ordered_tick::OrderedTicks,
last_level_up: u64,
/// The arm swing (`LivingEntity.SwingState`) and how many swings started.
swing: horizon_client::first_person::SwingState,
@@ -5668,38 +5670,22 @@ impl Game<'_> {
self.right = false;
}
/// FallingBlockEntity.tick: fall with 0.04 gravity and 0.98 drag, then land.
fn tick_falling(&mut self) -> Result<()> {
let falling = std::mem::take(&mut self.falling);
let mut kept = Vec::with_capacity(falling.len());
let mut collision_error = None;
/// One falling block's `tick`: places or drops its block in its own tick,
/// so a block falling onto one that just landed lands on top of it. `i` is
/// its place in the list; the caller has checked it is ticking.
fn tick_one_falling(&mut self, i: usize) -> Result<()> {
let entity_drops = self.ui.rules.bool("entity_drops");
// ServerLevel.tick: only entities in entity-ticking chunks tick.
let ticking = self.entity_ticking_area();
// FallingBlockEntity.tick places or drops each block in its own tick,
// so a block falling onto one that just landed lands on top of it.
for mut f in falling {
if !ticking(f.position.x.floor() as i32, f.position.z.floor() as i32) {
kept.push(f);
continue;
let mut f = self.falling.remove(i);
let original = f.clone();
match self.fall_step(&mut f, entity_drops) {
Ok(FallStep::Falling) => self.falling.insert(i.min(self.falling.len()), f),
Ok(FallStep::Gone) => {}
Ok(FallStep::Dropped(at)) => self.drop_falling_block(f.state, at),
Ok(FallStep::Landed(pos)) => self.finish_falling_block(f, pos),
Err(error) => {
self.falling.insert(i.min(self.falling.len()), original);
return Err(error);
}
let original = f.clone();
match self.fall_step(&mut f, entity_drops) {
Ok(FallStep::Falling) => kept.push(f),
Ok(FallStep::Gone) => {}
Ok(FallStep::Dropped(at)) => self.drop_falling_block(f.state, at),
Ok(FallStep::Landed(pos)) => self.finish_falling_block(f, pos),
Err(error) => {
kept.push(original);
collision_error.get_or_insert(error);
}
}
}
// Blocks that started falling meanwhile tick from the next tick on.
let started = std::mem::replace(&mut self.falling, kept.into());
self.falling.extend(started);
self.process_level_events()?;
if let Some(error) = collision_error {
return Err(error);
}
Ok(())
}
@@ -7527,9 +7513,11 @@ impl Game<'_> {
self.native_joins_flush(false, 0);
self.lan_mark_synced();
}
self.begin_ordered_ticks();
if running {
horizon_client::spans::time("tick_paintings", || self.tick_paintings());
horizon_client::spans::time("tick_item_frames", || self.tick_item_frames());
self.ordered_before(ordered_tick::first_join(&self.armor_stands))?;
horizon_client::spans::time("tick_armor_stands", || self.tick_armor_stands())?;
horizon_client::spans::time("tick_cushions", || self.tick_cushions());
// ServerLevel.tick: the arrivals of this server tick are in the level
@@ -7537,7 +7525,9 @@ impl Game<'_> {
// own tick opens.
self.land_portal_arrivals();
self.handle_vehicle_portals();
self.ordered_before(ordered_tick::first_join(&self.boats))?;
horizon_client::spans::time("tick_boats", || self.tick_boats());
self.ordered_before(ordered_tick::first_join(&self.carts))?;
horizon_client::spans::time("tick_carts", || self.tick_carts());
self.tick_vehicle_watch();
let from = self.lan_synced_from();
@@ -7548,7 +7538,9 @@ impl Game<'_> {
self.tick_lodestone_compasses();
horizon_client::spans::time("tick_bolts", || self.tick_bolts());
horizon_client::spans::time("tick_fishing", || self.tick_fishing());
self.ordered_before(ordered_tick::first_join(&self.tridents))?;
horizon_client::spans::time("tick_tridents", || self.tick_tridents());
self.ordered_before(ordered_tick::first_join(&self.rockets))?;
horizon_client::spans::time("tick_rockets", || self.tick_rockets());
horizon_client::spans::time("tick_ominous_item_spawners", || {
self.tick_ominous_item_spawners()
@@ -7559,6 +7551,7 @@ impl Game<'_> {
self.handle_entity_portals();
self.handle_end_gateway_portals();
});
self.ordered_before(ordered_tick::first_join(&self.dropped))?;
horizon_client::spans::time("tick_items", || self.tick_items());
if self.remote.is_none() {
self.hoppers_catch_items();
@@ -7572,11 +7565,14 @@ impl Game<'_> {
// The ride the player's file names, once the vehicle is in the world.
self.restore_root_vehicle();
if running {
self.ordered_before(ordered_tick::first_join(&self.mobs))?;
horizon_client::spans::time("tick_mobs", || self.tick_mobs())?;
// Dead, despawned or gone into a hive.
self.native_leaves_flush();
horizon_client::spans::time("recheck_golem_openers", || self.recheck_golem_openers());
self.tick_mod_container_viewers();
// Orbs, primed TNT and falling blocks still to tick, in join order.
self.run_ordered_ticks(horizon_client::join_seq::JoinSeq::MAX)?;
horizon_client::spans::time("tick_experience", || self.tick_experience());
// Merged, despawned or picked up.
self.native_leaves_flush();
@@ -7603,8 +7599,6 @@ impl Game<'_> {
self.keep_gateway_destinations();
}
if running {
horizon_client::spans::time("tick_falling", || self.tick_falling())?;
horizon_client::spans::time("tick_tnt", || self.tick_tnt())?;
self.apply_late_wind_pushes();
}
if running {
@@ -13512,17 +13506,15 @@ impl Game<'_> {
}
(min + self.world.random.next_int_bound(max - min + 1).unwrap_or(0)) as u32
}
fn tick_experience(&mut self) {
if self.xp_delay > 0 {
self.xp_delay -= 1;
}
/// `ExperienceOrb.followNearbyPlayer`'s candidates: each orb goes for the
/// nearest living player within 8 blocks, the host's own or a guest. Each
/// is the player's feet and the point the orb homes on.
pub(super) fn orb_followed(&self) -> Vec<([f64; 3], [f64; 3])> {
let spectator = self.spectator();
let player = self
.player
.as_ref()
.filter(|p| !p.health.dead_or_dying() && !spectator);
// ExperienceOrb.followNearbyPlayer: each orb goes for the nearest
// living player within 8 blocks, the host's own or a guest.
let mut followed: Vec<([f64; 3], [f64; 3])> = player
.map(|p| {
let feet = [p.position.x, p.position.y, p.position.z];
@@ -13544,6 +13536,19 @@ impl Game<'_> {
}),
);
}
followed
}
/// The orbs' tick, but for the movement: that is `tick_ordered_before`'s,
/// in join order with the other kinds.
fn tick_experience(&mut self) {
if self.xp_delay > 0 {
self.xp_delay -= 1;
}
let spectator = self.spectator();
let player = self
.player
.as_ref()
.filter(|p| !p.health.dead_or_dying() && !spectator);
// Player.aiStep: the pickup area is the box inflated by (1, 0.5, 1).
let bounds = player.map(|p| {
let w = f64::from(p.dimensions.width) / 2.0;
@@ -13560,38 +13565,6 @@ impl Game<'_> {
],
)
});
let mut orbs = std::mem::take(&mut self.orbs);
// An orb has no random of its own here: lava draws from one made on
// first use, apart from the level stream.
let mut lava_random = None;
let mut next_float = || {
lava_random
.get_or_insert_with(dispenser::entity_random)
.next_float()
};
orbs.retain_mut(|o| {
let distance = |feet: &[f64; 3]| -> f64 {
(feet[0] - o.position.x).powi(2)
+ (feet[1] - o.position.y).powi(2)
+ (feet[2] - o.position.z).powi(2)
};
let target = followed
.iter()
.filter(|(feet, _)| distance(feet) < 64.0)
.min_by(|a, b| distance(&a.0).total_cmp(&distance(&b.0)))
.map(|(_, target)| *target);
o.tick(
&self.world,
self.res.facts(),
self.collision,
target,
self.remote.is_none(),
&mut next_float,
Some(self.border.geometry()),
&self.res.motion().fluids,
)
});
self.orbs = orbs;
// ExperienceOrb.scanForMerges runs on the server only.
if self.remote.is_none() {
self.merge_orbs();
@@ -18825,6 +18798,7 @@ impl<'a> Game<'a> {
experience: Experience::default(),
orbs: Default::default(),
xp_delay: 0,
ordered: Default::default(),
last_level_up: 0,
swing: Default::default(),
swings_started: 0,
@@ -1188,6 +1188,7 @@ impl Game<'_> {
}
self.handle_mob_events(events)?;
self.lan_guest_mob_events(guest_events);
self.ordered_before(super::ordered_tick::first_join(&self.creatures.projectiles))?;
self.tick_projectiles()?;
self.tick_fangs();
self.tick_allays();
+4 -11
View File
@@ -155,8 +155,10 @@ pub(super) trait Entity: EntityRules {
let b = self.explosion_box();
f64::from((b.max_position().y - b.min_position().y) as f32 * 0.85f32)
}
/// Whether this kind ticks after the projectiles do (orbs, falling blocks,
/// primed TNT), where vanilla ticks every entity in the order it joined.
/// Whether this kind ticks after the projectiles do whatever its join
/// number, where vanilla ticks every entity in the order it joined. No kind
/// does since orbs, falling blocks and primed TNT tick in join order
/// (`game/ordered_tick.rs`).
fn ticks_after_projectiles(&self) -> bool {
false
}
@@ -839,9 +841,6 @@ impl Entity for ItemEntity {
}
impl Entity for ExperienceOrb {
fn ticks_after_projectiles(&self) -> bool {
true
}
fn native_type(&self) -> Cow<'static, str> {
"minecraft:experience_orb".into()
}
@@ -1118,9 +1117,6 @@ impl Entity for ArmorStand {
}
impl Entity for PrimedTnt {
fn ticks_after_projectiles(&self) -> bool {
true
}
fn native_type(&self) -> Cow<'static, str> {
"minecraft:tnt".into()
}
@@ -1178,9 +1174,6 @@ impl Entity for PrimedTnt {
}
impl Entity for FallingBlock {
fn ticks_after_projectiles(&self) -> bool {
true
}
fn native_type(&self) -> Cow<'static, str> {
"minecraft:falling_block".into()
}
@@ -0,0 +1,151 @@
//! PURPOSE: ticks experience orbs, primed TNT and falling blocks in the order
//! they joined the level, between the other kinds' batches.
//! WHY: `ServerLevel.tick` ticks every entity in join order, one kind among
//! the rest; these three used to tick as three fixed batches (orbs after the
//! mobs, falling blocks and TNT after the gateways), so a TNT that joined
//! before a boat blew up after the boat moved (#6509, tick order slice 2).
//! REFS: vanilla `ServerLevel.tick` (`entityTickList.forEach`),
//! `EntityTickList`; `join_seq.rs`; `tools/test_tick_order_parity.py`.
//! LOGS: none. READS-BY: `Game::tick_world`, `tick_mobs`.
//! ENDS-WHEN: every kind ticks from the join-order walk.
use super::{Game, Result};
use horizon_client::join_seq::{JoinSeq, Joined};
use horizon_core::random::RandomSource as _;
/// What this tick has ticked of the three kinds.
#[derive(Default, Clone)]
pub(super) struct OrderedTicks {
/// Joins at or above this started after the tick began; they tick next tick.
born: JoinSeq,
/// The last join number ticked, if any: later ones are still to come.
done: Option<JoinSeq>,
/// The orbs' lava random, made on first use.
lava_random: Option<horizon_core::random::LegacyRandom>,
}
#[derive(Clone, Copy)]
enum Pending {
Orb,
Tnt,
Falling,
}
/// The lowest join number in a batch, or none when it is empty.
pub(super) fn first_join<T: Joined>(list: &[T]) -> Option<JoinSeq> {
list.iter().map(Joined::join_seq).min()
}
impl Game<'_> {
/// A level tick starts: what has joined so far ticks this tick.
pub(super) fn begin_ordered_ticks(&mut self) {
self.ordered = OrderedTicks {
born: JoinSeq::upcoming(),
done: None,
lava_random: None,
};
}
/// Ticks, one at a time in join order, every orb, primed TNT and falling
/// block that joined before `cut` and has not ticked this tick.
pub(super) fn run_ordered_ticks(&mut self, cut: JoinSeq) -> Result<()> {
let mut first_error = None;
let mut followed = None;
let mut events = false;
let entity_ticking = self.entity_ticking_area();
loop {
let (done, born) = (self.ordered.done, self.ordered.born);
let wanted = |j: JoinSeq| done.is_none_or(|d| j > d) && j < cut && j < born;
let mut next: Option<(JoinSeq, Pending)> = None;
let mut offer = |j: JoinSeq, kind: Pending| {
if wanted(j) && next.is_none_or(|(n, _)| j < n) {
next = Some((j, kind));
}
};
self.orbs.iter().for_each(|e| offer(e.join, Pending::Orb));
self.tnts.iter().for_each(|e| offer(e.join, Pending::Tnt));
self.falling
.iter()
.for_each(|e| offer(e.join, Pending::Falling));
let Some((join, kind)) = next else { break };
self.ordered.done = Some(join);
// ServerLevel.tick: only entities in entity-ticking chunks tick.
match kind {
Pending::Tnt => {
let Some(i) = self.tnts.iter().position(|e| e.join == join) else {
continue;
};
let at = self.tnts[i].position;
if entity_ticking(at.x.floor() as i32, at.z.floor() as i32) {
if let Err(e) = self.tick_one_tnt(i) {
first_error.get_or_insert(e);
}
}
}
Pending::Falling => {
let Some(i) = self.falling.iter().position(|e| e.join == join) else {
continue;
};
let at = self.falling[i].position;
if entity_ticking(at.x.floor() as i32, at.z.floor() as i32) {
events = true;
if let Err(e) = self.tick_one_falling(i) {
first_error.get_or_insert(e);
}
}
}
Pending::Orb => {
let Some(i) = self.orbs.iter().position(|e| e.join == join) else {
continue;
};
let followed = followed.get_or_insert_with(|| self.orb_followed());
let o = &mut self.orbs[i];
let distance = |feet: &[f64; 3]| -> f64 {
(feet[0] - o.position.x).powi(2)
+ (feet[1] - o.position.y).powi(2)
+ (feet[2] - o.position.z).powi(2)
};
let target = followed
.iter()
.filter(|(feet, _)| distance(feet) < 64.0)
.min_by(|a, b| distance(&a.0).total_cmp(&distance(&b.0)))
.map(|(_, target)| *target);
// An orb has no random of its own here: lava draws from one
// made on first use, apart from the level stream.
let random = &mut self.ordered.lava_random;
let mut next_float = || {
random
.get_or_insert_with(super::dispenser::entity_random)
.next_float()
};
let stays = o.tick(
&self.world,
self.res.facts(),
self.collision,
target,
self.remote.is_none(),
&mut next_float,
Some(self.border.geometry()),
&self.res.motion().fluids,
);
if !stays {
self.orbs.remove(i);
}
}
}
}
if events && let Err(e) = self.process_level_events() {
first_error.get_or_insert(e);
}
first_error.map_or(Ok(()), Err)
}
/// Before a batch of another kind ticks: the three kinds' entities that
/// joined before its first one tick first.
pub(super) fn ordered_before(&mut self, first: Option<JoinSeq>) -> Result<()> {
match first {
Some(first) => self.run_ordered_ticks(first),
None => Ok(()),
}
}
}
+47 -59
View File
@@ -229,65 +229,53 @@ impl Game<'_> {
/// `PrimedTnt.tick` for every primed TNT, in order; an expired fuse
/// explodes immediately (later TNT in the list feel its knockback this
/// tick). TNT primed by an explosion starts ticking next tick.
pub(super) fn tick_tnt(&mut self) -> Result<()> {
let mut count = self.tnts.len();
let mut i = 0;
// ServerLevel.tick: only entities in entity-ticking chunks tick.
let ticking = self.entity_ticking_area();
while i < count {
let t = &mut self.tnts[i];
if !ticking(t.position.x.floor() as i32, t.position.z.floor() as i32) {
i += 1;
continue;
}
let tops = t.tick(
&self.world,
self.collision,
&self.pack.assets,
self.res.facts(),
&self.res.motion().fluids,
Some(self.border.geometry()),
)?;
for pos in tops {
self.bubble_column_particles(pos);
}
let t = &mut self.tnts[i];
if t.fuse <= 0 {
let center = [
t.position.x,
t.position.y + PrimedTnt::SIZE * 0.0625,
t.position.z,
];
let bounds = t.bounds();
let (owner, guest, power) = (t.owner, t.guest, t.power);
let in_water = self.bounds_in_water(bounds);
self.tnts.remove(i);
count -= 1;
// ExplosionInteraction.TNT: the tnt_explosion_drop_decay rule.
let interaction = self.destroy_type("tnt_explosion_drop_decay");
self.combat.explosion_in_water = in_water;
// `Explosion.getIndirectSourceEntity`: a player-lit TNT names
// its igniter, which `blast_attacker` and the damage type read.
self.combat.blast_guest = guest;
self.combat.blast_owner = tnt_blast_owner(
owner,
|| {
self.lan
.as_ref()
.and_then(|host| self.player_attacker(host, guest))
},
|id| self.mob_display_name(id),
);
let result = self.with_hive_emptying_blast(|g| {
g.server_explode(center, power, interaction, false)
});
self.combat.blast_owner = None;
self.combat.blast_guest = None;
self.combat.explosion_in_water = false;
result?;
} else {
i += 1;
}
/// One primed TNT's `tick`: it moves, and explodes when its fuse is out.
/// `i` is its place in the list; the caller has checked it is ticking.
pub(super) fn tick_one_tnt(&mut self, i: usize) -> Result<()> {
let t = &mut self.tnts[i];
let tops = t.tick(
&self.world,
self.collision,
&self.pack.assets,
self.res.facts(),
&self.res.motion().fluids,
Some(self.border.geometry()),
)?;
for pos in tops {
self.bubble_column_particles(pos);
}
let t = &mut self.tnts[i];
if t.fuse <= 0 {
let center = [
t.position.x,
t.position.y + PrimedTnt::SIZE * 0.0625,
t.position.z,
];
let bounds = t.bounds();
let (owner, guest, power) = (t.owner, t.guest, t.power);
let in_water = self.bounds_in_water(bounds);
self.tnts.remove(i);
// ExplosionInteraction.TNT: the tnt_explosion_drop_decay rule.
let interaction = self.destroy_type("tnt_explosion_drop_decay");
self.combat.explosion_in_water = in_water;
// `Explosion.getIndirectSourceEntity`: a player-lit TNT names
// its igniter, which `blast_attacker` and the damage type read.
self.combat.blast_guest = guest;
self.combat.blast_owner = tnt_blast_owner(
owner,
|| {
self.lan
.as_ref()
.and_then(|host| self.player_attacker(host, guest))
},
|id| self.mob_display_name(id),
);
let result = self
.with_hive_emptying_blast(|g| g.server_explode(center, power, interaction, false));
self.combat.blast_owner = None;
self.combat.blast_guest = None;
self.combat.explosion_in_water = false;
result?;
}
Ok(())
}
+1 -1
View File
@@ -651,7 +651,7 @@ mod tests {
power: 4.0,
join: Default::default(),
};
assert!(Entity::ticks_after_projectiles(&tnt));
assert!(!Entity::ticks_after_projectiles(&tnt));
let arrow = Projectile::new(ProjectileKind::Snowball, Vec3::ZERO, None);
assert!(!Entity::ticks_after_projectiles(&arrow));
}
+7
View File
@@ -27,6 +27,13 @@ impl JoinSeq {
fn next() -> Self {
Self(NEXT.fetch_add(1, Ordering::Relaxed))
}
/// Above every number a join can get.
pub const MAX: Self = Self(u64::MAX);
/// The number the next join will get or a later one: every entity that has
/// joined so far is below it.
pub fn upcoming() -> Self {
Self(NEXT.load(Ordering::Relaxed))
}
pub fn is_joined(self) -> bool {
self.0 != 0
}
+1 -1
View File
@@ -46,7 +46,7 @@ except ImportError:
from test_locator_bar_parity import generate_world
NAME = 'tick-order-parity'
TOLERANCE = 1e-6
TOLERANCE = float(os.environ.get('TICK_ORDER_TOLERANCE', '1e-6')) # 0 for exact
PROPERTIES = {
'online-mode': 'false',
'white-list': 'false',