Use stored entity bounds for supported box selector targets

Keep point distance and sorting, and retain the unresolved special-kind fallbacks.

Co-Authored-By: Codex <[email protected]>
This commit is contained in:
Florian ERNSTandCodex committed 2026-11-05 13:04:55 +01:00
1 parent f319701e8c
commit 2d11bd857a
5 files changed
+313 -6

No files matched your search

+102 -6
View File
@@ -14,6 +14,7 @@ mod players;
pub(super) mod pregen;
mod profiles;
mod raid;
mod selector_bounds;
mod terrain_cmds;
mod test_locate;
mod test_runs;
@@ -880,14 +881,21 @@ impl Game<'_> {
}
if s.delta.iter().any(Option::is_some) {
let d = s.delta.map(|v| v.unwrap_or(0.0));
for i in 0..3 {
let (a, b) = (
origin[i].min(origin[i] + d[i]),
origin[i].max(origin[i] + d[i]) + 1.0,
);
if p[i] < a || p[i] >= b {
if let Some(bounds) = self.selector_target_bounds(t) {
if !selector_bounds::selector_box(origin, d).intersects(bounds) {
return None;
}
} else {
// Unmodeled boxes retain their previous point fallback.
for i in 0..3 {
let (a, b) = (
origin[i].min(origin[i] + d[i]),
origin[i].max(origin[i] + d[i]) + 1.0,
);
if p[i] < a || p[i] >= b {
return None;
}
}
}
}
Some((t, d2))
@@ -4400,3 +4408,91 @@ mod teleport_rider_tests {
assert_eq!(rider.position, Vec3::new(0.0, 64.0, 0.0));
}
}
#[cfg(test)]
mod selector_bounds_tests {
use super::*;
use crate::game::{Audio, Options, Progress, Settings, load_resources};
use horizon_client::{entities::ArmorStand, mobs::Mob};
use horizon_core::geometry::Vec3;
#[test]
fn actual_selector_uses_body_overlap_but_distance_stays_at_the_origin() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("entity-types.json").exists() {
eprintln!("Prepared resources unavailable; Game selector coverage did not run");
return;
}
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
let kind = game.res.mob_types().get("minecraft:pig").unwrap().clone();
game.mobs
.push(Mob::new(30, kind, Vec3::new(10.5, 220.0, 10.5), 0.0));
let target = Target::Mob(30);
let mut selector = Selector {
kind: 'e',
include_entities: true,
limit: usize::MAX,
delta: [Some(0.0); 3],
..Default::default()
};
let origin = [9.5, 220.0, 10.5];
assert_eq!(
game.select_in_level(vec![target], &selector, origin),
vec![(target, 1.0)]
);
selector.distance = Some((None, Some(0.9)));
assert!(
game.select_in_level(vec![target], &selector, origin)
.is_empty()
);
selector.distance = None;
assert!(
game.select_in_level(vec![target], &selector, [11.0, 220.0, 10.5])
.is_empty()
);
selector.delta = [Some(-1.0), None, None];
assert_eq!(
game.select_in_level(vec![target], &selector, [11.5, 220.0, 10.5])
.len(),
1
);
game.armor_stands
.push(ArmorStand::new(31, Vec3::new(30.5, 220.0, 10.5), 0.0));
selector.delta = [Some(0.0); 3];
for (x, count) in [(29.5, 1), (29.25, 0), (30.75, 0), (29.250000000000004, 1)] {
assert_eq!(
game.select_in_level(vec![Target::Stand(31)], &selector, [x, 220.0, 10.5])
.len(),
count
);
}
}
}
@@ -0,0 +1,75 @@
//! The physical bounds already kept for selector targets. This is not a
//! rendered box or a new source of entity dimensions.
use super::{Game, Target};
use crate::game::entity::Entity;
use horizon_core::geometry::{Aabb, Vec3};
fn extents((lo, hi): ([f64; 3], [f64; 3])) -> Aabb {
Aabb::new(
Vec3::new(lo[0], lo[1], lo[2]),
Vec3::new(hi[0], hi[1], hi[2]),
)
}
/// `EntitySelectorParser.createAabb`, moved by the selector origin.
pub(super) fn selector_box(origin: [f64; 3], delta: [f64; 3]) -> Aabb {
let lo = std::array::from_fn(|i| origin[i] + delta[i].min(0.0));
let hi = std::array::from_fn(|i| origin[i] + (delta[i].max(0.0) + 1.0));
extents((lo, hi))
}
impl Game<'_> {
/// `Entity.getBoundingBox`: use each simulated kind's existing physical
/// box. None means no authoritative box is modeled here yet; the selector
/// retains its existing point fallback for those kinds.
pub(super) fn selector_target_bounds(&self, target: Target) -> Option<Aabb> {
match target {
Target::Player => self.player.as_ref().map(|p| p.bounds()),
Target::Guest(conn) => self.command_guest(conn).map(|p| p.bounds()),
Target::Mob(id) => self.mobs.iter().find(|e| e.id == id).map(Entity::bounds),
Target::Item(id) => self.dropped.iter().find(|e| e.id == id).map(Entity::bounds),
Target::Orb(id) => self.orbs.iter().find(|e| e.id == id).map(Entity::bounds),
Target::Projectile(id) => self
.creatures
.projectiles
.iter()
.find(|e| e.id == id)
.map(Entity::bounds),
Target::Stand(id) => self
.armor_stands
.iter()
.find(|e| e.id == id)
.map(Entity::bounds),
Target::Boat(id) => self.boats.iter().find(|e| e.id == id).map(Entity::bounds),
Target::Cart(id) => self.carts.iter().find(|e| e.id == id).map(Entity::bounds),
Target::Tnt(id) => self.tnts.iter().find(|e| e.id == id).map(Entity::bounds),
Target::Falling(id) => self.falling.iter().find(|e| e.id == id).map(Entity::bounds),
Target::Rocket(id) => self.rockets.iter().find(|e| e.id == id).map(Entity::bounds),
Target::Trident(id) => self
.tridents
.iter()
.find(|e| e.id == id)
.map(Entity::bounds),
Target::Frame(id) => self
.item_frames
.iter()
.find(|e| e.id == id)
.map(|e| extents(e.bounds())),
Target::Painting(id) => self
.paintings
.iter()
.find(|e| e.id == id)
.map(|e| extents(e.bounds())),
Target::Crystal(id) => self
.crystals
.iter()
.find(|e| e.id == id)
.map(|e| e.bounds()),
Target::Other(_)
| Target::Fangs(_)
| Target::Dragon(_)
| Target::Spawner(_)
| Target::Cloud(_) => None,
}
}
}
+106
View File
@@ -0,0 +1,106 @@
"""Strict zero-tick box selection versus independent point distance/sorting."""
from command_exec_corpus import cmd
def selector_bounds():
ops = []
def run(text, expected=None, label=None, record=True):
op = cmd(text, record=record, expect_result=expected)
if label:
op['label'] = label
ops.append(op)
run('/tick freeze', 1)
run('/gamerule spawn_mobs false')
run('/gamerule advance_time false')
run('/forceload add 0 0 63 31', 8)
ops.append({'op': 'settle', 'required_chunks': [[x, z] for x in range(4) for z in range(2)]})
for x in range(4):
for z in range(2):
run(f'/execute if loaded {x*16} 220 {z*16}', 1)
run('/fill 0 219 0 63 219 31 minecraft:stone', 2048)
run('/fill 0 220 0 63 224 31 minecraft:air')
for x in [10, 20, 30, 40, 50]:
run(f'/execute if block {x} 219 10 minecraft:stone', 1)
run('/execute if block 10 220 10 minecraft:air', 1)
run('/tp @s 0.5 220 0.5')
run('/gamemode creative')
run('/execute if entity @s[type=minecraft:player,gamemode=creative,nbt={Health:20f,Pos:[0.5d,220d,0.5d]}]', 1)
run('/data get entity @s UUID', 4)
run('/kill @e[type=!player]', record=False)
run('/scoreboard objectives add h030 dummy', 1)
run('/execute store result score start h030 run time query gametime', record=False)
actors = [
('pig', 'pig', 10.5, 'Health:10f,NoAI:1b,', 'Health:10f,NoAI:1b,'),
('golem', 'iron_golem', 20.5, 'Health:100f,NoAI:1b,', 'Health:100f,NoAI:1b,'),
('stand', 'armor_stand', 30.5, 'Health:20f,', 'Health:20f,'),
('item', 'item', 40.5, 'Health:5s,PickupDelay:32767s,Item:{id:"minecraft:stone",count:3},', 'Health:5s,Item:{id:"minecraft:stone",count:3},'),
('boat', 'oak_boat', 50.5, '', ''),
('far_pig', 'pig', 12.5, 'Health:10f,NoAI:1b,', 'Health:10f,NoAI:1b,'),
]
for n, (name, kind, x, extra, guard) in enumerate(actors, 1):
tags = f'"h030_{name}"' + (',"h030_pigs"' if kind == 'pig' else '')
run(f'/summon minecraft:{kind} {x} 220 10.5 {{UUID:[I;30,0,0,{n}],Tags:[{tags}],{extra}NoGravity:1b}}', 1)
run(f'/execute if entity @e[type=minecraft:{kind},tag=h030_{name},nbt={{UUID:[I;30,0,0,{n}],{guard}NoGravity:1b,Pos:[{x}d,220d,10.5d]}}]', 1)
run(f'/data get entity 0000001e-0000-0000-0000-{n:012x} UUID', 4)
run(f'/data get entity 0000001e-0000-0000-0000-{n:012x} Pos')
checks = [
('pig-overlap-x', '9.5 220 10.5', 'tag=h030_pig,dx=0,dy=0,dz=0', 1),
('pig-separated-x', '11 220 10.5', 'tag=h030_pig,dx=0,dy=0,dz=0', 0),
('pig-point-distance', '9.5 220 10.5', 'tag=h030_pig,dx=0,dy=0,dz=0,distance=..0.9', 0),
('pig-distance-one', '9.5 220 10.5', 'tag=h030_pig,dx=0,dy=0,dz=0,distance=..1', 1),
('pig-overlap-z', '10.5 220 9.5', 'tag=h030_pig,dz=0', 1),
('pig-positive-x', '8.5 220 10.5', 'tag=h030_pig,dx=1', 1),
('pig-negative-x', '11.5 220 10.5', 'tag=h030_pig,dx=-1', 1),
('pig-positive-z', '10.5 220 8.5', 'tag=h030_pig,dz=1', 1),
('pig-negative-z', '10.5 220 11.5', 'tag=h030_pig,dz=-1', 1),
('pig-positive-y', '10.5 218.5 10.5', 'tag=h030_pig,dy=1', 1),
('pig-negative-y', '10.5 221.5 10.5', 'tag=h030_pig,dy=-1', 1),
('pig-bottom-touch', '10.5 219 10.5', 'tag=h030_pig,dy=0', 0),
('pig-top-touch', '10.5 220.89999997615814 10.5', 'tag=h030_pig,dy=0', 0),
('pig-type-positive', '9.5 220 10.5', 'tag=h030_pig,type=minecraft:pig,dx=0', 1),
('pig-type-negative', '9.5 220 10.5', 'tag=h030_pig,type=!minecraft:pig,dx=0', 0),
('pig-type-other-negative', '9.5 220 10.5', 'tag=h030_pig,type=!minecraft:cow,dx=0,limit=1', 1),
('golem-wide', '19 220 10.5', 'tag=h030_golem,dx=0', 1),
('golem-tall', '20.5 222 10.5', 'tag=h030_golem,dy=0', 1),
('golem-separated', '22 220 10.5', 'tag=h030_golem,dx=0', 0),
('stand-overlap', '29.5 220 10.5', 'tag=h030_stand,dx=0', 1),
('stand-left-touch', '29.25 220 10.5', 'tag=h030_stand,dx=0', 0),
('stand-right-touch', '30.75 220 10.5', 'tag=h030_stand,dx=0', 0),
('stand-left-inside', '29.250000000000004 220 10.5', 'tag=h030_stand,dx=0', 1),
('stand-tall', '30.5 221.5 10.5', 'tag=h030_stand,dy=0', 1),
('item-overlap', '39.5 220 10.5', 'tag=h030_item,dx=0', 1),
('item-left-touch', '39.375 220 10.5', 'tag=h030_item,dx=0', 0),
('item-right-touch', '40.625 220 10.5', 'tag=h030_item,dx=0', 0),
('item-front-touch', '40.5 220 9.375', 'tag=h030_item,dz=0', 0),
('item-back-touch', '40.5 220 10.625', 'tag=h030_item,dz=0', 0),
('item-top-touch', '40.5 220.25 10.5', 'tag=h030_item,dy=0', 0),
('boat-overlap', '49 220 10.5', 'tag=h030_boat,dx=0', 1),
('boat-left-touch', '48.8125 220 10.5', 'tag=h030_boat,dx=0', 0),
('boat-right-touch', '51.1875 220 10.5', 'tag=h030_boat,dx=0', 0),
('pigs-both', '9.5 220 10.5', 'tag=h030_pigs,dx=3', 2),
('pigs-limit', '9.5 220 10.5', 'tag=h030_pigs,dx=3,limit=1', 1),
]
for label, origin, options, expected in checks:
run('/scoreboard players set result h030 -99', -99)
run('/scoreboard players set success h030 -99', -99)
run('/scoreboard players get result h030', -99)
run('/scoreboard players get success h030', -99)
run(f'/execute positioned {origin} store result score result h030 store success score success h030 run execute if entity @e[{options}]', expected, label)
if expected == 0:
ops[-1].pop('expect_result')
ops[-1]['expect_callback'] = [False, 0]
run('/scoreboard players get result h030', expected)
run('/scoreboard players get success h030', int(expected > 0))
for sort, tag in [('nearest', 'pig'), ('furthest', 'far_pig')]:
run(f'/execute positioned 9.5 220 10.5 as @e[tag=h030_pigs,dx=3,sort={sort},limit=1] if entity @s[tag=h030_{tag}]', 1, 'point-sort-' + sort)
run(f'/execute positioned 9.5 220 10.5 as @e[tag=h030_pigs,distance=..4,sort={sort},limit=1] if entity @s[tag=h030_{tag}]', 1, 'unboxed-point-sort-' + sort)
for n, (name, kind, x, _, guard) in enumerate(actors, 1):
run(f'/execute if entity @e[type=minecraft:{kind},tag=h030_{name},nbt={{UUID:[I;30,0,0,{n}],{guard}NoGravity:1b,Pos:[{x}d,220d,10.5d]}}]', 1)
run(f'/data get entity 0000001e-0000-0000-0000-{n:012x} Pos')
run('/execute store result score end h030 run time query gametime', record=False)
run('/scoreboard players operation end h030 -= start h030', record=False)
run('/scoreboard players get end h030', 0, 'zero-world-ticks')
run('/execute if entity @s[type=minecraft:player,gamemode=creative,nbt={Health:20f,Pos:[0.5d,220d,0.5d]}]', 1)
return ops
+4
View File
@@ -128,6 +128,7 @@ def main():
parser.add_argument("--pufferfish-mob-fixture", action="store_true")
parser.add_argument("--pufferfish-mob-duration-diagnostic", action="store_true")
parser.add_argument("--pufferfish-mob-overlap-control", action="store_true")
parser.add_argument("--selector-bounds-fixture", action="store_true")
args = parser.parse_args()
build = args.work_dir.resolve() if args.work_dir else lab.LOCAL / 'command-exec-parity'
build.mkdir(parents=True, exist_ok=True)
@@ -250,6 +251,9 @@ def main():
parser.error('reinforcement fixtures require --only with one exact group')
if any(name in isolated_recipes for name, _ in groups) and sum(name != 'setup' for name, _ in groups) != 1:
parser.error('crafting recipe fixtures require --only with one exact group')
if args.selector_bounds_fixture:
from selector_bounds_corpus import selector_bounds
groups = [("setup", corpus_module.group_setup()), ("selector entity bounds", selector_bounds())]
script = []
for _, lines in groups:
script.extend(lines)
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env python3
"""Box selectors through actual Game entity bounds; point distance and sort controls."""
import argparse
from pathlib import Path
import subprocess
import sys
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--binary', type=Path)
parser.add_argument('--reference-only', action='store_true')
parser.add_argument('--work-dir', type=Path)
args = parser.parse_args()
root = Path(__file__).resolve().parents[1]
command = [sys.executable, str(root / 'tools/test_command_exec_parity.py'), '--selector-bounds-fixture',
'--work-dir', str(args.work_dir.resolve() if args.work_dir else root / '.local/selector-bounds-parity')]
if args.binary:
command += ['--binary', str(args.binary.resolve())]
if args.reference_only:
command.append('--reference-only')
return subprocess.run(command, cwd=root).returncode
if __name__ == '__main__':
sys.exit(main())