mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 02:56:17 -10:00
Dispatch eligible mob leashes through quad physics
Use the live holder eligibility and preserve the float lookup, yaw and angular-friction boundaries needed by quad attachments. Keep single-spring arithmetic unchanged. Co-Authored-By: Codex <[email protected]>
This commit is contained in:
1 parent
63eccfc355
commit
1686cd755a
5 files changed
+211
-7
No files matched your search
@@ -201,6 +201,16 @@ pub fn elastic_pull(
|
||||
} else {
|
||||
(&OWN, &THEIRS)
|
||||
};
|
||||
// Vec3.yRot takes a float angle and widens Mth's float lookup results.
|
||||
// Bound the repair to quad attachment physics; retain the single spring.
|
||||
let rotate = |v: [f64; 3], angle: f64| {
|
||||
if quad {
|
||||
let v = Vec3::new(v[0], v[1], v[2]).rotate_y(angle as f32);
|
||||
[v.x, v.y, v.z]
|
||||
} else {
|
||||
rot_y(v, angle)
|
||||
}
|
||||
};
|
||||
let (w, h) = size;
|
||||
let own_yaw = f64::from(yaw.to_radians());
|
||||
let holder_yaw = f64::from(holder.yaw.to_radians());
|
||||
@@ -208,13 +218,13 @@ pub fn elastic_pull(
|
||||
let mut torque = 0.0;
|
||||
let mut any = false;
|
||||
for (mine, other) in own.iter().zip(theirs) {
|
||||
let lever = rot_y([mine[0] * w, mine[1] * h, mine[2] * w], -own_yaw);
|
||||
let lever = rotate([mine[0] * w, mine[1] * h, mine[2] * w], -own_yaw);
|
||||
let object = [
|
||||
position.x + lever[0],
|
||||
position.y + lever[1],
|
||||
position.z + lever[2],
|
||||
];
|
||||
let arm = rot_y(
|
||||
let arm = rotate(
|
||||
[
|
||||
other[0] * holder.width,
|
||||
other[1] * holder.height,
|
||||
@@ -375,13 +385,20 @@ pub fn tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
return;
|
||||
}
|
||||
let stretched = distance > ELASTIC - state.width - f64::from(m.width());
|
||||
// Leashable.checkElasticInteractions: both ends must support the shared
|
||||
// quad attachment points. The live holder was resolved and checked above;
|
||||
// these physics points are independent of the rendered rope offsets.
|
||||
let quad = supports_quad_leash(m.species())
|
||||
&& matches!(holder, Holder::Mob(id) if w.seen(id).is_some_and(|s|
|
||||
matches!(s.species, Species::Ghast | Species::HappyGhast)));
|
||||
let pulled = stretched
|
||||
&& spring(
|
||||
&& elastic_pull(
|
||||
m.position,
|
||||
m.yaw,
|
||||
(f64::from(m.width()), f64::from(m.height())),
|
||||
&state,
|
||||
m.velocity,
|
||||
quad,
|
||||
)
|
||||
.is_some_and(|(force, torque)| {
|
||||
m.extra.leash_spin += 10.0 * torque;
|
||||
@@ -402,7 +419,12 @@ pub fn tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
if !pulled {
|
||||
close_range(m, w, &state);
|
||||
}
|
||||
m.yaw -= m.extra.leash_spin as f32;
|
||||
// tickLeash widens the float yaw before subtracting double momentum.
|
||||
if quad {
|
||||
m.yaw = (f64::from(m.yaw) - m.extra.leash_spin) as f32;
|
||||
} else {
|
||||
m.yaw -= m.extra.leash_spin as f32;
|
||||
}
|
||||
let friction = if m.on_ground {
|
||||
0.6 * 0.91
|
||||
} else if m.is_in_liquid() {
|
||||
@@ -410,7 +432,20 @@ pub fn tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
} else {
|
||||
0.91
|
||||
};
|
||||
m.extra.leash_spin *= friction;
|
||||
if quad {
|
||||
// angularFriction returns a float, including the ground product.
|
||||
let friction = if m.on_ground {
|
||||
let [x, y, z] = m.on_pos(0.500_001);
|
||||
w.facts.get(w.world.block(x, y, z)).friction * 0.91_f32
|
||||
} else if m.is_in_liquid() {
|
||||
0.8_f32
|
||||
} else {
|
||||
0.91_f32
|
||||
};
|
||||
m.extra.leash_spin *= f64::from(friction);
|
||||
} else {
|
||||
m.extra.leash_spin *= friction;
|
||||
}
|
||||
}
|
||||
/// The lead comes off (`Mob.onLeashRemoved`): no more home.
|
||||
pub fn remove(m: &mut Mob) -> Option<Holder> {
|
||||
@@ -619,6 +654,61 @@ pub fn quad_holder_offsets(m: &Mob) -> Option<[[f64; 3]; 4]> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mob_tick_selects_quad_only_when_both_live_ends_support_it() {
|
||||
assert!(crate::mobs::world::with_test_world(&[], |world, types| {
|
||||
for (kind, holder_kind, quad) in [
|
||||
("horse", "happy_ghast", true),
|
||||
("horse", "ghast", true),
|
||||
("horse", "cow", false),
|
||||
("llama", "happy_ghast", false),
|
||||
] {
|
||||
let new = |id, kind: &str, position, yaw| {
|
||||
Mob::new(
|
||||
id,
|
||||
std::sync::Arc::new(types.types[&format!("minecraft:{kind}")].clone()),
|
||||
position,
|
||||
yaw,
|
||||
)
|
||||
};
|
||||
let mut mob = new(1, kind, Vec3::new(-7.5, 100.0, -4.5), 90.0);
|
||||
let holder = new(2, holder_kind, Vec3::new(0.5, 104.0, 0.5), 30.0);
|
||||
let others = [holder.seen(None)];
|
||||
let mut w = world.clone();
|
||||
w.others = &others;
|
||||
mob.extra.leash = Some(Holder::Mob(holder.id));
|
||||
let state = holder_state(&w, Holder::Mob(holder.id)).unwrap();
|
||||
let (force, torque) = elastic_pull(
|
||||
mob.position,
|
||||
mob.yaw,
|
||||
(f64::from(mob.width()), f64::from(mob.height())),
|
||||
&state,
|
||||
Vec3::ZERO,
|
||||
quad,
|
||||
)
|
||||
.unwrap();
|
||||
let mut events = Vec::new();
|
||||
tick(&mut mob, &w, &mut events);
|
||||
assert_eq!(
|
||||
mob.velocity,
|
||||
Vec3::new(force[0] * 0.8, force[1] * 0.2, force[2] * 0.8),
|
||||
"{kind} held by {holder_kind}"
|
||||
);
|
||||
let yaw = if quad {
|
||||
(90.0 - 10.0 * torque) as f32
|
||||
} else {
|
||||
90.0 - (10.0 * torque) as f32
|
||||
};
|
||||
assert_eq!(mob.yaw, yaw);
|
||||
assert!(
|
||||
!events
|
||||
.iter()
|
||||
.any(|e| matches!(e, MobEvent::LeashBreak { .. }))
|
||||
);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_players_leash_does_not_pull_toward_a_different_player() {
|
||||
crate::mobs::world::with_test_world(&[], |world, types| {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Guarded live mob leash dispatch; all numeric feedback compares strictly."""
|
||||
try:
|
||||
from .command_exec_corpus import cmd, setup
|
||||
except ImportError:
|
||||
from command_exec_corpus import cmd, setup
|
||||
|
||||
|
||||
def mob_quad_leash(raw=False):
|
||||
out = setup('/tick freeze', '/gamerule minecraft:spawn_mobs false',
|
||||
'/gamerule minecraft:advance_time false', '/gamemode spectator',
|
||||
'/tp @s 0.5 115 0.5 0 0')
|
||||
out += [cmd('/forceload add -32 -32 31 31', expect_result=16), {'op': 'settle'}]
|
||||
for x in [-32, -16, 0, 16]:
|
||||
for z in [-32, -16, 0, 16]:
|
||||
out.append(cmd(f'/execute if loaded {x} 100 {z}', expect_result=1))
|
||||
out.append(cmd('/fill -32 99 -32 31 99 31 ice', expect_result=4096))
|
||||
# Each valid slab is below32768, unlike the historical53248-block clear.
|
||||
for lo, hi, count in [(100, 106, 28672), (107, 112, 24576)]:
|
||||
out += [cmd(f'/fill -32 {lo} -32 31 {hi} 31 stone', expect_result=count),
|
||||
cmd(f'/fill -32 {lo} -32 31 {hi} 31 air', expect_result=count)]
|
||||
out += setup('/scoreboard objectives add leash_audit dummy',
|
||||
'/data modify storage h302:observer uuid set from entity @s UUID')
|
||||
out.append(cmd('/data get entity @s UUID', expect_result=4))
|
||||
for scene, holder_type in enumerate(['happy_ghast', 'cow'], 1):
|
||||
holder_health = 20 if holder_type == 'happy_ghast' else 10
|
||||
holder_pos = '0.5 104 0.5' if scene == 1 else '-5.5 100 -4.5'
|
||||
holder_nbt_pos = '[0.5d,104d,0.5d]' if scene == 1 else '[-5.5d,100d,-4.5d]'
|
||||
holder = f'00000001-0000-0002-0000-00030000000{scene}'
|
||||
horse = f'0000012e-0000-0000-0000-00000000000{scene}'
|
||||
out += setup('/kill @e[type=!minecraft:player]')
|
||||
out += [cmd(f'/summon minecraft:{holder_type} {holder_pos} '
|
||||
f'{{UUID:[I;1,2,3,{scene}],Tags:["h302_holder"],NoAI:1b,NoGravity:1b,'
|
||||
f'Health:{holder_health}f,Rotation:[30f,0f],PersistenceRequired:1b}}', expect_result=1),
|
||||
cmd('/summon minecraft:horse -7.5 100 -4.5 '
|
||||
f'{{UUID:[I;302,0,0,{scene}],Tags:["h302_horse"],NoAI:1b,Health:53f,attributes:[{{id:"minecraft:max_health",base:53d}}],'
|
||||
f'Rotation:[90f,0f],PersistenceRequired:1b,leash:{{UUID:[I;1,2,3,{scene}]}}}}', expect_result=1)]
|
||||
def guards():
|
||||
return [cmd('/data get entity @s playerGameType', expect_result=3),
|
||||
cmd('/execute if entity @s[type=player,nbt={playerGameType:3,Health:20f,Pos:[0.5d,115d,0.5d]}]', expect_result=1),
|
||||
cmd(f'/data get entity {holder} UUID', expect_result=4),
|
||||
cmd(f'/execute as {holder} if entity @s[type=minecraft:{holder_type},tag=h302_holder,nbt={{Health:{holder_health}f,NoAI:1b,NoGravity:1b,Pos:{holder_nbt_pos},Rotation:[30f,0f]}}]', expect_result=1),
|
||||
cmd(f'/data get entity {horse} UUID', expect_result=4),
|
||||
cmd(f'/execute as {horse} if entity @s[type=minecraft:horse,tag=h302_horse,nbt={{Health:53f,NoAI:1b,Pos:[-7.5d,100d,-4.5d]}}]', expect_result=1),
|
||||
cmd('/execute if block -8 99 -5 minecraft:ice', expect_result=1),
|
||||
cmd('/execute if block 0 104 0 minecraft:air', expect_result=1)]
|
||||
out += guards()
|
||||
out += [cmd(f'/data get entity {horse} leash.UUID', expect_result=4),
|
||||
cmd(f'/execute as {horse} on leasher run data get entity @s UUID')]
|
||||
out += setup('/execute store result score start leash_audit run time query gametime')
|
||||
for at, n in [(10, 10), (20, 10), (40, 20)]:
|
||||
out += [cmd(f'/tick step {n}', expect_result=1), {'op': 'tick', 'n': n}]
|
||||
out += setup('/execute store result score now leash_audit run time query gametime',
|
||||
'/scoreboard players operation now leash_audit -= start leash_audit')
|
||||
out += [cmd('/scoreboard players get now leash_audit', expect_result=at)] + guards()
|
||||
out += [cmd(f'/execute as {horse} on leasher if entity @s[nbt={{UUID:[I;1,2,3,{scene}]}}]', expect_result=1),
|
||||
cmd(f'/data get entity {horse} leash.UUID', expect_result=4)]
|
||||
# Exact three-decimal command contract, not a comparison tolerance.
|
||||
# Each successful numeric read has a separately observed success callback.
|
||||
for field in ['Rotation[0]', 'Motion[0]', 'Motion[1]', 'Motion[2]']:
|
||||
out += setup('/scoreboard players set value leash_audit -99',
|
||||
'/scoreboard players set success leash_audit 17',
|
||||
f'/execute store success score success leash_audit store result score value leash_audit run data get entity {horse} {field} 1000')
|
||||
out.append(cmd('/scoreboard players get success leash_audit', expect_result=1))
|
||||
op = cmd('/scoreboard players get value leash_audit')
|
||||
op['label'] = f'h302 {holder_type}/{at}/{field} x1000'
|
||||
out.append(op)
|
||||
out.append(cmd(f'/data get entity {horse} Pos'))
|
||||
if raw:
|
||||
for field in ['Rotation[0]', 'Motion']:
|
||||
op = cmd(f'/data get entity {horse} {field}')
|
||||
op['label'] = f'h302 raw/{holder_type}/{at}/{field}'
|
||||
out.append(op)
|
||||
out += setup('/execute store success score changed leash_audit run data modify storage h302:observer uuid set from entity @s UUID')
|
||||
out += [cmd('/scoreboard players get changed leash_audit', expect_result=0),
|
||||
cmd('/data get entity @s UUID', expect_result=4)]
|
||||
return out
|
||||
@@ -11,7 +11,7 @@ Leashable.setDelayedLeashHolderId inline crates/horizon-client/src/game/remote.r
|
||||
Leashable.readLeashData port crates/horizon-client/src/mobs/leash.rs::pending_from_nbt holder UUID or fence knot block; kept pending until the holder exists
|
||||
Leashable.restoreLeashFromSave port crates/horizon-client/src/game/leads.rs::Game::tick_leads holder found by UUID or knot block; a lead whose holder never shows up drops as an item after 100 ticks
|
||||
Leashable.angularFriction inline crates/horizon-client/src/mobs/leash.rs::tick gap: minor: ground friction is a fixed 0.6 times 0.91, not the friction of the block under the mob (ice, slime)
|
||||
Leashable.checkElasticInteractions port crates/horizon-client/src/mobs/leash.rs::tick gap: minor: one attachment pair only; no quad leash for a horse, llama or sniffer led by a happy ghast
|
||||
Leashable.checkElasticInteractions port crates/horizon-client/src/mobs/leash.rs::tick live mob dispatch selects shared quad physics only when both holder and leashee support it; llama remains single
|
||||
Leashable.getHolderMovement port crates/horizon-client/src/mobs/leash.rs::holder_state gap: minor: the holder's velocity is always used; a NoAI mob holder should count as standing still
|
||||
Leashable.computeElasticInteraction port crates/horizon-client/src/mobs/leash.rs::spring gap: minor: single attachment pair (0, 0.5, 0.5 of the mob and 0, 0.5, 0 of the holder); the four-point version is missing
|
||||
Leashable.createQuadLeashOffsets port crates/horizon-client/src/mobs/leash.rs::Holder::create_quad_leash_offsets four attachment points around an entity, front-left first, clockwise from above
|
||||
|
||||
|
@@ -83,8 +83,11 @@ def main():
|
||||
parser.add_argument('--binary', type=Path, help='use an already built Horizon game instead of building it')
|
||||
parser.add_argument('--slot-codec-diagnostic', action='store_true',
|
||||
help='retain the separate invalid inline slot-range codec diagnostic')
|
||||
parser.add_argument('--work-dir', type=Path, help='isolated scratch output directory')
|
||||
parser.add_argument('--mob-quad-fixture', action='store_true', help='guarded live mob quad-leash scene')
|
||||
parser.add_argument('--mob-quad-raw-diagnostic', action='store_true', help='retain full-precision leash diagnostic')
|
||||
args = parser.parse_args()
|
||||
build = lab.LOCAL / 'command-exec-parity'
|
||||
build = args.work_dir.resolve() if args.work_dir else lab.LOCAL / 'command-exec-parity'
|
||||
build.mkdir(parents=True, exist_ok=True)
|
||||
report_path = build / 'report.json'
|
||||
report_path.unlink(missing_ok=True)
|
||||
@@ -115,6 +118,12 @@ def main():
|
||||
if args.sulfur_power_load_diagnostic:
|
||||
groups = [('setup', corpus_module.group_setup()),
|
||||
('sulfur powered-load diagnostic', corpus_module.sulfur_cube_fuse_ticks(True))]
|
||||
if args.mob_quad_fixture:
|
||||
try:
|
||||
from .mob_quad_leash_corpus import mob_quad_leash
|
||||
except ImportError:
|
||||
from mob_quad_leash_corpus import mob_quad_leash
|
||||
groups = [('setup', corpus_module.group_setup()), ('mob quad leash dispatch', mob_quad_leash(args.mob_quad_raw_diagnostic))]
|
||||
if args.only:
|
||||
groups = [g for g in groups if args.only in g[0] or g[0] == 'setup']
|
||||
if args.slot_codec_diagnostic:
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare live NoAI horse leash dispatch against pinned Java26.3."""
|
||||
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('--prepare', action='store_true')
|
||||
parser.add_argument('--raw-diagnostic', action='store_true', help='add full-precision yaw/motion records')
|
||||
args = parser.parse_args()
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
folder = 'mob-quad-leash-raw-diagnostic' if args.raw_diagnostic else 'mob-quad-leash-parity'
|
||||
command = [sys.executable, str(root / 'tools/test_command_exec_parity.py'),
|
||||
'--mob-quad-fixture', '--work-dir', str(root / '.local' / folder)]
|
||||
if args.raw_diagnostic:
|
||||
command.append('--mob-quad-raw-diagnostic')
|
||||
if args.binary:
|
||||
command += ['--binary', str(args.binary.resolve())]
|
||||
if args.prepare:
|
||||
command.append('--prepare')
|
||||
return subprocess.run(command, cwd=root).returncode
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user