feat(stealth): route concealed public chat as system messages
Release / release (push) Successful in 4m59s
CI / build (push) Successful in 2m46s

Preserve final moderation, selected recipients, viewer-aware formatting, native content filters and client chat settings. Retain profile/chat sessions through tab-list status updates. Verify real native pipeline, packet codecs, RSA signed message chains and acknowledgement bookkeeping without changing authentication settings. No deployment is included.
This commit is contained in:
dmg
2026-09-12 22:05:08 -04:00
parent c1bd4406d2
commit 8b412141da
11 changed files with 563 additions and 9 deletions
@@ -137,6 +137,8 @@ class EyePluginLifecycleTest {
assertTrue(listeners.stream().anyMatch(EyePotionListener.class::isInstance));
assertTrue(listeners.stream().anyMatch(EyeEquipment.class::isInstance));
assertTrue(listeners.stream().anyMatch(CombatRevealListener.class::isInstance), "plugin startup must install combat breaking");
assertTrue(listeners.stream().anyMatch(StealthSystemChat.class::isInstance), "plugin startup must register anonymous public-chat routing");
assertTrue(registeredPackets.stream().anyMatch(TabListMaskingListener.class::isInstance), "plugin startup must preserve profile/session data while hiding tab entries");
assertTrue(listeners.stream().anyMatch(PotionDamageListener.class::isInstance), "plugin startup must observe potion provenance");
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");
@@ -0,0 +1,74 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.util.ArrayList;
import java.util.UUID;
import net.minecraft.network.chat.*;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.ClientboundPlayerChatPacket;
import net.minecraft.network.protocol.game.ClientboundSystemChatPacket;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.network.ServerGamePacketListenerImpl;
import org.bukkit.craftbukkit.CraftRegistry;
import org.bukkit.craftbukkit.entity.CraftPlayer;
/** Real native dispatch/codec/signature/ack state, with only the socket and already-authenticated player boundaries doubled. */
final class NativeChatWire {
final ArrayList<Packet<?>> packets = new ArrayList<>();
final LastSeenMessagesValidator serverSeen = new LastSeenMessagesValidator(20);
private final LastSeenMessagesTracker clientSeen = new LastSeenMessagesTracker(20);
private final MessageSignatureCache clientCache = MessageSignatureCache.createDefault();
private int nextChatIndex;
NativeChatWire(CraftPlayer recipient) throws Exception {
var player = recipient.getHandle();
set(ServerPlayer.class, player, "chatVisibility", net.minecraft.world.entity.player.ChatVisiblity.FULL);
var connection = mock(ServerGamePacketListenerImpl.class);
player.connection = connection;
when(connection.getCraftPlayer()).thenReturn(recipient);
when(recipient.canSeePlayer(any(UUID.class))).thenReturn(true);
set(ServerGamePacketListenerImpl.class, connection, "lastSeenMessages", serverSeen);
set(ServerGamePacketListenerImpl.class, connection, "messageSignatureCache", MessageSignatureCache.createDefault());
doCallRealMethod().when(connection).sendPlayerChatMessage(any(PlayerChatMessage.class), any(ChatType.Bound.class));
doCallRealMethod().when(player).sendChatMessage(any(OutgoingChatMessage.class), anyBoolean(), any(ChatType.Bound.class), nullable(Component.class));
doCallRealMethod().when(recipient).sendMessage(any(net.kyori.adventure.text.Component.class));
doAnswer(call -> { packets.add(call.getArgument(0)); return null; }).when(connection).send(org.mockito.ArgumentMatchers.<Packet<?>>any());
}
void receive(boolean system, UUID session, SignedMessageValidator validator, String expected) throws Exception {
assertEquals(1, packets.size(), "exactly one packet for the selected recipient");
var buffer = new net.minecraft.network.RegistryFriendlyByteBuf(io.netty.buffer.Unpooled.buffer(), CraftRegistry.getMinecraftRegistry());
try {
if (system) {
var packet = assertInstanceOf(ClientboundSystemChatPacket.class, packets.getFirst());
ClientboundSystemChatPacket.STREAM_CODEC.encode(buffer, packet);
var decoded = ClientboundSystemChatPacket.STREAM_CODEC.decode(buffer);
assertFalse(decoded.overlay());
assertEquals("[Stealth] Anonymous: " + expected, decoded.content().getString());
} else {
var packet = assertInstanceOf(ClientboundPlayerChatPacket.class, packets.getFirst());
ClientboundPlayerChatPacket.STREAM_CODEC.encode(buffer, packet);
var decoded = ClientboundPlayerChatPacket.STREAM_CODEC.decode(buffer);
assertEquals(nextChatIndex++, decoded.globalIndex(), "system messages must not create signed packet-index gaps");
var body = decoded.body().unpack(clientCache).orElseThrow();
var message = new PlayerChatMessage(new SignedMessageLink(decoded.index(), decoded.sender(), session), decoded.signature(), body,
decoded.unsignedContent(), decoded.filterMask());
assertEquals(expected, body.content());
assertNotNull(validator.updateAndValidate(message), "the retained native client signature chain must accept normal chat again");
assertTrue(clientSeen.addPending(decoded.signature(), true));
clientCache.push(body, decoded.signature());
}
} finally { buffer.release(); }
var acknowledgement = clientSeen.generateAndApplyUpdate();
assertEquals(acknowledgement.lastSeen(), serverSeen.applyUpdate(acknowledgement.update()));
assertEquals(20, serverSeen.trackedMessagesCount(), "no invisible signed packets may accumulate in server pending state");
packets.clear();
}
private static void set(Class<?> type, Object receiver, String name, Object value) throws Exception {
var field = type.getDeclaredField(name);
field.setAccessible(true);
field.set(receiver, value);
}
}
@@ -45,9 +45,17 @@ class NativeCombatTabRestoreTest {
when(protocol.createPacket(any(PacketType.class))).thenAnswer(call -> new PacketContainer(call.getArgument(0)));
var sent = new ArrayList<PacketContainer>();
doAnswer(call -> { sent.add(call.getArgument(1)); return null; }).when(protocol).sendServerPacket(eq(observer), any(PacketContainer.class));
new ProtocolLibTabListController(protocol).add(observer, target);
var controller = new ProtocolLibTabListController(protocol);
controller.remove(observer, id);
var hidden = assertInstanceOf(ClientboundPlayerInfoUpdatePacket.class, sent.getFirst().getHandle(),
"tab hiding must not remove the profile/chat session needed for later signed messages");
assertEquals(java.util.EnumSet.of(ClientboundPlayerInfoUpdatePacket.Action.UPDATE_LISTED), hidden.actions());
assertEquals(id, hidden.entries().getFirst().profileId());
assertFalse(hidden.entries().getFirst().listed());
sent.clear();
controller.add(observer, target);
var packet = assertInstanceOf(ClientboundPlayerInfoUpdatePacket.class, sent.getFirst().getHandle());
assertTrue(packet.actions().contains(ClientboundPlayerInfoUpdatePacket.Action.ADD_PLAYER));
assertFalse(packet.actions().contains(ClientboundPlayerInfoUpdatePacket.Action.ADD_PLAYER), "restoration must retain the existing profile/chat session");
assertTrue(packet.actions().contains(ClientboundPlayerInfoUpdatePacket.Action.UPDATE_LISTED));
var entry = packet.entries().getFirst();
assertEquals(id, entry.profileId());
@@ -61,7 +69,14 @@ class NativeCombatTabRestoreTest {
ClientboundPlayerInfoUpdatePacket.STREAM_CODEC.encode(buffer, packet);
var decoded = ClientboundPlayerInfoUpdatePacket.STREAM_CODEC.decode(buffer);
assertEquals(packet.actions(), decoded.actions());
assertEquals(packet.entries(), decoded.entries());
var restored = decoded.entries().getFirst();
assertEquals(entry.profileId(), restored.profileId());
assertEquals(entry.listed(), restored.listed());
assertEquals(entry.latency(), restored.latency());
assertEquals(entry.gameMode(), restored.gameMode());
assertEquals(entry.displayName(), restored.displayName());
assertNull(restored.profile(), "no profile replacement is encoded");
assertNull(restored.chatSession(), "no chat-session replacement is encoded");
} finally { buffer.release(); }
}
}
@@ -0,0 +1,203 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import io.papermc.paper.adventure.ChatProcessor;
import io.papermc.paper.event.player.AsyncChatEvent;
import io.papermc.paper.event.player.ChatEvent;
import java.util.List;
import java.util.UUID;
import net.kyori.adventure.text.Component;
import net.minecraft.network.chat.PlayerChatMessage;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import org.bukkit.Bukkit;
import org.bukkit.craftbukkit.CraftRegistry;
import org.bukkit.craftbukkit.CraftServer;
import org.bukkit.craftbukkit.CraftWorld;
import org.bukkit.craftbukkit.entity.CraftPlayer;
import org.bukkit.event.EventHandler;
import org.bukkit.event.HandlerList;
import org.bukkit.plugin.EventExecutor;
import org.bukkit.plugin.PluginManager;
import org.bukkit.plugin.RegisteredListener;
import org.bukkit.plugin.java.JavaPlugin;
import org.junit.jupiter.api.BeforeAll;
class NativeSystemChatTest {
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"local", "cancelled", "late-cancelled", "ordinary", "metadata", "custom-renderer", "transition", "filtered", "partially-filtered", "commands-only", "chat-hidden", "signed-chain", "async-main", "legacy", "legacy-cancelled"})
@SuppressWarnings("try")
void actualPaperPipelinePreservesModerationAndAnonymousSystemDelivery(String scenario) throws Exception {
var server = mock(CraftServer.class);
var console = mock(org.bukkit.command.ConsoleCommandSender.class);
try (var platform = mockStatic(Bukkit.class, call -> switch (call.getMethod().getName()) {
case "getServer" -> server;
case "getConsoleSender" -> console;
case "isPrimaryThread" -> true;
default -> call.callRealMethod();
})) {
var sender = mock(CraftPlayer.class);
var recipient = mock(CraftPlayer.class);
var outsider = mock(CraftPlayer.class);
UUID senderId = UUID.randomUUID();
when(sender.getUniqueId()).thenReturn(senderId);
when(sender.getName()).thenReturn("Secret");
when(sender.displayName()).thenReturn(Component.text("Secret"));
when(sender.getDisplayName()).thenReturn("Secret");
var nativeSender = mock(ServerPlayer.class);
var nativeRecipient = mock(ServerPlayer.class);
var nativeOutsider = mock(ServerPlayer.class);
when(sender.getHandle()).thenReturn(nativeSender);
when(recipient.getHandle()).thenReturn(nativeRecipient);
when(outsider.getHandle()).thenReturn(nativeOutsider);
when(nativeSender.getBukkitEntity()).thenReturn(sender);
when(nativeRecipient.getBukkitEntity()).thenReturn(recipient);
when(nativeRecipient.getChatVisibility()).thenReturn(scenario.equals("commands-only") ? net.minecraft.world.entity.player.ChatVisiblity.SYSTEM
: scenario.equals("chat-hidden") ? net.minecraft.world.entity.player.ChatVisiblity.HIDDEN : net.minecraft.world.entity.player.ChatVisiblity.FULL);
when(nativeOutsider.getBukkitEntity()).thenReturn(outsider);
var world = mock(CraftWorld.class);
var nativeWorld = mock(ServerLevel.class);
when(sender.getWorld()).thenReturn(world);
when(world.getHandle()).thenReturn(nativeWorld);
when(nativeSender.level()).thenReturn(nativeWorld);
when(nativeWorld.registryAccess()).thenReturn(CraftRegistry.getMinecraftRegistry());
var config = mock(io.papermc.paper.configuration.WorldConfiguration.class);
config.scoreboards = mock(io.papermc.paper.configuration.WorldConfiguration.Scoreboards.class);
when(nativeWorld.paperConfig()).thenReturn(config);
var nativeServer = mock(MinecraftServer.class);
nativeServer.server = server;
nativeServer.console = console;
var playerList = mock(net.minecraft.server.players.PlayerList.class);
when(nativeServer.getPlayerList()).thenReturn(playerList);
when(playerList.getPlayers()).thenReturn(List.of(nativeSender, nativeRecipient, nativeOutsider));
var plugin = mock(JavaPlugin.class);
when(plugin.isEnabled()).thenReturn(true);
var concealed = new java.util.concurrent.atomic.AtomicBoolean(!scenario.equals("ordinary"));
var listener = new StealthSystemChat(id -> senderId.equals(id) && concealed.get());
var method = StealthSystemChat.class.getMethod("onChat", ChatEvent.class);
var annotation = method.getAnnotation(EventHandler.class);
ChatEvent.getHandlerList().register(new RegisteredListener(listener, EventExecutor.create(method, ChatEvent.class), annotation.priority(), plugin, annotation.ignoreCancelled()));
org.bukkit.event.Listener legacy = new org.bukkit.event.Listener() { };
if (scenario.startsWith("legacy")) {
org.bukkit.event.player.AsyncPlayerChatEvent.getHandlerList().register(new RegisteredListener(legacy, (registered, event) -> {
var chat = (org.bukkit.event.player.AsyncPlayerChatEvent) event;
chat.getRecipients().retainAll(java.util.Set.of(recipient));
chat.setMessage("moderated");
chat.setFormat("[Local] %1$s: %2$s");
if (scenario.equals("legacy-cancelled")) { chat.setCancelled(true); }
}, org.bukkit.event.EventPriority.HIGHEST, plugin, false));
}
var manager = mock(PluginManager.class);
when(server.getPluginManager()).thenReturn(manager);
doAnswer(call -> {
var event = call.getArgument(0, org.bukkit.event.Event.class);
if (event instanceof AsyncChatEvent chat && !scenario.equals("async-main")) {
chat.viewers().retainAll(java.util.Set.of(recipient)); // Ordinary local-chat/moderation stage.
if (scenario.equals("cancelled")) { chat.setCancelled(true); }
if (scenario.equals("metadata")) {
chat.message(Component.text("hello Secret").hoverEvent(net.kyori.adventure.text.event.HoverEvent.showEntity(
net.kyori.adventure.key.Key.key("minecraft:player"), senderId, Component.text("Secret")))
.clickEvent(net.kyori.adventure.text.event.ClickEvent.suggestCommand("/msg Secret hi")).insertion(senderId.toString()));
}
if (scenario.equals("custom-renderer")) {
chat.renderer((source, display, message, viewer) -> {
assertSame(recipient, viewer, "viewer-aware formatting must see the original recipient");
return Component.text("[Local] ").append(display).append(Component.text(": ")).append(message);
});
}
}
if (event instanceof ChatEvent chat && scenario.equals("async-main")) {
assertFalse(chat.isAsynchronous());
chat.viewers().retainAll(java.util.Set.of(recipient));
}
for (var registered : event.getHandlers().getRegisteredListeners()) { registered.callEvent(event); }
if (event instanceof ChatEvent chat && scenario.equals("late-cancelled")) { chat.setCancelled(true); }
return null;
}).when(manager).callEvent(any());
try {
if (scenario.equals("signed-chain")) {
verifySignedChain(nativeServer, nativeSender, recipient, senderId, concealed);
return;
}
var message = PlayerChatMessage.unsigned(senderId, "hello");
if (scenario.endsWith("filtered")) {
when(nativeSender.shouldFilterMessageTo(nativeRecipient)).thenReturn(true);
var mask = new net.minecraft.network.chat.FilterMask(5);
mask.setFiltered(0);
message = message.filter(scenario.equals("filtered") ? net.minecraft.network.chat.FilterMask.FULLY_FILTERED : mask);
}
if (scenario.equals("async-main")) {
var queue = new java.util.concurrent.LinkedBlockingQueue<Runnable>();
var queueField = MinecraftServer.class.getDeclaredField("processQueue");
queueField.setAccessible(true);
queueField.set(nativeServer, queue);
Thread main = Thread.currentThread();
doAnswer(call -> { assertSame(main, Thread.currentThread(), "system delivery must stay on the main event thread"); return null; }).when(recipient).sendMessage(any(Component.class));
var processor = new ChatProcessor(nativeServer, nativeSender, message, true);
var completed = java.util.concurrent.CompletableFuture.runAsync(processor::process);
var work = queue.poll(5, java.util.concurrent.TimeUnit.SECONDS);
assertNotNull(work, "native asynchronous chat must queue its synchronous event");
work.run();
completed.get(5, java.util.concurrent.TimeUnit.SECONDS);
} else { new ChatProcessor(nativeServer, nativeSender, message, false).process(); }
if (scenario.equals("ordinary")) {
verify(recipient, never()).sendMessage(any(Component.class));
verify(nativeRecipient).sendChatMessage(any(net.minecraft.network.chat.OutgoingChatMessage.class), anyBoolean(), any(net.minecraft.network.chat.ChatType.Bound.class), nullable(net.minecraft.network.chat.Component.class));
return;
}
if (scenario.contains("cancelled") || java.util.Set.of("filtered", "commands-only", "chat-hidden").contains(scenario)) { verify(recipient, never()).sendMessage(any(Component.class)); }
else {
String expected = scenario.equals("legacy") ? "[Local] Anonymous: moderated" : scenario.equals("partially-filtered") ? "[Stealth] Anonymous: #ello" : scenario.equals("custom-renderer") ? "[Local] Anonymous: hello" : "[Stealth] Anonymous: hello" + (scenario.equals("metadata") ? " Anonymous" : "");
verify(recipient).sendMessage(Component.text(expected));
}
verify(nativeRecipient, never()).sendChatMessage(any(net.minecraft.network.chat.OutgoingChatMessage.class), anyBoolean(), any(net.minecraft.network.chat.ChatType.Bound.class), any());
verify(outsider, never()).sendMessage(any(Component.class));
verify(console, never()).sendMessage(any(Component.class));
verify(sender, never()).sendMessage(any(Component.class));
verify(nativeOutsider, never()).sendChatMessage(any(net.minecraft.network.chat.OutgoingChatMessage.class), anyBoolean(), any(net.minecraft.network.chat.ChatType.Bound.class), any());
if (scenario.equals("transition")) {
concealed.set(false);
new ChatProcessor(nativeServer, nativeSender, PlayerChatMessage.unsigned(senderId, "normal"), false).process();
verify(nativeRecipient).sendChatMessage(any(net.minecraft.network.chat.OutgoingChatMessage.class), anyBoolean(), any(net.minecraft.network.chat.ChatType.Bound.class), nullable(net.minecraft.network.chat.Component.class));
verify(recipient, times(1)).sendMessage(any(Component.class));
}
} finally { HandlerList.unregisterAll(listener); HandlerList.unregisterAll(legacy); }
}
}
private static void verifySignedChain(MinecraftServer server, ServerPlayer sender, CraftPlayer recipient, UUID senderId,
java.util.concurrent.atomic.AtomicBoolean concealed) throws Exception {
var generator = java.security.KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
var keys = generator.generateKeyPair();
var publicKey = new net.minecraft.world.entity.player.ProfilePublicKey(new net.minecraft.world.entity.player.ProfilePublicKey.Data(
java.time.Instant.now().plusSeconds(3600), keys.getPublic(), new byte[0]));
UUID session = UUID.randomUUID();
var encoder = new net.minecraft.network.chat.SignedMessageChain(senderId, session)
.encoder(net.minecraft.util.Signer.from(keys.getPrivate(), "SHA256withRSA"));
var decoder = new net.minecraft.network.chat.SignedMessageChain(senderId, session).decoder(publicKey);
var receiver = new net.minecraft.network.chat.SignedMessageValidator.KeyBased(publicKey.createSignatureValidator(), publicKey.data()::hasExpired);
var wire = new NativeChatWire(recipient);
for (int index = 0; index < 66; index++) {
boolean anonymous = index > 0 && index < 65;
concealed.set(anonymous);
String text = "hello " + index;
var body = new net.minecraft.network.chat.SignedMessageBody(text, java.time.Instant.now(), index + 1L, net.minecraft.network.chat.LastSeenMessages.EMPTY);
var signature = encoder.pack(body);
var message = decoder.unpack(signature, body);
assertTrue(message.verify(publicKey.createSignatureValidator()));
assertEquals(index, message.link().index());
new ChatProcessor(server, sender, message, false).process();
wire.receive(anonymous, session, receiver, text);
}
var invalid = new net.minecraft.network.chat.SignedMessageChain(senderId, session).decoder(publicKey);
assertThrows(net.minecraft.network.chat.SignedMessageChain.DecodeException.class,
() -> invalid.unpack(new net.minecraft.network.chat.MessageSignature(new byte[256]), net.minecraft.network.chat.SignedMessageBody.unsigned("tampered")));
assertThrows(net.minecraft.network.chat.LastSeenMessagesValidator.ValidationException.class,
() -> new net.minecraft.network.chat.LastSeenMessagesValidator(20).applyOffset(Integer.MAX_VALUE));
}
}
@@ -0,0 +1,81 @@
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.EnumSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
class NativeTabMaskingTest {
private static net.minecraft.network.chat.RemoteChatSession.Data session;
@BeforeAll static void bootstrap() throws Exception {
NativeRuntime.bootstrap();
var generator = java.security.KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
session = new net.minecraft.network.chat.RemoteChatSession.Data(UUID.randomUUID(), new net.minecraft.world.entity.player.ProfilePublicKey.Data(
java.time.Instant.now().plusSeconds(3600).truncatedTo(java.time.temporal.ChronoUnit.MILLIS), generator.generateKeyPair().getPublic(), new byte[0]));
}
@Test
@SuppressWarnings("try")
void serverInitializationKeepsProfileAndChatStateButCannotRelistAConcealedPlayer() {
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 hiddenId = UUID.randomUUID(), otherId = UUID.randomUUID(), viewerId = UUID.randomUUID();
var viewer = mock(org.bukkit.entity.Player.class);
when(viewer.getUniqueId()).thenReturn(viewerId);
var snapshot = new java.util.concurrent.atomic.AtomicReference<>(Set.of(hiddenId));
var warnings = new java.util.ArrayList<String>();
var listener = new TabListMaskingListener(mock(org.bukkit.plugin.Plugin.class), snapshot::get, warnings::add);
var hidden = entry(hiddenId, "Hidden");
var other = entry(otherId, "Other");
var original = new ClientboundPlayerInfoUpdatePacket(EnumSet.allOf(ClientboundPlayerInfoUpdatePacket.Action.class), List.of(hidden, other));
var packet = new PacketContainer(PacketType.Play.Server.PLAYER_INFO, original);
var event = PacketEvent.fromServer(this, packet, viewer);
listener.onPacketSending(event);
var projected = assertInstanceOf(ClientboundPlayerInfoUpdatePacket.class, event.getPacket().getHandle());
assertFalse(projected.entries().getFirst().listed(), "native initialization must not undo concealment");
assertEquals(original.actions(), projected.actions());
var expected = new ClientboundPlayerInfoUpdatePacket.Entry(hiddenId, hidden.profile(), false, hidden.latency(), hidden.gameMode(), hidden.displayName(), hidden.showHat(), hidden.listOrder(), hidden.chatSession());
assertEquals(expected, projected.entries().getFirst());
assertSame(other, projected.entries().get(1));
assertTrue(original.entries().getFirst().listed());
var buffer = new net.minecraft.network.RegistryFriendlyByteBuf(io.netty.buffer.Unpooled.buffer(), org.bukkit.craftbukkit.CraftRegistry.getMinecraftRegistry());
try {
ClientboundPlayerInfoUpdatePacket.STREAM_CODEC.encode(buffer, projected);
var decoded = ClientboundPlayerInfoUpdatePacket.STREAM_CODEC.decode(buffer);
assertEquals(projected.actions(), decoded.actions());
assertEquals(projected.entries(), decoded.entries(), "profile/session and all other native fields survive the wire codec");
} finally { buffer.release(); }
when(viewer.getUniqueId()).thenReturn(hiddenId);
var self = PacketEvent.fromServer(this, packet, viewer);
listener.onPacketSending(self);
assertSame(packet, self.getPacket(), "retain existing self-tab behavior");
snapshot.set(Set.of());
var ended = PacketEvent.fromServer(this, packet, viewer);
listener.onPacketSending(ended);
assertSame(packet, ended.getPacket());
assertTrue(warnings.isEmpty(), warnings.toString());
}
}
private static ClientboundPlayerInfoUpdatePacket.Entry entry(UUID id, String name) {
return new ClientboundPlayerInfoUpdatePacket.Entry(id, new com.mojang.authlib.GameProfile(id, name), true, 37,
net.minecraft.world.level.GameType.SURVIVAL, net.minecraft.network.chat.Component.literal(name), true, 8,
session);
}
}