mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
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:
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
@@ -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`.
|
||||
|
||||
Reference in new issue
Block a user