3 Commits
Author SHA1 Message Date
dmg 3f721e928a fix(ignis): retain Echo recovery warnings during shutdown
Release / release (push) Successful in 4m57s
CI / build (push) Successful in 1m13s
Report failed reward saves independently of the tick callback with owner and receipt identifiers. Verify the approved lightweight consumption, inert-copy and failure/retry contract using native inventories and real persistence. Keep cross-save hard-crash recovery an explicit operator limitation; add no autosave-length restrictions or transaction protocol.
2026-09-12 23:15:21 -04:00
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
20 changed files with 910 additions and 31 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.
+11 -7
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
@@ -35,9 +37,9 @@ Take uncancelled void damage, ascend while gliding with elytra in the same world
The earned Echo Shard is named **Echo of the Abyss**, with cryptic lore and a UUID-bound receipt. Right-click it to activate permanent obfuscated-name advancement announcements. It does not hide general identity or alter advancement titles/descriptions. Only the bound owner may use it, and activation cannot grant twice. Lost, unconsumed rewards can be earned again by repeating the feat. Admins can also grant a bound Echo.
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.
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, so it can be retried. Failed challenge-completion saves log the owner UUID and receipt ID for administrator recovery even if shutdown prevents the player callback; an online player is also notified when scheduling remains available. Pending delivery acknowledgements can retry without offering another copy while the original remains in the inventory.
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
@@ -114,12 +116,14 @@ Offline targets use UUIDs already present in Ignis state; online exact names are
Writes use a same-directory temporary file, flush it to disk, and require atomic replacement. Unsupported atomic moves or write failures retain the prior active snapshot and fail the mutation; there is no non-atomic fallback. Abandoned temporary files are ignored, never auto-promoted. This does not guarantee survival of arbitrary storage hardware or power failures.
For a reported failed Echo award, restore writable storage first and match the logged owner UUID/receipt to the player. Inspect saved activation and pending rewards before granting anything: pending rewards retry automatically when inventory space is available. If the failed award needs replacement, use the existing operator-only `/ignisadmin give <target> echo` after review. A UUID not yet saved in Ignis must reconnect so its exact online name can be targeted. Do not reset earned progression merely to remove an inert leftover item, and do not grant a second reward for an already activated benefit.
If loading fails, preserve the original file for investigation. Under an approved maintenance plan, stop the server, back up current data, restore a verified matching-version backup, and restart. Do not delete state to recover: that loses earned progress and invalidation records. Do not hand-edit the binary file or import another plugin's state. Restoring an old backup can also restore older revocation/progress state; review that consequence before doing it.
## Verification limits
JUnit tests exercise real temporary-file persistence, serialized writes, fault injection, threshold policies, receipt/owner/reset rules, challenge transitions, announcement components, command execution/completion, mocked Bukkit boundaries, metadata, and static workflow guardrails. YAML/shell parsing is not remote CI verification.
Live Purpur startup/shutdown, event sequencing, item appearance/consumption, inventory durability across hard crashes, protections, and Stealth/client compatibility still require a manual smoke test. Minecraft inventory persistence and this plugin's state file are not a single atomic transaction: a hard crash between item delivery and Minecraft saving inventory needs operator recovery review. If disconnect/death happens during activation, the durable benefit can commit before the physical item is removed; any retained copy is inert, not another usable unlock. These limitations prevent claiming complete live/crash acceptance or deploying automatically.
Live Purpur startup/shutdown, event sequencing, item appearance/consumption, inventory durability across hard crashes, protections, and Stealth/client compatibility still require a manual smoke test. Minecraft inventory persistence and this plugin's state file are not a single atomic transaction: a hard crash between item delivery and Minecraft saving inventory needs operator recovery review. If disconnect/death happens during activation, the durable benefit can commit before the physical item is removed; any retained copy is inert, not another usable unlock. The approved lightweight contract guarantees one-item consumption in uninterrupted play and one permanent owner activation, not crash-atomic physical consumption. Hard crashes between the two saves may require administrator recovery; extra copies left by interruptions cannot activate again. No cross-save transaction protocol, forced player saves or autosave-length item restrictions are added. This is not a claim of live verification or permission to deploy.
Before a later approved production release, test: void damage/elytra/End Stone success and all interruption paths; other players and lookalikes failing activation; full inventory and failed saves; logout/death during activation; reset/re-earn invalidation; all advancement types and hover/click content with Stealth; admin permission/confirmation/offline UUID cases; direct-drink attribution after reconnect/restart and external effect replacement; skin outages; manual/shift/Crafter ingredient protection; tradable Mask issuer/holder resets; own/other-player helmet rendering, inventory interactions, and preservation of external invisibility; real consumed-pearl launch/statistic ordering; Dagger regular/shift/Crafter recipes, stale receipts, vault-key protection, target visibility, thin obstacles, scaled/posed bodies, hazardous/unsupported exits, world/chunk boundaries, cooldowns across copies/disconnect, and cancelled or redirected protection-plugin teleports.
+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()); }
}
}
}
@@ -60,13 +60,19 @@ public final class EchoGameplay implements Listener {
UUID owner = player.getUniqueId(), receipt = UUID.randomUUID();
long generation = states.snapshot().players().getOrDefault(owner, PlayerState.empty()).track(Track.VOID).generation();
states.update(state -> state.players().getOrDefault(owner, PlayerState.empty()).track(Track.VOID).generation() == generation
? EchoRewards.award(state, owner, receipt) : state).whenComplete((state, error) -> onServer(() -> {
? EchoRewards.award(state, owner, receipt) : state).whenComplete((state, error) -> {
// Report independently of the tick callback: disable can prevent its delivery.
if (error != null) {
plugin.getLogger().warning("Echo reward save failed; administrator recovery is required.");
Player online = plugin.getServer().getPlayer(owner);
if (online != null) online.sendMessage("Your void reward could not be saved. Please contact an administrator.");
} else retryDeliveries();
}));
plugin.getLogger().warning("Echo reward save failed for " + owner + " (receipt " + receipt
+ "); administrator recovery is required.");
}
onServer(() -> {
if (error != null) {
Player online = plugin.getServer().getPlayer(owner);
if (online != null) online.sendMessage("Your void reward could not be saved. Please contact an administrator.");
} else retryDeliveries();
});
});
}
}
@@ -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,229 @@
package games.dmg.spigotignis;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.EntityEquipment;
import net.minecraft.world.entity.player.Inventory;
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.block.Action;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.plugin.java.JavaPlugin;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
class NativeEchoLifecycleTest {
@TempDir Path directory;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@ParameterizedTest
@CsvSource({"0,1", "0,3", "40,1", "40,3"})
void actualInventoryConsumesExactlyOneOnceAndReopensActivated(int slot, int amount) throws Exception {
try (var f = new Fixture(directory)) {
var echo = f.items.create(f.token);
echo.setAmount(amount);
f.inventory.setItem(slot, echo);
var use = f.use(slot);
f.gameplay.onUse(use);
f.gameplay.onUse(use);
f.callback();
assertTrue(f.gameplay.activated(f.owner));
assertEquals(amount - 1, f.count());
f.gameplay.onUse(use);
assertEquals(amount - 1, f.count());
assertFalse(EchoRewards.valid(f.states.snapshot(), f.owner, f.token));
try (var reopened = StateService.open(f.disk).get(5, TimeUnit.SECONDS)) {
assertTrue(new EchoGameplay(f.plugin, reopened, f.items).activated(f.owner));
assertFalse(EchoRewards.valid(reopened.snapshot(), f.owner, f.token));
}
}
}
@ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"disconnect", "death", "disable"})
void interruptedCopiesRemainInertAfterReopen(String interruption) throws Exception {
try (var f = new Fixture(directory)) {
var copy = f.items.create(f.token);
f.inventory.setItem(0, copy);
f.gameplay.onUse(f.use(0));
var callback = f.awaitCallback();
assertTrue(f.gameplay.activated(f.owner));
if (interruption.equals("disconnect")) {
var quit = mock(org.bukkit.event.player.PlayerQuitEvent.class);
when(quit.getPlayer()).thenReturn(f.player);
f.gameplay.onQuit(quit);
when(f.server.getPlayer(f.owner)).thenReturn(null);
callback.run();
assertEquals(1, f.count());
} else if (interruption.equals("death")) {
// The world/drop boundary removes the item before the pending callback.
f.inventory.clear();
var death = mock(org.bukkit.event.entity.PlayerDeathEvent.class);
when(death.getEntity()).thenReturn(f.player);
f.gameplay.onDeath(death);
callback.run();
f.inventory.setItem(0, copy); // later pickup of the retained drop
} else {
f.enabled.set(false); // Bukkit cancels the queued callback on disable; do not execute it.
assertEquals(1, f.count());
}
try (var reopened = StateService.open(f.disk).get(5, TimeUnit.SECONDS)) {
var resumed = new EchoGameplay(f.plugin, reopened, f.items);
f.enabled.set(true);
when(f.server.getPlayer(f.owner)).thenReturn(f.player);
assertTrue(resumed.activated(f.owner));
resumed.onUse(f.use(0));
assertEquals(1, f.count(), "interrupted copies are inert, not consumed a second time");
assertFalse(EchoRewards.valid(reopened.snapshot(), f.owner, f.token));
assertTrue(f.callbacks.isEmpty(), "a spent receipt cannot schedule another activation");
}
}
}
@ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"foreign", "unknown", "reset", "denied"})
void onlyTheCurrentOwnerReceiptCanActivateAndProtectionIsRespected(String rejection) throws Exception {
try (var f = new Fixture(directory)) {
var token = rejection.equals("unknown") ? new EchoToken(f.owner, UUID.randomUUID(), 0) : f.token;
var echo = f.items.create(token);
f.inventory.setItem(0, echo);
if (rejection.equals("foreign")) { when(f.player.getUniqueId()).thenReturn(UUID.randomUUID()); }
if (rejection.equals("reset")) {
f.states.update(state -> ProgressAdministration.reset(state, f.owner, java.util.Set.of(Track.VOID))).get(5, TimeUnit.SECONDS);
f.states.update(state -> EchoRewards.award(state, f.owner, UUID.randomUUID())).get(5, TimeUnit.SECONDS);
}
var use = f.use(0);
if (rejection.equals("denied")) { use.setUseItemInHand(org.bukkit.event.Event.Result.DENY); }
f.gameplay.onUse(use);
assertFalse(f.gameplay.activated(f.owner));
assertEquals(echo, f.inventory.getItem(0));
assertTrue(f.callbacks.isEmpty());
}
}
@org.junit.jupiter.api.Test
void failedActivationLeavesRealItemsUnconsumedAndCanBeRetried() throws Exception {
try (var f = new Fixture(directory)) {
var echo = f.items.create(f.token);
echo.setAmount(3);
f.inventory.setItem(0, echo);
f.failSave.set(true);
f.gameplay.onUse(f.use(0));
f.callback();
assertFalse(f.gameplay.activated(f.owner));
assertEquals(3, f.count());
verify(f.player).sendMessage(contains("It has not been consumed; try again"));
f.failSave.set(false);
f.gameplay.onUse(f.use(0));
f.callback();
assertTrue(f.gameplay.activated(f.owner));
assertEquals(2, f.count());
}
}
@org.junit.jupiter.api.Test
void actualFullInventoryDefersAndFailedAcknowledgementRetriesWithoutAnotherOffer() throws Exception {
try (var f = new Fixture(directory)) {
for (int slot = 0; slot < 36; slot++) {
f.inventory.setItem(slot, new org.bukkit.inventory.ItemStack(org.bukkit.Material.COBBLESTONE, 64));
}
f.gameplay.retryDeliveries();
assertEquals(0, f.count());
assertTrue(f.states.snapshot().players().get(f.owner).pendingRewards().containsKey(f.token.receipt()));
assertTrue(f.callbacks.isEmpty());
f.inventory.setItem(5, null);
f.failSave.set(true);
f.gameplay.retryDeliveries();
f.gameplay.retryDeliveries();
f.callback();
assertEquals(1, f.count());
assertTrue(f.states.snapshot().players().get(f.owner).pendingRewards().containsKey(f.token.receipt()));
f.failSave.set(false);
f.gameplay.retryDeliveries();
f.callback();
f.gameplay.retryDeliveries();
assertEquals(1, f.count());
assertTrue(f.states.snapshot().players().get(f.owner).pendingRewards().isEmpty());
try (var reopened = StateService.open(f.disk).get(5, TimeUnit.SECONDS)) {
assertTrue(reopened.snapshot().players().get(f.owner).pendingRewards().isEmpty());
assertTrue(EchoRewards.valid(reopened.snapshot(), f.owner, f.token));
}
}
}
private static final class Fixture implements AutoCloseable {
final UUID owner = UUID.randomUUID();
final EchoToken token = new EchoToken(owner, UUID.randomUUID(), 0);
final EchoItems items = new EchoItems();
final CraftServer server = mock(CraftServer.class);
final org.mockito.MockedStatic<Bukkit> platform;
final JavaPlugin plugin = mock(JavaPlugin.class);
final CraftPlayer player = mock(CraftPlayer.class);
final CraftInventoryPlayer inventory;
final LinkedBlockingQueue<Runnable> callbacks = new LinkedBlockingQueue<>();
final AtomicBoolean enabled = new AtomicBoolean(true), failSave = new AtomicBoolean();
final FileStateRepository disk;
final StateService states;
final EchoGameplay gameplay;
Fixture(Path directory) throws Exception {
platform = mockStatic(Bukkit.class, call -> switch (call.getMethod().getName()) {
case "getServer" -> server;
case "isPrimaryThread" -> true;
default -> call.callRealMethod();
});
var nativePlayer = mock(ServerPlayer.class);
when(nativePlayer.getBukkitEntity()).thenReturn(player);
when(player.getHandle()).thenReturn(nativePlayer);
inventory = new CraftInventoryPlayer(new Inventory(nativePlayer, new EntityEquipment()));
when(player.getInventory()).thenReturn(inventory);
when(player.getUniqueId()).thenReturn(owner);
when(plugin.getServer()).thenReturn(server);
when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger());
when(plugin.isEnabled()).thenAnswer(call -> enabled.get());
when(server.getPlayer(owner)).thenReturn(player);
doReturn(List.of(player)).when(server).getOnlinePlayers();
var scheduler = mock(CraftScheduler.class);
when(server.getScheduler()).thenReturn(scheduler);
when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenAnswer(call -> { callbacks.add(call.getArgument(1)); return null; });
disk = new FileStateRepository(directory.resolve("state.bin"));
states = StateService.open(new StateRepository() {
@Override public IgnisState load() throws IOException { return disk.load(); }
@Override public void save(IgnisState state) throws IOException {
if (failSave.get()) { throw new IOException("simulated unavailable storage"); }
disk.save(state);
}
}).get(5, TimeUnit.SECONDS);
states.update(state -> EchoRewards.award(state, owner, token.receipt())).get(5, TimeUnit.SECONDS);
gameplay = new EchoGameplay(plugin, states, items);
}
PlayerInteractEvent use(int slot) {
return new PlayerInteractEvent(player, Action.RIGHT_CLICK_AIR, inventory.getItem(slot), null,
org.bukkit.block.BlockFace.SELF, slot == 40 ? EquipmentSlot.OFF_HAND : EquipmentSlot.HAND);
}
Runnable awaitCallback() throws InterruptedException {
var callback = callbacks.poll(5, TimeUnit.SECONDS);
assertNotNull(callback);
return callback;
}
void callback() throws InterruptedException { awaitCallback().run(); }
int count() {
return java.util.Arrays.stream(inventory.getContents()).filter(item -> items.identify(item).filter(token::equals).isPresent())
.mapToInt(org.bukkit.inventory.ItemStack::getAmount).sum();
}
@Override public void close() { states.close(); platform.close(); }
}
}
@@ -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,92 @@
package games.dmg.spigotignis;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.io.IOException;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.plugin.java.JavaPlugin;
class EchoRewardFailureTest {
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"online", "offline", "disabled", "scheduler-rejected"})
void failedEarnedRewardReportsRecoveryEvenWithoutPlayerCallback(String mode) throws Exception {
var entered = new CountDownLatch(1);
var release = new CountDownLatch(1);
var repository = new StateRepository() {
@Override public IgnisState load() { return IgnisState.empty(); }
@Override public void save(IgnisState state) throws IOException {
entered.countDown();
try { if (!release.await(5, TimeUnit.SECONDS)) { throw new IOException("test barrier timeout"); } }
catch (InterruptedException failure) { Thread.currentThread().interrupt(); throw new IOException(failure); }
throw new IOException("simulated unavailable storage");
}
};
var enabled = new AtomicBoolean(true);
var plugin = mock(JavaPlugin.class);
when(plugin.isEnabled()).thenAnswer(call -> enabled.get());
var server = mock(Server.class);
when(plugin.getServer()).thenReturn(server);
var logger = mock(java.util.logging.Logger.class);
when(plugin.getLogger()).thenReturn(logger);
var player = mock(Player.class);
UUID owner = UUID.randomUUID();
when(player.getUniqueId()).thenReturn(owner);
if (mode.equals("online")) { when(server.getPlayer(owner)).thenReturn(player); }
var callbacks = new java.util.concurrent.LinkedBlockingQueue<Runnable>();
var scheduler = mock(org.bukkit.scheduler.BukkitScheduler.class);
when(server.getScheduler()).thenReturn(scheduler);
when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenAnswer(call -> {
if (mode.equals("scheduler-rejected")) { throw new org.bukkit.plugin.IllegalPluginAccessException("simulated shutdown"); }
callbacks.add(call.getArgument(1));
return null;
});
var world = mock(World.class);
when(world.getUID()).thenReturn(UUID.randomUUID());
when(player.getWorld()).thenReturn(world);
var block = mock(Block.class);
when(block.getType()).thenReturn(Material.END_STONE);
when(world.getBlockAt(any(Location.class))).thenReturn(block);
try (var states = StateService.open(repository).get(5, TimeUnit.SECONDS)) {
var gameplay = new EchoGameplay(plugin, states, new EchoItems());
var damage = mock(EntityDamageEvent.class);
when(damage.getEntity()).thenReturn(player);
when(damage.getCause()).thenReturn(EntityDamageEvent.DamageCause.VOID);
when(damage.getFinalDamage()).thenReturn(4.0);
gameplay.onDamage(damage);
var move = mock(PlayerMoveEvent.class);
when(move.getPlayer()).thenReturn(player);
when(move.hasChangedPosition()).thenReturn(true);
when(move.getTo()).thenReturn(new Location(world, 0, 10, 0));
when(player.isGliding()).thenReturn(true);
gameplay.onMove(move);
when(player.isOnGround()).thenReturn(true);
gameplay.onMove(move);
assertTrue(entered.await(5, TimeUnit.SECONDS));
enabled.set(!mode.equals("disabled"));
release.countDown();
states.update(state -> state).handle((state, failure) -> null).get(5, TimeUnit.SECONDS);
verify(logger).warning(contains("administrator recovery"));
verify(logger).warning(contains(owner.toString()));
assertFalse(states.snapshot().players().containsKey(owner));
verify(server, never()).getPlayer(any(UUID.class));
if (mode.equals("online") || mode.equals("offline")) {
var callback = callbacks.poll(5, TimeUnit.SECONDS);
assertNotNull(callback);
callback.run();
} else { assertTrue(callbacks.isEmpty()); }
if (mode.equals("online")) { verify(player).sendMessage(contains("contact an administrator")); }
else { verify(player, never()).sendMessage(anyString()); }
} finally { release.countDown(); }
}
}
@@ -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"));