feat(stealth): reveal nearby players to eligible Eye wearers
Release / release (push) Successful in 4m46s
CI / build (push) Successful in 2m31s

Use private client teams and current recipient-specific metadata authorization without changing server teams, potion effects or other identity channels. Preserve effective team visibility and platform hiding. Add immediate lifecycle invalidation, fail-closed capture and cleanup-before-recreation recovery, verified with native codecs, delivery, ProtocolLib and plugin lifecycle tests. No deployment is included.
This commit is contained in:
dmg
2026-09-12 20:05:37 -04:00
parent 67942ced7c
commit 0499a31f71
18 changed files with 1637 additions and 3 deletions
+4 -2
View File
@@ -16,7 +16,9 @@ Earn a separate, personal unlock through eight cumulative online hours of Night
After the unlock is saved, craft an **Eye of True Seeing** with eight Netherite Blocks around an Eye of Ender. Right-click an authenticated Eye in either hand to move it into an empty helmet slot. Existing helmets are not replaced. Traded Eyes still require the holder's own saved unlock; renamed ordinary Eyes do not qualify. Automated crafters cannot bypass progression. Vanilla handles crafting ingredient consumption. After the unlock is saved, craft an **Eye of True Seeing** with eight Netherite Blocks around an Eye of Ender. Right-click an authenticated Eye in either hand to move it into an empty helmet slot. Existing helmets are not replaced. Traded Eyes still require the holder's own saved unlock; renamed ordinary Eyes do not qualify. Automated crafters cannot bypass progression. Vanilla handles crafting ingredient consumption.
**Local name/body revelation is separate US-008 work and is not implemented by this acquisition story.** Eligibility is checked even for an Eye manually placed in the helmet slot. While worn by an eligible player, the Eye reveals active Stealth targets' real overhead names within **16 blocks in three dimensions and line of sight**. It also reveals their bodies if they have gameplay invisibility, only to that observer. Ordinary invisible players without a concealed Stealth session are not revealed. Platform hiding, spectator mode, vanished metadata and effective team name-visibility restrictions take precedence. Eligibility is checked even for an Eye manually placed in the helmet slot.
Revelation uses private client-only teams and recipient-specific metadata; it does not change server teams, remove potion effects or end Stealth sessions. Tab-list, chat, suggestions and server-list concealment remain unchanged, including for the wearer. Unequipping, leaving range/sight, lifecycle changes and disable withdraw the projection and restore current raw state. Failed deliveries revoke authorization and queue cleanup before recreation. Live-client appearance/timing and compatibility remain separate validation follow-ups.
Eye records are independently UUID-keyed in `plugins/SpigotStealth/state.yml` (schema 2). Legacy Stealth records and extension fields are preserved. Crafting/use wait for successful save acknowledgement. Shutdown queues a final snapshot without blocking the tick thread, and initialization callbacks cannot revive a disabled or superseded lifecycle. Unacknowledged progress at abrupt process/power loss is not guaranteed. Eye records are independently UUID-keyed in `plugins/SpigotStealth/state.yml` (schema 2). Legacy Stealth records and extension fields are preserved. Crafting/use wait for successful save acknowledgement. Shutdown queues a final snapshot without blocking the tick thread, and initialization callbacks cannot revive a disabled or superseded lifecycle. Unacknowledged progress at abrupt process/power loss is not guaranteed.
@@ -28,6 +30,6 @@ The repository and checkout are now `purpur-stealth` (previously remote `spigot-
Replace the old plugin JAR when installing the new distribution; never load both JARs together. Old tags and `spigot-stealth-*` release assets are preserved. Repository rename redirects have been checked against the previously deployed v1.5.0 download. Publishing a release does not authorize deployment. Replace the old plugin JAR when installing the new distribution; never load both JARs together. Old tags and `spigot-stealth-*` release assets are preserved. Repository rename redirects have been checked against the previously deployed v1.5.0 download. Publishing a release does not authorize deployment.
The v2.0.0 migration itself left the Eye, combat reveal and concealed-chat stories pending. Subsequent Eye acquisition work is described above; Eye revelation, combat reveal and system-chat routing remain separate unfinished stories. Runtime plugin metadata is unchanged. The v2.0.0 migration itself left the Eye, combat reveal and concealed-chat stories pending. Subsequent Eye acquisition and local revelation work is described above; combat-session breaking and system-chat routing remain separate unfinished stories. Runtime plugin metadata is unchanged.
See the [canonical project](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-stealth/index.md), [stories](https://git.garvis.dev/dmg/somc-okf/src/branch/main/user-stories/purpur-stealth/index.md), and [development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md). See the [canonical project](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-stealth/index.md), [stories](https://git.garvis.dev/dmg/somc-okf/src/branch/main/user-stories/purpur-stealth/index.md), and [development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md).
@@ -0,0 +1,66 @@
package games.dmg.spigotstealth;
import com.comphenix.protocol.PacketType;
import com.comphenix.protocol.events.ListenerPriority;
import com.comphenix.protocol.events.PacketAdapter;
import com.comphenix.protocol.events.PacketContainer;
import com.comphenix.protocol.events.PacketEvent;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.function.Supplier;
import org.bukkit.plugin.Plugin;
/** Outgoing packet projection reads immutable authorization and never queries world or inventory state. */
public final class EyePacketListener extends PacketAdapter {
private final Supplier<Map<UUID, Map<Integer, EyeRevealController.Projection>>> views;
private final Consumer<UUID> refresh;
private final Consumer<String> warning;
private final Set<PacketType> warned = ConcurrentHashMap.newKeySet();
public EyePacketListener(Plugin plugin, Supplier<Map<UUID, Map<Integer, EyeRevealController.Projection>>> views,
Consumer<UUID> refresh, Consumer<String> warning) {
super(plugin, ListenerPriority.HIGHEST, PacketType.Play.Server.ENTITY_METADATA, PacketType.Play.Server.SCOREBOARD_TEAM);
this.views = Objects.requireNonNull(views);
this.refresh = Objects.requireNonNull(refresh);
this.warning = Objects.requireNonNull(warning);
}
@Override public void onPacketSending(PacketEvent event) {
if (event.isCancelled() || event.getPlayer() == null || event.isPlayerTemporary()) { return; }
try {
// UUID is immutable connection identity; no inventory, world, visibility or effect queries occur here.
UUID viewer = event.getPlayer().getUniqueId();
if (viewer == null) { return; }
var current = views.get().getOrDefault(viewer, Map.of());
if (current.isEmpty()) { return; }
Object original = event.getPacket().getHandle();
if (event.getPacketType().equals(PacketType.Play.Server.ENTITY_METADATA)) {
int id = (Integer) original.getClass().getMethod("id").invoke(original);
var projection = current.get(id);
if (projection != null && projection.revealBody()) {
event.setPacket(new PacketContainer(event.getPacketType(), EyePacketProjection.metadataPacket(original)));
}
} else if (event.getPacketType().equals(PacketType.Play.Server.SCOREBOARD_TEAM)) {
String name = (String) original.getClass().getMethod("getName").invoke(original);
var privateTeams = current.values().stream().map(EyeRevealController.Projection::privateTeam)
.collect(java.util.stream.Collectors.toUnmodifiableSet());
var names = current.values().stream().map(EyeRevealController.Projection::playerName)
.collect(java.util.stream.Collectors.toUnmodifiableSet());
if (!privateTeams.contains(name) && current.values().stream().anyMatch(p -> p.sourceTeam().filter(name::equals).isPresent())) {
refresh.accept(viewer); // Only marks work; the callback must not query Bukkit here.
}
Object projected = EyePacketProjection.teamPacket(original, privateTeams, names);
if (projected != original) { event.setPacket(new PacketContainer(event.getPacketType(), projected)); }
}
} catch (ReflectiveOperationException | RuntimeException exception) {
if (warned.add(event.getPacketType())) {
warning.accept("Eye projection unavailable for " + event.getPacketType()
+ "; original packet retained. Failure: " + exception.getClass().getSimpleName());
}
}
}
}
@@ -0,0 +1,115 @@
package games.dmg.spigotstealth;
import java.lang.reflect.RecordComponent;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/** Pure copies of declared-runtime packet data; no Bukkit lookups or global entity/team mutations. */
public final class EyePacketProjection {
private static final String TEAM_PARAMETERS = "net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket$Parameters";
private EyePacketProjection() { }
public static Object teamPacket(Object original, java.util.Set<String> privateTeams, java.util.Set<String> projectedPlayers) {
Objects.requireNonNull(original, "original");
Class<?> type = original.getClass();
if (!type.getName().equals("net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket")) {
throw new IllegalArgumentException("Unsupported native team packet");
}
try {
String name = (String) type.getMethod("getName").invoke(original);
var players = (java.util.Collection<?>) type.getMethod("getPlayers").invoke(original);
var parameters = (java.util.Optional<?>) type.getMethod("getParameters").invoke(original);
boolean privateTeam = privateTeams.contains(name);
var projectedParameters = privateTeam ? parameters.map(EyePacketProjection::teamParameters) : parameters;
var projectedMembers = privateTeam ? players : players.stream().filter(player -> !projectedPlayers.contains(player)).toList();
if (projectedParameters == parameters && projectedMembers.size() == players.size()) { return original; }
var method = type.getDeclaredField("method");
method.setAccessible(true);
var constructor = type.getDeclaredConstructor(String.class, int.class, java.util.Optional.class, java.util.Collection.class);
constructor.setAccessible(true);
return constructor.newInstance(name, method.getInt(original), projectedParameters, projectedMembers);
} catch (ReflectiveOperationException exception) {
throw new IllegalStateException("Could not project native team membership", exception);
}
}
public static Object metadataPacket(Object original) {
Objects.requireNonNull(original, "original");
Class<?> type = original.getClass();
if (!type.getName().equals("net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket")) {
throw new IllegalArgumentException("Unsupported native metadata packet");
}
try {
var values = (List<?>) type.getMethod("packedItems").invoke(original);
var projected = new ArrayList<Object>(values.size());
for (Object value : values) {
Class<?> valueType = value.getClass();
if (!valueType.getName().equals("net.minecraft.network.syncher.SynchedEntityData$DataValue")) {
throw new IllegalArgumentException("Unsupported native metadata value");
}
int index = (Integer) valueType.getMethod("id").invoke(value);
if (index != 0) { projected.add(value); continue; }
Object raw = valueType.getMethod("value").invoke(value);
if (!(raw instanceof Byte flags)) { throw new IllegalArgumentException("Shared entity flags must be a byte"); }
Object serializer = valueType.getMethod("serializer").invoke(value);
projected.add(valueType.getConstructor(int.class,
Class.forName("net.minecraft.network.syncher.EntityDataSerializer"), Object.class)
.newInstance(index, serializer, (byte) (flags & ~0x20)));
}
return type.getConstructor(int.class, List.class)
.newInstance(type.getMethod("id").invoke(original), List.copyOf(projected));
} catch (ReflectiveOperationException exception) {
throw new IllegalStateException("Could not project native entity metadata", exception);
}
}
public static Object teamParameters(Object original) {
try {
Object empty = Class.forName("net.minecraft.network.chat.Component").getMethod("empty").invoke(null);
return copyParameters(original, java.util.Map.of("playerPrefix", empty));
} catch (ReflectiveOperationException exception) {
throw new IllegalStateException("Could not project the native team prefix", exception);
}
}
public static Object teamVisibility(Object original, boolean visible) {
try {
Object visibility = Class.forName("net.minecraft.world.scores.Team$Visibility").getField(visible ? "ALWAYS" : "NEVER").get(null);
return copyParameters(original, java.util.Map.of("nameTagVisibility", visibility));
} catch (ReflectiveOperationException exception) {
throw new IllegalStateException("Could not preserve effective team visibility", exception);
}
}
private static Object copyParameters(Object original, java.util.Map<String, Object> replacements) {
Objects.requireNonNull(original, "original");
Class<?> type = original.getClass();
if (!TEAM_PARAMETERS.equals(type.getName()) || !type.isRecord()) {
throw new IllegalArgumentException("Unsupported native team parameter layout");
}
try {
RecordComponent[] components = type.getRecordComponents();
Class<?>[] types = new Class<?>[components.length];
Object[] values = new Object[components.length];
int replaced = 0;
for (int index = 0; index < components.length; index++) {
var component = components[index];
types[index] = component.getType();
if (replacements.containsKey(component.getName())) {
values[index] = replacements.get(component.getName());
replaced++;
} else {
// Accessors may consult global collision configuration. Copy the stored record data verbatim.
var field = type.getDeclaredField(component.getName());
field.setAccessible(true);
values[index] = field.get(original);
}
}
if (replaced != replacements.size()) { throw new IllegalArgumentException("Required native team fields are unavailable"); }
return type.getDeclaredConstructor(types).newInstance(values);
} catch (ReflectiveOperationException exception) {
throw new IllegalStateException("Could not project the native team prefix", exception);
}
}
}
@@ -0,0 +1,233 @@
package games.dmg.spigotstealth;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.function.Predicate;
import java.util.function.Supplier;
import org.bukkit.GameMode;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.metadata.MetadataValue;
import org.bukkit.potion.PotionEffectType;
/** Server-thread visibility capture and delivery; published projections contain no mutable Bukkit objects. */
public final class EyeRevealController implements Runnable, AutoCloseable {
public record Projection(UUID targetId, int entityId, String playerName, String privateTeam,
Optional<String> sourceTeam, boolean revealBody) { }
public interface Delivery {
void show(Player viewer, Player target, Projection projection, boolean create);
void restore(Player viewer, Optional<Player> target, Projection projection);
}
private final Supplier<? extends Collection<? extends Player>> onlinePlayers;
private final EyeEquipment equipment;
private final Predicate<UUID> concealed;
private final Delivery delivery;
private final Supplier<String> teamNames;
private final java.util.function.Consumer<Throwable> failure;
private record RestoreKey(UUID viewer, String team) { }
private record TargetKey(UUID viewer, UUID target) { }
private final Map<RestoreKey, Projection> restoring = new java.util.LinkedHashMap<>();
private final Set<UUID> warned = new HashSet<>();
private volatile Map<UUID, Map<Integer, Projection>> views = Map.of();
private volatile boolean closed;
private final Set<UUID> dirty = java.util.concurrent.ConcurrentHashMap.newKeySet();
public EyeRevealController(Supplier<? extends Collection<? extends Player>> onlinePlayers, EyeEquipment equipment,
Predicate<UUID> concealed, Delivery delivery, Supplier<String> teamNames) {
this(onlinePlayers, equipment, concealed, delivery, teamNames,
error -> java.util.logging.Logger.getLogger(EyeRevealController.class.getName())
.warning("Eye delivery failed; cleanup queued: " + error.getClass().getSimpleName()));
}
public EyeRevealController(Supplier<? extends Collection<? extends Player>> onlinePlayers, EyeEquipment equipment,
Predicate<UUID> concealed, Delivery delivery, Supplier<String> teamNames, java.util.function.Consumer<Throwable> failure) {
this.onlinePlayers = Objects.requireNonNull(onlinePlayers);
this.equipment = Objects.requireNonNull(equipment);
this.concealed = Objects.requireNonNull(concealed);
this.delivery = Objects.requireNonNull(delivery);
this.teamNames = Objects.requireNonNull(teamNames);
this.failure = Objects.requireNonNull(failure);
}
public Map<UUID, Map<Integer, Projection>> snapshot() { return views; }
/** Packet-thread safe: defer all Bukkit access and actual delivery to the next server-thread frame. */
public void requestRefresh(UUID viewer) {
if (!closed) { dirty.add(Objects.requireNonNull(viewer)); }
}
@Override public void run() {
if (closed) { return; }
var requested = Set.copyOf(dirty);
requested.forEach(dirty::remove);
List<? extends Player> online = List.copyOf(onlinePlayers.get());
var players = new HashMap<UUID, Player>();
online.forEach(player -> players.put(player.getUniqueId(), player));
var attemptedRestores = new HashSet<RestoreKey>();
retryRestores(players, attemptedRestores);
var blocked = restoring.entrySet().stream().map(entry -> new TargetKey(entry.getKey().viewer(), entry.getValue().targetId()))
.collect(java.util.stream.Collectors.toUnmodifiableSet());
Map<UUID, Map<Integer, Projection>> previous = views;
var usedNames = new HashSet<String>();
previous.values().forEach(view -> view.values().forEach(projection -> usedNames.add(projection.privateTeam())));
restoring.values().forEach(projection -> usedNames.add(projection.privateTeam()));
var next = new HashMap<UUID, Map<Integer, Projection>>();
for (Player viewer : online) {
try {
if (!viewer.isOnline() || viewer.isDead() || !equipment.isEligibleWearer(viewer)) { continue; }
Location origin = viewer.getLocation();
var observer = new EyeVisibility.Viewer(viewer.getUniqueId(), position(origin), true);
var targets = new HashMap<Integer, Projection>();
for (Player target : online) {
if (!target.isOnline() || target.isDead() || blocked.contains(new TargetKey(viewer.getUniqueId(), target.getUniqueId()))) { continue; }
Location destination = target.getLocation();
var subject = new EyeVisibility.Target(target.getUniqueId(), position(destination),
concealed.test(target.getUniqueId()), target.getGameMode() == GameMode.SPECTATOR,
target.getMetadata("vanished").stream().anyMatch(MetadataValue::asBoolean));
boolean visible = viewer.canSee(target) && target.getTrackedBy().contains(viewer);
if (!EyeVisibility.canReveal(observer, subject, visible, true)) { continue; }
var sourceTeam = viewer.getScoreboard().getEntryTeam(target.getName());
if (!EyeTeamVisibility.visible(viewer, sourceTeam)
|| !chunksLoaded(origin.getWorld(), origin, destination) || !viewer.hasLineOfSight(target)) { continue; }
Projection old = previous.getOrDefault(viewer.getUniqueId(), Map.of()).get(target.getEntityId());
String team = old != null && old.targetId().equals(target.getUniqueId()) && old.playerName().equals(target.getName())
? old.privateTeam() : allocateTeam(viewer, usedNames);
var baseline = Optional.ofNullable(sourceTeam).map(org.bukkit.scoreboard.Team::getName);
targets.put(target.getEntityId(), new Projection(target.getUniqueId(), target.getEntityId(), target.getName(),
team, baseline, target.hasPotionEffect(PotionEffectType.INVISIBILITY)));
}
if (!targets.isEmpty()) { next.put(viewer.getUniqueId(), Map.copyOf(targets)); }
} catch (RuntimeException error) {
// Never retain a stale authorization when an authoritative visibility query fails.
report(viewer.getUniqueId(), error);
}
}
views = Map.copyOf(next); // Publish authorization before any outgoing projection/restoration packet.
previous.forEach((viewerId, targets) -> {
Player viewer = players.get(viewerId);
if (viewer == null || !viewer.isOnline()) { return; }
targets.values().forEach(old -> {
Projection current = views.getOrDefault(viewerId, Map.of()).get(old.entityId());
if (current == null || !current.privateTeam().equals(old.privateTeam())) {
restoring.putIfAbsent(new RestoreKey(viewerId, old.privateTeam()), old);
}
});
});
retryRestores(players, attemptedRestores);
views.forEach((viewerId, targets) -> targets.values().forEach(current -> {
Projection old = previous.getOrDefault(viewerId, Map.of()).get(current.entityId());
if (!current.equals(old) || requested.contains(viewerId)) {
try {
delivery.show(players.get(viewerId), players.get(current.targetId()), current,
old == null || !current.privateTeam().equals(old.privateTeam()));
warned.remove(viewerId);
} catch (RuntimeException error) {
deny(viewerId, current.targetId());
restoring.putIfAbsent(new RestoreKey(viewerId, current.privateTeam()), current);
report(viewerId, error);
}
}
}));
}
public void hide(UUID viewer, UUID target) {
withdraw((observer, projection) -> observer.equals(viewer) && projection.targetId().equals(target));
}
public void withdrawViewer(UUID viewer) { withdraw((observer, projection) -> observer.equals(viewer)); }
public void withdrawTarget(UUID target) { withdraw((observer, projection) -> projection.targetId().equals(target)); }
/** Lifecycle callbacks call this on the server thread before the next scheduled visibility capture. */
private void withdraw(java.util.function.BiPredicate<UUID, Projection> remove) {
var previous = views;
var next = new HashMap<UUID, Map<Integer, Projection>>();
previous.forEach((viewer, targets) -> {
var retained = new HashMap<Integer, Projection>();
targets.forEach((id, projection) -> { if (!remove.test(viewer, projection)) { retained.put(id, projection); } });
if (!retained.isEmpty()) { next.put(viewer, Map.copyOf(retained)); }
});
views = Map.copyOf(next);
var players = new HashMap<UUID, Player>();
onlinePlayers.get().forEach(player -> players.put(player.getUniqueId(), player));
previous.forEach((viewerId, targets) -> {
Player viewer = players.get(viewerId);
if (viewer != null && viewer.isOnline()) {
targets.values().stream().filter(projection -> remove.test(viewerId, projection))
.forEach(projection -> restoring.putIfAbsent(new RestoreKey(viewerId, projection.privateTeam()), projection));
}
});
retryRestores(players, new HashSet<>());
}
@Override public void close() {
if (closed && restoring.isEmpty()) { return; }
closed = true;
dirty.clear();
withdraw((viewer, projection) -> true);
}
private void retryRestores(Map<UUID, Player> players, Set<RestoreKey> attempted) {
for (var entry : List.copyOf(restoring.entrySet())) {
var key = entry.getKey();
if (!attempted.add(key)) { continue; }
var projection = entry.getValue();
Player viewer = players.get(key.viewer());
if (viewer == null || !viewer.isOnline()) { restoring.remove(key); continue; }
try {
delivery.restore(viewer, Optional.ofNullable(players.get(projection.targetId())), projection);
restoring.remove(key);
} catch (RuntimeException error) {
deny(key.viewer(), projection.targetId());
report(key.viewer(), error);
}
}
}
private void deny(UUID viewer, UUID target) {
var next = new HashMap<>(views);
var retained = new HashMap<>(next.getOrDefault(viewer, Map.of()));
retained.values().removeIf(projection -> projection.targetId().equals(target));
if (retained.isEmpty()) { next.remove(viewer); }
else { next.put(viewer, Map.copyOf(retained)); }
views = Map.copyOf(next);
}
private void report(UUID viewer, RuntimeException error) {
if (warned.add(viewer)) { failure.accept(error); }
}
private String allocateTeam(Player viewer, Set<String> used) {
for (int attempt = 0; attempt < 32; attempt++) {
String name = teamNames.get();
if (name != null && name.matches("[a-zA-Z0-9_-]{1,16}") && !used.contains(name)
&& viewer.getScoreboard().getTeam(name) == null) {
used.add(name);
return name;
}
}
throw new IllegalStateException("Could not allocate an isolated Eye team name");
}
private static EyeVisibility.Position position(Location location) {
return new EyeVisibility.Position(location.getWorld().getUID(), location.getX(), location.getY(), location.getZ());
}
private static boolean chunksLoaded(World world, Location from, Location to) {
int minX = (int) Math.floor(Math.min(from.getX(), to.getX())) >> 4;
int maxX = (int) Math.floor(Math.max(from.getX(), to.getX())) >> 4;
int minZ = (int) Math.floor(Math.min(from.getZ(), to.getZ())) >> 4;
int maxZ = (int) Math.floor(Math.max(from.getZ(), to.getZ())) >> 4;
for (int x = minX; x <= maxX; x++) {
for (int z = minZ; z <= maxZ; z++) { if (!world.isChunkLoaded(x, z)) { return false; } }
}
return true;
}
}
@@ -0,0 +1,57 @@
package games.dmg.spigotstealth;
import com.destroystokyo.paper.event.player.PlayerArmorChangeEvent;
import io.papermc.paper.event.player.PlayerUntrackEntityEvent;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityPotionEffectEvent;
import org.bukkit.event.entity.PlayerDeathEvent;
import org.bukkit.event.player.PlayerChangedWorldEvent;
import org.bukkit.event.player.PlayerGameModeChangeEvent;
import org.bukkit.event.player.PlayerHideEntityEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.player.PlayerRespawnEvent;
import org.bukkit.event.player.PlayerTeleportEvent;
/** Immediate server-thread invalidation; ordinary motion and line-of-sight changes are polled each tick. */
public final class EyeRevealEvents implements Listener {
private final EyeRevealController controller;
public EyeRevealEvents(EyeRevealController controller) { this.controller = java.util.Objects.requireNonNull(controller); }
@EventHandler(priority = EventPriority.MONITOR) public void onHide(PlayerHideEntityEvent event) {
if (event.getEntity() instanceof org.bukkit.entity.Player player) { controller.hide(event.getPlayer().getUniqueId(), player.getUniqueId()); }
}
@EventHandler(priority = EventPriority.MONITOR) public void onUntrack(PlayerUntrackEntityEvent event) {
if (event.getEntity() instanceof org.bukkit.entity.Player player) { controller.hide(event.getPlayer().getUniqueId(), player.getUniqueId()); }
}
@EventHandler(priority = EventPriority.MONITOR) public void onArmor(PlayerArmorChangeEvent event) {
if (event.getSlot() == org.bukkit.inventory.EquipmentSlot.HEAD) { controller.withdrawViewer(event.getPlayer().getUniqueId()); }
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onGameMode(PlayerGameModeChangeEvent event) {
if (!event.isCancelled() && event.getNewGameMode() == org.bukkit.GameMode.SPECTATOR) { both(event.getPlayer().getUniqueId()); }
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onEffect(EntityPotionEffectEvent event) {
if (!event.isCancelled() && event.getEntity() instanceof org.bukkit.entity.Player player
&& org.bukkit.potion.PotionEffectType.INVISIBILITY.equals(event.getModifiedType())) {
controller.withdrawTarget(player.getUniqueId());
}
}
@EventHandler(priority = EventPriority.MONITOR) public void onQuit(PlayerQuitEvent event) { both(event.getPlayer().getUniqueId()); }
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onTeleport(PlayerTeleportEvent event) {
if (!event.isCancelled()) { both(event.getPlayer().getUniqueId()); }
}
@EventHandler(priority = EventPriority.MONITOR) public void onRespawn(PlayerRespawnEvent event) { both(event.getPlayer().getUniqueId()); }
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onDeath(PlayerDeathEvent event) {
if (!cancelled(event)) { both(event.getEntity().getUniqueId()); }
}
@EventHandler(priority = EventPriority.MONITOR) public void onWorld(PlayerChangedWorldEvent event) { both(event.getPlayer().getUniqueId()); }
private void both(java.util.UUID player) {
controller.withdrawViewer(player);
controller.withdrawTarget(player);
}
private static boolean cancelled(org.bukkit.event.Event event) {
return event instanceof org.bukkit.event.Cancellable cancellable && cancellable.isCancelled();
}
}
@@ -0,0 +1,58 @@
package games.dmg.spigotstealth;
import com.comphenix.protocol.ProtocolManager;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Predicate;
import org.bukkit.event.HandlerList;
import org.bukkit.plugin.java.JavaPlugin;
import org.bukkit.scheduler.BukkitTask;
/** Owns only the Eye's viewer-specific runtime and its lifecycle resources. */
final class EyeRevealRuntime implements AutoCloseable {
private final JavaPlugin plugin;
private final ProtocolManager protocol;
private final EyeRevealController controller;
private final EyePacketListener packets;
private final EyeRevealEvents events;
private BukkitTask task;
private boolean started;
private boolean closed;
EyeRevealRuntime(JavaPlugin plugin, EyeEquipment equipment, Predicate<UUID> concealed, ProtocolManager protocol) {
this.plugin = Objects.requireNonNull(plugin);
this.protocol = Objects.requireNonNull(protocol);
controller = new EyeRevealController(plugin.getServer()::getOnlinePlayers, equipment, concealed, new NativeEyeDelivery(),
() -> UUID.randomUUID().toString().replace("-", "").substring(0, 16),
failure -> plugin.getLogger().warning("Eye rendering failed; unsafe views withheld: " + failure.getClass().getSimpleName()));
packets = new EyePacketListener(plugin, controller::snapshot, controller::requestRefresh, plugin.getLogger()::warning);
events = new EyeRevealEvents(controller);
}
public void start() {
if (closed) { throw new IllegalStateException("Eye runtime is closed"); }
if (started) { return; }
if (!plugin.isEnabled()) { throw new IllegalStateException("Cannot start Eye rendering for a disabled plugin"); }
started = true;
try {
protocol.addPacketListener(packets);
plugin.getServer().getPluginManager().registerEvents(events, plugin);
task = plugin.getServer().getScheduler().runTaskTimer(plugin, controller, 1L, 1L);
} catch (RuntimeException exception) {
close();
throw exception;
}
}
@Override public void close() {
if (closed) { return; }
closed = true;
if (task != null) { task.cancel(); }
try {
controller.close(); // Withdraw authorization and restore raw state before unregistering the outgoing gate.
} finally {
protocol.removePacketListener(packets);
HandlerList.unregisterAll(events);
}
}
}
@@ -0,0 +1,20 @@
package games.dmg.spigotstealth;
import org.bukkit.entity.Player;
import org.bukkit.scoreboard.Team;
/** Preserve effective name visibility when a viewer-only team changes client membership relationships. */
final class EyeTeamVisibility {
private EyeTeamVisibility() { }
static boolean visible(Player viewer, Team source) {
if (source == null) { return true; }
boolean sameTeam = source.equals(viewer.getScoreboard().getEntryTeam(viewer.getName()));
return switch (source.getOption(Team.Option.NAME_TAG_VISIBILITY)) {
case ALWAYS -> true;
case NEVER -> false;
// CraftTeam maps these to native HIDE_FOR_* values by ordinal, not to SHOW_FOR_*.
case FOR_OWN_TEAM -> !sameTeam;
case FOR_OTHER_TEAMS -> sameTeam;
};
}
}
@@ -0,0 +1,21 @@
package games.dmg.spigotstealth;
import java.util.UUID;
/** Immutable, platform-independent inputs to the local Eye reveal decision. */
public final class EyeVisibility {
private EyeVisibility() { }
public record Position(UUID world, double x, double y, double z) { }
public record Viewer(UUID id, Position position, boolean eligible) { }
public record Target(UUID id, Position position, boolean concealed, boolean spectator, boolean vanished) { }
public static boolean canReveal(Viewer viewer, Target target, boolean visibleToViewer, boolean lineOfSight) {
if (!viewer.eligible() || !target.concealed() || target.spectator() || target.vanished()
|| !visibleToViewer || !lineOfSight || viewer.id().equals(target.id())
|| !viewer.position().world().equals(target.position().world())) { return false; }
double dx = viewer.position().x() - target.position().x();
double dy = viewer.position().y() - target.position().y();
double dz = viewer.position().z() - target.position().z();
return dx * dx + dy * dy + dz * dz <= 16.0 * 16.0;
}
}
@@ -0,0 +1,108 @@
package games.dmg.spigotstealth;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import org.bukkit.entity.Player;
/** Main-thread native packet preparation/delivery. Outgoing authorization performs the final projection. */
final class NativeEyeDelivery implements EyeRevealController.Delivery {
private final Constructor<?> teamPacket;
private final Constructor<?> parameters;
private final Constructor<?> scoreboard;
private final Constructor<?> playerTeam;
private final Constructor<?> metadata;
private final Constructor<?> dataValue;
private final Field nativeTeam;
private final Method playerHandle;
private final Method entityData;
private final Method dataGet;
private final Object sharedFlags;
private final Object byteSerializer;
private final Field connection;
private final Method sendPacket;
NativeEyeDelivery() {
try {
Class<?> team = Class.forName("net.minecraft.world.scores.PlayerTeam");
Class<?> board = Class.forName("net.minecraft.world.scores.Scoreboard");
teamPacket = Class.forName("net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket")
.getDeclaredConstructor(String.class, int.class, Optional.class, Collection.class);
teamPacket.setAccessible(true);
parameters = Class.forName("net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket$Parameters")
.getConstructor(team);
scoreboard = board.getConstructor();
playerTeam = team.getConstructor(board, String.class);
nativeTeam = Class.forName("org.bukkit.craftbukkit.scoreboard.CraftTeam").getDeclaredField("team");
nativeTeam.setAccessible(true);
playerHandle = Class.forName("org.bukkit.craftbukkit.entity.CraftPlayer").getMethod("getHandle");
Class<?> entity = Class.forName("net.minecraft.world.entity.Entity");
entityData = entity.getMethod("getEntityData");
var flags = entity.getDeclaredField("DATA_SHARED_FLAGS_ID");
flags.setAccessible(true);
sharedFlags = flags.get(null);
Class<?> accessor = Class.forName("net.minecraft.network.syncher.EntityDataAccessor");
dataGet = Class.forName("net.minecraft.network.syncher.SynchedEntityData").getMethod("get", accessor);
byteSerializer = Class.forName("net.minecraft.network.syncher.EntityDataSerializers").getField("BYTE").get(null);
dataValue = Class.forName("net.minecraft.network.syncher.SynchedEntityData$DataValue").getConstructor(
int.class, Class.forName("net.minecraft.network.syncher.EntityDataSerializer"), Object.class);
metadata = Class.forName("net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket").getConstructor(int.class, List.class);
connection = Class.forName("net.minecraft.server.level.ServerPlayer").getField("connection");
sendPacket = Class.forName("net.minecraft.server.network.ServerCommonPacketListenerImpl")
.getMethod("send", Class.forName("net.minecraft.network.protocol.Packet"));
} catch (ReflectiveOperationException exception) {
throw new IllegalStateException("Native Eye delivery is unsupported on this server version", exception);
}
}
@Override public void show(Player viewer, Player target, EyeRevealController.Projection projection, boolean create) {
try {
var source = viewer.getScoreboard().getEntryTeam(projection.playerName());
Object basis = source == null ? playerTeam.newInstance(scoreboard.newInstance(), projection.privateTeam()) : nativeTeam.get(source);
// Send unprojected data. The packet listener checks the latest per-viewer authorization at dispatch.
Object privateParameters = EyePacketProjection.teamVisibility(parameters.newInstance(basis), EyeTeamVisibility.visible(viewer, source));
Object team = teamPacket.newInstance(projection.privateTeam(), create ? 0 : 2,
Optional.of(privateParameters), create ? List.of(projection.playerName()) : List.of());
var packets = List.of(team, rawMetadata(target));
for (Object packet : packets) { send(viewer, packet); }
} catch (ReflectiveOperationException exception) {
throw new IllegalStateException("Could not deliver a private Eye view", exception);
}
}
@Override public void restore(Player viewer, Optional<Player> target, EyeRevealController.Projection projection) {
try {
var packets = new ArrayList<Object>();
var source = viewer.getScoreboard().getEntryTeam(projection.playerName());
Object basis = source == null ? playerTeam.newInstance(scoreboard.newInstance(), projection.privateTeam()) : nativeTeam.get(source);
Object baseline = parameters.newInstance(basis);
// Restore presentation before changing membership, avoiding a temporary unteamed/plain-name gap.
packets.add(teamPacket.newInstance(projection.privateTeam(), 2,
Optional.of(EyePacketProjection.teamVisibility(baseline, EyeTeamVisibility.visible(viewer, source))), List.of()));
if (target.isPresent() && target.orElseThrow().isOnline()) { packets.add(rawMetadata(target.orElseThrow())); }
if (source != null) {
packets.add(teamPacket.newInstance(source.getName(), 2, Optional.of(baseline), List.of()));
packets.add(teamPacket.newInstance(source.getName(), 3, Optional.empty(), List.of(projection.playerName())));
}
packets.add(teamPacket.newInstance(projection.privateTeam(), 1, Optional.empty(), List.of()));
// Prepare everything before sending, avoiding half-created views on reflection/layout failures.
for (Object packet : packets) { send(viewer, packet); }
} catch (ReflectiveOperationException exception) {
throw new IllegalStateException("Could not restore an Eye observer's baseline", exception);
}
}
private Object rawMetadata(Player player) throws ReflectiveOperationException {
Object value = dataGet.invoke(entityData.invoke(playerHandle.invoke(player)), sharedFlags);
if (!(value instanceof Byte)) { throw new IllegalStateException("Native player flags are unavailable"); }
return metadata.newInstance(player.getEntityId(), List.of(dataValue.newInstance(0, byteSerializer, value)));
}
private void send(Player viewer, Object packet) throws ReflectiveOperationException {
sendPacket.invoke(connection.get(playerHandle.invoke(viewer)), packet);
}
}
@@ -22,6 +22,7 @@ public final class SpigotStealthPlugin extends JavaPlugin {
private EyeProgressionService eyeProgression; private EyeProgressionService eyeProgression;
private EyePotionListener eyePotions; private EyePotionListener eyePotions;
private boolean eyeRecipeRegistered; private boolean eyeRecipeRegistered;
private EyeRevealRuntime eyeReveal;
@Override @Override
public void onEnable() { public void onEnable() {
@@ -68,6 +69,7 @@ public final class SpigotStealthPlugin extends JavaPlugin {
logSaveFailure(eyeProgression.stopAll()); logSaveFailure(eyeProgression.stopAll());
} }
if (progression != null) { logSaveFailure(progression.stopAll()); } if (progression != null) { logSaveFailure(progression.stopAll()); }
if (eyeReveal != null) { eyeReveal.close(); }
if (protocolManager != null) { protocolManager.removePacketListeners(this); } if (protocolManager != null) { protocolManager.removePacketListeners(this); }
if (identityPresentation != null && sessions != null) { if (identityPresentation != null && sessions != null) {
for (org.bukkit.entity.Player player : getServer().getOnlinePlayers()) { for (org.bukkit.entity.Player player : getServer().getOnlinePlayers()) {
@@ -166,7 +168,16 @@ public final class SpigotStealthPlugin extends JavaPlugin {
eyeRecipeRegistered = true; eyeRecipeRegistered = true;
getServer().getPluginManager().registerEvents(eyePotions, this); getServer().getPluginManager().registerEvents(eyePotions, this);
getServer().getPluginManager().registerEvents(eyeCrafting, this); getServer().getPluginManager().registerEvents(eyeCrafting, this);
getServer().getPluginManager().registerEvents(new EyeEquipment(eyeProgression, eyeItems), this); var eyeEquipment = new EyeEquipment(eyeProgression, eyeItems);
getServer().getPluginManager().registerEvents(eyeEquipment, this);
try {
eyeReveal = new EyeRevealRuntime(this, eyeEquipment, sessions::isConcealed, protocolManager);
eyeReveal.start();
} catch (RuntimeException exception) {
getLogger().severe("Unable to initialize Eye rendering: " + rootMessage(exception));
getServer().getPluginManager().disablePlugin(this);
return;
}
getServer().getScheduler().runTaskTimer(this, ignored -> { getServer().getScheduler().runTaskTimer(this, ignored -> {
eyePotions.checkpoint(getServer()::getPlayer); eyePotions.checkpoint(getServer()::getPlayer);
for (var player : getServer().getOnlinePlayers()) { for (var player : getServer().getOnlinePlayers()) {
@@ -0,0 +1,58 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import io.netty.buffer.Unpooled;
import java.util.List;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket;
import net.minecraft.network.syncher.EntityDataAccessor;
import net.minecraft.network.syncher.EntityDataSerializers;
import net.minecraft.network.syncher.SynchedEntityData;
import org.bukkit.craftbukkit.CraftRegistry;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
class EyeMetadataProjectionTest {
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test
void nativeMetadataProjectionClearsOnlyInvisibilityAndKeepsTheSharedPacketUnchanged() throws Exception {
var config = new io.papermc.paper.configuration.GlobalConfiguration();
config.anticheat = config.new Anticheat();
config.anticheat.obfuscation = config.anticheat.new Obfuscation();
config.anticheat.obfuscation.items = config.anticheat.obfuscation.new Items();
var server = org.mockito.Mockito.mock(net.minecraft.server.MinecraftServer.class);
org.mockito.Mockito.when(server.registryAccess()).thenReturn(CraftRegistry.getMinecraftRegistry().freeze());
try (var runtime = org.mockito.Mockito.mockStatic(net.minecraft.server.MinecraftServer.class);
var platform = org.mockito.Mockito.mockStatic(io.papermc.paper.configuration.GlobalConfiguration.class)) {
runtime.when(net.minecraft.server.MinecraftServer::getServer).thenReturn(server);
platform.when(io.papermc.paper.configuration.GlobalConfiguration::get).thenReturn(config);
config.anticheat.obfuscation.items.bindDataSanitizer();
assertProjection();
}
}
private static void assertProjection() throws Exception {
var accessorField = net.minecraft.world.entity.Entity.class.getDeclaredField("DATA_SHARED_FLAGS_ID");
accessorField.setAccessible(true);
var accessor = assertInstanceOf(EntityDataAccessor.class, accessorField.get(null));
assertEquals(0, accessor.id());
assertSame(EntityDataSerializers.BYTE, accessor.serializer());
for (int flags = 0; flags <= 255; flags++) {
var sharedFlags = new SynchedEntityData.DataValue<>(0, EntityDataSerializers.BYTE, (byte) flags);
var air = new SynchedEntityData.DataValue<>(1, EntityDataSerializers.INT, 300);
var original = new ClientboundSetEntityDataPacket(42, List.of(sharedFlags, air));
var projected = assertInstanceOf(ClientboundSetEntityDataPacket.class, EyePacketProjection.metadataPacket(original));
assertEquals(42, projected.id());
assertEquals((byte) (flags & ~0x20), projected.packedItems().getFirst().value(), "flag byte " + flags);
assertSame(air, projected.packedItems().get(1));
assertEquals((byte) flags, original.packedItems().getFirst().value());
var buffer = new RegistryFriendlyByteBuf(Unpooled.buffer(), CraftRegistry.getMinecraftRegistry());
try {
ClientboundSetEntityDataPacket.STREAM_CODEC.encode(buffer, projected);
assertEquals(projected, ClientboundSetEntityDataPacket.STREAM_CODEC.decode(buffer));
} finally { buffer.release(); }
}
}
}
@@ -0,0 +1,122 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import com.comphenix.protocol.PacketType;
import com.comphenix.protocol.events.PacketContainer;
import com.comphenix.protocol.events.PacketEvent;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket;
import net.minecraft.network.syncher.EntityDataSerializers;
import net.minecraft.network.syncher.SynchedEntityData;
import org.bukkit.entity.Player;
import org.bukkit.plugin.Plugin;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
class EyePacketListenerTest {
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test
@SuppressWarnings("try")
void metadataIsProjectedOnlyForTheCurrentlyAuthorizedRecipientWithoutMutatingSharedPackets() {
var server = mock(org.bukkit.craftbukkit.CraftServer.class);
try (var platform = mockStatic(org.bukkit.Bukkit.class, call -> switch (call.getMethod().getName()) {
case "getServer" -> server;
case "isPrimaryThread" -> true;
case "getVersion" -> "Purpur 2618 (MC: 26.2)";
case "getMinecraftVersion" -> "26.2";
case "getBukkitVersion" -> "26.2-R0.1-SNAPSHOT";
default -> call.callRealMethod();
})) {
UUID viewerId = UUID.randomUUID();
var viewer = mock(Player.class);
when(viewer.getUniqueId()).thenReturn(viewerId);
var other = mock(Player.class);
when(other.getUniqueId()).thenReturn(UUID.randomUUID());
var projection = new EyeRevealController.Projection(UUID.randomUUID(), 42, "Concealed", "eye-private", Optional.of("source"), true);
var snapshot = new AtomicReference<>(Map.of(viewerId, Map.of(42, projection)));
var warnings = new ArrayList<String>();
var dirty = new ArrayList<UUID>();
var listener = new EyePacketListener(mock(Plugin.class), snapshot::get, dirty::add, warnings::add);
assertEquals(Set.of(PacketType.Play.Server.ENTITY_METADATA, PacketType.Play.Server.SCOREBOARD_TEAM),
listener.getSendingWhitelist().getTypes());
var raw = new ClientboundSetEntityDataPacket(42, List.of(new SynchedEntityData.DataValue<>(0, EntityDataSerializers.BYTE, (byte) 0x63)));
var shared = new PacketContainer(PacketType.Play.Server.ENTITY_METADATA, raw);
var first = PacketEvent.fromServer(this, shared, viewer);
listener.onPacketSending(first);
var shown = assertInstanceOf(ClientboundSetEntityDataPacket.class, first.getPacket().getHandle());
assertEquals((byte) 0x43, shown.packedItems().getFirst().value());
assertEquals((byte) 0x63, raw.packedItems().getFirst().value());
var second = PacketEvent.fromServer(this, shared, other);
listener.onPacketSending(second);
assertSame(shared, second.getPacket());
snapshot.set(Map.of());
var late = PacketEvent.fromServer(this, shared, viewer);
listener.onPacketSending(late);
assertSame(shared, late.getPacket(), "queued packets must use current, not captured authorization");
snapshot.set(Map.of(viewerId, Map.of(42, projection)));
var cancelled = PacketEvent.fromServer(this, shared, viewer);
cancelled.setCancelled(true);
listener.onPacketSending(cancelled);
assertTrue(cancelled.isCancelled());
assertSame(shared, cancelled.getPacket());
var noBody = new EyeRevealController.Projection(projection.targetId(), 42, projection.playerName(),
projection.privateTeam(), projection.sourceTeam(), false);
snapshot.set(Map.of(viewerId, Map.of(42, noBody)));
var notGameplayInvisible = PacketEvent.fromServer(this, shared, viewer);
listener.onPacketSending(notGameplayInvisible);
assertSame(shared, notGameplayInvisible.getPacket());
snapshot.set(Map.of(viewerId, Map.of(42, projection)));
var board = new net.minecraft.world.scores.Scoreboard();
var privateTeam = board.addPlayerTeam("eye-private");
privateTeam.setPlayerPrefix(net.minecraft.network.chat.Component.empty().withStyle(net.minecraft.ChatFormatting.OBFUSCATED));
board.addPlayerToTeam("Concealed", privateTeam);
var privateRaw = net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.createAddOrModifyPacket(privateTeam, true);
var privateWrapped = new PacketContainer(PacketType.Play.Server.SCOREBOARD_TEAM, privateRaw);
var privateEvent = PacketEvent.fromServer(this, privateWrapped, viewer);
listener.onPacketSending(privateEvent);
var shownTeam = assertInstanceOf(net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.class, privateEvent.getPacket().getHandle());
assertFalse(shownTeam.getParameters().orElseThrow().playerPrefix().getStyle().isObfuscated());
assertTrue(privateRaw.getParameters().orElseThrow().playerPrefix().getStyle().isObfuscated());
assertTrue(dirty.isEmpty(), "our own private updates must not create a refresh loop");
var source = board.addPlayerTeam("source");
source.setPlayerPrefix(net.minecraft.network.chat.Component.empty().withStyle(net.minecraft.ChatFormatting.OBFUSCATED));
board.addPlayerToTeam("Concealed", source);
board.addPlayerToTeam("Other", source);
var sourceRaw = net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.createAddOrModifyPacket(source, true);
var sourceWrapped = new PacketContainer(PacketType.Play.Server.SCOREBOARD_TEAM, sourceRaw);
var sourceEvent = PacketEvent.fromServer(this, sourceWrapped, viewer);
listener.onPacketSending(sourceEvent);
var filtered = assertInstanceOf(net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.class, sourceEvent.getPacket().getHandle());
assertEquals(Set.of("Other"), Set.copyOf(filtered.getPlayers()));
assertTrue(filtered.getParameters().orElseThrow().playerPrefix().getStyle().isObfuscated());
assertEquals(List.of(viewerId), dirty, "source style updates request a main-thread refresh");
var otherEvent = PacketEvent.fromServer(this, sourceWrapped, other);
listener.onPacketSending(otherEvent);
assertSame(sourceWrapped, otherEvent.getPacket());
var system = new PacketContainer(PacketType.Play.Server.SYSTEM_CHAT,
new net.minecraft.network.protocol.game.ClientboundSystemChatPacket(net.minecraft.network.chat.Component.literal("Unchanged"), false));
var systemEvent = PacketEvent.fromServer(this, system, viewer);
listener.onPacketSending(systemEvent);
assertSame(system, systemEvent.getPacket());
var tabRemoval = new PacketContainer(PacketType.Play.Server.PLAYER_INFO_REMOVE);
tabRemoval.getUUIDLists().write(0, List.of(projection.targetId()));
var tabEvent = PacketEvent.fromServer(this, tabRemoval, viewer);
listener.onPacketSending(tabEvent);
assertSame(tabRemoval, tabEvent.getPacket());
assertEquals(List.of(projection.targetId()), tabEvent.getPacket().getUUIDLists().read(0));
assertTrue(warnings.isEmpty());
verify(viewer, never()).getInventory();
verify(viewer, never()).getWorld();
verify(viewer, never()).hasLineOfSight(any(org.bukkit.entity.Entity.class));
}
}
}
@@ -100,6 +100,9 @@ class EyePluginLifecycleTest {
default -> call.callRealMethod(); default -> call.callRealMethod();
}); var protocol = mockStatic(ProtocolLibrary.class)) { }); var protocol = mockStatic(ProtocolLibrary.class)) {
var protocolManager = mock(ProtocolManager.class); var protocolManager = mock(ProtocolManager.class);
var registeredPackets = new ArrayList<com.comphenix.protocol.events.PacketListener>();
doAnswer(call -> { registeredPackets.add(call.getArgument(0)); return null; }).when(protocolManager).addPacketListener(any());
doAnswer(call -> { registeredPackets.remove(call.getArgument(0)); return null; }).when(protocolManager).removePacketListener(any());
protocol.when(ProtocolLibrary::getProtocolManager).thenReturn(protocolManager); protocol.when(ProtocolLibrary::getProtocolManager).thenReturn(protocolManager);
try { try {
plugin.onEnable(); plugin.onEnable();
@@ -133,6 +136,8 @@ class EyePluginLifecycleTest {
assertTrue(new EyeItems().isEye(recipe.get().getResult())); assertTrue(new EyeItems().isEye(recipe.get().getResult()));
assertTrue(listeners.stream().anyMatch(EyePotionListener.class::isInstance)); assertTrue(listeners.stream().anyMatch(EyePotionListener.class::isInstance));
assertTrue(listeners.stream().anyMatch(EyeEquipment.class::isInstance)); assertTrue(listeners.stream().anyMatch(EyeEquipment.class::isInstance));
assertTrue(listeners.stream().anyMatch(EyeRevealEvents.class::isInstance), "plugin startup must register reveal invalidation hooks");
assertTrue(registeredPackets.stream().anyMatch(EyePacketListener.class::isInstance), "plugin startup must register the per-observer packet gate");
var inventory = mock(CraftingInventory.class); var inventory = mock(CraftingInventory.class);
when(inventory.getRecipe()).thenAnswer(ignored -> recipe.get()); when(inventory.getRecipe()).thenAnswer(ignored -> recipe.get());
var result = new AtomicReference<>(recipe.get().getResult()); var result = new AtomicReference<>(recipe.get().getResult());
@@ -0,0 +1,335 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import org.bukkit.GameMode;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
import org.bukkit.potion.PotionEffectType;
import org.bukkit.scoreboard.Scoreboard;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class EyeRevealControllerTest {
@TempDir Path directory;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test
void onlyTheEligibleObserverGetsAnImmutableProjectionAndUnequippingRestoresTheBaseline() throws Exception {
var world = mock(World.class);
when(world.getUID()).thenReturn(UUID.randomUUID());
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
var viewer = player(world, "Viewer", 1, 0);
var unearned = player(world, "Unearned", 2, 0);
var target = player(world, "Concealed", 42, 8);
when(target.getTrackedBy()).thenReturn(Set.of(viewer, unearned));
when(viewer.canSee(target)).thenReturn(true);
when(viewer.hasLineOfSight(target)).thenReturn(true);
when(target.hasPotionEffect(PotionEffectType.INVISIBILITY)).thenReturn(true);
var items = new EyeItems();
var helmet = new AtomicReference<>(items.create());
when(viewer.getInventory().getHelmet()).thenAnswer(ignored -> helmet.get());
when(unearned.getInventory().getHelmet()).thenReturn(items.create());
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
var initial = new PersistentStealthState(Map.of(), Map.of())
.withEyeProgress(viewer.getUniqueId(), new EyeProgress(28_800_000, true));
var actions = new ArrayList<String>();
var controllerRef = new AtomicReference<EyeRevealController>();
var delivery = new EyeRevealController.Delivery() {
@Override public void show(Player observer, Player subject, EyeRevealController.Projection projection, boolean create) {
actions.add((create ? "create:" : "refresh:") + observer.getName() + ":" + subject.getName());
assertTrue(projection.revealBody());
assertEquals(projection, controllerRef.get().snapshot().get(observer.getUniqueId()).get(projection.entityId()));
}
@Override public void restore(Player observer, Optional<Player> subject, EyeRevealController.Projection projection) {
assertFalse(controllerRef.get().snapshot().getOrDefault(observer.getUniqueId(), Map.of()).containsKey(projection.entityId()),
"authorization must be withdrawn before sending restoration packets");
actions.add("restore:" + observer.getName() + ":" + projection.playerName());
}
};
try (var states = new StealthStateManager(repository, initial)) {
var equipment = new EyeEquipment(new EyeProgressionService(states, () -> 0L, ignored -> { }), items);
var ids = new AtomicInteger();
var controller = new EyeRevealController(() -> List.of(viewer, unearned, target), equipment,
id -> id.equals(target.getUniqueId()), delivery, () -> "eye" + ids.incrementAndGet());
controllerRef.set(controller);
controller.run();
var snapshot = controller.snapshot();
assertNotNull(snapshot.get(viewer.getUniqueId()), "an eligible observer needs a projection");
assertEquals(Set.of(42), snapshot.get(viewer.getUniqueId()).keySet());
assertFalse(snapshot.containsKey(unearned.getUniqueId()));
assertEquals(List.of("create:Viewer:Concealed"), actions);
assertThrows(UnsupportedOperationException.class, () -> snapshot.clear());
assertThrows(UnsupportedOperationException.class, () -> snapshot.get(viewer.getUniqueId()).clear());
controller.run();
assertEquals(1, actions.size(), "unchanged frames must not resend packets");
UUID observerId = viewer.getUniqueId();
java.util.concurrent.CompletableFuture.runAsync(() -> {
controller.requestRefresh(observerId);
controller.requestRefresh(observerId);
}).get(3, java.util.concurrent.TimeUnit.SECONDS);
assertEquals(1, actions.size(), "packet-thread invalidation must not perform delivery");
controller.run();
assertEquals(List.of("create:Viewer:Concealed", "refresh:Viewer:Concealed"), actions,
"source updates must coalesce into a main-thread refresh");
helmet.set(new ItemStack(Material.IRON_HELMET));
controller.run();
assertTrue(controller.snapshot().isEmpty());
assertEquals(List.of("create:Viewer:Concealed", "refresh:Viewer:Concealed", "restore:Viewer:Concealed"), actions);
assertEquals(1, snapshot.get(viewer.getUniqueId()).size(), "published snapshots cannot be mutated by later frames");
helmet.set(items.create());
controller.run();
controller.close();
assertTrue(controller.snapshot().isEmpty(), "disable must withdraw all projections");
assertEquals(5, actions.size());
assertEquals("restore:Viewer:Concealed", actions.getLast());
controller.close();
controller.run();
assertEquals(5, actions.size(), "closed controllers cannot resend or revive projections");
verify(target, never()).removePotionEffect(any());
verify(target, never()).setInvisible(anyBoolean());
verify(viewer, never()).hasLineOfSight(unearned);
verify(unearned, never()).hasLineOfSight(any(org.bukkit.entity.Entity.class));
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"range", "sight", "hidden", "spectator", "vanished", "untracked", "unloaded", "world", "death", "session", "capture-failure", "team-hidden"})
void platformChangesWithdrawTheProjectionWithoutLoadingChunksOrForcingVisibility(String change) throws Exception {
var world = mock(World.class);
when(world.getUID()).thenReturn(UUID.randomUUID());
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
var viewer = player(world, "Viewer", 1, 0);
var target = player(world, "Concealed", 42, 8);
when(target.getTrackedBy()).thenReturn(Set.of(viewer));
when(viewer.canSee(target)).thenReturn(true);
when(viewer.hasLineOfSight(target)).thenReturn(true);
var items = new EyeItems();
when(viewer.getInventory().getHelmet()).thenReturn(items.create());
var initial = new PersistentStealthState(Map.of(), Map.of())
.withEyeProgress(viewer.getUniqueId(), new EyeProgress(28_800_000, true));
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
var session = new java.util.concurrent.atomic.AtomicBoolean(true);
var restored = new AtomicInteger();
var delivery = new EyeRevealController.Delivery() {
@Override public void show(Player observer, Player subject, EyeRevealController.Projection projection, boolean create) {
assertFalse(projection.revealBody(), "identity concealment alone does not authorize changing body flags");
}
@Override public void restore(Player observer, Optional<Player> subject, EyeRevealController.Projection projection) {
assertSame(viewer, observer);
assertEquals(target.getUniqueId(), projection.targetId());
restored.incrementAndGet();
}
};
try (var states = new StealthStateManager(repository, initial)) {
var controller = new EyeRevealController(() -> List.of(viewer, target),
new EyeEquipment(new EyeProgressionService(states, () -> 0L, ignored -> { }), items),
id -> id.equals(target.getUniqueId()) && session.get(), delivery, () -> "eye1");
controller.run();
assertEquals(1, controller.snapshot().get(viewer.getUniqueId()).size());
switch (change) {
case "range" -> when(target.getLocation()).thenReturn(new Location(world, 16.001, 64, 0));
case "sight" -> when(viewer.hasLineOfSight(target)).thenReturn(false);
case "hidden" -> when(viewer.canSee(target)).thenReturn(false);
case "spectator" -> when(target.getGameMode()).thenReturn(GameMode.SPECTATOR);
case "vanished" -> {
var flag = mock(org.bukkit.metadata.MetadataValue.class);
when(flag.asBoolean()).thenReturn(true);
when(target.getMetadata("vanished")).thenReturn(List.of(flag));
}
case "untracked" -> when(target.getTrackedBy()).thenReturn(Set.of());
case "unloaded" -> when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(false);
case "world" -> {
var otherWorld = mock(World.class);
when(otherWorld.getUID()).thenReturn(UUID.randomUUID());
when(target.getLocation()).thenReturn(new Location(otherWorld, 8, 64, 0));
}
case "death" -> when(target.isDead()).thenReturn(true);
case "session" -> session.set(false);
case "capture-failure" -> when(target.getMetadata("vanished")).thenThrow(new IllegalStateException("Visibility state unavailable"));
case "team-hidden" -> {
var hidden = mock(org.bukkit.scoreboard.Team.class);
when(hidden.getOption(org.bukkit.scoreboard.Team.Option.NAME_TAG_VISIBILITY)).thenReturn(org.bukkit.scoreboard.Team.OptionStatus.NEVER);
when(viewer.getScoreboard().getEntryTeam(target.getName())).thenReturn(hidden);
}
default -> throw new AssertionError(change);
}
org.mockito.Mockito.<Object>clearInvocations(world, viewer, target);
controller.run();
assertTrue(controller.snapshot().isEmpty(), change);
assertEquals(1, restored.get());
if (!change.equals("sight")) { verify(viewer, never()).hasLineOfSight(target); }
verify(world, never()).getChunkAt(anyInt(), anyInt());
verify(world, never()).loadChunk(anyInt(), anyInt());
verify(viewer, never()).showPlayer(any(), any());
verify(target, never()).removePotionEffect(any());
verify(target, never()).setInvisible(anyBoolean());
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"pair", "viewer", "target", "hide-event", "untrack-event", "helmet-event", "spectator-event", "potion-event", "quit-event", "teleport-event", "respawn-event", "death-event", "world-event", "cancelled-teleport", "cancelled-mode", "cancelled-potion", "other-armor", "other-effect"})
void lifecycleInvalidationWithdrawsAuthorizationImmediatelyAndOnlyForAffectedViews(String change) throws Exception {
var world = mock(World.class);
when(world.getUID()).thenReturn(UUID.randomUUID());
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
var first = player(world, "First", 1, 0);
var second = player(world, "Second", 2, 0);
var target = player(world, "Concealed", 42, 8);
when(target.getTrackedBy()).thenReturn(Set.of(first, second));
var items = new EyeItems();
for (var viewer : List.of(first, second)) {
when(viewer.canSee(target)).thenReturn(true);
when(viewer.hasLineOfSight(target)).thenReturn(true);
when(viewer.getInventory().getHelmet()).thenReturn(items.create());
}
var initial = new PersistentStealthState(Map.of(), Map.of())
.withEyeProgress(first.getUniqueId(), new EyeProgress(28_800_000, true))
.withEyeProgress(second.getUniqueId(), new EyeProgress(28_800_000, true));
var restored = new ArrayList<UUID>();
var delivery = new EyeRevealController.Delivery() {
@Override public void show(Player observer, Player subject, EyeRevealController.Projection projection, boolean create) { }
@Override public void restore(Player observer, Optional<Player> subject, EyeRevealController.Projection projection) {
restored.add(observer.getUniqueId());
}
};
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
try (var states = new StealthStateManager(repository, initial)) {
var ids = new AtomicInteger();
var controller = new EyeRevealController(() -> List.of(first, second, target),
new EyeEquipment(new EyeProgressionService(states, () -> 0L, ignored -> { }), items),
id -> id.equals(target.getUniqueId()), delivery, () -> "eye" + ids.incrementAndGet());
controller.run();
assertEquals(2, controller.snapshot().size());
var events = new EyeRevealEvents(controller);
switch (change) {
case "cancelled-teleport" -> {
var event = new org.bukkit.event.player.PlayerTeleportEvent(first, first.getLocation(), target.getLocation());
event.setCancelled(true);
events.onTeleport(event);
}
case "cancelled-mode" -> {
var event = new org.bukkit.event.player.PlayerGameModeChangeEvent(target, GameMode.SPECTATOR);
event.setCancelled(true);
events.onGameMode(event);
}
case "cancelled-potion" -> {
var event = new org.bukkit.event.entity.EntityPotionEffectEvent(target,
new org.bukkit.potion.PotionEffect(PotionEffectType.INVISIBILITY, 100, 0), null, null,
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.MILK, org.bukkit.event.entity.EntityPotionEffectEvent.Action.REMOVED, false);
event.setCancelled(true);
events.onEffect(event);
}
case "other-armor" -> events.onArmor(new com.destroystokyo.paper.event.player.PlayerArmorChangeEvent(first,
com.destroystokyo.paper.event.player.PlayerArmorChangeEvent.SlotType.CHEST, new ItemStack(Material.AIR), new ItemStack(Material.IRON_CHESTPLATE)));
case "other-effect" -> events.onEffect(new org.bukkit.event.entity.EntityPotionEffectEvent(target, null,
new org.bukkit.potion.PotionEffect(PotionEffectType.NIGHT_VISION, 100, 0), null,
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.COMMAND, org.bukkit.event.entity.EntityPotionEffectEvent.Action.ADDED, false));
case "hide-event" -> events.onHide(new org.bukkit.event.player.PlayerHideEntityEvent(first, target));
case "untrack-event" -> events.onUntrack(new io.papermc.paper.event.player.PlayerUntrackEntityEvent(first, target));
case "helmet-event" -> events.onArmor(new com.destroystokyo.paper.event.player.PlayerArmorChangeEvent(first,
com.destroystokyo.paper.event.player.PlayerArmorChangeEvent.SlotType.HEAD, items.create(), new ItemStack(Material.AIR)));
case "spectator-event" -> events.onGameMode(new org.bukkit.event.player.PlayerGameModeChangeEvent(target, GameMode.SPECTATOR));
case "potion-event" -> events.onEffect(new org.bukkit.event.entity.EntityPotionEffectEvent(target,
new org.bukkit.potion.PotionEffect(PotionEffectType.INVISIBILITY, 100, 0), null, null,
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.MILK, org.bukkit.event.entity.EntityPotionEffectEvent.Action.REMOVED, false));
case "quit-event" -> events.onQuit(new org.bukkit.event.player.PlayerQuitEvent(first, net.kyori.adventure.text.Component.empty()));
case "teleport-event" -> events.onTeleport(new org.bukkit.event.player.PlayerTeleportEvent(first, first.getLocation(), target.getLocation()));
case "respawn-event" -> events.onRespawn(new org.bukkit.event.player.PlayerRespawnEvent(first, first.getLocation(), false));
case "death-event" -> events.onDeath(new org.bukkit.event.entity.PlayerDeathEvent(target, mock(org.bukkit.damage.DamageSource.class),
new ArrayList<>(), 0, net.kyori.adventure.text.Component.empty(), false));
case "world-event" -> events.onWorld(new org.bukkit.event.player.PlayerChangedWorldEvent(first, world));
case "pair" -> controller.hide(first.getUniqueId(), target.getUniqueId());
case "viewer" -> controller.withdrawViewer(first.getUniqueId());
case "target" -> controller.withdrawTarget(target.getUniqueId());
default -> throw new AssertionError(change);
}
if (change.startsWith("cancelled-") || change.startsWith("other-")) {
assertEquals(2, controller.snapshot().size());
assertTrue(restored.isEmpty(), "cancelled or unrelated events must not disturb valid projections");
return;
}
assertFalse(controller.snapshot().containsKey(first.getUniqueId()), "invalidation cannot wait for the next frame");
boolean allObservers = Set.of("target", "spectator-event", "potion-event", "death-event").contains(change);
assertEquals(allObservers ? 0 : 1, controller.snapshot().size());
assertEquals(allObservers ? Set.of(first.getUniqueId(), second.getUniqueId()) : Set.of(first.getUniqueId()), Set.copyOf(restored));
}
}
@Test
void failedDeliveryRevokesAuthorizationAndCleanupMustSucceedBeforeRetryingTheView() throws Exception {
var world = mock(World.class);
when(world.getUID()).thenReturn(UUID.randomUUID());
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
var viewer = player(world, "Viewer", 1, 0);
var target = player(world, "Concealed", 42, 8);
when(target.getTrackedBy()).thenReturn(Set.of(viewer));
when(viewer.canSee(target)).thenReturn(true);
when(viewer.hasLineOfSight(target)).thenReturn(true);
var items = new EyeItems();
when(viewer.getInventory().getHelmet()).thenReturn(items.create());
var initial = new PersistentStealthState(Map.of(), Map.of())
.withEyeProgress(viewer.getUniqueId(), new EyeProgress(28_800_000, true));
var shows = new AtomicInteger();
var restores = new AtomicInteger();
var failures = new ArrayList<Throwable>();
var delivery = new EyeRevealController.Delivery() {
@Override public void show(Player observer, Player subject, EyeRevealController.Projection projection, boolean create) {
assertTrue(create);
if (shows.incrementAndGet() == 1) { throw new IllegalStateException("Injected partial delivery failure"); }
}
@Override public void restore(Player observer, Optional<Player> subject, EyeRevealController.Projection projection) {
if (restores.incrementAndGet() == 1) { throw new IllegalStateException("Injected restoration failure"); }
}
};
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
try (var states = new StealthStateManager(repository, initial)) {
var ids = new AtomicInteger();
var controller = new EyeRevealController(() -> List.of(viewer, target),
new EyeEquipment(new EyeProgressionService(states, () -> 0L, ignored -> { }), items),
id -> id.equals(target.getUniqueId()), delivery, () -> "eye" + ids.incrementAndGet(), failures::add);
assertDoesNotThrow(controller::run);
assertTrue(controller.snapshot().isEmpty(), "failed delivery cannot retain outgoing reveal authorization");
assertDoesNotThrow(controller::run);
assertEquals(1, shows.get(), "failed cleanup must block recreation of the view");
assertTrue(controller.snapshot().isEmpty());
controller.run();
assertEquals(2, shows.get());
assertEquals(2, restores.get());
assertEquals(1, controller.snapshot().get(viewer.getUniqueId()).size());
assertFalse(failures.isEmpty());
controller.withdrawViewer(viewer.getUniqueId());
assertEquals(3, restores.get());
assertTrue(controller.snapshot().isEmpty());
controller.close();
}
}
private static Player player(World world, String name, int entityId, double x) {
var player = mock(Player.class);
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
when(player.getName()).thenReturn(name);
when(player.getEntityId()).thenReturn(entityId);
when(player.getLocation()).thenReturn(new Location(world, x, 64, 0));
when(player.isOnline()).thenReturn(true);
when(player.getGameMode()).thenReturn(GameMode.SURVIVAL);
when(player.getInventory()).thenReturn(mock(PlayerInventory.class));
when(player.getScoreboard()).thenReturn(mock(Scoreboard.class));
return player;
}
}
@@ -0,0 +1,169 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import com.comphenix.protocol.PacketType;
import com.comphenix.protocol.ProtocolManager;
import com.comphenix.protocol.events.PacketContainer;
import com.comphenix.protocol.events.PacketEvent;
import com.comphenix.protocol.events.PacketListener;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.Component;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket;
import net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket;
import net.minecraft.network.syncher.EntityDataAccessor;
import net.minecraft.network.syncher.SynchedEntityData;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.network.ServerGamePacketListenerImpl;
import net.minecraft.world.scores.Scoreboard;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.craftbukkit.CraftServer;
import org.bukkit.craftbukkit.entity.CraftPlayer;
import org.bukkit.craftbukkit.inventory.CraftInventoryPlayer;
import org.bukkit.craftbukkit.scheduler.CraftScheduler;
import org.bukkit.craftbukkit.scoreboard.CraftScoreboard;
import org.bukkit.event.Listener;
import org.bukkit.plugin.PluginManager;
import org.bukkit.plugin.java.JavaPlugin;
import org.bukkit.potion.PotionEffectType;
import org.bukkit.scheduler.BukkitTask;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class EyeRevealRuntimeTest {
@TempDir Path directory;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test
@SuppressWarnings({"try", "unchecked"})
void registeredRuntimeProjectsAndRestoresRealPacketsWithoutChangingTheServerOrIdentityChannels() throws Exception {
var server = mock(CraftServer.class);
try (var platform = mockStatic(org.bukkit.Bukkit.class, call -> switch (call.getMethod().getName()) {
case "getServer" -> server;
case "isPrimaryThread" -> true;
case "getVersion" -> "Purpur 2618 (MC: 26.2)";
case "getMinecraftVersion" -> "26.2";
case "getBukkitVersion" -> "26.2-R0.1-SNAPSHOT";
default -> call.callRealMethod();
})) {
var world = mock(World.class);
when(world.getUID()).thenReturn(UUID.randomUUID());
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
var viewer = mock(CraftPlayer.class);
var target = mock(CraftPlayer.class);
UUID viewerId = UUID.randomUUID(), targetId = UUID.randomUUID();
when(viewer.getUniqueId()).thenReturn(viewerId);
when(target.getUniqueId()).thenReturn(targetId);
when(viewer.getName()).thenReturn("Viewer");
when(target.getName()).thenReturn("Concealed");
when(viewer.isOnline()).thenReturn(true);
when(target.isOnline()).thenReturn(true);
when(viewer.getEntityId()).thenReturn(1);
when(target.getEntityId()).thenReturn(42);
when(viewer.getLocation()).thenReturn(new Location(world, 0, 64, 0));
when(target.getLocation()).thenReturn(new Location(world, 8, 64, 0));
when(viewer.canSee(target)).thenReturn(true);
when(viewer.hasLineOfSight(target)).thenReturn(true);
when(target.getTrackedBy()).thenReturn(Set.of(viewer));
when(target.hasPotionEffect(PotionEffectType.INVISIBILITY)).thenReturn(true);
var inventory = mock(CraftInventoryPlayer.class);
when(viewer.getInventory()).thenReturn(inventory);
var items = new EyeItems();
when(inventory.getHelmet()).thenReturn(items.create());
var board = new Scoreboard();
var baseline = board.addPlayerTeam("source");
baseline.setPlayerPrefix(Component.empty().withStyle(ChatFormatting.OBFUSCATED));
board.addPlayerToTeam("Concealed", baseline);
var constructor = CraftScoreboard.class.getDeclaredConstructor(Scoreboard.class);
constructor.setAccessible(true);
when(viewer.getScoreboard()).thenReturn(constructor.newInstance(board));
var viewerHandle = mock(ServerPlayer.class);
viewerHandle.connection = mock(ServerGamePacketListenerImpl.class);
when(viewer.getHandle()).thenReturn(viewerHandle);
var targetHandle = mock(ServerPlayer.class);
when(target.getHandle()).thenReturn(targetHandle);
var data = mock(SynchedEntityData.class);
when(targetHandle.getEntityData()).thenReturn(data);
var field = net.minecraft.world.entity.Entity.class.getDeclaredField("DATA_SHARED_FLAGS_ID");
field.setAccessible(true);
var flags = (EntityDataAccessor<Byte>) field.get(null);
when(data.get(flags)).thenReturn((byte) 0x60);
var protocol = mock(ProtocolManager.class);
var packetListeners = new ArrayList<PacketListener>();
doAnswer(call -> { packetListeners.add(call.getArgument(0)); return null; }).when(protocol).addPacketListener(any());
doAnswer(call -> { packetListeners.remove(call.getArgument(0)); return null; }).when(protocol).removePacketListener(any());
var sent = new ArrayList<Packet<?>>();
doAnswer(call -> {
Packet<?> raw = call.getArgument(0);
PacketType type = raw instanceof ClientboundSetPlayerTeamPacket ? PacketType.Play.Server.SCOREBOARD_TEAM : PacketType.Play.Server.ENTITY_METADATA;
var event = PacketEvent.fromServer(this, new PacketContainer(type, raw), viewer);
for (var listener : List.copyOf(packetListeners)) {
if (listener.getSendingWhitelist().getTypes().contains(type)) { listener.onPacketSending(event); }
}
if (!event.isCancelled()) { sent.add((Packet<?>) event.getPacket().getHandle()); }
return null;
}).when(viewerHandle.connection).send(org.mockito.ArgumentMatchers.<Packet<?>>any());
var plugin = mock(JavaPlugin.class);
when(plugin.getServer()).thenReturn(server);
when(plugin.isEnabled()).thenReturn(true);
when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger());
var manager = mock(PluginManager.class);
when(server.getPluginManager()).thenReturn(manager);
var listeners = new ArrayList<Listener>();
doAnswer(call -> { listeners.add(call.getArgument(0)); return null; }).when(manager).registerEvents(any(), eq(plugin));
doReturn(List.of(viewer, target)).when(server).getOnlinePlayers();
var scheduler = mock(CraftScheduler.class);
when(server.getScheduler()).thenReturn(scheduler);
var ticks = new ArrayList<Runnable>();
var task = mock(BukkitTask.class);
when(scheduler.runTaskTimer(eq(plugin), any(Runnable.class), eq(1L), eq(1L))).thenAnswer(call -> {
ticks.add(call.getArgument(1)); return task;
});
var initial = new PersistentStealthState(Map.of(), Map.of()).withEyeProgress(viewerId, new EyeProgress(28_800_000, true));
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
try (var states = new StealthStateManager(repository, initial)) {
var equipment = new EyeEquipment(new EyeProgressionService(states, () -> 0L, ignored -> { }), items);
var runtime = new EyeRevealRuntime(plugin, equipment, targetId::equals, protocol);
runtime.start();
assertEquals(1, ticks.size(), "the runtime must register a one-tick visibility loop");
ticks.getFirst().run();
var create = assertInstanceOf(ClientboundSetPlayerTeamPacket.class, sent.getFirst());
assertFalse(create.getParameters().orElseThrow().playerPrefix().getStyle().isObfuscated());
assertEquals((byte) 0x40, assertInstanceOf(ClientboundSetEntityDataPacket.class, sent.get(1)).packedItems().getFirst().value());
assertSame(baseline, board.getPlayersTeam("Concealed"));
assertNull(board.getPlayerTeam(create.getName()));
var events = assertInstanceOf(EyeRevealEvents.class, listeners.getFirst());
when(viewer.canSee(target)).thenReturn(false);
events.onHide(new org.bukkit.event.player.PlayerHideEntityEvent(viewer, target));
assertTrue(assertInstanceOf(ClientboundSetPlayerTeamPacket.class, sent.get(2)).getParameters().orElseThrow().playerPrefix().getStyle().isObfuscated());
assertEquals((byte) 0x60, assertInstanceOf(ClientboundSetEntityDataPacket.class, sent.get(3)).packedItems().getFirst().value());
ticks.getFirst().run();
assertEquals(7, sent.size(), "platform-hidden targets must remain hidden");
when(viewer.canSee(target)).thenReturn(true);
ticks.getFirst().run();
assertFalse(assertInstanceOf(ClientboundSetPlayerTeamPacket.class, sent.get(7)).getParameters().orElseThrow().playerPrefix().getStyle().isObfuscated());
runtime.close();
int count = sent.size();
ticks.getFirst().run();
assertEquals(count, sent.size());
assertTrue(packetListeners.isEmpty());
verify(task).cancel();
assertSame(baseline, board.getPlayersTeam("Concealed"));
assertEquals((byte) 0x60, data.get(flags));
verify(target, never()).removePotionEffect(any());
verify(target, never()).setInvisible(anyBoolean());
assertEquals(initial, states.snapshot());
}
}
}
}
@@ -0,0 +1,82 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import io.netty.buffer.Unpooled;
import java.util.Optional;
import net.minecraft.ChatFormatting;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.chat.Component;
import net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.Parameters;
import net.minecraft.world.scores.Team;
import net.minecraft.world.scores.TeamColor;
import org.bukkit.craftbukkit.CraftRegistry;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
class EyeTeamProjectionTest {
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test
void nativeTeamProjectionRemovesOnlyTheScramblingPrefixAndRoundTripsEveryOtherField() {
var config = new io.papermc.paper.configuration.GlobalConfiguration();
config.collisions = config.new Collisions();
config.collisions.enablePlayerCollisions = true;
try (var platform = org.mockito.Mockito.mockStatic(io.papermc.paper.configuration.GlobalConfiguration.class)) {
platform.when(io.papermc.paper.configuration.GlobalConfiguration::get).thenReturn(config);
assertProjection();
}
}
@Test
void privateTeamsRevealOnlyTheirTargetWhileSharedServerMembershipUpdatesStayMasked() {
var board = new net.minecraft.world.scores.Scoreboard();
var shared = board.addPlayerTeam("shared");
shared.setPlayerPrefix(Component.empty().withStyle(ChatFormatting.OBFUSCATED));
board.addPlayerToTeam("Alice", shared);
board.addPlayerToTeam("Bob", shared);
var packet = net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.createAddOrModifyPacket(shared, true);
var filtered = assertInstanceOf(net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.class,
EyePacketProjection.teamPacket(packet, java.util.Set.of("eye-private"), java.util.Set.of("Alice")));
assertEquals(java.util.Set.of("Bob"), java.util.Set.copyOf(filtered.getPlayers()),
"server membership updates must not pull an active Eye target out of its private team");
assertSame(packet.getParameters().orElseThrow(), filtered.getParameters().orElseThrow());
assertTrue(filtered.getParameters().orElseThrow().playerPrefix().getStyle().isObfuscated());
assertEquals(java.util.Set.of("Alice", "Bob"), java.util.Set.copyOf(packet.getPlayers()));
var isolated = board.addPlayerTeam("eye-private");
isolated.setPlayerPrefix(Component.empty().withStyle(ChatFormatting.OBFUSCATED));
board.addPlayerToTeam("Alice", isolated);
var privatePacket = net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.createAddOrModifyPacket(isolated, true);
var revealed = assertInstanceOf(net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.class,
EyePacketProjection.teamPacket(privatePacket, java.util.Set.of("eye-private"), java.util.Set.of("Alice")));
assertEquals(java.util.Set.of("Alice"), java.util.Set.copyOf(revealed.getPlayers()));
assertFalse(revealed.getParameters().orElseThrow().playerPrefix().getStyle().isObfuscated());
assertTrue(privatePacket.getParameters().orElseThrow().playerPrefix().getStyle().isObfuscated());
assertSame(privatePacket, EyePacketProjection.teamPacket(privatePacket, java.util.Set.of(), java.util.Set.of()),
"without current authorization even queued private packets must retain their original mask");
for (var action : net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.Action.values()) {
var membership = net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.createPlayerPacket(shared, "Alice", action);
var projected = assertInstanceOf(net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.class,
EyePacketProjection.teamPacket(membership, java.util.Set.of("eye-private"), java.util.Set.of("Alice")));
assertTrue(projected.getPlayers().isEmpty());
assertEquals(membership.getPlayerAction(), projected.getPlayerAction());
}
}
private static void assertProjection() {
var original = new Parameters(Component.literal("Keep team display"),
Component.empty().withStyle(ChatFormatting.OBFUSCATED), Component.literal("Keep suffix"),
Team.Visibility.ALWAYS, Team.CollisionRule.PUSH_OWN_TEAM, Optional.of(TeamColor.AQUA), (byte) 0x53);
var projected = assertInstanceOf(Parameters.class, EyePacketProjection.teamParameters(original));
var expected = new Parameters(original.displayName(), Component.empty(), original.playerSuffix(),
original.nameTagVisibility(), original.collisionRule(), original.color(), original.options());
assertEquals(expected, projected);
assertNotSame(original, projected);
assertTrue(original.playerPrefix().getStyle().isObfuscated(), "the shared server/team packet must remain unchanged");
var buffer = new RegistryFriendlyByteBuf(Unpooled.buffer(), CraftRegistry.getMinecraftRegistry());
try {
Parameters.STREAM_CODEC.encode(buffer, projected);
assertEquals(projected, Parameters.STREAM_CODEC.decode(buffer));
} finally { buffer.release(); }
}
}
@@ -0,0 +1,125 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.util.ArrayList;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.Component;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket;
import net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket;
import net.minecraft.network.syncher.EntityDataAccessor;
import net.minecraft.network.syncher.SynchedEntityData;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.network.ServerGamePacketListenerImpl;
import net.minecraft.world.scores.Scoreboard;
import net.minecraft.world.scores.TeamColor;
import org.bukkit.craftbukkit.entity.CraftPlayer;
import org.bukkit.craftbukkit.scoreboard.CraftScoreboard;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
class NativeEyeDeliveryTest {
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.CsvSource({"ALWAYS,false,true", "NEVER,false,false", "HIDE_FOR_OWN_TEAM,true,false",
"HIDE_FOR_OWN_TEAM,false,true", "HIDE_FOR_OTHER_TEAMS,true,true", "HIDE_FOR_OTHER_TEAMS,false,false"})
@SuppressWarnings("unchecked") // The declared runtime accessor's BYTE serializer is verified in the metadata codec tests.
void deliveryUsesOnlyTheViewerConnectionAndRestoresCurrentStateWithoutChangingServerTeamsOrFlags(
net.minecraft.world.scores.Team.Visibility visibility, boolean teammate, boolean visible) throws Exception {
var board = new Scoreboard();
var baseline = board.addPlayerTeam("source");
baseline.setPlayerPrefix(Component.empty().withStyle(ChatFormatting.OBFUSCATED));
baseline.setColor(Optional.of(TeamColor.AQUA));
baseline.setNameTagVisibility(visibility);
if (teammate) { board.addPlayerToTeam("Viewer", baseline); }
board.addPlayerToTeam("Concealed", baseline);
board.addPlayerToTeam("OtherConcealed", baseline);
var boardConstructor = CraftScoreboard.class.getDeclaredConstructor(Scoreboard.class);
boardConstructor.setAccessible(true);
var bukkitBoard = boardConstructor.newInstance(board);
var viewer = mock(CraftPlayer.class);
var viewerHandle = mock(ServerPlayer.class);
var connection = mock(ServerGamePacketListenerImpl.class);
viewerHandle.connection = connection;
when(viewer.getHandle()).thenReturn(viewerHandle);
when(viewer.getName()).thenReturn("Viewer");
when(viewer.getScoreboard()).thenReturn(bukkitBoard);
var sent = new ArrayList<Packet<?>>();
doAnswer(call -> { sent.add(call.getArgument(0)); return null; })
.when(connection).send(org.mockito.ArgumentMatchers.<Packet<?>>any());
var target = mock(CraftPlayer.class);
var targetHandle = mock(ServerPlayer.class);
when(target.getHandle()).thenReturn(targetHandle);
when(target.getEntityId()).thenReturn(42);
when(target.isOnline()).thenReturn(true);
var data = mock(SynchedEntityData.class);
when(targetHandle.getEntityData()).thenReturn(data);
var accessorField = net.minecraft.world.entity.Entity.class.getDeclaredField("DATA_SHARED_FLAGS_ID");
accessorField.setAccessible(true);
var flagsAccessor = (EntityDataAccessor<Byte>) accessorField.get(null);
var flags = new AtomicReference<>((byte) 0xa0);
when(data.get(flagsAccessor)).thenAnswer(ignored -> flags.get());
var projection = new EyeRevealController.Projection(UUID.randomUUID(), 42, "Concealed", "eye-private",
Optional.of("source"), true);
var delivery = new NativeEyeDelivery();
delivery.show(viewer, target, projection, true);
assertEquals(2, sent.size(), "a private team and raw metadata refresh are required");
var team = assertInstanceOf(ClientboundSetPlayerTeamPacket.class, sent.get(0));
assertEquals("eye-private", team.getName());
assertEquals(ClientboundSetPlayerTeamPacket.Action.ADD, team.getTeamAction());
assertEquals(java.util.List.of("Concealed"), java.util.List.copyOf(team.getPlayers()));
assertEquals(visible ? net.minecraft.world.scores.Team.Visibility.ALWAYS : net.minecraft.world.scores.Team.Visibility.NEVER,
team.getParameters().orElseThrow().nameTagVisibility(), "private teams must preserve effective visibility, not change team relationships");
assertTrue(team.getParameters().orElseThrow().playerPrefix().getStyle().isObfuscated(),
"raw state must reach the outgoing authorization gate before any prefix is revealed");
var metadata = assertInstanceOf(ClientboundSetEntityDataPacket.class, sent.get(1));
assertEquals(42, metadata.id());
assertEquals((byte) 0xa0, metadata.packedItems().getFirst().value());
assertNull(board.getPlayerTeam("eye-private"));
assertEquals(teammate ? Set.of("Viewer", "Concealed", "OtherConcealed") : Set.of("Concealed", "OtherConcealed"), Set.copyOf(baseline.getPlayers()));
assertEquals((byte) 0xa0, flags.get());
// Another plugin can move the actor to a different real team while the private view is active.
var replacement = board.addPlayerTeam("replacement");
replacement.setPlayerPrefix(Component.empty().withStyle(ChatFormatting.OBFUSCATED));
replacement.setColor(Optional.of(TeamColor.GOLD));
replacement.setNameTagVisibility(visibility);
board.addPlayerToTeam("Concealed", replacement);
flags.set((byte) 9);
sent.clear();
delivery.restore(viewer, Optional.of(target), projection);
assertEquals(5, sent.size(), "restore the mask and flags before moving or removing client team membership");
var privateMask = assertInstanceOf(ClientboundSetPlayerTeamPacket.class, sent.get(0));
assertEquals("eye-private", privateMask.getName());
assertTrue(privateMask.getParameters().orElseThrow().playerPrefix().getStyle().isObfuscated());
boolean nowVisible = visibility == net.minecraft.world.scores.Team.Visibility.ALWAYS
|| visibility == net.minecraft.world.scores.Team.Visibility.HIDE_FOR_OWN_TEAM;
assertEquals(nowVisible ? net.minecraft.world.scores.Team.Visibility.ALWAYS : net.minecraft.world.scores.Team.Visibility.NEVER,
privateMask.getParameters().orElseThrow().nameTagVisibility());
var restoredFlags = assertInstanceOf(ClientboundSetEntityDataPacket.class, sent.get(1));
assertEquals((byte) 9, restoredFlags.packedItems().getFirst().value());
var restoredTeam = assertInstanceOf(ClientboundSetPlayerTeamPacket.class, sent.get(2));
assertEquals("replacement", restoredTeam.getName());
assertEquals(Optional.of(TeamColor.GOLD), restoredTeam.getParameters().orElseThrow().color());
assertEquals(visibility, restoredTeam.getParameters().orElseThrow().nameTagVisibility());
var membership = assertInstanceOf(ClientboundSetPlayerTeamPacket.class, sent.get(3));
assertEquals("replacement", membership.getName());
assertEquals(ClientboundSetPlayerTeamPacket.Action.ADD, membership.getPlayerAction());
assertEquals(java.util.List.of("Concealed"), java.util.List.copyOf(membership.getPlayers()));
var removal = assertInstanceOf(ClientboundSetPlayerTeamPacket.class, sent.get(4));
assertEquals("eye-private", removal.getName());
assertEquals(ClientboundSetPlayerTeamPacket.Action.REMOVE, removal.getTeamAction());
assertSame(replacement, board.getPlayersTeam("Concealed"));
assertEquals(teammate ? Set.of("Viewer", "OtherConcealed") : Set.of("OtherConcealed"), Set.copyOf(baseline.getPlayers()));
assertNull(board.getPlayerTeam("eye-private"));
verify(data, never()).set(any(), any());
verify(connection, times(7)).send(org.mockito.ArgumentMatchers.<Packet<?>>any());
verifyNoMoreInteractions(connection);
}
}
@@ -0,0 +1,47 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class EyeVisibilityTest {
private final UUID world = UUID.randomUUID();
private final UUID viewerId = UUID.randomUUID();
private final UUID targetId = UUID.randomUUID();
@Test
void personalEligibilityConcealmentVisibilityAndSightMustAllPermitTheReveal() {
var position = new EyeVisibility.Position(world, 0, 64, 0);
var viewer = new EyeVisibility.Viewer(viewerId, position, true);
var target = new EyeVisibility.Target(targetId, position, true, false, false);
assertFalse(EyeVisibility.canReveal(new EyeVisibility.Viewer(viewerId, position, false), target, true, true),
"an unearned or unequipped observer cannot reveal a target");
assertFalse(EyeVisibility.canReveal(viewer, new EyeVisibility.Target(targetId, position, false, false, false), true, true));
assertFalse(EyeVisibility.canReveal(viewer, new EyeVisibility.Target(targetId, position, true, true, false), true, true),
"the Eye must not bypass spectator hiding");
assertFalse(EyeVisibility.canReveal(viewer, new EyeVisibility.Target(targetId, position, true, false, true), true, true),
"the Eye must not bypass administrative vanish");
assertFalse(EyeVisibility.canReveal(viewer, target, false, true), "platform visibility denial takes precedence");
assertFalse(EyeVisibility.canReveal(viewer, target, true, false), "walls must block revelation");
assertFalse(EyeVisibility.canReveal(viewer, new EyeVisibility.Target(viewerId, position, true, false, false), true, true));
assertFalse(EyeVisibility.canReveal(viewer, new EyeVisibility.Target(targetId,
new EyeVisibility.Position(UUID.randomUUID(), 0, 64, 0), true, false, false), true, true));
assertFalse(EyeVisibility.canReveal(viewer, new EyeVisibility.Target(targetId,
new EyeVisibility.Position(world, Double.NaN, 64, 0), true, false, false), true, true));
assertTrue(EyeVisibility.canReveal(viewer, target, true, true));
}
@Test
void anEligibleWearerCanRevealAtTheInclusiveThreeDimensionalSixteenBlockBoundary() {
var viewer = new EyeVisibility.Viewer(viewerId, new EyeVisibility.Position(world, 0, 64, 0), true);
var horizontal = new EyeVisibility.Target(targetId, new EyeVisibility.Position(world, 16, 64, 0), true, false, false);
var vertical = new EyeVisibility.Target(targetId, new EyeVisibility.Position(world, 0, 80, 0), true, false, false);
assertTrue(EyeVisibility.canReveal(viewer, horizontal, true, true));
assertTrue(EyeVisibility.canReveal(viewer, vertical, true, true));
var diagonal = new EyeVisibility.Target(targetId, new EyeVisibility.Position(world, 12, 76, 0), true, false, false);
var beyond = new EyeVisibility.Target(targetId, new EyeVisibility.Position(world, 16.001, 64, 0), true, false, false);
assertFalse(EyeVisibility.canReveal(viewer, diagonal, true, true));
assertFalse(EyeVisibility.canReveal(viewer, beyond, true, true));
}
}