diff --git a/README.md b/README.md index 4c047d8..f999f87 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ The earned Echo Shard is named **Echo of the Abyss**, with cryptic lore and a UU Full inventories retain a pending reward; online delivery retries every five seconds using the same receipt identity. Temporary inventory/drop/swap locks prevent ordinary moves during the short activation commit. Disk failures do not consume the item on activation; failed completion saves produce a player-facing recovery message rather than pretending the reward was saved. -Announcement handling uses Purpur's Adventure `PlayerAdvancementDoneEvent.message`, replacing only the first name argument of the three vanilla advancement translations. It preserves silent events and unrelated/custom message formats. Advancement transformation itself needs no ProtocolLib interception; the plugin's ProtocolLib dependency serves Mask equipment projection. Existing Stealth outbound masking sees the obfuscated alias rather than the original name. Live coexistence remains unverified. +Announcement handling uses Purpur's Adventure `PlayerAdvancementDoneEvent.message`, replacing only the first name argument of the three vanilla advancement translations. It preserves silent events and unrelated/custom message formats. An advancement-only ProtocolLib output backstop also covers Purpur's affected-player-only branch, which sends the original pre-event component rather than the event's replacement. The backstop identifies an unambiguous player UUID in the name slot's entity hover and checks saved activation; it does not infer identity from name text, touch titles/descriptions or broaden recipients. Ordinary/custom chat remains unchanged. Native activation/reopen, event/delivery branches, packet codecs and plugin lifecycle tests cover this fix; the exact live report setting/format remains unconfirmed. Existing Stealth outbound masking sees the obfuscated alias rather than the original name. Live coexistence remains unverified. ## The Mask diff --git a/build.gradle.kts b/build.gradle.kts index fdd613f..7bf1e23 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,6 @@ +import java.net.URI +import java.security.MessageDigest + plugins { java } group = "games.dmg" @@ -30,6 +33,56 @@ tasks.test { systemProperty("distribution.jar", tasks.jar.get().archiveFile.get().asFile.absolutePath) systemProperty("distribution.version", project.version.toString()) } +// Native API/event/delivery regressions run without starting a listening server. +val ignisRuntime = layout.buildDirectory.dir("ignis-runtime") +val downloadIgnisRuntime = tasks.register("downloadIgnisRuntime") { + val launcher = ignisRuntime.map { it.file("purpur-26.2-2618.jar") } + outputs.file(launcher) + doLast { + val file = launcher.get().asFile + file.parentFile.mkdirs() + val connection = URI("https://api.purpurmc.org/v2/purpur/26.2/2618/download").toURL().openConnection() + connection.connectTimeout = 30_000 + connection.readTimeout = 120_000 + val bytes = connection.getInputStream().use { it.readBytes() } + val digest = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) } + check(digest == "4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31") { "Purpur 2618 checksum mismatch" } + file.writeBytes(bytes) + } +} +val prepareIgnisRuntime = tasks.register("prepareIgnisRuntime") { + dependsOn(downloadIgnisRuntime) + javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(25) } + classpath = files(ignisRuntime.map { it.file("purpur-26.2-2618.jar") }) + mainClass = "io.papermc.paperclip.Main" + jvmArgs("-Dpaperclip.patchonly=true") + workingDir(ignisRuntime) + outputs.dir(ignisRuntime.map { it.dir("versions") }) + outputs.dir(ignisRuntime.map { it.dir("libraries") }) +} +val nativeTest = sourceSets.create("nativeTest") +dependencies { + add(nativeTest.implementationConfigurationName, platform("org.junit:junit-bom:5.13.4")) + add(nativeTest.implementationConfigurationName, "org.junit.jupiter:junit-jupiter") + add(nativeTest.implementationConfigurationName, "org.mockito:mockito-core:5.18.0") + add(nativeTest.implementationConfigurationName, "net.dmulloy2:ProtocolLib:5.4.0") + add(nativeTest.compileOnlyConfigurationName, "org.jetbrains:annotations:26.0.2") + add(nativeTest.compileOnlyConfigurationName, "org.checkerframework:checker-qual:3.49.2") + add(nativeTest.runtimeOnlyConfigurationName, "org.junit.platform:junit-platform-launcher") +} +val nativeRuntimeJars = files(fileTree(ignisRuntime) { include("versions/**/*.jar", "libraries/**/*.jar") }).builtBy(prepareIgnisRuntime) +nativeTest.compileClasspath += sourceSets.main.get().output + nativeRuntimeJars +nativeTest.runtimeClasspath += sourceSets.main.get().output + nativeRuntimeJars +val nativeIgnisTest = tasks.register("nativeIgnisTest") { + description = "Runs native Purpur regressions without starting a server" + testClassesDirs = nativeTest.output.classesDirs + classpath = nativeTest.runtimeClasspath + useJUnitPlatform() + maxHeapSize = "1G" + workingDir(ignisRuntime) +} +tasks.check { dependsOn(nativeIgnisTest) } + val pluginVersion = version tasks.processResources { filesMatching("plugin.yml") { expand("version" to pluginVersion) } diff --git a/src/main/java/games/dmg/spigotignis/AdvancementMasker.java b/src/main/java/games/dmg/spigotignis/AdvancementMasker.java index 7a7f306..dc53803 100644 --- a/src/main/java/games/dmg/spigotignis/AdvancementMasker.java +++ b/src/main/java/games/dmg/spigotignis/AdvancementMasker.java @@ -14,11 +14,33 @@ public final class AdvancementMasker { private AdvancementMasker() { } public static Component mask(Component original) { + return maskNameSlots(original, name -> true); + } + + /** Packet-only fallback requires a single authenticated player UUID in the name slot, never a text-name guess. */ + public static Component maskIdentified(Component original, java.util.function.Predicate activated) { + return maskNameSlots(original, name -> { + var identities = new java.util.HashSet(); + collectIdentities(name, identities); + return identities.size() == 1 && activated.test(identities.iterator().next()); + }); + } + + private static void collectIdentities(Component component, java.util.Set identities) { + if (component.hoverEvent() != null && component.hoverEvent().value() instanceof net.kyori.adventure.text.event.HoverEvent.ShowEntity entity + && entity.type().equals(net.kyori.adventure.key.Key.key("minecraft:player"))) { + identities.add(entity.id()); + } + for (var child : component.children()) { collectIdentities(child, identities); } + } + + private static Component maskNameSlots(Component original, java.util.function.Predicate authorized) { if (original == null) return null; Component result = original; boolean changed = false; if (result instanceof TranslatableComponent translation && KEYS.contains(translation.key()) - && !translation.arguments().isEmpty()) { + && !translation.arguments().isEmpty() + && translation.arguments().getFirst().value() instanceof Component name && authorized.test(name)) { var arguments = new ArrayList(translation.arguments()); arguments.set(0, Component.text("Anonymous").decorate(TextDecoration.OBFUSCATED).insertion("")); result = translation.arguments(arguments); @@ -26,7 +48,7 @@ public final class AdvancementMasker { } var children = new ArrayList(); for (Component child : original.children()) { - Component masked = mask(child); + Component masked = maskNameSlots(child, authorized); children.add(masked); changed |= masked != child; } diff --git a/src/main/java/games/dmg/spigotignis/AnnouncementPacketListener.java b/src/main/java/games/dmg/spigotignis/AnnouncementPacketListener.java new file mode 100644 index 0000000..1cfc8db --- /dev/null +++ b/src/main/java/games/dmg/spigotignis/AnnouncementPacketListener.java @@ -0,0 +1,51 @@ +package games.dmg.spigotignis; + +import com.comphenix.protocol.PacketType; +import com.comphenix.protocol.events.ListenerPriority; +import com.comphenix.protocol.events.PacketAdapter; +import com.comphenix.protocol.events.PacketEvent; +import java.util.UUID; +import java.util.function.Consumer; +import java.util.function.Predicate; +import org.bukkit.plugin.Plugin; + +/** Advancement-only wire backstop for native delivery paths which do not use the event's replacement component. */ +public final class AnnouncementPacketListener extends PacketAdapter { + private final Predicate activated; + private final Consumer warning; + private final java.lang.reflect.Method content, overlay, asAdventure, asVanilla; + private final java.lang.reflect.Constructor packet; + private final java.util.concurrent.atomic.AtomicBoolean warned = new java.util.concurrent.atomic.AtomicBoolean(); + + public AnnouncementPacketListener(Plugin plugin, Predicate activated, Consumer warning) { + super(plugin, ListenerPriority.HIGHEST, PacketType.Play.Server.SYSTEM_CHAT); + this.activated = java.util.Objects.requireNonNull(activated); + this.warning = java.util.Objects.requireNonNull(warning); + try { + var nativeComponent = Class.forName("net.minecraft.network.chat.Component"); + var packetType = Class.forName("net.minecraft.network.protocol.game.ClientboundSystemChatPacket"); + var adventure = Class.forName("io.papermc.paper.adventure.PaperAdventure"); + content = packetType.getMethod("content"); + overlay = packetType.getMethod("overlay"); + asAdventure = adventure.getMethod("asAdventure", nativeComponent); + asVanilla = adventure.getMethod("asVanilla", net.kyori.adventure.text.Component.class); + packet = packetType.getConstructor(nativeComponent, boolean.class); + } catch (ReflectiveOperationException failure) { throw new IllegalStateException("Unsupported native announcement packet layout", failure); } + } + + @Override public void onPacketSending(PacketEvent event) { + if (event.isCancelled()) { return; } + try { + Object originalPacket = event.getPacket().getHandle(); + if ((boolean) overlay.invoke(originalPacket)) { return; } + var original = (net.kyori.adventure.text.Component) asAdventure.invoke(null, content.invoke(originalPacket)); + var masked = AdvancementMasker.maskIdentified(original, activated); + if (masked != original) { + Object projected = packet.newInstance(asVanilla.invoke(null, masked), false); + event.setPacket(new com.comphenix.protocol.events.PacketContainer(PacketType.Play.Server.SYSTEM_CHAT, projected)); + } + } catch (ReflectiveOperationException | RuntimeException failure) { + if (warned.compareAndSet(false, true)) { warning.accept("Ignis announcement projection unavailable; original delivery retained: " + failure.getClass().getSimpleName()); } + } + } +} diff --git a/src/main/java/games/dmg/spigotignis/SpigotIgnisPlugin.java b/src/main/java/games/dmg/spigotignis/SpigotIgnisPlugin.java index c5e7548..adc7b80 100644 --- a/src/main/java/games/dmg/spigotignis/SpigotIgnisPlugin.java +++ b/src/main/java/games/dmg/spigotignis/SpigotIgnisPlugin.java @@ -6,6 +6,7 @@ import org.bukkit.plugin.java.JavaPlugin; /** Registers gameplay only after durable state initialization succeeds. */ public final class SpigotIgnisPlugin extends JavaPlugin { private CompletableFuture initialization; + private AnnouncementPacketListener announcementPackets; private MaskProgressListener maskProgress; private MaskCrafting maskCrafting; private MaskEffects maskEffects; @@ -33,6 +34,8 @@ public final class SpigotIgnisPlugin extends JavaPlugin { var gameplay = new EchoGameplay(this, service, new EchoItems()); getServer().getPluginManager().registerEvents(gameplay, this); getServer().getPluginManager().registerEvents(new AnnouncementListener(gameplay::activated), this); + announcementPackets = new AnnouncementPacketListener(this, gameplay::activated, getLogger()::warning); + com.comphenix.protocol.ProtocolLibrary.getProtocolManager().addPacketListener(announcementPackets); getServer().getScheduler().runTaskTimer(this, gameplay::retryDeliveries, 1L, 100L); var maskTracker = new InvisibilityProgress(service, System::nanoTime); maskProgress = new MaskProgressListener(this, maskTracker); @@ -94,6 +97,7 @@ public final class SpigotIgnisPlugin extends JavaPlugin { @Override public void onDisable() { try { + if (announcementPackets != null) com.comphenix.protocol.ProtocolLibrary.getProtocolManager().removePacketListener(announcementPackets); if (heartEffects != null) heartEffects.close(); if (heartCrafting != null) heartCrafting.close(); if (maskEffects != null) maskEffects.close(); diff --git a/src/nativeTest/java/games/dmg/spigotignis/AnnouncementPluginLifecycleTest.java b/src/nativeTest/java/games/dmg/spigotignis/AnnouncementPluginLifecycleTest.java new file mode 100644 index 0000000..cb04f76 --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotignis/AnnouncementPluginLifecycleTest.java @@ -0,0 +1,83 @@ +package games.dmg.spigotignis; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import com.comphenix.protocol.ProtocolLibrary; +import com.comphenix.protocol.ProtocolManager; +import com.comphenix.protocol.events.PacketListener; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import org.bukkit.Bukkit; +import org.bukkit.craftbukkit.CraftServer; +import org.bukkit.craftbukkit.scheduler.CraftScheduler; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class AnnouncementPluginLifecycleTest { + @TempDir Path directory; + @BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); } + + @Test + @SuppressWarnings({"try", "unchecked"}) + void actualEntrypointRegistersAndRemovesTheAnnouncementWireBackstop() throws Exception { + var server = mock(CraftServer.class); + try (var protocol = mockStatic(ProtocolLibrary.class); + var platform = mockStatic(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 packets = new ArrayList(); + var manager = mock(ProtocolManager.class); + protocol.when(ProtocolLibrary::getProtocolManager).thenReturn(manager); + doAnswer(call -> { packets.add(call.getArgument(0)); return null; }).when(manager).addPacketListener(any()); + doAnswer(call -> { packets.remove(call.getArgument(0)); return null; }).when(manager).removePacketListener(any()); + var plugin = mock(SpigotIgnisPlugin.class, CALLS_REAL_METHODS); + doReturn(true).when(plugin).isEnabled(); + doReturn(server).when(plugin).getServer(); + doReturn("SpigotIgnis").when(plugin).getName(); + doReturn(directory.toFile()).when(plugin).getDataFolder(); + doReturn(java.util.logging.Logger.getAnonymousLogger()).when(plugin).getLogger(); + doReturn(mock(org.bukkit.command.PluginCommand.class)).when(plugin).getCommand(anyString()); + for (var name : java.util.List.of("heartSaveWarning", "pearlSaveWarning")) { + var field = SpigotIgnisPlugin.class.getDeclaredField(name); + field.setAccessible(true); + field.set(plugin, new java.util.concurrent.atomic.AtomicBoolean()); + } + var scheduler = mock(CraftScheduler.class); + when(server.getScheduler()).thenReturn(scheduler); + var callbacks = new LinkedBlockingQueue(); + when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenAnswer(call -> { callbacks.add(call.getArgument(1)); return null; }); + var events = new ArrayList(); + var plugins = mock(org.bukkit.plugin.PluginManager.class); + when(server.getPluginManager()).thenReturn(plugins); + doAnswer(call -> { events.add(call.getArgument(0)); return null; }).when(plugins).registerEvents(any(), eq(plugin)); + plugin.onEnable(); + var initialization = SpigotIgnisPlugin.class.getDeclaredField("initialization"); + initialization.setAccessible(true); + var service = ((CompletableFuture) initialization.get(plugin)).get(5, TimeUnit.SECONDS); + try { + var callback = callbacks.poll(5, TimeUnit.SECONDS); + assertNotNull(callback); + callback.run(); + assertTrue(events.stream().anyMatch(AnnouncementListener.class::isInstance)); + assertTrue(packets.stream().anyMatch(AnnouncementPacketListener.class::isInstance), "native delivery backstop must be registered by the real plugin"); + verify(server, never()).createPlayerProfile(anyString()); + } finally { + plugin.onDisable(); + service.close(); + var worker = StateService.class.getDeclaredField("worker"); + worker.setAccessible(true); + assertTrue(((java.util.concurrent.ExecutorService) worker.get(service)).awaitTermination(5, TimeUnit.SECONDS)); + } + assertTrue(packets.stream().noneMatch(AnnouncementPacketListener.class::isInstance), "disable must remove the wire listener"); + } + } +} diff --git a/src/nativeTest/java/games/dmg/spigotignis/NativeEchoAnnouncementTest.java b/src/nativeTest/java/games/dmg/spigotignis/NativeEchoAnnouncementTest.java new file mode 100644 index 0000000..f04fc98 --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotignis/NativeEchoAnnouncementTest.java @@ -0,0 +1,190 @@ +package games.dmg.spigotignis; + +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 io.papermc.paper.adventure.PaperAdventure; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.TranslatableComponent; +import net.kyori.adventure.text.format.TextDecoration; +import net.minecraft.advancements.Advancement; +import net.minecraft.advancements.AdvancementHolder; +import net.minecraft.advancements.AdvancementType; +import net.minecraft.network.protocol.Packet; +import net.minecraft.network.protocol.game.ClientboundSystemChatPacket; +import net.minecraft.server.PlayerAdvancements; +import net.minecraft.server.level.ServerPlayer; +import org.bukkit.Bukkit; +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.event.EventHandler; +import org.bukkit.event.player.PlayerAdvancementDoneEvent; +import org.bukkit.plugin.EventExecutor; +import org.bukkit.plugin.RegisteredListener; +import org.bukkit.plugin.java.JavaPlugin; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.io.TempDir; + +class NativeEchoAnnouncementTest { + @TempDir Path directory; + @BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.MethodSource("cases") + @SuppressWarnings("try") + void consumedEchoSurvivesReopenAndMasksNativeAnnouncementDelivery(AdvancementType type, String mode) throws Exception { + boolean onlyAffected = !mode.equals("broadcast"); + var server = mock(CraftServer.class); + try (var platform = mockStatic(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 owner = UUID.randomUUID(), receipt = UUID.randomUUID(); + var token = new EchoToken(owner, receipt, 0); + var player = mock(CraftPlayer.class); + when(player.getUniqueId()).thenReturn(owner); + when(player.getName()).thenReturn("EchoOwner"); + var nativePlayer = mock(ServerPlayer.class); + when(player.getHandle()).thenReturn(nativePlayer); + when(nativePlayer.getBukkitEntity()).thenReturn(player); + var inventory = mock(CraftInventoryPlayer.class); + when(player.getInventory()).thenReturn(inventory); + when(inventory.getSize()).thenReturn(41); + var items = new EchoItems(); + var slot = new AtomicReference<>(items.create(token)); + when(inventory.getItem(0)).thenAnswer(call -> slot.get()); + doAnswer(call -> { slot.set(call.getArgument(1)); return null; }).when(inventory).setItem(eq(0), any()); + var plugin = mock(JavaPlugin.class); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getServer()).thenReturn(server); + when(server.getPlayer(owner)).thenReturn(player); + var scheduler = mock(CraftScheduler.class); + when(server.getScheduler()).thenReturn(scheduler); + var callbacks = new LinkedBlockingQueue(); + when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenAnswer(call -> { callbacks.add(call.getArgument(1)); return null; }); + var repository = new FileStateRepository(directory.resolve("state.bin")); + try (var states = StateService.open(repository).get(5, TimeUnit.SECONDS)) { + states.update(state -> EchoRewards.award(state, owner, receipt)).get(5, TimeUnit.SECONDS); + var gameplay = new EchoGameplay(plugin, states, items); + var use = new org.bukkit.event.player.PlayerInteractEvent(player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR, + slot.get(), null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND); + gameplay.onUse(use); + var callback = callbacks.poll(5, TimeUnit.SECONDS); + assertNotNull(callback); + callback.run(); + assertNull(slot.get(), "happy-path consumption must remove the real receipt item"); + assertTrue(gameplay.activated(owner)); + } + try (var states = StateService.open(repository).get(5, TimeUnit.SECONDS)) { + var gameplay = new EchoGameplay(plugin, states, items); + assertTrue(gameplay.activated(owner), "saved activation must survive service reopen"); + if (mode.equals("reset")) { states.update(state -> ProgressAdministration.reset(state, owner, java.util.Set.of(Track.VOID))).get(5, TimeUnit.SECONDS); } + if (mode.equals("ordinary")) { when(player.getUniqueId()).thenReturn(UUID.randomUUID()); } + var listener = new AnnouncementListener(gameplay::activated); + var warnings = new ArrayList(); + var packets = new AnnouncementPacketListener(plugin, gameplay::activated, warnings::add); + var delivered = new ArrayList(); + nativePlayer.connection = mock(net.minecraft.server.network.ServerGamePacketListenerImpl.class); + org.mockito.stubbing.Answer capture = call -> { + var raw = call.getArgument(0, ClientboundSystemChatPacket.class); + var outgoing = PacketEvent.fromServer(this, new PacketContainer(PacketType.Play.Server.SYSTEM_CHAT, raw), player); + if (mode.equals("wire-cancelled")) { outgoing.setCancelled(true); } + var before = raw.content(); + packets.onPacketSending(outgoing); + assertSame(before, raw.content(), "shared native packet content must not be mutated"); + if (!outgoing.isCancelled()) { + var projected = (ClientboundSystemChatPacket) outgoing.getPacket().getHandle(); + var buffer = new net.minecraft.network.RegistryFriendlyByteBuf(io.netty.buffer.Unpooled.buffer(), org.bukkit.craftbukkit.CraftRegistry.getMinecraftRegistry()); + try { + ClientboundSystemChatPacket.STREAM_CODEC.encode(buffer, projected); + var decoded = ClientboundSystemChatPacket.STREAM_CODEC.decode(buffer); + assertFalse(decoded.overlay()); + delivered.add(PaperAdventure.asAdventure(decoded.content())); + } finally { buffer.release(); } + } + return null; + }; + doAnswer(capture).when(nativePlayer.connection).send(org.mockito.ArgumentMatchers.>any()); + doAnswer(capture).when(nativePlayer.connection).send(org.mockito.ArgumentMatchers.>any(), any(io.netty.channel.ChannelFutureListener.class)); + doCallRealMethod().when(player).sendMessage(any(Component.class)); + doCallRealMethod().when(nativePlayer).sendMessage(any(Component.class)); + doCallRealMethod().when(nativePlayer).sendSystemMessage(any(net.minecraft.network.chat.Component.class)); + doCallRealMethod().when(nativePlayer).sendSystemMessage(any(net.minecraft.network.chat.Component.class), anyBoolean()); + var nativeWorld = mock(net.minecraft.server.level.ServerLevel.class); + when(nativePlayer.level()).thenReturn(nativeWorld); + var rules = mock(net.minecraft.world.level.gamerules.GameRules.class); + when(rules.get(net.minecraft.world.level.gamerules.GameRules.SHOW_ADVANCEMENT_MESSAGES)).thenReturn(!mode.equals("rule-off")); + when(nativeWorld.getGameRules()).thenReturn(rules); + var players = mock(net.minecraft.server.players.PlayerList.class); + doAnswer(call -> { player.sendMessage(PaperAdventure.asAdventure(call.getArgument(0, net.minecraft.network.chat.Component.class))); return null; }) + .when(players).broadcastSystemMessage(any(net.minecraft.network.chat.Component.class), eq(false)); + var advancements = mock(PlayerAdvancements.class); + set(advancements, "player", nativePlayer); + set(advancements, "playerList", players); + var name = Component.text("EchoOwner").hoverEvent(net.kyori.adventure.text.event.HoverEvent.showEntity( + net.kyori.adventure.key.Key.key("minecraft:player"), player.getUniqueId(), Component.text("EchoOwner"))); + when(nativePlayer.getDisplayName()).thenReturn(PaperAdventure.asVanilla(name)); + var title = Component.text("EchoOwner's Challenge").hoverEvent(Component.text("Keep the description")); + var advancement = new Advancement(Optional.empty(), Optional.empty(), net.minecraft.advancements.AdvancementRewards.EMPTY, + Map.of(), net.minecraft.advancements.AdvancementRequirements.EMPTY, false, Optional.of(PaperAdventure.asVanilla(title))); + var holder = new AdvancementHolder(net.minecraft.resources.Identifier.withDefaultNamespace("echo_regression"), advancement); + var original = PaperAdventure.asAdventure(type.createAnnouncement(holder, nativePlayer)); + var event = new PlayerAdvancementDoneEvent(player, holder.toBukkit(), original); + if (mode.equals("silent")) { event.message(null); } + var method = AnnouncementListener.class.getMethod("onAdvancement", PlayerAdvancementDoneEvent.class); + var annotation = method.getAnnotation(EventHandler.class); + new RegisteredListener(listener, EventExecutor.create(method, PlayerAdvancementDoneEvent.class), annotation.priority(), plugin, annotation.ignoreCancelled()).callEvent(event); + var nativeDelivery = PlayerAdvancements.class.getDeclaredMethod("lambda$award$1", PlayerAdvancementDoneEvent.class, Component.class, net.minecraft.advancements.DisplayInfo.class); + nativeDelivery.setAccessible(true); + boolean previous = org.purpurmc.purpur.PurpurConfig.advancementOnlyBroadcastToAffectedPlayer; + try { + org.purpurmc.purpur.PurpurConfig.advancementOnlyBroadcastToAffectedPlayer = onlyAffected; + nativeDelivery.invoke(advancements, event, original, null); + } finally { org.purpurmc.purpur.PurpurConfig.advancementOnlyBroadcastToAffectedPlayer = previous; } + if (java.util.Set.of("silent", "rule-off", "wire-cancelled").contains(mode)) { + assertTrue(delivered.isEmpty()); + assertTrue(warnings.isEmpty()); + return; + } + assertEquals(1, delivered.size()); + var message = assertInstanceOf(TranslatableComponent.class, delivered.getFirst()); + var alias = assertInstanceOf(Component.class, message.arguments().getFirst().value()); + boolean eligible = !java.util.Set.of("ordinary", "reset").contains(mode); + assertEquals(eligible ? TextDecoration.State.TRUE : TextDecoration.State.NOT_SET, alias.decoration(TextDecoration.OBFUSCATED), "native delivery must honor current activation"); + if (eligible) { assertNull(alias.hoverEvent()); } + else { assertEquals(name, alias); } + assertEquals(title, message.arguments().get(1).value()); + assertTrue(warnings.isEmpty(), warnings.toString()); + } + } + } + + private static java.util.stream.Stream cases() { + return java.util.Arrays.stream(AdvancementType.values()).flatMap(type -> java.util.stream.Stream.of( + "broadcast", "only-affected", "ordinary", "reset", "silent", "rule-off", "wire-cancelled") + .map(mode -> org.junit.jupiter.params.provider.Arguments.of(type, mode))); + } + + private static void set(PlayerAdvancements target, String name, Object value) throws Exception { + var field = PlayerAdvancements.class.getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } +} diff --git a/src/nativeTest/java/games/dmg/spigotignis/NativeRuntime.java b/src/nativeTest/java/games/dmg/spigotignis/NativeRuntime.java new file mode 100644 index 0000000..cefe775 --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotignis/NativeRuntime.java @@ -0,0 +1,60 @@ +package games.dmg.spigotignis; + +import java.util.List; +import java.util.stream.Stream; +import net.minecraft.SharedConstants; +import net.minecraft.commands.Commands; +import net.minecraft.core.HolderLookup; +import net.minecraft.core.LayeredRegistryAccess; +import net.minecraft.core.Registry; +import net.minecraft.core.RegistryAccess; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.RegistryDataLoader; +import net.minecraft.server.Bootstrap; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.RegistryLayer; +import net.minecraft.server.ReloadableServerResources; +import net.minecraft.server.packs.PackType; +import net.minecraft.server.packs.repository.ServerPacksSource; +import net.minecraft.server.packs.resources.MultiPackResourceManager; +import net.minecraft.server.permissions.LevelBasedPermissionSet; +import net.minecraft.tags.TagLoader; +import net.minecraft.util.Util; +import net.minecraft.world.flag.FeatureFlags; +import net.minecraft.world.level.DataPackConfig; +import net.minecraft.world.level.WorldDataConfiguration; +import org.bukkit.craftbukkit.CraftRegistry; + +/** Vanilla registry/tag/component bootstrap, shared in approach with the other Purpur native suites. */ +final class NativeRuntime { + private NativeRuntime() { } + private static boolean ready; + static synchronized void bootstrap() throws Exception { + if (ready) { return; } + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + var flags = FeatureFlags.VANILLA_SET; + var packs = ServerPacksSource.createVanillaTrustedRepository(); + MinecraftServer.configurePackRepository(packs, new WorldDataConfiguration(new DataPackConfig( + FeatureFlags.REGISTRY.toNames(flags).stream().map(Identifier::getPath).toList(), List.of()), flags), true, false); + try (var resources = new MultiPackResourceManager(PackType.SERVER_DATA, packs.openAllSelected())) { + LayeredRegistryAccess layers = RegistryLayer.createRegistryAccess(); + List> tags = TagLoader.loadTagsForExistingRegistries(resources, layers.getLayer(RegistryLayer.STATIC)); + List> lookups = TagLoader.buildUpdatedLookups(layers.getAccessForLoading(RegistryLayer.WORLDGEN), tags); + RegistryAccess.Frozen worldgen = RegistryDataLoader.load(resources, lookups, + RegistryDataLoader.WORLDGEN_REGISTRIES, Util.backgroundExecutor()).join(); + layers = layers.replaceFrom(RegistryLayer.WORLDGEN, worldgen); + RegistryAccess.Frozen dimensions = RegistryDataLoader.load(resources, + Stream.concat(lookups.stream(), worldgen.listRegistries()).toList(), + RegistryDataLoader.DIMENSION_REGISTRIES, Util.backgroundExecutor()).join(); + layers = layers.replaceFrom(RegistryLayer.DIMENSIONS, dimensions); + Class.forName(org.bukkit.Registry.class.getName()); + var datapack = ReloadableServerResources.loadResources(resources, layers, tags, flags, + Commands.CommandSelection.DEDICATED, LevelBasedPermissionSet.ALL_PERMISSIONS, + Util.backgroundExecutor(), Runnable::run).join(); + datapack.updateComponentsAndStaticRegistryTags(); + CraftRegistry.setMinecraftRegistry(layers.compositeAccess().freeze()); + } + ready = true; + } +} diff --git a/src/test/java/games/dmg/spigotignis/AdvancementMaskerTest.java b/src/test/java/games/dmg/spigotignis/AdvancementMaskerTest.java index e707253..f050843 100644 --- a/src/test/java/games/dmg/spigotignis/AdvancementMaskerTest.java +++ b/src/test/java/games/dmg/spigotignis/AdvancementMaskerTest.java @@ -30,6 +30,27 @@ class AdvancementMaskerTest { assertNull(AdvancementMasker.mask(null)); } + @Test + void packetFallbackUsesOnlyAnUnambiguousPlayerIdentityInTheNameSlot() { + var active = java.util.UUID.randomUUID(); + var other = java.util.UUID.randomUUID(); + var name = Component.text("Player").hoverEvent(net.kyori.adventure.text.event.HoverEvent.showEntity( + net.kyori.adventure.key.Key.key("minecraft:player"), active, Component.text("Player"))); + var ordinary = Component.text("Player").hoverEvent(net.kyori.adventure.text.event.HoverEvent.showEntity( + net.kyori.adventure.key.Key.key("minecraft:player"), other, Component.text("Player"))); + var title = Component.text("Player's title").hoverEvent(name); + var announcement = Component.translatable("chat.type.advancement.task", name, title); + assertEquals(AdvancementMasker.mask(announcement), AdvancementMasker.maskIdentified(announcement, active::equals)); + for (var candidate : java.util.List.of( + Component.translatable("chat.type.advancement.task", ordinary, title), + Component.translatable("chat.type.advancement.task", Component.text("Player"), title), + Component.translatable("chat.type.advancement.task", Component.empty().append(name).append(ordinary), title), + Component.translatable("chat.type.text", name, title), + Component.text("A custom Player announcement"))) { + assertSame(candidate, AdvancementMasker.maskIdentified(candidate, active::equals)); + } + } + @Test void replacesOnlyAdvancementNameAndPreservesAdvancementDetails() { var name = Component.text("RealPlayer").clickEvent(ClickEvent.suggestCommand("/msg RealPlayer "))