Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bfcf36f04 |
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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 "))
|
||||
|
||||
Reference in new issue
Block a user