From 67942ced7c91f8bec2392e62cbe05077dfffb530 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Sat, 12 Sep 2026 17:35:01 -0400 Subject: [PATCH] feat(stealth): earn and equip the Eye of True Seeing 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. --- README.md | 14 +- build.gradle.kts | 57 ++++ .../games/dmg/spigotstealth/EyeCrafting.java | 66 ++++ .../games/dmg/spigotstealth/EyeEquipment.java | 47 +++ .../games/dmg/spigotstealth/EyeItems.java | 34 ++ .../dmg/spigotstealth/EyePotionListener.java | 101 ++++++ .../games/dmg/spigotstealth/EyeProgress.java | 13 + .../spigotstealth/EyeProgressionService.java | 73 +++++ .../spigotstealth/PersistentStealthState.java | 21 +- .../spigotstealth/SpigotStealthPlugin.java | 118 +++++-- .../spigotstealth/StealthStateManager.java | 38 ++- .../YamlStealthStateRepository.java | 35 +- .../dmg/spigotstealth/EyeCraftingTest.java | 215 +++++++++++++ .../dmg/spigotstealth/EyeEquipmentTest.java | 92 ++++++ .../games/dmg/spigotstealth/EyeItemsTest.java | 39 +++ .../spigotstealth/EyePluginLifecycleTest.java | 216 +++++++++++++ .../EyePotionProgressionTest.java | 302 ++++++++++++++++++ .../dmg/spigotstealth/NativeRuntime.java | 61 ++++ .../spigotstealth/EyeProgressStateTest.java | 37 +++ .../EyeProgressionServiceTest.java | 90 ++++++ .../EyeStatePersistenceTest.java | 103 ++++++ .../StealthStateShutdownTest.java | 57 ++++ 22 files changed, 1792 insertions(+), 37 deletions(-) create mode 100644 src/main/java/games/dmg/spigotstealth/EyeCrafting.java create mode 100644 src/main/java/games/dmg/spigotstealth/EyeEquipment.java create mode 100644 src/main/java/games/dmg/spigotstealth/EyeItems.java create mode 100644 src/main/java/games/dmg/spigotstealth/EyePotionListener.java create mode 100644 src/main/java/games/dmg/spigotstealth/EyeProgress.java create mode 100644 src/main/java/games/dmg/spigotstealth/EyeProgressionService.java create mode 100644 src/nativeTest/java/games/dmg/spigotstealth/EyeCraftingTest.java create mode 100644 src/nativeTest/java/games/dmg/spigotstealth/EyeEquipmentTest.java create mode 100644 src/nativeTest/java/games/dmg/spigotstealth/EyeItemsTest.java create mode 100644 src/nativeTest/java/games/dmg/spigotstealth/EyePluginLifecycleTest.java create mode 100644 src/nativeTest/java/games/dmg/spigotstealth/EyePotionProgressionTest.java create mode 100644 src/nativeTest/java/games/dmg/spigotstealth/NativeRuntime.java create mode 100644 src/test/java/games/dmg/spigotstealth/EyeProgressStateTest.java create mode 100644 src/test/java/games/dmg/spigotstealth/EyeProgressionServiceTest.java create mode 100644 src/test/java/games/dmg/spigotstealth/EyeStatePersistenceTest.java create mode 100644 src/test/java/games/dmg/spigotstealth/StealthStateShutdownTest.java diff --git a/README.md b/README.md index b2d3c4b..c63c330 100644 --- a/README.md +++ b/README.md @@ -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-.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). diff --git a/build.gradle.kts b/build.gradle.kts index 0df4aab..e1f24ae 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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("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("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 { diff --git a/src/main/java/games/dmg/spigotstealth/EyeCrafting.java b/src/main/java/games/dmg/spigotstealth/EyeCrafting.java new file mode 100644 index 0000000..5be91e8 --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/EyeCrafting.java @@ -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); + } + } +} diff --git a/src/main/java/games/dmg/spigotstealth/EyeEquipment.java b/src/main/java/games/dmg/spigotstealth/EyeEquipment.java new file mode 100644 index 0000000..ce1d5bf --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/EyeEquipment.java @@ -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); + } +} diff --git a/src/main/java/games/dmg/spigotstealth/EyeItems.java b/src/main/java/games/dmg/spigotstealth/EyeItems.java new file mode 100644 index 0000000..a6632fd --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/EyeItems.java @@ -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)); + } +} diff --git a/src/main/java/games/dmg/spigotstealth/EyePotionListener.java b/src/main/java/games/dmg/spigotstealth/EyePotionListener.java new file mode 100644 index 0000000..1ed41e6 --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/EyePotionListener.java @@ -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 saveFailure; + private final Map expectedEffects = new HashMap<>(); + + public EyePotionListener(EyeProgressionService progression, Consumer 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 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 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())); + } +} diff --git a/src/main/java/games/dmg/spigotstealth/EyeProgress.java b/src/main/java/games/dmg/spigotstealth/EyeProgress.java new file mode 100644 index 0000000..ca92bca --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/EyeProgress.java @@ -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 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); } +} diff --git a/src/main/java/games/dmg/spigotstealth/EyeProgressionService.java b/src/main/java/games/dmg/spigotstealth/EyeProgressionService.java new file mode 100644 index 0000000..641e1f6 --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/EyeProgressionService.java @@ -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 unlocked; + private final Map active = new HashMap<>(); + private final Set announced = ConcurrentHashMap.newKeySet(); + + /** The notification callback must marshal Bukkit work onto the server thread. */ + public EyeProgressionService(StealthStateManager states, LongSupplier nanoTime, Consumer 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 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 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 stop(UUID id) { + CompletableFuture saved = checkpoint(id); + active.remove(id); + return saved; + } + + public CompletableFuture 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 activePlayerIds() { return Set.copyOf(active.keySet()); } +} diff --git a/src/main/java/games/dmg/spigotstealth/PersistentStealthState.java b/src/main/java/games/dmg/spigotstealth/PersistentStealthState.java index 63ad14d..57d5429 100644 --- a/src/main/java/games/dmg/spigotstealth/PersistentStealthState.java +++ b/src/main/java/games/dmg/spigotstealth/PersistentStealthState.java @@ -9,11 +9,18 @@ import java.util.UUID; public record PersistentStealthState( Map players, SleepCountPolicy sleepCountPolicy, - Map unknownFields) { + Map unknownFields, + Map 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 players, SleepCountPolicy sleepCountPolicy, + Map 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 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 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); } } diff --git a/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java b/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java index 30f3299..71b2a67 100644 --- a/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java +++ b/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java @@ -12,14 +12,20 @@ import org.bukkit.plugin.java.JavaPlugin; /** Bukkit entry point for Spigot Stealth. */ public final class SpigotStealthPlugin extends JavaPlugin { private CompletableFuture stateManagerFuture; + private CompletableFuture 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 shutdownCompletion() { + return shutdown == null ? CompletableFuture.completedFuture(null) : shutdown; } public StealthSettings settings() { return settings; } - private void finishInitialization(StealthStateManager manager) { - java.util.function.Consumer mainThread = - runnable -> getServer().getScheduler().runTask(this, runnable); + private void finishInitialization(StealthStateManager manager, long epoch) { + java.util.function.Consumer 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 save) { save.exceptionally(failure -> { getLogger().severe("Unable to save Spigot Stealth state: " + rootMessage(failure)); diff --git a/src/main/java/games/dmg/spigotstealth/StealthStateManager.java b/src/main/java/games/dmg/spigotstealth/StealthStateManager.java index 76bbfa3..f3587cc 100644 --- a/src/main/java/games/dmg/spigotstealth/StealthStateManager.java +++ b/src/main/java/games/dmg/spigotstealth/StealthStateManager.java @@ -14,7 +14,9 @@ import java.util.function.UnaryOperator; public final class StealthStateManager implements AutoCloseable { private final StealthStateRepository repository; private final AtomicReference state; + private final AtomicReference durableState; private final ExecutorService ioExecutor; + private CompletableFuture 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 update(UnaryOperator operation) { + /** Last successfully written snapshot, for rewards that must not precede persistence acknowledgement. */ + public PersistentStealthState durableSnapshot() { return durableState.get(); } + + public synchronized CompletableFuture update(UnaryOperator operation) { + if (closing != null) { return CompletableFuture.failedFuture(new IllegalStateException("Stealth state is closing")); } PersistentStealthState updated = state.updateAndGet(operation); return persist(updated); } - public CompletableFuture save() { - return persist(state.get()); + public synchronized CompletableFuture save() { + return closing == null ? persist(state.get()) : closing; } private CompletableFuture 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 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(); } } diff --git a/src/main/java/games/dmg/spigotstealth/YamlStealthStateRepository.java b/src/main/java/games/dmg/spigotstealth/YamlStealthStateRepository.java index 50d2337..00a2607 100644 --- a/src/main/java/games/dmg/spigotstealth/YamlStealthStateRepository.java +++ b/src/main/java/games/dmg/spigotstealth/YamlStealthStateRepository.java @@ -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 ROOT_FIELDS = Set.of("schema-version", "sleep-count-policy", "players"); + private static final Set ROOT_FIELDS = Set.of("schema-version", "sleep-count-policy", "players", "eye-progress"); + private static final Set EYE_FIELDS = Set.of("accumulated-millis", "unlocked"); private static final Set 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 parseEyes(ConfigurationSection section) { + Map 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"); diff --git a/src/nativeTest/java/games/dmg/spigotstealth/EyeCraftingTest.java b/src/nativeTest/java/games/dmg/spigotstealth/EyeCraftingTest.java new file mode 100644 index 0000000..71fbdd4 --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotstealth/EyeCraftingTest.java @@ -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; + } +} diff --git a/src/nativeTest/java/games/dmg/spigotstealth/EyeEquipmentTest.java b/src/nativeTest/java/games/dmg/spigotstealth/EyeEquipmentTest.java new file mode 100644 index 0000000..d24b97d --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotstealth/EyeEquipmentTest.java @@ -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(); + 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.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); + } +} diff --git a/src/nativeTest/java/games/dmg/spigotstealth/EyeItemsTest.java b/src/nativeTest/java/games/dmg/spigotstealth/EyeItemsTest.java new file mode 100644 index 0000000..1b8937e --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotstealth/EyeItemsTest.java @@ -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)); + } +} diff --git a/src/nativeTest/java/games/dmg/spigotstealth/EyePluginLifecycleTest.java b/src/nativeTest/java/games/dmg/spigotstealth/EyePluginLifecycleTest.java new file mode 100644 index 0000000..8e687cd --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotstealth/EyePluginLifecycleTest.java @@ -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(); + when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenAnswer(call -> { + main.add(call.getArgument(1)); return mock(BukkitTask.class); + }); + var timers = new ArrayList(); + doAnswer(call -> { + Consumer action = call.getArgument(1); + timers.add(() -> action.accept(mock(BukkitTask.class))); + return null; + }).when(scheduler).runTaskTimer(eq(plugin), org.mockito.ArgumentMatchers.>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(); + when(server.addRecipe(any(Recipe.class))).thenAnswer(call -> { recipe.set(call.getArgument(0)); return true; }); + var listeners = new ArrayList(); + 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 listeners, Event event) throws Exception { + var registered = new ArrayList(); + 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); } + } +} diff --git a/src/nativeTest/java/games/dmg/spigotstealth/EyePotionProgressionTest.java b/src/nativeTest/java/games/dmg/spigotstealth/EyePotionProgressionTest.java new file mode 100644 index 0000000..589d71d --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotstealth/EyePotionProgressionTest.java @@ -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()); + } + } +} diff --git a/src/nativeTest/java/games/dmg/spigotstealth/NativeRuntime.java b/src/nativeTest/java/games/dmg/spigotstealth/NativeRuntime.java new file mode 100644 index 0000000..5e66104 --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotstealth/NativeRuntime.java @@ -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 layers = RegistryLayer.createRegistryAccess(); + List> tags = TagLoader.loadTagsForExistingRegistries(resources, layers.getLayer(RegistryLayer.STATIC)); + List> 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; + } +} diff --git a/src/test/java/games/dmg/spigotstealth/EyeProgressStateTest.java b/src/test/java/games/dmg/spigotstealth/EyeProgressStateTest.java new file mode 100644 index 0000000..b53fa3a --- /dev/null +++ b/src/test/java/games/dmg/spigotstealth/EyeProgressStateTest.java @@ -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(); + 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)); + } +} diff --git a/src/test/java/games/dmg/spigotstealth/EyeProgressionServiceTest.java b/src/test/java/games/dmg/spigotstealth/EyeProgressionServiceTest.java new file mode 100644 index 0000000..f4fe24a --- /dev/null +++ b/src/test/java/games/dmg/spigotstealth/EyeProgressionServiceTest.java @@ -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 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()); + } + } +} diff --git a/src/test/java/games/dmg/spigotstealth/EyeStatePersistenceTest.java b/src/test/java/games/dmg/spigotstealth/EyeStatePersistenceTest.java new file mode 100644 index 0000000..329d9bb --- /dev/null +++ b/src/test/java/games/dmg/spigotstealth/EyeStatePersistenceTest.java @@ -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(); } + } +} diff --git a/src/test/java/games/dmg/spigotstealth/StealthStateShutdownTest.java b/src/test/java/games/dmg/spigotstealth/StealthStateShutdownTest.java new file mode 100644 index 0000000..82c96d2 --- /dev/null +++ b/src/test/java/games/dmg/spigotstealth/StealthStateShutdownTest.java @@ -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(); + } + } +}