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,58 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import io.netty.buffer.Unpooled;
|
||||
import java.util.List;
|
||||
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||
import net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket;
|
||||
import net.minecraft.network.syncher.EntityDataAccessor;
|
||||
import net.minecraft.network.syncher.EntityDataSerializers;
|
||||
import net.minecraft.network.syncher.SynchedEntityData;
|
||||
import org.bukkit.craftbukkit.CraftRegistry;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class EyeMetadataProjectionTest {
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test
|
||||
void nativeMetadataProjectionClearsOnlyInvisibilityAndKeepsTheSharedPacketUnchanged() throws Exception {
|
||||
var config = new io.papermc.paper.configuration.GlobalConfiguration();
|
||||
config.anticheat = config.new Anticheat();
|
||||
config.anticheat.obfuscation = config.anticheat.new Obfuscation();
|
||||
config.anticheat.obfuscation.items = config.anticheat.obfuscation.new Items();
|
||||
var server = org.mockito.Mockito.mock(net.minecraft.server.MinecraftServer.class);
|
||||
org.mockito.Mockito.when(server.registryAccess()).thenReturn(CraftRegistry.getMinecraftRegistry().freeze());
|
||||
try (var runtime = org.mockito.Mockito.mockStatic(net.minecraft.server.MinecraftServer.class);
|
||||
var platform = org.mockito.Mockito.mockStatic(io.papermc.paper.configuration.GlobalConfiguration.class)) {
|
||||
runtime.when(net.minecraft.server.MinecraftServer::getServer).thenReturn(server);
|
||||
platform.when(io.papermc.paper.configuration.GlobalConfiguration::get).thenReturn(config);
|
||||
config.anticheat.obfuscation.items.bindDataSanitizer();
|
||||
assertProjection();
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertProjection() throws Exception {
|
||||
var accessorField = net.minecraft.world.entity.Entity.class.getDeclaredField("DATA_SHARED_FLAGS_ID");
|
||||
accessorField.setAccessible(true);
|
||||
var accessor = assertInstanceOf(EntityDataAccessor.class, accessorField.get(null));
|
||||
assertEquals(0, accessor.id());
|
||||
assertSame(EntityDataSerializers.BYTE, accessor.serializer());
|
||||
for (int flags = 0; flags <= 255; flags++) {
|
||||
var sharedFlags = new SynchedEntityData.DataValue<>(0, EntityDataSerializers.BYTE, (byte) flags);
|
||||
var air = new SynchedEntityData.DataValue<>(1, EntityDataSerializers.INT, 300);
|
||||
var original = new ClientboundSetEntityDataPacket(42, List.of(sharedFlags, air));
|
||||
var projected = assertInstanceOf(ClientboundSetEntityDataPacket.class, EyePacketProjection.metadataPacket(original));
|
||||
assertEquals(42, projected.id());
|
||||
assertEquals((byte) (flags & ~0x20), projected.packedItems().getFirst().value(), "flag byte " + flags);
|
||||
assertSame(air, projected.packedItems().get(1));
|
||||
assertEquals((byte) flags, original.packedItems().getFirst().value());
|
||||
var buffer = new RegistryFriendlyByteBuf(Unpooled.buffer(), CraftRegistry.getMinecraftRegistry());
|
||||
try {
|
||||
ClientboundSetEntityDataPacket.STREAM_CODEC.encode(buffer, projected);
|
||||
assertEquals(projected, ClientboundSetEntityDataPacket.STREAM_CODEC.decode(buffer));
|
||||
} finally { buffer.release(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import com.comphenix.protocol.PacketType;
|
||||
import com.comphenix.protocol.events.PacketContainer;
|
||||
import com.comphenix.protocol.events.PacketEvent;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket;
|
||||
import net.minecraft.network.syncher.EntityDataSerializers;
|
||||
import net.minecraft.network.syncher.SynchedEntityData;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class EyePacketListenerTest {
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("try")
|
||||
void metadataIsProjectedOnlyForTheCurrentlyAuthorizedRecipientWithoutMutatingSharedPackets() {
|
||||
var server = mock(org.bukkit.craftbukkit.CraftServer.class);
|
||||
try (var platform = mockStatic(org.bukkit.Bukkit.class, call -> switch (call.getMethod().getName()) {
|
||||
case "getServer" -> server;
|
||||
case "isPrimaryThread" -> true;
|
||||
case "getVersion" -> "Purpur 2618 (MC: 26.2)";
|
||||
case "getMinecraftVersion" -> "26.2";
|
||||
case "getBukkitVersion" -> "26.2-R0.1-SNAPSHOT";
|
||||
default -> call.callRealMethod();
|
||||
})) {
|
||||
UUID viewerId = UUID.randomUUID();
|
||||
var viewer = mock(Player.class);
|
||||
when(viewer.getUniqueId()).thenReturn(viewerId);
|
||||
var other = mock(Player.class);
|
||||
when(other.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
var projection = new EyeRevealController.Projection(UUID.randomUUID(), 42, "Concealed", "eye-private", Optional.of("source"), true);
|
||||
var snapshot = new AtomicReference<>(Map.of(viewerId, Map.of(42, projection)));
|
||||
var warnings = new ArrayList<String>();
|
||||
var dirty = new ArrayList<UUID>();
|
||||
var listener = new EyePacketListener(mock(Plugin.class), snapshot::get, dirty::add, warnings::add);
|
||||
assertEquals(Set.of(PacketType.Play.Server.ENTITY_METADATA, PacketType.Play.Server.SCOREBOARD_TEAM),
|
||||
listener.getSendingWhitelist().getTypes());
|
||||
var raw = new ClientboundSetEntityDataPacket(42, List.of(new SynchedEntityData.DataValue<>(0, EntityDataSerializers.BYTE, (byte) 0x63)));
|
||||
var shared = new PacketContainer(PacketType.Play.Server.ENTITY_METADATA, raw);
|
||||
var first = PacketEvent.fromServer(this, shared, viewer);
|
||||
listener.onPacketSending(first);
|
||||
var shown = assertInstanceOf(ClientboundSetEntityDataPacket.class, first.getPacket().getHandle());
|
||||
assertEquals((byte) 0x43, shown.packedItems().getFirst().value());
|
||||
assertEquals((byte) 0x63, raw.packedItems().getFirst().value());
|
||||
var second = PacketEvent.fromServer(this, shared, other);
|
||||
listener.onPacketSending(second);
|
||||
assertSame(shared, second.getPacket());
|
||||
snapshot.set(Map.of());
|
||||
var late = PacketEvent.fromServer(this, shared, viewer);
|
||||
listener.onPacketSending(late);
|
||||
assertSame(shared, late.getPacket(), "queued packets must use current, not captured authorization");
|
||||
snapshot.set(Map.of(viewerId, Map.of(42, projection)));
|
||||
var cancelled = PacketEvent.fromServer(this, shared, viewer);
|
||||
cancelled.setCancelled(true);
|
||||
listener.onPacketSending(cancelled);
|
||||
assertTrue(cancelled.isCancelled());
|
||||
assertSame(shared, cancelled.getPacket());
|
||||
var noBody = new EyeRevealController.Projection(projection.targetId(), 42, projection.playerName(),
|
||||
projection.privateTeam(), projection.sourceTeam(), false);
|
||||
snapshot.set(Map.of(viewerId, Map.of(42, noBody)));
|
||||
var notGameplayInvisible = PacketEvent.fromServer(this, shared, viewer);
|
||||
listener.onPacketSending(notGameplayInvisible);
|
||||
assertSame(shared, notGameplayInvisible.getPacket());
|
||||
snapshot.set(Map.of(viewerId, Map.of(42, projection)));
|
||||
var board = new net.minecraft.world.scores.Scoreboard();
|
||||
var privateTeam = board.addPlayerTeam("eye-private");
|
||||
privateTeam.setPlayerPrefix(net.minecraft.network.chat.Component.empty().withStyle(net.minecraft.ChatFormatting.OBFUSCATED));
|
||||
board.addPlayerToTeam("Concealed", privateTeam);
|
||||
var privateRaw = net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.createAddOrModifyPacket(privateTeam, true);
|
||||
var privateWrapped = new PacketContainer(PacketType.Play.Server.SCOREBOARD_TEAM, privateRaw);
|
||||
var privateEvent = PacketEvent.fromServer(this, privateWrapped, viewer);
|
||||
listener.onPacketSending(privateEvent);
|
||||
var shownTeam = assertInstanceOf(net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.class, privateEvent.getPacket().getHandle());
|
||||
assertFalse(shownTeam.getParameters().orElseThrow().playerPrefix().getStyle().isObfuscated());
|
||||
assertTrue(privateRaw.getParameters().orElseThrow().playerPrefix().getStyle().isObfuscated());
|
||||
assertTrue(dirty.isEmpty(), "our own private updates must not create a refresh loop");
|
||||
var source = board.addPlayerTeam("source");
|
||||
source.setPlayerPrefix(net.minecraft.network.chat.Component.empty().withStyle(net.minecraft.ChatFormatting.OBFUSCATED));
|
||||
board.addPlayerToTeam("Concealed", source);
|
||||
board.addPlayerToTeam("Other", source);
|
||||
var sourceRaw = net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.createAddOrModifyPacket(source, true);
|
||||
var sourceWrapped = new PacketContainer(PacketType.Play.Server.SCOREBOARD_TEAM, sourceRaw);
|
||||
var sourceEvent = PacketEvent.fromServer(this, sourceWrapped, viewer);
|
||||
listener.onPacketSending(sourceEvent);
|
||||
var filtered = assertInstanceOf(net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.class, sourceEvent.getPacket().getHandle());
|
||||
assertEquals(Set.of("Other"), Set.copyOf(filtered.getPlayers()));
|
||||
assertTrue(filtered.getParameters().orElseThrow().playerPrefix().getStyle().isObfuscated());
|
||||
assertEquals(List.of(viewerId), dirty, "source style updates request a main-thread refresh");
|
||||
var otherEvent = PacketEvent.fromServer(this, sourceWrapped, other);
|
||||
listener.onPacketSending(otherEvent);
|
||||
assertSame(sourceWrapped, otherEvent.getPacket());
|
||||
var system = new PacketContainer(PacketType.Play.Server.SYSTEM_CHAT,
|
||||
new net.minecraft.network.protocol.game.ClientboundSystemChatPacket(net.minecraft.network.chat.Component.literal("Unchanged"), false));
|
||||
var systemEvent = PacketEvent.fromServer(this, system, viewer);
|
||||
listener.onPacketSending(systemEvent);
|
||||
assertSame(system, systemEvent.getPacket());
|
||||
var tabRemoval = new PacketContainer(PacketType.Play.Server.PLAYER_INFO_REMOVE);
|
||||
tabRemoval.getUUIDLists().write(0, List.of(projection.targetId()));
|
||||
var tabEvent = PacketEvent.fromServer(this, tabRemoval, viewer);
|
||||
listener.onPacketSending(tabEvent);
|
||||
assertSame(tabRemoval, tabEvent.getPacket());
|
||||
assertEquals(List.of(projection.targetId()), tabEvent.getPacket().getUUIDLists().read(0));
|
||||
assertTrue(warnings.isEmpty());
|
||||
verify(viewer, never()).getInventory();
|
||||
verify(viewer, never()).getWorld();
|
||||
verify(viewer, never()).hasLineOfSight(any(org.bukkit.entity.Entity.class));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,9 @@ class EyePluginLifecycleTest {
|
||||
default -> call.callRealMethod();
|
||||
}); var protocol = mockStatic(ProtocolLibrary.class)) {
|
||||
var protocolManager = mock(ProtocolManager.class);
|
||||
var registeredPackets = new ArrayList<com.comphenix.protocol.events.PacketListener>();
|
||||
doAnswer(call -> { registeredPackets.add(call.getArgument(0)); return null; }).when(protocolManager).addPacketListener(any());
|
||||
doAnswer(call -> { registeredPackets.remove(call.getArgument(0)); return null; }).when(protocolManager).removePacketListener(any());
|
||||
protocol.when(ProtocolLibrary::getProtocolManager).thenReturn(protocolManager);
|
||||
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());
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.bukkit.GameMode;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.PlayerInventory;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
import org.bukkit.scoreboard.Scoreboard;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class EyeRevealControllerTest {
|
||||
@TempDir Path directory;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test
|
||||
void onlyTheEligibleObserverGetsAnImmutableProjectionAndUnequippingRestoresTheBaseline() throws Exception {
|
||||
var world = mock(World.class);
|
||||
when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||
var viewer = player(world, "Viewer", 1, 0);
|
||||
var unearned = player(world, "Unearned", 2, 0);
|
||||
var target = player(world, "Concealed", 42, 8);
|
||||
when(target.getTrackedBy()).thenReturn(Set.of(viewer, unearned));
|
||||
when(viewer.canSee(target)).thenReturn(true);
|
||||
when(viewer.hasLineOfSight(target)).thenReturn(true);
|
||||
when(target.hasPotionEffect(PotionEffectType.INVISIBILITY)).thenReturn(true);
|
||||
var items = new EyeItems();
|
||||
var helmet = new AtomicReference<>(items.create());
|
||||
when(viewer.getInventory().getHelmet()).thenAnswer(ignored -> helmet.get());
|
||||
when(unearned.getInventory().getHelmet()).thenReturn(items.create());
|
||||
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
|
||||
var initial = new PersistentStealthState(Map.of(), Map.of())
|
||||
.withEyeProgress(viewer.getUniqueId(), new EyeProgress(28_800_000, true));
|
||||
var actions = new ArrayList<String>();
|
||||
var controllerRef = new AtomicReference<EyeRevealController>();
|
||||
var delivery = new EyeRevealController.Delivery() {
|
||||
@Override public void show(Player observer, Player subject, EyeRevealController.Projection projection, boolean create) {
|
||||
actions.add((create ? "create:" : "refresh:") + observer.getName() + ":" + subject.getName());
|
||||
assertTrue(projection.revealBody());
|
||||
assertEquals(projection, controllerRef.get().snapshot().get(observer.getUniqueId()).get(projection.entityId()));
|
||||
}
|
||||
@Override public void restore(Player observer, Optional<Player> subject, EyeRevealController.Projection projection) {
|
||||
assertFalse(controllerRef.get().snapshot().getOrDefault(observer.getUniqueId(), Map.of()).containsKey(projection.entityId()),
|
||||
"authorization must be withdrawn before sending restoration packets");
|
||||
actions.add("restore:" + observer.getName() + ":" + projection.playerName());
|
||||
}
|
||||
};
|
||||
try (var states = new StealthStateManager(repository, initial)) {
|
||||
var equipment = new EyeEquipment(new EyeProgressionService(states, () -> 0L, ignored -> { }), items);
|
||||
var ids = new AtomicInteger();
|
||||
var controller = new EyeRevealController(() -> List.of(viewer, unearned, target), equipment,
|
||||
id -> id.equals(target.getUniqueId()), delivery, () -> "eye" + ids.incrementAndGet());
|
||||
controllerRef.set(controller);
|
||||
controller.run();
|
||||
var snapshot = controller.snapshot();
|
||||
assertNotNull(snapshot.get(viewer.getUniqueId()), "an eligible observer needs a projection");
|
||||
assertEquals(Set.of(42), snapshot.get(viewer.getUniqueId()).keySet());
|
||||
assertFalse(snapshot.containsKey(unearned.getUniqueId()));
|
||||
assertEquals(List.of("create:Viewer:Concealed"), actions);
|
||||
assertThrows(UnsupportedOperationException.class, () -> snapshot.clear());
|
||||
assertThrows(UnsupportedOperationException.class, () -> snapshot.get(viewer.getUniqueId()).clear());
|
||||
controller.run();
|
||||
assertEquals(1, actions.size(), "unchanged frames must not resend packets");
|
||||
UUID observerId = viewer.getUniqueId();
|
||||
java.util.concurrent.CompletableFuture.runAsync(() -> {
|
||||
controller.requestRefresh(observerId);
|
||||
controller.requestRefresh(observerId);
|
||||
}).get(3, java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertEquals(1, actions.size(), "packet-thread invalidation must not perform delivery");
|
||||
controller.run();
|
||||
assertEquals(List.of("create:Viewer:Concealed", "refresh:Viewer:Concealed"), actions,
|
||||
"source updates must coalesce into a main-thread refresh");
|
||||
helmet.set(new ItemStack(Material.IRON_HELMET));
|
||||
controller.run();
|
||||
assertTrue(controller.snapshot().isEmpty());
|
||||
assertEquals(List.of("create:Viewer:Concealed", "refresh:Viewer:Concealed", "restore:Viewer:Concealed"), actions);
|
||||
assertEquals(1, snapshot.get(viewer.getUniqueId()).size(), "published snapshots cannot be mutated by later frames");
|
||||
helmet.set(items.create());
|
||||
controller.run();
|
||||
controller.close();
|
||||
assertTrue(controller.snapshot().isEmpty(), "disable must withdraw all projections");
|
||||
assertEquals(5, actions.size());
|
||||
assertEquals("restore:Viewer:Concealed", actions.getLast());
|
||||
controller.close();
|
||||
controller.run();
|
||||
assertEquals(5, actions.size(), "closed controllers cannot resend or revive projections");
|
||||
verify(target, never()).removePotionEffect(any());
|
||||
verify(target, never()).setInvisible(anyBoolean());
|
||||
verify(viewer, never()).hasLineOfSight(unearned);
|
||||
verify(unearned, never()).hasLineOfSight(any(org.bukkit.entity.Entity.class));
|
||||
}
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(strings = {"range", "sight", "hidden", "spectator", "vanished", "untracked", "unloaded", "world", "death", "session", "capture-failure", "team-hidden"})
|
||||
void platformChangesWithdrawTheProjectionWithoutLoadingChunksOrForcingVisibility(String change) throws Exception {
|
||||
var world = mock(World.class);
|
||||
when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||
var viewer = player(world, "Viewer", 1, 0);
|
||||
var target = player(world, "Concealed", 42, 8);
|
||||
when(target.getTrackedBy()).thenReturn(Set.of(viewer));
|
||||
when(viewer.canSee(target)).thenReturn(true);
|
||||
when(viewer.hasLineOfSight(target)).thenReturn(true);
|
||||
var items = new EyeItems();
|
||||
when(viewer.getInventory().getHelmet()).thenReturn(items.create());
|
||||
var initial = new PersistentStealthState(Map.of(), Map.of())
|
||||
.withEyeProgress(viewer.getUniqueId(), new EyeProgress(28_800_000, true));
|
||||
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
|
||||
var session = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
var restored = new AtomicInteger();
|
||||
var delivery = new EyeRevealController.Delivery() {
|
||||
@Override public void show(Player observer, Player subject, EyeRevealController.Projection projection, boolean create) {
|
||||
assertFalse(projection.revealBody(), "identity concealment alone does not authorize changing body flags");
|
||||
}
|
||||
@Override public void restore(Player observer, Optional<Player> subject, EyeRevealController.Projection projection) {
|
||||
assertSame(viewer, observer);
|
||||
assertEquals(target.getUniqueId(), projection.targetId());
|
||||
restored.incrementAndGet();
|
||||
}
|
||||
};
|
||||
try (var states = new StealthStateManager(repository, initial)) {
|
||||
var controller = new EyeRevealController(() -> List.of(viewer, target),
|
||||
new EyeEquipment(new EyeProgressionService(states, () -> 0L, ignored -> { }), items),
|
||||
id -> id.equals(target.getUniqueId()) && session.get(), delivery, () -> "eye1");
|
||||
controller.run();
|
||||
assertEquals(1, controller.snapshot().get(viewer.getUniqueId()).size());
|
||||
switch (change) {
|
||||
case "range" -> when(target.getLocation()).thenReturn(new Location(world, 16.001, 64, 0));
|
||||
case "sight" -> when(viewer.hasLineOfSight(target)).thenReturn(false);
|
||||
case "hidden" -> when(viewer.canSee(target)).thenReturn(false);
|
||||
case "spectator" -> when(target.getGameMode()).thenReturn(GameMode.SPECTATOR);
|
||||
case "vanished" -> {
|
||||
var flag = mock(org.bukkit.metadata.MetadataValue.class);
|
||||
when(flag.asBoolean()).thenReturn(true);
|
||||
when(target.getMetadata("vanished")).thenReturn(List.of(flag));
|
||||
}
|
||||
case "untracked" -> when(target.getTrackedBy()).thenReturn(Set.of());
|
||||
case "unloaded" -> when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(false);
|
||||
case "world" -> {
|
||||
var otherWorld = mock(World.class);
|
||||
when(otherWorld.getUID()).thenReturn(UUID.randomUUID());
|
||||
when(target.getLocation()).thenReturn(new Location(otherWorld, 8, 64, 0));
|
||||
}
|
||||
case "death" -> when(target.isDead()).thenReturn(true);
|
||||
case "session" -> session.set(false);
|
||||
case "capture-failure" -> when(target.getMetadata("vanished")).thenThrow(new IllegalStateException("Visibility state unavailable"));
|
||||
case "team-hidden" -> {
|
||||
var hidden = mock(org.bukkit.scoreboard.Team.class);
|
||||
when(hidden.getOption(org.bukkit.scoreboard.Team.Option.NAME_TAG_VISIBILITY)).thenReturn(org.bukkit.scoreboard.Team.OptionStatus.NEVER);
|
||||
when(viewer.getScoreboard().getEntryTeam(target.getName())).thenReturn(hidden);
|
||||
}
|
||||
default -> throw new AssertionError(change);
|
||||
}
|
||||
org.mockito.Mockito.<Object>clearInvocations(world, viewer, target);
|
||||
controller.run();
|
||||
assertTrue(controller.snapshot().isEmpty(), change);
|
||||
assertEquals(1, restored.get());
|
||||
if (!change.equals("sight")) { verify(viewer, never()).hasLineOfSight(target); }
|
||||
verify(world, never()).getChunkAt(anyInt(), anyInt());
|
||||
verify(world, never()).loadChunk(anyInt(), anyInt());
|
||||
verify(viewer, never()).showPlayer(any(), any());
|
||||
verify(target, never()).removePotionEffect(any());
|
||||
verify(target, never()).setInvisible(anyBoolean());
|
||||
}
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(strings = {"pair", "viewer", "target", "hide-event", "untrack-event", "helmet-event", "spectator-event", "potion-event", "quit-event", "teleport-event", "respawn-event", "death-event", "world-event", "cancelled-teleport", "cancelled-mode", "cancelled-potion", "other-armor", "other-effect"})
|
||||
void lifecycleInvalidationWithdrawsAuthorizationImmediatelyAndOnlyForAffectedViews(String change) throws Exception {
|
||||
var world = mock(World.class);
|
||||
when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||
var first = player(world, "First", 1, 0);
|
||||
var second = player(world, "Second", 2, 0);
|
||||
var target = player(world, "Concealed", 42, 8);
|
||||
when(target.getTrackedBy()).thenReturn(Set.of(first, second));
|
||||
var items = new EyeItems();
|
||||
for (var viewer : List.of(first, second)) {
|
||||
when(viewer.canSee(target)).thenReturn(true);
|
||||
when(viewer.hasLineOfSight(target)).thenReturn(true);
|
||||
when(viewer.getInventory().getHelmet()).thenReturn(items.create());
|
||||
}
|
||||
var initial = new PersistentStealthState(Map.of(), Map.of())
|
||||
.withEyeProgress(first.getUniqueId(), new EyeProgress(28_800_000, true))
|
||||
.withEyeProgress(second.getUniqueId(), new EyeProgress(28_800_000, true));
|
||||
var restored = new ArrayList<UUID>();
|
||||
var delivery = new EyeRevealController.Delivery() {
|
||||
@Override public void show(Player observer, Player subject, EyeRevealController.Projection projection, boolean create) { }
|
||||
@Override public void restore(Player observer, Optional<Player> subject, EyeRevealController.Projection projection) {
|
||||
restored.add(observer.getUniqueId());
|
||||
}
|
||||
};
|
||||
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
|
||||
try (var states = new StealthStateManager(repository, initial)) {
|
||||
var ids = new AtomicInteger();
|
||||
var controller = new EyeRevealController(() -> List.of(first, second, target),
|
||||
new EyeEquipment(new EyeProgressionService(states, () -> 0L, ignored -> { }), items),
|
||||
id -> id.equals(target.getUniqueId()), delivery, () -> "eye" + ids.incrementAndGet());
|
||||
controller.run();
|
||||
assertEquals(2, controller.snapshot().size());
|
||||
var events = new EyeRevealEvents(controller);
|
||||
switch (change) {
|
||||
case "cancelled-teleport" -> {
|
||||
var event = new org.bukkit.event.player.PlayerTeleportEvent(first, first.getLocation(), target.getLocation());
|
||||
event.setCancelled(true);
|
||||
events.onTeleport(event);
|
||||
}
|
||||
case "cancelled-mode" -> {
|
||||
var event = new org.bukkit.event.player.PlayerGameModeChangeEvent(target, GameMode.SPECTATOR);
|
||||
event.setCancelled(true);
|
||||
events.onGameMode(event);
|
||||
}
|
||||
case "cancelled-potion" -> {
|
||||
var event = new org.bukkit.event.entity.EntityPotionEffectEvent(target,
|
||||
new org.bukkit.potion.PotionEffect(PotionEffectType.INVISIBILITY, 100, 0), null, null,
|
||||
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.MILK, org.bukkit.event.entity.EntityPotionEffectEvent.Action.REMOVED, false);
|
||||
event.setCancelled(true);
|
||||
events.onEffect(event);
|
||||
}
|
||||
case "other-armor" -> events.onArmor(new com.destroystokyo.paper.event.player.PlayerArmorChangeEvent(first,
|
||||
com.destroystokyo.paper.event.player.PlayerArmorChangeEvent.SlotType.CHEST, new ItemStack(Material.AIR), new ItemStack(Material.IRON_CHESTPLATE)));
|
||||
case "other-effect" -> events.onEffect(new org.bukkit.event.entity.EntityPotionEffectEvent(target, null,
|
||||
new org.bukkit.potion.PotionEffect(PotionEffectType.NIGHT_VISION, 100, 0), null,
|
||||
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.COMMAND, org.bukkit.event.entity.EntityPotionEffectEvent.Action.ADDED, false));
|
||||
case "hide-event" -> events.onHide(new org.bukkit.event.player.PlayerHideEntityEvent(first, target));
|
||||
case "untrack-event" -> events.onUntrack(new io.papermc.paper.event.player.PlayerUntrackEntityEvent(first, target));
|
||||
case "helmet-event" -> events.onArmor(new com.destroystokyo.paper.event.player.PlayerArmorChangeEvent(first,
|
||||
com.destroystokyo.paper.event.player.PlayerArmorChangeEvent.SlotType.HEAD, items.create(), new ItemStack(Material.AIR)));
|
||||
case "spectator-event" -> events.onGameMode(new org.bukkit.event.player.PlayerGameModeChangeEvent(target, GameMode.SPECTATOR));
|
||||
case "potion-event" -> events.onEffect(new org.bukkit.event.entity.EntityPotionEffectEvent(target,
|
||||
new org.bukkit.potion.PotionEffect(PotionEffectType.INVISIBILITY, 100, 0), null, null,
|
||||
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.MILK, org.bukkit.event.entity.EntityPotionEffectEvent.Action.REMOVED, false));
|
||||
case "quit-event" -> events.onQuit(new org.bukkit.event.player.PlayerQuitEvent(first, net.kyori.adventure.text.Component.empty()));
|
||||
case "teleport-event" -> events.onTeleport(new org.bukkit.event.player.PlayerTeleportEvent(first, first.getLocation(), target.getLocation()));
|
||||
case "respawn-event" -> events.onRespawn(new org.bukkit.event.player.PlayerRespawnEvent(first, first.getLocation(), false));
|
||||
case "death-event" -> events.onDeath(new org.bukkit.event.entity.PlayerDeathEvent(target, mock(org.bukkit.damage.DamageSource.class),
|
||||
new ArrayList<>(), 0, net.kyori.adventure.text.Component.empty(), false));
|
||||
case "world-event" -> events.onWorld(new org.bukkit.event.player.PlayerChangedWorldEvent(first, world));
|
||||
case "pair" -> controller.hide(first.getUniqueId(), target.getUniqueId());
|
||||
case "viewer" -> controller.withdrawViewer(first.getUniqueId());
|
||||
case "target" -> controller.withdrawTarget(target.getUniqueId());
|
||||
default -> throw new AssertionError(change);
|
||||
}
|
||||
if (change.startsWith("cancelled-") || change.startsWith("other-")) {
|
||||
assertEquals(2, controller.snapshot().size());
|
||||
assertTrue(restored.isEmpty(), "cancelled or unrelated events must not disturb valid projections");
|
||||
return;
|
||||
}
|
||||
assertFalse(controller.snapshot().containsKey(first.getUniqueId()), "invalidation cannot wait for the next frame");
|
||||
boolean allObservers = Set.of("target", "spectator-event", "potion-event", "death-event").contains(change);
|
||||
assertEquals(allObservers ? 0 : 1, controller.snapshot().size());
|
||||
assertEquals(allObservers ? Set.of(first.getUniqueId(), second.getUniqueId()) : Set.of(first.getUniqueId()), Set.copyOf(restored));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedDeliveryRevokesAuthorizationAndCleanupMustSucceedBeforeRetryingTheView() throws Exception {
|
||||
var world = mock(World.class);
|
||||
when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||
var viewer = player(world, "Viewer", 1, 0);
|
||||
var target = player(world, "Concealed", 42, 8);
|
||||
when(target.getTrackedBy()).thenReturn(Set.of(viewer));
|
||||
when(viewer.canSee(target)).thenReturn(true);
|
||||
when(viewer.hasLineOfSight(target)).thenReturn(true);
|
||||
var items = new EyeItems();
|
||||
when(viewer.getInventory().getHelmet()).thenReturn(items.create());
|
||||
var initial = new PersistentStealthState(Map.of(), Map.of())
|
||||
.withEyeProgress(viewer.getUniqueId(), new EyeProgress(28_800_000, true));
|
||||
var shows = new AtomicInteger();
|
||||
var restores = new AtomicInteger();
|
||||
var failures = new ArrayList<Throwable>();
|
||||
var delivery = new EyeRevealController.Delivery() {
|
||||
@Override public void show(Player observer, Player subject, EyeRevealController.Projection projection, boolean create) {
|
||||
assertTrue(create);
|
||||
if (shows.incrementAndGet() == 1) { throw new IllegalStateException("Injected partial delivery failure"); }
|
||||
}
|
||||
@Override public void restore(Player observer, Optional<Player> subject, EyeRevealController.Projection projection) {
|
||||
if (restores.incrementAndGet() == 1) { throw new IllegalStateException("Injected restoration failure"); }
|
||||
}
|
||||
};
|
||||
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
|
||||
try (var states = new StealthStateManager(repository, initial)) {
|
||||
var ids = new AtomicInteger();
|
||||
var controller = new EyeRevealController(() -> List.of(viewer, target),
|
||||
new EyeEquipment(new EyeProgressionService(states, () -> 0L, ignored -> { }), items),
|
||||
id -> id.equals(target.getUniqueId()), delivery, () -> "eye" + ids.incrementAndGet(), failures::add);
|
||||
assertDoesNotThrow(controller::run);
|
||||
assertTrue(controller.snapshot().isEmpty(), "failed delivery cannot retain outgoing reveal authorization");
|
||||
assertDoesNotThrow(controller::run);
|
||||
assertEquals(1, shows.get(), "failed cleanup must block recreation of the view");
|
||||
assertTrue(controller.snapshot().isEmpty());
|
||||
controller.run();
|
||||
assertEquals(2, shows.get());
|
||||
assertEquals(2, restores.get());
|
||||
assertEquals(1, controller.snapshot().get(viewer.getUniqueId()).size());
|
||||
assertFalse(failures.isEmpty());
|
||||
controller.withdrawViewer(viewer.getUniqueId());
|
||||
assertEquals(3, restores.get());
|
||||
assertTrue(controller.snapshot().isEmpty());
|
||||
controller.close();
|
||||
}
|
||||
}
|
||||
|
||||
private static Player player(World world, String name, int entityId, double x) {
|
||||
var player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(player.getName()).thenReturn(name);
|
||||
when(player.getEntityId()).thenReturn(entityId);
|
||||
when(player.getLocation()).thenReturn(new Location(world, x, 64, 0));
|
||||
when(player.isOnline()).thenReturn(true);
|
||||
when(player.getGameMode()).thenReturn(GameMode.SURVIVAL);
|
||||
when(player.getInventory()).thenReturn(mock(PlayerInventory.class));
|
||||
when(player.getScoreboard()).thenReturn(mock(Scoreboard.class));
|
||||
return player;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import com.comphenix.protocol.PacketType;
|
||||
import com.comphenix.protocol.ProtocolManager;
|
||||
import com.comphenix.protocol.events.PacketContainer;
|
||||
import com.comphenix.protocol.events.PacketEvent;
|
||||
import com.comphenix.protocol.events.PacketListener;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.network.protocol.Packet;
|
||||
import net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket;
|
||||
import net.minecraft.network.syncher.EntityDataAccessor;
|
||||
import net.minecraft.network.syncher.SynchedEntityData;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.server.network.ServerGamePacketListenerImpl;
|
||||
import net.minecraft.world.scores.Scoreboard;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.craftbukkit.CraftServer;
|
||||
import org.bukkit.craftbukkit.entity.CraftPlayer;
|
||||
import org.bukkit.craftbukkit.inventory.CraftInventoryPlayer;
|
||||
import org.bukkit.craftbukkit.scheduler.CraftScheduler;
|
||||
import org.bukkit.craftbukkit.scoreboard.CraftScoreboard;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
import org.bukkit.scheduler.BukkitTask;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class EyeRevealRuntimeTest {
|
||||
@TempDir Path directory;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test
|
||||
@SuppressWarnings({"try", "unchecked"})
|
||||
void registeredRuntimeProjectsAndRestoresRealPacketsWithoutChangingTheServerOrIdentityChannels() throws Exception {
|
||||
var server = mock(CraftServer.class);
|
||||
try (var platform = mockStatic(org.bukkit.Bukkit.class, call -> switch (call.getMethod().getName()) {
|
||||
case "getServer" -> server;
|
||||
case "isPrimaryThread" -> true;
|
||||
case "getVersion" -> "Purpur 2618 (MC: 26.2)";
|
||||
case "getMinecraftVersion" -> "26.2";
|
||||
case "getBukkitVersion" -> "26.2-R0.1-SNAPSHOT";
|
||||
default -> call.callRealMethod();
|
||||
})) {
|
||||
var world = mock(World.class);
|
||||
when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||
var viewer = mock(CraftPlayer.class);
|
||||
var target = mock(CraftPlayer.class);
|
||||
UUID viewerId = UUID.randomUUID(), targetId = UUID.randomUUID();
|
||||
when(viewer.getUniqueId()).thenReturn(viewerId);
|
||||
when(target.getUniqueId()).thenReturn(targetId);
|
||||
when(viewer.getName()).thenReturn("Viewer");
|
||||
when(target.getName()).thenReturn("Concealed");
|
||||
when(viewer.isOnline()).thenReturn(true);
|
||||
when(target.isOnline()).thenReturn(true);
|
||||
when(viewer.getEntityId()).thenReturn(1);
|
||||
when(target.getEntityId()).thenReturn(42);
|
||||
when(viewer.getLocation()).thenReturn(new Location(world, 0, 64, 0));
|
||||
when(target.getLocation()).thenReturn(new Location(world, 8, 64, 0));
|
||||
when(viewer.canSee(target)).thenReturn(true);
|
||||
when(viewer.hasLineOfSight(target)).thenReturn(true);
|
||||
when(target.getTrackedBy()).thenReturn(Set.of(viewer));
|
||||
when(target.hasPotionEffect(PotionEffectType.INVISIBILITY)).thenReturn(true);
|
||||
var inventory = mock(CraftInventoryPlayer.class);
|
||||
when(viewer.getInventory()).thenReturn(inventory);
|
||||
var items = new EyeItems();
|
||||
when(inventory.getHelmet()).thenReturn(items.create());
|
||||
var board = new Scoreboard();
|
||||
var baseline = board.addPlayerTeam("source");
|
||||
baseline.setPlayerPrefix(Component.empty().withStyle(ChatFormatting.OBFUSCATED));
|
||||
board.addPlayerToTeam("Concealed", baseline);
|
||||
var constructor = CraftScoreboard.class.getDeclaredConstructor(Scoreboard.class);
|
||||
constructor.setAccessible(true);
|
||||
when(viewer.getScoreboard()).thenReturn(constructor.newInstance(board));
|
||||
var viewerHandle = mock(ServerPlayer.class);
|
||||
viewerHandle.connection = mock(ServerGamePacketListenerImpl.class);
|
||||
when(viewer.getHandle()).thenReturn(viewerHandle);
|
||||
var targetHandle = mock(ServerPlayer.class);
|
||||
when(target.getHandle()).thenReturn(targetHandle);
|
||||
var data = mock(SynchedEntityData.class);
|
||||
when(targetHandle.getEntityData()).thenReturn(data);
|
||||
var field = net.minecraft.world.entity.Entity.class.getDeclaredField("DATA_SHARED_FLAGS_ID");
|
||||
field.setAccessible(true);
|
||||
var flags = (EntityDataAccessor<Byte>) field.get(null);
|
||||
when(data.get(flags)).thenReturn((byte) 0x60);
|
||||
var protocol = mock(ProtocolManager.class);
|
||||
var packetListeners = new ArrayList<PacketListener>();
|
||||
doAnswer(call -> { packetListeners.add(call.getArgument(0)); return null; }).when(protocol).addPacketListener(any());
|
||||
doAnswer(call -> { packetListeners.remove(call.getArgument(0)); return null; }).when(protocol).removePacketListener(any());
|
||||
var sent = new ArrayList<Packet<?>>();
|
||||
doAnswer(call -> {
|
||||
Packet<?> raw = call.getArgument(0);
|
||||
PacketType type = raw instanceof ClientboundSetPlayerTeamPacket ? PacketType.Play.Server.SCOREBOARD_TEAM : PacketType.Play.Server.ENTITY_METADATA;
|
||||
var event = PacketEvent.fromServer(this, new PacketContainer(type, raw), viewer);
|
||||
for (var listener : List.copyOf(packetListeners)) {
|
||||
if (listener.getSendingWhitelist().getTypes().contains(type)) { listener.onPacketSending(event); }
|
||||
}
|
||||
if (!event.isCancelled()) { sent.add((Packet<?>) event.getPacket().getHandle()); }
|
||||
return null;
|
||||
}).when(viewerHandle.connection).send(org.mockito.ArgumentMatchers.<Packet<?>>any());
|
||||
var plugin = mock(JavaPlugin.class);
|
||||
when(plugin.getServer()).thenReturn(server);
|
||||
when(plugin.isEnabled()).thenReturn(true);
|
||||
when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger());
|
||||
var manager = mock(PluginManager.class);
|
||||
when(server.getPluginManager()).thenReturn(manager);
|
||||
var listeners = new ArrayList<Listener>();
|
||||
doAnswer(call -> { listeners.add(call.getArgument(0)); return null; }).when(manager).registerEvents(any(), eq(plugin));
|
||||
doReturn(List.of(viewer, target)).when(server).getOnlinePlayers();
|
||||
var scheduler = mock(CraftScheduler.class);
|
||||
when(server.getScheduler()).thenReturn(scheduler);
|
||||
var ticks = new ArrayList<Runnable>();
|
||||
var task = mock(BukkitTask.class);
|
||||
when(scheduler.runTaskTimer(eq(plugin), any(Runnable.class), eq(1L), eq(1L))).thenAnswer(call -> {
|
||||
ticks.add(call.getArgument(1)); return task;
|
||||
});
|
||||
var initial = new PersistentStealthState(Map.of(), Map.of()).withEyeProgress(viewerId, new EyeProgress(28_800_000, true));
|
||||
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
|
||||
try (var states = new StealthStateManager(repository, initial)) {
|
||||
var equipment = new EyeEquipment(new EyeProgressionService(states, () -> 0L, ignored -> { }), items);
|
||||
var runtime = new EyeRevealRuntime(plugin, equipment, targetId::equals, protocol);
|
||||
runtime.start();
|
||||
assertEquals(1, ticks.size(), "the runtime must register a one-tick visibility loop");
|
||||
ticks.getFirst().run();
|
||||
var create = assertInstanceOf(ClientboundSetPlayerTeamPacket.class, sent.getFirst());
|
||||
assertFalse(create.getParameters().orElseThrow().playerPrefix().getStyle().isObfuscated());
|
||||
assertEquals((byte) 0x40, assertInstanceOf(ClientboundSetEntityDataPacket.class, sent.get(1)).packedItems().getFirst().value());
|
||||
assertSame(baseline, board.getPlayersTeam("Concealed"));
|
||||
assertNull(board.getPlayerTeam(create.getName()));
|
||||
var events = assertInstanceOf(EyeRevealEvents.class, listeners.getFirst());
|
||||
when(viewer.canSee(target)).thenReturn(false);
|
||||
events.onHide(new org.bukkit.event.player.PlayerHideEntityEvent(viewer, target));
|
||||
assertTrue(assertInstanceOf(ClientboundSetPlayerTeamPacket.class, sent.get(2)).getParameters().orElseThrow().playerPrefix().getStyle().isObfuscated());
|
||||
assertEquals((byte) 0x60, assertInstanceOf(ClientboundSetEntityDataPacket.class, sent.get(3)).packedItems().getFirst().value());
|
||||
ticks.getFirst().run();
|
||||
assertEquals(7, sent.size(), "platform-hidden targets must remain hidden");
|
||||
when(viewer.canSee(target)).thenReturn(true);
|
||||
ticks.getFirst().run();
|
||||
assertFalse(assertInstanceOf(ClientboundSetPlayerTeamPacket.class, sent.get(7)).getParameters().orElseThrow().playerPrefix().getStyle().isObfuscated());
|
||||
runtime.close();
|
||||
int count = sent.size();
|
||||
ticks.getFirst().run();
|
||||
assertEquals(count, sent.size());
|
||||
assertTrue(packetListeners.isEmpty());
|
||||
verify(task).cancel();
|
||||
assertSame(baseline, board.getPlayersTeam("Concealed"));
|
||||
assertEquals((byte) 0x60, data.get(flags));
|
||||
verify(target, never()).removePotionEffect(any());
|
||||
verify(target, never()).setInvisible(anyBoolean());
|
||||
assertEquals(initial, states.snapshot());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import io.netty.buffer.Unpooled;
|
||||
import java.util.Optional;
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.Parameters;
|
||||
import net.minecraft.world.scores.Team;
|
||||
import net.minecraft.world.scores.TeamColor;
|
||||
import org.bukkit.craftbukkit.CraftRegistry;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class EyeTeamProjectionTest {
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test
|
||||
void nativeTeamProjectionRemovesOnlyTheScramblingPrefixAndRoundTripsEveryOtherField() {
|
||||
var config = new io.papermc.paper.configuration.GlobalConfiguration();
|
||||
config.collisions = config.new Collisions();
|
||||
config.collisions.enablePlayerCollisions = true;
|
||||
try (var platform = org.mockito.Mockito.mockStatic(io.papermc.paper.configuration.GlobalConfiguration.class)) {
|
||||
platform.when(io.papermc.paper.configuration.GlobalConfiguration::get).thenReturn(config);
|
||||
assertProjection();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void privateTeamsRevealOnlyTheirTargetWhileSharedServerMembershipUpdatesStayMasked() {
|
||||
var board = new net.minecraft.world.scores.Scoreboard();
|
||||
var shared = board.addPlayerTeam("shared");
|
||||
shared.setPlayerPrefix(Component.empty().withStyle(ChatFormatting.OBFUSCATED));
|
||||
board.addPlayerToTeam("Alice", shared);
|
||||
board.addPlayerToTeam("Bob", shared);
|
||||
var packet = net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.createAddOrModifyPacket(shared, true);
|
||||
var filtered = assertInstanceOf(net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.class,
|
||||
EyePacketProjection.teamPacket(packet, java.util.Set.of("eye-private"), java.util.Set.of("Alice")));
|
||||
assertEquals(java.util.Set.of("Bob"), java.util.Set.copyOf(filtered.getPlayers()),
|
||||
"server membership updates must not pull an active Eye target out of its private team");
|
||||
assertSame(packet.getParameters().orElseThrow(), filtered.getParameters().orElseThrow());
|
||||
assertTrue(filtered.getParameters().orElseThrow().playerPrefix().getStyle().isObfuscated());
|
||||
assertEquals(java.util.Set.of("Alice", "Bob"), java.util.Set.copyOf(packet.getPlayers()));
|
||||
var isolated = board.addPlayerTeam("eye-private");
|
||||
isolated.setPlayerPrefix(Component.empty().withStyle(ChatFormatting.OBFUSCATED));
|
||||
board.addPlayerToTeam("Alice", isolated);
|
||||
var privatePacket = net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.createAddOrModifyPacket(isolated, true);
|
||||
var revealed = assertInstanceOf(net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.class,
|
||||
EyePacketProjection.teamPacket(privatePacket, java.util.Set.of("eye-private"), java.util.Set.of("Alice")));
|
||||
assertEquals(java.util.Set.of("Alice"), java.util.Set.copyOf(revealed.getPlayers()));
|
||||
assertFalse(revealed.getParameters().orElseThrow().playerPrefix().getStyle().isObfuscated());
|
||||
assertTrue(privatePacket.getParameters().orElseThrow().playerPrefix().getStyle().isObfuscated());
|
||||
assertSame(privatePacket, EyePacketProjection.teamPacket(privatePacket, java.util.Set.of(), java.util.Set.of()),
|
||||
"without current authorization even queued private packets must retain their original mask");
|
||||
for (var action : net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.Action.values()) {
|
||||
var membership = net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.createPlayerPacket(shared, "Alice", action);
|
||||
var projected = assertInstanceOf(net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.class,
|
||||
EyePacketProjection.teamPacket(membership, java.util.Set.of("eye-private"), java.util.Set.of("Alice")));
|
||||
assertTrue(projected.getPlayers().isEmpty());
|
||||
assertEquals(membership.getPlayerAction(), projected.getPlayerAction());
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertProjection() {
|
||||
var original = new Parameters(Component.literal("Keep team display"),
|
||||
Component.empty().withStyle(ChatFormatting.OBFUSCATED), Component.literal("Keep suffix"),
|
||||
Team.Visibility.ALWAYS, Team.CollisionRule.PUSH_OWN_TEAM, Optional.of(TeamColor.AQUA), (byte) 0x53);
|
||||
var projected = assertInstanceOf(Parameters.class, EyePacketProjection.teamParameters(original));
|
||||
var expected = new Parameters(original.displayName(), Component.empty(), original.playerSuffix(),
|
||||
original.nameTagVisibility(), original.collisionRule(), original.color(), original.options());
|
||||
assertEquals(expected, projected);
|
||||
assertNotSame(original, projected);
|
||||
assertTrue(original.playerPrefix().getStyle().isObfuscated(), "the shared server/team packet must remain unchanged");
|
||||
var buffer = new RegistryFriendlyByteBuf(Unpooled.buffer(), CraftRegistry.getMinecraftRegistry());
|
||||
try {
|
||||
Parameters.STREAM_CODEC.encode(buffer, projected);
|
||||
assertEquals(projected, Parameters.STREAM_CODEC.decode(buffer));
|
||||
} finally { buffer.release(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.network.protocol.Packet;
|
||||
import net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket;
|
||||
import net.minecraft.network.syncher.EntityDataAccessor;
|
||||
import net.minecraft.network.syncher.SynchedEntityData;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.server.network.ServerGamePacketListenerImpl;
|
||||
import net.minecraft.world.scores.Scoreboard;
|
||||
import net.minecraft.world.scores.TeamColor;
|
||||
import org.bukkit.craftbukkit.entity.CraftPlayer;
|
||||
import org.bukkit.craftbukkit.scoreboard.CraftScoreboard;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class NativeEyeDeliveryTest {
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.CsvSource({"ALWAYS,false,true", "NEVER,false,false", "HIDE_FOR_OWN_TEAM,true,false",
|
||||
"HIDE_FOR_OWN_TEAM,false,true", "HIDE_FOR_OTHER_TEAMS,true,true", "HIDE_FOR_OTHER_TEAMS,false,false"})
|
||||
@SuppressWarnings("unchecked") // The declared runtime accessor's BYTE serializer is verified in the metadata codec tests.
|
||||
void deliveryUsesOnlyTheViewerConnectionAndRestoresCurrentStateWithoutChangingServerTeamsOrFlags(
|
||||
net.minecraft.world.scores.Team.Visibility visibility, boolean teammate, boolean visible) throws Exception {
|
||||
var board = new Scoreboard();
|
||||
var baseline = board.addPlayerTeam("source");
|
||||
baseline.setPlayerPrefix(Component.empty().withStyle(ChatFormatting.OBFUSCATED));
|
||||
baseline.setColor(Optional.of(TeamColor.AQUA));
|
||||
baseline.setNameTagVisibility(visibility);
|
||||
if (teammate) { board.addPlayerToTeam("Viewer", baseline); }
|
||||
board.addPlayerToTeam("Concealed", baseline);
|
||||
board.addPlayerToTeam("OtherConcealed", baseline);
|
||||
var boardConstructor = CraftScoreboard.class.getDeclaredConstructor(Scoreboard.class);
|
||||
boardConstructor.setAccessible(true);
|
||||
var bukkitBoard = boardConstructor.newInstance(board);
|
||||
var viewer = mock(CraftPlayer.class);
|
||||
var viewerHandle = mock(ServerPlayer.class);
|
||||
var connection = mock(ServerGamePacketListenerImpl.class);
|
||||
viewerHandle.connection = connection;
|
||||
when(viewer.getHandle()).thenReturn(viewerHandle);
|
||||
when(viewer.getName()).thenReturn("Viewer");
|
||||
when(viewer.getScoreboard()).thenReturn(bukkitBoard);
|
||||
var sent = new ArrayList<Packet<?>>();
|
||||
doAnswer(call -> { sent.add(call.getArgument(0)); return null; })
|
||||
.when(connection).send(org.mockito.ArgumentMatchers.<Packet<?>>any());
|
||||
var target = mock(CraftPlayer.class);
|
||||
var targetHandle = mock(ServerPlayer.class);
|
||||
when(target.getHandle()).thenReturn(targetHandle);
|
||||
when(target.getEntityId()).thenReturn(42);
|
||||
when(target.isOnline()).thenReturn(true);
|
||||
var data = mock(SynchedEntityData.class);
|
||||
when(targetHandle.getEntityData()).thenReturn(data);
|
||||
var accessorField = net.minecraft.world.entity.Entity.class.getDeclaredField("DATA_SHARED_FLAGS_ID");
|
||||
accessorField.setAccessible(true);
|
||||
var flagsAccessor = (EntityDataAccessor<Byte>) accessorField.get(null);
|
||||
var flags = new AtomicReference<>((byte) 0xa0);
|
||||
when(data.get(flagsAccessor)).thenAnswer(ignored -> flags.get());
|
||||
var projection = new EyeRevealController.Projection(UUID.randomUUID(), 42, "Concealed", "eye-private",
|
||||
Optional.of("source"), true);
|
||||
var delivery = new NativeEyeDelivery();
|
||||
delivery.show(viewer, target, projection, true);
|
||||
assertEquals(2, sent.size(), "a private team and raw metadata refresh are required");
|
||||
var team = assertInstanceOf(ClientboundSetPlayerTeamPacket.class, sent.get(0));
|
||||
assertEquals("eye-private", team.getName());
|
||||
assertEquals(ClientboundSetPlayerTeamPacket.Action.ADD, team.getTeamAction());
|
||||
assertEquals(java.util.List.of("Concealed"), java.util.List.copyOf(team.getPlayers()));
|
||||
assertEquals(visible ? net.minecraft.world.scores.Team.Visibility.ALWAYS : net.minecraft.world.scores.Team.Visibility.NEVER,
|
||||
team.getParameters().orElseThrow().nameTagVisibility(), "private teams must preserve effective visibility, not change team relationships");
|
||||
assertTrue(team.getParameters().orElseThrow().playerPrefix().getStyle().isObfuscated(),
|
||||
"raw state must reach the outgoing authorization gate before any prefix is revealed");
|
||||
var metadata = assertInstanceOf(ClientboundSetEntityDataPacket.class, sent.get(1));
|
||||
assertEquals(42, metadata.id());
|
||||
assertEquals((byte) 0xa0, metadata.packedItems().getFirst().value());
|
||||
assertNull(board.getPlayerTeam("eye-private"));
|
||||
assertEquals(teammate ? Set.of("Viewer", "Concealed", "OtherConcealed") : Set.of("Concealed", "OtherConcealed"), Set.copyOf(baseline.getPlayers()));
|
||||
assertEquals((byte) 0xa0, flags.get());
|
||||
// Another plugin can move the actor to a different real team while the private view is active.
|
||||
var replacement = board.addPlayerTeam("replacement");
|
||||
replacement.setPlayerPrefix(Component.empty().withStyle(ChatFormatting.OBFUSCATED));
|
||||
replacement.setColor(Optional.of(TeamColor.GOLD));
|
||||
replacement.setNameTagVisibility(visibility);
|
||||
board.addPlayerToTeam("Concealed", replacement);
|
||||
flags.set((byte) 9);
|
||||
sent.clear();
|
||||
delivery.restore(viewer, Optional.of(target), projection);
|
||||
assertEquals(5, sent.size(), "restore the mask and flags before moving or removing client team membership");
|
||||
var privateMask = assertInstanceOf(ClientboundSetPlayerTeamPacket.class, sent.get(0));
|
||||
assertEquals("eye-private", privateMask.getName());
|
||||
assertTrue(privateMask.getParameters().orElseThrow().playerPrefix().getStyle().isObfuscated());
|
||||
boolean nowVisible = visibility == net.minecraft.world.scores.Team.Visibility.ALWAYS
|
||||
|| visibility == net.minecraft.world.scores.Team.Visibility.HIDE_FOR_OWN_TEAM;
|
||||
assertEquals(nowVisible ? net.minecraft.world.scores.Team.Visibility.ALWAYS : net.minecraft.world.scores.Team.Visibility.NEVER,
|
||||
privateMask.getParameters().orElseThrow().nameTagVisibility());
|
||||
var restoredFlags = assertInstanceOf(ClientboundSetEntityDataPacket.class, sent.get(1));
|
||||
assertEquals((byte) 9, restoredFlags.packedItems().getFirst().value());
|
||||
var restoredTeam = assertInstanceOf(ClientboundSetPlayerTeamPacket.class, sent.get(2));
|
||||
assertEquals("replacement", restoredTeam.getName());
|
||||
assertEquals(Optional.of(TeamColor.GOLD), restoredTeam.getParameters().orElseThrow().color());
|
||||
assertEquals(visibility, restoredTeam.getParameters().orElseThrow().nameTagVisibility());
|
||||
var membership = assertInstanceOf(ClientboundSetPlayerTeamPacket.class, sent.get(3));
|
||||
assertEquals("replacement", membership.getName());
|
||||
assertEquals(ClientboundSetPlayerTeamPacket.Action.ADD, membership.getPlayerAction());
|
||||
assertEquals(java.util.List.of("Concealed"), java.util.List.copyOf(membership.getPlayers()));
|
||||
var removal = assertInstanceOf(ClientboundSetPlayerTeamPacket.class, sent.get(4));
|
||||
assertEquals("eye-private", removal.getName());
|
||||
assertEquals(ClientboundSetPlayerTeamPacket.Action.REMOVE, removal.getTeamAction());
|
||||
assertSame(replacement, board.getPlayersTeam("Concealed"));
|
||||
assertEquals(teammate ? Set.of("Viewer", "OtherConcealed") : Set.of("OtherConcealed"), Set.copyOf(baseline.getPlayers()));
|
||||
assertNull(board.getPlayerTeam("eye-private"));
|
||||
verify(data, never()).set(any(), any());
|
||||
verify(connection, times(7)).send(org.mockito.ArgumentMatchers.<Packet<?>>any());
|
||||
verifyNoMoreInteractions(connection);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user