Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bfcf36f04 | ||
|
|
e4bc296ce1 | ||
|
|
7edfe71d2d |
No files matched your search
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -102,6 +104,8 @@ Administration requires **`ignis.admin`** (default: operators), checked independ
|
||||
/ignisadmin reset <online-name|known-UUID> <void|mask|dagger|heart|all> confirm
|
||||
```
|
||||
|
||||
`inspect` reports saved progress for all four tracks (Mask/Heart milliseconds and Dagger pearls), pending/issued reward counts, recipe unlock eligibility, and Echo eligibility separately from permanent advancement-name-scrambling activation. It also reports the active void attempt: `awaiting-elytra-ascent`, `awaiting-end-stone`, or `none`, including world UUID and original damage height when active. Inspection is read-only: it does not checkpoint progress, advance/cancel challenges, deliver items or write state. Counters exclude uncommitted tracker time; recipe unlocks do not mean item effects are currently equipped or active. Known offline UUIDs remain inspectable; unfinished void attempts are not restored after disconnect/restart.
|
||||
|
||||
Reset confirmation must match the same sender, target, and tracks within 30 seconds; `confirm` alone cannot bypass the initial request. Reset clears the selected progress/unlocks/activation, pending rewards and issuance receipts, and advances generation, making old Echo copies invalid even after re-earning. Other tracks are unchanged by a single-track reset. Echo grants unlock eligibility but still require consumption to activate scrambling; already activated players do not gain another activation. Admin Mask/Dagger grants unlock use and queue authenticated items for inventory-ready delivery; new Masks also require profile resolution. Mask/Dagger delivery retries remember an already offered gift while its acknowledgement save is failing, so trading it away does not cause another offer in the same plugin session. This marker is not durable across process restart; failed-acknowledgement/restart recovery still requires operator review. Admin mutations are audited, not routine private progress reads.
|
||||
|
||||
Offline targets use UUIDs already present in Ignis state; online exact names are resolved without network profile lookups. Commands and contextual completion do not claim that unimplemented reward items are available.
|
||||
|
||||
+59
-1
@@ -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
@@ -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()); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,11 @@ public final class EchoGameplay implements Listener {
|
||||
@EventHandler public void onQuit(PlayerQuitEvent event) { cancel(event.getPlayer().getUniqueId()); }
|
||||
public void cancel(UUID player) { trial.cancel(player); }
|
||||
|
||||
/** Read-only server-thread inspection; does not advance, cancel or persist the attempt. */
|
||||
public java.util.Optional<VoidTrial.Snapshot> trialSnapshot(UUID player) {
|
||||
return trial.snapshot(player);
|
||||
}
|
||||
|
||||
public boolean activated(UUID player) {
|
||||
return states.snapshot().players().getOrDefault(player, PlayerState.empty()).track(Track.VOID).activated();
|
||||
}
|
||||
|
||||
@@ -69,8 +69,19 @@ public final class IgnisCommands implements CommandExecutor, TabCompleter {
|
||||
if (args.length == 2 && args[0].equalsIgnoreCase("inspect")) {
|
||||
UUID id = resolve(args[1]);
|
||||
var player = states.snapshot().players().getOrDefault(id, PlayerState.empty());
|
||||
sender.sendMessage(id + ": " + player.tracks() + "; pending=" + player.pendingRewards().size()
|
||||
+ "; issued=" + player.issuedRewards().size());
|
||||
String progress = java.util.Arrays.stream(Track.values())
|
||||
.map(track -> track + "=" + player.track(track).progress())
|
||||
.collect(java.util.stream.Collectors.joining(", "));
|
||||
sender.sendMessage(id + ": saved progress (MASK/HEART milliseconds, DAGGER pearls): " + progress
|
||||
+ "; pending=" + player.pendingRewards().size() + "; issued=" + player.issuedRewards().size());
|
||||
sender.sendMessage("Recipe unlocks: mask=" + status(player, Track.MASK)
|
||||
+ ", dagger=" + status(player, Track.DAGGER) + ", heart=" + status(player, Track.HEART));
|
||||
sender.sendMessage("Echo: eligibility=" + status(player, Track.VOID) + "; advancement-name scrambling="
|
||||
+ (player.track(Track.VOID).activated() ? "active" : "inactive"));
|
||||
sender.sendMessage("Void challenge: " + gameplay.trialSnapshot(id)
|
||||
.map(attempt -> (attempt.escaped() ? "awaiting-end-stone" : "awaiting-elytra-ascent")
|
||||
+ "; world=" + attempt.world() + "; damage-y=" + attempt.damageHeight())
|
||||
.orElse("none"));
|
||||
} else if (args.length == 1 && args[0].equalsIgnoreCase("limits")) {
|
||||
sender.sendMessage(states.snapshot().limits().toString());
|
||||
} else if (args.length == 4 && args[0].equalsIgnoreCase("limits") && args[1].equalsIgnoreCase("set")) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -6,6 +6,7 @@ import java.util.UUID;
|
||||
|
||||
/** Server-thread transient attempts. Never persisted across disconnect/restart. */
|
||||
public final class VoidTrial {
|
||||
public record Snapshot(UUID world, double damageHeight, boolean escaped) { }
|
||||
private record Attempt(UUID world, double damageHeight, boolean escaped) { }
|
||||
private final Map<UUID, Attempt> attempts = new HashMap<>();
|
||||
|
||||
@@ -30,5 +31,12 @@ public final class VoidTrial {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Server-thread read only: never advances, cancels or persists an attempt. */
|
||||
public java.util.Optional<Snapshot> snapshot(UUID player) {
|
||||
Attempt attempt = attempts.get(player);
|
||||
return attempt == null ? java.util.Optional.empty()
|
||||
: java.util.Optional.of(new Snapshot(attempt.world(), attempt.damageHeight(), attempt.escaped()));
|
||||
}
|
||||
|
||||
public void cancel(UUID player) { attempts.remove(player); }
|
||||
}
|
||||
@@ -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,140 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.player.PlayerMoveEvent;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class ProgressInspectionTest {
|
||||
@TempDir Path directory;
|
||||
private final UUID owner = UUID.randomUUID(), worldId = UUID.randomUUID();
|
||||
private final JavaPlugin plugin = mock(JavaPlugin.class);
|
||||
private final Server server = mock(Server.class);
|
||||
private final Player player = mock(Player.class);
|
||||
private final World world = mock(World.class);
|
||||
private final CommandSender admin = mock(CommandSender.class);
|
||||
private final Command command = mock(Command.class);
|
||||
private final EchoItems items = mock(EchoItems.class);
|
||||
private final List<String> messages = new ArrayList<>();
|
||||
private StateService states;
|
||||
private EchoGameplay gameplay;
|
||||
private IgnisCommands handler;
|
||||
private Path file;
|
||||
|
||||
@BeforeEach
|
||||
void setup() throws Exception {
|
||||
file = directory.resolve("state.bin");
|
||||
states = StateService.open(new FileStateRepository(file)).get();
|
||||
states.update(state -> new IgnisState(state.limits(), Map.of(owner, PlayerState.empty()))).get();
|
||||
when(plugin.getServer()).thenReturn(server);
|
||||
when(player.getUniqueId()).thenReturn(owner);
|
||||
when(player.getName()).thenReturn("Target");
|
||||
when(player.getWorld()).thenReturn(world);
|
||||
when(player.getY()).thenReturn(-65.0);
|
||||
when(world.getUID()).thenReturn(worldId);
|
||||
when(server.getPlayerExact("Target")).thenReturn(player);
|
||||
when(admin.hasPermission("ignis.admin")).thenReturn(true);
|
||||
when(command.getName()).thenReturn("ignisadmin");
|
||||
doAnswer(call -> { messages.add(call.getArgument(0, String.class)); return null; })
|
||||
.when(admin).sendMessage(anyString());
|
||||
gameplay = new EchoGameplay(plugin, states, items);
|
||||
handler = new IgnisCommands(plugin, states, gameplay);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void close() { if (states != null) states.close(); }
|
||||
|
||||
@Test
|
||||
void commandReportsRealGameplayAttemptStagesWithoutSavingOrInterruptingThem() throws Exception {
|
||||
var before = states.snapshot();
|
||||
byte[] bytes = Files.readAllBytes(file);
|
||||
EntityDamageEvent damage = mock(EntityDamageEvent.class);
|
||||
when(damage.getEntity()).thenReturn(player);
|
||||
when(damage.getCause()).thenReturn(EntityDamageEvent.DamageCause.VOID);
|
||||
when(damage.getFinalDamage()).thenReturn(1.0);
|
||||
gameplay.onDamage(damage);
|
||||
inspect("Target");
|
||||
assertContains("Void challenge: awaiting-elytra-ascent");
|
||||
assertContains("world=" + worldId);
|
||||
assertContains("damage-y=-65.0");
|
||||
when(player.isGliding()).thenReturn(true);
|
||||
gameplay.onMove(new PlayerMoveEvent(player, new Location(world, 0, -65, 0), new Location(world, 0, 20, 0)));
|
||||
messages.clear();
|
||||
inspect("Target");
|
||||
assertContains("Void challenge: awaiting-end-stone");
|
||||
var quit = mock(PlayerQuitEvent.class);
|
||||
when(quit.getPlayer()).thenReturn(player);
|
||||
gameplay.onQuit(quit);
|
||||
messages.clear();
|
||||
inspect(owner.toString());
|
||||
assertContains("Void challenge: none");
|
||||
assertSame(before, states.snapshot());
|
||||
assertArrayEquals(bytes, Files.readAllBytes(file));
|
||||
verifyNoInteractions(items);
|
||||
verify(plugin, never()).getLogger();
|
||||
verify(server, never()).getScheduler();
|
||||
}
|
||||
|
||||
@Test
|
||||
void savedProgressAndRecipeUnlocksDoNotMisreportAnUnconsumedEchoAsActive() throws Exception {
|
||||
UUID receipt = UUID.randomUUID();
|
||||
states.update(state -> EchoRewards.award(ProgressAdministration.progress(
|
||||
ProgressAdministration.progress(state, owner, Track.MASK, state.limits().invisibilityMillis(), false),
|
||||
owner, Track.DAGGER, 3, false), owner, receipt)).get();
|
||||
var before = states.snapshot();
|
||||
byte[] bytes = Files.readAllBytes(file);
|
||||
inspect(owner.toString());
|
||||
assertContains("MASK=" + before.limits().invisibilityMillis());
|
||||
assertContains("DAGGER=3");
|
||||
assertContains("HEART=0");
|
||||
assertContains("Recipe unlocks: mask=unlocked, dagger=locked, heart=locked");
|
||||
assertContains("Echo: eligibility=unlocked; advancement-name scrambling=inactive");
|
||||
assertContains("pending=1");
|
||||
assertContains("issued=1");
|
||||
assertSame(before, states.snapshot());
|
||||
assertArrayEquals(bytes, Files.readAllBytes(file));
|
||||
verify(server, never()).getPlayerExact(anyString());
|
||||
verifyNoInteractions(items);
|
||||
states.update(state -> EchoRewards.activate(state, owner, new EchoToken(owner, receipt, 0))).get();
|
||||
messages.clear();
|
||||
inspect(owner.toString());
|
||||
assertContains("advancement-name scrambling=active");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonAdminInspectionIsDeniedBeforeReadingOrResolvingState() {
|
||||
when(admin.hasPermission("ignis.admin")).thenReturn(false);
|
||||
var denied = new IgnisCommands(plugin, null, null);
|
||||
denied.onCommand(admin, command, "ignisadmin", new String[] {"inspect", "Target"});
|
||||
assertEquals(List.of("You do not have permission to administer Ignis."), messages);
|
||||
verifyNoInteractions(server);
|
||||
}
|
||||
|
||||
private void inspect(String target) {
|
||||
assertTrue(handler.onCommand(admin, command, "ignisadmin", new String[] {"inspect", target}));
|
||||
}
|
||||
|
||||
private void assertContains(String fragment) {
|
||||
assertTrue(messages.stream().anyMatch(message -> message.contains(fragment)),
|
||||
() -> "Missing '" + fragment + "' in " + messages);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
|
||||
@@ -5,6 +5,42 @@ import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class VoidTrialTest {
|
||||
@Test
|
||||
void inspectionIsImmutableAndDoesNotChangeChallengeCompletion() {
|
||||
var trial = new VoidTrial();
|
||||
UUID player = UUID.randomUUID(), world = UUID.randomUUID();
|
||||
assertTrue(trial.snapshot(player).isEmpty());
|
||||
trial.damaged(player, world, -65);
|
||||
assertTrue(trial.snapshot(player).isPresent(), "Active attempt must be inspectable");
|
||||
var initial = trial.snapshot(player).orElseThrow();
|
||||
assertEquals(world, initial.world());
|
||||
assertEquals(-65, initial.damageHeight());
|
||||
assertFalse(initial.escaped());
|
||||
assertEquals(initial, trial.snapshot(player).orElseThrow());
|
||||
trial.moved(player, world, 20, false, false, false);
|
||||
assertFalse(trial.snapshot(player).orElseThrow().escaped());
|
||||
trial.moved(player, world, 20, true, false, false);
|
||||
assertTrue(trial.snapshot(player).orElseThrow().escaped());
|
||||
assertFalse(initial.escaped(), "A returned snapshot must not change after later events");
|
||||
assertTrue(trial.moved(player, world, 60, false, true, true));
|
||||
assertTrue(trial.snapshot(player).isEmpty());
|
||||
assertFalse(trial.moved(player, world, 60, false, true, true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void inspectionClearsAfterCancellationWorldChangeAndRestart() {
|
||||
var trial = new VoidTrial();
|
||||
UUID player = UUID.randomUUID(), world = UUID.randomUUID();
|
||||
trial.damaged(player, world, -65);
|
||||
trial.cancel(player);
|
||||
assertTrue(trial.snapshot(player).isEmpty());
|
||||
trial.damaged(player, world, -65);
|
||||
trial.moved(player, UUID.randomUUID(), 60, true, false, false);
|
||||
assertTrue(trial.snapshot(player).isEmpty());
|
||||
trial.damaged(player, world, -65);
|
||||
assertTrue(new VoidTrial().snapshot(player).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void requiresDamageAscentWhileGlidingAndFirstLandingOnEndStone() {
|
||||
var trial = new VoidTrial();
|
||||
|
||||
Reference in new issue
Block a user