Merge pull request #11670 from horizon-rust/fix/breeding-bottle-xp-11388

🤖 Agent: breeding now adds one fresh orb of the whole 1-7 roll (no award split, no merge), and XP bottle orbs start 0.25 along the hit face's normal with their motion turned away from it; orb_lava/orb_water/orb_saved_uuid/bed_explosion harnesses passed.
This commit is contained in:
Ludovic Marie authored and GitHub committed 2026-11-07 09:17:36 +01:00
commit 004a39be07
4 files changed
+129 -18

No files matched your search

+42 -9
View File
@@ -14500,12 +14500,19 @@ impl Game<'_> {
/// `ExperienceOrb.award`: orbs of the largest sizes; each draws the id
/// `tryMergeToExisting` compares from the level random, then moves with
/// its own random.
fn spawn_orbs(&mut self, at: [f64; 3], mut value: u32) {
fn spawn_orbs(&mut self, at: [f64; 3], value: u32) {
self.spawn_orbs_toward(at, [0.0; 3], value);
}
/// `ExperienceOrb.awardWithDirection`: `award` whose fresh orbs start
/// `roughly` that way (`new ExperienceOrb(level, pos, roughly, value)`).
fn spawn_orbs_toward(&mut self, at: [f64; 3], roughly: [f64; 3], mut value: u32) {
while value > 0 {
let v = ExperienceOrb::split(value);
value -= v;
let merge_id = self.world.random.next_int_bound(40).unwrap_or(0);
self.award_orb(at, v, merge_id);
if !self.merge_new_orb(at, v, merge_id) {
self.add_orb_toward(at, roughly, v);
}
}
}
/// `ExperienceOrb.tryMergeToExisting`: an orb of the same value within a
@@ -14513,6 +14520,12 @@ impl Game<'_> {
/// the new one as one more in its count (and restarts its age); otherwise
/// a fresh orb spawns.
fn award_orb(&mut self, at: [f64; 3], value: u32, merge_id: i32) {
if !self.merge_new_orb(at, value, merge_id) {
self.add_orb(at, value);
}
}
/// `tryMergeToExisting`: true when an orb took the new one.
fn merge_new_orb(&mut self, at: [f64; 3], value: u32, merge_id: i32) -> bool {
let centre = Vec3::new(at[0], at[1], at[2]);
let half = Vec3::new(0.5, 0.5, 0.5);
let area = Aabb::new(centre.subtract(half), centre.add_vector(half));
@@ -14534,8 +14547,9 @@ impl Game<'_> {
if let Some(i) = target {
self.orbs[i].count += 1;
self.orbs[i].age = 0;
true
} else {
self.add_orb(at, value);
false
}
}
/// Whether orb `into` takes orb `from` (0: a spawning orb of `from_value`),
@@ -14609,10 +14623,33 @@ impl Game<'_> {
/// `new ExperienceOrb(level, pos, Vec3.ZERO, value)`: yaw and motion from
/// the orb's own random.
fn add_orb(&mut self, at: [f64; 3], value: u32) {
let at = self.unstuck_orb(at);
self.add_orb_toward(at, [0.0; 3], value);
}
/// `new ExperienceOrb(level, pos, roughly, value)`: a random motion that
/// points against `roughly` is turned round, and the orb starts half its
/// size (0.25) along `roughly`, unstuck if it collides there.
fn add_orb_toward(&mut self, at: [f64; 3], roughly: [f64; 3], value: u32) {
let len_sqr = roughly.iter().map(|v| v * v).sum::<f64>();
// Vec3.normalize: a vector shorter than 1e-5 has no direction.
let len = len_sqr.sqrt();
let unit = if len < 1.0e-5 {
[0.0; 3]
} else {
roughly.map(|v| v / len)
};
let at = self.unstuck_orb([0, 1, 2].map(|a| at[a] + unit[a] * 0.25));
let mut r = dispenser::entity_random();
let _yaw = r.next_float();
let p = Vec3::new(at[0], at[1], at[2]);
let mut velocity = Vec3::new(
(r.next_double() * 0.2 - 0.1) * 2.0,
r.next_double() * 0.2 * 2.0,
(r.next_double() * 0.2 - 0.1) * 2.0,
);
let dot = roughly[0] * velocity.x + roughly[1] * velocity.y + roughly[2] * velocity.z;
if len_sqr > 0.0 && dot < 0.0 {
velocity = Vec3::new(-velocity.x, -velocity.y, -velocity.z);
}
self.next_entity += 1;
self.orbs.push(ExperienceOrb {
rotation: [0.0, 0.0],
@@ -14621,11 +14658,7 @@ impl Game<'_> {
id: self.next_entity,
position: p,
previous: p,
velocity: Vec3::new(
(r.next_double() * 0.2 - 0.1) * 2.0,
r.next_double() * 0.2 * 2.0,
(r.next_double() * 0.2 - 0.1) * 2.0,
),
velocity,
value,
age: 0,
on_ground: false,
+36 -5
View File
@@ -3470,10 +3470,11 @@ impl Game<'_> {
}
}
/// `Animal.finalizeSpawnChildFromBreeding`'s experience orb: only when the
/// `mob_drops` game rule is on.
/// `mob_drops` game rule is on, and one `new ExperienceOrb` of the whole
/// value (not split or merged as `ExperienceOrb.award` does).
fn spawn_breeding_orb(&mut self, at: [f64; 3], amount: u32) {
if self.ui.rules.bool("mob_drops") {
self.spawn_orbs(at, amount);
self.add_orb(at, amount);
}
}
/// `ServerPlayer.tellNeutralMobsThatIDied`: the mobs within 32 blocks
@@ -3834,13 +3835,14 @@ impl Game<'_> {
self.breeding_orb(parent, [p.position.x, p.position.y, p.position.z]);
}
/// `Animal.finalizeSpawnChildFromBreeding`'s experience, from the breeding
/// parent's own random, and only when the `mob_drops` game rule is on.
/// parent's own random, and only when the `mob_drops` game rule is on: one
/// fresh orb of 1 to 7 (#11388).
fn breeding_orb(&mut self, parent: u32, at: [f64; 3]) {
if self.ui.rules.bool("mob_drops")
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == parent)
{
let xp = 1 + m.random.next_int_bound(7).unwrap_or(0) as u32;
self.spawn_orbs(at, xp);
self.add_orb(at, xp);
}
}
/// `MobEffectUtil.addEffectToPlayersAround` for an elder guardian:
@@ -5719,7 +5721,7 @@ impl Game<'_> {
_ => None,
};
let owner = self.potion_owner(p.owner);
self.splash(stack, at, mob, block, p.age, &owner);
self.splash(stack, at, mob, block, p.age, &owner, p.velocity);
if stack.item.as_ref() != "minecraft:experience_bottle" {
let item = stack.item.to_string();
self.native_potion_splash(&item, [at.x, at.y, at.z]);
@@ -9102,3 +9104,32 @@ mod pickable_projectile_tests {
}
}
}
#[cfg(test)]
mod breeding_orb_tests {
use super::*;
/// `Animal.finalizeSpawnChildFromBreeding`: one fresh orb of the parent's
/// 1-to-7 roll, never split (4 is not 3 + 1) or merged (#11388).
#[test]
#[ignore = "needs prepared resources (HORIZON_TEST_RESOURCES)"]
fn breeding_drops_one_orb_of_the_whole_roll() {
let mut game = super::super::native_machines::tests::server_game();
let kind = game.res.mob_types().get("minecraft:cow").unwrap().clone();
game.mobs
.push(Mob::new(7, kind, Vec3::new(8.5, 64.0, 8.5), 0.0));
let mut values = std::collections::BTreeSet::new();
for _ in 0..40 {
let roll = 1 + game.mobs[0].random.clone().next_int_bound(7).unwrap() as u32;
let orbs = game.orbs.len();
game.breeding_orb(7, [8.5, 64.0, 8.5]);
assert_eq!(game.orbs.len(), orbs + 1, "a fresh orb, not merged");
assert_eq!(
game.orbs.last().map(|o| (o.value, o.count)),
Some((roll, 1))
);
values.insert(roll);
}
assert!(values.iter().any(|v| matches!(v, 4..=6)), "{values:?}");
}
}
+49 -4
View File
@@ -1361,6 +1361,7 @@ impl Game<'_> {
t.age,
t.owner.clone(),
t.guest,
t.velocity,
));
return false;
}
@@ -1388,7 +1389,7 @@ impl Game<'_> {
);
}
}
for (stack, at, mob, block, age, owner, guest) in landed {
for (stack, at, mob, block, age, owner, guest, motion) in landed {
// Projectile.onHit (super.onHit of AbstractThrownPotion): a block
// is heard at its centre, an entity at the hit.
let heard = if mob.is_some() {
@@ -1406,7 +1407,7 @@ impl Game<'_> {
attacker: None,
guest,
};
self.splash(&stack, at, mob, block, age, &owner);
self.splash(&stack, at, mob, block, age, &owner, motion);
if item != "minecraft:experience_bottle" {
self.native_potion_splash(&item, [at.x, at.y, at.z]);
}
@@ -1910,7 +1911,7 @@ impl Game<'_> {
/// `AbstractThrownPotion.onHit` with `ThrownSplashPotion.onHitAsPotion`, or
/// `ThrownLingeringPotion.onHitAsPotion` for a lingering potion, read from
/// the stack's `potion_contents`; `mob` is where it hit a mob, `hit` the
/// block and face it hit, `age` its age.
/// block and face it hit, `age` its age, `motion` its motion as it hit.
pub(super) fn splash(
&mut self,
stack: &ItemStack,
@@ -1919,6 +1920,7 @@ impl Game<'_> {
hit: Option<([i32; 3], Direction)>,
age: u32,
owner: &PotionOwner,
motion: Vec3,
) {
let block = [at.x, at.y, at.z].map(|v| v.floor() as i32);
if &*stack.item == "minecraft:experience_bottle" {
@@ -1928,7 +1930,13 @@ impl Game<'_> {
self.level_event_at(1053, block, 0);
let r = &mut self.world.random;
let value = 3 + r.next_int_bound(5).unwrap_or(0) + r.next_int_bound(5).unwrap_or(0);
self.spawn_orbs([at.x, at.y, at.z], value as u32);
// awardWithDirection: away from the face it hit, or back along
// its own motion when it hit an entity (#11388).
let roughly = match hit {
Some((_, face)) => face.normal().map(f64::from),
None => [-motion.x, -motion.y, -motion.z],
};
self.spawn_orbs_toward([at.x, at.y, at.z], roughly, value as u32);
self.native_exp_bottle([at.x, at.y, at.z], value as u32);
return;
}
@@ -2496,3 +2504,40 @@ mod sound_on_added_tests {
);
}
}
#[cfg(test)]
mod xp_bottle_tests {
use super::*;
/// `ThrownExperienceBottle.onHit` uses `awardWithDirection`: every fresh
/// orb starts 0.25 out from the face it hit, or back along the bottle's
/// motion after an entity hit, moving that way (#11388).
#[test]
#[ignore = "needs prepared resources (HORIZON_TEST_RESOURCES)"]
fn bottle_orbs_start_away_from_what_it_hit() {
let mut game = super::super::native_machines::tests::server_game();
let bottle = ItemStack::new("minecraft:experience_bottle", 1);
let at = Vec3::new(8.5, 70.5, 8.5);
// (hit face, the bottle's motion, the axis and sign the orbs leave by)
for (face, motion, axis, sign) in [
(
Some(([9, 70, 8], Direction::West)),
Vec3::new(1.0, 0.0, 0.0),
0,
-1.0,
),
(None, Vec3::new(0.0, 0.0, 1.0), 2, -1.0),
] {
game.orbs.clear();
game.splash(&bottle, at, None, face, 0, &PotionOwner::default(), motion);
assert!(!game.orbs.is_empty());
let at = [at.x, at.y, at.z];
for o in game.orbs.iter() {
let p = [o.position.x, o.position.y, o.position.z];
let v = [o.velocity.x, o.velocity.y, o.velocity.z];
assert!((p[axis] - (at[axis] + sign * 0.25)).abs() < 1e-9, "{p:?}");
assert!(v[axis] * sign >= 0.0, "{v:?}");
}
}
}
}
+2
View File
@@ -52,6 +52,7 @@ impl Game {
fn add_orb(&mut self, _: [f64; 3], value: u32) { self.added.push(value); }
ORB_MERGES
AWARD_ORB
MERGE_NEW_ORB
MERGE_ORBS
}
fn orb(id: u32, x: f64, value: u32, ticks: u32) -> ExperienceOrb {
@@ -118,6 +119,7 @@ class ExperienceOrbMergeTests(unittest.TestCase):
source = (ROOT / "crates/horizon-client/src/game.rs").read_text(encoding="utf-8")
fixture = (FIXTURE.replace("ORB_MERGES", method(source, "orb_merges"))
.replace("AWARD_ORB", method(source, "award_orb"))
.replace("MERGE_NEW_ORB", method(source, "merge_new_orb"))
.replace("MERGE_ORBS", method(source, "merge_orbs")))
with tempfile.TemporaryDirectory() as folder:
src, exe = Path(folder) / "orb.rs", Path(folder) / "orb.exe"