Merge pull request #1417 from ludovic111/fix/st2-rules

🤖 Agent: merged after verify and CI parity passed; lan_e2e (hands, styles, feedback, chat, frames, riding, mounts, beds, contacts, worldspawn) passes on the server thread and in-frame with all four merged on main (bugs-st-2).
This commit is contained in:
Ludovic Marie authored and GitHub committed 2026-10-23 13:20:42 +02:00
commit 6be7daebc8
6 files changed
+117 -53

No files matched your search

+13 -5
View File
@@ -4306,7 +4306,7 @@ impl Game<'_> {
/// world's entities and blocks, and the network hub.
fn tick_world(&mut self, running: bool) -> Result<()> {
self.update_chunk_tickets();
horizon_client::spans::time("tick_effects", || self.tick_effects());
horizon_client::spans::time("tick_effects", || self.tick_effects(running));
// Player.tick's ItemCooldowns.tick for the window's player (a
// server's players count theirs down in `tick_guests`).
self.shield_cooldown = self.shield_cooldown.saturating_sub(1);
@@ -7037,10 +7037,16 @@ impl Game<'_> {
// Item.getAttackDamageBonus (a mace's smash), before the critical hit.
amount += self.smash_damage_bonus(target_kind);
let player = self.player.as_ref();
// Player.canCriticalAttack: falling, off the ground, not sprinting.
let falling =
player.is_some_and(|p| !p.ground.on_ground && p.fall_distance > 0.0 && !p.sprinting);
let crit = scale > 0.9 && falling;
// Player.canCriticalAttack: falling, off the ground, not in water,
// not blind (isMobilityRestricted), not riding, not sprinting.
let falling = player.is_some_and(|p| {
!p.ground.on_ground
&& p.fall_distance > 0.0
&& !p.fluids.was_touching_water
&& !p.sprinting
});
let restricted = self.potions.effects.get("blindness").is_some() || self.riding.is_some();
let crit = scale > 0.9 && falling && !restricted;
if crit {
amount *= 1.5;
}
@@ -7649,6 +7655,8 @@ impl Game<'_> {
world.game_time = self.world.game_time;
world.day_time = self.world.day_time;
world.weather = self.world.weather;
// The game rules and the difficulty hold in every dimension.
world.health_rules = self.world.health_rules;
// An open hub keeps hearing the new level's block changes.
world.block_log = self.world.block_log.as_ref().map(|_| Vec::new());
world.light_log = self.world.light_log.as_ref().map(|_| Default::default());
@@ -810,7 +810,10 @@ impl Game<'_> {
None => break,
},
};
if self.slot_get(slot) != item && self.slot_set(slot, item) {
// ItemStack.isEmpty: air (or a count of 0) is an empty slot.
// SlotAccess.set counts every slot it sets, the same item too.
let item = item.filter(|s| &*s.item != "minecraft:air" && s.count > 0);
if self.slot_set(slot, item) {
changed += 1;
}
}
@@ -8,6 +8,15 @@ use super::Game;
use horizon_client::{enchant::EffectContext, items::ItemStack};
use horizon_core::random::RandomSource;
/// Where Mending found a damaged item (`EnchantedItemInUse.inSlot`).
#[derive(Clone, Copy)]
enum MendSlot {
Main,
Off,
/// An `armor` index (head first).
Armor(usize),
}
impl Game<'_> {
/// `Player.getDestroySpeed` after the tool speed: mining efficiency, haste
/// and mining fatigue.
@@ -314,36 +323,29 @@ impl Game<'_> {
pub(super) fn mending(&mut self, amount: u32) -> u32 {
let mut amount = amount;
loop {
// EnchantmentHelper.getRandomItemWith(REPAIR_WITH_XP, damaged): held and armor.
let mut candidates: Vec<(bool, usize)> = Vec::new();
if let Some(s) = &self.inventory[self.selected]
&& s.damage > 0
&& s.enchantment_level("minecraft:mending") > 0
{
candidates.push((false, self.selected));
}
for (i, s) in self.armor.iter().enumerate() {
if let Some(s) = s
&& s.damage > 0
&& s.enchantment_level("minecraft:mending") > 0
{
candidates.push((true, i));
}
}
// EnchantmentHelper.getRandomItemWith(REPAIR_WITH_XP, damaged), in
// EquipmentSlot order: the main hand, the off hand, then feet, legs,
// chest and head (`armor` holds head first).
let mending = |s: &Option<ItemStack>| {
s.as_ref()
.is_some_and(|s| s.damage > 0 && s.enchantment_level("minecraft:mending") > 0)
};
let candidates: Vec<MendSlot> = [MendSlot::Main, MendSlot::Off]
.into_iter()
.chain((0..self.armor.len()).rev().map(MendSlot::Armor))
.filter(|&slot| mending(self.mend_slot(slot)))
.collect();
if candidates.is_empty() {
return amount;
}
let (armor, i) = candidates[self
let slot = candidates[self
.world
.random
.next_int_bound(candidates.len() as i32)
.unwrap_or(0) as usize];
let stack = if armor {
self.armor[i].clone()
} else {
self.inventory[i].clone()
let Some(stack) = self.mend_slot(slot).clone() else {
return amount;
};
let Some(stack) = stack else { return amount };
let ctx = EffectContext {
items: Some(self.res.items()),
..Default::default()
@@ -356,12 +358,7 @@ impl Game<'_> {
amount as f32,
) as u32;
let repaired = to_repair_xp.min(stack.damage);
let slot = if armor {
&mut self.armor[i]
} else {
&mut self.inventory[i]
};
if let Some(s) = slot.as_mut() {
if let Some(s) = self.mend_slot_mut(slot).as_mut() {
s.damage -= repaired;
}
if repaired == 0 || to_repair_xp == 0 {
@@ -374,6 +371,20 @@ impl Game<'_> {
amount = remaining;
}
}
fn mend_slot(&self, slot: MendSlot) -> &Option<ItemStack> {
match slot {
MendSlot::Main => &self.inventory[self.selected],
MendSlot::Off => &self.offhand,
MendSlot::Armor(i) => &self.armor[i],
}
}
fn mend_slot_mut(&mut self, slot: MendSlot) -> &mut Option<ItemStack> {
match slot {
MendSlot::Main => &mut self.inventory[self.selected],
MendSlot::Off => &mut self.offhand,
MendSlot::Armor(i) => &mut self.armor[i],
}
}
/// Bow enchantments at release: (extra base damage, infinity keeps the arrow).
pub(super) fn bow_enchantments(&mut self) -> (f64, bool) {
let Some(bow) = self.inventory[self.selected].clone() else {
+3
View File
@@ -232,6 +232,9 @@ impl Game<'_> {
p.world.game_time = self.world.game_time;
p.world.day_time = self.world.day_time;
p.world.weather = self.world.weather;
// The game rules and the difficulty are the server's, not a
// level's: natural regeneration holds in every dimension.
p.world.health_rules = self.world.health_rules;
// The sculk tables' watched blocks are built once per game.
p.world.watched = std::mem::take(&mut self.world.watched);
self.swap_level(&mut p);
+7 -3
View File
@@ -297,10 +297,14 @@ impl Game<'_> {
}
}
/// The local player's effects (`tick_mob_effects`), then the potions
/// in flight, which a server without a local player still has.
pub(super) fn tick_effects(&mut self) {
/// in flight, which a server without a local player still has. A frozen
/// tick (`/tick freeze`) leaves the potions where they are
/// (`TickRateManager.isEntityFrozen`); players' effects still run.
pub(super) fn tick_effects(&mut self, running: bool) {
self.tick_mob_effects();
self.tick_thrown_potions();
if running {
self.tick_thrown_potions();
}
}
/// `LivingEntity.tickEffects` (server side) for `self.player`, the local
/// player or a guest acting (`as_guest`), then its attribute modifiers
+51 -16
View File
@@ -306,9 +306,18 @@ impl Game<'_> {
}
}
/// `PhantomSpawner.tick`: tired players under an open night sky draw
/// phantoms.
/// phantoms. A custom spawner ticks only while `spawn_mobs` is on
/// (`ServerChunkCache.tickChunks`), and this one only spawns enemies
/// (`ServerLevel.isSpawningMonsters`: `spawn_monsters` too). Every
/// player of the level counts: the game's own and the hosted ones (a
/// server thread's players are all hosted).
pub(super) fn spawn_phantoms(&mut self) {
if !self.ui.rules.bool("spawn_phantoms") || self.dim != Dim::Overworld {
let rules = &self.ui.rules;
if !rules.bool("spawn_mobs")
|| !rules.bool("spawn_monsters")
|| !rules.bool("spawn_phantoms")
|| self.dim != Dim::Overworld
{
return;
}
self.stats.phantom_tick -= 1;
@@ -317,16 +326,42 @@ impl Game<'_> {
}
let r = &mut self.world.random;
self.stats.phantom_tick += (60 + r.next_int_bound(60).unwrap_or(0)) * 20;
if self.sky_darken() < 5 || self.spectator() {
if self.sky_darken() < 5 {
return;
}
let Some(p) = self.player.as_ref() else {
return;
};
let rest = |s: &Stats| s.get("custom", "minecraft:time_since_rest");
// `level.players()`: (block position, time since rest) of each player
// who isn't a spectator.
let mut players = Vec::new();
if let Some(p) = self.player.as_ref()
&& !self.spectator()
{
players.push((p.position, rest(&self.stats)));
}
if let Some(host) = self.lan.as_ref() {
let level = self.dim.id();
for g in host.hub.guests.iter().filter(|g| {
g.placed
&& g.dimension == level
&& g.game_mode != horizon_client::lan::guest::SPECTATOR
}) {
let since = host.play.get(&g.conn).map_or(0, |p| rest(&p.stats));
players.push((
Vec3::new(g.position[0], g.position[1], g.position[2]),
since,
));
}
}
for (position, since) in players {
self.spawn_phantoms_near(position, since);
}
}
/// One player's turn of `PhantomSpawner.tick`.
fn spawn_phantoms_near(&mut self, position: Vec3, since_rest: i32) {
let pos = [
p.position.x.floor() as i32,
p.position.y.floor() as i32,
p.position.z.floor() as i32,
position.x.floor() as i32,
position.y.floor() as i32,
position.z.floor() as i32,
];
if pos[1] < 63 || !self.can_see_sky(pos) {
return;
@@ -336,15 +371,15 @@ impl Game<'_> {
if r.next_float() * 3.0 >= 2.0 {
return;
}
let rest = self.stats.get("custom", "minecraft:time_since_rest").max(1);
if r.next_int_bound(rest).unwrap_or(0) < 72000 {
if r.next_int_bound(since_rest.max(1)).unwrap_or(0) < 72000 {
return;
}
let at = [
pos[0] - 10 + r.next_int_bound(21).unwrap_or(10),
pos[1] + 20 + r.next_int_bound(15).unwrap_or(0),
pos[2] - 10 + r.next_int_bound(21).unwrap_or(10),
];
// `playerPos.above(20 + nextInt(15)).east(-10 + nextInt(21)).south(...)`:
// the height is drawn first.
let up = 20 + r.next_int_bound(15).unwrap_or(0);
let east = -10 + r.next_int_bound(21).unwrap_or(10);
let south = -10 + r.next_int_bound(21).unwrap_or(10);
let at = [pos[0] + east, pos[1] + up, pos[2] + south];
if !self
.res
.facts()