Fix opening container vehicles (#4370)

Right-clicking a chest boat, a chest raft, a chest minecart or a hopper
minecart did nothing: the server-side container path (Game::lan_open_container,
shared by LAN guests and single-player players) only knew block containers and
silently dropped the vehicle's placeholder key.

- give a container vehicle its own menu and title: ChestMenu.threeRows (9x3,
  27 slots) for chest boats, rafts and chest minecarts, HopperMenu (5 slots)
  for hopper minecarts, titled with Entity.getDisplayName's name key, as
  AbstractChestBoat.createMenu / MinecartChest.createMenu /
  MinecartHopper.createMenu / MenuProvider.getDisplayName do;
- fire CONTAINER_OPEN with the player and PiglinAi.angerNearbyPiglins on a
  chest boat and a chest minecart (MinecartChest overrides interact; a hopper
  minecart does not), and CONTAINER_CLOSE on their close (MinecartChest
  overrides stopOpen; a hopper minecart inherits the no-op);
- send OPEN_INVENTORY while riding a chest boat (HasCustomInventoryScreen ->
  ServerGamePacketListenerImpl.handlePlayerCommand), so E opens the chest
  instead of the player's inventory;
- spill a chest boat's contents on every destroying removal, as
  AbstractChestBoat.remove does (a creative hit discards, DISCARDED
  shouldDestroy is true, and Containers.dropContents has no entity_drops
  gate; the boat item stays on the entity_drops death path);
- drop the chest minecart's open/close chest sounds: vanilla plays none.

Tests: tools/test_vehicle_container_parity.py (new, 13/13 menu cases against
the real 26.3 jars), live before/after on the server thread, and two-client LAN
item-loss/duplication runs.

Refs #4370
Co-Authored-By: DeepSeek Harness (deepseek-flash) <[email protected]>
This commit is contained in:
chi4a committed 2026-10-30 21:57:07 +02:00
1 parent 19ca1c8b33
commit 56c8af8bd8
9 files changed
+444 -48

No files matched your search

@@ -81,6 +81,28 @@ pub fn double_chest_name(first: Option<&str>, second: Option<&str>) -> horizon_w
display_name(first.or(second), "container.chestDouble")
}
/// The menu a container vehicle's `createMenu` builds (`ContainerEntity`):
/// `AbstractChestBoat.createMenu` and `MinecartChest.createMenu` are
/// `ChestMenu.threeRows` (a 9x3 menu), `MinecartHopper.createMenu` is a
/// `HopperMenu` (five slots). `None` for any other entity type.
pub fn vehicle_menu(entity_type: &str) -> Option<(&'static str, usize)> {
match entity_type {
"minecraft:chest_minecart" => Some(("minecraft:generic_9x3", 27)),
"minecraft:hopper_minecart" => Some(("minecraft:hopper", HOPPER_SLOTS)),
n if n.ends_with("_chest_boat") || n.ends_with("_chest_raft") => {
Some(("minecraft:generic_9x3", 27))
}
_ => None,
}
}
/// `MenuProvider.getDisplayName` for a container vehicle: `Entity.getDisplayName`
/// is its custom name, else `getName`, the entity type's name key. Horizon's
/// vehicles keep no custom name, so this is the type's name key.
pub fn vehicle_menu_name(entity_type: &str) -> String {
format!("entity.{}", entity_type.replacen(':', ".", 1))
}
impl Campfire {
/// `placeFood`'s slot: the first empty one, given the item's
/// `campfire_cooking` time; it takes one item of `held`.
@@ -100,6 +100,7 @@ mod tooltip_display_fixture;
mod trading_fixture;
mod translucency_fixture;
mod trident_probe;
mod vehicle_container_fixture;
mod weather_fixture;
mod world_border_render_fixture;
mod world_options_fixture;
@@ -194,6 +195,7 @@ probes! {
trading_fixture,
trident_probe,
translucency_fixture,
vehicle_container_fixture,
weather_fixture,
world_border_render_fixture,
world_options_fixture,
@@ -0,0 +1,33 @@
//! The menu a container vehicle opens for tools/test_vehicle_container_parity.py:
//! reads one entity type id per line and prints the menu type its `createMenu`
//! builds (`ChestMenu.threeRows` for a chest boat or chest minecart,
//! `HopperMenu` for a hopper minecart - `horizon_client::containers::vehicle_menu`,
//! the menu `lan_open_vehicle_container` sends), its container size, and the
//! title's name key (`MenuProvider.getDisplayName` = `Entity.getDisplayName`).
//!
//! usage: vehicle_container_fixture INPUT OUTPUT
use horizon_client::containers::{vehicle_menu, vehicle_menu_name};
use std::fs;
fn case(entity_type: &str) -> Option<String> {
let entity_type = entity_type.trim();
let (menu, size) = vehicle_menu(entity_type)?;
Some(format!("{menu} {size} {}", vehicle_menu_name(entity_type)))
}
pub fn main() -> horizon_client::Result<()> {
let args: Vec<String> = crate::args().collect();
let input = fs::read_to_string(
args.get(1)
.ok_or("usage: vehicle_container_fixture INPUT OUTPUT")?,
)?;
let lines: Vec<String> = input
.lines()
.map(|line| case(line).unwrap_or_else(|| "error".to_owned()))
.collect();
fs::write(
args.get(2).ok_or("missing OUTPUT")?,
lines.join("\n") + "\n",
)?;
Ok(())
}
+59 -16
View File
@@ -792,16 +792,7 @@ impl Game<'_> {
}
// ChestBoat.interact: sneaking (or a full seat) opens the chest.
if self.boats[i].chest && (sneaking || self.riding.is_some() || !self.boat_has_room(i)) {
let b = &self.boats[i];
let key = (i32::MAX - 15, super::minecarts::CART_KEY_Y, b.id as i32);
self.world.block_entities.insert(
key,
horizon_client::containers::BlockEntity::Chest(b.items.clone()),
);
self.container = Some(key);
self.screen = super::Screen::Inventory;
self.capture(false);
self.right = false;
self.open_chest_boat_container(i);
self.swing_arm();
return true;
}
@@ -817,6 +808,51 @@ impl Game<'_> {
self.yaw = self.boats[i].yaw;
true
}
/// `ContainerEntity.interactWithContainerVehicle` (`AbstractChestBoat`):
/// the chest's 27 slots go under a placeholder key that the menu edits; a
/// server hands them to the player as a `ChestMenu` (`as_guest` ->
/// `lan_open_container`). `AbstractChestBoat.interact` fires
/// `CONTAINER_OPEN` and angers the piglins that see it.
pub(super) fn open_chest_boat_container(&mut self, i: usize) {
let b = &self.boats[i];
let key = (i32::MAX - 15, super::minecarts::CART_KEY_Y, b.id as i32);
let at = [b.position.x, b.position.y + 0.5, b.position.z];
let items = b.items.clone();
self.world
.block_entities
.insert(key, horizon_client::containers::BlockEntity::Chest(items));
self.container = Some(key);
self.screen = super::Screen::Inventory;
self.capture(false);
self.right = false;
let ctx = self.player_context(None);
self.post_game_event("minecraft:container_open", at, ctx);
self.anger_piglins(true);
}
/// `AbstractChestBoat.openCustomInventoryScreen` (`HasCustomInventoryScreen`),
/// the inventory key while riding a chest boat: its chest opens, not the
/// player's inventory.
pub(super) fn open_ridden_chest_boat(&mut self) -> bool {
let Some(id) = self.riding else {
return false;
};
let Some(i) = self.boats.iter().position(|b| b.id == id) else {
return false;
};
if !self.boats[i].chest {
return false;
}
self.open_chest_boat_container(i);
true
}
/// Whether the player rides a chest boat: `HasCustomInventoryScreen`, so
/// the inventory key sends `ServerboundPlayerCommandPacket.Action.OPEN_INVENTORY`
/// (`ServerGamePacketListenerImpl.handlePlayerCommand` opens its screen).
pub(super) fn riding_chest_boat(&self) -> bool {
self.riding
.and_then(|id| self.boats.iter().find(|b| b.id == id))
.is_some_and(|b| b.chest)
}
/// `VehicleEntity.hurtServer` from the player's attack.
pub(super) fn attack_boat(&mut self) -> bool {
let Some((i, d)) = self.boat_hit() else {
@@ -835,8 +871,11 @@ impl Game<'_> {
true
}
/// `VehicleEntity.hurtServer`: wobble, add the damage and break past 40
/// (`creative`: a creative player's hit removes it without drops).
/// True when the boat was removed.
/// (`creative`: a creative player's hit discards it straight away).
/// `AbstractChestBoat.remove` drops the chest contents for every removal
/// reason that destroys the boat (DISCARDED included), while the boat item
/// itself only drops on the death path (`VehicleEntity.destroy`), not when
/// a creative hit discards it. True when the boat was removed.
pub(super) fn hurt_boat(&mut self, i: usize, damage: f32, creative: bool) -> bool {
let b = &mut self.boats[i];
b.hurt_dir = -b.hurt_dir;
@@ -847,11 +886,15 @@ impl Game<'_> {
self.eject_boat_mobs(i, true);
let b = self.boats.remove(i);
let at = [b.position.x, b.position.y, b.position.z];
// `AbstractChestBoat.remove` spills the chest through
// `Containers.dropContents` for every destroying removal reason;
// that has no `entity_drops` gate of its own.
for stack in b.items.iter().flatten() {
self.spawn_item(stack.clone(), at, super::dispenser::new_item_motion(), 10);
}
// `VehicleEntity.destroy(level, dropItem)` drops the boat item on
// the death path only, and only with `entity_drops` on.
if !creative && self.ui.rules.bool("entity_drops") {
// ChestBoat.destroy: chestVehicleDestroyed spills the contents.
for stack in b.items.iter().flatten() {
self.spawn_item(stack.clone(), at, super::dispenser::new_item_motion(), 10);
}
let item = b.entity_type();
self.spawn_item(
ItemStack {
+96 -1
View File
@@ -898,6 +898,12 @@ impl Game<'_> {
/// Hand a container the guest opened over (ServerPlayer.openMenu);
/// false when it is not one a guest can open.
fn lan_open_container(&mut self, host: &mut LanHost, conn: u32, pos: (i32, i32, i32)) -> bool {
// A chest minecart, hopper minecart or chest boat keeps its slots
// under a placeholder key (`ContainerEntity`): the menu is the
// vehicle's own, not a block's.
if pos.1 == super::minecarts::CART_KEY_Y {
return self.lan_open_vehicle_container(host, conn, pos);
}
let name = &self
.res
.facts()
@@ -978,6 +984,51 @@ impl Game<'_> {
true
}
/// `ContainerEntity.interactWithContainerVehicle` for a guest: the menu
/// the vehicle's own `MenuProvider.createMenu` builds - three rows of a
/// `ChestMenu` for a chest boat or a chest minecart, a `HopperMenu` for a
/// hopper minecart - titled with the vehicle's name.
fn lan_open_vehicle_container(
&mut self,
host: &mut LanHost,
conn: u32,
pos: (i32, i32, i32),
) -> bool {
let entity_type = if let Some(cart) = self.carts.iter().find(|c| c.id as i32 == pos.2) {
cart.kind.id().to_owned()
} else if let Some(boat) = self.boats.iter().find(|b| b.id as i32 == pos.2) {
if !boat.chest {
return false;
}
boat.entity_type()
} else {
return false;
};
// `createMenu` (ChestMenu.threeRows or a HopperMenu), and
// `MenuProvider.getDisplayName`, the vehicle's name key.
let Some((menu, _)) = horizon_client::containers::vehicle_menu(&entity_type) else {
return false;
};
let title = horizon_protocol::text::translatable(
&horizon_client::containers::vehicle_menu_name(&entity_type),
&[],
);
let slots = Slots::Block(pos);
let Some(items) = host
.hub
.guest(conn)
.and_then(|g| slots.read(&self.world, g))
else {
return false;
};
// ServerPlayer.openMenu closes the menu that was open.
self.guest_container_closed(host, conn);
self.lan_station_closed(host, conn, None);
host.hub
.open_container(conn, menu, title, pos, slots, items);
true
}
/// The guest's open container is closing (`AbstractContainerMenu.removed`):
/// it no longer counts among the openers of the lid, and the lid's close
/// sound is played for everyone around (`ChestBlockEntity.onClose`, a
@@ -1455,7 +1506,11 @@ impl Game<'_> {
// mount's inventory (`HasCustomInventoryScreen`).
S::PlayerCommand(p) if p.action == 5 => {
self.as_guest(host, conn, |g| {
g.open_ridden_mount_menu();
// HasCustomInventoryScreen: a tamed mount's horse
// inventory, or a chest boat's chest.
if !g.open_ridden_mount_menu() {
g.open_ridden_chest_boat();
}
});
}
S::PlayerCommand(p) if p.action == 6 => self.lan_start_gliding(host, conn),
@@ -1510,6 +1565,17 @@ impl Game<'_> {
self.drop_guest_items(host, gi, drops);
}
S::ContainerClose(p) => {
// A vehicle's menu closed: its slots go back to the vehicle
// (`ContainerEntity`), and a chest boat's `stopOpen` fires
// CONTAINER_CLOSE with the player as the source.
let vehicle = host.hub.guests[gi]
.menu
.as_ref()
.filter(|m| m.window == p.container)
.and_then(|m| match m.slots {
Slots::Block(key) if key.1 == super::minecarts::CART_KEY_Y => Some(key),
_ => None,
});
if host.hub.guests[gi]
.menu
.as_ref()
@@ -1517,6 +1583,35 @@ impl Game<'_> {
{
self.guest_container_closed(host, conn);
}
if let Some(key) = vehicle {
self.close_cart_container(key);
// `stopOpen`: a chest boat and a chest minecart fire
// CONTAINER_CLOSE with the closing player's context; a
// hopper minecart keeps `Container.stopOpen`'s no-op.
let at = if let Some(b) = self.boats.iter().find(|b| b.id as i32 == key.2) {
Some([b.position.x, b.position.y + 0.5, b.position.z])
} else if let Some(c) = self.carts.iter().find(|c| c.id as i32 == key.2) {
(c.kind == super::minecarts::CartKind::Chest).then_some([
c.position.x,
c.position.y,
c.position.z,
])
} else {
None
};
if let Some(at) = at {
let spect = host.hub.guest(conn).is_some_and(|g| g.spectator());
let ctx = horizon_client::sculk::Context {
source: Some(horizon_client::mobs::Who::Player),
spectator: spect,
targetable: !spect,
living: true,
player: true,
..Default::default()
};
self.post_game_event("minecraft:container_close", at, ctx);
}
}
host.hub.container_closed(conn, p.container);
self.guest_carried_back(host, gi);
}
+27 -26
View File
@@ -1069,24 +1069,7 @@ impl Game<'_> {
self.riding = Some(self.carts[i].id);
}
// AbstractMinecartContainer.interact: no sneak check.
CartKind::Chest | CartKind::Hopper => {
self.unpack_cart_loot(i);
let c = self.carts[i].clone();
let key = (i32::MAX - 15, CART_KEY_Y, c.id as i32);
self.world
.block_entities
.insert(key, BlockEntity::Chest(c.items.clone()));
self.container = Some(key);
self.screen = super::Screen::Inventory;
self.capture(false);
self.right = false;
if c.kind == CartKind::Chest {
let at = [c.position.x, c.position.y, c.position.z];
let pitch = self.world.random.next_float() * 0.1 + 0.9;
self.audio
.play("minecraft:block.chest.open", Some(at), 0.5, pitch);
}
}
CartKind::Chest | CartKind::Hopper => self.open_cart_container(i),
CartKind::Furnace => {
let fuel = held
.as_ref()
@@ -1121,6 +1104,32 @@ impl Game<'_> {
self.swing_arm();
true
}
/// `AbstractMinecartContainer.interact`: the menu opens over the cart's
/// own slots, kept under a placeholder key until it closes
/// (`store_cart_items`/`close_cart_container`).
pub(super) fn open_cart_container(&mut self, i: usize) {
self.unpack_cart_loot(i);
let c = self.carts[i].clone();
let key = (i32::MAX - 15, CART_KEY_Y, c.id as i32);
self.world
.block_entities
.insert(key, BlockEntity::Chest(c.items.clone()));
self.container = Some(key);
self.screen = super::Screen::Inventory;
self.capture(false);
self.right = false;
if c.kind == CartKind::Chest {
// `MinecartChest.interact` (which overrides
// `AbstractMinecartContainer.interact`): a successful open fires
// CONTAINER_OPEN with the player and angers the piglins that see
// it. `MinecartHopper` does not override it, so a hopper minecart
// fires neither. Vanilla plays no open sound for a chest minecart.
let at = [c.position.x, c.position.y, c.position.z];
let ctx = self.player_context(None);
self.post_game_event("minecraft:container_open", at, ctx);
self.anger_piglins(true);
}
}
/// Closing the cart's container open under `key` copies the items back.
pub(super) fn close_cart_container(&mut self, key: (i32, i32, i32)) {
if key.1 != CART_KEY_Y {
@@ -1128,14 +1137,6 @@ impl Game<'_> {
}
self.store_cart_items(key);
self.world.block_entities.remove(&key);
if let Some(c) = self.carts.iter().find(|c| c.id as i32 == key.2)
&& c.kind == CartKind::Chest
{
let at = [c.position.x, c.position.y, c.position.z];
let pitch = self.world.random.next_float() * 0.1 + 0.9;
self.audio
.play("minecraft:block.chest.close", Some(at), 0.5, pitch);
}
}
/// The menu of a chest or hopper minecart or a chest boat works on the
/// vehicle's own items (`ContainerEntity`): the placeholder under `key`
+6 -5
View File
@@ -6399,7 +6399,7 @@ impl Game<'_> {
let all = self.has_control_down();
self.remote_drop(all);
}
KeyCode::KeyE if self.riding_mount_with_menu() => {
KeyCode::KeyE if self.riding_mount_with_menu() || self.riding_chest_boat() => {
let id = self.remote.as_ref().map_or(0, |r| r.entity_id);
self.send(S::PlayerCommand(play::PlayerCommand {
id,
@@ -6561,10 +6561,11 @@ impl Game<'_> {
}
Ok(true)
}
// MultiPlayerGameMode.isServerControlledInventory: riding a
// horse or llama, the server opens its inventory
// (ServerboundPlayerCommandPacket OPEN_INVENTORY).
KeyCode::KeyE if self.riding_mount_with_menu() => {
// Open the ridden vehicle's own screen (`HasCustomInventoryScreen`)
// instead of the player's inventory: riding a horse, llama or
// chest boat, the inventory key sends
// ServerboundPlayerCommandPacket OPEN_INVENTORY.
KeyCode::KeyE if self.riding_mount_with_menu() || self.riding_chest_boat() => {
if let Some(r) = self.remote.as_mut() {
r.pending_keys.push(key);
}
@@ -0,0 +1,148 @@
package dev.horizonlab.parity;
import java.io.PrintWriter;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.UUID;
import com.mojang.authlib.GameProfile;
import net.minecraft.core.RegistryAccess;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.MenuProvider;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.EntitySpawnReason;
import net.minecraft.world.entity.EntitySpawnRequest;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.inventory.AbstractContainerMenu;
import net.minecraft.world.item.component.ResolvableProfile;
import net.minecraft.world.level.GameType;
import net.minecraft.world.level.Level;
/**
* Original oracle: the menu a container vehicle opens. Each input line is an
* entity type id; EntityType.create makes it on the stand-in ServerLevel (which
* only hands out entity ids), its MenuProvider.createMenu(id, inventory,
* player) - the menu ServerPlayer.openMenu sends - gives the menu, and
* getDisplayName (the vehicle's custom name or its type name) the title.
* Prints the menu type, the container's slot count (without the player's 36)
* and the title's name key.
*/
public final class VanillaVehicleContainerOracle {
/** A stand-in server level: only the entity id counter is real. */
static final class Lvl extends ServerLevel {
int next = 1;
Lvl() {
super(null, null, null, null, null, null, false, 0L, List.of(), false);
}
@Override
public int getNextEntityId() {
return next++;
}
@Override
public RegistryAccess registryAccess() {
return ParityData.access;
}
@Override
public MinecraftServer getServer() {
return null;
}
@Override
public net.minecraft.world.flag.FeatureFlagSet enabledFeatures() {
return net.minecraft.world.flag.FeatureFlags.REGISTRY.allFlags();
}
}
/** A player the menu constructor may ask isSpectator() (a loot roll). */
static final class FakePlayer extends Player {
FakePlayer(Lvl level) {
super(level, new GameProfile(new UUID(1L, 2L), "oracle"));
}
@Override
public GameType gameMode() {
return GameType.SURVIVAL;
}
@Override
public ResolvableProfile getProfile() {
return ResolvableProfile.createResolved(this.getGameProfile());
}
@Override
public boolean isSpectator() {
return false;
}
}
static Object unsafe;
static Method allocate;
static Method putObject;
static Method objectFieldOffset;
static void reflect() throws Exception {
var unsafeField = Class.forName("sun.misc.Unsafe").getDeclaredField("theUnsafe");
unsafeField.setAccessible(true);
unsafe = unsafeField.get(null);
allocate = unsafe.getClass().getMethod("allocateInstance", Class.class);
putObject = unsafe.getClass().getMethod("putObject", Object.class, long.class, Object.class);
objectFieldOffset = unsafe.getClass().getMethod("objectFieldOffset", Field.class);
}
static Lvl level() throws Exception {
Lvl level = (Lvl) allocate.invoke(unsafe, Lvl.class);
Field registryAccess = net.minecraft.world.level.Level.class.getDeclaredField("registryAccess");
registryAccess.setAccessible(true);
long offset = (long) objectFieldOffset.invoke(unsafe, registryAccess);
putObject.invoke(unsafe, level, offset, ParityData.access);
return level;
}
static String menu(Lvl level, String id) throws Exception {
EntityType<?> type = BuiltInRegistries.ENTITY_TYPE.getValue(Identifier.parse(id));
if (type == null) {
return "error";
}
Entity entity = type.create(level, new EntitySpawnRequest(EntitySpawnReason.LOAD, true));
if (!(entity instanceof MenuProvider provider)) {
return "error";
}
Inventory inventory = new Inventory(null, null);
AbstractContainerMenu menu = provider.createMenu(0, inventory, new FakePlayer(level));
if (menu == null) {
return "error";
}
String name = entity.getCustomName() == null ? entity.getType().getDescriptionId() : "custom";
return BuiltInRegistries.MENU.getKey(menu.getType()) + " " + (menu.slots.size() - 36) + " " + name;
}
public static void main(String[] args) throws Exception {
ParityData.init();
reflect();
Lvl level = level();
try (var out = new PrintWriter(Files.newBufferedWriter(Path.of(args[1]), StandardCharsets.UTF_8));
var lines = Files.lines(Path.of(args[0]))) {
for (String line : (Iterable<String>) lines::iterator) {
try {
out.println(menu(level, line.strip()));
} catch (Exception e) {
System.err.println(line + ": " + e);
e.printStackTrace();
out.println("error");
}
}
}
}
}
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Compare the menu a container vehicle opens against vanilla 26.3.
The Java oracle (VanillaVehicleContainerOracle) creates every vehicle on the
shared stand-in server level and asks its MenuProvider for createMenu - the
menu a server sends in ClientboundOpenScreenPacket - and prints its MenuType,
the container's slot count and the title's translation key
(Entity.getDisplayName: the vehicle's custom name or its type's name). The Rust
example (vehicle_container_fixture) answers with
horizon_client::containers::vehicle_menu and vehicle_menu_name, the menu
`lan_open_vehicle_container` sends. Compared for every chest boat wood type,
the bamboo chest raft, the chest minecart and the hopper minecart.
What it does not cover: the interaction itself (the right-click/sneak/riding
paths - the live before/after runs cover those), the GUI drawing, and the
custom name a renamed vehicle would show (Horizon's vehicles keep no custom
name).
"""
import argparse
try:
from . import interaction_lab as lib
except ImportError:
import interaction_lab as lib
WOODS = ['oak', 'spruce', 'birch', 'jungle', 'acacia', 'dark_oak', 'mangrove',
'cherry', 'pale_oak', 'poplar']
CASES = (['minecraft:%s_chest_boat' % wood for wood in WOODS]
+ ['minecraft:bamboo_chest_raft',
'minecraft:chest_minecart',
'minecraft:hopper_minecart'])
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--limit', type=int, default=4)
args = parser.parse_args()
work = lib.work_dir('vehicle-container-parity')
vanilla = lib.Vanilla(work)
vanilla.compile('ParityData', 'VanillaVehicleContainerOracle')
(work / 'cases.txt').write_text(''.join(c + '\n' for c in CASES), encoding='utf-8')
vanilla.run('VanillaVehicleContainerOracle', work / 'cases.txt', work / 'native.txt')
lib.cargo_example('horizon-client', 'vehicle_container_fixture', work / 'cases.txt', work / 'rust.txt')
errors = sum(line == 'error' for line in (work / 'native.txt').read_text(encoding='utf-8').split('\n'))
if errors:
print(f'{errors} cases fail in vanilla')
total = lib.compare(CASES, work / 'native.txt', work / 'rust.txt', lambda case, line: case, limit=args.limit)
raise SystemExit(1 if total or errors else 0)
if __name__ == '__main__':
main()