Merge pull request #10031 from TheMrZZ/fix/h326-boat-live-leash

🤖 Agent: data get on a leashed boat now reads its leash from the same save path the world writes (write_boat_leash); merged main twice, boat_leash_view harness passes and the u/3 union builds with clippy -D warnings.
This commit is contained in:
Ludovic Marie authored and GitHub committed 2026-11-07 09:22:59 +01:00
commit 5075231ffa
7 files changed
+245 -3

No files matched your search

@@ -506,6 +506,69 @@ impl Game<'_> {
mod tests {
use super::*;
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn command_view_uses_live_and_pending_boat_leashes_not_raw_tags() {
use crate::game::{Audio, Options, Progress, Settings, commands::Target, load_resources};
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
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 (id, uuid, actual_holder, unknown) =
(1, [326, 1, 0, 1], [326, 2, 0, 1], [111, 222, 333, 444]);
game.boats
.push(Boat::new(id, "oak".into(), Vec3::new(6.5, 100.0, 4.5), 0.0));
game.persist.uuids.insert(id, uuid);
game.level.player_uuid = Some([326, 3, 0, 1]);
let read = |game: &mut Game<'_>| game.entity_nbt(Target::Boat(id)).unwrap();
assert!(!game.persist.raw.contains_key(&id));
assert_eq!(
read(&mut game).get("UUID"),
Some(&Tag::IntArray(uuid.to_vec()))
);
assert!(read(&mut game).get("leash").is_none());
game.boats[0].leash = Some(Holder::Player);
game.boats[0].leash_player = Some(actual_holder);
let expected = Tag::compound([("UUID", Tag::IntArray(actual_holder.to_vec()))]);
assert_eq!(read(&mut game).get("leash"), Some(&expected));
assert_eq!(read(&mut game).get("leash"), Some(&expected));
let delayed = Tag::compound([("UUID", Tag::IntArray(unknown.to_vec()))]);
game.persist
.raw
.insert(id, Tag::compound([("leash", delayed.clone())]));
assert_eq!(read(&mut game).get("leash"), Some(&expected));
game.boats[0].leash = None;
game.boats[0].leash_player = None;
game.boats[0].leash_pending = Some(Pending::Uuid(unknown));
assert_eq!(read(&mut game).get("leash"), Some(&delayed));
game.boats[0].leash_pending = None;
assert!(read(&mut game).get("leash").is_none());
}
#[test]
fn a_boat_is_centred_half_its_height_up() {
let b = Boat::new(1, "oak".into(), Vec3::new(1.0, 64.0, 2.0), 0.0);
@@ -599,6 +599,47 @@ impl Game<'_> {
}
// Java produced the fixture file before this world was opened; map_state loads it.
"crafter_max_map_fixture" => {}
"boat_interact" => {
let uuid = u128::from_str_radix(
&op["uuid"]
.as_str()
.ok_or("boat click: missing UUID")?
.replace('-', ""),
16,
)?;
self.in_owner_level(|g| -> Result<()> {
let id = g
.boat_by_uuid(horizon_client::lan::playerdata::uuid_ints(uuid))
.ok_or("boat click: missing live boat")?;
let owner = g.serving.as_ref().ok_or("boat click: no server")?.owner;
let conn = g
.lan
.as_ref()
.and_then(|h| {
h.hub
.guests
.iter()
.find(|p| p.uuid == owner && p.placed)
.map(|p| p.conn)
})
.ok_or("boat click: owner not connected")?;
let mut host = g.lan.take().ok_or("boat click: no hub")?;
g.lan_guest_packet(
&mut host,
conn,
horizon_protocol::packets::play::Serverbound::Interact(
horizon_protocol::packets::play::Interact {
id: id as i32,
hand: 0,
location: [0.0; 3],
secondary: false,
},
),
);
g.lan = Some(host);
Ok(())
})?;
}
"use_item" => {
let owner = self
.serving
@@ -136,8 +136,9 @@ impl Game<'_> {
}
Target::Boat(id) => {
let uuid = self.uuid_for(id);
let b = self.boats.iter().find(|b| b.id == id)?;
entity_nbt::boat_to_nbt(
let i = self.boats.iter().position(|b| b.id == id)?;
let b = &self.boats[i];
let mut tag = entity_nbt::boat_to_nbt(
&b.entity_type(),
b.position,
b.velocity,
@@ -145,7 +146,10 @@ impl Game<'_> {
uuid,
self.persist.raw.get(&id),
b.chest.then_some(&b.items[..]),
)
);
// AbstractBoat.addAdditionalSaveData: the live or pending leash.
self.write_boat_leash(&mut tag, i);
tag
}
Target::Cart(id) => {
let uuid = self.uuid_for(id);
@@ -836,6 +836,14 @@ public final class VanillaCommandExecOracle {
}
}
}
case "boat_interact" -> {
var boat = level.getEntity(UUID.fromString(op.get("uuid").getAsString()));
if (!(boat instanceof net.minecraft.world.entity.vehicle.boat.AbstractBoat))
throw new IllegalStateException("boat click: missing live boat");
player.connection.handleInteract(new net.minecraft.network.protocol.game.ServerboundInteractPacket(
boat.getId(), net.minecraft.world.InteractionHand.MAIN_HAND,
net.minecraft.world.phys.Vec3.ZERO, false));
}
case "use_item" -> {
if (!op.has("sequence")) {
player.gameMode.useItem(player, player.level(), player.getMainHandItem(),
+92
View File
@@ -0,0 +1,92 @@
"""Live boat leash command reads after real connected-owner interactions."""
def cases(cmd, setup):
boat = '00000146-0000-0001-0000-000000000001'
pending = '00000146-0000-0001-0000-000000000002'
unknown = '[I;111,222,333,444]'
ops = [cmd('/tick freeze'), cmd('/forceload add 0 0 31 31'),
{'op': 'wait_chunks', 'chunks': [[0, 0], [1, 0], [0, 1], [1, 1]]}]
for x, z in [(0, 0), (16, 0), (0, 16), (16, 16)]:
ops.append(cmd(f'/execute if loaded {x} 100 {z}', expect_result=1))
ops += [cmd('/fill 0 99 0 31 99 31 stone', expect_result=1024),
cmd('/fill 0 100 0 31 104 31 air'),
cmd('/gamemode survival @s'), cmd('/tp @s 4.5 100 4.5 0 0'),
cmd('/clear @s'), cmd('/scoreboard objectives add boat_view dummy'),
cmd('/item replace entity @s hotbar.0 with lead 2', expect_result=1)]
ops += setup('/execute store result score #start boat_view run time query gametime',
'/data modify storage boat_view:guard owner set from entity @s UUID')
def owner():
return [cmd('/execute if entity @s[type=player,gamemode=survival,nbt={Pos:[4.5d,100d,4.5d],Health:20f}]', expect_result=1),
cmd('/data get entity @s UUID'), cmd('/data get entity @s Inventory')]
def actor(target, x, z, tag):
return [
cmd(f'/execute if entity @e[type=oak_boat,tag={tag},nbt={{UUID:[I;326,1,0,{int(target[-8:], 16)}],Pos:[{x}d,100d,{z}d],NoGravity:1b}}]', expect_result=1),
cmd(f'/data get entity {target} UUID', expect_result=4),
cmd(f'/data get entity {target} Pos', expect_result=3),
cmd(f'/execute if block {int(x)} 99 {int(z)} stone', expect_result=1)]
def leash(target, present):
rows = [cmd(f'/data get entity {target} leash'),
cmd(f'/data get entity {target} leash.UUID', expect_result=4 if present else None)]
rows += setup('/scoreboard players set #r boat_view 31',
'/scoreboard players set #s boat_view 17')
rows += [cmd('/scoreboard players get #r boat_view', expect_result=31),
cmd('/scoreboard players get #s boat_view', expect_result=17),
cmd('/execute store result score #r boat_view store success score #s boat_view '
f'run data get entity {target} leash.UUID'),
cmd('/scoreboard players get #r boat_view', expect_result=4 if present else None),
cmd('/scoreboard players get #s boat_view', expect_result=int(present))]
return rows
ops += owner()
ops += [cmd('/summon oak_boat 6.5 100 4.5 {UUID:[I;326,1,0,1],Tags:["boat_view"],NoGravity:1b,Motion:[0d,0d,0d]}')]
ops += actor(boat, 6.5, 4.5, 'boat_view') + leash(boat, False)
# Attach, detach and attach again. Each click traverses the real owner packet path.
for attached, lead_count in [(True, 1), (False, 1), (True, 0)]:
ops.append({'op': 'boat_interact', 'uuid': boat})
ops += owner() + actor(boat, 6.5, 4.5, 'boat_view') + leash(boat, attached)
ops.append(cmd('/clear @s lead 0', expect_result=lead_count if lead_count else None))
if not lead_count:
ops.append(cmd('/execute unless items entity @s hotbar.0 lead', expect_result=1))
if attached:
ops += setup('/data modify storage boat_view:guard owner set from entity @s UUID')
ops.append(cmd('/execute store success score #different boat_view run data modify '
f'storage boat_view:guard owner set from entity {boat} leash.UUID'))
ops.append(cmd('/scoreboard players get #different boat_view', expect_result=0))
ops += leash(boat, True) # Reads themselves do not change the live association.
ops += [cmd(f'/summon oak_boat 10.5 100 4.5 {{UUID:[I;326,1,0,2],Tags:["boat_pending"],NoGravity:1b,leash:{{UUID:{unknown}}}}}')]
ops += actor(pending, 10.5, 4.5, 'boat_pending') + leash(pending, True)
ops.append(cmd('/execute unless entity 0000006f-0000-00de-0000-014d000001bc', expect_result=1))
ops += [cmd(f'/execute if entity {pending}'),
cmd('/data get storage boat_view:guard owner')]
ops += setup('/execute store result score #elapsed boat_view run time query gametime',
'/scoreboard players operation #elapsed boat_view -= #start boat_view')
ops += [cmd('/scoreboard players get #elapsed boat_view', expect_result=0)] + owner()
# Independently measured loaded-link control: after resolution and a real
# detach, stale input NBT must not override the now-absent live association.
# This is the pinned harness owner's UUID, positively checked before use.
holder = '[I;-1608632253,-450807400,-1786055591,-2146546415]'
loaded = '00000146-0000-0001-0000-000000000003'
ops += [cmd(f'/execute if entity @s[nbt={{UUID:{holder}}}]', expect_result=1),
cmd('/tp @s 4.5 100 8.5 0 0'),
cmd('/execute if entity @s[type=player,gamemode=survival,nbt={Pos:[4.5d,100d,8.5d],Health:20f}]', expect_result=1),
cmd(f'/summon oak_boat 6.5 100 8.5 {{UUID:[I;326,1,0,3],Tags:["boat_loaded"],NoGravity:1b,Motion:[0d,0d,0d],leash:{{UUID:{holder}}}}}')]
ops += actor(loaded, 6.5, 8.5, 'boat_loaded') + leash(loaded, True)
ops.append(cmd(f'/execute as {loaded} on leasher if entity @s[type=player]'))
ops += setup('/execute store result score #loaded_start boat_view run time query gametime')
ops += [cmd('/tick step 1', expect_result=1), {'op': 'tick', 'n': 1}]
ops += actor(loaded, 6.5, 8.5, 'boat_loaded') + leash(loaded, True)
ops += [cmd(f'/execute as {loaded} on leasher if entity @s[nbt={{UUID:{holder}}}]', expect_result=1),
cmd('/item replace entity @s hotbar.0 with lead', expect_result=1),
{'op': 'boat_interact', 'uuid': loaded}]
ops += actor(loaded, 6.5, 8.5, 'boat_loaded') + leash(loaded, False)
ops += [cmd(f'/execute as {loaded} on leasher if entity @s[type=player]'),
cmd('/clear @s lead 0', expect_result=1),
cmd('/execute if entity @s[type=player,gamemode=survival,nbt={Pos:[4.5d,100d,8.5d],Health:20f}]', expect_result=1)]
ops += setup('/execute store result score #loaded_elapsed boat_view run time query gametime',
'/scoreboard players operation #loaded_elapsed boat_view -= #loaded_start boat_view')
ops += [cmd('/scoreboard players get #loaded_elapsed boat_view', expect_result=1)]
return ops
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env python3
"""Boat command-visible live leash data after real owner clicks."""
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('--prepare', 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'),
'--boat-leash-view-fixture', '--work-dir',
str(args.work_dir.resolve() if args.work_dir else root / '.local/boat-leash-view-parity')]
if args.binary:
command += ['--binary', str(args.binary.resolve())]
for name in ['reference_only', 'prepare']:
if getattr(args, name):
command.append('--' + name.replace('_', '-'))
return subprocess.run(command, cwd=root).returncode
if __name__ == '__main__':
sys.exit(main())
+5
View File
@@ -121,6 +121,7 @@ def main():
parser.add_argument('--item-contents-provider-empty-control', action='store_true')
parser.add_argument("--boat-item-hit-fixture", action="store_true")
parser.add_argument("--reference-only", action="store_true")
parser.add_argument("--boat-leash-view-fixture", action="store_true")
parser.add_argument("--boat-motion-diagnostic", action="store_true")
parser.add_argument('--reinforcement-original-diagnostic', action='store_true', help='replay the original unisolated174/108 fixture, retaining its terrain prerequisite failure')
parser.add_argument('--reinforcement-target-diagnostic', action='store_true', help='retain the separate creative-callee target relation record (full209)')
@@ -318,6 +319,10 @@ def main():
if args.selector_bounds_fixture:
from selector_bounds_corpus import selector_bounds
groups = [("setup", corpus_module.group_setup()), ("selector entity bounds", selector_bounds())]
if args.boat_leash_view_fixture:
from boat_leash_view_corpus import cases as boat_leash_views
groups = [('setup', corpus_module.group_setup()),
('boat live leash view', boat_leash_views(corpus_module.cmd, corpus_module.setup))]
if args.merchant_crafted_fixture:
from merchant_crafted_corpus import cases
groups = [('merchant crafted statistics', cases())]