Thrown potions and eyes of ender fly on a server's clients

A splash or lingering potion, an experience bottle or an eye of ender a
player threw was never an entity a server tracked: on single player's server
thread, for LAN guests and on a dedicated server the item was used up and
nothing flew.

The server now numbers them like its other projectiles and tracks them as
vanilla's entities: splash_potion, lingering_potion and experience_bottle
with ThrowableItemProjectile.DATA_ITEM_STACK (the potion, for its colour),
and eye_of_ender. A client draws them where the server puts them and drops
them when the server removes them.

lan_e2e's new `thrown` check: the host and the guest each throw a splash
potion and an eye of ender, and both sides see them fly.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-10-23 10:02:19 +02:00
1 parent 0d8249e124
commit c5fe5d4bcf
5 files changed
+212 -10

No files matched your search

+51
View File
@@ -97,8 +97,58 @@ impl EndState {
}
}
impl EndState {
/// The eyes' entity ids, for a server to number them.
pub(super) fn eye_ids(&mut self) -> impl Iterator<Item = &mut u32> {
self.eyes.iter_mut().map(|e| &mut e.id)
}
/// The numbered eyes as a server's clients track them: (id, position, motion).
pub(super) fn eyes_tracked(&self) -> impl Iterator<Item = (u32, [f64; 3], [f64; 3])> {
self.eyes
.iter()
.filter(|e| e.id != 0)
.map(|e| (e.id, e.position.to_array(), e.velocity.to_array()))
}
/// `handleAddEntity` for an eye of ender on a server's client: it flies
/// where the server says.
pub(super) fn add_remote_eye(&mut self, id: u32, at: [f64; 3], velocity: [f64; 3]) {
self.eyes.retain(|e| e.id != id);
let at = glam::DVec3::from_array(at);
self.eyes.push(EyeOfEnder {
id,
position: at,
previous: at,
velocity: glam::DVec3::from_array(velocity),
target: at,
life: 0,
survive: false,
});
}
/// One tick of a server's client: `step` moves each toward where the
/// server put it.
pub(super) fn step_remote_eyes(&mut self, mut step: impl FnMut(u32, [f64; 3]) -> [f64; 3]) {
for e in &mut self.eyes {
e.previous = e.position;
e.life += 1;
e.position = glam::DVec3::from_array(step(e.id, e.position.to_array()));
}
}
pub(super) fn remove_remote_eye(&mut self, id: u32) {
self.eyes.retain(|e| e.id != id);
}
pub(super) fn clear_remote_eyes(&mut self) {
self.eyes.clear();
}
/// Where each eye is, for `status`.
pub(super) fn eye_positions(&self) -> impl Iterator<Item = (u32, [f64; 3])> {
self.eyes.iter().map(|e| (e.id, e.position.to_array()))
}
}
/// The EyeOfEnder entity.
struct EyeOfEnder {
/// Its entity id once a server's clients track it (0 until then).
id: u32,
position: glam::DVec3,
previous: glam::DVec3,
velocity: glam::DVec3,
@@ -1023,6 +1073,7 @@ impl Game<'_> {
};
let survive = self.world.random.next_int_bound(5).unwrap_or(0) > 0;
self.end.eyes.push(EyeOfEnder {
id: 0,
position: start,
previous: start,
velocity: glam::DVec3::ZERO,
@@ -277,6 +277,8 @@ impl Game<'_> {
self.rockets.iter_mut().for_each(|r| give(&mut r.id));
self.crystals.iter_mut().for_each(|c| give(&mut c.id));
self.bolts.iter_mut().for_each(|b| give(&mut b.id));
self.potions.thrown_ids().for_each(&mut give);
self.end.eye_ids().for_each(&mut give);
}
/// Primed TNT, falling blocks, projectiles, tridents, firework rockets and
@@ -359,6 +361,26 @@ impl Game<'_> {
}
out.push(e);
}
// Thrown potions and bottles: ThrowableItemProjectile.DATA_ITEM_STACK
// (the potion's colour), which a plain bottle's type already has.
for (id, kind, stack, position, velocity) in self.potions.thrown_tracked() {
let mut e = state(id, kind, at(position), 0.0, false);
e.velocity = at(velocity);
if stack.extra.is_some() || stack.count != 1 {
e.meta.push((
8,
serializer::ITEM_STACK,
Value::Item(hub.stack(&Some(stack.clone()))),
));
}
out.push(e);
}
// EyeOfEnder: its item is the plain eye (DATA_ITEM_STACK's default).
for (id, position, velocity) in self.end.eyes_tracked() {
let mut e = state(id, "minecraft:eye_of_ender", position, 0.0, false);
e.velocity = velocity;
out.push(e);
}
for t in self.tridents.iter().filter(|t| t.id != 0) {
let mut e = state(t.id, "minecraft:trident", at(t.position), t.yaw, false);
e.pitch = t.pitch;
+70 -8
View File
@@ -18,6 +18,8 @@ use horizon_core::{geometry::Vec3, random::RandomSource};
/// A thrown splash or lingering potion in flight.
#[derive(Clone, Debug)]
pub(super) struct ThrownPotion {
/// Its entity id once a server's clients track it (0 until then).
id: u32,
stack: ItemStack,
/// The position before the last tick, for drawing between ticks.
previous: Vec3,
@@ -47,6 +49,7 @@ impl Game<'_> {
/// player's throws and dispensers both end up here).
pub(super) fn launch_thrown(&mut self, stack: ItemStack, position: Vec3, velocity: Vec3) {
self.potions.thrown.push(ThrownPotion {
id: 0,
stack,
previous: position,
position,
@@ -90,19 +93,78 @@ impl PotionState {
pub(super) fn thrown_entities(&self) -> Vec<(&'static str, Vec3, Vec3)> {
self.thrown
.iter()
.map(|t| {
let kind = match &*t.stack.item {
"minecraft:lingering_potion" => "minecraft:lingering_potion",
"minecraft:experience_bottle" => "minecraft:experience_bottle",
_ => "minecraft:splash_potion",
};
(kind, t.position, t.velocity)
})
.map(|t| (thrown_kind(&t.stack), t.position, t.velocity))
.collect()
}
pub(super) fn clear_thrown(&mut self) {
self.thrown.clear();
}
/// Their entity ids, for a server to number them.
pub(super) fn thrown_ids(&mut self) -> impl Iterator<Item = &mut u32> {
self.thrown.iter_mut().map(|t| &mut t.id)
}
/// The numbered ones as a server's clients track them: (id, entity
/// type, item, position, motion).
pub(super) fn thrown_tracked(
&self,
) -> impl Iterator<Item = (u32, &'static str, &ItemStack, Vec3, Vec3)> {
self.thrown.iter().filter(|t| t.id != 0).map(|t| {
(
t.id,
thrown_kind(&t.stack),
&t.stack,
t.position,
t.velocity,
)
})
}
/// `handleAddEntity` for a thrown potion or bottle on a server's client:
/// drawn where the server says (its item comes in its entity data).
pub(super) fn add_remote_thrown(&mut self, id: u32, kind: &str, at: Vec3, velocity: Vec3) {
self.thrown.retain(|t| t.id != id);
let item = match kind {
"minecraft:lingering_potion" | "minecraft:experience_bottle" => kind,
_ => "minecraft:splash_potion",
};
self.thrown.push(ThrownPotion {
id,
stack: ItemStack::new(item, 1),
previous: at,
position: at,
velocity,
age: 0,
});
}
/// ThrowableItemProjectile.DATA_ITEM_STACK: the potion it shows.
pub(super) fn remote_thrown_item(&mut self, id: u32, stack: ItemStack) {
if let Some(t) = self.thrown.iter_mut().find(|t| t.id == id) {
t.stack = stack;
}
}
/// One tick of a server's client: `step` moves each toward where the
/// server put it.
pub(super) fn step_remote_thrown(&mut self, mut step: impl FnMut(u32, Vec3) -> Vec3) {
for t in &mut self.thrown {
t.previous = t.position;
t.age += 1;
t.position = step(t.id, t.position);
}
}
pub(super) fn remove_remote_thrown(&mut self, id: u32) {
self.thrown.retain(|t| t.id != id);
}
pub(super) fn has_thrown(&self, id: u32) -> bool {
self.thrown.iter().any(|t| t.id == id)
}
}
/// The entity type of a thrown potion or bottle.
fn thrown_kind(stack: &ItemStack) -> &'static str {
match &*stack.item {
"minecraft:lingering_potion" => "minecraft:lingering_potion",
"minecraft:experience_bottle" => "minecraft:experience_bottle",
_ => "minecraft:splash_potion",
}
}
impl Game<'_> {
@@ -79,6 +79,15 @@ impl Game<'_> {
self.rockets
.push(super::fireworks::Rocket::remote(id, at, velocity));
}
"minecraft:splash_potion"
| "minecraft:lingering_potion"
| "minecraft:experience_bottle" => {
self.potions.add_remote_thrown(id, kind, at, velocity);
}
"minecraft:eye_of_ender" => {
self.end
.add_remote_eye(id, position, [velocity.x, velocity.y, velocity.z])
}
"minecraft:end_crystal" => self.crystals.push(super::dragon::Crystal {
id,
position: at,
@@ -105,6 +114,17 @@ impl Game<'_> {
/// `handleSetEntityData` for these types: what changes how they look.
pub(super) fn remote_projectile_data(&mut self, id: i32, values: &[(u8, i32, Value)]) {
let id = id as u32;
if self.potions.has_thrown(id) {
// ThrowableItemProjectile.DATA_ITEM_STACK: the potion it is.
for (index, _, value) in values {
if let (8, Value::Item(stack)) = (index, value)
&& let Some(stack) = self.to_stack(stack)
{
self.potions.remote_thrown_item(id, stack);
}
}
return;
}
for (index, _, value) in values {
if let Some(t) = self.tnts.iter_mut().find(|t| t.id == id) {
// PrimedTnt.DATA_FUSE_ID.
@@ -184,6 +204,11 @@ impl Game<'_> {
c.time += 1;
c.position = step(c.id, c.position);
}
self.potions.step_remote_thrown(&mut step);
self.end.step_remote_eyes(|id, at| {
let at = step(id, Vec3::new(at[0], at[1], at[2]));
[at.x, at.y, at.z]
});
}
pub(super) fn remove_remote_projectile(&mut self, id: u32) {
@@ -193,6 +218,8 @@ impl Game<'_> {
self.tridents.retain(|t| t.id != id);
self.rockets.retain(|r| r.id != id);
self.crystals.retain(|c| c.id != id);
self.potions.remove_remote_thrown(id);
self.end.remove_remote_eye(id);
}
/// Every one a server sent (a new connection, level or dimension).
@@ -203,6 +230,8 @@ impl Game<'_> {
self.tridents.clear();
self.rockets.clear();
self.crystals.clear();
self.potions.clear_thrown();
self.end.clear_remote_eyes();
}
/// Their kind and place, for `status`.
@@ -225,6 +254,12 @@ impl Game<'_> {
for p in &self.creatures.projectiles {
out.push(json!({"id": p.id, "kind": format!("{:?}", p.kind).split([' ', '{']).next().unwrap_or(""), "position": at(p.position)}));
}
for (kind, p, _) in self.potions.thrown_entities() {
out.push(json!({"kind": kind, "position": at(p)}));
}
for (id, p) in self.end.eye_positions() {
out.push(json!({"id": id, "kind": "minecraft:eye_of_ender", "position": p}));
}
out
}
}
+34 -2
View File
@@ -107,6 +107,8 @@ address. The script then checks, from both sides, what a guest's game used to ge
guest as the host has them (kind, place, rotation, what they hold or wear, the variant).
- projectiles: primed TNT (one falling), an end crystal and an arrow that lands reach the guest where
the host has them; a falling block lands on both sides.
- thrown: a splash potion and an eye of ender the host and the guest each throw fly on both
sides (#1363, #1391);
- fireball: a fire charge a dispenser shoots onto stone lights a fire both sides see, and the fire
goes out on its own within a minute (it ticks: FireBlock.tick on a floor that doesn't burn)
@@ -208,7 +210,7 @@ ROOT = Path(__file__).resolve().parents[1]
GUEST_NAME = 'Guest'
# The host plays single player on its server thread (--server-thread).
SERVER_THREAD = False
CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'sign', 'damage', 'mobs', 'hazards', 'pvp', 'arrows', 'kills', 'hurt', 'looks', 'leads', 'respawn', 'containers', 'feedback', 'titles', 'chat', 'commands', 'recipes', 'machines', 'brewing', 'loom', 'stonecutter', 'grindstone', 'anvil', 'smithing', 'enchanting', 'beacon', 'crafter', 'lectern', 'cartography', 'trials', 'clicks', 'sleep', 'predict', 'push', 'eat', 'eating', 'xp', 'objects', 'projectiles', 'worn', 'blast', 'barter', 'deflect', 'fireball', 'light', 'time', 'swings', 'cracks', 'plates', 'effects', 'portals', 'distance', 'growth', 'flight', 'ladder', 'boats', 'minecarts', 'frames', 'lightning', 'riding', 'mounts', 'worldspawn', 'beds', 'shield', 'deathmining', 'contacts', 'absorption', 'breath', 'lava', 'levels', 'drops', 'underwater']
CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'sign', 'damage', 'mobs', 'hazards', 'pvp', 'arrows', 'kills', 'hurt', 'looks', 'leads', 'respawn', 'containers', 'feedback', 'titles', 'chat', 'commands', 'recipes', 'machines', 'brewing', 'loom', 'stonecutter', 'grindstone', 'anvil', 'smithing', 'enchanting', 'beacon', 'crafter', 'lectern', 'cartography', 'trials', 'clicks', 'sleep', 'predict', 'push', 'eat', 'eating', 'xp', 'objects', 'projectiles', 'thrown', 'worn', 'blast', 'barter', 'deflect', 'fireball', 'light', 'time', 'swings', 'cracks', 'plates', 'effects', 'portals', 'distance', 'growth', 'flight', 'ladder', 'boats', 'minecarts', 'frames', 'lightning', 'riding', 'mounts', 'worldspawn', 'beds', 'shield', 'deathmining', 'contacts', 'absorption', 'breath', 'lava', 'levels', 'drops', 'underwater']
class Game:
@@ -2612,6 +2614,35 @@ def check_projectiles(host, guest, out):
return f'{len(theirs)} identical, fuses (host, guest) {fuses}'
def check_thrown(host, guest, out):
spots = apart(host, guest)
result = {}
for label, player, other in (('host', host, guest), ('guest', guest, host)):
x, y, z = spots[label]
player('command', command='gamemode survival')
for item, kind in (('splash_potion', 'minecraft:splash_potion'), ('ender_eye', 'minecraft:eye_of_ender')):
player('command', command=f'item replace entity @s weapon.mainhand with minecraft:{item}')
player.wait(10)
# Up at 45 degrees, into open sky.
player.raw('look-at', f'{x + 0.5:.2f}', f'{y + 11.6:.2f}', f'{z + 10.5:.2f}')
player.raw('use-item')
seen = {'own': 0, 'other': 0}
for _ in range(10):
player.wait(2)
for side, game in (('own', player), ('other', other)):
if any(p['kind'] == kind for p in game('status')['projectiles']):
seen[side] += 1
if all(seen.values()):
break
player.shot(out, f'thrown-{label}-{item}')
other.shot(out, f'thrown-{label}-{item}-seen')
player.wait(100) # it lands or drops
result[f'{label} {item}'] = seen
for name, seen in result.items():
assert all(seen.values()), f'{name}: seen in flight (samples) {result}'
return result
def check_blast(host, guest, out):
guest('command', command='gamemode survival')
guest('command', command='clear @s')
@@ -3945,7 +3976,8 @@ CHECK_FNS = {'tab': check_tab, 'self': check_self, 'armour': check_armour,
'lava': check_lava, 'levels': check_levels, 'drops': check_drops,
'underwater': check_underwater,
'looks': check_looks,
'leads': check_leads}
'leads': check_leads,
'thrown': check_thrown}
def run(ctl, out, resources, checks):