1 Commits
Author SHA1 Message Date
dmg 5bfcf36f04 fix(ignis): mask affected-player advancement announcements
Release / release (push) Successful in 7m14s
CI / build (push) Successful in 2m41s
Retain event-level masking and add a UUID-gated advancement-only packet backstop for Purpur delivery that uses the pre-event component. Verify real Echo activation and reopen, native delivery branches, system codecs, identity boundaries and plugin registration. Echo recovery and production deployment remain separate.
2026-09-12 22:47:31 -04:00
9 changed files with 487 additions and 3 deletions

No files matched your search

+1 -1
View File
@@ -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
+53
View File
@@ -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<JavaExec>("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<Test>("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) }
@@ -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<java.util.UUID> activated) {
return maskNameSlots(original, name -> {
var identities = new java.util.HashSet<java.util.UUID>();
collectIdentities(name, identities);
return identities.size() == 1 && activated.test(identities.iterator().next());
});
}
private static void collectIdentities(Component component, java.util.Set<java.util.UUID> 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<Component> 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<ComponentLike>(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<Component>();
for (Component child : original.children()) {
Component masked = mask(child);
Component masked = maskNameSlots(child, authorized);
children.add(masked);
changed |= masked != child;
}
@@ -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<UUID> activated;
private final Consumer<String> 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<UUID> activated, Consumer<String> 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()); }
}
}
}
@@ -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<StateService> 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();
@@ -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<PacketListener>();
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<Runnable>();
when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenAnswer(call -> { callbacks.add(call.getArgument(1)); return null; });
var events = new ArrayList<org.bukkit.event.Listener>();
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<StateService>) 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");
}
}
}
@@ -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<Runnable>();
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<String>();
var packets = new AnnouncementPacketListener(plugin, gameplay::activated, warnings::add);
var delivered = new ArrayList<Component>();
nativePlayer.connection = mock(net.minecraft.server.network.ServerGamePacketListenerImpl.class);
org.mockito.stubbing.Answer<Void> 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.<Packet<?>>any());
doAnswer(capture).when(nativePlayer.connection).send(org.mockito.ArgumentMatchers.<Packet<?>>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<org.junit.jupiter.params.provider.Arguments> 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);
}
}
@@ -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<RegistryLayer> layers = RegistryLayer.createRegistryAccess();
List<Registry.PendingTags<?>> tags = TagLoader.loadTagsForExistingRegistries(resources, layers.getLayer(RegistryLayer.STATIC));
List<HolderLookup.RegistryLookup<?>> 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;
}
}
@@ -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.<Component>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 "))