mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 02:56:17 -10:00
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:
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(())
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user