2 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
dmg e4bc296ce1 feat(ignis): rename Purpur distribution and scope release access
Release / release (push) Successful in 4m58s
CI / build (push) Successful in 1m13s
BREAKING CHANGE: New release artifacts use purpur-ignis-<version>.jar. Java 25/Purpur requirements, runtime SpigotIgnis, data folder, PDC/recipe namespaces and gameplay remain unchanged. Replace the old JAR rather than installing both distributions. The existing credential helper now permits only the renamed HTTPS repository path.
2026-09-12 14:41:49 -04:00
17 changed files with 573 additions and 23 deletions

No files matched your search

+1 -1
View File
@@ -13,6 +13,6 @@ while IFS='=' read -r key value && [ -n "$key" ]; do
done
[ "$protocol" = https ] || exit 0
[ "$host" = git.garvis.dev ] || exit 0
[ "$path" = dmg/spigot-ignis.git ] || exit 0
[ "$path" = dmg/purpur-ignis.git ] || exit 0
[ -n "${IGNIS_RELEASE_TOKEN:-}" ] || exit 0
printf 'username=dmg\npassword=%s\n\n' "$IGNIS_RELEASE_TOKEN"
+1 -1
View File
@@ -35,6 +35,6 @@ jobs:
- name: Upload development artifact
uses: actions/upload-artifact@v3
with:
name: spigot-ignis-${{ github.sha }}
name: purpur-ignis-${{ github.sha }}
path: build/libs/*.jar
if-no-files-found: error
+6 -6
View File
@@ -47,7 +47,7 @@ jobs:
set -euo pipefail
test -n "$IGNIS_RELEASE_TOKEN" || { echo 'RELEASE_TOKEN is required'; exit 1; }
test "$GITEA_SERVER_URL" = 'https://git.garvis.dev'
test "$REPOSITORY" = 'dmg/spigot-ignis'
test "$REPOSITORY" = 'dmg/purpur-ignis'
# semantic-release overrides GIT_ASKPASS; use Git's credential protocol instead.
# Token stays in environment/credential stdout, never in the remote URL or argv.
export GIT_TERMINAL_PROMPT=0
@@ -85,8 +85,8 @@ jobs:
if: steps.release.outputs.created == 'true'
uses: actions/upload-artifact@v3
with:
name: spigot-ignis-${{ steps.release.outputs.version }}
path: build/libs/spigot-ignis-${{ steps.release.outputs.version }}.jar
name: purpur-ignis-${{ steps.release.outputs.version }}
path: build/libs/purpur-ignis-${{ steps.release.outputs.version }}.jar
if-no-files-found: error
- name: Publish versioned Gitea asset
@@ -102,9 +102,9 @@ jobs:
run: |
set -euo pipefail
test "$GITEA_SERVER_URL" = 'https://git.garvis.dev'
test "$REPOSITORY" = 'dmg/spigot-ignis'
test "$REPOSITORY" = 'dmg/purpur-ignis'
api_url="${GITEA_SERVER_URL}/api/v1/repos/${REPOSITORY}/releases"
jar="build/libs/spigot-ignis-${VERSION}.jar"
jar="build/libs/purpur-ignis-${VERSION}.jar"
payload=$(mktemp)
response=$(mktemp)
trap 'rm -f "$payload" "$response"' EXIT
@@ -127,4 +127,4 @@ jobs:
curl --fail --silent --show-error --max-time 120 --proto '=https' \
--header @- --header 'Content-Type: application/octet-stream' \
--data-binary "@$jar" --output /dev/null \
"${api_url}/${release_id}/assets?name=spigot-ignis-${VERSION}.jar"
"${api_url}/${release_id}/assets?name=purpur-ignis-${VERSION}.jar"
+2 -2
View File
@@ -1,8 +1,8 @@
# Spigot Ignis agent entrypoint
# Purpur Ignis agent entrypoint
The canonical knowledge bundle is the sibling `../somc-okf/` repository, remotely at https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md.
Before changes read its root index, `processes/development.md`, `runbooks/development-cycle.md`, `projects/spigot-ignis/index.md`, `projects/spigot-ignis/engineering.md`, and the relevant `user-stories/spigot-ignis/` documents. Follow the parent workspace instructions. Do not create a local knowledge/design bundle.
Before changes read its root index, `processes/development.md`, `runbooks/development-cycle.md`, `projects/purpur-ignis/index.md`, `projects/purpur-ignis/engineering.md`, and the relevant `user-stories/purpur-ignis/` documents. Follow the parent workspace instructions. Do not create a local knowledge/design bundle.
Use Java 25 and the declared Purpur API. Test behavior first with meaningful failures, implement minimally, then run `./gradlew clean check jar`. Never mark server/client behavior verified through domain tests alone.
+7 -5
View File
@@ -1,7 +1,9 @@
# Spigot Ignis
# Purpur Ignis
Server-wide earned shadow progression for Social Minecraft. **Echo of the Abyss, advancement-name scrambling, The Mask, The Dagger, and The Heart of Shadows are implemented in source; live-server/client verification is pending.**
The repository, checkout and new artifacts use `purpur-ignis`. Runtime `SpigotIgnis`, Java packages/entrypoint, `plugins/SpigotIgnis/state.bin`, all `spigotignis:` item/recipe/provenance keys and existing gameplay are retained. Replace the old JAR rather than installing both distributions. Old tags/assets remain available. This rename does not close known Echo consumption/recovery gaps.
## Build
Requires Java 17+ to launch Gradle; the wrapper resolves a Java 25 toolchain for compilation/testing. Toolchain downloads require network access when no compatible cache is available.
@@ -11,13 +13,13 @@ Requires Java 17+ to launch Gradle; the wrapper resolves a Java 25 toolchain for
./gradlew clean jar -PreleaseVersion=0.1.0
```
Targets Purpur `26.2.build.2618-stable` and requires **ProtocolLib 5.4.0** for Mask helmet projection. Neither server API nor ProtocolLib is bundled. Source builds do not require the private wiki.
The runtime requires Java 25. New JARs are `build/libs/purpur-ignis-<version>.jar`. Targets Purpur `26.2.build.2618-stable` and requires **ProtocolLib 5.4.0** for Mask helmet projection. Neither server API nor ProtocolLib is bundled. Source builds do not require the private wiki.
## Knowledge and delivery
The [canonical project](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/spigot-ignis/index.md) and stories live in the shared private SoMC wiki. There is no repository-local knowledge bundle.
The [canonical project](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-ignis/index.md) and stories live in the shared private SoMC wiki. There is no repository-local knowledge bundle.
CI/release workflows publish versioned source artifacts; verify their exact run/commit before using any artifact. Release publication requires the repository Actions secret `RELEASE_TOKEN`, provisioned separately. The release workflow uses a repository-scoped Git credential helper and curl stdin for authentication rather than embedding a release credential in URLs or process arguments. Semantic-release overrides `GIT_ASKPASS`, so askpass is not used for release authentication. Never enable tracing. GitOps and production release need separate approvals.
CI/release workflows publish versioned source artifacts; verify their exact run/commit before using any artifact. Release publication requires the repository Actions secret `RELEASE_TOKEN`, provisioned separately. The release workflow uses a Git credential helper scoped exclusively to `https://git.garvis.dev/dmg/purpur-ignis.git` and curl stdin for authentication rather than embedding a release credential in URLs or process arguments. Semantic-release overrides `GIT_ASKPASS`, so askpass is not used for release authentication. Never enable tracing. GitOps and production release need separate approvals.
## Foundation interfaces and state
@@ -37,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
+59 -1
View File
@@ -1,3 +1,6 @@
import java.net.URI
import java.security.MessageDigest
plugins { java }
group = "games.dmg"
@@ -24,7 +27,62 @@ dependencies {
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
tasks.test { useJUnitPlatform() }
tasks.test {
useJUnitPlatform()
dependsOn(tasks.jar)
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) }
+1 -1
View File
@@ -1,2 +1,2 @@
plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" }
rootProject.name = "spigot-ignis"
rootProject.name = "purpur-ignis"
@@ -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 "))
@@ -0,0 +1,56 @@
package games.dmg.spigotignis;
import static org.junit.jupiter.api.Assertions.*;
import java.io.DataInputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.jar.JarFile;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
class PurpurDistributionTest {
@Test
void packagesNewNameWithoutChangingRuntimeIdentityOrBundlingTheApi() throws Exception {
Path artifact = Path.of(System.getProperty("distribution.jar"));
String version = System.getProperty("distribution.version");
assertEquals("purpur-ignis-" + version + ".jar", artifact.getFileName().toString());
try (var jar = new JarFile(artifact.toFile())) {
Map<?, ?> metadata = new Yaml().load(jar.getInputStream(jar.getJarEntry("plugin.yml")));
assertEquals(version, metadata.get("version"));
assertEquals("SpigotIgnis", metadata.get("name"), "Retain the data directory and persistent namespace");
assertEquals("games.dmg.spigotignis.SpigotIgnisPlugin", metadata.get("main"));
assertTrue(((Map<?, ?>) metadata.get("permissions")).containsKey("ignis.admin"));
assertEquals(java.util.List.of("ProtocolLib"), metadata.get("depend"));
var descriptor = new org.bukkit.plugin.PluginDescriptionFile(jar.getInputStream(jar.getJarEntry("plugin.yml")));
assertEquals("spigotignis", descriptor.namespace());
assertFalse(jar.stream().anyMatch(entry -> entry.getName().startsWith("org/bukkit/")
|| entry.getName().startsWith("org/purpurmc/") || entry.getName().startsWith("com/comphenix/") || entry.getName().endsWith("Test.class")));
}
}
@Test
void pluginBytecodeTargetsJava25() throws Exception {
try (var jar = new JarFile(System.getProperty("distribution.jar"));
var bytecode = new DataInputStream(jar.getInputStream(
jar.getJarEntry("games/dmg/spigotignis/SpigotIgnisPlugin.class")))) {
assertEquals(0xCAFEBABE, bytecode.readInt());
bytecode.readUnsignedShort();
assertEquals(69, bytecode.readUnsignedShort());
}
}
@Test
void releaseAndCiAgreeWithThePurpurDistributionNameAndToolchain() throws Exception {
String build = Files.readString(Path.of("build.gradle.kts"));
assertTrue(build.contains("purpur-api:26.2.build.2618-stable"));
assertFalse(build.contains("org.spigotmc:spigot-api"));
for (String workflow : new String[] {"ci.yml", "release.yml"}) {
String content = Files.readString(Path.of(".gitea/workflows", workflow));
assertTrue(content.contains("java-version: 25"));
assertTrue(content.contains("purpur-ignis-"), workflow);
assertFalse(content.contains("spigot-ignis-"), workflow);
}
}
}
@@ -11,8 +11,9 @@ class ReleaseCredentialTest {
void credentialHelperRefusesOtherHostsProtocolsRepositoriesAndOperations() throws Exception {
Path helper = Path.of(".gitea/scripts/git-credential-ignis.sh").toAbsolutePath();
for (String request : java.util.List.of(
"protocol=https\nhost=evil.example\npath=dmg/spigot-ignis.git\n\n",
"protocol=http\nhost=git.garvis.dev\npath=dmg/spigot-ignis.git\n\n",
"protocol=https\nhost=evil.example\npath=dmg/purpur-ignis.git\n\n",
"protocol=http\nhost=git.garvis.dev\npath=dmg/purpur-ignis.git\n\n",
"protocol=https\nhost=git.garvis.dev\npath=dmg/spigot-ignis.git\n\n",
"protocol=https\nhost=git.garvis.dev\npath=dmg/other.git\n\n")) {
var builder = new ProcessBuilder("sh", helper.toString(), "get");
builder.environment().put("IGNIS_RELEASE_TOKEN", "synthetic-test-token");
@@ -38,7 +39,7 @@ class ReleaseCredentialTest {
builder.environment().put("GIT_TERMINAL_PROMPT", "0");
Process process = builder.start();
try (var input = process.getOutputStream()) {
input.write("protocol=https\nhost=git.garvis.dev\npath=dmg/spigot-ignis.git\n\n".getBytes(StandardCharsets.UTF_8));
input.write("protocol=https\nhost=git.garvis.dev\npath=dmg/purpur-ignis.git\n\n".getBytes(StandardCharsets.UTF_8));
}
assertTrue(process.waitFor(3, TimeUnit.SECONDS));
assertEquals(0, process.exitValue());
@@ -12,7 +12,9 @@ class ReleaseWorkflowTest {
Path path = Path.of(".gitea/workflows/release.yml");
assertTrue(Files.exists(path), "Release workflow must exist");
String workflow = Files.readString(path);
assertTrue(workflow.contains("spigot-ignis-${VERSION}.jar"));
assertTrue(workflow.contains("purpur-ignis-${VERSION}.jar"));
assertEquals(2, workflow.split("test \\\"\\$REPOSITORY\\\" = 'dmg/purpur-ignis'", -1).length - 1);
assertFalse(workflow.contains("dmg/spigot-ignis"));
assertTrue(workflow.contains("--header @-"));
assertTrue(workflow.contains("git-credential-ignis.sh"));
assertTrue(workflow.contains("credential.useHttpPath true"));