Native mods: hide a player from lower-permission players (native_hide_player)

A Host helper for a vanish: the hub drops the player from the Tab lists of
players below the level, the entity tracker stops pairing them with those
players (as for a spectator), and their join and leave messages are skipped
for them. Kept by UUID for a player who is off. Helper-only fragment.

Closes #6996

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rdbtCVSandClaude Opus 5.5 committed 2026-11-02 09:48:20 +00:00
1 parent 26b9c0c9ba
commit eacdc5b519
11 files changed
+234 -27

No files matched your search

+1
View File
@@ -3266,6 +3266,7 @@ impl Game<'_> {
leash: None,
spectator: false,
watching: false,
hidden: None,
})
}
}
@@ -52,6 +52,7 @@ fn state(id: u32, kind: &str, position: [f64; 3], yaw: f32, on_ground: bool) ->
leash: None,
spectator: false,
watching: false,
hidden: None,
}
}
@@ -450,6 +450,9 @@ impl Host for super::Game<'_> {
) -> u32 {
self.native_move_items(dimension, from, from_face, to, to_face, item, max)
}
fn hide_player(&mut self, uuid: u128, level: Option<u8>) -> bool {
self.native_hide_player(uuid, level)
}
fn item_stack(&self, batch: &mut HudBatch, stack: &ItemStack, at: [f32; 2], size: f32) -> bool {
self.native_item_stack(batch, stack, at, size)
}
@@ -343,6 +343,26 @@ impl Game<'_> {
}
true
}
/// Hides that player (a vanish) from the players below permission
/// `level`, or shows them again (None): from the next tick those players
/// lose them from the Tab list and the world (their client stops
/// tracking the player, as it does a spectator), and don't read their
/// joining or leaving; players of `level` or more still see them, and
/// a player made an operator meanwhile sees them again. By UUID: kept
/// for a player who is off (who joins hidden), until a mod sets it again
/// or the server stops. Works anywhere on a server, inside a player's
/// hook too; false on a client and in a world nobody else can join.
/// API @API:hide_player@.
pub(super) fn native_hide_player(&mut self, uuid: u128, level: Option<u8>) -> bool {
let Some(host) = &mut self.lan else {
return false;
};
match level {
Some(level) => host.hub.hidden.insert(uuid, level),
None => host.hub.hidden.remove(&uuid),
};
true
}
/// Shows `nickname` in place of that player's name in chat (their
/// messages, `/me`, `/msg`) and in the Tab list; None shows their name
/// again. Hovering the shown name gives the real one, which stays in the
+57 -5
View File
@@ -80,6 +80,9 @@ pub struct EntityState {
/// `ServerPlayer.getCamera() != this`: a spectator watching through
/// another entity is shown to nobody (`ServerPlayer.broadcastToPlayer`).
pub watching: bool,
/// A player a native mod hid (`native_hide_player`): only clients whose
/// player has at least this permission level track it.
pub hidden: Option<u8>,
}
impl EntityState {
@@ -107,6 +110,7 @@ impl EntityState {
self.leash = None;
self.spectator = false;
self.watching = false;
self.hidden = None;
}
}
@@ -259,6 +263,8 @@ struct ServerEntity {
spectator: bool,
/// Likewise for a spectator who watches through a camera.
watching: bool,
/// Likewise for a player a native mod hid.
hidden: Option<u8>,
}
/// SectionPos.of(entity).
@@ -322,6 +328,7 @@ impl ServerEntity {
section: section_of(e.position),
spectator: e.spectator,
watching: e.watching,
hidden: e.hidden,
}
}
@@ -691,6 +698,9 @@ pub struct Viewer<'a> {
pub entity_id: i32,
/// Its player is in spectator mode: it tracks the other spectators too.
pub spectator: bool,
/// Its player's permission level: players a native mod hid from lower
/// levels are not tracked by it.
pub permission: u8,
/// It accepted a move since the last tick (ChunkMap.move): every pair
/// of it is checked again.
pub moved: bool,
@@ -717,17 +727,20 @@ struct Probe {
chunk: u32,
spectator: bool,
watching: bool,
hidden: Option<u8>,
}
/// `ServerPlayer.broadcastToPlayer` (`Entity`'s is `true`): to a spectator,
/// a player is shown only while its camera is itself; to anyone else, a
/// spectator is hidden.
fn broadcast_to_player(viewer_spectator: bool, p: &Probe) -> bool {
if viewer_spectator {
/// spectator is hidden. A player a native mod hid is shown only to players
/// of at least its level.
fn broadcast_to_player(v: &Viewer, p: &Probe) -> bool {
let shown = if v.spectator {
!p.watching
} else {
!p.spectator
}
};
shown && p.hidden.is_none_or(|level| v.permission >= level)
}
/// TrackedEntity.updatePlayer: within the type's range (capped by the view
@@ -740,7 +753,7 @@ fn broadcast_to_player(viewer_spectator: bool, p: &Probe) -> bool {
fn sees(v: &Viewer, p: &Probe, chunks: &[(i32, i32)], held: &mut [u8]) -> bool {
let range = f64::from(p.range.min(v.view_distance * 16));
let (dx, dz) = (v.position[0] - p.x, v.position[2] - p.z);
dx * dx + dz * dz <= range * range && broadcast_to_player(v.spectator, p) && {
dx * dx + dz * dz <= range * range && broadcast_to_player(v, p) && {
let held = &mut held[p.chunk as usize];
if *held == 0 {
*held = 1 + u8::from(v.chunks.contains(&chunks[p.chunk as usize]));
@@ -840,10 +853,12 @@ impl Entities {
|| section != s.section
|| e.spectator != s.spectator
|| e.watching != s.watching
|| e.hidden != s.hidden
{
s.section = section;
s.spectator = e.spectator;
s.watching = e.watching;
s.hidden = e.hidden;
self.moved.push(index);
self.is_moved[index] = true;
}
@@ -870,6 +885,7 @@ impl Entities {
chunk,
spectator: e.spectator,
watching: e.watching,
hidden: e.hidden,
});
}
// TrackedEntity.getEffectiveRange: a vehicle is tracked as far as its
@@ -1089,6 +1105,7 @@ mod tests {
leash: None,
spectator: false,
watching: false,
hidden: None,
}
}
@@ -1531,6 +1548,7 @@ mod tests {
positions: [[f64; 3]; 2],
moved: [bool; 2],
spectators: [bool; 2],
permissions: [u8; 2],
/// The first client's entities gone, where they were last sent.
gone: Vec<[f64; 3]>,
}
@@ -1544,6 +1562,7 @@ mod tests {
positions: [[0.0, 64.0, 0.0]; 2],
moved: [false; 2],
spectators: [false; 2],
permissions: [0; 2],
gone: Vec::new(),
}
}
@@ -1557,6 +1576,7 @@ mod tests {
Viewer {
entity_id: 1,
spectator: self.spectators[0],
permission: self.permissions[0],
moved: self.moved[0],
position: self.positions[0],
view_distance: 10,
@@ -1568,6 +1588,7 @@ mod tests {
Viewer {
entity_id: 2,
spectator: self.spectators[1],
permission: self.permissions[1],
moved: self.moved[1],
position: self.positions[1],
view_distance: 10,
@@ -1782,6 +1803,37 @@ mod tests {
assert!(c.trackers[0].is_tracking(2));
}
/// A player a native mod hid is tracked only by clients whose player has
/// at least its level, and by everyone again once shown.
#[test]
fn a_hidden_player_is_tracked_only_from_its_level_up() {
let mut level = Entities::default();
let mut c = Clients::new();
c.permissions = [0, 3];
let mut op = state(0.0);
(op.id, op.uuid, op.kind) = (9, 9, "minecraft:player".into());
op.hidden = Some(3);
c.tick(&mut level, &[&op]);
c.tick(&mut level, &[&op]);
assert!(
!c.trackers[0].is_tracking(9),
"a player of level 0 doesn't see it"
);
assert!(c.trackers[1].is_tracking(9), "an operator of level 3 does");
// Hidden after it was seen: the client below the level loses it.
op.hidden = None;
c.tick(&mut level, &[&op]);
assert!(c.trackers[0].is_tracking(9));
op.hidden = Some(4);
c.tick(&mut level, &[&op]);
assert!(!c.trackers[0].is_tracking(9) && !c.trackers[1].is_tracking(9));
// A client whose player was made an operator (its pairs checked again).
c.permissions = [4, 3];
c.moved = [true, false];
c.tick(&mut level, &[&op]);
assert!(c.trackers[0].is_tracking(9) && !c.trackers[1].is_tracking(9));
}
#[test]
fn pairing_and_unpairing_are_kept_for_the_game() {
let mut level = Entities::default();
+4
View File
@@ -184,6 +184,9 @@ pub struct Guest {
/// It accepted a move since the last entity range checks
/// (ServerChunkCache.move), which then check all of its pairs again.
pub moved: bool,
/// The players a native mod hid from it (`Hub::hidden`) that its player
/// list no longer has.
pub tab_hidden: crate::fast_hash::FxHashSet<u128>,
/// The client finished loading terrain (ServerboundPlayerLoadedPacket).
pub loaded: bool,
/// Since it joined or respawned, the client hasn't said it loaded the level
@@ -404,6 +407,7 @@ impl Guest {
replay: None,
changes: Default::default(),
moved: false,
tab_hidden: Default::default(),
loaded: false,
loading: true,
load_timeout: CLIENT_LOADED_TIMEOUT,
+124 -18
View File
@@ -234,6 +234,10 @@ pub struct Hub {
/// The entities players started and stopped tracking, collected by
/// `collect_tracking`, for the game to tell the mods.
pub native_tracking: Vec<TrackingBatch>,
/// Players a native mod hid (`native_hide_player`), by UUID, on or off:
/// only players of at least that permission level see them, in the
/// player list, the world and their joining and leaving.
pub hidden: crate::fast_hash::FxHashMap<u128, u8>,
pub lan: Lan,
pub config: HubConfig,
pub encoder: ChunkEncoder,
@@ -576,6 +580,7 @@ impl Hub {
mod_commands: (serde_json::Value::Null, None),
native_menu_events: Vec::new(),
native_tracking: Vec::new(),
hidden: Default::default(),
teams: Vec::new(),
nicknames: std::collections::HashMap::new(),
}
@@ -846,6 +851,72 @@ impl Hub {
}
}
/// Whether a player of `permission` sees the player `uuid`: not one a
/// native mod hid from players below a higher level.
pub fn sees(&self, permission: u8, uuid: u128) -> bool {
self.hidden
.get(&uuid)
.is_none_or(|&level| permission >= level)
}
/// The player lists after `hidden` or a permission changed: each placed
/// player loses the players hidden from it (ClientboundPlayerInfoRemove)
/// and gets back those it sees again (an ADD_PLAYER entry). A change
/// checks the client's entity pairs again, so the tracker follows too.
/// Nothing runs while nobody is hidden.
fn sync_hidden(&mut self) {
if self.hidden.is_empty() && self.guests.iter().all(|g| g.tab_hidden.is_empty()) {
return;
}
let candidates: Vec<usize> = (0..self.guests.len())
.filter(|&j| {
let u = self.guests[j].uuid;
self.guests[j].placed
&& (self.hidden.contains_key(&u)
|| self.guests.iter().any(|g| g.tab_hidden.contains(&u)))
})
.collect();
for i in 0..self.guests.len() {
if !self.guests[i].placed {
continue;
}
for &j in &candidates {
let (viewer, other) = (&self.guests[i], &self.guests[j]);
if i == j {
continue;
}
let hide = !self.sees(viewer.permission, other.uuid);
if hide == viewer.tab_hidden.contains(&other.uuid) {
continue;
}
let packet = if hide {
C::PlayerInfoRemove(play::PlayerInfoRemove {
uuids: vec![other.uuid],
})
} else {
C::PlayerInfoUpdate(play::PlayerInfoUpdate {
actions: Self::INFO_ACTIONS,
entries: vec![self.player_info(
other.uuid,
&other.name,
other.game_mode,
other.latency.load(Ordering::Relaxed),
)],
})
};
let uuid = other.uuid;
let viewer = &mut self.guests[i];
viewer.send(&packet);
if hide {
viewer.tab_hidden.insert(uuid);
} else {
viewer.tab_hidden.remove(&uuid);
}
viewer.moved = true;
}
}
}
fn player_info(&self, uuid: u128, name: &str, mode: i32, latency: i32) -> play::PlayerInfo {
let guest = self.guests.iter().find(|g| g.uuid == uuid);
play::PlayerInfo {
@@ -985,17 +1056,21 @@ impl Hub {
self.send_permission_level(i);
self.send_initial_recipe_book(i);
// PlayerList.placeNewPlayer broadcasts the join line before
// players.add: the others read it, the newcomer doesn't.
self.broadcast_except(
conn,
&C::SystemChat(play::SystemChat {
content: yellow_names(
"multiplayer.player.joined",
vec![display_name(self.guests[i].uuid, &name)],
),
overlay: false,
}),
);
// players.add: the others read it, the newcomer doesn't (nor those
// a native mod hides it from).
let joined = C::SystemChat(play::SystemChat {
content: yellow_names(
"multiplayer.player.joined",
vec![display_name(self.guests[i].uuid, &name)],
),
overlay: false,
})
.encode();
for g in self.guests.iter().filter(|g| g.placed && g.conn != conn) {
if self.sees(g.permission, uuid) {
g.sender.send(joined.clone());
}
}
self.guests[i].placed = true;
self.resend_teleport(i, None);
let g = &self.guests[i];
@@ -1008,7 +1083,15 @@ impl Hub {
if let Some(host) = ctx.host {
entries.push(self.player_info(host.uuid, &host.name, host.game_mode, 0));
}
// Players a native mod hid from the newcomer stay out of its list,
// and a hidden newcomer out of theirs.
let permission = self.guests[i].permission;
let mut hidden = Vec::new();
for o in self.guests.iter().filter(|o| o.conn != conn && o.placed) {
if !self.sees(permission, o.uuid) {
hidden.push(o.uuid);
continue;
}
entries.push(self.player_info(
o.uuid,
&o.name,
@@ -1016,15 +1099,24 @@ impl Hub {
o.latency.load(Ordering::Relaxed),
));
}
self.guests[i].tab_hidden.extend(hidden);
self.guests[i].send(&C::PlayerInfoUpdate(play::PlayerInfoUpdate {
actions: Self::INFO_ACTIONS,
entries,
}));
let newcomer = play::PlayerInfoUpdate {
let newcomer = C::PlayerInfoUpdate(play::PlayerInfoUpdate {
actions: Self::INFO_ACTIONS,
entries: vec![self.player_info(uuid, &name, mode, 0)],
};
self.broadcast(&C::PlayerInfoUpdate(newcomer));
})
.encode();
let level = self.hidden.get(&uuid).copied();
for g in self.guests.iter_mut().filter(|g| g.placed) {
if level.is_some_and(|l| g.permission < l) {
g.tab_hidden.insert(uuid);
} else {
g.sender.send(newcomer.clone());
}
}
self.send_level_info(i, ctx, true);
// ServerLevel.addNewPlayer: the chunk tracking starts at the player.
let g = &mut self.guests[i];
@@ -1600,6 +1692,7 @@ impl Hub {
}
}
self.sync_containers(ctx.world);
self.sync_hidden();
self.track_entities(ctx);
for i in 0..self.guests.len() {
if self.here(&self.guests[i]) {
@@ -1674,10 +1767,20 @@ impl Hub {
self.broadcast(&C::RemoveEntities(play::RemoveEntities {
ids: vec![g.entity_id],
}));
self.system_message(yellow_names(
"multiplayer.player.left",
vec![display_name(g.uuid, &g.name)],
));
let left = C::SystemChat(play::SystemChat {
content: yellow_names(
"multiplayer.player.left",
vec![display_name(g.uuid, &g.name)],
),
overlay: false,
})
.encode();
for o in self.guests.iter_mut().filter(|o| o.placed) {
if o.tab_hidden.remove(&g.uuid) {
continue;
}
o.sender.send(left.clone());
}
g
}
@@ -2329,6 +2432,7 @@ impl Hub {
leash: None,
spectator: false,
watching: false,
hidden: None,
}
}
@@ -2424,6 +2528,7 @@ impl Hub {
);
state.spectator = g.spectator();
state.watching = g.watching;
state.hidden = self.hidden.get(&g.uuid).copied();
stuck_meta(&mut state, g.stuck);
shoulder_meta(&mut state, g.shoulders);
state
@@ -2452,6 +2557,7 @@ impl Hub {
.map(|(g, (out, gone))| Viewer {
entity_id: g.entity_id,
spectator: g.spectator(),
permission: g.permission,
moved: std::mem::take(&mut g.moved),
position: g.position,
view_distance: g.view_distance,
+3 -4
View File
@@ -2013,10 +2013,9 @@ pub trait Hooks: Send + Sync {
/// does not happen: no damage, no knockback, no sound, no block reaction, the
/// arrow does not stick, a pearl does not teleport, a potion or a fireball
/// does not burst. The projectile flies on and is not discarded, so a mod
/// that cancels also decides what becomes of it: it hits again the next tick
/// unless the mod moves, turns or removes it. An arrow stays where it would
/// have hit, and the other projectiles go on through the whole of their
/// motion. Covers what `tick_projectiles` flies: arrows, tridents, snowballs,
/// that cancels also decides what becomes of it. It continues through its
/// whole motion and may hit something on a later tick. Covers what
/// `tick_projectiles` flies: arrows, tridents, snowballs,
/// eggs, pearls, potions, fireballs, wither skulls, wind charges, llama spit and
/// shulker bullets, from players, mobs and dispensers. It does not fire for
/// a breeze's deflection, for a hit on a wind charge, for fireworks or for
+11
View File
@@ -664,6 +664,17 @@ pub trait Host {
item: Option<&str>,
max: u32,
) -> u32;
/// Hides that player (a vanish) from the players below permission
/// `level`, or shows them again (None): from the next tick those players
/// lose them from the Tab list and the world (their client stops
/// tracking the player, as it does a spectator), and don't read their
/// joining or leaving; players of `level` or more still see them, and
/// a player made an operator meanwhile sees them again. By UUID: kept
/// for a player who is off (who joins hidden), until a mod sets it again
/// or the server stops. Works anywhere on a server, inside a player's
/// hook too; false on a client and in a world nobody else can join.
/// API @API:hide_player@.
fn hide_player(&mut self, uuid: u128, level: Option<u8>) -> bool;
/// Draw a stack and its slot decorations, except the count, at `at`,
/// `size` GUI pixels square. False for an unknown item. API @API:item_stack@.
fn item_stack(&self, batch: &mut HudBatch, stack: &ItemStack, at: [f32; 2], size: f32) -> bool;
+1
View File
@@ -1042,6 +1042,7 @@ fn guests_see_each_other_and_the_world_entities() {
leash: None,
spectator: false,
watching: false,
hidden: None,
};
let first = thread::spawn(move || {
let mut bot = connect(port, "Alex", false)?;
@@ -0,0 +1,9 @@
{
"hooks": [],
"helpers": [
{"name": "hide_player", "target": "horizon_client::game::native_server::Game<'_>::native_hide_player"}
],
"rust_summary": "hiding a player from the players below a permission level (`native_hide_player`).",
"doc_summary": "hiding a player (a vanish): `native_hide_player(uuid, level)` hides that player, from the next tick, from the players below permission `level` (None shows them again): they leave those players' Tab list and world (their clients stop tracking them, as they do a spectator) and those players don't read them joining or leaving. Players of `level` or more still see them; a player made an operator sees them again. It is kept by UUID, for a player who is off too, until a mod sets it again or the server stops. Server only; false on a client. Bundles need \"hooks\": @API:hide_player@.",
"comments": ["crates/horizon-client/src/game/native_server.rs"]
}