From 5639d42a34b294387a1e7af70a09e6d5e132bf31 Mon Sep 17 00:00:00 2001 From: TheMrZZ <30944236+TheMrZZ@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:21:42 +0100 Subject: [PATCH] Handle boat callers in execute on leasher Read the boat's existing resolved leash state and reuse the holder conversion already used for mobs. Keep pending saved UUIDs separate. Cover pending, live and unleashed contexts through the real Game command path, with typed subjects, callbacks, stores and measured clocks. Retain movement as a separate raw diagnostic and isolate the harness directory. Co-Authored-By: Codex gpt-6.1-sol --- .../src/game/commands/execute.rs | 28 ++++-- tools/boat_leasher_corpus.py | 99 +++++++++++++++++++ tools/test_boat_leasher_parity.py | 32 ++++++ tools/test_command_exec_parity.py | 3 +- 4 files changed, 151 insertions(+), 11 deletions(-) create mode 100644 tools/boat_leasher_corpus.py create mode 100644 tools/test_boat_leasher_parity.py diff --git a/crates/horizon-client/src/game/commands/execute.rs b/crates/horizon-client/src/game/commands/execute.rs index 3eac23d1bd..b4c25e6ce4 100644 --- a/crates/horizon-client/src/game/commands/execute.rs +++ b/crates/horizon-client/src/game/commands/execute.rs @@ -685,13 +685,22 @@ impl Game<'_> { .collect(), _ => Vec::new(), }, - // Leashable.getLeashHolder: a fence knot is not an entity here. - Some("leasher") => match entity { - Target::Mob(id) => self - .mobs - .iter() - .find(|m| m.id == id) - .and_then(|m| m.extra.leash.map(|holder| (holder, m.extra.leash_player))) + // Leashable.getLeashHolder: the server's resolved holder, not a + // saved UUID that is still waiting for tickLeash to restore it. + Some("leasher") => { + let leash = + match entity { + Target::Mob(id) => self.mobs.iter().find(|m| m.id == id).and_then(|m| { + m.extra.leash.map(|holder| (holder, m.extra.leash_player)) + }), + Target::Boat(id) => self + .boats + .iter() + .find(|b| b.id == id) + .and_then(|b| b.leash.map(|holder| (holder, b.leash_player))), + _ => None, + }; + leash .and_then(|(holder, player)| match holder { // The player who tied it (the host's when unrecorded). horizon_client::mobs::leash::Holder::Player => { @@ -703,9 +712,8 @@ impl Game<'_> { | horizon_client::mobs::leash::Holder::Knot(_) => None, }) .into_iter() - .collect(), - _ => Vec::new(), - }, + .collect() + } // TraceableEntity.getOwner: who shot a projectile, who threw an // item, who summoned a vex. Some("origin") => match entity { diff --git a/tools/boat_leasher_corpus.py b/tools/boat_leasher_corpus.py new file mode 100644 index 0000000000..474deb306d --- /dev/null +++ b/tools/boat_leasher_corpus.py @@ -0,0 +1,99 @@ +"""Live boat-holder command contract, with an explicit movement diagnostic. + +The gate covers subjects, saved links, clocks, relation feedback and callbacks, +including empty pending/unleashed relations. Initial Motion/Rotation and all +post-tick Pos/Motion/Rotation belong only to the raw diagnostic, not physics +parity. Initial positions remain exact positive prerequisites in both modes. +""" + + +def boat_leasher(cmd, setup, raw_diagnostic=False): + holder_words = '[I;1,2,3,4]' + holder = '00000001-0000-0002-0000-000300000004' + boat = '00000130-0000-0000-0000-000000000001' + free = '00000130-0000-0000-0000-000000000002' + mob = '00000130-0000-0000-0000-000000000003' + actors = [(holder, 'happy_ghast', 'h304_holder', holder_words, '[0.5d,104d,0.5d]', 20), + (boat, 'oak_boat', 'h304_boat', '[I;304,0,0,1]', '[8.5d,100d,3.5d]', None), + (free, 'oak_boat', 'h304_free', '[I;304,0,0,2]', '[-12.5d,100d,8.5d]', None), + (mob, 'pig', 'h304_mob', '[I;304,0,0,3]', '[3.5d,104d,-3.5d]', 10)] + out = [cmd('/tick freeze', expect_result=1), + cmd('/gamemode spectator', expect_result=1), + cmd('/tp @s 0.5 115 0.5', expect_result=1), + cmd('/forceload add -32 -32 31 31', expect_result=16), + {'op': 'settle'}] + out += [cmd(f'/execute if loaded {x} 100 {z}', expect_result=1) + for x in (-32, -16, 0, 16) for z in (-32, -16, 0, 16)] + out += [cmd('/fill -32 99 -32 31 99 31 ice', expect_result=4096)] + # Seed the slabs first so successful clearing is measured, even if the + # generated terrain was already air. Each volume is below 32768 blocks. + for lo, hi, volume in ((100, 106, 28672), (107, 112, 24576)): + out += [cmd(f'/fill -32 {lo} -32 31 {hi} 31 barrier', expect_result=volume), + cmd(f'/fill -32 {lo} -32 31 {hi} 31 air', expect_result=volume)] + out += [cmd('/scoreboard objectives add h304 dummy', expect_result=1)] + out += setup('/execute store result score start h304 run time query gametime') + common = 'NoAI:1b,NoGravity:1b,Silent:1b,PersistenceRequired:1b' + out += [cmd('/summon happy_ghast 0.5 104 0.5 {UUID:' + holder_words + + ',Tags:["h304_holder"],' + common + ',Health:20f,Rotation:[30f,0f]}', expect_result=1), + cmd('/summon oak_boat 8.5 100 3.5 {UUID:[I;304,0,0,1],Tags:["h304_boat"],' + 'Rotation:[0f,0f],leash:{UUID:' + holder_words + '}}', expect_result=1), + cmd('/summon oak_boat -12.5 100 8.5 {UUID:[I;304,0,0,2],Tags:["h304_free"],' + 'Rotation:[0f,0f]}', expect_result=1), + cmd('/summon pig 3.5 104 -3.5 {UUID:[I;304,0,0,3],Tags:["h304_mob"],' + + common + ',Health:10f,leash:{UUID:' + holder_words + '}}', expect_result=1)] + + def relation(uid, live): + out.extend(setup('/scoreboard players set result h304 31', + '/scoreboard players set success h304 17', + '/scoreboard players set seen h304 0')) + check = 'if entity @s[nbt={UUID:' + holder_words + '}]' + out.append(cmd(f'/execute as {uid} on leasher {check}', + expect_result=1 if live else None)) + out.append(cmd('/execute store result score result h304 store success score success h304 ' + f'as {uid} on leasher run execute {check}')) + # Java leaves both sentinels unchanged when the relation has no source. + out.extend([cmd('/scoreboard players get result h304', expect_result=1 if live else 31), + cmd('/scoreboard players get success h304', expect_result=1 if live else 17), + cmd(f'/execute as {uid} on leasher run data get entity @s UUID', + expect_result=4 if live else None), + cmd(f'/execute as {uid} on leasher run scoreboard players add seen h304 1', + expect_result=1 if live else None), + cmd('/scoreboard players get seen h304', expect_result=int(live))]) + + for elapsed, step in ((0, 0), (10, 10), (20, 10), (40, 20)): + if step: + out += [cmd(f'/tick step {step}', expect_result=1), {'op': 'tick', 'n': step}] + out += setup('/execute store result score elapsed h304 run time query gametime', + '/scoreboard players operation elapsed h304 -= start h304') + out += [cmd('/scoreboard players get elapsed h304', expect_result=elapsed), + cmd('/data get entity @s playerGameType', expect_result=3), + cmd('/data get entity @s Health', expect_result=20), + cmd('/data get entity @s Pos', expect_result=3), + cmd('/execute if entity @s[nbt={Health:20f,Pos:[0.5d,115d,0.5d]}]', expect_result=1)] + for uid, kind, tag, words, pos, health in actors: + fields = f'UUID:{words}' + if health is not None: + fields += f',Health:{health}f,{common}' + if not elapsed: + fields += ',Pos:' + pos + out.append(cmd(f'/execute as {uid} if entity @s[type={kind},tag={tag},nbt={{{fields}}}]', + expect_result=1)) + out.append(cmd(f'/data get entity {uid} UUID', expect_result=4)) + if health is not None: + out.append(cmd(f'/data get entity {uid} Health', expect_result=health)) + if not elapsed: + out.append(cmd(f'/data get entity {uid} Pos', expect_result=3)) + if raw_diagnostic: + for field in (('Motion', 'Rotation') if not elapsed else ('Pos', 'Motion', 'Rotation')): + out.append(cmd(f'/data get entity {uid} {field}')) + for uid in (boat, mob): + out.append(cmd(f'/data get entity {uid} leash.UUID', expect_result=4)) + out.append(cmd(f'/data get entity {free} leash')) + relation(boat, elapsed > 0) + relation(free, False) + relation(mob, elapsed > 0) + out += setup('/kill @e[tag=h304_boat]', '/kill @e[tag=h304_free]', + '/kill @e[tag=h304_mob]', '/kill @e[tag=h304_holder]', + '/scoreboard objectives remove h304', '/forceload remove -32 -32 31 31', + '/gamemode creative', '/tp @s 8.5 230 8.5 0 0', '/tick unfreeze') + return out diff --git a/tools/test_boat_leasher_parity.py b/tools/test_boat_leasher_parity.py new file mode 100644 index 0000000000..9471117a61 --- /dev/null +++ b/tools/test_boat_leasher_parity.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""Check a boat caller's live leasher through the real Game command path. + +Use --raw-diagnostic to retain the independently scoped movement observations. +All other arguments, including --binary, are the command-oracle runner's. +""" +import sys + +try: + from . import command_exec_corpus as corpus + from . import test_command_exec_parity as runner + from .boat_leasher_corpus import boat_leasher +except ImportError: + import command_exec_corpus as corpus + import test_command_exec_parity as runner + from boat_leasher_corpus import boat_leasher + + +def main(): + raw = '--raw-diagnostic' in sys.argv + if raw: + sys.argv.remove('--raw-diagnostic') + if not any(a == '--work-dir' or a.startswith('--work-dir=') for a in sys.argv[1:]): + sys.argv.extend(['--work-dir', str(runner.lab.LOCAL / 'boat-leasher-parity')]) + operations = boat_leasher(corpus.cmd, corpus.setup, raw) + runner.corpus_module.groups = lambda: [ + ('setup', corpus.group_setup()), ('boat live leasher', operations)] + runner.main() + + +if __name__ == '__main__': + main() diff --git a/tools/test_command_exec_parity.py b/tools/test_command_exec_parity.py index a96080f08a..053860aff4 100644 --- a/tools/test_command_exec_parity.py +++ b/tools/test_command_exec_parity.py @@ -83,8 +83,9 @@ 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') 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)