Native mods: a fake player's use closes what it opened

A machine's fake player restored its swapped fields but never ran the close
side of the use. A trader stayed in its session, a vehicle kept its menu's
stand-in, and a shulker box's opener count stayed up. It now runs the real
close paths while the fake inventory is in place, and swaps station,
craft_table, ender_open, hook, experience and the respawn point too.

Closing keeps the use as a player's: the click counts, and the same events
and sounds fire. Refusing to open would need a gate at every open site.

Fixes #9409

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JLHp7yEJwPTJ2kw29LWdk1
This commit is contained in:
martipopsandClaude Opus 5.5 committed 2026-11-04 13:51:58 -05:00
1 parent 2599f46c1f
commit c269066ca3
2 files changed
+278 -6

No files matched your search

@@ -5,6 +5,7 @@
use super::{Game, GuestUse, Hit, InteractTarget, Screen};
use horizon_client::{
command::args::GameMode,
containers::BlockEntity,
first_person::Hand,
items::ItemStack,
mod_api::{NativeFakeUse, NativeUseTarget},
@@ -88,11 +89,14 @@ impl Game<'_> {
/// looks at); on an entity (`Entity`) `Player.interactOn` (a sheep
/// sheared, a cow milked). With `sneaking` it is a secondary use, as a
/// player holding sneak's. The fake player is in survival, holds `stack`
/// in its main hand and nothing else, and has no connection: no screen
/// it opens stays open, and its stats and advancements count for no
/// one. The answer is what it holds and carries afterwards. None, with
/// nothing used, off a server and while a player's action is being run
/// (from inside a player's hook: run it from a block entity's tick).
/// in its main hand and nothing else, and has no connection: what it
/// opens closes again through the real close paths before this returns
/// (a lid opens and shuts, a trader is free again), and its stats and
/// advancements count for no one. The click still counts as used, as a
/// player's would. The answer is what it holds and carries afterwards.
/// None, with nothing used, off a server and while a player's action is
/// being run (from inside a player's hook: run it from a block entity's
/// tick).
/// API @API:fake_player@.
pub(super) fn native_use_as_player(
&mut self,
@@ -172,10 +176,27 @@ impl Game<'_> {
let tick_hit = self.tick_hit.take();
let used_as = self.guest_use.replace(guest_use);
let failed = std::mem::replace(&mut self.use_failed, false);
let station = self.station.take();
let craft_table = self.craft_table.take();
let experience = std::mem::take(&mut self.experience);
let hook = self.hook.take();
let respawn = (
self.respawn_config.take(),
self.spawn.take(),
self.level.spawn,
);
// An ender inventory left open goes back while the fake player acts,
// and its stand-in returns afterwards, as a guest's turn does.
let ender_open = self.ender_open;
self.close_ender_chest();
self.block_reach = self.interaction_range("minecraft:block_interaction_range", 4.5, 5.0);
self.entity_reach = self.interaction_range("minecraft:entity_interaction_range", 3.0, 5.0);
let consumed = matches!(self.use_item(), Ok(Some(_))) && !self.use_failed;
// The close side of what the use opened, with the fake inventory still
// in place: what a menu holds comes back to the fake player.
self.close_container();
self.close_book();
let mut left = std::mem::replace(&mut self.inventory, inventory);
let held = left
@@ -218,6 +239,18 @@ impl Game<'_> {
self.tick_hit = tick_hit;
self.guest_use = used_as;
self.use_failed = failed;
self.station = station;
self.craft_table = craft_table;
self.experience = experience;
// A hook this use cast has not ticked and no client has seen it.
self.hook = hook;
(self.respawn_config, self.spawn, self.level.spawn) = respawn;
if let Some(key) = ender_open {
self.world
.block_entities
.insert(key, BlockEntity::Chest(self.ender_items.clone()));
self.ender_open = Some(key);
}
Some(NativeFakeUse {
consumed,
held,
@@ -225,3 +258,240 @@ impl Game<'_> {
})
}
}
#[cfg(test)]
mod tests {
use super::super::{
Audio, Options, Progress, Settings,
boats::Boat,
load_resources,
minecarts::{CART_KEY_Y, CartKind, Minecart},
respawn_anchor::RespawnConfig,
stations::{Kind, Station},
};
use super::*;
use horizon_client::chunk::Chunk;
use horizon_core::geometry::Direction;
use std::sync::Arc;
/// A server's game on the prepared resources: nobody is playing, and the
/// chunk at the origin is loaded.
fn server_game() -> Game<'static> {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES")
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
);
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 res: &'static _ = Box::leak(Box::new(loaded.resources));
let mut game = Game::new(
res,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.options.dedicated = Some(Vec::new());
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game
}
fn fake_use(game: &mut Game<'_>, target: NativeUseTarget, sneaking: bool) -> NativeFakeUse {
game.native_use_as_player(
ItemStack::new("minecraft:stick", 1),
[8.5, 66.0, 8.5],
0.0,
0.0,
target,
sneaking,
)
.expect("a server with nobody acting")
}
fn on_entity(id: u32) -> NativeUseTarget {
NativeUseTarget::Entity {
id: id as i32,
location: [0.0; 3],
}
}
/// Clicks the top of the block at `pos`.
fn click(game: &mut Game<'_>, pos: [i32; 3]) -> NativeFakeUse {
let point = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 1.0,
f64::from(pos[2]) + 0.5,
];
let target = NativeUseTarget::Block {
pos,
face: Direction::Up,
point,
};
fake_use(game, target, false)
}
/// Puts `block` at `pos` and clicks it.
fn click_new(game: &mut Game<'_>, block: &str, pos: [i32; 3]) -> NativeFakeUse {
let state = game
.pack
.assets
.registry
.default_state(block)
.expect("a vanilla block");
game.world.set(pos[0], pos[1], pos[2], state).unwrap();
click(game, pos)
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_trader_a_fake_player_used_trades_with_the_next_one() {
let mut game = server_game();
let id = game
.spawn_mob("minecraft:wandering_trader", [8.5, 65.0, 8.5], true)
.expect("a wandering trader");
assert!(fake_use(&mut game, on_entity(id), false).consumed);
let trader = game.mobs.iter().find(|m| m.id == id).unwrap();
// With no offers the menu never opens, and this test would say nothing.
let offers = trader.extra.trade.as_ref().map_or(0, |t| t.offers.len());
assert!(offers > 0, "a wandering trader has offers");
assert!(!trader.extra.flag2, "the trader is not left trading");
assert!(game.station.is_none(), "the session is not left open");
assert!(
fake_use(&mut game, on_entity(id), false).consumed,
"the next use opens it again",
);
}
/// A chest minecart, a hopper minecart and a chest boat: the items stay on
/// the vehicle, and the menu's stand-in is out of the level, where a
/// hopper transfer would edit it instead of the vehicle.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_vehicles_chest_a_fake_player_used_keeps_its_items_and_no_stand_in() {
let mut game = server_game();
let at = Vec3::new(8.5, 65.0, 8.5);
let diamonds = Some(ItemStack::new("minecraft:diamond", 5));
let mut vehicles = Vec::new();
for kind in [CartKind::Chest, CartKind::Hopper] {
game.next_entity += 1;
let mut cart = Minecart::new(game.next_entity, kind, at);
cart.items[3] = diamonds.clone();
vehicles.push((game.next_entity, false));
game.carts.push(cart);
}
game.next_entity += 1;
let mut boat = Boat::new(game.next_entity, "oak".into(), at, 0.0).with_chest();
boat.items[3] = diamonds.clone();
// ChestBoat.interact opens the chest for a sneaking player.
vehicles.push((game.next_entity, true));
game.boats.push(boat);
for (id, sneaking) in vehicles {
// Twice: the second use is the next one finding it as it was.
for _ in 0..2 {
assert!(fake_use(&mut game, on_entity(id), sneaking).consumed);
let key = (i32::MAX - 15, CART_KEY_Y, id as i32);
assert!(
game.world.block_entities.get(&key).is_none(),
"vehicle {id} left a stand-in",
);
assert!(game.container.is_none());
let items = game
.carts
.iter()
.find(|c| c.id == id)
.map(|c| &c.items)
.or_else(|| game.boats.iter().find(|b| b.id == id).map(|b| &b.items))
.unwrap();
assert_eq!(items[3], diamonds, "vehicle {id} lost its items");
}
}
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_shulker_box_and_an_ender_chest_a_fake_player_used_are_left_shut() {
let mut game = server_game();
let shulker = [8, 64, 8];
let count = |game: &Game<'_>| game.be_anims.shulkers.get(&shulker).map(|s| s.open_count);
assert!(click_new(&mut game, "minecraft:shulker_box", shulker).consumed);
assert_eq!(count(&game), Some(0), "the use opened it, and it shut");
assert!(game.container.is_none());
assert!(click(&mut game, shulker).consumed, "the next use opens it");
assert_eq!(count(&game), Some(0), "the count doesn't climb");
let ender = [10, 64, 8];
let key = (ender[0], ender[1], ender[2]);
assert!(click_new(&mut game, "minecraft:ender_chest", ender).consumed);
assert!(game.ender_open.is_none());
assert!(
game.world.block_entities.get(&key).is_none(),
"a stand-in chest stayed",
);
assert!(!game.lid_openers.contains_key(&key), "its lid stayed open");
}
/// Each field holds a value the use can't have written, so a swap that
/// forgets one fails whether the use changed it or not.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn the_servers_own_player_state_comes_back_after_a_fake_use() {
let mut game = server_game();
let config = RespawnConfig {
dim: game.dim,
pos: [1, 2, 3],
yaw: 0.0,
forced: false,
};
let spawn = Vec3::new(1.0, 2.0, 3.0);
game.station = Some(Station::new_kind(Kind::Anvil, [4, 5, 6]));
game.craft_table = Some([7, 8, 9]);
game.experience.level = 7;
game.respawn_config = Some(config);
game.spawn = Some(spawn);
let stand_in = (12, 64, 8);
game.open_ender_chest(stand_in);
game.container = None;
assert!(click_new(&mut game, "minecraft:crafting_table", [8, 64, 8]).consumed);
assert_eq!(
game.station.as_ref().map(|s| (s.kind, s.pos)),
Some((Kind::Anvil, [4, 5, 6])),
);
assert_eq!(game.craft_table, Some([7, 8, 9]));
assert_eq!(game.experience.level, 7);
assert_eq!(game.respawn_config, Some(config));
assert_eq!(game.spawn, Some(spawn));
assert_eq!(game.ender_open, Some(stand_in));
assert!(game.world.block_entities.get(&stand_in).is_some());
let rod = game
.native_use_as_player(
ItemStack::new("minecraft:fishing_rod", 1),
[8.5, 66.0, 8.5],
0.0,
0.0,
NativeUseTarget::Item,
false,
)
.expect("a server with nobody acting");
assert!(rod.consumed, "the rod casts");
assert!(game.hook.is_none(), "no hook is left for nobody to reel in");
}
}
+3 -1
View File
@@ -2843,7 +2843,9 @@ These are what Create's drill, saw, harvester and deployer need.
the players within 64 blocks. Pass a negative id of your own, the same each time; any other stage removes the crack.
- `native_use_as_player(stack, eye, yaw, pitch, target, sneaking)` is NeoForge's `FakePlayer`. It uses `stack` on a
block (`useItemOn`), in the air (`useItem`) or on an entity (`interactOn`). The fake player is in survival and holds
nothing else. It has no connection: nothing it opens stays open, and its stats and advancements count for no one.
nothing else. It has no connection, so what it opens closes again before the call returns, through the same paths a
player's close takes: a lid opens and shuts, a trader is free for the next player, and the click still counts as used.
Its stats and advancements count for no one.
The answer (`NativeFakeUse`) says whether the use did something, and what the fake player holds and carries
afterwards. Call it from a block entity's tick, not from inside a player's hook: while a player acts, it answers
`None`.