Spectating players are tracked only by other spectators

ChunkMap.TrackedEntity.updatePlayer pairs an entity with a player only
when Entity.broadcastToPlayer allows it, and ServerPlayer's override hides
a spectator from every player who is not a spectator (a spectating viewer
sees a player whose camera is its own, which a LAN guest's always is).
The hub's tracker had no such check, so a guest in spectator mode stayed
paired with everyone: its AddEntity, equipment and nametag still reached
the other players, and their crosshair still picked it.

Player states now carry whether the player is a spectator, viewers too,
and the pair check follows broadcastToPlayer. Going in or out of spectator
mode checks the player's pairs again (vanilla's ChunkMap.move does it at
the player's next move), so the others lose it, or get it back with its
armour, at once.

Part of #3520

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
d3grandoandClaude Opus 5.5 committed 2026-10-30 13:08:40 +07:00
1 parent b51637616b
commit e445e0edf6
5 files changed
+87 -8

No files matched your search

+1
View File
@@ -2887,6 +2887,7 @@ impl Game<'_> {
precise: false,
needs_sync: false,
leash: None,
spectator: false,
})
}
}
@@ -31,6 +31,7 @@ fn state(id: u32, kind: &str, position: [f64; 3], yaw: f32, on_ground: bool) ->
precise: false,
needs_sync: false,
leash: None,
spectator: false,
}
}
+74 -4
View File
@@ -67,6 +67,9 @@ pub struct EntityState {
pub needs_sync: bool,
/// `Leashable.getLeashHolder`: the entity holding its lead.
pub leash: Option<i32>,
/// `ServerPlayer.isSpectator`: a player in spectator mode, whom only the
/// clients of other spectators track (`ServerPlayer.broadcastToPlayer`).
pub spectator: bool,
}
impl EntityState {
@@ -90,6 +93,7 @@ impl EntityState {
self.precise = false;
self.needs_sync = false;
self.leash = None;
self.spectator = false;
}
}
@@ -235,6 +239,9 @@ struct ServerEntity {
/// TrackedEntity.lastSectionPos: its pairs are checked again when it
/// moves to another section.
section: [i32; 3],
/// Whether it was a spectator last tick: its pairs are checked again when
/// that changes, as `ServerPlayer.broadcastToPlayer` answers otherwise.
spectator: bool,
}
/// SectionPos.of(entity).
@@ -275,6 +282,7 @@ impl ServerEntity {
index: 0,
added: true,
section: section_of(e.position),
spectator: e.spectator,
}
}
@@ -602,6 +610,8 @@ impl Tracker {
/// view distance and chunks.
pub struct Viewer<'a> {
pub entity_id: i32,
/// Its player is in spectator mode: it tracks the other spectators too.
pub spectator: bool,
/// It accepted a move since the last tick (ChunkMap.move): every pair
/// of it is checked again.
pub moved: bool,
@@ -624,16 +634,20 @@ struct Probe {
id: i32,
range: i32,
chunk: u32,
spectator: bool,
}
/// TrackedEntity.updatePlayer: within the type's range (capped by the view
/// distance) horizontally, in a chunk the client has. `held` remembers for
/// distance) horizontally, shown to the client (`Entity.broadcastToPlayer`:
/// `ServerPlayer`'s hides a spectator from players who are not spectators;
/// a guest's camera is always its own player, so a spectating client sees
/// every player) and in a chunk the client has. `held` remembers for
/// each of the tick's chunks whether the client has it (0: not looked up
/// yet, 1: no, 2: yes), so each chunk is looked up once per client.
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 && {
dx * dx + dz * dz <= range * range && (v.spectator || !p.spectator) && {
let held = &mut held[p.chunk as usize];
if *held == 0 {
*held = 1 + u8::from(v.chunks.contains(&chunks[p.chunk as usize]));
@@ -718,11 +732,14 @@ impl Entities {
});
s.seen = now;
s.index = index;
// ChunkMap.addEntity, and ChunkMap.tick's section change:
// ChunkMap.addEntity, ChunkMap.tick's section change, and a
// player going in or out of spectator mode (which ChunkMap.move
// catches at the player's next move, a second later at most):
// updatePlayers.
let section = section_of(e.position);
if added || section != s.section {
if added || section != s.section || e.spectator != s.spectator {
s.section = section;
s.spectator = e.spectator;
self.moved.push(index);
self.is_moved[index] = true;
}
@@ -743,6 +760,7 @@ impl Entities {
.range_percent
.map_or(s.range * 16, |p| p * (s.range * 16) / 100),
chunk,
spectator: e.spectator,
});
}
// TrackedEntity.getEffectiveRange: a vehicle is tracked as far as its
@@ -921,6 +939,7 @@ mod tests {
precise: false,
needs_sync: false,
leash: None,
spectator: false,
}
}
@@ -1264,6 +1283,7 @@ mod tests {
out: [Vec<Vec<u8>>; 2],
positions: [[f64; 3]; 2],
moved: [bool; 2],
spectators: [bool; 2],
}
impl Clients {
@@ -1274,6 +1294,7 @@ mod tests {
out: Default::default(),
positions: [[0.0, 64.0, 0.0]; 2],
moved: [false; 2],
spectators: [false; 2],
}
}
fn tick(&mut self, level: &mut Entities, entities: &[&EntityState]) {
@@ -1285,6 +1306,7 @@ mod tests {
let mut viewers = [
Viewer {
entity_id: 1,
spectator: self.spectators[0],
moved: self.moved[0],
position: self.positions[0],
view_distance: 10,
@@ -1294,6 +1316,7 @@ mod tests {
},
Viewer {
entity_id: 2,
spectator: self.spectators[1],
moved: self.moved[1],
position: self.positions[1],
view_distance: 10,
@@ -1431,6 +1454,53 @@ mod tests {
}
}
/// ServerPlayer.broadcastToPlayer: a spectator is shown only to other
/// spectators, who see everyone. Going in or out of spectator mode checks
/// its pairs again, though nobody moved.
#[test]
fn spectators_are_tracked_only_by_spectators() {
let mut level = Entities::default();
let mut c = Clients::new();
let player = |id: i32| {
let mut p = state(0.0);
(p.id, p.uuid, p.kind) = (id, id as u128, "minecraft:player".into());
p
};
let (mut one, mut two) = (player(1), player(2));
// A third player, already spectating, that neither client plays.
let mut three = player(3);
three.spectator = true;
c.tick(&mut level, &[&one, &two, &three]);
c.tick(&mut level, &[&one, &two, &three]);
assert!(c.trackers[0].is_tracking(2) && c.trackers[1].is_tracking(1));
assert!(!c.trackers[0].is_tracking(3) && !c.trackers[1].is_tracking(3));
// The second player starts spectating: the first client loses it,
// and it sees the first player and the other spectator.
(two.spectator, c.spectators[1]) = (true, true);
c.tick(&mut level, &[&one, &two, &three]);
assert!(!c.trackers[0].is_tracking(2));
let removed = body(play::Clientbound::RemoveEntities(play::RemoveEntities {
ids: vec![2],
}));
assert_eq!(c.out[0].first(), Some(&removed));
assert!(c.trackers[1].is_tracking(1) && c.trackers[1].is_tracking(3));
// Its armour changes go to nobody who doesn't track it.
two.equipment.push((5, Some(Stack::new(5, 1))));
c.tick(&mut level, &[&one, &two, &three]);
assert!(c.out[0].is_empty());
// The first player starts spectating too: both spectators show up.
(one.spectator, c.spectators[0]) = (true, true);
c.tick(&mut level, &[&one, &two, &three]);
assert!(c.trackers[0].is_tracking(2) && c.trackers[0].is_tracking(3));
// Both back in survival: they still see each other, and lose the
// third player.
(one.spectator, c.spectators[0]) = (false, false);
(two.spectator, c.spectators[1]) = (false, false);
c.tick(&mut level, &[&one, &two, &three]);
assert!(c.trackers[0].is_tracking(2) && c.trackers[1].is_tracking(1));
assert!(!c.trackers[0].is_tracking(3) && !c.trackers[1].is_tracking(3));
}
#[test]
fn a_forgotten_entity_is_paired_again_at_the_next_tick() {
// A respawned player: the hub removes it from the other clients and
+10 -4
View File
@@ -1749,6 +1749,7 @@ impl Hub {
precise: false,
needs_sync: false,
leash: None,
spectator: false,
}
}
@@ -1785,7 +1786,7 @@ impl Hub {
let e = &h.equipment;
self.equipment_of(&e[0], &e[1], [&e[5], &e[4], &e[3], &e[2]])
};
all.push(self.player_state(
let mut state = self.player_state(
h.entity_id,
h.uuid,
h.position,
@@ -1800,7 +1801,9 @@ impl Hub {
equipment,
h.attributes.clone(),
&[],
));
);
state.spectator = h.game_mode == 3;
all.push(state);
}
let placed: Vec<usize> = (0..self.guests.len())
.filter(|&i| self.here(&self.guests[i]))
@@ -1819,7 +1822,7 @@ impl Hub {
let equipment =
self.equipment_of(&main, &off, [&armor[0], &armor[1], &armor[2], &armor[3]]);
let g = &self.guests[i];
self.player_state(
let mut state = self.player_state(
g.entity_id,
g.uuid,
g.position,
@@ -1837,7 +1840,9 @@ impl Hub {
.map(|m| super::attributes::syncable(&m.attributes))
.unwrap_or_default(),
&g.effects.list,
)
);
state.spectator = g.spectator();
state
})
.collect();
all.extend(guest_states);
@@ -1858,6 +1863,7 @@ impl Hub {
.zip(outs.iter_mut())
.map(|(g, out)| Viewer {
entity_id: g.entity_id,
spectator: g.spectator(),
moved: std::mem::take(&mut g.moved),
position: g.position,
view_distance: g.view_distance,
+1
View File
@@ -721,6 +721,7 @@ fn guests_see_each_other_and_the_world_entities() {
precise: false,
needs_sync: false,
leash: None,
spectator: false,
};
let first = thread::spawn(move || {
let mut bot = connect(port, "Alex", false)?;