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.
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import com.comphenix.protocol.PacketType;
|
||||
import com.comphenix.protocol.events.ListenerPriority;
|
||||
import com.comphenix.protocol.events.PacketAdapter;
|
||||
import com.comphenix.protocol.events.PacketContainer;
|
||||
import com.comphenix.protocol.events.PacketEvent;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
/** Outgoing packet projection reads immutable authorization and never queries world or inventory state. */
|
||||
public final class EyePacketListener extends PacketAdapter {
|
||||
private final Supplier<Map<UUID, Map<Integer, EyeRevealController.Projection>>> views;
|
||||
private final Consumer<UUID> refresh;
|
||||
private final Consumer<String> warning;
|
||||
private final Set<PacketType> warned = ConcurrentHashMap.newKeySet();
|
||||
|
||||
public EyePacketListener(Plugin plugin, Supplier<Map<UUID, Map<Integer, EyeRevealController.Projection>>> views,
|
||||
Consumer<UUID> refresh, Consumer<String> warning) {
|
||||
super(plugin, ListenerPriority.HIGHEST, PacketType.Play.Server.ENTITY_METADATA, PacketType.Play.Server.SCOREBOARD_TEAM);
|
||||
this.views = Objects.requireNonNull(views);
|
||||
this.refresh = Objects.requireNonNull(refresh);
|
||||
this.warning = Objects.requireNonNull(warning);
|
||||
}
|
||||
|
||||
@Override public void onPacketSending(PacketEvent event) {
|
||||
if (event.isCancelled() || event.getPlayer() == null || event.isPlayerTemporary()) { return; }
|
||||
try {
|
||||
// UUID is immutable connection identity; no inventory, world, visibility or effect queries occur here.
|
||||
UUID viewer = event.getPlayer().getUniqueId();
|
||||
if (viewer == null) { return; }
|
||||
var current = views.get().getOrDefault(viewer, Map.of());
|
||||
if (current.isEmpty()) { return; }
|
||||
Object original = event.getPacket().getHandle();
|
||||
if (event.getPacketType().equals(PacketType.Play.Server.ENTITY_METADATA)) {
|
||||
int id = (Integer) original.getClass().getMethod("id").invoke(original);
|
||||
var projection = current.get(id);
|
||||
if (projection != null && projection.revealBody()) {
|
||||
event.setPacket(new PacketContainer(event.getPacketType(), EyePacketProjection.metadataPacket(original)));
|
||||
}
|
||||
} else if (event.getPacketType().equals(PacketType.Play.Server.SCOREBOARD_TEAM)) {
|
||||
String name = (String) original.getClass().getMethod("getName").invoke(original);
|
||||
var privateTeams = current.values().stream().map(EyeRevealController.Projection::privateTeam)
|
||||
.collect(java.util.stream.Collectors.toUnmodifiableSet());
|
||||
var names = current.values().stream().map(EyeRevealController.Projection::playerName)
|
||||
.collect(java.util.stream.Collectors.toUnmodifiableSet());
|
||||
if (!privateTeams.contains(name) && current.values().stream().anyMatch(p -> p.sourceTeam().filter(name::equals).isPresent())) {
|
||||
refresh.accept(viewer); // Only marks work; the callback must not query Bukkit here.
|
||||
}
|
||||
Object projected = EyePacketProjection.teamPacket(original, privateTeams, names);
|
||||
if (projected != original) { event.setPacket(new PacketContainer(event.getPacketType(), projected)); }
|
||||
}
|
||||
} catch (ReflectiveOperationException | RuntimeException exception) {
|
||||
if (warned.add(event.getPacketType())) {
|
||||
warning.accept("Eye projection unavailable for " + event.getPacketType()
|
||||
+ "; original packet retained. Failure: " + exception.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import java.lang.reflect.RecordComponent;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Pure copies of declared-runtime packet data; no Bukkit lookups or global entity/team mutations. */
|
||||
public final class EyePacketProjection {
|
||||
private static final String TEAM_PARAMETERS = "net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket$Parameters";
|
||||
private EyePacketProjection() { }
|
||||
|
||||
public static Object teamPacket(Object original, java.util.Set<String> privateTeams, java.util.Set<String> projectedPlayers) {
|
||||
Objects.requireNonNull(original, "original");
|
||||
Class<?> type = original.getClass();
|
||||
if (!type.getName().equals("net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket")) {
|
||||
throw new IllegalArgumentException("Unsupported native team packet");
|
||||
}
|
||||
try {
|
||||
String name = (String) type.getMethod("getName").invoke(original);
|
||||
var players = (java.util.Collection<?>) type.getMethod("getPlayers").invoke(original);
|
||||
var parameters = (java.util.Optional<?>) type.getMethod("getParameters").invoke(original);
|
||||
boolean privateTeam = privateTeams.contains(name);
|
||||
var projectedParameters = privateTeam ? parameters.map(EyePacketProjection::teamParameters) : parameters;
|
||||
var projectedMembers = privateTeam ? players : players.stream().filter(player -> !projectedPlayers.contains(player)).toList();
|
||||
if (projectedParameters == parameters && projectedMembers.size() == players.size()) { return original; }
|
||||
var method = type.getDeclaredField("method");
|
||||
method.setAccessible(true);
|
||||
var constructor = type.getDeclaredConstructor(String.class, int.class, java.util.Optional.class, java.util.Collection.class);
|
||||
constructor.setAccessible(true);
|
||||
return constructor.newInstance(name, method.getInt(original), projectedParameters, projectedMembers);
|
||||
} catch (ReflectiveOperationException exception) {
|
||||
throw new IllegalStateException("Could not project native team membership", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public static Object metadataPacket(Object original) {
|
||||
Objects.requireNonNull(original, "original");
|
||||
Class<?> type = original.getClass();
|
||||
if (!type.getName().equals("net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket")) {
|
||||
throw new IllegalArgumentException("Unsupported native metadata packet");
|
||||
}
|
||||
try {
|
||||
var values = (List<?>) type.getMethod("packedItems").invoke(original);
|
||||
var projected = new ArrayList<Object>(values.size());
|
||||
for (Object value : values) {
|
||||
Class<?> valueType = value.getClass();
|
||||
if (!valueType.getName().equals("net.minecraft.network.syncher.SynchedEntityData$DataValue")) {
|
||||
throw new IllegalArgumentException("Unsupported native metadata value");
|
||||
}
|
||||
int index = (Integer) valueType.getMethod("id").invoke(value);
|
||||
if (index != 0) { projected.add(value); continue; }
|
||||
Object raw = valueType.getMethod("value").invoke(value);
|
||||
if (!(raw instanceof Byte flags)) { throw new IllegalArgumentException("Shared entity flags must be a byte"); }
|
||||
Object serializer = valueType.getMethod("serializer").invoke(value);
|
||||
projected.add(valueType.getConstructor(int.class,
|
||||
Class.forName("net.minecraft.network.syncher.EntityDataSerializer"), Object.class)
|
||||
.newInstance(index, serializer, (byte) (flags & ~0x20)));
|
||||
}
|
||||
return type.getConstructor(int.class, List.class)
|
||||
.newInstance(type.getMethod("id").invoke(original), List.copyOf(projected));
|
||||
} catch (ReflectiveOperationException exception) {
|
||||
throw new IllegalStateException("Could not project native entity metadata", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public static Object teamParameters(Object original) {
|
||||
try {
|
||||
Object empty = Class.forName("net.minecraft.network.chat.Component").getMethod("empty").invoke(null);
|
||||
return copyParameters(original, java.util.Map.of("playerPrefix", empty));
|
||||
} catch (ReflectiveOperationException exception) {
|
||||
throw new IllegalStateException("Could not project the native team prefix", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public static Object teamVisibility(Object original, boolean visible) {
|
||||
try {
|
||||
Object visibility = Class.forName("net.minecraft.world.scores.Team$Visibility").getField(visible ? "ALWAYS" : "NEVER").get(null);
|
||||
return copyParameters(original, java.util.Map.of("nameTagVisibility", visibility));
|
||||
} catch (ReflectiveOperationException exception) {
|
||||
throw new IllegalStateException("Could not preserve effective team visibility", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static Object copyParameters(Object original, java.util.Map<String, Object> replacements) {
|
||||
Objects.requireNonNull(original, "original");
|
||||
Class<?> type = original.getClass();
|
||||
if (!TEAM_PARAMETERS.equals(type.getName()) || !type.isRecord()) {
|
||||
throw new IllegalArgumentException("Unsupported native team parameter layout");
|
||||
}
|
||||
try {
|
||||
RecordComponent[] components = type.getRecordComponents();
|
||||
Class<?>[] types = new Class<?>[components.length];
|
||||
Object[] values = new Object[components.length];
|
||||
int replaced = 0;
|
||||
for (int index = 0; index < components.length; index++) {
|
||||
var component = components[index];
|
||||
types[index] = component.getType();
|
||||
if (replacements.containsKey(component.getName())) {
|
||||
values[index] = replacements.get(component.getName());
|
||||
replaced++;
|
||||
} else {
|
||||
// Accessors may consult global collision configuration. Copy the stored record data verbatim.
|
||||
var field = type.getDeclaredField(component.getName());
|
||||
field.setAccessible(true);
|
||||
values[index] = field.get(original);
|
||||
}
|
||||
}
|
||||
if (replaced != replacements.size()) { throw new IllegalArgumentException("Required native team fields are unavailable"); }
|
||||
return type.getDeclaredConstructor(types).newInstance(values);
|
||||
} catch (ReflectiveOperationException exception) {
|
||||
throw new IllegalStateException("Could not project the native team prefix", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.GameMode;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.metadata.MetadataValue;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
|
||||
/** Server-thread visibility capture and delivery; published projections contain no mutable Bukkit objects. */
|
||||
public final class EyeRevealController implements Runnable, AutoCloseable {
|
||||
public record Projection(UUID targetId, int entityId, String playerName, String privateTeam,
|
||||
Optional<String> sourceTeam, boolean revealBody) { }
|
||||
public interface Delivery {
|
||||
void show(Player viewer, Player target, Projection projection, boolean create);
|
||||
void restore(Player viewer, Optional<Player> target, Projection projection);
|
||||
}
|
||||
|
||||
private final Supplier<? extends Collection<? extends Player>> onlinePlayers;
|
||||
private final EyeEquipment equipment;
|
||||
private final Predicate<UUID> concealed;
|
||||
private final Delivery delivery;
|
||||
private final Supplier<String> teamNames;
|
||||
private final java.util.function.Consumer<Throwable> failure;
|
||||
private record RestoreKey(UUID viewer, String team) { }
|
||||
private record TargetKey(UUID viewer, UUID target) { }
|
||||
private final Map<RestoreKey, Projection> restoring = new java.util.LinkedHashMap<>();
|
||||
private final Set<UUID> warned = new HashSet<>();
|
||||
private volatile Map<UUID, Map<Integer, Projection>> views = Map.of();
|
||||
private volatile boolean closed;
|
||||
private final Set<UUID> dirty = java.util.concurrent.ConcurrentHashMap.newKeySet();
|
||||
|
||||
public EyeRevealController(Supplier<? extends Collection<? extends Player>> onlinePlayers, EyeEquipment equipment,
|
||||
Predicate<UUID> concealed, Delivery delivery, Supplier<String> teamNames) {
|
||||
this(onlinePlayers, equipment, concealed, delivery, teamNames,
|
||||
error -> java.util.logging.Logger.getLogger(EyeRevealController.class.getName())
|
||||
.warning("Eye delivery failed; cleanup queued: " + error.getClass().getSimpleName()));
|
||||
}
|
||||
|
||||
public EyeRevealController(Supplier<? extends Collection<? extends Player>> onlinePlayers, EyeEquipment equipment,
|
||||
Predicate<UUID> concealed, Delivery delivery, Supplier<String> teamNames, java.util.function.Consumer<Throwable> failure) {
|
||||
this.onlinePlayers = Objects.requireNonNull(onlinePlayers);
|
||||
this.equipment = Objects.requireNonNull(equipment);
|
||||
this.concealed = Objects.requireNonNull(concealed);
|
||||
this.delivery = Objects.requireNonNull(delivery);
|
||||
this.teamNames = Objects.requireNonNull(teamNames);
|
||||
this.failure = Objects.requireNonNull(failure);
|
||||
}
|
||||
|
||||
public Map<UUID, Map<Integer, Projection>> snapshot() { return views; }
|
||||
|
||||
/** Packet-thread safe: defer all Bukkit access and actual delivery to the next server-thread frame. */
|
||||
public void requestRefresh(UUID viewer) {
|
||||
if (!closed) { dirty.add(Objects.requireNonNull(viewer)); }
|
||||
}
|
||||
|
||||
@Override public void run() {
|
||||
if (closed) { return; }
|
||||
var requested = Set.copyOf(dirty);
|
||||
requested.forEach(dirty::remove);
|
||||
List<? extends Player> online = List.copyOf(onlinePlayers.get());
|
||||
var players = new HashMap<UUID, Player>();
|
||||
online.forEach(player -> players.put(player.getUniqueId(), player));
|
||||
var attemptedRestores = new HashSet<RestoreKey>();
|
||||
retryRestores(players, attemptedRestores);
|
||||
var blocked = restoring.entrySet().stream().map(entry -> new TargetKey(entry.getKey().viewer(), entry.getValue().targetId()))
|
||||
.collect(java.util.stream.Collectors.toUnmodifiableSet());
|
||||
Map<UUID, Map<Integer, Projection>> previous = views;
|
||||
var usedNames = new HashSet<String>();
|
||||
previous.values().forEach(view -> view.values().forEach(projection -> usedNames.add(projection.privateTeam())));
|
||||
restoring.values().forEach(projection -> usedNames.add(projection.privateTeam()));
|
||||
var next = new HashMap<UUID, Map<Integer, Projection>>();
|
||||
for (Player viewer : online) {
|
||||
try {
|
||||
if (!viewer.isOnline() || viewer.isDead() || !equipment.isEligibleWearer(viewer)) { continue; }
|
||||
Location origin = viewer.getLocation();
|
||||
var observer = new EyeVisibility.Viewer(viewer.getUniqueId(), position(origin), true);
|
||||
var targets = new HashMap<Integer, Projection>();
|
||||
for (Player target : online) {
|
||||
if (!target.isOnline() || target.isDead() || blocked.contains(new TargetKey(viewer.getUniqueId(), target.getUniqueId()))) { continue; }
|
||||
Location destination = target.getLocation();
|
||||
var subject = new EyeVisibility.Target(target.getUniqueId(), position(destination),
|
||||
concealed.test(target.getUniqueId()), target.getGameMode() == GameMode.SPECTATOR,
|
||||
target.getMetadata("vanished").stream().anyMatch(MetadataValue::asBoolean));
|
||||
boolean visible = viewer.canSee(target) && target.getTrackedBy().contains(viewer);
|
||||
if (!EyeVisibility.canReveal(observer, subject, visible, true)) { continue; }
|
||||
var sourceTeam = viewer.getScoreboard().getEntryTeam(target.getName());
|
||||
if (!EyeTeamVisibility.visible(viewer, sourceTeam)
|
||||
|| !chunksLoaded(origin.getWorld(), origin, destination) || !viewer.hasLineOfSight(target)) { continue; }
|
||||
Projection old = previous.getOrDefault(viewer.getUniqueId(), Map.of()).get(target.getEntityId());
|
||||
String team = old != null && old.targetId().equals(target.getUniqueId()) && old.playerName().equals(target.getName())
|
||||
? old.privateTeam() : allocateTeam(viewer, usedNames);
|
||||
var baseline = Optional.ofNullable(sourceTeam).map(org.bukkit.scoreboard.Team::getName);
|
||||
targets.put(target.getEntityId(), new Projection(target.getUniqueId(), target.getEntityId(), target.getName(),
|
||||
team, baseline, target.hasPotionEffect(PotionEffectType.INVISIBILITY)));
|
||||
}
|
||||
if (!targets.isEmpty()) { next.put(viewer.getUniqueId(), Map.copyOf(targets)); }
|
||||
} catch (RuntimeException error) {
|
||||
// Never retain a stale authorization when an authoritative visibility query fails.
|
||||
report(viewer.getUniqueId(), error);
|
||||
}
|
||||
}
|
||||
views = Map.copyOf(next); // Publish authorization before any outgoing projection/restoration packet.
|
||||
previous.forEach((viewerId, targets) -> {
|
||||
Player viewer = players.get(viewerId);
|
||||
if (viewer == null || !viewer.isOnline()) { return; }
|
||||
targets.values().forEach(old -> {
|
||||
Projection current = views.getOrDefault(viewerId, Map.of()).get(old.entityId());
|
||||
if (current == null || !current.privateTeam().equals(old.privateTeam())) {
|
||||
restoring.putIfAbsent(new RestoreKey(viewerId, old.privateTeam()), old);
|
||||
}
|
||||
});
|
||||
});
|
||||
retryRestores(players, attemptedRestores);
|
||||
views.forEach((viewerId, targets) -> targets.values().forEach(current -> {
|
||||
Projection old = previous.getOrDefault(viewerId, Map.of()).get(current.entityId());
|
||||
if (!current.equals(old) || requested.contains(viewerId)) {
|
||||
try {
|
||||
delivery.show(players.get(viewerId), players.get(current.targetId()), current,
|
||||
old == null || !current.privateTeam().equals(old.privateTeam()));
|
||||
warned.remove(viewerId);
|
||||
} catch (RuntimeException error) {
|
||||
deny(viewerId, current.targetId());
|
||||
restoring.putIfAbsent(new RestoreKey(viewerId, current.privateTeam()), current);
|
||||
report(viewerId, error);
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
public void hide(UUID viewer, UUID target) {
|
||||
withdraw((observer, projection) -> observer.equals(viewer) && projection.targetId().equals(target));
|
||||
}
|
||||
public void withdrawViewer(UUID viewer) { withdraw((observer, projection) -> observer.equals(viewer)); }
|
||||
public void withdrawTarget(UUID target) { withdraw((observer, projection) -> projection.targetId().equals(target)); }
|
||||
|
||||
/** Lifecycle callbacks call this on the server thread before the next scheduled visibility capture. */
|
||||
private void withdraw(java.util.function.BiPredicate<UUID, Projection> remove) {
|
||||
var previous = views;
|
||||
var next = new HashMap<UUID, Map<Integer, Projection>>();
|
||||
previous.forEach((viewer, targets) -> {
|
||||
var retained = new HashMap<Integer, Projection>();
|
||||
targets.forEach((id, projection) -> { if (!remove.test(viewer, projection)) { retained.put(id, projection); } });
|
||||
if (!retained.isEmpty()) { next.put(viewer, Map.copyOf(retained)); }
|
||||
});
|
||||
views = Map.copyOf(next);
|
||||
var players = new HashMap<UUID, Player>();
|
||||
onlinePlayers.get().forEach(player -> players.put(player.getUniqueId(), player));
|
||||
previous.forEach((viewerId, targets) -> {
|
||||
Player viewer = players.get(viewerId);
|
||||
if (viewer != null && viewer.isOnline()) {
|
||||
targets.values().stream().filter(projection -> remove.test(viewerId, projection))
|
||||
.forEach(projection -> restoring.putIfAbsent(new RestoreKey(viewerId, projection.privateTeam()), projection));
|
||||
}
|
||||
});
|
||||
retryRestores(players, new HashSet<>());
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
if (closed && restoring.isEmpty()) { return; }
|
||||
closed = true;
|
||||
dirty.clear();
|
||||
withdraw((viewer, projection) -> true);
|
||||
}
|
||||
|
||||
private void retryRestores(Map<UUID, Player> players, Set<RestoreKey> attempted) {
|
||||
for (var entry : List.copyOf(restoring.entrySet())) {
|
||||
var key = entry.getKey();
|
||||
if (!attempted.add(key)) { continue; }
|
||||
var projection = entry.getValue();
|
||||
Player viewer = players.get(key.viewer());
|
||||
if (viewer == null || !viewer.isOnline()) { restoring.remove(key); continue; }
|
||||
try {
|
||||
delivery.restore(viewer, Optional.ofNullable(players.get(projection.targetId())), projection);
|
||||
restoring.remove(key);
|
||||
} catch (RuntimeException error) {
|
||||
deny(key.viewer(), projection.targetId());
|
||||
report(key.viewer(), error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void deny(UUID viewer, UUID target) {
|
||||
var next = new HashMap<>(views);
|
||||
var retained = new HashMap<>(next.getOrDefault(viewer, Map.of()));
|
||||
retained.values().removeIf(projection -> projection.targetId().equals(target));
|
||||
if (retained.isEmpty()) { next.remove(viewer); }
|
||||
else { next.put(viewer, Map.copyOf(retained)); }
|
||||
views = Map.copyOf(next);
|
||||
}
|
||||
|
||||
private void report(UUID viewer, RuntimeException error) {
|
||||
if (warned.add(viewer)) { failure.accept(error); }
|
||||
}
|
||||
|
||||
private String allocateTeam(Player viewer, Set<String> used) {
|
||||
for (int attempt = 0; attempt < 32; attempt++) {
|
||||
String name = teamNames.get();
|
||||
if (name != null && name.matches("[a-zA-Z0-9_-]{1,16}") && !used.contains(name)
|
||||
&& viewer.getScoreboard().getTeam(name) == null) {
|
||||
used.add(name);
|
||||
return name;
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("Could not allocate an isolated Eye team name");
|
||||
}
|
||||
|
||||
private static EyeVisibility.Position position(Location location) {
|
||||
return new EyeVisibility.Position(location.getWorld().getUID(), location.getX(), location.getY(), location.getZ());
|
||||
}
|
||||
|
||||
private static boolean chunksLoaded(World world, Location from, Location to) {
|
||||
int minX = (int) Math.floor(Math.min(from.getX(), to.getX())) >> 4;
|
||||
int maxX = (int) Math.floor(Math.max(from.getX(), to.getX())) >> 4;
|
||||
int minZ = (int) Math.floor(Math.min(from.getZ(), to.getZ())) >> 4;
|
||||
int maxZ = (int) Math.floor(Math.max(from.getZ(), to.getZ())) >> 4;
|
||||
for (int x = minX; x <= maxX; x++) {
|
||||
for (int z = minZ; z <= maxZ; z++) { if (!world.isChunkLoaded(x, z)) { return false; } }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import com.destroystokyo.paper.event.player.PlayerArmorChangeEvent;
|
||||
import io.papermc.paper.event.player.PlayerUntrackEntityEvent;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityPotionEffectEvent;
|
||||
import org.bukkit.event.entity.PlayerDeathEvent;
|
||||
import org.bukkit.event.player.PlayerChangedWorldEvent;
|
||||
import org.bukkit.event.player.PlayerGameModeChangeEvent;
|
||||
import org.bukkit.event.player.PlayerHideEntityEvent;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
import org.bukkit.event.player.PlayerRespawnEvent;
|
||||
import org.bukkit.event.player.PlayerTeleportEvent;
|
||||
|
||||
/** Immediate server-thread invalidation; ordinary motion and line-of-sight changes are polled each tick. */
|
||||
public final class EyeRevealEvents implements Listener {
|
||||
private final EyeRevealController controller;
|
||||
public EyeRevealEvents(EyeRevealController controller) { this.controller = java.util.Objects.requireNonNull(controller); }
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR) public void onHide(PlayerHideEntityEvent event) {
|
||||
if (event.getEntity() instanceof org.bukkit.entity.Player player) { controller.hide(event.getPlayer().getUniqueId(), player.getUniqueId()); }
|
||||
}
|
||||
@EventHandler(priority = EventPriority.MONITOR) public void onUntrack(PlayerUntrackEntityEvent event) {
|
||||
if (event.getEntity() instanceof org.bukkit.entity.Player player) { controller.hide(event.getPlayer().getUniqueId(), player.getUniqueId()); }
|
||||
}
|
||||
@EventHandler(priority = EventPriority.MONITOR) public void onArmor(PlayerArmorChangeEvent event) {
|
||||
if (event.getSlot() == org.bukkit.inventory.EquipmentSlot.HEAD) { controller.withdrawViewer(event.getPlayer().getUniqueId()); }
|
||||
}
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onGameMode(PlayerGameModeChangeEvent event) {
|
||||
if (!event.isCancelled() && event.getNewGameMode() == org.bukkit.GameMode.SPECTATOR) { both(event.getPlayer().getUniqueId()); }
|
||||
}
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onEffect(EntityPotionEffectEvent event) {
|
||||
if (!event.isCancelled() && event.getEntity() instanceof org.bukkit.entity.Player player
|
||||
&& org.bukkit.potion.PotionEffectType.INVISIBILITY.equals(event.getModifiedType())) {
|
||||
controller.withdrawTarget(player.getUniqueId());
|
||||
}
|
||||
}
|
||||
@EventHandler(priority = EventPriority.MONITOR) public void onQuit(PlayerQuitEvent event) { both(event.getPlayer().getUniqueId()); }
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onTeleport(PlayerTeleportEvent event) {
|
||||
if (!event.isCancelled()) { both(event.getPlayer().getUniqueId()); }
|
||||
}
|
||||
@EventHandler(priority = EventPriority.MONITOR) public void onRespawn(PlayerRespawnEvent event) { both(event.getPlayer().getUniqueId()); }
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onDeath(PlayerDeathEvent event) {
|
||||
if (!cancelled(event)) { both(event.getEntity().getUniqueId()); }
|
||||
}
|
||||
@EventHandler(priority = EventPriority.MONITOR) public void onWorld(PlayerChangedWorldEvent event) { both(event.getPlayer().getUniqueId()); }
|
||||
|
||||
private void both(java.util.UUID player) {
|
||||
controller.withdrawViewer(player);
|
||||
controller.withdrawTarget(player);
|
||||
}
|
||||
private static boolean cancelled(org.bukkit.event.Event event) {
|
||||
return event instanceof org.bukkit.event.Cancellable cancellable && cancellable.isCancelled();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import com.comphenix.protocol.ProtocolManager;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Predicate;
|
||||
import org.bukkit.event.HandlerList;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.bukkit.scheduler.BukkitTask;
|
||||
|
||||
/** Owns only the Eye's viewer-specific runtime and its lifecycle resources. */
|
||||
final class EyeRevealRuntime implements AutoCloseable {
|
||||
private final JavaPlugin plugin;
|
||||
private final ProtocolManager protocol;
|
||||
private final EyeRevealController controller;
|
||||
private final EyePacketListener packets;
|
||||
private final EyeRevealEvents events;
|
||||
private BukkitTask task;
|
||||
private boolean started;
|
||||
private boolean closed;
|
||||
|
||||
EyeRevealRuntime(JavaPlugin plugin, EyeEquipment equipment, Predicate<UUID> concealed, ProtocolManager protocol) {
|
||||
this.plugin = Objects.requireNonNull(plugin);
|
||||
this.protocol = Objects.requireNonNull(protocol);
|
||||
controller = new EyeRevealController(plugin.getServer()::getOnlinePlayers, equipment, concealed, new NativeEyeDelivery(),
|
||||
() -> UUID.randomUUID().toString().replace("-", "").substring(0, 16),
|
||||
failure -> plugin.getLogger().warning("Eye rendering failed; unsafe views withheld: " + failure.getClass().getSimpleName()));
|
||||
packets = new EyePacketListener(plugin, controller::snapshot, controller::requestRefresh, plugin.getLogger()::warning);
|
||||
events = new EyeRevealEvents(controller);
|
||||
}
|
||||
|
||||
public void start() {
|
||||
if (closed) { throw new IllegalStateException("Eye runtime is closed"); }
|
||||
if (started) { return; }
|
||||
if (!plugin.isEnabled()) { throw new IllegalStateException("Cannot start Eye rendering for a disabled plugin"); }
|
||||
started = true;
|
||||
try {
|
||||
protocol.addPacketListener(packets);
|
||||
plugin.getServer().getPluginManager().registerEvents(events, plugin);
|
||||
task = plugin.getServer().getScheduler().runTaskTimer(plugin, controller, 1L, 1L);
|
||||
} catch (RuntimeException exception) {
|
||||
close();
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) { return; }
|
||||
closed = true;
|
||||
if (task != null) { task.cancel(); }
|
||||
try {
|
||||
controller.close(); // Withdraw authorization and restore raw state before unregistering the outgoing gate.
|
||||
} finally {
|
||||
protocol.removePacketListener(packets);
|
||||
HandlerList.unregisterAll(events);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.scoreboard.Team;
|
||||
|
||||
/** Preserve effective name visibility when a viewer-only team changes client membership relationships. */
|
||||
final class EyeTeamVisibility {
|
||||
private EyeTeamVisibility() { }
|
||||
static boolean visible(Player viewer, Team source) {
|
||||
if (source == null) { return true; }
|
||||
boolean sameTeam = source.equals(viewer.getScoreboard().getEntryTeam(viewer.getName()));
|
||||
return switch (source.getOption(Team.Option.NAME_TAG_VISIBILITY)) {
|
||||
case ALWAYS -> true;
|
||||
case NEVER -> false;
|
||||
// CraftTeam maps these to native HIDE_FOR_* values by ordinal, not to SHOW_FOR_*.
|
||||
case FOR_OWN_TEAM -> !sameTeam;
|
||||
case FOR_OTHER_TEAMS -> sameTeam;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/** Immutable, platform-independent inputs to the local Eye reveal decision. */
|
||||
public final class EyeVisibility {
|
||||
private EyeVisibility() { }
|
||||
public record Position(UUID world, double x, double y, double z) { }
|
||||
public record Viewer(UUID id, Position position, boolean eligible) { }
|
||||
public record Target(UUID id, Position position, boolean concealed, boolean spectator, boolean vanished) { }
|
||||
|
||||
public static boolean canReveal(Viewer viewer, Target target, boolean visibleToViewer, boolean lineOfSight) {
|
||||
if (!viewer.eligible() || !target.concealed() || target.spectator() || target.vanished()
|
||||
|| !visibleToViewer || !lineOfSight || viewer.id().equals(target.id())
|
||||
|| !viewer.position().world().equals(target.position().world())) { return false; }
|
||||
double dx = viewer.position().x() - target.position().x();
|
||||
double dy = viewer.position().y() - target.position().y();
|
||||
double dz = viewer.position().z() - target.position().z();
|
||||
return dx * dx + dy * dy + dz * dz <= 16.0 * 16.0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Main-thread native packet preparation/delivery. Outgoing authorization performs the final projection. */
|
||||
final class NativeEyeDelivery implements EyeRevealController.Delivery {
|
||||
private final Constructor<?> teamPacket;
|
||||
private final Constructor<?> parameters;
|
||||
private final Constructor<?> scoreboard;
|
||||
private final Constructor<?> playerTeam;
|
||||
private final Constructor<?> metadata;
|
||||
private final Constructor<?> dataValue;
|
||||
private final Field nativeTeam;
|
||||
private final Method playerHandle;
|
||||
private final Method entityData;
|
||||
private final Method dataGet;
|
||||
private final Object sharedFlags;
|
||||
private final Object byteSerializer;
|
||||
private final Field connection;
|
||||
private final Method sendPacket;
|
||||
|
||||
NativeEyeDelivery() {
|
||||
try {
|
||||
Class<?> team = Class.forName("net.minecraft.world.scores.PlayerTeam");
|
||||
Class<?> board = Class.forName("net.minecraft.world.scores.Scoreboard");
|
||||
teamPacket = Class.forName("net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket")
|
||||
.getDeclaredConstructor(String.class, int.class, Optional.class, Collection.class);
|
||||
teamPacket.setAccessible(true);
|
||||
parameters = Class.forName("net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket$Parameters")
|
||||
.getConstructor(team);
|
||||
scoreboard = board.getConstructor();
|
||||
playerTeam = team.getConstructor(board, String.class);
|
||||
nativeTeam = Class.forName("org.bukkit.craftbukkit.scoreboard.CraftTeam").getDeclaredField("team");
|
||||
nativeTeam.setAccessible(true);
|
||||
playerHandle = Class.forName("org.bukkit.craftbukkit.entity.CraftPlayer").getMethod("getHandle");
|
||||
Class<?> entity = Class.forName("net.minecraft.world.entity.Entity");
|
||||
entityData = entity.getMethod("getEntityData");
|
||||
var flags = entity.getDeclaredField("DATA_SHARED_FLAGS_ID");
|
||||
flags.setAccessible(true);
|
||||
sharedFlags = flags.get(null);
|
||||
Class<?> accessor = Class.forName("net.minecraft.network.syncher.EntityDataAccessor");
|
||||
dataGet = Class.forName("net.minecraft.network.syncher.SynchedEntityData").getMethod("get", accessor);
|
||||
byteSerializer = Class.forName("net.minecraft.network.syncher.EntityDataSerializers").getField("BYTE").get(null);
|
||||
dataValue = Class.forName("net.minecraft.network.syncher.SynchedEntityData$DataValue").getConstructor(
|
||||
int.class, Class.forName("net.minecraft.network.syncher.EntityDataSerializer"), Object.class);
|
||||
metadata = Class.forName("net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket").getConstructor(int.class, List.class);
|
||||
connection = Class.forName("net.minecraft.server.level.ServerPlayer").getField("connection");
|
||||
sendPacket = Class.forName("net.minecraft.server.network.ServerCommonPacketListenerImpl")
|
||||
.getMethod("send", Class.forName("net.minecraft.network.protocol.Packet"));
|
||||
} catch (ReflectiveOperationException exception) {
|
||||
throw new IllegalStateException("Native Eye delivery is unsupported on this server version", exception);
|
||||
}
|
||||
}
|
||||
|
||||
@Override public void show(Player viewer, Player target, EyeRevealController.Projection projection, boolean create) {
|
||||
try {
|
||||
var source = viewer.getScoreboard().getEntryTeam(projection.playerName());
|
||||
Object basis = source == null ? playerTeam.newInstance(scoreboard.newInstance(), projection.privateTeam()) : nativeTeam.get(source);
|
||||
// Send unprojected data. The packet listener checks the latest per-viewer authorization at dispatch.
|
||||
Object privateParameters = EyePacketProjection.teamVisibility(parameters.newInstance(basis), EyeTeamVisibility.visible(viewer, source));
|
||||
Object team = teamPacket.newInstance(projection.privateTeam(), create ? 0 : 2,
|
||||
Optional.of(privateParameters), create ? List.of(projection.playerName()) : List.of());
|
||||
var packets = List.of(team, rawMetadata(target));
|
||||
for (Object packet : packets) { send(viewer, packet); }
|
||||
} catch (ReflectiveOperationException exception) {
|
||||
throw new IllegalStateException("Could not deliver a private Eye view", exception);
|
||||
}
|
||||
}
|
||||
|
||||
@Override public void restore(Player viewer, Optional<Player> target, EyeRevealController.Projection projection) {
|
||||
try {
|
||||
var packets = new ArrayList<Object>();
|
||||
var source = viewer.getScoreboard().getEntryTeam(projection.playerName());
|
||||
Object basis = source == null ? playerTeam.newInstance(scoreboard.newInstance(), projection.privateTeam()) : nativeTeam.get(source);
|
||||
Object baseline = parameters.newInstance(basis);
|
||||
// Restore presentation before changing membership, avoiding a temporary unteamed/plain-name gap.
|
||||
packets.add(teamPacket.newInstance(projection.privateTeam(), 2,
|
||||
Optional.of(EyePacketProjection.teamVisibility(baseline, EyeTeamVisibility.visible(viewer, source))), List.of()));
|
||||
if (target.isPresent() && target.orElseThrow().isOnline()) { packets.add(rawMetadata(target.orElseThrow())); }
|
||||
if (source != null) {
|
||||
packets.add(teamPacket.newInstance(source.getName(), 2, Optional.of(baseline), List.of()));
|
||||
packets.add(teamPacket.newInstance(source.getName(), 3, Optional.empty(), List.of(projection.playerName())));
|
||||
}
|
||||
packets.add(teamPacket.newInstance(projection.privateTeam(), 1, Optional.empty(), List.of()));
|
||||
// Prepare everything before sending, avoiding half-created views on reflection/layout failures.
|
||||
for (Object packet : packets) { send(viewer, packet); }
|
||||
} catch (ReflectiveOperationException exception) {
|
||||
throw new IllegalStateException("Could not restore an Eye observer's baseline", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private Object rawMetadata(Player player) throws ReflectiveOperationException {
|
||||
Object value = dataGet.invoke(entityData.invoke(playerHandle.invoke(player)), sharedFlags);
|
||||
if (!(value instanceof Byte)) { throw new IllegalStateException("Native player flags are unavailable"); }
|
||||
return metadata.newInstance(player.getEntityId(), List.of(dataValue.newInstance(0, byteSerializer, value)));
|
||||
}
|
||||
|
||||
private void send(Player viewer, Object packet) throws ReflectiveOperationException {
|
||||
sendPacket.invoke(connection.get(playerHandle.invoke(viewer)), packet);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ public final class SpigotStealthPlugin extends JavaPlugin {
|
||||
private EyeProgressionService eyeProgression;
|
||||
private 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()) {
|
||||
|
||||
Reference in New Issue
Block a user