diff --git a/crates/horizon-client/src/game/commands.rs b/crates/horizon-client/src/game/commands.rs index ff4d2c3a45..a581da0c72 100644 --- a/crates/horizon-client/src/game/commands.rs +++ b/crates/horizon-client/src/game/commands.rs @@ -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 + ); + } + } +} diff --git a/crates/horizon-client/src/game/commands/selector_bounds.rs b/crates/horizon-client/src/game/commands/selector_bounds.rs new file mode 100644 index 0000000000..1be4dcc700 --- /dev/null +++ b/crates/horizon-client/src/game/commands/selector_bounds.rs @@ -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 { + 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, + } + } +} diff --git a/tools/selector_bounds_corpus.py b/tools/selector_bounds_corpus.py new file mode 100644 index 0000000000..fb89209537 --- /dev/null +++ b/tools/selector_bounds_corpus.py @@ -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 diff --git a/tools/test_command_exec_parity.py b/tools/test_command_exec_parity.py index 2263ed8027..3aa7a2e06a 100644 --- a/tools/test_command_exec_parity.py +++ b/tools/test_command_exec_parity.py @@ -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) diff --git a/tools/test_selector_bounds_parity.py b/tools/test_selector_bounds_parity.py new file mode 100644 index 0000000000..352d982e3b --- /dev/null +++ b/tools/test_selector_bounds_parity.py @@ -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())