1 Commits
Author SHA1 Message Date
dmg 67942ced7c feat(stealth): earn and equip the Eye of True Seeing
Release / release (push) Successful in 7m15s
CI / build (push) Successful in 2m15s
Add independent eight-hour Night Vision progression, save-gated crafting and holder eligibility, authenticated helmet items and guarded inventory movement. Verify source attribution, native item/recipe/event adapters and actual plugin lifecycle. Drain final state asynchronously and reject stale lifecycle callbacks. Per-viewer revelation remains the next story; no deployment is included.
2026-09-12 17:35:01 -04:00
22 changed files with 1792 additions and 37 deletions
+13 -1
View File
@@ -10,12 +10,24 @@ Progression-gated identity concealment for Purpur 26.2 build 2618, built and tes
Artifacts are written to `build/libs/purpur-stealth-<version>.jar`. Gitea CI verifies pushes/pull requests; approved main-branch conventional commits drive versioned releases. The distribution tests inspect the built JAR, embedded version/runtime identity, Java 25 bytecode, and workflow naming.
## Eye of True Seeing
Earn a separate, personal unlock through eight cumulative online hours of Night Vision from directly drunk potions. Splash/lingering potions, commands, plugins and other sources do not count. Rejected and cosmetic-only replacements do not change attribution. Unattributed restored effects cannot inherit the drink timer; a new effective drink can start a new interval. Offline time is excluded.
After the unlock is saved, craft an **Eye of True Seeing** with eight Netherite Blocks around an Eye of Ender. Right-click an authenticated Eye in either hand to move it into an empty helmet slot. Existing helmets are not replaced. Traded Eyes still require the holder's own saved unlock; renamed ordinary Eyes do not qualify. Automated crafters cannot bypass progression. Vanilla handles crafting ingredient consumption.
**Local name/body revelation is separate US-008 work and is not implemented by this acquisition story.** Eligibility is checked even for an Eye manually placed in the helmet slot.
Eye records are independently UUID-keyed in `plugins/SpigotStealth/state.yml` (schema 2). Legacy Stealth records and extension fields are preserved. Crafting/use wait for successful save acknowledgement. Shutdown queues a final snapshot without blocking the tick thread, and initialization callbacks cannot revive a disabled or superseded lifecycle. Unacknowledged progress at abrupt process/power loss is not guaranteed.
`check` includes `nativeStealthTest`, which downloads SHA-256-pinned Purpur 2618, patches it without starting a listening server, and exercises real potion events, items, recipes, event dispatch and plugin lifecycle with external platform doubles. Live-client appearance and gameplay checks remain separate follow-ups.
## Migration compatibility
The repository and checkout are now `purpur-stealth` (previously remote `spigot-stealth`, local `spigot-invisibilty`). Runtime identity remains **SpigotStealth**, including `plugins/SpigotStealth/`, the existing Java entrypoint/packages, command names, `spigotstealth` permission/namespace identifiers, and persisted progression/session data. Java 17 is no longer supported for new builds or artifacts.
Replace the old plugin JAR when installing the new distribution; never load both JARs together. Old tags and `spigot-stealth-*` release assets are preserved. Repository rename redirects have been checked against the previously deployed v1.5.0 download. Publishing a release does not authorize deployment.
This migration does not implement the pending Eye, combat reveal, or concealed-chat stories. Existing gameplay and plugin metadata are unchanged.
The v2.0.0 migration itself left the Eye, combat reveal and concealed-chat stories pending. Subsequent Eye acquisition work is described above; Eye revelation, combat reveal and system-chat routing remain separate unfinished stories. Runtime plugin metadata is unchanged.
See the [canonical project](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-stealth/index.md), [stories](https://git.garvis.dev/dmg/somc-okf/src/branch/main/user-stories/purpur-stealth/index.md), and [development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md).
+57
View File
@@ -1,3 +1,6 @@
import java.net.URI
import java.security.MessageDigest
plugins {
java
}
@@ -43,6 +46,60 @@ tasks.test {
systemProperty("distribution.version", project.version.toString())
}
// Exercise real potion events, item components and recipes without starting a listening server.
val stealthRuntime = layout.buildDirectory.dir("stealth-runtime")
val downloadStealthRuntime = tasks.register("downloadStealthRuntime") {
val launcher = stealthRuntime.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 prepareStealthRuntime = tasks.register<JavaExec>("prepareStealthRuntime") {
dependsOn(downloadStealthRuntime)
javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(25) }
classpath = files(stealthRuntime.map { it.file("purpur-26.2-2618.jar") })
mainClass = "io.papermc.paperclip.Main"
jvmArgs("-Dpaperclip.patchonly=true")
workingDir(stealthRuntime)
outputs.dir(stealthRuntime.map { it.dir("versions") })
outputs.dir(stealthRuntime.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(stealthRuntime) {
include("versions/**/*.jar", "libraries/**/*.jar")
}).builtBy(prepareStealthRuntime)
nativeTest.compileClasspath += sourceSets.main.get().output + nativeRuntimeJars
nativeTest.runtimeClasspath += sourceSets.main.get().output + nativeRuntimeJars
val nativeStealthTest = tasks.register<Test>("nativeStealthTest") {
description = "Runs native Purpur adapter regressions without starting a server"
testClassesDirs = nativeTest.output.classesDirs
classpath = nativeTest.runtimeClasspath
useJUnitPlatform()
maxHeapSize = "1G"
workingDir(stealthRuntime)
}
tasks.check { dependsOn(nativeStealthTest) }
val pluginVersion = version
tasks.processResources {
@@ -0,0 +1,66 @@
package games.dmg.spigotstealth;
import java.util.Objects;
import org.bukkit.Keyed;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.inventory.PrepareItemCraftEvent;
import org.bukkit.inventory.ShapedRecipe;
/** Personal crafting unlock for the transferable Eye. Vanilla owns ingredient consumption. */
public final class EyeCrafting implements Listener {
public static final NamespacedKey RECIPE_KEY = new NamespacedKey("spigotstealth", "eye_of_true_seeing");
private final EyeItems items;
private final EyeProgressionService progression;
public EyeCrafting(EyeProgressionService progression, EyeItems items) {
this.progression = Objects.requireNonNull(progression, "progression");
this.items = Objects.requireNonNull(items, "items");
}
public ShapedRecipe recipe() {
return new ShapedRecipe(RECIPE_KEY, items.create()).shape("NNN", "NEN", "NNN")
.setIngredient('N', Material.NETHERITE_BLOCK).setIngredient('E', Material.ENDER_EYE);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onCraft(org.bukkit.event.inventory.CraftItemEvent event) {
if (event.isCancelled() || !matches(event.getRecipe())) { return; }
if (!(event.getWhoClicked() instanceof Player player) || !progression.isUnlocked(player.getUniqueId())
|| !accepts(event.getInventory().getMatrix()) || !items.isEye(event.getInventory().getResult())
|| event.getInventory().getResult().getAmount() != 1) {
event.setCancelled(true);
event.getInventory().setResult(null);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onCrafter(org.bukkit.event.block.CrafterCraftEvent event) {
if (matches(event.getRecipe())) { event.setCancelled(true); }
}
private static boolean matches(org.bukkit.inventory.Recipe recipe) {
return recipe instanceof Keyed keyed && RECIPE_KEY.equals(keyed.getKey());
}
private static boolean accepts(org.bukkit.inventory.ItemStack[] matrix) {
if (matrix.length != 9) { return false; }
for (int index = 0; index < matrix.length; index++) {
if (matrix[index] == null || matrix[index].getAmount() <= 0
|| matrix[index].getType() != (index == 4 ? Material.ENDER_EYE : Material.NETHERITE_BLOCK)) { return false; }
}
return true;
}
@EventHandler(priority = EventPriority.HIGHEST)
public void onPrepare(PrepareItemCraftEvent event) {
if (!(event.getInventory().getRecipe() instanceof Keyed keyed) || !RECIPE_KEY.equals(keyed.getKey())) { return; }
if (!(event.getView().getPlayer() instanceof Player player) || !progression.isUnlocked(player.getUniqueId())) {
event.getInventory().setResult(null);
}
}
}
@@ -0,0 +1,47 @@
package games.dmg.spigotstealth;
import java.util.Objects;
import org.bukkit.entity.Player;
import org.bukkit.event.Event;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.EquipmentSlot;
/** Player-specific eligibility and inventory movement; never invokes native Ender Eye use. */
public final class EyeEquipment implements Listener {
private final EyeProgressionService progression;
private final EyeItems items;
public EyeEquipment(EyeProgressionService progression, EyeItems items) {
this.progression = Objects.requireNonNull(progression, "progression");
this.items = Objects.requireNonNull(items, "items");
}
public boolean isEligibleWearer(Player player) {
return progression.isUnlocked(player.getUniqueId()) && items.isEye(player.getInventory().getHelmet());
}
// Interact-in-air may start with block use denied; respect item-use denial rather than isCancelled().
@EventHandler(priority = EventPriority.HIGHEST)
public void onUse(PlayerInteractEvent event) {
if (!event.getAction().isRightClick() || event.useItemInHand() == Event.Result.DENY || !items.isEye(event.getItem())) { return; }
event.setUseItemInHand(Event.Result.DENY);
event.setUseInteractedBlock(Event.Result.DENY);
Player player = event.getPlayer();
if (player.isDead() || !progression.isUnlocked(player.getUniqueId())
|| (event.getHand() != EquipmentSlot.HAND && event.getHand() != EquipmentSlot.OFF_HAND)) { return; }
var inventory = player.getInventory();
var helmet = inventory.getHelmet();
if (helmet != null && helmet.getAmount() > 0 && !helmet.getType().isAir()) { return; }
var held = inventory.getItem(event.getHand());
if (!items.isEye(held)) { return; }
var equipped = held.clone();
equipped.setAmount(1);
var remaining = held.clone();
remaining.setAmount(held.getAmount() - 1);
inventory.setItem(event.getHand(), remaining.getAmount() == 0 ? null : remaining);
inventory.setHelmet(equipped);
}
}
@@ -0,0 +1,34 @@
package games.dmg.spigotstealth;
import net.kyori.adventure.text.Component;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.ItemStack;
import org.bukkit.persistence.PersistentDataType;
/** Creates and identifies the transferable Eye; eligibility belongs to the holder's durable progression. */
public final class EyeItems {
private static final NamespacedKey IDENTITY = new NamespacedKey("spigotstealth", "eye_of_true_seeing");
public ItemStack create() {
var eye = new ItemStack(Material.ENDER_EYE);
var meta = eye.getItemMeta();
meta.displayName(Component.text("Eye of True Seeing"));
meta.getPersistentDataContainer().set(IDENTITY, PersistentDataType.BYTE, (byte) 1);
meta.setMaxStackSize(1);
var equippable = meta.getEquippable();
equippable.setSlot(EquipmentSlot.HEAD);
equippable.setDispensable(false);
equippable.setEquipOnInteract(false);
equippable.setDamageOnHurt(false);
meta.setEquippable(equippable);
eye.setItemMeta(meta);
return eye;
}
public boolean isEye(ItemStack item) {
return item != null && item.getAmount() > 0 && item.getType() == Material.ENDER_EYE && item.hasItemMeta()
&& Byte.valueOf((byte) 1).equals(item.getItemMeta().getPersistentDataContainer().get(IDENTITY, PersistentDataType.BYTE));
}
}
@@ -0,0 +1,101 @@
package games.dmg.spigotstealth;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityPotionEffectEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
/** Night Vision potion-event adapter; progression and persistence are owned by the service. */
public final class EyePotionListener implements Listener {
private final EyeProgressionService progression;
private final Consumer<Throwable> saveFailure;
private final Map<UUID, PotionEffect> expectedEffects = new HashMap<>();
public EyePotionListener(EyeProgressionService progression, Consumer<Throwable> saveFailure) {
this.progression = Objects.requireNonNull(progression, "progression");
this.saveFailure = Objects.requireNonNull(saveFailure, "saveFailure");
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onPotionEffect(EntityPotionEffectEvent event) {
if (event.isCancelled() || !(event.getEntity() instanceof Player player)
|| !PotionEffectType.NIGHT_VISION.equals(event.getModifiedType())) {
return;
}
if (event.getAction() == EntityPotionEffectEvent.Action.CHANGED && !replacesActiveEffect(event)) {
return;
}
boolean addedOrChanged = event.getAction() == EntityPotionEffectEvent.Action.ADDED
|| event.getAction() == EntityPotionEffectEvent.Action.CHANGED;
if (addedOrChanged && event.getCause() == EntityPotionEffectEvent.Cause.POTION_DRINK) {
if (!continuesEffect(expectedEffects.get(player.getUniqueId()), event.getOldEffect())) {
progression.abandon(player.getUniqueId());
}
expectedEffects.put(player.getUniqueId(), event.getNewEffect());
observe(progression.begin(player.getUniqueId()));
} else {
finish(player.getUniqueId(), event.getOldEffect());
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onQuit(PlayerQuitEvent event) {
finish(event.getPlayer().getUniqueId(), event.getPlayer().getPotionEffect(PotionEffectType.NIGHT_VISION));
}
/** Server-thread observation; a missing/unattributed effect cannot inherit an old drink interval. */
public void checkpoint(Function<UUID, Player> onlinePlayer) {
for (var id : progression.activePlayerIds()) {
Player player = onlinePlayer.apply(id);
var actual = player == null || !player.isOnline() || player.isDead()
? null : player.getPotionEffect(PotionEffectType.NIGHT_VISION);
var expected = expectedEffects.get(id);
if (!continuesEffect(expected, actual) || (!actual.isInfinite() && actual.getDuration() <= 0)) {
expectedEffects.remove(id);
progression.abandon(id); // Discard the unobserved tail, never attribute it to the old source.
} else {
expectedEffects.put(id, actual);
observe(progression.checkpoint(id));
}
}
}
private void finish(UUID id, PotionEffect finalEffect) {
var expected = expectedEffects.remove(id);
if (continuesEffect(expected, finalEffect)) { observe(progression.stop(id)); }
else { progression.abandon(id); }
}
private static boolean continuesEffect(PotionEffect expected, PotionEffect actual) {
return actual != null && expected != null && actual.getAmplifier() == expected.getAmplifier()
&& (expected.isInfinite() ? actual.isInfinite()
: !actual.isInfinite() && actual.getDuration() <= expected.getDuration());
}
private void observe(CompletableFuture<Void> saved) {
saved.whenComplete((ignored, failure) -> {
if (failure != null) { saveFailure.accept(failure); }
});
}
private static boolean replacesActiveEffect(EntityPotionEffectEvent event) {
if (!event.isOverride() || event.getOldEffect() == null || event.getNewEffect() == null) { return false; }
var previous = event.getOldEffect();
var next = event.getNewEffect();
// Native update can return true for particles/icons alone. Hidden weaker effects are not active yet.
return next.getAmplifier() > previous.getAmplifier()
|| (next.getAmplifier() == previous.getAmplifier() && !previous.isInfinite()
&& (next.isInfinite() || next.getDuration() > previous.getDuration()));
}
}
@@ -0,0 +1,13 @@
package games.dmg.spigotstealth;
import java.util.Map;
/** Independent durable Eye of True Seeing progress. Active potion intervals are never resumed from disk. */
public record EyeProgress(long accumulatedMillis, boolean unlocked, Map<String, Object> unknownFields) {
public EyeProgress {
if (accumulatedMillis < 0) { throw new IllegalArgumentException("Eye progress cannot be negative"); }
unknownFields = Map.copyOf(unknownFields);
}
public EyeProgress(long accumulatedMillis, boolean unlocked) { this(accumulatedMillis, unlocked, Map.of()); }
public static EyeProgress empty() { return new EyeProgress(0, false); }
}
@@ -0,0 +1,73 @@
package games.dmg.spigotstealth;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import java.util.function.LongSupplier;
/** Server-thread qualifying intervals; durable rewards are acknowledged by the existing I/O executor. */
public final class EyeProgressionService {
public static final Duration UNLOCK_THRESHOLD = Duration.ofHours(8);
private record Interval(long startedNanos, long creditedMillis) { }
private final StealthStateManager states;
private final LongSupplier nanoTime;
private final Consumer<UUID> unlocked;
private final Map<UUID, Interval> active = new HashMap<>();
private final Set<UUID> announced = ConcurrentHashMap.newKeySet();
/** The notification callback must marshal Bukkit work onto the server thread. */
public EyeProgressionService(StealthStateManager states, LongSupplier nanoTime, Consumer<UUID> unlocked) {
this.states = Objects.requireNonNull(states, "states");
this.nanoTime = Objects.requireNonNull(nanoTime, "nanoTime");
this.unlocked = Objects.requireNonNull(unlocked, "unlocked");
states.durableSnapshot().eyes().forEach((id, progress) -> {
if (progress.unlocked()) { announced.add(id); }
});
}
public CompletableFuture<Void> begin(UUID id) {
Objects.requireNonNull(id, "id");
if (active.containsKey(id)) { return checkpoint(id); }
active.put(id, new Interval(nanoTime.getAsLong(), 0));
return CompletableFuture.completedFuture(null);
}
public CompletableFuture<Void> checkpoint(UUID id) {
Interval interval = active.get(id);
if (interval == null) { return CompletableFuture.completedFuture(null); }
// Keep the original start: repeated checkpoints must not discard fractional milliseconds.
long total = Math.max(interval.creditedMillis(), Math.max(0, nanoTime.getAsLong() - interval.startedNanos()) / 1_000_000);
long elapsed = total - interval.creditedMillis();
if (elapsed == 0) { return CompletableFuture.completedFuture(null); }
active.put(id, new Interval(interval.startedNanos(), total));
return states.update(state -> {
EyeProgress previous = state.eyeProgress(id);
long accumulated = Math.addExact(previous.accumulatedMillis(), elapsed);
return state.withEyeProgress(id, new EyeProgress(accumulated,
previous.unlocked() || accumulated >= UNLOCK_THRESHOLD.toMillis(), previous.unknownFields()));
}).thenRun(() -> {
if (isUnlocked(id) && announced.add(id)) { unlocked.accept(id); }
});
}
public CompletableFuture<Void> stop(UUID id) {
CompletableFuture<Void> saved = checkpoint(id);
active.remove(id);
return saved;
}
public CompletableFuture<Void> stopAll() {
return CompletableFuture.allOf(activePlayerIds().stream().map(this::stop).toArray(CompletableFuture[]::new));
}
/** Defensive offline sweep: do not invent credit for the unobserved interval after the last checkpoint. */
public void abandon(UUID id) { active.remove(id); }
public boolean isUnlocked(UUID id) { return states.durableSnapshot().eyeProgress(id).unlocked(); }
public Set<UUID> activePlayerIds() { return Set.copyOf(active.keySet()); }
}
@@ -9,11 +9,18 @@ import java.util.UUID;
public record PersistentStealthState(
Map<UUID, PlayerStealthState> players,
SleepCountPolicy sleepCountPolicy,
Map<String, Object> unknownFields) {
Map<String, Object> unknownFields,
Map<UUID, EyeProgress> eyes) {
public PersistentStealthState {
players = Map.copyOf(new LinkedHashMap<>(Objects.requireNonNull(players, "players")));
Objects.requireNonNull(sleepCountPolicy, "sleepCountPolicy");
unknownFields = Map.copyOf(new LinkedHashMap<>(Objects.requireNonNull(unknownFields, "unknownFields")));
eyes = Map.copyOf(Objects.requireNonNull(eyes, "eyes"));
}
public PersistentStealthState(Map<UUID, PlayerStealthState> players, SleepCountPolicy sleepCountPolicy,
Map<String, Object> unknownFields) {
this(players, sleepCountPolicy, unknownFields, Map.of());
}
public PersistentStealthState(
@@ -22,6 +29,14 @@ public record PersistentStealthState(
this(players, SleepCountPolicy.EXCLUDE, unknownFields);
}
public EyeProgress eyeProgress(UUID playerId) { return eyes.getOrDefault(playerId, EyeProgress.empty()); }
public PersistentStealthState withEyeProgress(UUID playerId, EyeProgress progress) {
Map<UUID, EyeProgress> updated = new LinkedHashMap<>(eyes);
updated.put(playerId, progress);
return new PersistentStealthState(players, sleepCountPolicy, unknownFields, updated);
}
public PlayerStealthState player(UUID playerId) {
return players.getOrDefault(playerId, PlayerStealthState.empty(playerId));
}
@@ -29,10 +44,10 @@ public record PersistentStealthState(
public PersistentStealthState withPlayer(PlayerStealthState player) {
Map<UUID, PlayerStealthState> updated = new LinkedHashMap<>(players);
updated.put(player.playerId(), player);
return new PersistentStealthState(updated, sleepCountPolicy, unknownFields);
return new PersistentStealthState(updated, sleepCountPolicy, unknownFields, eyes);
}
public PersistentStealthState withSleepCountPolicy(SleepCountPolicy policy) {
return new PersistentStealthState(players, policy, unknownFields);
return new PersistentStealthState(players, policy, unknownFields, eyes);
}
}
@@ -12,14 +12,20 @@ import org.bukkit.plugin.java.JavaPlugin;
/** Bukkit entry point for Spigot Stealth. */
public final class SpigotStealthPlugin extends JavaPlugin {
private CompletableFuture<StealthStateManager> stateManagerFuture;
private CompletableFuture<Void> shutdown;
private volatile long lifecycleEpoch;
private StealthSettings settings;
private QualifyingInvisibilityService progression;
private StealthSessionService sessions;
private IdentityPresentation identityPresentation;
private ProtocolManager protocolManager;
private EyeProgressionService eyeProgression;
private EyePotionListener eyePotions;
private boolean eyeRecipeRegistered;
@Override
public void onEnable() {
long epoch = ++lifecycleEpoch;
saveDefaultConfig();
try {
settings = StealthSettings.from(getConfig().getValues(true));
@@ -30,45 +36,73 @@ public final class SpigotStealthPlugin extends JavaPlugin {
}
Path stateFile = getDataFolder().toPath().resolve("state.yml");
stateManagerFuture = StealthStateManager.load(new YamlStealthStateRepository(stateFile));
stateManagerFuture = shutdownCompletion().thenCompose(ignored ->
StealthStateManager.load(new YamlStealthStateRepository(stateFile)));
stateManagerFuture.whenComplete((manager, failure) -> {
if (failure != null) {
getLogger().severe("Unable to load Spigot Stealth state: " + rootMessage(failure));
getServer().getScheduler().runTask(this, () -> getServer().getPluginManager().disablePlugin(this));
onServer(epoch, () -> getServer().getPluginManager().disablePlugin(this));
return;
}
getServer().getScheduler().runTask(this, () -> finishInitialization(manager));
if (!isEnabled() || epoch != lifecycleEpoch) {
logSaveFailure(manager.closeAsync());
return;
}
try {
getServer().getScheduler().runTask(this, () -> {
if (!isEnabled() || epoch != lifecycleEpoch) { logSaveFailure(manager.closeAsync()); }
else { finishInitialization(manager, epoch); }
});
} catch (org.bukkit.plugin.IllegalPluginAccessException ignored) {
logSaveFailure(manager.closeAsync());
}
});
}
@Override
public void onDisable() {
if (protocolManager != null) {
protocolManager.removePacketListeners(this);
}
if (identityPresentation != null && sessions != null) {
for (org.bukkit.entity.Player player : getServer().getOnlinePlayers()) {
if (sessions.isConcealed(player.getUniqueId())) {
identityPresentation.reveal(player);
sessions.endConcealment(player.getUniqueId());
++lifecycleEpoch;
try {
if (eyePotions != null) {
eyePotions.checkpoint(getServer()::getPlayer);
logSaveFailure(eyeProgression.stopAll());
}
if (progression != null) { logSaveFailure(progression.stopAll()); }
if (protocolManager != null) { protocolManager.removePacketListeners(this); }
if (identityPresentation != null && sessions != null) {
for (org.bukkit.entity.Player player : getServer().getOnlinePlayers()) {
if (sessions.isConcealed(player.getUniqueId())) {
try { identityPresentation.reveal(player); }
finally { logSaveFailure(sessions.endConcealment(player.getUniqueId())); }
}
}
}
} finally {
try {
if (eyeRecipeRegistered) {
getServer().removeRecipe(EyeCrafting.RECIPE_KEY);
eyeRecipeRegistered = false;
}
} finally {
if (stateManagerFuture != null) {
shutdown = stateManagerFuture.thenCompose(StealthStateManager::closeAsync);
logSaveFailure(shutdown);
}
}
}
if (progression != null) {
progression.stopAll().join();
}
if (stateManagerFuture != null && stateManagerFuture.isDone() && !stateManagerFuture.isCompletedExceptionally()) {
stateManagerFuture.join().close();
}
}
/** Durable shutdown acknowledgement; never wait for it on the server thread. */
CompletableFuture<Void> shutdownCompletion() {
return shutdown == null ? CompletableFuture.completedFuture(null) : shutdown;
}
public StealthSettings settings() {
return settings;
}
private void finishInitialization(StealthStateManager manager) {
java.util.function.Consumer<Runnable> mainThread =
runnable -> getServer().getScheduler().runTask(this, runnable);
private void finishInitialization(StealthStateManager manager, long epoch) {
java.util.function.Consumer<Runnable> mainThread = runnable -> onServer(epoch, runnable);
BukkitUnlockNotifier notifier = new BukkitUnlockNotifier(
getServer()::getPlayer,
mainThread,
@@ -113,6 +147,36 @@ public final class SpigotStealthPlugin extends JavaPlugin {
settings.unlockThreshold());
stealthAdminPluginCommand.setExecutor(stealthAdminCommand);
stealthAdminPluginCommand.setTabCompleter(stealthAdminCommand);
eyeProgression = new EyeProgressionService(manager, this::monotonicNanos, id -> mainThread.accept(() -> {
var player = getServer().getPlayer(id);
if (player != null) {
player.discoverRecipe(EyeCrafting.RECIPE_KEY);
player.sendMessage("You unlocked the Eye of True Seeing! Craft it with eight Netherite Blocks around an Eye of Ender.");
}
}));
eyePotions = new EyePotionListener(eyeProgression,
failure -> getLogger().severe("Unable to save Eye progression: " + rootMessage(failure)));
var eyeItems = new EyeItems();
var eyeCrafting = new EyeCrafting(eyeProgression, eyeItems);
if (!getServer().addRecipe(eyeCrafting.recipe())) {
getLogger().severe("Unable to register the Eye recipe; disabling Stealth rather than replacing another recipe.");
getServer().getPluginManager().disablePlugin(this);
return;
}
eyeRecipeRegistered = true;
getServer().getPluginManager().registerEvents(eyePotions, this);
getServer().getPluginManager().registerEvents(eyeCrafting, this);
getServer().getPluginManager().registerEvents(new EyeEquipment(eyeProgression, eyeItems), this);
getServer().getScheduler().runTaskTimer(this, ignored -> {
eyePotions.checkpoint(getServer()::getPlayer);
for (var player : getServer().getOnlinePlayers()) {
if (eyeProgression.isUnlocked(player.getUniqueId())) {
if (!player.hasDiscoveredRecipe(EyeCrafting.RECIPE_KEY)) { player.discoverRecipe(EyeCrafting.RECIPE_KEY); }
} else if (player.hasDiscoveredRecipe(EyeCrafting.RECIPE_KEY)) {
player.undiscoverRecipe(EyeCrafting.RECIPE_KEY);
}
}
}, 20L, 20L);
getServer().getScheduler().runTaskTimer(this, ignored -> {
for (java.util.UUID playerId : progression.activePlayerIds()) {
logSaveFailure(progression.checkpoint(playerId));
@@ -127,6 +191,20 @@ public final class SpigotStealthPlugin extends JavaPlugin {
getLogger().info("Spigot Stealth enabled");
}
/** Clock boundary for deterministic gameplay lifecycle verification. */
long monotonicNanos() { return System.nanoTime(); }
private void onServer(long epoch, Runnable action) {
if (!isEnabled() || epoch != lifecycleEpoch) { return; }
try {
getServer().getScheduler().runTask(this, () -> {
if (isEnabled() && epoch == lifecycleEpoch) { action.run(); }
});
} catch (org.bukkit.plugin.IllegalPluginAccessException ignored) {
// A durable unlock remains saved even if disable races its notification.
}
}
private void logSaveFailure(CompletableFuture<Void> save) {
save.exceptionally(failure -> {
getLogger().severe("Unable to save Spigot Stealth state: " + rootMessage(failure));
@@ -14,7 +14,9 @@ import java.util.function.UnaryOperator;
public final class StealthStateManager implements AutoCloseable {
private final StealthStateRepository repository;
private final AtomicReference<PersistentStealthState> state;
private final AtomicReference<PersistentStealthState> durableState;
private final ExecutorService ioExecutor;
private CompletableFuture<Void> closing;
public StealthStateManager(StealthStateRepository repository, PersistentStealthState initialState) {
this(repository, initialState, newIoExecutor());
@@ -26,6 +28,7 @@ public final class StealthStateManager implements AutoCloseable {
ExecutorService ioExecutor) {
this.repository = Objects.requireNonNull(repository, "repository");
this.state = new AtomicReference<>(Objects.requireNonNull(initialState, "initialState"));
this.durableState = new AtomicReference<>(initialState);
this.ioExecutor = ioExecutor;
}
@@ -35,7 +38,7 @@ public final class StealthStateManager implements AutoCloseable {
return CompletableFuture.supplyAsync(() -> {
try {
return new StealthStateManager(repository, repository.load(), executor);
} catch (IOException exception) {
} catch (IOException | RuntimeException exception) {
executor.shutdown();
throw new CompletionException(exception);
}
@@ -46,36 +49,51 @@ public final class StealthStateManager implements AutoCloseable {
return state.get();
}
public CompletableFuture<Void> update(UnaryOperator<PersistentStealthState> operation) {
/** Last successfully written snapshot, for rewards that must not precede persistence acknowledgement. */
public PersistentStealthState durableSnapshot() { return durableState.get(); }
public synchronized CompletableFuture<Void> update(UnaryOperator<PersistentStealthState> operation) {
if (closing != null) { return CompletableFuture.failedFuture(new IllegalStateException("Stealth state is closing")); }
PersistentStealthState updated = state.updateAndGet(operation);
return persist(updated);
}
public CompletableFuture<Void> save() {
return persist(state.get());
public synchronized CompletableFuture<Void> save() {
return closing == null ? persist(state.get()) : closing;
}
private CompletableFuture<Void> persist(PersistentStealthState snapshot) {
return CompletableFuture.runAsync(() -> {
try {
repository.save(snapshot);
durableState.set(snapshot);
} catch (IOException exception) {
throw new CompletionException(exception);
}
}, ioExecutor);
}
/** Queue the final snapshot and stop accepting mutations, without waiting on the caller's thread. */
public synchronized CompletableFuture<Void> closeAsync() {
if (closing == null) {
closing = persist(state.get());
ioExecutor.shutdown();
}
return closing;
}
/** Blocking convenience for non-server callers such as tests; server lifecycle callers should use closeAsync. */
@Override
public void close() {
save().join();
ioExecutor.shutdown();
try {
if (!ioExecutor.awaitTermination(10L, TimeUnit.SECONDS)) {
closeAsync().join();
} finally {
try {
if (!ioExecutor.awaitTermination(10L, TimeUnit.SECONDS)) { ioExecutor.shutdownNow(); }
} catch (InterruptedException exception) {
ioExecutor.shutdownNow();
Thread.currentThread().interrupt();
}
} catch (InterruptedException exception) {
ioExecutor.shutdownNow();
Thread.currentThread().interrupt();
}
}
@@ -17,7 +17,8 @@ import org.bukkit.configuration.file.YamlConfiguration;
/** Defensive YAML repository using atomic file replacement where available. */
public final class YamlStealthStateRepository implements StealthStateRepository {
private static final Set<String> ROOT_FIELDS = Set.of("schema-version", "sleep-count-policy", "players");
private static final Set<String> ROOT_FIELDS = Set.of("schema-version", "sleep-count-policy", "players", "eye-progress");
private static final Set<String> EYE_FIELDS = Set.of("accumulated-millis", "unlocked");
private static final Set<String> PLAYER_FIELDS = Set.of(
"last-known-name", "accumulated-millis", "unlocked", "prepared-login", "concealed", "qualifying-since");
private final Path stateFile;
@@ -56,14 +57,15 @@ public final class YamlStealthStateRepository implements StealthStateRepository
return new PersistentStealthState(
players,
SleepCountPolicy.fromPersisted(yaml.get("sleep-count-policy")),
unknownRoot);
unknownRoot,
parseEyes(yaml.getConfigurationSection("eye-progress")));
}
@Override
public void save(PersistentStealthState state) throws IOException {
YamlConfiguration yaml = new YamlConfiguration();
state.unknownFields().forEach(yaml::set);
yaml.set("schema-version", 1);
yaml.set("schema-version", 2);
yaml.set("sleep-count-policy", state.sleepCountPolicy().persistedValue());
for (PlayerStealthState player : state.players().values()) {
String base = "players." + player.playerId() + ".";
@@ -75,6 +77,12 @@ public final class YamlStealthStateRepository implements StealthStateRepository
yaml.set(base + "concealed", player.concealed());
yaml.set(base + "qualifying-since", player.qualifyingSince() == null ? null : player.qualifyingSince().toString());
}
state.eyes().forEach((id, progress) -> {
String base = "eye-progress." + id + ".";
progress.unknownFields().forEach((key, value) -> yaml.set(base + key, value));
yaml.set(base + "accumulated-millis", progress.accumulatedMillis());
yaml.set(base + "unlocked", progress.unlocked());
});
Path parent = stateFile.toAbsolutePath().getParent();
if (parent != null) {
Files.createDirectories(parent);
@@ -88,6 +96,27 @@ public final class YamlStealthStateRepository implements StealthStateRepository
}
}
private static Map<UUID, EyeProgress> parseEyes(ConfigurationSection section) {
Map<UUID, EyeProgress> eyes = new LinkedHashMap<>();
if (section == null) { return eyes; }
for (String key : section.getKeys(false)) {
try {
UUID id = UUID.fromString(key);
ConfigurationSection entry = section.getConfigurationSection(key);
if (entry == null) { continue; }
Object raw = entry.get("accumulated-millis");
if (!(raw instanceof Long || raw instanceof Integer || raw instanceof Short || raw instanceof Byte)) {
continue;
}
eyes.put(id, new EyeProgress(requireNonNegativeLong(entry, "accumulated-millis"),
requireBoolean(entry, "unlocked"), unknownValues(entry, EYE_FIELDS)));
} catch (IllegalArgumentException ignored) {
// A malformed Eye record cannot grant an unlock or erase independent Stealth records.
}
}
return eyes;
}
private static PlayerStealthState parsePlayer(UUID playerId, ConfigurationSection section) {
long accumulated = requireNonNegativeLong(section, "accumulated-millis");
boolean unlocked = requireBoolean(section, "unlocked");
@@ -0,0 +1,215 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.nio.file.Path;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.event.inventory.PrepareItemCraftEvent;
import org.bukkit.inventory.CraftingInventory;
import org.bukkit.inventory.InventoryView;
import org.bukkit.inventory.ItemStack;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class EyeCraftingTest {
@TempDir Path directory;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test
void eightNetheriteBlocksAroundAnEnderEyeCanOnlyBeCraftedAfterThePersonalUnlock() throws Exception {
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
UUID id = UUID.randomUUID();
try (var states = new StealthStateManager(repository, repository.load())) {
var progression = new EyeProgressionService(states, () -> 0L, ignored -> { });
var items = new EyeItems();
var crafting = new EyeCrafting(progression, items);
var recipe = crafting.recipe();
assertArrayEquals(new String[] {"NNN", "NEN", "NNN"}, recipe.getShape());
assertEquals(Material.NETHERITE_BLOCK, recipe.getIngredientMap().get('N').getType());
assertEquals(Material.ENDER_EYE, recipe.getIngredientMap().get('E').getType());
assertTrue(items.isEye(recipe.getResult()));
var player = mock(Player.class);
when(player.getUniqueId()).thenReturn(id);
var inventory = mock(CraftingInventory.class);
when(inventory.getRecipe()).thenReturn(recipe);
when(inventory.getMatrix()).thenReturn(matrix());
var result = new AtomicReference<>(recipe.getResult());
when(inventory.getResult()).thenAnswer(ignored -> result.get());
doAnswer(call -> { result.set(call.getArgument(0)); return null; }).when(inventory).setResult(any());
var view = mock(InventoryView.class);
when(view.getPlayer()).thenReturn(player);
when(view.getTopInventory()).thenReturn(inventory);
var event = new PrepareItemCraftEvent(inventory, view, false);
crafting.onPrepare(event);
assertNull(result.get(), "a locked player cannot obtain the recipe result");
states.update(state -> state.withEyeProgress(id, new EyeProgress(28_800_000, true))).get(3, TimeUnit.SECONDS);
result.set(recipe.getResult()); // A fresh native recipe preparation after earning the unlock.
crafting.onPrepare(event);
assertTrue(items.isEye(result.get()));
assertEquals(1, result.get().getAmount());
verify(inventory, never()).setMatrix(any()); // Vanilla owns all ingredient consumption.
}
}
@Test
void resultClicksRecheckEligibilityIngredientsAndIdentityWithoutBypassingCancellation() throws Exception {
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
UUID id = UUID.randomUUID();
try (var states = new StealthStateManager(repository, repository.load())) {
var progression = new EyeProgressionService(states, () -> 0L, ignored -> { });
var items = new EyeItems();
var crafting = new EyeCrafting(progression, items);
var recipe = crafting.recipe();
var player = mock(Player.class);
when(player.getUniqueId()).thenReturn(id);
var inventory = mock(CraftingInventory.class);
when(inventory.getRecipe()).thenReturn(recipe);
ItemStack[] ingredients = matrix();
when(inventory.getMatrix()).thenReturn(ingredients);
var result = new AtomicReference<>(recipe.getResult());
when(inventory.getResult()).thenAnswer(ignored -> result.get());
doAnswer(call -> { result.set(call.getArgument(0)); return null; }).when(inventory).setResult(any());
var view = mock(InventoryView.class);
when(view.getPlayer()).thenReturn(player);
when(view.getTopInventory()).thenReturn(inventory);
var locked = click(recipe, view);
crafting.onCraft(locked);
assertTrue(locked.isCancelled(), "a stale preview must not bypass the personal unlock on shift-click");
assertNull(result.get());
states.update(state -> state.withEyeProgress(id, new EyeProgress(28_800_000, true))).get(3, TimeUnit.SECONDS);
result.set(recipe.getResult());
var allowed = click(recipe, view);
crafting.onCraft(allowed);
assertFalse(allowed.isCancelled());
assertTrue(items.isEye(result.get()));
ingredients[0] = new ItemStack(Material.DIRT);
var invalidIngredients = click(recipe, view);
crafting.onCraft(invalidIngredients);
assertTrue(invalidIngredients.isCancelled());
assertNull(result.get());
ingredients[0] = new ItemStack(Material.NETHERITE_BLOCK);
result.set(new ItemStack(Material.ENDER_EYE));
var invalidResult = click(recipe, view);
crafting.onCraft(invalidResult);
assertTrue(invalidResult.isCancelled());
result.set(recipe.getResult());
result.get().setAmount(2);
var inflatedResult = click(recipe, view);
crafting.onCraft(inflatedResult);
assertTrue(inflatedResult.isCancelled());
result.set(recipe.getResult());
var cancelled = click(recipe, view);
cancelled.setCancelled(true);
crafting.onCraft(cancelled);
assertTrue(cancelled.isCancelled());
var otherRecipe = new org.bukkit.inventory.ShapedRecipe(new org.bukkit.NamespacedKey("other", "eye"),
new ItemStack(Material.ENDER_EYE)).shape("E").setIngredient('E', Material.ENDER_EYE);
var unrelated = click(otherRecipe, view);
crafting.onCraft(unrelated);
assertFalse(unrelated.isCancelled());
verify(inventory, never()).setMatrix(any());
}
}
@Test
void pendingOrFailedUnlockWritesCannotExposeACraftableResult() throws Exception {
var disk = new YamlStealthStateRepository(directory.resolve("state.yml"));
var started = new java.util.concurrent.CountDownLatch(1);
var release = new java.util.concurrent.CountDownLatch(1);
var firstWrite = new java.util.concurrent.atomic.AtomicBoolean(true);
StealthStateRepository repository = new StealthStateRepository() {
@Override public PersistentStealthState load() throws java.io.IOException { return disk.load(); }
@Override public void save(PersistentStealthState state) throws java.io.IOException {
if (firstWrite.getAndSet(false)) {
started.countDown();
try {
if (!release.await(3, TimeUnit.SECONDS)) { throw new java.io.IOException("Test write gate timed out"); }
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new java.io.IOException(exception);
}
throw new java.io.IOException("Injected write failure");
}
disk.save(state);
}
};
UUID id = UUID.randomUUID();
try (var states = new StealthStateManager(repository, repository.load())) {
try {
var items = new EyeItems();
var crafting = new EyeCrafting(new EyeProgressionService(states, () -> 0L, ignored -> { }), items);
var recipe = crafting.recipe();
var player = mock(Player.class);
when(player.getUniqueId()).thenReturn(id);
var inventory = mock(CraftingInventory.class);
when(inventory.getRecipe()).thenReturn(recipe);
when(inventory.getMatrix()).thenReturn(matrix());
var result = new AtomicReference<>(recipe.getResult());
when(inventory.getResult()).thenAnswer(ignored -> result.get());
doAnswer(call -> { result.set(call.getArgument(0)); return null; }).when(inventory).setResult(any());
var view = mock(InventoryView.class);
when(view.getPlayer()).thenReturn(player);
when(view.getTopInventory()).thenReturn(inventory);
var event = new PrepareItemCraftEvent(inventory, view, false);
var pending = states.update(state -> state.withEyeProgress(id, new EyeProgress(28_800_000, true)));
assertTrue(started.await(3, TimeUnit.SECONDS));
assertTrue(states.snapshot().eyeProgress(id).unlocked());
crafting.onPrepare(event);
assertNull(result.get(), "in-memory progress is not a durable crafting unlock");
release.countDown();
assertThrows(java.util.concurrent.ExecutionException.class, () -> pending.get(3, TimeUnit.SECONDS));
result.set(recipe.getResult());
crafting.onPrepare(event);
assertNull(result.get(), "failed writes must not grant the recipe");
var denied = click(recipe, view);
crafting.onCraft(denied);
assertTrue(denied.isCancelled());
states.save().get(3, TimeUnit.SECONDS);
result.set(recipe.getResult());
crafting.onPrepare(event);
assertTrue(items.isEye(result.get()));
var allowed = click(recipe, view);
crafting.onCraft(allowed);
assertFalse(allowed.isCancelled());
} finally { release.countDown(); }
}
}
@Test
void automatedCraftersCannotBypassPersonalProgression() throws Exception {
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
try (var states = new StealthStateManager(repository, repository.load())) {
var items = new EyeItems();
var crafting = new EyeCrafting(new EyeProgressionService(states, () -> 0L, ignored -> { }), items);
var block = mock(org.bukkit.block.Block.class);
var event = new org.bukkit.event.block.CrafterCraftEvent(block, crafting.recipe(), items.create());
crafting.onCrafter(event);
assertTrue(event.isCancelled(), "a block has no personal Eye unlock");
var other = new org.bukkit.inventory.ShapedRecipe(new org.bukkit.NamespacedKey("other", "eye"),
new ItemStack(Material.ENDER_EYE)).shape("E").setIngredient('E', Material.ENDER_EYE);
var unrelated = new org.bukkit.event.block.CrafterCraftEvent(block, other, other.getResult());
crafting.onCrafter(unrelated);
assertFalse(unrelated.isCancelled());
}
}
private static org.bukkit.event.inventory.CraftItemEvent click(org.bukkit.inventory.Recipe recipe, InventoryView view) {
return new org.bukkit.event.inventory.CraftItemEvent(recipe, view,
org.bukkit.event.inventory.InventoryType.SlotType.RESULT, 0,
org.bukkit.event.inventory.ClickType.SHIFT_LEFT, org.bukkit.event.inventory.InventoryAction.MOVE_TO_OTHER_INVENTORY);
}
private static ItemStack[] matrix() {
var result = new ItemStack[9];
for (int index = 0; index < result.length; index++) {
result[index] = new ItemStack(index == 4 ? Material.ENDER_EYE : Material.NETHERITE_BLOCK);
}
return result;
}
}
@@ -0,0 +1,92 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.nio.file.Path;
import java.util.EnumMap;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import org.bukkit.Material;
import org.bukkit.block.BlockFace;
import org.bukkit.entity.Player;
import org.bukkit.event.Event;
import org.bukkit.event.block.Action;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class EyeEquipmentTest {
@TempDir Path directory;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test
void tradedEyesRequireTheHoldersOwnUnlockAndEquipWithoutThrowingOrReplacingAHelmet() throws Exception {
UUID earner = UUID.randomUUID(), holder = UUID.randomUUID();
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
try (var states = new StealthStateManager(repository, repository.load())) {
states.update(state -> state.withEyeProgress(earner, new EyeProgress(28_800_000, true))).get(3, TimeUnit.SECONDS);
var items = new EyeItems();
var equipment = new EyeEquipment(new EyeProgressionService(states, () -> 0L, ignored -> { }), items);
var player = mock(Player.class);
when(player.getUniqueId()).thenReturn(holder);
var inventory = mock(PlayerInventory.class);
when(player.getInventory()).thenReturn(inventory);
var helmet = new AtomicReference<ItemStack>();
when(inventory.getHelmet()).thenAnswer(ignored -> helmet.get());
doAnswer(call -> { helmet.set(call.getArgument(0)); return null; }).when(inventory).setHelmet(any());
var hands = new EnumMap<EquipmentSlot, ItemStack>(EquipmentSlot.class);
when(inventory.getItem(any(EquipmentSlot.class))).thenAnswer(call -> hands.get(call.getArgument(0)));
doAnswer(call -> { hands.put(call.getArgument(0), call.getArgument(1)); return null; })
.when(inventory).setItem(any(EquipmentSlot.class), nullable(ItemStack.class));
ItemStack traded = items.create();
hands.put(EquipmentSlot.HAND, traded);
helmet.set(traded.clone());
assertFalse(equipment.isEligibleWearer(player), "equipping somebody else's Eye must not bypass the personal unlock");
helmet.set(null);
var denied = use(player, traded, EquipmentSlot.HAND);
equipment.onUse(denied);
assertEquals(Event.Result.DENY, denied.useItemInHand(), "custom Eyes must never fall through to vanilla Ender Eye use");
assertNull(helmet.get());
assertSame(traded, hands.get(EquipmentSlot.HAND));
states.update(state -> state.withEyeProgress(holder, new EyeProgress(28_800_000, true))).get(3, TimeUnit.SECONDS);
var allowed = use(player, traded, EquipmentSlot.HAND);
equipment.onUse(allowed);
assertEquals(Event.Result.DENY, allowed.useItemInHand());
assertNull(hands.get(EquipmentSlot.HAND));
assertTrue(items.isEye(helmet.get()));
assertEquals(1, helmet.get().getAmount());
assertTrue(equipment.isEligibleWearer(player));
var ordinaryHelmet = new ItemStack(Material.IRON_HELMET);
helmet.set(ordinaryHelmet);
hands.put(EquipmentSlot.OFF_HAND, items.create());
equipment.onUse(use(player, hands.get(EquipmentSlot.OFF_HAND), EquipmentSlot.OFF_HAND));
assertSame(ordinaryHelmet, helmet.get());
assertTrue(items.isEye(hands.get(EquipmentSlot.OFF_HAND)));
assertFalse(equipment.isEligibleWearer(player), "holding an Eye is not wearing it");
helmet.set(null);
equipment.onUse(use(player, hands.get(EquipmentSlot.OFF_HAND), EquipmentSlot.OFF_HAND));
assertNull(hands.get(EquipmentSlot.OFF_HAND));
assertTrue(equipment.isEligibleWearer(player));
helmet.set(null);
hands.put(EquipmentSlot.HAND, items.create());
var cancelled = use(player, hands.get(EquipmentSlot.HAND), EquipmentSlot.HAND);
cancelled.setUseItemInHand(Event.Result.DENY);
equipment.onUse(cancelled);
assertNull(helmet.get());
assertTrue(items.isEye(hands.get(EquipmentSlot.HAND)));
var ordinary = use(player, new ItemStack(Material.ENDER_EYE), EquipmentSlot.HAND);
equipment.onUse(ordinary);
assertEquals(Event.Result.DEFAULT, ordinary.useItemInHand());
}
}
private static PlayerInteractEvent use(Player player, ItemStack item, EquipmentSlot hand) {
return new PlayerInteractEvent(player, Action.RIGHT_CLICK_AIR, item, null, BlockFace.SELF, hand);
}
}
@@ -0,0 +1,39 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import net.kyori.adventure.text.Component;
import org.bukkit.Material;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.ItemStack;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
class EyeItemsTest {
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test
void createdEyeIsAnAuthenticatedHelmetItemButPlainAndRenamedEyesAreNot() {
var items = new EyeItems();
ItemStack eye = items.create();
assertEquals(Material.ENDER_EYE, eye.getType());
assertEquals(Component.text("Eye of True Seeing"), eye.getItemMeta().displayName());
assertTrue(items.isEye(eye));
assertTrue(items.isEye(eye.clone()), "ordinary inventory transfer must retain item identity");
assertEquals(EquipmentSlot.HEAD, eye.getItemMeta().getEquippable().getSlot());
assertEquals(1, eye.getMaxStackSize());
assertFalse(eye.getItemMeta().getEquippable().isDispensable());
assertFalse(eye.getItemMeta().getEquippable().isEquipOnInteract(),
"the eligibility-checked interaction handler, not native item use, equips the Eye");
var ordinary = new ItemStack(Material.ENDER_EYE);
assertFalse(items.isEye(ordinary));
var renamed = ordinary.getItemMeta();
renamed.displayName(eye.getItemMeta().displayName());
ordinary.setItemMeta(renamed);
assertFalse(items.isEye(ordinary), "anvil/display names cannot authenticate an Eye");
var wrongMaterial = eye.clone();
wrongMaterial.setType(Material.STONE);
assertFalse(items.isEye(wrongMaterial));
assertFalse(items.isEye(null));
}
}
@@ -0,0 +1,216 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import com.comphenix.protocol.ProtocolLibrary;
import com.comphenix.protocol.ProtocolManager;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import java.util.logging.Logger;
import org.bukkit.Server;
import org.bukkit.command.PluginCommand;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.entity.Player;
import org.bukkit.event.Event;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.inventory.PrepareItemCraftEvent;
import org.bukkit.inventory.CraftingInventory;
import org.bukkit.inventory.InventoryView;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.Recipe;
import org.bukkit.plugin.EventExecutor;
import org.bukkit.plugin.PluginDescriptionFile;
import org.bukkit.plugin.PluginManager;
import org.bukkit.plugin.RegisteredListener;
import org.bukkit.scheduler.BukkitScheduler;
import org.bukkit.scheduler.BukkitTask;
import org.bukkit.scoreboard.Scoreboard;
import org.bukkit.scoreboard.ScoreboardManager;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class EyePluginLifecycleTest {
@TempDir Path directory;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"normal", "disabled", "reenabled", "presentation-failure"})
@SuppressWarnings("try") // The scoped Bukkit boundary delegates non-server-metadata operations to native code.
void startupAndLateCallbacksRespectSavedUnlocksAndDisable(String mode) throws Exception {
UUID id = UUID.randomUUID(), learner = UUID.randomUUID();
var disk = new YamlStealthStateRepository(directory.resolve("state.yml"));
disk.save(new PersistentStealthState(Map.of(), Map.of()).withEyeProgress(id, new EyeProgress(28_800_000, true)));
var plugin = mock(SpigotStealthPlugin.class, CALLS_REAL_METHODS);
var clock = new java.util.concurrent.atomic.AtomicLong();
doAnswer(ignored -> clock.get()).when(plugin).monotonicNanos();
Server server = mock(org.bukkit.craftbukkit.CraftServer.class);
var plugins = mock(PluginManager.class);
BukkitScheduler scheduler = mock(org.bukkit.craftbukkit.scheduler.CraftScheduler.class);
var player = mock(Player.class);
when(player.getUniqueId()).thenReturn(id);
doReturn(List.of(player)).when(server).getOnlinePlayers();
when(server.getPlayer(id)).thenReturn(player);
doReturn(server).when(plugin).getServer();
doReturn(true).when(plugin).isEnabled();
doReturn(directory.toFile()).when(plugin).getDataFolder();
doReturn(new YamlConfiguration()).when(plugin).getConfig();
doReturn(Logger.getAnonymousLogger()).when(plugin).getLogger();
doReturn(new PluginDescriptionFile("SpigotStealth", "test", SpigotStealthPlugin.class.getName()))
.when(plugin).getDescription();
doNothing().when(plugin).saveDefaultConfig();
doReturn(mock(PluginCommand.class)).when(plugin).getCommand("stealth");
doReturn(mock(PluginCommand.class)).when(plugin).getCommand("stealthadmin");
when(server.getPluginManager()).thenReturn(plugins);
when(server.getScheduler()).thenReturn(scheduler);
ScoreboardManager boards = mock(org.bukkit.craftbukkit.scoreboard.CraftScoreboardManager.class);
when(server.getScoreboardManager()).thenReturn(boards);
when(boards.getMainScoreboard()).thenReturn(mock(org.bukkit.craftbukkit.scoreboard.CraftScoreboard.class));
var main = new LinkedBlockingQueue<Runnable>();
when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenAnswer(call -> {
main.add(call.getArgument(1)); return mock(BukkitTask.class);
});
var timers = new ArrayList<Runnable>();
doAnswer(call -> {
Consumer<BukkitTask> action = call.getArgument(1);
timers.add(() -> action.accept(mock(BukkitTask.class)));
return null;
}).when(scheduler).runTaskTimer(eq(plugin), org.mockito.ArgumentMatchers.<Consumer<BukkitTask>>any(), anyLong(), anyLong());
when(scheduler.runTaskTimer(eq(plugin), any(Runnable.class), anyLong(), anyLong())).thenAnswer(call -> {
timers.add(call.getArgument(1)); return mock(BukkitTask.class);
});
var recipe = new AtomicReference<Recipe>();
when(server.addRecipe(any(Recipe.class))).thenAnswer(call -> { recipe.set(call.getArgument(0)); return true; });
var listeners = new ArrayList<Listener>();
doAnswer(call -> { listeners.add(call.getArgument(0)); return null; }).when(plugins).registerEvents(any(), eq(plugin));
try (var platform = mockStatic(org.bukkit.Bukkit.class, call -> switch (call.getMethod().getName()) {
case "getServer" -> server;
case "getVersion" -> "Purpur 2618 (MC: 26.2)";
case "getMinecraftVersion" -> "26.2";
case "getBukkitVersion" -> "26.2-R0.1-SNAPSHOT";
default -> call.callRealMethod();
}); var protocol = mockStatic(ProtocolLibrary.class)) {
var protocolManager = mock(ProtocolManager.class);
protocol.when(ProtocolLibrary::getProtocolManager).thenReturn(protocolManager);
try {
plugin.onEnable();
assertNull(recipe.get(), "Bukkit registration must wait for durable state initialization and main-thread dispatch");
Runnable initialization = main.poll(3, TimeUnit.SECONDS);
assertNotNull(initialization);
if (mode.equals("disabled") || mode.equals("reenabled")) {
doReturn(false).when(plugin).isEnabled();
plugin.onDisable();
if (mode.equals("reenabled")) {
doReturn(true).when(plugin).isEnabled();
plugin.onEnable();
Runnable currentInitialization = main.poll(3, TimeUnit.SECONDS);
assertNotNull(currentInitialization);
currentInitialization.run();
int registeredCount = listeners.size();
assertNotNull(recipe.get());
initialization.run();
assertEquals(registeredCount, listeners.size(), "an old lifecycle callback must not touch a re-enabled plugin");
verify(server, times(1)).addRecipe(any(Recipe.class));
return;
}
initialization.run();
assertTrue(listeners.isEmpty(), "a queued initialization must not register listeners after disable");
assertNull(recipe.get());
verify(server, never()).removeRecipe(any());
return;
}
initialization.run();
assertNotNull(recipe.get(), "plugin startup must register the Eye recipe");
assertTrue(new EyeItems().isEye(recipe.get().getResult()));
assertTrue(listeners.stream().anyMatch(EyePotionListener.class::isInstance));
assertTrue(listeners.stream().anyMatch(EyeEquipment.class::isInstance));
var inventory = mock(CraftingInventory.class);
when(inventory.getRecipe()).thenAnswer(ignored -> recipe.get());
var result = new AtomicReference<>(recipe.get().getResult());
when(inventory.getResult()).thenAnswer(ignored -> result.get());
doAnswer(call -> { result.set(call.getArgument(0)); return null; }).when(inventory).setResult(any());
var view = mock(InventoryView.class);
when(view.getPlayer()).thenReturn(player);
when(view.getTopInventory()).thenReturn(inventory);
var prepare = new PrepareItemCraftEvent(inventory, view, false);
dispatch(plugin, listeners, prepare);
assertTrue(new EyeItems().isEye(result.get()), "the restored personal unlock must permit crafting");
when(player.getUniqueId()).thenReturn(learner);
when(server.getPlayer(learner)).thenReturn(player);
dispatch(plugin, listeners, prepare);
assertNull(result.get(), "registered handlers must deny an unearned player's result");
var effect = new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.NIGHT_VISION, 9600, 0);
dispatch(plugin, listeners, new org.bukkit.event.entity.EntityPotionEffectEvent(player, null, effect, player,
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.POTION_DRINK,
org.bukkit.event.entity.EntityPotionEffectEvent.Action.ADDED, false));
clock.set(java.time.Duration.ofHours(8).toNanos());
dispatch(plugin, listeners, new org.bukkit.event.entity.EntityPotionEffectEvent(player, effect, null, null,
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.EXPIRATION,
org.bukkit.event.entity.EntityPotionEffectEvent.Action.REMOVED, false));
Runnable unlocked = main.poll(3, TimeUnit.SECONDS);
assertNotNull(unlocked, "the registered potion listener must persist the unlock and schedule its notification");
assertTrue(disk.load().eyeProgress(learner).unlocked());
unlocked.run();
result.set(recipe.get().getResult());
dispatch(plugin, listeners, prepare);
assertTrue(new EyeItems().isEye(result.get()), "the registered crafting listener must see the newly saved unlock");
clearInvocations(player);
when(player.getUniqueId()).thenReturn(id);
timers.forEach(Runnable::run);
verify(player).discoverRecipe(EyeCrafting.RECIPE_KEY);
// Disable must checkpoint and drain an in-flight qualifying interval, not just remove the recipe.
when(player.getUniqueId()).thenReturn(learner);
when(player.isOnline()).thenReturn(true);
when(player.getPotionEffect(org.bukkit.potion.PotionEffectType.NIGHT_VISION)).thenReturn(effect);
dispatch(plugin, listeners, new org.bukkit.event.entity.EntityPotionEffectEvent(player, null, effect, player,
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.POTION_DRINK,
org.bukkit.event.entity.EntityPotionEffectEvent.Action.ADDED, false));
clock.addAndGet(1_234_000_000L);
} finally {
if (mode.equals("presentation-failure")) {
doThrow(new IllegalStateException("Injected presentation cleanup failure"))
.when(protocolManager).removePacketListeners(plugin);
try {
assertThrows(IllegalStateException.class, plugin::onDisable);
plugin.shutdownCompletion().get(3, TimeUnit.SECONDS);
assertEquals(28_801_234, disk.load().eyeProgress(learner).accumulatedMillis(),
"presentation cleanup failure must not prevent Eye progress from draining");
} finally {
doNothing().when(protocolManager).removePacketListeners(plugin);
plugin.onDisable();
plugin.shutdownCompletion().get(3, TimeUnit.SECONDS);
}
} else {
plugin.onDisable();
plugin.shutdownCompletion().get(3, TimeUnit.SECONDS);
}
}
}
verify(server).removeRecipe(EyeCrafting.RECIPE_KEY);
assertEquals(28_801_234, disk.load().eyeProgress(learner).accumulatedMillis());
}
private static void dispatch(SpigotStealthPlugin plugin, List<Listener> listeners, Event event) throws Exception {
var registered = new ArrayList<RegisteredListener>();
for (var listener : listeners) {
for (var method : listener.getClass().getMethods()) {
var handler = method.getAnnotation(EventHandler.class);
if (handler == null || method.getParameterCount() != 1 || !method.getParameterTypes()[0].isInstance(event)) { continue; }
registered.add(new RegisteredListener(listener,
EventExecutor.create(method, method.getParameterTypes()[0].asSubclass(Event.class)),
handler.priority(), plugin, handler.ignoreCancelled()));
}
}
registered.sort(java.util.Comparator.comparing(RegisteredListener::getPriority));
for (var handler : registered) { handler.callEvent(event); }
}
}
@@ -0,0 +1,302 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.nio.file.Path;
import java.time.Duration;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import org.bukkit.entity.Player;
import org.bukkit.event.entity.EntityPotionEffectEvent;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Real Purpur effect/event objects, real progression/persistence, only player and clock are external doubles. */
class EyePotionProgressionTest {
@TempDir Path directory;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test
void aTransitionBeforeTheNextObservationCannotCreditAnUnattributedRestoredEffect() throws Exception {
for (String transition : java.util.List.of("quit", "remove", "refresh")) {
UUID id = UUID.randomUUID();
var player = mock(Player.class);
when(player.getUniqueId()).thenReturn(id);
when(player.isOnline()).thenReturn(true);
var clock = new AtomicLong();
var repository = new YamlStealthStateRepository(directory.resolve(transition + ".yml"));
try (var states = new StealthStateManager(repository, repository.load())) {
var progression = new EyeProgressionService(states, clock::get, ignored -> { });
var listener = new EyePotionListener(progression, failure -> fail(failure));
var strong = new PotionEffect(PotionEffectType.NIGHT_VISION, 100, 1);
var restored = new PotionEffect(PotionEffectType.NIGHT_VISION, 400, 0);
listener.onPotionEffect(new EntityPotionEffectEvent(player, null, strong, player,
EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.ADDED, false));
when(player.getPotionEffect(PotionEffectType.NIGHT_VISION)).thenReturn(strong);
clock.set(Duration.ofSeconds(1).toNanos());
listener.checkpoint(ignored -> player);
clock.set(Duration.ofSeconds(3).toNanos());
when(player.getPotionEffect(PotionEffectType.NIGHT_VISION)).thenReturn(restored);
switch (transition) {
case "quit" -> listener.onQuit(new org.bukkit.event.player.PlayerQuitEvent(
player, net.kyori.adventure.text.Component.empty()));
case "remove" -> listener.onPotionEffect(new EntityPotionEffectEvent(player, restored, null, null,
EntityPotionEffectEvent.Cause.EXPIRATION, EntityPotionEffectEvent.Action.REMOVED, false));
case "refresh" -> listener.onPotionEffect(new EntityPotionEffectEvent(player, restored,
new PotionEffect(PotionEffectType.NIGHT_VISION, 800, 0), player,
EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.CHANGED, true));
default -> throw new AssertionError(transition);
}
states.save().get(3, TimeUnit.SECONDS);
assertEquals(1000, repository.load().eyeProgress(id).accumulatedMillis(), transition);
}
}
}
@Test
void periodicObservationStopsAtUnattributedHiddenEffectRestorationOrMissingPlayer() throws Exception {
UUID id = UUID.randomUUID();
var player = mock(Player.class);
when(player.getUniqueId()).thenReturn(id);
when(player.isOnline()).thenReturn(true);
var clock = new AtomicLong();
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
try (var states = new StealthStateManager(repository, repository.load())) {
var progression = new EyeProgressionService(states, clock::get, ignored -> { });
var listener = new EyePotionListener(progression, failure -> fail(failure));
var strong = new PotionEffect(PotionEffectType.NIGHT_VISION, 100, 1);
listener.onPotionEffect(new EntityPotionEffectEvent(player, null, strong, player,
EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.ADDED, false));
when(player.getPotionEffect(PotionEffectType.NIGHT_VISION)).thenReturn(
new PotionEffect(PotionEffectType.NIGHT_VISION, 80, 1));
clock.set(Duration.ofSeconds(1).toNanos());
listener.checkpoint(ignored -> player);
states.save().get(3, TimeUnit.SECONDS);
assertEquals(1000, repository.load().eyeProgress(id).accumulatedMillis());
// Native hidden-effect promotion need not emit a new potion-source event.
when(player.getPotionEffect(PotionEffectType.NIGHT_VISION)).thenReturn(
new PotionEffect(PotionEffectType.NIGHT_VISION, 400, 0));
clock.set(Duration.ofSeconds(2).toNanos());
listener.checkpoint(ignored -> player);
clock.set(Duration.ofHours(12).toNanos());
listener.checkpoint(ignored -> player);
states.save().get(3, TimeUnit.SECONDS);
assertEquals(1000, repository.load().eyeProgress(id).accumulatedMillis(),
"unattributed restored effects must not inherit the drink timer");
assertTrue(progression.activePlayerIds().isEmpty());
listener.onPotionEffect(new EntityPotionEffectEvent(player, null, strong, player,
EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.ADDED, false));
clock.addAndGet(Duration.ofDays(1).toNanos());
listener.checkpoint(ignored -> null);
states.save().get(3, TimeUnit.SECONDS);
assertEquals(1000, repository.load().eyeProgress(id).accumulatedMillis());
assertTrue(progression.activePlayerIds().isEmpty());
}
}
@Test
void disconnectClosesTheIntervalAndRestartDoesNotCountTheOfflineGap() throws Exception {
UUID id = UUID.randomUUID();
var player = mock(Player.class);
when(player.getUniqueId()).thenReturn(id);
var clock = new AtomicLong();
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
try (var states = new StealthStateManager(repository, repository.load())) {
var progression = new EyeProgressionService(states, clock::get, ignored -> { });
var listener = new EyePotionListener(progression, failure -> fail(failure));
var effect = new PotionEffect(PotionEffectType.NIGHT_VISION, 200, 0);
listener.onPotionEffect(new EntityPotionEffectEvent(player, null, effect, player,
EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.ADDED, false));
clock.set(Duration.ofSeconds(1).toNanos());
when(player.getPotionEffect(PotionEffectType.NIGHT_VISION)).thenReturn(effect);
listener.onQuit(new org.bukkit.event.player.PlayerQuitEvent(player, net.kyori.adventure.text.Component.empty()));
clock.addAndGet(Duration.ofDays(7).toNanos());
progression.checkpoint(id).get(3, TimeUnit.SECONDS);
states.save().get(3, TimeUnit.SECONDS);
assertEquals(1000, repository.load().eyeProgress(id).accumulatedMillis(),
"disconnect must close the qualifying interval before offline time passes");
assertFalse(progression.isUnlocked(id));
}
try (var restarted = new StealthStateManager(repository, repository.load())) {
var progression = new EyeProgressionService(restarted, clock::get, ignored -> { });
assertEquals(1000, restarted.snapshot().eyeProgress(id).accumulatedMillis());
assertTrue(progression.activePlayerIds().isEmpty());
assertFalse(progression.isUnlocked(id));
}
}
@Test
void everyNonDrinkSourceIsExcludedAndAnEffectiveExternalReplacementStopsCredit() throws Exception {
UUID id = UUID.randomUUID();
var player = mock(Player.class);
when(player.getUniqueId()).thenReturn(id);
var clock = new AtomicLong();
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
try (var states = new StealthStateManager(repository, repository.load())) {
var progression = new EyeProgressionService(states, clock::get, ignored -> { });
var listener = new EyePotionListener(progression, failure -> fail(failure));
var effect = new PotionEffect(PotionEffectType.NIGHT_VISION, 200, 0);
for (var cause : EntityPotionEffectEvent.Cause.values()) {
if (cause == EntityPotionEffectEvent.Cause.POTION_DRINK) { continue; }
listener.onPotionEffect(new EntityPotionEffectEvent(player, null, effect, null,
cause, EntityPotionEffectEvent.Action.ADDED, false));
clock.addAndGet(Duration.ofSeconds(1).toNanos());
progression.checkpoint(id).get(3, TimeUnit.SECONDS);
assertEquals(0, states.snapshot().eyeProgress(id).accumulatedMillis(), cause.toString());
listener.onPotionEffect(new EntityPotionEffectEvent(player, effect, null, null,
EntityPotionEffectEvent.Cause.EXPIRATION, EntityPotionEffectEvent.Action.REMOVED, false));
}
listener.onPotionEffect(new EntityPotionEffectEvent(player, null, effect, player,
EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.ADDED, false));
clock.addAndGet(Duration.ofSeconds(1).toNanos());
listener.onPotionEffect(new EntityPotionEffectEvent(player, effect,
new PotionEffect(PotionEffectType.NIGHT_VISION, 400, 0), null,
EntityPotionEffectEvent.Cause.PLUGIN, EntityPotionEffectEvent.Action.CHANGED, true));
clock.addAndGet(Duration.ofHours(12).toNanos());
progression.checkpoint(id).get(3, TimeUnit.SECONDS);
states.save().get(3, TimeUnit.SECONDS);
assertEquals(1000, repository.load().eyeProgress(id).accumulatedMillis());
assertFalse(progression.isUnlocked(id));
}
}
@Test
void cancelledAndUnrelatedEffectEventsCannotStartOrInterruptCredit() throws Exception {
UUID id = UUID.randomUUID();
var player = mock(Player.class);
when(player.getUniqueId()).thenReturn(id);
var clock = new AtomicLong();
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
try (var states = new StealthStateManager(repository, repository.load())) {
var progression = new EyeProgressionService(states, clock::get, ignored -> { });
var listener = new EyePotionListener(progression, failure -> fail(failure));
var effect = new PotionEffect(PotionEffectType.NIGHT_VISION, 200, 0);
var cancelled = new EntityPotionEffectEvent(player, null, effect, player,
EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.ADDED, false);
cancelled.setCancelled(true);
listener.onPotionEffect(cancelled);
clock.set(Duration.ofSeconds(1).toNanos());
progression.checkpoint(id).get(3, TimeUnit.SECONDS);
assertEquals(0, states.snapshot().eyeProgress(id).accumulatedMillis());
cancelled.setCancelled(false);
listener.onPotionEffect(cancelled);
clock.set(Duration.ofSeconds(2).toNanos());
var cancelledRemoval = new EntityPotionEffectEvent(player, effect, null, null,
EntityPotionEffectEvent.Cause.MILK, EntityPotionEffectEvent.Action.CLEARED, false);
cancelledRemoval.setCancelled(true);
listener.onPotionEffect(cancelledRemoval);
listener.onPotionEffect(new EntityPotionEffectEvent(player, null,
new PotionEffect(PotionEffectType.INVISIBILITY, 200, 0), null,
EntityPotionEffectEvent.Cause.PLUGIN, EntityPotionEffectEvent.Action.ADDED, false));
clock.set(Duration.ofSeconds(3).toNanos());
progression.stop(id).get(3, TimeUnit.SECONDS);
assertEquals(2000, repository.load().eyeProgress(id).accumulatedMillis());
}
}
@Test
void cosmeticOnlyOverridesDoNotReassignTheActiveEffectsSource() throws Exception {
// Purpur reports override=true even when only particle/icon flags change.
var nativeActive = new net.minecraft.world.effect.MobEffectInstance(
net.minecraft.world.effect.MobEffects.NIGHT_VISION, 200, 1);
assertTrue(nativeActive.update(new net.minecraft.world.effect.MobEffectInstance(
net.minecraft.world.effect.MobEffects.NIGHT_VISION, 100, 0, false, false, false)));
assertEquals(1, nativeActive.getAmplifier());
assertEquals(200, nativeActive.getDuration());
UUID id = UUID.randomUUID();
var player = mock(Player.class);
when(player.getUniqueId()).thenReturn(id);
var clock = new AtomicLong();
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
try (var states = new StealthStateManager(repository, repository.load())) {
var progression = new EyeProgressionService(states, clock::get, ignored -> { });
var listener = new EyePotionListener(progression, failure -> fail(failure));
var weak = new PotionEffect(PotionEffectType.NIGHT_VISION, 100, 0, false, false, false);
var strong = new PotionEffect(PotionEffectType.NIGHT_VISION, 200, 1);
listener.onPotionEffect(new EntityPotionEffectEvent(player, strong, weak, player,
EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.CHANGED, true));
clock.set(Duration.ofSeconds(1).toNanos());
progression.checkpoint(id).get(3, TimeUnit.SECONDS);
assertEquals(0, states.snapshot().eyeProgress(id).accumulatedMillis(),
"a cosmetic drink change must not claim the stronger existing effect");
listener.onPotionEffect(new EntityPotionEffectEvent(player, null, strong, player,
EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.ADDED, false));
clock.set(Duration.ofSeconds(2).toNanos());
listener.onPotionEffect(new EntityPotionEffectEvent(player, strong, weak, null,
EntityPotionEffectEvent.Cause.PLUGIN, EntityPotionEffectEvent.Action.CHANGED, true));
clock.set(Duration.ofSeconds(3).toNanos());
listener.onPotionEffect(new EntityPotionEffectEvent(player, strong, null, null,
EntityPotionEffectEvent.Cause.EXPIRATION, EntityPotionEffectEvent.Action.REMOVED, false));
states.save().get(3, TimeUnit.SECONDS);
assertEquals(2000, states.snapshot().eyeProgress(id).accumulatedMillis());
}
}
@Test
void rejectedReplacementsNeitherStartNorStopQualifyingTime() throws Exception {
UUID id = UUID.randomUUID();
var player = mock(Player.class);
when(player.getUniqueId()).thenReturn(id);
var clock = new AtomicLong();
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
try (var states = new StealthStateManager(repository, repository.load())) {
var progression = new EyeProgressionService(states, clock::get, ignored -> { });
var listener = new EyePotionListener(progression, failure -> fail(failure));
var weak = new PotionEffect(PotionEffectType.NIGHT_VISION, 100, 0);
var strong = new PotionEffect(PotionEffectType.NIGHT_VISION, 200, 1);
listener.onPotionEffect(new EntityPotionEffectEvent(player, strong, weak, player,
EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.CHANGED, false));
clock.set(Duration.ofSeconds(1).toNanos());
progression.checkpoint(id).get(3, TimeUnit.SECONDS);
assertEquals(0, states.snapshot().eyeProgress(id).accumulatedMillis(),
"a rejected drink must not claim an existing non-drink effect");
listener.onPotionEffect(new EntityPotionEffectEvent(player, null, strong, player,
EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.ADDED, false));
clock.set(Duration.ofSeconds(2).toNanos());
listener.onPotionEffect(new EntityPotionEffectEvent(player, strong, weak, null,
EntityPotionEffectEvent.Cause.PLUGIN, EntityPotionEffectEvent.Action.CHANGED, false));
clock.set(Duration.ofSeconds(3).toNanos());
listener.onPotionEffect(new EntityPotionEffectEvent(player, strong, null, null,
EntityPotionEffectEvent.Cause.EXPIRATION, EntityPotionEffectEvent.Action.REMOVED, false));
states.save().get(3, TimeUnit.SECONDS);
assertEquals(2000, states.snapshot().eyeProgress(id).accumulatedMillis(),
"a rejected plugin replacement must not interrupt the effective drunk potion");
}
}
@Test
void effectiveDirectDrinkAccumulatesAcrossRefreshAndDurablyUnlocksAtEightHours() throws Exception {
UUID id = UUID.randomUUID();
var player = mock(Player.class);
when(player.getUniqueId()).thenReturn(id);
var clock = new AtomicLong();
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
try (var states = new StealthStateManager(repository, repository.load())) {
var progression = new EyeProgressionService(states, clock::get, ignored -> { });
var listener = new EyePotionListener(progression, failure -> fail(failure));
var nightVision = new PotionEffect(PotionEffectType.NIGHT_VISION, 9600, 0);
listener.onPotionEffect(new EntityPotionEffectEvent(player, null, nightVision, player,
EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.ADDED, true));
for (int refresh = 1; refresh < 60; refresh++) {
clock.set(Duration.ofSeconds(refresh * 480L).toNanos());
listener.onPotionEffect(new EntityPotionEffectEvent(player, new PotionEffect(PotionEffectType.NIGHT_VISION, 1, 0), nightVision, player,
EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.CHANGED, true));
}
clock.set(Duration.ofHours(8).toNanos() - 1_000_000);
progression.checkpoint(id).get(3, TimeUnit.SECONDS);
assertFalse(progression.isUnlocked(id));
clock.addAndGet(1_000_000);
listener.onPotionEffect(new EntityPotionEffectEvent(player, nightVision, null, null,
EntityPotionEffectEvent.Cause.EXPIRATION, EntityPotionEffectEvent.Action.REMOVED, false));
states.save().get(3, TimeUnit.SECONDS);
assertTrue(progression.isUnlocked(id), "eight hours of effective drink events must unlock the Eye");
assertEquals(28_800_000, repository.load().eyeProgress(id).accumulatedMillis());
assertTrue(progression.activePlayerIds().isEmpty());
}
}
}
@@ -0,0 +1,61 @@
package games.dmg.spigotstealth;
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;
/** Loads vanilla registries/tags/components using the same path as the server's own tests. */
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;
}
}
@@ -0,0 +1,37 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class EyeProgressStateTest {
@Test
void eyeProgressRemainsIndependentAcrossExistingStealthAndSleepUpdates() {
UUID id = UUID.randomUUID();
var original = new PersistentStealthState(Map.of(), Map.of("future-root", "preserved"));
var eye = new EyeProgress(28_800_123, true, Map.of("future-eye", "preserved"));
var withEye = original.withEyeProgress(id, eye);
assertEquals(eye, withEye.eyeProgress(id));
assertEquals(EyeProgress.empty(), original.eyeProgress(id), "Updates must leave old snapshots immutable");
var changed = withEye.withPlayer(PlayerStealthState.empty(id).withProgress(123, true))
.withSleepCountPolicy(SleepCountPolicy.INCLUDE);
assertEquals(eye, changed.eyeProgress(id));
assertEquals(123, changed.player(id).accumulatedMillis());
assertEquals("preserved", changed.unknownFields().get("future-root"));
assertEquals(EyeProgress.empty(), changed.eyeProgress(UUID.randomUUID()));
}
@Test
void progressRejectsNegativeTimeAndCopiesUnknownFields() {
assertThrows(IllegalArgumentException.class, () -> new EyeProgress(-1, false));
var extras = new HashMap<String, Object>();
extras.put("extension", "old");
var progress = new EyeProgress(1, false, extras);
extras.put("extension", "new");
assertEquals("old", progress.unknownFields().get("extension"));
assertThrows(UnsupportedOperationException.class, () -> progress.unknownFields().put("other", 1));
}
}
@@ -0,0 +1,90 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicLong;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class EyeProgressionServiceTest {
@TempDir Path directory;
private final UUID id = UUID.randomUUID();
private final AtomicLong clock = new AtomicLong();
private final List<UUID> notifications = new CopyOnWriteArrayList<>();
@Test
void exactEightHoursUnlocksOnceWithoutOverlapOrLostSubmillisecondCredit() throws Exception {
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
try (var states = new StealthStateManager(repository, repository.load())) {
var progression = new EyeProgressionService(states, clock::get, notifications::add);
progression.begin(id).join();
clock.set(600_000);
progression.checkpoint(id).join();
clock.set(1_200_000);
progression.begin(id).join(); // A refresh, not an overlapping timer.
assertEquals(1, states.snapshot().eyeProgress(id).accumulatedMillis());
clock.set(Duration.ofHours(8).toNanos() - 1);
progression.checkpoint(id).join();
assertFalse(progression.isUnlocked(id));
clock.incrementAndGet();
progression.checkpoint(id).join();
assertTrue(progression.isUnlocked(id));
assertEquals(List.of(id), notifications);
clock.addAndGet(Duration.ofSeconds(3).toNanos());
progression.stop(id).join();
assertEquals(28_803_000, states.snapshot().eyeProgress(id).accumulatedMillis());
assertEquals(List.of(id), notifications);
assertTrue(progression.activePlayerIds().isEmpty());
}
}
@Test
void intervalsAndRestartPreserveProgressWithoutOfflineOrUnobservedCredit() throws Exception {
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
try (var states = new StealthStateManager(repository, repository.load())) {
var progression = new EyeProgressionService(states, clock::get, notifications::add);
progression.begin(id).join();
clock.addAndGet(Duration.ofHours(2).toNanos());
progression.stop(id).join();
clock.addAndGet(Duration.ofDays(30).toNanos());
progression.checkpoint(id).join();
assertEquals(Duration.ofHours(2).toMillis(), states.snapshot().eyeProgress(id).accumulatedMillis());
}
try (var states = new StealthStateManager(repository, repository.load())) {
var restarted = new EyeProgressionService(states, clock::get, notifications::add);
assertTrue(restarted.activePlayerIds().isEmpty());
restarted.begin(id).join();
clock.addAndGet(Duration.ofHours(6).toNanos());
restarted.stopAll().join();
assertTrue(restarted.isUnlocked(id));
assertEquals(28_800_000, repository.load().eyeProgress(id).accumulatedMillis());
assertTrue(restarted.activePlayerIds().isEmpty());
}
}
@Test
void missingDisconnectObservationDiscardsUnobservedTailAndExistingUnlocksDoNotRenotify() throws Exception {
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
var existing = new PersistentStealthState(Map.of(), Map.of())
.withEyeProgress(id, new EyeProgress(28_800_000, true));
try (var states = new StealthStateManager(repository, existing)) {
var progression = new EyeProgressionService(states, clock::get, notifications::add);
assertTrue(progression.isUnlocked(id));
progression.begin(id).join();
clock.addAndGet(Duration.ofSeconds(1).toNanos());
progression.checkpoint(id).join();
clock.addAndGet(Duration.ofHours(1).toNanos());
progression.abandon(id);
progression.checkpoint(id).join();
assertEquals(28_801_000, states.snapshot().eyeProgress(id).accumulatedMillis());
assertTrue(progression.activePlayerIds().isEmpty());
assertTrue(notifications.isEmpty());
}
}
}
@@ -0,0 +1,103 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletionException;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class EyeStatePersistenceTest {
@TempDir Path directory;
@Test
void yamlRoundTripPreservesEyeAndStealthProgressAndUnknownFields() throws Exception {
UUID id = UUID.randomUUID();
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
var eye = new EyeProgress(28_800_123, true, Map.of("future-eye", "keep"));
var state = new PersistentStealthState(Map.of(id, PlayerStealthState.empty(id).withProgress(456, true)),
Map.of("future-root", "keep")).withEyeProgress(id, eye);
repository.save(state);
var loaded = repository.load();
assertEquals(eye, loaded.eyeProgress(id));
assertEquals(state.player(id), loaded.player(id));
assertEquals(state.unknownFields(), loaded.unknownFields());
}
@Test
void legacyFilesStartAtZeroAndMalformedEyeRecordsCannotGrantUnlocksOrEraseStealth() throws Exception {
UUID id = UUID.randomUUID();
Path file = directory.resolve("state.yml");
var repository = new YamlStealthStateRepository(file);
var legacy = new PersistentStealthState(Map.of(id, PlayerStealthState.empty(id).withProgress(456, true)), Map.of());
repository.save(legacy);
assertEquals(EyeProgress.empty(), repository.load().eyeProgress(id));
// Deliberately malformed independent section, not a malformed existing player record.
Files.writeString(file, Files.readString(file) + "\neye-progress:\n " + id
+ ":\n accumulated-millis: -1\n unlocked: true\n");
var loaded = repository.load();
assertEquals(EyeProgress.empty(), loaded.eyeProgress(id));
assertEquals(legacy.player(id), loaded.player(id));
}
@Test
void pendingWriteDoesNotExposeDurableUnlockAndSavesOffTheCallingThread() throws Exception {
UUID id = UUID.randomUUID();
var initial = new PersistentStealthState(Map.of(), Map.of());
var entered = new CountDownLatch(1);
var release = new CountDownLatch(1);
Thread caller = Thread.currentThread();
var repository = new StealthStateRepository() {
@Override public PersistentStealthState load() { return initial; }
@Override public void save(PersistentStealthState state) throws IOException {
assertNotEquals(caller, Thread.currentThread());
entered.countDown();
try {
if (!release.await(5, TimeUnit.SECONDS)) { throw new IOException("test write timeout"); }
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new IOException(exception);
}
}
};
var manager = new StealthStateManager(repository, initial);
try {
var saved = manager.update(state -> state.withEyeProgress(id, new EyeProgress(28_800_000, true)));
assertTrue(entered.await(5, TimeUnit.SECONDS));
assertTrue(manager.snapshot().eyeProgress(id).unlocked());
assertFalse(manager.durableSnapshot().eyeProgress(id).unlocked());
release.countDown();
saved.join();
assertTrue(manager.durableSnapshot().eyeProgress(id).unlocked());
} finally { release.countDown(); manager.close(); }
}
@Test
void failedWriteCannotGrantDurableUnlockAndSuccessfulRetryCan() {
UUID id = UUID.randomUUID();
var initial = new PersistentStealthState(Map.of(), Map.of());
var fail = new AtomicBoolean(true);
var repository = new StealthStateRepository() {
@Override public PersistentStealthState load() { return initial; }
@Override public void save(PersistentStealthState state) throws IOException {
if (fail.get()) { throw new IOException("test disk failure"); }
}
};
var manager = new StealthStateManager(repository, initial);
try {
assertThrows(CompletionException.class, () -> manager.update(state ->
state.withEyeProgress(id, new EyeProgress(28_800_000, true))).join());
assertFalse(manager.durableSnapshot().eyeProgress(id).unlocked());
fail.set(false);
manager.save().join();
assertTrue(manager.durableSnapshot().eyeProgress(id).unlocked());
} finally { fail.set(false); manager.close(); }
}
}
@@ -0,0 +1,57 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import java.io.IOException;
import java.nio.file.Path;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class StealthStateShutdownTest {
@TempDir Path directory;
@Test
void shutdownReturnsWithoutWaitingForDiskButDrainsTheFinalSnapshotAndRejectsLaterMutations() throws Exception {
var disk = new YamlStealthStateRepository(directory.resolve("state.yml"));
var saving = new CountDownLatch(1);
var release = new CountDownLatch(1);
StealthStateRepository repository = new StealthStateRepository() {
@Override public PersistentStealthState load() throws IOException { return disk.load(); }
@Override public void save(PersistentStealthState state) throws IOException {
saving.countDown();
try {
if (!release.await(5, TimeUnit.SECONDS)) { throw new IOException("Test disk gate timed out"); }
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new IOException(exception);
}
disk.save(state);
}
};
var states = new StealthStateManager(repository, repository.load());
UUID id = UUID.randomUUID();
try {
states.update(state -> state.withEyeProgress(id, new EyeProgress(1234, false)));
assertTrue(saving.await(3, TimeUnit.SECONDS));
// A blocked save must not block the lifecycle caller. The test thread still releases the disk on failure.
var closing = CompletableFuture.supplyAsync(states::closeAsync).get(1, TimeUnit.SECONDS);
assertFalse(closing.isDone(), "shutdown completion must await the queued durable snapshot");
assertSame(closing, states.closeAsync());
assertThrows(ExecutionException.class, () -> states.update(state ->
state.withEyeProgress(id, new EyeProgress(28_800_000, true))).get(1, TimeUnit.SECONDS));
assertEquals(1234, states.snapshot().eyeProgress(id).accumulatedMillis());
release.countDown();
closing.get(3, TimeUnit.SECONDS);
assertEquals(1234, disk.load().eyeProgress(id).accumulatedMillis());
assertEquals(disk.load(), states.durableSnapshot());
} finally {
release.countDown();
states.close();
}
}
}