From 0499a31f712d7d338eed6f96a467b444fc34d63f Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Sat, 12 Sep 2026 20:05:37 -0400 Subject: [PATCH] feat(stealth): reveal nearby players to eligible Eye wearers 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. --- README.md | 6 +- .../dmg/spigotstealth/EyePacketListener.java | 66 ++++ .../spigotstealth/EyePacketProjection.java | 115 ++++++ .../spigotstealth/EyeRevealController.java | 233 ++++++++++++ .../dmg/spigotstealth/EyeRevealEvents.java | 57 +++ .../dmg/spigotstealth/EyeRevealRuntime.java | 58 +++ .../dmg/spigotstealth/EyeTeamVisibility.java | 20 ++ .../dmg/spigotstealth/EyeVisibility.java | 21 ++ .../dmg/spigotstealth/NativeEyeDelivery.java | 108 ++++++ .../spigotstealth/SpigotStealthPlugin.java | 13 +- .../EyeMetadataProjectionTest.java | 58 +++ .../spigotstealth/EyePacketListenerTest.java | 122 +++++++ .../spigotstealth/EyePluginLifecycleTest.java | 5 + .../EyeRevealControllerTest.java | 335 ++++++++++++++++++ .../spigotstealth/EyeRevealRuntimeTest.java | 169 +++++++++ .../spigotstealth/EyeTeamProjectionTest.java | 82 +++++ .../spigotstealth/NativeEyeDeliveryTest.java | 125 +++++++ .../dmg/spigotstealth/EyeVisibilityTest.java | 47 +++ 18 files changed, 1637 insertions(+), 3 deletions(-) create mode 100644 src/main/java/games/dmg/spigotstealth/EyePacketListener.java create mode 100644 src/main/java/games/dmg/spigotstealth/EyePacketProjection.java create mode 100644 src/main/java/games/dmg/spigotstealth/EyeRevealController.java create mode 100644 src/main/java/games/dmg/spigotstealth/EyeRevealEvents.java create mode 100644 src/main/java/games/dmg/spigotstealth/EyeRevealRuntime.java create mode 100644 src/main/java/games/dmg/spigotstealth/EyeTeamVisibility.java create mode 100644 src/main/java/games/dmg/spigotstealth/EyeVisibility.java create mode 100644 src/main/java/games/dmg/spigotstealth/NativeEyeDelivery.java create mode 100644 src/nativeTest/java/games/dmg/spigotstealth/EyeMetadataProjectionTest.java create mode 100644 src/nativeTest/java/games/dmg/spigotstealth/EyePacketListenerTest.java create mode 100644 src/nativeTest/java/games/dmg/spigotstealth/EyeRevealControllerTest.java create mode 100644 src/nativeTest/java/games/dmg/spigotstealth/EyeRevealRuntimeTest.java create mode 100644 src/nativeTest/java/games/dmg/spigotstealth/EyeTeamProjectionTest.java create mode 100644 src/nativeTest/java/games/dmg/spigotstealth/NativeEyeDeliveryTest.java create mode 100644 src/test/java/games/dmg/spigotstealth/EyeVisibilityTest.java diff --git a/README.md b/README.md index c63c330..07ab06f 100644 --- a/README.md +++ b/README.md @@ -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. -**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. @@ -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. -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). diff --git a/src/main/java/games/dmg/spigotstealth/EyePacketListener.java b/src/main/java/games/dmg/spigotstealth/EyePacketListener.java new file mode 100644 index 0000000..538011a --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/EyePacketListener.java @@ -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>> views; + private final Consumer refresh; + private final Consumer warning; + private final Set warned = ConcurrentHashMap.newKeySet(); + + public EyePacketListener(Plugin plugin, Supplier>> views, + Consumer refresh, Consumer 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()); + } + } + } +} diff --git a/src/main/java/games/dmg/spigotstealth/EyePacketProjection.java b/src/main/java/games/dmg/spigotstealth/EyePacketProjection.java new file mode 100644 index 0000000..f6870f0 --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/EyePacketProjection.java @@ -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 privateTeams, java.util.Set 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(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 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); + } + } +} diff --git a/src/main/java/games/dmg/spigotstealth/EyeRevealController.java b/src/main/java/games/dmg/spigotstealth/EyeRevealController.java new file mode 100644 index 0000000..4f8eba7 --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/EyeRevealController.java @@ -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 sourceTeam, boolean revealBody) { } + public interface Delivery { + void show(Player viewer, Player target, Projection projection, boolean create); + void restore(Player viewer, Optional target, Projection projection); + } + + private final Supplier> onlinePlayers; + private final EyeEquipment equipment; + private final Predicate concealed; + private final Delivery delivery; + private final Supplier teamNames; + private final java.util.function.Consumer failure; + private record RestoreKey(UUID viewer, String team) { } + private record TargetKey(UUID viewer, UUID target) { } + private final Map restoring = new java.util.LinkedHashMap<>(); + private final Set warned = new HashSet<>(); + private volatile Map> views = Map.of(); + private volatile boolean closed; + private final Set dirty = java.util.concurrent.ConcurrentHashMap.newKeySet(); + + public EyeRevealController(Supplier> onlinePlayers, EyeEquipment equipment, + Predicate concealed, Delivery delivery, Supplier 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> onlinePlayers, EyeEquipment equipment, + Predicate concealed, Delivery delivery, Supplier teamNames, java.util.function.Consumer 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> 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 online = List.copyOf(onlinePlayers.get()); + var players = new HashMap(); + online.forEach(player -> players.put(player.getUniqueId(), player)); + var attemptedRestores = new HashSet(); + 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> previous = views; + var usedNames = new HashSet(); + previous.values().forEach(view -> view.values().forEach(projection -> usedNames.add(projection.privateTeam()))); + restoring.values().forEach(projection -> usedNames.add(projection.privateTeam())); + var next = new HashMap>(); + 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(); + 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 remove) { + var previous = views; + var next = new HashMap>(); + previous.forEach((viewer, targets) -> { + var retained = new HashMap(); + 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(); + 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 players, Set 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 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; + } +} diff --git a/src/main/java/games/dmg/spigotstealth/EyeRevealEvents.java b/src/main/java/games/dmg/spigotstealth/EyeRevealEvents.java new file mode 100644 index 0000000..3c0f77c --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/EyeRevealEvents.java @@ -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(); + } +} diff --git a/src/main/java/games/dmg/spigotstealth/EyeRevealRuntime.java b/src/main/java/games/dmg/spigotstealth/EyeRevealRuntime.java new file mode 100644 index 0000000..3ae715c --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/EyeRevealRuntime.java @@ -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 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); + } + } +} diff --git a/src/main/java/games/dmg/spigotstealth/EyeTeamVisibility.java b/src/main/java/games/dmg/spigotstealth/EyeTeamVisibility.java new file mode 100644 index 0000000..0cc45e4 --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/EyeTeamVisibility.java @@ -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; + }; + } +} diff --git a/src/main/java/games/dmg/spigotstealth/EyeVisibility.java b/src/main/java/games/dmg/spigotstealth/EyeVisibility.java new file mode 100644 index 0000000..28bcebb --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/EyeVisibility.java @@ -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; + } +} diff --git a/src/main/java/games/dmg/spigotstealth/NativeEyeDelivery.java b/src/main/java/games/dmg/spigotstealth/NativeEyeDelivery.java new file mode 100644 index 0000000..965afa6 --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/NativeEyeDelivery.java @@ -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 target, EyeRevealController.Projection projection) { + try { + var packets = new ArrayList(); + 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); + } +} diff --git a/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java b/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java index 71b2a67..704d4d2 100644 --- a/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java +++ b/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java @@ -22,6 +22,7 @@ public final class SpigotStealthPlugin extends JavaPlugin { private EyeProgressionService eyeProgression; private EyePotionListener eyePotions; private boolean eyeRecipeRegistered; + private EyeRevealRuntime eyeReveal; @Override public void onEnable() { @@ -68,6 +69,7 @@ public final class SpigotStealthPlugin extends JavaPlugin { logSaveFailure(eyeProgression.stopAll()); } if (progression != null) { logSaveFailure(progression.stopAll()); } + if (eyeReveal != null) { eyeReveal.close(); } if (protocolManager != null) { protocolManager.removePacketListeners(this); } if (identityPresentation != null && sessions != null) { for (org.bukkit.entity.Player player : getServer().getOnlinePlayers()) { @@ -166,7 +168,16 @@ public final class SpigotStealthPlugin extends JavaPlugin { eyeRecipeRegistered = true; getServer().getPluginManager().registerEvents(eyePotions, 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 -> { eyePotions.checkpoint(getServer()::getPlayer); for (var player : getServer().getOnlinePlayers()) { diff --git a/src/nativeTest/java/games/dmg/spigotstealth/EyeMetadataProjectionTest.java b/src/nativeTest/java/games/dmg/spigotstealth/EyeMetadataProjectionTest.java new file mode 100644 index 0000000..22b641b --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotstealth/EyeMetadataProjectionTest.java @@ -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(); } + } + } +} diff --git a/src/nativeTest/java/games/dmg/spigotstealth/EyePacketListenerTest.java b/src/nativeTest/java/games/dmg/spigotstealth/EyePacketListenerTest.java new file mode 100644 index 0000000..070957d --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotstealth/EyePacketListenerTest.java @@ -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(); + var dirty = new ArrayList(); + 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)); + } + } +} diff --git a/src/nativeTest/java/games/dmg/spigotstealth/EyePluginLifecycleTest.java b/src/nativeTest/java/games/dmg/spigotstealth/EyePluginLifecycleTest.java index 8e687cd..cfab09c 100644 --- a/src/nativeTest/java/games/dmg/spigotstealth/EyePluginLifecycleTest.java +++ b/src/nativeTest/java/games/dmg/spigotstealth/EyePluginLifecycleTest.java @@ -100,6 +100,9 @@ class EyePluginLifecycleTest { default -> call.callRealMethod(); }); var protocol = mockStatic(ProtocolLibrary.class)) { var protocolManager = mock(ProtocolManager.class); + var registeredPackets = new ArrayList(); + 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); try { plugin.onEnable(); @@ -133,6 +136,8 @@ class EyePluginLifecycleTest { assertTrue(new EyeItems().isEye(recipe.get().getResult())); assertTrue(listeners.stream().anyMatch(EyePotionListener.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); when(inventory.getRecipe()).thenAnswer(ignored -> recipe.get()); var result = new AtomicReference<>(recipe.get().getResult()); diff --git a/src/nativeTest/java/games/dmg/spigotstealth/EyeRevealControllerTest.java b/src/nativeTest/java/games/dmg/spigotstealth/EyeRevealControllerTest.java new file mode 100644 index 0000000..d6f4de4 --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotstealth/EyeRevealControllerTest.java @@ -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(); + var controllerRef = new AtomicReference(); + 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 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 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.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(); + 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 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(); + 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 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; + } +} diff --git a/src/nativeTest/java/games/dmg/spigotstealth/EyeRevealRuntimeTest.java b/src/nativeTest/java/games/dmg/spigotstealth/EyeRevealRuntimeTest.java new file mode 100644 index 0000000..454db71 --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotstealth/EyeRevealRuntimeTest.java @@ -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) field.get(null); + when(data.get(flags)).thenReturn((byte) 0x60); + var protocol = mock(ProtocolManager.class); + var packetListeners = new ArrayList(); + 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>(); + 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.>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(); + 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(); + 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()); + } + } + } +} diff --git a/src/nativeTest/java/games/dmg/spigotstealth/EyeTeamProjectionTest.java b/src/nativeTest/java/games/dmg/spigotstealth/EyeTeamProjectionTest.java new file mode 100644 index 0000000..21dba4d --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotstealth/EyeTeamProjectionTest.java @@ -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(); } + } +} diff --git a/src/nativeTest/java/games/dmg/spigotstealth/NativeEyeDeliveryTest.java b/src/nativeTest/java/games/dmg/spigotstealth/NativeEyeDeliveryTest.java new file mode 100644 index 0000000..369af3a --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotstealth/NativeEyeDeliveryTest.java @@ -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>(); + doAnswer(call -> { sent.add(call.getArgument(0)); return null; }) + .when(connection).send(org.mockito.ArgumentMatchers.>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) 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.>any()); + verifyNoMoreInteractions(connection); + } +} diff --git a/src/test/java/games/dmg/spigotstealth/EyeVisibilityTest.java b/src/test/java/games/dmg/spigotstealth/EyeVisibilityTest.java new file mode 100644 index 0000000..cd77f07 --- /dev/null +++ b/src/test/java/games/dmg/spigotstealth/EyeVisibilityTest.java @@ -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)); + } +}