diff --git a/AGENTS.md b/AGENTS.md index 1fd198e..c0791ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,10 @@ A grave's identity and state transition must remain consistent across memory, wo 8. Keep Bukkit/world/entity/inventory access on the correct Bukkit or Folia execution context; keep SQL and filesystem work off server/region threads. 9. Use stable UUID/world/location identity and preserve legacy serialized records and database schemas through explicit migrations. 10. Optional integrations and marker implementations must remain guarded when dependencies, worlds, entities, or materials are unavailable. +11. Pending persisted markers retain exact-position destruction protection through archive and chunk admission without + exposing claimable metadata or rewards. Queries run on the event/region owner, use the existing restoration index, + and reject destroyed/removed records, wrong materials and successor identities. Deferred physics repair rechecks + ownership. Deferred hologram cleanup must preserve any live grave's adopted display at the entity-owned cleanup boundary. ## Drop-in upgrade and compatibility contract diff --git a/GraveStonesPlus/pom.xml b/GraveStonesPlus/pom.xml index 651da8c..67be3d2 100644 --- a/GraveStonesPlus/pom.xml +++ b/GraveStonesPlus/pom.xml @@ -70,6 +70,15 @@ + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + -javaagent:${settings.localRepository}/org/mockito/mockito-core/5.23.0/mockito-core-5.23.0.jar + 90 + + org.apache.maven.plugins maven-jar-plugin @@ -265,6 +274,18 @@ + + org.mockito + mockito-core + 5.23.0 + test + + + org.junit.jupiter + junit-jupiter + 5.10.3 + test + org.spigotmc spigot-api @@ -479,4 +500,4 @@ - \ No newline at end of file + diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/GraveStonesPlus.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/GraveStonesPlus.java index b37680e..a07c167 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/GraveStonesPlus.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/GraveStonesPlus.java @@ -6,6 +6,7 @@ import java.util.Timer; import java.util.TimerTask; import java.util.UUID; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import org.bukkit.Bukkit; @@ -26,8 +27,12 @@ import com.bencodez.gravestonesplus.events.GraveRemoveReason; import com.bencodez.gravestonesplus.graves.Grave; import com.bencodez.gravestonesplus.graves.GraveDisplayEntityHandle; +import com.bencodez.gravestonesplus.graves.TrustedGraveAccess; +import com.bencodez.gravestonesplus.graves.TrustedGraveAccessStore; +import com.bencodez.gravestonesplus.graves.GraveReminderStore; import com.bencodez.gravestonesplus.listeners.GraveBlockProtectionListener; import com.bencodez.gravestonesplus.listeners.GraveClaimListener; +import com.bencodez.gravestonesplus.listeners.GraveCleanupListener; import com.bencodez.gravestonesplus.listeners.PlayerDeathListener; import com.bencodez.gravestonesplus.nbt.NBTConfigManager; import com.bencodez.gravestonesplus.pluginhandles.PvpManagerHandle; @@ -77,8 +82,14 @@ public class GraveStonesPlus extends AdvancedCorePlugin { @Getter private GraveDisplayEntityHandle graveDisplayEntityHandler; - @Getter private NBTConfigManager nbtConfigManager; + private record ReloadFiles(String config, String graves, String nbt) { } + private record RuntimeConfiguration(Config config, GraveLocations graves, NBTConfigManager nbt) { } + private volatile RuntimeConfiguration runtimeConfiguration; + public NBTConfigManager getNbtConfigManager() { + RuntimeConfiguration current = runtimeConfiguration; + return current == null ? nbtConfigManager : current.nbt(); + } @Getter private boolean nbtAPIHooked = false; @@ -86,6 +97,29 @@ public class GraveStonesPlus extends AdvancedCorePlugin { @Getter private PlayerBreakManager playerBreakManager; + @Getter + private TrustedGraveAccessStore trustedGraveAccessStore; + @Getter private com.bencodez.gravestonesplus.graves.FeatureIO featureIO; + private volatile CompletableFuture pendingReload; + private volatile boolean reloadRetired; + @Getter private com.bencodez.gravestonesplus.graves.GraveMarkerCleanup markerCleanup; + @Getter private com.bencodez.gravestonesplus.graves.GraveRestoration graveRestoration; + @Getter private com.bencodez.gravestonesplus.graves.GraveClaimLedger graveClaimLedger; + @Getter private com.bencodez.gravestonesplus.graves.GraveNavigation graveNavigation; + + @Getter + private GraveReminderStore graveReminderStore; + private com.bencodez.gravestonesplus.listeners.GraveReminderListener graveReminderListener; + + /** Restored records were already persisted; admission never republishes a partial startup list. */ + public void restoreActiveRecord(Grave grave) { if (!containsByKey(graves, grave)) graves.add(grave); } + public void restoreBrokenRecord(Grave grave) { if (!containsByKey(brokenGraves, grave)) brokenGraves.add(grave); } + /** Withdraw only this restored instance; its backend record remains pending, not broken. */ + public void withholdRestoredRecord(Grave grave) { + synchronized (graves) { graves.remove(grave); } + } + + public void addGrave(Grave grave) { if (grave == null || grave.getGravesConfig() == null) { return; @@ -121,33 +155,51 @@ public void removeGrave(Grave grave) { } public void recreateBrokenGrave(Grave grave) { + recreateBrokenGrave(grave, null); + } + + public java.util.concurrent.CompletableFuture recreateBrokenGrave(Grave grave, Player actor) { + return recreateBrokenGrave(grave, actor, actor != null); + } + + /** Common recovery wrapper with an explicit marker policy for legacy callers. */ + public java.util.concurrent.CompletableFuture recreateBrokenGrave(Grave grave, Player actor, + boolean createMarker) { if (grave == null || grave.getGravesConfig() == null) { - return; + return java.util.concurrent.CompletableFuture.completedFuture(false); } - grave.getGravesConfig().setDestroyed(false); - + var completion = grave.recreateAsync(actor, createMarker).thenCompose(restored -> { + if (!restored) return java.util.concurrent.CompletableFuture.completedFuture(false); if (!containsByKey(graves, grave)) { graves.add(grave); } removeFromListByKey(brokenGraves, grave); if (storageManager.isMySQL()) { - storageManager.setBroken(grave.getGravesConfig(), false); - } else { - storageManager.saveBrokenGravesFromObjects(brokenGraves); - storageManager.saveGravesFromObjects(graves); + return storageManager.saveRecreatedGrave(grave.getGravesConfig()); } + return storageManager.saveSnapshotFromObjects(graves, brokenGraves); + }); + completion.whenComplete((success, failure) -> { + if (failure != null) { + getLogger().warning("Failed to recreate grave " + grave.getGravesConfig().getLocation() + + ": " + failure.getMessage()); + } + }); + // Caller cancellation/completion affects only observation. The internal stage + // must still move the lists and request persistence after recreation commits. + return completion.copy(); } private boolean containsByKey(List list, Grave grave) { if (list == null || grave == null || grave.getGravesConfig() == null || grave.getGravesConfig().getLocation() == null - || grave.getGravesConfig().getLocation().getWorld() == null) { + || grave.getGravesConfig().getWorldId() == null) { return false; } String key = buildGraveKey(grave); - for (Grave g : list) { + for (Grave g : snapshot(list)) { if (g != null && g.getGravesConfig() != null && key.equals(buildGraveKey(g))) { return true; } @@ -158,16 +210,11 @@ private boolean containsByKey(List list, Grave grave) { private void removeFromListByKey(List list, Grave grave) { if (list == null || grave == null || grave.getGravesConfig() == null || grave.getGravesConfig().getLocation() == null - || grave.getGravesConfig().getLocation().getWorld() == null) { + || grave.getGravesConfig().getWorldId() == null) { return; } - String key = buildGraveKey(grave); - for (int i = list.size() - 1; i >= 0; i--) { - Grave g = list.get(i); - if (g != null && g.getGravesConfig() != null && key.equals(buildGraveKey(g))) { - list.remove(i); - } - } + String key = buildGraveKey(grave); + synchronized (list) { list.removeIf(g -> g != null && g.getGravesConfig() != null && key.equals(buildGraveKey(g))); } } public void callEventSync(final Event event) { @@ -183,13 +230,26 @@ public void callEventSync(final Event event) { private String buildGraveKey(Grave grave) { GravesConfig cfg = grave.getGravesConfig(); - return cfg.getUuid().toString() + "|" + cfg.getLocation().getWorld().getUID().toString() + "|" + return cfg.getUuid().toString() + "|" + cfg.getWorldId().toString() + "|" + cfg.getLocation().getBlockX() + "," + cfg.getLocation().getBlockY() + "," + cfg.getLocation().getBlockZ() + "|" + cfg.getTime(); } @Override public void onPostLoad() { + if (featureIO != null && !featureIO.isTerminated()) throw new IllegalStateException("Previous grave disk owner is still draining; cannot reload selected state yet"); + featureIO = new com.bencodez.gravestonesplus.graves.FeatureIO(); + reloadRetired = false; + graveClaimLedger = new com.bencodez.gravestonesplus.graves.GraveClaimLedger( + new com.bencodez.gravestonesplus.graves.DurableFeatureState(featureIO, + new java.io.File(getDataFolder(), "selected-claims.yml").toPath(), 8192), + new java.io.File(getDataFolder(), "selected-claim-archive").toPath()); + markerCleanup = new com.bencodez.gravestonesplus.graves.GraveMarkerCleanup(this, + new com.bencodez.gravestonesplus.graves.DurableFeatureState(featureIO, + new java.io.File(getDataFolder(), "selected-marker-cleanup.yml").toPath(), 8192)); + graveNavigation = new com.bencodez.gravestonesplus.graves.GraveNavigation(this); + trustedGraveAccessStore = new TrustedGraveAccessStore(this); + graveReminderStore = new GraveReminderStore(this); graveDisplayEntityHandler = new GraveDisplayEntityHandle(this); graves = Collections.synchronizedList(new ArrayList()); @@ -198,85 +258,8 @@ public void onPostLoad() { storageManager = new GraveStorageManager(this); storageManager.init(); - // Load broken graves - plugin.getBukkitScheduler().runTask(plugin, new Runnable() { - @Override - public void run() { - List gravesbroken1 = storageManager.loadBrokenGraves(); - if (gravesbroken1 != null) { - for (GravesConfig gr : gravesbroken1) { - Grave grave = new Grave(plugin, gr); - try { - grave.loadChunk(false); - } catch (Exception e) { - e.printStackTrace(); - } - grave.removeHologramsAround(); - if (gr.isDestroyed()) { - if (gr.getDestroyedTime() == 0) { - gr.setDestroyedTime(System.currentTimeMillis()); - debug("Fixed broken grave time: " + grave.getGravesConfig().getLocation()); - debug("Broken Grave loaded: " + grave.getGravesConfig().getLocation()); - brokenGraves.add(grave); - } else { - ParsedDuration duration = ParsedDuration.parse(configFile.getBrokenGraveTimeLimit(), - TimeUnit.HOURS); - long limitMillis = duration.getMillis(); - - if (System.currentTimeMillis() - gr.getDestroyedTime() > limitMillis) { - debug("Broken Grave at " + grave.getGravesConfig().getLocation() - + " has reached it's time limit and will be removed"); - } else { - debug("Broken Grave loaded: " + grave.getGravesConfig().getLocation()); - brokenGraves.add(grave); - } - } - } - } - gravesConfig.setBrokenGraves(brokenGraves); - } - } - }); - - // Load active graves - plugin.getBukkitScheduler().runTask(plugin, new Runnable() { - @Override - public void run() { - List graves1 = storageManager.loadGraves(); - if (graves1 != null) { - for (GravesConfig gr : graves1) { - Grave grave = new Grave(plugin, gr); - try { - grave.loadChunk(false); - } catch (Exception e) { - e.printStackTrace(); - } - grave.removeHologramsAround(); - grave.loadBlockMeta(gr.getLocation().getBlock()); - - // Display-entities: ensure display entity is present if block is barrier - grave.checkBlockDisplayAndFixIfMissing(); - - if (grave.isValid()) { - grave.createHologram(); - grave.checkTimeLimit(getConfigFile().getGraveTimeLimit()); - graves.add(grave); - debug("Grave loaded: " + grave.getGravesConfig().getLocation()); - - } else { - // Only remove if it truly isn't a grave anymore - grave.removeGrave(GraveRemoveReason.INVALID); - debug("Grave at " + grave.getGravesConfig().getLocation() + " is not valid"); - } - } - - // For flatfile, save once after load to persist any displayUUID fixes - if (!storageManager.isMySQL()) { - storageManager.saveGravesFromObjects(graves); - } - } - } - }); + graveRestoration = new com.bencodez.gravestonesplus.graves.GraveRestoration(this, storageManager); + graveRestoration.start(); commandLoader = new CommandLoader(this); commandLoader.loadCommands(); @@ -286,7 +269,11 @@ public void run() { Bukkit.getPluginManager().registerEvents(new PlayerDeathListener(this), this); Bukkit.getPluginManager().registerEvents(new GraveClaimListener(this), this); + Bukkit.getPluginManager().registerEvents(new GraveCleanupListener(this), this); Bukkit.getPluginManager().registerEvents(new GraveBlockProtectionListener(this), this); + graveReminderListener = new com.bencodez.gravestonesplus.listeners.GraveReminderListener(this); + Bukkit.getPluginManager().registerEvents(graveReminderListener, this); + graveReminderListener.start(); plugin.debug("Graves type: " + configFile.getGraveDisplayTypeEnum().name()); @@ -298,21 +285,7 @@ public void run() { @Override public void run() { if (getConfigFile().isGlowingEffectNearGrave() && plugin != null) { - for (int i = graves.size() - 1; i >= 0; i--) { - Grave grave = graves.get(i); - if (!grave.isRemove()) { - if (grave.isValid()) { - grave.checkGlowing(); - } else { - plugin.getBukkitScheduler().runTask(plugin, () -> { - ; - grave.removeGrave(GraveRemoveReason.INVALID); - grave.removeHologramsAround(); - - }, grave.getGravesConfig().getLocation()); - } - } - } + for (Grave grave : activeGraveSnapshot()) refreshGraveGlow(grave); } else { cancel(); } @@ -380,6 +353,16 @@ public void onPreLoad() { @Override public void onUnLoad() { + synchronized (this) { + reloadRetired = true; + CompletableFuture reload = pendingReload; + if (reload != null) reload.completeExceptionally(new IllegalStateException("Grave reload lifecycle retired")); + } + + if (graveRestoration != null) graveRestoration.retire(); + if (markerCleanup != null) markerCleanup.retire(); + if (graveReminderListener != null) graveReminderListener.stop(); + if (graveReminderStore != null) graveReminderStore.close(); if (playerBreakManager != null) { playerBreakManager.stop(); } @@ -394,7 +377,8 @@ public void onUnLoad() { storageManager.close(); } - for (Grave grave : graves) { + if (featureIO != null) featureIO.close(); + for (Grave grave : activeGraveSnapshot()) { grave.removeHologram(); grave.removeTimer(); } @@ -403,45 +387,149 @@ public void onUnLoad() { } @Override - public void reload() { - configFile.reloadData(); - gravesConfig.reloadData(); - nbtConfigManager.reloadConfig(); - updateAdvancedCoreHook(); - reloadAdvancedCore(false); - } + public void reload() { + reloadAsync(); + } + + /** + * Reloads the files off-thread and completes after the Bukkit owner has + * published the new configuration. The legacy void method intentionally + * remains fire-and-forget for binary/source compatibility. + */ + public CompletableFuture reloadAsync() { + final CompletableFuture completion; + final com.bencodez.gravestonesplus.graves.FeatureIO diskOwner; + synchronized (this) { + if (reloadRetired || !isEnabled() || featureIO == null) return CompletableFuture.failedFuture( + new IllegalStateException("Grave configuration lifecycle is unavailable")); + if (pendingReload != null && !pendingReload.isDone()) return CompletableFuture.failedFuture( + new IllegalStateException("A grave configuration reload is already pending")); + completion = new CompletableFuture<>(); + pendingReload = completion; + diskOwner = featureIO; + if (playerBreakManager != null) playerBreakManager.invalidateAuthorization(); + } + try { + diskOwner.submit(() -> { + java.io.File nbtRules = new java.io.File(getDataFolder(), "nbt_rules.yml"); + if (!nbtRules.exists()) saveResource("nbt_rules.yml", false); + return new ReloadFiles(readReloadFile(configFile.getdFile()), + readReloadFile(gravesConfig.getdFile()), readReloadFile(nbtRules)); + }).whenComplete((loaded, failure) -> { + if (failure != null) { + getLogger().severe("Grave configuration reload failed; inspect configuration before retrying."); + completion.completeExceptionally(failure); + return; + } + try { + getBukkitScheduler().runTask(this, () -> { + if (reloadRetired || !isEnabled() || featureIO != diskOwner || pendingReload != completion || completion.isDone()) { + completion.completeExceptionally(new IllegalStateException("Grave reload lifecycle retired")); + return; + } + RuntimeConfiguration previous = runtimeConfiguration; + boolean published = false; + try { + Config preparedConfig = Config.prepare(this, parseReloadFile(loaded.config())); + GraveLocations preparedGraves = GraveLocations.prepare(this, parseReloadFile(loaded.graves())); + NBTConfigManager preparedNbt = NBTConfigManager.prepare(this, + parseReloadFile(loaded.nbt()), preparedConfig.getKeepItemsWithLore()); + runtimeConfiguration = new RuntimeConfiguration(preparedConfig, preparedGraves, preparedNbt); + published = true; + updateAdvancedCoreHook(); reloadAdvancedCore(false); + for (Grave grave : activeGraveSnapshot()) getBukkitScheduler().runTask(this, + () -> grave.checkTimeLimit(getConfigFile().getGraveTimeLimit()), grave.getGravesConfig().getLocation()); + completion.complete(null); + } catch (RuntimeException ownerFailure) { + if (published) { + runtimeConfiguration = previous; + try { updateAdvancedCoreHook(); } + catch (RuntimeException rollbackFailure) { ownerFailure.addSuppressed(rollbackFailure); } + } + getLogger().severe("Grave reload owner publication failed; inspect configuration before retrying."); + completion.completeExceptionally(ownerFailure); + } + }); + } catch (RuntimeException schedulingFailure) { + completion.completeExceptionally(schedulingFailure); + } + }); + } catch (RuntimeException admissionFailure) { + completion.completeExceptionally(admissionFailure); + } + return completion; + } + + /** Read bytes only on I/O; Bukkit YAML deserialization runs on the publication owner. */ + private static String readReloadFile(java.io.File file) { + final int limit = 16 * 1024 * 1024; + try (var input = java.nio.file.Files.newInputStream(file.toPath())) { + byte[] bytes = input.readNBytes(limit + 1); + if (bytes.length > limit) throw new IllegalStateException("Grave reload file exceeds capacity"); + return new String(bytes, java.nio.charset.StandardCharsets.UTF_8); + } catch (java.io.IOException failure) { + throw new IllegalStateException("Grave configuration file cannot be read", failure); + } + } + private static org.bukkit.configuration.file.YamlConfiguration parseReloadFile(String text) { + var data = new org.bukkit.configuration.file.YamlConfiguration(); + try { data.loadFromString(text); return data; } + catch (org.bukkit.configuration.InvalidConfigurationException failure) { + throw new IllegalStateException("Grave configuration file cannot be parsed", failure); + } + } private void updateAdvancedCoreHook() { getJavascriptEngine().put("GraveStonesPlus", this); - setConfigData(new YMLConfig(plugin, configFile.getData()) { + setConfigData(new YMLConfig(plugin, getConfigFile().getData()) { @Override public void setValue(String path, Object value) { - configFile.setValue(path, value); + getConfigFile().setValue(path, value); } @Override public void saveData() { - configFile.saveData(); + getConfigFile().saveData(); } @Override public void createSection(String key) { - configFile.createSection(key); + getConfigFile().createSection(key); } }); setLoadUserData(false); setLoadVault(false); } - @Getter private Config configFile; + public Config getConfigFile() { + RuntimeConfiguration current = runtimeConfiguration; + return current == null ? configFile : current.config(); + } - @Getter private GraveLocations gravesConfig; - + public GraveLocations getGravesConfig() { + RuntimeConfiguration current = runtimeConfiguration; + return current == null ? gravesConfig : current.graves(); + } + + private List limitGraveSnapshot() { + List result = activeGraveSnapshot(); + if (getGraveRestoration() == null) return result; + java.util.Set identities = new java.util.HashSet<>(); + for (Grave grave : result) identities.add(com.bencodez.gravestonesplus.graves.GraveIdentity.of(grave.getGravesConfig())); + for (Grave grave : getGraveRestoration().pendingActiveForLimits()) { + String identity = com.bencodez.gravestonesplus.graves.GraveIdentity.of(grave.getGravesConfig()); + if (identities.add(identity)) result.add(grave); + } + return result; + } public int numberOfGraves(UUID uuid) { int num = 0; - for (Grave grave : getGraves()) { + for (Grave grave : limitGraveSnapshot()) { + if (grave.isRemove() || grave.getGravesConfig().isDestroyed() || grave.isLimitPayoutPending() + || (getGraveRestoration() != null && (getGraveRestoration().hasPendingLimitRemoval(grave) + || getGraveRestoration().isCreationReplacement(grave)))) continue; if (grave.getGravesConfig().getUuid().equals(uuid)) { num++; } @@ -451,7 +539,10 @@ public int numberOfGraves(UUID uuid) { public Grave getOldestGrave(UUID uuid) { Grave oldest = null; - for (Grave grave : getGraves()) { + for (Grave grave : limitGraveSnapshot()) { + if (grave.isRemove() || grave.getGravesConfig().isDestroyed() || grave.isLimitPayoutPending() + || (getGraveRestoration() != null && (getGraveRestoration().hasPendingLimitRemoval(grave) + || getGraveRestoration().isCreationReplacement(grave)))) continue; if (grave.getGravesConfig().getUuid().equals(uuid)) { if (oldest == null) { oldest = grave; @@ -466,7 +557,7 @@ public Grave getOldestGrave(UUID uuid) { public List getGraves(Player player) { ArrayList ownedGraves = new ArrayList(); - for (Grave grave : getGraves()) { + for (Grave grave : activeGraveSnapshot()) { if (grave.isOwner(player)) { ownedGraves.add(grave); } @@ -474,9 +565,16 @@ public List getGraves(Player player) { return ownedGraves; } + /** Graves the player may navigate to; recovery is checked separately. */ + public List getAccessibleGraves(Player player) { + ArrayList result = new ArrayList(); + for (Grave grave : activeGraveSnapshot()) if (canTrack(grave, player)) result.add(grave); + return result; + } + public List getBrokenGraves(Player player) { ArrayList ownedGraves = new ArrayList(); - for (Grave grave : getBrokenGraves()) { + for (Grave grave : brokenGraveSnapshot()) { if (grave.isOwner(player)) { ownedGraves.add(grave); } @@ -486,7 +584,7 @@ public List getBrokenGraves(Player player) { public List getGraves(String player) { ArrayList ownedGraves = new ArrayList(); - for (Grave grave : getGraves()) { + for (Grave grave : activeGraveSnapshot()) { if (grave.isOwner(player)) { ownedGraves.add(grave); } @@ -506,4 +604,36 @@ public Grave getLatestGrave(Player player) { } return cGrave; } -} \ No newline at end of file + + private static List snapshot(List list) { synchronized (list) { return new ArrayList<>(list); } } + public List activeGraveSnapshot() { synchronized (graves) { return new ArrayList<>(graves); } } + public List brokenGraveSnapshot() { synchronized (brokenGraves) { return new ArrayList<>(brokenGraves); } } + /** Startup logical records may be unloaded; native glow/marker work stays on their region. */ + public void refreshGraveGlow(Grave grave) { + org.bukkit.Location location = grave.getGravesConfig().getLocation(); + if (location == null || location.getWorld() == null) return; + getBukkitScheduler().runTask(this, () -> { + if (!isEnabled() || grave.isRemove() || grave.getGravesConfig().isDestroyed() + || !getConfigFile().isGlowingEffectNearGrave() + || !location.getWorld().isChunkLoaded(location.getBlockX() >> 4, location.getBlockZ() >> 4)) return; + if (grave.isValid()) grave.checkGlowing(); + else grave.removeGrave(GraveRemoveReason.INVALID); + }, location); + } + /** Eligibility reads only stored state; never probes blocks or loads chunks. */ + public boolean isLiveGrave(Grave grave) { + if (grave == null || grave.isRemove() || grave.getGravesConfig().isDestroyed() + || com.bencodez.gravestonesplus.graves.GraveIdentity.of(grave.getGravesConfig()) == null) return false; + long deadline = grave.getExpiryDeadline(); + if (deadline > 0 && deadline <= System.currentTimeMillis()) return false; + synchronized (graves) { return graves.contains(grave); } + } + public boolean canRecover(Grave grave, Player player) { + return isLiveGrave(grave) && player != null && (grave.isOwner(player) + || trustedGraveAccessStore.allows(com.bencodez.gravestonesplus.graves.GraveIdentity.of(grave.getGravesConfig()), player.getUniqueId(), TrustedGraveAccess.Kind.RECOVER)); + } + public boolean canTrack(Grave grave, Player player) { + return isLiveGrave(grave) && player != null && !graveClaimLedger.isBlocked(com.bencodez.gravestonesplus.graves.GraveIdentity.of(grave.getGravesConfig())) + && (grave.isOwner(player) || trustedGraveAccessStore.allows(com.bencodez.gravestonesplus.graves.GraveIdentity.of(grave.getGravesConfig()), player.getUniqueId(), TrustedGraveAccess.Kind.TRACK)); + } +} diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/breaking/PlayerBreakManager.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/breaking/PlayerBreakManager.java index 45be16f..d101bec 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/breaking/PlayerBreakManager.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/breaking/PlayerBreakManager.java @@ -57,6 +57,9 @@ public class PlayerBreakManager { * Repeating cleanup and progress task. */ private BukkitTask task; + private com.bencodez.simpleapi.folialib.wrapper.task.WrappedTask ownedTask; + private volatile long generation; + private final java.util.concurrent.atomic.AtomicLong authorizationGeneration = new java.util.concurrent.atomic.AtomicLong(); /** * Creates the manager. @@ -72,19 +75,19 @@ public PlayerBreakManager(GraveStonesPlus plugin) { */ public void start() { stop(); - task = Bukkit.getScheduler().runTaskTimer(plugin, new Runnable() { - - @Override - public void run() { - tick(); - } - }, 1L, 2L); + final long expected = ++generation; + ownedTask = plugin.getBukkitScheduler().getFoliaLib().getImpl().runTimer(() -> { + if (generation == expected) tick(); + }, 1L, 2L); } /** * Stops the repeating task and clears active attempts. */ public void stop() { + generation++; + invalidateAuthorization(); + if (ownedTask != null) { ownedTask.cancel(); ownedTask = null; } if (task != null) { task.cancel(); task = null; @@ -94,6 +97,12 @@ public void stop() { ownerLastCompleted.clear(); } + /** Reload invalidates completed proofs as well as in-progress attempts. */ + public void invalidateAuthorization() { + authorizationGeneration.incrementAndGet(); + activeBreaks.clear(); ownerActiveBreaks.clear(); ownerLastCompleted.clear(); + } + /** * Handles a hit on a grave by a player. * @@ -110,7 +119,7 @@ public boolean handleHit(Player player, Grave grave) { return false; } - if (grave.getGravesConfig().isDestroyed() || !grave.isValid()) { + if (!eligible(player, grave) || !grave.legacyBreakAllowed(player)) { return false; } @@ -124,6 +133,7 @@ public boolean handleHit(Player player, Grave grave) { active.setGraveId(graveKey); active.setStartTime(now); active.setLastHitTime(now); + active.setAuthorizationGeneration(authorizationGeneration.get()); activeBreaks.put(player.getUniqueId(), active); sendStartMessage(player); @@ -134,8 +144,7 @@ public boolean handleHit(Player player, Grave grave) { active.setLastHitTime(now); if ((now - active.getStartTime()) >= getRequiredBreakTime().getMillis()) { - completeBreak(player, grave); - activeBreaks.remove(player.getUniqueId()); + if (activeBreaks.remove(player.getUniqueId(), active)) completeBreak(player, grave, active); return true; } @@ -216,90 +225,40 @@ public double getProgress(UUID playerUUID) { /** * Performs cleanup and optional progress updates. */ - private void tick() { - long now = System.currentTimeMillis(); - ParsedDuration timeout = getHitTimeout(); - - Iterator> iterator = activeBreaks.entrySet().iterator(); - while (iterator.hasNext()) { - Map.Entry entry = iterator.next(); - UUID playerUUID = entry.getKey(); - ActiveGraveBreak active = entry.getValue(); - - Player player = Bukkit.getPlayer(playerUUID); - if (player == null || !player.isOnline()) { - iterator.remove(); - continue; - } - - Grave grave = getGraveByKey(active.getGraveId()); - if (grave == null) { - iterator.remove(); - continue; - } - - if (!grave.isValid() || grave.getGravesConfig().isDestroyed()) { - iterator.remove(); - continue; - } - - if (isTimedOut(active, timeout, now)) { - sendCancelledMessage(player); - iterator.remove(); - continue; - } - - if (plugin.getConfigFile().isBreakOtherGravesActionBarMessage()) { - sendProgress(player, active); - } - } - - ParsedDuration ownerTimeout = getHitTimeoutOwner(); - - Iterator> ownerIt = ownerActiveBreaks.entrySet().iterator(); - while (ownerIt.hasNext()) { - Map.Entry entry = ownerIt.next(); - UUID playerUUID = entry.getKey(); - ActiveGraveBreak active = entry.getValue(); - - Player player = Bukkit.getPlayer(playerUUID); - if (player == null || !player.isOnline()) { - ownerIt.remove(); - continue; - } - - Grave grave = getGraveByKey(active.getGraveId()); - if (grave == null) { - ownerIt.remove(); - continue; - } - - if (!grave.isValid() || grave.getGravesConfig().isDestroyed()) { - ownerIt.remove(); - continue; - } - - if (isTimedOut(active, ownerTimeout, now)) { - sendCancelledMessageOwner(player); - ownerIt.remove(); - continue; - } - - if (plugin.getConfigFile().isBreakOwnGraveActionBarMessage()) { - sendProgress(player, active); - } - - long required = getRequiredBreakTimeOwner().getMillis(); - if ((now - active.getStartTime()) >= required) { - // plugin.debug("Owner break: completing from tick for " + player.getName()); - - ownerIt.remove(); - recordOwnerCompletion(player); - - completeOwnerBreak(player, grave); - } - } - } + private void tick() { + final long expected = generation; + java.util.Set players = new java.util.HashSet<>(activeBreaks.keySet()); players.addAll(ownerActiveBreaks.keySet()); + for (UUID uuid : players) { + Player player = Bukkit.getPlayer(uuid); + if (player == null) { activeBreaks.remove(uuid); ownerActiveBreaks.remove(uuid); continue; } + plugin.getBukkitScheduler().runTask(plugin, () -> { if (generation == expected) tickPlayer(player); }, player); + } + } + private void tickPlayer(Player player) { + UUID uuid = player.getUniqueId(); long now = System.currentTimeMillis(); + ActiveGraveBreak active = activeBreaks.get(uuid); + if (active != null) { + Grave grave = getGraveByKey(active.getGraveId()); + if (active.getAuthorizationGeneration() != authorizationGeneration.get() || !eligible(player, grave) + || (!plugin.canRecover(grave, player) && !grave.legacyBreakAllowed(player))) activeBreaks.remove(uuid, active); + else if (isTimedOut(active, getHitTimeout(), now)) { sendCancelledMessage(player); activeBreaks.remove(uuid, active); } + else if (plugin.getConfigFile().isBreakOtherGravesActionBarMessage()) sendProgress(player, active); + } + active = ownerActiveBreaks.get(uuid); + if (active != null) { + Grave grave = getGraveByKey(active.getGraveId()); + if (!eligible(player, grave) || !grave.isOwner(player)) ownerActiveBreaks.remove(uuid, active); + else if (isTimedOut(active, getHitTimeoutOwner(), now)) { sendCancelledMessageOwner(player); ownerActiveBreaks.remove(uuid, active); } + else { + if (plugin.getConfigFile().isBreakOwnGraveActionBarMessage()) sendProgress(player, active); + if (now - active.getStartTime() >= getRequiredBreakTimeOwner().getMillis() + && ownerActiveBreaks.remove(uuid, active)) { recordOwnerCompletion(player); completeOwnerBreak(player, grave); } + } + } + } + private boolean eligible(Player player, Grave grave) { + return player.isOnline() && plugin.isLiveGrave(grave) && grave.getDistance(player) >= 0 && grave.getDistance(player) <= 7; + } /** * Handles a hit on an owner's grave. Uses BreakOwnGraves config values. @@ -313,7 +272,7 @@ public boolean handleOwnerHit(Player player, Grave grave) { return false; } - if (grave.getGravesConfig().isDestroyed() || !grave.isValid()) { + if (!plugin.isLiveGrave(grave)) { return false; } @@ -405,7 +364,7 @@ private void sendCancelledMessageOwner(Player player) { * @param grave Grave */ private void completeOwnerBreak(Player player, Grave grave) { - if (grave == null || !grave.isValid() || grave.getGravesConfig().isDestroyed()) { + if (grave == null || !plugin.isLiveGrave(grave)) { return; } @@ -426,6 +385,7 @@ private void recordOwnerCompletion(final Player player) { final UUID playerUUID = player.getUniqueId(); ownerLastCompleted.put(playerUUID, System.currentTimeMillis()); + // Cleanup changes only UUID timestamps and must not depend on a live entity. plugin.getBukkitScheduler().runTaskLater(plugin, () -> { Long last = ownerLastCompleted.get(playerUUID); if (last != null) { @@ -462,20 +422,32 @@ private ParsedDuration getHitTimeoutOwner() { * @param player Player * @param grave Grave */ - private void completeBreak(Player player, Grave grave) { - if (grave == null || !grave.isValid() || grave.getGravesConfig().isDestroyed()) { + private void completeBreak(Player player, Grave grave, ActiveGraveBreak active) { + if (active.getAuthorizationGeneration() != authorizationGeneration.get() || !eligible(player, grave) || !grave.legacyBreakAllowed(player)) { return; } - grave.claim(player); + grave.claimAfterTimedBreak(player, new CompletedBreak(player, grave, active)); - if (plugin.getConfigFile().isBreakOtherGravesSendMessage()) { - player.sendMessage( - MessageAPI.colorize(MessageAPI.replacePlaceHolder(plugin.getConfigFile().getFormatGraveBrokeOther(), - "%player%", grave.getGravesConfig().getPlayerName()))); - } } + /** Only the manager can mint this proof after the timed hit path completes. */ + public final class CompletedBreak { + private final UUID playerId; + private final String graveId; + private final long expectedGeneration, elapsedMillis; + private CompletedBreak(Player player, Grave grave, ActiveGraveBreak active) { + playerId = player.getUniqueId(); graveId = buildGraveKey(grave); + expectedGeneration = active.getAuthorizationGeneration(); elapsedMillis = System.currentTimeMillis() - active.getStartTime(); + } + public boolean allows(Player player, Grave grave) { + return plugin.getPlayerBreakManager() == PlayerBreakManager.this + && authorizationGeneration.get() == expectedGeneration && playerId.equals(player.getUniqueId()) + && graveId.equals(buildGraveKey(grave)) && elapsedMillis >= getRequiredBreakTime().getMillis() + && eligible(player, grave) && grave.legacyBreakAllowed(player); + } + } + /** * Sends a start message to the player. * @@ -568,7 +540,7 @@ private Grave getGraveByKey(String graveKey) { return null; } - for (Grave grave : plugin.getGraves()) { + for (Grave grave : plugin.activeGraveSnapshot()) { if (grave != null && grave.getGravesConfig() != null && graveKey.equals(buildGraveKey(grave))) { return grave; } @@ -616,5 +588,6 @@ public static class ActiveGraveBreak { * Last hit time in milliseconds. */ private long lastHitTime; + private long authorizationGeneration; } -} \ No newline at end of file +} diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/commands/CommandLoader.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/commands/CommandLoader.java index 6b5e901..e68af25 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/commands/CommandLoader.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/commands/CommandLoader.java @@ -24,9 +24,16 @@ import com.bencodez.advancedcore.api.command.CommandHandler; import com.bencodez.advancedcore.api.inventory.BInventory; +import com.bencodez.advancedcore.api.inventory.BInventoryButton; +import com.bencodez.advancedcore.api.inventory.BInventory.ClickEvent; +import com.bencodez.advancedcore.api.item.ItemBuilder; +import com.bencodez.simpleapi.command.TabCompleteHandle; +import com.bencodez.simpleapi.command.TabCompleteHandler; import com.bencodez.gravestonesplus.GraveStonesPlus; import com.bencodez.gravestonesplus.events.GraveRemoveReason; import com.bencodez.gravestonesplus.graves.Grave; +import com.bencodez.gravestonesplus.graves.GraveCreationTask; +import com.bencodez.gravestonesplus.graves.TrustedGraveAccess; import com.bencodez.gravestonesplus.storage.GravesConfig; import net.md_5.bungee.api.chat.TextComponent; @@ -61,13 +68,32 @@ public ArrayList helpText(CommandSender sender) { } public void loadCommands() { + // Registered placeholder names are also the bundled dispatcher's argument grammar. + TabCompleteHandler.getInstance().addTabCompleteOption(new TabCompleteHandle("(GraveHelper)") { + @Override public void reload() { updateReplacements(); } + @Override public void updateReplacements() { + ArrayList names = new ArrayList<>(); + for (Player player : Bukkit.getOnlinePlayers()) names.add(player.getName()); + setReplace(names); + } + }.updateOnLoginLogout()); + TabCompleteHandler.getInstance().addTabCompleteOption("(GraveAccessKind)", "track", "recover"); + TabCompleteHandler.getInstance().addTabCompleteOption("(GraveDuration)", "30m", "2h", "7d"); plugin.getCommands().add( new CommandHandler(plugin, new String[] { "Reload" }, "GraveStonesPlus.Reload", "Reload the plugin") { @Override public void execute(CommandSender sender, String[] args) { - plugin.reload(); - sendMessage(sender, "&cGraveStonesPlus reloaded"); + plugin.reloadAsync().whenComplete((ignored, failure) -> { + Runnable reply = () -> { + if (sender instanceof Player player && (!plugin.isEnabled() || !player.isOnline() + || !player.hasPermission("GraveStonesPlus.Reload"))) return; + if (failure == null) sendMessage(sender, "&cGraveStonesPlus reloaded"); + else sendMessage(sender, "&cGraveStonesPlus reload failed; inspect the server log"); + }; + if (sender instanceof Player player) plugin.getBukkitScheduler().runTask(plugin, reply, player); + else plugin.getBukkitScheduler().runTask(plugin, reply); + }); } }); @@ -131,13 +157,12 @@ public void execute(CommandSender sender, String[] args) { @Override public void execute(CommandSender sender, String[] args) { - List graves = plugin.getGraves((Player) sender); - ArrayList msg = new ArrayList(); - msg.add("Current graves:"); - for (Grave gr : graves) { - msg.add(gr.getGraveMessage()); - } - sendMessage(sender, msg); + if (!(sender instanceof Player player)) return; + ownerDispatch(player, "GraveStonesPlus.Graves").accept(() -> { + BInventory inv = new BInventory("Accessible Graves"); + for (Grave gr : plugin.getAccessibleGraves(player)) inv.addButton(gr.getNavigationGUIItem(player)); + inv.openInventory(player); + }); } }); @@ -308,7 +333,8 @@ public void run() { @Override public void execute(CommandSender sender, String[] args) { - Grave grave = plugin.getLatestGrave((Player) sender); + Grave grave = plugin.getGraveNavigation().target((Player) sender); + if (grave == null) grave = plugin.getLatestGrave((Player) sender); if (grave != null) { grave.giveCompass((Player) sender); } else { @@ -320,16 +346,19 @@ public void execute(CommandSender sender, String[] args) { plugin.getCommands().add(new CommandHandler(plugin, new String[] { "ClaimGrave" }, "GraveStonesPlus.ClaimGrave", "Claim Grave nearby", false) { - @Override - public void execute(CommandSender sender, String[] args) { - Player p = (Player) sender; - List graves = plugin.getGraves(p); + @Override + public void execute(CommandSender sender, String[] args) { + if (!(sender instanceof Player p)) return; + ownerDispatch(p, "GraveStonesPlus.ClaimGrave").accept(() -> { + List graves = plugin.activeGraveSnapshot(); if (graves != null) { for (Grave grave : graves) { + if (!plugin.isLiveGrave(grave) || plugin.getGraveClaimLedger().isBlocked( + com.bencodez.gravestonesplus.graves.GraveIdentity.of(grave.getGravesConfig()))) continue; double distance = grave.getDistance(p); if (distance < plugin.getConfigFile().getGraveClaimDistance() && distance >= 0) { - grave.removeSkull(); - grave.claim(p); + if (!plugin.canRecover(grave, p) && !grave.legacyClaimAllowed(p)) continue; + grave.claimNearby(p); return; } } @@ -337,7 +366,8 @@ public void execute(CommandSender sender, String[] args) { } else { sendMessage(sender, "&cYou don't have a grave"); } - } + }); + } }); plugin.getCommands().add(new CommandHandler(plugin, new String[] { "ViewGrave" }, "GraveStonesPlus.ViewGrave", @@ -396,6 +426,109 @@ public void execute(CommandSender sender, String[] args) { } }); + plugin.getCommands().add(new CommandHandler(plugin, new String[] { "Track" }, "GraveStonesPlus.Track", "Track the grave you are looking at", false) { + @Override public void execute(CommandSender sender, String[] args) { + if (!(sender instanceof Player player)) return; + plugin.getBukkitScheduler().runTask(plugin, () -> { + if (!plugin.isEnabled() || !player.isOnline() || !player.hasPermission("GraveStonesPlus.Track")) return; + Grave grave = targetGrave(player); + if (grave == null || !plugin.getGraveNavigation().select(player, grave)) { sendMessage(sender, "&cYou are not allowed to track that grave."); return; } + sendMessage(sender, "&aSelected grave: " + grave.getNavigationMessage(player)); + }, player); + } + }); + plugin.getCommands().add(new CommandHandler(plugin, new String[] { "TrustList" }, "GraveStonesPlus.Trust", "List helper grants for the grave you are looking at", false) { + @Override public void execute(CommandSender sender, String[] args) { + if (!(sender instanceof Player owner)) return; + ownerDispatch(owner, "GraveStonesPlus.Trust").accept(() -> { + Grave grave = ownerGrave(owner); + if (grave == null || !grave.isOwner(owner) || !plugin.isLiveGrave(grave)) { sendMessage(sender, "&cLook at your grave first."); return; } + for (TrustedGraveAccess grant : plugin.getTrustedGraveAccessStore().list(com.bencodez.gravestonesplus.graves.GraveIdentity.of(grave.getGravesConfig()))) sendMessage(sender, "&7" + grant.getHelper().toString() + " &f" + grant.getKind() + " until " + new java.util.Date(grant.getExpiresAt())); + }); + } + }); + plugin.getCommands().add(new CommandHandler(plugin, new String[] { "Trust", "(GraveHelper)", "(GraveAccessKind)", "(GraveDuration)" }, "GraveStonesPlus.Trust", "Grant bounded tracking or recovery access", false) { + @Override public void execute(CommandSender sender, String[] args) { + if (!(sender instanceof Player owner)) return; + var dispatch = ownerDispatch(owner, "GraveStonesPlus.Trust"); + dispatch.accept(() -> { + if (args.length != 4) { sendMessage(sender, "&cUsage: /gravestonesplus trust "); return; } + TrustedGraveAccess.Kind kind; + try { kind = TrustedGraveAccess.Kind.valueOf(args[2].toUpperCase(java.util.Locale.ROOT)); } + catch (IllegalArgumentException invalid) { sendMessage(sender, "&cType must be track or recover."); return; } + try { + long duration = com.bencodez.simpleapi.time.ParsedDuration.parse(args[3], java.util.concurrent.TimeUnit.MILLISECONDS).getMillis(); + if (duration <= 0 || duration > 90L * 24 * 60 * 60 * 1000) throw new IllegalArgumentException(); + java.util.UUID helper = knownHelper(args[1]); + if (helper == null) { sendMessage(sender, "&cUse an online player name or a UUID."); return; } + Grave grave = ownerGrave(owner); if (grave == null || !grave.isOwner(owner) || !plugin.isLiveGrave(grave)) { sendMessage(sender, "&cLook at your grave first."); return; } + grave.grantHelper(owner, helper, kind, System.currentTimeMillis() + duration) + .whenComplete((saved, failure) -> dispatch.accept(() -> sendMessage(sender, + failure == null && Boolean.TRUE.equals(saved) ? "&aGrant saved. Tracking and recovery remain separate." : "&cGrant was not durably saved."))); + } catch (Exception e) { sendMessage(sender, "&cDuration must be valid and no longer than 90 days."); } + }); + } + }); + plugin.getCommands().add(new CommandHandler(plugin, new String[] { "RevokeTrust", "(GraveHelper)", "(GraveAccessKind)" }, "GraveStonesPlus.Trust.Revoke", "Revoke helper access", false) { + @Override public void execute(CommandSender sender, String[] args) { + if (!(sender instanceof Player owner)) return; + var dispatch = ownerDispatch(owner, "GraveStonesPlus.Trust.Revoke"); + dispatch.accept(() -> { + if (args.length != 3) return; + TrustedGraveAccess.Kind kind; + try { kind = TrustedGraveAccess.Kind.valueOf(args[2].toUpperCase(java.util.Locale.ROOT)); } + catch (IllegalArgumentException invalid) { sendMessage(sender, "&cType must be track or recover."); return; } + java.util.UUID helper = knownHelper(args[1]); + if (helper == null) { sendMessage(sender, "&cUse an online player name or a UUID."); return; } + Grave grave = ownerGrave(owner); if (grave == null || !grave.isOwner(owner) || !plugin.isLiveGrave(grave)) return; + grave.revokeHelper(owner, helper, kind) + .whenComplete((removed, failure) -> dispatch.accept(() -> sendMessage(sender, + failure != null || !Boolean.TRUE.equals(removed) ? "&cGrant revocation could not be saved; access is denied locally." : "&aGrant revoked."))); + }); + } + }); + plugin.getCommands().add(new CommandHandler(plugin, new String[] { "InspectItem" }, "GraveStonesPlus.InspectItem", "Explain held-item rule context", false) { + @Override public void execute(CommandSender sender, String[] args) { + if (!(sender instanceof Player player)) return; + ownerDispatch(player, "GraveStonesPlus.InspectItem").accept(() -> { + ItemStack item = player.getInventory().getItemInMainHand(); + if (item == null || item.getType().isAir()) { sendMessage(sender, "&eHold an item to inspect."); return; } + sendMessage(sender, new ArrayList<>(com.bencodez.gravestonesplus.listeners.ItemRuleEvaluator.inspect(plugin, player, item))); + }); + } + }); + + } + + /** The bundled dispatcher is async. Reuse reload's existing authorization fence for owner work and replies. */ + private java.util.function.Consumer ownerDispatch(Player player, String permission) { + var manager = plugin.getPlayerBreakManager(); + long generation = manager == null ? 0 : manager.getAuthorizationGeneration().get(); + return action -> plugin.getBukkitScheduler().runTask(plugin, () -> { + if (!plugin.isEnabled() || !player.isOnline() || !player.hasPermission(permission) + || plugin.getPlayerBreakManager() != manager + || manager != null && manager.getAuthorizationGeneration().get() != generation) return; + action.run(); + }, player); + } + + private UUID knownHelper(String value) { + try { return UUID.fromString(value); } catch (IllegalArgumentException ignored) { } + Player online = Bukkit.getPlayerExact(value); + if (online != null) return online.getUniqueId(); + // Cached lookup avoids profile/network I/O on a gameplay callback. + return null; + } + private Grave ownerGrave(Player player) { + Grave selected = plugin.getGraveNavigation().target(player); + if (selected != null && selected.isOwner(player)) return selected; + return targetGrave(player); + } + + private Grave targetGrave(Player player) { + Block block = player.getTargetBlock(null, 7); + for (Grave grave : plugin.activeGraveSnapshot()) if (plugin.isLiveGrave(grave) && grave.isGrave(block)) return grave; + return null; } public void createTestGrave(UUID uuid, String name, Location deathLocation, @@ -417,25 +550,11 @@ public void createTestGrave(UUID uuid, String name, Location deathLocation, sender.sendMessage("Failed to find air block for grave"); return; } - - if (plugin.numberOfGraves(uuid) >= plugin.getConfigFile().getGraveLimit()) { - Grave oldest = plugin.getOldestGrave(uuid); - if (oldest != null) { - if (plugin.getConfigFile().isDropItemsOnGraveRemoval()) { - oldest.dropItemsOnGround(null); // if your method supports null, otherwise make a dev-safe overload - } - oldest.removeGrave(GraveRemoveReason.LIMIT_REACHED); - } - } - final Location emptyBlockFinal = emptyBlock; - plugin.getBukkitScheduler().runTaskLater(plugin, () -> { - Grave grave = new Grave(plugin, - new GravesConfig(uuid, name, emptyBlockFinal, itemsWithSlot, droppedExp, deathMessage, - System.currentTimeMillis(), false, 0, null, null, - plugin.getConfigFile().getGraveDisplayTypeEnum().name())); - + GravesConfig record = new GravesConfig(uuid, name, emptyBlockFinal, itemsWithSlot, droppedExp, deathMessage, + System.currentTimeMillis(), false, 0, null, null, plugin.getConfigFile().getGraveDisplayTypeEnum().name()); + GraveCreationTask creation = new GraveCreationTask(plugin, record, grave -> { grave.loadChunk(false); grave.createGrave(); grave.createHologram(); @@ -445,7 +564,26 @@ public void createTestGrave(UUID uuid, String name, Location deathLocation, sender.sendMessage("Generated grave for " + name + " at " + emptyBlockFinal.getBlockX() + ", " + emptyBlockFinal.getBlockY() + ", " + emptyBlockFinal.getBlockZ()); - }, 2L, emptyBlockFinal); + }); + creation.schedule(emptyBlockFinal); + boolean accepted = false; + try { + if (creation.priorGraves() >= plugin.getConfigFile().getGraveLimit()) { + Grave oldest = creation.replacement(); + if (oldest != null) { + if (plugin.getConfigFile().isDropItemsOnGraveRemoval()) { + if (!oldest.admitLimitRemoval(null)) { + sender.sendMessage("Grave creation declined: limit payout admission unavailable"); + return; + } + } else oldest.removeGrave(GraveRemoveReason.LIMIT_REACHED); + } + } + creation.accept(); + accepted = true; + } finally { + if (!accepted) creation.cancel(); + } } private HashMap generateRandomItems() { diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/config/Config.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/config/Config.java index f58f4db..3096a89 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/config/Config.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/config/Config.java @@ -30,6 +30,19 @@ public Config(AdvancedCorePlugin plugin) { super(plugin, new File(plugin.getDataFolder(), "Config.yml"), true); } + private boolean suppressMigrationPersistence; + private Config(AdvancedCorePlugin plugin, org.bukkit.configuration.file.FileConfiguration prepared) { + super(plugin, new File(plugin.getDataFolder(), "Config.yml"), plugin.getBukkitScheduler(), false); + suppressMigrationPersistence = true; + setData(prepared); + loadValues(); + suppressMigrationPersistence = false; + } + /** Owner-thread preparation only: no file read or publication. */ + public static Config prepare(AdvancedCorePlugin plugin, org.bukkit.configuration.file.FileConfiguration data) { + return new Config(plugin, data); + } + @Override public void loadValues() { migrateLegacyConfigKeys(); @@ -79,7 +92,7 @@ private void migrateLegacyConfigKeys() { changed = true; } - if (changed) { + if (changed && !suppressMigrationPersistence) { saveData(); } } @@ -179,6 +192,17 @@ private void migrateLegacyConfigKeys() { @ConfigDataInt(path = "GraveClaimDistance") private int graveClaimDistance = 10; + @Getter + @ConfigDataInt(path = "Reminders.ProtectionMinutes") + private int protectionReminderMinutes = 0; + + @Getter + @ConfigDataInt(path = "Reminders.ExpiryMinutes") + private int expiryReminderMinutes = 0; + + public long getProtectionReminderMillis() { return Math.max(0L, protectionReminderMinutes) * 60_000L; } + public long getExpiryReminderMillis() { return Math.max(0L, expiryReminderMinutes) * 60_000L; } + @Getter @ConfigDataInt(path = "PercentageDrops") private int percentageDrops = 100; @@ -297,4 +321,4 @@ public GraveDisplayType getGraveDisplayTypeEnum() { @Getter @ConfigDataString(path = "Format.Grave.Bottom") private String formatGraveBottom = "Reason: %reason%"; -} \ No newline at end of file +} diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/events/GraveClaimEvent.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/events/GraveClaimEvent.java index f7371a4..955e02e 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/events/GraveClaimEvent.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/events/GraveClaimEvent.java @@ -8,7 +8,10 @@ /** * Called when a player attempts to claim a grave (receive its items/exp). - * This event is cancellable. + * This event is cancellable. On Folia it runs on the grave region; the + * claimant may belong to another region. Schedule mutable player access on + * the player entity scheduler. Grave item/experience changes are captured + * after the cancellable callbacks and before durable reservation. */ public class GraveClaimEvent extends GraveEvent implements Cancellable { diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/DurableFeatureState.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/DurableFeatureState.java new file mode 100644 index 0000000..f535689 --- /dev/null +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/DurableFeatureState.java @@ -0,0 +1,151 @@ +package com.bencodez.gravestonesplus.graves; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; + +/** Immutable acknowledged cache; only the bounded disk owner reads/writes files. */ +public final class DurableFeatureState { + public interface Disk { + Map load() throws Exception; + void save(Map state) throws Exception; + } + private final FeatureIO io; + private final Disk disk; + private final int limit; + private volatile Map state = Map.of(); + private volatile boolean ready; + private volatile boolean uncertain; + private final CompletableFuture loaded; + public DurableFeatureState(FeatureIO io, Path path, int limit) { this(io, yamlDisk(path, limit), limit); } + public DurableFeatureState(FeatureIO io, Disk disk, int limit) { + this.io = io; this.disk = disk; this.limit = limit; + loaded = io.submit(() -> { + try { + Map found = disk.load(); + if (found.size() > limit) throw new IOException("feature state exceeds capacity"); + state = Map.copyOf(found); ready = true; return true; + } catch (Exception failure) { uncertain = true; return false; } + }); + } + public CompletableFuture loaded() { return loaded; } + public boolean isReady() { return ready && !uncertain; } + public Map snapshot() { return state; } + public FeatureIO.Admission admit() { return isReady() ? io.admit() : null; } + /** Bounded read on the same owner as publication; archive lookups never run on gameplay threads. */ + public CompletableFuture inspect(Function, Boolean> inspection) { + FeatureIO.Admission admitted = admit(); + if (admitted == null) return CompletableFuture.completedFuture(false); + return admitted.submit(() -> isReady() && inspection.apply(state)); + } + public CompletableFuture update(Function, Boolean> mutation) { + FeatureIO.Admission admitted = admit(); + if (admitted == null) return CompletableFuture.completedFuture(false); + return update(admitted, mutation); + } + public CompletableFuture update(FeatureIO.Admission admitted, Function, Boolean> mutation) { + return admitted.submit(() -> { + if (!isReady()) return false; + Map next = new HashMap<>(state); + if (!mutation.apply(next)) return false; + if (next.size() > limit) return false; + try { + disk.save(Map.copyOf(next)); + state = Map.copyOf(next); return true; + } catch (Exception failure) { + // A move/fsync failure can have published the successor. Deny until restart, + // where a strict read reconciles it; never advertise a safe retry here. + uncertain = true; return false; + } + }); + } + @FunctionalInterface + interface DirectoryChannel { FileChannel open(Path directory) throws IOException; } + + public static Disk yamlDisk(Path path, int limit) { + // OpenJDK's native Windows provider cannot obtain directory handles through + // FileChannel.open(READ). Other providers must retain directory fsync. + boolean nativeWindows = path.getFileSystem().provider().getClass().getName() + .equals("sun.nio.fs.WindowsFileSystemProvider"); + return yamlDisk(path, limit, nativeWindows, directory -> FileChannel.open(directory, StandardOpenOption.READ)); + } + + // Per-store seam exercises the real publication without changing global OS state. + static Disk yamlDisk(Path path, int limit, boolean nativeWindows, DirectoryChannel channels) { + return new Disk() { + public Map load() throws Exception { + if (!Files.exists(path)) return Map.of(); + if (Files.size(path) > 4_194_304L) throw new IOException("feature file too large"); + YamlConfiguration yaml = new YamlConfiguration(); yaml.load(path.toFile()); + Map result = new HashMap<>(); + ConfigurationSection entries = yaml.getConfigurationSection("entries"); + if (entries != null) for (String encoded : entries.getKeys(false)) { + if (result.size() >= limit) throw new IOException("feature state exceeds capacity"); + String key = new String(Base64.getUrlDecoder().decode(encoded), StandardCharsets.UTF_8); + Object value = entries.get(encoded); + if (!(value instanceof String)) throw new IOException("invalid feature entry"); + result.put(key, (String) value); + } + // Preserve reminders from the initial uncommitted implementation. + if (entries == null && yaml.contains("sent")) for (String key : yaml.getStringList("sent")) result.put(key, "sent"); + if (entries == null && !yaml.getKeys(false).isEmpty() && !yaml.contains("sent") + && !(yaml.getKeys(false).equals(Set.of("version")) && yaml.getInt("version") == 1)) throw new IOException("unknown feature file"); + return result; + } + public void save(Map state) throws Exception { + YamlConfiguration yaml = new YamlConfiguration(); yaml.set("version", 1); yaml.createSection("entries"); + for (var entry : state.entrySet()) { + String key = Base64.getUrlEncoder().withoutPadding().encodeToString(entry.getKey().getBytes(StandardCharsets.UTF_8)); + yaml.set("entries." + key, entry.getValue()); + } + byte[] bytes = yaml.saveToString().getBytes(StandardCharsets.UTF_8); + if (bytes.length > 4_194_304) throw new IOException("feature file too large"); + publishBytes(path, bytes, nativeWindows, channels); + } + }; + } + + /** Publish an already serialized document; callers retain serialization ownership. */ + public static void publishDocument(Path path, String document) throws IOException { + boolean nativeWindows = path.getFileSystem().provider().getClass().getName() + .equals("sun.nio.fs.WindowsFileSystemProvider"); + publishBytes(path, document.getBytes(StandardCharsets.UTF_8), nativeWindows, + directory -> FileChannel.open(directory, StandardOpenOption.READ)); + } + + private static void publishBytes(Path path, byte[] bytes, boolean nativeWindows, + DirectoryChannel channels) throws IOException { + Files.createDirectories(path.toAbsolutePath().getParent()); + Path temp = Files.createTempFile(path.toAbsolutePath().getParent(), path.getFileName().toString(), ".tmp"); + try { + try (FileChannel channel = FileChannel.open(temp, StandardOpenOption.WRITE)) { + ByteBuffer buffer = ByteBuffer.wrap(bytes); + while (buffer.hasRemaining()) channel.write(buffer); + channel.force(true); + } + Files.move(temp, path, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + synchronizeDirectory(path.toAbsolutePath().getParent(), nativeWindows, channels); + } finally { Files.deleteIfExists(temp); } + } + + private static void synchronizeDirectory(Path parent, boolean nativeWindows, DirectoryChannel channels) throws IOException { + final FileChannel directory; + try { directory = channels.open(parent); } + catch (AccessDeniedException unsupportedHandle) { + // Windows lacks a Java directory handle; the successor file has already + // been forced and atomically replaced. Do not swallow force failures or + // any errors from POSIX/custom providers, missing directories or files. + if (!nativeWindows || !parent.toString().equals(unsupportedHandle.getFile()) || !Files.isDirectory(parent)) + throw unsupportedHandle; + return; + } + try (directory) { directory.force(true); } + } +} diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/FeatureIO.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/FeatureIO.java new file mode 100644 index 0000000..1e1527f --- /dev/null +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/FeatureIO.java @@ -0,0 +1,87 @@ +package com.bencodez.gravestonesplus.graves; + +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Supplier; + +/** One bounded owner for selected feature disk work. Never uses the common pool. */ +public final class FeatureIO implements AutoCloseable { + private final ThreadPoolExecutor worker; + private final Semaphore capacity; + private volatile Thread owner; + private final Object lifecycle = new Object(); + private int reservations; + private boolean closing; + private volatile Runnable drainListener = () -> { }; + public FeatureIO() { this(128); } + public FeatureIO(int capacity) { + this.capacity = new Semaphore(capacity + 1); + worker = new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, + // One physical queue slot per admission, even while the old worker + // frame finishes a drain after releasing its logical admission. + new ArrayBlockingQueue<>(capacity + 1), r -> { + Thread t = new Thread(r, "gsp-feature-io"); t.setDaemon(false); owner = t; return t; + }, new ThreadPoolExecutor.AbortPolicy()); + } + /** Reserves publication capacity before a caller sends a non-replayable message. */ + public Admission admit() { + synchronized (lifecycle) { + if (closing || worker.isShutdown() || !capacity.tryAcquire()) return null; + reservations++; return new Admission(); + } + } + public final class Admission implements AutoCloseable { + private final AtomicBoolean used = new AtomicBoolean(); + public CompletableFuture submit(Supplier action) { + if (!used.compareAndSet(false, true)) return CompletableFuture.failedFuture(new RejectedExecutionException("admission already used")); + CompletableFuture result = new CompletableFuture<>(); + synchronized (lifecycle) { + try { worker.execute(() -> { + T value = null; Throwable failure = null; + try { value = action.get(); } catch (Throwable ex) { failure = ex; } + capacity.release(); + // Recovery of an unrelated backend must not change this publication acknowledgment. + try { notifyDrain(); } catch (RuntimeException ex) { java.util.logging.Logger.getLogger("GraveStonesPlus").severe("Backend recovery drain failed; the completed feature publication remains acknowledged."); } + if (failure == null) result.complete(value); else result.completeExceptionally(failure); + }); } catch (RejectedExecutionException failure) { capacity.release(); result.completeExceptionally(failure); } + finally { reservations--; lifecycle.notifyAll(); } + } + return result; + } + @Override public void close() { + if (used.compareAndSet(false, true)) synchronized (lifecycle) { capacity.release(); reservations--; lifecycle.notifyAll(); } + } + } + public CompletableFuture submit(Supplier action) { + Admission admitted = admit(); + return admitted == null ? CompletableFuture.failedFuture(new RejectedExecutionException("feature worker unavailable")) : admitted.submit(action); + } + public CompletableFuture barrier() { return submit(() -> null); } + /** One storage owner flushes its coalesced recovery snapshot after older work. */ + public void onDrain(Runnable listener) { drainListener = listener; } + public boolean hasQueuedWork() { return !worker.getQueue().isEmpty(); } + private void notifyDrain() { if (!hasQueuedWork()) drainListener.run(); } + public boolean isTerminated() { return worker.isTerminated(); } + @Override public void close() { + if (Thread.currentThread() == owner) throw new IllegalStateException("Disk owner cannot wait for its own shutdown"); + boolean interrupted = false; + synchronized (lifecycle) { + closing = true; + // A ticket obtained before sending must remain publishable during shutdown. + while (reservations > 0) { + try { lifecycle.wait(); } catch (InterruptedException failure) { interrupted = true; } + } + // If full, the last admitted task performs the same final drain pass. + try { worker.execute(this::notifyDrain); } catch (RejectedExecutionException alreadyFullOrClosed) { } + worker.shutdown(); + } + try { + // Returning is the shutdown completion boundary. Do not discard accepted + // writes after an arbitrary timeout; slow storage makes shutdown wait. + while (!worker.isTerminated()) { + try { worker.awaitTermination(2, TimeUnit.SECONDS); } + catch (InterruptedException failure) { interrupted = true; } + } + } finally { if (interrupted) Thread.currentThread().interrupt(); } + } +} diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/Grave.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/Grave.java index a533e14..067272d 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/Grave.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/Grave.java @@ -3,6 +3,7 @@ import java.util.ArrayList; import java.util.Date; import java.util.HashMap; +import java.util.List; import java.util.Map.Entry; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ThreadLocalRandom; @@ -69,6 +70,7 @@ public class Grave { * Scheduled expiration task for this grave. */ private ScheduledFuture expirationTask; + private long expirationGeneration; @Getter @Setter @@ -77,10 +79,15 @@ public class Grave { private final GraveStonesPlus plugin; @Getter - private boolean remove = false; + private volatile boolean remove = false; + private volatile boolean claimInProgress; + @Getter private volatile boolean limitPayoutPending; + private java.lang.ref.WeakReference limitRecipient = new java.lang.ref.WeakReference<>(null); + private volatile long expiryDeadline; + private GraveMarkerCleanup.Ticket cleanupTicket; @Getter - private ItemDisplay itemDisplay; + private volatile ItemDisplay itemDisplay; private boolean chunkLoaded = false; @@ -101,7 +108,9 @@ public Grave(GraveStonesPlus plugin, GravesConfig gravesConfig) { * @param block Block */ public void loadBlockMeta(Block block) { - MiscUtils.getInstance().setBlockMeta(block, "Grave", this); + block.removeMetadata("Grave", plugin); + block.setMetadata("Grave", new org.bukkit.metadata.FixedMetadataValue(plugin, this)); + GraveMarkerCleanup.tag(plugin, block, GraveIdentity.of(gravesConfig)); } /** @@ -129,6 +138,21 @@ private void callEventSync(final Event event) { * @param task If true, run chunk load in a scheduled task */ public void loadChunk(boolean task) { + if (plugin.getBukkitScheduler().getFoliaLib().isFolia()) { + // Folia's force-loaded flag belongs to the global region, not this + // chunk's owner. Its location scheduler holds a chunk ticket while + // work is pending; retain the existing six-second window that way + // without changing persistent world force-load state. + Runnable load = () -> { + if (!plugin.isEnabled()) return; + gravesConfig.getLocation().getBlock().getChunk().load(false); + plugin.getBukkitScheduler().runTaskLater(plugin, () -> {}, 20 * 6, + gravesConfig.getLocation()); + }; + if (task) plugin.getBukkitScheduler().runTask(plugin, load, gravesConfig.getLocation()); + else load.run(); + return; + } if (task) { plugin.getBukkitScheduler().runTask(plugin, new Runnable() { @@ -248,6 +272,7 @@ public void run() { if (itemDisplay != null) { getGravesConfig().setDisplayUUID(itemDisplay.getUniqueId()); itemDisplay.getPersistentDataContainer().set(plugin.getKey(), PersistentDataType.INTEGER, 1); + itemDisplay.getPersistentDataContainer().set(GraveMarkerCleanup.identityKey(plugin), PersistentDataType.STRING, GraveIdentity.of(gravesConfig)); } block.setType(Material.BARRIER); @@ -319,25 +344,49 @@ public void onClick(Player player) { /** * Removes nearby hologram entities associated with the grave. */ - public void removeHologramsAround() { - try { - Location hologramLocation = gravesConfig.getLocation().getBlock().getLocation().clone().add(.5, 0, .5); - for (Entity entity : hologramLocation.getWorld().getNearbyEntities(hologramLocation, 1, 3, 1)) { - if (entity.getType().equals(EntityType.ARMOR_STAND) - || entity.getType().equals(EntityType.ITEM_DISPLAY)) { - if (entity.getPersistentDataContainer().has(plugin.getKey(), PersistentDataType.INTEGER)) { - Integer value = entity.getPersistentDataContainer().get(plugin.getKey(), - PersistentDataType.INTEGER); - if (value != null && value.intValue() == 1) { - entity.remove(); - } - } - } - } - } catch (Exception e) { - plugin.debug(e); - } - } + public void removeHologramsAround() { + try { + removeHologram(); + Location center = gravesConfig.getLocation().getBlock().getLocation().clone().add(.5, 0, .5); + String identity = GraveIdentity.of(gravesConfig); + for (Entity entity : center.getWorld().getNearbyEntities(center, 1, 3, 1)) { + // Nearby entities can straddle region boundaries. Inspect and remove on their owner. + plugin.getBukkitScheduler().runTask(plugin, () -> reconcileSurvivingHologram(entity, center, identity), entity); + } + } catch (Exception failure) { plugin.debug(failure); } + } + + private void reconcileSurvivingHologram(Entity entity, Location center, String identity) { + if (!plugin.isEnabled()) return; + if (entity.getType() != EntityType.ARMOR_STAND && entity.getType() != EntityType.ITEM_DISPLAY) return; + // Restoration may adopt this display after scheduling nearby cleanup. Recheck at + // execution, including a live successor's adoption of the same physical display. + if (entity.getType() == EntityType.ITEM_DISPLAY && ((!remove && !gravesConfig.isDestroyed() && entity == itemDisplay) + || plugin.activeGraveSnapshot().stream().anyMatch(grave -> !grave.isRemove() + && !grave.getGravesConfig().isDestroyed() && entity == grave.getItemDisplay()))) return; + var data = entity.getPersistentDataContainer(); + String tagged = data.get(GraveMarkerCleanup.identityKey(plugin), PersistentDataType.STRING); + if (identity.equals(tagged)) { entity.remove(); return; } + // Upgrade only the old plugin-tagged stands at the four exact native hologram positions. + // Never adopt a successor identity or an arbitrary nearby/untagged entity. + if (tagged != null || entity.getType() != EntityType.ARMOR_STAND + || !Integer.valueOf(1).equals(data.get(plugin.getKey(), PersistentDataType.INTEGER))) return; + Location actual = entity.getLocation(); + if (actual == null || actual.getWorld() != center.getWorld() + || Math.abs(actual.getX() - center.getX()) > 0.000001 + || Math.abs(actual.getZ() - center.getZ()) > 0.000001) return; + double y = actual.getY() - center.getY(); + if (Math.abs(y - 1.5) > 0.000001 && Math.abs(y - 1.25) > 0.000001 + && Math.abs(y - 1) > 0.000001 && Math.abs(y + 2) > 0.000001) return; + // The matching position is in this entity's chunk. Revalidate current marker ownership + // after scheduler admission so an old callback cannot adopt a replacement grave's stands. + if (remove || gravesConfig.isDestroyed() || !center.getWorld().isChunkLoaded(center.getBlockX() >> 4, center.getBlockZ() >> 4)) return; + boolean current = center.getBlock().getMetadata("Grave").stream() + .anyMatch(value -> value.getOwningPlugin() == plugin && value.value() == this); + if (!current) return; + data.set(GraveMarkerCleanup.identityKey(plugin), PersistentDataType.STRING, identity); + entity.remove(); + } /** * Creates the hologram lines above the grave. @@ -354,51 +403,85 @@ public void createHologram() { placeholders.put("reason", gravesConfig.getDeathMessage()); if (topHologram != null) { - topHologram.kill(); + releaseHologram(topHologram); } - topHologram = new Hologram(hologramLocation.add(0, 1.5, 0), + topHologram = newManagedHologram(hologramLocation.add(0, 1.5, 0), PlaceholderUtils.replacePlaceHolder(plugin.getConfigFile().getFormatGraveTop(), placeholders), true, - false, plugin.getKey(), 1, "Grave", this); + false); if (middleHologram != null) { - middleHologram.kill(); + releaseHologram(middleHologram); } - middleHologram = new Hologram(hologramLocation.subtract(0, .25, 0), + middleHologram = newManagedHologram(hologramLocation.subtract(0, .25, 0), PlaceholderUtils.replacePlaceHolder(plugin.getConfigFile().getFormatGraveMiddle(), placeholders), true, - false, plugin.getKey(), 1, "Grave", this); + false); if (bottomHologram != null) { - bottomHologram.kill(); + releaseHologram(bottomHologram); } - bottomHologram = new Hologram(hologramLocation.subtract(0, .25, 0), + bottomHologram = newManagedHologram(hologramLocation.subtract(0, .25, 0), PlaceholderUtils.replacePlaceHolder(plugin.getConfigFile().getFormatGraveBottom(), placeholders), true, - false, plugin.getKey(), 1, "Grave", this); + false); + for (Hologram hologram : new Hologram[] { topHologram, middleHologram, bottomHologram }) if (hologram != null) + tagHologram(hologram, 0); checkGlowing(); } + /** AdvancedCore may defer entity construction; tag only once the actual entity exists. */ + void tagHologram(Hologram hologram, int attempt) { + if (hologram == null || !plugin.isEnabled()) return; + org.bukkit.entity.ArmorStand stand = hologram.getArmorStand(); + boolean retired = remove || gravesConfig.isDestroyed() || hologram != topHologram && hologram != middleHologram + && hologram != bottomHologram && hologram != glowingHologram; + if (retired) releaseHologram(hologram); + if (stand != null) { + if (!retired) stand.getPersistentDataContainer().set(GraveMarkerCleanup.identityKey(plugin), PersistentDataType.STRING, GraveIdentity.of(gravesConfig)); + } else if (attempt < 5) plugin.getBukkitScheduler().runTaskLater(plugin, () -> tagHologram(hologram, attempt + 1), 1L, gravesConfig.getLocation()); + } + private void tagDisplay() { + if (itemDisplay != null) itemDisplay.getPersistentDataContainer().set(GraveMarkerCleanup.identityKey(plugin), PersistentDataType.STRING, GraveIdentity.of(gravesConfig)); + } + /** * Removes the hologram for this grave. */ public void removeHologram() { if (topHologram != null) { - topHologram.kill(); + releaseHologram(topHologram); topHologram = null; } if (middleHologram != null) { - middleHologram.kill(); + releaseHologram(middleHologram); middleHologram = null; } if (bottomHologram != null) { - bottomHologram.kill(); + releaseHologram(bottomHologram); bottomHologram = null; } if (glowingHologram != null) { - glowingHologram.kill(); + releaseHologram(glowingHologram); glowingHologram = null; } } + /** All grave wrappers share AC's mutable registry; world work remains on the grave owner. */ + private Hologram newManagedHologram(Location location, String name, boolean marker, boolean glowing) { + synchronized (plugin.getHologramHandler()) { + return new Hologram(location, name, marker, glowing, plugin.getKey(), 1, "Grave", this); + } + } + + /** AC kill() uses a global sync handoff; unregister separately from entity-owned removal. */ + private void releaseHologram(Hologram hologram) { + synchronized (plugin.getHologramHandler()) { plugin.getHologramHandler().remove(hologram, false); } + org.bukkit.entity.ArmorStand stand = hologram.getArmorStand(); + if (stand != null) { + try { plugin.getBukkitScheduler().runTask(plugin, stand::remove, stand); } + catch (RuntimeException rejected) { plugin.debug(rejected); } // Exact PDC cleanup remains journaled. + } + } + /** * Checks if the grave marker is valid based on its stored display type. * @@ -419,10 +502,31 @@ public boolean isValid() { } } - public String getGraveMessage() { - Location loc = gravesConfig.getLocation(); - return "Location: " + loc.getWorld().getName() + " (" + loc.getBlockX() + "," + loc.getBlockY() + "," - + loc.getBlockZ() + ") Time of death: " + new Date(gravesConfig.getTime()); + /** Describe retained positions without resolving or loading an unavailable world. */ + public String getLocationDescription() { + Location loc = gravesConfig.getLocation(); + if (loc == null) return "location unavailable"; + org.bukkit.World world = loc.getWorld(); + String name = world == null ? "world unavailable" + + (gravesConfig.getWorldId() == null ? "" : " [" + gravesConfig.getWorldId() + "]") : world.getName(); + return name + " (" + loc.getBlockX() + "," + loc.getBlockY() + "," + loc.getBlockZ() + ")"; + } + + public String getGraveMessage() { + return "Location: " + getLocationDescription() + " Time of death: " + new Date(gravesConfig.getTime()); + } + + public String getNavigationMessage(Player player) { + Location target = gravesConfig.getLocation(); + if (target == null || target.getWorld() == null) return "world unavailable"; + target = target.clone(); + Location source = player.getLocation(); + if (source != null) source = source.clone(); + if (source == null || source.getWorld() == null || !source.getWorld().getUID().equals(target.getWorld().getUID())) + return "world " + target.getWorld().getName() + " (different world)"; + double dx = target.getX() - source.getX(), dz = target.getZ() - source.getZ(); + String vertical = target.getY() > source.getY() ? "above" : target.getY() < source.getY() ? "below" : "level"; + return target.getWorld().getName() + ", " + Math.round(Math.sqrt(dx * dx + dz * dz)) + " blocks, " + vertical; } public boolean isOwner(String player) { @@ -434,48 +538,115 @@ public boolean isOwner(String player) { * * @param reason Removal reason */ - public void removeGrave(final GraveRemoveReason reason) { - // Always handle remove on main thread because timers call from async thread - if (!Bukkit.isPrimaryThread()) { - plugin.getBukkitScheduler().runTask(plugin, () -> { - removeGrave(reason); - }, gravesConfig.getLocation()); - return; - } - - GraveRemoveEvent removeEvent = new GraveRemoveEvent(this, - reason == null ? GraveRemoveReason.MANUAL_REMOVE : reason); - callEventSync(removeEvent); - if (removeEvent.isCancelled()) { - return; - } - - remove = true; - - gravesConfig.setDestroyed(true); - gravesConfig.setDestroyedTime(System.currentTimeMillis()); - - plugin.getBukkitScheduler().runTask(GraveStonesPlus.plugin, new Runnable() { - - @Override - public void run() { - // Only remove the ItemDisplay if this grave is a display-entity grave - if (getStoredDisplayTypeOrDefault() == GraveDisplayType.DISPLAY_ENTITY) { - if (itemDisplay != null) { - itemDisplay.remove(); - itemDisplay = null; - } - } - - gravesConfig.getLocation().getBlock().removeMetadata("Grave", plugin); - gravesConfig.getLocation().getBlock().setType(Material.AIR); - } - }, gravesConfig.getLocation()); - - removeHologram(); - removeTimer(); - plugin.removeGrave(this); - } + public void removeGrave(final GraveRemoveReason reason) { + if (reason == GraveRemoveReason.LIMIT_REACHED && !plugin.isLiveGrave(this) + && plugin.getGraveRestoration() != null + && plugin.getGraveRestoration().hasPending(GraveIdentity.of(gravesConfig))) { + plugin.getGraveRestoration().requestLimitRemoval(this, false); + return; + } + // Existing Bukkit callers observe the cancellable state transition before + // returning. Folia's global thread is not the grave region owner. + if (Bukkit.isPrimaryThread() && !plugin.getBukkitScheduler().getFoliaLib().isFolia()) { + if (removeState(reason, () -> true)) + scheduleMarkerCleanup(); + return; + } + plugin.getBukkitScheduler().runTask(plugin, () -> removeOwned(reason), gravesConfig.getLocation()); + } + private void removeOwned(GraveRemoveReason reason) { + if (removeState(reason, () -> true)) cleanupMarker(); + } + /** Admit player authority on the actor owner; dispatch grave events on the marker owner. */ + public void removeByPlayer(Player player, GraveRemoveReason reason) { + if (player == null) return; + plugin.getBukkitScheduler().runTask(plugin, () -> { + if (!plugin.isEnabled() || !player.isOnline() || !plugin.isLiveGrave(this) + || (!isOwner(player) && !canAdminister(player))) return; + // Like recreation, player authority is admitted immediately before + // the region handoff. Do not read a distant Player from that region. + plugin.getBukkitScheduler().runTask(plugin, () -> { + if (!removeState(reason, () -> plugin.isEnabled() && plugin.isLiveGrave(this))) return; + cleanupMarker(); + try { + plugin.getBukkitScheduler().runTask(plugin, () -> { + if (plugin.isEnabled() && player.isOnline()) + player.sendMessage(MessageAPI.colorize("&cGrave removed")); + }, player); + } catch (RuntimeException retired) { + // Removal already completed. Retiring feedback cannot undo it. + plugin.debug("Grave removal feedback could not be scheduled."); + } + }, gravesConfig.getLocation()); + }, player); + } + private synchronized boolean removeState(GraveRemoveReason reason, java.util.function.BooleanSupplier authorized) { + if (remove || claimInProgress || !expirationEligible(reason) || !authorized.getAsBoolean()) return false; + GraveMarkerCleanup cleanup = plugin.getMarkerCleanup(); + GraveMarkerCleanup.Ticket ticket = cleanup == null ? null : cleanup.prepare(gravesConfig); + if (cleanup != null && ticket == null) { + if (reason == GraveRemoveReason.EXPIRED) retryExpiration(expiryDeadline); + return false; + } + long expiringDeadline = expiryDeadline; + GraveRemoveEvent event = new GraveRemoveEvent(this, reason == null ? GraveRemoveReason.MANUAL_REMOVE : reason); + try { Bukkit.getPluginManager().callEvent(event); } + catch (RuntimeException failure) { if (ticket != null) ticket.close(); throw failure; } + if (event.isCancelled()) { + if (ticket != null) ticket.close(); + if (reason == GraveRemoveReason.EXPIRED && expiryDeadline == expiringDeadline + && expiringDeadline <= System.currentTimeMillis()) expiryDeadline = 0; + return false; + } + if (claimInProgress || remove || !expirationEligible(reason) || !authorized.getAsBoolean()) { if (ticket != null) ticket.close(); return false; } + cleanupTicket = ticket; + remove = true; gravesConfig.setDestroyed(true); gravesConfig.setDestroyedTime(System.currentTimeMillis()); + plugin.getGraveNavigation().invalidate(GraveIdentity.of(gravesConfig)); + plugin.getTrustedGraveAccessStore().revokeForGraveAsync(GraveIdentity.of(gravesConfig)); + if (limitPayoutPending) { + limitPayoutPending = false; limitRecipient.clear(); + plugin.getGraveClaimLedger().cancelQueuedLimit(GraveIdentity.of(gravesConfig)); + } + removeTimer(); plugin.removeGrave(this); return true; + } + /** Admission pressure is transient; retry one current deadline without retaining a queue of tasks. */ + private synchronized void retryExpiration(long deadline) { + if (!plugin.isEnabled() || remove || gravesConfig.isDestroyed() || deadline <= 0 || expiryDeadline != deadline) return; + removeTimer(); + long generation = expirationGeneration; + expirationTask = plugin.getTimer().schedule(() -> { + plugin.getBukkitScheduler().runTask(plugin, () -> { + synchronized (Grave.this) { + if (!plugin.isEnabled() || generation != expirationGeneration || deadline != expiryDeadline + || remove || gravesConfig.isDestroyed()) return; + removeOwned(GraveRemoveReason.EXPIRED); + } + }, gravesConfig.getLocation()); + }, 5L, TimeUnit.SECONDS); + } + private boolean expirationEligible(GraveRemoveReason reason) { + if (reason != GraveRemoveReason.EXPIRED) return true; + var restoration = plugin.getGraveRestoration(); + return (restoration == null || !restoration.isConflicted(GraveIdentity.of(gravesConfig))) + && expiryDeadline > 0 && expiryDeadline <= System.currentTimeMillis(); + } + public java.util.concurrent.CompletableFuture grantHelper(Player owner, java.util.UUID helper, + TrustedGraveAccess.Kind kind, long expiresAt) { + synchronized (plugin.getTrustedGraveAccessStore()) { + if (owner == null || !owner.isOnline() || !isOwner(owner) || !owner.hasPermission("GraveStonesPlus.Trust") + || !plugin.isLiveGrave(this)) return java.util.concurrent.CompletableFuture.completedFuture(false); + return plugin.getTrustedGraveAccessStore().grantAsync(GraveIdentity.of(gravesConfig), helper, kind, expiresAt); + } + } + + public java.util.concurrent.CompletableFuture revokeHelper(Player owner, java.util.UUID helper, + TrustedGraveAccess.Kind kind) { + synchronized (plugin.getTrustedGraveAccessStore()) { + if (owner == null || !owner.isOnline() || !isOwner(owner) || !owner.hasPermission("GraveStonesPlus.Trust.Revoke") + || !plugin.isLiveGrave(this)) return java.util.concurrent.CompletableFuture.completedFuture(false); + return plugin.getTrustedGraveAccessStore().revokeAsync(GraveIdentity.of(gravesConfig), helper, kind); + } + } /** * Removes the grave using {@link GraveRemoveReason#MANUAL_REMOVE}. @@ -495,13 +666,15 @@ private void schedule(ParsedDuration duration) { return; } - if (expirationTask != null && !expirationTask.isDone()) { - expirationTask.cancel(false); - } - final long durationMillis = duration.getMillis(); long deathTime = gravesConfig.getTime(); - long expireTime = deathTime + durationMillis; + long expireTime = GraveDeadlines.deadline(deathTime, durationMillis); + if (expireTime <= 0) { + plugin.debug("Invalid grave expiry deadline; retaining the existing expiry schedule."); + return; + } + expiryDeadline = expireTime; + if (expirationTask != null && !expirationTask.isDone()) expirationTask.cancel(false); long delay = expireTime - System.currentTimeMillis(); if (delay <= 0) { @@ -513,8 +686,8 @@ private void schedule(ParsedDuration duration) { @Override public void run() { - long currentExpireTime = gravesConfig.getTime() + durationMillis; - if (currentExpireTime < System.currentTimeMillis()) { + long currentExpireTime = expiryDeadline; + if (currentExpireTime > 0 && currentExpireTime <= System.currentTimeMillis()) { removeGrave(GraveRemoveReason.EXPIRED); } } @@ -528,6 +701,7 @@ public void run() { */ public void checkTimeLimit(int timeLimit) { if (timeLimit <= 0) { + removeTimer(); expiryDeadline = 0; return; } @@ -542,26 +716,14 @@ public void checkTimeLimit(int timeLimit) { * as {@code 30m}, {@code 2h}, or {@code 7d} */ public void checkTimeLimit(String timeLimitRaw) { - if (timeLimitRaw == null || timeLimitRaw.trim().isEmpty()) { - return; - } - - String trimmed = timeLimitRaw.trim(); - - try { - if (trimmed.matches("\\d+")) { - checkTimeLimit(Integer.parseInt(trimmed)); - return; - } - - ParsedDuration duration = ParsedDuration.parse(trimmed, TimeUnit.MINUTES); - checkTimeLimit(duration); - } catch (NumberFormatException e) { - plugin.debug("Invalid numeric grave time limit: " + timeLimitRaw); - } catch (Exception e) { - plugin.debug("Invalid grave time limit duration: " + timeLimitRaw); - } - } + final ParsedDuration replacement; + try { replacement = GraveTimeLimit.parse(timeLimitRaw); } + catch (RuntimeException invalid) { + plugin.debug("Invalid grave time limit; retaining the existing expiry schedule."); + return; + } + checkTimeLimit(replacement); + } /** * Checks the configured grave time limit using a parsed duration and removes or @@ -571,67 +733,100 @@ public void checkTimeLimit(String timeLimitRaw) { */ public void checkTimeLimit(ParsedDuration timeLimit) { if (timeLimit == null) { + removeTimer(); expiryDeadline = 0; return; } long durationMillis = timeLimit.getMillis(); if (durationMillis <= 0) { + removeTimer(); expiryDeadline = 0; return; } long deathTime = gravesConfig.getTime(); - long expireTime = deathTime + durationMillis; - - if (expireTime < System.currentTimeMillis()) { + long expireTime = GraveDeadlines.deadline(deathTime, durationMillis); + if (expireTime <= 0) { + plugin.debug("Invalid grave expiry deadline; retaining the existing expiry schedule."); + return; + } + expiryDeadline = expireTime; + + if (expireTime > 0 && expireTime <= System.currentTimeMillis()) { removeGrave(GraveRemoveReason.EXPIRED); } else { schedule(timeLimit); } } - public BInventoryButton getGUIItem() { - Location loc = gravesConfig.getLocation(); + public long getExpiryDeadline() { return expiryDeadline; } + + /** Render on the viewer's entity owner; navigation lore snapshots their current position. */ + public BInventoryButton getNavigationGUIItem(Player viewer) { return guiItem(true, viewer); } + public BInventoryButton getGUIItem() { return guiItem(false, null); } + private BInventoryButton guiItem(boolean navigation, Player menuViewer) { BInventoryButton b = new BInventoryButton( new ItemBuilder(Material.PLAYER_HEAD).setSkullOwner(Bukkit.getOfflinePlayer(gravesConfig.getUuid())) .setName("&3&l" + gravesConfig.getPlayerName()) - .addLoreLine("&3" + "Location: " + loc.getWorld().getName() + " (" + loc.getBlockX() + "," - + loc.getBlockY() + "," + loc.getBlockZ() + ")") + .addLoreLine("&3Location: " + getLocationDescription()) .addLoreLine("&3" + "Time of death: " + new Date(gravesConfig.getTime())) - .addLoreLine("&b" + "Click to Teleport").addLoreLine("&4Shift right click to remove") - .addLoreLine("&cShift left click to view items")) { - - @Override - public void onClick(ClickEvent clickEvent) { - Grave grave = (Grave) getData("grave"); - if (clickEvent.getClick().equals(ClickType.SHIFT_RIGHT)) { - grave.removeGrave(GraveRemoveReason.MANUAL_REMOVE); - clickEvent.getWhoClicked().sendMessage(MessageAPI.colorize("&cGrave removed")); - } else if (clickEvent.getClick().equals(ClickType.SHIFT_LEFT)) { - openGUIWithItems(clickEvent.getPlayer()); - } else { - Location loc = grave.getGravesConfig().getLocation(); - Player p = clickEvent.getWhoClicked(); - plugin.getBukkitScheduler().runTask(plugin, new Runnable() { - - @Override - public void run() { - p.teleport(loc.clone().add(0, 1, 0)); - } - }, loc); - } - } + .addLoreLine(navigation ? "&bClick to select compass; right click to recover nearby" : "&bClick to Teleport").addLoreLine("&4Shift right click to remove") + .addLoreLine("&cShift left click to view items") + .addLoreLine(navigation ? "&7" + getNavigationMessage(menuViewer) : "")) { + + @Override + public void onClick(ClickEvent clickEvent) { + Grave grave = (Grave) getData("grave"); + Player viewer = clickEvent.getPlayer(); + ClickType click = clickEvent.getClick(); + // BInventory clicks are asynchronous by default. Only immutable + // click intent crosses this hop; authorization and player reads wait. + plugin.getBukkitScheduler().runTask(plugin, () -> { + if (!plugin.isEnabled() || !viewer.isOnline()) return; + if (!plugin.isLiveGrave(grave) || (!plugin.canTrack(grave, viewer) && !canAdminister(viewer))) { + viewer.sendMessage(MessageAPI.colorize("&cThat grave is no longer accessible.")); return; + } + if (click == ClickType.SHIFT_RIGHT) { + if (!grave.isOwner(viewer) && !canAdminister(viewer)) { + viewer.sendMessage(MessageAPI.colorize("&cOnly the grave owner can remove this grave.")); return; + } + grave.removeByPlayer(viewer, GraveRemoveReason.MANUAL_REMOVE); + } else if (click == ClickType.SHIFT_LEFT) { + if (!grave.canViewContents(viewer)) { + viewer.sendMessage(MessageAPI.colorize("&cYou can track this grave but cannot recover its contents.")); return; + } + grave.openGUIWithItems(viewer); + } else if (navigation) { + if (click == ClickType.RIGHT) { + if (!viewer.hasPermission("GraveStonesPlus.ClaimGrave")) { + viewer.sendMessage(MessageAPI.colorize("&cYou don't have permission to claim graves.")); return; + } + double distance = grave.getDistance(viewer); + if (distance >= 0 && distance < plugin.getConfigFile().getGraveClaimDistance()) grave.claimNearby(viewer); + else viewer.sendMessage("\u00a7cMove near the grave to recover it."); + } else { + if (!viewer.hasPermission("GraveStonesPlus.Compass")) { + viewer.sendMessage(MessageAPI.colorize("&cYou don't have permission to use compasses.")); return; + } + grave.giveCompass(viewer); + } + } else if (grave.isOwner(viewer) || canAdminister(viewer)) { + Location destination = grave.getGravesConfig().getLocation().clone().add(0, 1, 0); + if (plugin.getBukkitScheduler().getFoliaLib().isFolia()) + plugin.getBukkitScheduler().getFoliaLib().getImpl().teleportAsync(viewer, destination); + else viewer.teleport(destination); + } + }, viewer); + } }; b.addData("grave", this); return b; } public BInventoryButton getGUIItemBroken() { - Location loc = gravesConfig.getLocation(); BInventoryButton b = new BInventoryButton( new ItemBuilder(Material.PLAYER_HEAD).setSkullOwner(Bukkit.getOfflinePlayer(gravesConfig.getUuid())) .setName("&3&l" + gravesConfig.getPlayerName()) - .addLoreLine("&3" + "Location: " + loc.getWorld().getName() + " (" + loc.getBlockX() + "," - + loc.getBlockY() + "," + loc.getBlockZ() + ")") + .addLoreLine("&3Location: " + getLocationDescription()) .addLoreLine("&3" + "Time of death: " + new Date(gravesConfig.getTime())) .addLoreLine("&b" + "Click to Teleport").addLoreLine("&4Shift right click to create") .addLoreLine("&cShift left click to view items") @@ -640,31 +835,25 @@ public BInventoryButton getGUIItemBroken() { @Override public void onClick(ClickEvent clickEvent) { Grave grave = (Grave) getData("grave"); - if (clickEvent.getClick().equals(ClickType.SHIFT_RIGHT)) { - loadChunk(true); - grave.createGrave(); - plugin.getBukkitScheduler().runTask(plugin, new Runnable() { - - @Override - public void run() { - grave.createHologram(); - } - }, grave.getGravesConfig().getLocation()); - plugin.recreateBrokenGrave(grave); - clickEvent.getWhoClicked().sendMessage(MessageAPI.colorize("&cGrave readded")); - } else if (clickEvent.getClick().equals(ClickType.SHIFT_LEFT)) { - openGUIWithItems(clickEvent.getPlayer()); - } else { - Location loc = grave.getGravesConfig().getLocation(); - Player p = clickEvent.getWhoClicked(); - plugin.getBukkitScheduler().runTask(plugin, new Runnable() { - - @Override - public void run() { - p.teleport(loc.clone().add(0, 1, 0)); - } - }, loc); - } + Player actor = clickEvent.getPlayer(); + ClickType intent = clickEvent.getClick(); + if (actor == null) return; + // Native BInventory dispatch is asynchronous; only click intent crosses this hop. + plugin.getBukkitScheduler().runTask(plugin, () -> { + if (!plugin.isEnabled() || !actor.isOnline() || !grave.canAdministerHistorical(actor)) return; + if (intent == ClickType.SHIFT_RIGHT) { + plugin.recreateBrokenGrave(grave, actor).whenComplete((restored, failure) -> + plugin.getBukkitScheduler().runTask(plugin, () -> { + if (plugin.isEnabled() && actor.isOnline()) actor.sendMessage(Boolean.TRUE.equals(restored) && failure == null + ? MessageAPI.colorize("&cGrave readded") + : "\u00a7cThis grave could not be recreated; recovery records or unavailable storage may block it."); + }, actor)); + } else if (intent == ClickType.SHIFT_LEFT) { + grave.openGUIWithItems(actor); + } else { + grave.teleportHistorical(actor); + } + }, actor); } }; @@ -672,33 +861,28 @@ public void run() { return b; } - public String getAllGraveMessage() { - Location loc = gravesConfig.getLocation(); - return "Player: " + gravesConfig.getPlayerName() + ", Location: " + loc.getWorld().getName() + " (" - + loc.getBlockX() + "," + loc.getBlockY() + "," + loc.getBlockZ() + ") Time of death: " - + new Date(gravesConfig.getTime()); - } - - public double getDistance(Player p) { - if (p.getWorld().getUID().equals(gravesConfig.getLocation().getWorld().getUID())) { - return gravesConfig.getLocation().distance(p.getLocation()); - } - return -1; - } - - public boolean isGrave(Block clicked) { - Block currentBlock = gravesConfig.getLocation().getBlock(); - if (currentBlock.getLocation().getWorld().getUID().equals(clicked.getWorld().getUID())) { - if (currentBlock.getX() == clicked.getX()) { - if (currentBlock.getY() == clicked.getY()) { - if (currentBlock.getZ() == clicked.getZ()) { - return true; - } - } - } - } - return false; - } + public String getAllGraveMessage() { + return "Player: " + gravesConfig.getPlayerName() + ", " + getGraveMessage(); + } + + /** Caller owns the player; one location snapshot keeps world and distance consistent. */ + public double getDistance(Player p) { + Location target = gravesConfig.getLocation(); + if (p == null || target == null || target.getWorld() == null) return -1; + target = target.clone(); + Location source = p.getLocation(); + if (source == null || source.getWorld() == null) return -1; + source = source.clone(); + if (!source.getWorld().getUID().equals(target.getWorld().getUID())) return -1; + return target.distance(source); + } + + public boolean isGrave(Block clicked) { + Location loc = gravesConfig.getLocation(); + return clicked != null && loc != null && loc.getWorld() != null + && loc.getWorld().getUID().equals(clicked.getWorld().getUID()) + && loc.getBlockX() == clicked.getX() && loc.getBlockY() == clicked.getY() && loc.getBlockZ() == clicked.getZ(); + } /** * For display entities, try to find the ItemDisplay and recreate it if missing. @@ -741,35 +925,43 @@ public void checkBlockDisplayAndFixIfMissing() { if (itemDisplay != null) { gravesConfig.setDisplayUUID(itemDisplay.getUniqueId()); itemDisplay.getPersistentDataContainer().set(plugin.getKey(), PersistentDataType.INTEGER, 1); + itemDisplay.getPersistentDataContainer().set(GraveMarkerCleanup.identityKey(plugin), PersistentDataType.STRING, GraveIdentity.of(gravesConfig)); } } catch (Exception e) { plugin.debug(e); } } + tagDisplay(); } - public void giveCompass(Player p) { - ItemStack compass = new ItemStack(Material.COMPASS); - CompassMeta meta = (CompassMeta) compass.getItemMeta(); - meta.setLodestone(getGravesConfig().getLocation()); - meta.setLodestoneTracked(false); - NamespacedKey key = new NamespacedKey(plugin, "gravecompass"); - meta.getPersistentDataContainer().set(key, PersistentDataType.LONG, getGravesConfig().getTime()); - meta.setDisplayName(p.getName() + " last grave: "); - - compass.setItemMeta(meta); - - final ItemStack itemToGive = compass; - plugin.getBukkitScheduler().runTask(plugin, new Runnable() { - - @Override - public void run() { - plugin.getFullInventoryHandler().giveItem(p, itemToGive); - } - }, p.getLocation()); - } - - public void removeTimer() { + public void giveCompass(Player player) { + plugin.getBukkitScheduler().runTask(plugin, () -> { + if (!plugin.isEnabled() || !player.isOnline() || !player.hasPermission("GraveStonesPlus.Compass") + || !plugin.getGraveNavigation().select(player, this)) return; + ItemStack compass = new ItemStack(Material.COMPASS); + CompassMeta meta = (CompassMeta) compass.getItemMeta(); + meta.setLodestone(gravesConfig.getLocation()); meta.setLodestoneTracked(false); + meta.getPersistentDataContainer().set(new NamespacedKey(plugin, "gravecompass"), PersistentDataType.LONG, gravesConfig.getTime()); + meta.getPersistentDataContainer().set(new NamespacedKey(plugin, "graveidentity"), PersistentDataType.STRING, GraveIdentity.of(gravesConfig)); + meta.setDisplayName(gravesConfig.getPlayerName() + " grave"); meta.setLore(java.util.List.of(getNavigationMessage(player))); + compass.setItemMeta(meta); + // AC giveItem queues another unguarded delivery, including retained overflow. + // Issue this optional navigation item at the authorization boundary instead. + var overflow = player.getInventory().addItem(compass); + if (!overflow.isEmpty()) { + if (plugin.getOptions().isDropOnFullInv()) { + for (ItemStack item : overflow.values()) player.getWorld().dropItem(player.getLocation(), item); + } else { + player.sendMessage("\u00a7cMake room in your inventory, then request the grave compass again."); + return; + } + } + player.sendMessage("\u00a7aSelected grave: " + getNavigationMessage(player)); + }, player); + } + + public synchronized void removeTimer() { + expirationGeneration++; if (expirationTask != null && !expirationTask.isDone()) { expirationTask.cancel(false); } @@ -779,7 +971,7 @@ public boolean canPlayerBreak(Player player) { if (isOwner(player)) { return true; } - return player.hasPermission("GraveStonesPlus.BreakOtherGraves"); + return plugin.canRecover(this, player) || player.hasPermission("GraveStonesPlus.BreakOtherGraves"); } public boolean canNonOwnerClaim() { @@ -869,7 +1061,8 @@ public void checkGlowing() { Location glowLoc = graveLoc.getBlock().getLocation().clone().add(0.5, -2, 0.5); if (glowingHologram == null) { - glowingHologram = new Hologram(glowLoc, "", false, true, plugin.getKey(), 1, "Grave", this); + glowingHologram = newManagedHologram(glowLoc, "", false, true); + tagHologram(glowingHologram, 0); } glowingHologram.glow(true); @@ -880,44 +1073,230 @@ public void checkGlowing() { private void removeGlowingHologram() { if (glowingHologram != null) { - glowingHologram.kill(); + releaseHologram(glowingHologram); glowingHologram = null; } } - public void createBrokenGrave() { - gravesConfig.setDestroyed(false); - gravesConfig.setDestroyedTime(0); - createGrave(); - createHologram(); - } - - public void dropItemsOnGround(Player p) { - Location loc = getGravesConfig().getLocation(); - ArrayList items = new ArrayList(getGravesConfig().getItems().values()); - int chance = plugin.getConfigFile().getPercentageDrops(); - final ArrayList itemsToDrop = new ArrayList(); - for (ItemStack item : items) { - if (item != null && !item.getType().equals(Material.AIR)) { - if (chance == 100 || ThreadLocalRandom.current().nextInt(100) < chance) { - itemsToDrop.add(item); - } - } - } - plugin.getBukkitScheduler().runTask(plugin, new Runnable() { - - @Override - public void run() { - for (ItemStack item : itemsToDrop) { - if (item != null && !item.getType().equals(Material.AIR)) { - loc.getWorld().dropItem(loc, item); - } - } - } - }, loc); - } - - public void openGUIWithItems(Player p) { + public void markRecreated() { removeTimer(); expiryDeadline = 0; remove = false; } + /** + * Legacy fire-and-forget entry point. Route it through the plugin wrapper so + * the same list and storage transition used by administrative recovery is + * applied after the marker has been recreated. The historical API + * intentionally creates a marker. + */ + public void createBrokenGrave() { plugin.recreateBrokenGrave(this, null, true); } + /** Check durable history before any marker, destroyed-state or active-list mutation. */ + public java.util.concurrent.CompletableFuture recreateAsync(Player actor, boolean createMarker) { + final String identity = GraveIdentity.of(gravesConfig); + synchronized (this) { + if (!recreationStateAllowed(identity)) return java.util.concurrent.CompletableFuture.completedFuture(false); + } + var result = new java.util.concurrent.CompletableFuture(); + plugin.getGraveClaimLedger().canRecreate(identity).thenCompose(allowed -> { + // Removal can become historical even when its first revocation failed or + // could not enter the worker. Reconcile those persisted grants before + // exposing this identity as live again, including after restart. + synchronized (this) { + if (!Boolean.TRUE.equals(allowed) || !recreationStateAllowed(identity)) + return java.util.concurrent.CompletableFuture.completedFuture(false); + return plugin.getTrustedGraveAccessStore().revokeForGraveAsync(identity); + } + }).whenComplete((allowed, failure) -> { + if (failure != null || !Boolean.TRUE.equals(allowed)) { result.complete(false); return; } + Runnable scheduleCommit = () -> { + try { + plugin.getBukkitScheduler().getFoliaLib().getImpl().runAtLocation(gravesConfig.getLocation(), task -> { + synchronized (this) { + if (!recreationStateAllowed(identity)) { result.complete(false); return; } + markRecreated(); gravesConfig.setDestroyed(false); gravesConfig.setDestroyedTime(0); + if (createMarker) { + createGrave(); + plugin.getBukkitScheduler().runTask(plugin, () -> { if (!remove && plugin.isEnabled()) createHologram(); }, gravesConfig.getLocation()); + } + result.complete(true); + } + }).whenComplete((unused, error) -> { if (error != null) result.completeExceptionally(error); }); + } catch (RuntimeException rejected) { result.completeExceptionally(rejected); } + }; + if (actor == null) scheduleCommit.run(); + else try { + // Player authority belongs to the actor owner; all marker mutation belongs to the grave region. + plugin.getBukkitScheduler().getFoliaLib().getImpl().runAtEntityWithFallback(actor, ignored -> { + if (!plugin.isEnabled() || !actor.isOnline() || !canAdministerHistorical(actor)) { result.complete(false); return; } + scheduleCommit.run(); + }, () -> result.complete(false)).whenComplete((scheduled, error) -> { + if (error != null || scheduled != com.bencodez.simpleapi.folialib.enums.EntityTaskResult.SUCCESS) result.complete(false); + }); + } catch (RuntimeException rejected) { result.completeExceptionally(rejected); } + }); + return result; + } + + private boolean recreationStateAllowed(String identity) { + return identity != null && plugin.isEnabled() && gravesConfig.isDestroyed() && !claimInProgress + && identity.equals(GraveIdentity.of(gravesConfig)) && !plugin.getGraveClaimLedger().isBlocked(identity); + } + public void teleportHistorical(Player actor) { + if (actor == null) return; + Location target = gravesConfig.getLocation().clone().add(0, 1, 0); + plugin.getBukkitScheduler().runTask(plugin, () -> { + if (actor.isOnline() && plugin.isEnabled() && canAdministerHistorical(actor)) plugin.getBukkitScheduler().getFoliaLib().getImpl().teleportAsync(actor, target); + }, actor); + } + + /** + * Legacy item-only drop API. This does not remove the grave, pay experience, + * or reserve a recovery; callers remain responsible for the grave lifecycle. + * Items are captured before the region handoff so later edits cannot change + * an already requested drop. Player is retained for source compatibility. + */ + public void dropItemsOnGround(Player recipient) { + Location location = gravesConfig.getLocation(); + if (location == null || location.getWorld() == null) return; + Location target = location.clone(); + var items = GravePayout.select(gravesConfig.getItems(), plugin.getConfigFile().getPercentageDrops(), + () -> ThreadLocalRandom.current().nextInt(100)); + plugin.getBukkitScheduler().runTask(plugin, () -> { + if (!plugin.isEnabled()) return; + for (ItemStack item : items.values()) target.getWorld().dropItem(target, item); + }, target); + } + + /** Limit-removal payout shares the claim ledger, so queued drops cannot race recovery. */ + public void dropAndRemoveForLimit(Player recipient) { + if (!plugin.isLiveGrave(this) && plugin.getGraveRestoration() != null + && plugin.getGraveRestoration().requestLimitRemoval(this, true)) return; + queueLimitPayout(recipient, false); + } + /** Capture callers must obtain worker admission before consuming replacement contents. + * A deferred grave also queues its payout now; its marker still waits for restoration. */ + public boolean admitLimitRemoval(Player recipient) { + boolean pending = !plugin.isLiveGrave(this); + if (pending && (plugin.getGraveRestoration() == null + || !plugin.getGraveRestoration().canQueuePendingLimit(this))) return false; + return queueLimitPayout(recipient, pending); + } + /** Restoration invokes this only after AVAILABLE archive admission on the marker owner. */ + void queuePendingLimitPayout() { queueLimitPayout(null, true); } + void removePendingForLimit() { removeOwned(GraveRemoveReason.LIMIT_REACHED); } + private boolean queueLimitPayout(Player recipient, boolean pendingRecord) { + String identity = GraveIdentity.of(gravesConfig); + // Do not acquire restoration ownership while holding the grave monitor: + // restoration can check expiry while it owns its own record map. + boolean pendingAdmission = pendingRecord && plugin.getGraveRestoration().canQueuePendingLimit(this); + synchronized (this) { + if (remove || claimInProgress || limitPayoutPending + || (!plugin.isLiveGrave(this) && !pendingAdmission) + || plugin.getGraveClaimLedger().isBlocked(identity)) return false; + claimInProgress = true; + limitRecipient = new java.lang.ref.WeakReference<>(recipient); + } + final String evidence; + try { evidence = GraveClaimLedger.evidence(com.bencodez.gravestonesplus.storage.GraveStorageManager.snapshot(gravesConfig)); } + catch (RuntimeException invalid) { releaseClaim(); return false; } + var admitted = plugin.getGraveClaimLedger().admitLimit(identity, gravesConfig.getUuid(), evidence); + if (admitted == null) { releaseClaim(); return false; } + admitted.whenComplete((saved, failure) -> { + if (failure == null && Boolean.TRUE.equals(saved)) { + limitPayoutPending = true; + plugin.getGraveRestoration().retainLimit(this); + } + releaseClaim(); + if (limitPayoutPending) plugin.getGraveRestoration().retry(); + }); + return true; + } + void markLimitPayoutPending() { limitPayoutPending = true; } + /** Invoked by restoration only on a loaded chunk's owning scheduler. */ + void resumeLimitPayout() { + String identity = GraveIdentity.of(gravesConfig); + synchronized (this) { + if (remove || claimInProgress || !limitPayoutPending || !plugin.isEnabled()) return; + if (expiryDeadline > 0 && expiryDeadline <= System.currentTimeMillis()) { + removeOwned(GraveRemoveReason.EXPIRED); return; + } + if (!plugin.isLiveGrave(this)) return; + if (!prepareCleanup()) return; + claimInProgress = true; + } + final com.bencodez.gravestonesplus.storage.GravesConfig captured; + try { captured = com.bencodez.gravestonesplus.storage.GraveStorageManager.snapshot(gravesConfig); } + catch (RuntimeException invalid) { releaseClaim(); return; } + plugin.getGraveClaimLedger().reserveQueuedLimit(identity, gravesConfig.getUuid()).whenComplete((saved, failure) -> { + if (failure != null || !Boolean.TRUE.equals(saved)) { releaseClaim(); return; } + java.util.concurrent.atomic.AtomicBoolean started = new java.util.concurrent.atomic.AtomicBoolean(); + Runnable retired = () -> { if (started.compareAndSet(false, true)) deferLimitPayout(identity); }; + try { + plugin.getBukkitScheduler().getFoliaLib().getImpl().runAtLocation(captured.getLocation(), task -> { + if (!started.compareAndSet(false, true)) return; + Location loc = captured.getLocation(); + // The disk reservation can outlive the grave's remaining lifetime. + // No world payout has occurred, so return ownership to the queued + // record before releaseClaim dispatches normal cancellable expiry. + if (expiryDeadline > 0 && expiryDeadline <= System.currentTimeMillis()) { deferLimitPayout(identity); return; } + if (!plugin.isEnabled() || !plugin.isLiveGrave(this) || loc.getWorld() == null + || !loc.getWorld().isChunkLoaded(loc.getBlockX() >> 4, loc.getBlockZ() >> 4)) { deferLimitPayout(identity); return; } + GraveRemoveEvent event = new GraveRemoveEvent(this, GraveRemoveReason.LIMIT_REACHED); + Bukkit.getPluginManager().callEvent(event); + if (event.isCancelled()) { + limitPayoutPending = false; cancelClaim(identity); return; + } + // Listeners may run until the deadline or change the time limit. + if (expiryDeadline > 0 && expiryDeadline <= System.currentTimeMillis()) { deferLimitPayout(identity); return; } + if (!plugin.isLiveGrave(this)) { + limitPayoutPending = false; cancelClaim(identity); return; + } + if (!plugin.isEnabled() || !loc.getWorld().isChunkLoaded(loc.getBlockX() >> 4, loc.getBlockZ() >> 4)) { + deferLimitPayout(identity); return; + } + try { + for (ItemStack item : GravePayout.select(captured.getItems(), plugin.getConfigFile().getPercentageDrops(), + () -> ThreadLocalRandom.current().nextInt(100)).values()) loc.getWorld().dropItem(loc, item); + if (captured.getExp() > 0) loc.getWorld().spawn(loc, org.bukkit.entity.ExperienceOrb.class).setExperience(captured.getExp()); + plugin.getGraveClaimLedger().paid(identity, gravesConfig.getUuid()).whenComplete((paid, paidFailure) -> { + if (paidFailure != null || !Boolean.TRUE.equals(paid)) plugin.getLogger().severe("Grave ground payout completed but its completion record could not be saved; reservation prevents replay."); + }); + remove = true; gravesConfig.setDestroyed(true); gravesConfig.setDestroyedTime(System.currentTimeMillis()); + plugin.removeGrave(this); plugin.getTrustedGraveAccessStore().revokeForGraveAsync(identity); + plugin.getGraveNavigation().invalidate(identity); removeTimer(); cleanupMarker(); + Player recipient = limitRecipient.get(); limitRecipient.clear(); + if (recipient != null) plugin.getBukkitScheduler().runTask(plugin, () -> { + if (plugin.isEnabled() && recipient.isOnline()) recipient.sendMessage(MessageAPI.colorize(plugin.getConfigFile().getFormatGraveLimitBreak())); + }, recipient); + } catch (RuntimeException interrupted) { + plugin.getLogger().severe("Grave ground payout interrupted after reservation; replay is blocked and exact contents/XP remain in recovery evidence."); + } finally { releaseClaim(); } + }).whenComplete((result, schedulingFailure) -> { if (schedulingFailure != null) retired.run(); }); + } catch (RuntimeException schedulingFailure) { retired.run(); } + }); + } + private void deferLimitPayout(String identity) { + plugin.getGraveClaimLedger().deferLimitReservation(identity, gravesConfig.getUuid()) + .whenComplete((saved, failure) -> releaseClaim()); + } + + public boolean canAdminister(Player player) { + return player.hasPermission("GraveStonesPlus.Admin") || player.hasPermission("GraveStonesPlus.AllGraves") + || player.hasPermission("GraveStonesPlus.AllGraves.Player"); + } + private boolean canAdministerHistorical(Player player) { + return player != null && (player.hasPermission("GraveStonesPlus.Admin") + || player.hasPermission("GraveStonesPlus.AllBrokenGraves") + || player.hasPermission("GraveStonesPlus.AllBrokenGraves.Player")); + } + public boolean canViewContents(Player player) { + return player != null && (isOwner(player) || plugin.canRecover(this, player) + || canAdminister(player) || gravesConfig.isDestroyed() && canAdministerHistorical(player) + || player.hasPermission("GraveStonesPlus.ViewGrave")); + } + public void openGUIWithItems(Player player) { + if (player == null) return; + plugin.getBukkitScheduler().runTask(plugin, () -> { + if (player.isOnline() && canViewContents(player)) openItemsOwned(player); + }, player); + } + private void openItemsOwned(Player p) { BInventory inv = new BInventory("Grave Items"); for (Entry entry : getGravesConfig().getItems().entrySet()) { switch (entry.getKey().intValue()) { @@ -1000,145 +1379,244 @@ public void onClick(ClickEvent clickEvent) { inv.openInventory(p); } - public void claim(final Player player) { - // Fire claim event (sync). Claim is initiated from sync events/listeners. - GraveClaimEvent claimEvent = new GraveClaimEvent(this, player, isOwner(player)); - callEventSync(claimEvent); - if (claimEvent.isCancelled()) { - return; - } - - final AdvancedCoreUser user = plugin.getUserManager().getUser(player); - user.giveExp(getGravesConfig().getExp()); - - final int chance = plugin.getConfigFile().getPercentageDrops(); - - plugin.getBukkitScheduler().runTaskAsynchronously(plugin, new Runnable() { - - @Override - public void run() { - removeCompass(); - - HashMap toGive = new HashMap(); - for (Entry e : getGravesConfig().getItems().entrySet()) { - if (chance == 100 || ThreadLocalRandom.current().nextInt(100) < chance) { - ItemStack cloned = e.getValue() == null ? null : e.getValue().clone(); - toGive.put(e.getKey(), cloned); - } - } - - plugin.getBukkitScheduler().runTask(plugin, new Runnable() { - - @Override - public void run() { - PlayerInventory inv = player.getInventory(); - ArrayList leftovers = new ArrayList(); - boolean notInCorrectSlot = false; - - for (Entry e : toGive.entrySet()) { - Integer key = e.getKey(); - ItemStack stack = e.getValue(); - if (stack == null || stack.getType().isAir()) { - continue; - } - - if (key.intValue() >= 0) { - ItemStack cur = inv.getItem(key.intValue()); - if (cur == null || cur.getType().isAir()) { - inv.setItem(key.intValue(), stack); - } else { - notInCorrectSlot = true; - leftovers.add(stack); - } - } else { - switch (key.intValue()) { - case -1: - if (inv.getHelmet() == null || inv.getHelmet().getType().isAir()) { - inv.setHelmet(stack); - } else { - notInCorrectSlot = true; - leftovers.add(stack); - } - break; - case -2: - if (inv.getChestplate() == null || inv.getChestplate().getType().isAir()) { - inv.setChestplate(stack); - } else { - notInCorrectSlot = true; - leftovers.add(stack); - } - break; - case -3: - if (inv.getLeggings() == null || inv.getLeggings().getType().isAir()) { - inv.setLeggings(stack); - } else { - notInCorrectSlot = true; - leftovers.add(stack); - } - break; - case -4: - if (inv.getBoots() == null || inv.getBoots().getType().isAir()) { - inv.setBoots(stack); - } else { - notInCorrectSlot = true; - leftovers.add(stack); - } - break; - case -5: - if (inv.getItemInOffHand() == null || inv.getItemInOffHand().getType().isAir()) { - inv.setItemInOffHand(stack); - } else { - notInCorrectSlot = true; - leftovers.add(stack); - } - break; - default: - notInCorrectSlot = true; - leftovers.add(stack); - break; - } - } - } - - if (!leftovers.isEmpty()) { - user.giveItems(leftovers.toArray(new ItemStack[0])); - } - - if (isOwner(player)) { - user.sendMessage(plugin.getConfigFile().getFormatGraveBroke()); - } - if (notInCorrectSlot) { - user.sendMessage(plugin.getConfigFile().getFormatItemsNotInGrave()); - } - - removeHologramsAround(); - removeGrave(GraveRemoveReason.CLAIMED); - } - }); - } - }); + public void claim(final Player player) { + if (player == null) return; + dispatchClaim(player, null, false); + } + /** Menu/command recovery retains its permission and nearby requirement through payout. */ + public void claimNearby(final Player player) { + if (player != null) dispatchClaim(player, null, true); + } + private boolean nearbyClaimAllowed(Player player) { + if (!player.hasPermission("GraveStonesPlus.ClaimGrave")) return false; + double distance = getDistance(player); + return distance >= 0 && distance < plugin.getConfigFile().getGraveClaimDistance(); + } + public void claimAfterTimedBreak(Player player, com.bencodez.gravestonesplus.breaking.PlayerBreakManager.CompletedBreak proof) { + if (player != null && proof != null) dispatchClaim(player, proof, false); + } + private boolean claimAllowed(Player player, com.bencodez.gravestonesplus.breaking.PlayerBreakManager.CompletedBreak proof) { + return plugin.canRecover(this, player) || legacyClaimAllowed(player) || (proof != null && proof.allows(player, this)); + } + private void dispatchClaim(Player player, com.bencodez.gravestonesplus.breaking.PlayerBreakManager.CompletedBreak proof, boolean nearby) { + Runnable admission = () -> beginClaim(player, proof, nearby); + // Preserve the synchronous cancellable callback boundary for callers + // already on ordinary Bukkit's owner thread. + if (!plugin.getBukkitScheduler().getFoliaLib().isFolia() && Bukkit.isPrimaryThread()) admission.run(); + else plugin.getBukkitScheduler().runTask(plugin, admission, player); + } + private void beginClaim(Player player, com.bencodez.gravestonesplus.breaking.PlayerBreakManager.CompletedBreak proof, boolean nearby) { + if (!player.isOnline() || !plugin.isEnabled() || !plugin.isLiveGrave(this) + || !claimAllowed(player, proof) || nearby && !nearbyClaimAllowed(player)) return; + String identity = GraveIdentity.of(gravesConfig); + if (plugin.getGraveClaimLedger().isBlocked(identity)) { + player.sendMessage("\u00a7cThis grave has a pending or completed recovery record; it cannot be paid again."); return; + } + boolean owner = isOwner(player); + java.util.UUID recipient = player.getUniqueId(); + Runnable capture = () -> captureClaim(player, proof, nearby, identity, owner, recipient); + if (!plugin.getBukkitScheduler().getFoliaLib().isFolia()) capture.run(); + else try { plugin.getBukkitScheduler().runTask(plugin, capture, gravesConfig.getLocation()); } + catch (RuntimeException rejected) { + plugin.getLogger().warning("Grave claim region admission failed; no reservation or payout was made."); + } + } + private void captureClaim(Player player, com.bencodez.gravestonesplus.breaking.PlayerBreakManager.CompletedBreak proof, + boolean nearby, String identity, boolean owner, java.util.UUID recipient) { + synchronized (this) { + if (claimInProgress || !plugin.isEnabled() || !plugin.isLiveGrave(this) + || !java.util.Objects.equals(identity, GraveIdentity.of(gravesConfig)) + || plugin.getGraveClaimLedger().isBlocked(identity)) return; + if (!prepareCleanup()) return; + claimInProgress = true; + } + // Events are part of the admission boundary: handlers may amend the live + // grave contents or XP, so capture only after both callbacks have run. + GraveClaimEvent claimEvent = new GraveClaimEvent(this, player, owner); + GraveRemoveEvent removeEvent = new GraveRemoveEvent(this, GraveRemoveReason.CLAIMED); + try { + Bukkit.getPluginManager().callEvent(claimEvent); + if (!claimEvent.isCancelled()) Bukkit.getPluginManager().callEvent(removeEvent); + } catch (RuntimeException callbackFailure) { + releaseClaim(); + plugin.getLogger().warning("Grave claim callback failed; claim remains available without payout."); + return; + } + if (claimEvent.isCancelled() || removeEvent.isCancelled() + || !plugin.isEnabled() || !plugin.isLiveGrave(this)) { + releaseClaim(); return; + } + final com.bencodez.gravestonesplus.storage.GravesConfig captured; + final String evidence; + try { + captured = com.bencodez.gravestonesplus.storage.GraveStorageManager.snapshot(gravesConfig); + evidence = GraveClaimLedger.evidence(captured); + } catch (RuntimeException invalid) { + releaseClaim(); + plugin.getLogger().warning("Grave recovery evidence could not be captured; no payout was made."); return; + } + Runnable reserve = () -> { + if (!player.isOnline() || !plugin.isEnabled() || !plugin.isLiveGrave(this) + || !claimAllowed(player, proof) || nearby && !nearbyClaimAllowed(player)) { releaseClaim(); return; } + reserveClaim(player, proof, nearby, identity, recipient, captured, evidence); + }; + if (!plugin.getBukkitScheduler().getFoliaLib().isFolia()) reserve.run(); + else { + var started = new java.util.concurrent.atomic.AtomicBoolean(); + Runnable retired = () -> { if (started.compareAndSet(false, true)) releaseClaim(); }; + try { + plugin.getBukkitScheduler().getFoliaLib().getImpl().runAtEntityWithFallback(player, task -> { + if (started.compareAndSet(false, true)) reserve.run(); + }, retired).whenComplete((result, failure) -> { + if (failure != null || result != com.bencodez.simpleapi.folialib.enums.EntityTaskResult.SUCCESS) retired.run(); + }); + } catch (RuntimeException rejected) { retired.run(); } + } + } + private void reserveClaim(Player player, com.bencodez.gravestonesplus.breaking.PlayerBreakManager.CompletedBreak proof, + boolean nearby, String identity, java.util.UUID recipient, + com.bencodez.gravestonesplus.storage.GravesConfig captured, String evidence) { + plugin.getGraveClaimLedger().reserve(identity, recipient, evidence).whenComplete((saved, failure) -> { + java.util.concurrent.atomic.AtomicBoolean started = new java.util.concurrent.atomic.AtomicBoolean(); + Runnable retired = () -> { + if (started.compareAndSet(false, true)) { + if (failure == null && Boolean.TRUE.equals(saved)) cancelClaim(identity); else releaseClaim(); + } + }; + try { + plugin.getBukkitScheduler().getFoliaLib().getImpl().runAtEntityWithFallback(player, task -> { + if (!started.compareAndSet(false, true)) return; + if (failure != null || !Boolean.TRUE.equals(saved)) { + releaseClaim(); + player.sendMessage("\u00a7cRecovery was not durably reserved. No payout was made; restart/reconcile storage before retrying."); return; + } + // Serialize the helper authorization boundary with revocation admission. + synchronized (plugin.getTrustedGraveAccessStore()) { + if (!player.isOnline() || !plugin.isEnabled() || !plugin.isLiveGrave(this) + || (!claimAllowed(player, proof) || (nearby && !nearbyClaimAllowed(player)))) { cancelClaim(identity); return; } + try { + java.util.Map payout = GravePayout.select(captured.getItems(), plugin.getConfigFile().getPercentageDrops(), () -> ThreadLocalRandom.current().nextInt(100)); + List> overflow = new ArrayList<>(); + boolean displaced = GravePayout.deliver(player, payout, captured.getExp(), item -> { + var handler = plugin.getFullInventoryHandler(); + if (handler != null) overflow.add(handler.giveItemAsync(player, item).toCompletableFuture()); + else player.getWorld().dropItem(player.getLocation(), item); + }); + if (overflow.isEmpty()) completeClaim(player, identity, displaced); + else java.util.concurrent.CompletableFuture.allOf(overflow.toArray(java.util.concurrent.CompletableFuture[]::new)) + .whenComplete((unused, deliveryFailure) -> finishOverflowClaim(player, identity, displaced, deliveryFailure)); + } catch (RuntimeException payoutFailure) { + plugin.getLogger().severe("Grave recovery interrupted after durable reservation; automatic replay is blocked. Inspect selected-claims.yml and retained grave data."); + player.sendMessage("\u00a7cRecovery was interrupted. This grave is held for administrator reconciliation; do not retry the payout."); + releaseClaim(); + } + } + }, retired).whenComplete((result, schedulingFailure) -> { + if (schedulingFailure != null || result != com.bencodez.simpleapi.folialib.enums.EntityTaskResult.SUCCESS) retired.run(); + }); + } catch (RuntimeException schedulingFailure) { retired.run(); } + }); + } + private void finishOverflowClaim(Player player, String identity, boolean displaced, Throwable deliveryFailure) { + var started = new java.util.concurrent.atomic.AtomicBoolean(); + Runnable retired = () -> { + if (!started.compareAndSet(false, true)) return; + plugin.getLogger().warning("Grave overflow completion could not be acknowledged on its recipient; recovery evidence remains reserved."); + releaseClaim(); + }; + try { + plugin.getBukkitScheduler().getFoliaLib().getImpl().runAtEntityWithFallback(player, task -> { + if (!started.compareAndSet(false, true)) return; + if (deliveryFailure == null && player.isOnline() && plugin.isEnabled()) completeClaim(player, identity, displaced); + else { + plugin.getLogger().warning("Grave overflow delivery was not acknowledged; recovery evidence remains reserved for reconciliation."); + if (player.isOnline()) player.sendMessage("\u00a7cRecovery is held for administrator reconciliation; overflow delivery was not acknowledged."); + releaseClaim(); + } + }, retired).whenComplete((scheduled, failure) -> { + if (failure != null || scheduled != com.bencodez.simpleapi.folialib.enums.EntityTaskResult.SUCCESS) retired.run(); + }); + } catch (RuntimeException schedulingFailure) { retired.run(); } + } + /** Recipient-owner commit boundary, reached only after all selected items and XP are acknowledged. */ + private void completeClaim(Player player, String identity, boolean displaced) { + try { + plugin.getGraveClaimLedger().paid(identity, player.getUniqueId()).whenComplete((paidSaved, paidFailure) -> { + if (paidFailure != null || !Boolean.TRUE.equals(paidSaved)) plugin.getLogger().severe("Grave payout completed but completion record could not be saved; durable reservation still prevents replay."); + }); + remove = true; gravesConfig.setDestroyed(true); gravesConfig.setDestroyedTime(System.currentTimeMillis()); + plugin.removeGrave(this); + plugin.getTrustedGraveAccessStore().revokeForGraveAsync(identity); + plugin.getGraveNavigation().invalidate(identity); removeTimer(); + scheduleMarkerCleanup(); + if (isOwner(player) || plugin.getConfigFile().isBreakOtherGravesSendMessage()) player.sendMessage(MessageAPI.colorize(isOwner(player) + ? plugin.getConfigFile().getFormatGraveBroke() + : MessageAPI.replacePlaceHolder(plugin.getConfigFile().getFormatGraveBrokeOther(), "player", gravesConfig.getPlayerName()))); + if (displaced) player.sendMessage(MessageAPI.colorize(plugin.getConfigFile().getFormatItemsNotInGrave())); + } finally { releaseClaim(); } + } + /** Removal is committed already; region rejection must not abandon its admitted journal slot. */ + private void scheduleMarkerCleanup() { + try { plugin.getBukkitScheduler().runTask(plugin, this::cleanupMarker, gravesConfig.getLocation()); } + catch (RuntimeException rejected) { + // Persist only the captured intent here. World/entity cleanup remains region-owned. + synchronized (this) { + GraveMarkerCleanup.Ticket ticket = cleanupTicket; + if (ticket != null) { cleanupTicket = null; ticket.commit(); } + } + plugin.getLogger().warning("Grave removal completed; marker cleanup is retained while its region scheduler is unavailable."); + } + } + private synchronized boolean prepareCleanup() { + GraveMarkerCleanup cleanup = plugin.getMarkerCleanup(); + if (cleanup == null) return true; + cleanupTicket = cleanup.prepare(gravesConfig); return cleanupTicket != null; + } + private synchronized void releaseClaim() { + claimInProgress = false; + if (!remove && cleanupTicket != null) { cleanupTicket.close(); cleanupTicket = null; } + if (!remove && expiryDeadline > 0 && expiryDeadline <= System.currentTimeMillis()) removeGrave(GraveRemoveReason.EXPIRED); + } + private void cancelClaim(String identity) { + plugin.getGraveClaimLedger().cancel(identity).whenComplete((saved, failure) -> releaseClaim()); + } + private synchronized void cleanupMarker() { + removeHologram(); + GraveMarkerCleanup cleanup = plugin.getMarkerCleanup(); + if (cleanup != null) { + GraveMarkerCleanup.Ticket ticket = cleanupTicket; cleanupTicket = null; + if (ticket != null) ticket.commit(); else cleanup.retain(gravesConfig); + return; + } + // Legacy API/test seam when the selected lifecycle has not initialized. + if (gravesConfig.getLocation().getWorld() == null || !gravesConfig.getLocation().getWorld().isChunkLoaded(gravesConfig.getLocation().getBlockX() >> 4, gravesConfig.getLocation().getBlockZ() >> 4)) return; + if (gravesConfig.getLocation().getWorld() == null) return; + // Cleanup does not force-load a chunk; a stored removed grave is already inaccessible. + Location loc = gravesConfig.getLocation(); + if (!loc.getWorld().isChunkLoaded(loc.getBlockX() >> 4, loc.getBlockZ() >> 4)) return; + Block block = loc.getBlock(); + Object owner = block.getMetadata("Grave").stream().filter(m -> m.getOwningPlugin() == plugin).map(org.bukkit.metadata.MetadataValue::value).findFirst().orElse(null); + if (owner != this) return; + if (itemDisplay != null) { itemDisplay.remove(); itemDisplay = null; } + block.removeMetadata("Grave", plugin); + if (isValid()) block.setType(Material.AIR); + } + /** Invoked only after a matching chunk-load event; never forces a chunk. */ + public void cleanupDeferredMarker() { if (remove) cleanupMarker(); } + + public boolean legacyClaimAllowed(Player player) { + return isOwner(player) || (!plugin.getConfigFile().isBreakOtherGravesRequireBreakTime() && legacyBreakAllowed(player)); + } + /** Rights shared by starting, progressing and completing the physical timed-break path. */ + public boolean legacyBreakAllowed(Player player) { + if (isOwner(player)) return true; + if (!plugin.getConfigFile().isBreakOtherGravesEnabled()) return false; + if (plugin.getConfigFile().isBreakOtherGravesRequirePermission() + && !player.hasPermission("GraveStonesPlus.BreakOtherGraves")) return false; + return canNonOwnerClaim(); } - public void removeCompass() { - NamespacedKey key = new NamespacedKey(plugin, "gravecompass"); - for (Player player : Bukkit.getOnlinePlayers()) { - for (ItemStack item : player.getInventory().getContents()) { - if (item != null && item.getType().equals(Material.COMPASS)) { - if (item.hasItemMeta() && item.getItemMeta() instanceof CompassMeta) { - CompassMeta meta = (CompassMeta) item.getItemMeta(); - if (meta.getPersistentDataContainer().has(key, PersistentDataType.LONG)) { - Long v = meta.getPersistentDataContainer().get(key, PersistentDataType.LONG); - if (v != null - && (v.longValue() == getGravesConfig().getTime() || (meta.getLodestone() != null - && meta.getLodestone().equals(getGravesConfig().getLocation())))) { - player.getInventory().remove(item); - } - } - } - } - } - } - } + public void removeCompass() { plugin.getGraveNavigation().invalidate(GraveIdentity.of(gravesConfig)); } public void checkBlockDisplay() { boolean hasDisplayEntities = true; @@ -1188,5 +1666,6 @@ public void checkBlockDisplay() { plugin.debug(e); } } + tagDisplay(); } -} \ No newline at end of file +} diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveClaimLedger.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveClaimLedger.java new file mode 100644 index 0000000..a8c4a47 --- /dev/null +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveClaimLedger.java @@ -0,0 +1,181 @@ +package com.bencodez.gravestonesplus.graves; + +import java.util.*; +import java.util.concurrent.CompletableFuture; + +/** Serializes all recipients at the grave identity; unresolved reservations never replay. */ +public final class GraveClaimLedger { + private final DurableFeatureState state; + private final java.nio.file.Path archive; + private final CompletableFuture loaded; + private volatile boolean archiveReady; + private final Map recentPaid = new LinkedHashMap<>(); + private final Set pending = new HashSet<>(); + public GraveClaimLedger(DurableFeatureState state) { this(state, null); } + /** Archive completed identities separately, retaining bounded active state and lookup cache. */ + public GraveClaimLedger(DurableFeatureState state, java.nio.file.Path archive) { + this.state = state; this.archive = archive == null ? null : archive.toAbsolutePath().normalize(); + if (archive == null) { + archiveReady = true; loaded = state.loaded(); + } else loaded = state.loaded().thenCompose(ready -> { + if (!ready) return CompletableFuture.completedFuture(false); + return state.update(next -> { + for (var entry : new ArrayList<>(next.entrySet())) if (entry.getValue().startsWith("PAID:")) { + archivePaid(entry.getKey(), entry.getValue()); + next.remove(entry.getKey()); rememberPaid(entry.getKey(), entry.getValue()); + } + return true; + }); + }).handle((ready, failure) -> { archiveReady = failure == null && Boolean.TRUE.equals(ready); return archiveReady; }); + } + public CompletableFuture loaded() { return loaded; } + public boolean isReady() { return archiveReady && state.isReady(); } + public synchronized boolean isBlocked(String grave) { return grave == null || !archiveReady || !state.isReady() || recentPaid.containsKey(grave) || pending.contains(grave) || state.snapshot().containsKey(grave); } + public CompletableFuture canRecreate(String grave) { + if (isBlocked(grave)) return CompletableFuture.completedFuture(false); + return state.inspect(current -> !isBlocked(grave) && !current.containsKey(grave) && !archived(grave)) + .exceptionally(failure -> false); + } + public enum Restoration { AVAILABLE, QUEUED_LIMIT, RESERVED, PAID, UNAVAILABLE } + /** Rejected/failed reads are unknown, never proof of absence or completed payout. */ + public CompletableFuture restoration(String grave) { + if (grave == null || !archiveReady || !state.isReady()) return CompletableFuture.completedFuture(Restoration.UNAVAILABLE); + FeatureIO.Admission admitted = state.admit(); + if (admitted == null) return CompletableFuture.completedFuture(Restoration.UNAVAILABLE); + return admitted.submit(() -> { + if (!state.isReady() || !archiveReady) return Restoration.UNAVAILABLE; + if (archived(grave)) return Restoration.PAID; + String value = state.snapshot().get(grave); + if (value != null) return value.startsWith("PAID:") ? Restoration.PAID + : value.startsWith("QUEUED_LIMIT:") ? Restoration.QUEUED_LIMIT : Restoration.RESERVED; + synchronized (this) { return pending.contains(grave) ? Restoration.RESERVED : Restoration.AVAILABLE; } + }).exceptionally(failure -> Restoration.UNAVAILABLE); + } + public CompletableFuture isArchived(String grave) { + return restoration(grave).thenApply(result -> result == Restoration.PAID); + } + public CompletableFuture reserve(String grave, UUID recipient) { return reserve(grave, recipient, ""); } + public synchronized CompletableFuture reserve(String grave, UUID recipient, String evidence) { + if (recipient == null || evidence == null || evidence.length() > 350_000 || isBlocked(grave) || pending.size() >= 128) return CompletableFuture.completedFuture(false); + pending.add(grave); + // Keep pending on uncertain publication/rejection: no payout, no unsafe retry. + return state.update(next -> { + // This disk lookup is on the serial persistence owner, before any payout. + if (next.containsKey(grave) || archived(grave)) return false; + next.put(grave, "RESERVED:" + recipient + (evidence.isEmpty() ? "" : ":" + evidence)); return true; + }).thenApply(saved -> { + if (!saved && state.isReady()) synchronized (this) { pending.remove(grave); } + return saved; + }); + } + /** A queued ground payout has not performed any gameplay side effect and may resume. */ + public CompletableFuture queueLimit(String grave, UUID recipient, String evidence) { + CompletableFuture admitted = admitLimit(grave, recipient, evidence); + return admitted == null ? CompletableFuture.completedFuture(false) : admitted; + } + /** Null means immediate rejection; a returned future owns a reserved persistence slot. + * Admission is not a durable acknowledgement: callers must retain contents on later failure. */ + public synchronized CompletableFuture admitLimit(String grave, UUID recipient, String evidence) { + if (recipient == null || evidence == null || evidence.length() > 350_000 || isBlocked(grave) || pending.size() >= 128) + return null; + FeatureIO.Admission admission = state.admit(); + if (admission == null) return null; + pending.add(grave); + return state.update(admission, next -> { + if (next.containsKey(grave) || archived(grave)) return false; + next.put(grave, "QUEUED_LIMIT:" + recipient + ":" + evidence); return true; + }).thenApply(saved -> { + if (!saved && state.isReady()) synchronized (this) { pending.remove(grave); } + return saved; + }); + } + /** Publish non-replayable ownership before the first world payout operation. */ + public CompletableFuture reserveQueuedLimit(String grave, UUID recipient) { + return state.update(next -> { + String value = next.get(grave), prefix = "QUEUED_LIMIT:" + recipient + ":"; + if (value == null || !value.startsWith(prefix)) return false; + next.put(grave, "RESERVED:" + recipient + ":" + value.substring(prefix.length())); return true; + }); + } + /** Only used before any item/XP mutation when the owning chunk disappears. */ + public CompletableFuture deferLimitReservation(String grave, UUID recipient) { + return state.update(next -> { + String value = next.get(grave), prefix = "RESERVED:" + recipient + ":"; + if (value == null || !value.startsWith(prefix)) return false; + next.put(grave, "QUEUED_LIMIT:" + recipient + ":" + value.substring(prefix.length())); return true; + }); + } + public CompletableFuture cancelQueuedLimit(String grave) { + return state.update(next -> { + String value = next.get(grave); + if (value == null || !value.startsWith("QUEUED_LIMIT:")) return false; + next.remove(grave); return true; + }).thenApply(saved -> { if (saved) synchronized (this) { pending.remove(grave); } return saved; }); + } + public CompletableFuture cancel(String grave) { + return state.update(next -> { next.remove(grave); return true; }).thenApply(saved -> { + if (saved) synchronized (this) { pending.remove(grave); } return saved; + }); + } + public CompletableFuture paid(String grave, UUID recipient) { + return state.update(next -> { + String existing = next.get(grave); + if (existing == null || !(existing.equals("RESERVED:" + recipient) + || existing.startsWith("RESERVED:" + recipient + ":") || existing.equals("PAID:" + recipient))) return false; + String paid = "PAID:" + recipient; + if (archive == null) next.put(grave, paid); + else { archivePaid(grave, paid); next.remove(grave); } + return true; + }).thenApply(saved -> { + if (saved) synchronized (this) { pending.remove(grave); rememberPaid(grave, "PAID:" + recipient); } return saved; + }); + } + public synchronized String status(String grave) { return recentPaid.containsKey(grave) ? recentPaid.get(grave) : state.snapshot().get(grave); } + private synchronized void rememberPaid(String grave, String value) { + recentPaid.put(grave, value); + if (recentPaid.size() > 512) recentPaid.remove(recentPaid.keySet().iterator().next()); + } + private java.nio.file.Path archiveFile(String grave) { + try { + String hash = java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256") + .digest(grave.getBytes(java.nio.charset.StandardCharsets.UTF_8))); + return archive.resolve(hash + ".yml"); + } catch (java.security.NoSuchAlgorithmException impossible) { throw new IllegalStateException(impossible); } + } + private boolean archived(String grave) { + if (archive == null) return false; + try { + java.nio.file.Path file = archiveFile(grave); + if (java.nio.file.Files.isSymbolicLink(archive) || java.nio.file.Files.isSymbolicLink(file)) + throw new java.io.IOException("unsafe claim archive path"); + if (java.nio.file.Files.notExists(file, java.nio.file.LinkOption.NOFOLLOW_LINKS)) return false; + var record = DurableFeatureState.yamlDisk(file, 1).load(); + String value = record.get(grave); + if (record.size() != 1 || value == null || !value.matches("PAID:[a-f0-9-]{36}")) + throw new java.io.IOException("invalid claim tombstone"); + UUID.fromString(value.substring(5)); + rememberPaid(grave, value); return true; + } catch (Exception failure) { throw new IllegalStateException("Claim archive is unavailable; replay remains blocked", failure); } + } + private void archivePaid(String grave, String value) { + if (archived(grave)) return; + try { + // Publish the tombstone before retiring the active reservation. Backend + // deletion acknowledgments are unnecessary because replay identity is retained. + DurableFeatureState.yamlDisk(archiveFile(grave), 1).save(Map.of(grave, value)); + } catch (Exception failure) { throw new IllegalStateException("Claim tombstone publication failed", failure); } + } + /** Preserve exact slot keys rather than the legacy encoded-array armour remapping. */ + public static String evidence(com.bencodez.gravestonesplus.storage.GravesConfig grave) { + org.bukkit.configuration.file.YamlConfiguration yaml = new org.bukkit.configuration.file.YamlConfiguration(); + yaml.set("identity", GraveIdentity.of(grave)); yaml.set("experience", grave.getExp()); + for (var entry : grave.getItems().entrySet()) { + if (entry.getKey() < -5 || entry.getKey() > 40) throw new IllegalArgumentException("unsupported grave slot"); + if (entry.getValue() != null) yaml.set("items." + entry.getKey(), entry.getValue().serialize()); + } + byte[] bytes = yaml.saveToString().getBytes(java.nio.charset.StandardCharsets.UTF_8); + if (bytes.length > 262_144) throw new IllegalArgumentException("grave recovery evidence exceeds 256 KiB"); + return java.util.Base64.getEncoder().encodeToString(bytes); + } + +} diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveCreationTask.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveCreationTask.java new file mode 100644 index 0000000..c0c1c80 --- /dev/null +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveCreationTask.java @@ -0,0 +1,75 @@ +package com.bencodez.gravestonesplus.graves; + +import org.bukkit.Location; +import com.bencodez.gravestonesplus.GraveStonesPlus; + +/** Internal creation handoff shared by death and administrative test graves. + * A location callback may run before its caller finishes replacement preflight. + * The admitted timer retries only that short preparation window; it performs + * creation once and cancels itself. No second scheduler admission is required + * after native drops are consumed. */ +public final class GraveCreationTask implements Runnable { + private static final int PREPARING = 0, ACCEPTED = 1, RUNNING = 2, FINISHED = 3; + private final GraveStonesPlus plugin; + private final Runnable creation; + private final com.bencodez.gravestonesplus.storage.GravesConfig record; + private final GraveRestoration restoration; + private int priorGraves; + private Grave replacement; + private final java.util.concurrent.atomic.AtomicInteger phase = new java.util.concurrent.atomic.AtomicInteger(); + private final java.util.concurrent.atomic.AtomicReference task = new java.util.concurrent.atomic.AtomicReference<>(); + public GraveCreationTask(GraveStonesPlus plugin, Runnable creation) { + this.plugin = plugin; this.creation = creation; this.record = null; this.restoration = null; + } + public GraveCreationTask(GraveStonesPlus plugin, + com.bencodez.gravestonesplus.storage.GravesConfig record, java.util.function.Consumer creation) { + this.plugin = plugin; this.record = record; this.restoration = plugin.getGraveRestoration(); + this.creation = () -> { + Grave grave = restoration == null ? new Grave(plugin, record) : restoration.creationGrave(record); + creation.accept(grave); + if (restoration != null) restoration.completeCreation(record); + }; + } + public int priorGraves() { return priorGraves; } + public Grave replacement() { return replacement; } + public void schedule(Location location) { + try { + if (record != null) { + if (restoration == null) { + priorGraves = plugin.numberOfGraves(record.getUuid()); + replacement = priorGraves >= plugin.getConfigFile().getGraveLimit() ? plugin.getOldestGrave(record.getUuid()) : null; + } else { + var admission = restoration.reserveCreation(record); + priorGraves = admission.priorGraves(); replacement = admission.replacement(); + } + } + // The legacy location overload interpreted its delay in seconds. + // Preserve that delay explicitly while obtaining a cancellable handle. + var scheduled = plugin.getBukkitScheduler().getFoliaLib().getImpl().runAtLocationTimer( + location, this, 2L, 1L, java.util.concurrent.TimeUnit.SECONDS); + task.set(scheduled); + if (scheduled == null || scheduled.isCancelled()) + throw new java.util.concurrent.RejectedExecutionException("Grave creation scheduler unavailable"); + if (phase.get() == FINISHED) cancel(); + } catch (RuntimeException rejected) { cancel(); throw rejected; } + } + public void accept() { phase.compareAndSet(PREPARING, ACCEPTED); } + public void cancel() { + phase.set(FINISHED); + if (restoration != null) restoration.releaseCreation(record); + var current = task.getAndSet(null); + if (current != null) try { current.cancel(); } + catch (RuntimeException unavailable) { + // The phase fence still prevents any retired callback from creating a grave. + // A platform cancellation failure must not skip kept-item rollback. + task.compareAndSet(null, current); + plugin.getLogger().fine("Grave creation task cancellation unavailable"); + } + } + @Override public void run() { + if (phase.get() == FINISHED) { cancel(); return; } + if (!phase.compareAndSet(ACCEPTED, RUNNING)) return; + try { if (plugin.isEnabled()) creation.run(); } + finally { cancel(); } + } +} diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveDeadlines.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveDeadlines.java new file mode 100644 index 0000000..1cbbbf7 --- /dev/null +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveDeadlines.java @@ -0,0 +1,25 @@ +package com.bencodez.gravestonesplus.graves; + +import java.util.concurrent.TimeUnit; +import com.bencodez.simpleapi.time.ParsedDuration; + +/** Same legacy numeric minutes and duration semantics used by expiration. */ +public final class GraveDeadlines { + private GraveDeadlines() { } + public static long expiry(long death, String configured) { + if (configured == null || configured.trim().isEmpty()) return 0; + try { + long duration = configured.trim().matches("\\d+") + ? Math.multiplyExact(Long.parseLong(configured.trim()), 60_000L) + : ParsedDuration.parse(configured.trim(), TimeUnit.MINUTES).getMillis(); + return deadline(death, duration); + } catch (RuntimeException invalid) { return 0; } + } + public static long deadline(long death, long duration) { + if (duration <= 0) return 0; + try { return Math.addExact(death, duration); } catch (ArithmeticException overflow) { return 0; } + } + public static boolean eligible(long now, long deadline, long threshold) { + return threshold > 0 && deadline > now && deadline - now <= threshold; + } +} diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveIdentity.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveIdentity.java new file mode 100644 index 0000000..6fae81f --- /dev/null +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveIdentity.java @@ -0,0 +1,15 @@ +package com.bencodez.gravestonesplus.graves; + +import com.bencodez.gravestonesplus.storage.GravesConfig; +import org.bukkit.Location; + +/** Existing owner/world/block/death identity, independent of marker UUID or name. */ +public final class GraveIdentity { + private GraveIdentity() { } + public static String of(GravesConfig grave) { + Location loc = grave.getLocation(); + if (grave.getUuid() == null || loc == null || grave.getWorldId() == null) return null; + return grave.getUuid() + "/" + grave.getTime() + "/" + grave.getWorldId() + + "/" + loc.getBlockX() + "/" + loc.getBlockY() + "/" + loc.getBlockZ(); + } +} diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveMarkerCleanup.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveMarkerCleanup.java new file mode 100644 index 0000000..1c98d7c --- /dev/null +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveMarkerCleanup.java @@ -0,0 +1,170 @@ +package com.bencodez.gravestonesplus.graves; + +import java.util.*; +import java.util.concurrent.CompletableFuture; +import org.bukkit.*; +import org.bukkit.block.*; +import org.bukkit.entity.Entity; +import org.bukkit.persistence.PersistentDataType; +import com.bencodez.gravestonesplus.GraveStonesPlus; +import com.bencodez.gravestonesplus.storage.GravesConfig; + +/** Durable removal intents, indexed by chunk independently of historical grave retention. */ +public final class GraveMarkerCleanup { + private static final int LIMIT = 8192; + private final GraveStonesPlus plugin; + private final DurableFeatureState state; + private final Set tickets = new HashSet<>(); + private volatile Map> index = Map.of(); + private boolean retired; + private int cursor; + private final com.bencodez.simpleapi.folialib.wrapper.task.WrappedTask timer; + private volatile boolean valid; + private final CompletableFuture loaded; + private record ChunkKey(UUID world, int x, int z) {} + private record Marker(String id, UUID world, int x, int y, int z, Material material) {} + public GraveMarkerCleanup(GraveStonesPlus plugin, DurableFeatureState state) { + this.plugin = plugin; this.state = state; + loaded = state.loaded().thenApply(ready -> { + if (!ready) return false; + try { reindex(); valid = true; return true; } catch (RuntimeException invalid) { plugin.getLogger().severe("Invalid marker cleanup journal; removals remain blocked until reconciliation."); return false; } + }); + timer = plugin.getBukkitScheduler().getFoliaLib().getImpl().runTimer(this::retryLoaded, 20L, 100L); + } + public CompletableFuture loaded() { return loaded; } + public static NamespacedKey identityKey(GraveStonesPlus plugin) { return new NamespacedKey(plugin, "grave_marker_identity"); } + static NamespacedKey positionKey(GraveStonesPlus plugin, Block block) { + return new NamespacedKey(plugin, "grave_marker_" + (block.getX() & 15) + "_" + block.getY() + "_" + (block.getZ() & 15)); + } + /** Caller owns this block/region. A pending legacy record must never protect a tagged successor. */ + public static boolean hasConflictingIdentity(GraveStonesPlus plugin, Block block, String identity) { + for (var value : block.getMetadata("Grave")) if (value.getOwningPlugin() == plugin && value.value() instanceof Grave grave + && !identity.equals(GraveIdentity.of(grave.getGravesConfig()))) return true; + String chunkId = block.getChunk().getPersistentDataContainer().get(positionKey(plugin, block), PersistentDataType.STRING); + if (chunkId != null && !identity.equals(chunkId)) return true; + if (block.getState() instanceof TileState tile) { + String tileId = tile.getPersistentDataContainer().get(identityKey(plugin), PersistentDataType.STRING); + if (tileId != null && !identity.equals(tileId)) return true; + } + return false; + } + /** Region-owned tag, including chunk PDC for non-tile barrier markers. */ + public static void tag(GraveStonesPlus plugin, Block block, String identity) { + if (identity == null) return; + block.getChunk().getPersistentDataContainer().set(positionKey(plugin, block), PersistentDataType.STRING, identity); + if (block.getState() instanceof TileState tile) { + tile.getPersistentDataContainer().set(identityKey(plugin), PersistentDataType.STRING, identity); tile.update(); + } + } + private static Marker marker(String id, String type) { + String[] parts = id.split("/", -1); + if (parts.length != 6) throw new IllegalArgumentException("Invalid cleanup identity"); + UUID.fromString(parts[0]); if (Long.parseLong(parts[1]) < 0 || !Set.of("PLAYER_HEAD", "CHEST", "BARRIER").contains(type)) throw new IllegalArgumentException("Invalid cleanup record"); + return new Marker(id, UUID.fromString(parts[2]), Integer.parseInt(parts[3]), Integer.parseInt(parts[4]), Integer.parseInt(parts[5]), Material.valueOf(type)); + } + private Material material(GravesConfig grave) { + GraveDisplayType type; + try { type = GraveDisplayType.valueOf(grave.getGraveDisplayType().trim().toUpperCase(Locale.ROOT)); } + catch (RuntimeException invalid) { type = plugin.getConfigFile().getGraveDisplayTypeEnum(); } + if (type == null) return null; + return switch (type) { + case CHEST -> Material.CHEST; case DISPLAY_ENTITY -> Material.BARRIER; case PLAYER_HEAD -> Material.PLAYER_HEAD; + }; + } + public synchronized Ticket prepare(GravesConfig grave) { + String id = GraveIdentity.of(grave); + if (retired || !valid || id == null || !state.isReady() || tickets.size() >= 128 || tickets.stream().anyMatch(t -> t.id.equals(id)) + || !state.snapshot().containsKey(id) && state.snapshot().size() + tickets.size() >= LIMIT) return null; + // Match existing marker normalization/fallback before reserving capacity. + // A malformed public display setting must never leave an untracked admission. + Material material = material(grave); if (material == null) return null; + org.bukkit.Location location = grave.getLocation(); if (location == null) return null; + World world = location.getWorld(); + FeatureIO.Admission admission = state.admit(); if (admission == null) return null; + try { + Ticket ticket = new Ticket(id, material.name(), world, admission); tickets.add(ticket); return ticket; + } catch (RuntimeException | Error failure) { admission.close(); throw failure; } + } + public final class Ticket implements AutoCloseable { + private final String id, type; + private final FeatureIO.Admission admission; + private final World world; + private boolean used; + private Ticket(String id, String type, World world, FeatureIO.Admission admission) { this.id = id; this.type = type; this.world = world; this.admission = admission; } + public CompletableFuture commit() { + synchronized (GraveMarkerCleanup.this) { + if (used) return CompletableFuture.completedFuture(false); + used = true; + } + return state.update(admission, next -> { next.put(id, type); return true; }).whenComplete((saved, failure) -> { + // A queued publication still owns its journal slot until the acknowledged cache changes. + synchronized (GraveMarkerCleanup.this) { tickets.remove(this); } + if (failure == null && Boolean.TRUE.equals(saved)) { reindex(); dispatch(id, world); } + else plugin.getLogger().warning("Grave marker cleanup intent unavailable; backend record must remain retained."); + }); + } + public void close() { + synchronized (GraveMarkerCleanup.this) { if (used) return; used = true; tickets.remove(this); } + admission.close(); + } + } + public CompletableFuture retain(GravesConfig grave) { + Ticket ticket = prepare(grave); return ticket == null ? CompletableFuture.completedFuture(false) : ticket.commit(); + } + public synchronized void retire() { retired = true; if (timer != null) timer.cancel(); for (Ticket ticket : List.copyOf(tickets)) ticket.close(); } + private void reindex() { + Map> next = new HashMap<>(); + for (var entry : state.snapshot().entrySet()) { + Marker marker = marker(entry.getKey(), entry.getValue()); + next.computeIfAbsent(new ChunkKey(marker.world, marker.x >> 4, marker.z >> 4), key -> new ArrayList<>()).add(marker.id); + } + Map> fixed = new HashMap<>(); next.forEach((key, value) -> fixed.put(key, List.copyOf(value))); index = Map.copyOf(fixed); + } + public void onChunk(World world, int x, int z) { index.getOrDefault(new ChunkKey(world.getUID(), x, z), List.of()).stream().limit(128).forEach(id -> dispatch(id, world)); } + /** Global scheduler resolves at most 128 recorded identities per pass; no world/chunk scans. */ + private synchronized void retryLoaded() { + if (retired || !valid || !plugin.isEnabled()) return; + List ids = new ArrayList<>(state.snapshot().keySet()); if (ids.isEmpty()) return; + for (int i = 0; i < Math.min(128, ids.size()); i++) { + String id = ids.get((cursor + i) % ids.size()); Marker marker = marker(id, state.snapshot().get(id)); + World world = Bukkit.getWorld(marker.world); if (world != null) dispatch(id, world); + } + cursor = (cursor + Math.min(128, ids.size())) % ids.size(); + } + private void dispatch(String id, World world) { + String type = state.snapshot().get(id); if (type == null || retired || !plugin.isEnabled()) return; + Marker marker = marker(id, type); if (world == null) return; + Location location = new Location(world, marker.x, marker.y, marker.z); + try { plugin.getBukkitScheduler().runTask(plugin, () -> { + if (retired || !plugin.isEnabled() || !world.isChunkLoaded(marker.x >> 4, marker.z >> 4)) return; + if (cleanup(marker, location)) state.update(next -> { next.remove(id); return true; }).thenAccept(saved -> { if (saved) reindex(); }); + }, location); } catch (RuntimeException rejected) { plugin.getLogger().warning("Marker cleanup is durably retained while its region scheduler is unavailable."); } + } + private boolean cleanup(Marker marker, Location location) { + GraveRestoration restoration = plugin.getGraveRestoration(); + if (restoration != null && (restoration.isLoading() || restoration.hasPending(marker.id))) return false; + // Recreation can legitimately reuse the same logical identity. An old intent + // must be retired rather than destroying the now-active marker. + if (plugin.activeGraveSnapshot().stream().anyMatch(grave -> !grave.isRemove() && !grave.getGravesConfig().isDestroyed() + && marker.id.equals(GraveIdentity.of(grave.getGravesConfig())))) return true; + Block block = location.getBlock(); Chunk chunk = block.getChunk(); + Object metadata = block.getMetadata("Grave").stream().filter(value -> value.getOwningPlugin() == plugin) + .map(org.bukkit.metadata.MetadataValue::value).findFirst().orElse(null); + String metadataId = metadata instanceof Grave grave ? GraveIdentity.of(grave.getGravesConfig()) : null; + if (metadata instanceof Grave grave && !grave.isRemove() && !grave.getGravesConfig().isDestroyed() && marker.id.equals(metadataId)) return true; + String chunkId = chunk.getPersistentDataContainer().get(positionKey(plugin, block), PersistentDataType.STRING); + var blockState = block.getState(); + boolean tileMarker = blockState instanceof TileState; + String tileId = tileMarker ? ((TileState) blockState).getPersistentDataContainer().get(identityKey(plugin), PersistentDataType.STRING) : null; + boolean newer = metadataId != null && !marker.id.equals(metadataId) || chunkId != null && !marker.id.equals(chunkId) || tileId != null && !marker.id.equals(tileId); + boolean owned = !newer && (marker.id.equals(metadataId) || marker.id.equals(tileId) || !tileMarker && marker.id.equals(chunkId)); + // Only this loaded chunk is inspected. Entity ownership survives a restart through PDC. + for (Entity entity : chunk.getEntities()) if (marker.id.equals(entity.getPersistentDataContainer().get(identityKey(plugin), PersistentDataType.STRING))) entity.remove(); + if (newer) return true; // Old marker was replaced; never alter its successor. + if (!owned) return block.getType() != marker.material; // Untagged legacy marker remains for conservative reconciliation. + block.removeMetadata("Grave", plugin); + chunk.getPersistentDataContainer().remove(positionKey(plugin, block)); + if (block.getType() == marker.material) block.setType(Material.AIR); + return true; + } +} diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveNavigation.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveNavigation.java new file mode 100644 index 0000000..35083e6 --- /dev/null +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveNavigation.java @@ -0,0 +1,93 @@ +package com.bencodez.gravestonesplus.graves; + +import java.util.*; +import org.bukkit.*; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.CompassMeta; +import org.bukkit.persistence.PersistentDataType; +import com.bencodez.gravestonesplus.GraveStonesPlus; + +/** Selection and compass validation read identities and locations only. */ +public final class GraveNavigation { + private final GraveStonesPlus plugin; + private final Map selected = new java.util.concurrent.ConcurrentHashMap<>(); + public GraveNavigation(GraveStonesPlus plugin) { this.plugin = plugin; } + public Grave target(Player player) { + if (loading()) return null; + String identity = selected.get(player.getUniqueId()); + if (identity == null) return null; + Grave grave = resolve(identity); + if (grave == null && pending(identity)) return null; + if (grave == null || !plugin.canTrack(grave, player)) { selected.remove(player.getUniqueId()); return null; } + return grave; + } + private Grave resolve(String identity) { + for (Grave grave : plugin.activeGraveSnapshot()) if (identity.equals(GraveIdentity.of(grave.getGravesConfig()))) return grave; + return null; + } + public boolean select(Player player, Grave grave) { + if (!plugin.canTrack(grave, player) || (selected.size() >= 4096 && !selected.containsKey(player.getUniqueId()))) return false; + selected.put(player.getUniqueId(), GraveIdentity.of(grave.getGravesConfig())); return true; + } + public void forget(Player player) { selected.remove(player.getUniqueId()); } + public void invalidate(String identity) { + if (identity == null) return; + selected.values().removeIf(identity::equals); + // Inventory changes belong to each player's entity owner. The bounded + // GraveReminderListener refreshes up to 16 players per five-second pass + // (and on login), while selection authorization is invalidated above + // immediately. Do not fan out owner tasks once per grave removal. + } + private boolean loading() { + return plugin.getGraveRestoration() != null && plugin.getGraveRestoration().isLoading() + || !plugin.getTrustedGraveAccessStore().isReady() || !plugin.getGraveClaimLedger().isReady(); + } + private boolean pending(String identity) { + return plugin.getGraveRestoration() != null && plugin.getGraveRestoration().hasPending(identity) + && !plugin.getGraveRestoration().isConflicted(identity); + } + public void refresh(Player player) { + if (loading()) return; + target(player); + if (!player.isOnline()) return; + NamespacedKey identityKey = new NamespacedKey(plugin, "graveidentity"); + NamespacedKey legacy = new NamespacedKey(plugin, "gravecompass"); + ItemStack[] contents = player.getInventory().getContents(); + for (int slot = 0; slot < contents.length; slot++) { + ItemStack item = contents[slot]; + if (item == null || item.getType() != Material.COMPASS || !(item.getItemMeta() instanceof CompassMeta meta)) continue; + String identity = meta.getPersistentDataContainer().get(identityKey, PersistentDataType.STRING); + if (identity == null && !meta.getPersistentDataContainer().has(legacy, PersistentDataType.LONG)) continue; + String legacyIdentity = identity == null ? legacyIdentity(player, meta, legacy) : null; + Grave grave = identity == null ? legacyTarget(player, meta, legacy) : resolve(identity); + // Legacy items predate the identity tag. Reconstruct the same stable + // owner/time/position identity before deciding that a withheld record + // is stale; restoration may have finished its backend read while the + // matching record is still awaiting admission. + if (grave == null && pending(identity == null ? legacyIdentity : identity)) continue; + if (grave == null || !plugin.canTrack(grave, player)) { + meta.setLodestone(null); meta.getPersistentDataContainer().remove(identityKey); + meta.getPersistentDataContainer().remove(legacy); meta.setDisplayName("Inactive grave compass"); + } else { + meta.setLodestone(grave.getGravesConfig().getLocation()); meta.setLodestoneTracked(false); + meta.getPersistentDataContainer().set(identityKey, PersistentDataType.STRING, GraveIdentity.of(grave.getGravesConfig())); + meta.setLore(List.of(grave.getNavigationMessage(player))); + } + item.setItemMeta(meta); player.getInventory().setItem(slot, item); + } + } + private Grave legacyTarget(Player player, CompassMeta meta, NamespacedKey key) { + Long time = meta.getPersistentDataContainer().get(key, PersistentDataType.LONG); + for (Grave grave : plugin.getGraves(player)) if (time != null && time == grave.getGravesConfig().getTime() + && meta.getLodestone() != null && meta.getLodestone().equals(grave.getGravesConfig().getLocation())) return grave; + return null; + } + private String legacyIdentity(Player player, CompassMeta meta, NamespacedKey key) { + Long time = meta.getPersistentDataContainer().get(key, PersistentDataType.LONG); + Location location = meta.getLodestone(); + if (time == null || location == null || location.getWorld() == null) return null; + return player.getUniqueId() + "/" + time + "/" + location.getWorld().getUID() + + "/" + location.getBlockX() + "/" + location.getBlockY() + "/" + location.getBlockZ(); + } +} diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GravePayout.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GravePayout.java new file mode 100644 index 0000000..de97051 --- /dev/null +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GravePayout.java @@ -0,0 +1,42 @@ +package com.bencodez.gravestonesplus.graves; + +import java.util.*; +import java.util.function.IntSupplier; +import java.util.function.Consumer; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.bukkit.entity.Player; + +/** Existing per-stack chance and slot placement, with explicit recipient-location overflow. */ +public final class GravePayout { + private GravePayout() { } + public static Map select(Map stored, int chance, IntSupplier random) { + Map result = new TreeMap<>(); + for (var entry : stored.entrySet()) if (entry.getValue() != null && !entry.getValue().getType().isAir() + && (chance == 100 || random.getAsInt() < chance)) result.put(entry.getKey(), entry.getValue().clone()); + return result; + } + /** Called exclusively on recipient entity owner; succeeds after every selected value is delivered. */ + public static boolean deliver(Player player, Map items, int experience) { + return deliver(player, items, experience, overflow -> player.getWorld().dropItem(player.getLocation(), overflow)); + } + public static boolean deliver(Player player, Map items, int experience, Consumer overflowSink) { + PlayerInventory inventory = player.getInventory(); + boolean displaced = false; + List displacedItems = new ArrayList<>(); + for (var entry : items.entrySet()) { + int slot = switch (entry.getKey()) { case -1 -> 39; case -2 -> 38; case -3 -> 37; case -4 -> 36; case -5 -> 40; default -> entry.getKey(); }; + ItemStack item = entry.getValue(); + if (slot >= 0 && slot <= 40 && (inventory.getItem(slot) == null || inventory.getItem(slot).getType().isAir())) inventory.setItem(slot, item); + else { + displaced = true; + displacedItems.add(item); + } + } + for (ItemStack displacedItem : displacedItems) { + for (ItemStack overflow : inventory.addItem(displacedItem).values()) overflowSink.accept(overflow); + } + player.giveExp(experience); + return displaced; + } +} diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveReminderKey.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveReminderKey.java new file mode 100644 index 0000000..12f3604 --- /dev/null +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveReminderKey.java @@ -0,0 +1,12 @@ +package com.bencodez.gravestonesplus.graves; + +import java.util.UUID; + +/** Stable identity for one reminder deadline; deadline changes intentionally create a new reminder. */ +public final class GraveReminderKey { + private GraveReminderKey() { } + public static String of(UUID player, UUID owner, long graveTime, String kind, long deadline) { + if (player == null || owner == null || kind == null || kind.trim().isEmpty()) throw new IllegalArgumentException("missing reminder identity"); + return player + "/" + owner + "/" + graveTime + "/" + kind + "/" + deadline; + } +} diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveReminderStore.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveReminderStore.java new file mode 100644 index 0000000..3b396ec --- /dev/null +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveReminderStore.java @@ -0,0 +1,56 @@ +package com.bencodez.gravestonesplus.graves; + +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.function.BooleanSupplier; +import com.bencodez.gravestonesplus.GraveStonesPlus; + +/** Delivery acknowledgment first, then durable suppression. No offline marks. */ +public final class GraveReminderStore { + public record Notice(String key, long deadline, String description) { } + private final DurableFeatureState state; + private final java.util.function.Consumer warning; + private final Map delivered = new HashMap<>(); + public GraveReminderStore(GraveStonesPlus plugin) { + this(new DurableFeatureState(plugin.getFeatureIO(), new java.io.File(plugin.getDataFolder(), "grave-reminders.yml").toPath(), 8192), message -> plugin.getLogger().warning(message)); + } + public GraveReminderStore(DurableFeatureState state) { this(state, message -> { }); } + public GraveReminderStore(DurableFeatureState state, java.util.function.Consumer warning) { this.state = state; this.warning = warning; } + public CompletableFuture loaded() { return state.loaded(); } + public boolean isReady() { return state.isReady(); } + /** Must be called on the recipient entity owner. Sender returns true only after send. */ + public synchronized List deliver(List candidates, long now, + java.util.function.Predicate> sender) { + if (!state.isReady()) return List.of(); + delivered.entrySet().removeIf(entry -> entry.getValue() <= now); + List fresh = candidates.stream().filter(n -> n.deadline() > now + && !state.snapshot().containsKey(n.key()) && !delivered.containsKey(n.key())).distinct().limit(64).toList(); + if (fresh.isEmpty() || delivered.size() + fresh.size() > 8192) return List.of(); + Set retained = new HashSet<>(delivered.keySet()); + state.snapshot().keySet().stream().filter(key -> deadlineOf(key) > now).forEach(retained::add); + for (Notice notice : fresh) retained.add(notice.key()); + if (retained.size() > 8192) return List.of(); + FeatureIO.Admission admitted = state.admit(); + if (admitted == null) return List.of(); + try { + if (!sender.test(fresh)) { admitted.close(); return List.of(); } + } catch (RuntimeException failure) { admitted.close(); throw failure; } + for (Notice notice : fresh) delivered.put(notice.key(), notice.deadline()); + state.update(admitted, next -> { + next.keySet().removeIf(key -> deadlineOf(key) <= now); + for (Notice notice : fresh) next.put(notice.key(), "sent"); + return true; + }).whenComplete((saved, failure) -> { + if (failure != null || !Boolean.TRUE.equals(saved)) warning.accept("Grave reminder was delivered but suppression could not be saved; local replay is suppressed, restart may repeat it. Check feature storage."); + }); + return fresh; + } + public static long deadlineOf(String key) { + try { return Long.parseLong(key.substring(key.lastIndexOf('/') + 1)); } + catch (RuntimeException invalid) { return Long.MAX_VALUE; } + } + // Legacy entry point must not mark before a delivery acknowledgment. + @Deprecated public boolean markIfNew(UUID player, UUID owner, long time, String kind, long deadline) { return false; } + @Deprecated public CompletableFuture markIfNewAsync(UUID player, UUID owner, long time, String kind, long deadline) { return CompletableFuture.completedFuture(false); } + public void close() { /* shared FeatureIO owns lifecycle */ } +} diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java new file mode 100644 index 0000000..589dc53 --- /dev/null +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java @@ -0,0 +1,530 @@ +package com.bencodez.gravestonesplus.graves; + +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.block.Block; +import com.bencodez.gravestonesplus.GraveStonesPlus; +import com.bencodez.gravestonesplus.storage.*; + +/** Serial, bounded admission before any startup marker metadata, navigation or timer exposure. */ +public final class GraveRestoration { + private static final int MAX_BATCH = 128; + private final GraveStonesPlus plugin; + private final GraveStorageManager storage; + private final Map pending = new LinkedHashMap<>(); + private final Map admitted = new HashMap<>(); + private final Set published = new HashSet<>(); + private final Set activeRecords = new HashSet<>(); + private final Map limitRequests = new HashMap<>(); + private final Set displayRepairs = new HashSet<>(); + private final Set savingDisplays = new HashSet<>(); + private final Set conflicts = ConcurrentHashMap.newKeySet(); + private final Map> chunks = new HashMap<>(); + // Shares pending record ownership and retention; never scans worlds or publishes a live Grave. + private final Map> positions = new HashMap<>(); + private record ChunkKey(UUID world, int x, int z) {} + private record PositionKey(UUID world, int x, int y, int z) { + ChunkKey chunk() { return new ChunkKey(world, x >> 4, z >> 4); } + } + private final AtomicBoolean running = new AtomicBoolean(); + private volatile boolean retired; + private volatile boolean recordsLoaded; + private int cursor; + private com.bencodez.simpleapi.folialib.wrapper.task.WrappedTask timer; + public GraveRestoration(GraveStonesPlus plugin, GraveStorageManager storage) { this.plugin = plugin; this.storage = storage; } + // Unpublished creation reservations share logical capacity but never enter marker restoration. + private final Set creating = new HashSet<>(); + private static final int MAX_CREATIONS = 4096; + private final Map creationReplacements = new HashMap<>(); + public record CreationAdmission(int priorGraves, Grave replacement) { } + public synchronized boolean isCreationReplacement(Grave grave) { + return grave != null && creationReplacements.containsValue(GraveIdentity.of(grave.getGravesConfig())); + } + /** Called on the capture owner before scheduler admission. Returns the prior logical count. */ + public synchronized CreationAdmission reserveCreation(GravesConfig record) { + String identity = GraveIdentity.of(record); + if (retired || identity == null || creating.size() >= MAX_CREATIONS || pending.containsKey(identity)) + throw new java.util.concurrent.RejectedExecutionException("Grave creation capacity unavailable"); + int prior = plugin.numberOfGraves(record.getUuid()); + // A successfully admitted asynchronous eviction may temporarily retain one extra + // grave. Do not admit another replacement until that overflow has settled. + if (plugin.getConfigFile().isDropItemsOnGraveRemoval() + && (prior > plugin.getConfigFile().getGraveLimit() + || creationReplacements.keySet().stream().anyMatch(id -> { + GravesConfig pendingCreation = pending.get(id); + return pendingCreation != null && record.getUuid().equals(pendingCreation.getUuid()); + }))) + throw new java.util.concurrent.RejectedExecutionException("Previous grave-limit eviction is pending"); + Grave replacement = prior >= plugin.getConfigFile().getGraveLimit() ? plugin.getOldestGrave(record.getUuid()) : null; + if (replacement != null) creationReplacements.put(identity, GraveIdentity.of(replacement.getGravesConfig())); + pending.put(identity, record); activeRecords.add(identity); creating.add(identity); + return new CreationAdmission(prior, replacement); + } + /** Preserve the wrapper selected by an intervening limit request. Constructor has no world access. */ + public synchronized Grave creationGrave(GravesConfig record) { + String identity = GraveIdentity.of(record); + if (retired || !creating.contains(identity) || pending.get(identity) != record) + throw new java.util.concurrent.RejectedExecutionException("Grave creation admission retired"); + return admitted.computeIfAbsent(identity, ignored -> new Grave(plugin, record)); + } + /** Publication transfers the same identity; only deferred replacement intents remain in restoration. */ + public synchronized void completeCreation(GravesConfig record) { + String identity = GraveIdentity.of(record); + if (!creating.remove(identity) || pending.get(identity) != record) return; + creationReplacements.remove(identity); + if (limitRequests.containsKey(identity)) { + index(identity, record); + } else { + pending.remove(identity); admitted.remove(identity); activeRecords.remove(identity); + } + } + /** Cancelling an unpublished reservation cannot retire any persisted grave or history. */ + public synchronized void releaseCreation(GravesConfig record) { + String identity = GraveIdentity.of(record); + if (!creating.contains(identity) || pending.get(identity) != record) return; + creating.remove(identity); creationReplacements.remove(identity); pending.remove(identity); admitted.remove(identity); + activeRecords.remove(identity); limitRequests.remove(identity); + } + /** Coalesce a durable limit request into the existing bounded restoration/chunk retry lane. */ + public synchronized void retainLimit(Grave grave) { + GravesConfig record = grave.getGravesConfig(); String identity = GraveIdentity.of(record); + if (retired) return; + limitRequests.putIfAbsent(identity, true); + pending.put(identity, record); admitted.put(identity, grave); activeRecords.add(identity); + if (plugin.isLiveGrave(grave)) published.add(identity); + index(identity, record); + } + /** Logical capacity only: never publishes marker metadata or claim eligibility. */ + public synchronized List pendingActiveForLimits() { + if (retired) return List.of(); + List result = new ArrayList<>(); + for (String identity : activeRecords) { + GravesConfig record = pending.get(identity); + if (record == null || record.isDestroyed()) continue; + Grave grave = admitted.get(identity); + if (grave == null || grave.getGravesConfig() != record) { + grave = new Grave(plugin, record); admitted.put(identity, grave); + } + if (!grave.isRemove()) result.add(grave); + } + return result; + } + public synchronized boolean canQueuePendingLimit(Grave grave) { + if (retired || grave == null) return false; + String identity = GraveIdentity.of(grave.getGravesConfig()); + return activeRecords.contains(identity) && pending.get(identity) == grave.getGravesConfig() + && admitted.get(identity) == grave && !published.contains(identity) + && (creating.contains(identity) || limitRequests.containsKey(identity)) + && !grave.isRemove() && !grave.getGravesConfig().isDestroyed(); + } + public synchronized boolean requestLimitRemoval(Grave grave, boolean drop) { + if (retired || grave == null) return false; + String identity = GraveIdentity.of(grave.getGravesConfig()); + if (!activeRecords.contains(identity) || pending.get(identity) != grave.getGravesConfig() + || admitted.get(identity) != grave || published.contains(identity) + || grave.isRemove() || grave.getGravesConfig().isDestroyed()) return false; + // The first admitted intent wins, just like an already queued durable payout. + // Reloads or duplicate callbacks cannot change that intent. One entry per record. + limitRequests.putIfAbsent(identity, drop); + return true; + } + public synchronized boolean hasPendingLimitRemoval(Grave grave) { + if (retired || grave == null) return false; + String identity = GraveIdentity.of(grave.getGravesConfig()); + return activeRecords.contains(identity) && pending.get(identity) == grave.getGravesConfig() + && admitted.get(identity) == grave && limitRequests.containsKey(identity) + && !grave.isRemove() && !grave.getGravesConfig().isDestroyed(); + } + public void start() { + storage.beginRestoration(); + // Flat-file initialization already decoded the document. Index plain record + // identity/position fields before listeners open, even on Folia's global + // startup context. Item cloning/encoding and claim admission stay deferred. + if (!storage.isMySQL()) { + try { + var cached = cachedFlatRecords(); + indexCachedRecords(cached); + if (org.bukkit.Bukkit.isPrimaryThread()) { + running.set(true); + acceptRecordsOnOwner(cached, true).whenComplete(this::accepted); + } + } catch (RuntimeException failure) { + plugin.getLogger().warning("Cached grave records remain withheld until owner preparation can retry."); + } + } + // Even an unavailable archive must leave backend records retained for shutdown. + retry(); + timer = plugin.getBukkitScheduler().getFoliaLib().getImpl().runTimer(this::retry, 20L, 100L); + } + public void retry() { + if (retired || !plugin.isEnabled() || !running.compareAndSet(false, true)) return; + if (!recordsLoaded) { + CompletableFuture read = storage.isMySQL() + ? storage.loadMySQLForRestoration() + : loadCachedFlatOnOwner(); + read.thenCompose(this::acceptRecordsOnOwner).whenComplete(this::accepted); + } else cycle(); + } + private void accepted(Void loaded, Throwable error) { + if (error != null) { running.set(false); plugin.getLogger().warning("Stored graves remain withheld until backend restoration can retry."); } + else cycle(); + } + private GraveStorageManager.RestorationRecords cachedFlatRecords() { + return new GraveStorageManager.RestorationRecords(storage.loadGraves(), storage.loadBrokenGraves()); + } + private CompletableFuture loadCachedFlatOnOwner() { + var result = new CompletableFuture(); + try { plugin.getBukkitScheduler().runTask(plugin, () -> { + if (retired || !plugin.isEnabled()) { result.completeExceptionally(new IllegalStateException("Grave restoration retired")); return; } + try { result.complete(cachedFlatRecords()); } + catch (RuntimeException failure) { result.completeExceptionally(failure); } + }); } catch (RuntimeException rejected) { result.completeExceptionally(rejected); } + return result; + } + private CompletableFuture acceptRecordsOnOwner(GraveStorageManager.RestorationRecords records) { + return acceptRecordsOnOwner(records, false); + } + private CompletableFuture acceptRecordsOnOwner(GraveStorageManager.RestorationRecords records, boolean inline) { + var result = new CompletableFuture(); + class Accept implements Runnable { + int activeCursor, brokenCursor, invalid; + void schedule() { + try { plugin.getBukkitScheduler().runTask(plugin, this); } + catch (RuntimeException rejected) { result.completeExceptionally(rejected); } + } + public void run() { + if (retired || !plugin.isEnabled()) { result.completeExceptionally(new IllegalStateException("Grave restoration retired")); return; } + try { + int activeEnd = Math.min(records.active().size(), activeCursor + MAX_BATCH); + int brokenEnd = Math.min(records.broken().size(), brokenCursor + MAX_BATCH - (activeEnd - activeCursor)); + invalid = acceptRecordBatch(records.active().subList(activeCursor, activeEnd), + records.broken().subList(brokenCursor, brokenEnd), invalid); + activeCursor = activeEnd; brokenCursor = brokenEnd; + if (activeCursor == records.active().size() && brokenCursor == records.broken().size()) { + storage.finishRestorationRetention(); + synchronized (GraveRestoration.this) { recordsLoaded = true; } + result.complete(null); + } else schedule(); + } catch (RuntimeException failure) { result.completeExceptionally(failure); } + } + } + Accept accept = new Accept(); + if (inline) accept.run(); else accept.schedule(); + return result; + } + private synchronized void indexCachedRecords(GraveStorageManager.RestorationRecords records) { + // Borrow already-loaded records only until owner preparation. Do not construct + // GravesConfig, resolve worlds, serialize inventories or publish live Graves here. + for (GravesConfig grave : records.active()) { + String identity = GraveIdentity.of(grave); + if (identity != null && !grave.isDestroyed()) activeRecords.add(identity); + } + for (GravesConfig grave : concat(records.active(), records.broken())) { + String identity = GraveIdentity.of(grave); + if (identity != null) { pending.put(identity, grave); index(identity, grave); } + } + } + private int acceptRecordBatch(List active, List broken, int invalid) { + storage.retainRestorationBatch(active, broken); + synchronized (this) { + for (GravesConfig grave : active) { + String identity = GraveIdentity.of(grave); + if (identity != null && !grave.isDestroyed()) activeRecords.add(identity); + } + for (GravesConfig grave : concat(active, broken)) { + String identity = GraveIdentity.of(grave); String key = identity == null ? "unresolved-" + invalid++ : identity; + pending.put(key, grave); + if (identity != null) index(key, grave); + } + return invalid; + } + } + private static List concat(List active, List broken) { + List result = new ArrayList<>(active); result.addAll(broken); return result; + } + private synchronized void cycle() { + List all = new ArrayList<>(pending.keySet()); + if (retired || all.isEmpty()) { running.set(false); return; } + List batch = new ArrayList<>(); + for (int i = 0; i < Math.min(MAX_BATCH, all.size()); i++) batch.add(all.get((cursor + i) % all.size())); + cursor = (cursor + batch.size()) % all.size(); process(batch.iterator()); + } + private void process(Iterator batch) { + if (retired || !plugin.isEnabled() || !batch.hasNext()) { running.set(false); return; } + String key = batch.next(); GravesConfig record; + synchronized (this) { record = savingDisplays.contains(key) || creating.contains(key) ? null : pending.get(key); } + if (record == null) { process(batch); return; } + plugin.getGraveClaimLedger().restoration(GraveIdentity.of(record)).whenComplete((decision, error) -> { + if (retired || !plugin.isEnabled()) { running.set(false); return; } + if (error != null || decision == GraveClaimLedger.Restoration.UNAVAILABLE) { process(batch); return; } + if (decision == GraveClaimLedger.Restoration.RESERVED) { process(batch); return; } + Location loc = record.getLocation(); + if (loc != null && loc.getWorld() == null && record.getWorldId() != null) { + try { + plugin.getBukkitScheduler().runTask(plugin, () -> { + if (retired || !plugin.isEnabled()) { running.set(false); return; } + try { + loc.setWorld(org.bukkit.Bukkit.getWorld(record.getWorldId())); + processResolved(batch, key, record, decision, loc); + } catch (RuntimeException unavailable) { running.set(false); } + }); + } catch (RuntimeException rejected) { running.set(false); } + } else processResolved(batch, key, record, decision, loc); + }); + } + private void processResolved(Iterator batch, String key, GravesConfig record, + GraveClaimLedger.Restoration decision, Location loc) { + // Both active and historical records require a resolved world before publication. + // Keep unavailable worlds pending so menus and recreation never see a null world. + if (loc == null || loc.getWorld() == null) { process(batch); return; } + if (decision == GraveClaimLedger.Restoration.PAID || record.isDestroyed()) { + plugin.getMarkerCleanup().retain(record).whenComplete((saved, failure) -> { + if (retired || !plugin.isEnabled()) { running.set(false); return; } + if (!Boolean.TRUE.equals(saved) || failure != null) { process(batch); return; } + try { plugin.getBukkitScheduler().runTask(plugin, () -> { + if (retired || !plugin.isEnabled()) { running.set(false); return; } + try { + record.setDestroyed(true); if (record.getDestroyedTime() == 0) record.setDestroyedTime(System.currentTimeMillis()); + long lifetime = com.bencodez.simpleapi.time.ParsedDuration.parse(plugin.getConfigFile().getBrokenGraveTimeLimit(), TimeUnit.HOURS).getMillis(); + if (lifetime <= 0 || System.currentTimeMillis() - record.getDestroyedTime() <= lifetime) plugin.restoreBrokenRecord(new Grave(plugin, record)); + resolved(key, record); persistResolvedRemoval(record); + } finally { process(batch); } + }); } catch (RuntimeException rejected) { running.set(false); } + }); + return; + } + try { + plugin.getBukkitScheduler().getFoliaLib().getImpl().runAtLocation(loc, task -> { + if (retired || !plugin.isEnabled()) return; + // Recheck the process-local reservation fence after the asynchronous archive decision. + if (decision != GraveClaimLedger.Restoration.QUEUED_LIMIT && plugin.getGraveClaimLedger().isBlocked(GraveIdentity.of(record))) return; + UUID beforeDisplay = record.getDisplayUUID(), beforeInteraction = record.getInteractUUID(); + Grave grave; + synchronized (this) { + grave = admitted.get(key); + if (grave == null || grave.getGravesConfig() != record) { + grave = new Grave(plugin, record); admitted.put(key, grave); + } + } + if (grave.isRemove() || record.isDestroyed()) { resolved(key, record); return; } + Boolean limitRequest; + synchronized (this) { limitRequest = limitRequests.get(key); } + if (limitRequest != null && decision == GraveClaimLedger.Restoration.AVAILABLE) { + // Archive checks precede the request. A PAID/RESERVED record + // must never become a fresh limit payout or claimable grave. + if (limitRequest) grave.queuePendingLimitPayout(); + else { + grave.removePendingForLimit(); + synchronized (this) { limitRequests.remove(key); } + if (grave.isRemove() || record.isDestroyed()) resolved(key, record); + } + return; + } + boolean loaded = loc.getWorld().isChunkLoaded(loc.getBlockX() >> 4, loc.getBlockZ() >> 4); + // Material alone cannot prove ownership: a newer grave may occupy this position. + // Keep the old record pending without publishing it or changing the successor. + if (loaded && GraveMarkerCleanup.hasConflictingIdentity(plugin, loc.getBlock(), GraveIdentity.of(record))) { + boolean firstConflict; + synchronized (this) { firstConflict = conflicts.add(key); published.remove(key); } + plugin.withholdRestoredRecord(grave); + grave.removeTimer(); + if (firstConflict) plugin.getGraveNavigation().invalidate(GraveIdentity.of(record)); + return; + } + synchronized (this) { + // An unloaded chunk cannot establish that a previously observed successor went away. + if (!loaded && conflicts.contains(key)) return; + if (loaded) conflicts.remove(key); + } + if (decision == GraveClaimLedger.Restoration.QUEUED_LIMIT) { + grave.markLimitPayoutPending(); + synchronized (this) { + if (!published.contains(key)) { + grave.checkTimeLimit(plugin.getConfigFile().getGraveTimeLimit()); + if (grave.isRemove() || record.isDestroyed()) { resolved(key, record); return; } + plugin.restoreActiveRecord(grave); published.add(key); + } + } + if (loaded) grave.resumeLimitPayout(); + return; + } + if (loaded && !grave.isValid()) { + var removal = new com.bencodez.gravestonesplus.events.GraveRemoveEvent(grave, com.bencodez.gravestonesplus.events.GraveRemoveReason.INVALID); + org.bukkit.Bukkit.getPluginManager().callEvent(removal); + if (removal.isCancelled()) return; + // An integration can repair the marker while observing the event. + if (!grave.isValid()) { + Grave invalid = grave; + plugin.getMarkerCleanup().retain(record).thenCompose(saved -> { + if (!saved || retired || !plugin.isEnabled()) return CompletableFuture.completedFuture(false); + // A recreated historical grave retains its identity. Revoke durably + // before publishing removal so old helpers cannot inherit recreation. + return plugin.getTrustedGraveAccessStore().revokeForGraveAsync(GraveIdentity.of(record)); + }).thenAccept(saved -> { + if (saved && !retired && plugin.isEnabled()) plugin.getBukkitScheduler().runTask(plugin, () -> { + if (retired || !plugin.isEnabled() || invalid.isValid()) return; + plugin.getGraveNavigation().invalidate(GraveIdentity.of(record)); + record.setDestroyed(true); record.setDestroyedTime(System.currentTimeMillis()); + plugin.removeGrave(invalid); plugin.restoreBrokenRecord(invalid); resolved(key, record); persistResolvedRemoval(record); + }, loc); + }); return; + } + } + // Set the authoritative deadline before logical publication, without probing an unloaded marker. + synchronized (this) { + if (!published.contains(key)) { + grave.checkTimeLimit(plugin.getConfigFile().getGraveTimeLimit()); + // Native Bukkit expiration can remove synchronously during this call. + if (grave.isRemove() || record.isDestroyed()) { resolved(key, record); return; } + plugin.restoreActiveRecord(grave); published.add(key); + } + } + if (!loaded) return; + grave.loadBlockMeta(loc.getBlock()); grave.removeHologramsAround(); grave.checkBlockDisplayAndFixIfMissing(); grave.createHologram(); + synchronized (this) { + if (!Objects.equals(beforeDisplay, record.getDisplayUUID()) || !Objects.equals(beforeInteraction, record.getInteractUUID())) + displayRepairs.add(key); + if (!displayRepairs.contains(key)) { resolved(key, record); return; } + if (!savingDisplays.add(key)) return; + } + // A repaired marker identity must survive a crash, not only graceful shutdown. + // Keep this record indexed until the serial storage owner acknowledges it. + persistDisplayRepair(key, record, loc); + + }).whenComplete((unused, failure) -> process(batch)); + } catch (RuntimeException rejected) { running.set(false); } + } + private void persistDisplayRepair(String key, GravesConfig record, Location loc) { + UUID display = record.getDisplayUUID(), interaction = record.getInteractUUID(); + CompletableFuture saved; + try { + saved = storage.isMySQL() ? storage.saveDisplayIdentity(record) + : storage.saveSnapshotFromObjects(plugin.activeGraveSnapshot(), plugin.brokenGraveSnapshot()); + } catch (RuntimeException failure) { + synchronized (this) { savingDisplays.remove(key); } + return; + } + saved.whenComplete((acknowledged, failure) -> { + if (failure != null || !Boolean.TRUE.equals(acknowledged) || retired || !plugin.isEnabled()) { + synchronized (this) { savingDisplays.remove(key); } + return; + } + try { plugin.getBukkitScheduler().runTask(plugin, () -> { + synchronized (this) { + savingDisplays.remove(key); + if (retired || !plugin.isEnabled() || pending.get(key) != record) return; + if (Objects.equals(display, record.getDisplayUUID()) && Objects.equals(interaction, record.getInteractUUID())) + resolved(key, record); + } + }, loc); } catch (RuntimeException rejected) { + synchronized (this) { savingDisplays.remove(key); } + } + }); + } + private void persistResolvedRemoval(GravesConfig record) { + if (storage.isMySQL()) storage.updateExistingGrave(record, true); + else { storage.saveBrokenGravesFromObjects(plugin.brokenGraveSnapshot()); storage.saveGravesFromObjects(plugin.activeGraveSnapshot()); } + } + private synchronized void resolved(String key, GravesConfig record) { + pending.remove(key); admitted.remove(key); published.remove(key); conflicts.remove(key); + activeRecords.remove(key); limitRequests.remove(key); + displayRepairs.remove(key); savingDisplays.remove(key); + // The key retains the original position even if an integration moved its mutable record. + if (key != null && !key.startsWith("unresolved-")) { + String[] identity = key.split("/", -1); + PositionKey position = new PositionKey(UUID.fromString(identity[2]), Integer.parseInt(identity[3]), Integer.parseInt(identity[4]), Integer.parseInt(identity[5])); + Set exact = positions.get(position); if (exact != null) { exact.remove(key); if (exact.isEmpty()) positions.remove(position); } + Set values = chunks.get(position.chunk()); if (values != null) { values.remove(key); if (values.isEmpty()) chunks.remove(position.chunk()); } + } + storage.restorationResolved(record); + } + private void index(String identity, GravesConfig record) { + Location loc = record.getLocation(); + if (identity == null || loc == null || record.getWorldId() == null) return; + PositionKey position = new PositionKey(record.getWorldId(), loc.getBlockX(), loc.getBlockY(), loc.getBlockZ()); + if (!record.isDestroyed()) positions.computeIfAbsent(position, ignored -> new LinkedHashSet<>()).add(identity); + chunks.computeIfAbsent(position.chunk(), ignored -> new LinkedHashSet<>()).add(identity); + } + /** Event/region-owner-only read: protect an exact pending marker without admitting claims. */ + public boolean protectsPendingMarker(Block block) { + return block != null && pendingMarkerIdentity(block, block.getType()) != null; + } + /** Expected material also lets a deferred physics repair recheck its original ownership. */ + public synchronized String pendingMarkerIdentity(Block block, Material expected) { + if (retired || block == null || !plugin.isEnabled()) return null; + PositionKey position = new PositionKey(block.getWorld().getUID(), block.getX(), block.getY(), block.getZ()); + Set candidates = positions.get(position); + if (candidates == null && !recordsLoaded) return startupTaggedIdentity(block, expected, position); + if (candidates == null || !block.getWorld().isChunkLoaded(position.x() >> 4, position.z() >> 4)) return null; + for (String identity : candidates) { + GravesConfig record = pending.get(identity); Grave grave = admitted.get(identity); + if (record == null || record.isDestroyed() || grave != null && grave.isRemove() + || !identity.equals(GraveIdentity.of(record)) || markerMaterial(record) != expected + || GraveMarkerCleanup.hasConflictingIdentity(plugin, block, identity)) continue; + return identity; + } + // A prior batch may have indexed an older grave at this position while + // the tagged successor still awaits its own record-admission batch. + return recordsLoaded ? null : startupTaggedIdentity(block, expected, position); + } + /** + * MySQL records are decoded asynchronously. Until that completes, use only + * the exact marker tag written at creation time; this protects events without + * admitting a claim or scanning the world. Legacy untagged markers remain + * intentionally outside this fallback. + */ + private String startupTaggedIdentity(Block block, Material expected, PositionKey position) { + if (!block.getWorld().isChunkLoaded(position.x() >> 4, position.z() >> 4) + || !Set.of(Material.CHEST, Material.PLAYER_HEAD, Material.BARRIER).contains(expected) + || block.getType() != expected && block.getType() != Material.AIR) return null; + var chunk = block.getChunk(); + if (chunk == null) return null; + String chunkId = chunk.getPersistentDataContainer().get( + GraveMarkerCleanup.positionKey(plugin, block), org.bukkit.persistence.PersistentDataType.STRING); + String tileId = null; + var state = block.getState(); + if (state instanceof org.bukkit.block.TileState tile) { + tileId = tile.getPersistentDataContainer().get(GraveMarkerCleanup.identityKey(plugin), + org.bukkit.persistence.PersistentDataType.STRING); + } + // Metadata already identifies a live or removed owner. Cleanup can be + // withheld during startup; its tags must not become a fresh pending marker. + for (var value : block.getMetadata("Grave")) { + if (value.getOwningPlugin() == plugin && value.value() instanceof Grave) return null; + } + if (block.getType() != Material.AIR && expected != Material.BARRIER && tileId == null) return null; + if (chunkId == null && tileId == null || chunkId != null && tileId != null && !chunkId.equals(tileId)) return null; + String identity = chunkId != null ? chunkId : tileId; + String[] parts = identity.split("/", -1); + try { + if (parts.length != 6 || Long.parseLong(parts[1]) < 0 || !position.world().toString().equals(parts[2]) + || Integer.parseInt(parts[3]) != position.x() || Integer.parseInt(parts[4]) != position.y() + || Integer.parseInt(parts[5]) != position.z()) return null; + UUID.fromString(parts[0]); UUID.fromString(parts[2]); + } catch (RuntimeException invalid) { return null; } + GravesConfig record = pending.get(identity); Grave grave = admitted.get(identity); + if (record != null && (record.isDestroyed() || !identity.equals(GraveIdentity.of(record)) + || markerMaterial(record) != expected) || grave != null && grave.isRemove()) return null; + return GraveMarkerCleanup.hasConflictingIdentity(plugin, block, identity) ? null : identity; + } + private Material markerMaterial(GravesConfig record) { + GraveDisplayType type; + try { type = GraveDisplayType.valueOf(record.getGraveDisplayType().trim().toUpperCase(Locale.ROOT)); } + catch (RuntimeException invalid) { type = plugin.getConfigFile().getGraveDisplayTypeEnum(); } + if (type == null) return null; + return switch (type) { case CHEST -> Material.CHEST; case DISPLAY_ENTITY -> Material.BARRIER; case PLAYER_HEAD -> Material.PLAYER_HEAD; }; + } + public synchronized void onChunk(UUID world, int x, int z) { + if (retired || !recordsLoaded || !running.compareAndSet(false, true)) return; + // Immediately completed UNAVAILABLE decisions recurse on this owner. Keep + // native chunk admission bounded like the timer, leaving the rest indexed. + process(chunks.getOrDefault(new ChunkKey(world, x, z), Set.of()).stream().limit(MAX_BATCH).toList().iterator()); + } + public synchronized boolean isLoading() { return !recordsLoaded; } + public boolean isConflicted(String identity) { return identity != null && conflicts.contains(identity); } + public synchronized boolean hasPending(String identity) { return identity != null && pending.containsKey(identity); } + public void retire() { retired = true; if (timer != null) timer.cancel(); } +} diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveTimeLimit.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveTimeLimit.java new file mode 100644 index 0000000..c0af657 --- /dev/null +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveTimeLimit.java @@ -0,0 +1,41 @@ +package com.bencodez.gravestonesplus.graves; + +import java.time.Duration; +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; +import com.bencodez.simpleapi.time.ParsedDuration; + +/** Validates replacement lifetimes before changing an existing grave timer. */ +final class GraveTimeLimit { + private static final Pattern SEGMENT = Pattern.compile("([0-9]+)\\s*([a-z]+)"); + private static final Set UNITS = Set.of("ms", "msec", "msecs", "millisecond", "milliseconds", + "s", "sec", "secs", "second", "seconds", "m", "min", "mins", "minute", "minutes", + "h", "hr", "hrs", "hour", "hours", "d", "day", "days", "w", "wk", "wks", "week", "weeks", + "mo", "mon", "mons", "month", "months"); + private GraveTimeLimit() { } + static ParsedDuration parse(String raw) { + if (raw == null || raw.isBlank()) return ParsedDuration.empty(); + String text = raw.trim().toLowerCase(Locale.ROOT); + if (text.matches("[+-]?[0-9]+")) { + long minutes = Long.parseLong(text); + return ParsedDuration.ofMillis(minutes <= 0 ? 0 : Math.multiplyExact(minutes, 60_000L)); + } + if (text.startsWith("p")) { + return ParsedDuration.ofMillis(Duration.parse(text.toUpperCase(Locale.ROOT)).toMillis()); + } + var tokens = SEGMENT.matcher(text); + int end = 0; long millis = 0; + while (tokens.find()) { + if (!text.substring(end, tokens.start()).isBlank() || !UNITS.contains(tokens.group(2))) + throw new IllegalArgumentException("Invalid grave lifetime"); + long value = Long.parseLong(tokens.group(1)); + long unit = ParsedDuration.parse("1" + tokens.group(2), TimeUnit.MINUTES).getMillis(); + millis = Math.addExact(millis, Math.multiplyExact(value, unit)); + end = tokens.end(); + } + if (end == 0 || !text.substring(end).isBlank()) throw new IllegalArgumentException("Invalid grave lifetime"); + return ParsedDuration.ofMillis(millis); + } +} diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/TrustedGraveAccess.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/TrustedGraveAccess.java new file mode 100644 index 0000000..49fecc6 --- /dev/null +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/TrustedGraveAccess.java @@ -0,0 +1,27 @@ +package com.bencodez.gravestonesplus.graves; + +import java.util.UUID; + +/** A bounded, persisted grant for one player and one grave. */ +public final class TrustedGraveAccess { + public enum Kind { TRACK, RECOVER } + private final UUID graveOwner; + private final long graveTime; + private final UUID helper; + private final Kind kind; + private final long expiresAt; + + public TrustedGraveAccess(UUID graveOwner, long graveTime, UUID helper, Kind kind, long expiresAt) { + this.graveOwner = graveOwner; + this.graveTime = graveTime; + this.helper = helper; + this.kind = kind; + this.expiresAt = expiresAt; + } + public UUID getGraveOwner() { return graveOwner; } + public long getGraveTime() { return graveTime; } + public UUID getHelper() { return helper; } + public Kind getKind() { return kind; } + public long getExpiresAt() { return expiresAt; } + public boolean isActive(long now) { return expiresAt > now; } +} diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/TrustedGraveAccessStore.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/TrustedGraveAccessStore.java new file mode 100644 index 0000000..557612f --- /dev/null +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/TrustedGraveAccessStore.java @@ -0,0 +1,128 @@ +package com.bencodez.gravestonesplus.graves; + +import java.nio.file.Path; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.function.LongSupplier; +import com.bencodez.gravestonesplus.GraveStonesPlus; + +/** Grants are visible only after durable acknowledgment. Failed revocation denies locally. */ +public final class TrustedGraveAccessStore { + public static final long MAX_LIFETIME = 90L * 24 * 60 * 60 * 1000; + private final DurableFeatureState state; + private final LongSupplier clock; + private final GraveStonesPlus plugin; + private final Map denied = new HashMap<>(); + private final Map deniedGraves = new HashMap<>(); + private long operation; + private boolean denyAll; + public TrustedGraveAccessStore(GraveStonesPlus plugin) { + this(new DurableFeatureState(plugin.getFeatureIO(), new java.io.File(plugin.getDataFolder(), "selected-access.yml").toPath(), 4096), System::currentTimeMillis, plugin); + } + public TrustedGraveAccessStore(DurableFeatureState state, LongSupplier clock) { this(state, clock, null); } + TrustedGraveAccessStore(DurableFeatureState state, LongSupplier clock, GraveStonesPlus plugin) { + this.state = state; this.clock = clock; this.plugin = plugin; + } + public CompletableFuture loaded() { return state.loaded(); } + public boolean isReady() { return state.isReady(); } + private static String key(String grave, UUID helper, TrustedGraveAccess.Kind kind) { return grave + "/" + helper + "/" + kind; } + public synchronized CompletableFuture grantAsync(String grave, UUID helper, TrustedGraveAccess.Kind kind, long expiresAt) { + long now = clock.getAsLong(); + if (grave == null || helper == null || kind == null || expiresAt <= now || expiresAt - now > MAX_LIFETIME) return CompletableFuture.completedFuture(false); + String key = key(grave, helper, kind); + final long admitted; synchronized (this) { admitted = ++operation; } + return state.update(next -> { + next.entrySet().removeIf(e -> expired(e.getValue(), clock.getAsLong())); + next.put(key, Long.toString(expiresAt)); return true; + }).thenApply(saved -> { if (saved) synchronized (this) { if (denied.getOrDefault(key, Long.MIN_VALUE) <= admitted) denied.remove(key); } return saved; }); + } + private static boolean expired(String value, long now) { + try { return Long.parseLong(value) <= now; } catch (RuntimeException failure) { return true; } + } + public synchronized CompletableFuture revokeAsync(String grave, UUID helper, TrustedGraveAccess.Kind kind) { + if (grave == null || helper == null || kind == null) return CompletableFuture.completedFuture(false); + String key = key(grave, helper, kind); + final long admitted; + synchronized (this) { + admitted = ++operation; + if (denied.size() >= 4096 && !denied.containsKey(key)) denyAll = true; + else denied.put(key, admitted); + } + return state.update(next -> { next.remove(key); return true; }).thenApply(saved -> { + if (saved) synchronized (this) { if (Objects.equals(denied.get(key), admitted)) denied.remove(key); } return saved; + }); + } + public synchronized boolean allows(String grave, UUID helper, TrustedGraveAccess.Kind kind) { + if (grave == null || helper == null || kind == null || !state.isReady() || denyAll) return false; + String key = key(grave, helper, kind); + String value = state.snapshot().get(key); + return !deniedGraves.containsKey(grave) && !denied.containsKey(key) && value != null && !expired(value, clock.getAsLong()); + } + public List list(String grave) { + if (grave == null || !state.isReady()) return List.of(); + String[] identity = grave.split("/"); + List result = new ArrayList<>(); + for (var entry : state.snapshot().entrySet()) if (entry.getKey().startsWith(grave + "/")) { + try { + String[] parts = entry.getKey().substring(grave.length() + 1).split("/"); + UUID helper = UUID.fromString(parts[0]); TrustedGraveAccess.Kind kind = TrustedGraveAccess.Kind.valueOf(parts[1]); + if (allows(grave, helper, kind)) result.add(new TrustedGraveAccess(UUID.fromString(identity[0]), Long.parseLong(identity[1]), helper, kind, Long.parseLong(entry.getValue()))); + } catch (RuntimeException ignored) { } + } + return List.copyOf(result); + } + public synchronized CompletableFuture revokeForGraveAsync(String grave) { + if (grave == null) return CompletableFuture.completedFuture(false); + // Deny before publication, including if the grave is recreated while cleanup waits. + long admitted = ++operation; + if (deniedGraves.size() >= 4096 && !deniedGraves.containsKey(grave)) denyAll = true; + else deniedGraves.put(grave, admitted); + return state.update(next -> { next.keySet().removeIf(key -> key.startsWith(grave + "/")); return true; }).thenApply(saved -> { + if (saved) synchronized (this) { if (Objects.equals(deniedGraves.get(grave), admitted)) deniedGraves.remove(grave); } + return saved; + }); + } + /** Resolve an owner/death reference only when it identifies one exact stored grave. */ + private String legacyIdentity(UUID owner, long time) { + if (owner == null) return null; + String prefix = owner + "/" + time + "/"; + Set identities = new HashSet<>(); + if (plugin != null) { + for (List records : List.of(plugin.activeGraveSnapshot(), plugin.brokenGraveSnapshot())) + for (Grave grave : records) { + String identity = GraveIdentity.of(grave.getGravesConfig()); + if (identity != null && identity.startsWith(prefix)) identities.add(identity); + } + } + // A standalone store can resolve already-persisted canonical grants after restart. + // Shortened legacy rows cannot establish location ownership and are never promoted. + for (String key : state.snapshot().keySet()) { + String[] parts = key.split("/", -1); + if (parts.length != 8 || !key.startsWith(prefix)) continue; + try { + UUID.fromString(parts[2]); Integer.parseInt(parts[3]); Integer.parseInt(parts[4]); Integer.parseInt(parts[5]); + UUID.fromString(parts[6]); TrustedGraveAccess.Kind.valueOf(parts[7]); + identities.add(String.join("/", Arrays.copyOf(parts, 6))); + } catch (IllegalArgumentException invalid) { /* A malformed row cannot establish grave ownership. */ } + } + return identities.size() == 1 ? identities.iterator().next() : null; + } + // Preserve owner/death signatures without writing keys the real authorization path cannot read. + public CompletableFuture grantAsync(UUID owner, long time, UUID helper, TrustedGraveAccess.Kind kind, long expires) { + return grantAsync(legacyIdentity(owner, time), helper, kind, expires); + } + public CompletableFuture revokeAsync(UUID owner, long time, UUID helper, TrustedGraveAccess.Kind kind) { + return revokeAsync(legacyIdentity(owner, time), helper, kind); + } + public boolean allows(UUID owner, long time, UUID helper, TrustedGraveAccess.Kind kind) { + return allows(legacyIdentity(owner, time), helper, kind); + } + public List list(UUID owner, long time) { return list(legacyIdentity(owner, time)); } + public CompletableFuture revokeForGraveAsync(UUID owner, long time) { + String identity = legacyIdentity(owner, time); + if (identity == null) return CompletableFuture.failedFuture(new IllegalArgumentException("Grave reference is missing or ambiguous")); + return revokeForGraveAsync(identity).thenCompose(saved -> Boolean.TRUE.equals(saved) + ? CompletableFuture.completedFuture(null) + : CompletableFuture.failedFuture(new IllegalStateException("Grave access revocation was not persisted"))); + } +} diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/GraveBlockProtectionListener.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/GraveBlockProtectionListener.java index 74336fa..e6fecdb 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/GraveBlockProtectionListener.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/GraveBlockProtectionListener.java @@ -18,6 +18,8 @@ import com.bencodez.advancedcore.api.misc.MiscUtils; import com.bencodez.gravestonesplus.GraveStonesPlus; import com.bencodez.gravestonesplus.graves.Grave; +import com.bencodez.gravestonesplus.graves.GraveIdentity; +import com.bencodez.gravestonesplus.graves.GraveMarkerCleanup; import lombok.Getter; import lombok.Setter; @@ -251,6 +253,9 @@ private boolean isProtectedGraveMarker(Block block) { return false; } + var restoration = plugin.getGraveRestoration(); + if (restoration != null && restoration.protectsPendingMarker(block)) return true; + Grave grave = getGrave(block); return grave != null && grave.isValid() && isProtected(block); } @@ -307,10 +312,22 @@ private void restoreIfRemovedNextTick(final Block block, final Material expected return; } + Grave original = getGrave(block); + var restoration = plugin.getGraveRestoration(); + String identity = original != null ? GraveIdentity.of(original.getGravesConfig()) + : restoration == null ? null : restoration.pendingMarkerIdentity(block, expected); + plugin.getBukkitScheduler().runTask(plugin, () -> { - if (block.getType() == Material.AIR) { + if (!plugin.isEnabled() || identity == null || !block.getWorld().isChunkLoaded(block.getX() >> 4, block.getZ() >> 4) + || GraveMarkerCleanup.hasConflictingIdentity(plugin, block, identity)) return; + Grave current = getGrave(block); + boolean live = current != null && !current.isRemove() && !current.getGravesConfig().isDestroyed() + && identity.equals(GraveIdentity.of(current.getGravesConfig())) && current.isGrave(block); + boolean pending = restoration != null && identity.equals(restoration.pendingMarkerIdentity(block, expected)); + if ((live || pending) && block.getType() == Material.AIR) { block.setType(expected, false); + GraveMarkerCleanup.tag(plugin, block, identity); } }, block.getLocation()); } -} \ No newline at end of file +} diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/GraveClaimListener.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/GraveClaimListener.java index f7ffdac..e6165c4 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/GraveClaimListener.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/GraveClaimListener.java @@ -44,27 +44,27 @@ public class GraveClaimListener implements Listener { * Tracks active held-left-click attempts. */ @Getter - private final Map mining = new HashMap(); + private final Map mining = new java.util.concurrent.ConcurrentHashMap(); /** * Last time a player swung at a grave. */ @Getter - private final Map lastSwing = new HashMap(); + private final Map lastSwing = new java.util.concurrent.ConcurrentHashMap(); /** * Tracks the last grave handled for a player to avoid duplicate processing from * multiple events firing for the same action. */ @Getter - private final Map lastHandledGrave = new HashMap(); + private final Map lastHandledGrave = new java.util.concurrent.ConcurrentHashMap(); /** * Tracks the last handled time for a player to avoid duplicate processing from * multiple events firing for the same action. */ @Getter - private final Map lastHandledTime = new HashMap(); + private final Map lastHandledTime = new java.util.concurrent.ConcurrentHashMap(); /** * Creates the listener. @@ -87,16 +87,11 @@ public void run() { continue; } - Grave grave = mining.get(uuid); - if (grave == null || !grave.isValid() || grave.getGravesConfig().isDestroyed()) { - clearPlayerState(uuid); - continue; - } - - Long last = lastSwing.get(uuid); - if (last == null || now - last > 400L) { - clearPlayerState(uuid); - } + plugin.getBukkitScheduler().runTask(plugin, () -> { + Grave grave = mining.get(uuid); + Long last = lastSwing.get(uuid); + if (!player.isOnline() || !plugin.isLiveGrave(grave) || last == null || now - last > 400L) clearPlayerState(uuid); + }, player); } } }, 5L, 5L); @@ -127,6 +122,13 @@ public void handleClaiming(Grave grave, Player player) { return; } + // A per-grave recovery grant is an explicit owner authorization and is + // revalidated again by Grave.claim at the final mutation boundary. + if (plugin.canRecover(grave, player)) { + grave.claim(player); + return; + } + if (!plugin.getConfigFile().isBreakOtherGravesEnabled()) { player.sendMessage(MessageAPI.colorize(plugin.getConfigFile().getFormatNotYourGrave())); return; @@ -175,6 +177,8 @@ public void onPlayerAnimation(PlayerAnimationEvent event) { return; } + if (isPendingMarker(block)) return; + Grave grave = getGraveFromBlock(block); if (grave == null || !grave.isValid()) { return; @@ -212,6 +216,8 @@ public void onPlayerInteract(PlayerInteractEvent event) { return; } + if (isPendingMarker(clicked)) { event.setCancelled(true); return; } + Grave grave = getGraveFromBlock(clicked); if (grave == null) { return; @@ -246,6 +252,8 @@ public void onBlockBreak(BlockBreakEvent event) { return; } + if (isPendingMarker(block)) { event.setCancelled(true); return; } + Grave grave = getGraveFromBlock(block); if (grave == null) { return; @@ -314,6 +322,11 @@ private Grave getGraveFromBlock(Block block) { return (Grave) obj; } + private boolean isPendingMarker(Block block) { + var restoration = plugin.getGraveRestoration(); + return restoration != null && restoration.protectsPendingMarker(block); + } + /** * Checks whether the material is used as a grave marker. * @@ -363,4 +376,4 @@ private void clearPlayerState(UUID uuid) { lastHandledTime.remove(uuid); plugin.getPlayerBreakManager().cancelBreak(uuid); } -} \ No newline at end of file +} diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/GraveCleanupListener.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/GraveCleanupListener.java new file mode 100644 index 0000000..0ce5dd8 --- /dev/null +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/GraveCleanupListener.java @@ -0,0 +1,13 @@ +package com.bencodez.gravestonesplus.listeners; +import org.bukkit.event.*; +import org.bukkit.event.world.ChunkLoadEvent; +import com.bencodez.gravestonesplus.GraveStonesPlus; +/** Per-chunk persisted cleanup and withheld startup reconciliation, without forced loading. */ +public final class GraveCleanupListener implements Listener { + private final GraveStonesPlus plugin; + public GraveCleanupListener(GraveStonesPlus plugin) { this.plugin = plugin; } + @EventHandler public void onChunkLoad(ChunkLoadEvent event) { + if (plugin.getMarkerCleanup() != null) plugin.getMarkerCleanup().onChunk(event.getWorld(), event.getChunk().getX(), event.getChunk().getZ()); + if (plugin.getGraveRestoration() != null) plugin.getGraveRestoration().onChunk(event.getWorld().getUID(), event.getChunk().getX(), event.getChunk().getZ()); + } +} diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/GraveReminderListener.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/GraveReminderListener.java new file mode 100644 index 0000000..ccb2024 --- /dev/null +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/GraveReminderListener.java @@ -0,0 +1,120 @@ +package com.bencodez.gravestonesplus.listeners; + +import java.util.*; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; +import com.bencodez.gravestonesplus.GraveStonesPlus; +import com.bencodez.gravestonesplus.graves.*; + +/** Bounded round-robin scheduling. All recipient reads and messages use entity ownership. */ +public final class GraveReminderListener implements Listener { + private final GraveStonesPlus plugin; + private volatile boolean running; + private volatile long generation; + private int cursor; + private com.bencodez.simpleapi.folialib.wrapper.task.WrappedTask task; + private final Map graveCursors = new java.util.concurrent.ConcurrentHashMap<>(); + private final java.util.concurrent.ConcurrentMap scheduled = new java.util.concurrent.ConcurrentHashMap<>(); + private static final int LOGIN_PENDING = Integer.MIN_VALUE; + public GraveReminderListener(GraveStonesPlus plugin) { this.plugin = plugin; } + public void start() { + running = true; long expected = ++generation; + task = plugin.getBukkitScheduler().getFoliaLib().getImpl().runTimer(() -> { + if (!running || generation != expected) return; + List online = new ArrayList<>(Bukkit.getOnlinePlayers()); + if (online.isEmpty()) return; + int count = Math.min(16, online.size()); + for (int i = 0; i < count; i++) { + Player player = online.get(Math.floorMod(cursor++, online.size())); + schedule(player, expected); + } + }, 20L, 100L); + } + public void stop() { if (task != null) { task.cancel(); task = null; } running = false; generation++; scheduled.clear(); graveCursors.clear(); } + @EventHandler public void onJoin(PlayerJoinEvent event) { + UUID id = event.getPlayer().getUniqueId(); + if (graveCursors.size() < 4096 || graveCursors.containsKey(id)) graveCursors.put(id, LOGIN_PENDING); + schedule(event.getPlayer(), generation); + } + private void schedule(Player player, long expected) { + UUID id = player.getUniqueId(); + Object admission = new Object(); + synchronized (scheduled) { + if (!running || generation != expected || scheduled.size() >= 128 + || scheduled.putIfAbsent(id, admission) != null) return; + } + var started = new java.util.concurrent.atomic.AtomicBoolean(); + Runnable release = () -> scheduled.remove(id, admission); + Runnable retired = () -> { if (started.compareAndSet(false, true)) release.run(); }; + try { + plugin.getBukkitScheduler().getFoliaLib().getImpl().runAtEntityWithFallback(player, ignored -> { + if (!started.compareAndSet(false, true)) return; + try { + if (running && generation == expected && scheduled.get(id) == admission && player.isOnline()) + checkPlayer(player); + } finally { release.run(); } + }, retired).whenComplete((result, failure) -> { + if (failure != null || result != com.bencodez.simpleapi.folialib.enums.EntityTaskResult.SUCCESS) + retired.run(); + }); + } catch (RuntimeException rejected) { retired.run(); } + } + @EventHandler public void onQuit(org.bukkit.event.player.PlayerQuitEvent event) { + scheduled.remove(event.getPlayer().getUniqueId()); graveCursors.remove(event.getPlayer().getUniqueId()); + plugin.getGraveNavigation().forget(event.getPlayer()); + } + public void checkPlayer(Player player) { + if (!running || !player.isOnline()) return; + // Navigation has its own grave/access readiness guards. A failed + // reminder file must not prevent bounded cleanup of stale compasses. + plugin.getGraveNavigation().refresh(player); + if (!readyForLoginSummary()) return; + boolean loginSummary = graveCursors.getOrDefault(player.getUniqueId(), 0) == LOGIN_PENDING; + long now = System.currentTimeMillis(); + List notices = new ArrayList<>(); + List owned = plugin.getGraves(player); + int start = graveCursors.getOrDefault(player.getUniqueId(), 0); + if (start == LOGIN_PENDING) start = 0; + int examined = Math.min(256, owned.size()); + for (int i = 0; i < examined; i++) { + Grave grave = owned.get(Math.floorMod(start + i, owned.size())); + if (!plugin.isLiveGrave(grave)) continue; + long expiry = grave.getExpiryDeadline(); + long protection = plugin.getConfigFile().isBreakOtherGravesEnabled() + ? GraveDeadlines.deadline(grave.getGravesConfig().getTime(), plugin.getConfigFile().getBreakOtherGravesTimeBeforeBreakable().getMillis()) : 0; + add(notices, player, grave, "expiry", expiry, plugin.getConfigFile().getExpiryReminderMillis(), now); + add(notices, player, grave, "protection", protection, plugin.getConfigFile().getProtectionReminderMillis(), now); + } + List sent = plugin.getGraveReminderStore().deliver(notices, now, fresh -> { + if (!running || !player.isOnline()) return false; + String details = fresh.stream().limit(3).map(GraveReminderStore.Notice::description).collect(java.util.stream.Collectors.joining("; ")); + player.sendMessage("\u00a7eGraves: " + details + (fresh.size() > 3 ? "; +" + (fresh.size() - 3) + " deadlines" : "") + ". /gravestonesplus graves"); + return true; + }); + if (loginSummary && sent.isEmpty() && player.isOnline()) { + List accessible = plugin.getAccessibleGraves(player); + if (!accessible.isEmpty()) player.sendMessage("\u00a7eYou have " + accessible.size() + " accessible grave(s). " + + accessible.get(0).getNavigationMessage(player) + ". /gravestonesplus graves"); + } + // Publish the cursor only after delivery/summary completes. A failed send retains the login retry. + if (graveCursors.size() < 4096 || graveCursors.containsKey(player.getUniqueId())) + graveCursors.put(player.getUniqueId(), start + examined); + } + private boolean readyForLoginSummary() { + var restoration = plugin.getGraveRestoration(); + return plugin.getGraveReminderStore().isReady() + && plugin.getTrustedGraveAccessStore().isReady() + && plugin.getGraveClaimLedger().isReady() + && (restoration == null || !restoration.isLoading()); + } + private void add(List notices, Player player, Grave grave, String kind, long deadline, long threshold, long now) { + if (!GraveDeadlines.eligible(now, deadline, threshold)) return; + String key = player.getUniqueId() + "/" + GraveIdentity.of(grave.getGravesConfig()) + "/" + kind + "/" + deadline; + String label = kind.equals("expiry") ? "expires" : "protection ends"; + notices.add(new GraveReminderStore.Notice(key, deadline, + grave.getLocationDescription() + " " + label + " in " + Math.max(1, (deadline - now + 59_999) / 60_000) + "m")); + } +} diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/ItemRuleEvaluator.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/ItemRuleEvaluator.java new file mode 100644 index 0000000..7ef4300 --- /dev/null +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/ItemRuleEvaluator.java @@ -0,0 +1,90 @@ +package com.bencodez.gravestonesplus.listeners; + +import java.util.*; +import java.util.function.Predicate; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.enchantments.Enchantment; +import com.bencodez.gravestonesplus.GraveStonesPlus; +import com.bencodez.gravestonesplus.nbt.NBTRule; +import com.bencodez.gravestonesplus.nbt.NBTConfigManager.RulesSnapshot; +import com.bencodez.advancedcore.api.messages.PlaceholderUtils; + +/** Shared ordered death routing. No random sampling, writes, or death mutation. */ +public final class ItemRuleEvaluator { + public enum Outcome { IGNORE, SOULBOUND, KEEP_LORE, KEEP_NBT, GRAVE_NBT, VANISH, GRAVE_DEFAULT, EMPTY } + private final Predicate ignore, soulbound, keep, grave, vanish; + private final String lore; + public ItemRuleEvaluator(Predicate ignore, Predicate soulbound, String lore, + Predicate keep, Predicate grave) { + this(ignore, soulbound, lore, keep, grave, ItemRuleEvaluator::vanishes); + } + public ItemRuleEvaluator(Predicate ignore, Predicate soulbound, String lore, + Predicate keep, Predicate grave, Predicate vanish) { + this.ignore = ignore; this.soulbound = soulbound; this.lore = lore; this.keep = keep; this.grave = grave; this.vanish = vanish; + } + public static ItemRuleEvaluator forPlayer(GraveStonesPlus plugin, Player player) { + final RulesSnapshot snapshot = plugin.isNbtAPIHooked() && plugin.getNbtConfigManager() != null + ? plugin.getNbtConfigManager().snapshot() : null; + return forPlayer(plugin, player, snapshot); + } + private static ItemRuleEvaluator forPlayer(GraveStonesPlus plugin, Player player, RulesSnapshot snapshot) { + HashMap placeholders = new HashMap<>(); + placeholders.put("player", player.getName()); placeholders.put("displayname", player.getDisplayName()); + String lore = PlaceholderUtils.replacePlaceHolder(snapshot == null ? plugin.getConfigFile().getKeepItemsWithLore() : snapshot.keepLore(), placeholders); + return new ItemRuleEvaluator(item -> matches(plugin, item, 0, snapshot), + item -> plugin.getSlimefun() != null && plugin.getSlimefun().isSoulBoundItem(item.clone()), lore, + item -> matches(plugin, item, 1, snapshot), item -> matches(plugin, item, 2, snapshot)); + } + private static boolean matches(GraveStonesPlus plugin, ItemStack item, int stage, RulesSnapshot snapshot) { + if (!plugin.isNbtAPIHooked() || snapshot == null) return false; + List rules = switch (stage) { case 0 -> snapshot.ignore(); case 1 -> snapshot.keep(); default -> snapshot.grave(); }; + return new PlayerDeathNBTHandle().doesItemMatchAnyRule(plugin, item.clone(), rules); + } + public Outcome evaluate(ItemStack item) { + if (item == null || item.getType().isAir()) return Outcome.EMPTY; + if (ignore.test(item)) return Outcome.IGNORE; + if (soulbound.test(item)) return Outcome.SOULBOUND; + if (matchesLore(item, lore)) return Outcome.KEEP_LORE; + if (keep.test(item)) return Outcome.KEEP_NBT; + if (grave.test(item)) return Outcome.GRAVE_NBT; + if (vanish.test(item)) return Outcome.VANISH; + return Outcome.GRAVE_DEFAULT; + } + public static boolean matchesLore(ItemStack item, String text) { + if (item == null || text == null || text.isEmpty() || !item.hasItemMeta()) return false; + ItemMeta meta = item.getItemMeta(); + if (meta == null || !meta.hasLore() || meta.getLore() == null) return false; + for (String line : meta.getLore()) if (line != null && line.equalsIgnoreCase(text)) return true; + return false; + } + public static boolean vanishes(ItemStack item) { + return item != null && item.hasItemMeta() && item.getItemMeta().getEnchants().containsKey(Enchantment.VANISHING_CURSE); + } + public static int effectiveChance(int configured) { return Math.max(0, Math.min(100, configured)); } + public static List inspect(GraveStonesPlus plugin, Player player, ItemStack held) { + ItemStack item = held.clone(); + List lines = new ArrayList<>(); + lines.add("&7Held: &f" + item.getType() + " x" + item.getAmount()); + if (item.hasItemMeta()) { + ItemMeta meta = item.getItemMeta(); + lines.add("&7Metadata: &fdisplay=" + (meta.hasDisplayName() ? meta.getDisplayName() : "none") + + ", lore=" + (meta.hasLore() ? meta.getLore() : "none") + ", enchants=" + meta.getEnchants() + + ", flags=" + meta.getItemFlags() + ", PDC keys=" + meta.getPersistentDataContainer().getKeys()); + } + lines.add("&7Order: Ignore NBT > Slimefun > exact lore > Keep NBT > Grave NBT > Vanishing > grave default"); + RulesSnapshot snapshot = plugin.isNbtAPIHooked() && plugin.getNbtConfigManager() != null + ? plugin.getNbtConfigManager().snapshot() : null; + if (plugin.isNbtAPIHooked() && plugin.getNbtConfigManager() != null) { + PlayerDeathNBTHandle matcher = new PlayerDeathNBTHandle(); + lines.add("&7NBT matches: Ignore=" + matcher.matchingRuleNames(plugin, item, snapshot.ignore()) + + "; Keep=" + matcher.matchingRuleNames(plugin, item, snapshot.keep()) + + "; Grave=" + matcher.matchingRuleNames(plugin, item, snapshot.grave())); + } else lines.add("&7NBTAPI unavailable: NBT stages are inactive."); + lines.add("&7When grave creation proceeds: &f" + forPlayer(plugin, player, snapshot).evaluate(item)); + lines.add("&7Later recovery/drop chance: &f" + effectiveChance(plugin.getConfigFile().getPercentageDrops()) + "% per stack; no chance sampled."); + lines.add("&eRead only. World/permission/keep-inventory/combat/placement checks and external death hooks can change eligibility. NBT list indices are unsupported."); + return List.copyOf(lines); + } +} diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/PlayerDeathListener.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/PlayerDeathListener.java index b78f1b1..92d8553 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/PlayerDeathListener.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/PlayerDeathListener.java @@ -1,8 +1,12 @@ package com.bencodez.gravestonesplus.listeners; import java.util.HashMap; +import java.util.ArrayList; import java.util.List; import java.util.UUID; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import java.util.function.BiConsumer; import org.bukkit.Location; @@ -22,6 +26,7 @@ import com.bencodez.gravestonesplus.GraveStonesPlus; import com.bencodez.gravestonesplus.events.GraveRemoveReason; import com.bencodez.gravestonesplus.graves.Grave; +import com.bencodez.gravestonesplus.graves.GraveCreationTask; import com.bencodez.gravestonesplus.nbt.NBTRule; import com.bencodez.gravestonesplus.storage.GravesConfig; import com.bencodez.simpleapi.messages.MessageAPI; @@ -38,10 +43,37 @@ public PlayerDeathListener(GraveStonesPlus plugin) { this.plugin = plugin; } - private final HashMap> deathItems = new HashMap>(); + // Players in separate Folia regions can die/respawn concurrently. Each value is + // one death generation; delayed callbacks must never consume a successor's items. + private static final class KeptBatch { + final Map items; + volatile boolean accepted; + KeptBatch(Map source) { + HashMap snapshot = new HashMap<>(); + source.forEach((slot, item) -> snapshot.put(slot, item.clone())); + items = Map.copyOf(snapshot); + } + } + private static final class KeptItems { + final List batches; + // A new identity fences callbacks even when the new death captures no items. + KeptItems(KeptItems previous) { batches = previous.batches; } + KeptItems(List batches) { this.batches = List.copyOf(batches); } + KeptItems(KeptItems previous, Map source) { + List merged = previous == null ? new ArrayList<>() : new ArrayList<>(previous.batches); + merged.add(new KeptBatch(source)); + batches = List.copyOf(merged); + } + KeptBatch newest() { return batches.get(batches.size() - 1); } + boolean ready() { return batches.stream().allMatch(batch -> batch.accepted); } + } + private static final int MAX_PENDING_KEPT_SNAPSHOTS = 8; + private final ConcurrentMap deathItems = new ConcurrentHashMap<>(); @EventHandler(priority = EventPriority.HIGHEST) public void onPlayerDeath(PlayerDeathEvent event) { + final UUID playerId = event.getEntity().getUniqueId(); + deathItems.computeIfPresent(playerId, (id, previous) -> new KeptItems(previous)); final Location deathLocation = event.getEntity().getLocation(); if (plugin.getConfigFile().getDisabledWorlds().contains(deathLocation.getWorld().getName())) { plugin.debug("Graves in " + deathLocation.getWorld().getName() + " are disabled"); @@ -78,16 +110,7 @@ public void onPlayerDeath(PlayerDeathEvent event) { } final Player entity = event.getEntity(); - if (plugin.numberOfGraves(entity.getUniqueId()) >= plugin.getConfigFile().getGraveLimit()) { - Grave oldest = plugin.getOldestGrave(entity.getUniqueId()); - if (oldest != null) { - if (plugin.getConfigFile().isDropItemsOnGraveRemoval()) { - oldest.dropItemsOnGround(entity); - } - oldest.removeGrave(GraveRemoveReason.LIMIT_REACHED); - entity.sendMessage(MessageAPI.colorize(plugin.getConfigFile().getFormatGraveLimitBreak())); - } - } + final String playerName = entity.getName(); Location emptyBlock = null; if (deathLocation.getBlock().isEmpty() && deathLocation.getBlockY() > deathLocation.getWorld().getMinHeight() @@ -114,12 +137,8 @@ public void onPlayerDeath(PlayerDeathEvent event) { // Add any other player-related placeholders you might need here final String deathMessage = event.getDeathMessage(); - if (plugin.getConfigFile().isKeepAllExp()) { - event.setDroppedExp(getTotalExperience(event.getEntity())); - } - final int droppedExp = event.getDroppedExp(); - event.setDroppedExp(0); // Clear Bukkit's default dropped experience - event.getDrops().clear(); // Clear Bukkit's default dropped items + final int droppedExp = plugin.getConfigFile().isKeepAllExp() + ? getTotalExperience(event.getEntity()) : event.getDroppedExp(); // Store items that will go into the grave and items that will be kept HashMap itemsWithSlot = new HashMap(); // Items to be placed in the @@ -127,73 +146,14 @@ public void onPlayerDeath(PlayerDeathEvent event) { HashMap keepItems = new HashMap(); // Items to be kept in the player's // inventory - BiConsumer processItem = (idx, item) -> { - if (item != null && !item.getType().isAir()) { // Ensure item exists and is not air - // plugin.getLogger().info("Processing item: " + item.getType().name() + " at - // slot " + idx); // Debug log - - // --- Priority 1: Ignore NBT Rules --- - if (plugin.getNbtConfigManager() != null - && doesItemMatchAnyRule(item, plugin.getNbtConfigManager().getIgnoreNbtRules())) { - // plugin.getLogger().info(" Item " + item.getType().name() + " matched an - // 'ignore' NBT rule. Skipping further processing."); - return; // Return immediately, this item is not handled by GraveStonesPlus - } - - // --- Priority 2: Slimefun Soulbound Items --- - if (plugin.getSlimefun() != null && plugin.getSlimefun().isSoulBoundItem(item)) { - // plugin.getLogger().info(" Item " + item.getType().name() + " is Slimefun - // Soulbound. Allowing Slimefun to handle."); - return; // Assume Slimefun will handle and keep these items itself, so we don't - // interfere - } - - // --- Priority 3: Lore Matching Rules --- - // Here, playerPlaceholders will be used to replace placeholders in the - // keepItemsWithLore config - final String loreToMatch = com.bencodez.advancedcore.api.messages.PlaceholderUtils - .replacePlaceHolder(plugin.getConfigFile().getKeepItemsWithLore(), playerPlaceholders); - if (keepItemsWithMatchingLore(item, loreToMatch)) { - keepItems.put(idx, item); - // plugin.getLogger().info(" Item " + item.getType().name() + " kept due to - // matching lore: '" + loreToMatch + "'"); - return; - } - - // --- Priority 4: Keep NBT Rules --- - if (plugin.getNbtConfigManager() != null - && doesItemMatchAnyRule(item, plugin.getNbtConfigManager().getKeepNbtRules())) { - keepItems.put(idx, item); - // plugin.getLogger().info(" Item " + item.getType().name() + " kept due to - // matching NBT 'keep' rule."); - return; - } - - // --- Priority 5: Grave NBT Rules --- - if (plugin.getNbtConfigManager() != null - && doesItemMatchAnyRule(item, plugin.getNbtConfigManager().getGraveNbtRules())) { - itemsWithSlot.put(idx, item); - // plugin.getLogger().info(" Item " + item.getType().name() + " put in grave due - // to matching NBT 'grave' rule."); - return; - } - - // --- Priority 6: Curse of Vanishing --- - if (hasCurseOfVanishing(item)) { - // plugin.getLogger().info(" Item " + item.getType().name() + " has Curse of - // Vanishing and not kept by any rule, will be removed."); - return; // The item will not be added to any list, thus being removed - } - - // --- Priority 7: Default behavior (if none of the above rules match) --- - itemsWithSlot.put(idx, item); - // plugin.getLogger().info(" Item " + item.getType().name() + " (default) put in - // grave."); - - } else { - // plugin.getLogger().info(" Slot " + idx + " is empty or air, skipping."); - } - }; + final ItemRuleEvaluator evaluator = ItemRuleEvaluator.forPlayer(plugin, entity); + BiConsumer processItem = (idx, item) -> { + switch (evaluator.evaluate(item)) { + case KEEP_LORE, KEEP_NBT -> keepItems.put(idx, item); + case GRAVE_NBT, GRAVE_DEFAULT -> itemsWithSlot.put(idx, item.clone()); + default -> { } + } + }; // Iterate through player inventory slots and process items for (int i = 0; i < 36; i++) { // Main inventory (0-35) @@ -206,19 +166,35 @@ && doesItemMatchAnyRule(item, plugin.getNbtConfigManager().getGraveNbtRules())) processItem.accept(-4, inv.getBoots()); // Boots processItem.accept(-5, inv.getItemInOffHand()); // Offhand - if (!keepItems.isEmpty()) { - deathItems.put(event.getEntity().getUniqueId(), keepItems); + // Admission comes before limit removal, but a failed preflight must not + // leave these same items eligible for both native drops and respawn. + KeptItems[] admitted = { null }; + if (!keepItems.isEmpty()) { + deathItems.compute(playerId, (id, pending) -> { + if (pending != null && pending.batches.size() >= MAX_PENDING_KEPT_SNAPSHOTS) return pending; + KeptItems merged = new KeptItems(pending, keepItems); + admitted[0] = merged; + return merged; + }); + if (admitted[0] == null) { + plugin.getLogger().warning("Kept items not retained: pending snapshot limit reached"); + return; + } } final Location emptyBlockFinal = emptyBlock; - plugin.getBukkitScheduler().runTaskLater(plugin, new Runnable() { - @Override - public void run() { - Grave grave = new Grave(plugin, - new GravesConfig(entity.getUniqueId(), entity.getName(), emptyBlockFinal, itemsWithSlot, - droppedExp, deathMessage, System.currentTimeMillis(), false, 0, null, null, - plugin.getConfigFile().getGraveDisplayTypeEnum().name())); + + final GravesConfig record; + try { + record = new GravesConfig(playerId, playerName, emptyBlockFinal, itemsWithSlot, + droppedExp, deathMessage, System.currentTimeMillis(), false, 0, null, null, + plugin.getConfigFile().getGraveDisplayTypeEnum().name()); + } catch (RuntimeException rejected) { + if (admitted[0] != null) rollbackKeptBatch(playerId, admitted[0]); + throw rejected; + } + GraveCreationTask handoff = new GraveCreationTask(plugin, record, grave -> { grave.loadChunk(false); grave.createGrave(); @@ -236,15 +212,67 @@ public void run() { String msg = MessageAPI.colorize(com.bencodez.advancedcore.api.messages.PlaceholderUtils .replacePlaceHolder(plugin.getConfigFile().getFormatDeath(), placeholders)); if (!msg.isEmpty()) { - entity.sendMessage(msg); + try { + plugin.getBukkitScheduler().runTask(plugin, () -> { + if (plugin.isEnabled() && entity.isOnline()) entity.sendMessage(msg); + }, entity); + } catch (RuntimeException rejected) { + // A retired player cannot receive a message; the grave itself is already established. + plugin.getLogger().fine("Skipping grave notification: player scheduler unavailable"); + } } plugin.getLogger().info("Grave: " + emptyBlockFinal.toString()); - } - }, 2, emptyBlockFinal); + }); + boolean scheduled = false; + try { + handoff.schedule(emptyBlockFinal); + scheduled = true; + } finally { + if (!scheduled && admitted[0] != null) rollbackKeptBatch(playerId, admitted[0]); + } + + boolean accepted = false; + try { + if (handoff.priorGraves() >= plugin.getConfigFile().getGraveLimit()) { + Grave oldest = handoff.replacement(); + if (oldest != null) { + if (plugin.getConfigFile().isDropItemsOnGraveRemoval()) { + if (!oldest.admitLimitRemoval(entity)) { + plugin.getLogger().warning("Grave creation declined: limit payout admission unavailable; death drops retained"); + return; + } + } else { + oldest.removeGrave(GraveRemoveReason.LIMIT_REACHED); + entity.sendMessage(MessageAPI.colorize(plugin.getConfigFile().getFormatGraveLimitBreak())); + } + } + } + event.setDroppedExp(0); + event.getDrops().clear(); + if (admitted[0] != null) admitted[0].newest().accepted = true; + handoff.accept(); + accepted = true; + } finally { + if (!accepted) { + handoff.cancel(); + if (admitted[0] != null) rollbackKeptBatch(playerId, admitted[0]); + } + } } + private void rollbackKeptBatch(UUID playerId, KeptItems admitted) { + // Remove only this death's batch from the current generation. Restoring + // an earlier whole snapshot would discard a successor's kept items. + KeptBatch batch = admitted.newest(); + deathItems.computeIfPresent(playerId, (id, pending) -> { + List retained = new ArrayList<>(pending.batches); + retained.removeIf(candidate -> candidate == batch); + return retained.isEmpty() ? null : new KeptItems(retained); + }); + } + private boolean doesItemMatchAnyRule(ItemStack item, List keepNbtRules) { if (plugin.isNbtAPIHooked()) { return new PlayerDeathNBTHandle().doesItemMatchAnyRule(plugin, item, keepNbtRules); @@ -256,66 +284,86 @@ private boolean doesItemMatchAnyRule(ItemStack item, List keepNbtRules) public void onRespawn(PlayerRespawnEvent event) { Player player = event.getPlayer(); UUID uuid = player.getUniqueId(); - if (deathItems.containsKey(uuid)) { - HashMap items = deathItems.remove(uuid); - plugin.getBukkitScheduler().runTask(plugin, () -> { + KeptItems pending = deathItems.get(uuid); + if (pending != null) { + try { + plugin.getBukkitScheduler().runTask(plugin, () -> { + if (!plugin.isEnabled() || !player.isOnline() || player.isDead() || !pending.ready() || !deathItems.remove(uuid, pending)) return; PlayerInventory inv = player.getInventory(); - items.forEach((slot, item) -> { + pending.batches.forEach(batch -> batch.items.forEach((slot, item) -> { if (item == null || item.getType().isAir()) return; if (slot >= 0) { if (inv.getItem(slot) == null) inv.setItem(slot, item); else - inv.addItem(item); + dropOverflow(player, inv.addItem(item)); } else { switch (slot) { case -1: if (isSlotAvailable(inv.getHelmet())) inv.setHelmet(item); else - inv.addItem(item); + dropOverflow(player, inv.addItem(item)); break; case -2: if (isSlotAvailable(inv.getChestplate())) inv.setChestplate(item); else - inv.addItem(item); + dropOverflow(player, inv.addItem(item)); break; case -3: if (isSlotAvailable(inv.getLeggings())) inv.setLeggings(item); else - inv.addItem(item); + dropOverflow(player, inv.addItem(item)); break; case -4: if (isSlotAvailable(inv.getBoots())) inv.setBoots(item); else - inv.addItem(item); + dropOverflow(player, inv.addItem(item)); break; case -5: if (isSlotAvailable(inv.getItemInOffHand())) inv.setItemInOffHand(item); else - inv.addItem(item); + dropOverflow(player, inv.addItem(item)); break; } } - }); - }); + })); + }, player); + } catch (RuntimeException rejected) { + // Keep this generation for a later respawn; never mutate a retired player off-owner. + plugin.getLogger().warning("Kept items remain pending: player scheduler unavailable"); + } } if (plugin.getConfigFile().isGiveCompassOnRespawn()) { - plugin.getBukkitScheduler().runTaskLaterAsynchronously(plugin, () -> { + Runnable deliverCompass = () -> { + if (!plugin.isEnabled() || !player.isOnline() || !player.hasPermission("GraveStonesPlus.Compass")) return; Grave grave = plugin.getLatestGrave(player); - if (grave != null) { - plugin.getBukkitScheduler().runTask(plugin, () -> grave.giveCompass(player)); + if (grave != null && plugin.canTrack(grave, player)) { + grave.giveCompass(player); } - }, 5L); + }; + var folia = plugin.getBukkitScheduler().getFoliaLib(); + // The original respawn delay is five server ticks. Its Spigot/Paper entity-delay + // overloads recurse, so use the synchronous delay there. Folia needs the + // moving player's entity owner; a retired entity receives no compass. + if (folia.isFolia()) folia.getImpl().runAtEntityLater(player, deliverCompass, () -> {}, 5L); + else folia.getImpl().runLater(deliverCompass, 5L); } } + private void dropOverflow(Player player, Map leftovers) { + if (leftovers == null) return; + leftovers.values().forEach(item -> { + if (item != null && !item.getType().isAir()) player.getWorld().dropItemNaturally(player.getLocation(), item); + }); + } + /** * Calculates a player's total experience as an integer. This sums up the XP for * each completed level plus the XP in the current level. @@ -373,31 +421,9 @@ public boolean isSlotAvailable(ItemStack slot) { return slot == null || slot.getType().isAir(); } - public boolean keepItemsWithMatchingLore(ItemStack item, String text) { - if (item == null || item.getType().isAir() || text == null || text.isEmpty()) { - return false; - } - if (item.hasItemMeta()) { - ItemMeta meta = item.getItemMeta(); - if (meta.hasLore()) { - // Iterate through each line of the item's Lore and compare with the provided - // text - for (String loreLine : meta.getLore()) { - if (loreLine.equalsIgnoreCase(text)) { // Use equalsIgnoreCase for case-insensitive comparison - return true; - } - } - } - } - return false; - } + public boolean keepItemsWithMatchingLore(ItemStack item, String text) { return ItemRuleEvaluator.matchesLore(item, text); } - public boolean hasCurseOfVanishing(ItemStack item) { - if (item != null && item.hasItemMeta()) { - return item.getItemMeta().getEnchants().containsKey(Enchantment.VANISHING_CURSE); - } - return false; - } + public boolean hasCurseOfVanishing(ItemStack item) { return ItemRuleEvaluator.vanishes(item); } public Location getAirBlock(Location loc) { int startingY = loc.getBlockY(); @@ -440,4 +466,4 @@ public boolean isReplaceable(Material material) { return false; } } -} \ No newline at end of file +} diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/PlayerDeathNBTHandle.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/PlayerDeathNBTHandle.java index 55e1f5f..eef623e 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/PlayerDeathNBTHandle.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/PlayerDeathNBTHandle.java @@ -1,6 +1,7 @@ package com.bencodez.gravestonesplus.listeners; import java.util.List; +import java.util.ArrayList; import org.bukkit.inventory.ItemStack; @@ -11,6 +12,13 @@ import de.tr7zw.nbtapi.NBTItem; public class PlayerDeathNBTHandle { + /** Returns matching rule names using the same matcher used during death processing. */ + public List matchingRuleNames(GraveStonesPlus plugin, ItemStack item, List rules) { + List result = new ArrayList<>(); + if (!plugin.isNbtAPIHooked() || rules == null) return result; + for (NBTRule rule : rules) if (doesItemMatchAnyRule(plugin, item, java.util.Collections.singletonList(rule))) result.add(rule.getName()); + return result; + } /** * Generic NBT rule matching method. Checks if the given item matches any rule * in the provided list of NBT rules. diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/nbt/NBTConfigManager.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/nbt/NBTConfigManager.java index 04b4cf7..cda2313 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/nbt/NBTConfigManager.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/nbt/NBTConfigManager.java @@ -12,15 +12,25 @@ public class NBTConfigManager { private GraveStonesPlus plugin; private FileConfiguration config; - private List ignoreNbtRules; - private List keepNbtRules; - private List graveNbtRules; + private volatile RulesSnapshot rules = new RulesSnapshot(List.of(), List.of(), List.of()); public NBTConfigManager(GraveStonesPlus plugin) { this.plugin = plugin; loadConfig(); } + private NBTConfigManager(GraveStonesPlus plugin, FileConfiguration data, String keepLore) { + this.plugin = plugin; + this.config = data; + rules = new RulesSnapshot(loadRulesFromSection("ignore_nbt_rules"), + loadRulesFromSection("keep_nbt_rules"), loadRulesFromSection("grave_nbt_rules"), + keepLore == null ? "" : keepLore); + } + /** Prepare against the same general-config generation, without publishing or reading files. */ + public static NBTConfigManager prepare(GraveStonesPlus plugin, FileConfiguration data, String keepLore) { + return new NBTConfigManager(plugin, data, keepLore); + } + public void loadConfig() { File file = new File(plugin.getDataFolder(), "nbt_rules.yml"); if (!file.exists()) { @@ -29,13 +39,16 @@ public void loadConfig() { this.config = YamlConfiguration.loadConfiguration(file); - this.ignoreNbtRules = loadRulesFromSection("ignore_nbt_rules"); - this.keepNbtRules = loadRulesFromSection("keep_nbt_rules"); - this.graveNbtRules = loadRulesFromSection("grave_nbt_rules"); + List ignore = loadRulesFromSection("ignore_nbt_rules"); + List keep = loadRulesFromSection("keep_nbt_rules"); + List grave = loadRulesFromSection("grave_nbt_rules"); + String keepLore = plugin.getConfigFile() == null ? "" : plugin.getConfigFile().getKeepItemsWithLore(); + RulesSnapshot loaded = new RulesSnapshot(ignore, keep, grave, keepLore == null ? "" : keepLore); + rules = loaded; - plugin.getLogger().info("Loaded " + ignoreNbtRules.size() + " NBT ignore rules."); - plugin.getLogger().info("Loaded " + keepNbtRules.size() + " NBT keep rules."); - plugin.getLogger().info("Loaded " + graveNbtRules.size() + " NBT grave rules."); + plugin.getLogger().info("Loaded " + loaded.ignore().size() + " NBT ignore rules."); + plugin.getLogger().info("Loaded " + loaded.keep().size() + " NBT keep rules."); + plugin.getLogger().info("Loaded " + loaded.grave().size() + " NBT grave rules."); } private List loadRulesFromSection(String sectionName) { @@ -61,19 +74,26 @@ private List loadRulesFromSection(String sectionName) { public List getIgnoreNbtRules() { - return ignoreNbtRules; + return rules.ignore(); } public List getKeepNbtRules() { - return keepNbtRules; + return rules.keep(); } public List getGraveNbtRules() { - return graveNbtRules; + return rules.grave(); + } + + public RulesSnapshot snapshot() { return rules; } + + public record RulesSnapshot(List ignore, List keep, List grave, String keepLore) { + public RulesSnapshot(List ignore, List keep, List grave) { this(ignore, keep, grave, ""); } + public RulesSnapshot { ignore = List.copyOf(ignore); keep = List.copyOf(keep); grave = List.copyOf(grave); } } public void reloadConfig() { loadConfig(); } -} \ No newline at end of file +} diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/FlatFileGraveStorageBackend.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/FlatFileGraveStorageBackend.java index ce2cc88..fd4d72b 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/FlatFileGraveStorageBackend.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/FlatFileGraveStorageBackend.java @@ -48,8 +48,28 @@ public void saveBrokenGraves(List graves) { graveLocations.saveData(); } + @Override + public void saveSnapshot(List active, List broken) { + graveLocations.saveSnapshot(active, broken); + } + + @Override + public void saveStartupSnapshot(List active, List broken) { + // Only detached, owner-prepared records enter this worker-only path. + var activeRecords = active.stream().map(GravesConfig::preparedRecordForStorage).toList(); + var brokenRecords = broken.stream().map(GravesConfig::preparedRecordForStorage).toList(); + var path = graveLocations.getdFile().toPath(); + try { + String baseline = java.nio.file.Files.exists(path) ? java.nio.file.Files.readString(path) : ""; + String document = StartupGraveDocument.merge(baseline, activeRecords, brokenRecords); + com.bencodez.gravestonesplus.graves.DurableFeatureState.publishDocument(path, document); + } catch (java.io.IOException failure) { + throw new java.io.UncheckedIOException("Failed to preserve startup grave state", failure); + } + } + @Override public void close() { // no-op } -} \ No newline at end of file +} diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GraveLocations.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GraveLocations.java index 78c7855..e93f12a 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GraveLocations.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GraveLocations.java @@ -14,6 +14,14 @@ public GraveLocations(AdvancedCorePlugin plugin) { super(plugin, new File(plugin.getDataFolder(), "Graves.yml"), true); } + private GraveLocations(AdvancedCorePlugin plugin, org.bukkit.configuration.file.FileConfiguration prepared) { + super(plugin, new File(plugin.getDataFolder(), "Graves.yml"), plugin.getBukkitScheduler(), false); + setData(prepared); + } + public static GraveLocations prepare(AdvancedCorePlugin plugin, org.bukkit.configuration.file.FileConfiguration data) { + return new GraveLocations(plugin, data); + } + @Override public void loadValues() { } @@ -38,6 +46,18 @@ public void setBrokenGrave(List grave) { setValue("brokengraves", grave); } + /** Update both sections in memory, then perform the single checked atomic publication. */ + public void saveSnapshot(List active, List broken) { + getData().set("brokengraves", broken != null ? broken : new ArrayList()); + getData().set("graves", active != null ? active : new ArrayList()); + try { + com.bencodez.gravestonesplus.graves.DurableFeatureState.publishDocument( + getdFile().toPath(), getData().saveToString()); + } catch (java.io.IOException failure) { + throw new java.io.UncheckedIOException("Failed to publish complete grave state", failure); + } + } + public void setBrokenGraves(List brokenGraves) { ArrayList gravesConfig = new ArrayList(); for (Grave grave : brokenGraves) { diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GraveStorageBackend.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GraveStorageBackend.java index 8950e51..215355a 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GraveStorageBackend.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GraveStorageBackend.java @@ -40,6 +40,26 @@ public interface GraveStorageBackend { */ void saveBrokenGraves(List graves); + default void saveSnapshot(List active, List broken) { + saveBrokenGraves(broken); + saveGraves(active); + } + + /** Merge captures with the persisted baseline when shutdown interrupts initial restoration. */ + default void saveStartupSnapshot(List active, List broken) { + throw new UnsupportedOperationException("Backend cannot preserve incomplete startup state"); + } + + /** Acknowledge display metadata only; never recreate or modify contents of a grave. */ + default void saveDisplayIdentity(GravesConfig grave) { + throw new UnsupportedOperationException("Backend cannot acknowledge display identity"); + } + + /** Strict existing-row recreation acknowledgement; unsupported backends fail closed. */ + default void saveRecreatedGrave(GravesConfig grave) { + throw new UnsupportedOperationException("Backend cannot acknowledge recreation"); + } + /** * Insert or update a single grave (incremental write). Default implementation * is no-op (used only by MySQL backend). @@ -87,4 +107,4 @@ default void deleteGrave(GravesConfig grave) { * Close backend resources. */ void close(); -} \ No newline at end of file +} diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GraveStorageManager.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GraveStorageManager.java index badd9fa..64cc52b 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GraveStorageManager.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GraveStorageManager.java @@ -16,6 +16,45 @@ public class GraveStorageManager { private final GraveStonesPlus plugin; private GraveStorageBackend backend; private DataStorageType type = DataStorageType.FLATFILE; + private record Recovery(List active, List broken) { } + private Recovery retained; + private Recovery latestPrepared; + private boolean recovering; + private boolean restorationLoading; + private boolean closeRequested; + private boolean backendClosed; + public synchronized void beginRestoration() { restorationLoading = true; } + private final java.util.Map restoringActive = new java.util.LinkedHashMap<>(); + private final java.util.Map restoringBroken = new java.util.LinkedHashMap<>(); + /** Backend records remain authoritative until a region admission resolves them. */ + public synchronized void retainRestoration(List active, List broken) { + retainRestorationBatch(active, broken); + finishRestorationRetention(); + } + /** Owner-prepared partial admission; partial reads never release the startup write fence. */ + public synchronized void retainRestorationBatch(List active, List broken) { + if (closeRequested || backendClosed) throw new IllegalStateException("storage is closing"); + for (GravesConfig grave : active) restoringActive.put(restorationKey(grave), snapshot(grave)); + for (GravesConfig grave : broken) restoringBroken.put(restorationKey(grave), snapshot(grave)); + } + public synchronized void finishRestorationRetention() { + if (closeRequested || backendClosed) throw new IllegalStateException("storage is closing"); + restorationLoading = false; + if (retained != null) retained = recoverySnapshot(); + } + private String restorationKey(GravesConfig grave) { + String id = com.bencodez.gravestonesplus.graves.GraveIdentity.of(grave); + return id == null ? "unresolved-" + java.util.UUID.randomUUID() : id; + } + public synchronized void restorationResolved(GravesConfig grave) { + String id = com.bencodez.gravestonesplus.graves.GraveIdentity.of(grave); + restoringActive.remove(id); restoringBroken.remove(id); + } + private List preserving(List current, boolean broken) { + java.util.Map result = new java.util.LinkedHashMap<>(broken ? restoringBroken : restoringActive); + for (GravesConfig grave : current) result.put(restorationKey(grave), grave); + return List.copyOf(result.values()); + } /** * Construct a storage manager. @@ -24,6 +63,7 @@ public class GraveStorageManager { */ public GraveStorageManager(GraveStonesPlus plugin) { this.plugin = plugin; + plugin.getFeatureIO().onDrain(this::flushRetained); } /** @@ -36,6 +76,7 @@ public void init() { ConfigurationSection dbSection = plugin.getConfigFile().getData().getConfigurationSection("Database"); if (dbSection == null) { plugin.getLogger().severe("Database section missing, falling back to FLATFILE"); + type = DataStorageType.FLATFILE; backend = new FlatFileGraveStorageBackend(new GraveLocations(plugin)); } else { String tableName = "gravestones_graves"; @@ -66,6 +107,41 @@ public List loadBrokenGraves() { return backend != null ? backend.loadBrokenGraves() : new ArrayList(); } + public record RestorationRecords(List active, List broken) {} + /** Keep JDBC on the serial I/O owner and Bukkit decoding/world lookup on the server owner. */ + public java.util.concurrent.CompletableFuture loadMySQLForRestoration() { + if (!(backend instanceof MySQLGraveStorageBackend sql)) + return java.util.concurrent.CompletableFuture.failedFuture(new IllegalStateException("MySQL backend unavailable")); + return plugin.getFeatureIO().submit(() -> + List.of(sql.loadDeferred(false), sql.loadDeferred(true))).thenCompose(raw -> { + var result = new java.util.concurrent.CompletableFuture(); + class Decode implements Runnable { + final List active = new ArrayList<>(), broken = new ArrayList<>(); + int audience, cursor; + void schedule() { + try { plugin.getBukkitScheduler().runTask(plugin, this); } + catch (RuntimeException failure) { result.completeExceptionally(failure); } + } + public void run() { + if (!plugin.isEnabled()) { result.completeExceptionally(new IllegalStateException("Plugin retired during restoration")); return; } + try { + // Fair owner admission: never decode an entire large SQL installation in one tick. + int budget = 128; + while (audience < raw.size() && budget > 0) { + var rows = raw.get(audience); + if (cursor == rows.size()) { audience++; cursor = 0; continue; } + (audience == 0 ? active : broken).add(rows.get(cursor++).get()); budget--; + } + if (audience == raw.size() || audience == 1 && cursor == raw.get(1).size()) + result.complete(new RestorationRecords(List.copyOf(active), List.copyOf(broken))); + else schedule(); + } catch (Throwable failure) { result.completeExceptionally(failure); } + } + } + new Decode().schedule(); return result; + }); + } + /** * Incremental broken flag update. For flatfile this is a no-op here (flatfile * is saved via bulk list). @@ -73,9 +149,10 @@ public List loadBrokenGraves() { * @param grave config * @param broken broken flag */ - public void setBroken(GravesConfig grave, boolean broken) { + public synchronized void setBroken(GravesConfig grave, boolean broken) { if (backend != null && isMySQL()) { - backend.setBroken(grave, broken); + GravesConfig snapshot = snapshot(grave); + enqueue(() -> backend.setBroken(snapshot, broken)); } } @@ -84,17 +161,21 @@ public void setBroken(GravesConfig grave, boolean broken) { * * @param graves active grave objects */ - public void saveGravesFromObjects(List graves) { + public synchronized void saveGravesFromObjects(List graves) { List list = new ArrayList(); if (graves != null) { - for (Grave g : graves) { + for (Grave g : copy(graves)) { if (g != null && g.getGravesConfig() != null) { - list.add(g.getGravesConfig()); + list.add(snapshot(g.getGravesConfig())); } } } if (backend != null) { - backend.saveGraves(list); + if (restorationLoading) { retained = recoverySnapshot(); return; } + // Compose before admission: later region resolution cannot erase a record + // from a previously captured snapshot. Following removal snapshots win in order. + List safe = preserving(list, false); + enqueue(() -> backend.saveGraves(safe)); } } @@ -112,9 +193,10 @@ public boolean isMySQL() { * @param grave config * @param broken broken flag */ - public void upsertGrave(GravesConfig grave, boolean broken) { + public synchronized void upsertGrave(GravesConfig grave, boolean broken) { if (backend != null && isMySQL()) { - backend.upsertGrave(grave, broken); + GravesConfig snapshot = snapshot(grave); + enqueue(() -> backend.upsertGrave(snapshot, broken)); } } @@ -124,9 +206,10 @@ public void upsertGrave(GravesConfig grave, boolean broken) { * @param grave config * @param broken broken flag */ -public void updateExistingGrave(GravesConfig grave, boolean broken) { +public synchronized void updateExistingGrave(GravesConfig grave, boolean broken) { if (backend != null && isMySQL()) { - backend.updateExistingGrave(grave, broken); + GravesConfig snapshot = snapshot(grave); + enqueue(() -> backend.updateExistingGrave(snapshot, broken)); } } @@ -137,26 +220,192 @@ public void updateExistingGrave(GravesConfig grave, boolean broken) { * * @param brokenGraves broken grave objects */ - public void saveBrokenGravesFromObjects(List brokenGraves) { + public synchronized void saveBrokenGravesFromObjects(List brokenGraves) { List list = new ArrayList(); if (brokenGraves != null) { - for (Grave g : brokenGraves) { + for (Grave g : copy(brokenGraves)) { if (g != null && g.getGravesConfig() != null) { - list.add(g.getGravesConfig()); + list.add(snapshot(g.getGravesConfig())); } } } if (backend != null) { - backend.saveBrokenGraves(list); + if (restorationLoading) { retained = recoverySnapshot(); return; } + // Compose before admission: later region resolution cannot erase a record + // from a previously captured snapshot. Following removal snapshots win in order. + List safe = preserving(list, true); + enqueue(() -> backend.saveBrokenGraves(safe)); } } + /** Capture on the marker owner, acknowledge metadata on the serial storage worker. */ + public synchronized java.util.concurrent.CompletableFuture saveDisplayIdentity(GravesConfig grave) { + if (backend == null || !isMySQL()) return java.util.concurrent.CompletableFuture.failedFuture( + new IllegalStateException("MySQL grave backend unavailable")); + GravesConfig captured = snapshot(grave); + return enqueueFuture(() -> { backend.saveDisplayIdentity(captured); return true; }); + } + + /** Acknowledge this exact existing SQL row without rewriting or serializing its contents. */ + public synchronized java.util.concurrent.CompletableFuture saveRecreatedGrave(GravesConfig grave) { + if (backend == null || !isMySQL()) return java.util.concurrent.CompletableFuture.failedFuture( + new IllegalStateException("MySQL grave backend unavailable")); + GravesConfig captured = snapshot(grave); + return enqueueFuture(() -> { backend.saveRecreatedGrave(captured); return true; }); + } + + public synchronized java.util.concurrent.CompletableFuture saveSnapshotFromObjects( + List activeGraves, List brokenGraves) { + List active = snapshotObjects(activeGraves); + List broken = snapshotObjects(brokenGraves); + if (backend == null) return java.util.concurrent.CompletableFuture.failedFuture(new IllegalStateException("storage backend unavailable")); + if (restorationLoading) { retained = recoverySnapshot(); return java.util.concurrent.CompletableFuture.failedFuture(new IllegalStateException("storage restoration pending")); } + List safeActive = preserving(active, false); + List safeBroken = preserving(broken, true); + if (isMySQL()) return enqueueFuture(() -> { + for (GravesConfig grave : safeActive) backend.upsertGrave(grave, false); + for (GravesConfig grave : safeBroken) backend.upsertGrave(grave, true); + return true; + }); + return enqueueFuture(() -> { backend.saveSnapshot(safeActive, safeBroken); return true; }); + } + + private static List snapshotObjects(List graves) { + List result = new ArrayList<>(); + if (graves != null) for (Grave grave : copy(graves)) + if (grave != null && grave.getGravesConfig() != null) result.add(snapshot(grave.getGravesConfig())); + return result; + } + /** * Close backend. */ - public void close() { - if (backend != null) { - backend.close(); - } + public synchronized void close() { + if (backend == null || backendClosed || closeRequested) return; + // Closing is a terminal request, rather than an ordinary write admission. + // FeatureIO invokes the drain listener after all admitted work has run, + // including its final bounded shutdown drain when the queue is saturated. + closeRequested = true; + com.bencodez.gravestonesplus.graves.FeatureIO.Admission wake = plugin.getFeatureIO().admit(); + if (wake != null) wake.submit(() -> null); + } + private static List copy(List graves) { synchronized (graves) { return new ArrayList<>(graves); } } + public static GravesConfig snapshot(GravesConfig grave) { + java.util.HashMap items = new java.util.HashMap<>(); + for (var entry : grave.getItems().entrySet()) items.put(entry.getKey(), entry.getValue() == null ? null : entry.getValue().clone()); + GravesConfig copy = new GravesConfig(grave.getUuid(), grave.getPlayerName(), grave.getLocation().clone(), items, + grave.getExp(), grave.getDeathMessage(), grave.getTime(), grave.isDestroyed(), grave.getDestroyedTime(), + grave.getDisplayUUID(), grave.getInteractUUID(), grave.getGraveDisplayType()); + copy.setWorldId(grave.getWorldId()); copy.prepareItemsForStorage(); return copy; + } + /** Retain one latest authoritative snapshot when admission fails, rather than + * discarding a death/removal transition. Capture and admission share this lock. */ + private synchronized void enqueue(Runnable write) { + if (closeRequested || backendClosed) return; + if (retained != null || recovering) { retained = recoverySnapshot(); return; } + latestPrepared = recoverySnapshot(); + com.bencodez.gravestonesplus.graves.FeatureIO.Admission admitted = plugin.getFeatureIO().admit(); + if (admitted == null) { retained = latestPrepared; return; } + var started = new java.util.concurrent.atomic.AtomicBoolean(); + admitted.submit(() -> { + started.set(true); + try { write.run(); return true; } + catch (RuntimeException failure) { + synchronized (this) { if (retained == null) retained = latestPrepared; } + throw failure; + } + }).whenComplete((saved, failure) -> { + if (failure != null) { + // Action failure already retained before FeatureIO's owner drain. Only + // rejected execution retains the latest owner-prepared state; never rescan Bukkit items here. + if (!started.get()) synchronized (this) { if (!backendClosed && retained == null) retained = latestPrepared; } + plugin.getLogger().severe("Grave backend write failed; storage recovery was required."); + } + }); + } + + private synchronized java.util.concurrent.CompletableFuture enqueueFuture(java.util.function.Supplier write) { + if (closeRequested || backendClosed) return java.util.concurrent.CompletableFuture.failedFuture(new IllegalStateException("storage is closing")); + if (retained != null || recovering) { retained = recoverySnapshot(); return java.util.concurrent.CompletableFuture.failedFuture(new IllegalStateException("storage recovery pending")); } + latestPrepared = recoverySnapshot(); + var admitted = plugin.getFeatureIO().admit(); + if (admitted == null) { retained = latestPrepared; return java.util.concurrent.CompletableFuture.failedFuture(new java.util.concurrent.RejectedExecutionException("feature worker unavailable")); } + var started = new java.util.concurrent.atomic.AtomicBoolean(); + var result = admitted.submit(() -> { started.set(true); try { return write.get(); } catch (RuntimeException failure) { + synchronized (this) { if (retained == null) retained = latestPrepared; } throw failure; + } }); + result.whenComplete((saved, failure) -> { if (failure != null) { + if (!started.get()) synchronized (this) { if (!backendClosed && retained == null) retained = latestPrepared; } + plugin.getLogger().severe("Grave backend snapshot write failed; storage recovery was required."); + } }); + return result; } -} \ No newline at end of file + private Recovery recoverySnapshot() { + List active = new ArrayList<>(), broken = new ArrayList<>(); + for (Grave grave : plugin.activeGraveSnapshot()) active.add(snapshot(grave.getGravesConfig())); + for (Grave grave : plugin.brokenGraveSnapshot()) broken.add(snapshot(grave.getGravesConfig())); + return new Recovery(preserving(active, false), preserving(broken, true)); + } + /** Runs only on the serial disk owner after older admitted work has drained. */ + private void flushRetained() { + while (true) { + Recovery recovery; + boolean loading; + synchronized (this) { + if (backendClosed) return; + loading = restorationLoading; + recovery = loading && !closeRequested ? null : retained; + if (!loading || closeRequested) retained = null; + recovering = recovery != null; + if (recovery != null) recovery = new Recovery(preserving(recovery.active(), false), preserving(recovery.broken(), true)); + } + if (loading && recovery == null) { + closeBackendIfRequested(); + return; + } + if (recovery == null) { + closeBackendIfRequested(); + return; + } + boolean failed = false; + try { + if (isMySQL()) { + for (GravesConfig grave : recovery.active()) backend.upsertGrave(grave, false); + for (GravesConfig grave : recovery.broken()) backend.upsertGrave(grave, true); + } else { + if (loading) backend.saveStartupSnapshot(recovery.active(), recovery.broken()); + else backend.saveSnapshot(recovery.active(), recovery.broken()); + } + } catch (RuntimeException failure) { + synchronized (this) { if (retained == null) retained = recovery; } + failed = true; + plugin.getLogger().severe("Retained grave backend snapshot could not be flushed; storage requires repair before shutdown."); + } finally { synchronized (this) { recovering = false; } } + // No busy retry on broken storage, and give queued feature commands priority. + if (failed || plugin.getFeatureIO().hasQueuedWork()) { + if (failed) closeBackendIfRequested(true); + return; + } + } + } + + /** Called only by the FeatureIO serial owner after accepted work drains. */ + private void closeBackendIfRequested() { closeBackendIfRequested(false); } + + private void closeBackendIfRequested(boolean recoveryFailed) { + GraveStorageBackend toClose; + synchronized (this) { + if (!closeRequested || backendClosed || (!recoveryFailed && retained != null) || recovering + || plugin.getFeatureIO().hasQueuedWork()) return; + backendClosed = true; + toClose = backend; + } + try { + if (toClose != null) toClose.close(); + } catch (RuntimeException failure) { + // The terminal close is intentionally at-most-once. The worker's + // bounded shutdown must not keep retrying a backend that rejected it. + plugin.getLogger().severe("Grave backend close failed after storage drain."); + } + } +} diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GravesConfig.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GravesConfig.java index 90d86ea..20fbfba 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GravesConfig.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GravesConfig.java @@ -25,12 +25,52 @@ public class GravesConfig implements ConfigurationSerializable { @Getter private HashMap items; + // Set only on detached owner-prepared storage snapshots. Never serialized as a new field. + private transient String preparedItems; + private transient RuntimeException preparationFailure; + private transient RuntimeException binaryPreparationFailure; + private transient Map preparedLegacyItems; + + void prepareItemsForStorage() { + try { preparedItems = InventorySerializer.encode(items); } + catch (IOException failure) { + binaryPreparationFailure = new java.io.UncheckedIOException("Could not prepare grave items", failure); + try { preparedLegacyItems = PreparedLegacyItems.capture(items); } + catch (RuntimeException legacyFailure) { + binaryPreparationFailure.addSuppressed(legacyFailure); preparationFailure = binaryPreparationFailure; + } + } + catch (RuntimeException failure) { preparationFailure = failure; } + } + + String encodedItemsForStorage() { + if (binaryPreparationFailure != null) throw binaryPreparationFailure; + if (preparationFailure != null) throw preparationFailure; + if (preparedItems != null) return preparedItems; + // Compatibility for direct owner-context users of the legacy backend API. + try { return InventorySerializer.encode(items); } + catch (IOException failure) { throw new java.io.UncheckedIOException("Could not encode grave items", failure); } + } + + Map preparedRecordForStorage() { + if (preparationFailure != null) throw preparationFailure; + if (preparedItems == null && preparedLegacyItems == null) throw new IllegalStateException("Grave items were not owner-prepared"); + Map record = new java.util.LinkedHashMap<>(serialize()); + record.put("==", GravesConfig.class.getName()); + return record; + } + @Getter private int exp; @Getter private Location location; + /** Original UUID remains serializable while the world is unavailable. */ + @Getter + @Setter + private UUID worldId; + @Getter private UUID uuid; @@ -45,11 +85,11 @@ public class GravesConfig implements ConfigurationSerializable { @Getter @Setter - private boolean destroyed; + private volatile boolean destroyed; @Getter @Setter - private long destroyedTime; + private volatile long destroyedTime; @Getter @Setter @@ -88,6 +128,7 @@ public GravesConfig(UUID uuid, String playerName, Location loc, HashMap(); this.exp = exp; this.playerName = playerName; @@ -102,6 +143,7 @@ public GravesConfig(UUID uuid, String playerName, Location loc, HashMap serialize() { + if (preparationFailure != null) throw preparationFailure; Map serialized = new HashMap(); serialized.put("DeathMessage", deathMessage); @@ -116,7 +158,7 @@ public Map serialize() { } serialized.put("PlayerName", playerName); - serialized.put("World", location.getWorld().getUID().toString()); + serialized.put("World", worldId.toString()); serialized.put("X", location.getBlockX()); serialized.put("Y", location.getBlockY()); serialized.put("Z", location.getBlockZ()); @@ -127,9 +169,13 @@ public Map serialize() { // New: store the display type used for this grave serialized.put("GraveDisplayType", graveDisplayType); - // New primary format + // Prepared fallback contains no Bukkit objects; disk workers only emit plain YAML values. + if (preparedLegacyItems != null) { + serialized.put("Items", preparedLegacyItems); return serialized; + } + // New primary format try { - serialized.put("ItemsEncoded", InventorySerializer.encode(items)); + serialized.put("ItemsEncoded", preparedItems != null ? preparedItems : InventorySerializer.encode(items)); } catch (IOException e) { // Fallback: if encoding fails, write legacy format so data is not lost serialized.put("Items", items); @@ -152,7 +198,8 @@ public static GravesConfig deserialize(Map deserialize) { UUID uuid = UUID.fromString(deserialize.get("UUID").toString()); String playerName = deserialize.get("PlayerName").toString(); - World world = Bukkit.getWorld(UUID.fromString(deserialize.get("World").toString())); + UUID worldId = UUID.fromString(deserialize.get("World").toString()); + World world = Bukkit.getWorld(worldId); Location loc = new Location(world, NumberConversions.toInt(deserialize.get("X")), NumberConversions.toInt(deserialize.get("Y")), NumberConversions.toInt(deserialize.get("Z"))); @@ -205,8 +252,9 @@ public static GravesConfig deserialize(Map deserialize) { } } - return new GravesConfig(uuid, playerName, loc, items, exp, deathMessage, time, destroyed, destroyedTime, - displayUUID, interactUUID, normalizeDisplayType(displayTypeStr, displayUUID)); + GravesConfig record = new GravesConfig(uuid, playerName, loc, items, exp, deathMessage, time, destroyed, destroyedTime, + displayUUID, interactUUID, normalizeDisplayType(displayTypeStr, displayUUID)); + record.setWorldId(worldId); return record; } /** diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GravesMySQLTable.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GravesMySQLTable.java index 82300e2..d024e34 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GravesMySQLTable.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GravesMySQLTable.java @@ -43,9 +43,38 @@ public class GravesMySQLTable extends AbstractSqlTable { public GravesMySQLTable(AdvancedCorePlugin plugin, String tableName, ConfigurationSection databaseSection) { super(tableName, new MysqlConfigSpigot(databaseSection), plugin.getOptions().getDebug().isDebug(), true); this.plugin = plugin; - init(); + try { + // Legacy SimpleAPI constructors can return after a failed open. Never + // publish that wrapper or retry queries against an uninitialized pool. + if (mysql.getConnectionManager().isClosed()) { + throw new IllegalStateException("Grave SQL connection pool did not initialize; check the preceding database error"); + } + init(); + } + catch (RuntimeException | Error failure) { + // A failed constructor cannot publish this table to the backend owner. + try { closeOwnedPool(); } + catch (RuntimeException | Error cleanup) { failure.addSuppressed(cleanup); } + throw failure; + } } + @Override public void close() { closeOwnedPool(); } + + private void closeOwnedPool() { + try { super.close(); } + catch (RuntimeException | Error failure) { + // Older SimpleAPI versions skip worker retirement if pool close throws. + // Keep this table safe without requiring a synchronized dependency upgrade. + try { + var worker = mysql.getThreadPool(); + if (worker != null && !worker.isShutdown()) worker.shutdown(); + } + catch (RuntimeException | Error cleanup) { if (cleanup != failure) failure.addSuppressed(cleanup); } + throw failure; + } + } + @Override public String getPrimaryKeyColumn() { return "id"; @@ -87,24 +116,32 @@ public String buildCreateTableSql(DbType dbType) { return sb.toString(); } + private java.util.logging.Logger constructionSafeLogger() { + // Older SimpleAPI may dispatch logging from super(), before this.plugin + // can be assigned. Its bootstrap diagnostics cannot depend on this field. + return plugin == null ? java.util.logging.Logger.getLogger("GraveStonesPlus") : plugin.getLogger(); + } + @Override public void logSevere(String msg) { - plugin.getLogger().severe(msg); + constructionSafeLogger().severe(msg); } @Override public void logInfo(String msg) { - plugin.getLogger().info(msg); + constructionSafeLogger().info(msg); } @Override public void debug(Throwable t) { - plugin.debug(t); + if (plugin != null) plugin.debug(t); + else constructionSafeLogger().log(java.util.logging.Level.FINE, "SQL initialization diagnostic", t); } @Override public void debug(String msg) { - plugin.debug(msg); + if (plugin != null) plugin.debug(msg); + else constructionSafeLogger().fine(msg); } /** @@ -116,7 +153,17 @@ public void debug(String msg) { * @param broken whether grave is broken */ public void upsertGrave(GravesConfig grave, boolean broken) { - if (grave == null || grave.getUuid() == null || grave.getLocation() == null || grave.getLocation().getWorld() == null) { + upsertGrave(grave, broken, false); + } + + /** Internal backend acknowledgement; legacy direct callers retain logging-only failures. */ + void upsertGraveChecked(GravesConfig grave, boolean broken) { + upsertGrave(grave, broken, true); + } + + private void upsertGrave(GravesConfig grave, boolean broken, boolean checked) { + if (grave == null || grave.getUuid() == null || grave.getLocation() == null || grave.getWorldId() == null) { + if (checked) throw new IllegalArgumentException("Grave write requires a complete identity"); return; } @@ -137,11 +184,13 @@ public void upsertGrave(GravesConfig grave, boolean broken) { try (Connection conn = mysql.getConnectionManager().getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { + if (checked && !conn.getAutoCommit()) throw new SQLException("Grave write requires an auto-commit connection"); bindInsert(ps, grave, broken); ps.executeUpdate(); } catch (SQLException e) { + if (checked) throw new IllegalStateException("Grave SQL write was not acknowledged", e); debug(e); } } @@ -154,7 +203,17 @@ public void upsertGrave(GravesConfig grave, boolean broken) { * @param broken whether grave is broken */ public void updateExistingGrave(GravesConfig grave, boolean broken) { - if (grave == null || grave.getUuid() == null || grave.getLocation() == null || grave.getLocation().getWorld() == null) { + updateExistingGrave(grave, broken, false); + } + + /** Internal backend acknowledgement; legacy direct callers retain logging-only failures. */ + void updateExistingGraveChecked(GravesConfig grave, boolean broken) { + updateExistingGrave(grave, broken, true); + } + + private void updateExistingGrave(GravesConfig grave, boolean broken, boolean checked) { + if (grave == null || grave.getUuid() == null || grave.getLocation() == null || grave.getWorldId() == null) { + if (checked) throw new IllegalArgumentException("Grave write requires a complete identity"); return; } @@ -178,6 +237,7 @@ public void updateExistingGrave(GravesConfig grave, boolean broken) { try (Connection conn = mysql.getConnectionManager().getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { + if (checked && !conn.getAutoCommit()) throw new SQLException("Grave write requires an auto-commit connection"); ps.setInt(1, broken ? 1 : 0); ps.setString(2, safeString(grave.getPlayerName())); @@ -188,10 +248,10 @@ public void updateExistingGrave(GravesConfig grave, boolean broken) { ps.setString(7, grave.getDisplayUUID() != null ? grave.getDisplayUUID().toString() : null); ps.setString(8, grave.getInteractUUID() != null ? grave.getInteractUUID().toString() : null); ps.setString(9, safeString(grave.getGraveDisplayType())); - ps.setString(10, encodeItemsSafe(grave.getItems())); + ps.setString(10, grave.encodedItemsForStorage()); ps.setString(11, grave.getUuid().toString()); - ps.setString(12, grave.getLocation().getWorld().getUID().toString()); + ps.setString(12, grave.getWorldId().toString()); ps.setInt(13, grave.getLocation().getBlockX()); ps.setInt(14, grave.getLocation().getBlockY()); ps.setInt(15, grave.getLocation().getBlockZ()); @@ -200,10 +260,64 @@ public void updateExistingGrave(GravesConfig grave, boolean broken) { ps.executeUpdate(); } catch (SQLException e) { + if (checked) throw new IllegalStateException("Grave SQL write was not acknowledged", e); debug(e); } } + /** Update only display identities on this exact still-active record. */ + public void saveDisplayIdentity(GravesConfig grave) { + if (grave == null || grave.getUuid() == null || grave.getLocation() == null || grave.getWorldId() == null) + throw new IllegalArgumentException("Display repair requires an existing grave identity"); + String sql = "UPDATE " + qi(tableName) + " SET " + qi("display_uuid") + "=?, " + + qi("interact_uuid") + "=? WHERE " + qi("player_uuid") + "=? AND " + + qi("world_uuid") + "=? AND " + qi("x") + "=? AND " + qi("y") + "=? AND " + + qi("z") + "=? AND " + qi("time") + "=? AND " + qi("broken") + "=0 AND " + qi("destroyed") + "=0;"; + try (Connection connection = mysql.getConnectionManager().getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + if (!connection.getAutoCommit()) throw new SQLException("Display repair requires an auto-commit connection"); + statement.setString(1, grave.getDisplayUUID() == null ? null : grave.getDisplayUUID().toString()); + statement.setString(2, grave.getInteractUUID() == null ? null : grave.getInteractUUID().toString()); + statement.setString(3, grave.getUuid().toString()); statement.setString(4, grave.getWorldId().toString()); + statement.setInt(5, grave.getLocation().getBlockX()); statement.setInt(6, grave.getLocation().getBlockY()); + statement.setInt(7, grave.getLocation().getBlockZ()); statement.setLong(8, grave.getTime()); + if (statement.executeUpdate() != 1) throw new SQLException("Active grave display identity was not acknowledged"); + } catch (SQLException failure) { + throw new IllegalStateException("Failed to persist grave display identity", failure); + } + } + + /** Restore existing metadata with one checked statement; contents/XP are unchanged. */ + public void saveRecreatedGrave(GravesConfig grave) { + if (grave == null || grave.getUuid() == null || grave.getLocation() == null || grave.getWorldId() == null) + throw new IllegalArgumentException("Recreation requires an existing grave identity"); + String sql = "UPDATE " + qi(tableName) + " SET " + qi("broken") + "=?, " + + qi("destroyed") + "=?, " + qi("destroyed_time") + "=?, " + + qi("display_uuid") + "=?, " + qi("interact_uuid") + "=?, " + + qi("grave_display_type") + "=? WHERE " + qi("player_uuid") + "=? AND " + + qi("world_uuid") + "=? AND " + qi("x") + "=? AND " + qi("y") + "=? AND " + + qi("z") + "=? AND " + qi("time") + "=?;"; + try (Connection connection = mysql.getConnectionManager().getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + if (!connection.getAutoCommit()) throw new SQLException("Recreation requires an auto-commit connection"); + statement.setInt(1, 0); + statement.setInt(2, grave.isDestroyed() ? 1 : 0); + statement.setLong(3, grave.getDestroyedTime()); + statement.setString(4, grave.getDisplayUUID() == null ? null : grave.getDisplayUUID().toString()); + statement.setString(5, grave.getInteractUUID() == null ? null : grave.getInteractUUID().toString()); + statement.setString(6, safeString(grave.getGraveDisplayType())); + statement.setString(7, grave.getUuid().toString()); + statement.setString(8, grave.getWorldId().toString()); + statement.setInt(9, grave.getLocation().getBlockX()); + statement.setInt(10, grave.getLocation().getBlockY()); + statement.setInt(11, grave.getLocation().getBlockZ()); + statement.setLong(12, grave.getTime()); + if (statement.executeUpdate() != 1) throw new SQLException("Existing grave row was not acknowledged"); + } catch (SQLException failure) { + throw new IllegalStateException("Failed to persist recreated grave", failure); + } + } + /** * Update only the broken flag for a grave (incremental write). * @@ -211,7 +325,17 @@ public void updateExistingGrave(GravesConfig grave, boolean broken) { * @param broken new broken flag */ public void setBroken(GravesConfig grave, boolean broken) { - if (grave == null || grave.getUuid() == null || grave.getLocation() == null || grave.getLocation().getWorld() == null) { + setBroken(grave, broken, false); + } + + /** Internal backend acknowledgement; legacy direct callers retain logging-only failures. */ + void setBrokenChecked(GravesConfig grave, boolean broken) { + setBroken(grave, broken, true); + } + + private void setBroken(GravesConfig grave, boolean broken, boolean checked) { + if (grave == null || grave.getUuid() == null || grave.getLocation() == null || grave.getWorldId() == null) { + if (checked) throw new IllegalArgumentException("Grave write requires a complete identity"); return; } @@ -225,10 +349,11 @@ public void setBroken(GravesConfig grave, boolean broken) { try (Connection conn = mysql.getConnectionManager().getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { + if (checked && !conn.getAutoCommit()) throw new SQLException("Grave write requires an auto-commit connection"); ps.setInt(1, broken ? 1 : 0); ps.setString(2, grave.getUuid().toString()); - ps.setString(3, grave.getLocation().getWorld().getUID().toString()); + ps.setString(3, grave.getWorldId().toString()); ps.setInt(4, grave.getLocation().getBlockX()); ps.setInt(5, grave.getLocation().getBlockY()); ps.setInt(6, grave.getLocation().getBlockZ()); @@ -237,6 +362,7 @@ public void setBroken(GravesConfig grave, boolean broken) { ps.executeUpdate(); } catch (SQLException e) { + if (checked) throw new IllegalStateException("Grave SQL write was not acknowledged", e); debug(e); } } @@ -247,7 +373,17 @@ public void setBroken(GravesConfig grave, boolean broken) { * @param grave grave config */ public void deleteGrave(GravesConfig grave) { - if (grave == null || grave.getUuid() == null || grave.getLocation() == null || grave.getLocation().getWorld() == null) { + deleteGrave(grave, false); + } + + /** Internal backend acknowledgement; legacy direct callers retain logging-only failures. */ + void deleteGraveChecked(GravesConfig grave) { + deleteGrave(grave, true); + } + + private void deleteGrave(GravesConfig grave, boolean checked) { + if (grave == null || grave.getUuid() == null || grave.getLocation() == null || grave.getWorldId() == null) { + if (checked) throw new IllegalArgumentException("Grave write requires a complete identity"); return; } @@ -261,9 +397,10 @@ public void deleteGrave(GravesConfig grave) { try (Connection conn = mysql.getConnectionManager().getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { + if (checked && !conn.getAutoCommit()) throw new SQLException("Grave write requires an auto-commit connection"); ps.setString(1, grave.getUuid().toString()); - ps.setString(2, grave.getLocation().getWorld().getUID().toString()); + ps.setString(2, grave.getWorldId().toString()); ps.setInt(3, grave.getLocation().getBlockX()); ps.setInt(4, grave.getLocation().getBlockY()); ps.setInt(5, grave.getLocation().getBlockZ()); @@ -272,6 +409,7 @@ public void deleteGrave(GravesConfig grave) { ps.executeUpdate(); } catch (SQLException e) { + if (checked) throw new IllegalStateException("Grave SQL write was not acknowledged", e); debug(e); } } @@ -282,66 +420,48 @@ public void deleteGrave(GravesConfig grave) { * @param broken broken flag * @return list of graves */ - public List loadAll(boolean broken) { - List out = new ArrayList(); - - String sql = "SELECT * FROM " + qi(tableName) + " WHERE " + qi("broken") + "=?;"; - try (Connection conn = mysql.getConnectionManager().getConnection(); - PreparedStatement ps = conn.prepareStatement(sql)) { - - ps.setInt(1, broken ? 1 : 0); - - try (ResultSet rs = ps.executeQuery()) { - while (rs.next()) { - UUID playerUuid = safeUuid(rs.getString("player_uuid")); - if (playerUuid == null) { - continue; - } - - UUID worldUuid = safeUuid(rs.getString("world_uuid")); - World world = worldUuid != null ? Bukkit.getWorld(worldUuid) : null; - if (world == null) { - continue; - } - - Location loc = new Location(world, rs.getInt("x"), rs.getInt("y"), rs.getInt("z")); - - HashMap items = decodeItemsSafe(rs.getString("items_encoded")); - - String playerName = rs.getString("player_name"); - String deathMessage = rs.getString("death_message"); - long time = rs.getLong("time"); - int exp = rs.getInt("exp"); - boolean destroyed = rs.getInt("destroyed") == 1; - long destroyedTime = rs.getLong("destroyed_time"); - - UUID display = safeUuid(rs.getString("display_uuid")); - UUID interact = safeUuid(rs.getString("interact_uuid")); - - // NEW: may be null for older rows - String graveDisplayType = rs.getString("grave_display_type"); - - out.add(new GravesConfig(playerUuid, - playerName != null ? playerName : "", - loc, - items, - exp, - deathMessage != null ? deathMessage : "", - time, - destroyed, - destroyedTime, - display, - interact, - graveDisplayType)); - } - } - - } catch (SQLException e) { - debug(e); - } - - return out; - } + /** Legacy synchronous API: caller owns Bukkit materialization, never use on the I/O worker. */ + public List loadAll(boolean broken) { + try { return new ArrayList<>(loadDeferred(broken).stream().map(java.util.function.Supplier::get).toList()); } + catch (java.util.concurrent.CompletionException failure) { + if (failure.getCause() instanceof SQLException sql) { debug(sql); return new ArrayList<>(); } + throw failure; + } + } + /** JDBC-only phase. The returned decoder must run on the supported server context. */ + List> loadDeferred(boolean broken) { + List rows = new ArrayList<>(); + String sql = "SELECT * FROM " + qi(tableName) + " WHERE " + qi("broken") + "=?;"; + try (Connection conn = mysql.getConnectionManager().getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setInt(1, broken ? 1 : 0); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + UUID player = safeUuid(rs.getString("player_uuid")), world = safeUuid(rs.getString("world_uuid")); + if (player == null || world == null) continue; + rows.add(new StoredGrave(player, world, rs.getInt("x"), rs.getInt("y"), rs.getInt("z"), + rs.getString("items_encoded"), rs.getString("player_name"), rs.getString("death_message"), + rs.getLong("time"), rs.getInt("exp"), rs.getInt("destroyed") == 1, + rs.getLong("destroyed_time"), safeUuid(rs.getString("display_uuid")), + safeUuid(rs.getString("interact_uuid")), rs.getString("grave_display_type"))); + } + } + } catch (SQLException failure) { + // An incomplete read is unknown, not a successful empty restoration. + throw new java.util.concurrent.CompletionException(failure); + } + return rows.stream().>map(row -> () -> { + World world = Bukkit.getWorld(row.world()); + GravesConfig record = new GravesConfig(row.player(), safeString(row.name()), + new Location(world, row.x(), row.y(), row.z()), decodeItemsSafe(row.items()), + row.exp(), safeString(row.death()), row.time(), row.destroyed(), row.destroyedTime(), + row.display(), row.interact(), row.type()); + record.setWorldId(row.world()); return record; + }).toList(); + } + private record StoredGrave(UUID player, UUID world, int x, int y, int z, String items, String name, + String death, long time, int exp, boolean destroyed, long destroyedTime, + UUID display, UUID interact, String type) {} private void bindInsert(PreparedStatement ps, GravesConfig g, boolean broken) throws SQLException { ps.setInt(1, broken ? 1 : 0); @@ -352,14 +472,14 @@ private void bindInsert(PreparedStatement ps, GravesConfig g, boolean broken) th ps.setInt(6, g.getExp()); ps.setInt(7, g.isDestroyed() ? 1 : 0); ps.setLong(8, g.getDestroyedTime()); - ps.setString(9, g.getLocation().getWorld().getUID().toString()); + ps.setString(9, g.getWorldId().toString()); ps.setInt(10, g.getLocation().getBlockX()); ps.setInt(11, g.getLocation().getBlockY()); ps.setInt(12, g.getLocation().getBlockZ()); ps.setString(13, g.getDisplayUUID() != null ? g.getDisplayUUID().toString() : null); ps.setString(14, g.getInteractUUID() != null ? g.getInteractUUID().toString() : null); ps.setString(15, safeString(g.getGraveDisplayType())); - ps.setString(16, encodeItemsSafe(g.getItems())); + ps.setString(16, g.encodedItemsForStorage()); } private static String safeString(String s) { @@ -377,14 +497,6 @@ private static UUID safeUuid(String s) { } } - private static String encodeItemsSafe(HashMap items) { - try { - return InventorySerializer.encode(items); - } catch (IOException e) { - return ""; - } - } - private static HashMap decodeItemsSafe(String encoded) { try { return InventorySerializer.decode(encoded); @@ -392,4 +504,4 @@ private static HashMap decodeItemsSafe(String encoded) { return new HashMap(); } } -} \ No newline at end of file +} diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/MySQLGraveStorageBackend.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/MySQLGraveStorageBackend.java index 7de1d10..ecc2553 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/MySQLGraveStorageBackend.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/MySQLGraveStorageBackend.java @@ -48,6 +48,10 @@ public List loadBrokenGraves() { return table != null ? table.loadAll(true) : new ArrayList(); } + List> loadDeferred(boolean broken) { + return table != null ? table.loadDeferred(broken) : List.of(); + } + @Override public void saveGraves(List graves) { // Bulk is intentionally not used for MySQL. @@ -62,34 +66,44 @@ public void saveBrokenGraves(List graves) { @Override public void upsertGrave(GravesConfig grave, boolean broken) { - if (table != null) { - table.upsertGrave(grave, broken); - } + if (table == null) throw new IllegalStateException("MySQL grave table unavailable"); + table.upsertGraveChecked(grave, broken); } + @Override public void saveDisplayIdentity(GravesConfig grave) { + if (table == null) throw new IllegalStateException("MySQL grave table unavailable"); + table.saveDisplayIdentity(grave); + } + + @Override public void saveRecreatedGrave(GravesConfig grave) { + if (table == null) throw new IllegalStateException("MySQL grave table unavailable"); + table.saveRecreatedGrave(grave); + } + @Override public void setBroken(GravesConfig grave, boolean broken) { - if (table != null) { - table.setBroken(grave, broken); - } + if (table == null) throw new IllegalStateException("MySQL grave table unavailable"); + table.setBrokenChecked(grave, broken); } @Override public void updateExistingGrave(GravesConfig grave, boolean broken) { - if (table != null) { - table.updateExistingGrave(grave, broken); - } + if (table == null) throw new IllegalStateException("MySQL grave table unavailable"); + table.updateExistingGraveChecked(grave, broken); } @Override public void deleteGrave(GravesConfig grave) { - if (table != null) { - table.deleteGrave(grave); - } + if (table == null) throw new IllegalStateException("MySQL grave table unavailable"); + table.deleteGraveChecked(grave); } @Override - public void close() { - // keep pool for plugin lifetime; handled by AdvancedCore/plugin lifecycle + public synchronized void close() { + // This table creates its own SimpleAPI pool; AdvancedCore's user-storage + // cleanup does not own it. The manager calls us after its serial writes drain. + GravesMySQLTable closing = table; + table = null; + if (closing != null) closing.close(); } -} \ No newline at end of file +} diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/PreparedLegacyItems.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/PreparedLegacyItems.java new file mode 100644 index 0000000..21864ba --- /dev/null +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/PreparedLegacyItems.java @@ -0,0 +1,54 @@ +package com.bencodez.gravestonesplus.storage; + +import java.util.*; +import org.bukkit.configuration.serialization.ConfigurationSerializable; +import org.bukkit.configuration.serialization.ConfigurationSerialization; +import org.bukkit.inventory.ItemStack; + +/** Owner-thread capture of the existing Bukkit YAML item representation. */ +final class PreparedLegacyItems { + private PreparedLegacyItems() { } + + static Map capture(Map items) { + Map captured = new LinkedHashMap<>(); + Set visiting = Collections.newSetFromMap(new IdentityHashMap<>()); + items.forEach((slot, item) -> captured.put(slot, detach(item, visiting, 0))); + return Collections.unmodifiableMap(captured); + } + + private static Object detach(Object value, Set visiting, int depth) { + if (value == null || value instanceof String || value instanceof Boolean || value instanceof Character + || value instanceof Byte || value instanceof Short || value instanceof Integer || value instanceof Long + || value instanceof Float || value instanceof Double || value instanceof java.math.BigInteger + || value instanceof java.math.BigDecimal) return value; + if (depth > 64 || !visiting.add(value)) throw new IllegalArgumentException("Cyclic or excessively nested legacy item data"); + try { + if (value instanceof ConfigurationSerializable serializable) { + Map encoded = new LinkedHashMap<>(); + encoded.put(ConfigurationSerialization.SERIALIZED_TYPE_KEY, ConfigurationSerialization.getAlias(serializable.getClass())); + // Match Bukkit's representer: serialized fields retain their original precedence. + encoded.putAll(serializable.serialize()); + return detach(encoded, visiting, depth + 1); + } + if (value instanceof Map map) { + Map result = new LinkedHashMap<>(); + map.forEach((key, item) -> result.put(detach(key, visiting, depth + 1), detach(item, visiting, depth + 1))); + return Collections.unmodifiableMap(result); + } + if (value instanceof Collection collection) { + List result = new ArrayList<>(collection.size()); + for (Object item : collection) result.add(detach(item, visiting, depth + 1)); + return Collections.unmodifiableList(result); + } + // SnakeYAML represents byte arrays as binary scalars; other arrays as sequences. + if (value instanceof byte[] bytes) return bytes.clone(); + if (value instanceof Date date) return new Date(date.getTime()); + if (value.getClass().isArray()) { + int length = java.lang.reflect.Array.getLength(value); List result = new ArrayList<>(length); + for (int i = 0; i < length; i++) result.add(detach(java.lang.reflect.Array.get(value, i), visiting, depth + 1)); + return Collections.unmodifiableList(result); + } + throw new IllegalArgumentException("Unsupported legacy item data: " + value.getClass().getName()); + } finally { visiting.remove(value); } + } +} diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/StartupGraveDocument.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/StartupGraveDocument.java new file mode 100644 index 0000000..238ede1 --- /dev/null +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/StartupGraveDocument.java @@ -0,0 +1,99 @@ +package com.bencodez.gravestonesplus.storage; + +import java.io.StringReader; +import java.io.StringWriter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.nodes.MappingNode; +import org.yaml.snakeyaml.nodes.Node; +import org.yaml.snakeyaml.nodes.NodeTuple; +import org.yaml.snakeyaml.nodes.ScalarNode; +import org.yaml.snakeyaml.nodes.SequenceNode; + +/** + * Merge owner-prepared records into an interrupted startup's persisted document. + * Composition and serialization operate on YAML nodes only: legacy Bukkit items + * and grave records are never instantiated, cloned or encoded on the disk worker. + */ +final class StartupGraveDocument { + private StartupGraveDocument() { } + + static String merge(String document, List> active, + List> broken) { + Yaml yaml = new Yaml(); + Node node = yaml.compose(new StringReader(document)); + MappingNode root; + if (node == null) root = (MappingNode) yaml.represent(new HashMap()); + else if (node instanceof MappingNode mapping) root = mapping; + else throw new IllegalArgumentException("Grave document must be a mapping"); + SequenceNode activeSection = section(yaml, root, "graves"); + SequenceNode brokenSection = section(yaml, root, "brokengraves"); + Set replaced = new HashSet<>(); + List activeNodes = prepared(yaml, active, replaced); + List brokenNodes = prepared(yaml, broken, replaced); + replace(activeSection, activeNodes, replaced); + replace(brokenSection, brokenNodes, replaced); + StringWriter result = new StringWriter(); + yaml.serialize(root, result); + return result.toString(); + } + + private static SequenceNode section(Yaml yaml, MappingNode root, String name) { + Node found = null; + for (NodeTuple tuple : root.getValue()) { + if (tuple.getKeyNode() instanceof ScalarNode key && name.equals(key.getValue())) { + if (found != null) throw new IllegalArgumentException("Duplicate grave section"); + found = tuple.getValueNode(); + } + } + if (found instanceof SequenceNode sequence) return sequence; + if (found != null) throw new IllegalArgumentException("Grave section must be a list"); + SequenceNode created = (SequenceNode) yaml.represent(new ArrayList<>()); + root.getValue().add(new NodeTuple(yaml.represent(name), created)); + return created; + } + + private static List prepared(Yaml yaml, List> records, Set identities) { + List nodes = new ArrayList<>(); + for (Map record : records) { + Node node = yaml.represent(record); + String identity = identity(node); + if (identity == null || !identities.add(identity)) + throw new IllegalArgumentException("Invalid or conflicting prepared grave identity"); + nodes.add(node); + } + return nodes; + } + + private static void replace(SequenceNode section, List additions, Set identities) { + section.getValue().removeIf(node -> identities.contains(identity(node))); + section.getValue().addAll(additions); + } + + private static String identity(Node node) { + if (!(node instanceof MappingNode mapping)) return null; + Map fields = new HashMap<>(); + Set wanted = Set.of("UUID", "Time", "World", "X", "Y", "Z"); + for (NodeTuple tuple : mapping.getValue()) { + if (tuple.getKeyNode() instanceof ScalarNode key && wanted.contains(key.getValue())) { + if (!(tuple.getValueNode() instanceof ScalarNode value) + || fields.putIfAbsent(key.getValue(), value.getValue()) != null) return null; + } + } + if (!fields.keySet().containsAll(wanted)) return null; + try { + return java.util.UUID.fromString(fields.get("UUID")) + "/" + + Long.parseLong(fields.get("Time")) + "/" + + java.util.UUID.fromString(fields.get("World")) + "/" + + Integer.parseInt(fields.get("X")) + "/" + + Integer.parseInt(fields.get("Y")) + "/" + + Integer.parseInt(fields.get("Z")); + } catch (IllegalArgumentException invalid) { return null; } + } +} diff --git a/GraveStonesPlus/src/main/resources/Config.yml b/GraveStonesPlus/src/main/resources/Config.yml index 4915a9f..e67be58 100644 --- a/GraveStonesPlus/src/main/resources/Config.yml +++ b/GraveStonesPlus/src/main/resources/Config.yml @@ -128,6 +128,18 @@ GraveClaimDistance: 10 # Give a compass on respawn pointing to the last grave GiveCompassOnRespawn: false +# Login reminders are one current summary, deduplicated in grave-reminders.yml. +# Protection and expiry are separate reminders; set either to 0 to disable. +Reminders: + # Disabled by default. Set either value to 60 (or another number) to opt in. + ProtectionMinutes: 0 + ExpiryMinutes: 0 + +# Trusted grave helpers are granted per grave with: +# /gravestonesplus trust +# /gravestonesplus revoketrust +# Grants are bounded to 90 days and stored in selected-access.yml. Tracking never grants recovery. + # ========================================================= # Grave Limits & Cleanup @@ -141,7 +153,8 @@ DropItemsOnGraveRemoval: true # Time before a grave is automatically removed # Supports: 10m, 1h, 12h, 1d, 7d -# Set to '' to disable +# Set to '' or 0 to disable; bare positive numbers are legacy minutes. +# Invalid or overflowing replacements keep existing grave timers unchanged. GraveTimeLimit: '' # Time broken graves are kept before being removed @@ -187,4 +200,4 @@ Format: Grave: Top: '%player% died here!' Middle: 'Died at %time%' - Bottom: 'Reason: %reason%' \ No newline at end of file + Bottom: 'Reason: %reason%' diff --git a/GraveStonesPlus/src/main/resources/plugin.yml b/GraveStonesPlus/src/main/resources/plugin.yml index e4913b8..a52f350 100644 --- a/GraveStonesPlus/src/main/resources/plugin.yml +++ b/GraveStonesPlus/src/main/resources/plugin.yml @@ -15,4 +15,15 @@ permissions: GraveStonesPlus.AllowGrave: description: Permission to have graves default: true - \ No newline at end of file + GraveStonesPlus.Trust: + description: Grant bounded per-grave helper access + default: false + GraveStonesPlus.Trust.Revoke: + description: Revoke bounded per-grave helper access + default: false + GraveStonesPlus.Track: + description: Track an authorized grave + default: true + GraveStonesPlus.InspectItem: + description: Inspect held item rule context + default: false diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/AdminCreationAdmissionTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/AdminCreationAdmissionTest.java new file mode 100644 index 0000000..18194b6 --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/AdminCreationAdmissionTest.java @@ -0,0 +1,124 @@ +package com.bencodez.gravestonesplus.graves; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.util.*; +import java.util.concurrent.TimeUnit; +import org.bukkit.Location; +import org.bukkit.block.Block; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import com.bencodez.gravestonesplus.commands.CommandLoader; + +/** Test-grave creation must not destroy a real grave when scheduler admission fails. */ +class AdminCreationAdmissionTest { + private static Location prepare(GraveActionsTest.Harness h, Grave oldest, boolean drop) { + when(h.config.getDisabledWorlds()).thenReturn(new ArrayList<>()); + when(h.config.getGraveLimit()).thenReturn(1); + when(h.config.isDropItemsOnGraveRemoval()).thenReturn(drop); + when(h.config.getGraveDisplayTypeEnum()).thenReturn(GraveDisplayType.CHEST); + when(h.plugin.numberOfGraves(h.owner.getUniqueId())).thenReturn(1); + when(h.plugin.getOldestGrave(h.owner.getUniqueId())).thenReturn(oldest); + when(h.world.getMinHeight()).thenReturn(-64); when(h.world.getMaxHeight()).thenReturn(320); + Block block = mock(Block.class); when(block.isEmpty()).thenReturn(true); + when(h.world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(block); + when(h.world.getBlockAt(any(Location.class))).thenReturn(block); + return new Location(h.world, 2, 64, 2); + } + @Test void acceptedDelayedCreationReservesCapacityBeforeItsLocationCallback() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + Location target = prepare(h, mock(Grave.class), false); + h.active.clear(); + GraveActionsTest.limitRestoration(h); + UUID ownerId = h.owner.getUniqueId(); + when(h.plugin.numberOfGraves(ownerId)).thenCallRealMethod(); + when(h.plugin.getOldestGrave(ownerId)).thenCallRealMethod(); + var task = mock(com.bencodez.simpleapi.folialib.wrapper.task.WrappedTask.class); + when(h.platform.runAtLocationTimer(eq(target), any(Runnable.class), eq(2L), eq(1L), eq(TimeUnit.SECONDS))) + .thenReturn(task); // Hold the actual delayed callback, as during rapid repeated requests. + new CommandLoader(h.plugin).createTestGrave(ownerId, "Owner", target, + new HashMap<>(Map.of(4, h.stored)), 7, "test", h.owner); + verify(h.plugin, never()).addGrave(any()); + assertEquals(1, h.plugin.numberOfGraves(ownerId), + "Accepted creation must reserve capacity before another request can observe it"); + } + } + @Test void rejectedLimitWorkerAdmissionCancelsOnlyReplacement() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + Grave oldest = mock(Grave.class); Location target = prepare(h, oldest, true); + var task = mock(com.bencodez.simpleapi.folialib.wrapper.task.WrappedTask.class); + Runnable[] work = { null }; + when(h.platform.runAtLocationTimer(eq(target), any(Runnable.class), eq(2L), eq(1L), eq(TimeUnit.SECONDS))) + .thenAnswer(call -> { work[0] = call.getArgument(1); return task; }); + new CommandLoader(h.plugin).createTestGrave(h.owner.getUniqueId(), "Owner", target, + new HashMap<>(Map.of(4, h.stored)), 7, "test", h.owner); + work[0].run(); verify(task).cancel(); verify(h.plugin, never()).addGrave(any()); + verify(oldest).admitLimitRemoval(null); verify(oldest, never()).removeGrave(any()); + verify(h.owner).sendMessage("Grave creation declined: limit payout admission unavailable"); + } + } + @ParameterizedTest @ValueSource(booleans = { false, true }) + void rejectedCreationKeepsExistingGraveAndNeverPublishesReplacement(boolean drop) throws Exception { + try (var h = new GraveActionsTest.Harness()) { + Grave oldest = mock(Grave.class); Location target = prepare(h, oldest, drop); + doThrow(new java.util.concurrent.RejectedExecutionException("region retired")) + .when(h.scheduler).runTaskLater(eq(h.plugin), any(Runnable.class), eq(2L), eq(target)); + when(h.platform.runAtLocationTimer(eq(target), any(Runnable.class), eq(2L), eq(1L), eq(TimeUnit.SECONDS))) + .thenThrow(new java.util.concurrent.RejectedExecutionException("region retired")); + var items = new HashMap(Map.of(4, h.stored)); + assertThrows(java.util.concurrent.RejectedExecutionException.class, () -> + new CommandLoader(h.plugin).createTestGrave(h.owner.getUniqueId(), "Owner", target, items, 7, "test", h.owner)); + verify(oldest, never()).removeGrave(any()); verify(oldest, never()).dropAndRemoveForLimit(any()); + verify(h.plugin, never()).addGrave(any()); + assertEquals(Map.of(4, h.stored), items); + } + } + @ParameterizedTest @ValueSource(booleans = { false, true }) + void failedLimitRemovalCancelsAdmittedCreationEvenWhenCallbackArrivesEarly(boolean drop) throws Exception { + try (var h = new GraveActionsTest.Harness()) { + Grave oldest = mock(Grave.class); Location target = prepare(h, oldest, drop); + Runnable[] work = { null }; + var task = mock(com.bencodez.simpleapi.folialib.wrapper.task.WrappedTask.class); + when(h.platform.runAtLocationTimer(eq(target), any(Runnable.class), eq(2L), eq(1L), eq(TimeUnit.SECONDS))) + .thenAnswer(call -> { work[0] = call.getArgument(1); work[0].run(); return task; }); + if (drop) doThrow(new IllegalStateException("limit unavailable")).when(oldest).admitLimitRemoval(null); + else doThrow(new IllegalStateException("limit unavailable")).when(oldest).removeGrave(any()); + assertThrows(IllegalStateException.class, () -> new CommandLoader(h.plugin).createTestGrave( + h.owner.getUniqueId(), "Owner", target, new HashMap<>(Map.of(4, h.stored)), 7, "test", h.owner)); + assertNotNull(work[0]); work[0].run(); work[0].run(); + verify(task, times(1)).cancel(); verify(h.plugin, never()).addGrave(any()); + verify(h.world, times(1)).getBlockAt(any(Location.class)); + } + } + @ParameterizedTest @ValueSource(booleans = { false, true }) + void admittedCreationRunsOnceAfterPreflightAndPreservesLimitPolicy(boolean drop) throws Exception { + try (var h = new GraveActionsTest.Harness(); var construction = mockConstruction(Grave.class)) { + Grave oldest = mock(Grave.class); Location target = prepare(h, oldest, drop); + Runnable[] work = { null }; + var task = mock(com.bencodez.simpleapi.folialib.wrapper.task.WrappedTask.class); + when(h.platform.runAtLocationTimer(eq(target), any(Runnable.class), eq(2L), eq(1L), eq(TimeUnit.SECONDS))) + .thenAnswer(call -> { + work[0] = call.getArgument(1); work[0].run(); + verify(h.plugin, never()).addGrave(any()); return task; + }); + if (drop) when(oldest.admitLimitRemoval(null)).thenReturn(true); + new CommandLoader(h.plugin).createTestGrave(h.owner.getUniqueId(), "Owner", target, + new HashMap<>(Map.of(4, h.stored)), 7, "test", h.owner); + if (drop) { + verify(oldest).admitLimitRemoval(null); verify(oldest, never()).removeGrave(any()); + } else { + verify(oldest).removeGrave(com.bencodez.gravestonesplus.events.GraveRemoveReason.LIMIT_REACHED); + verify(oldest, never()).dropAndRemoveForLimit(any()); + } + assertTrue(construction.constructed().isEmpty()); + work[0].run(); work[0].run(); + assertEquals(1, construction.constructed().size()); + Grave created = construction.constructed().get(0); + verify(created).loadChunk(false); verify(created).createGrave(); verify(created).createHologram(); + verify(h.plugin, times(1)).addGrave(created); verify(task, times(1)).cancel(); + verify(h.owner, times(1)).sendMessage("Generated grave for Owner at 2, 64, 2"); + } + } +} diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/ClaimArchiveTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/ClaimArchiveTest.java new file mode 100644 index 0000000..8a09199 --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/ClaimArchiveTest.java @@ -0,0 +1,88 @@ +package com.bencodez.gravestonesplus.graves; + +import static org.junit.jupiter.api.Assertions.*; +import java.nio.file.*; +import java.util.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ClaimArchiveTest { + @TempDir Path directory; + private final UUID recipient = UUID.randomUUID(); + @Test void completedClaimsDoNotExhaustActiveCapacityAndEvictedIdsStillCannotReplay() throws Exception { + var disk = new FeaturePersistenceTest.Disk(); + try (FeatureIO io = new FeatureIO()) { + var state = new DurableFeatureState(io, disk, 2); + var ledger = new GraveClaimLedger(state, directory.resolve("archive")); + assertTrue(FeaturePersistenceTest.await(ledger.loaded())); + for (int i = 0; i < 520; i++) { + assertTrue(FeaturePersistenceTest.await(ledger.reserve("grave-" + i, recipient))); + assertTrue(FeaturePersistenceTest.await(ledger.paid("grave-" + i, recipient))); + assertTrue(state.snapshot().isEmpty()); + } + // The first identity is outside the bounded recent cache. Disk must still deny it. + assertFalse(FeaturePersistenceTest.await(ledger.reserve("grave-0", recipient))); + assertTrue(FeaturePersistenceTest.await(ledger.isArchived("grave-0"))); + assertTrue(ledger.isBlocked("grave-0")); + assertTrue(FeaturePersistenceTest.await(ledger.reserve("new-grave", recipient))); + } + try (FeatureIO io = new FeatureIO()) { + var ledger = new GraveClaimLedger(new DurableFeatureState(io, disk, 2), directory.resolve("archive")); + assertTrue(FeaturePersistenceTest.await(ledger.loaded())); + assertFalse(FeaturePersistenceTest.await(ledger.reserve("grave-0", recipient))); + assertTrue(FeaturePersistenceTest.await(ledger.isArchived("grave-0"))); + assertFalse(FeaturePersistenceTest.await(ledger.reserve("new-grave", recipient))); + assertTrue(FeaturePersistenceTest.await(ledger.reserve("after-restart", recipient))); + } + } + @Test void migratePaidButNeverRetireUncertainReservations() throws Exception { + var disk = new FeaturePersistenceTest.Disk(); + disk.values = Map.of("old-paid", "PAID:" + recipient, "uncertain", "RESERVED:" + recipient); + try (FeatureIO io = new FeatureIO()) { + var state = new DurableFeatureState(io, disk, 2); + var ledger = new GraveClaimLedger(state, directory.resolve("archive")); + assertTrue(FeaturePersistenceTest.await(ledger.loaded())); + assertEquals(Set.of("uncertain"), state.snapshot().keySet()); + assertFalse(FeaturePersistenceTest.await(ledger.reserve("old-paid", recipient))); + assertFalse(FeaturePersistenceTest.await(ledger.reserve("uncertain", recipient))); + assertTrue(FeaturePersistenceTest.await(ledger.reserve("new", recipient))); + } + } + @Test void failedTombstonePublicationRetainsPayoutReservationAcrossRestart() throws Exception { + var disk = new FeaturePersistenceTest.Disk(); + Path archive = directory.resolve("archive"); + try (FeatureIO io = new FeatureIO()) { + var state = new DurableFeatureState(io, disk, 2); var ledger = new GraveClaimLedger(state, archive); + assertTrue(FeaturePersistenceTest.await(ledger.loaded())); + assertTrue(FeaturePersistenceTest.await(ledger.reserve("grave", recipient))); + Files.writeString(archive, "not a directory"); + assertThrows(Exception.class, () -> FeaturePersistenceTest.await(ledger.paid("grave", recipient))); + assertTrue(state.snapshot().get("grave").startsWith("RESERVED:")); + assertTrue(ledger.isBlocked("grave")); + } + Files.delete(archive); + try (FeatureIO io = new FeatureIO()) { + var ledger = new GraveClaimLedger(new DurableFeatureState(io, disk, 2), archive); + assertTrue(FeaturePersistenceTest.await(ledger.loaded())); + assertFalse(FeaturePersistenceTest.await(ledger.reserve("grave", recipient))); + } + } + @Test void acknowledgedArchiveSurvivesActiveJournalCompletionFailure() throws Exception { + var disk = new FeaturePersistenceTest.Disk(); Path archive = directory.resolve("archive"); + try (FeatureIO io = new FeatureIO()) { + var state = new DurableFeatureState(io, disk, 2); var ledger = new GraveClaimLedger(state, archive); + assertTrue(FeaturePersistenceTest.await(ledger.loaded())); + assertTrue(FeaturePersistenceTest.await(ledger.reserve("grave", recipient))); + disk.fail = true; + assertFalse(FeaturePersistenceTest.await(ledger.paid("grave", recipient))); + assertTrue(ledger.isBlocked("grave")); + try (var files = Files.list(archive)) { assertEquals(1, files.count()); } + } + disk.fail = false; + try (FeatureIO io = new FeatureIO()) { + var ledger = new GraveClaimLedger(new DurableFeatureState(io, disk, 2), archive); + assertTrue(FeaturePersistenceTest.await(ledger.loaded())); + assertFalse(FeaturePersistenceTest.await(ledger.reserve("grave", recipient))); + } + } +} diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/CompassPermissionRegressionTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/CompassPermissionRegressionTest.java new file mode 100644 index 0000000..ea32bef --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/CompassPermissionRegressionTest.java @@ -0,0 +1,151 @@ +package com.bencodez.gravestonesplus.graves; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import java.util.concurrent.ConcurrentLinkedQueue; +import com.bencodez.advancedcore.AdvancedCoreConfigOptions; +import com.bencodez.advancedcore.api.command.CommandHandler; +import com.bencodez.gravestonesplus.commands.CommandLoader; +import com.bencodez.gravestonesplus.listeners.PlayerDeathListener; +import org.bukkit.*; +import org.bukkit.block.Block; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.ClickType; +import org.bukkit.event.player.PlayerRespawnEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.CompassMeta; +import org.bukkit.persistence.PersistentDataContainer; +import org.junit.jupiter.api.Test; +import org.mockito.MockedConstruction; + +class CompassPermissionRegressionTest { + private static final class Commands { + final Queue work = new ConcurrentLinkedQueue<>(); + final ArrayList handlers = new ArrayList<>(); + Commands(GraveActionsTest.Harness h) { + var options = mock(AdvancedCoreConfigOptions.class); when(options.getFormatNoPerms()).thenReturn("Denied"); + when(h.plugin.getOptions()).thenReturn(options); when(h.plugin.getCommands()).thenReturn(handlers); + doAnswer(call -> { work.add(call.getArgument(1)); return null; }).when(h.scheduler) + .runTaskAsynchronously(eq(h.plugin), any(Runnable.class)); + new CommandLoader(h.plugin).loadCommands(); + Block target = mock(Block.class); when(target.getWorld()).thenReturn(h.world); + when(target.getX()).thenReturn(1); when(target.getY()).thenReturn(64); when(target.getZ()).thenReturn(1); + when(h.owner.getTargetBlock(isNull(), eq(7))).thenReturn(target); + when(h.helper.getTargetBlock(isNull(), eq(7))).thenReturn(target); + when(h.plugin.getLatestGrave(h.owner)).thenReturn(h.grave); + } + CommandHandler get(String name) { + return handlers.stream().filter(command -> command.getArgs()[0].equalsIgnoreCase(name)).findFirst().orElseThrow(); + } + void run(Player player, String name) { + assertTrue(get(name).runCommand(player, new String[] { name })); + if (!work.isEmpty()) work.remove().run(); + } + } + private static MockedConstruction compasses() { + return mockConstruction(ItemStack.class, (item, context) -> { + CompassMeta meta = mock(CompassMeta.class); when(meta.getPersistentDataContainer()).thenReturn(mock(PersistentDataContainer.class)); + when(item.getItemMeta()).thenReturn(meta); + }); + } + @Test void actualTrackCommandSelectsForOwnerAndTrustedHelperWithoutCompassPermission() throws Exception { + try (var h = new GraveActionsTest.Harness(); var items = compasses()) { + Commands commands = new Commands(h); h.grant(TrustedGraveAccess.Kind.TRACK); + for (Player player : List.of(h.owner, h.helper)) { + when(player.hasPermission("GraveStonesPlus.Track")).thenReturn(true); + when(player.hasPermission("GraveStonesPlus.Compass")).thenReturn(false); + commands.run(player, "track"); + verify(player, never()).getTargetBlock(any(), anyInt()); // Dispatcher has only scheduled the actor task. + h.drain(); assertSame(h.grave, h.navigation.target(player)); + verify(player).sendMessage(argThat((String message) -> message.contains("Selected grave:"))); + commands.run(player, "compass"); h.drain(); + } + assertTrue(items.constructed().isEmpty()); verify(h.ownerInventory, never()).addItem(any(ItemStack[].class)); + verify(h.helperInventory, never()).addItem(any(ItemStack[].class)); verify(h.plugin, never()).getFullInventoryHandler(); + } + } + @Test void actualTrackCommandRechecksTrackAndTrustAtActorBoundary() throws Exception { + for (boolean revokeTrust : List.of(false, true)) try (var h = new GraveActionsTest.Harness(); var items = compasses()) { + Commands commands = new Commands(h); h.grant(TrustedGraveAccess.Kind.TRACK); + when(h.helper.hasPermission("GraveStonesPlus.Track")).thenReturn(true); + commands.run(h.helper, "track"); + if (revokeTrust) FeaturePersistenceTest.await(h.access.revokeAsync(h.identity(), h.helper.getUniqueId(), TrustedGraveAccess.Kind.TRACK)); + else when(h.helper.hasPermission("GraveStonesPlus.Track")).thenReturn(false); + h.drain(); assertNull(h.navigation.target(h.helper)); assertTrue(items.constructed().isEmpty()); + verify(h.helperInventory, never()).addItem(any(ItemStack[].class)); + } + } + @Test void actualCompassCommandRechecksRevokedPermissionBeforeAnyItemDelivery() throws Exception { + try (var h = new GraveActionsTest.Harness(); var items = compasses()) { + Commands commands = new Commands(h); when(h.owner.hasPermission("GraveStonesPlus.Compass")).thenReturn(true); + commands.run(h.owner, "compass"); assertFalse(h.entityWork.isEmpty()); + when(h.owner.hasPermission("GraveStonesPlus.Compass")).thenReturn(false); h.drain(); + assertTrue(items.constructed().isEmpty()); verify(h.ownerInventory, never()).addItem(any(ItemStack[].class)); + verify(h.plugin, never()).getFullInventoryHandler(); + } + } + @Test void directDeferredDeliveryRejectsRevokedTrustRemovedGraveAndOfflineOrDisabledRecipient() throws Exception { + for (String change : List.of("trust", "removed", "offline", "disabled")) try (var h = new GraveActionsTest.Harness(); var items = compasses()) { + h.grant(TrustedGraveAccess.Kind.TRACK); when(h.helper.hasPermission("GraveStonesPlus.Compass")).thenReturn(true); + h.grave.giveCompass(h.helper); + switch (change) { + case "trust" -> FeaturePersistenceTest.await(h.access.revokeAsync(h.identity(), h.helper.getUniqueId(), TrustedGraveAccess.Kind.TRACK)); + case "removed" -> h.grave.getGravesConfig().setDestroyed(true); + case "offline" -> when(h.helper.isOnline()).thenReturn(false); + case "disabled" -> when(h.plugin.isEnabled()).thenReturn(false); + } + h.drain(); assertTrue(items.constructed().isEmpty()); verify(h.helperInventory, never()).addItem(any(ItemStack[].class)); + } + } + @Test void menuDeliveryRechecksPermissionAfterSuccessfulClickAdmission() throws Exception { + try (var h = new GraveActionsTest.Harness(); var items = compasses(); + var builders = mockConstruction(com.bencodez.advancedcore.api.item.ItemBuilder.class, withSettings().defaultAnswer(RETURNS_SELF))) { + when(h.owner.hasPermission("GraveStonesPlus.Compass")).thenReturn(true); + var click = mock(com.bencodez.advancedcore.api.inventory.BInventory.ClickEvent.class); + when(click.getPlayer()).thenReturn(h.owner); when(click.getClick()).thenReturn(ClickType.LEFT); + h.grave.getNavigationGUIItem(h.owner).onClick(click); assertFalse(h.entityWork.isEmpty()); + when(h.owner.hasPermission("GraveStonesPlus.Compass")).thenReturn(false); h.drain(); + assertTrue(items.constructed().isEmpty()); verify(h.ownerInventory, never()).addItem(any(ItemStack[].class)); + } + } + @Test void respawnUsesPlayerOwnerAndRevalidatesPermissionAfterDelay() throws Exception { + try (var h = new GraveActionsTest.Harness(); var items = compasses()) { + when(h.config.isGiveCompassOnRespawn()).thenReturn(true); when(h.plugin.getLatestGrave(h.owner)).thenReturn(h.grave); + when(h.owner.hasPermission("GraveStonesPlus.Compass")).thenReturn(true); + Queue delayed = new ConcurrentLinkedQueue<>(); + when(h.platform.runLater(any(Runnable.class), eq(5L))).thenAnswer(call -> { + delayed.add(call.getArgument(0)); return mock(com.bencodez.simpleapi.folialib.wrapper.task.WrappedTask.class); + }); + PlayerRespawnEvent event = mock(PlayerRespawnEvent.class); when(event.getPlayer()).thenReturn(h.owner); + new PlayerDeathListener(h.plugin).onRespawn(event); assertEquals(1, delayed.size()); + delayed.remove().run(); when(h.owner.hasPermission("GraveStonesPlus.Compass")).thenReturn(false); h.drain(); + assertTrue(items.constructed().isEmpty()); verify(h.ownerInventory, never()).addItem(any(ItemStack[].class)); + verify(h.scheduler, never()).runTask(eq(h.plugin), any(Runnable.class)); + verify(h.scheduler, never()).runTaskLaterAsynchronously(any(), any(), anyLong()); + } + } + @Test void permittedCompassDeliveryHasNoFurtherUnguardedSchedulerHop() throws Exception { + try (var h = new GraveActionsTest.Harness(); var items = compasses()) { + Commands commands = new Commands(h); when(h.owner.hasPermission("GraveStonesPlus.Compass")).thenReturn(true); + when(h.ownerInventory.addItem(any(ItemStack[].class))).thenReturn(new HashMap<>()); + commands.run(h.owner, "compass"); h.entityWork.remove().run(); + assertEquals(1, items.constructed().size()); verify(h.ownerInventory).addItem(items.constructed().getFirst()); + assertSame(h.grave, h.navigation.target(h.owner)); assertTrue(h.entityWork.isEmpty()); + verify(h.plugin, never()).getFullInventoryHandler(); + } + } + @Test void fullInventoryCompassHonorsDropPolicyAndNeverQueuesLaterDelivery() throws Exception { + for (boolean drop : List.of(false, true)) try (var h = new GraveActionsTest.Harness(); var items = compasses()) { + var options = mock(AdvancedCoreConfigOptions.class); when(options.isDropOnFullInv()).thenReturn(drop); when(h.plugin.getOptions()).thenReturn(options); + when(h.owner.hasPermission("GraveStonesPlus.Compass")).thenReturn(true); + when(h.ownerInventory.addItem(any(ItemStack[].class))).thenAnswer(call -> new HashMap<>(Map.of(0, ((ItemStack[]) call.getRawArguments()[0])[0]))); + h.grave.giveCompass(h.owner); h.entityWork.remove().run(); + assertEquals(1, items.constructed().size()); assertSame(h.grave, h.navigation.target(h.owner)); assertTrue(h.entityWork.isEmpty()); + verify(h.world, times(drop ? 1 : 0)).dropItem(h.owner.getLocation(), items.constructed().getFirst()); + if (!drop) verify(h.owner).sendMessage(argThat((String message) -> message.contains("Make room"))); + verify(h.plugin, never()).getFullInventoryHandler(); + } + } +} diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/CreationCapacityTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/CreationCapacityTest.java new file mode 100644 index 0000000..01c1fa8 --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/CreationCapacityTest.java @@ -0,0 +1,131 @@ +package com.bencodez.gravestonesplus.graves; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.util.*; +import java.util.concurrent.TimeUnit; +import org.bukkit.*; +import org.junit.jupiter.api.Test; +import com.bencodez.gravestonesplus.storage.*; + +class CreationCapacityTest { + private GravesConfig record(GraveActionsTest.Harness h, long time) { + return new GravesConfig(h.owner.getUniqueId(), "Owner", new Location(h.world, 3, 64, 3), + new HashMap<>(Map.of(4, h.stored)), 7, "test", time, false, 0, null, null, "CHEST"); + } + private GraveRestoration restoration(GraveActionsTest.Harness h) { + h.active.clear(); when(h.config.getGraveLimit()).thenReturn(1); + when(h.plugin.numberOfGraves(any())).thenCallRealMethod(); + when(h.plugin.getOldestGrave(any())).thenCallRealMethod(); + return GraveActionsTest.limitRestoration(h); + } + @Test void repeatedAdmissionsReserveDistinctOldestAndKeepOneLogicalSlot() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var restoration = restoration(h); + var first = record(h, 100); var second = record(h, 200); var third = record(h, 300); + assertEquals(0, restoration.reserveCreation(first).priorGraves()); + var two = restoration.reserveCreation(second); + assertEquals(1, two.priorGraves()); assertSame(first, two.replacement().getGravesConfig()); + assertEquals(1, h.plugin.numberOfGraves(h.owner.getUniqueId())); + var three = restoration.reserveCreation(third); + assertEquals(1, three.priorGraves()); assertSame(second, three.replacement().getGravesConfig()); + assertNotSame(two.replacement(), three.replacement()); + assertEquals(1, h.plugin.numberOfGraves(h.owner.getUniqueId())); + assertTrue(h.active.isEmpty()); + verify(h.world, never()).getChunkAt(anyInt(), anyInt()); + verify(h.owner, never()).giveExp(anyInt()); + // Failed later admission restores its own selection; earlier accepted work remains. + restoration.releaseCreation(third); restoration.releaseCreation(third); + assertFalse(restoration.isCreationReplacement(three.replacement())); + assertTrue(restoration.isCreationReplacement(two.replacement())); + assertEquals(1, h.plugin.numberOfGraves(h.owner.getUniqueId())); + } + } + @Test void dropReplacementRejectsFurtherAdmissionsBeforeAndAfterFailedSave() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var restoration = restoration(h); + when(h.config.isDropItemsOnGraveRemoval()).thenReturn(true); + h.active.add(h.grave); + var next = record(h, h.grave.getGravesConfig().getTime() + 1); + var later = record(h, next.getTime() + 1); + var selected = restoration.reserveCreation(next); + assertSame(h.grave, selected.replacement()); + assertThrows(java.util.concurrent.RejectedExecutionException.class, () -> restoration.reserveCreation(later)); + h.claimDisk.fail = true; + assertTrue(h.grave.admitLimitRemoval(h.owner), "Admission reserves work, not a successful save"); + Grave created = restoration.creationGrave(next); h.active.add(created); restoration.completeCreation(next); + h.drain(); + assertEquals(2, h.plugin.numberOfGraves(h.owner.getUniqueId())); + assertThrows(java.util.concurrent.RejectedExecutionException.class, () -> restoration.reserveCreation(later)); + assertFalse(h.grave.getGravesConfig().isDestroyed()); assertFalse(next.isDestroyed()); + verify(h.world, never()).dropItem(any(), any()); + } + } + @Test void fullPersistenceWorkerRejectsLimitAdmissionWithoutBlockingOrRemovingContents() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + GraveActionsTest.limitRestoration(h); + var tickets = new ArrayList(); + try { + FeatureIO.Admission ticket; + while ((ticket = h.io.admit()) != null) tickets.add(ticket); + assertFalse(h.grave.admitLimitRemoval(h.owner)); + assertFalse(h.grave.getGravesConfig().isDestroyed()); + assertNull(h.ledger.status(h.identity())); + verify(h.world, never()).dropItem(any(), any()); + } finally { tickets.forEach(FeatureIO.Admission::close); } + assertTrue(h.grave.admitLimitRemoval(h.owner)); h.drain(); + assertTrue(h.ledger.status(h.identity()).startsWith("QUEUED_LIMIT:")); + } + } + @Test void schedulerRejectionReleasesNewAdmissionAndLeavesExistingGraveSelectable() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var restoration = restoration(h); h.active.add(h.grave); + var record = record(h, h.grave.getGravesConfig().getTime() + 1); + var creation = new GraveCreationTask(h.plugin, record, grave -> fail("Rejected task ran")); + when(h.platform.runAtLocationTimer(any(Location.class), any(Runnable.class), eq(2L), eq(1L), eq(TimeUnit.SECONDS))) + .thenThrow(new java.util.concurrent.RejectedExecutionException("retired region")); + assertThrows(java.util.concurrent.RejectedExecutionException.class, () -> creation.schedule(record.getLocation())); + creation.run(); + assertEquals(1, h.plugin.numberOfGraves(h.owner.getUniqueId())); + assertSame(h.grave, h.plugin.getOldestGrave(h.owner.getUniqueId())); + assertFalse(restoration.hasPending(GraveIdentity.of(record))); + assertFalse(h.grave.isRemove()); assertFalse(h.grave.getGravesConfig().isDestroyed()); + verify(h.plugin, never()).removeGrave(any()); + } + } + @Test void delayedPublicationUsesSelectedWrapperOnceAndReleasesCapacityReservation() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var restoration = restoration(h); var record = record(h, 100); + var observed = new ArrayList(); + var creation = new GraveCreationTask(h.plugin, record, grave -> { observed.add(grave); h.active.add(grave); }); + var task = mock(com.bencodez.simpleapi.folialib.wrapper.task.WrappedTask.class); + when(h.platform.runAtLocationTimer(any(Location.class), any(Runnable.class), eq(2L), eq(1L), eq(TimeUnit.SECONDS))) + .thenReturn(task); + creation.schedule(record.getLocation()); + Grave pending = restoration.pendingActiveForLimits().getFirst(); + assertFalse(h.plugin.isLiveGrave(pending)); + creation.run(); assertTrue(observed.isEmpty(), "Callback cannot run before preflight acceptance"); + creation.accept(); creation.run(); creation.run(); creation.cancel(); + assertEquals(List.of(pending), observed); + assertFalse(restoration.hasPending(GraveIdentity.of(record))); + assertEquals(1, h.plugin.numberOfGraves(h.owner.getUniqueId())); + verify(task, times(1)).cancel(); + } + } + @Test void reservationCapacityRejectsOverflowAndReclaimsOnlyReleasedIdentity() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var restoration = GraveActionsTest.limitRestoration(h); + when(h.config.getGraveLimit()).thenReturn(Integer.MAX_VALUE); + var first = record(h, 0); + restoration.reserveCreation(first); + for (int i = 1; i < 4096; i++) restoration.reserveCreation(record(h, i)); + var overflow = record(h, 4096); + assertThrows(java.util.concurrent.RejectedExecutionException.class, () -> restoration.reserveCreation(overflow)); + assertThrows(java.util.concurrent.RejectedExecutionException.class, () -> restoration.reserveCreation(first)); + restoration.releaseCreation(first); + assertDoesNotThrow(() -> restoration.reserveCreation(overflow)); + assertTrue(restoration.hasPending(GraveIdentity.of(overflow))); + assertFalse(restoration.hasPending(GraveIdentity.of(first))); + } + } +} diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/DeathPlayerOwnershipTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/DeathPlayerOwnershipTest.java new file mode 100644 index 0000000..ef0fdfe --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/DeathPlayerOwnershipTest.java @@ -0,0 +1,489 @@ +package com.bencodez.gravestonesplus.graves; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.util.*; +import org.bukkit.*; +import org.bukkit.block.Block; +import org.bukkit.entity.Player; +import org.bukkit.event.entity.PlayerDeathEvent; +import org.bukkit.event.player.PlayerRespawnEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.bukkit.inventory.meta.ItemMeta; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import com.bencodez.gravestonesplus.listeners.PlayerDeathListener; + +/** Production death/respawn entry points, with independently controlled region/player queues. */ +class DeathPlayerOwnershipTest { + private static void capture(GraveActionsTest.Harness h, PlayerDeathListener listener, Player player, + PlayerInventory inventory, ItemStack item) { + when(h.folia.isFolia()).thenReturn(true); + when(h.config.getDisabledWorlds()).thenReturn(new ArrayList<>()); + when(h.config.getGraveLimit()).thenReturn(10); + when(h.config.getKeepItemsWithLore()).thenReturn("keep"); + when(h.config.getFormatDeath()).thenReturn("Grave at %x%"); + when(h.config.getGraveDisplayTypeEnum()).thenReturn(GraveDisplayType.CHEST); + when(h.world.getMinHeight()).thenReturn(-64); when(h.world.getMaxHeight()).thenReturn(320); + Block block = mock(Block.class); when(block.isEmpty()).thenReturn(true); + when(h.world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(block); + when(h.world.getBlockAt(any(Location.class))).thenReturn(block); + when(player.getName()).thenReturn("Owner"); when(player.getDisplayName()).thenReturn("Owner"); + when(player.hasPermission("GraveStonesPlus.AllowGrave")).thenReturn(true); + when(inventory.getContents()).thenReturn(new ItemStack[] { item }); + when(inventory.getArmorContents()).thenReturn(new ItemStack[4]); + when(inventory.getItem(4)).thenReturn(item); + ItemMeta meta = mock(ItemMeta.class); + when(meta.hasLore()).thenReturn(true); when(meta.getLore()).thenReturn(List.of("keep")); + when(item.hasItemMeta()).thenReturn(true); when(item.getItemMeta()).thenReturn(meta); + doAnswer(call -> { + Runnable work = call.getArgument(1); + var task = mock(com.bencodez.simpleapi.folialib.wrapper.task.WrappedTask.class); + doAnswer(cancel -> { h.regionWork.remove(work); return null; }).when(task).cancel(); + h.regionWork.add(work); return task; + }).when(h.platform).runAtLocationTimer(any(Location.class), any(Runnable.class), eq(2L), eq(1L), + eq(java.util.concurrent.TimeUnit.SECONDS)); + PlayerDeathEvent event = mock(PlayerDeathEvent.class); + when(event.getEntity()).thenReturn(player); when(event.getDrops()).thenReturn(new ArrayList<>()); + listener.onPlayerDeath(event); + verify(event).setDroppedExp(0); + // Respawn replaces the original inventory contents. + when(inventory.getItem(4)).thenReturn(null); + } + private static void respawn(PlayerDeathListener listener, Player player) { + PlayerRespawnEvent event = mock(PlayerRespawnEvent.class); when(event.getPlayer()).thenReturn(player); + listener.onRespawn(event); + } + @Test void rejectedLimitAdmissionLeavesDeathDropsXpAndExistingGraveUntouched() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var listener = new PlayerDeathListener(h.plugin); + capture(h, listener, h.owner, h.ownerInventory, h.stored); + when(h.ownerInventory.getItem(4)).thenReturn(h.stored); + when(h.config.getGraveLimit()).thenReturn(1); + when(h.config.isDropItemsOnGraveRemoval()).thenReturn(true); + when(h.plugin.numberOfGraves(h.owner.getUniqueId())).thenReturn(1); + when(h.plugin.getOldestGrave(h.owner.getUniqueId())).thenReturn(h.grave); + GraveActionsTest.limitRestoration(h); + h.io.close(); // Actual persistence owner rejects the new limit request. + var event = mock(PlayerDeathEvent.class); when(event.getEntity()).thenReturn(h.owner); + var drops = new ArrayList<>(List.of(h.stored)); when(event.getDrops()).thenReturn(drops); + when(event.getDroppedExp()).thenReturn(7); + listener.onPlayerDeath(event); + assertEquals(List.of(h.stored), drops); verify(event, never()).setDroppedExp(anyInt()); + assertFalse(h.grave.isRemove()); assertFalse(h.grave.getGravesConfig().isDestroyed()); + verify(h.plugin, never()).removeGrave(any()); + assertEquals(1, h.regionWork.size(), "Only the earlier accepted death remains scheduled"); + } + } + @Test void keptItemsUseMovingPlayerOwnerAndRepeatedCallbacksDeliverOnlyOnce() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var listener = new PlayerDeathListener(h.plugin); + capture(h, listener, h.owner, h.ownerInventory, h.stored); + respawn(listener, h.owner); respawn(listener, h.owner); + assertTrue(h.ownerWork.isEmpty(), "No inventory access through the global scheduler"); + assertEquals(2, h.entityWork.size()); + verify(h.ownerInventory, never()).setItem(anyInt(), any()); + h.drain(); + verify(h.ownerInventory, times(1)).setItem(4, h.cloned); + respawn(listener, h.owner); assertTrue(h.entityWork.isEmpty()); + } + } + @Test void rejectedRespawnAdmissionKeepsItemsForTheNextRespawn() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var listener = new PlayerDeathListener(h.plugin); + capture(h, listener, h.owner, h.ownerInventory, h.stored); + doThrow(new IllegalStateException("retired player scheduler")).doAnswer(call -> { + h.entityWork.add(call.getArgument(1)); return null; + }).when(h.scheduler).runTask(eq(h.plugin), any(Runnable.class), eq(h.owner)); + assertDoesNotThrow(() -> respawn(listener, h.owner)); + respawn(listener, h.owner); h.drain(); + verify(h.ownerInventory, times(1)).setItem(eq(4), any(ItemStack.class)); + } + } + @ParameterizedTest @ValueSource(booleans = { false, true }) + void offlineOrDisabledCallbackRetainsSnapshotAndAnotherPlayerCannotConsumeIt(boolean disabled) throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var listener = new PlayerDeathListener(h.plugin); + capture(h, listener, h.owner, h.ownerInventory, h.stored); + respawn(listener, h.helper); assertTrue(h.entityWork.isEmpty()); + respawn(listener, h.owner); + if (disabled) when(h.plugin.isEnabled()).thenReturn(false); + else when(h.owner.isOnline()).thenReturn(false); + h.drain(); + verify(h.ownerInventory, never()).setItem(anyInt(), any()); + when(h.owner.isOnline()).thenReturn(true); when(h.plugin.isEnabled()).thenReturn(true); + respawn(listener, h.owner); h.drain(); + verify(h.ownerInventory, times(1)).setItem(eq(4), any(ItemStack.class)); + verify(h.helperInventory, never()).setItem(anyInt(), any()); + } + } + @Test void staleRespawnCallbackCannotConsumeTheNextDeathEvenWithIdenticalItems() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var listener = new PlayerDeathListener(h.plugin); + capture(h, listener, h.owner, h.ownerInventory, h.stored); + respawn(listener, h.owner); + capture(h, listener, h.owner, h.ownerInventory, h.stored); + when(h.ownerInventory.getItem(4)).thenReturn(null, h.cloned); + h.entityWork.remove().run(); + verify(h.ownerInventory, never()).setItem(anyInt(), any()); + respawn(listener, h.owner); h.drain(); + verify(h.ownerInventory, times(1)).setItem(4, h.cloned); + verify(h.ownerInventory, times(1)).addItem(h.cloned); + respawn(listener, h.owner); assertTrue(h.entityWork.isEmpty()); + } + } + @Test void successiveDifferentItemsSharingASlotBothSurviveAndFullInventoryDropsOverflow() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var listener = new PlayerDeathListener(h.plugin); + capture(h, listener, h.owner, h.ownerInventory, h.stored); + respawn(listener, h.owner); + var second = mock(ItemStack.class); var secondClone = mock(ItemStack.class); + var material = h.stored.getType(); + when(second.getType()).thenReturn(material); when(second.clone()).thenReturn(secondClone); + when(secondClone.getType()).thenReturn(material); + capture(h, listener, h.owner, h.ownerInventory, second); + when(h.ownerInventory.getItem(4)).thenReturn(null, h.cloned); + when(h.ownerInventory.addItem(secondClone)).thenReturn(new HashMap<>(Map.of(0, secondClone))); + h.entityWork.remove().run(); verify(h.ownerInventory, never()).setItem(anyInt(), any()); + respawn(listener, h.owner); respawn(listener, h.owner); h.drain(); + verify(h.ownerInventory, times(1)).setItem(4, h.cloned); + verify(h.ownerInventory, times(1)).addItem(secondClone); + verify(h.world, times(1)).dropItemNaturally(any(Location.class), eq(secondClone)); + respawn(listener, h.owner); assertTrue(h.entityWork.isEmpty()); + } + } + @ParameterizedTest @ValueSource(booleans = { false, true }) + void snapshotBoundLeavesNewDeathDropsAndExperienceUntouchedAndRetainsEarlierEntitlements(boolean dropOldContents) throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var listener = new PlayerDeathListener(h.plugin); + for (int i = 0; i < 8; i++) capture(h, listener, h.owner, h.ownerInventory, h.stored); + when(h.ownerInventory.getItem(4)).thenReturn(h.stored); + Grave oldest = mock(Grave.class); + when(h.plugin.numberOfGraves(h.owner.getUniqueId())).thenReturn(1); + when(h.config.getGraveLimit()).thenReturn(1); + when(h.config.isDropItemsOnGraveRemoval()).thenReturn(dropOldContents); + when(h.plugin.getOldestGrave(h.owner.getUniqueId())).thenReturn(oldest); + var event = mock(PlayerDeathEvent.class); when(event.getEntity()).thenReturn(h.owner); + var drops = new ArrayList<>(List.of(h.stored)); when(event.getDrops()).thenReturn(drops); + when(event.getDroppedExp()).thenReturn(7); + listener.onPlayerDeath(event); + assertEquals(List.of(h.stored), drops); verify(event, never()).setDroppedExp(anyInt()); + verify(oldest, never()).removeGrave(any()); + verify(oldest, never()).dropAndRemoveForLimit(any()); + assertEquals(8, h.regionWork.size(), "Rejected ninth death must not queue a replacement"); + when(h.ownerInventory.getItem(4)).thenReturn(null, h.cloned); + respawn(listener, h.owner); respawn(listener, h.owner); h.drain(); + verify(h.ownerInventory, times(1)).setItem(4, h.cloned); + verify(h.ownerInventory, times(7)).addItem(h.cloned); + respawn(listener, h.owner); assertTrue(h.entityWork.isEmpty()); + } + } + @ParameterizedTest @ValueSource(booleans = { false, true }) + void missingReplacementLocationPreservesOldGraveAndNewDeathDrops(boolean dropOldContents) throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var listener = new PlayerDeathListener(h.plugin); + capture(h, listener, h.owner, h.ownerInventory, h.stored); + when(h.ownerInventory.getItem(4)).thenReturn(h.stored); + Grave oldest = mock(Grave.class); + when(h.plugin.numberOfGraves(h.owner.getUniqueId())).thenReturn(1); + when(h.config.getGraveLimit()).thenReturn(1); + when(h.config.isDropItemsOnGraveRemoval()).thenReturn(dropOldContents); + when(h.plugin.getOldestGrave(h.owner.getUniqueId())).thenReturn(oldest); + Block occupied = mock(Block.class); + when(occupied.isEmpty()).thenReturn(false); + when(occupied.getType()).thenReturn(Material.STONE); + when(h.world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(occupied); + when(h.world.getBlockAt(any(Location.class))).thenReturn(occupied); + var event = mock(PlayerDeathEvent.class); + when(event.getEntity()).thenReturn(h.owner); + var drops = new ArrayList<>(List.of(h.stored)); + when(event.getDrops()).thenReturn(drops); + when(event.getDroppedExp()).thenReturn(7); + listener.onPlayerDeath(event); + assertEquals(List.of(h.stored), drops); + verify(event, never()).setDroppedExp(anyInt()); + verify(oldest, never()).removeGrave(any()); + verify(oldest, never()).dropAndRemoveForLimit(any()); + assertEquals(1, h.regionWork.size(), "Failed location preflight must not queue a replacement"); + } + } + @ParameterizedTest @ValueSource(booleans = { false, true }) + void acceptedReplacementStillUsesConfiguredLimitRemoval(boolean dropOldContents) throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var listener = new PlayerDeathListener(h.plugin); + capture(h, listener, h.owner, h.ownerInventory, h.stored); + when(h.ownerInventory.getItem(4)).thenReturn(h.stored); + Grave oldest = mock(Grave.class); + when(h.plugin.numberOfGraves(h.owner.getUniqueId())).thenReturn(1); + when(h.config.getGraveLimit()).thenReturn(1); + when(h.config.isDropItemsOnGraveRemoval()).thenReturn(dropOldContents); + when(h.plugin.getOldestGrave(h.owner.getUniqueId())).thenReturn(oldest); + var event = mock(PlayerDeathEvent.class); + when(event.getEntity()).thenReturn(h.owner); + var drops = new ArrayList<>(List.of(h.stored)); + when(event.getDrops()).thenReturn(drops); + if (dropOldContents) when(oldest.admitLimitRemoval(h.owner)).thenReturn(true); + listener.onPlayerDeath(event); + assertTrue(drops.isEmpty()); verify(event).setDroppedExp(0); + if (dropOldContents) { + verify(oldest, times(1)).admitLimitRemoval(h.owner); + verify(oldest, never()).removeGrave(any()); + } else { + verify(oldest, times(1)).removeGrave(com.bencodez.gravestonesplus.events.GraveRemoveReason.LIMIT_REACHED); + verify(oldest, never()).dropAndRemoveForLimit(any()); + } + assertEquals(2, h.regionWork.size()); + } + } + @ParameterizedTest @ValueSource(booleans = { false, true }) + void failedLimitPreflightRollsBackOnlyNewKeptBatchAndCannotPayBeforeDropsClear(boolean dropOldContents) throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var listener = new PlayerDeathListener(h.plugin); + capture(h, listener, h.owner, h.ownerInventory, h.stored); + when(h.ownerInventory.getItem(4)).thenReturn(h.stored); + Grave oldest = mock(Grave.class); + when(h.plugin.numberOfGraves(h.owner.getUniqueId())).thenReturn(1); + when(h.config.getGraveLimit()).thenReturn(1); + when(h.config.isDropItemsOnGraveRemoval()).thenReturn(dropOldContents); + when(h.plugin.getOldestGrave(h.owner.getUniqueId())).thenReturn(oldest); + org.mockito.stubbing.Answer fail = call -> { + respawn(listener, h.owner); + h.entityWork.remove().run(); + verify(h.ownerInventory, never()).setItem(anyInt(), any()); + throw new IllegalStateException("limit removal rejected"); + }; + if (dropOldContents) doAnswer(fail).when(oldest).admitLimitRemoval(any()); + else doAnswer(fail).when(oldest).removeGrave(any()); + var event = mock(PlayerDeathEvent.class); + when(event.getEntity()).thenReturn(h.owner); + var drops = new ArrayList<>(List.of(h.stored)); + when(event.getDrops()).thenReturn(drops); + assertThrows(IllegalStateException.class, () -> listener.onPlayerDeath(event)); + assertEquals(List.of(h.stored), drops); verify(event, never()).setDroppedExp(anyInt()); + assertEquals(1, h.regionWork.size()); + when(h.ownerInventory.getItem(4)).thenReturn(null); + respawn(listener, h.owner); h.drain(); + verify(h.ownerInventory, times(1)).setItem(4, h.cloned); + verify(h.ownerInventory, never()).addItem(any(ItemStack.class)); + } + } + @Test void failedPreflightDoesNotDiscardASuccessorKeptBatch() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var listener = new PlayerDeathListener(h.plugin); + capture(h, listener, h.owner, h.ownerInventory, h.stored); + when(h.ownerInventory.getItem(4)).thenReturn(h.stored); + Grave oldest = mock(Grave.class); + when(h.plugin.numberOfGraves(h.owner.getUniqueId())).thenReturn(1); + when(h.config.getGraveLimit()).thenReturn(1); + when(h.plugin.getOldestGrave(h.owner.getUniqueId())).thenReturn(oldest); + var successor = mock(ItemStack.class); var successorClone = mock(ItemStack.class); + var material = h.stored.getType(); + when(successor.getType()).thenReturn(material); when(successor.clone()).thenReturn(successorClone); + when(successorClone.getType()).thenReturn(material); + doAnswer(call -> { + capture(h, listener, h.owner, h.ownerInventory, successor); + throw new IllegalStateException("predecessor removal failed"); + }).when(oldest).removeGrave(any()); + var event = mock(PlayerDeathEvent.class); + when(event.getEntity()).thenReturn(h.owner); + var drops = new ArrayList<>(List.of(h.stored)); when(event.getDrops()).thenReturn(drops); + assertThrows(IllegalStateException.class, () -> listener.onPlayerDeath(event)); + assertEquals(List.of(h.stored), drops); verify(event, never()).setDroppedExp(anyInt()); + when(h.ownerInventory.getItem(4)).thenReturn(null, h.cloned); + respawn(listener, h.owner); h.drain(); + verify(h.ownerInventory, times(1)).setItem(4, h.cloned); + verify(h.ownerInventory, times(1)).addItem(successorClone); + verify(h.ownerInventory, never()).addItem(h.cloned); + } + } + @ParameterizedTest @ValueSource(booleans = { false, true }) + void rejectedCreationSchedulerPreservesNativeDeathAndEarlierKeptItems(boolean keepNewItems) throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var listener = new PlayerDeathListener(h.plugin); + capture(h, listener, h.owner, h.ownerInventory, h.stored); + when(h.ownerInventory.getItem(4)).thenReturn(h.stored); + Grave oldest = mock(Grave.class); + when(h.plugin.numberOfGraves(h.owner.getUniqueId())).thenReturn(10); + when(h.plugin.getOldestGrave(h.owner.getUniqueId())).thenReturn(oldest); + if (!keepNewItems) { + when(h.config.getKeepItemsWithLore()).thenReturn(""); + when(h.stored.hasItemMeta()).thenReturn(false); + } + doThrow(new IllegalStateException("creation scheduler retired")) + .when(h.platform).runAtLocationTimer(any(Location.class), any(Runnable.class), eq(2L), eq(1L), + eq(java.util.concurrent.TimeUnit.SECONDS)); + var event = mock(PlayerDeathEvent.class); + when(event.getEntity()).thenReturn(h.owner); + var drops = new ArrayList<>(List.of(h.stored)); + when(event.getDrops()).thenReturn(drops); when(event.getDroppedExp()).thenReturn(7); + // A rejected handoff cannot establish ownership of this death's contents. + // Check native fallback independently of whether rejection is propagated. + try { listener.onPlayerDeath(event); } + catch (IllegalStateException expected) { + assertEquals("creation scheduler retired", expected.getMessage()); + } + when(h.ownerInventory.getItem(4)).thenReturn(null, h.cloned); + respawn(listener, h.owner); h.drain(); + assertAll( + () -> assertEquals(List.of(h.stored), drops, "Rejected creation must preserve native drops"), + () -> verify(event, never()).setDroppedExp(anyInt()), + () -> verify(oldest, never()).removeGrave(any()), + () -> verify(oldest, never()).dropAndRemoveForLimit(any()), + () -> verify(h.ownerInventory, times(1)).setItem(4, h.cloned), + () -> verify(h.ownerInventory, never()).addItem(any(ItemStack.class))); + } + } + @ParameterizedTest @ValueSource(booleans = { false, true }) + void missingOrAlreadyCancelledTaskHandleRejectsBeforeConsumingDeath(boolean alreadyCancelled) throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var listener = new PlayerDeathListener(h.plugin); + capture(h, listener, h.owner, h.ownerInventory, h.stored); + when(h.ownerInventory.getItem(4)).thenReturn(h.stored); + var task = mock(com.bencodez.simpleapi.folialib.wrapper.task.WrappedTask.class); + when(task.isCancelled()).thenReturn(true); + doReturn(alreadyCancelled ? task : null).when(h.platform).runAtLocationTimer( + any(Location.class), any(Runnable.class), eq(2L), eq(1L), eq(java.util.concurrent.TimeUnit.SECONDS)); + var event = mock(PlayerDeathEvent.class); when(event.getEntity()).thenReturn(h.owner); + var drops = new ArrayList<>(List.of(h.stored)); when(event.getDrops()).thenReturn(drops); + assertThrows(java.util.concurrent.RejectedExecutionException.class, () -> listener.onPlayerDeath(event)); + assertEquals(List.of(h.stored), drops); verify(event, never()).setDroppedExp(anyInt()); + when(h.ownerInventory.getItem(4)).thenReturn(null); + respawn(listener, h.owner); h.drain(); + verify(h.ownerInventory, times(1)).setItem(4, h.cloned); + verify(h.ownerInventory, never()).addItem(any(ItemStack.class)); + if (alreadyCancelled) verify(task, times(1)).cancel(); + } + } + @Test void earlyRegionCallbackWaitsForNativeConsumptionAndCreatesOnceWithoutAnotherAdmission() throws Exception { + try (var h = new GraveActionsTest.Harness(); var graves = mockConstruction(Grave.class)) { + var listener = new PlayerDeathListener(h.plugin); + capture(h, listener, h.owner, h.ownerInventory, h.stored); + Runnable[] callback = { null }; + var task = mock(com.bencodez.simpleapi.folialib.wrapper.task.WrappedTask.class); + when(h.ownerInventory.getItem(4)).thenReturn(h.stored); + var event = mock(PlayerDeathEvent.class); when(event.getEntity()).thenReturn(h.owner); + var drops = new ArrayList<>(List.of(h.stored)); when(event.getDrops()).thenReturn(drops); + doAnswer(call -> { + callback[0] = call.getArgument(1); + callback[0].run(); callback[0].run(); + assertEquals(List.of(h.stored), drops); + verify(event, never()).setDroppedExp(anyInt()); + assertTrue(graves.constructed().isEmpty()); + return task; + }).when(h.platform).runAtLocationTimer(any(Location.class), any(Runnable.class), eq(2L), eq(1L), + eq(java.util.concurrent.TimeUnit.SECONDS)); + clearInvocations(h.platform); + listener.onPlayerDeath(event); + assertTrue(drops.isEmpty()); assertTrue(graves.constructed().isEmpty()); + callback[0].run(); callback[0].run(); + assertEquals(1, graves.constructed().size()); + verify(task, times(1)).cancel(); + verify(h.platform, times(1)).runAtLocationTimer(any(Location.class), any(Runnable.class), + eq(2L), eq(1L), eq(java.util.concurrent.TimeUnit.SECONDS)); + } + } + @Test void failedPreflightCancelsEvenAnEarlyCallbackAndCancellationFailureCannotKeepItsBatch() throws Exception { + try (var h = new GraveActionsTest.Harness(); var graves = mockConstruction(Grave.class)) { + var listener = new PlayerDeathListener(h.plugin); + capture(h, listener, h.owner, h.ownerInventory, h.stored); + when(h.ownerInventory.getItem(4)).thenReturn(h.stored); + when(h.plugin.numberOfGraves(h.owner.getUniqueId())).thenReturn(1); + when(h.config.getGraveLimit()).thenReturn(1); + Grave oldest = mock(Grave.class); when(h.plugin.getOldestGrave(h.owner.getUniqueId())).thenReturn(oldest); + doThrow(new IllegalStateException("limit removal rejected")).when(oldest).removeGrave(any()); + Runnable[] callback = { null }; + var task = mock(com.bencodez.simpleapi.folialib.wrapper.task.WrappedTask.class); + doThrow(new IllegalStateException("cancel failed")).doNothing().when(task).cancel(); + doAnswer(call -> { callback[0] = call.getArgument(1); callback[0].run(); return task; }) + .when(h.platform).runAtLocationTimer(any(Location.class), any(Runnable.class), eq(2L), eq(1L), + eq(java.util.concurrent.TimeUnit.SECONDS)); + var event = mock(PlayerDeathEvent.class); when(event.getEntity()).thenReturn(h.owner); + var drops = new ArrayList<>(List.of(h.stored)); when(event.getDrops()).thenReturn(drops); + var failure = assertThrows(IllegalStateException.class, () -> listener.onPlayerDeath(event)); + assertEquals("limit removal rejected", failure.getMessage()); + assertEquals(List.of(h.stored), drops); verify(event, never()).setDroppedExp(anyInt()); + callback[0].run(); callback[0].run(); + assertTrue(graves.constructed().isEmpty()); verify(task, times(2)).cancel(); + when(h.ownerInventory.getItem(4)).thenReturn(null); + respawn(listener, h.owner); h.drain(); + verify(h.ownerInventory, times(1)).setItem(4, h.cloned); + verify(h.ownerInventory, never()).addItem(any(ItemStack.class)); + } + } + @Test void deferredGraveReceivesDetachedCapturedItems() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var listener = new PlayerDeathListener(h.plugin); + capture(h, listener, h.owner, h.ownerInventory, h.stored); + when(h.ownerInventory.getItem(4)).thenReturn(h.stored); + when(h.config.getKeepItemsWithLore()).thenReturn(""); + when(h.stored.hasItemMeta()).thenReturn(false); + var event = mock(PlayerDeathEvent.class); + when(event.getEntity()).thenReturn(h.owner); when(event.getDrops()).thenReturn(new ArrayList<>()); + listener.onPlayerDeath(event); + var records = new ArrayList(); + try (var graves = mockConstruction(Grave.class, (grave, context) -> + records.add((com.bencodez.gravestonesplus.storage.GravesConfig) context.arguments().get(1)))) { + h.regionWork.remove().run(); // first kept-only death + h.regionWork.remove().run(); // second grave-bound death + assertSame(h.cloned, records.get(1).getItems().get(4)); + assertNotSame(h.stored, records.get(1).getItems().get(4)); + } + } + } + @Test void simultaneousPendingPlayersRestoreTheirOwnSnapshotsOnly() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var listener = new PlayerDeathListener(h.plugin); + capture(h, listener, h.owner, h.ownerInventory, h.stored); + capture(h, listener, h.helper, h.helperInventory, h.cloned); + respawn(listener, h.helper); respawn(listener, h.owner); + assertTrue(h.ownerWork.isEmpty()); assertEquals(2, h.entityWork.size()); + h.entityWork.remove().run(); + verify(h.helperInventory).setItem(4, h.cloned); + verify(h.ownerInventory, never()).setItem(anyInt(), any()); + h.entityWork.remove().run(); verify(h.ownerInventory).setItem(4, h.cloned); + } + } + @ParameterizedTest @ValueSource(strings = { "keepInventory", "permission", "noKeptItems" }) + void subsequentDeathFencesOlderRespawnWithoutDiscardingUndeliveredItems(String change) throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var listener = new PlayerDeathListener(h.plugin); + capture(h, listener, h.owner, h.ownerInventory, h.stored); + respawn(listener, h.owner); + PlayerDeathEvent secondDeath = mock(PlayerDeathEvent.class); + when(secondDeath.getEntity()).thenReturn(h.owner); + when(secondDeath.getDrops()).thenReturn(new ArrayList<>()); + if (change.equals("keepInventory")) when(secondDeath.getKeepInventory()).thenReturn(true); + else if (change.equals("permission")) when(h.owner.hasPermission("GraveStonesPlus.AllowGrave")).thenReturn(false); + // No item is present in the inventory after the first native death/respawn. + listener.onPlayerDeath(secondDeath); + h.entityWork.remove().run(); + verify(h.ownerInventory, never()).setItem(anyInt(), any()); + respawn(listener, h.owner); h.drain(); + verify(h.ownerInventory, times(1)).setItem(4, h.cloned); + } + } + @Test void deadPlayerCallbackKeepsThePendingSnapshotForALivingRespawn() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var listener = new PlayerDeathListener(h.plugin); + capture(h, listener, h.owner, h.ownerInventory, h.stored); + respawn(listener, h.owner); when(h.owner.isDead()).thenReturn(true); h.drain(); + verify(h.ownerInventory, never()).setItem(anyInt(), any()); + when(h.owner.isDead()).thenReturn(false); respawn(listener, h.owner); h.drain(); + verify(h.ownerInventory).setItem(4, h.cloned); + } + } + @Test void graveLocationTaskDoesNotReadOrMessageThePlayerUntilItsOwnerRuns() throws Exception { + try (var h = new GraveActionsTest.Harness(); var graves = mockConstruction(Grave.class)) { + var listener = new PlayerDeathListener(h.plugin); + capture(h, listener, h.owner, h.ownerInventory, h.stored); + clearInvocations(h.owner); + h.regionWork.remove().run(); + verify(h.owner, never()).getUniqueId(); verify(h.owner, never()).getName(); + verify(h.owner, never()).sendMessage(anyString()); + assertEquals(1, graves.constructed().size()); assertEquals(1, h.entityWork.size()); + h.drain(); verify(h.owner).sendMessage("Grave at 1"); + } + } +} diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/DurableFeaturePublicationTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/DurableFeaturePublicationTest.java new file mode 100644 index 0000000..08d8d5a --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/DurableFeaturePublicationTest.java @@ -0,0 +1,99 @@ +package com.bencodez.gravestonesplus.graves; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.nio.channels.FileChannel; +import java.nio.file.*; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** Real YAML/file force/atomic replacement, with only directory-handle capabilities injected. */ +class DurableFeaturePublicationTest { + @TempDir Path directory; + private static final String GRAVE = FeaturePersistenceTest.GRAVE; + private static final UUID RECIPIENT = FeaturePersistenceTest.HELPER; + + @Test void windowsUnsupportedDirectoryOpenAllowsStartupPublicationAndRestart() throws Exception { + Path file = directory.resolve("claims.yml"), archive = directory.resolve("archive"); + var opens = new AtomicInteger(); + DurableFeatureState.Disk disk = DurableFeatureState.yamlDisk(file, 8192, true, parent -> { + assertEquals("gsp-feature-io", Thread.currentThread().getName()); + assertEquals(directory.toAbsolutePath(), parent); + assertTrue(Files.isRegularFile(file)); // The actual successor was atomically published first. + opens.incrementAndGet(); throw new AccessDeniedException(parent.toString()); + }); + try (FeatureIO io = new FeatureIO()) { + var state = new DurableFeatureState(io, disk, 8192); + var ledger = new GraveClaimLedger(state, archive); + assertTrue(FeaturePersistenceTest.await(ledger.loaded())); assertTrue(state.isReady()); + assertEquals(GraveClaimLedger.Restoration.AVAILABLE, FeaturePersistenceTest.await(ledger.restoration(GRAVE))); + assertTrue(FeaturePersistenceTest.await(ledger.reserve(GRAVE, RECIPIENT))); + assertTrue(FeaturePersistenceTest.await(ledger.paid(GRAVE, RECIPIENT))); + assertTrue(ledger.isBlocked(GRAVE)); + } + try (FeatureIO io = new FeatureIO()) { + var ledger = new GraveClaimLedger(new DurableFeatureState(io, disk, 8192), archive); + assertTrue(FeaturePersistenceTest.await(ledger.loaded())); + assertEquals(GraveClaimLedger.Restoration.PAID, FeaturePersistenceTest.await(ledger.restoration(GRAVE))); + assertFalse(FeaturePersistenceTest.await(ledger.reserve(GRAVE, RECIPIENT))); + } + assertTrue(opens.get() >= 4); + try (var paths = Files.list(directory)) { assertFalse(paths.anyMatch(path -> path.toString().endsWith(".tmp"))); } + } + + @Test void posixAccessDeniedAtDirectoryOpenLeavesStartupUnavailable() throws Exception { + assertStartupFails(false, parent -> { throw new AccessDeniedException(parent.toString()); }); + } + + @Test void windowsGenericOpenErrorIsStillFatal() throws Exception { + assertStartupFails(true, parent -> { throw new IOException("disk I/O failure"); }); + } + + @Test void windowsAccessDeniedForAnotherPathIsStillFatal() throws Exception { + assertStartupFails(true, parent -> { throw new AccessDeniedException(parent.resolve("other").toString()); }); + } + + private void assertStartupFails(boolean windows, DurableFeatureState.DirectoryChannel channels) throws Exception { + Path file = directory.resolve("claims.yml"); + try (FeatureIO io = new FeatureIO()) { + var state = new DurableFeatureState(io, DurableFeatureState.yamlDisk(file, 8192, windows, channels), 8192); + var ledger = new GraveClaimLedger(state, directory.resolve("archive")); + assertFalse(FeaturePersistenceTest.await(ledger.loaded())); assertFalse(state.isReady()); + assertTrue(Files.exists(file)); assertEquals(Map.of(), state.snapshot()); + assertEquals(GraveClaimLedger.Restoration.UNAVAILABLE, FeaturePersistenceTest.await(ledger.restoration(GRAVE))); + assertFalse(FeaturePersistenceTest.await(ledger.reserve(GRAVE, RECIPIENT))); + assertTrue(ledger.isBlocked(GRAVE)); + } + } + + @Test void windowsForceAccessDeniedAfterReplacementCannotAcknowledgeOrAllowRetry() throws Exception { + Path file = directory.resolve("claims.yml"); var fail = new AtomicBoolean(); + FileChannel channel = mock(FileChannel.class); + doAnswer(call -> { if (fail.get()) throw new AccessDeniedException(directory.toString()); return null; }) + .when(channel).force(true); + var disk = DurableFeatureState.yamlDisk(file, 8192, true, parent -> channel); + try (FeatureIO io = new FeatureIO()) { + var state = new DurableFeatureState(io, disk, 8192); + var ledger = new GraveClaimLedger(state, directory.resolve("archive")); + assertTrue(FeaturePersistenceTest.await(ledger.loaded())); fail.set(true); + assertFalse(FeaturePersistenceTest.await(ledger.reserve(GRAVE, RECIPIENT))); + assertFalse(state.isReady()); assertEquals(Map.of(), state.snapshot()); assertTrue(ledger.isBlocked(GRAVE)); + assertFalse(FeaturePersistenceTest.await(ledger.reserve(GRAVE, RECIPIENT))); + assertEquals("RESERVED:" + RECIPIENT, disk.load().get(GRAVE)); // Replacement can have committed despite failure. + } + verify(channel, times(2)).force(true); verify(channel, times(2)).close(); + // A strict real-file read on restart must reconcile the successor, never replay payout. + try (FeatureIO io = new FeatureIO()) { + var ledger = new GraveClaimLedger(new DurableFeatureState(io, file, 8192), directory.resolve("archive")); + assertTrue(FeaturePersistenceTest.await(ledger.loaded())); + assertEquals(GraveClaimLedger.Restoration.RESERVED, FeaturePersistenceTest.await(ledger.restoration(GRAVE))); + assertFalse(FeaturePersistenceTest.await(ledger.reserve(GRAVE, RECIPIENT))); + } + } +} diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/FeaturePersistenceTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/FeaturePersistenceTest.java new file mode 100644 index 0000000..6ac3c54 --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/FeaturePersistenceTest.java @@ -0,0 +1,282 @@ +package com.bencodez.gravestonesplus.graves; + +import static org.junit.jupiter.api.Assertions.*; +import java.io.IOException; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class FeaturePersistenceTest { + @TempDir Path directory; + static final String GRAVE = UUID.randomUUID() + "/100/" + UUID.randomUUID() + "/1/2/3"; + static final UUID HELPER = UUID.randomUUID(); + static T await(CompletableFuture future) throws Exception { return future.get(3, TimeUnit.SECONDS); } + static final class Disk implements DurableFeatureState.Disk { + Map values = Map.of(); + volatile boolean fail; + volatile CountDownLatch entered, release; + String thread; + public Map load() { return values; } + public void save(Map next) throws Exception { + thread = Thread.currentThread().getName(); + if (entered != null) { entered.countDown(); assertTrue(release.await(2, TimeUnit.SECONDS)); } + if (fail) throw new IOException("injected save failure"); + values = Map.copyOf(next); + } + } + @Test void grantOnlyPublishesAfterWorkerAcknowledges() throws Exception { + try (FeatureIO io = new FeatureIO()) { + Disk disk = new Disk(); DurableFeatureState state = new DurableFeatureState(io, disk, 4096); + TrustedGraveAccessStore store = new TrustedGraveAccessStore(state, () -> 100L); assertTrue(await(store.loaded())); + disk.entered = new CountDownLatch(1); disk.release = new CountDownLatch(1); + var write = store.grantAsync(GRAVE, HELPER, TrustedGraveAccess.Kind.TRACK, 200); + assertTrue(disk.entered.await(2, TimeUnit.SECONDS)); + assertFalse(write.isDone()); assertFalse(store.allows(GRAVE, HELPER, TrustedGraveAccess.Kind.TRACK)); + disk.release.countDown(); assertTrue(await(write)); + assertTrue(store.allows(GRAVE, HELPER, TrustedGraveAccess.Kind.TRACK)); + assertEquals("gsp-feature-io", disk.thread); + } + } + @Test void failedGrantNeverAuthorizesAndRestartReconcilesOriginal() throws Exception { + Disk disk = new Disk(); + try (FeatureIO io = new FeatureIO()) { + DurableFeatureState state = new DurableFeatureState(io, disk, 4096); + var store = new TrustedGraveAccessStore(state, () -> 100L); await(store.loaded()); disk.fail = true; + assertFalse(await(store.grantAsync(GRAVE, HELPER, TrustedGraveAccess.Kind.RECOVER, 200))); + assertFalse(store.allows(GRAVE, HELPER, TrustedGraveAccess.Kind.RECOVER)); + } + disk.fail = false; + try (FeatureIO io = new FeatureIO()) { + var store = new TrustedGraveAccessStore(new DurableFeatureState(io, disk, 4096), () -> 100L); + await(store.loaded()); assertFalse(store.allows(GRAVE, HELPER, TrustedGraveAccess.Kind.RECOVER)); + } + } + @Test void revocationDeniesBeforeAcknowledgmentAndSurvivesRestart() throws Exception { + Disk disk = new Disk(); + try (FeatureIO io = new FeatureIO()) { + var store = new TrustedGraveAccessStore(new DurableFeatureState(io, disk, 4096), () -> 100L); await(store.loaded()); + await(store.grantAsync(GRAVE, HELPER, TrustedGraveAccess.Kind.RECOVER, 200)); + disk.entered = new CountDownLatch(1); disk.release = new CountDownLatch(1); + var revoke = store.revokeAsync(GRAVE, HELPER, TrustedGraveAccess.Kind.RECOVER); + assertFalse(store.allows(GRAVE, HELPER, TrustedGraveAccess.Kind.RECOVER)); + assertTrue(disk.entered.await(2, TimeUnit.SECONDS)); disk.release.countDown(); assertTrue(await(revoke)); + } + try (FeatureIO io = new FeatureIO()) { + var store = new TrustedGraveAccessStore(new DurableFeatureState(io, disk, 4096), () -> 100L); + await(store.loaded()); assertFalse(store.allows(GRAVE, HELPER, TrustedGraveAccess.Kind.RECOVER)); + } + } + @Test void failedRevocationDoesNotClaimDurabilityOrRestoreLocalAccess() throws Exception { + try (FeatureIO io = new FeatureIO()) { + Disk disk = new Disk(); var store = new TrustedGraveAccessStore(new DurableFeatureState(io, disk, 4096), () -> 100L); + await(store.loaded()); await(store.grantAsync(GRAVE, HELPER, TrustedGraveAccess.Kind.RECOVER, 200)); disk.fail = true; + assertFalse(await(store.revokeAsync(GRAVE, HELPER, TrustedGraveAccess.Kind.RECOVER))); + assertFalse(store.allows(GRAVE, HELPER, TrustedGraveAccess.Kind.RECOVER)); + assertFalse(disk.values.isEmpty()); // restart still needs reconciliation if revocation did not save + } + } + @Test void kindsIdentitiesExpiryAndLifetimesAreEnforcedByStore() throws Exception { + AtomicLong clock = new AtomicLong(100); + try (FeatureIO io = new FeatureIO()) { + var store = new TrustedGraveAccessStore(new DurableFeatureState(io, new Disk(), 4096), clock::get); await(store.loaded()); + assertFalse(await(store.grantAsync(GRAVE, HELPER, TrustedGraveAccess.Kind.TRACK, 0))); + assertFalse(await(store.grantAsync(GRAVE, HELPER, TrustedGraveAccess.Kind.TRACK, 101 + TrustedGraveAccessStore.MAX_LIFETIME))); + assertTrue(await(store.grantAsync(GRAVE, HELPER, TrustedGraveAccess.Kind.TRACK, 200))); + assertFalse(store.allows(GRAVE, HELPER, TrustedGraveAccess.Kind.RECOVER)); + assertFalse(store.allows(GRAVE + "0", HELPER, TrustedGraveAccess.Kind.TRACK)); + assertFalse(store.allows(GRAVE, UUID.randomUUID(), TrustedGraveAccess.Kind.TRACK)); + clock.set(200); assertFalse(store.allows(GRAVE, HELPER, TrustedGraveAccess.Kind.TRACK)); + } + } + + @Test void offlineUuidGrantRoundTripsAndKeepsKindAndGraveBoundaries() throws Exception { + Disk disk = new Disk(); + UUID offlineHelper = UUID.randomUUID(); + String otherGrave = GRAVE + "/other"; + try (FeatureIO io = new FeatureIO()) { + var store = new TrustedGraveAccessStore(new DurableFeatureState(io, disk, 4096), () -> 100L); + assertTrue(await(store.loaded())); + assertTrue(await(store.grantAsync(GRAVE, offlineHelper, TrustedGraveAccess.Kind.RECOVER, 200))); + assertTrue(store.allows(GRAVE, offlineHelper, TrustedGraveAccess.Kind.RECOVER)); + assertFalse(store.allows(GRAVE, offlineHelper, TrustedGraveAccess.Kind.TRACK)); + assertFalse(store.allows(otherGrave, offlineHelper, TrustedGraveAccess.Kind.RECOVER)); + } + try (FeatureIO io = new FeatureIO()) { + var reopened = new TrustedGraveAccessStore(new DurableFeatureState(io, disk, 4096), () -> 100L); + assertTrue(await(reopened.loaded())); + assertTrue(reopened.allows(GRAVE, offlineHelper, TrustedGraveAccess.Kind.RECOVER)); + assertFalse(reopened.allows(GRAVE, offlineHelper, TrustedGraveAccess.Kind.TRACK)); + assertFalse(reopened.allows(otherGrave, offlineHelper, TrustedGraveAccess.Kind.RECOVER)); + } + } + @Test void capacityAllowsReplacementAndRevocationAndPrunesExpired() throws Exception { + AtomicLong clock = new AtomicLong(100); + try (FeatureIO io = new FeatureIO()) { + var store = new TrustedGraveAccessStore(new DurableFeatureState(io, new Disk(), 1), clock::get); await(store.loaded()); + assertTrue(await(store.grantAsync(GRAVE, HELPER, TrustedGraveAccess.Kind.TRACK, 200))); + assertTrue(await(store.grantAsync(GRAVE, HELPER, TrustedGraveAccess.Kind.TRACK, 201))); + assertFalse(await(store.grantAsync(GRAVE, HELPER, TrustedGraveAccess.Kind.RECOVER, 201))); + assertTrue(await(store.revokeAsync(GRAVE, HELPER, TrustedGraveAccess.Kind.TRACK))); + assertTrue(await(store.grantAsync(GRAVE, HELPER, TrustedGraveAccess.Kind.RECOVER, 201))); + clock.set(202); assertTrue(await(store.grantAsync(GRAVE, HELPER, TrustedGraveAccess.Kind.TRACK, 300))); + } + } + @Test void boundedQueueRejectionAndShutdownCompleteExceptionally() throws Exception { + FeatureIO io = new FeatureIO(1); + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + var first = io.submit(() -> { entered.countDown(); try { release.await(2, TimeUnit.SECONDS); } catch (InterruptedException e) { throw new RuntimeException(e); } return true; }); + assertTrue(entered.await(2, TimeUnit.SECONDS)); var queued = io.submit(() -> true); + assertThrows(ExecutionException.class, () -> await(io.submit(() -> true))); + release.countDown(); assertTrue(await(first)); assertTrue(await(queued)); io.close(); + assertThrows(ExecutionException.class, () -> await(io.submit(() -> true))); + } + @Test void realAtomicFileRoundTripAndMalformedStateFailClosed() throws Exception { + Path file = directory.resolve("state.yml"); + try (FeatureIO io = new FeatureIO()) { + var state = new DurableFeatureState(io, file, 4); assertTrue(await(state.loaded())); + assertTrue(await(state.update(next -> { next.put(GRAVE, "value"); return true; }))); + } + try (FeatureIO io = new FeatureIO()) { + var state = new DurableFeatureState(io, file, 4); assertTrue(await(state.loaded())); assertEquals("value", state.snapshot().get(GRAVE)); + } + Files.writeString(file, "entries: [broken"); + try (FeatureIO io = new FeatureIO()) { + var state = new DurableFeatureState(io, file, 4); assertFalse(await(state.loaded())); + assertFalse(await(state.update(next -> { next.clear(); return true; }))); + } + } + @Test void concurrentOwnerHelperReserveHasOnlyOneWinnerAndRestartBlocksReplay() throws Exception { + Disk disk = new Disk(); + try (FeatureIO io = new FeatureIO()) { + var ledger = new GraveClaimLedger(new DurableFeatureState(io, disk, 8192)); await(ledger.loaded()); + var owner = ledger.reserve(GRAVE, UUID.randomUUID()); var helper = ledger.reserve(GRAVE, HELPER); + assertTrue(await(owner)); assertFalse(await(helper)); assertTrue(ledger.isBlocked(GRAVE)); + } + try (FeatureIO io = new FeatureIO()) { + var ledger = new GraveClaimLedger(new DurableFeatureState(io, disk, 8192)); await(ledger.loaded()); + assertTrue(ledger.isBlocked(GRAVE)); assertTrue(ledger.status(GRAVE).startsWith("RESERVED:")); + assertFalse(await(ledger.reserve(GRAVE, HELPER))); + } + } + @Test void cancelledClaimOnlyUnlocksAfterAcknowledgmentAndPaidClaimNeverReplays() throws Exception { + try (FeatureIO io = new FeatureIO()) { + Disk disk = new Disk(); var ledger = new GraveClaimLedger(new DurableFeatureState(io, disk, 8192)); await(ledger.loaded()); + assertTrue(await(ledger.reserve(GRAVE, HELPER))); + disk.entered = new CountDownLatch(1); disk.release = new CountDownLatch(1); + var cancel = ledger.cancel(GRAVE); assertTrue(disk.entered.await(2, TimeUnit.SECONDS)); assertTrue(ledger.isBlocked(GRAVE)); + disk.release.countDown(); assertTrue(await(cancel)); assertFalse(ledger.isBlocked(GRAVE)); + assertTrue(await(ledger.reserve(GRAVE, HELPER))); assertTrue(await(ledger.paid(GRAVE, HELPER))); + assertTrue(ledger.isBlocked(GRAVE)); assertEquals("PAID:" + HELPER, ledger.status(GRAVE)); + } + } + @Test void failedClaimReservationHasNoAcknowledgmentAndStaysBlocked() throws Exception { + try (FeatureIO io = new FeatureIO()) { + Disk disk = new Disk(); var ledger = new GraveClaimLedger(new DurableFeatureState(io, disk, 8192)); await(ledger.loaded()); disk.fail = true; + assertFalse(await(ledger.reserve(GRAVE, HELPER))); assertTrue(ledger.isBlocked(GRAVE)); assertTrue(disk.values.isEmpty()); + } + } + @Test void emptyPublishedStateAndLastRevocationRemainReadableAfterRestart() throws Exception { + Path file = directory.resolve("empty.yml"); + try (FeatureIO io = new FeatureIO()) { + var state = new DurableFeatureState(io, file, 4); await(state.loaded()); + assertTrue(await(state.update(next -> { next.put("one", "value"); return true; }))); + assertTrue(await(state.update(next -> { next.clear(); return true; }))); + } + try (FeatureIO io = new FeatureIO()) { + var state = new DurableFeatureState(io, file, 4); assertTrue(await(state.loaded())); assertTrue(state.isReady()); assertTrue(state.snapshot().isEmpty()); + } + // Also reconcile files produced by the initial versioned empty writer. + Files.writeString(file, "version: 1\n"); + try (FeatureIO io = new FeatureIO()) { assertTrue(await(new DurableFeatureState(io, file, 4).loaded())); } + } + @Test void olderGrantAcknowledgmentCannotOverrideNewerPendingRevocation() throws Exception { + var firstEntered = new CountDownLatch(1); var firstRelease = new CountDownLatch(1); + var secondEntered = new CountDownLatch(1); var secondRelease = new CountDownLatch(1); + AtomicInteger writes = new AtomicInteger(); + DurableFeatureState.Disk disk = new DurableFeatureState.Disk() { + public Map load() { return Map.of(); } + public void save(Map state) throws Exception { + if (writes.incrementAndGet() == 1) { firstEntered.countDown(); assertTrue(firstRelease.await(3, TimeUnit.SECONDS)); } + else { secondEntered.countDown(); assertTrue(secondRelease.await(3, TimeUnit.SECONDS)); } + } + }; + try (FeatureIO io = new FeatureIO()) { + var store = new TrustedGraveAccessStore(new DurableFeatureState(io, disk, 4096), () -> 100L); await(store.loaded()); + var grant = store.grantAsync(GRAVE, HELPER, TrustedGraveAccess.Kind.RECOVER, 200); + assertTrue(firstEntered.await(2, TimeUnit.SECONDS)); var revoke = store.revokeAsync(GRAVE, HELPER, TrustedGraveAccess.Kind.RECOVER); + firstRelease.countDown(); assertTrue(await(grant)); assertTrue(secondEntered.await(2, TimeUnit.SECONDS)); + assertFalse(store.allows(GRAVE, HELPER, TrustedGraveAccess.Kind.RECOVER)); + secondRelease.countDown(); assertTrue(await(revoke)); assertFalse(store.allows(GRAVE, HELPER, TrustedGraveAccess.Kind.RECOVER)); + } finally { firstRelease.countDown(); secondRelease.countDown(); } + } + @Test void shutdownWaitsForAcceptedWritesPastOldTwoSecondReturn() throws Exception { + FeatureIO io = new FeatureIO(1); var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + AtomicInteger published = new AtomicInteger(); + var busy = io.submit(() -> { entered.countDown(); try { release.await(5, TimeUnit.SECONDS); } catch (InterruptedException e) { throw new RuntimeException(e); } return true; }); + assertTrue(entered.await(2, TimeUnit.SECONDS)); var finalWrite = io.submit(published::incrementAndGet); + var closing = CompletableFuture.runAsync(io::close); + try { Thread.sleep(2200); assertFalse(closing.isDone()); assertEquals(0, published.get()); } + finally { release.countDown(); } + await(busy); await(finalWrite); await(closing); assertTrue(io.isTerminated()); assertEquals(1, published.get()); + } + + @Test void admissionGuaranteesQueueSpaceWhileCompletedWorkerIsStillDraining() throws Exception { + try (FeatureIO io = new FeatureIO(1)) { + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + io.onDrain(() -> { entered.countDown(); try { assertTrue(release.await(3, TimeUnit.SECONDS)); } + catch (InterruptedException e) { throw new RuntimeException(e); } }); + var first = io.submit(() -> true); assertTrue(entered.await(2, TimeUnit.SECONDS)); + var one = io.admit(); var two = io.admit(); assertNotNull(one); assertNotNull(two); assertNull(io.admit()); + var savedOne = one.submit(() -> true); var savedTwo = two.submit(() -> true); + assertFalse(savedTwo.isCompletedExceptionally()); release.countDown(); + assertTrue(await(first)); assertTrue(await(savedOne)); assertTrue(await(savedTwo)); + } + } + + @Test void grantAndRevocationAdmissionFollowTheirOperationOrderAcrossThreads() throws Exception { + DurableFeatureState state = org.mockito.Mockito.mock(DurableFeatureState.class); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); var revoking = new CountDownLatch(1); + var values = new java.util.concurrent.ConcurrentHashMap(); AtomicInteger calls = new AtomicInteger(); + org.mockito.Mockito.when(state.update(org.mockito.ArgumentMatchers.any())).thenAnswer(call -> { + if (calls.incrementAndGet() == 1) { entered.countDown(); assertTrue(release.await(3, TimeUnit.SECONDS)); } + java.util.function.Function,Boolean> mutation = call.getArgument(0); + mutation.apply(values); return CompletableFuture.completedFuture(true); + }); + org.mockito.Mockito.when(state.isReady()).thenReturn(true); org.mockito.Mockito.when(state.snapshot()).thenAnswer(call -> Map.copyOf(values)); + var store = new TrustedGraveAccessStore(state, () -> 100L); + var grant = CompletableFuture.supplyAsync(() -> store.grantAsync(GRAVE, HELPER, TrustedGraveAccess.Kind.RECOVER, 200)); + assertTrue(entered.await(2, TimeUnit.SECONDS)); + var revoke = CompletableFuture.supplyAsync(() -> { revoking.countDown(); return store.revokeAsync(GRAVE, HELPER, TrustedGraveAccess.Kind.RECOVER); }); + assertTrue(revoking.await(2, TimeUnit.SECONDS)); + try { assertThrows(TimeoutException.class, () -> revoke.get(100, TimeUnit.MILLISECONDS)); } + finally { release.countDown(); } + assertTrue(await(await(grant))); assertTrue(await(await(revoke))); assertTrue(values.isEmpty()); + assertFalse(store.allows(GRAVE, HELPER, TrustedGraveAccess.Kind.RECOVER)); + } + + @Test void shutdownHonorsUnusedPublicationTicketInsteadOfRejectingItsSubmission() throws Exception { + FeatureIO io = new FeatureIO(1); FeatureIO.Admission admitted = io.admit(); assertNotNull(admitted); + var entered = new CountDownLatch(1); + var closing = CompletableFuture.runAsync(() -> { entered.countDown(); io.close(); }); + assertTrue(entered.await(2, TimeUnit.SECONDS)); + // close cannot return or reject this ticket while the sender is using it. + assertThrows(TimeoutException.class, () -> closing.get(100, TimeUnit.MILLISECONDS)); + assertTrue(await(admitted.submit(() -> true))); await(closing); assertTrue(io.isTerminated()); + } + @Test void graveCleanupDeniesPendingAndExistingGrantsUntilDurableRemoval() throws Exception { + Disk disk = new Disk(); + try (FeatureIO io = new FeatureIO()) { + var store = new TrustedGraveAccessStore(new DurableFeatureState(io, disk, 4096), () -> 100L); await(store.loaded()); + assertTrue(await(store.grantAsync(GRAVE, HELPER, TrustedGraveAccess.Kind.TRACK, 200))); + disk.entered = new CountDownLatch(1); disk.release = new CountDownLatch(1); + var removal = store.revokeForGraveAsync(GRAVE); + assertTrue(disk.entered.await(2, TimeUnit.SECONDS)); assertFalse(store.allows(GRAVE, HELPER, TrustedGraveAccess.Kind.TRACK)); + disk.release.countDown(); assertTrue(await(removal)); assertFalse(store.allows(GRAVE, HELPER, TrustedGraveAccess.Kind.TRACK)); + assertTrue(await(store.grantAsync(GRAVE, HELPER, TrustedGraveAccess.Kind.TRACK, 200))); assertTrue(store.allows(GRAVE, HELPER, TrustedGraveAccess.Kind.TRACK)); + } + } + +} diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java new file mode 100644 index 0000000..40d4c46 --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java @@ -0,0 +1,883 @@ +package com.bencodez.gravestonesplus.graves; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.lang.reflect.Field; +import java.util.*; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.logging.Logger; +import com.bencodez.gravestonesplus.GraveStonesPlus; +import com.bencodez.gravestonesplus.config.Config; +import com.bencodez.gravestonesplus.events.*; +import com.bencodez.gravestonesplus.storage.GravesConfig; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; +import com.bencodez.simpleapi.time.ParsedDuration; +import org.bukkit.*; +import org.bukkit.entity.*; +import org.bukkit.inventory.*; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.PluginManager; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +class GraveActionsTest { + static void field(Object target, String name, Object value) throws Exception { + Field field = GraveStonesPlus.class.getDeclaredField(name); field.setAccessible(true); field.set(target, value); + } + static void retryOnOwner(Harness h, GraveRestoration restoration) { + h.bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + try { restoration.retry(); Runnable task; while ((task = h.ownerWork.poll()) != null) task.run(); } + finally { h.bukkit.when(Bukkit::isPrimaryThread).thenReturn(false); } + } + static final class Harness implements AutoCloseable { + final FeatureIO io = new FeatureIO(); + final FeaturePersistenceTest.Disk accessDisk = new FeaturePersistenceTest.Disk(), claimDisk = new FeaturePersistenceTest.Disk(); + final TrustedGraveAccessStore access = new TrustedGraveAccessStore(new DurableFeatureState(io, accessDisk, 4096), System::currentTimeMillis); + final GraveClaimLedger ledger = new GraveClaimLedger(new DurableFeatureState(io, claimDisk, 8192)); + final GraveStonesPlus plugin = mock(GraveStonesPlus.class); + final Config config = mock(Config.class); + final BukkitScheduler scheduler = mock(BukkitScheduler.class); + final com.bencodez.simpleapi.folialib.FoliaLib folia = mock(com.bencodez.simpleapi.folialib.FoliaLib.class); + final com.bencodez.simpleapi.folialib.impl.ServerImplementation platform = mock(com.bencodez.simpleapi.folialib.impl.ServerImplementation.class); + final World world = mock(World.class); + final Player owner = mock(Player.class), helper = mock(Player.class); + final PlayerInventory ownerInventory = mock(PlayerInventory.class), helperInventory = mock(PlayerInventory.class); + final PluginManager events = mock(PluginManager.class); + final Queue entityWork = new ConcurrentLinkedQueue<>(), regionWork = new ConcurrentLinkedQueue<>(), ownerWork = new ConcurrentLinkedQueue<>(); + final List active = Collections.synchronizedList(new ArrayList<>()); + final GraveNavigation navigation = new GraveNavigation(plugin); + final MockedStatic bukkit = mockStatic(Bukkit.class); + final Grave grave; + final ItemStack stored = mock(ItemStack.class), cloned = mock(ItemStack.class); + Harness() throws Exception { + when(plugin.getName()).thenReturn("GraveStonesPlus"); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger()); + when(plugin.isEnabled()).thenReturn(true); when(plugin.getConfigFile()).thenReturn(config); + when(scheduler.getFoliaLib()).thenReturn(folia); when(folia.getImpl()).thenReturn(platform); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); when(plugin.getTrustedGraveAccessStore()).thenReturn(access); + when(plugin.getHologramHandler()).thenReturn(new com.bencodez.advancedcore.api.hologram.HologramHandler(plugin)); + when(plugin.getGraveClaimLedger()).thenReturn(ledger); when(plugin.getGraveNavigation()).thenReturn(navigation); + when(plugin.activeGraveSnapshot()).thenCallRealMethod(); when(plugin.isLiveGrave(any())).thenCallRealMethod(); + when(plugin.canRecover(any(), any())).thenCallRealMethod(); when(plugin.canTrack(any(), any())).thenCallRealMethod(); + field(plugin, "graves", active); field(plugin, "trustedGraveAccessStore", access); field(plugin, "graveClaimLedger", ledger); + when(world.getUID()).thenReturn(UUID.randomUUID()); when(world.getName()).thenReturn("overworld"); + for (Player player : List.of(owner, helper)) { + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); when(player.isOnline()).thenReturn(true); + when(player.getWorld()).thenReturn(world); when(player.getLocation()).thenReturn(new Location(world, 1, 64, 1)); + } + when(owner.getInventory()).thenReturn(ownerInventory); when(helper.getInventory()).thenReturn(helperInventory); + when(ownerInventory.getContents()).thenReturn(new ItemStack[41]); when(helperInventory.getContents()).thenReturn(new ItemStack[41]); + when(plugin.getGraves(any(Player.class))).thenCallRealMethod(); when(plugin.getAccessibleGraves(any())).thenCallRealMethod(); + when(config.getPercentageDrops()).thenReturn(100); when(config.getFormatGraveBroke()).thenReturn("Paid"); + when(config.getFormatGraveBrokeOther()).thenReturn("Recovered %player%"); when(config.getFormatItemsNotInGrave()).thenReturn("Overflow"); + when(config.getBreakOtherGravesTimeBeforeBreakable()).thenReturn(ParsedDuration.parse("1m", java.util.concurrent.TimeUnit.MINUTES)); + Material material = mock(Material.class); when(material.isAir()).thenReturn(false); + when(stored.getType()).thenReturn(material); when(cloned.getType()).thenReturn(material); when(stored.clone()).thenReturn(cloned); when(cloned.clone()).thenReturn(cloned); + when(cloned.serialize()).thenReturn(Map.of("type", "DIAMOND", "amount", 2)); + HashMap items = new HashMap<>(); items.put(4, stored); + grave = new Grave(plugin, new GravesConfig(owner.getUniqueId(), "Owner", new Location(world, 1, 64, 1), items, 17, + "test", System.currentTimeMillis(), false, 0, null, null, "PLAYER_HEAD")); active.add(grave); + bukkit.when(Bukkit::getPluginManager).thenReturn(events); bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of()); + doAnswer(call -> { assertTrue(call.getArgument(2) instanceof Player || call.getArgument(2) instanceof ArmorStand); entityWork.add(call.getArgument(1)); return null; }) + .when(scheduler).runTask(eq(plugin), any(Runnable.class), any(Entity.class)); + doAnswer(call -> { regionWork.add(call.getArgument(1)); return null; }) + .when(scheduler).runTask(eq(plugin), any(Runnable.class), any(Location.class)); + doAnswer(call -> { active.remove(call.getArgument(0)); return null; }).when(plugin).removeGrave(any()); + doAnswer(call -> { + java.util.function.Consumer work = call.getArgument(1); + var future = new java.util.concurrent.CompletableFuture(); + entityWork.add(() -> { work.accept(null); future.complete(com.bencodez.simpleapi.folialib.enums.EntityTaskResult.SUCCESS); }); return future; + }).when(platform).runAtEntityWithFallback(any(Entity.class), any(), any(Runnable.class)); + doAnswer(call -> { + java.util.function.Consumer work = call.getArgument(1); + var future = new java.util.concurrent.CompletableFuture(); + regionWork.add(() -> { work.accept(null); future.complete(null); }); return future; + }).when(platform).runAtLocation(any(Location.class), any()); + doAnswer(call -> { ownerWork.add(call.getArgument(1)); return null; }) + .when(scheduler).runTask(eq(plugin), any(Runnable.class)); + FeaturePersistenceTest.await(access.loaded()); FeaturePersistenceTest.await(ledger.loaded()); + } + String identity() { return GraveIdentity.of(grave.getGravesConfig()); } + void drain() throws Exception { + for (int i = 0; i < 4; i++) { + Runnable task; + while ((task = ownerWork.poll()) != null) { + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + try { task.run(); } + finally { bukkit.when(Bukkit::isPrimaryThread).thenReturn(false); } + } + while ((task = entityWork.poll()) != null) task.run(); + FeaturePersistenceTest.await(io.barrier()); + } + } + void grant(TrustedGraveAccess.Kind kind) throws Exception { assertTrue(FeaturePersistenceTest.await(access.grantAsync(identity(), helper.getUniqueId(), kind, System.currentTimeMillis() + 60_000))); } + public void close() { bukkit.close(); io.close(); } + } + @Test void trackingNeverRecoversAndRecoveryNeverTracksWithoutSeparateGrant() throws Exception { + try (Harness h = new Harness()) { + h.grant(TrustedGraveAccess.Kind.TRACK); + assertTrue(h.plugin.canTrack(h.grave, h.helper)); assertFalse(h.plugin.canRecover(h.grave, h.helper)); + h.grave.claim(h.helper); h.drain(); verify(h.helper, never()).giveExp(anyInt()); + FeaturePersistenceTest.await(h.access.revokeAsync(h.identity(), h.helper.getUniqueId(), TrustedGraveAccess.Kind.TRACK)); + h.grant(TrustedGraveAccess.Kind.RECOVER); + assertTrue(h.plugin.canRecover(h.grave, h.helper)); assertFalse(h.plugin.canTrack(h.grave, h.helper)); + h.grave.claim(h.helper); h.drain(); verify(h.helper).giveExp(17); verify(h.helperInventory).setItem(4, h.cloned); + } + } + + @Test void ownerAndHelperAuthorizationUsesStableUuidAcrossDisplayNameChanges() throws Exception { + try (Harness h = new Harness()) { + when(h.owner.getName()).thenReturn("OriginalOwner"); + assertTrue(h.plugin.canTrack(h.grave, h.owner)); + assertTrue(h.plugin.canRecover(h.grave, h.owner)); + + h.grant(TrustedGraveAccess.Kind.RECOVER); + when(h.helper.getName()).thenReturn("RenamedHelper"); + assertTrue(h.plugin.canRecover(h.grave, h.helper)); + assertFalse(h.plugin.canTrack(h.grave, h.helper)); + + when(h.owner.getName()).thenReturn("RenamedOwner"); + assertTrue(h.plugin.canTrack(h.grave, h.owner)); + assertTrue(h.plugin.canRecover(h.grave, h.owner)); + } + } + @Test void ownerAndHelperConcurrentClaimsPayOnlyWinnerOnce() throws Exception { + try (Harness h = new Harness()) { + h.grant(TrustedGraveAccess.Kind.RECOVER); + h.grave.claim(h.owner); h.grave.claim(h.helper); h.grave.claim(h.owner); h.drain(); + verify(h.owner, times(1)).giveExp(17); verify(h.helper, never()).giveExp(anyInt()); + verify(h.ownerInventory, times(1)).setItem(4, h.cloned); assertTrue(h.ledger.isBlocked(h.identity())); + assertTrue(h.grave.getGravesConfig().isDestroyed()); + verify(h.scheduler, never()).runTask(any(Plugin.class), any(Runnable.class)); + } + } + @Test void failedAsyncReserveDoesNotDeliverItemsXpOrRemoveGrave() throws Exception { + try (Harness h = new Harness()) { + h.claimDisk.fail = true; h.grave.claim(h.owner); h.drain(); + verify(h.owner, never()).giveExp(anyInt()); verify(h.ownerInventory, never()).setItem(anyInt(), any()); + verify(h.plugin, never()).removeGrave(any()); assertFalse(h.grave.getGravesConfig().isDestroyed()); + } + } + @Test void revocationDuringReserveIsCheckedAtFinalPayoutBoundary() throws Exception { + try (Harness h = new Harness()) { + h.grant(TrustedGraveAccess.Kind.RECOVER); h.grave.claim(h.helper); + h.entityWork.remove().run(); FeaturePersistenceTest.await(h.io.barrier()); + FeaturePersistenceTest.await(h.access.revokeAsync(h.identity(), h.helper.getUniqueId(), TrustedGraveAccess.Kind.RECOVER)); + h.drain(); verify(h.helper, never()).giveExp(anyInt()); assertFalse(h.ledger.isBlocked(h.identity())); + } + } + @Test void claimAndRemovalCancellationOccurBeforePayout() throws Exception { + try (Harness h = new Harness()) { + doAnswer(call -> { if (call.getArgument(0) instanceof GraveRemoveEvent e) e.setCancelled(true); return null; }).when(h.events).callEvent(any()); + h.grave.claim(h.owner); h.drain(); verify(h.owner, never()).giveExp(anyInt()); + assertFalse(h.ledger.isBlocked(h.identity())); assertFalse(h.grave.getGravesConfig().isDestroyed()); + } + } + @Test void claimCallbacksChangeContentsAndExperienceBeforeDurableEvidence() throws Exception { + try (Harness h = new Harness()) { + h.grant(TrustedGraveAccess.Kind.RECOVER); + ItemStack replacement = mock(ItemStack.class); Material material = mock(Material.class); + when(material.isAir()).thenReturn(false); when(replacement.getType()).thenReturn(material); + when(replacement.clone()).thenReturn(replacement); + when(replacement.serialize()).thenReturn(Map.of("type", "GOLD_INGOT", "amount", 3)); + doAnswer(call -> { if (call.getArgument(0) instanceof GraveClaimEvent) { + h.grave.getGravesConfig().getItems().clear(); h.grave.getGravesConfig().getItems().put(7, replacement); + try { Field exp = GravesConfig.class.getDeclaredField("exp"); exp.setAccessible(true); exp.setInt(h.grave.getGravesConfig(), 31); } catch (ReflectiveOperationException failure) { throw new AssertionError(failure); } + } return null; }).when(h.events).callEvent(any()); + h.grave.claim(h.owner); h.entityWork.remove().run(); FeaturePersistenceTest.await(h.io.barrier()); + String status = h.ledger.status(h.identity()); assertTrue(status.startsWith("RESERVED:")); + var evidence = new org.bukkit.configuration.file.YamlConfiguration(); + evidence.loadFromString(new String(Base64.getDecoder().decode(status.split(":", 3)[2]), java.nio.charset.StandardCharsets.UTF_8)); + assertEquals(31, evidence.getInt("experience")); + assertEquals(Set.of("7"), evidence.getConfigurationSection("items").getKeys(false)); + assertEquals("GOLD_INGOT", evidence.getString("items.7.type")); assertEquals(3, evidence.getInt("items.7.amount")); + h.drain(); verify(h.ownerInventory).setItem(7, replacement); + verify(h.ownerInventory, never()).setItem(eq(4), any()); verify(h.owner).giveExp(31); + assertTrue(h.ledger.status(h.identity()).startsWith("PAID:")); + assertTrue(h.ledger.isBlocked(h.identity())); + } + } + @Test void callbackRevocationAndDisconnectDoNotPayoutOrMarkSuccess() throws Exception { + try (Harness h = new Harness()) { + h.grant(TrustedGraveAccess.Kind.RECOVER); + doAnswer(call -> { if (call.getArgument(0) instanceof GraveClaimEvent) h.access.revokeAsync(h.identity(), h.helper.getUniqueId(), TrustedGraveAccess.Kind.RECOVER); return null; }).when(h.events).callEvent(any()); + h.grave.claim(h.helper); h.drain(); verify(h.helper, never()).giveExp(anyInt()); + h.grave.claim(h.owner); h.entityWork.remove().run(); FeaturePersistenceTest.await(h.io.barrier()); + when(h.owner.isOnline()).thenReturn(false); h.drain(); verify(h.owner, never()).giveExp(anyInt()); + } + } + @Test void overflowIsDroppedAtRecipientAndXpIsFullWithLegacyChance() throws Exception { + try (Harness h = new Harness()) { + ItemStack occupied = mock(ItemStack.class); Material type = h.stored.getType(); when(occupied.getType()).thenReturn(type); + when(h.ownerInventory.getItem(4)).thenReturn(occupied); when(h.ownerInventory.addItem(h.cloned)).thenReturn(new HashMap<>(Map.of(0, h.cloned))); + h.grave.claim(h.owner); h.drain(); verify(h.world).dropItem(h.owner.getLocation(), h.cloned); verify(h.owner).giveExp(17); + assertEquals(Map.of(4, h.cloned), GravePayout.select(Map.of(4, h.stored), 100, () -> { fail("100% consumes RNG"); return 0; })); + assertTrue(GravePayout.select(Map.of(4, h.stored), 0, () -> 0).isEmpty()); + } + } + @Test void selectionUsesStableIdentityAndRevocationOrRemovalInvalidatesWithoutChunks() throws Exception { + try (Harness h = new Harness()) { + h.grant(TrustedGraveAccess.Kind.TRACK); assertTrue(h.navigation.select(h.helper, h.grave)); assertSame(h.grave, h.navigation.target(h.helper)); + FeaturePersistenceTest.await(h.access.revokeAsync(h.identity(), h.helper.getUniqueId(), TrustedGraveAccess.Kind.TRACK)); + assertNull(h.navigation.target(h.helper)); + assertTrue(h.navigation.select(h.owner, h.grave)); h.grave.getGravesConfig().setDestroyed(true); assertNull(h.navigation.target(h.owner)); + verify(h.world, never()).getBlockAt(anyInt(), anyInt(), anyInt()); verify(h.world, never()).getChunkAt(anyInt(), anyInt()); + } + } + @Test void navigationShowsWorldDistanceAndVerticalSeparation() throws Exception { + try (Harness h = new Harness()) { + when(h.owner.getLocation()).thenReturn(new Location(h.world, 4, 60, 5)); + assertEquals("overworld, 5 blocks, above", h.grave.getNavigationMessage(h.owner)); + when(h.owner.getLocation()).thenReturn(new Location(h.world, 1, 70, 1)); assertTrue(h.grave.getNavigationMessage(h.owner).endsWith("below")); + World other = mock(World.class); when(other.getUID()).thenReturn(UUID.randomUUID()); when(h.owner.getWorld()).thenReturn(other); when(h.owner.getLocation()).thenReturn(new Location(other, 1, 70, 1)); + assertEquals("world overworld (different world)", h.grave.getNavigationMessage(h.owner)); + verify(h.world, never()).getBlockAt(anyInt(), anyInt(), anyInt()); + } + } + @Test void distanceAndLoreUseOnePlayerLocationSnapshotAcrossWorldChange() throws Exception { + try (var h = new Harness()) { + var other = mock(World.class); when(other.getUID()).thenReturn(UUID.randomUUID()); + // A split getWorld/getLocation read would compare old world to a new-world location and throw. + when(h.owner.getWorld()).thenReturn(h.world); + when(h.owner.getLocation()).thenReturn(new Location(other, 1, 64, 1)); clearInvocations(h.owner); + double distance = assertDoesNotThrow(() -> h.grave.getDistance(h.owner)); assertEquals(-1D, distance); + verify(h.owner, times(1)).getLocation(); verify(h.owner, never()).getWorld(); clearInvocations(h.owner); + assertEquals("world overworld (different world)", h.grave.getNavigationMessage(h.owner)); + verify(h.owner, times(1)).getLocation(); verify(h.owner, never()).getWorld(); + } + } + + @Test void legacyBreakOtherConfigPermissionAndProtectionRemainIndependentOfTrust() throws Exception { + try (Harness h = new Harness()) { + assertFalse(h.grave.legacyClaimAllowed(h.helper)); when(h.config.isBreakOtherGravesEnabled()).thenReturn(true); + assertFalse(h.grave.legacyClaimAllowed(h.helper)); + when(h.config.getBreakOtherGravesTimeBeforeBreakable()).thenReturn(ParsedDuration.parse("0", java.util.concurrent.TimeUnit.MINUTES)); + assertTrue(h.grave.legacyClaimAllowed(h.helper)); when(h.config.isBreakOtherGravesRequirePermission()).thenReturn(true); + assertFalse(h.grave.legacyClaimAllowed(h.helper)); when(h.helper.hasPermission("GraveStonesPlus.BreakOtherGraves")).thenReturn(true); + assertTrue(h.grave.legacyClaimAllowed(h.helper)); assertFalse(h.plugin.canTrack(h.grave, h.helper)); + h.grave.claim(h.helper); h.drain(); verify(h.helper).giveExp(17); + } + } + @Test void directContentViewRequiresRecoveryOrHistoricalStaffAuthority() throws Exception { + try (Harness h = new Harness()) { + h.grant(TrustedGraveAccess.Kind.TRACK); assertFalse(h.grave.canViewContents(h.helper)); + h.grave.openGUIWithItems(h.helper); h.drain(); verify(h.helper, never()).openInventory(any(org.bukkit.inventory.Inventory.class)); + when(h.helper.hasPermission("GraveStonesPlus.AllBrokenGraves")).thenReturn(true); + assertFalse(h.grave.canAdminister(h.helper)); assertFalse(h.grave.canViewContents(h.helper)); + h.grave.getGravesConfig().setDestroyed(true); assertTrue(h.grave.canViewContents(h.helper)); + h.grave.getGravesConfig().setDestroyed(false); assertFalse(h.grave.canViewContents(h.helper)); + when(h.helper.hasPermission("GraveStonesPlus.AllGraves")).thenReturn(true); + assertTrue(h.grave.canAdminister(h.helper)); assertTrue(h.grave.canViewContents(h.helper)); + } + } + @Test void legacyGraveShapePreservesOwnerExperienceLocationAndExactSlotMap() throws Exception { + try (Harness h = new Harness()) { + h.bukkit.when(() -> Bukkit.getWorld(h.world.getUID())).thenReturn(h.world); + HashMap oldItems = new HashMap<>(Map.of(-1, h.stored, 0, h.stored)); + Map old = new HashMap<>(); + old.put("UUID", h.owner.getUniqueId().toString()); old.put("World", h.world.getUID().toString()); + old.put("PlayerName", "Old owner name"); old.put("X", 1); old.put("Y", 64); old.put("Z", 1); + old.put("Time", 123L); old.put("EXP", 42); old.put("Items", oldItems); + GravesConfig restored = GravesConfig.deserialize(old); + assertEquals(h.owner.getUniqueId(), restored.getUuid()); assertEquals(42, restored.getExp()); + assertEquals(123L, restored.getTime()); assertEquals(oldItems, restored.getItems()); + assertEquals("PLAYER_HEAD", restored.getGraveDisplayType()); assertFalse(restored.isDestroyed()); + } + } + @Test void recoveryEvidencePreservesDistinctArmorSlotsAndXpAcrossRestart() throws Exception { + try (Harness h = new Harness()) { + HashMap items = new HashMap<>(Map.of(-1, h.cloned, 0, h.cloned)); + GravesConfig record = new GravesConfig(h.owner.getUniqueId(), "Owner", h.owner.getLocation(), items, 42, "death", 123, false, 0, null, null, null); + String payload = GraveClaimLedger.evidence(record); + assertTrue(FeaturePersistenceTest.await(h.ledger.reserve(GraveIdentity.of(record), h.owner.getUniqueId(), payload))); + var restarted = new GraveClaimLedger(new DurableFeatureState(h.io, h.claimDisk, 8192)); FeaturePersistenceTest.await(restarted.loaded()); + String saved = restarted.status(GraveIdentity.of(record)); assertTrue(saved.endsWith(payload)); + String yaml = new String(Base64.getDecoder().decode(payload), java.nio.charset.StandardCharsets.UTF_8); + org.bukkit.configuration.file.YamlConfiguration evidence = new org.bukkit.configuration.file.YamlConfiguration(); evidence.loadFromString(yaml); + assertEquals(42, evidence.getInt("experience")); assertEquals(Set.of("-1", "0"), evidence.getConfigurationSection("items").getKeys(false)); + } + } + @Test void actualScheduledDeadlineChangesAndDisableInvalidateEligibility() throws Exception { + try (Harness h = new Harness()) { + java.util.concurrent.ScheduledExecutorService timer = java.util.concurrent.Executors.newSingleThreadScheduledExecutor(); + when(h.plugin.getTimer()).thenReturn(timer); + try { + h.grave.checkTimeLimit("2"); assertEquals(h.grave.getGravesConfig().getTime() + 120_000, h.grave.getExpiryDeadline()); + h.grave.checkTimeLimit("3m"); assertEquals(h.grave.getGravesConfig().getTime() + 180_000, h.grave.getExpiryDeadline()); + h.grave.checkTimeLimit("0"); assertEquals(0, h.grave.getExpiryDeadline()); + h.grave.checkTimeLimit("invalid"); assertEquals(0, h.grave.getExpiryDeadline()); + } finally { timer.shutdownNow(); } + } + } + + @Test void invalidExpiryReplacementRetainsTheActualScheduledTaskAndDeadline() throws Exception { + try (Harness h = new Harness()) { + var timer = java.util.concurrent.Executors.newSingleThreadScheduledExecutor(); + when(h.plugin.getTimer()).thenReturn(timer); + try { + h.grave.checkTimeLimit("1h"); long deadline = h.grave.getExpiryDeadline(); + Field taskField = Grave.class.getDeclaredField("expirationTask"); taskField.setAccessible(true); + var original = (java.util.concurrent.ScheduledFuture) taskField.get(h.grave); + assertNotNull(original); + for (String invalid : List.of("tomorrow", "999999999999999999999999", "9223372036854775807m", "9223372036854775807ms")) { + h.grave.checkTimeLimit(invalid); + assertEquals(deadline, h.grave.getExpiryDeadline()); + assertSame(original, taskField.get(h.grave)); assertFalse(original.isCancelled()); + } + h.grave.checkTimeLimit("2h"); assertTrue(original.isCancelled()); + assertEquals(h.grave.getGravesConfig().getTime() + 7_200_000, h.grave.getExpiryDeadline()); + var replacement = (java.util.concurrent.ScheduledFuture) taskField.get(h.grave); + h.grave.checkTimeLimit(""); assertTrue(replacement.isCancelled()); + assertEquals(0, h.grave.getExpiryDeadline()); + } finally { timer.shutdownNow(); } + } + } + + @Test void loginAndPeriodicRemindersSendOneUsefulSummaryOnEntityOwner() throws Exception { + try (Harness h = new Harness()) { + var state = new DurableFeatureState(h.io, new FeaturePersistenceTest.Disk(), 8192); + var reminders = new GraveReminderStore(state); FeaturePersistenceTest.await(reminders.loaded()); + when(h.plugin.getGraveReminderStore()).thenReturn(reminders); + when(h.config.isBreakOtherGravesEnabled()).thenReturn(true); + when(h.config.getProtectionReminderMillis()).thenReturn(60_000L); when(h.config.getExpiryReminderMillis()).thenReturn(60_000L); + java.util.concurrent.ScheduledExecutorService timer = java.util.concurrent.Executors.newSingleThreadScheduledExecutor(); when(h.plugin.getTimer()).thenReturn(timer); + var listener = new com.bencodez.gravestonesplus.listeners.GraveReminderListener(h.plugin); + try { + h.grave.checkTimeLimit("1m"); listener.start(); + verify(h.platform).runTimer(any(Runnable.class), eq(20L), eq(100L)); + listener.onJoin(new org.bukkit.event.player.PlayerJoinEvent(h.owner, "")); h.drain(); + verify(h.owner, times(1)).sendMessage(argThat((String text) -> text.contains("protection ends") && text.contains("expires"))); + listener.checkPlayer(h.owner); h.drain(); assertEquals(2, state.snapshot().size()); + verify(h.owner, times(1)).sendMessage(any(String.class)); + verify(h.scheduler, never()).runTask(any(Plugin.class), any(Runnable.class)); + } finally { listener.stop(); timer.shutdownNow(); } + } + } + @Test void offlineJoinDoesNotMarkDeadlinesAndNextOnlineCheckCanSend() throws Exception { + try (Harness h = new Harness()) { + var state = new DurableFeatureState(h.io, new FeaturePersistenceTest.Disk(), 8192); + var reminders = new GraveReminderStore(state); FeaturePersistenceTest.await(reminders.loaded()); + when(h.plugin.getGraveReminderStore()).thenReturn(reminders); + when(h.config.isBreakOtherGravesEnabled()).thenReturn(true); when(h.config.getProtectionReminderMillis()).thenReturn(60_000L); + var listener = new com.bencodez.gravestonesplus.listeners.GraveReminderListener(h.plugin); listener.start(); + try { + when(h.owner.isOnline()).thenReturn(false); listener.onJoin(new org.bukkit.event.player.PlayerJoinEvent(h.owner, "")); h.drain(); + assertTrue(state.snapshot().isEmpty()); verify(h.owner, never()).sendMessage(any(String.class)); + when(h.owner.isOnline()).thenReturn(true); listener.onJoin(new org.bukkit.event.player.PlayerJoinEvent(h.owner, "")); h.drain(); + assertEquals(1, state.snapshot().size()); verify(h.owner, times(1)).sendMessage(any(String.class)); + } finally { listener.stop(); } + } + } + + @Test void cancelledExpirationRestoresOwnerActionsAndRespectsRescheduledDeadline() throws Exception { + try (Harness h = new Harness()) { + when(h.plugin.getTimer()).thenReturn(mock(java.util.concurrent.ScheduledExecutorService.class)); + doAnswer(call -> { if (call.getArgument(0) instanceof GraveRemoveEvent e) e.setCancelled(true); return null; }).when(h.events).callEvent(any()); + h.grave.checkTimeLimit(1); // public API: elapsed deadlines can be set by an integration + java.lang.reflect.Field deadline = Grave.class.getDeclaredField("expiryDeadline"); deadline.setAccessible(true); deadline.setLong(h.grave, System.currentTimeMillis() - 1); + assertFalse(h.plugin.isLiveGrave(h.grave)); + h.grave.removeGrave(GraveRemoveReason.EXPIRED); h.regionWork.remove().run(); + assertEquals(0, h.grave.getExpiryDeadline()); assertTrue(h.plugin.isLiveGrave(h.grave)); assertTrue(h.plugin.canTrack(h.grave, h.owner)); + long replacement = System.currentTimeMillis() + 60_000; + doAnswer(call -> { if (call.getArgument(0) instanceof GraveRemoveEvent e) { deadline.setLong(h.grave, replacement); e.setCancelled(true); } return null; }).when(h.events).callEvent(any()); + deadline.setLong(h.grave, System.currentTimeMillis() - 1); h.grave.removeGrave(GraveRemoveReason.EXPIRED); h.regionWork.remove().run(); + assertEquals(replacement, h.grave.getExpiryDeadline()); assertTrue(h.plugin.isLiveGrave(h.grave)); + } + } + @Test void saturatedMysqlAdmissionRetainsLatestDeathSnapshotForDrainAndShutdown() throws Exception { + try (Harness h = new Harness()) { + FeatureIO small = new FeatureIO(1); when(h.plugin.getFeatureIO()).thenReturn(small); + when(h.plugin.brokenGraveSnapshot()).thenReturn(List.of()); + var backend = mock(com.bencodez.gravestonesplus.storage.GraveStorageBackend.class); + var manager = new com.bencodez.gravestonesplus.storage.GraveStorageManager(h.plugin); + java.lang.reflect.Field backendField = manager.getClass().getDeclaredField("backend"); backendField.setAccessible(true); backendField.set(manager, backend); + java.lang.reflect.Field typeField = manager.getClass().getDeclaredField("type"); typeField.setAccessible(true); typeField.set(manager, com.bencodez.gravestonesplus.storage.DataStorageType.MYSQL); + var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1); + var busy = small.submit(() -> { entered.countDown(); try { release.await(3, java.util.concurrent.TimeUnit.SECONDS); } catch (InterruptedException e) { throw new RuntimeException(e); } return true; }); + assertTrue(entered.await(2, java.util.concurrent.TimeUnit.SECONDS)); var queued = small.submit(() -> true); + manager.upsertGrave(h.grave.getGravesConfig(), false); verify(backend, never()).upsertGrave(any(), anyBoolean()); + release.countDown(); FeaturePersistenceTest.await(busy); FeaturePersistenceTest.await(queued); small.close(); + org.mockito.ArgumentCaptor written = org.mockito.ArgumentCaptor.forClass(GravesConfig.class); + verify(backend).upsertGrave(written.capture(), eq(false)); assertEquals(h.grave.getGravesConfig().getUuid(), written.getValue().getUuid()); + assertEquals(17, written.getValue().getExp()); assertEquals(Set.of(4), written.getValue().getItems().keySet()); + } + } + + @Test void retiredRecipientCancelsUnpaidReservationForBothRetirementPaths() throws Exception { + for (boolean immediatelyRetired : List.of(true, false)) try (Harness h = new Harness()) { + doAnswer(call -> { + Runnable retired = call.getArgument(2); + if (!immediatelyRetired) retired.run(); + return java.util.concurrent.CompletableFuture.completedFuture(immediatelyRetired + ? com.bencodez.simpleapi.folialib.enums.EntityTaskResult.SCHEDULER_RETIRED + : com.bencodez.simpleapi.folialib.enums.EntityTaskResult.ENTITY_RETIRED); + }).when(h.platform).runAtEntityWithFallback(any(Entity.class), any(), any(Runnable.class)); + h.grave.claim(h.owner); h.drain(); + verify(h.owner, never()).giveExp(anyInt()); assertFalse(h.ledger.isBlocked(h.identity())); + doAnswer(call -> { java.util.function.Consumer work = call.getArgument(1); + h.entityWork.add(() -> work.accept(null)); return new java.util.concurrent.CompletableFuture<>(); + }).when(h.platform).runAtEntityWithFallback(any(Entity.class), any(), any(Runnable.class)); + h.grave.claim(h.owner); h.drain(); verify(h.owner).giveExp(17); + } + } + @Test void expirationOverlappingReservationRetriesAfterCancelledPayout() throws Exception { + try (Harness h = new Harness()) { + // Static Bukkit mocks are thread-local. This cancellation continuation + // runs on the storage worker, which must report that it is not an owner. + var workerBukkit = FeaturePersistenceTest.await(h.io.submit(() -> { + var context = mockStatic(Bukkit.class); + context.when(Bukkit::isPrimaryThread).thenReturn(false); + return context; + })); + try { + h.grave.claim(h.owner); h.entityWork.remove().run(); FeaturePersistenceTest.await(h.io.barrier()); + Field deadline = Grave.class.getDeclaredField("expiryDeadline"); deadline.setAccessible(true); + deadline.setLong(h.grave, System.currentTimeMillis() - 1); + h.grave.removeGrave(GraveRemoveReason.EXPIRED); h.regionWork.remove().run(); + assertFalse(h.grave.getGravesConfig().isDestroyed()); h.drain(); + assertFalse(h.ledger.isBlocked(h.identity())); verify(h.owner, never()).giveExp(anyInt()); + h.regionWork.remove().run(); assertTrue(h.grave.getGravesConfig().isDestroyed()); + } finally { FeaturePersistenceTest.await(h.io.submit(() -> { workerBukkit.close(); return null; })); } + } + } + + @Test void publicRemovalOnBukkitOwnerPublishesStateAndEventBeforeReturning() throws Exception { + try (Harness h = new Harness()) { + groundWorld(h); h.bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + h.grave.removeGrave(GraveRemoveReason.MANUAL_REMOVE); + assertTrue(h.grave.isRemove()); assertTrue(h.grave.getGravesConfig().isDestroyed()); + assertTrue(h.active.isEmpty()); verify(h.events).callEvent(any(GraveRemoveEvent.class)); + verify(h.plugin).removeGrave(h.grave); + // Preserve the native API's deferred marker cleanup independently of state publication. + assertEquals(1, h.regionWork.size()); + verify(h.world.getBlockAt(h.grave.getGravesConfig().getLocation()), never()).setType(any()); + h.regionWork.remove().run(); + verify(h.world.getBlockAt(h.grave.getGravesConfig().getLocation())).setType(Material.AIR); + } + } + @Test void publicRemovalOnBukkitOwnerHonorsCancellationBeforeReturning() throws Exception { + try (Harness h = new Harness()) { + h.bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + doAnswer(call -> { ((GraveRemoveEvent) call.getArgument(0)).setCancelled(true); return null; }) + .when(h.events).callEvent(any(GraveRemoveEvent.class)); + h.grave.removeGrave(GraveRemoveReason.MANUAL_REMOVE); + assertFalse(h.grave.isRemove()); assertFalse(h.grave.getGravesConfig().isDestroyed()); + assertTrue(h.regionWork.isEmpty()); verify(h.plugin, never()).removeGrave(any()); + } + } + @org.junit.jupiter.params.ParameterizedTest @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void publicRemovalOnFoliaAlwaysHandsOffToTheMarkerRegion(boolean primary) throws Exception { + try (Harness h = new Harness()) { + h.bukkit.when(Bukkit::isPrimaryThread).thenReturn(primary); when(h.folia.isFolia()).thenReturn(true); + h.grave.removeGrave(GraveRemoveReason.MANUAL_REMOVE); + assertFalse(h.grave.isRemove()); assertEquals(1, h.regionWork.size()); + verify(h.events, never()).callEvent(any()); verify(h.plugin, never()).removeGrave(any()); + h.regionWork.remove().run(); + assertTrue(h.grave.isRemove()); verify(h.events).callEvent(any(GraveRemoveEvent.class)); + verify(h.plugin).removeGrave(h.grave); + } + } + + @Test void ownerGrantRejectsRemovedGraveAndRevokedPermissionAtAdmission() throws Exception { + try (Harness h = new Harness()) { + assertFalse(FeaturePersistenceTest.await(h.grave.grantHelper(h.owner, h.helper.getUniqueId(), TrustedGraveAccess.Kind.RECOVER, System.currentTimeMillis() + 60_000))); + when(h.owner.hasPermission("GraveStonesPlus.Trust")).thenReturn(true); + assertTrue(FeaturePersistenceTest.await(h.grave.grantHelper(h.owner, h.helper.getUniqueId(), TrustedGraveAccess.Kind.RECOVER, System.currentTimeMillis() + 60_000))); + assertFalse(FeaturePersistenceTest.await(h.grave.revokeHelper(h.owner, h.helper.getUniqueId(), TrustedGraveAccess.Kind.RECOVER))); + when(h.owner.hasPermission("GraveStonesPlus.Trust.Revoke")).thenReturn(true); + assertTrue(FeaturePersistenceTest.await(h.grave.revokeHelper(h.owner, h.helper.getUniqueId(), TrustedGraveAccess.Kind.RECOVER))); + h.grave.removeGrave(GraveRemoveReason.MANUAL_REMOVE); h.regionWork.remove().run(); FeaturePersistenceTest.await(h.io.barrier()); + assertFalse(FeaturePersistenceTest.await(h.grave.grantHelper(h.owner, h.helper.getUniqueId(), TrustedGraveAccess.Kind.RECOVER, System.currentTimeMillis() + 60_000))); + assertFalse(h.access.allows(h.identity(), h.helper.getUniqueId(), TrustedGraveAccess.Kind.RECOVER)); + } + } + @Test void distantPlayerRemovalRunsTheEventAndMutationOnlyOnTheGraveOwner() throws Exception { + try (Harness h = new Harness()) { + var region = new java.util.concurrent.atomic.AtomicBoolean(); + doAnswer(call -> { + if (call.getArgument(0) instanceof GraveRemoveEvent) + assertTrue(region.get(), "Grave listeners must run on the marker region"); + return null; + }).when(h.events).callEvent(any()); + h.grave.removeByPlayer(h.owner, GraveRemoveReason.MANUAL_REMOVE); + h.entityWork.remove().run(); + verify(h.events, never()).callEvent(any()); + assertFalse(h.grave.isRemove()); assertEquals(1, h.regionWork.size()); + // Player APIs are forbidden on the marker's distant region. + when(h.owner.isOnline()).thenAnswer(call -> { + assertFalse(region.get(), "Player online checks belong to the actor owner"); return true; + }); + when(h.owner.getUniqueId()).thenAnswer(call -> { + assertFalse(region.get(), "Player identity access belongs to the actor owner"); + return h.grave.getGravesConfig().getUuid(); + }); + region.set(true); + try { h.regionWork.remove().run(); } + finally { region.set(false); } + assertTrue(h.grave.isRemove()); verify(h.owner, never()).sendMessage(any(String.class)); + h.drain(); verify(h.owner).sendMessage(any(String.class)); + verify(h.plugin).removeGrave(h.grave); + } + } + + @Test void retiredOrReplacedGraveCannotBeRemovedByAnAdmittedPlayerCallback() throws Exception { + for (boolean disabled : List.of(false, true)) try (Harness h = new Harness()) { + h.grave.removeByPlayer(h.owner, GraveRemoveReason.MANUAL_REMOVE); + h.entityWork.remove().run(); + if (disabled) when(h.plugin.isEnabled()).thenReturn(false); + else h.active.clear(); + h.drain(); + verify(h.events, never()).callEvent(any()); + verify(h.owner, never()).sendMessage(any(String.class)); + assertFalse(h.grave.isRemove()); + } + } + + @Test void playerRemovalRechecksPermissionAndCancellationBeforeAdvertisingSuccess() throws Exception { + try (Harness h = new Harness()) { + when(h.helper.hasPermission("GraveStonesPlus.Admin")).thenReturn(true); + h.grave.removeByPlayer(h.helper, GraveRemoveReason.MANUAL_REMOVE); + when(h.helper.hasPermission("GraveStonesPlus.Admin")).thenReturn(false); h.drain(); + assertFalse(h.grave.getGravesConfig().isDestroyed()); verify(h.helper, never()).sendMessage(any(String.class)); + doAnswer(call -> { if (call.getArgument(0) instanceof GraveRemoveEvent event) event.setCancelled(true); return null; }).when(h.events).callEvent(any()); + h.grave.removeByPlayer(h.owner, GraveRemoveReason.MANUAL_REMOVE); h.drain(); + assertFalse(h.grave.getGravesConfig().isDestroyed()); verify(h.owner, never()).sendMessage(any(String.class)); + } + } + + @Test void expirationRechecksExtendedOrDisabledDeadlineBeforeAndAfterEvent() throws Exception { + for (boolean duringEvent : List.of(false, true)) for (long replacement : List.of(0L, System.currentTimeMillis() + 60_000)) try (Harness h = new Harness()) { + Field deadline = Grave.class.getDeclaredField("expiryDeadline"); deadline.setAccessible(true); + deadline.setLong(h.grave, System.currentTimeMillis() - 1); + h.grave.removeGrave(GraveRemoveReason.EXPIRED); + if (duringEvent) doAnswer(call -> { if (call.getArgument(0) instanceof GraveRemoveEvent) deadline.setLong(h.grave, replacement); return null; }).when(h.events).callEvent(any()); + else deadline.setLong(h.grave, replacement); + h.regionWork.remove().run(); assertFalse(h.grave.getGravesConfig().isDestroyed()); assertTrue(h.plugin.isLiveGrave(h.grave)); + verify(h.plugin, never()).removeGrave(any()); + } + } + @Test void sameProcessRecreationOfExpiredUnclaimedGraveRestoresAccessAndPayout() throws Exception { + try (Harness h = new Harness()) { + Field deadline = Grave.class.getDeclaredField("expiryDeadline"); deadline.setAccessible(true); deadline.setLong(h.grave, System.currentTimeMillis() - 1); + h.grave.removeGrave(GraveRemoveReason.EXPIRED); h.regionWork.remove().run(); FeaturePersistenceTest.await(h.io.barrier()); + assertTrue(h.grave.getGravesConfig().isDestroyed()); assertFalse(h.plugin.isLiveGrave(h.grave)); + var storage = mock(com.bencodez.gravestonesplus.storage.GraveStorageManager.class); + when(storage.saveSnapshotFromObjects(any(), any())).thenReturn(java.util.concurrent.CompletableFuture.completedFuture(true)); + when(storage.saveRecreatedGrave(any())).thenReturn(java.util.concurrent.CompletableFuture.completedFuture(true)); + field(h.plugin, "storageManager", storage); field(h.plugin, "brokenGraves", Collections.synchronizedList(new ArrayList<>(List.of(h.grave)))); + doCallRealMethod().when(h.plugin).recreateBrokenGrave(any()); + doCallRealMethod().when(h.plugin).recreateBrokenGrave(any(), any()); + doCallRealMethod().when(h.plugin).recreateBrokenGrave(any(), any(), anyBoolean()); + h.plugin.recreateBrokenGrave(h.grave); h.drain(); h.regionWork.remove().run(); + assertEquals(0, h.grave.getExpiryDeadline()); assertTrue(h.plugin.canTrack(h.grave, h.owner)); + h.grave.claim(h.owner); h.drain(); verify(h.owner).giveExp(17); + } + } + + @Test void legacyCreateBrokenGraveUsesAuthoritativeWrapperAndCreatesMarker() throws Exception { + try (Harness h = new Harness()) { + Field deadline = Grave.class.getDeclaredField("expiryDeadline"); deadline.setAccessible(true); + deadline.setLong(h.grave, System.currentTimeMillis() - 1); + h.grave.removeGrave(GraveRemoveReason.EXPIRED); h.regionWork.remove().run(); h.drain(); + assertTrue(h.grave.getGravesConfig().isDestroyed()); assertTrue(h.active.isEmpty()); + var storage = mock(com.bencodez.gravestonesplus.storage.GraveStorageManager.class); + field(h.plugin, "storageManager", storage); + var broken = Collections.synchronizedList(new ArrayList<>(List.of(h.grave))); + field(h.plugin, "brokenGraves", broken); + doCallRealMethod().when(h.plugin).recreateBrokenGrave(any(), any(), anyBoolean()); + + h.grave.createBrokenGrave(); h.drain(); + assertEquals(1, h.regionWork.size()); + h.regionWork.remove().run(); h.drain(); + + assertFalse(h.grave.getGravesConfig().isDestroyed()); + assertEquals(List.of(h.grave), h.active); assertTrue(broken.isEmpty()); + assertEquals(0, h.grave.getExpiryDeadline()); + verify(storage).saveSnapshotFromObjects(h.active, broken); + } + } + + @Test void recreationAcknowledgesPublicationInsteadOfAdmission() throws Exception { + for (boolean mysql : List.of(false, true)) for (boolean cancel : List.of(false, true)) try (Harness h = new Harness()) { + Field deadline = Grave.class.getDeclaredField("expiryDeadline"); deadline.setAccessible(true); + deadline.setLong(h.grave, System.currentTimeMillis() - 1); + h.grave.removeGrave(GraveRemoveReason.EXPIRED); h.regionWork.remove().run(); h.drain(); + var publication = new java.util.concurrent.CompletableFuture(); + var storage = mock(com.bencodez.gravestonesplus.storage.GraveStorageManager.class); + when(storage.isMySQL()).thenReturn(mysql); + when(storage.saveSnapshotFromObjects(any(), any())).thenReturn(publication); + when(storage.saveRecreatedGrave(any())).thenReturn(publication); + var broken = Collections.synchronizedList(new ArrayList<>(List.of(h.grave))); + field(h.plugin, "storageManager", storage); field(h.plugin, "brokenGraves", broken); + doCallRealMethod().when(h.plugin).recreateBrokenGrave(any(), any()); + doCallRealMethod().when(h.plugin).recreateBrokenGrave(any(), any(), anyBoolean()); + + var observer = h.plugin.recreateBrokenGrave(h.grave, null); h.drain(); + h.regionWork.remove().run(); + assertEquals(List.of(h.grave), h.active); assertTrue(broken.isEmpty()); + if (mysql) verify(storage).saveRecreatedGrave(h.grave.getGravesConfig()); + else verify(storage).saveSnapshotFromObjects(h.active, broken); + assertFalse(observer.isDone()); + if (cancel) assertTrue(observer.cancel(true)); + assertFalse(publication.isCancelled()); + publication.complete(true); + if (!cancel) assertTrue(FeaturePersistenceTest.await(observer)); + assertFalse(h.grave.getGravesConfig().isDestroyed()); + verify(h.owner, never()).giveExp(anyInt()); + } + } + + @Test void cancellingRecreationObserverCannotSkipCommittedListAndBackendTransfer() throws Exception { + assertRecreationObserverIsolation(observer -> assertTrue(observer.cancel(true)), true); + } + + @Test void externallyCompletingRecreationObserverCannotSkipCommittedListAndBackendTransfer() throws Exception { + for (boolean suppliedResult : List.of(false, true)) + assertRecreationObserverIsolation(observer -> assertTrue(observer.complete(suppliedResult)), true); + } + + @Test void exceptionallyCompletingRecreationObserverCannotSkipCommittedListAndBackendTransfer() throws Exception { + assertRecreationObserverIsolation(observer -> + assertTrue(observer.completeExceptionally(new IllegalStateException("caller completion"))), true); + } + + @Test void cancellingCompletedRecreationObserverCannotUndoListAndBackendTransfer() throws Exception { + assertRecreationObserverIsolation(observer -> assertFalse(observer.cancel(true)), false); + } + + private void assertRecreationObserverIsolation( + java.util.function.Consumer> interfere, + boolean beforeCommit) throws Exception { + for (boolean mysql : List.of(false, true)) try (Harness h = new Harness()) { + Field deadline = Grave.class.getDeclaredField("expiryDeadline"); deadline.setAccessible(true); + deadline.setLong(h.grave, System.currentTimeMillis() - 1); + h.grave.removeGrave(GraveRemoveReason.EXPIRED); h.regionWork.remove().run(); h.drain(); + assertTrue(h.grave.getGravesConfig().isDestroyed()); assertTrue(h.active.isEmpty()); + var storage = mock(com.bencodez.gravestonesplus.storage.GraveStorageManager.class); + when(storage.isMySQL()).thenReturn(mysql); + when(storage.saveSnapshotFromObjects(any(), any())).thenReturn(java.util.concurrent.CompletableFuture.completedFuture(true)); + when(storage.saveRecreatedGrave(any())).thenReturn(java.util.concurrent.CompletableFuture.completedFuture(true)); + List broken = Collections.synchronizedList(new ArrayList<>(List.of(h.grave))); + field(h.plugin, "storageManager", storage); field(h.plugin, "brokenGraves", broken); + doCallRealMethod().when(h.plugin).recreateBrokenGrave(any(), any()); + doCallRealMethod().when(h.plugin).recreateBrokenGrave(any(), any(), anyBoolean()); + + var observer = h.plugin.recreateBrokenGrave(h.grave, null); h.drain(); + assertFalse(observer.isDone()); assertEquals(1, h.regionWork.size()); + assertTrue(h.grave.getGravesConfig().isDestroyed()); assertTrue(h.active.isEmpty()); + assertEquals(List.of(h.grave), broken); verifyNoInteractions(storage); + if (beforeCommit) interfere.accept(observer); + h.regionWork.remove().run(); + if (!beforeCommit) { + assertTrue(FeaturePersistenceTest.await(observer)); + interfere.accept(observer); + } + + assertFalse(h.grave.getGravesConfig().isDestroyed()); assertEquals(0, h.grave.getExpiryDeadline()); + assertEquals(List.of(h.grave), h.active); assertTrue(broken.isEmpty()); + assertTrue(h.plugin.canTrack(h.grave, h.owner)); + if (mysql) { + verify(storage).saveRecreatedGrave(h.grave.getGravesConfig()); + verify(storage, never()).saveGravesFromObjects(any()); + verify(storage, never()).saveBrokenGravesFromObjects(any()); + } else { + verify(storage).saveSnapshotFromObjects(h.active, broken); + verify(storage, never()).setBroken(any(), anyBoolean()); + } + verify(h.owner, never()).giveExp(anyInt()); verify(h.helper, never()).giveExp(anyInt()); + } + } + + @Test void nonOwnerSuccessMessagePreservesConfiguredBreakingOtherSwitch() throws Exception { + for (boolean enabled : List.of(false, true)) try (Harness h = new Harness()) { + h.grant(TrustedGraveAccess.Kind.RECOVER); when(h.config.isBreakOtherGravesSendMessage()).thenReturn(enabled); + h.grave.claim(h.helper); h.drain(); verify(h.helper).giveExp(17); + verify(h.helper, times(enabled ? 1 : 0)).sendMessage("Recovered Owner"); + } + } + + @Test void legacyGroundDropOnlyDropsCapturedItemsWithoutChangingGraveOrExperience() throws Exception { + try (Harness h = new Harness()) { + groundWorld(h); + h.grave.dropItemsOnGround(h.owner); + assertEquals(1, h.regionWork.size()); + // Change the mutable source after scheduling: the accepted drop owns its snapshot. + h.grave.getGravesConfig().getItems().clear(); + h.regionWork.remove().run(); + verify(h.world).dropItem(eq(h.grave.getGravesConfig().getLocation()), same(h.cloned)); + verify(h.world, never()).spawn(any(Location.class), eq(ExperienceOrb.class)); + verify(h.owner, never()).giveExp(anyInt()); verify(h.plugin, never()).removeGrave(any()); + verify(h.events, never()).callEvent(any()); + assertTrue(h.plugin.isLiveGrave(h.grave)); assertFalse(h.grave.isLimitPayoutPending()); + assertFalse(h.grave.getGravesConfig().isDestroyed()); assertEquals(17, h.grave.getGravesConfig().getExp()); + assertNull(h.ledger.status(h.identity())); assertTrue(h.claimDisk.values.isEmpty()); + } + } + @Test void legacyGroundDropWorksWithoutRecoveryInfrastructureAndCanBeUsedOnAHistoricalRecord() throws Exception { + try (Harness h = new Harness()) { + groundWorld(h); h.grave.getGravesConfig().setDestroyed(true); + when(h.plugin.getGraveClaimLedger()).thenReturn(null); when(h.plugin.getMarkerCleanup()).thenReturn(null); + h.grave.dropItemsOnGround(null); h.regionWork.remove().run(); + verify(h.world).dropItem(any(Location.class), same(h.cloned)); + verify(h.world, never()).spawn(any(Location.class), eq(ExperienceOrb.class)); + verify(h.plugin, never()).removeGrave(any()); assertEquals(1, h.grave.getGravesConfig().getItems().size()); + } + } + + static ExperienceOrb groundWorld(Harness h) { + when(h.world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true); + org.bukkit.block.Block marker = mock(org.bukkit.block.Block.class); + var markerChunk = mock(org.bukkit.Chunk.class); + when(marker.getChunk()).thenReturn(markerChunk); + when(markerChunk.getPersistentDataContainer()).thenReturn(mock(org.bukkit.persistence.PersistentDataContainer.class)); + var material = new java.util.concurrent.atomic.AtomicReference<>(Material.PLAYER_HEAD); + when(marker.getType()).thenAnswer(call -> material.get()); + doAnswer(call -> { material.set(call.getArgument(0)); return null; }).when(marker).setType(any(Material.class)); + Grave markerOwner = h.active.isEmpty() ? h.grave : h.active.getFirst(); + Map> metadata = new HashMap<>(); + metadata.put("Grave", List.of(new org.bukkit.metadata.FixedMetadataValue(h.plugin, markerOwner))); + when(marker.getMetadata(anyString())).thenAnswer(call -> metadata.getOrDefault(call.getArgument(0), List.of())); + doAnswer(call -> { metadata.put(call.getArgument(0), List.of(call.getArgument(1, org.bukkit.metadata.MetadataValue.class))); return null; }) + .when(marker).setMetadata(anyString(), any(org.bukkit.metadata.MetadataValue.class)); + doAnswer(call -> { + String key = call.getArgument(0); Plugin owner = call.getArgument(1); + metadata.computeIfPresent(key, (ignored, values) -> values.stream().filter(value -> value.getOwningPlugin() != owner).toList()); + return null; + }).when(marker).removeMetadata(anyString(), any(Plugin.class)); + when(marker.getWorld()).thenReturn(h.world); + when(marker.getLocation()).thenAnswer(call -> markerOwner.getGravesConfig().getLocation().clone()); + when(h.world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(marker); + // Location.getBlock uses this overload on the bundled Spigot API. + when(h.world.getBlockAt(any(Location.class))).thenReturn(marker); + ExperienceOrb orb = mock(ExperienceOrb.class); when(h.world.spawn(any(Location.class), eq(ExperienceOrb.class))).thenReturn(orb); + return orb; + } + static GraveRestoration limitRestoration(Harness h) { + var backend = mock(com.bencodez.gravestonesplus.storage.GraveStorageManager.class); + when(backend.loadGraves()).thenReturn(List.of()); when(backend.loadBrokenGraves()).thenReturn(List.of()); + when(h.plugin.getFeatureIO()).thenReturn(h.io); + var restoration = new GraveRestoration(h.plugin, backend); when(h.plugin.getGraveRestoration()).thenReturn(restoration); + return restoration; + } + static void drainGround(Harness h) throws Exception { + for (int i = 0; i < 6; i++) { + h.drain(); Runnable task; int bound = 0; + while ((task = h.regionWork.poll()) != null) { assertTrue(++bound <= 32); task.run(); } + } + h.drain(); + } + @Test void queuedLimitGroundPayoutAndOwnerHelperClaimsHaveOneWinnerAndFullXp() throws Exception { + try (Harness h = new Harness()) { + limitRestoration(h); + ExperienceOrb orb = groundWorld(h); h.grant(TrustedGraveAccess.Kind.RECOVER); + h.grave.dropAndRemoveForLimit(h.owner); h.grave.claim(h.helper); h.grave.claim(h.owner); h.grave.dropAndRemoveForLimit(h.owner); + h.drain(); verify(h.world, never()).dropItem(any(), any()); + drainGround(h); + verify(h.world, times(1)).dropItem(h.grave.getGravesConfig().getLocation(), h.cloned); verify(orb).setExperience(17); + verify(h.owner, never()).giveExp(anyInt()); verify(h.helper, never()).giveExp(anyInt()); + assertEquals(Material.AIR, h.world.getBlockAt(h.grave.getGravesConfig().getLocation()).getType()); + assertTrue(h.world.getBlockAt(h.grave.getGravesConfig().getLocation()).getMetadata("Grave").isEmpty()); + assertTrue(h.grave.getGravesConfig().isDestroyed()); assertTrue(h.ledger.status(h.identity()).startsWith("PAID:")); + h.grave.dropAndRemoveForLimit(h.owner); h.grave.claim(h.helper); h.drain(); verify(h.world, times(1)).dropItem(any(), any()); + } + } + @Test void failedLimitReservationLeavesGraveContentsAndWorldUntouched() throws Exception { + try (Harness h = new Harness()) { + limitRestoration(h); + h.claimDisk.fail = true; h.grave.dropAndRemoveForLimit(h.owner); h.drain(); + assertTrue(h.regionWork.isEmpty()); verify(h.world, never()).dropItem(any(), any()); verify(h.world, never()).spawn(any(Location.class), eq(ExperienceOrb.class)); + verify(h.plugin, never()).removeGrave(any()); assertFalse(h.grave.getGravesConfig().isDestroyed()); + } + } + @Test void cancelledGroundPayoutUnlocksButUnloadedPayoutRetainsDurableRetryWithoutLoadingChunks() throws Exception { + for (int mode : List.of(0, 1, 2)) try (Harness h = new Harness()) { + limitRestoration(h); + if (mode != 1) { groundWorld(h); doAnswer(call -> { + if (call.getArgument(0) instanceof GraveRemoveEvent event) { + if (mode == 0) event.setCancelled(true); else when(h.world.isChunkLoaded(anyInt(), anyInt())).thenReturn(false); + } return null; + }).when(h.events).callEvent(any()); } + h.grave.dropAndRemoveForLimit(h.owner); h.drain(); drainGround(h); + assertEquals(mode != 0, h.ledger.isBlocked(h.identity())); + if (mode != 0) assertTrue(h.ledger.status(h.identity()).startsWith("QUEUED_LIMIT:")); + assertFalse(h.grave.getGravesConfig().isDestroyed()); + verify(h.world, never()).dropItem(any(), any()); verify(h.world, never()).getChunkAt(anyInt(), anyInt()); + } + } + @Test void partialGroundPayoutRetainsEvidenceAndBlocksHelperReplayAfterRestart() throws Exception { + try (Harness h = new Harness()) { + limitRestoration(h); + groundWorld(h); when(h.world.spawn(any(Location.class), eq(ExperienceOrb.class))).thenThrow(new IllegalStateException("spawn failed")); + h.grant(TrustedGraveAccess.Kind.RECOVER); h.grave.dropAndRemoveForLimit(h.owner); h.drain(); drainGround(h); + assertTrue(h.ledger.status(h.identity()).startsWith("RESERVED:")); assertFalse(h.grave.getGravesConfig().isDestroyed()); + h.grave.claim(h.helper); h.grave.dropAndRemoveForLimit(h.owner); h.drain(); verify(h.world, times(1)).dropItem(any(), any()); verify(h.helper, never()).giveExp(anyInt()); + var restart = new GraveClaimLedger(new DurableFeatureState(h.io, h.claimDisk, 8192)); FeaturePersistenceTest.await(restart.loaded()); assertTrue(restart.isBlocked(h.identity())); + } + } + + @Test void unloadedLimitPayoutRetriesOnChunkLoadAndDoesNotSelectTheSameOldestAgain() throws Exception { + try (Harness h = new Harness()) { + ExperienceOrb orb = groundWorld(h); when(h.world.isChunkLoaded(anyInt(), anyInt())).thenReturn(false); + var restoration = limitRestoration(h); + when(h.plugin.getOldestGrave(any())).thenCallRealMethod(); + h.grave.dropAndRemoveForLimit(h.owner); drainGround(h); + assertTrue(h.grave.isLimitPayoutPending()); assertNull(h.plugin.getOldestGrave(h.owner.getUniqueId())); + assertTrue(h.ledger.status(h.identity()).startsWith("QUEUED_LIMIT:")); verify(h.world, never()).dropItem(any(), any()); + h.grave.dropAndRemoveForLimit(h.owner); h.grave.claim(h.owner); drainGround(h); + when(h.world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true); + restoration.onChunk(h.world.getUID(), 0, 0); drainGround(h); + verify(h.world, times(1)).dropItem(any(), eq(h.cloned)); verify(orb).setExperience(17); + assertTrue(h.grave.isRemove()); assertTrue(h.ledger.status(h.identity()).startsWith("PAID:")); + restoration.onChunk(h.world.getUID(), 0, 0); drainGround(h); verify(h.world, times(1)).dropItem(any(), any()); + verify(h.world, never()).getChunkAt(anyInt(), anyInt()); restoration.retire(); + } + } + @Test void queuedLimitStateSurvivesRestartAndCannotBecomeAReplayableReservation() throws Exception { + try (Harness h = new Harness()) { + var restoration = limitRestoration(h); h.grave.dropAndRemoveForLimit(h.owner); drainGround(h); restoration.retire(); + var restartedLedger = new GraveClaimLedger(new DurableFeatureState(h.io, h.claimDisk, 8192)); + assertTrue(FeaturePersistenceTest.await(restartedLedger.loaded())); + assertEquals(GraveClaimLedger.Restoration.QUEUED_LIMIT, FeaturePersistenceTest.await(restartedLedger.restoration(h.identity()))); + when(h.plugin.getGraveClaimLedger()).thenReturn(restartedLedger); + h.active.clear(); var backend = mock(com.bencodez.gravestonesplus.storage.GraveStorageManager.class); + when(backend.loadGraves()).thenReturn(List.of(h.grave.getGravesConfig())); when(backend.loadBrokenGraves()).thenReturn(List.of()); + doAnswer(i -> { h.active.add(i.getArgument(0)); return null; }).when(h.plugin).restoreActiveRecord(any()); + var restarted = new GraveRestoration(h.plugin, backend); when(h.plugin.getGraveRestoration()).thenReturn(restarted); + retryOnOwner(h, restarted); drainGround(h); assertEquals(1, h.active.size()); assertTrue(h.active.getFirst().isLimitPayoutPending()); + assertTrue(restarted.hasPending(h.identity())); verify(h.world, never()).dropItem(any(), any()); + ExperienceOrb orb = groundWorld(h); restarted.onChunk(h.world.getUID(), 0, 0); drainGround(h); + verify(h.world, times(1)).dropItem(any(), eq(h.cloned)); verify(orb).setExperience(17); + assertEquals(GraveClaimLedger.Restoration.PAID, FeaturePersistenceTest.await(restartedLedger.restoration(h.identity()))); + assertFalse(FeaturePersistenceTest.await(restartedLedger.reserveQueuedLimit(h.identity(), h.owner.getUniqueId()))); + assertFalse(FeaturePersistenceTest.await(restartedLedger.deferLimitReservation(h.identity(), h.owner.getUniqueId()))); + restarted.retire(); + } + } + + @Test void manualRemovalCancelsOnlyUnstartedLimitPayout() throws Exception { + try (Harness h = new Harness()) { + var restoration = limitRestoration(h); h.grave.dropAndRemoveForLimit(h.owner); drainGround(h); + assertTrue(h.grave.isLimitPayoutPending()); + h.grave.removeGrave(GraveRemoveReason.MANUAL_REMOVE); drainGround(h); + assertTrue(h.grave.isRemove()); assertFalse(h.grave.isLimitPayoutPending()); + assertNull(h.ledger.status(h.identity())); verify(h.world, never()).dropItem(any(), any()); + restoration.retire(); + } + } + + @Test void nearbyRecoveryRechecksMenuAndCommandConditionsAfterReservation() throws Exception { + for (String change : List.of("distance", "world", "permission")) try (Harness h = new Harness()) { + when(h.owner.hasPermission("GraveStonesPlus.ClaimGrave")).thenReturn(true); + when(h.config.getGraveClaimDistance()).thenReturn(10); + h.grave.claimNearby(h.owner); h.entityWork.remove().run(); FeaturePersistenceTest.await(h.io.barrier()); + assertFalse(h.entityWork.isEmpty()); assertTrue(h.ledger.isBlocked(h.identity())); + if (change.equals("distance")) when(h.owner.getLocation()).thenReturn(new Location(h.world, 50, 64, 1)); + if (change.equals("world")) { World other=mock(World.class);when(other.getUID()).thenReturn(UUID.randomUUID());when(h.owner.getWorld()).thenReturn(other); when(h.owner.getLocation()).thenReturn(new Location(other, 1, 70, 1)); } + if (change.equals("permission")) when(h.owner.hasPermission("GraveStonesPlus.ClaimGrave")).thenReturn(false); + h.drain(); verify(h.owner,never()).giveExp(anyInt()); verify(h.ownerInventory,never()).setItem(anyInt(),any()); + assertFalse(h.ledger.isBlocked(h.identity())); assertFalse(h.grave.getGravesConfig().isDestroyed()); + when(h.owner.getWorld()).thenReturn(h.world);when(h.owner.getLocation()).thenReturn(new Location(h.world,1,64,1)); + when(h.owner.hasPermission("GraveStonesPlus.ClaimGrave")).thenReturn(true); + h.grave.claimNearby(h.owner);h.drain();verify(h.owner).giveExp(17); + } + } +} diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveLoginReadinessTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveLoginReadinessTest.java new file mode 100644 index 0000000..970ccd6 --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveLoginReadinessTest.java @@ -0,0 +1,94 @@ +package com.bencodez.gravestonesplus.graves; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import com.bencodez.gravestonesplus.listeners.GraveReminderListener; + +/** Regression coverage for login summaries racing asynchronous startup admission. */ +class GraveLoginReadinessTest { + @Test void loginSummaryWaitsForFeatureStoresAndRestoration() throws Exception { + try (GraveActionsTest.Harness h = new GraveActionsTest.Harness()) { + var reminders = new GraveReminderStore(new DurableFeatureState(h.io, new FeaturePersistenceTest.Disk(), 8192)); + FeaturePersistenceTest.await(reminders.loaded()); + when(h.plugin.getGraveReminderStore()).thenReturn(reminders); + var restoration = mock(GraveRestoration.class); + when(h.plugin.getGraveRestoration()).thenReturn(restoration); + when(restoration.isLoading()).thenReturn(true); + h.bukkit.when(org.bukkit.Bukkit::getOnlinePlayers).thenReturn(java.util.List.of(h.owner)); + AtomicReference tick = new AtomicReference<>(); + when(h.platform.runTimer(any(Runnable.class), eq(20L), eq(100L))) + .thenAnswer(invocation -> { tick.set(invocation.getArgument(0)); return mock(com.bencodez.simpleapi.folialib.wrapper.task.WrappedTask.class); }); + var listener = new GraveReminderListener(h.plugin); + listener.start(); + listener.onJoin(new org.bukkit.event.player.PlayerJoinEvent(h.owner, "")); + h.drain(); + verify(h.owner, never()).sendMessage(anyString()); + + when(restoration.isLoading()).thenReturn(false); + tick.get().run(); + h.drain(); + verify(h.owner).sendMessage(argThat((String message) -> message.contains("accessible grave"))); + listener.stop(); + } + } + + @Test void failedReadinessRetriesAndQuitOrStopDropsDeferredLogin() throws Exception { + try (GraveActionsTest.Harness h = new GraveActionsTest.Harness()) { + var reminders = new GraveReminderStore(new DurableFeatureState(h.io, new FeaturePersistenceTest.Disk(), 8192)); + FeaturePersistenceTest.await(reminders.loaded()); + when(h.plugin.getGraveReminderStore()).thenReturn(reminders); + var restoration = mock(GraveRestoration.class); + when(h.plugin.getGraveRestoration()).thenReturn(restoration); + when(restoration.isLoading()).thenReturn(true); + h.bukkit.when(org.bukkit.Bukkit::getOnlinePlayers).thenReturn(java.util.List.of(h.owner)); + var tick = new AtomicReference(); + when(h.platform.runTimer(any(Runnable.class), eq(20L), eq(100L))) + .thenAnswer(invocation -> { tick.set(invocation.getArgument(0)); return mock(com.bencodez.simpleapi.folialib.wrapper.task.WrappedTask.class); }); + var listener = new GraveReminderListener(h.plugin); listener.start(); + listener.onJoin(new org.bukkit.event.player.PlayerJoinEvent(h.owner, "")); h.drain(); + when(restoration.isLoading()).thenReturn(false); + tick.get().run(); h.drain(); + verify(h.owner).sendMessage(argThat((String message) -> message.contains("accessible grave"))); + clearInvocations(h.owner); + listener.onJoin(new org.bukkit.event.player.PlayerJoinEvent(h.owner, "")); + listener.onQuit(new org.bukkit.event.player.PlayerQuitEvent(h.owner, "")); + tick.get().run(); h.drain(); + verify(h.owner, never()).sendMessage(anyString()); + listener.stop(); + } + } + @Test void unavailableReminderStateAndFailedSendRetainOneLoginSummaryUntilSuccess() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var reminders = mock(GraveReminderStore.class); when(h.plugin.getGraveReminderStore()).thenReturn(reminders); + var tick = new AtomicReference(); h.bukkit.when(org.bukkit.Bukkit::getOnlinePlayers).thenReturn(java.util.List.of(h.owner)); + when(h.platform.runTimer(any(Runnable.class), eq(20L), eq(100L))) + .thenAnswer(call -> { tick.set(call.getArgument(0)); return mock(com.bencodez.simpleapi.folialib.wrapper.task.WrappedTask.class); }); + var listener = new GraveReminderListener(h.plugin); listener.start(); + listener.onJoin(new org.bukkit.event.player.PlayerJoinEvent(h.owner, "")); h.drain(); + verify(h.owner, never()).sendMessage(anyString()); verify(reminders, never()).deliver(any(), anyLong(), any()); + when(reminders.isReady()).thenReturn(true); + doThrow(new IllegalStateException("fixture send failure")).doNothing().when(h.owner).sendMessage(anyString()); + tick.get().run(); org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, h::drain); + tick.get().run(); h.drain(); + tick.get().run(); h.drain(); + verify(h.owner, times(2)).sendMessage(argThat((String message) -> message.contains("accessible grave"))); + listener.stop(); + } + } + @Test void stoppedListenerCannotDeliverQueuedOrPeriodicLoginSummary() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var reminders = mock(GraveReminderStore.class); when(reminders.isReady()).thenReturn(true); + when(h.plugin.getGraveReminderStore()).thenReturn(reminders); + var tick = new AtomicReference(); h.bukkit.when(org.bukkit.Bukkit::getOnlinePlayers).thenReturn(java.util.List.of(h.owner)); + when(h.platform.runTimer(any(Runnable.class), eq(20L), eq(100L))) + .thenAnswer(call -> { tick.set(call.getArgument(0)); return mock(com.bencodez.simpleapi.folialib.wrapper.task.WrappedTask.class); }); + var listener = new GraveReminderListener(h.plugin); listener.start(); + listener.onJoin(new org.bukkit.event.player.PlayerJoinEvent(h.owner, "")); listener.stop(); + h.drain(); tick.get().run(); h.drain(); + verify(h.owner, never()).sendMessage(anyString()); verify(reminders, never()).deliver(any(), anyLong(), any()); + } + } + +} diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveRegionOwnershipTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveRegionOwnershipTest.java new file mode 100644 index 0000000..64726af --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveRegionOwnershipTest.java @@ -0,0 +1,230 @@ +package com.bencodez.gravestonesplus.graves; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import org.bukkit.Chunk; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.junit.jupiter.api.Test; + +/** Exercises the real marker scheduler, including actors in a different region. */ +class GraveRegionOwnershipTest { + @Test void remoteClaimCallbacksBelongToTheGraveRegion() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + when(h.folia.isFolia()).thenReturn(true); + var graveOwner = new AtomicBoolean(); + doAnswer(call -> { + assertTrue(graveOwner.get(), "claim callbacks must run on the grave owner, not the distant player owner"); + if (call.getArgument(0) instanceof com.bencodez.gravestonesplus.events.GraveClaimEvent event) + event.setCancelled(true); + return null; + }).when(h.events).callEvent(any()); + h.grave.claim(h.owner); + assertEquals(1, h.entityWork.size()); + h.entityWork.remove().run(); + verify(h.events, never()).callEvent(any()); + assertEquals(1, h.regionWork.size()); + graveOwner.set(true); h.regionWork.remove().run(); graveOwner.set(false); + verify(h.events).callEvent(any(com.bencodez.gravestonesplus.events.GraveClaimEvent.class)); + verify(h.ownerInventory, never()).setItem(anyInt(), any()); + verify(h.owner, never()).giveExp(anyInt()); + } + } + @Test void bukkitClaimCallbacksRemainSynchronousOnPrimaryThread() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + h.bukkit.when(org.bukkit.Bukkit::isPrimaryThread).thenReturn(true); + doAnswer(call -> { if (call.getArgument(0) instanceof com.bencodez.gravestonesplus.events.GraveClaimEvent event) + event.setCancelled(true); return null; + }).when(h.events).callEvent(any()); + h.grave.claim(h.owner); + verify(h.events).callEvent(any(com.bencodez.gravestonesplus.events.GraveClaimEvent.class)); + assertTrue(h.entityWork.isEmpty()); assertTrue(h.regionWork.isEmpty()); + assertFalse(h.ledger.isBlocked(h.identity())); + } + } + @Test void foliaClaimCapturesCallbackChangesAndAdmitsOnlyOnePayout() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + when(h.folia.isFolia()).thenReturn(true); + var actor = new AtomicBoolean(); var marker = new AtomicBoolean(); + when(h.owner.isOnline()).thenAnswer(call -> { assertTrue(actor.get()); assertFalse(marker.get()); return true; }); + when(h.stored.clone()).thenAnswer(call -> { assertTrue(marker.get()); return h.cloned; }); + doAnswer(call -> { + assertTrue(marker.get()); assertFalse(actor.get()); + if (call.getArgument(0) instanceof com.bencodez.gravestonesplus.events.GraveClaimEvent) { + var exp = com.bencodez.gravestonesplus.storage.GravesConfig.class.getDeclaredField("exp"); + exp.setAccessible(true); exp.setInt(h.grave.getGravesConfig(), 31); + } + return null; + }).when(h.events).callEvent(any()); + h.grave.claim(h.owner); h.grave.claim(h.owner); + actor.set(true); while (!h.entityWork.isEmpty()) h.entityWork.remove().run(); actor.set(false); + assertEquals(2, h.regionWork.size()); + marker.set(true); while (!h.regionWork.isEmpty()) h.regionWork.remove().run(); marker.set(false); + verify(h.events, times(1)).callEvent(any(com.bencodez.gravestonesplus.events.GraveClaimEvent.class)); + assertEquals(1, h.entityWork.size()); + actor.set(true); h.entityWork.remove().run(); actor.set(false); + FeaturePersistenceTest.await(h.io.barrier()); + actor.set(true); while (!h.entityWork.isEmpty()) h.entityWork.remove().run(); actor.set(false); + FeaturePersistenceTest.await(h.io.barrier()); + verify(h.owner, times(1)).giveExp(31); + verify(h.ownerInventory, times(1)).setItem(4, h.cloned); + } + } + @Test void rejectedClaimRegionAdmissionLeavesTheGraveClaimable() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + when(h.folia.isFolia()).thenReturn(true); + doThrow(new java.util.concurrent.RejectedExecutionException("fixture retired region")) + .when(h.scheduler).runTask(eq(h.plugin), any(Runnable.class), any(Location.class)); + h.grave.claim(h.owner); h.entityWork.remove().run(); + verify(h.events, never()).callEvent(any()); assertFalse(h.ledger.isBlocked(h.identity())); + var inProgress = Grave.class.getDeclaredField("claimInProgress"); inProgress.setAccessible(true); + assertFalse(inProgress.getBoolean(h.grave)); verify(h.owner, never()).giveExp(anyInt()); + } + } + @Test void retiredActorAfterClaimCallbacksReleasesUnreservedAdmission() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + when(h.folia.isFolia()).thenReturn(true); + doAnswer(call -> { ((Runnable) call.getArgument(2)).run(); + return java.util.concurrent.CompletableFuture.completedFuture( + com.bencodez.simpleapi.folialib.enums.EntityTaskResult.SUCCESS); + }).when(h.platform).runAtEntityWithFallback(any(org.bukkit.entity.Entity.class), any(), any(Runnable.class)); + h.grave.claim(h.owner); h.entityWork.remove().run(); h.regionWork.remove().run(); + assertFalse(h.ledger.isBlocked(h.identity())); + var inProgress = Grave.class.getDeclaredField("claimInProgress"); inProgress.setAccessible(true); + assertFalse(inProgress.getBoolean(h.grave)); verify(h.owner, never()).giveExp(anyInt()); + } + } + @Test void foliaChunkLoadUsesRegionTicketWithoutGlobalForceLoadedState() throws Exception { + for (boolean deferred : List.of(false, true)) { + try (var h = new GraveActionsTest.Harness()) { + when(h.folia.isFolia()).thenReturn(true); + var owned = new AtomicBoolean(); + var block = mock(Block.class); var chunk = mock(Chunk.class); + when(h.world.getBlockAt(any(Location.class))).thenAnswer(call -> { + assertTrue(owned.get()); return block; + }); + when(block.getChunk()).thenReturn(chunk); + when(chunk.load(false)).thenAnswer(call -> { assertTrue(owned.get()); return true; }); + if (!deferred) owned.set(true); + h.grave.loadChunk(deferred); + if (deferred) { + verify(chunk, never()).load(false); + assertEquals(1, h.regionWork.size()); + owned.set(true); h.regionWork.remove().run(); + } + verify(chunk).load(false); + verify(chunk, never()).isForceLoaded(); verify(chunk, never()).setForceLoaded(anyBoolean()); + verify(h.scheduler).runTaskLater(eq(h.plugin), any(Runnable.class), eq(120L), + eq(h.grave.getGravesConfig().getLocation())); + assertTrue(h.ownerWork.isEmpty()); + } + } + } + + @Test void disabledFoliaChunkLoadDoesNotTouchWorldOrRetainTicket() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + when(h.folia.isFolia()).thenReturn(true); + h.grave.loadChunk(true); when(h.plugin.isEnabled()).thenReturn(false); + h.regionWork.remove().run(); + verify(h.world, never()).getBlockAt(any(Location.class)); + verify(h.scheduler, never()).runTaskLater(eq(h.plugin), any(Runnable.class), anyLong(), any(Location.class)); + } + } + + @Test void bukkitChunkLoadPreservesExistingForceLoadAndRelease() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var block = mock(Block.class); var chunk = mock(Chunk.class); + when(h.world.getBlockAt(any(Location.class))).thenReturn(block); when(block.getChunk()).thenReturn(chunk); + h.grave.loadChunk(false); + verify(chunk).isForceLoaded(); verify(chunk).setForceLoaded(true); verify(chunk).load(false); + var release = org.mockito.ArgumentCaptor.forClass(Runnable.class); + verify(h.scheduler).runTaskLater(eq(h.plugin), release.capture(), eq(120L), any(Location.class)); + release.getValue().run(); verify(chunk).setForceLoaded(false); + } + } + + @Test void remoteActorAuthorizesOnEntityOwnerAndMarkerUsesSavedLocation() throws Exception { + for (String type : List.of("CHEST", "PLAYER_HEAD", "DISPLAY_ENTITY")) { + try (var h = new GraveActionsTest.Harness()) { + when(h.folia.isFolia()).thenReturn(true); + var actorOwner = new AtomicBoolean(); + var graveOwner = new AtomicBoolean(); + when(h.helper.hasPermission(anyString())).thenAnswer(call -> { + assertTrue(actorOwner.get(), "authorization must use the actor owner"); + assertFalse(graveOwner.get(), "actor may be in a different region"); + return call.getArgument(0).equals("GraveStonesPlus.Admin"); + }); + var grave = spy(h.grave); + // Metadata and display implementations have their own tests. Keep the + // real createGrave/loadChunk methods to prove world access is scheduled. + doAnswer(call -> { assertTrue(graveOwner.get()); return null; }).when(grave).loadBlockMeta(any()); + doAnswer(call -> { assertTrue(graveOwner.get()); return null; }).when(grave).createHologram(); + var block = mock(Block.class); var chunk = mock(Chunk.class); + when(h.world.getBlockAt(any(Location.class))).thenAnswer(call -> { + assertTrue(graveOwner.get(), "block lookup belongs to the grave region"); + assertFalse(actorOwner.get()); return block; + }); + when(block.getChunk()).thenReturn(chunk); + when(chunk.isForceLoaded()).thenReturn(true); + doAnswer(call -> { assertTrue(graveOwner.get()); assertFalse(actorOwner.get()); return null; }) + .when(block).setType(any(Material.class)); + var display = mock(com.bencodez.gravestonesplus.graves.GraveDisplayEntityHandle.class); + when(h.plugin.getGraveDisplayEntityHandler()).thenReturn(display); + when(display.createDisplay(grave)).thenAnswer(call -> { + assertTrue(graveOwner.get()); assertFalse(actorOwner.get()); return null; + }); + h.active.clear(); grave.getGravesConfig().setDestroyed(true); + grave.getGravesConfig().setGraveDisplayType(type); + var restored = grave.recreateAsync(h.helper, true); + FeaturePersistenceTest.await(h.io.barrier()); FeaturePersistenceTest.await(h.io.barrier()); + assertTrue(h.regionWork.isEmpty()); assertFalse(restored.isDone()); + assertEquals(1, h.entityWork.size()); assertTrue(grave.getGravesConfig().isDestroyed()); + verify(h.world, never()).getBlockAt(any(Location.class)); + actorOwner.set(true); h.entityWork.remove().run(); actorOwner.set(false); + assertEquals(1, h.regionWork.size()); assertFalse(restored.isDone()); + assertTrue(grave.getGravesConfig().isDestroyed()); + graveOwner.set(true); h.regionWork.remove().run(); graveOwner.set(false); + assertTrue(FeaturePersistenceTest.await(restored)); + assertFalse(grave.getGravesConfig().isDestroyed()); + verify(h.world, never()).getBlockAt(any(Location.class)); + assertEquals(2, h.regionWork.size(), "marker and hologram each dispatch to the grave"); + graveOwner.set(true); + while (!h.regionWork.isEmpty()) h.regionWork.remove().run(); + graveOwner.set(false); + Material material = type.equals("CHEST") ? Material.CHEST : type.equals("PLAYER_HEAD") ? Material.PLAYER_HEAD : Material.BARRIER; + verify(block).setType(material); verify(grave).loadBlockMeta(block); verify(grave).createHologram(); + Location savedLocation = grave.getGravesConfig().getLocation(); + verify(h.scheduler, times(2)).runTask(eq(h.plugin), any(Runnable.class), eq(savedLocation)); + } + } + } + + @Test void remoteActorPermissionRevocationRejectsBeforeMarkerDispatch() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + when(h.folia.isFolia()).thenReturn(true); + h.grave.getGravesConfig().setDestroyed(true); + var restored = h.grave.recreateAsync(h.helper, true); + FeaturePersistenceTest.await(h.io.barrier()); FeaturePersistenceTest.await(h.io.barrier()); + assertTrue(h.regionWork.isEmpty()); h.entityWork.remove().run(); + assertFalse(FeaturePersistenceTest.await(restored)); + assertTrue(h.grave.getGravesConfig().isDestroyed()); + assertTrue(h.regionWork.isEmpty()); + verify(h.world, never()).getBlockAt(any(Location.class)); + } + } + + @Test void locationOnlyRecreationKeepsLocationScheduledMarkerPath() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + when(h.folia.isFolia()).thenReturn(true); + var grave = spy(h.grave); doNothing().when(grave).createGrave(); doNothing().when(grave).createHologram(); + grave.getGravesConfig().setDestroyed(true); + var restored = grave.recreateAsync(null, true); h.drain(); + h.regionWork.remove().run(); + assertTrue(FeaturePersistenceTest.await(restored)); verify(grave).createGrave(); + assertTrue(h.entityWork.isEmpty()); + } + } +} diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveReminderDeliveryTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveReminderDeliveryTest.java new file mode 100644 index 0000000..fea7290 --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveReminderDeliveryTest.java @@ -0,0 +1,82 @@ +package com.bencodez.gravestonesplus.graves; + +import static org.junit.jupiter.api.Assertions.*; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.atomic.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class GraveReminderDeliveryTest { + @TempDir Path directory; + static GraveReminderStore.Notice notice(long deadline) { return new GraveReminderStore.Notice("player/grave/expiry/" + deadline, deadline, "expires soon"); } + @Test void sendsOneSummaryThenPersistsAndSuppressesRestartDuplicates() throws Exception { + Path file = directory.resolve("reminders.yml"); AtomicInteger sends = new AtomicInteger(); + try (FeatureIO io = new FeatureIO()) { + var state = new DurableFeatureState(io, file, 8192); var store = new GraveReminderStore(state); FeaturePersistenceTest.await(store.loaded()); + assertEquals(2, store.deliver(List.of(notice(200), notice(300)), 100, fresh -> { + assertTrue(state.snapshot().isEmpty()); assertEquals(2, fresh.size()); sends.incrementAndGet(); return true; + }).size()); + assertTrue(store.deliver(List.of(notice(200)), 100, fresh -> { fail("same-process replay"); return true; }).isEmpty()); + FeaturePersistenceTest.await(io.barrier()); assertEquals(2, state.snapshot().size()); + } + try (FeatureIO io = new FeatureIO()) { + var store = new GraveReminderStore(new DurableFeatureState(io, file, 8192)); FeaturePersistenceTest.await(store.loaded()); + assertTrue(store.deliver(List.of(notice(200)), 100, fresh -> { fail("restart replay"); return true; }).isEmpty()); + } + assertEquals(1, sends.get()); + } + @Test void offlineOrRejectedDeliveryNeverMarksAndLaterLoginRemainsEligible() throws Exception { + try (FeatureIO io = new FeatureIO()) { + var state = new DurableFeatureState(io, new FeaturePersistenceTest.Disk(), 8192); var store = new GraveReminderStore(state); FeaturePersistenceTest.await(store.loaded()); + assertTrue(store.deliver(List.of(notice(200)), 100, fresh -> false).isEmpty()); + FeaturePersistenceTest.await(io.barrier()); assertTrue(state.snapshot().isEmpty()); + assertEquals(1, store.deliver(List.of(notice(200)), 100, fresh -> true).size()); + } + } + @Test void changedDeadlinesAreNewAndElapsedDeadlinesReclaimCapacity() throws Exception { + try (FeatureIO io = new FeatureIO()) { + var state = new DurableFeatureState(io, new FeaturePersistenceTest.Disk(), 1); var store = new GraveReminderStore(state); FeaturePersistenceTest.await(store.loaded()); + assertEquals(1, store.deliver(List.of(notice(200)), 100, fresh -> true).size()); FeaturePersistenceTest.await(io.barrier()); + assertEquals(1, store.deliver(List.of(notice(300)), 201, fresh -> true).size()); FeaturePersistenceTest.await(io.barrier()); + assertEquals(Set.of(notice(300).key()), state.snapshot().keySet()); + } + } + @Test void failedPersistenceNeverPretendsDurableAndSuppressesLocalRepeat() throws Exception { + try (FeatureIO io = new FeatureIO()) { + var disk = new FeaturePersistenceTest.Disk(); var state = new DurableFeatureState(io, disk, 8192); var store = new GraveReminderStore(state); FeaturePersistenceTest.await(store.loaded()); disk.fail = true; + assertEquals(1, store.deliver(List.of(notice(200)), 100, fresh -> true).size()); FeaturePersistenceTest.await(io.barrier()); + assertTrue(state.snapshot().isEmpty()); assertFalse(state.isReady()); + assertTrue(store.deliver(List.of(notice(200)), 100, fresh -> { fail("duplicate after failure"); return true; }).isEmpty()); + } + } + @Test void disabledZeroInvalidAndLegacyMinuteTimerEligibilityMatchesProduction() { + assertEquals(0, GraveDeadlines.expiry(100, "0")); assertEquals(0, GraveDeadlines.expiry(100, "")); + assertEquals(0, GraveDeadlines.expiry(100, "invalid")); assertEquals(120100, GraveDeadlines.expiry(100, "2")); + assertEquals(120100, GraveDeadlines.expiry(100, "2m")); assertEquals(0, GraveDeadlines.deadline(Long.MAX_VALUE, 1)); + assertFalse(GraveDeadlines.eligible(100, 0, 100)); assertFalse(GraveDeadlines.eligible(100, 200, 0)); + assertFalse(GraveDeadlines.eligible(200, 200, 100)); assertFalse(GraveDeadlines.eligible(99, 200, 100)); + assertTrue(GraveDeadlines.eligible(100, 200, 100)); + } + @Test void oldReminderFileAndDeadlineIdentityRemainReadable() throws Exception { + Path file = directory.resolve("reminders.yml"); Files.writeString(file, "sent:\n- player/grave/expiry/200\n"); + try (FeatureIO io = new FeatureIO()) { + var store = new GraveReminderStore(new DurableFeatureState(io, file, 8192)); assertTrue(FeaturePersistenceTest.await(store.loaded())); + assertTrue(store.deliver(List.of(notice(200)), 100, fresh -> { fail("legacy replay"); return true; }).isEmpty()); + assertEquals(1, store.deliver(List.of(notice(201)), 100, fresh -> true).size()); + } + } + @Test void fullWorkerNeverSendsWithoutPublicationAdmission() throws Exception { + try (FeatureIO io = new FeatureIO(1)) { + var state = new DurableFeatureState(io, new FeaturePersistenceTest.Disk(), 8192); + var store = new GraveReminderStore(state); FeaturePersistenceTest.await(store.loaded()); + var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1); + var busy = io.submit(() -> { entered.countDown(); try { release.await(2, java.util.concurrent.TimeUnit.SECONDS); } catch (InterruptedException e) { throw new RuntimeException(e); } return true; }); + assertTrue(entered.await(2, java.util.concurrent.TimeUnit.SECONDS)); var queued = io.submit(() -> true); + assertTrue(store.deliver(List.of(notice(200)), 100, fresh -> { fail("sent without admitted persistence"); return true; }).isEmpty()); + release.countDown(); FeaturePersistenceTest.await(busy); FeaturePersistenceTest.await(queued); + assertEquals(1, store.deliver(List.of(notice(200)), 100, fresh -> true).size()); + } + } + +} diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveReviewRegressionTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveReviewRegressionTest.java new file mode 100644 index 0000000..31fa3fa --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveReviewRegressionTest.java @@ -0,0 +1,180 @@ +package com.bencodez.gravestonesplus.graves; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Field; +import java.util.*; + +import com.bencodez.advancedcore.AdvancedCoreConfigOptions; +import com.bencodez.advancedcore.api.command.CommandHandler; +import com.bencodez.advancedcore.api.item.FullInventoryHandler; +import com.bencodez.gravestonesplus.commands.CommandLoader; +import com.bencodez.gravestonesplus.listeners.GraveReminderListener; +import com.bencodez.gravestonesplus.storage.GravesConfig; +import com.bencodez.simpleapi.time.ParsedDuration; +import org.bukkit.Location; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.Test; + +class GraveReviewRegressionTest { + @Test void claimRestoresAvailableOriginalSlotsBeforeInsertingDisplacedStacks() throws Exception { + try (GraveActionsTest.Harness h = new GraveActionsTest.Harness()) { + ItemStack occupied = mock(ItemStack.class), originalZero = mock(ItemStack.class), originalOne = mock(ItemStack.class); + org.bukkit.Material type = h.stored.getType(); + for (ItemStack stack : List.of(occupied, originalZero, originalOne)) { + when(stack.getType()).thenReturn(type); + when(stack.clone()).thenReturn(stack); + } + when(originalZero.serialize()).thenReturn(Map.of("type", "DIAMOND", "amount", 2)); + when(originalOne.serialize()).thenReturn(Map.of("type", "GOLD_INGOT", "amount", 3)); + ItemStack[] slots = new ItemStack[41]; slots[0] = occupied; + when(h.ownerInventory.getItem(anyInt())).thenAnswer(call -> slots[call.getArgument(0, Integer.class)]); + doAnswer(call -> { slots[call.getArgument(0, Integer.class)] = call.getArgument(1); return null; }) + .when(h.ownerInventory).setItem(anyInt(), any(ItemStack.class)); + when(h.ownerInventory.addItem(any(ItemStack.class))).thenAnswer(call -> { + ItemStack stack = call.getArgument(0); + for (int slot = 0; slot < 36; slot++) if (slots[slot] == null) { + slots[slot] = stack; + return new HashMap(); + } + return new HashMap<>(Map.of(0, stack)); + }); + Grave grave = new Grave(h.plugin, new GravesConfig(h.owner.getUniqueId(), "Owner", h.owner.getLocation(), + new HashMap<>(Map.of(0, originalZero, 1, originalOne)), 17, "death", System.currentTimeMillis(), + false, 0, null, null, "PLAYER_HEAD")); + h.active.clear(); h.active.add(grave); + + grave.claim(h.owner); h.drain(); + + assertSame(occupied, slots[0]); + assertSame(originalOne, slots[1], "An available original slot must be reserved before displaced insertion"); + assertSame(originalZero, slots[2]); + verify(h.ownerInventory).setItem(1, originalOne); + verify(h.ownerInventory).addItem(originalZero); + verify(h.ownerInventory, never()).addItem(originalOne); + verify(h.world, never()).dropItem(any(), any()); + verify(h.owner).giveExp(17); + assertTrue(grave.getGravesConfig().isDestroyed()); + } + } + + @Test void fullInventoryClaimRetainsOverflowWhenAdvancedCoreDropPolicyIsDisabled() throws Exception { + assertOverflowPolicy(false); + } + + @Test void fullInventoryClaimDropsOverflowWhenAdvancedCoreDropPolicyIsEnabled() throws Exception { + assertOverflowPolicy(true); + } + + private void assertOverflowPolicy(boolean drop) throws Exception { + try (GraveActionsTest.Harness h = new GraveActionsTest.Harness()) { + AdvancedCoreConfigOptions options = mock(AdvancedCoreConfigOptions.class); + UUID ownerId = h.owner.getUniqueId(); h.bukkit.when(() -> org.bukkit.Bukkit.getPlayer(ownerId)).thenReturn(h.owner); + when(options.isDropOnFullInv()).thenReturn(drop); when(options.getFormatInvFull()).thenReturn(""); + when(h.plugin.getOptions()).thenReturn(options); + var serverData = mock(com.bencodez.advancedcore.data.ServerData.class); + when(serverData.getData()).thenReturn(new org.bukkit.configuration.file.YamlConfiguration()); + when(h.plugin.getServerDataFile()).thenReturn(serverData); + FullInventoryHandler handler = spy(new FullInventoryHandler(h.plugin)); + try { + when(h.plugin.getFullInventoryHandler()).thenReturn(handler); + ItemStack occupied = mock(ItemStack.class); org.bukkit.Material type = h.stored.getType(); + when(occupied.getType()).thenReturn(type); + when(h.ownerInventory.getItem(4)).thenReturn(occupied); + when(h.ownerInventory.addItem(h.cloned)).thenReturn(new HashMap<>(Map.of(0, h.cloned))); + when(h.world.dropItem(h.owner.getLocation(), h.cloned)).thenReturn(mock(org.bukkit.entity.Item.class)); + var delivery = new java.util.concurrent.atomic.AtomicReference>(); + doAnswer(call -> { + var stage = (java.util.concurrent.CompletionStage) call.callRealMethod(); + delivery.set(stage.toCompletableFuture()); return stage; + }).when(handler).giveItemAsync(h.owner, h.cloned); + + var completionScheduled = new java.util.concurrent.CountDownLatch(1); + doAnswer(call -> { + java.util.function.Consumer work = call.getArgument(1); + var completed = new java.util.concurrent.CompletableFuture(); + h.entityWork.add(() -> { work.accept(null); completed.complete(com.bencodez.simpleapi.folialib.enums.EntityTaskResult.SUCCESS); }); + if (delivery.get() != null) completionScheduled.countDown(); + return completed; + }).when(h.platform).runAtEntityWithFallback(any(org.bukkit.entity.Entity.class), any(), any(Runnable.class)); + h.grave.claim(h.owner); h.drain(); + assertNotNull(delivery.get()); FeaturePersistenceTest.await(delivery.get()); + assertTrue(completionScheduled.await(3, java.util.concurrent.TimeUnit.SECONDS), "overflow completion must schedule the recipient callback"); h.drain(); + + verify(handler).giveItemAsync(h.owner, h.cloned); + verify(h.world, times(drop ? 1 : 0)).dropItem(h.owner.getLocation(), h.cloned); + if (drop) assertFalse(handler.getItems().containsKey(h.owner.getUniqueId())); + else assertEquals(List.of(h.cloned), handler.getItems().get(h.owner.getUniqueId())); + verify(h.owner).giveExp(17); + assertTrue(h.grave.getGravesConfig().isDestroyed()); + assertTrue(h.ledger.status(h.identity()).startsWith("PAID:")); + } finally { handler.shutdown(); } + } + } + + @Test void claimCommandHonorsOtherPlayersBreakTimeWhileTrustedRecoveryRemainsImmediate() throws Exception { + try (GraveActionsTest.Harness h = new GraveActionsTest.Harness()) { + when(h.plugin.getOptions()).thenReturn(mock(AdvancedCoreConfigOptions.class)); + ArrayList commands = new ArrayList<>(); when(h.plugin.getCommands()).thenReturn(commands); + new CommandLoader(h.plugin).loadCommands(); + CommandHandler claim = commands.stream().filter(command -> command.getArgs()[0].equalsIgnoreCase("ClaimGrave")) + .findFirst().orElseThrow(); + when(h.helper.hasPermission("GraveStonesPlus.ClaimGrave")).thenReturn(true); + when(h.helper.hasPermission("GraveStonesPlus.BreakOtherGraves")).thenReturn(true); + when(h.config.getGraveClaimDistance()).thenReturn(10); + when(h.config.isBreakOtherGravesEnabled()).thenReturn(true); + when(h.config.isBreakOtherGravesRequirePermission()).thenReturn(true); + when(h.config.isBreakOtherGravesRequireBreakTime()).thenReturn(true); + when(h.config.getBreakOtherGravesTimeBeforeBreakable()).thenReturn(ParsedDuration.parse("0", java.util.concurrent.TimeUnit.MINUTES)); + assertTrue(claim.hasPerm(h.helper)); assertTrue(h.grave.canNonOwnerClaim()); + assertFalse(h.plugin.canRecover(h.grave, h.helper)); + + claim.execute(h.helper, new String[] { "claimgrave" }); h.drain(); + + verify(h.helper, never()).giveExp(anyInt()); verify(h.helperInventory, never()).setItem(anyInt(), any()); + assertFalse(h.ledger.isBlocked(h.identity())); assertFalse(h.grave.getGravesConfig().isDestroyed()); + h.grant(TrustedGraveAccess.Kind.RECOVER); + claim.execute(h.helper, new String[] { "claimgrave" }); h.drain(); + verify(h.helper).giveExp(17); verify(h.helperInventory).setItem(4, h.cloned); + assertTrue(h.grave.getGravesConfig().isDestroyed()); + assertTrue(h.ledger.status(h.identity()).startsWith("PAID:")); + } + } + + @Test void fortyGravesDeliverAllEightyWarningsAcrossCappedPassesWithoutDuplicates() throws Exception { + try (GraveActionsTest.Harness h = new GraveActionsTest.Harness()) { + DurableFeatureState state = new DurableFeatureState(h.io, new FeaturePersistenceTest.Disk(), 8192); + GraveReminderStore store = new GraveReminderStore(state); FeaturePersistenceTest.await(store.loaded()); + when(h.plugin.getGraveReminderStore()).thenReturn(store); + when(h.config.isBreakOtherGravesEnabled()).thenReturn(true); + when(h.config.getProtectionReminderMillis()).thenReturn(120_000L); + when(h.config.getExpiryReminderMillis()).thenReturn(120_000L); + long created = System.currentTimeMillis(), expiry = created + 90_000; + Field expiryDeadline = Grave.class.getDeclaredField("expiryDeadline"); expiryDeadline.setAccessible(true); + Set expected = new HashSet<>(); h.active.clear(); + for (int index = 0; index < 40; index++) { + Grave grave = new Grave(h.plugin, new GravesConfig(h.owner.getUniqueId(), "Owner", new Location(h.world, index, 64, 1), + new HashMap<>(), 0, "death", created, false, 0, null, null, "PLAYER_HEAD")); + expiryDeadline.setLong(grave, expiry); h.active.add(grave); + String prefix = h.owner.getUniqueId() + "/" + GraveIdentity.of(grave.getGravesConfig()) + "/"; + expected.add(prefix + "expiry/" + expiry); + expected.add(prefix + "protection/" + (created + 60_000)); + } + GraveReminderListener listener = new GraveReminderListener(h.plugin); listener.start(); + try { + listener.checkPlayer(h.owner); h.drain(); + assertEquals(64, state.snapshot().size(), "The first delivery must obey the 64-warning cap"); + verify(h.owner).sendMessage(argThat((String message) -> message.contains("+61 deadlines"))); + + listener.checkPlayer(h.owner); h.drain(); + assertEquals(expected, state.snapshot().keySet(), "Already-delivered warnings must not crowd out the remaining 16"); + verify(h.owner).sendMessage(argThat((String message) -> message.contains("+13 deadlines"))); + + listener.checkPlayer(h.owner); h.drain(); + assertEquals(expected, state.snapshot().keySet()); + verify(h.owner, times(2)).sendMessage(any(String.class)); + } finally { listener.stop(); } + } + } +} diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveTimeLimitTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveTimeLimitTest.java new file mode 100644 index 0000000..6e4f656 --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveTimeLimitTest.java @@ -0,0 +1,25 @@ +package com.bencodez.gravestonesplus.graves; + +import static org.junit.jupiter.api.Assertions.*; +import java.util.List; +import org.junit.jupiter.api.Test; + +class GraveTimeLimitTest { + @Test void preservesLegacyAndSupportedDurationUnits() { + assertEquals(120_000, GraveTimeLimit.parse("2").getMillis()); + for (String text : List.of("1h30m", "1 H 30 MINUTES", "PT1H30M")) + assertEquals(5_400_000, GraveTimeLimit.parse(text).getMillis()); + assertEquals(1, GraveTimeLimit.parse("1ms").getMillis()); + assertEquals(604_800_000, GraveTimeLimit.parse("1week").getMillis()); + assertEquals(2_592_000_000L, GraveTimeLimit.parse("1month").getMillis()); + for (String text : List.of("", " ", "0", "-1", "0m0s", "PT0S")) + assertEquals(0, GraveTimeLimit.parse(text).getMillis()); + assertEquals(0, GraveTimeLimit.parse(null).getMillis()); + } + @Test void malformedAndOverflowingInputCannotBecomeDisabledOrSaturated() { + for (String text : List.of("tomorrow", "2unknown", "1h garbage", "1h,30m", "PTtomorrow", + "999999999999999999999999999", "9223372036854775807m", "9223372036854775807ms1ms", + "PT999999999999999999999999999H")) + assertThrows(RuntimeException.class, () -> GraveTimeLimit.parse(text), text); + } +} diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/HistoricalMenuOwnershipTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/HistoricalMenuOwnershipTest.java new file mode 100644 index 0000000..7eb4256 --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/HistoricalMenuOwnershipTest.java @@ -0,0 +1,65 @@ +package com.bencodez.gravestonesplus.graves; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; +import org.bukkit.entity.Entity; +import org.bukkit.event.inventory.ClickType; +import org.junit.jupiter.api.Test; +import com.bencodez.advancedcore.api.inventory.BInventory; +import com.bencodez.advancedcore.api.item.ItemBuilder; + +class HistoricalMenuOwnershipTest { + @Test void historicalClicksAuthorizeOnlyOnActorOwnerAndDispatchEachIntent() throws Exception { + for (ClickType intent : List.of(ClickType.LEFT, ClickType.SHIFT_LEFT, ClickType.SHIFT_RIGHT)) { + try (var h = new GraveActionsTest.Harness(); + var builders = mockConstruction(ItemBuilder.class, withSettings().defaultAnswer(RETURNS_SELF))) { + var owned = new AtomicBoolean(); + var grave = spy(h.grave); + doNothing().when(grave).openGUIWithItems(h.helper); + doNothing().when(grave).teleportHistorical(h.helper); + when(h.plugin.recreateBrokenGrave(grave, h.helper)).thenReturn(CompletableFuture.completedFuture(true)); + when(h.helper.hasPermission(anyString())).thenAnswer(call -> { + assertTrue(owned.get(), "permission access must occur on the actor owner"); + return call.getArgument(0).equals("GraveStonesPlus.AllBrokenGraves"); + }); + var click = mock(BInventory.ClickEvent.class); + when(click.getPlayer()).thenReturn(h.helper); when(click.getClick()).thenReturn(intent); + clearInvocations(h.helper); + grave.getGUIItemBroken().onClick(click); + verify(h.helper, never()).hasPermission(anyString()); + verify(h.plugin, never()).recreateBrokenGrave(any(), any()); + assertEquals(1, h.entityWork.size()); + owned.set(true); h.entityWork.remove().run(); + if (intent == ClickType.SHIFT_RIGHT) verify(h.plugin).recreateBrokenGrave(grave, h.helper); + else if (intent == ClickType.SHIFT_LEFT) verify(grave).openGUIWithItems(h.helper); + else verify(grave).teleportHistorical(h.helper); + verify(h.scheduler, never()).runTask(eq(h.plugin), any(Runnable.class)); + verify(h.scheduler, atLeastOnce()).runTask(eq(h.plugin), any(Runnable.class), eq((Entity) h.helper)); + } + } + } + + @Test void historicalClicksRevalidatePermissionOnlineAndPluginStateBeforeMutation() throws Exception { + for (String change : List.of("revoked", "offline", "disabled")) { + try (var h = new GraveActionsTest.Harness(); + var builders = mockConstruction(ItemBuilder.class, withSettings().defaultAnswer(RETURNS_SELF))) { + when(h.helper.hasPermission("GraveStonesPlus.AllBrokenGraves")).thenReturn(true); + var click = mock(BInventory.ClickEvent.class); + when(click.getPlayer()).thenReturn(h.helper); when(click.getClick()).thenReturn(ClickType.SHIFT_RIGHT); + h.grave.getGUIItemBroken().onClick(click); + switch (change) { + case "revoked" -> when(h.helper.hasPermission("GraveStonesPlus.AllBrokenGraves")).thenReturn(false); + case "offline" -> when(h.helper.isOnline()).thenReturn(false); + case "disabled" -> when(h.plugin.isEnabled()).thenReturn(false); + } + h.entityWork.remove().run(); + verify(h.plugin, never()).recreateBrokenGrave(any(), any()); + verify(h.helper, never()).teleport(any(org.bukkit.Location.class)); + verify(h.helper, never()).openInventory(any(org.bukkit.inventory.Inventory.class)); + } + } + } +} diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/HologramCleanupRegressionTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/HologramCleanupRegressionTest.java new file mode 100644 index 0000000..399b8cc --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/HologramCleanupRegressionTest.java @@ -0,0 +1,163 @@ +package com.bencodez.gravestonesplus.graves; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Field; +import java.util.*; +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.hologram.Hologram; +import com.bencodez.advancedcore.api.hologram.HologramHandler; +import com.bencodez.gravestonesplus.events.GraveRemoveEvent; +import com.bencodez.gravestonesplus.events.GraveRemoveReason; +import org.bukkit.*; +import org.bukkit.entity.*; +import org.junit.jupiter.api.Test; + +class HologramCleanupRegressionTest { + private static void field(Object target, Class type, String name, Object value) throws Exception { + Field field = type.getDeclaredField(name); field.setAccessible(true); field.set(target, value); + } + @SuppressWarnings("unchecked") + private static List registered(HologramHandler handler) throws Exception { + Field field = HologramHandler.class.getDeclaredField("list"); field.setAccessible(true); + return List.copyOf((List) field.get(handler)); + } + private static Hologram create(GraveActionsTest.Harness h, ArmorStand stand) { + when(h.world.spawnEntity(any(Location.class), eq(EntityType.ARMOR_STAND))).thenReturn(stand); + return new Hologram(h.grave.getGravesConfig().getLocation().clone(), "grave", true, false); + } + @Test void claimManualRemovalAndExpiryReleaseAllRealWrappersWithoutGlobalEntityKill() throws Exception { + for (String action : List.of("claim", "manual", "expiry")) try (var h = new GraveActionsTest.Harness(); + var core = mockStatic(AdvancedCorePlugin.class)) { + core.when(AdvancedCorePlugin::getInstance).thenReturn(h.plugin); + h.bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + var state = new DurableFeatureState(h.io, new FeaturePersistenceTest.Disk(), 8192); + var cleanup = new GraveMarkerCleanup(h.plugin, state); + when(h.plugin.getMarkerCleanup()).thenReturn(cleanup); assertTrue(FeaturePersistenceTest.await(cleanup.loaded())); + List owned = new ArrayList<>(); + for (String name : List.of("topHologram", "middleHologram", "bottomHologram", "glowingHologram")) { + ArmorStand stand = mock(ArmorStand.class); owned.add(stand); + field(h.grave, Grave.class, name, create(h, stand)); + } + ArmorStand successor = mock(ArmorStand.class); Hologram unrelated = create(h, successor); + assertEquals(5, registered(h.plugin.getHologramHandler()).size()); + h.bukkit.when(Bukkit::isPrimaryThread).thenReturn(false); // Folia owner tasks are not Bukkit's primary thread. + if (action.equals("claim")) { + h.grave.claim(h.owner); h.drain(); + verify(h.owner).giveExp(17); + } else { + if (action.equals("expiry")) field(h.grave, Grave.class, "expiryDeadline", System.currentTimeMillis() - 1); + h.grave.removeGrave(action.equals("expiry") ? GraveRemoveReason.EXPIRED : GraveRemoveReason.MANUAL_REMOVE); + } + h.regionWork.remove().run(); FeaturePersistenceTest.await(h.io.barrier()); + assertTrue(h.grave.isRemove()); assertTrue(state.snapshot().containsKey(h.identity())); + assertEquals(List.of(unrelated), registered(h.plugin.getHologramHandler())); + assertNull(h.grave.getTopHologram()); assertNull(h.grave.getMiddleHologram()); + assertNull(h.grave.getBottomHologram()); assertNull(h.grave.getGlowingHologram()); + for (ArmorStand stand : owned) { + verify(stand, never()).remove(); verify(stand, never()).setHealth(anyDouble()); + verify(h.scheduler).runTask(eq(h.plugin), any(Runnable.class), eq(stand)); + } + h.grave.removeHologram(); // Repeated/deferred cleanup cannot release a successor. + while (!h.entityWork.isEmpty()) h.entityWork.remove().run(); + for (ArmorStand stand : owned) verify(stand).remove(); + verify(successor, never()).remove(); + verify(h.scheduler, never()).executeOrScheduleSync(any(), any()); + verify(h.world, never()).getChunkAt(anyInt(), anyInt()); + cleanup.retire(); + } + } + @Test void cancelledRemovalKeepsRealWrapperAndEntityRegistered() throws Exception { + try (var h = new GraveActionsTest.Harness(); var core = mockStatic(AdvancedCorePlugin.class)) { + core.when(AdvancedCorePlugin::getInstance).thenReturn(h.plugin); h.bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + ArmorStand stand = mock(ArmorStand.class); Hologram hologram = create(h, stand); + field(h.grave, Grave.class, "topHologram", hologram); + var cleanup = new GraveMarkerCleanup(h.plugin, new DurableFeatureState(h.io, new FeaturePersistenceTest.Disk(), 8192)); + when(h.plugin.getMarkerCleanup()).thenReturn(cleanup); FeaturePersistenceTest.await(cleanup.loaded()); + doAnswer(call -> { ((GraveRemoveEvent) call.getArgument(0)).setCancelled(true); return null; }).when(h.events).callEvent(any()); + h.grave.removeGrave(GraveRemoveReason.MANUAL_REMOVE); assertTrue(h.regionWork.isEmpty()); + assertFalse(h.grave.isRemove()); assertSame(hologram, h.grave.getTopHologram()); + assertEquals(List.of(hologram), registered(h.plugin.getHologramHandler())); verify(stand, never()).remove(); + assertTrue(h.entityWork.isEmpty()); cleanup.retire(); + } + } + @Test void legacyUnloadedCleanupStillUnregistersKnownWrapperWithoutLoadingChunk() throws Exception { + try (var h = new GraveActionsTest.Harness(); var core = mockStatic(AdvancedCorePlugin.class)) { + core.when(AdvancedCorePlugin::getInstance).thenReturn(h.plugin); h.bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + ArmorStand stand = mock(ArmorStand.class); Hologram hologram = create(h, stand); + field(h.grave, Grave.class, "topHologram", hologram); + h.bukkit.when(Bukkit::isPrimaryThread).thenReturn(false); + when(h.world.isChunkLoaded(anyInt(), anyInt())).thenReturn(false); + h.grave.removeGrave(GraveRemoveReason.MANUAL_REMOVE); h.regionWork.remove().run(); + assertNull(h.grave.getTopHologram()); assertTrue(registered(h.plugin.getHologramHandler()).isEmpty()); + verify(stand, never()).remove(); h.entityWork.remove().run(); verify(stand).remove(); + verify(h.world, never()).getChunkAt(anyInt(), anyInt()); verify(h.world, never()).getBlockAt(any(Location.class)); + verify(h.scheduler, never()).executeOrScheduleSync(any(), any()); + } + } + @Test void lateEntityCreationAfterDurableRemovalUnregistersAndUsesEntityOwner() throws Exception { + try (var h = new GraveActionsTest.Harness(); var core = mockStatic(AdvancedCorePlugin.class)) { + core.when(AdvancedCorePlugin::getInstance).thenReturn(h.plugin); h.bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + ArmorStand stand = mock(ArmorStand.class); Hologram hologram = create(h, stand); + field(hologram, Hologram.class, "armorStand", null); field(h.grave, Grave.class, "topHologram", hologram); + doAnswer(call -> { h.regionWork.add(call.getArgument(1)); return null; }).when(h.scheduler) + .runTaskLater(eq(h.plugin), any(Runnable.class), eq(1L), any(Location.class)); + h.grave.tagHologram(hologram, 0); Runnable lateTag = h.regionWork.remove(); + var cleanup = new GraveMarkerCleanup(h.plugin, new DurableFeatureState(h.io, new FeaturePersistenceTest.Disk(), 8192)); + when(h.plugin.getMarkerCleanup()).thenReturn(cleanup); FeaturePersistenceTest.await(cleanup.loaded()); + h.grave.removeGrave(GraveRemoveReason.MANUAL_REMOVE); h.regionWork.remove().run(); FeaturePersistenceTest.await(h.io.barrier()); + assertTrue(registered(h.plugin.getHologramHandler()).isEmpty()); + field(hologram, Hologram.class, "armorStand", stand); h.bukkit.when(Bukkit::isPrimaryThread).thenReturn(false); + lateTag.run(); verify(stand, never()).remove(); h.entityWork.remove().run(); verify(stand).remove(); + assertTrue(registered(h.plugin.getHologramHandler()).isEmpty()); + verify(stand, never()).getPersistentDataContainer(); verify(h.scheduler, never()).executeOrScheduleSync(any(), any()); + cleanup.retire(); + } + } + @Test void invalidGlowRefreshDoesNotReleaseWrappersBeforeCancellableRemoval() throws Exception { + try (var h = new GraveActionsTest.Harness(); var core = mockStatic(AdvancedCorePlugin.class)) { + core.when(AdvancedCorePlugin::getInstance).thenReturn(h.plugin); h.bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + ArmorStand stand = mock(ArmorStand.class); Hologram hologram = create(h, stand); + field(h.grave, Grave.class, "topHologram", hologram); + var block = mock(org.bukkit.block.Block.class); when(block.getType()).thenReturn(Material.AIR); + when(h.world.getBlockAt(any(Location.class))).thenReturn(block); when(h.world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(block); + when(h.world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true); when(h.config.isGlowingEffectNearGrave()).thenReturn(true); + doCallRealMethod().when(h.plugin).refreshGraveGlow(any()); + doAnswer(call -> { ((GraveRemoveEvent) call.getArgument(0)).setCancelled(true); return null; }).when(h.events).callEvent(any()); + h.plugin.refreshGraveGlow(h.grave); h.regionWork.remove().run(); + assertSame(hologram, h.grave.getTopHologram()); assertEquals(List.of(hologram), registered(h.plugin.getHologramHandler())); + assertTrue(h.regionWork.isEmpty()); assertFalse(h.grave.isRemove()); + assertSame(hologram, h.grave.getTopHologram()); assertEquals(List.of(hologram), registered(h.plugin.getHologramHandler())); + verify(stand, never()).remove(); assertTrue(h.entityWork.isEmpty()); + } + } + @Test void glowRemovalLeavesRealRegistryEmptyForShutdownWithoutGlobalEntityKill() throws Exception { + try (var h = new GraveActionsTest.Harness(); var core = mockStatic(AdvancedCorePlugin.class)) { + core.when(AdvancedCorePlugin::getInstance).thenReturn(h.plugin); h.bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + ArmorStand stand = mock(ArmorStand.class); Hologram hologram = create(h, stand); + field(h.grave, Grave.class, "glowingHologram", hologram); + h.bukkit.when(Bukkit::isPrimaryThread).thenReturn(false); + h.grave.checkGlowing(); // Disabled glow uses the same unregister-only release. + assertNull(h.grave.getGlowingHologram()); assertTrue(registered(h.plugin.getHologramHandler()).isEmpty()); + h.entityWork.remove().run(); verify(stand).remove(); + assertDoesNotThrow(h.plugin.getHologramHandler()::onShutDown); + verify(h.scheduler, never()).executeOrScheduleSync(any(), any()); + } + } + @Test void lateTagOfReplacedWrapperRemovesOnlyItsOriginalStand() throws Exception { + try (var h = new GraveActionsTest.Harness(); var core = mockStatic(AdvancedCorePlugin.class)) { + core.when(AdvancedCorePlugin::getInstance).thenReturn(h.plugin); h.bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + ArmorStand previous = mock(ArmorStand.class), current = mock(ArmorStand.class); + Hologram old = create(h, previous), replacement = create(h, current); + field(h.grave, Grave.class, "topHologram", replacement); + h.bukkit.when(Bukkit::isPrimaryThread).thenReturn(false); + h.grave.tagHologram(old, 1); + assertEquals(List.of(replacement), registered(h.plugin.getHologramHandler())); + verify(previous, never()).remove(); h.entityWork.remove().run(); verify(previous).remove(); + verify(previous, never()).getPersistentDataContainer(); verify(current, never()).remove(); + assertSame(replacement, h.grave.getTopHologram()); + verify(h.scheduler, never()).executeOrScheduleSync(any(), any()); + } + } +} diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java new file mode 100644 index 0000000..988b4c3 --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java @@ -0,0 +1,1499 @@ +package com.bencodez.gravestonesplus.graves; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.*; +import org.bukkit.*; +import org.bukkit.block.*; +import org.bukkit.entity.Entity; +import org.bukkit.persistence.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import com.bencodez.gravestonesplus.storage.*; +import com.bencodez.gravestonesplus.listeners.GraveCleanupListener; +import com.bencodez.gravestonesplus.listeners.GraveClaimListener; +import com.bencodez.gravestonesplus.events.GraveRemoveReason; +import com.bencodez.gravestonesplus.listeners.GraveBlockProtectionListener; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +class MarkerRestorationRegressionTest { + @TempDir Path directory; + private GraveMarkerCleanup cleanup(GraveActionsTest.Harness h, DurableFeatureState state) throws Exception { + var cleanup = new GraveMarkerCleanup(h.plugin, state); when(h.plugin.getMarkerCleanup()).thenReturn(cleanup); FeaturePersistenceTest.await(cleanup.loaded()); return cleanup; + } + private static final class Marker { + final Block block=mock(Block.class); final Chunk chunk=mock(Chunk.class); final PersistentDataContainer chunkData=mock(PersistentDataContainer.class), tileData=mock(PersistentDataContainer.class); + final Map values=new HashMap<>(), tileValues=new HashMap<>(); + Marker(GraveActionsTest.Harness h) { + when(h.world.getBlockAt(anyInt(),anyInt(),anyInt())).thenReturn(block); when(h.world.getBlockAt(any(Location.class))).thenReturn(block); when(h.world.isChunkLoaded(anyInt(),anyInt())).thenReturn(true); + when(block.getX()).thenReturn(1); when(block.getY()).thenReturn(64); when(block.getZ()).thenReturn(1); when(block.getWorld()).thenReturn(h.world); when(block.getChunk()).thenReturn(chunk); + when(block.getType()).thenReturn(Material.PLAYER_HEAD); when(block.getMetadata("Grave")).thenReturn(List.of()); when(chunk.getPersistentDataContainer()).thenReturn(chunkData); when(chunk.getEntities()).thenReturn(new Entity[0]); + TileState tile=mock(TileState.class); when(block.getState()).thenReturn(tile); when(tile.getPersistentDataContainer()).thenReturn(tileData); + when(tileData.get(any(),eq(PersistentDataType.STRING))).thenAnswer(i->tileValues.get(i.getArgument(0))); + doAnswer(i->{tileValues.put(i.getArgument(0),i.getArgument(2));return null;}).when(tileData).set(any(),eq(PersistentDataType.STRING),anyString()); + when(chunkData.get(any(),eq(PersistentDataType.STRING))).thenAnswer(i->values.get(i.getArgument(0))); + doAnswer(i->{values.put(i.getArgument(0),i.getArgument(2));return null;}).when(chunkData).set(any(),eq(PersistentDataType.STRING),anyString()); + doAnswer(i->{values.remove(i.getArgument(0));return null;}).when(chunkData).remove(any()); + } + void removeForPhysicsRepair(Material expected) { + tileValues.clear(); + when(block.getType()).thenReturn(Material.AIR); + when(block.getState()).thenReturn(mock(BlockState.class)); + doAnswer(call -> { + TileState replacement = mock(TileState.class); + when(replacement.getPersistentDataContainer()).thenReturn(tileData); + when(block.getState()).thenReturn(replacement); + when(block.getType()).thenReturn(expected); + return null; + }).when(block).setType(expected, false); + } + } + @Test void paidClaimRetainsCleanupWhenRegionHandoffIsRejected() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var marker = new Marker(h); + GraveMarkerCleanup.tag(h.plugin, marker.block, h.identity()); + var disk = new FeaturePersistenceTest.Disk(); + var state = new DurableFeatureState(h.io, disk, 8192); + var cleanup = cleanup(h, state); + try { + doThrow(new IllegalStateException("region retired")).when(h.scheduler) + .runTask(eq(h.plugin), any(Runnable.class), any(Location.class)); + h.grave.claim(h.owner); h.drain(); + verify(h.ownerInventory).setItem(4, h.cloned); verify(h.owner).giveExp(17); + assertTrue(h.grave.isRemove()); assertTrue(h.grave.getGravesConfig().isDestroyed()); + assertTrue(h.ledger.isBlocked(h.identity())); + assertEquals("PLAYER_HEAD", state.snapshot().get(h.identity()), "paid marker cleanup must survive scheduler rejection"); + assertEquals("PLAYER_HEAD", disk.values.get(h.identity())); + verify(marker.block, never()).setType(any()); + // A repeated player request cannot repeat payout while cleanup is waiting. + h.grave.claim(h.owner); h.drain(); + verify(h.owner, times(1)).giveExp(17); verify(h.ownerInventory, times(1)).setItem(4, h.cloned); + doAnswer(call -> { h.regionWork.add(call.getArgument(1)); return null; }).when(h.scheduler) + .runTask(eq(h.plugin), any(Runnable.class), any(Location.class)); + cleanup.onChunk(h.world, 0, 0); h.regionWork.remove().run(); FeaturePersistenceTest.await(h.io.barrier()); + verify(marker.block).setType(Material.AIR); assertTrue(state.snapshot().isEmpty()); + } finally { cleanup.retire(); } + } + } + @Test void committedBukkitRemovalRetainsCleanupWhenRegionHandoffIsRejected() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var state = new DurableFeatureState(h.io, new FeaturePersistenceTest.Disk(), 8192); + var cleanup = cleanup(h, state); + try { + h.bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); when(h.folia.isFolia()).thenReturn(false); + doThrow(new IllegalStateException("region retired")).when(h.scheduler) + .runTask(eq(h.plugin), any(Runnable.class), any(Location.class)); + assertDoesNotThrow(() -> h.grave.removeGrave(GraveRemoveReason.MANUAL_REMOVE)); + FeaturePersistenceTest.await(h.io.barrier()); + assertTrue(h.grave.isRemove()); assertEquals("PLAYER_HEAD", state.snapshot().get(h.identity())); + verify(h.owner, never()).giveExp(anyInt()); + } finally { cleanup.retire(); } + } + } + private GraveRestoration restoration(GraveActionsTest.Harness h, GravesConfig record, + CompletableFuture initial, CompletableFuture chunk) { + h.active.clear(); when(h.config.isDisableArmorStands()).thenReturn(true); when(h.config.getGraveTimeLimit()).thenReturn("0"); + var ledger = mock(GraveClaimLedger.class); when(ledger.restoration(any())).thenReturn(initial, chunk); when(ledger.isReady()).thenReturn(true); + when(h.plugin.getGraveClaimLedger()).thenReturn(ledger); when(h.plugin.getFeatureIO()).thenReturn(h.io); + var backend = mock(GraveStorageManager.class); when(backend.loadGraves()).thenReturn(List.of(record)); when(backend.loadBrokenGraves()).thenReturn(List.of()); + doAnswer(i -> { h.active.add(i.getArgument(0)); return null; }).when(h.plugin).restoreActiveRecord(any()); + var restoration = new GraveRestoration(h.plugin, backend); when(h.plugin.getGraveRestoration()).thenReturn(restoration); return restoration; + } + private static Block neighbor(GraveActionsTest.Harness h, Material type, int x) { + var block = mock(Block.class); when(block.getWorld()).thenReturn(h.world); when(block.getType()).thenReturn(type); + when(block.getX()).thenReturn(x); when(block.getY()).thenReturn(64); when(block.getZ()).thenReturn(1); return block; + } + @Test void startupPdcFallbackProtectsExactTaggedPositionUntilRecordsLoad() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var marker = new Marker(h); GraveMarkerCleanup.tag(h.plugin, marker.block, h.identity()); + var backend = mock(GraveStorageManager.class); when(backend.isMySQL()).thenReturn(true); + when(backend.loadMySQLForRestoration()).thenReturn(new CompletableFuture<>()); + var restoration = new GraveRestoration(h.plugin, backend); when(h.plugin.getGraveRestoration()).thenReturn(restoration); + restoration.start(); + assertTrue(restoration.isLoading()); assertTrue(restoration.protectsPendingMarker(marker.block)); + when(marker.block.getMetadata("Grave")).thenReturn(List.of(new org.bukkit.metadata.FixedMetadataValue(h.plugin, h.grave))); + assertFalse(restoration.protectsPendingMarker(marker.block)); + when(marker.block.getMetadata("Grave")).thenReturn(List.of()); + when(marker.block.getType()).thenReturn(Material.AIR); + assertEquals(h.identity(), restoration.pendingMarkerIdentity(marker.block, Material.PLAYER_HEAD)); + when(marker.block.getType()).thenReturn(Material.PLAYER_HEAD); marker.tileValues.clear(); + assertNull(restoration.pendingMarkerIdentity(marker.block, Material.PLAYER_HEAD)); + GraveMarkerCleanup.tag(h.plugin, marker.block, h.identity()); + when(marker.block.getType()).thenReturn(Material.DIRT); assertFalse(restoration.protectsPendingMarker(marker.block)); + when(marker.block.getType()).thenReturn(Material.PLAYER_HEAD); + String[] successorParts = h.identity().split("/", -1); successorParts[1] = Long.toString(Long.parseLong(successorParts[1]) + 1); + GraveMarkerCleanup.tag(h.plugin, marker.block, String.join("/", successorParts)); + assertNotEquals(h.identity(), restoration.pendingMarkerIdentity(marker.block, Material.PLAYER_HEAD)); + restoration.retire(); + } + } + @ParameterizedTest @CsvSource({"PLAYER_HEAD,false", "PLAYER_HEAD,true", "CHEST,false", "CHEST,true"}) + void startupPhysicsCallbackRepairsOnlyTheCapturedTaggedMarker(Material type, boolean replaced) throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var marker = new Marker(h); + when(marker.block.getType()).thenReturn(type); + when(marker.block.getLocation()).thenReturn(h.grave.getGravesConfig().getLocation()); + GraveMarkerCleanup.tag(h.plugin, marker.block, h.identity()); + var backend = mock(GraveStorageManager.class); when(backend.isMySQL()).thenReturn(true); + var read = new CompletableFuture(); + when(backend.loadMySQLForRestoration()).thenReturn(read); + var restoration = new GraveRestoration(h.plugin, backend); when(h.plugin.getGraveRestoration()).thenReturn(restoration); + restoration.start(); + var physics = mock(org.bukkit.event.block.BlockPhysicsEvent.class); when(physics.getBlock()).thenReturn(marker.block); + new GraveBlockProtectionListener(h.plugin).onPhysics(physics); verify(physics).setCancelled(true); + if (replaced) { + String[] parts = h.identity().split("/", -1); parts[1] = Long.toString(Long.parseLong(parts[1]) + 1); + GraveMarkerCleanup.tag(h.plugin, marker.block, String.join("/", parts)); + } + marker.removeForPhysicsRepair(type); + assertFalse(restoration.protectsPendingMarker(marker.block)); + h.regionWork.remove().run(); + if (replaced) verify(marker.block, never()).setType(any(Material.class), anyBoolean()); + else { + verify(marker.block).setType(type, false); + assertEquals(h.identity(), marker.tileValues.get(GraveMarkerCleanup.identityKey(h.plugin))); + assertTrue(restoration.protectsPendingMarker(marker.block)); + var broken = new org.bukkit.event.block.BlockBreakEvent(marker.block, h.owner); + new GraveClaimListener(h.plugin).onBlockBreak(broken); assertTrue(broken.isCancelled()); + } + read.complete(new GraveStorageManager.RestorationRecords(List.of(), List.of())); h.drain(); + assertFalse(restoration.isLoading()); + when(marker.block.getType()).thenReturn(type); + assertFalse(restoration.protectsPendingMarker(marker.block)); + verify(h.owner, never()).giveExp(anyInt()); restoration.retire(); + } + } + @Test void partialMySqlAdmissionKeepsAnUnindexedTaggedSuccessorProtected() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + h.active.clear(); + var marker = new Marker(h); GraveMarkerCleanup.tag(h.plugin, marker.block, h.identity()); + var previous = new GravesConfig(h.owner.getUniqueId(), "Owner", new Location(h.world, 1, 64, 1), + new HashMap<>(), 0, "old", h.grave.getGravesConfig().getTime() - 1, false, 0, null, null, "PLAYER_HEAD"); + var records = new ArrayList(); records.add(previous); + for (int i = 1; i < 128; i++) records.add(new GravesConfig(h.owner.getUniqueId(), "Owner", + new Location(h.world, i + 1, 64, 1), new HashMap<>(), 0, "other", i, false, 0, null, null, "PLAYER_HEAD")); + records.add(h.grave.getGravesConfig()); + var backend = mock(GraveStorageManager.class); when(backend.isMySQL()).thenReturn(true); + when(backend.loadMySQLForRestoration()).thenReturn(CompletableFuture.completedFuture( + new GraveStorageManager.RestorationRecords(records, List.of()))); + var ledger = mock(GraveClaimLedger.class); when(ledger.restoration(any())).thenReturn(new CompletableFuture<>()); + when(h.plugin.getGraveClaimLedger()).thenReturn(ledger); + var restoration = new GraveRestoration(h.plugin, backend); when(h.plugin.getGraveRestoration()).thenReturn(restoration); + restoration.start(); + assertTrue(restoration.protectsPendingMarker(marker.block)); + h.ownerWork.remove().run(); + assertTrue(restoration.isLoading()); assertTrue(restoration.hasPending(GraveIdentity.of(previous))); + assertFalse(restoration.hasPending(h.identity())); verify(ledger, never()).restoration(any()); + assertNativeAndEnvironmentalProtection(h, marker.block, neighbor(h, Material.PLAYER_HEAD, 300)); + h.ownerWork.remove().run(); + assertFalse(restoration.isLoading()); assertTrue(restoration.hasPending(h.identity())); + assertTrue(restoration.protectsPendingMarker(marker.block)); + verify(marker.block, never()).setMetadata(anyString(), any()); + verify(marker.block, never()).setType(any(Material.class), anyBoolean()); restoration.retire(); + } + } + @ParameterizedTest @ValueSource(booleans = {false, true}) + void startupFallbackAndPhysicsRepairRejectKnownRemovedOrDestroyedRecords(boolean destroyed) throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var marker = new Marker(h); when(marker.block.getLocation()).thenReturn(h.grave.getGravesConfig().getLocation()); + GraveMarkerCleanup.tag(h.plugin, marker.block, h.identity()); + var backend = mock(GraveStorageManager.class); when(backend.isMySQL()).thenReturn(true); + when(backend.loadMySQLForRestoration()).thenReturn(new CompletableFuture<>()); + var restoration = new GraveRestoration(h.plugin, backend); when(h.plugin.getGraveRestoration()).thenReturn(restoration); + restoration.start(); + var physics = mock(org.bukkit.event.block.BlockPhysicsEvent.class); when(physics.getBlock()).thenReturn(marker.block); + new GraveBlockProtectionListener(h.plugin).onPhysics(physics); verify(physics).setCancelled(true); + Grave removed = mock(Grave.class); when(removed.getGravesConfig()).thenReturn(h.grave.getGravesConfig()); + when(removed.isRemove()).thenReturn(!destroyed); h.grave.getGravesConfig().setDestroyed(destroyed); + restoration.retainLimit(removed); + assertFalse(restoration.protectsPendingMarker(marker.block)); + marker.removeForPhysicsRepair(Material.PLAYER_HEAD); + assertNull(restoration.pendingMarkerIdentity(marker.block, Material.PLAYER_HEAD)); + h.regionWork.remove().run(); verify(marker.block, never()).setType(any(Material.class), anyBoolean()); + verify(h.owner, never()).giveExp(anyInt()); restoration.retire(); + } + } + @ParameterizedTest @ValueSource(booleans = {false, true}) + void startupPhysicsCannotResurrectClaimedOrRemovedLiveMetadata(boolean claimed) throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var marker = new Marker(h); when(marker.block.getLocation()).thenReturn(h.grave.getGravesConfig().getLocation()); + var metadata = new ArrayList(); + when(marker.block.getMetadata("Grave")).thenAnswer(call -> List.copyOf(metadata)); + when(marker.block.hasMetadata("Grave")).thenAnswer(call -> !metadata.isEmpty()); + doAnswer(call -> { metadata.clear(); metadata.add(call.getArgument(1)); return null; }) + .when(marker.block).setMetadata(eq("Grave"), any()); + doAnswer(call -> { metadata.clear(); return null; }).when(marker.block).removeMetadata("Grave", h.plugin); + h.grave.loadBlockMeta(marker.block); + var state = new DurableFeatureState(h.io, new FeaturePersistenceTest.Disk(), 8192); + var cleanup = cleanup(h, state); + var backend = mock(GraveStorageManager.class); when(backend.isMySQL()).thenReturn(true); + when(backend.loadMySQLForRestoration()).thenReturn(new CompletableFuture<>()); + var restoration = new GraveRestoration(h.plugin, backend); when(h.plugin.getGraveRestoration()).thenReturn(restoration); + restoration.start(); + var utilities = com.bencodez.advancedcore.api.misc.MiscUtils.getInstance(); + var metadataHost = utilities.getClass().getDeclaredField("plugin"); metadataHost.setAccessible(true); + Object previousHost = metadataHost.get(utilities); metadataHost.set(utilities, h.plugin); + try { + var physics = mock(org.bukkit.event.block.BlockPhysicsEvent.class); when(physics.getBlock()).thenReturn(marker.block); + new GraveBlockProtectionListener(h.plugin).onPhysics(physics); verify(physics).setCancelled(true); + if (claimed) { h.grave.claim(h.owner); h.drain(); verify(h.owner).giveExp(17); } + else { + h.bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + try { h.grave.removeGrave(GraveRemoveReason.MANUAL_REMOVE); } + finally { h.bukkit.when(Bukkit::isPrimaryThread).thenReturn(false); } + verify(h.owner, never()).giveExp(anyInt()); + } + assertTrue(h.grave.isRemove()); assertTrue(h.grave.getGravesConfig().isDestroyed()); + assertFalse(restoration.hasPending(h.identity())); assertTrue(restoration.pendingActiveForLimits().isEmpty()); + assertSame(h.grave, metadata.getFirst().value()); + assertEquals(h.identity(), marker.values.get(GraveMarkerCleanup.positionKey(h.plugin, marker.block))); + marker.removeForPhysicsRepair(Material.PLAYER_HEAD); + h.regionWork.remove().run(); + verify(marker.block, never()).setType(any(Material.class), anyBoolean()); + assertNull(restoration.pendingMarkerIdentity(marker.block, Material.PLAYER_HEAD)); + // Ordinary removal owns the cleanup ticket, but loading withholds + // its physical cleanup without publishing this grave into restoration. + h.regionWork.remove().run(); FeaturePersistenceTest.await(h.io.barrier()); + Runnable task; while ((task = h.regionWork.poll()) != null) task.run(); + assertEquals("PLAYER_HEAD", state.snapshot().get(h.identity())); + assertTrue(restoration.isLoading()); assertFalse(restoration.hasPending(h.identity())); + assertEquals(h.identity(), marker.values.get(GraveMarkerCleanup.positionKey(h.plugin, marker.block))); + if (claimed) { h.grave.claim(h.owner); h.drain(); verify(h.owner, times(1)).giveExp(17); } + } finally { metadataHost.set(utilities, previousHost); restoration.retire(); cleanup.retire(); } + } + } + private void assertNativeAndEnvironmentalProtection(GraveActionsTest.Harness h, Block marker, Block neighbor) { + var claims = new GraveClaimListener(h.plugin); var protection = new GraveBlockProtectionListener(h.plugin); + var broken = new org.bukkit.event.block.BlockBreakEvent(marker, h.owner); claims.onBlockBreak(broken); assertTrue(broken.isCancelled()); + var adjacent = new org.bukkit.event.block.BlockBreakEvent(neighbor, h.owner); claims.onBlockBreak(adjacent); assertFalse(adjacent.isCancelled()); + var interact = new org.bukkit.event.player.PlayerInteractEvent(h.owner, org.bukkit.event.block.Action.RIGHT_CLICK_BLOCK, + null, marker, org.bukkit.block.BlockFace.UP); + claims.onPlayerInteract(interact); assertTrue(interact.isCancelled()); + when(h.owner.getEyeLocation()).thenReturn(new Location(h.world, 1, 64, 1)); + when(h.world.rayTraceBlocks(any(Location.class), any(org.bukkit.util.Vector.class), eq(6.0), eq(FluidCollisionMode.NEVER), eq(true))) + .thenReturn(new org.bukkit.util.RayTraceResult(new org.bukkit.util.Vector(1, 64, 1), marker, org.bukkit.block.BlockFace.UP)); + claims.onPlayerAnimation(new org.bukkit.event.player.PlayerAnimationEvent(h.owner)); assertTrue(claims.getMining().isEmpty()); + var entityExplosion = mock(org.bukkit.event.entity.EntityExplodeEvent.class); var entityBlocks = new ArrayList<>(List.of(marker, neighbor)); + when(entityExplosion.blockList()).thenReturn(entityBlocks); protection.onEntityExplode(entityExplosion); assertEquals(List.of(neighbor), entityBlocks); + var blockExplosion = mock(org.bukkit.event.block.BlockExplodeEvent.class); var blockBlocks = new ArrayList<>(List.of(marker, neighbor)); + when(blockExplosion.blockList()).thenReturn(blockBlocks); protection.onBlockExplode(blockExplosion); assertEquals(List.of(neighbor), blockBlocks); + var fluid = mock(Block.class); when(fluid.getType()).thenReturn(Material.WATER); + var flow = new org.bukkit.event.block.BlockFromToEvent(fluid, marker); protection.onBlockFromTo(flow); assertTrue(flow.isCancelled()); + var freeFlow = new org.bukkit.event.block.BlockFromToEvent(fluid, neighbor); protection.onBlockFromTo(freeFlow); assertFalse(freeFlow.isCancelled()); + var bucket = mock(org.bukkit.event.player.PlayerBucketEmptyEvent.class); when(bucket.getBlock()).thenReturn(marker); + protection.onPlayerBucketEmpty(bucket); verify(bucket).setCancelled(true); + var extend = mock(org.bukkit.event.block.BlockPistonExtendEvent.class); when(extend.getBlocks()).thenReturn(List.of(marker)); + protection.onPistonExtend(extend); verify(extend).setCancelled(true); + var retract = mock(org.bukkit.event.block.BlockPistonRetractEvent.class); when(retract.getBlocks()).thenReturn(List.of(marker)); + protection.onPistonRetract(retract); verify(retract).setCancelled(true); + var drops = mock(org.bukkit.event.block.BlockDropItemEvent.class); when(drops.getBlock()).thenReturn(marker); + protection.onBlockDropItem(drops); verify(drops).setCancelled(true); + var physics = mock(org.bukkit.event.block.BlockPhysicsEvent.class); when(physics.getBlock()).thenReturn(marker); + protection.onPhysics(physics); verify(physics).setCancelled(true); + verify(h.owner, never()).giveExp(anyInt()); verify(h.ownerInventory, never()).setItem(anyInt(), any()); + verify(h.events, never()).callEvent(any(com.bencodez.gravestonesplus.events.GraveClaimEvent.class)); + } + @ParameterizedTest @EnumSource(value = Material.class, names = {"PLAYER_HEAD", "BARRIER", "CHEST"}) + void startupIndexesCachedFlatRecordsBeforeTheDiskOwnerOrArchiveCanRun(Material type) throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var marker = new Marker(h); when(marker.block.getType()).thenReturn(type); + when(marker.block.getLocation()).thenReturn(h.grave.getGravesConfig().getLocation()); + h.grave.getGravesConfig().setGraveDisplayType(type == Material.BARRIER ? "DISPLAY_ENTITY" : type.name()); + var held = new CompletableFuture(); + var restoration = restoration(h, h.grave.getGravesConfig(), held, held); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + var blocked = h.io.submit(() -> { entered.countDown(); try { release.await(); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } return null; }); + try { + assertTrue(entered.await(2, TimeUnit.SECONDS)); + h.bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + try { restoration.start(); } + finally { h.bukkit.when(Bukkit::isPrimaryThread).thenReturn(false); } + assertFalse(restoration.isLoading()); assertTrue(h.active.isEmpty()); + assertTrue(restoration.protectsPendingMarker(marker.block)); + assertNativeAndEnvironmentalProtection(h, marker.block, neighbor(h, type, 2)); + verify(marker.block, never()).setMetadata(anyString(), any()); + verify(h.world, never()).getChunkAt(anyInt(), anyInt()); + } finally { restoration.retire(); release.countDown(); blocked.get(2, TimeUnit.SECONDS); } + } + } + @ParameterizedTest @EnumSource(value = Material.class, names = {"PLAYER_HEAD", "BARRIER", "CHEST"}) + void startupOffOwnerProtectsCachedMarkersBeforePreparation(Material type) throws Exception { + try (var h = new GraveActionsTest.Harness()) { + h.grave.getGravesConfig().setGraveDisplayType(type == Material.BARRIER ? "DISPLAY_ENTITY" : type.name()); + var owners = new ArrayDeque(); + doAnswer(call -> { owners.add(call.getArgument(1)); return null; }) + .when(h.scheduler).runTask(eq(h.plugin), any(Runnable.class)); + var held = new CompletableFuture(); + var restoration = restoration(h, h.grave.getGravesConfig(), held, held); + restoration.start(); + assertTrue(restoration.isLoading()); assertEquals(1, owners.size()); + verify(h.grave.getGravesConfig().getItems().get(4), never()).serialize(); + assertTrue(restoration.hasPending(h.identity())); + var marker = new Marker(h); when(marker.block.getType()).thenReturn(type); + assertTrue(restoration.protectsPendingMarker(marker.block)); + assertNativeAndEnvironmentalProtection(h, marker.block, neighbor(h, type, 2)); + verify(h.grave.getGravesConfig().getItems().get(4), never()).clone(); + verify(h.grave.getGravesConfig().getItems().get(4), never()).serialize(); + restoration.onChunk(h.world.getUID(), 0, 0); + assertTrue(restoration.isLoading()); assertTrue(h.active.isEmpty()); + verify(h.plugin.getGraveClaimLedger(), never()).restoration(anyString()); + h.bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + try { owners.remove().run(); owners.remove().run(); } + finally { h.bukkit.when(Bukkit::isPrimaryThread).thenReturn(false); } + assertFalse(restoration.isLoading()); assertTrue(restoration.hasPending(h.identity())); + assertTrue(owners.isEmpty()); restoration.retire(); + } + } + @ParameterizedTest @ValueSource(booleans = {false, true}) + void largeCachedStartupBudgetsItemRetentionAndRetiredCallbacksCannotFinish(boolean interrupt) throws Exception { + try (var h = new GraveActionsTest.Harness(); var encoding = mockStatic(InventorySerializer.class)) { + encoding.when(() -> InventorySerializer.encode(any())).thenReturn("captured"); + when(h.plugin.getFeatureIO()).thenReturn(h.io); h.active.clear(); + List records = new ArrayList<>(); + for (int i = 0; i < 260; i++) records.add(new GravesConfig(h.owner.getUniqueId(), "owner", + new Location(h.world, 1, 64, 1), new HashMap<>(h.grave.getGravesConfig().getItems()), + 17, "death", i + 1, false, 0, null, null, "PLAYER_HEAD")); + var backend = mock(GraveStorageBackend.class); + when(backend.loadGraves()).thenReturn(records); when(backend.loadBrokenGraves()).thenReturn(List.of()); + var manager = new GraveStorageManager(h.plugin); + var field = GraveStorageManager.class.getDeclaredField("backend"); field.setAccessible(true); field.set(manager, backend); + var ledger = mock(GraveClaimLedger.class); when(ledger.restoration(any())).thenReturn(new CompletableFuture<>()); + when(h.plugin.getGraveClaimLedger()).thenReturn(ledger); + h.bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); clearInvocations(h.stored); + var restoration = new GraveRestoration(h.plugin, manager); + restoration.start(); assertTrue(restoration.isLoading()); assertEquals(1, h.ownerWork.size()); + verify(h.stored, times(128)).clone(); verify(ledger, never()).restoration(any()); + restoration.retry(); assertEquals(1, h.ownerWork.size()); verify(backend, times(1)).loadGraves(); + if (interrupt) { + restoration.retire(); h.ownerWork.remove().run(); assertTrue(restoration.isLoading()); + verify(h.stored, times(128)).clone(); verify(ledger, never()).restoration(any()); + // An owner snapshot during partial startup must merge with the + // unread disk baseline, rather than replace it on shutdown. + manager.saveGravesFromObjects(List.of()); + manager.close(); FeaturePersistenceTest.await(h.io.barrier()); + verify(backend).saveStartupSnapshot(argThat(list -> list.size() == 128), eq(List.of())); + verify(backend, never()).saveSnapshot(any(), any()); + } else { + h.ownerWork.remove().run(); assertTrue(restoration.isLoading()); verify(h.stored, times(256)).clone(); + h.ownerWork.remove().run(); assertFalse(restoration.isLoading()); verify(h.stored, times(260)).clone(); + assertTrue(h.ownerWork.isEmpty()); assertEquals(260, restoration.pendingActiveForLimits().size()); + verify(backend, never()).saveSnapshot(any(), any()); restoration.retire(); + } + } + } + @Test void pendingStoredActiveGraveStillCountsTowardTheOwnerLimit() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var held = new CompletableFuture(); + var restoration = restoration(h, h.grave.getGravesConfig(), held, held); + when(h.plugin.numberOfGraves(any())).thenCallRealMethod(); + h.bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + try { restoration.start(); } + finally { h.bukkit.when(Bukkit::isPrimaryThread).thenReturn(false); } + try { + assertTrue(h.active.isEmpty(), "The held archive decision must withhold gameplay admission"); + assertEquals(1, h.plugin.numberOfGraves(h.grave.getGravesConfig().getUuid()), + "Stored active graves must not disappear from limit accounting during restoration"); + verify(h.world, never()).getChunkAt(anyInt(), anyInt()); + verify(h.events, never()).callEvent(any()); + } finally { restoration.retire(); } + } + } + + @Test void limitAccountingDeduplicatesPublishedIdentityWithoutAdmittingPendingClaims() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var held = new CompletableFuture(); + var restoration = restoration(h, h.grave.getGravesConfig(), held, held); + when(h.plugin.numberOfGraves(any())).thenCallRealMethod(); + when(h.plugin.getOldestGrave(any())).thenCallRealMethod(); + h.bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + try { restoration.start(); } finally { h.bukkit.when(Bukkit::isPrimaryThread).thenReturn(false); } + try { + Grave pending = h.plugin.getOldestGrave(h.owner.getUniqueId()); + assertNotNull(pending); assertSame(h.grave.getGravesConfig(), pending.getGravesConfig()); + assertFalse(h.plugin.isLiveGrave(pending)); + pending.claim(h.owner); h.drain(); + verify(h.owner, never()).giveExp(anyInt()); verify(h.ownerInventory, never()).setItem(anyInt(), any()); + h.active.add(pending); + assertEquals(1, h.plugin.numberOfGraves(h.owner.getUniqueId())); + assertEquals(0, h.plugin.numberOfGraves(h.helper.getUniqueId())); + } finally { restoration.retire(); } + } + } + @Test void brokenRecordsDoNotReserveActiveLimitCapacity() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + h.active.clear(); when(h.plugin.numberOfGraves(any())).thenCallRealMethod(); + var backend = mock(GraveStorageManager.class); + when(backend.loadGraves()).thenReturn(List.of()); + // The legacy broken section, not merely the destroyed flag, defines history. + when(backend.loadBrokenGraves()).thenReturn(List.of(h.grave.getGravesConfig())); + var ledger = mock(GraveClaimLedger.class); + when(ledger.restoration(any())).thenReturn(new CompletableFuture<>()); + when(h.plugin.getGraveClaimLedger()).thenReturn(ledger); + var restoration = new GraveRestoration(h.plugin, backend); when(h.plugin.getGraveRestoration()).thenReturn(restoration); + h.bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + try { restoration.start(); } finally { h.bukkit.when(Bukkit::isPrimaryThread).thenReturn(false); } + try { assertEquals(0, h.plugin.numberOfGraves(h.owner.getUniqueId())); } + finally { restoration.retire(); } + } + } + @Test void pendingLimitAdmissionRejectsDifferentWrapperAndRetiredRestoration() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var held = new CompletableFuture(); + var restoration = restoration(h, h.grave.getGravesConfig(), held, held); + h.bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + try { restoration.start(); } finally { h.bukkit.when(Bukkit::isPrimaryThread).thenReturn(false); } + try { + Grave pending = restoration.pendingActiveForLimits().getFirst(); + assertTrue(restoration.requestLimitRemoval(pending, true)); + assertTrue(restoration.hasPendingLimitRemoval(pending)); + Grave impostor = new Grave(h.plugin, pending.getGravesConfig()); + assertFalse(restoration.hasPendingLimitRemoval(impostor)); + assertFalse(restoration.requestLimitRemoval(impostor, true)); + impostor.queuePendingLimitPayout(); + verify(h.plugin.getGraveClaimLedger(), never()).admitLimit(any(), any(), any()); + restoration.retire(); + assertFalse(restoration.hasPendingLimitRemoval(pending)); + pending.queuePendingLimitPayout(); + verify(h.plugin.getGraveClaimLedger(), never()).admitLimit(any(), any(), any()); + } finally { restoration.retire(); } + } + } + @ParameterizedTest @ValueSource(booleans = { false, true }) + void pendingLimitIntentWaitsForArchiveAndFirstRequestWins(boolean drop) throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var held = new CompletableFuture(); + var restoration = restoration(h, h.grave.getGravesConfig(), held, held); + when(h.plugin.getOldestGrave(any())).thenCallRealMethod(); + var ledger = h.plugin.getGraveClaimLedger(); + when(ledger.admitLimit(any(), any(), any())).thenReturn(CompletableFuture.completedFuture(false)); + doAnswer(call -> { if (call.getArgument(0) instanceof com.bencodez.gravestonesplus.events.GraveRemoveEvent event) + event.setCancelled(true); return null; }).when(h.events).callEvent(any()); + h.bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + try { restoration.start(); } finally { h.bukkit.when(Bukkit::isPrimaryThread).thenReturn(false); } + try { + Grave pending = h.plugin.getOldestGrave(h.owner.getUniqueId()); assertNotNull(pending); + assertTrue(restoration.requestLimitRemoval(pending, drop)); + assertTrue(restoration.requestLimitRemoval(pending, !drop)); + assertNull(h.plugin.getOldestGrave(h.owner.getUniqueId()), "An admitted replacement is not selected twice"); + verify(ledger, never()).admitLimit(any(), any(), any()); verify(h.events, never()).callEvent(any()); + assertTrue(h.active.isEmpty()); + held.complete(GraveClaimLedger.Restoration.AVAILABLE); + Runnable task; while ((task = h.regionWork.poll()) != null) task.run(); + if (drop) { + String identity = h.identity(); + UUID ownerId = h.owner.getUniqueId(); + verify(ledger).admitLimit(eq(identity), eq(ownerId), anyString()); + verify(h.events, never()).callEvent(any()); + } else { + verify(ledger, never()).admitLimit(any(), any(), any()); + verify(h.events).callEvent(any(com.bencodez.gravestonesplus.events.GraveRemoveEvent.class)); + } + assertFalse(pending.isRemove()); assertFalse(h.grave.getGravesConfig().isDestroyed()); + verify(h.world, never()).getChunkAt(anyInt(), anyInt()); + } finally { restoration.retire(); } + } + } + @ParameterizedTest @EnumSource(value = GraveClaimLedger.Restoration.class, names = {"RESERVED", "UNAVAILABLE"}) + void reservedOrUnavailableArchiveCannotExecutePendingLimitIntent(GraveClaimLedger.Restoration decision) throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var held = new CompletableFuture(); + var restoration = restoration(h, h.grave.getGravesConfig(), held, held); + var ledger = h.plugin.getGraveClaimLedger(); + h.bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + try { restoration.start(); } finally { h.bukkit.when(Bukkit::isPrimaryThread).thenReturn(false); } + try { + Grave pending = restoration.pendingActiveForLimits().getFirst(); + assertTrue(restoration.requestLimitRemoval(pending, true)); held.complete(decision); + Runnable task; while ((task = h.regionWork.poll()) != null) task.run(); + assertTrue(h.active.isEmpty()); verify(ledger, never()).admitLimit(any(), any(), any()); + verify(h.events, never()).callEvent(any()); assertFalse(pending.isRemove()); + } finally { restoration.retire(); } + } + } + + @Test void lateOwnerAcceptanceCannotPublishRecordsAfterRetirement() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + h.bukkit.when(Bukkit::isPrimaryThread).thenReturn(false); + var owners = new ArrayDeque(); + doAnswer(call -> { owners.add(call.getArgument(1)); return null; }) + .when(h.scheduler).runTask(eq(h.plugin), any(Runnable.class)); + var held = new CompletableFuture(); + var restoration = restoration(h, h.grave.getGravesConfig(), held, held); + restoration.retry(); assertTrue(restoration.isLoading()); + owners.remove().run(); // read is complete; accepting/encoding remains owner scheduled + assertEquals(1, owners.size()); restoration.retire(); owners.remove().run(); + assertTrue(restoration.isLoading()); assertFalse(restoration.hasPending(h.identity())); + verify(h.plugin.getGraveClaimLedger(), never()).restoration(any()); + } + } + @ParameterizedTest @EnumSource(value = Material.class, names = {"PLAYER_HEAD", "BARRIER", "CHEST"}) + void actualNativeAndEnvironmentalListenersProtectExactPendingMarkersAcrossArchiveAndChunkAdmission(Material type) throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var marker = new Marker(h); when(marker.block.getType()).thenReturn(type); when(marker.block.getLocation()).thenReturn(h.grave.getGravesConfig().getLocation()); + h.grave.getGravesConfig().setGraveDisplayType(type == Material.BARRIER ? "DISPLAY_ENTITY" : type.name()); + when(marker.chunk.getWorld()).thenReturn(h.world); when(marker.chunk.getX()).thenReturn(0); when(marker.chunk.getZ()).thenReturn(0); + // A barrier restored by this test needs no entity creation to exercise native protection. + if (type == Material.BARRIER) when(h.plugin.getGraveDisplayEntityHandler()).thenReturn(mock(GraveDisplayEntityHandle.class)); + var initial = new CompletableFuture(); var chunk = new CompletableFuture(); + var restoration = restoration(h, h.grave.getGravesConfig(), initial, chunk); + when(h.world.isChunkLoaded(anyInt(), anyInt())).thenReturn(false); + GraveActionsTest.retryOnOwner(h, restoration); FeaturePersistenceTest.await(h.io.barrier()); + when(h.world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true); + var adjacent = neighbor(h, type, 2); + assertNativeAndEnvironmentalProtection(h, marker.block, adjacent); + assertTrue(h.active.isEmpty()); verify(marker.block, never()).setMetadata(anyString(), any()); + verify(h.plugin.getGraveClaimLedger(), times(1)).restoration(any()); + h.regionWork.remove().run(); // Deferred physics repair cannot create a second marker. + when(h.world.isChunkLoaded(anyInt(), anyInt())).thenReturn(false); + initial.complete(GraveClaimLedger.Restoration.AVAILABLE); h.regionWork.remove().run(); + assertEquals(1, h.active.size()); verify(marker.block, never()).setMetadata(anyString(), any()); + when(h.world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true); + new GraveCleanupListener(h.plugin).onChunkLoad(new org.bukkit.event.world.ChunkLoadEvent(marker.chunk, false)); + assertNativeAndEnvironmentalProtection(h, marker.block, adjacent); + assertTrue(restoration.hasPending(h.identity())); verify(marker.block, never()).setMetadata(anyString(), any()); + verify(h.plugin.getGraveClaimLedger(), times(2)).restoration(any()); + h.regionWork.remove().run(); + chunk.complete(GraveClaimLedger.Restoration.AVAILABLE); h.regionWork.remove().run(); + assertFalse(restoration.hasPending(h.identity())); assertFalse(restoration.protectsPendingMarker(marker.block)); + verify(marker.block, times(1)).setMetadata(eq("Grave"), any()); + verify(marker.block, never()).setType(any(Material.class), anyBoolean()); verify(h.world, never()).getChunkAt(anyInt(), anyInt()); + restoration.retire(); + } + } + @Test void exactPendingProtectionRejectsChangedRemovedAndSuccessorRecordsWithoutProtectingOtherWorlds() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var marker = new Marker(h); var admission = new CompletableFuture(); + var restoration = restoration(h, h.grave.getGravesConfig(), admission, admission); + GraveActionsTest.retryOnOwner(h, restoration); FeaturePersistenceTest.await(h.io.barrier()); assertTrue(restoration.protectsPendingMarker(marker.block)); + var otherWorld = mock(World.class); when(otherWorld.getUID()).thenReturn(UUID.randomUUID()); + var other = neighbor(h, Material.PLAYER_HEAD, 1); when(other.getWorld()).thenReturn(otherWorld); + assertFalse(restoration.protectsPendingMarker(other)); assertFalse(restoration.protectsPendingMarker(neighbor(h, Material.PLAYER_HEAD, 2))); + when(marker.block.getType()).thenReturn(Material.CHEST); assertFalse(restoration.protectsPendingMarker(marker.block)); + when(marker.block.getType()).thenReturn(Material.PLAYER_HEAD); + h.grave.getGravesConfig().getLocation().setX(2); assertFalse(restoration.protectsPendingMarker(marker.block)); + h.grave.getGravesConfig().getLocation().setX(1); + h.grave.getGravesConfig().setDestroyed(true); assertFalse(restoration.protectsPendingMarker(marker.block)); + var broken = new org.bukkit.event.block.BlockBreakEvent(marker.block, h.owner); new GraveClaimListener(h.plugin).onBlockBreak(broken); assertFalse(broken.isCancelled()); + h.grave.getGravesConfig().setDestroyed(false); + GraveMarkerCleanup.tag(h.plugin, marker.block, h.identity() + "-successor"); assertFalse(restoration.protectsPendingMarker(marker.block)); + marker.values.clear(); marker.tileValues.clear(); assertTrue(restoration.protectsPendingMarker(marker.block)); + restoration.retire(); assertFalse(restoration.protectsPendingMarker(marker.block)); + } + } + @Test void pendingPhysicsRepairCannotResurrectARemovedRecordAfterAdmissionDelay() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var marker = new Marker(h); when(marker.block.getLocation()).thenReturn(h.grave.getGravesConfig().getLocation()); + var admission = new CompletableFuture(); + var restoration = restoration(h, h.grave.getGravesConfig(), admission, admission); + GraveActionsTest.retryOnOwner(h, restoration); FeaturePersistenceTest.await(h.io.barrier()); + var physics = mock(org.bukkit.event.block.BlockPhysicsEvent.class); when(physics.getBlock()).thenReturn(marker.block); + new GraveBlockProtectionListener(h.plugin).onPhysics(physics); verify(physics).setCancelled(true); + h.grave.getGravesConfig().setDestroyed(true); when(marker.block.getType()).thenReturn(Material.AIR); + h.regionWork.remove().run(); verify(marker.block, never()).setType(any(Material.class), anyBoolean()); + assertFalse(restoration.protectsPendingMarker(marker.block)); restoration.retire(); + } + } + @ParameterizedTest @ValueSource(booleans = {false, true}) + void pendingPhysicsRepairRestoresOnlyItsStillOwnedMarker(boolean replaced) throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var marker = new Marker(h); when(marker.block.getLocation()).thenReturn(h.grave.getGravesConfig().getLocation()); + var admission = new CompletableFuture(); + var restoration = restoration(h, h.grave.getGravesConfig(), admission, admission); + GraveActionsTest.retryOnOwner(h, restoration); FeaturePersistenceTest.await(h.io.barrier()); + var physics = mock(org.bukkit.event.block.BlockPhysicsEvent.class); when(physics.getBlock()).thenReturn(marker.block); + new GraveBlockProtectionListener(h.plugin).onPhysics(physics); verify(physics).setCancelled(true); + if (replaced) GraveMarkerCleanup.tag(h.plugin, marker.block, h.identity() + "-successor"); + when(marker.block.getType()).thenReturn(Material.AIR); h.regionWork.remove().run(); + if (replaced) verify(marker.block, never()).setType(any(Material.class), anyBoolean()); + else verify(marker.block).setType(Material.PLAYER_HEAD, false); + verify(h.plugin.getGraveClaimLedger(), times(1)).restoration(any()); + verify(h.owner, never()).giveExp(anyInt()); restoration.retire(); + } + } + @ParameterizedTest @ValueSource(booleans = {false, true}) + void repairedDisplayIdentityIsAcknowledgedBeforeRestorationResolvesAndRetriesFailure(boolean mysql) throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var marker = new Marker(h); when(marker.block.getType()).thenReturn(Material.BARRIER); + when(marker.block.getLocation()).thenReturn(h.grave.getGravesConfig().getLocation()); + var record = h.grave.getGravesConfig(); record.setGraveDisplayType("DISPLAY_ENTITY"); + UUID oldDisplay = UUID.randomUUID(), replacement = UUID.randomUUID(); record.setDisplayUUID(oldDisplay); + var graveKey = new NamespacedKey(h.plugin, "grave"); when(h.plugin.getKey()).thenReturn(graveKey); + var display = mock(org.bukkit.entity.ItemDisplay.class); when(display.getUniqueId()).thenReturn(replacement); + when(display.getPersistentDataContainer()).thenReturn(mock(PersistentDataContainer.class)); + var handler = mock(GraveDisplayEntityHandle.class); when(h.plugin.getGraveDisplayEntityHandler()).thenReturn(handler); + when(handler.createDisplay(any())).thenReturn(display); + var decision = CompletableFuture.completedFuture(GraveClaimLedger.Restoration.AVAILABLE); + var restoration = restoration(h, record, decision, decision); + var field = GraveRestoration.class.getDeclaredField("storage"); field.setAccessible(true); + var backend = (GraveStorageManager) field.get(restoration); when(backend.isMySQL()).thenReturn(mysql); + when(backend.loadMySQLForRestoration()).thenReturn(CompletableFuture.completedFuture( + new GraveStorageManager.RestorationRecords(List.of(record), List.of()))); + var first = new CompletableFuture(); var retry = new CompletableFuture(); + if (mysql) when(backend.saveDisplayIdentity(any())).thenReturn(first, retry); + else when(backend.saveSnapshotFromObjects(any(), any())).thenReturn(first, retry); + GraveActionsTest.retryOnOwner(h, restoration); FeaturePersistenceTest.await(h.io.barrier()); h.drain(); h.regionWork.remove().run(); + assertEquals(replacement, record.getDisplayUUID()); assertTrue(restoration.hasPending(h.identity())); + verify(backend, never()).restorationResolved(any()); + if (mysql) verify(backend).saveDisplayIdentity(record); + else verify(backend).saveSnapshotFromObjects(eq(h.active), any()); + first.completeExceptionally(new IllegalStateException("fixture display write unavailable")); + assertTrue(restoration.hasPending(h.identity())); + GraveActionsTest.retryOnOwner(h, restoration); FeaturePersistenceTest.await(h.io.barrier()); h.drain(); h.regionWork.remove().run(); + verify(handler, times(1)).createDisplay(any()); // retry reuses the repaired display, not another spawn + assertTrue(restoration.hasPending(h.identity())); + retry.complete(true); + verify(backend, never()).restorationResolved(any()); // completion must return to the marker's owner + h.regionWork.remove().run(); assertFalse(restoration.hasPending(h.identity())); verify(backend).restorationResolved(record); + restoration.retire(); + } + } + @ParameterizedTest @ValueSource(booleans = {false, true}) + void actualRestorationPreservesAdoptedLegacyAndIdentityTaggedDisplaysAfterDelayedEntityCleanup(boolean alreadyTagged) throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var marker = new Marker(h); when(marker.block.getType()).thenReturn(Material.BARRIER); when(marker.block.getLocation()).thenReturn(h.grave.getGravesConfig().getLocation()); + h.grave.getGravesConfig().setGraveDisplayType("DISPLAY_ENTITY"); UUID displayId = UUID.randomUUID(); h.grave.getGravesConfig().setDisplayUUID(displayId); + var graveKey = new NamespacedKey(h.plugin, "grave"); + when(h.plugin.getKey()).thenReturn(graveKey); + var display = mock(org.bukkit.entity.ItemDisplay.class); when(display.getUniqueId()).thenReturn(displayId); when(display.getType()).thenReturn(org.bukkit.entity.EntityType.ITEM_DISPLAY); + var displayData = mock(PersistentDataContainer.class); when(display.getPersistentDataContainer()).thenReturn(displayData); + Map tags = new HashMap<>(); if (alreadyTagged) tags.put(GraveMarkerCleanup.identityKey(h.plugin), h.identity()); + when(displayData.get(any(), eq(PersistentDataType.STRING))).thenAnswer(i -> tags.get(i.getArgument(0))); + when(displayData.get(h.plugin.getKey(), PersistentDataType.INTEGER)).thenReturn(1); + doAnswer(i -> { tags.put(i.getArgument(0), i.getArgument(2)); return null; }).when(displayData).set(any(), eq(PersistentDataType.STRING), anyString()); + h.bukkit.when(() -> Bukkit.getEntity(displayId)).thenReturn(display); + var stale = mock(org.bukkit.entity.ItemDisplay.class); var foreign = mock(org.bukkit.entity.ArmorStand.class); + for (Entity entity : List.of(stale, foreign)) { + when(entity.getType()).thenReturn(entity == stale ? org.bukkit.entity.EntityType.ITEM_DISPLAY : org.bukkit.entity.EntityType.ARMOR_STAND); + var data = mock(PersistentDataContainer.class); when(entity.getPersistentDataContainer()).thenReturn(data); + when(data.get(any(), eq(PersistentDataType.STRING))).thenReturn(entity == stale ? h.identity() : "successor-identity"); + } + when(h.world.getNearbyEntities(any(Location.class), anyDouble(), anyDouble(), anyDouble())).thenReturn(List.of(display, stale, foreign)); + doAnswer(i -> { h.entityWork.add(i.getArgument(1)); return null; }).when(h.scheduler).runTask(eq(h.plugin), any(Runnable.class), any(Entity.class)); + // A predecessor's queued reconciliation must respect the new live instance's adoption too. + h.grave.removeHologramsAround(); + var admission = new CompletableFuture(); var restoration = restoration(h, h.grave.getGravesConfig(), admission, admission); + GraveActionsTest.retryOnOwner(h, restoration); FeaturePersistenceTest.await(h.io.barrier()); admission.complete(GraveClaimLedger.Restoration.AVAILABLE); h.regionWork.remove().run(); + assertFalse(restoration.hasPending(h.identity())); assertEquals(1, h.active.size()); assertSame(display, h.active.getFirst().getItemDisplay()); + assertEquals(h.identity(), tags.get(GraveMarkerCleanup.identityKey(h.plugin))); verify(display, never()).remove(); + while (!h.entityWork.isEmpty()) h.entityWork.remove().run(); + assertSame(display, h.active.getFirst().getItemDisplay()); verify(display, never()).remove(); verify(stale, atLeastOnce()).remove(); verify(foreign, never()).remove(); + assertEquals(displayId, h.active.getFirst().getGravesConfig().getDisplayUUID()); verify(marker.block, never()).setType(any(Material.class), anyBoolean()); + restoration.retire(); + } + } + @Test void expiredUnloadedCleanupSurvivesHistoricalEvictionAndJournalRestartWithoutLoading() throws Exception { + try(var h=new GraveActionsTest.Harness()) { + DurableFeatureState state=new DurableFeatureState(h.io,directory.resolve("cleanup.yml"),8192); var cleanup=cleanup(h,state); var marker=new Marker(h); + GraveMarkerCleanup.tag(h.plugin,marker.block,h.identity()); when(h.world.isChunkLoaded(anyInt(),anyInt())).thenReturn(false); + h.grave.removeGrave(com.bencodez.gravestonesplus.events.GraveRemoveReason.MANUAL_REMOVE); h.regionWork.remove().run(); FeaturePersistenceTest.await(h.io.barrier()); + h.regionWork.remove().run(); assertTrue(state.snapshot().containsKey(h.identity())); assertTrue(h.active.isEmpty()); verify(marker.block,never()).setType(Material.AIR); verify(h.world,never()).getChunkAt(anyInt(),anyInt()); + cleanup.retire(); var restarted=cleanup(h,new DurableFeatureState(h.io,directory.resolve("cleanup.yml"),8192)); when(h.plugin.brokenGraveSnapshot()).thenReturn(List.of()); + when(h.world.isChunkLoaded(anyInt(),anyInt())).thenReturn(true); when(marker.chunk.getX()).thenReturn(0); when(marker.chunk.getZ()).thenReturn(0); when(marker.chunk.getWorld()).thenReturn(h.world); + new GraveCleanupListener(h.plugin).onChunkLoad(new org.bukkit.event.world.ChunkLoadEvent(marker.chunk,false)); h.regionWork.remove().run(); FeaturePersistenceTest.await(h.io.barrier()); verify(marker.block).setType(Material.AIR); assertTrue(marker.values.isEmpty()); restarted.retire(); + } + } + @Test void oldCleanupNeverDeletesANewerTaggedMarkerAtSameCoordinates() throws Exception { + try(var h=new GraveActionsTest.Harness()) { + var state=new DurableFeatureState(h.io,new FeaturePersistenceTest.Disk(),8192); var cleanup=cleanup(h,state); var marker=new Marker(h); + h.active.clear(); h.grave.getGravesConfig().setDestroyed(true); GraveMarkerCleanup.tag(h.plugin,marker.block,h.identity()); assertTrue(FeaturePersistenceTest.await(cleanup.retain(h.grave.getGravesConfig()))); + GraveMarkerCleanup.tag(h.plugin,marker.block,h.identity()+"-newer"); h.regionWork.remove().run(); FeaturePersistenceTest.await(h.io.barrier()); + verify(marker.block,never()).setType(Material.AIR); assertTrue(marker.values.containsValue(h.identity()+"-newer")); assertTrue(state.snapshot().isEmpty()); cleanup.retire(); + } + } + @Test void normalizedMarkerTypesPublishCleanupWithoutLeakingShutdownAdmission() throws Exception { + for (String raw : List.of("chest", " CHEST ", "display_entity", "player_head")) try (var h = new GraveActionsTest.Harness()) { + var original = new DurableFeatureState(h.io, new FeaturePersistenceTest.Disk(), 8192); + assertTrue(FeaturePersistenceTest.await(original.loaded())); + var state = spy(original); + var admission = new java.util.concurrent.atomic.AtomicReference(); + doAnswer(call -> { + FeatureIO.Admission reserved = (FeatureIO.Admission) call.callRealMethod(); + admission.set(reserved); return reserved; + }).when(state).admit(); + var cleanup = cleanup(h, state); + try { + h.grave.getGravesConfig().setGraveDisplayType(raw); + var ticket = cleanup.prepare(h.grave.getGravesConfig()); assertNotNull(ticket); + assertTrue(FeaturePersistenceTest.await(ticket.commit())); + FeaturePersistenceTest.await(h.io.barrier()); + String expected = raw.trim().equalsIgnoreCase("display_entity") ? "BARRIER" : raw.trim().toUpperCase(Locale.ROOT); + assertEquals(expected, state.snapshot().get(h.identity())); + cleanup.retire(); h.io.close(); assertTrue(h.io.isTerminated(), "all cleanup admissions must finish before close returns"); + } finally { + // Also release a reservation leaked by the pre-fix constructor path, + // so a regression failure cannot wedge the entire test JVM shutdown. + if (admission.get() != null) admission.get().close(); + cleanup.retire(); + } + } + } + @Test void invalidMarkerConfigurationIsRejectedBeforeCapacityAdmission() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var original = new DurableFeatureState(h.io, new FeaturePersistenceTest.Disk(), 8192); + assertTrue(FeaturePersistenceTest.await(original.loaded())); var state = spy(original); + var admission = new java.util.concurrent.atomic.AtomicReference(); + doAnswer(call -> { + FeatureIO.Admission reserved = (FeatureIO.Admission) call.callRealMethod(); admission.set(reserved); return reserved; + }).when(state).admit(); + var cleanup = cleanup(h, state); + try { + h.grave.getGravesConfig().setGraveDisplayType("not-a-marker"); + assertNull(cleanup.prepare(h.grave.getGravesConfig())); verify(state, never()).admit(); + when(h.config.getGraveDisplayTypeEnum()).thenReturn(GraveDisplayType.CHEST); + var ticket = cleanup.prepare(h.grave.getGravesConfig()); assertNotNull(ticket); + ticket.close(); cleanup.retire(); h.io.close(); assertTrue(h.io.isTerminated()); + } finally { if (admission.get() != null) admission.get().close(); cleanup.retire(); } + } + } + @Test void ticketRegistrationExceptionReleasesItsActualDiskAdmission() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var original = new DurableFeatureState(h.io, new FeaturePersistenceTest.Disk(), 8192); + assertTrue(FeaturePersistenceTest.await(original.loaded())); var state = spy(original); + var admission = new java.util.concurrent.atomic.AtomicReference(); + doAnswer(call -> { + FeatureIO.Admission reserved = (FeatureIO.Admission) call.callRealMethod(); admission.set(reserved); return reserved; + }).when(state).admit(); + var cleanup = cleanup(h, state); + Set failingRegistry = new HashSet<>() { + @Override public boolean add(GraveMarkerCleanup.Ticket ticket) { throw new IllegalStateException("injected ticket registration failure"); } + }; + var registry = GraveMarkerCleanup.class.getDeclaredField("tickets"); registry.setAccessible(true); registry.set(cleanup, failingRegistry); + var capacity = new ArrayList(); + try { + assertThrows(IllegalStateException.class, () -> cleanup.prepare(h.grave.getGravesConfig())); + FeatureIO.Admission reserved; while ((reserved = h.io.admit()) != null) capacity.add(reserved); + assertEquals(129, capacity.size(), "a constructor/registration exception must release the admission it obtained"); + } finally { + capacity.forEach(FeatureIO.Admission::close); + if (admission.get() != null) admission.get().close(); + cleanup.retire(); + } + h.io.close(); assertTrue(h.io.isTerminated()); + } + } + + @Test void nativeChunkAdmissionBoundsThousandsOfUnavailableRecordsAndRetriesAfterCapacityReturns() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + h.active.clear(); when(h.plugin.getFeatureIO()).thenReturn(h.io); + var records = new ArrayList(); + for (int i = 0; i < 5000; i++) records.add(new GravesConfig(h.owner.getUniqueId(), "Owner", new Location(h.world, 1, 64, 1), + new HashMap<>(), 0, "retained", 1000L + i, false, 0, null, null, "PLAYER_HEAD")); + var backend = mock(GraveStorageManager.class); when(backend.loadGraves()).thenReturn(records); when(backend.loadBrokenGraves()).thenReturn(List.of()); + var initiallyUnavailable = mock(GraveClaimLedger.class); + when(initiallyUnavailable.restoration(any())).thenReturn(CompletableFuture.completedFuture(GraveClaimLedger.Restoration.UNAVAILABLE)); + when(h.plugin.getGraveClaimLedger()).thenReturn(initiallyUnavailable); + var restoration = new GraveRestoration(h.plugin, backend); when(h.plugin.getGraveRestoration()).thenReturn(restoration); + GraveActionsTest.retryOnOwner(h, restoration); FeaturePersistenceTest.await(h.io.barrier()); assertFalse(restoration.isLoading()); + var ledger = spy(h.ledger); var decisions = new java.util.concurrent.atomic.AtomicInteger(); + doAnswer(call -> { decisions.incrementAndGet(); return call.callRealMethod(); }).when(ledger).restoration(any()); + when(h.plugin.getGraveClaimLedger()).thenReturn(ledger); + var chunk = mock(Chunk.class); when(chunk.getWorld()).thenReturn(h.world); when(chunk.getX()).thenReturn(0); when(chunk.getZ()).thenReturn(0); + var event = new org.bukkit.event.world.ChunkLoadEvent(chunk, false); var listener = new GraveCleanupListener(h.plugin); + var reservations = new ArrayList(); + try { + FeatureIO.Admission admitted; + while ((admitted = h.io.admit()) != null) reservations.add(admitted); + assertEquals(129, reservations.size()); + listener.onChunkLoad(event); assertEquals(128, decisions.get()); + listener.onChunkLoad(event); assertEquals(256, decisions.get(), "UNAVAILABLE batches must release the running fence"); + assertTrue(h.active.isEmpty()); assertTrue(h.regionWork.isEmpty()); + assertTrue(restoration.hasPending(GraveIdentity.of(records.getLast())), "the remaining records stay indexed"); + } finally { reservations.forEach(FeatureIO.Admission::close); } + try { + listener.onChunkLoad(event); FeaturePersistenceTest.await(h.io.barrier()); + assertEquals(257, decisions.get()); assertEquals(1, h.regionWork.size(), "capacity recovery must admit the next native restoration callback"); + verify(backend, never()).restorationResolved(any()); + } finally { + restoration.retire(); Runnable task; while ((task = h.regionWork.poll()) != null) task.run(); + } + } + } + + @Test void cleanupAdmissionFailurePreservesGraveAndContentsBeforeRemoval() throws Exception { + try(var h=new GraveActionsTest.Harness()) { + DurableFeatureState.Disk disk=new DurableFeatureState.Disk() { public Map load() throws Exception { throw new java.io.IOException("injected read failure"); } public void save(Map values) {} }; + var cleanup=new GraveMarkerCleanup(h.plugin,new DurableFeatureState(h.io,disk,8192)); when(h.plugin.getMarkerCleanup()).thenReturn(cleanup); assertFalse(FeaturePersistenceTest.await(cleanup.loaded())); + h.grave.removeGrave(com.bencodez.gravestonesplus.events.GraveRemoveReason.MANUAL_REMOVE); h.regionWork.remove().run(); assertFalse(h.grave.isRemove()); assertFalse(h.grave.getGravesConfig().isDestroyed()); assertEquals(17,h.grave.getGravesConfig().getExp()); verify(h.plugin,never()).removeGrave(any()); cleanup.retire(); + } + } + @Test void restoredStartupArchiveAdmissionPrecedesMetadataNavigationAndTimersForEveryDecision() throws Exception { + for(var decision:List.of(GraveClaimLedger.Restoration.AVAILABLE,GraveClaimLedger.Restoration.PAID,GraveClaimLedger.Restoration.RESERVED,GraveClaimLedger.Restoration.UNAVAILABLE)) try(var h=new GraveActionsTest.Harness()) { + h.active.clear(); when(h.config.isDisableArmorStands()).thenReturn(true); when(h.config.getGraveTimeLimit()).thenReturn("0"); when(h.config.getBrokenGraveTimeLimit()).thenReturn("7d"); var marker=new Marker(h); + var cleanup=cleanup(h,new DurableFeatureState(h.io,new FeaturePersistenceTest.Disk(),8192)); var ledger=mock(GraveClaimLedger.class); CompletableFuture admission=new CompletableFuture<>(); when(ledger.restoration(any())).thenReturn(admission); when(h.plugin.getGraveClaimLedger()).thenReturn(ledger); + var backend=mock(GraveStorageManager.class); when(backend.loadGraves()).thenReturn(List.of(h.grave.getGravesConfig())); when(backend.loadBrokenGraves()).thenReturn(List.of()); when(h.plugin.getFeatureIO()).thenReturn(h.io); + doAnswer(i->{h.active.add(i.getArgument(0));return null;}).when(h.plugin).restoreActiveRecord(any()); when(h.plugin.brokenGraveSnapshot()).thenReturn(List.of()); + var restoration=new GraveRestoration(h.plugin,backend); GraveActionsTest.retryOnOwner(h, restoration); FeaturePersistenceTest.await(h.io.barrier()); verify(marker.block,never()).getState(); assertTrue(h.active.isEmpty()); assertTrue(h.regionWork.isEmpty()); + admission.complete(decision); FeaturePersistenceTest.await(h.io.barrier()); Runnable task; while((task=h.regionWork.poll())!=null) task.run(); FeaturePersistenceTest.await(h.io.barrier()); + h.drain(); + if(decision==GraveClaimLedger.Restoration.AVAILABLE) { assertEquals(1,h.active.size()); verify(marker.block,atLeastOnce()).getState(); verify(backend).restorationResolved(any()); } + else { assertTrue(h.active.isEmpty()); verify(marker.block,never()).setMetadata(anyString(),any()); if(decision!=GraveClaimLedger.Restoration.PAID) verify(backend,never()).restorationResolved(any()); } + restoration.retire(); cleanup.retire(); + } + } + @Test void partialRestorationShutdownRetainsUnknownStoredGraves() throws Exception { + try(var h=new GraveActionsTest.Harness()) { + when(h.plugin.getFeatureIO()).thenReturn(h.io); when(h.plugin.brokenGraveSnapshot()).thenReturn(List.of()); h.active.clear(); + var backend=mock(GraveStorageBackend.class); var manager=new GraveStorageManager(h.plugin); var field=GraveStorageManager.class.getDeclaredField("backend");field.setAccessible(true);field.set(manager,backend); + manager.retainRestoration(List.of(h.grave.getGravesConfig()),List.of()); manager.saveGravesFromObjects(h.active); FeaturePersistenceTest.await(h.io.barrier()); + org.mockito.ArgumentCaptor> written=org.mockito.ArgumentCaptor.forClass(List.class); verify(backend).saveGraves(written.capture()); assertEquals(1,written.getValue().size()); assertEquals(17,written.getValue().getFirst().getExp()); + } + } + @Test void startupExpirationNeverRepublishesRemovedRecordOrAdoptsItsMarker() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var marker = new Marker(h); var record = h.grave.getGravesConfig(); + var time = GravesConfig.class.getDeclaredField("time"); time.setAccessible(true); time.setLong(record, System.currentTimeMillis() - 120_000); + var ready = CompletableFuture.completedFuture(GraveClaimLedger.Restoration.AVAILABLE); + var restoration = restoration(h, record, ready, ready); when(h.config.getGraveTimeLimit()).thenReturn("1m"); + GraveActionsTest.retryOnOwner(h, restoration); FeaturePersistenceTest.await(h.io.barrier()); + h.bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); h.regionWork.remove().run(); + assertTrue(record.isDestroyed()); assertTrue(h.active.isEmpty()); assertFalse(restoration.hasPending(GraveIdentity.of(record))); + verify(h.plugin, never()).restoreActiveRecord(any()); verify(marker.block, never()).setMetadata(anyString(), any()); + verify(h.plugin).removeGrave(any()); restoration.retire(); + } + } + @org.junit.jupiter.params.ParameterizedTest @ValueSource(booleans = {false, true}) + void historicalPublicationAndSnapshotPreparationWaitForOwnerAfterWorkerCompletion(boolean mysql) throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var record = h.grave.getGravesConfig(); record.setDestroyed(true); + var cleanup = mock(GraveMarkerCleanup.class); var saved = new CompletableFuture(); + when(cleanup.retain(record)).thenReturn(saved); when(h.plugin.getMarkerCleanup()).thenReturn(cleanup); + var ready = CompletableFuture.completedFuture(GraveClaimLedger.Restoration.PAID); + var restoration = restoration(h, record, ready, ready); when(h.config.getBrokenGraveTimeLimit()).thenReturn("7d"); + var storageField = GraveRestoration.class.getDeclaredField("storage"); storageField.setAccessible(true); + var backend = (GraveStorageManager) storageField.get(restoration); + when(backend.isMySQL()).thenReturn(mysql); + if (mysql) when(backend.loadMySQLForRestoration()).thenReturn(CompletableFuture.completedFuture(new GraveStorageManager.RestorationRecords(List.of(record), List.of()))); + when(h.plugin.brokenGraveSnapshot()).thenReturn(List.of(h.grave)); + Thread owner = Thread.currentThread(); var prepares = new java.util.concurrent.atomic.AtomicInteger(); + doAnswer(call -> { assertSame(owner, Thread.currentThread()); record.getItems().values().forEach(item -> item.clone().serialize()); prepares.incrementAndGet(); return null; }) + .when(backend).saveBrokenGravesFromObjects(any()); + doAnswer(call -> { assertSame(owner, Thread.currentThread()); record.getItems().values().forEach(item -> item.clone().serialize()); prepares.incrementAndGet(); return null; }) + .when(backend).updateExistingGrave(any(), eq(true)); + GraveActionsTest.retryOnOwner(h, restoration); FeaturePersistenceTest.await(h.io.barrier()); + FeaturePersistenceTest.await(h.io.submit(() -> { saved.complete(true); return true; })); + verify(h.plugin, never()).restoreBrokenRecord(any()); verify(backend, never()).restorationResolved(any()); assertEquals(0, prepares.get()); + h.drain(); verify(h.plugin).restoreBrokenRecord(any()); verify(backend).restorationResolved(record); + assertEquals(1, prepares.get()); assertFalse(restoration.hasPending(GraveIdentity.of(record))); restoration.retire(); + } + } + + @Test void historicalOnlyStaffRecreationAndTeleportRecheckActualActorAuthority() throws Exception { + for (String permission : List.of("GraveStonesPlus.AllBrokenGraves", "GraveStonesPlus.AllBrokenGraves.Player")) try (var h = new GraveActionsTest.Harness()) { + var marker = new Marker(h); var record = h.grave.getGravesConfig(); + when(h.helper.hasPermission(permission)).thenReturn(true); record.setDestroyed(true); + var recreated = h.grave.recreateAsync(h.helper, true); + FeaturePersistenceTest.await(h.io.barrier()); FeaturePersistenceTest.await(h.io.barrier()); + assertTrue(h.regionWork.isEmpty(), "no marker work before actor authorization"); + assertEquals(1, h.entityWork.size()); h.entityWork.remove().run(); + verify(marker.block, never()).setType(any()); assertTrue(record.isDestroyed()); assertFalse(recreated.isDone()); + clearInvocations(h.helper); + h.regionWork.remove().run(); assertTrue(FeaturePersistenceTest.await(recreated)); + verify(h.helper, never()).hasPermission(anyString()); verify(h.helper, never()).isOnline(); + when(h.config.isDisableArmorStands()).thenReturn(true); + assertEquals(2, h.regionWork.size(), "marker and hologram tasks are location-owned"); + while (!h.regionWork.isEmpty()) h.regionWork.remove().run(); + verify(marker.block).setType(Material.PLAYER_HEAD); + h.drain(); + h.grave.teleportHistorical(h.helper); h.entityWork.remove().run(); + verify(h.platform).teleportAsync(eq(h.helper), any(Location.class)); + record.setDestroyed(true); var revoked = h.grave.recreateAsync(h.helper, false); + FeaturePersistenceTest.await(h.io.barrier()); FeaturePersistenceTest.await(h.io.barrier()); + when(h.helper.hasPermission(permission)).thenReturn(false); h.entityWork.remove().run(); + assertFalse(FeaturePersistenceTest.await(revoked)); assertTrue(record.isDestroyed()); assertTrue(h.regionWork.isEmpty()); + clearInvocations(h.platform); h.grave.teleportHistorical(h.helper); h.entityWork.remove().run(); + verify(h.platform, never()).teleportAsync(any(), any()); + } + } + @Test void archiveAbsenceRejectionReservationPaidAndCorruptionAreDistinctOnActualLedger() throws Exception { + try(FeatureIO io=new FeatureIO(1)) { + Path journal=directory.resolve("claims.yml"), archive=directory.resolve("archive"); String id=FeaturePersistenceTest.GRAVE; + var ledger=new GraveClaimLedger(new DurableFeatureState(io,journal,8192),archive); assertTrue(FeaturePersistenceTest.await(ledger.loaded())); + assertEquals(GraveClaimLedger.Restoration.AVAILABLE,FeaturePersistenceTest.await(ledger.restoration(id))); + CountDownLatch entered=new CountDownLatch(1), release=new CountDownLatch(1); var blocking=io.submit(()->{ entered.countDown(); try { assertTrue(release.await(3,TimeUnit.SECONDS)); } catch(InterruptedException error) {throw new RuntimeException(error);}return true;}); + assertTrue(entered.await(2,TimeUnit.SECONDS)); var queued=io.submit(()->true); assertEquals(GraveClaimLedger.Restoration.UNAVAILABLE,FeaturePersistenceTest.await(ledger.restoration(id))); release.countDown();FeaturePersistenceTest.await(blocking);FeaturePersistenceTest.await(queued); + assertTrue(FeaturePersistenceTest.await(ledger.reserve(id,FeaturePersistenceTest.HELPER))); assertEquals(GraveClaimLedger.Restoration.RESERVED,FeaturePersistenceTest.await(ledger.restoration(id))); + assertTrue(FeaturePersistenceTest.await(ledger.paid(id,FeaturePersistenceTest.HELPER))); var restarted=new GraveClaimLedger(new DurableFeatureState(io,journal,8192),archive);FeaturePersistenceTest.await(restarted.loaded()); assertEquals(GraveClaimLedger.Restoration.PAID,FeaturePersistenceTest.await(restarted.restoration(id))); + String hash=java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(id.getBytes(java.nio.charset.StandardCharsets.UTF_8))); Files.writeString(archive.resolve(hash+".yml"),"version: 1\nentries: {}\n"); + assertEquals(GraveClaimLedger.Restoration.UNAVAILABLE,FeaturePersistenceTest.await(restarted.restoration(id))); + } + } + + @Test void historicalRecreationWithSameIdentityRetiresOldIntentWithoutDeletingNewMarker() throws Exception { + try(var h=new GraveActionsTest.Harness()) { + var state=new DurableFeatureState(h.io,new FeaturePersistenceTest.Disk(),8192); var cleanup=cleanup(h,state); var marker=new Marker(h); + GraveMarkerCleanup.tag(h.plugin,marker.block,h.identity()); h.grave.getGravesConfig().setDestroyed(true); assertTrue(FeaturePersistenceTest.await(cleanup.retain(h.grave.getGravesConfig()))); + h.grave.markRecreated(); h.grave.getGravesConfig().setDestroyed(false); h.regionWork.remove().run(); FeaturePersistenceTest.await(h.io.barrier()); + verify(marker.block,never()).setType(Material.AIR); assertTrue(marker.values.containsValue(h.identity())); assertTrue(state.snapshot().isEmpty()); cleanup.retire(); + } + } + + @Test void fullCleanupWorkerRejectsRemovalBeforeItemsOrBackendStateChange() throws Exception { + try(var h=new GraveActionsTest.Harness()) { + var cleanup=cleanup(h,new DurableFeatureState(h.io,new FeaturePersistenceTest.Disk(),8192)); CountDownLatch entered=new CountDownLatch(1), release=new CountDownLatch(1); + var blocking=h.io.submit(()->{entered.countDown();try {assertTrue(release.await(3,TimeUnit.SECONDS));}catch(InterruptedException error){throw new RuntimeException(error);}return true;}); assertTrue(entered.await(2,TimeUnit.SECONDS)); + List> queued=new ArrayList<>(); for(int i=0;i<128;i++) queued.add(h.io.submit(()->true)); + h.grave.removeGrave(com.bencodez.gravestonesplus.events.GraveRemoveReason.MANUAL_REMOVE);h.regionWork.remove().run(); assertFalse(h.grave.isRemove()); assertFalse(h.grave.getGravesConfig().isDestroyed()); verify(h.plugin,never()).removeGrave(any()); + release.countDown(); FeaturePersistenceTest.await(blocking);FeaturePersistenceTest.await(CompletableFuture.allOf(queued.toArray(CompletableFuture[]::new))); cleanup.retire(); + } + } + @Test void markerTagsAndDisplayOwnershipCleanOnlyExactHeadChestOrBarrierIdentity() throws Exception { + for(Material type:List.of(Material.PLAYER_HEAD,Material.CHEST,Material.BARRIER)) try(var h=new GraveActionsTest.Harness()) { + var cleanup=cleanup(h,new DurableFeatureState(h.io,new FeaturePersistenceTest.Disk(),8192)); var marker=new Marker(h); h.active.clear(); h.grave.getGravesConfig().setDestroyed(true); + h.grave.getGravesConfig().setGraveDisplayType(type==Material.BARRIER?"DISPLAY_ENTITY":type==Material.CHEST?"CHEST":"PLAYER_HEAD");when(marker.block.getType()).thenReturn(type); if(type==Material.BARRIER)when(marker.block.getState()).thenReturn(mock(BlockState.class)); + GraveMarkerCleanup.tag(h.plugin,marker.block,h.identity()); Entity owned=mock(Entity.class),other=mock(Entity.class); PersistentDataContainer ownedData=mock(PersistentDataContainer.class),otherData=mock(PersistentDataContainer.class); when(owned.getPersistentDataContainer()).thenReturn(ownedData);when(other.getPersistentDataContainer()).thenReturn(otherData); + String identity=h.identity(); when(ownedData.get(any(),eq(PersistentDataType.STRING))).thenReturn(identity);when(otherData.get(any(),eq(PersistentDataType.STRING))).thenReturn("newer");when(marker.chunk.getEntities()).thenReturn(new Entity[]{owned,other}); + assertTrue(FeaturePersistenceTest.await(cleanup.retain(h.grave.getGravesConfig()))); h.regionWork.remove().run();FeaturePersistenceTest.await(h.io.barrier()); verify(marker.block).setType(Material.AIR);verify(owned).remove();verify(other,never()).remove();cleanup.retire(); + } + } + @Test void shutdownReleasesPreparedCleanupCapacityWithoutWaitingForQueuedPlayerPayout() throws Exception { + try(var h=new GraveActionsTest.Harness()) { + var cleanup=cleanup(h,new DurableFeatureState(h.io,new FeaturePersistenceTest.Disk(),8192));h.grave.claim(h.owner);h.entityWork.remove().run();FeaturePersistenceTest.await(h.io.barrier()); assertFalse(h.entityWork.isEmpty()); + when(h.plugin.isEnabled()).thenReturn(false);cleanup.retire();assertTimeoutPreemptively(java.time.Duration.ofSeconds(3),h.io::close);verify(h.owner,never()).giveExp(anyInt());assertTrue(h.ledger.isBlocked(h.identity())); + } + } + + @Test void shutdownDuringInitialRestorationPreservesNewDeathAndExistingBackendRecords() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + when(h.plugin.getFeatureIO()).thenReturn(h.io); + var original = h.grave.getGravesConfig(); + var locations = mock(GraveLocations.class); + var graveFile = directory.resolve("Graves.yml"); + when(locations.getdFile()).thenReturn(graveFile.toFile()); + var oldRecord = original.serialize(); + Files.writeString(graveFile, new org.yaml.snakeyaml.Yaml().dump( + Map.of("graves", List.of(oldRecord), "brokengraves", List.of()))); + var backend = spy(new FlatFileGraveStorageBackend(locations)); + when(h.plugin.brokenGraveSnapshot()).thenReturn(List.of()); + var manager = new GraveStorageManager(h.plugin); + var field = GraveStorageManager.class.getDeclaredField("backend"); field.setAccessible(true); field.set(manager, backend); + manager.beginRestoration(); + var newlyCaptured = new com.bencodez.gravestonesplus.storage.GravesConfig(original.getUuid(), "Owner", + original.getLocation().clone(), new java.util.HashMap<>(original.getItems()), 7, "second death", + original.getTime() + 1, false, 0, null, null, "PLAYER_HEAD"); + var fresh = new Grave(h.plugin, newlyCaptured); + h.active.clear(); h.active.add(fresh); + manager.saveGravesFromObjects(h.active); + manager.close(); FeaturePersistenceTest.await(h.io.barrier()); + Map stored = new org.yaml.snakeyaml.Yaml().load(Files.readString(graveFile)); + var records = (List>) stored.get("graves"); + assertEquals(2, records.size(), "shutdown must preserve old and newly captured graves"); + assertTrue(records.contains(new org.yaml.snakeyaml.Yaml().load(new org.yaml.snakeyaml.Yaml().dump(oldRecord)))); + assertTrue(records.stream().anyMatch(record -> + ((Number) record.get("Time")).longValue() == newlyCaptured.getTime() + && ((Number) record.get("EXP")).intValue() == 7)); + var order = inOrder(backend); + order.verify(backend).saveStartupSnapshot(any(), any()); order.verify(backend).close(); + verify(locations, never()).loadGraves(); + } + } + + @Test void unavailableStartupArchiveStillLoadsAndPreservesBackendRecordsBeforeShutdown() throws Exception { + try(var h=new GraveActionsTest.Harness()) { + h.active.clear(); when(h.plugin.getFeatureIO()).thenReturn(h.io);when(h.plugin.brokenGraveSnapshot()).thenReturn(List.of()); + var ledger=mock(GraveClaimLedger.class);when(ledger.loaded()).thenReturn(CompletableFuture.completedFuture(false));when(ledger.restoration(any())).thenReturn(CompletableFuture.completedFuture(GraveClaimLedger.Restoration.UNAVAILABLE));when(h.plugin.getGraveClaimLedger()).thenReturn(ledger); + var backend=mock(GraveStorageBackend.class);when(backend.loadGraves()).thenReturn(List.of(h.grave.getGravesConfig()));when(backend.loadBrokenGraves()).thenReturn(List.of());var manager=new GraveStorageManager(h.plugin);var field=GraveStorageManager.class.getDeclaredField("backend");field.setAccessible(true);field.set(manager,backend); + var restoration=new GraveRestoration(h.plugin,manager);restoration.start();h.drain();manager.saveGravesFromObjects(h.active);FeaturePersistenceTest.await(h.io.barrier()); + org.mockito.ArgumentCaptor> written=org.mockito.ArgumentCaptor.forClass(List.class);verify(backend).saveGraves(written.capture());assertEquals(17,written.getValue().getFirst().getExp());assertTrue(h.regionWork.isEmpty());assertTrue(h.active.isEmpty());restoration.retire(); + } + } + @Test void shutdownSnapshotQueuedBeforeBackendReadCompletesMergesRecordsAtPublicationBoundary() throws Exception { + try(var h=new GraveActionsTest.Harness()) { + h.active.clear();when(h.plugin.getFeatureIO()).thenReturn(h.io);when(h.plugin.brokenGraveSnapshot()).thenReturn(List.of());var backend=mock(GraveStorageBackend.class);var manager=new GraveStorageManager(h.plugin);var field=GraveStorageManager.class.getDeclaredField("backend");field.setAccessible(true);field.set(manager,backend);manager.beginRestoration(); + CountDownLatch entered=new CountDownLatch(1),release=new CountDownLatch(1);var read=h.io.submit(()->{entered.countDown();try {assertTrue(release.await(3,TimeUnit.SECONDS));}catch(InterruptedException error){throw new RuntimeException(error);}manager.retainRestoration(List.of(h.grave.getGravesConfig()),List.of());return true;});assertTrue(entered.await(2,TimeUnit.SECONDS)); + manager.saveGravesFromObjects(h.active); manager.saveGravesFromObjects(h.active); release.countDown();FeaturePersistenceTest.await(read);FeaturePersistenceTest.await(h.io.barrier());org.mockito.ArgumentCaptor> written=org.mockito.ArgumentCaptor.forClass(List.class);verify(backend).saveSnapshot(written.capture(), eq(List.of()));for (var snapshot : written.getAllValues()) { assertEquals(1,snapshot.size()); assertEquals(17,snapshot.getFirst().getExp()); } + } + } + + @Test void queuedPreAdmissionSnapshotsPreserveRecordsButFollowingRemovalDoesNotResurrectThem() throws Exception { + for (boolean broken : List.of(false, true)) try (var h = new GraveActionsTest.Harness()) { + when(h.plugin.getFeatureIO()).thenReturn(h.io); + var backend = mock(GraveStorageBackend.class); var manager = new GraveStorageManager(h.plugin); + var field = GraveStorageManager.class.getDeclaredField("backend"); field.setAccessible(true); field.set(manager, backend); + manager.retainRestoration(broken ? List.of() : List.of(h.grave.getGravesConfig()), broken ? List.of(h.grave.getGravesConfig()) : List.of()); + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + var blocked = h.io.submit(() -> { entered.countDown(); try { assertTrue(release.await(3, TimeUnit.SECONDS)); } catch (InterruptedException failure) { throw new RuntimeException(failure); } return true; }); + assertTrue(entered.await(2, TimeUnit.SECONDS)); + try { + for (int i = 0; i < 2; i++) { + if (broken) manager.saveBrokenGravesFromObjects(List.of()); else manager.saveGravesFromObjects(List.of()); + } + manager.restorationResolved(h.grave.getGravesConfig()); + // A later actual removal publishes an empty list after its predecessors. + if (broken) manager.saveBrokenGravesFromObjects(List.of()); else manager.saveGravesFromObjects(List.of()); + } finally { release.countDown(); } + FeaturePersistenceTest.await(blocked); FeaturePersistenceTest.await(h.io.barrier()); + org.mockito.ArgumentCaptor> written = org.mockito.ArgumentCaptor.forClass(List.class); + if (broken) verify(backend, times(3)).saveBrokenGraves(written.capture()); else verify(backend, times(3)).saveGraves(written.capture()); + assertEquals(17, written.getAllValues().get(0).getFirst().getExp()); assertEquals(17, written.getAllValues().get(1).getFirst().getExp()); assertTrue(written.getAllValues().get(2).isEmpty()); + } + } + + @Test void backendPublicationNeverHoldsTheGameplayAdmissionLockDuringDiskWork() throws Exception { + try(var h=new GraveActionsTest.Harness()) { + when(h.plugin.getFeatureIO()).thenReturn(h.io);when(h.plugin.brokenGraveSnapshot()).thenReturn(List.of());var backend=mock(GraveStorageBackend.class);var manager=new GraveStorageManager(h.plugin);var field=GraveStorageManager.class.getDeclaredField("backend");field.setAccessible(true);field.set(manager,backend); + CountDownLatch entered=new CountDownLatch(1),release=new CountDownLatch(1);doAnswer(i->{entered.countDown();assertTrue(release.await(3,TimeUnit.SECONDS));return null;}).when(backend).saveGraves(anyList());manager.saveGravesFromObjects(h.active);assertTrue(entered.await(2,TimeUnit.SECONDS)); + try { assertTimeoutPreemptively(java.time.Duration.ofSeconds(1),()->manager.saveGravesFromObjects(h.active)); } finally {release.countDown();}FeaturePersistenceTest.await(h.io.barrier()); + } + } + + @Test void hologramRegistrationAndRetirementUseTheSameSharedRegistryMonitor() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var registry = spy(h.plugin.getHologramHandler()); when(h.plugin.getHologramHandler()).thenReturn(registry); + var create = Grave.class.getDeclaredMethod("newManagedHologram", Location.class, String.class, boolean.class, boolean.class); + create.setAccessible(true); + try (var constructors = mockConstruction(com.bencodez.advancedcore.api.hologram.Hologram.class, + (hologram, context) -> assertTrue(Thread.holdsLock(registry)))) { + var hologram = (com.bencodez.advancedcore.api.hologram.Hologram) create.invoke(h.grave,h.grave.getGravesConfig().getLocation(),"test",true,false); + doAnswer(i -> { assertTrue(Thread.holdsLock(registry)); return null; }).when(registry).remove(hologram,false); + var release = Grave.class.getDeclaredMethod("releaseHologram",com.bencodez.advancedcore.api.hologram.Hologram.class); + release.setAccessible(true); release.invoke(h.grave,hologram); + verify(registry).remove(hologram,false); assertEquals(1,constructors.constructed().size()); + } + } + } + @Test void hologramIdentityTagWaitsForDeferredEntityAndRemovesRetiredCreation() throws Exception { + try(var h=new GraveActionsTest.Harness()) { + var hologram=mock(com.bencodez.advancedcore.api.hologram.Hologram.class);var stand=mock(org.bukkit.entity.ArmorStand.class);var data=mock(PersistentDataContainer.class);when(stand.getPersistentDataContainer()).thenReturn(data); + var top=Grave.class.getDeclaredField("topHologram");top.setAccessible(true);top.set(h.grave,hologram); + doAnswer(i->{h.regionWork.add(i.getArgument(1));return null;}).when(h.scheduler).runTaskLater(eq(h.plugin),any(Runnable.class),eq(1L),any(Location.class));h.grave.tagHologram(hologram,0);assertEquals(1,h.regionWork.size()); + when(hologram.getArmorStand()).thenReturn(stand);h.regionWork.remove().run();NamespacedKey key=GraveMarkerCleanup.identityKey(h.plugin);String id=h.identity();verify(data).set(eq(key),eq(PersistentDataType.STRING),eq(id)); + when(hologram.getArmorStand()).thenReturn(null);h.grave.tagHologram(hologram,0);h.grave.getGravesConfig().setDestroyed(true);when(hologram.getArmorStand()).thenReturn(stand);h.regionWork.remove().run();verify(stand,never()).remove();h.entityWork.remove().run();verify(stand).remove(); + } + } + @Test void exactOwnedSurvivingHologramIsRemovedButUnownedAndSuccessorEntitiesRemain() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var marker = new Marker(h); when(marker.block.getLocation()).thenReturn(h.grave.getGravesConfig().getLocation()); + var owned = mock(org.bukkit.entity.ArmorStand.class); var legacy = mock(org.bukkit.entity.ArmorStand.class); var successor = mock(org.bukkit.entity.ArmorStand.class); + String identity = h.identity(); + for (var entity : List.of(owned, legacy, successor)) { + when(entity.getType()).thenReturn(org.bukkit.entity.EntityType.ARMOR_STAND); var data = mock(PersistentDataContainer.class); when(entity.getPersistentDataContainer()).thenReturn(data); + when(data.get(any(), eq(PersistentDataType.STRING))).thenReturn(entity == owned ? identity : entity == successor ? "different-grave" : null); + } + when(h.world.getNearbyEntities(any(Location.class), anyDouble(), anyDouble(), anyDouble())).thenReturn(List.of(owned, legacy, successor)); + h.grave.removeHologramsAround(); verify(owned, never()).remove(); + while (!h.entityWork.isEmpty()) h.entityWork.remove().run(); + verify(owned).remove(); verify(legacy, never()).remove(); verify(successor, never()).remove(); + } + } + @Test void legacyPluginTaggedHologramsMigrateAtExactOwnedPositionsWithoutTouchingNeighbors() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var marker = new Marker(h); when(marker.block.getLocation()).thenReturn(h.grave.getGravesConfig().getLocation()); + var ownership = mock(org.bukkit.metadata.MetadataValue.class); when(ownership.getOwningPlugin()).thenReturn(h.plugin); when(ownership.value()).thenReturn(h.grave); + when(marker.block.getMetadata("Grave")).thenReturn(List.of(ownership)); + var lines = new ArrayList(); + for (double height : new double[]{1.5, 1.25, 1, -2, 2, 1.5}) { + var stand = mock(org.bukkit.entity.ArmorStand.class); lines.add(stand); + when(stand.getType()).thenReturn(org.bukkit.entity.EntityType.ARMOR_STAND); + var data = mock(PersistentDataContainer.class); when(stand.getPersistentDataContainer()).thenReturn(data); + when(data.get(h.plugin.getKey(), PersistentDataType.INTEGER)).thenReturn(1); + Location position = marker.block.getLocation().clone().add(.5, height, .5); + if (lines.size() == 6) position.add(1,0,0); + when(stand.getLocation()).thenReturn(position); + } + when(h.world.getNearbyEntities(any(Location.class), anyDouble(), anyDouble(), anyDouble())).thenReturn(new ArrayList(lines)); + h.grave.removeHologramsAround(); + for (var line : lines) verify(line, never()).getPersistentDataContainer(); + while (!h.entityWork.isEmpty()) h.entityWork.remove().run(); + for (int i=0; i { h.active.add(i.getArgument(0)); return null; }).when(h.plugin).restoreActiveRecord(any()); + var restoration = new GraveRestoration(h.plugin, backend); when(h.plugin.getGraveRestoration()).thenReturn(restoration); + var compass = mock(org.bukkit.inventory.ItemStack.class); var meta = mock(org.bukkit.inventory.meta.CompassMeta.class); var data = mock(PersistentDataContainer.class); + when(compass.getType()).thenReturn(Material.COMPASS); when(compass.getItemMeta()).thenReturn(meta); when(meta.getPersistentDataContainer()).thenReturn(data); + String id = h.identity(); when(data.get(any(), eq(PersistentDataType.STRING))).thenReturn(id); when(h.ownerInventory.getContents()).thenReturn(new org.bukkit.inventory.ItemStack[] {compass}); + h.navigation.refresh(h.owner); verify(data, never()).remove(any()); // Loading must not erase a persisted target. + GraveActionsTest.retryOnOwner(h, restoration); FeaturePersistenceTest.await(h.io.barrier()); h.regionWork.remove().run(); + assertEquals(1, h.active.size()); Grave admitted = h.active.getFirst(); + assertEquals(h.grave.getGravesConfig().getTime() + 60_000L, admitted.getExpiryDeadline()); + assertTrue(h.navigation.select(h.owner, admitted)); assertTrue(h.navigation.select(h.helper, admitted)); + assertSame(admitted, h.navigation.target(h.owner)); assertSame(admitted, h.navigation.target(h.helper)); + assertEquals(List.of(admitted), h.plugin.getAccessibleGraves(h.owner)); + h.navigation.refresh(h.owner); verify(data, never()).remove(any()); verify(marker.block, never()).getState(); verify(h.world, never()).getChunkAt(anyInt(), anyInt()); + when(h.world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true); + restoration.onChunk(h.world.getUID(), 0, 0); FeaturePersistenceTest.await(h.io.barrier()); h.regionWork.remove().run(); + assertEquals(1, h.active.size()); assertSame(admitted, h.active.getFirst()); verify(backend, times(1)).restorationResolved(any()); + verify(marker.block, times(1)).setMetadata(eq("Grave"), any()); + restoration.onChunk(h.world.getUID(), 0, 0); FeaturePersistenceTest.await(h.io.barrier()); assertTrue(h.regionWork.isEmpty()); restoration.retire(); + } + } + @Test void unloadedPublicationIsWithdrawnWhenLoadedMarkerBelongsToASuccessor() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + h.grant(TrustedGraveAccess.Kind.RECOVER); h.grant(TrustedGraveAccess.Kind.TRACK); + var marker = new Marker(h); when(h.world.isChunkLoaded(anyInt(), anyInt())).thenReturn(false); + var ready = CompletableFuture.completedFuture(GraveClaimLedger.Restoration.AVAILABLE); + var restoration = restoration(h, h.grave.getGravesConfig(), ready, ready); + doAnswer(call -> { h.active.remove(call.getArgument(0)); return null; }).when(h.plugin).withholdRestoredRecord(any()); + var ledger = h.plugin.getGraveClaimLedger(); when(ledger.loaded()).thenReturn(CompletableFuture.completedFuture(true)); when(ledger.isReady()).thenReturn(true); + GraveActionsTest.retryOnOwner(h, restoration); FeaturePersistenceTest.await(h.io.barrier()); h.regionWork.remove().run(); + var scheduled = new ArrayList(); + var timer = mock(ScheduledExecutorService.class); var expiry = mock(ScheduledFuture.class); + when(timer.schedule(any(Runnable.class), anyLong(), any(TimeUnit.class))).thenAnswer(call -> { + scheduled.add(call.getArgument(0)); return expiry; + }); + when(h.plugin.getTimer()).thenReturn(timer); when(h.config.getGraveTimeLimit()).thenReturn("1m"); + Grave admitted = h.active.getFirst(); admitted.checkTimeLimit("1m"); long deadline = admitted.getExpiryDeadline(); + var compass = mock(org.bukkit.inventory.ItemStack.class); var meta = mock(org.bukkit.inventory.meta.CompassMeta.class); + var compassData = mock(PersistentDataContainer.class); + when(compass.getType()).thenReturn(Material.COMPASS); when(compass.getItemMeta()).thenReturn(meta); + when(meta.getPersistentDataContainer()).thenReturn(compassData); + when(compassData.get(any(), eq(PersistentDataType.STRING))).thenReturn(h.identity()); + when(h.helperInventory.getContents()).thenReturn(new org.bukkit.inventory.ItemStack[] {compass}); + assertTrue(h.navigation.select(h.owner, admitted)); assertTrue(h.navigation.select(h.helper, admitted)); + assertTrue(h.plugin.canRecover(admitted, h.helper)); + var successor = new Grave(h.plugin, new GravesConfig(UUID.randomUUID(), "Successor", new Location(h.world, 1, 64, 1), + new HashMap<>(), 0, "new", admitted.getGravesConfig().getTime() + 1, false, 0, null, null, "PLAYER_HEAD")); + h.active.add(successor); + when(h.world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true); + GraveMarkerCleanup.tag(h.plugin, marker.block, GraveIdentity.of(successor.getGravesConfig())); + var chunkTags = new HashMap<>(marker.values); var tileTags = new HashMap<>(marker.tileValues); + clearInvocations(marker.block, marker.chunkData, marker.tileData); + restoration.onChunk(h.world.getUID(), 0, 0); h.regionWork.remove().run(); + assertEquals(List.of(successor), h.active); assertTrue(restoration.hasPending(h.identity())); assertTrue(restoration.isConflicted(h.identity())); + assertFalse(h.plugin.isLiveGrave(admitted)); assertFalse(h.plugin.canRecover(admitted, h.helper)); + assertNull(h.navigation.target(h.owner)); assertNull(h.navigation.target(h.helper)); + h.navigation.refresh(h.helper); verify(meta).setLodestone(null); verify(compassData, atLeastOnce()).remove(any()); + verify(expiry).cancel(false); + // Cancellation cannot undo a callback already handed to the timer/region owner. + var expiryField = Grave.class.getDeclaredField("expiryDeadline"); expiryField.setAccessible(true); + expiryField.setLong(admitted, System.currentTimeMillis() - 1); + scheduled.getFirst().run(); h.drain(); + assertFalse(admitted.isRemove()); assertFalse(admitted.getGravesConfig().isDestroyed()); + admitted.claim(h.helper); h.drain(); verify(h.helper, never()).giveExp(anyInt()); + verify(h.plugin, never()).removeGrave(any()); verify(h.plugin, never()).restoreBrokenRecord(any()); + verify(marker.block, never()).setMetadata(anyString(), any()); verify(marker.block, never()).setType(any()); + assertEquals(chunkTags, marker.values); assertEquals(tileTags, marker.tileValues); + assertFalse(admitted.getGravesConfig().isDestroyed()); assertEquals(17, admitted.getGravesConfig().getExp()); + when(h.world.isChunkLoaded(anyInt(), anyInt())).thenReturn(false); + restoration.onChunk(h.world.getUID(), 0, 0); h.regionWork.remove().run(); + assertEquals(List.of(successor), h.active); assertTrue(restoration.isConflicted(h.identity())); + // Only a loaded region observing the original identity can re-admit the retained record. + when(h.world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true); + GraveMarkerCleanup.tag(h.plugin, marker.block, h.identity()); + restoration.onChunk(h.world.getUID(), 0, 0); h.regionWork.remove().run(); + assertFalse(restoration.isConflicted(h.identity())); assertFalse(restoration.hasPending(h.identity())); + assertTrue(h.active.contains(admitted)); assertTrue(h.active.contains(successor)); assertTrue(h.plugin.canRecover(admitted, h.helper)); + assertEquals(deadline, admitted.getExpiryDeadline(), "re-admission retains the original absolute expiry"); + verify(h.plugin, times(2)).restoreActiveRecord(admitted); verify(h.world, never()).getChunkAt(anyInt(), anyInt()); + restoration.retire(); + } + } + @ParameterizedTest @ValueSource(strings = {"AVAILABLE", "QUEUED_LIMIT"}) + void restorationNeverAdoptsATaggedSuccessorEvenWhenItsMaterialMatches(String decision) throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var marker = new Marker(h); + GraveMarkerCleanup.tag(h.plugin, marker.block, h.identity() + "-successor"); + var chunkTags = new HashMap<>(marker.values); var tileTags = new HashMap<>(marker.tileValues); + clearInvocations(marker.block, marker.chunkData, marker.tileData); + var ready = CompletableFuture.completedFuture(GraveClaimLedger.Restoration.valueOf(decision)); + var restoration = restoration(h, h.grave.getGravesConfig(), ready, ready); + GraveActionsTest.retryOnOwner(h, restoration); FeaturePersistenceTest.await(h.io.barrier()); + h.regionWork.remove().run(); + assertTrue(h.active.isEmpty()); assertTrue(restoration.hasPending(h.identity())); + assertFalse(restoration.protectsPendingMarker(marker.block)); + verify(h.plugin, never()).restoreActiveRecord(any()); verify(h.plugin, never()).restoreBrokenRecord(any()); + verify(marker.block, never()).setMetadata(anyString(), any()); verify(marker.block, never()).setType(any()); + assertEquals(chunkTags, marker.values); assertEquals(tileTags, marker.tileValues); + restoration.onChunk(h.world.getUID(), 0, 0); h.regionWork.remove().run(); + assertTrue(h.active.isEmpty()); assertTrue(restoration.hasPending(h.identity())); + assertEquals(chunkTags, marker.values); assertEquals(tileTags, marker.tileValues); + restoration.retire(); + } + } + @Test void initiallyLoadedInvalidMarkerIsNeverPublishedAsAnActiveRecord() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + h.active.clear(); var marker = new Marker(h); when(marker.block.getType()).thenReturn(Material.STONE); + var cleanup = cleanup(h, new DurableFeatureState(h.io, new FeaturePersistenceTest.Disk(), 8192)); + var ledger = mock(GraveClaimLedger.class); when(ledger.restoration(any())).thenReturn(CompletableFuture.completedFuture(GraveClaimLedger.Restoration.AVAILABLE)); + when(h.plugin.getGraveClaimLedger()).thenReturn(ledger); when(h.config.getBrokenGraveTimeLimit()).thenReturn("7d"); + var backend = mock(GraveStorageManager.class); when(backend.loadGraves()).thenReturn(List.of(h.grave.getGravesConfig())); when(backend.loadBrokenGraves()).thenReturn(List.of()); + when(h.plugin.getFeatureIO()).thenReturn(h.io); when(h.plugin.brokenGraveSnapshot()).thenReturn(List.of()); + var restoration = new GraveRestoration(h.plugin, backend); GraveActionsTest.retryOnOwner(h, restoration); FeaturePersistenceTest.await(h.io.barrier()); h.regionWork.remove().run(); FeaturePersistenceTest.await(h.io.barrier()); + GraveActionsTest.drainGround(h); + verify(h.plugin, never()).restoreActiveRecord(any()); verify(h.plugin).restoreBrokenRecord(any()); assertTrue(h.active.isEmpty()); verify(marker.block, never()).setMetadata(eq("Grave"), any()); + restoration.retire(); cleanup.retire(); + } + } + @Test void committedFinalCleanupSlotCannotBeReservedByAnotherGraveBeforeAcknowledgment() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var disk = new FeaturePersistenceTest.Disk(); var values = new HashMap(); + for (int i = 0; i < 8191; i++) values.put(h.owner.getUniqueId() + "/" + i + "/" + h.world.getUID() + "/2/64/2", "PLAYER_HEAD"); + disk.values = values; var state = new DurableFeatureState(h.io, disk, 8192); var cleanup = cleanup(h, state); + var first = new GravesConfig(h.owner.getUniqueId(), "Owner", new Location(h.world, 3, 64, 3), new HashMap<>(), 0, + "test", System.currentTimeMillis(), false, 0, null, null, "PLAYER_HEAD"); + var ticket = cleanup.prepare(first); assertNotNull(ticket); + disk.entered = new CountDownLatch(1); disk.release = new CountDownLatch(1); var write = ticket.commit(); + assertTrue(disk.entered.await(2, TimeUnit.SECONDS)); + try { + h.grave.removeGrave(com.bencodez.gravestonesplus.events.GraveRemoveReason.MANUAL_REMOVE); h.regionWork.remove().run(); + assertFalse(h.grave.isRemove()); assertEquals(17, h.grave.getGravesConfig().getExp()); verify(h.plugin, never()).removeGrave(any()); + assertNull(cleanup.prepare(h.grave.getGravesConfig())); + } finally { disk.release.countDown(); } + assertTrue(FeaturePersistenceTest.await(write)); assertEquals(8192, state.snapshot().size()); assertNull(cleanup.prepare(h.grave.getGravesConfig())); cleanup.retire(); + } + } + @Test void expirationRetriesWorkerAdmissionAndRemovesOnceWhenCapacityReturns() throws Exception { + expirationAdmissionRetry(false); + } + @Test void disabledDeadlineAndPluginShutdownInvalidateAnAdmittedExpirationRetry() throws Exception { + expirationAdmissionRetry(true); + } + private void expirationAdmissionRetry(boolean invalidate) throws Exception { + for (boolean shutdown : invalidate ? List.of(false, true) : List.of(false)) try (var h = new GraveActionsTest.Harness()) { + var cleanup = cleanup(h, new DurableFeatureState(h.io, new FeaturePersistenceTest.Disk(), 8192)); + var timer = mock(ScheduledExecutorService.class); var future = mock(ScheduledFuture.class); + when(h.plugin.getTimer()).thenReturn(timer); List retries = new ArrayList<>(); + doAnswer(i -> { retries.add(i.getArgument(0)); return future; }).when(timer).schedule(any(Runnable.class), eq(5L), eq(TimeUnit.SECONDS)); + var deadline = Grave.class.getDeclaredField("expiryDeadline"); deadline.setAccessible(true); deadline.setLong(h.grave, System.currentTimeMillis() - 1); + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + var blocking = h.io.submit(() -> { entered.countDown(); try { assertTrue(release.await(3, TimeUnit.SECONDS)); } catch (InterruptedException e) { throw new RuntimeException(e); } return true; }); + assertTrue(entered.await(2, TimeUnit.SECONDS)); List> queued = new ArrayList<>(); + for (int i = 0; i < 128; i++) queued.add(h.io.submit(() -> true)); + try { + h.grave.removeGrave(com.bencodez.gravestonesplus.events.GraveRemoveReason.EXPIRED); h.regionWork.remove().run(); + assertFalse(h.grave.isRemove()); assertEquals(1, retries.size()); + } finally { release.countDown(); } + FeaturePersistenceTest.await(blocking); FeaturePersistenceTest.await(CompletableFuture.allOf(queued.toArray(CompletableFuture[]::new))); + if (invalidate) { if (shutdown) when(h.plugin.isEnabled()).thenReturn(false); else h.grave.checkTimeLimit("0"); } + retries.getFirst().run(); h.regionWork.remove().run(); FeaturePersistenceTest.await(h.io.barrier()); + if (invalidate) { assertFalse(h.grave.isRemove()); verify(h.plugin, never()).removeGrave(any()); } + else { assertTrue(h.grave.isRemove()); verify(h.plugin, times(1)).removeGrave(h.grave); Runnable task; while ((task = h.regionWork.poll()) != null) task.run(); retries.getFirst().run(); h.regionWork.remove().run(); verify(h.plugin, times(1)).removeGrave(h.grave); } + cleanup.retire(); + } + } + @Test void glowRefreshUsesRegionOwnershipAndNeverProbesUnloadedMarkers() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var marker = new Marker(h); when(h.world.isChunkLoaded(anyInt(), anyInt())).thenReturn(false); when(h.config.isGlowingEffectNearGrave()).thenReturn(true); + doCallRealMethod().when(h.plugin).refreshGraveGlow(any()); h.plugin.refreshGraveGlow(h.grave); + verify(h.world, never()).isChunkLoaded(anyInt(), anyInt()); h.regionWork.remove().run(); verify(marker.block, never()).getType(); verify(h.world, never()).getChunkAt(anyInt(), anyInt()); + } + } + + @Test void missingWorldYamlRecordRemainsSerializableThroughUnionSaveAndLaterRestores() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + when(h.plugin.getFeatureIO()).thenReturn(h.io); when(h.plugin.brokenGraveSnapshot()).thenReturn(List.of()); + var globalTasks = new LinkedBlockingQueue(); Thread ownerThread = Thread.currentThread(); + doAnswer(call -> { globalTasks.add(call.getArgument(1)); return null; }) + .when(h.scheduler).runTask(eq(h.plugin), any(Runnable.class)); + UUID missing = UUID.randomUUID(); + var legacy = new HashMap(); legacy.put("UUID",h.owner.getUniqueId().toString()); legacy.put("PlayerName","Owner"); + legacy.put("World",missing.toString()); legacy.put("X",1);legacy.put("Y",64);legacy.put("Z",1);legacy.put("EXP",17);legacy.put("Time",12345L);legacy.put("Items", new HashMap()); + var unresolved=GravesConfig.deserialize(legacy); assertNull(unresolved.getLocation().getWorld()); assertEquals(missing,unresolved.getWorldId()); + assertEquals(missing.toString(),unresolved.serialize().get("World")); assertNotNull(GraveIdentity.of(unresolved)); + var backend=mock(GraveStorageBackend.class); var manager=new GraveStorageManager(h.plugin); + var field=GraveStorageManager.class.getDeclaredField("backend");field.setAccessible(true);field.set(manager,backend); + manager.retainRestoration(List.of(unresolved),List.of()); + doAnswer(i -> { List records = i.getArgument(0); for (GravesConfig grave : records) grave.serialize(); return null; }).when(backend).saveGraves(any()); + manager.saveGravesFromObjects(h.active); FeaturePersistenceTest.await(h.io.barrier()); + org.mockito.ArgumentCaptor> written=org.mockito.ArgumentCaptor.forClass(List.class);verify(backend).saveGraves(written.capture()); + assertEquals(2,written.getValue().size()); assertEquals(missing,written.getValue().getFirst().getWorldId()); assertEquals(17,written.getValue().getFirst().getExp()); + // The same pending identity can be admitted when the world becomes available later. + h.active.clear(); when(backend.loadGraves()).thenReturn(List.of(unresolved));when(backend.loadBrokenGraves()).thenReturn(List.of()); + when(h.config.isDisableArmorStands()).thenReturn(true);when(h.config.getGraveTimeLimit()).thenReturn("0"); var marker=new Marker(h); + doAnswer(i -> { h.active.add(i.getArgument(0));return null; }).when(h.plugin).restoreActiveRecord(any()); + var decisions = new LinkedBlockingQueue>(); + var ledger = mock(GraveClaimLedger.class); when(h.plugin.getGraveClaimLedger()).thenReturn(ledger); + when(ledger.restoration(any())).thenAnswer(i -> new CompletableFuture() { + @Override public CompletableFuture whenComplete(java.util.function.BiConsumer action) { + var registered = super.whenComplete(action); + decisions.add(this); // Publish only after the production continuation is installed. + return registered; + } + }); + var restoration=new GraveRestoration(h.plugin,manager);GraveActionsTest.retryOnOwner(h, restoration); + Objects.requireNonNull(globalTasks.poll(5,TimeUnit.SECONDS)).run(); + Objects.requireNonNull(globalTasks.poll(5,TimeUnit.SECONDS)).run(); + Objects.requireNonNull(decisions.poll(5,TimeUnit.SECONDS)).complete(GraveClaimLedger.Restoration.AVAILABLE); + Objects.requireNonNull(globalTasks.poll(5,TimeUnit.SECONDS)).run(); + FeaturePersistenceTest.await(h.io.barrier()); // Wait until the archive callback has released this restoration cycle. + assertTrue(h.regionWork.isEmpty()); + h.bukkit.when(() -> Bukkit.getWorld(missing)).thenAnswer(call -> { assertSame(ownerThread, Thread.currentThread()); return h.world; });GraveActionsTest.retryOnOwner(h, restoration); + Objects.requireNonNull(decisions.poll(5,TimeUnit.SECONDS)).complete(GraveClaimLedger.Restoration.AVAILABLE); Objects.requireNonNull(globalTasks.poll(5,TimeUnit.SECONDS)).run(); h.regionWork.remove().run(); + assertEquals(1,h.active.size());assertEquals(missing,h.active.getFirst().getGravesConfig().getWorldId()); verify(h.world,never()).getChunkAt(anyInt(),anyInt());restoration.retire(); + } + } + @Test void staleChunkTagCannotDeleteAnUntaggedReplacementChest() throws Exception { + try(var h=new GraveActionsTest.Harness()) { + h.active.clear();h.grave.getGravesConfig().setDestroyed(true);h.grave.getGravesConfig().setGraveDisplayType("CHEST"); + var state=new DurableFeatureState(h.io,new FeaturePersistenceTest.Disk(),8192);var cleanup=cleanup(h,state);var marker=new Marker(h);when(marker.block.getType()).thenReturn(Material.CHEST); + GraveMarkerCleanup.tag(h.plugin,marker.block,h.identity());marker.tileValues.clear(); + assertTrue(FeaturePersistenceTest.await(cleanup.retain(h.grave.getGravesConfig())));h.regionWork.remove().run();FeaturePersistenceTest.await(h.io.barrier()); + verify(marker.block,never()).setType(Material.AIR);assertTrue(state.snapshot().containsKey(h.identity()));assertTrue(marker.values.containsValue(h.identity()));cleanup.retire(); + } + } + @Test void startupCleanupWaitsUntilPendingRecreatedIdentityIsAdmitted() throws Exception { + try(var h=new GraveActionsTest.Harness()) { + var state=new DurableFeatureState(h.io,new FeaturePersistenceTest.Disk(),8192);var cleanup=cleanup(h,state);var marker=new Marker(h); + GraveMarkerCleanup.tag(h.plugin,marker.block,h.identity());h.active.clear();h.grave.getGravesConfig().setDestroyed(true); + assertTrue(FeaturePersistenceTest.await(cleanup.retain(h.grave.getGravesConfig()))); + var backend=mock(GraveStorageManager.class);h.grave.getGravesConfig().setDestroyed(false);when(backend.loadGraves()).thenReturn(List.of(h.grave.getGravesConfig()));when(backend.loadBrokenGraves()).thenReturn(List.of());when(h.plugin.getFeatureIO()).thenReturn(h.io); + var ledger=mock(GraveClaimLedger.class);var decision=new CompletableFuture();when(ledger.restoration(any())).thenReturn(decision);when(h.plugin.getGraveClaimLedger()).thenReturn(ledger); + var restoration=new GraveRestoration(h.plugin,backend);when(h.plugin.getGraveRestoration()).thenReturn(restoration); + h.regionWork.remove().run();FeaturePersistenceTest.await(h.io.barrier());verify(marker.block,never()).setType(Material.AIR);assertTrue(state.snapshot().containsKey(h.identity())); + GraveActionsTest.retryOnOwner(h, restoration);FeaturePersistenceTest.await(h.io.barrier());assertTrue(restoration.hasPending(h.identity()));cleanup.onChunk(h.world,0,0);h.regionWork.remove().run();verify(marker.block,never()).setType(Material.AIR); + when(h.config.isDisableArmorStands()).thenReturn(true);when(h.config.getGraveTimeLimit()).thenReturn("0");doAnswer(i->{h.active.add(i.getArgument(0));return null;}).when(h.plugin).restoreActiveRecord(any()); + decision.complete(GraveClaimLedger.Restoration.AVAILABLE);h.regionWork.remove().run();assertFalse(restoration.hasPending(h.identity())); + cleanup.onChunk(h.world,0,0);h.regionWork.remove().run();FeaturePersistenceTest.await(h.io.barrier());verify(marker.block,never()).setType(Material.AIR);assertTrue(state.snapshot().isEmpty());restoration.retire();cleanup.retire(); + } + } + @Test void unavailableIdentityDoesNotStarveFollowingHealthyStartupRecord() throws Exception { + try(var h=new GraveActionsTest.Harness()) { + h.active.clear();var marker=new Marker(h);when(h.config.isDisableArmorStands()).thenReturn(true);when(h.config.getGraveTimeLimit()).thenReturn("0"); + var healthy=new GravesConfig(h.owner.getUniqueId(),"Owner",new Location(h.world,1,64,1),new HashMap<>(),7,"",System.currentTimeMillis()+1,false,0,null,null,"PLAYER_HEAD"); + var ledger=mock(GraveClaimLedger.class);String bad=h.identity();when(ledger.restoration(any())).thenAnswer(i->CompletableFuture.completedFuture(bad.equals(i.getArgument(0))?GraveClaimLedger.Restoration.UNAVAILABLE:GraveClaimLedger.Restoration.AVAILABLE));when(h.plugin.getGraveClaimLedger()).thenReturn(ledger); + var backend=mock(GraveStorageManager.class);when(backend.loadGraves()).thenReturn(List.of(h.grave.getGravesConfig(),healthy));when(backend.loadBrokenGraves()).thenReturn(List.of());when(h.plugin.getFeatureIO()).thenReturn(h.io);doAnswer(i->{h.active.add(i.getArgument(0));return null;}).when(h.plugin).restoreActiveRecord(any()); + var restoration=new GraveRestoration(h.plugin,backend);GraveActionsTest.retryOnOwner(h, restoration);FeaturePersistenceTest.await(h.io.barrier());h.regionWork.remove().run(); + assertEquals(1,h.active.size());assertSame(healthy,h.active.getFirst().getGravesConfig());assertTrue(restoration.hasPending(bad));verify(backend).restorationResolved(healthy);restoration.retire(); + } + } + @Test void invalidStartupRemovalRemainsCancellableAndRepairable() throws Exception { + for(boolean repair:List.of(false,true)) try(var h=new GraveActionsTest.Harness()) { + h.active.clear();var marker=new Marker(h);when(marker.block.getType()).thenReturn(Material.STONE);when(h.config.isDisableArmorStands()).thenReturn(true);when(h.config.getGraveTimeLimit()).thenReturn("0"); + var state=new DurableFeatureState(h.io,new FeaturePersistenceTest.Disk(),8192);var cleanup=cleanup(h,state); + java.util.concurrent.atomic.AtomicInteger observed=new java.util.concurrent.atomic.AtomicInteger(); + doAnswer(i->{if(i.getArgument(0) instanceof com.bencodez.gravestonesplus.events.GraveRemoveEvent e){assertEquals(com.bencodez.gravestonesplus.events.GraveRemoveReason.INVALID,e.getReason());observed.incrementAndGet();if(repair)when(marker.block.getType()).thenReturn(Material.PLAYER_HEAD);else e.setCancelled(true);}return null;}).when(h.events).callEvent(any()); + var ledger=mock(GraveClaimLedger.class);when(ledger.restoration(any())).thenReturn(CompletableFuture.completedFuture(GraveClaimLedger.Restoration.AVAILABLE));when(h.plugin.getGraveClaimLedger()).thenReturn(ledger); + var backend=mock(GraveStorageManager.class);when(backend.loadGraves()).thenReturn(List.of(h.grave.getGravesConfig()));when(backend.loadBrokenGraves()).thenReturn(List.of());when(h.plugin.getFeatureIO()).thenReturn(h.io);doAnswer(i->{h.active.add(i.getArgument(0));return null;}).when(h.plugin).restoreActiveRecord(any()); + var restoration=new GraveRestoration(h.plugin,backend);GraveActionsTest.retryOnOwner(h, restoration);FeaturePersistenceTest.await(h.io.barrier());h.regionWork.remove().run();FeaturePersistenceTest.await(h.io.barrier()); + assertEquals(1,observed.get());assertFalse(h.grave.getGravesConfig().isDestroyed());assertTrue(state.snapshot().isEmpty());verify(h.plugin,never()).restoreBrokenRecord(any());assertEquals(repair?1:0,h.active.size());assertEquals(!repair,restoration.hasPending(h.identity()));restoration.retire();cleanup.retire(); + } + } + + @Test void invalidStartupRemovalRevokesBothHelperKindsBeforeHistoricalPublication() throws Exception { + for (boolean rejectRevocation : List.of(false, true)) try (var h = new GraveActionsTest.Harness()) { + h.grant(TrustedGraveAccess.Kind.TRACK); h.grant(TrustedGraveAccess.Kind.RECOVER); + h.active.clear(); var marker = new Marker(h); when(marker.block.getType()).thenReturn(Material.STONE); + var cleanup = cleanup(h, new DurableFeatureState(h.io, new FeaturePersistenceTest.Disk(), 8192)); + var ledger = mock(GraveClaimLedger.class); + when(ledger.restoration(any())).thenReturn(CompletableFuture.completedFuture(GraveClaimLedger.Restoration.AVAILABLE)); + when(h.plugin.getGraveClaimLedger()).thenReturn(ledger); + var backend = mock(GraveStorageManager.class); + when(backend.loadGraves()).thenReturn(List.of(h.grave.getGravesConfig())); when(backend.loadBrokenGraves()).thenReturn(List.of()); + when(h.plugin.getFeatureIO()).thenReturn(h.io); + CountDownLatch removed = new CountDownLatch(1); + doAnswer(i -> { removed.countDown(); return null; }).when(backend).restorationResolved(any()); + doAnswer(i -> { + assertFalse(h.access.allows(h.identity(), h.helper.getUniqueId(), TrustedGraveAccess.Kind.TRACK)); + assertFalse(h.access.allows(h.identity(), h.helper.getUniqueId(), TrustedGraveAccess.Kind.RECOVER)); + return null; + }).when(h.plugin).restoreBrokenRecord(any()); + if (rejectRevocation) h.accessDisk.fail = true; + var restoration = new GraveRestoration(h.plugin, backend); + GraveActionsTest.retryOnOwner(h, restoration); FeaturePersistenceTest.await(h.io.barrier()); h.regionWork.remove().run(); + FeaturePersistenceTest.await(h.io.barrier()); FeaturePersistenceTest.await(h.io.barrier()); + assertFalse(h.access.allows(h.identity(), h.helper.getUniqueId(), TrustedGraveAccess.Kind.TRACK)); + assertFalse(h.access.allows(h.identity(), h.helper.getUniqueId(), TrustedGraveAccess.Kind.RECOVER)); + GraveActionsTest.drainGround(h); + if (!rejectRevocation) assertTrue(removed.await(2, TimeUnit.SECONDS), "durable revocation/removal callback must complete"); + assertEquals(!rejectRevocation, h.grave.getGravesConfig().isDestroyed()); + if (rejectRevocation) { + assertTrue(restoration.hasPending(h.identity())); verify(h.plugin, never()).restoreBrokenRecord(any()); + verify(backend, never()).restorationResolved(any()); + } else { + verify(h.plugin).restoreBrokenRecord(any()); verify(backend).restorationResolved(any()); + var restarted = new TrustedGraveAccessStore(new DurableFeatureState(h.io, h.accessDisk, 4096), System::currentTimeMillis); + assertTrue(FeaturePersistenceTest.await(restarted.loaded())); + assertFalse(restarted.allows(h.identity(), h.helper.getUniqueId(), TrustedGraveAccess.Kind.RECOVER)); + assertTrue(restarted.list(h.identity()).isEmpty()); + } + restoration.retire(); cleanup.retire(); + } + } + + @Test void rejectedHistoricalCleanupDoesNotStarveHealthyFollowingRecord() throws Exception { + try(var h=new GraveActionsTest.Harness()) { + h.active.clear();var marker=new Marker(h);when(h.config.isDisableArmorStands()).thenReturn(true);when(h.config.getGraveTimeLimit()).thenReturn("0"); + h.grave.getGravesConfig().setDestroyed(true); + var healthy=new GravesConfig(h.owner.getUniqueId(),"Owner",new Location(h.world,1,64,1),new HashMap<>(),7,"",System.currentTimeMillis()+1,false,0,null,null,"PLAYER_HEAD"); + var ledger=mock(GraveClaimLedger.class);when(ledger.restoration(any())).thenReturn(CompletableFuture.completedFuture(GraveClaimLedger.Restoration.AVAILABLE));when(h.plugin.getGraveClaimLedger()).thenReturn(ledger); + var cleanup=mock(GraveMarkerCleanup.class);when(cleanup.retain(any())).thenReturn(CompletableFuture.completedFuture(false));when(h.plugin.getMarkerCleanup()).thenReturn(cleanup); + var backend=mock(GraveStorageManager.class);when(backend.loadGraves()).thenReturn(List.of(healthy));when(backend.loadBrokenGraves()).thenReturn(List.of(h.grave.getGravesConfig())); + // Broken record first in the active load also covers paid records loaded by SQL. + when(backend.loadGraves()).thenReturn(List.of(h.grave.getGravesConfig(),healthy));when(backend.loadBrokenGraves()).thenReturn(List.of()); + when(h.plugin.getFeatureIO()).thenReturn(h.io);doAnswer(i->{h.active.add(i.getArgument(0));return null;}).when(h.plugin).restoreActiveRecord(any()); + var restoration=new GraveRestoration(h.plugin,backend);GraveActionsTest.retryOnOwner(h, restoration);FeaturePersistenceTest.await(h.io.barrier());h.regionWork.remove().run(); + assertEquals(1,h.active.size());assertSame(healthy,h.active.getFirst().getGravesConfig());assertTrue(restoration.hasPending(h.identity())); + verify(backend,never()).restorationResolved(h.grave.getGravesConfig());verify(h.plugin,never()).restoreBrokenRecord(any());restoration.retire(); + } + } + @Test void paidAndDestroyedMissingWorldRecordsStayPendingThenRebindBeforeHistoricalPublication() throws Exception { + for(boolean paid:List.of(false,true)) try(var h=new GraveActionsTest.Harness()) { + var globalTasks = new LinkedBlockingQueue(); Thread ownerThread = Thread.currentThread(); + doAnswer(call -> { globalTasks.add(call.getArgument(1)); return null; }) + .when(h.scheduler).runTask(eq(h.plugin), any(Runnable.class)); + UUID missing=UUID.randomUUID();var record=h.grave.getGravesConfig();record.setWorldId(missing);record.getLocation().setWorld(null);record.setDestroyed(!paid); + h.active.clear();when(h.config.getBrokenGraveTimeLimit()).thenReturn("7d");when(h.plugin.getFeatureIO()).thenReturn(h.io); + when(h.plugin.brokenGraveSnapshot()).thenReturn(List.of());var cleanup=cleanup(h,new DurableFeatureState(h.io,new FeaturePersistenceTest.Disk(),8192)); + var backend=mock(GraveStorageManager.class);when(backend.loadGraves()).thenReturn(List.of(record));when(backend.loadBrokenGraves()).thenReturn(List.of()); + var decisions=new LinkedBlockingQueue>();var ledger=mock(GraveClaimLedger.class);when(h.plugin.getGraveClaimLedger()).thenReturn(ledger); + when(ledger.restoration(any())).thenAnswer(i->{var decision=new CompletableFuture();decisions.add(decision);return decision;}); + var restoration=new GraveRestoration(h.plugin,backend);GraveActionsTest.retryOnOwner(h, restoration); + Objects.requireNonNull(globalTasks.poll(5,TimeUnit.SECONDS)).run(); + Objects.requireNonNull(globalTasks.poll(5,TimeUnit.SECONDS)).run(); + var status=paid?GraveClaimLedger.Restoration.PAID:GraveClaimLedger.Restoration.AVAILABLE; + var firstDecision=Objects.requireNonNull(decisions.poll(5,TimeUnit.SECONDS)); + FeaturePersistenceTest.await(h.io.barrier()); // Attach callback before completing on the mocked Bukkit owner. + firstDecision.complete(status); Objects.requireNonNull(globalTasks.poll(5,TimeUnit.SECONDS)).run(); + assertTrue(restoration.hasPending(GraveIdentity.of(record)));assertEquals(missing.toString(),record.serialize().get("World"));assertEquals(17,record.getExp()); + verify(h.plugin,never()).restoreBrokenRecord(any());verify(backend,never()).restorationResolved(any()); + h.bukkit.when(()->Bukkit.getWorld(missing)).thenAnswer(call -> { assertSame(ownerThread, Thread.currentThread()); return h.world; });GraveActionsTest.retryOnOwner(h, restoration); + Objects.requireNonNull(decisions.poll(5,TimeUnit.SECONDS)).complete(status); Objects.requireNonNull(globalTasks.poll(5,TimeUnit.SECONDS)).run(); FeaturePersistenceTest.await(h.io.barrier()); + Objects.requireNonNull(globalTasks.poll(5,TimeUnit.SECONDS)).run(); + var restored=org.mockito.ArgumentCaptor.forClass(Grave.class);verify(h.plugin).restoreBrokenRecord(restored.capture()); + assertSame(h.world,restored.getValue().getGravesConfig().getLocation().getWorld());assertFalse(restoration.hasPending(GraveIdentity.of(record))); + verify(h.world,never()).getChunkAt(anyInt(),anyInt());restoration.retire();cleanup.retire(); + } + } +} diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/NavigationLegacyCompassRegressionTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/NavigationLegacyCompassRegressionTest.java new file mode 100644 index 0000000..4161bea --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/NavigationLegacyCompassRegressionTest.java @@ -0,0 +1,188 @@ +package com.bencodez.gravestonesplus.graves; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.CompassMeta; +import org.bukkit.persistence.PersistentDataContainer; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.Test; + +/** Regression coverage for legacy compasses during asynchronous restoration admission. */ +class NavigationLegacyCompassRegressionTest { + @Test void rejectedPlayerRefreshDoesNotInterruptRemovalOrOtherPlayers() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + h.grant(TrustedGraveAccess.Kind.TRACK); + assertTrue(h.navigation.select(h.owner, h.grave)); + h.bukkit.when(org.bukkit.Bukkit::getOnlinePlayers).thenReturn(java.util.List.of(h.owner, h.helper)); + doThrow(new java.util.concurrent.RejectedExecutionException("retired player")) + .when(h.scheduler).runTask(eq(h.plugin), any(Runnable.class), eq(h.owner)); + + h.grave.removeGrave(com.bencodez.gravestonesplus.events.GraveRemoveReason.MANUAL_REMOVE); + assertDoesNotThrow(() -> h.regionWork.remove().run()); + h.drain(); + + assertTrue(h.grave.isRemove()); + assertTrue(h.grave.getGravesConfig().isDestroyed()); + assertFalse(h.active.contains(h.grave)); + assertTrue(h.access.list(h.identity()).isEmpty(), "removal still revokes helper grants"); + assertNull(h.navigation.target(h.owner)); + verify(h.scheduler, never()).runTask(eq(h.plugin), any(Runnable.class), any(org.bukkit.entity.Entity.class)); + verify(h.plugin).removeGrave(h.grave); + } + } + + @Test void invalidationDoesNotFanOutOwnerTasks() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + assertTrue(h.navigation.select(h.owner, h.grave)); + h.bukkit.when(org.bukkit.Bukkit::getOnlinePlayers).thenReturn(java.util.List.of(h.owner, h.helper)); + + assertDoesNotThrow(() -> h.navigation.invalidate(h.identity())); + + assertNull(h.navigation.target(h.owner)); + verify(h.scheduler, never()).runTask(eq(h.plugin), any(Runnable.class), any(org.bukkit.entity.Entity.class)); + } + } + + @Test void repeatedInvalidationDoesNotIncreaseRefreshFanout() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + h.navigation.invalidate(h.identity()); + h.navigation.invalidate(h.identity()); + verify(h.scheduler, never()).runTask(eq(h.plugin), any(Runnable.class), any(org.bukkit.entity.Entity.class)); + } + } + + @Test void boundedLoginRefreshClearsRemovedCompassEvenWhenReminderStoreFailed() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + assertTrue(h.navigation.select(h.owner, h.grave)); + Compass compass = compass(h, h.grave.getGravesConfig().getTime(), h.grave.getGravesConfig().getLocation()); + when(h.ownerInventory.getContents()).thenReturn(new ItemStack[] { compass.item }); + var disk = new FeaturePersistenceTest.Disk(); + var state = new DurableFeatureState(h.io, disk, 8192); + var reminders = new GraveReminderStore(state); + assertTrue(FeaturePersistenceTest.await(reminders.loaded())); + disk.fail = true; + assertFalse(FeaturePersistenceTest.await(state.update(next -> { next.put("fixture", "value"); return true; }))); + assertFalse(reminders.isReady()); + when(h.plugin.getGraveReminderStore()).thenReturn(reminders); + var listener = new com.bencodez.gravestonesplus.listeners.GraveReminderListener(h.plugin); + listener.start(); + h.active.clear(); + h.navigation.invalidate(h.identity()); + verify(compass.item, never()).setItemMeta(any()); + listener.onJoin(new org.bukkit.event.player.PlayerJoinEvent(h.owner, "")); + h.drain(); + verify(compass.meta).setLodestone(null); + verify(compass.item).setItemMeta(compass.meta); + verify(h.owner, never()).sendMessage(anyString()); + listener.stop(); + } + } + + @Test void exactPendingLegacyCompassSurvivesAfterBackendLoadingCompletes() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + h.active.clear(); + long time = h.grave.getGravesConfig().getTime(); + var restoration = mock(GraveRestoration.class); + when(restoration.isLoading()).thenReturn(false); + when(restoration.hasPending(h.identity())).thenReturn(true); + when(h.plugin.getGraveRestoration()).thenReturn(restoration); + Compass compass = compass(h, time, new Location(h.world, 1, 64, 1)); + when(h.ownerInventory.getContents()).thenReturn(new ItemStack[] { compass.item }); + + h.navigation.refresh(h.owner); + + verify(compass.data, never()).remove(any()); + verify(compass.item, never()).setItemMeta(any()); + } + } + + @Test void unrelatedLegacyCompassIsClearedEvenWhileAnotherRecordIsPending() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + h.active.clear(); + var restoration = mock(GraveRestoration.class); + when(restoration.isLoading()).thenReturn(false); + when(restoration.hasPending(h.identity())).thenReturn(false); + when(h.plugin.getGraveRestoration()).thenReturn(restoration); + Compass compass = compass(h, h.grave.getGravesConfig().getTime() + 1, new Location(h.world, 1, 64, 1)); + when(h.ownerInventory.getContents()).thenReturn(new ItemStack[] { compass.item }); + + h.navigation.refresh(h.owner); + + verify(compass.data, times(2)).remove(any()); + verify(compass.item).setItemMeta(compass.meta); + } + } + + @Test void legacyCompassIsClearedAfterItsPendingRecordResolvesWithoutAdmission() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + h.active.clear(); + long time = h.grave.getGravesConfig().getTime(); + var restoration = mock(GraveRestoration.class); + when(restoration.isLoading()).thenReturn(false); + when(restoration.hasPending(h.identity())).thenReturn(true, false); + when(h.plugin.getGraveRestoration()).thenReturn(restoration); + Compass compass = compass(h, time, new Location(h.world, 1, 64, 1)); + when(h.ownerInventory.getContents()).thenReturn(new ItemStack[] { compass.item }); + + h.navigation.refresh(h.owner); + verify(compass.data, never()).remove(any()); + reset(compass.data, compass.item); + when(compass.item.getType()).thenReturn(Material.COMPASS); + when(compass.item.getItemMeta()).thenReturn(compass.meta); + when(compass.meta.getPersistentDataContainer()).thenReturn(compass.data); + when(compass.data.get(any(), eq(PersistentDataType.STRING))).thenReturn(null); + when(compass.data.has(any(), eq(PersistentDataType.LONG))).thenReturn(true); + when(compass.data.get(any(), eq(PersistentDataType.LONG))).thenReturn(time); + when(compass.meta.getLodestone()).thenReturn(new Location(h.world, 1, 64, 1)); + + h.navigation.refresh(h.owner); + + verify(compass.data, times(2)).remove(any()); + verify(compass.item).setItemMeta(compass.meta); + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void unavailableFeatureStateNeverErasesCompassOrSelection(boolean claimFailure) throws Exception { + try (var h = new GraveActionsTest.Harness()) { + assertTrue(h.navigation.select(h.owner, h.grave)); + Compass compass = compass(h, h.grave.getGravesConfig().getTime(), h.grave.getGravesConfig().getLocation()); + when(h.ownerInventory.getContents()).thenReturn(new ItemStack[] {compass.item}); + var disk = new FeaturePersistenceTest.Disk(); + var state = new DurableFeatureState(h.io, disk, 8192); + assertTrue(FeaturePersistenceTest.await(state.loaded())); + if (claimFailure) when(h.plugin.getGraveClaimLedger()).thenReturn(new GraveClaimLedger(state)); + else when(h.plugin.getTrustedGraveAccessStore()).thenReturn(new TrustedGraveAccessStore(state, System::currentTimeMillis)); + disk.fail = true; + assertFalse(FeaturePersistenceTest.await(state.update(next -> { next.put("fixture", "value"); return true; }))); + assertTrue(FeaturePersistenceTest.await(state.loaded()), "initial load succeeded before uncertain publication"); + assertNull(h.navigation.target(h.owner)); h.navigation.refresh(h.owner); + verify(compass.data, never()).remove(any()); verify(compass.item, never()).setItemMeta(any()); + // Restoring an available store lets the same selected target return without re-selection. + if (claimFailure) when(h.plugin.getGraveClaimLedger()).thenReturn(h.ledger); + else when(h.plugin.getTrustedGraveAccessStore()).thenReturn(h.access); + assertSame(h.grave, h.navigation.target(h.owner)); + } + } + + private static Compass compass(GraveActionsTest.Harness h, long time, Location location) { + ItemStack item = mock(ItemStack.class); + CompassMeta meta = mock(CompassMeta.class); + PersistentDataContainer data = mock(PersistentDataContainer.class); + when(item.getType()).thenReturn(Material.COMPASS); + when(item.getItemMeta()).thenReturn(meta); + when(meta.getPersistentDataContainer()).thenReturn(data); + when(data.get(any(), eq(PersistentDataType.STRING))).thenReturn(null); + when(data.has(any(), eq(PersistentDataType.LONG))).thenReturn(true); + when(data.get(any(), eq(PersistentDataType.LONG))).thenReturn(time); + when(meta.getLodestone()).thenReturn(location); + return new Compass(item, meta, data); + } + + private record Compass(ItemStack item, CompassMeta meta, PersistentDataContainer data) { } +} diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RecoveryCompletionTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RecoveryCompletionTest.java new file mode 100644 index 0000000..ee39a13 --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RecoveryCompletionTest.java @@ -0,0 +1,438 @@ +package com.bencodez.gravestonesplus.graves; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Method; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicReference; + +import com.bencodez.advancedcore.AdvancedCoreConfigOptions; +import com.bencodez.advancedcore.api.item.FullInventoryHandler; +import com.bencodez.gravestonesplus.breaking.PlayerBreakManager; +import com.bencodez.gravestonesplus.events.GraveClaimEvent; +import com.bencodez.gravestonesplus.events.GraveRemoveEvent; +import com.bencodez.gravestonesplus.storage.GraveStorageManager; +import com.bencodez.simpleapi.time.ParsedDuration; +import org.bukkit.entity.Entity; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class RecoveryCompletionTest { + @TempDir Path directory; + + static void overflow(GraveActionsTest.Harness h) { + ItemStack occupied = mock(ItemStack.class); + var type = h.stored.getType(); when(occupied.getType()).thenReturn(type); + when(h.ownerInventory.getItem(4)).thenReturn(occupied); + when(h.ownerInventory.addItem(h.cloned)).thenReturn(new HashMap<>(Map.of(0, h.cloned))); + } + + @Test void claimWaitsForEveryOverflowAcknowledgmentBeforePaidArchiveRemovalAndSuccess() throws Exception { + try (GraveActionsTest.Harness h = new GraveActionsTest.Harness()) { + overflow(h); + h.grave.getGravesConfig().getItems().put(5, h.stored); + ItemStack occupied = h.ownerInventory.getItem(4); when(h.ownerInventory.getItem(5)).thenReturn(occupied); + var handler = mock(FullInventoryHandler.class); when(h.plugin.getFullInventoryHandler()).thenReturn(handler); + var first = new CompletableFuture(); var second = new CompletableFuture(); + when(handler.giveItemAsync(h.owner, h.cloned)).thenReturn(first, second); + var ledger = new GraveClaimLedger(new DurableFeatureState(h.io, h.claimDisk, 8192), directory.resolve("archive")); + FeaturePersistenceTest.await(ledger.loaded()); when(h.plugin.getGraveClaimLedger()).thenReturn(ledger); + + h.grave.claim(h.owner); h.drain(); + assertTrue(ledger.status(h.identity()).startsWith("RESERVED:")); + assertFalse(Files.exists(directory.resolve("archive"))); + assertFalse(h.grave.getGravesConfig().isDestroyed()); verify(h.plugin, never()).removeGrave(any()); + verify(h.owner, never()).sendMessage("Paid"); verify(h.owner).giveExp(17); + + first.complete(null); h.drain(); + assertTrue(ledger.status(h.identity()).startsWith("RESERVED:")); + verify(h.plugin, never()).removeGrave(any()); + second.complete(null); h.drain(); + assertTrue(ledger.status(h.identity()).startsWith("PAID:")); + assertTrue(h.grave.getGravesConfig().isDestroyed()); verify(h.plugin).removeGrave(h.grave); + verify(h.owner).sendMessage("Paid"); + try (var files = Files.list(directory.resolve("archive"))) { assertEquals(1, files.count()); } + h.grave.claim(h.owner); h.drain(); verify(h.owner, times(1)).giveExp(17); + } + } + + @Test void retiredAdvancedCoreOverflowTaskLeavesExactReservationEvidenceAndNeverAdvertisesPaid() throws Exception { + try (GraveActionsTest.Harness h = new GraveActionsTest.Harness()) { + overflow(h); + FullInventoryHandler handler = spy(new FullInventoryHandler(h.plugin) { + @Override protected long getItemDeliveryTimeoutMillis() { return 20L; } + }); + try { + when(h.plugin.getFullInventoryHandler()).thenReturn(handler); + var delivery = new AtomicReference>(); + doAnswer(call -> { + var stage = (CompletionStage) call.callRealMethod(); + delivery.set(stage.toCompletableFuture()); return stage; + }).when(handler).giveItemAsync(h.owner, h.cloned); + // Keep the claim admission; discard the nested AdvancedCore entity task as Folia retirement does. + var calls = new java.util.concurrent.atomic.AtomicInteger(); + doAnswer(call -> { if (calls.incrementAndGet() != 2) h.entityWork.add(call.getArgument(1)); return null; }) + .when(h.scheduler).runTask(eq(h.plugin), any(Runnable.class), any(Entity.class)); + h.grave.claim(h.owner); h.drain(); assertNotNull(delivery.get()); + assertThrows(ExecutionException.class, () -> FeaturePersistenceTest.await(delivery.get())); h.drain(); + + assertTrue(h.ledger.status(h.identity()).startsWith("RESERVED:")); + String payload = h.ledger.status(h.identity()).split(":", 3)[2]; + var evidence = new org.bukkit.configuration.file.YamlConfiguration(); + evidence.loadFromString(new String(Base64.getDecoder().decode(payload), java.nio.charset.StandardCharsets.UTF_8)); + assertEquals(h.identity(), evidence.getString("identity")); assertEquals(17, evidence.getInt("experience")); + assertEquals(Set.of("4"), evidence.getConfigurationSection("items").getKeys(false)); + assertEquals("DIAMOND", evidence.getString("items.4.type")); assertEquals(2, evidence.getInt("items.4.amount")); + assertFalse(h.grave.getGravesConfig().isDestroyed()); verify(h.plugin, never()).removeGrave(any()); + verify(h.owner, never()).sendMessage("Paid"); assertTrue(handler.getItems().isEmpty()); + h.grave.claim(h.owner); h.drain(); verify(h.owner, times(1)).giveExp(17); + var restart = new GraveClaimLedger(new DurableFeatureState(h.io, h.claimDisk, 8192)); + FeaturePersistenceTest.await(restart.loaded()); assertTrue(restart.isBlocked(h.identity())); + } finally { handler.shutdown(); } + } + } + + @Test void failedOverflowAcknowledgmentPreservesReservationAfterOtherItemsWereDelivered() throws Exception { + try (GraveActionsTest.Harness h = new GraveActionsTest.Harness()) { + overflow(h); var handler = mock(FullInventoryHandler.class); when(h.plugin.getFullInventoryHandler()).thenReturn(handler); + var completion = new CompletableFuture(); when(handler.giveItemAsync(h.owner, h.cloned)).thenReturn(completion); + h.grave.claim(h.owner); h.drain(); completion.completeExceptionally(new IllegalStateException("retention failed")); h.drain(); + assertTrue(h.ledger.status(h.identity()).startsWith("RESERVED:")); + assertFalse(h.grave.getGravesConfig().isDestroyed()); verify(h.plugin, never()).removeGrave(any()); + verify(h.owner, never()).sendMessage("Paid"); verify(h.owner).giveExp(17); + } + } + + static PlayerBreakManager manager(GraveActionsTest.Harness h) { + PlayerBreakManager manager = new PlayerBreakManager(h.plugin); when(h.plugin.getPlayerBreakManager()).thenReturn(manager); + when(h.config.isBreakOtherGravesEnabled()).thenReturn(true); + when(h.config.isBreakOtherGravesRequirePermission()).thenReturn(true); + when(h.config.isBreakOtherGravesRequireBreakTime()).thenReturn(true); + when(h.helper.hasPermission("GraveStonesPlus.BreakOtherGraves")).thenReturn(true); + when(h.config.getBreakOtherGravesTimeBeforeBreakable()).thenReturn(duration("0")); + when(h.config.getBreakOtherGravesTime()).thenReturn(duration("1s")); + when(h.config.getBreakOtherGravesHitTimeout()).thenReturn(duration("1m")); + return manager; + } + static ParsedDuration duration(String value) { return ParsedDuration.parse(value, TimeUnit.MILLISECONDS); } + static void elapsed(PlayerBreakManager manager, GraveActionsTest.Harness h) { + manager.getActiveBreaks().get(h.helper.getUniqueId()).setStartTime(System.currentTimeMillis() - 2000); + } + static void tick(PlayerBreakManager manager, GraveActionsTest.Harness h) throws Exception { + Method method = PlayerBreakManager.class.getDeclaredMethod("tickPlayer", org.bukkit.entity.Player.class); + method.setAccessible(true); method.invoke(manager, h.helper); + } + + @Test void authorizedTimedOtherBreakSurvivesTickAndPaysThroughFinalClaimChecks() throws Exception { + try (GraveActionsTest.Harness h = new GraveActionsTest.Harness()) { + PlayerBreakManager manager = manager(h); + assertFalse(manager.handleHit(h.helper, h.grave)); elapsed(manager, h); tick(manager, h); + assertTrue(manager.isBreaking(h.helper, h.grave)); assertFalse(h.grave.legacyClaimAllowed(h.helper)); + assertTrue(manager.handleHit(h.helper, h.grave)); h.drain(); + verify(h.helper).giveExp(17); verify(h.helperInventory).setItem(4, h.cloned); + assertTrue(h.grave.getGravesConfig().isDestroyed()); assertTrue(h.ledger.status(h.identity()).startsWith("PAID:")); + } + } + + @Test void timedBreakTickRejectsDisabledRevokedOrProtectedOtherGraves() throws Exception { + for (int change : List.of(0, 1, 2)) try (GraveActionsTest.Harness h = new GraveActionsTest.Harness()) { + PlayerBreakManager manager = manager(h); assertFalse(manager.handleHit(h.helper, h.grave)); elapsed(manager, h); + if (change == 0) when(h.config.isBreakOtherGravesEnabled()).thenReturn(false); + else if (change == 1) when(h.helper.hasPermission("GraveStonesPlus.BreakOtherGraves")).thenReturn(false); + else when(h.config.getBreakOtherGravesTimeBeforeBreakable()).thenReturn(duration("1m")); + tick(manager, h); assertFalse(manager.isBreaking(h.helper, h.grave)); + assertFalse(manager.handleHit(h.helper, h.grave)); h.drain(); verify(h.helper, never()).giveExp(anyInt()); + } + } + + @Test void completedTimedBreakIsInvalidAfterReloadPermissionLossOrLongerDurationDuringReservation() throws Exception { + for (int change : List.of(0, 1, 2)) try (GraveActionsTest.Harness h = new GraveActionsTest.Harness()) { + CompletableFuture reload = null; + PlayerBreakManager manager = manager(h); manager.handleHit(h.helper, h.grave); elapsed(manager, h); + assertTrue(manager.handleHit(h.helper, h.grave)); h.entityWork.remove().run(); FeaturePersistenceTest.await(h.io.barrier()); + if (change == 0) { + GraveActionsTest.field(h.plugin, "playerBreakManager", manager); + GraveActionsTest.field(h.plugin, "featureIO", h.io); + GraveActionsTest.field(h.plugin, "configFile", h.config); + GraveActionsTest.field(h.plugin, "gravesConfig", mock(com.bencodez.gravestonesplus.storage.GraveLocations.class)); + GraveActionsTest.field(h.plugin, "nbtConfigManager", mock(com.bencodez.gravestonesplus.nbt.NBTConfigManager.class)); + reloadPaths(h); + doCallRealMethod().when(h.plugin).reloadAsync(); + when(h.plugin.getJavascriptEngine()).thenReturn(new HashMap<>()); + doAnswer(call -> { h.entityWork.add(call.getArgument(1)); return null; }) + .when(h.scheduler).runTask(eq(h.plugin), any(Runnable.class)); + reload = h.plugin.reloadAsync(); + assertFalse(reload.isDone(), "reload must not complete before the owner callback runs"); + } + else if (change == 1) when(h.helper.hasPermission("GraveStonesPlus.BreakOtherGraves")).thenReturn(false); + else when(h.config.getBreakOtherGravesTime()).thenReturn(duration("1m")); + h.drain(); if (reload != null) assertTrue(reload.isDone()); verify(h.helper, never()).giveExp(anyInt()); assertFalse(h.ledger.isBlocked(h.identity())); + assertFalse(h.grave.getGravesConfig().isDestroyed()); + } + } + + private void reloadPaths(GraveActionsTest.Harness h) throws Exception { + Path config = directory.resolve("Config.yml"), graves = directory.resolve("Graves.yml"); + Files.writeString(config, "Root: {}\n"); Files.writeString(graves, ""); + Files.writeString(directory.resolve("nbt_rules.yml"), "Root: {}\n"); + when(h.plugin.getDataFolder()).thenReturn(directory.toFile()); + when(h.config.getdFile()).thenReturn(config.toFile()); + var locations = mock(com.bencodez.gravestonesplus.storage.GraveLocations.class); + when(locations.getdFile()).thenReturn(graves.toFile()); + GraveActionsTest.field(h.plugin, "gravesConfig", locations); + } + + @Test void malformedYamlFailsBeforeConfigMutationOrOwnerPublication() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + GraveActionsTest.field(h.plugin, "featureIO", h.io); + GraveActionsTest.field(h.plugin, "configFile", h.config); + reloadPaths(h); + Files.writeString(directory.resolve("Config.yml"), "broken: [\n"); + doCallRealMethod().when(h.plugin).reloadAsync(); + var reload = h.plugin.reloadAsync(); FeaturePersistenceTest.await(h.io.barrier()); + h.ownerWork.remove().run(); + assertThrows(ExecutionException.class, () -> FeaturePersistenceTest.await(reload)); + verify(h.config, never()).reloadData(); + verify(h.plugin, never()).reloadAdvancedCore(false); + } + } + + @Test void missingNbtRulesAreRecreatedBeforeValidationAndOwnerPublication() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + GraveActionsTest.field(h.plugin, "featureIO", h.io); + GraveActionsTest.field(h.plugin, "configFile", h.config); + GraveActionsTest.field(h.plugin, "nbtConfigManager", mock(com.bencodez.gravestonesplus.nbt.NBTConfigManager.class)); + reloadPaths(h); + Path rules = directory.resolve("nbt_rules.yml"); + Files.delete(rules); + doAnswer(call -> { Files.writeString(rules, "ignore_nbt_rules: []\n"); return null; }) + .when(h.plugin).saveResource("nbt_rules.yml", false); + when(h.plugin.getJavascriptEngine()).thenReturn(new HashMap<>()); + when(h.plugin.activeGraveSnapshot()).thenReturn(List.of()); + doAnswer(call -> { h.entityWork.add(call.getArgument(1)); return null; }) + .when(h.scheduler).runTask(eq(h.plugin), any(Runnable.class)); + doCallRealMethod().when(h.plugin).reloadAsync(); + var reload = h.plugin.reloadAsync(); + FeaturePersistenceTest.await(h.io.barrier()); + assertTrue(Files.exists(rules)); + verify(h.plugin).saveResource("nbt_rules.yml", false); + assertFalse(reload.isDone()); + h.entityWork.remove().run(); + FeaturePersistenceTest.await(reload); + verify(h.plugin).reloadAdvancedCore(false); + } + } + + @Test void reloadOwnerCompletionRejectsConcurrentDisabledAndFailedPublication() throws Exception { + for (int outcome : List.of(0, 1, 2)) try (var h = new GraveActionsTest.Harness()) { + GraveActionsTest.field(h.plugin, "featureIO", h.io); + GraveActionsTest.field(h.plugin, "configFile", h.config); + GraveActionsTest.field(h.plugin, "gravesConfig", mock(com.bencodez.gravestonesplus.storage.GraveLocations.class)); + GraveActionsTest.field(h.plugin, "nbtConfigManager", mock(com.bencodez.gravestonesplus.nbt.NBTConfigManager.class)); + when(h.plugin.getJavascriptEngine()).thenReturn(new HashMap<>()); + when(h.plugin.activeGraveSnapshot()).thenReturn(List.of()); + doAnswer(call -> { h.entityWork.add(call.getArgument(1)); return null; }) + .when(h.scheduler).runTask(eq(h.plugin), any(Runnable.class)); + reloadPaths(h); + doCallRealMethod().when(h.plugin).reloadAsync(); + var reload = h.plugin.reloadAsync(); + FeaturePersistenceTest.await(h.io.barrier()); + assertFalse(reload.isDone()); + var duplicate = h.plugin.reloadAsync(); + assertThrows(ExecutionException.class, () -> FeaturePersistenceTest.await(duplicate)); + if (outcome == 1) when(h.plugin.isEnabled()).thenReturn(false); + if (outcome == 2) doThrow(new IllegalStateException("owner failed")).when(h.plugin).reloadAdvancedCore(false); + h.entityWork.remove().run(); + if (outcome == 0) { FeaturePersistenceTest.await(reload); verify(h.plugin).reloadAdvancedCore(false); } + else assertThrows(ExecutionException.class, () -> FeaturePersistenceTest.await(reload)); + if (outcome == 1) verify(h.plugin, never()).reloadAdvancedCore(false); + } + } + + @Test void reloadAsyncPropagatesReadFailureWithoutSchedulingOwnerPublication() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + GraveActionsTest.field(h.plugin, "featureIO", h.io); + GraveActionsTest.field(h.plugin, "configFile", h.config); + GraveActionsTest.field(h.plugin, "gravesConfig", mock(com.bencodez.gravestonesplus.storage.GraveLocations.class)); + GraveActionsTest.field(h.plugin, "nbtConfigManager", mock(com.bencodez.gravestonesplus.nbt.NBTConfigManager.class)); + reloadPaths(h); + Files.delete(directory.resolve("Config.yml")); + doCallRealMethod().when(h.plugin).reloadAsync(); + var reload = h.plugin.reloadAsync(); + assertThrows(ExecutionException.class, () -> FeaturePersistenceTest.await(reload)); + verify(h.scheduler, never()).runTask(eq(h.plugin), any(Runnable.class)); + } + } + + @Test void lateNbtPreparationFailureRetainsAllLiveConfigurationObjects() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + GraveActionsTest.field(h.plugin, "featureIO", h.io); + GraveActionsTest.field(h.plugin, "configFile", h.config); reloadPaths(h); + var oldNbt = mock(com.bencodez.gravestonesplus.nbt.NBTConfigManager.class); + GraveActionsTest.field(h.plugin, "nbtConfigManager", oldNbt); + doCallRealMethod().when(h.plugin).getConfigFile(); + doCallRealMethod().when(h.plugin).getGravesConfig(); + doCallRealMethod().when(h.plugin).getNbtConfigManager(); + var locations = h.plugin.getGravesConfig(); + Files.writeString(directory.resolve("Config.yml"), "KeepItemsWithLore: new-lore\nGraveTimeLimit: 9m\n"); + doCallRealMethod().when(h.plugin).reloadAsync(); + var reload = h.plugin.reloadAsync(); FeaturePersistenceTest.await(h.io.barrier()); + assertSame(h.config, h.plugin.getConfigFile()); + try (var nbt = mockStatic(com.bencodez.gravestonesplus.nbt.NBTConfigManager.class)) { + nbt.when(() -> com.bencodez.gravestonesplus.nbt.NBTConfigManager.prepare(eq(h.plugin), any(), any())) + .thenThrow(new IllegalStateException("late NBT preparation failed")); + h.ownerWork.remove().run(); + } + assertThrows(ExecutionException.class, () -> FeaturePersistenceTest.await(reload)); + assertSame(h.config, h.plugin.getConfigFile()); assertSame(locations, h.plugin.getGravesConfig()); + assertSame(oldNbt, h.plugin.getNbtConfigManager()); + verify(h.config, never()).reloadData(); verify(h.plugin, never()).reloadAdvancedCore(false); + } + } + + @Test void reloadPublishesOnePreparedGenerationWithoutRereadingChangedFiles() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + GraveActionsTest.field(h.plugin, "featureIO", h.io); + GraveActionsTest.field(h.plugin, "configFile", h.config); reloadPaths(h); + GraveActionsTest.field(h.plugin, "nbtConfigManager", mock(com.bencodez.gravestonesplus.nbt.NBTConfigManager.class)); + doCallRealMethod().when(h.plugin).getConfigFile(); doCallRealMethod().when(h.plugin).getGravesConfig(); + doCallRealMethod().when(h.plugin).getNbtConfigManager(); + when(h.plugin.getJavascriptEngine()).thenReturn(new HashMap<>()); + when(h.plugin.activeGraveSnapshot()).thenReturn(List.of()); + Files.writeString(directory.resolve("Config.yml"), "KeepItemsWithLore: captured-lore\nGraveTimeLimit: 9m\n"); + doCallRealMethod().when(h.plugin).reloadAsync(); + var reload = h.plugin.reloadAsync(); FeaturePersistenceTest.await(h.io.barrier()); + assertSame(h.config, h.plugin.getConfigFile()); + Files.writeString(directory.resolve("Config.yml"), "broken: [\n"); + Files.writeString(directory.resolve("nbt_rules.yml"), "broken: [\n"); + h.ownerWork.remove().run(); FeaturePersistenceTest.await(reload); + assertNotSame(h.config, h.plugin.getConfigFile()); + assertEquals("captured-lore", h.plugin.getConfigFile().getKeepItemsWithLore()); + assertEquals("captured-lore", h.plugin.getNbtConfigManager().snapshot().keepLore()); + assertEquals("9m", h.plugin.getConfigFile().getGraveTimeLimit()); + verify(h.config, never()).reloadData(); + } + } + + @Test void corePublicationFailureRestoresPreviousConfigurationGeneration() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + GraveActionsTest.field(h.plugin, "featureIO", h.io); + GraveActionsTest.field(h.plugin, "configFile", h.config); reloadPaths(h); + var oldNbt = mock(com.bencodez.gravestonesplus.nbt.NBTConfigManager.class); + GraveActionsTest.field(h.plugin, "nbtConfigManager", oldNbt); + doCallRealMethod().when(h.plugin).getConfigFile(); doCallRealMethod().when(h.plugin).getGravesConfig(); + doCallRealMethod().when(h.plugin).getNbtConfigManager(); + var oldGraves = h.plugin.getGravesConfig(); + when(h.plugin.getJavascriptEngine()).thenReturn(new HashMap<>()); + doThrow(new IllegalStateException("core publication failed")).when(h.plugin).reloadAdvancedCore(false); + doCallRealMethod().when(h.plugin).reloadAsync(); + var reload = h.plugin.reloadAsync(); FeaturePersistenceTest.await(h.io.barrier()); + h.ownerWork.remove().run(); + assertThrows(ExecutionException.class, () -> FeaturePersistenceTest.await(reload)); + assertSame(h.config, h.plugin.getConfigFile()); assertSame(oldGraves, h.plugin.getGravesConfig()); + assertSame(oldNbt, h.plugin.getNbtConfigManager()); + } + } + + @Test void timedClaimRevalidatesPermissionAndCancellationAfterClaimAndRemovalEvents() throws Exception { + for (int change : List.of(0, 1, 2)) try (GraveActionsTest.Harness h = new GraveActionsTest.Harness()) { + PlayerBreakManager manager = manager(h); manager.handleHit(h.helper, h.grave); elapsed(manager, h); + doAnswer(call -> { + if (call.getArgument(0) instanceof GraveClaimEvent event && change == 0) event.setCancelled(true); + if (call.getArgument(0) instanceof GraveRemoveEvent event) { + if (change == 1) event.setCancelled(true); + if (change == 2) when(h.helper.hasPermission("GraveStonesPlus.BreakOtherGraves")).thenReturn(false); + } + return null; + }).when(h.events).callEvent(any()); + assertTrue(manager.handleHit(h.helper, h.grave)); h.drain(); + verify(h.helper, never()).giveExp(anyInt()); assertFalse(h.ledger.isBlocked(h.identity())); + } + } + + @Test void archivedRecreationIsDeniedAfterRestartAndAfterCompletionCacheEvictionBeforeAnyMarkerOrListMutation() throws Exception { + for (boolean restart : List.of(false, true)) try (GraveActionsTest.Harness h = new GraveActionsTest.Harness()) { + Path archive = directory.resolve(restart ? "restart" : "eviction"); + var state = new DurableFeatureState(h.io, h.claimDisk, 8192); var ledger = new GraveClaimLedger(state, archive); + FeaturePersistenceTest.await(ledger.loaded()); + assertTrue(FeaturePersistenceTest.await(ledger.reserve(h.identity(), h.owner.getUniqueId()))); + assertTrue(FeaturePersistenceTest.await(ledger.paid(h.identity(), h.owner.getUniqueId()))); + if (restart) { ledger = new GraveClaimLedger(new DurableFeatureState(h.io, h.claimDisk, 8192), archive); FeaturePersistenceTest.await(ledger.loaded()); } + else for (int index = 0; index < 513; index++) { + assertTrue(FeaturePersistenceTest.await(ledger.reserve("completed-" + index, h.owner.getUniqueId()))); + assertTrue(FeaturePersistenceTest.await(ledger.paid("completed-" + index, h.owner.getUniqueId()))); + } + assertFalse(ledger.isBlocked(h.identity()), "The cache alone cannot establish absence from archived history"); + when(h.plugin.getGraveClaimLedger()).thenReturn(ledger); GraveActionsTest.field(h.plugin, "graveClaimLedger", ledger); + var storage = mock(GraveStorageManager.class); GraveActionsTest.field(h.plugin, "storageManager", storage); + GraveActionsTest.field(h.plugin, "brokenGraves", Collections.synchronizedList(new ArrayList<>(List.of(h.grave)))); + h.active.clear(); h.grave.getGravesConfig().setDestroyed(true); + doCallRealMethod().when(h.plugin).recreateBrokenGrave(any(), any()); + doCallRealMethod().when(h.plugin).recreateBrokenGrave(any(), any(), anyBoolean()); + when(h.helper.hasPermission("GraveStonesPlus.Admin")).thenReturn(true); + + assertFalse(FeaturePersistenceTest.await(h.plugin.recreateBrokenGrave(h.grave, h.helper))); + h.grave.createBrokenGrave(); h.drain(); + assertTrue(h.active.isEmpty()); assertTrue(h.grave.getGravesConfig().isDestroyed()); + assertTrue(h.regionWork.isEmpty()); verifyNoInteractions(storage); + verify(h.world, never()).getBlockAt(anyInt(), anyInt(), anyInt()); + verify(h.world, never()).getChunkAt(anyInt(), anyInt()); + } + } + + @Test void unknownArchiveAndRevokedRecreationPermissionFailBeforeWorldMutation() throws Exception { + try (GraveActionsTest.Harness h = new GraveActionsTest.Harness()) { + var ledger = new GraveClaimLedger(new DurableFeatureState(h.io, h.claimDisk, 8192), directory.resolve("archive")); + FeaturePersistenceTest.await(ledger.loaded()); when(h.plugin.getGraveClaimLedger()).thenReturn(ledger); + h.grave.getGravesConfig().setDestroyed(true); h.active.clear(); + when(h.helper.hasPermission("GraveStonesPlus.Admin")).thenReturn(true); + var recreation = h.grave.recreateAsync(h.helper, true); + FeaturePersistenceTest.await(h.io.barrier()); FeaturePersistenceTest.await(h.io.barrier()); + when(h.helper.hasPermission("GraveStonesPlus.Admin")).thenReturn(false); h.entityWork.remove().run(); h.drain(); + assertFalse(FeaturePersistenceTest.await(recreation)); assertTrue(h.regionWork.isEmpty()); + assertTrue(h.grave.getGravesConfig().isDestroyed()); + + assertTrue(FeaturePersistenceTest.await(ledger.reserve(h.identity(), h.owner.getUniqueId()))); + assertTrue(FeaturePersistenceTest.await(ledger.paid(h.identity(), h.owner.getUniqueId()))); + Path tombstone; try (var files = Files.list(directory.resolve("archive"))) { tombstone = files.findFirst().orElseThrow(); } + Files.writeString(tombstone, "entries: broken"); + var restart = new GraveClaimLedger(new DurableFeatureState(h.io, h.claimDisk, 8192), directory.resolve("archive")); + FeaturePersistenceTest.await(restart.loaded()); when(h.plugin.getGraveClaimLedger()).thenReturn(restart); + assertFalse(FeaturePersistenceTest.await(h.grave.recreateAsync(null, true))); + assertTrue(h.regionWork.isEmpty()); assertTrue(h.grave.getGravesConfig().isDestroyed()); + } + } + + @Test void reminderCapacityReservesPendingIdentitiesAndCountsPublishedOverlapOnce() throws Exception { + var disk = new FeaturePersistenceTest.Disk(); Map initial = new HashMap<>(); + for (int index = 0; index < 8100; index++) initial.put("existing-" + index + "/200", "sent"); + disk.values = initial; + try (FeatureIO io = new FeatureIO()) { + var state = new DurableFeatureState(io, disk, 8192); var store = new GraveReminderStore(state); FeaturePersistenceTest.await(store.loaded()); + disk.entered = new CountDownLatch(1); disk.release = new CountDownLatch(1); + assertEquals(64, store.deliver(notices(0, 64, 200), 100, fresh -> true).size()); + assertTrue(disk.entered.await(2, TimeUnit.SECONDS)); + try { + assertTrue(store.deliver(notices(64, 64, 200), 100, fresh -> { fail("chat sent beyond reserved suppression capacity"); return true; }).isEmpty()); + assertEquals(8100, state.snapshot().size()); + } finally { disk.release.countDown(); } + FeaturePersistenceTest.await(io.barrier()); assertEquals(8164, state.snapshot().size()); + assertEquals(28, store.deliver(notices(64, 28, 200), 100, fresh -> true).size()); + FeaturePersistenceTest.await(io.barrier()); assertEquals(8192, state.snapshot().size()); assertTrue(state.isReady()); + assertTrue(store.deliver(notices(92, 1, 200), 100, fresh -> { fail("full store sent chat"); return true; }).isEmpty()); + assertEquals(1, store.deliver(notices(92, 1, 300), 201, fresh -> true).size()); + FeaturePersistenceTest.await(io.barrier()); assertEquals(1, state.snapshot().size()); + var restart = new GraveReminderStore(new DurableFeatureState(io, disk, 8192)); FeaturePersistenceTest.await(restart.loaded()); + assertTrue(restart.deliver(notices(92, 1, 300), 201, fresh -> { fail("healthy restart repeated acknowledged reminder"); return true; }).isEmpty()); + } + } + static List notices(int start, int count, long deadline) { + List notices = new ArrayList<>(); + for (int index = start; index < start + count; index++) notices.add(new GraveReminderStore.Notice("new-" + index + "/" + deadline, deadline, "expires soon")); + return notices; + } +} diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/ReminderSchedulingAdmissionTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/ReminderSchedulingAdmissionTest.java new file mode 100644 index 0000000..689cc41 --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/ReminderSchedulingAdmissionTest.java @@ -0,0 +1,67 @@ +package com.bencodez.gravestonesplus.graves; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.function.Consumer; +import org.bukkit.entity.Entity; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.junit.jupiter.api.Test; +import com.bencodez.gravestonesplus.listeners.GraveReminderListener; +import com.bencodez.simpleapi.folialib.enums.EntityTaskResult; +import com.bencodez.simpleapi.folialib.wrapper.task.WrappedTask; + +class ReminderSchedulingAdmissionTest { + @Test void rejectedEntitySchedulingDoesNotPoisonLaterJoins() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var listener = spy(new GraveReminderListener(h.plugin)); + doNothing().when(listener).checkPlayer(any()); listener.start(); + doThrow(new RejectedExecutionException("fixture retired entity")) + .when(h.scheduler).runTask(eq(h.plugin), any(Runnable.class), any(Entity.class)); + doThrow(new RejectedExecutionException("fixture retired entity")) + .when(h.platform).runAtEntityWithFallback(any(Entity.class), any(), any(Runnable.class)); + assertDoesNotThrow(() -> listener.onJoin(new PlayerJoinEvent(h.owner, ""))); + var field = GraveReminderListener.class.getDeclaredField("scheduled"); field.setAccessible(true); + assertTrue(((Map) field.get(listener)).isEmpty()); + listener.stop(); + } + } + @Test void retiredAndFailedEntityTasksReleaseTheirExactAdmission() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var listener = spy(new GraveReminderListener(h.plugin)); + doNothing().when(listener).checkPlayer(any()); listener.start(); + var retired = new ArrayList(); var futures = new ArrayList>(); + doAnswer(call -> { retired.add(call.getArgument(2)); var future = new CompletableFuture(); + futures.add(future); return future; + }).when(h.platform).runAtEntityWithFallback(any(Entity.class), any(), any(Runnable.class)); + listener.onJoin(new PlayerJoinEvent(h.owner, "")); assertEquals(1, retired.size()); + retired.get(0).run(); listener.onJoin(new PlayerJoinEvent(h.owner, "")); assertEquals(2, retired.size()); + futures.get(1).completeExceptionally(new RejectedExecutionException("fixture scheduler failed")); + listener.onJoin(new PlayerJoinEvent(h.owner, "")); assertEquals(3, retired.size()); + retired.get(2).run(); listener.stop(); verify(listener, never()).checkPlayer(any()); + } + } + @Test void staleCallbackAfterQuitAndReloadCannotClearOrRunTheSuccessor() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var listener = spy(new GraveReminderListener(h.plugin)); + doNothing().when(listener).checkPlayer(any()); listener.start(); + var work = new ArrayList>(); var retired = new ArrayList(); + doAnswer(call -> { work.add(call.getArgument(1)); retired.add(call.getArgument(2)); + return new CompletableFuture(); + }).when(h.platform).runAtEntityWithFallback(any(Entity.class), any(), any(Runnable.class)); + listener.onJoin(new PlayerJoinEvent(h.owner, "")); + listener.onQuit(new PlayerQuitEvent(h.owner, "")); + listener.onJoin(new PlayerJoinEvent(h.owner, "")); assertEquals(2, work.size()); + work.get(0).accept(null); retired.get(0).run(); + listener.onJoin(new PlayerJoinEvent(h.owner, "")); assertEquals(2, work.size()); + listener.stop(); listener.start(); listener.onJoin(new PlayerJoinEvent(h.owner, "")); assertEquals(3, work.size()); + work.get(1).accept(null); retired.get(1).run(); + listener.onJoin(new PlayerJoinEvent(h.owner, "")); assertEquals(3, work.size()); + work.get(2).accept(null); verify(listener, times(1)).checkPlayer(h.owner); + listener.onJoin(new PlayerJoinEvent(h.owner, "")); assertEquals(4, work.size()); + retired.get(3).run(); listener.stop(); + } + } +} diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RemovalRecoveryRegressionTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RemovalRecoveryRegressionTest.java new file mode 100644 index 0000000..317d4b5 --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RemovalRecoveryRegressionTest.java @@ -0,0 +1,221 @@ +package com.bencodez.gravestonesplus.graves; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import java.util.concurrent.*; +import com.bencodez.gravestonesplus.events.*; +import com.bencodez.gravestonesplus.storage.*; +import org.bukkit.Location; +import org.bukkit.entity.ExperienceOrb; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +/** Exercises actual removal, durable access/claim stores, recreation and restoration. */ +class RemovalRecoveryRegressionTest { + @Test void failedRemovalRevocationCannotSurviveHistoricalRestartAndRecreation() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + h.grant(TrustedGraveAccess.Kind.TRACK); h.grant(TrustedGraveAccess.Kind.RECOVER); + h.accessDisk.fail = true; + h.grave.removeGrave(GraveRemoveReason.MANUAL_REMOVE); + GraveActionsTest.drainGround(h); + assertTrue(h.grave.getGravesConfig().isDestroyed()); + assertFalse(h.access.allows(h.identity(), h.helper.getUniqueId(), TrustedGraveAccess.Kind.RECOVER)); + assertEquals(2, h.accessDisk.values.size(), "failed publication retained the original disk grants"); + assertFalse(FeaturePersistenceTest.await(h.grave.recreateAsync(null, false))); + assertTrue(h.regionWork.isEmpty()); + + h.accessDisk.fail = false; + var restarted = new TrustedGraveAccessStore(new DurableFeatureState(h.io, h.accessDisk, 4096), System::currentTimeMillis); + assertTrue(FeaturePersistenceTest.await(restarted.loaded())); + when(h.plugin.getTrustedGraveAccessStore()).thenReturn(restarted); + assertTrue(restarted.allows(h.identity(), h.helper.getUniqueId(), TrustedGraveAccess.Kind.RECOVER)); + var backend = mock(GraveStorageManager.class); + when(backend.loadGraves()).thenReturn(List.of()); + when(backend.loadBrokenGraves()).thenReturn(List.of(h.grave.getGravesConfig())); + when(h.config.getBrokenGraveTimeLimit()).thenReturn("7d"); + when(h.plugin.getFeatureIO()).thenReturn(h.io); + var cleanup = mock(GraveMarkerCleanup.class); + when(cleanup.retain(any())).thenReturn(CompletableFuture.completedFuture(true)); + when(h.plugin.getMarkerCleanup()).thenReturn(cleanup); + var restored = new ArrayList(); var published = new CountDownLatch(1); + doAnswer(call -> { restored.add(call.getArgument(0)); published.countDown(); return null; }) + .when(h.plugin).restoreBrokenRecord(any()); + var restoration = new GraveRestoration(h.plugin, backend); + GraveActionsTest.retryOnOwner(h, restoration); h.drain(); assertTrue(published.await(2, TimeUnit.SECONDS)); + Grave historical = restored.getFirst(); + assertTrue(historical.getGravesConfig().isDestroyed()); + assertFalse(h.plugin.canRecover(historical, h.helper)); + + // A second failure at the actual recreation boundary must leave history unchanged. + h.accessDisk.fail = true; + assertFalse(FeaturePersistenceTest.await(historical.recreateAsync(null, false))); + assertTrue(historical.getGravesConfig().isDestroyed()); + assertEquals(2, h.accessDisk.values.size()); assertTrue(h.regionWork.isEmpty()); + h.accessDisk.fail = false; + var retryStore = new TrustedGraveAccessStore(new DurableFeatureState(h.io, h.accessDisk, 4096), System::currentTimeMillis); + assertTrue(FeaturePersistenceTest.await(retryStore.loaded())); + when(h.plugin.getTrustedGraveAccessStore()).thenReturn(retryStore); + GraveActionsTest.field(h.plugin, "trustedGraveAccessStore", retryStore); + h.accessDisk.entered = new CountDownLatch(1); h.accessDisk.release = new CountDownLatch(1); + var recreation = historical.recreateAsync(null, false); + try { + assertTrue(h.accessDisk.entered.await(2, TimeUnit.SECONDS)); + assertFalse(recreation.isDone()); assertTrue(historical.getGravesConfig().isDestroyed()); + assertTrue(h.regionWork.isEmpty(), "no marker/state callback before durable helper removal"); + assertFalse(retryStore.allows(h.identity(), h.helper.getUniqueId(), TrustedGraveAccess.Kind.TRACK)); + assertFalse(retryStore.allows(h.identity(), h.helper.getUniqueId(), TrustedGraveAccess.Kind.RECOVER)); + } finally { h.accessDisk.release.countDown(); } + h.drain(); h.regionWork.remove().run(); + assertTrue(FeaturePersistenceTest.await(recreation)); assertTrue(h.accessDisk.values.isEmpty()); + h.active.add(historical); + assertTrue(h.plugin.canRecover(historical, h.owner)); + assertFalse(h.plugin.canRecover(historical, h.helper)); + assertFalse(h.plugin.canTrack(historical, h.helper)); + assertFalse(retryStore.allows(h.identity(), h.helper.getUniqueId(), TrustedGraveAccess.Kind.RECOVER)); + var finalRestart = new TrustedGraveAccessStore(new DurableFeatureState(h.io, h.accessDisk, 4096), System::currentTimeMillis); + assertTrue(FeaturePersistenceTest.await(finalRestart.loaded())); assertTrue(finalRestart.list(h.identity()).isEmpty()); + assertEquals(17, historical.getGravesConfig().getExp()); + verify(h.owner, never()).giveExp(anyInt()); verify(h.helper, never()).giveExp(anyInt()); + restoration.retire(); + } + } + + @Test void recreationAttemptOnLiveGraveDoesNotRevokeLegitimateHelpers() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + h.grant(TrustedGraveAccess.Kind.TRACK); h.grant(TrustedGraveAccess.Kind.RECOVER); + assertFalse(FeaturePersistenceTest.await(h.grave.recreateAsync(null, false))); + assertTrue(h.plugin.canTrack(h.grave, h.helper)); assertTrue(h.plugin.canRecover(h.grave, h.helper)); + assertEquals(2, h.accessDisk.values.size()); assertTrue(h.regionWork.isEmpty()); + } + } + + @Test void rejectedRemovalRevocationAndRecreationRetryDoNotBlockGameplayOwners() throws Exception { + try (var h = new GraveActionsTest.Harness(); var accessIO = new FeatureIO(1)) { + h.grant(TrustedGraveAccess.Kind.TRACK); h.grant(TrustedGraveAccess.Kind.RECOVER); + var access = new TrustedGraveAccessStore(new DurableFeatureState(accessIO, h.accessDisk, 4096), System::currentTimeMillis); + assertTrue(FeaturePersistenceTest.await(access.loaded())); when(h.plugin.getTrustedGraveAccessStore()).thenReturn(access); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + var busy = accessIO.submit(() -> { + entered.countDown(); try { if (!release.await(2, TimeUnit.SECONDS)) throw new IllegalStateException("test worker not released"); } + catch (InterruptedException failure) { throw new IllegalStateException(failure); } return true; + }); + assertTrue(entered.await(2, TimeUnit.SECONDS)); var queued = accessIO.submit(() -> true); + try { + h.grave.removeGrave(GraveRemoveReason.MANUAL_REMOVE); h.regionWork.remove().run(); + assertTrue(h.grave.getGravesConfig().isDestroyed()); assertEquals(2, h.accessDisk.values.size()); + assertFalse(FeaturePersistenceTest.await(h.grave.recreateAsync(null, false))); + assertTrue(h.regionWork.isEmpty()); assertTrue(h.entityWork.isEmpty()); + assertFalse(access.allows(h.identity(), h.helper.getUniqueId(), TrustedGraveAccess.Kind.RECOVER)); + } finally { release.countDown(); } + FeaturePersistenceTest.await(busy); FeaturePersistenceTest.await(queued); + var recreation = h.grave.recreateAsync(null, false); + // Ledger and access intentionally use different serial workers in this rejection fixture. + h.drain(); FeaturePersistenceTest.await(accessIO.barrier()); h.drain(); + h.regionWork.remove().run(); assertTrue(FeaturePersistenceTest.await(recreation)); + assertTrue(h.accessDisk.values.isEmpty()); assertEquals(17, h.grave.getGravesConfig().getExp()); + } + } + + @Test void restoredQueuedLimitInitializesFutureAndPastDeadlinesBeforeAnyPayout() throws Exception { + for (boolean expired : List.of(false, true)) try (var h = new GraveActionsTest.Harness()) { + long death = System.currentTimeMillis() - (expired ? 120_000L : 0L); + var original = h.grave.getGravesConfig(); + var record = new GravesConfig(original.getUuid(), original.getPlayerName(), original.getLocation(), + new HashMap<>(original.getItems()), original.getExp(), original.getDeathMessage(), death, + false, 0L, null, null, "PLAYER_HEAD"); + String identity = GraveIdentity.of(record); + assertTrue(FeaturePersistenceTest.await(h.ledger.queueLimit(identity, record.getUuid(), + GraveClaimLedger.evidence(GraveStorageManager.snapshot(record))))); + var restartLedger = new GraveClaimLedger(new DurableFeatureState(h.io, h.claimDisk, 8192)); + assertTrue(FeaturePersistenceTest.await(restartLedger.loaded())); when(h.plugin.getGraveClaimLedger()).thenReturn(restartLedger); + h.active.clear(); when(h.config.getGraveTimeLimit()).thenReturn("1m"); + var timer = mock(ScheduledExecutorService.class); var timerTask = mock(ScheduledFuture.class); + when(timer.schedule(any(Runnable.class), anyLong(), any(TimeUnit.class))).thenReturn(timerTask); + when(h.plugin.getTimer()).thenReturn(timer); when(h.plugin.getFeatureIO()).thenReturn(h.io); + var backend = mock(GraveStorageManager.class); + when(backend.loadGraves()).thenReturn(List.of(record)); when(backend.loadBrokenGraves()).thenReturn(List.of()); + doAnswer(call -> { h.active.add(call.getArgument(0)); return null; }).when(h.plugin).restoreActiveRecord(any()); + var restoration = new GraveRestoration(h.plugin, backend); when(h.plugin.getGraveRestoration()).thenReturn(restoration); + GraveActionsTest.retryOnOwner(h, restoration); GraveActionsTest.drainGround(h); + var admitted = ArgumentCaptor.forClass(Grave.class); verify(h.plugin).restoreActiveRecord(admitted.capture()); + Grave restored = admitted.getValue(); assertEquals(death + 60_000L, restored.getExpiryDeadline()); + verify(h.world, never()).dropItem(any(), any()); verify(h.world, never()).getChunkAt(anyInt(), anyInt()); + if (expired) { + assertTrue(restored.isRemove()); assertTrue(record.isDestroyed()); assertTrue(h.active.isEmpty()); + assertNull(restartLedger.status(identity)); + GraveActionsTest.groundWorld(h); restored.resumeLimitPayout(); GraveActionsTest.drainGround(h); + verify(h.world, never()).dropItem(any(), any()); verify(h.world, never()).spawn(any(Location.class), eq(ExperienceOrb.class)); + } else { + assertTrue(h.plugin.isLiveGrave(restored)); assertTrue(restored.isLimitPayoutPending()); + verify(timer).schedule(any(Runnable.class), anyLong(), eq(TimeUnit.MILLISECONDS)); + var orb = GraveActionsTest.groundWorld(h); restoration.onChunk(h.world.getUID(), 0, 0); + GraveActionsTest.drainGround(h); + assertEquals(org.bukkit.Material.AIR, h.world.getBlockAt(record.getLocation()).getType()); + assertTrue(h.world.getBlockAt(record.getLocation()).getMetadata("Grave").isEmpty()); + verify(h.world, times(1)).dropItem(any(), eq(h.cloned)); verify(orb).setExperience(17); + assertEquals(GraveClaimLedger.Restoration.PAID, FeaturePersistenceTest.await(restartLedger.restoration(identity))); + } + restoration.retire(); + } + } + + @Test void cancelledExpiryKeepsQueuedContentsAndPermitsTheExistingNoDeadlinePolicy() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var restoration = GraveActionsTest.limitRestoration(h); var orb = GraveActionsTest.groundWorld(h); + assertTrue(FeaturePersistenceTest.await(h.ledger.queueLimit(h.identity(), h.owner.getUniqueId(), + GraveClaimLedger.evidence(GraveStorageManager.snapshot(h.grave.getGravesConfig()))))); + h.grave.markLimitPayoutPending(); + var deadline = Grave.class.getDeclaredField("expiryDeadline"); deadline.setAccessible(true); + deadline.setLong(h.grave, System.currentTimeMillis() - 1L); + doAnswer(call -> { + if (call.getArgument(0) instanceof GraveRemoveEvent event && event.getReason() == GraveRemoveReason.EXPIRED) + event.setCancelled(true); + return null; + }).when(h.events).callEvent(any()); + h.grave.resumeLimitPayout(); + assertFalse(h.grave.isRemove()); assertEquals(0L, h.grave.getExpiryDeadline()); + assertTrue(h.ledger.status(h.identity()).startsWith("QUEUED_LIMIT:")); + verify(h.world, never()).dropItem(any(), any()); + h.grave.resumeLimitPayout(); GraveActionsTest.drainGround(h); + verify(h.world, times(1)).dropItem(any(), eq(h.cloned)); verify(orb).setExperience(17); + assertTrue(h.ledger.status(h.identity()).startsWith("PAID:")); restoration.retire(); + } + } + + @Test void expiryDuringDiskReservationOrRemovalEventCannotDropQueuedContents() throws Exception { + for (boolean expireInEvent : List.of(false, true)) try (var h = new GraveActionsTest.Harness()) { + // Cancellation resumes on the storage worker, outside Bukkit ownership. + var workerBukkit = FeaturePersistenceTest.await(h.io.submit(() -> { + var context = mockStatic(org.bukkit.Bukkit.class); + context.when(org.bukkit.Bukkit::isPrimaryThread).thenReturn(false); + return context; + })); + try { + var restoration = GraveActionsTest.limitRestoration(h); GraveActionsTest.groundWorld(h); + assertTrue(FeaturePersistenceTest.await(h.ledger.queueLimit(h.identity(), h.owner.getUniqueId(), + GraveClaimLedger.evidence(GraveStorageManager.snapshot(h.grave.getGravesConfig()))))); + h.grave.markLimitPayoutPending(); + var deadline = Grave.class.getDeclaredField("expiryDeadline"); deadline.setAccessible(true); + if (expireInEvent) doAnswer(call -> { + if (call.getArgument(0) instanceof GraveRemoveEvent event && event.getReason() == GraveRemoveReason.LIMIT_REACHED) + deadline.setLong(h.grave, System.currentTimeMillis() - 1L); + return null; + }).when(h.events).callEvent(any()); + h.grave.resumeLimitPayout(); h.drain(); + assertTrue(h.ledger.status(h.identity()).startsWith("RESERVED:")); + if (!expireInEvent) deadline.setLong(h.grave, System.currentTimeMillis() - 1L); + h.regionWork.remove().run(); GraveActionsTest.drainGround(h); + verify(h.world, never()).dropItem(any(), any()); verify(h.world, never()).spawn(any(Location.class), eq(ExperienceOrb.class)); + assertEquals(org.bukkit.Material.AIR, h.world.getBlockAt(h.grave.getGravesConfig().getLocation()).getType()); + assertTrue(h.world.getBlockAt(h.grave.getGravesConfig().getLocation()).getMetadata("Grave").isEmpty()); + verify(h.owner, never()).giveExp(anyInt()); assertTrue(h.grave.getGravesConfig().isDestroyed()); + assertNull(h.ledger.status(h.identity())); assertEquals(17, h.grave.getGravesConfig().getExp()); + restoration.retire(); + } finally { + FeaturePersistenceTest.await(h.io.submit(() -> { workerBukkit.close(); return null; })); + } + } + } +} diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RespawnSchedulingRegressionTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RespawnSchedulingRegressionTest.java new file mode 100644 index 0000000..40c06c7 --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RespawnSchedulingRegressionTest.java @@ -0,0 +1,142 @@ +package com.bencodez.gravestonesplus.graves; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.util.*; +import java.util.function.Consumer; +import com.bencodez.gravestonesplus.breaking.PlayerBreakManager; +import com.bencodez.gravestonesplus.listeners.PlayerDeathListener; +import com.bencodez.simpleapi.folialib.impl.PaperImplementation; +import com.bencodez.simpleapi.folialib.impl.SpigotImplementation; +import com.bencodez.simpleapi.folialib.wrapper.task.WrappedTask; +import org.bukkit.Server; +import org.bukkit.entity.Player; +import org.bukkit.event.player.PlayerRespawnEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.CompassMeta; +import org.bukkit.persistence.PersistentDataContainer; +import org.bukkit.scheduler.BukkitTask; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedConstruction; + +class RespawnSchedulingRegressionTest { + /** Real SimpleAPI scheduling methods and implementation; only native Bukkit queues are controlled. */ + private static final class BundledScheduler { + final org.bukkit.scheduler.BukkitScheduler nativeScheduler = mock(org.bukkit.scheduler.BukkitScheduler.class); + final Queue delayed = new ArrayDeque<>(); + @SuppressWarnings("unchecked") + BundledScheduler(GraveActionsTest.Harness h, boolean paper) { + Server server = mock(Server.class); + when(h.plugin.getServer()).thenReturn(server); when(server.getScheduler()).thenReturn(nativeScheduler); + when(h.folia.getPlugin()).thenReturn(h.plugin); + var implementation = paper ? new PaperImplementation(h.folia) : new SpigotImplementation(h.folia); + when(h.folia.getImpl()).thenReturn(implementation); + var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class, CALLS_REAL_METHODS); + when(scheduler.getFoliaLib()).thenReturn(h.folia); when(h.plugin.getBukkitScheduler()).thenReturn(scheduler); + // The old entity-delay overload must see a valid entity to expose its recursion. + when(h.owner.isValid()).thenReturn(true); when(h.helper.isValid()).thenReturn(true); + when(nativeScheduler.runTaskLater(eq(h.plugin), any(Runnable.class), anyLong())).thenAnswer(call -> { + delayed.add(call.getArgument(1)); return mock(BukkitTask.class); + }); + doAnswer(call -> { + Consumer action = call.getArgument(1); + delayed.add(() -> action.accept(mock(BukkitTask.class))); return null; + }).when(nativeScheduler).runTaskLater(eq(h.plugin), any(Consumer.class), anyLong()); + doAnswer(call -> { + Consumer action = call.getArgument(1); + h.entityWork.add(() -> action.accept(mock(BukkitTask.class))); return null; + }).when(nativeScheduler).runTask(eq(h.plugin), any(Consumer.class)); + } + } + private static MockedConstruction compasses() { + return mockConstruction(ItemStack.class, (item, context) -> { + CompassMeta meta = mock(CompassMeta.class); + when(meta.getPersistentDataContainer()).thenReturn(mock(PersistentDataContainer.class)); + when(item.getItemMeta()).thenReturn(meta); + }); + } + private static void respawn(GraveActionsTest.Harness h, Player player) { + when(h.config.isGiveCompassOnRespawn()).thenReturn(true); + when(h.plugin.getLatestGrave(player)).thenReturn(h.grave); + when(player.hasPermission("GraveStonesPlus.Compass")).thenReturn(true); + PlayerRespawnEvent event = mock(PlayerRespawnEvent.class); when(event.getPlayer()).thenReturn(player); + new PlayerDeathListener(h.plugin).onRespawn(event); + } + @ParameterizedTest @ValueSource(booleans = { false, true }) + void bundledSpigotAndPaperDeliverOnlyAfterTheOriginalFiveSecondOwnerDelay(boolean paper) throws Exception { + try (var h = new GraveActionsTest.Harness(); var items = compasses()) { + var scheduler = new BundledScheduler(h, paper); + when(h.ownerInventory.addItem(any(ItemStack[].class))).thenReturn(new HashMap<>()); + assertDoesNotThrow(() -> respawn(h, h.owner)); + verify(scheduler.nativeScheduler).runTaskLater(eq(h.plugin), any(Runnable.class), eq(5L)); + assertEquals(1, scheduler.delayed.size()); assertTrue(items.constructed().isEmpty()); assertTrue(h.entityWork.isEmpty()); + scheduler.delayed.remove().run(); assertTrue(items.constructed().isEmpty()); assertEquals(1, h.entityWork.size()); + h.drain(); assertEquals(1, items.constructed().size()); verify(h.ownerInventory).addItem(items.constructed().getFirst()); + assertSame(h.grave, h.navigation.target(h.owner)); + verify(scheduler.nativeScheduler, never()).runTaskLaterAsynchronously(any(), any(Runnable.class), anyLong()); + } + } + @ParameterizedTest @ValueSource(booleans = { false, true }) + void bundledDelayedCallbackRejectsPermissionOfflineDisableAndGraveAccessChanges(boolean paper) throws Exception { + for (String change : List.of("permission", "offline", "disabled", "removed", "trust")) { + try (var h = new GraveActionsTest.Harness(); var items = compasses()) { + var scheduler = new BundledScheduler(h, paper); + h.grant(TrustedGraveAccess.Kind.TRACK); respawn(h, h.helper); + switch (change) { + case "permission" -> when(h.helper.hasPermission("GraveStonesPlus.Compass")).thenReturn(false); + case "offline" -> when(h.helper.isOnline()).thenReturn(false); + case "disabled" -> when(h.plugin.isEnabled()).thenReturn(false); + case "removed" -> h.grave.getGravesConfig().setDestroyed(true); + case "trust" -> FeaturePersistenceTest.await(h.access.revokeAsync(h.identity(), h.helper.getUniqueId(), TrustedGraveAccess.Kind.TRACK)); + } + scheduler.delayed.remove().run(); assertTrue(h.entityWork.isEmpty()); h.drain(); + assertTrue(items.constructed().isEmpty()); verify(h.helperInventory, never()).addItem(any(ItemStack[].class)); + } + } + } + @Test void foliaRespawnRetainsTheMovingPlayerEntityDelayAndRetirementBoundary() throws Exception { + try (var h = new GraveActionsTest.Harness(); var items = compasses()) { + when(h.folia.isFolia()).thenReturn(true); + List delayed = new ArrayList<>(), retired = new ArrayList<>(); + when(h.platform.runAtEntityLater(eq(h.owner), any(Runnable.class), any(Runnable.class), eq(5L))).thenAnswer(call -> { + delayed.add(call.getArgument(1)); retired.add(call.getArgument(2)); return mock(WrappedTask.class); + }); + respawn(h, h.owner); assertEquals(1, delayed.size()); retired.getFirst().run(); + assertTrue(h.entityWork.isEmpty()); assertTrue(items.constructed().isEmpty()); + verify(h.platform, never()).runLater(any(Runnable.class), anyLong(), any()); + // A later admitted respawn remains guarded at the final delivery callback. + respawn(h, h.owner); delayed.get(1).run(); assertEquals(1, h.entityWork.size()); + when(h.owner.hasPermission("GraveStonesPlus.Compass")).thenReturn(false); h.drain(); + assertTrue(items.constructed().isEmpty()); verify(h.ownerInventory, never()).addItem(any(ItemStack[].class)); + doReturn(null).when(h.platform).runAtEntityLater(eq(h.owner), any(Runnable.class), any(Runnable.class), eq(5L)); + respawn(h, h.owner); assertTrue(h.entityWork.isEmpty()); assertEquals(2, delayed.size()); + assertTrue(items.constructed().isEmpty()); + } + } + @ParameterizedTest @ValueSource(booleans = { false, true }) + @SuppressWarnings("unchecked") + void ownerBreakCompletionKeepsItsOriginalUuidOnlyTwentySecondCleanup(boolean paper) throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var scheduler = new BundledScheduler(h, paper); + var manager = new PlayerBreakManager(h.plugin); + when(h.plugin.getPlayerBreakManager()).thenReturn(manager); + when(h.config.getBreakOwnGraveTime()).thenReturn(RecoveryCompletionTest.duration("1s")); + when(h.config.getBreakOwnGraveHitTimeout()).thenReturn(RecoveryCompletionTest.duration("1m")); + assertFalse(manager.handleOwnerHit(h.owner, h.grave)); + manager.getOwnerActiveBreaks().get(h.owner.getUniqueId()).setStartTime(System.currentTimeMillis() - 2000); + assertDoesNotThrow(() -> assertTrue(manager.handleOwnerHit(h.owner, h.grave))); + // Both existing completion checkpoints schedule seconds, independent of a live entity. + verify(scheduler.nativeScheduler, times(2)).runTaskLater(eq(h.plugin), any(Consumer.class), eq(400L)); + assertEquals(2, scheduler.delayed.size()); assertFalse(h.entityWork.isEmpty()); + when(h.owner.isOnline()).thenReturn(false); + long newerCompletion = System.currentTimeMillis(); + manager.getOwnerLastCompleted().put(h.owner.getUniqueId(), newerCompletion); + scheduler.delayed.remove().run(); + assertEquals(newerCompletion, manager.getOwnerLastCompleted().get(h.owner.getUniqueId())); + manager.getOwnerLastCompleted().put(h.owner.getUniqueId(), System.currentTimeMillis() - 2000); + scheduler.delayed.remove().run(); assertTrue(manager.getOwnerLastCompleted().isEmpty()); + } + } +} diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/SelectedCommandDispatchTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/SelectedCommandDispatchTest.java new file mode 100644 index 0000000..d839bf8 --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/SelectedCommandDispatchTest.java @@ -0,0 +1,375 @@ +package com.bencodez.gravestonesplus.graves; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.util.*; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; +import com.bencodez.advancedcore.AdvancedCoreConfigOptions; +import com.bencodez.advancedcore.api.command.CommandHandler; +import com.bencodez.gravestonesplus.breaking.PlayerBreakManager; +import com.bencodez.gravestonesplus.commands.CommandLoader; +import com.bencodez.simpleapi.command.TabCompleteHandler; +import com.bencodez.simpleapi.folialib.impl.SpigotImplementation; +import org.bukkit.Bukkit; +import org.bukkit.Server; +import org.bukkit.block.Block; +import org.bukkit.inventory.ItemStack; +import org.bukkit.scheduler.BukkitTask; +import org.junit.jupiter.api.Test; + +class SelectedCommandDispatchTest { + /** Real bundled command and SimpleAPI scheduler code through controlled native queues. */ + private static final class Commands { + final GraveActionsTest.Harness h; + final Queue async = new ConcurrentLinkedQueue<>(); + final ArrayList handlers = new ArrayList<>(); + final ThreadLocal phase = ThreadLocal.withInitial(() -> "admission"); + final PlayerBreakManager manager; + int targetReads, inventoryReads; + @SuppressWarnings("unchecked") + Commands(GraveActionsTest.Harness h) { + this.h = h; + manager = new PlayerBreakManager(h.plugin); when(h.plugin.getPlayerBreakManager()).thenReturn(manager); + var options = mock(AdvancedCoreConfigOptions.class); when(options.getFormatNoPerms()).thenReturn("Denied"); + when(h.plugin.getOptions()).thenReturn(options); when(h.plugin.getCommands()).thenReturn(handlers); + when(h.owner.hasPermission(anyString())).thenReturn(true); + when(h.owner.getName()).thenReturn("Owner"); when(h.owner.getDisplayName()).thenReturn("Owner"); + when(h.helper.getName()).thenReturn("Alex"); + h.bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of(h.owner, h.helper)); + h.bukkit.when(() -> Bukkit.getPlayerExact("Alex")).thenAnswer(call -> { ownerOnly(); return h.helper; }); + Server server = mock(Server.class); + var nativeScheduler = mock(org.bukkit.scheduler.BukkitScheduler.class); + when(h.plugin.getServer()).thenReturn(server); when(server.getScheduler()).thenReturn(nativeScheduler); + when(h.folia.getPlugin()).thenReturn(h.plugin); + var implementation = new SpigotImplementation(h.folia); + when(h.folia.getImpl()).thenReturn(implementation); + var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class, CALLS_REAL_METHODS); + when(scheduler.getFoliaLib()).thenReturn(h.folia); when(h.plugin.getBukkitScheduler()).thenReturn(scheduler); + doAnswer(call -> { + Consumer work = call.getArgument(1); + async.add(() -> runAs("async", () -> work.accept(mock(BukkitTask.class)))); return null; + }).when(nativeScheduler).runTaskAsynchronously(eq(h.plugin), any(Consumer.class)); + doAnswer(call -> { + Consumer work = call.getArgument(1); + h.entityWork.add(() -> runAs("owner", () -> work.accept(mock(BukkitTask.class)))); return null; + }).when(nativeScheduler).runTask(eq(h.plugin), any(Consumer.class)); + Block target = mock(Block.class); + when(target.getWorld()).thenAnswer(call -> { ownerOnly(); return h.world; }); + when(target.getX()).thenAnswer(call -> { ownerOnly(); return 1; }); + when(target.getY()).thenAnswer(call -> { ownerOnly(); return 64; }); + when(target.getZ()).thenAnswer(call -> { ownerOnly(); return 1; }); + when(h.owner.getTargetBlock(isNull(), eq(7))).thenAnswer(call -> { ownerOnly(); targetReads++; return target; }); + when(h.owner.getInventory()).thenAnswer(call -> { ownerOnly(); inventoryReads++; return h.ownerInventory; }); + when(h.ownerInventory.getItemInMainHand()).thenReturn(h.stored); + when(h.config.getKeepItemsWithLore()).thenReturn(""); + doAnswer(call -> { ownerOnly(); return h.cloned; }).when(h.stored).clone(); + new CommandLoader(h.plugin).loadCommands(); + TabCompleteHandler.getInstance().onLogin(); + } + void ownerOnly() { assertEquals("owner", phase.get(), "Player/world/item read must run on the entity owner"); } + void runAs(String selected, Runnable work) { + String previous = phase.get(); phase.set(selected); + try { work.run(); } finally { phase.set(previous); } + } + CommandHandler get(String name) { + return handlers.stream().filter(command -> command.getArgs()[0].equalsIgnoreCase(name)).findFirst().orElseThrow(); + } + void dispatch(String... args) { dispatch(h.owner, args); } + void dispatch(org.bukkit.entity.Player actor, String... args) { + assertTrue(get(args[0]).runCommand(actor, args)); + assertEquals(1, async.size()); assertTrue(h.entityWork.isEmpty()); + async.remove().run(); assertTrue(async.isEmpty()); assertEquals(1, h.entityWork.size()); + assertEquals(0, targetReads); assertEquals(0, inventoryReads); + } + int locationReads, worldReads; + void guardNavigation(org.bukkit.entity.Player actor, java.util.concurrent.atomic.AtomicReference position) { + doAnswer(call -> { ownerOnly(); locationReads++; return position.get().clone(); }).when(actor).getLocation(); + doAnswer(call -> { ownerOnly(); worldReads++; return position.get().getWorld(); }).when(actor).getWorld(); + doAnswer(call -> { ownerOnly(); return null; }).when(actor).sendMessage(anyString()); + doAnswer(call -> { ownerOnly(); inventoryReads++; return actor == h.owner ? h.ownerInventory : h.helperInventory; }).when(actor).getInventory(); + when(actor.isValid()).thenReturn(true); + } + void resetReadCounts() { targetReads = 0; inventoryReads = 0; locationReads = 0; worldReads = 0; } + } + @Test void actualGravesDispatcherDefersNavigationRenderingAndInventoryToThePlayerOwner() throws Exception { + try (var h = new GraveActionsTest.Harness(); + var builders = mockConstruction(com.bencodez.advancedcore.api.item.ItemBuilder.class, withSettings().defaultAnswer(RETURNS_SELF))) { + var commands = new Commands(h); + var position = new java.util.concurrent.atomic.AtomicReference<>(new org.bukkit.Location(h.world, 1, 64, 1)); + commands.guardNavigation(h.owner, position); + var buttons = new ArrayList(); + try (var menus = mockConstruction(com.bencodez.advancedcore.api.inventory.BInventory.class, (menu, context) -> { + doAnswer(call -> { commands.ownerOnly(); buttons.add(call.getArgument(0)); return null; }).when(menu).addButton(any()); + doAnswer(call -> { commands.ownerOnly(); return null; }).when(menu).openInventory(h.owner); + })) { + commands.dispatch("Graves"); assertTrue(menus.constructed().isEmpty()); assertEquals(0, commands.locationReads); + position.set(new org.bukkit.Location(h.world, 5, 60, 1)); h.drain(); + assertEquals(1, commands.locationReads); assertEquals(0, commands.worldReads); assertEquals(1, buttons.size()); + verify(menus.constructed().getFirst()).openInventory(h.owner); + verify(builders.constructed().getFirst()).addLoreLine("&7overworld, 4 blocks, above"); + } + } + } + @Test void navigationRenderRechecksAccessPermissionRetirementAndReloadAfterDispatcherDelay() throws Exception { + for (String change : List.of("trust", "removed", "permission", "offline", "disabled", "reload")) try (var h = new GraveActionsTest.Harness(); + var builders = mockConstruction(com.bencodez.advancedcore.api.item.ItemBuilder.class, withSettings().defaultAnswer(RETURNS_SELF)); + var menus = mockConstruction(com.bencodez.advancedcore.api.inventory.BInventory.class)) { + h.grant(TrustedGraveAccess.Kind.TRACK); when(h.helper.hasPermission("GraveStonesPlus.Graves")).thenReturn(true); + var commands = new Commands(h); commands.guardNavigation(h.helper, + new java.util.concurrent.atomic.AtomicReference<>(new org.bukkit.Location(h.world, 1, 64, 1))); + commands.dispatch(h.helper, "Graves"); + switch (change) { + case "trust" -> FeaturePersistenceTest.await(h.access.revokeAsync(h.identity(), h.helper.getUniqueId(), TrustedGraveAccess.Kind.TRACK)); + case "removed" -> h.grave.getGravesConfig().setDestroyed(true); + case "permission" -> when(h.helper.hasPermission("GraveStonesPlus.Graves")).thenReturn(false); + case "offline" -> when(h.helper.isOnline()).thenReturn(false); + case "disabled" -> when(h.plugin.isEnabled()).thenReturn(false); + case "reload" -> commands.manager.invalidateAuthorization(); + } + h.drain(); assertTrue(builders.constructed().isEmpty()); assertEquals(0, commands.locationReads); + if (change.equals("trust") || change.equals("removed")) { + assertEquals(1, menus.constructed().size()); verify(menus.constructed().getFirst(), never()).addButton(any()); + } else assertTrue(menus.constructed().isEmpty()); + verify(h.helperInventory, never()).addItem(any(ItemStack[].class)); + } + } + private static void asyncMenuClick(Commands commands, GraveActionsTest.Harness h, + com.bencodez.advancedcore.api.inventory.BInventoryButton button, org.bukkit.entity.Player actor, + org.bukkit.event.inventory.ClickType click) { + var menu = new com.bencodez.advancedcore.api.inventory.BInventory("Native click").noSound(); + assertTrue(menu.isClickAsync(), "the native menu default is the reported asynchronous boundary"); + var nativeClick = mock(org.bukkit.event.inventory.InventoryClickEvent.class); + when(nativeClick.getWhoClicked()).thenReturn(actor); when(nativeClick.getClick()).thenReturn(click); + commands.resetReadCounts(); + commands.runAs("async", () -> menu.onClick(nativeClick, button)); + assertEquals(0, commands.locationReads); assertEquals(0, commands.worldReads); assertEquals(0, commands.inventoryReads); + assertEquals(1, h.entityWork.size()); + } + @Test void nativeNavigationClickSnapshotsIntentAndUsesPostTeleportOwnerPosition() throws Exception { + try (var h = new GraveActionsTest.Harness(); + var builders = mockConstruction(com.bencodez.advancedcore.api.item.ItemBuilder.class, withSettings().defaultAnswer(RETURNS_SELF))) { + var commands = new Commands(h); when(h.config.getGraveClaimDistance()).thenReturn(10); + var position = new java.util.concurrent.atomic.AtomicReference<>(new org.bukkit.Location(h.world, 1, 64, 1)); + commands.guardNavigation(h.owner, position); + var button = new java.util.concurrent.atomic.AtomicReference(); + commands.runAs("owner", () -> button.set(h.grave.getNavigationGUIItem(h.owner))); + asyncMenuClick(commands, h, button.get(), h.owner, org.bukkit.event.inventory.ClickType.RIGHT); + var other = mock(org.bukkit.World.class); when(other.getUID()).thenReturn(UUID.randomUUID()); + position.set(new org.bukkit.Location(other, 1, 64, 1)); h.drain(); + assertEquals(1, commands.locationReads); assertEquals(0, commands.worldReads); + verify(h.owner).sendMessage(argThat((String message) -> message.contains("Move near"))); + verify(h.owner, never()).giveExp(anyInt()); verify(h.ownerInventory, never()).setItem(anyInt(), any()); + assertFalse(h.ledger.isBlocked(h.identity())); + } + } + @Test void nativeNavigationClickRechecksTrustRecoveryLiveAndRetiredActorStateBeforeEffects() throws Exception { + for (String change : List.of("track", "recover", "removed", "offline", "disabled", "permission")) try (var h = new GraveActionsTest.Harness(); + var builders = mockConstruction(com.bencodez.advancedcore.api.item.ItemBuilder.class, withSettings().defaultAnswer(RETURNS_SELF))) { + h.grant(TrustedGraveAccess.Kind.TRACK); h.grant(TrustedGraveAccess.Kind.RECOVER); + when(h.helper.hasPermission("GraveStonesPlus.ClaimGrave")).thenReturn(true); + var commands = new Commands(h); when(h.config.getGraveClaimDistance()).thenReturn(10); + commands.guardNavigation(h.helper, new java.util.concurrent.atomic.AtomicReference<>(new org.bukkit.Location(h.world, 1, 64, 1))); + var button = new java.util.concurrent.atomic.AtomicReference(); + commands.runAs("owner", () -> button.set(h.grave.getNavigationGUIItem(h.helper))); + asyncMenuClick(commands, h, button.get(), h.helper, org.bukkit.event.inventory.ClickType.RIGHT); + switch (change) { + case "track" -> FeaturePersistenceTest.await(h.access.revokeAsync(h.identity(), h.helper.getUniqueId(), TrustedGraveAccess.Kind.TRACK)); + case "recover" -> FeaturePersistenceTest.await(h.access.revokeAsync(h.identity(), h.helper.getUniqueId(), TrustedGraveAccess.Kind.RECOVER)); + case "removed" -> h.grave.getGravesConfig().setDestroyed(true); + case "offline" -> when(h.helper.isOnline()).thenReturn(false); + case "disabled" -> when(h.plugin.isEnabled()).thenReturn(false); + case "permission" -> when(h.helper.hasPermission("GraveStonesPlus.ClaimGrave")).thenReturn(false); + } + h.drain(); assertEquals(change.equals("recover") ? 1 : 0, commands.locationReads); + verify(h.helper, never()).giveExp(anyInt()); verify(h.helperInventory, never()).setItem(anyInt(), any()); + assertFalse(h.ledger.isBlocked(h.identity())); + } + } + @Test void nativeNavigationRecoveryAndLegacyAdminTeleportKeepTheirExistingEffectsOnTheOwner() throws Exception { + for (boolean navigation : List.of(false, true)) try (var h = new GraveActionsTest.Harness(); + var builders = mockConstruction(com.bencodez.advancedcore.api.item.ItemBuilder.class, withSettings().defaultAnswer(RETURNS_SELF))) { + var commands = new Commands(h); when(h.config.getGraveClaimDistance()).thenReturn(10); + var actor = navigation ? h.owner : h.helper; + if (!navigation) when(h.helper.hasPermission("GraveStonesPlus.AllGraves")).thenReturn(true); + commands.guardNavigation(actor, new java.util.concurrent.atomic.AtomicReference<>(new org.bukkit.Location(h.world, 1, 64, 1))); + doAnswer(call -> { commands.ownerOnly(); return true; }).when(actor).teleport(any(org.bukkit.Location.class)); + var button = new java.util.concurrent.atomic.AtomicReference(); + commands.runAs("owner", () -> button.set(navigation ? h.grave.getNavigationGUIItem(actor) : h.grave.getGUIItem())); + asyncMenuClick(commands, h, button.get(), actor, navigation ? org.bukkit.event.inventory.ClickType.RIGHT : org.bukkit.event.inventory.ClickType.LEFT); + h.drain(); + if (navigation) { verify(actor).giveExp(17); verify(h.ownerInventory).setItem(4, h.cloned); } + else { verify(actor).teleport(h.grave.getGravesConfig().getLocation().clone().add(0, 1, 0)); verify(actor, never()).giveExp(anyInt()); } + } + } + + @Test void nativeLegacyMenuRechecksStaffPermissionBeforeOwnerTeleport() throws Exception { + try (var h = new GraveActionsTest.Harness(); + var builders = mockConstruction(com.bencodez.advancedcore.api.item.ItemBuilder.class, withSettings().defaultAnswer(RETURNS_SELF))) { + when(h.helper.hasPermission("GraveStonesPlus.AllGraves")).thenReturn(true); + var commands = new Commands(h); commands.guardNavigation(h.helper, + new java.util.concurrent.atomic.AtomicReference<>(new org.bukkit.Location(h.world, 1, 64, 1))); + var button = h.grave.getGUIItem(); + asyncMenuClick(commands, h, button, h.helper, org.bukkit.event.inventory.ClickType.LEFT); + when(h.helper.hasPermission("GraveStonesPlus.AllGraves")).thenReturn(false); h.drain(); + verify(h.helper, never()).teleport(any(org.bukkit.Location.class)); assertEquals(0, commands.locationReads); + verify(h.helper).sendMessage(argThat((String message) -> message.contains("no longer accessible"))); + } + } + + @Test void actualClaimCommandSkipsRetainedPartialPayoutAndInactiveGravesForOwnerAndRecoverHelper() throws Exception { + for (boolean helper : List.of(false, true)) try (var h = new GraveActionsTest.Harness()) { + when(h.config.getGraveClaimDistance()).thenReturn(10); + when(h.helper.hasPermission("GraveStonesPlus.ClaimGrave")).thenReturn(true); + if (helper) h.grant(TrustedGraveAccess.Kind.RECOVER); + // Actual item delivery succeeds, then XP fails, retaining the live grave + // and durable RESERVED evidence instead of making another payout safe. + doThrow(new IllegalStateException("injected partial XP payout")).when(h.owner).giveExp(17); + h.grave.claim(h.owner); h.drain(); + assertTrue(h.plugin.isLiveGrave(h.grave)); assertTrue(h.ledger.status(h.identity()).startsWith("RESERVED:")); + when(h.owner.isValid()).thenReturn(true); when(h.helper.isValid()).thenReturn(true); + var commands = new Commands(h); + var actor = helper ? h.helper : h.owner; assertTrue(h.plugin.canRecover(h.grave, actor)); + var items = new HashMap(); items.put(5, h.stored); + var healthy = new Grave(h.plugin, new com.bencodez.gravestonesplus.storage.GravesConfig(h.owner.getUniqueId(), "Owner", + new org.bukkit.Location(h.world, 2, 64, 1), items, 23, "healthy", h.grave.getGravesConfig().getTime() + 1, + false, 0, null, null, "PLAYER_HEAD")); h.active.add(healthy); + var inactive = new Grave(h.plugin, new com.bencodez.gravestonesplus.storage.GravesConfig(h.owner.getUniqueId(), "Owner", + new org.bukkit.Location(h.world, 3, 64, 1), new HashMap<>(), 99, "inactive", h.grave.getGravesConfig().getTime() + 2, + true, 1, null, null, "PLAYER_HEAD")); h.active.addFirst(inactive); + if (helper) assertTrue(FeaturePersistenceTest.await(h.access.grantAsync(GraveIdentity.of(healthy.getGravesConfig()), + h.helper.getUniqueId(), TrustedGraveAccess.Kind.RECOVER, System.currentTimeMillis() + 60_000))); + commands.guardNavigation(actor, new java.util.concurrent.atomic.AtomicReference<>(new org.bukkit.Location(h.world, 1, 64, 1))); + commands.resetReadCounts(); commands.dispatch(actor, "ClaimGrave"); + assertEquals(0, commands.locationReads); assertEquals(0, commands.worldReads); + h.drain(); + verify(actor).giveExp(23); verify(actor, never()).giveExp(99); + verify(helper ? h.helperInventory : h.ownerInventory).setItem(5, h.cloned); + verify(h.ownerInventory, times(1)).setItem(4, h.cloned); verify(h.owner, times(1)).giveExp(17); + if (helper) { verify(h.helper, never()).giveExp(17); verify(h.helperInventory, never()).setItem(4, h.cloned); } + assertTrue(healthy.getGravesConfig().isDestroyed()); assertTrue(h.active.contains(h.grave)); + assertTrue(h.ledger.status(h.identity()).startsWith("RESERVED:")); + } + } + + @Test void documentedTrustAndRevokeDispatchAndPersistForExactOnlineNamesAndUuids() throws Exception { + for (TrustedGraveAccess.Kind kind : TrustedGraveAccess.Kind.values()) for (boolean uuid : List.of(false, true)) try (var h = new GraveActionsTest.Harness()) { + var commands = new Commands(h); + String helper = uuid ? h.helper.getUniqueId().toString() : "Alex"; + commands.dispatch("Trust", helper, kind.name().toLowerCase(Locale.ROOT), "30m"); h.drain(); + assertTrue(h.access.allows(h.identity(), h.helper.getUniqueId(), kind)); + assertFalse(h.access.allows(h.identity(), h.helper.getUniqueId(), kind == TrustedGraveAccess.Kind.TRACK ? TrustedGraveAccess.Kind.RECOVER : TrustedGraveAccess.Kind.TRACK)); + assertTrue(commands.targetReads > 0); + verify(h.owner).sendMessage(argThat((String message) -> message.contains("Grant saved"))); + commands.resetReadCounts(); commands.dispatch("RevokeTrust", helper, kind.name().toLowerCase(Locale.ROOT)); h.drain(); + assertFalse(h.access.allows(h.identity(), h.helper.getUniqueId(), kind)); + assertTrue(commands.targetReads > 0); + verify(h.owner).sendMessage(argThat((String message) -> message.contains("Grant revoked"))); + var tab = TabCompleteHandler.getInstance().getTabCompleteOptions(); + assertEquals(List.of("track", "recover"), tab.get("(GraveAccessKind)")); + assertTrue(tab.get("(GraveDuration)").contains("30m")); + assertTrue(tab.get("(GraveHelper)").contains("Alex")); + } + } + @Test void listAndInspectionReadActualPlayerAndItemStateOnlyAfterNativeAsyncAndOwnerDispatch() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var commands = new Commands(h); h.grant(TrustedGraveAccess.Kind.TRACK); + commands.dispatch("TrustList"); h.drain(); assertTrue(commands.targetReads > 0); + verify(h.owner).sendMessage(argThat((String message) -> message.contains(h.helper.getUniqueId().toString()))); + commands.resetReadCounts(); commands.dispatch("InspectItem"); h.drain(); + assertEquals(0, commands.targetReads); assertEquals(1, commands.inventoryReads); verify(h.stored).clone(); + var inspection = org.mockito.ArgumentCaptor.forClass(String[].class); + verify(h.owner).sendMessage(inspection.capture()); + assertTrue(Arrays.stream(inspection.getValue()).anyMatch(message -> message.contains("When grave creation proceeds: ") && message.endsWith("GRAVE_DEFAULT"))); + verify(h.ownerInventory, never()).addItem(any(ItemStack[].class)); + verify(h.ownerInventory, never()).setItem(anyInt(), any()); verify(h.stored, never()).setItemMeta(any()); + } + } + private static String[] args(String command, String helper) { + return switch (command) { + case "Trust" -> new String[] { command, helper, "recover", "2h" }; + case "RevokeTrust" -> new String[] { command, helper, "recover" }; + default -> new String[] { command }; + }; + } + @Test void allFourIntroducedHandlersRecheckPermissionOnlineDisableAndReloadBeforeActorReads() throws Exception { + for (String command : List.of("TrustList", "Trust", "RevokeTrust", "InspectItem")) { + for (String change : List.of("permission", "offline", "disabled", "reload")) try (var h = new GraveActionsTest.Harness()) { + var commands = new Commands(h); commands.dispatch(args(command, h.helper.getUniqueId().toString())); + switch (change) { + case "permission" -> when(h.owner.hasPermission(anyString())).thenReturn(false); + case "offline" -> when(h.owner.isOnline()).thenReturn(false); + case "disabled" -> when(h.plugin.isEnabled()).thenReturn(false); + case "reload" -> commands.manager.invalidateAuthorization(); + } + h.drain(); assertEquals(0, commands.targetReads); assertEquals(0, commands.inventoryReads); + assertTrue(h.access.list(h.identity()).isEmpty()); + verify(h.owner, never()).sendMessage(any(String.class)); + } + } + } + @Test void actualDispatcherAndActorValidationRejectInvalidArgumentsAndInitialPermissionDenial() throws Exception { + for (String[] invalid : List.of( + new String[] { "Trust", "Alex", "admin", "30m" }, + new String[] { "Trust", "Alex", "track", "0" }, + new String[] { "Trust", "Alex", "track", "91d" }, + new String[] { "Trust", "Alex", "track", "bad" }, + new String[] { "Trust", "Missing", "track", "30m" }, + new String[] { "RevokeTrust", "Alex", "admin" })) { + try (var h = new GraveActionsTest.Harness()) { + var commands = new Commands(h); commands.dispatch(invalid); h.drain(); + assertEquals(0, commands.targetReads); assertTrue(h.access.list(h.identity()).isEmpty()); + verify(h.owner, atLeastOnce()).sendMessage(any(String.class)); + } + } + try (var h = new GraveActionsTest.Harness()) { + var commands = new Commands(h); + assertFalse(commands.get("Trust").runCommand(h.owner, new String[] { "Trust", "Alex", "track" })); + assertFalse(commands.get("RevokeTrust").runCommand(h.owner, new String[] { "RevokeTrust", "Alex" })); + assertTrue(commands.async.isEmpty()); assertTrue(h.entityWork.isEmpty()); + when(h.owner.hasPermission(anyString())).thenReturn(false); + assertTrue(commands.get("Trust").runCommand(h.owner, args("Trust", h.helper.getUniqueId().toString()))); + verify(h.owner).sendMessage("Denied"); assertTrue(commands.async.isEmpty()); assertTrue(h.entityWork.isEmpty()); + } + } + @Test void durableGrantReplyIsAlsoFencedWhenReloadHappensAfterPublication() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var commands = new Commands(h); commands.dispatch("Trust", h.helper.getUniqueId().toString(), "track", "7d"); + h.entityWork.remove().run(); FeaturePersistenceTest.await(h.io.barrier()); + assertTrue(h.access.allows(h.identity(), h.helper.getUniqueId(), TrustedGraveAccess.Kind.TRACK)); + assertEquals(1, h.entityWork.size()); commands.manager.invalidateAuthorization(); h.drain(); + verify(h.owner, never()).sendMessage(any(String.class)); + } + } + + @Test void reloadCommandDefersOwnerReplyUntilAsyncReloadCompletes() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var commands = new Commands(h); + var reload = new CompletableFuture(); + when(h.plugin.reloadAsync()).thenReturn(reload); + assertTrue(commands.get("Reload").runCommand(h.owner, new String[] { "Reload" })); + assertEquals(1, commands.async.size()); commands.async.remove().run(); + verify(h.owner, never()).sendMessage(any(String.class)); + reload.complete(null); + assertEquals(1, h.entityWork.size(), "completion reply must be dispatched to the owner scheduler"); + h.entityWork.remove().run(); + verify(h.owner).sendMessage("\u00a7cGraveStonesPlus reloaded"); + } + } + + @Test void reloadCommandReportsAsyncFailureOnlyOnOwnerScheduler() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var commands = new Commands(h); + var reload = new CompletableFuture(); + when(h.plugin.reloadAsync()).thenReturn(reload); + assertTrue(commands.get("Reload").runCommand(h.owner, new String[] { "Reload" })); + assertEquals(1, commands.async.size()); commands.async.remove().run(); + reload.completeExceptionally(new IllegalStateException("read failed")); + verify(h.owner, never()).sendMessage(any(String.class)); + assertEquals(1, h.entityWork.size()); + h.entityWork.remove().run(); + verify(h.owner).sendMessage("\u00a7cGraveStonesPlus reload failed; inspect the server log"); + } + } +} diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/TrustedGraveAccessTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/TrustedGraveAccessTest.java new file mode 100644 index 0000000..2ef5f48 --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/TrustedGraveAccessTest.java @@ -0,0 +1,39 @@ +package com.bencodez.gravestonesplus.graves; + +import static org.junit.jupiter.api.Assertions.*; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class TrustedGraveAccessTest { + @Test void trackingAndRecoveryAreDistinctAndExpiryIsBoundedByCaller() { + UUID owner = UUID.randomUUID(), helper = UUID.randomUUID(); + TrustedGraveAccess track = new TrustedGraveAccess(owner, 10L, helper, TrustedGraveAccess.Kind.TRACK, 100L); + TrustedGraveAccess recover = new TrustedGraveAccess(owner, 10L, helper, TrustedGraveAccess.Kind.RECOVER, 100L); + assertNotEquals(track.getKind(), recover.getKind()); + assertTrue(track.isActive(99L)); + assertFalse(track.isActive(100L)); + } + + @Test void expiredAndRevokedKindsCannotBeConfused() { + UUID owner = UUID.randomUUID(), helper = UUID.randomUUID(); + TrustedGraveAccess track = new TrustedGraveAccess(owner, 1L, helper, TrustedGraveAccess.Kind.TRACK, 50L); + TrustedGraveAccess recover = new TrustedGraveAccess(owner, 1L, helper, TrustedGraveAccess.Kind.RECOVER, 50L); + assertTrue(track.isActive(49L)); + assertTrue(recover.isActive(49L)); + assertNotEquals(track.getKind(), recover.getKind()); + assertFalse(track.isActive(50L)); + } + + @Test void reminderIdentityIncludesDeadlineAndRejectsMissingActors() { + UUID player = UUID.randomUUID(), owner = UUID.randomUUID(); + assertNotEquals(GraveReminderKey.of(player, owner, 4L, "expiry", 10L), GraveReminderKey.of(player, owner, 4L, "expiry", 11L)); + assertThrows(IllegalArgumentException.class, () -> GraveReminderKey.of(null, owner, 4L, "expiry", 10L)); + assertThrows(IllegalArgumentException.class, () -> GraveReminderKey.of(player, owner, 4L, "", 10L)); + } + + @Test void nonpositiveAndElapsedGrantsAreInactive() { + UUID owner = UUID.randomUUID(), helper = UUID.randomUUID(); + assertFalse(new TrustedGraveAccess(owner, 2L, helper, TrustedGraveAccess.Kind.TRACK, 0L).isActive(Long.MAX_VALUE)); + assertFalse(new TrustedGraveAccess(owner, 2L, helper, TrustedGraveAccess.Kind.RECOVER, 1L).isActive(1L)); + } +} diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/TrustedGraveLegacyIdentityTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/TrustedGraveLegacyIdentityTest.java new file mode 100644 index 0000000..8505fcd --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/TrustedGraveLegacyIdentityTest.java @@ -0,0 +1,80 @@ +package com.bencodez.gravestonesplus.graves; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.util.*; +import java.util.concurrent.CompletionException; +import org.bukkit.Location; +import org.junit.jupiter.api.Test; +import com.bencodez.gravestonesplus.storage.GravesConfig; + +class TrustedGraveLegacyIdentityTest { + @Test void ownerDeathOverloadsWriteAndReadTheSameLocationBoundGrantsAsCommands() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var disk = new FeaturePersistenceTest.Disk(); + var store = new TrustedGraveAccessStore(new DurableFeatureState(h.io, disk, 4096), System::currentTimeMillis, h.plugin); + assertTrue(FeaturePersistenceTest.await(store.loaded())); + when(h.plugin.brokenGraveSnapshot()).thenReturn(List.of()); + UUID owner = h.owner.getUniqueId(), helper = h.helper.getUniqueId(); long time = h.grave.getGravesConfig().getTime(); + assertTrue(FeaturePersistenceTest.await(store.grantAsync(owner, time, helper, TrustedGraveAccess.Kind.TRACK, System.currentTimeMillis() + 60_000))); + assertTrue(store.allows(h.identity(), helper, TrustedGraveAccess.Kind.TRACK)); + assertFalse(store.allows(h.identity(), helper, TrustedGraveAccess.Kind.RECOVER)); + assertTrue(store.allows(owner, time, helper, TrustedGraveAccess.Kind.TRACK)); assertEquals(1, store.list(owner, time).size()); + assertEquals(Set.of(h.identity() + "/" + helper + "/TRACK"), disk.values.keySet()); + assertTrue(FeaturePersistenceTest.await(store.revokeAsync(owner, time, helper, TrustedGraveAccess.Kind.TRACK))); + assertFalse(store.allows(h.identity(), helper, TrustedGraveAccess.Kind.TRACK)); + assertTrue(FeaturePersistenceTest.await(store.grantAsync(h.identity(), helper, TrustedGraveAccess.Kind.RECOVER, System.currentTimeMillis() + 60_000))); + assertEquals(1, store.list(owner, time).size()); + FeaturePersistenceTest.await(store.revokeForGraveAsync(owner, time)); assertTrue(store.list(h.identity()).isEmpty()); + verify(h.world, never()).getChunkAt(anyInt(), anyInt()); + } + } + @Test void missingAndAmbiguousReferencesCannotGrantOrRevokeAnotherGrave() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var disk = new FeaturePersistenceTest.Disk(); + var store = new TrustedGraveAccessStore(new DurableFeatureState(h.io, disk, 4096), System::currentTimeMillis, h.plugin); + FeaturePersistenceTest.await(store.loaded()); when(h.plugin.brokenGraveSnapshot()).thenReturn(List.of()); + UUID owner = h.owner.getUniqueId(), helper = h.helper.getUniqueId(); long time = h.grave.getGravesConfig().getTime(); + assertFalse(FeaturePersistenceTest.await(store.grantAsync(null, time, helper, TrustedGraveAccess.Kind.TRACK, System.currentTimeMillis() + 60_000))); + assertFalse(FeaturePersistenceTest.await(store.grantAsync(owner, time - 1, helper, TrustedGraveAccess.Kind.TRACK, System.currentTimeMillis() + 60_000))); + assertTrue(FeaturePersistenceTest.await(store.grantAsync(h.identity(), helper, TrustedGraveAccess.Kind.TRACK, System.currentTimeMillis() + 60_000))); + var other = new Grave(h.plugin, new GravesConfig(owner, "Owner", new Location(h.world, 5, 64, 1), new HashMap<>(), 0, + "other", time, true, 0, null, null, "PLAYER_HEAD")); + when(h.plugin.brokenGraveSnapshot()).thenReturn(List.of(other)); + assertFalse(FeaturePersistenceTest.await(store.grantAsync(owner, time, helper, TrustedGraveAccess.Kind.RECOVER, System.currentTimeMillis() + 60_000))); + assertFalse(FeaturePersistenceTest.await(store.revokeAsync(owner, time, helper, TrustedGraveAccess.Kind.TRACK))); + assertFalse(store.allows(owner, time, helper, TrustedGraveAccess.Kind.TRACK)); assertTrue(store.list(owner, time).isEmpty()); + assertThrows(CompletionException.class, () -> store.revokeForGraveAsync(owner, time).join()); + assertTrue(store.allows(h.identity(), helper, TrustedGraveAccess.Kind.TRACK)); assertEquals(1, disk.values.size()); + } + } + @Test void standaloneRestartResolvesCanonicalRowsButNeverPromotesShortenedRows() throws Exception { + try (var io = new FeatureIO()) { + var disk = new FeaturePersistenceTest.Disk(); String id = FeaturePersistenceTest.GRAVE; + String[] parts = id.split("/"); UUID owner = UUID.fromString(parts[0]), helper = FeaturePersistenceTest.HELPER; long time = Long.parseLong(parts[1]); + var store = new TrustedGraveAccessStore(new DurableFeatureState(io, disk, 4096), () -> 100L); + FeaturePersistenceTest.await(store.loaded()); + assertFalse(FeaturePersistenceTest.await(store.grantAsync(owner, time, helper, TrustedGraveAccess.Kind.TRACK, 200))); + assertTrue(FeaturePersistenceTest.await(store.grantAsync(id, helper, TrustedGraveAccess.Kind.TRACK, 200))); + var restarted = new TrustedGraveAccessStore(new DurableFeatureState(io, disk, 4096), () -> 100L); + FeaturePersistenceTest.await(restarted.loaded()); assertTrue(restarted.allows(owner, time, helper, TrustedGraveAccess.Kind.TRACK)); + assertTrue(FeaturePersistenceTest.await(restarted.revokeAsync(owner, time, helper, TrustedGraveAccess.Kind.TRACK))); + disk.values = Map.of(owner + "/" + time + "/" + helper + "/TRACK", "200"); + var legacy = new TrustedGraveAccessStore(new DurableFeatureState(io, disk, 4096), () -> 100L); + FeaturePersistenceTest.await(legacy.loaded()); assertFalse(legacy.allows(owner, time, helper, TrustedGraveAccess.Kind.TRACK)); + assertFalse(FeaturePersistenceTest.await(legacy.grantAsync(owner, time, helper, TrustedGraveAccess.Kind.RECOVER, 200))); + assertEquals(1, disk.values.size()); + } + } + @Test void wholeGraveCompatibilityRevocationReportsPersistenceFailure() throws Exception { + try (var io = new FeatureIO()) { + var disk = new FeaturePersistenceTest.Disk(); String id = FeaturePersistenceTest.GRAVE; + var store = new TrustedGraveAccessStore(new DurableFeatureState(io, disk, 4096), () -> 100L); + FeaturePersistenceTest.await(store.loaded()); + assertTrue(FeaturePersistenceTest.await(store.grantAsync(id, FeaturePersistenceTest.HELPER, TrustedGraveAccess.Kind.TRACK, 200))); + disk.fail = true; String[] parts = id.split("/"); + assertThrows(CompletionException.class, () -> store.revokeForGraveAsync(UUID.fromString(parts[0]), Long.parseLong(parts[1])).join()); + assertFalse(store.allows(id, FeaturePersistenceTest.HELPER, TrustedGraveAccess.Kind.TRACK)); + } + } +} diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/UnavailableWorldRenderingTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/UnavailableWorldRenderingTest.java new file mode 100644 index 0000000..41a4d9a --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/UnavailableWorldRenderingTest.java @@ -0,0 +1,67 @@ +package com.bencodez.gravestonesplus.graves; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.util.concurrent.ScheduledExecutorService; +import org.junit.jupiter.api.Test; +import com.bencodez.advancedcore.api.item.ItemBuilder; +import com.bencodez.gravestonesplus.listeners.GraveReminderListener; + +class UnavailableWorldRenderingTest { + @Test void allMenuAndCommandDisplaysRetainUnavailableWorldIdentityAndCoordinates() throws Exception { + try (var h = new GraveActionsTest.Harness(); + var builders = mockConstruction(ItemBuilder.class, withSettings().defaultAnswer(RETURNS_SELF))) { + var worldId = h.grave.getGravesConfig().getWorldId(); + h.grave.getGravesConfig().getLocation().setWorld(null); + String position = "world unavailable [" + worldId + "] (1,64,1)"; + assertEquals(position, h.grave.getLocationDescription()); + assertTrue(h.grave.getGraveMessage().contains(position)); + assertTrue(h.grave.getAllGraveMessage().contains(position)); + assertEquals("world unavailable", h.grave.getNavigationMessage(h.owner)); + assertDoesNotThrow(h.grave::getGUIItem); + assertDoesNotThrow(() -> h.grave.getNavigationGUIItem(h.owner)); + assertDoesNotThrow(h.grave::getGUIItemBroken); + assertEquals(3, builders.constructed().size()); + for (var builder : builders.constructed()) verify(builder).addLoreLine("&3Location: " + position); + verify(h.world, never()).getChunkAt(anyInt(), anyInt()); + } + } + + @Test void loadedWorldAndMissingLocationHaveExplicitDisplayResults() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + assertEquals("overworld (1,64,1)", h.grave.getLocationDescription()); + assertTrue(h.grave.getGraveMessage().startsWith("Location: overworld (1,64,1) Time of death: ")); + var field = com.bencodez.gravestonesplus.storage.GravesConfig.class.getDeclaredField("location"); + field.setAccessible(true); field.set(h.grave.getGravesConfig(), null); + assertEquals("location unavailable", h.grave.getLocationDescription()); + assertDoesNotThrow(h.grave::getAllGraveMessage); + } + } + + @Test void unavailableWorldRemindersDeliverDistinctDeadlinesAndSuppressRepeats() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + var state = new DurableFeatureState(h.io, new FeaturePersistenceTest.Disk(), 8192); + var reminders = new GraveReminderStore(state); + FeaturePersistenceTest.await(reminders.loaded()); + when(h.plugin.getGraveReminderStore()).thenReturn(reminders); + when(h.config.isBreakOtherGravesEnabled()).thenReturn(true); + when(h.config.getProtectionReminderMillis()).thenReturn(60_000L); + when(h.config.getExpiryReminderMillis()).thenReturn(60_000L); + when(h.plugin.getTimer()).thenReturn(mock(ScheduledExecutorService.class)); + h.grave.checkTimeLimit("1m"); + h.grave.getGravesConfig().getLocation().setWorld(null); + var listener = new GraveReminderListener(h.plugin); + listener.start(); + try { + assertDoesNotThrow(() -> listener.checkPlayer(h.owner)); + FeaturePersistenceTest.await(h.io.barrier()); + assertEquals(2, state.snapshot().size()); + verify(h.owner).sendMessage(argThat((String text) -> text.contains("world unavailable") + && text.contains("(1,64,1)") && text.contains("expires") && text.contains("protection ends"))); + listener.checkPlayer(h.owner); + verify(h.owner, times(1)).sendMessage(any(String.class)); + verify(h.world, never()).getChunkAt(anyInt(), anyInt()); + } finally { listener.stop(); } + } + } +} diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/listeners/ItemRuleEvaluatorTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/listeners/ItemRuleEvaluatorTest.java new file mode 100644 index 0000000..14d57fa --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/listeners/ItemRuleEvaluatorTest.java @@ -0,0 +1,102 @@ +package com.bencodez.gravestonesplus.listeners; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Predicate; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.junit.jupiter.api.Test; + +class ItemRuleEvaluatorTest { + ItemStack item(String... lore) { + ItemStack item = mock(ItemStack.class); ItemMeta meta = mock(ItemMeta.class); + Material material = mock(Material.class); when(material.isAir()).thenReturn(false); + when(item.getType()).thenReturn(material); when(item.getAmount()).thenReturn(2); + when(item.hasItemMeta()).thenReturn(true); when(item.getItemMeta()).thenReturn(meta); + when(meta.hasLore()).thenReturn(lore.length > 0); when(meta.getLore()).thenReturn(List.of(lore)); + when(meta.getEnchants()).thenReturn(Map.of()); return item; + } + Predicate failStage() { return ignored -> { fail("lower precedence provider called"); return false; }; } + @Test void ignoreShortCircuitsAllLowerRules() { + var evaluator = new ItemRuleEvaluator(i -> true, failStage(), "keep", failStage(), failStage(), failStage()); + assertEquals(ItemRuleEvaluator.Outcome.IGNORE, evaluator.evaluate(item("keep"))); + } + @Test void soulboundBeatsLoreAndNbt() { + var evaluator = new ItemRuleEvaluator(i -> false, i -> true, "keep", failStage(), failStage(), failStage()); + assertEquals(ItemRuleEvaluator.Outcome.SOULBOUND, evaluator.evaluate(item("keep"))); + } + @Test void exactLoreUsesWholeLineCaseInsensitiveAndBeatsKeepNbt() { + var evaluator = new ItemRuleEvaluator(i -> false, i -> false, "\u00a7aOwner Alex", failStage(), failStage(), failStage()); + ItemStack held = item("\u00a7aOWNER ALEX"); + assertEquals(ItemRuleEvaluator.Outcome.KEEP_LORE, evaluator.evaluate(held)); + assertFalse(ItemRuleEvaluator.matchesLore(item("prefix \u00a7aOwner Alex"), "\u00a7aOwner Alex")); + assertFalse(ItemRuleEvaluator.matchesLore(item("Owner Alex"), "\u00a7aOwner Alex")); + } + @Test void keepNbtAndGraveNbtKeepTheirOrder() { + assertEquals(ItemRuleEvaluator.Outcome.KEEP_NBT, + new ItemRuleEvaluator(i -> false, i -> false, "", i -> true, failStage(), failStage()).evaluate(item())); + assertEquals(ItemRuleEvaluator.Outcome.GRAVE_NBT, + new ItemRuleEvaluator(i -> false, i -> false, "", i -> false, i -> true, failStage()).evaluate(item())); + } + @Test void defaultAndEmptyRoutingConsumeNoRngOrMutation() { + ItemStack held = item("ordinary"); ItemMeta meta = held.getItemMeta(); + assertEquals(ItemRuleEvaluator.Outcome.GRAVE_DEFAULT, + new ItemRuleEvaluator(i -> false, i -> false, "", i -> false, i -> false, i -> false).evaluate(held)); + assertEquals(2, held.getAmount()); assertEquals(List.of("ordinary"), meta.getLore()); + verify(held, never()).setAmount(anyInt()); verify(held, never()).setItemMeta(any()); + verify(meta, never()).setLore(any()); verify(meta, never()).setDisplayName(any()); + assertEquals(ItemRuleEvaluator.Outcome.EMPTY, + new ItemRuleEvaluator(failStage(), failStage(), "", failStage(), failStage(), failStage()).evaluate(null)); + } + @Test void vanishingIsAfterGraveNbtAndBeforeDefault() { + assertEquals(ItemRuleEvaluator.Outcome.GRAVE_NBT, + new ItemRuleEvaluator(i -> false, i -> false, "", i -> false, i -> true, failStage()).evaluate(item())); + assertEquals(ItemRuleEvaluator.Outcome.VANISH, + new ItemRuleEvaluator(i -> false, i -> false, "", i -> false, i -> false, i -> true).evaluate(item())); + } + @Test void chanceIsOnlyDescribedNotSampledAndPreservesEffectiveLegacyLimits() { + assertEquals(0, ItemRuleEvaluator.effectiveChance(-1)); assertEquals(0, ItemRuleEvaluator.effectiveChance(0)); + assertEquals(50, ItemRuleEvaluator.effectiveChance(50)); assertEquals(100, ItemRuleEvaluator.effectiveChance(100)); + assertEquals(100, ItemRuleEvaluator.effectiveChance(101)); + } + @Test void oneRulesSnapshotIsImmutableAcrossPublicationGenerations() { + var oldRules = new com.bencodez.gravestonesplus.nbt.NBTRule("old", "path", "STRING", "old"); + var newerRules = new com.bencodez.gravestonesplus.nbt.NBTRule("new", "path", "STRING", "new"); + var old = new com.bencodez.gravestonesplus.nbt.NBTConfigManager.RulesSnapshot(List.of(), List.of(oldRules), List.of()); + var published = new com.bencodez.gravestonesplus.nbt.NBTConfigManager.RulesSnapshot(List.of(), List.of(), List.of(newerRules)); + assertEquals("old", old.keep().get(0).getName()); + assertTrue(old.grave().isEmpty()); + assertEquals("new", published.grave().get(0).getName()); + assertThrows(UnsupportedOperationException.class, () -> old.ignore().add(newerRules)); + } + @Test void productionFactoryReusesPlaceholderLoreAndNeverTouchesMissingNbtApi() { + com.bencodez.gravestonesplus.GraveStonesPlus plugin = mock(com.bencodez.gravestonesplus.GraveStonesPlus.class); + com.bencodez.gravestonesplus.config.Config config = mock(com.bencodez.gravestonesplus.config.Config.class); + org.bukkit.entity.Player player = mock(org.bukkit.entity.Player.class); + when(plugin.getConfigFile()).thenReturn(config); when(config.getKeepItemsWithLore()).thenReturn("Owner %player%"); + when(player.getName()).thenReturn("Alex"); when(player.getDisplayName()).thenReturn("Alex"); + ItemStack held = item("OWNER ALEX"); when(held.clone()).thenReturn(held); + assertEquals(ItemRuleEvaluator.Outcome.KEEP_LORE, ItemRuleEvaluator.forPlayer(plugin, player).evaluate(held)); + verify(plugin, never()).getNbtConfigManager(); verify(held, never()).setAmount(anyInt()); verify(held, never()).setItemMeta(any()); + } + @Test void heldInspectionUsesProductionFactoryWithoutNbtRngOrMetadataMutation() { + com.bencodez.gravestonesplus.GraveStonesPlus plugin = mock(com.bencodez.gravestonesplus.GraveStonesPlus.class); + com.bencodez.gravestonesplus.config.Config config = mock(com.bencodez.gravestonesplus.config.Config.class); + org.bukkit.entity.Player player = mock(org.bukkit.entity.Player.class); + when(plugin.getConfigFile()).thenReturn(config); when(config.getKeepItemsWithLore()).thenReturn("Owner %player%"); + when(config.getPercentageDrops()).thenReturn(50); when(player.getName()).thenReturn("Alex"); when(player.getDisplayName()).thenReturn("Alex"); + ItemStack held = item("OWNER ALEX"); when(held.clone()).thenReturn(held); + org.bukkit.persistence.PersistentDataContainer pdc = mock(org.bukkit.persistence.PersistentDataContainer.class); + ItemMeta meta = held.getItemMeta(); when(meta.getPersistentDataContainer()).thenReturn(pdc); when(pdc.getKeys()).thenReturn(Set.of()); + List result = ItemRuleEvaluator.inspect(plugin, player, held); + assertTrue(result.stream().anyMatch(line -> line.contains("KEEP_LORE"))); + assertTrue(result.stream().anyMatch(line -> line.contains("50% per stack"))); + assertTrue(result.stream().anyMatch(line -> line.contains("NBTAPI unavailable"))); + verify(plugin, never()).getNbtConfigManager(); verify(held, never()).setAmount(anyInt()); verify(held, never()).setItemMeta(any()); + verify(meta, never()).setLore(any()); verify(pdc, never()).set(any(), any(), any()); + } + +} diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/nbt/NBTConfigManagerTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/nbt/NBTConfigManagerTest.java new file mode 100644 index 0000000..ff37dcd --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/nbt/NBTConfigManagerTest.java @@ -0,0 +1,62 @@ +package com.bencodez.gravestonesplus.nbt; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import com.bencodez.gravestonesplus.GraveStonesPlus; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.logging.Logger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class NBTConfigManagerTest { + @TempDir Path root; + final java.util.concurrent.atomic.AtomicReference lore = new java.util.concurrent.atomic.AtomicReference<>("old lore"); + String rules(boolean keep) { + String stage = keep ? "keep_nbt_rules" : "grave_nbt_rules"; + return stage + ":\n safe:\n path: keep\n type: EXISTS\n value: true\n"; + } + NBTConfigManager manager() throws Exception { + Files.writeString(root.resolve("nbt_rules.yml"), rules(true)); + GraveStonesPlus plugin = mock(GraveStonesPlus.class); + var config = mock(com.bencodez.gravestonesplus.config.Config.class); + when(plugin.getConfigFile()).thenReturn(config); when(config.getKeepItemsWithLore()).thenAnswer(call -> lore.get()); + when(plugin.getDataFolder()).thenReturn(root.toFile()); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger()); + return new NBTConfigManager(plugin); + } + @Test void actualReloadKeepsPreviouslyCapturedGenerationAndPublishesCompleteReplacement() throws Exception { + var manager = manager(); var old = manager.snapshot(); + assertEquals("safe", old.keep().getFirst().getName()); assertTrue(old.grave().isEmpty()); + lore.set("new lore"); Files.writeString(root.resolve("nbt_rules.yml"), rules(false)); manager.reloadConfig(); + var current = manager.snapshot(); assertTrue(current.keep().isEmpty()); assertEquals("safe", current.grave().getFirst().getName()); + assertEquals("safe", old.keep().getFirst().getName()); assertTrue(old.grave().isEmpty()); + assertEquals("old lore", old.keepLore()); assertEquals("new lore", current.keepLore()); + assertThrows(UnsupportedOperationException.class, () -> old.keep().clear()); + } + @Test void readersNeverObserveAnUnprotectedGenerationDuringRealReloads() throws Exception { + var manager = manager(); var start = new CountDownLatch(1); + try (var pool = Executors.newFixedThreadPool(3)) { + var writer = pool.submit(() -> { + start.await(); + for (int i = 0; i < 40; i++) { Files.writeString(root.resolve("nbt_rules.yml"), rules(i % 2 == 0)); manager.reloadConfig(); } + return null; + }); + var readers = List.of(pool.submit(() -> verifyGenerations(manager, start)), pool.submit(() -> verifyGenerations(manager, start))); + start.countDown(); writer.get(10, TimeUnit.SECONDS); + for (var reader : readers) reader.get(10, TimeUnit.SECONDS); + } + } + Boolean verifyGenerations(NBTConfigManager manager, CountDownLatch start) throws InterruptedException { + start.await(); + for (int i = 0; i < 20_000; i++) { + var generation = manager.snapshot(); + assertEquals(1, generation.keep().size() + generation.grave().size()); + assertTrue(generation.ignore().isEmpty()); + } + return true; + } +} diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/FlatFileSnapshotPublicationTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/FlatFileSnapshotPublicationTest.java new file mode 100644 index 0000000..e785cbd --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/FlatFileSnapshotPublicationTest.java @@ -0,0 +1,86 @@ +package com.bencodez.gravestonesplus.storage; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; + +import com.bencodez.gravestonesplus.GraveStonesPlus; +import com.bencodez.gravestonesplus.graves.FeatureIO; + +class FlatFileSnapshotPublicationTest { + @Test void flatFileSnapshotUpdatesBothSectionsBeforeOneSave() { + GraveLocations locations = mock(GraveLocations.class); + FlatFileGraveStorageBackend backend = new FlatFileGraveStorageBackend(locations); + List active = List.of(), broken = List.of(); + backend.saveSnapshot(active, broken); + verify(locations).saveSnapshot(active, broken); + verifyNoMoreInteractions(locations); + } + + @org.junit.jupiter.api.io.TempDir java.nio.file.Path temporary; + + @Test void graveLocationsPublishesBothSectionsAndRetainsLegacyShape() throws Exception { + GraveLocations locations = mock(GraveLocations.class); + var data = new org.bukkit.configuration.file.YamlConfiguration(); + java.nio.file.Path target = temporary.resolve("Graves.yml"); + java.nio.file.Files.writeString(target, "graves: []\nbrokengraves: [old]\n"); + when(locations.getData()).thenReturn(data); + when(locations.getdFile()).thenReturn(target.toFile()); + doCallRealMethod().when(locations).saveSnapshot(any(), any()); + locations.saveSnapshot(List.of(), List.of()); + var stored = new org.bukkit.configuration.file.YamlConfiguration(); + stored.load(target.toFile()); + assertEquals(List.of(), stored.getList("graves")); + assertEquals(List.of(), stored.getList("brokengraves")); + verify(locations, never()).saveData(); + verify(locations, never()).setGrave(any()); + verify(locations, never()).setBrokenGrave(any()); + try (var files = java.nio.file.Files.list(temporary)) { + assertEquals(List.of(target), files.toList()); + } + } + + @Test void graveLocationsReportsPublicationFailureInsteadOfAcknowledgingIt() throws Exception { + GraveLocations locations = mock(GraveLocations.class); + java.nio.file.Path parentFile = temporary.resolve("not-a-directory"); + java.nio.file.Files.writeString(parentFile, "unchanged"); + when(locations.getData()).thenReturn(new org.bukkit.configuration.file.YamlConfiguration()); + when(locations.getdFile()).thenReturn(parentFile.resolve("Graves.yml").toFile()); + doCallRealMethod().when(locations).saveSnapshot(any(), any()); + assertThrows(java.io.UncheckedIOException.class, () -> locations.saveSnapshot(List.of(), List.of())); + assertEquals("unchanged", java.nio.file.Files.readString(parentFile)); + } + + @Test void managerFutureWaitsForWorkerAndRetainsFailedPublication() throws Exception { + GraveStonesPlus plugin = mock(GraveStonesPlus.class); + try (FeatureIO io = new FeatureIO(4)) { + when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getLogger("GraveSnapshotTest")); + when(plugin.getFeatureIO()).thenReturn(io); + when(plugin.activeGraveSnapshot()).thenReturn(List.of()); + when(plugin.brokenGraveSnapshot()).thenReturn(List.of()); + GraveStorageBackend backend = mock(GraveStorageBackend.class); + CountDownLatch started = new CountDownLatch(1), release = new CountDownLatch(1); + doAnswer(invocation -> { started.countDown(); release.await(2, TimeUnit.SECONDS); return null; }) + .when(backend).saveSnapshot(any(), any()); + GraveStorageManager manager = new GraveStorageManager(plugin); + var backendField = GraveStorageManager.class.getDeclaredField("backend"); + backendField.setAccessible(true); backendField.set(manager, backend); + var pending = manager.saveSnapshotFromObjects(List.of(), List.of()); + assertTrue(started.await(2, TimeUnit.SECONDS)); + assertFalse(pending.isDone()); + release.countDown(); + assertTrue(pending.get(2, TimeUnit.SECONDS)); + doThrow(new IllegalStateException("disk")).when(backend).saveSnapshot(any(), any()); + var failed = manager.saveSnapshotFromObjects(List.of(), List.of()); + assertThrows(Exception.class, () -> failed.get(2, TimeUnit.SECONDS)); + var retained = GraveStorageManager.class.getDeclaredField("retained"); + retained.setAccessible(true); + assertNotNull(retained.get(manager)); + } + } +} diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/GraveMySQLInitializationTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/GraveMySQLInitializationTest.java new file mode 100644 index 0000000..f8c4179 --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/GraveMySQLInitializationTest.java @@ -0,0 +1,107 @@ +package com.bencodez.gravestonesplus.storage; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.util.ArrayList; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.simpleapi.sql.mysql.ConnectionManager; +import com.bencodez.simpleapi.sql.mysql.DbType; + +class GraveMySQLInitializationTest { + @org.junit.jupiter.api.Test + void legacySuperclassLoggingDoesNotRequireTheSubclassPluginField() { + // CALLS_REAL_METHODS leaves the subclass field unassigned, matching a + // virtual callback from an older SimpleAPI superclass constructor. + var table = mock(GravesMySQLTable.class, CALLS_REAL_METHODS); + var logger = mock(java.util.logging.Logger.class); + var failure = new java.sql.SQLException("fixture authentication rejected"); + try (var loggers = mockStatic(java.util.logging.Logger.class)) { + loggers.when(() -> java.util.logging.Logger.getLogger("GraveStonesPlus")).thenReturn(logger); + table.logSevere("fixture initialization failed"); + table.logInfo("fixture setup"); + table.debug("fixture debug"); + table.debug(failure); + verify(logger).severe("fixture initialization failed"); + verify(logger).info("fixture setup"); + verify(logger).fine("fixture debug"); + verify(logger).log(java.util.logging.Level.FINE, "SQL initialization diagnostic", failure); + } + } + + @org.junit.jupiter.api.Test + void legacyFailedPoolIsRejectedBeforeTableQueriesAndWorkersAreRetired() { + var plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + var config = new YamlConfiguration(); + var workers = new ArrayList(); + try (var executors = mockStatic(Executors.class); + var managers = mockConstruction(ConnectionManager.class, (manager, context) -> { + // A legacy constructor reports success/returns, but its pool is unusable. + when(manager.open()).thenReturn(true); + when(manager.isClosed()).thenReturn(true); + var connection = mock(Connection.class); + when(manager.getConnectionChecked()).thenReturn(connection); + when(connection.prepareStatement(anyString())).thenReturn(mock(PreparedStatement.class)); + })) { + executors.when(() -> Executors.newFixedThreadPool(anyInt())).thenAnswer(call -> { + var worker = mock(ExecutorService.class); + workers.add(worker); return worker; + }); + var failure = assertThrows(IllegalStateException.class, + () -> new GravesMySQLTable(plugin, "graves", config) { + @Override public String buildCreateTableSql(DbType type) { + fail("A failed pool must not reach table initialization"); return ""; + } + }); + assertTrue(failure.getMessage().contains("did not initialize")); + verify(managers.constructed().getFirst()).close(); + assertFalse(workers.isEmpty()); + workers.forEach(worker -> verify(worker).shutdown()); + } + } + + @ParameterizedTest @ValueSource(booleans = {false, true}) + void failedPostConnectInitializationReleasesPoolAndWorkersPreservingFailure(boolean cleanupFails) { + var plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + var config = new YamlConfiguration(); + config.set("Host", "localhost"); config.set("Port", 3306); + config.set("Database", "test"); config.set("Username", "test"); + config.set("Password", "fixture"); config.set("MaxConnections", 1); + var setup = new IllegalStateException("fixture table initialization failure"); + var cleanup = new IllegalStateException("fixture close failure"); + var workers = new ArrayList(); + try (var executors = mockStatic(Executors.class); + var managers = mockConstruction(ConnectionManager.class, (manager, context) -> { + when(manager.open()).thenReturn(true); when(manager.getDbType()).thenReturn(DbType.MYSQL); + var connection = mock(Connection.class); var statement = mock(PreparedStatement.class); + when(manager.getConnectionChecked()).thenReturn(connection); + when(connection.prepareStatement(anyString())).thenReturn(statement); + if (cleanupFails) doThrow(cleanup).when(manager).close(); + })) { + executors.when(() -> Executors.newFixedThreadPool(anyInt())).thenAnswer(call -> { + var worker = mock(ExecutorService.class); + var stopped = new java.util.concurrent.atomic.AtomicBoolean(); + when(worker.isShutdown()).thenAnswer(ignored -> stopped.get()); + doAnswer(ignored -> { stopped.set(true); return null; }).when(worker).shutdown(); + workers.add(worker); return worker; + }); + var thrown = assertThrows(IllegalStateException.class, + () -> new GravesMySQLTable(plugin, "graves", config) { + @Override public String buildCreateTableSql(DbType type) { throw setup; } + }); + assertSame(setup, thrown); + verify(managers.constructed().getFirst()).close(); + assertFalse(workers.isEmpty()); + workers.forEach(worker -> verify(worker).shutdown()); + if (cleanupFails) assertArrayEquals(new Throwable[] { cleanup }, thrown.getSuppressed()); + else assertEquals(0, thrown.getSuppressed().length); + } + } +} diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/GraveStorageManagerTerminalCloseTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/GraveStorageManagerTerminalCloseTest.java new file mode 100644 index 0000000..2070f4a --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/GraveStorageManagerTerminalCloseTest.java @@ -0,0 +1,162 @@ +package com.bencodez.gravestonesplus.storage; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.logging.Logger; + +import org.junit.jupiter.api.Test; + +import com.bencodez.gravestonesplus.GraveStonesPlus; +import com.bencodez.gravestonesplus.graves.FeatureIO; + +class GraveStorageManagerTerminalCloseTest { + @Test + void failedWriteIsRetainedBeforeExactlyOneTerminalClose() throws Exception { + var backend = mock(GraveStorageBackend.class); + try (FeatureIO io = new FeatureIO(1)) { + var plugin = mock(GraveStonesPlus.class); + when(plugin.getFeatureIO()).thenReturn(io); + when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger()); + when(plugin.activeGraveSnapshot()).thenReturn(List.of()); + when(plugin.brokenGraveSnapshot()).thenReturn(List.of()); + doThrow(new RuntimeException("write failure")).when(backend).saveGraves(any()); + doThrow(new RuntimeException("recovery failure")).when(backend).saveSnapshot(any(), any()); + var manager = manager(plugin, backend); + + manager.saveGravesFromObjects(List.of()); + io.barrier().join(); + manager.close(); + } + verify(backend, times(1)).close(); + } + + @Test + void closeRequestSurvivesSaturatedQueueAndClosesOnceAfterRetainedDrain() throws Exception { + var backend = mock(GraveStorageBackend.class); + try (FeatureIO io = new FeatureIO(1)) { + var plugin = mock(GraveStonesPlus.class); + when(plugin.getFeatureIO()).thenReturn(io); + when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger()); + when(plugin.activeGraveSnapshot()).thenReturn(List.of()); + when(plugin.brokenGraveSnapshot()).thenReturn(List.of()); + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + var first = io.submit(() -> { + entered.countDown(); + try { release.await(2, TimeUnit.SECONDS); } catch (InterruptedException failure) { Thread.currentThread().interrupt(); } + return null; + }); + assertTrue(entered.await(2, TimeUnit.SECONDS)); + var second = io.submit(() -> null); + var manager = manager(plugin, backend); + manager.saveGravesFromObjects(List.of()); + assertNull(io.admit(), "running and queued work must occupy every admission"); + manager.close(); + manager.close(); + release.countDown(); + first.join(); + second.join(); + manager.saveGravesFromObjects(List.of()); + } + verify(backend, times(1)).close(); + verify(backend, times(1)).saveSnapshot(any(), any()); + verify(backend, never()).saveBrokenGraves(any()); + verify(backend, never()).saveGraves(any()); + var order = inOrder(backend); + order.verify(backend).saveSnapshot(any(), any()); + order.verify(backend).close(); + } + + @Test void idleAndRestoringManagersCloseWithoutWaitingForWorkerShutdown() throws Exception { + for (boolean loading : List.of(false, true)) { + var backend = mock(GraveStorageBackend.class); + var closed = new CountDownLatch(1); + doAnswer(call -> { assertEquals("gsp-feature-io", Thread.currentThread().getName()); closed.countDown(); return null; }) + .when(backend).close(); + try (FeatureIO io = new FeatureIO(1)) { + var plugin = plugin(io); + var manager = manager(plugin, backend); + if (loading) manager.beginRestoration(); + manager.close(); manager.close(); + assertTrue(closed.await(3, TimeUnit.SECONDS), "standalone close must wake the disk owner"); + manager.saveGravesFromObjects(List.of()); + } + verify(backend, times(1)).close(); + verify(backend, never()).saveGraves(any()); + } + } + + @Test void queuedFailureRecoversBeforeCloseAndDoesNotResurrectRecoveryAfterDrain() throws Exception { + var backend = mock(GraveStorageBackend.class); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + var order = new java.util.concurrent.CopyOnWriteArrayList(); + var writes = new java.util.concurrent.atomic.AtomicInteger(); + doAnswer(call -> { + if (writes.incrementAndGet() == 1) { + entered.countDown(); + assertTrue(release.await(3, TimeUnit.SECONDS)); + order.add("failed"); throw new IllegalStateException("fixture rejected write"); + } + order.add("recovered"); return null; + }).when(backend).saveGraves(any()); + doAnswer(call -> { + assertEquals(2, writes.incrementAndGet()); + order.add("recovered"); return null; + }).when(backend).saveSnapshot(any(), any()); + doAnswer(call -> { order.add("closed"); return null; }).when(backend).close(); + GraveStorageManager manager; + try (FeatureIO io = new FeatureIO(2)) { + manager = manager(plugin(io), backend); + manager.saveGravesFromObjects(List.of()); + try { + assertTrue(entered.await(3, TimeUnit.SECONDS)); + manager.close(); manager.close(); + } finally { release.countDown(); } + } + assertEquals(List.of("failed", "recovered", "closed"), order); + var retained = GraveStorageManager.class.getDeclaredField("retained"); retained.setAccessible(true); + assertNull(retained.get(manager), "a completed failed-write callback must not re-retain recovered state"); + manager.saveGravesFromObjects(List.of()); + assertEquals(2, writes.get()); verify(backend, times(1)).close(); + } + + @Test void permanentlyFailedRecoveryRetainsEvidenceAndNeverWritesAfterClose() throws Exception { + var backend = mock(GraveStorageBackend.class); + var closed = new java.util.concurrent.atomic.AtomicBoolean(); + doAnswer(call -> { assertFalse(closed.get(), "no retry may use a closed backend"); throw new IllegalStateException("fixture unavailable"); }) + .when(backend).saveGraves(any()); + doAnswer(call -> { assertFalse(closed.get(), "no recovery may use a closed backend"); throw new IllegalStateException("fixture recovery unavailable"); }) + .when(backend).saveSnapshot(any(), any()); + doAnswer(call -> { assertTrue(closed.compareAndSet(false, true)); return null; }).when(backend).close(); + GraveStorageManager manager; + try (FeatureIO io = new FeatureIO(2)) { + manager = manager(plugin(io), backend); + manager.saveGravesFromObjects(List.of()); + manager.close(); + } + assertTrue(closed.get()); verify(backend, times(1)).close(); + var retained = GraveStorageManager.class.getDeclaredField("retained"); retained.setAccessible(true); + assertNotNull(retained.get(manager), "failed recovery must remain observable rather than report a successful flush"); + } + + private static GraveStonesPlus plugin(FeatureIO io) { + var plugin = mock(GraveStonesPlus.class); + when(plugin.getFeatureIO()).thenReturn(io); + when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger()); + when(plugin.activeGraveSnapshot()).thenReturn(List.of()); + when(plugin.brokenGraveSnapshot()).thenReturn(List.of()); + return plugin; + } + + private static GraveStorageManager manager(GraveStonesPlus plugin, GraveStorageBackend backend) throws Exception { + var manager = new GraveStorageManager(plugin); + var field = GraveStorageManager.class.getDeclaredField("backend"); + field.setAccessible(true); + field.set(manager, backend); + return manager; + } +} diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/LegacyPreparedItemsTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/LegacyPreparedItemsTest.java new file mode 100644 index 0000000..69d5d4f --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/LegacyPreparedItemsTest.java @@ -0,0 +1,109 @@ +package com.bencodez.gravestonesplus.storage; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.io.IOException; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.TimeUnit; +import org.bukkit.*; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.configuration.serialization.*; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import com.bencodez.gravestonesplus.graves.FeatureIO; + +class LegacyPreparedItemsTest { + @TempDir Path directory; + + @SerializableAs("GspTestLegacyMeta") + public static final class LegacyMeta implements ConfigurationSerializable { + final Thread owner; + final List lore; + final byte[] data; + LegacyMeta(List lore, byte[] data) { owner = Thread.currentThread(); this.lore = lore; this.data = data; } + public Map serialize() { + assertSame(owner, Thread.currentThread(), "metadata serialized off owner"); + return Map.of("lore", lore, "custom-data", data); + } + public static LegacyMeta deserialize(Map fields) { + @SuppressWarnings("unchecked") List lore = (List) fields.get("lore"); + return new LegacyMeta(lore, (byte[]) fields.get("custom-data")); + } + } + @SerializableAs("GspTestLegacyItem") + public static final class LegacyItem extends ItemStack { + final Thread owner; + final LegacyMeta custom; + LegacyItem(int amount, LegacyMeta custom) { super(Material.STONE, amount); owner = Thread.currentThread(); this.custom = custom; } + @Override public Map serialize() { + assertSame(owner, Thread.currentThread(), "item serialized off owner"); + return Map.of("type", "STONE", "amount", getAmount(), "meta", custom); + } + @Override public LegacyItem clone() { return new LegacyItem(getAmount(), custom); } + public static LegacyItem deserialize(Map fields) { + return new LegacyItem(((Number) fields.get("amount")).intValue(), (LegacyMeta) fields.get("meta")); + } + } + private GravesConfig source(LegacyMeta meta) { + var items = new HashMap(); + items.put(0, new LegacyItem(2, meta)); items.put(-5, new LegacyItem(1, meta)); + var record = new GravesConfig(UUID.randomUUID(), "owner", new Location(null, 1, 64, 2), items, + 17, "death", 4, false, 0, null, null, "PLAYER_HEAD"); + record.setWorldId(UUID.randomUUID()); return record; + } + @Test void flatFileAndStartupFallbackRoundTripDetachedItemsAndNestedMetadata() throws Exception { + ConfigurationSerialization.registerClass(LegacyMeta.class); + ConfigurationSerialization.registerClass(LegacyItem.class); + ConfigurationSerialization.registerClass(GravesConfig.class); + try (var encoding = mockStatic(InventorySerializer.class); var bukkit = mockStatic(Bukkit.class); var io = new FeatureIO()) { + encoding.when(() -> InventorySerializer.encode(any())).thenThrow(new IOException("binary unavailable")); + var meta = new LegacyMeta(new ArrayList<>(List.of("custom lore")), new byte[]{1, 2, 3}); + var source = source(meta); var captured = GraveStorageManager.snapshot(source); + source.getItems().get(0).setAmount(9); meta.lore.add("later mutation"); meta.data[0] = 9; + assertThrows(java.io.UncheckedIOException.class, captured::encodedItemsForStorage, "SQL must not silently accept legacy YAML"); + var locations = mock(GraveLocations.class); + var yaml = new YamlConfiguration(); when(locations.getData()).thenReturn(yaml); + Path path = directory.resolve("Graves.yml"); when(locations.getdFile()).thenReturn(path.toFile()); + doCallRealMethod().when(locations).saveSnapshot(any(), any()); + var backend = new FlatFileGraveStorageBackend(locations); + io.submit(() -> { backend.saveSnapshot(List.of(captured), List.of()); return true; }).get(2, TimeUnit.SECONDS); + assertRestored(path, source); + // Interrupted-startup merge uses only plain prepared values, and retains unrelated records/settings. + Files.writeString(path, Files.readString(path) + "owner-extension: retained\n"); + io.submit(() -> { backend.saveStartupSnapshot(List.of(captured), List.of()); return true; }).get(2, TimeUnit.SECONDS); + assertRestored(path, source); + assertTrue(Files.readString(path).contains("owner-extension: retained")); + encoding.verify(() -> InventorySerializer.encode(any()), times(1)); + } finally { + ConfigurationSerialization.unregisterClass(LegacyMeta.class); + ConfigurationSerialization.unregisterClass(LegacyItem.class); + ConfigurationSerialization.unregisterClass(GravesConfig.class); + } + } + private void assertRestored(Path path, GravesConfig source) throws Exception { + var read = new YamlConfiguration(); read.loadFromString(Files.readString(path)); + var records = read.getList("graves"); assertNotNull(records); assertEquals(1, records.size()); + var restored = assertInstanceOf(GravesConfig.class, records.get(0)); + assertEquals(source.getUuid(), restored.getUuid()); assertEquals(source.getWorldId(), restored.getWorldId()); + assertEquals(17, restored.getExp()); assertEquals(Set.of(0, -5), restored.getItems().keySet()); + var item = assertInstanceOf(LegacyItem.class, restored.getItems().get(0)); + assertEquals(2, item.getAmount()); assertEquals(List.of("custom lore"), item.custom.lore); + assertArrayEquals(new byte[]{1, 2, 3}, item.custom.data); + assertEquals(1, restored.getItems().get(-5).getAmount()); + } + @Test void cyclicLegacyMetadataFailsExplicitlyWithoutDiscardingInventory() throws Exception { + try (var encoding = mockStatic(InventorySerializer.class)) { + encoding.when(() -> InventorySerializer.encode(any())).thenThrow(new IOException("binary unavailable")); + var item = mock(ItemStack.class); when(item.clone()).thenReturn(item); + Map cycle = new HashMap<>(); cycle.put("self", cycle); + when(item.serialize()).thenReturn(cycle); + var source = source(new LegacyMeta(List.of(), new byte[0])); source.getItems().put(0, item); + var captured = GraveStorageManager.snapshot(source); + var failure = assertThrows(java.io.UncheckedIOException.class, captured::preparedRecordForStorage); + assertInstanceOf(IllegalArgumentException.class, failure.getSuppressed()[0]); + assertEquals(Set.of(0, -5), captured.getItems().keySet()); + } + } +} diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/MySQLRestorationOwnershipTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/MySQLRestorationOwnershipTest.java new file mode 100644 index 0000000..a8fc912 --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/MySQLRestorationOwnershipTest.java @@ -0,0 +1,404 @@ +package com.bencodez.gravestonesplus.storage; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.sql.*; +import java.util.*; +import java.util.concurrent.*; +import org.bukkit.*; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.Test; +import org.bukkit.configuration.file.YamlConfiguration; +import com.bencodez.gravestonesplus.GraveStonesPlus; +import com.bencodez.gravestonesplus.config.Config; +import com.bencodez.gravestonesplus.graves.FeatureIO; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; +import com.bencodez.simpleapi.sql.mysql.*; + +class MySQLRestorationOwnershipTest { + enum Write { + UPSERT, UPDATE, BROKEN, DELETE; + void backend(MySQLGraveStorageBackend backend, GravesConfig grave) { + switch (this) { + case UPSERT -> backend.upsertGrave(grave, false); + case UPDATE -> backend.updateExistingGrave(grave, true); + case BROKEN -> backend.setBroken(grave, true); + case DELETE -> backend.deleteGrave(grave); + } + } + void legacy(GravesMySQLTable table, GravesConfig grave) { + switch (this) { + case UPSERT -> table.upsertGrave(grave, false); + case UPDATE -> table.updateExistingGrave(grave, true); + case BROKEN -> table.setBroken(grave, true); + case DELETE -> table.deleteGrave(grave); + } + } + } + private static void checkedBackend(Fixture h) throws Exception { + recreation(h); + doNothing().when(h.table).debug(any(Throwable.class)); + doCallRealMethod().when(h.backend).upsertGrave(any(), anyBoolean()); + doCallRealMethod().when(h.backend).updateExistingGrave(any(), anyBoolean()); + doCallRealMethod().when(h.backend).setBroken(any(), anyBoolean()); + doCallRealMethod().when(h.backend).deleteGrave(any()); + } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.EnumSource(Write.class) + void checkedBackendReportsSqlFailureWithoutChangingLegacyTableContract(Write write) throws Exception { + try (var h = new Fixture()) { + checkedBackend(h); + when(h.statement.executeUpdate()).thenThrow(new SQLException("fixture write failure")); + var grave = recreated(h); + assertThrows(ExecutionException.class, () -> h.io.submit(() -> { write.backend(h.backend, grave); return true; }).get(3, TimeUnit.SECONDS)); + assertTrue(h.io.submit(() -> { write.legacy(h.table, grave); return true; }).get(3, TimeUnit.SECONDS)); + verify(h.table).debug(any(Throwable.class)); + verify(h.statement, times(2)).close(); verify(h.connection, times(2)).close(); + } + } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.EnumSource(Write.class) + void checkedBackendRejectsUncommittedConnectionsBeforeExecuting(Write write) throws Exception { + try (var h = new Fixture()) { + checkedBackend(h); when(h.connection.getAutoCommit()).thenReturn(false); + assertThrows(ExecutionException.class, () -> h.io.submit(() -> { write.backend(h.backend, recreated(h)); return true; }).get(3, TimeUnit.SECONDS)); + verify(h.statement, never()).executeUpdate(); + verify(h.connection, never()).commit(); verify(h.connection, never()).rollback(); + verify(h.statement).close(); verify(h.connection).close(); + } + } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.EnumSource(Write.class) + void idempotentZeroAffectedRowsStillAcknowledgesSuccessfulSql(Write write) throws Exception { + try (var h = new Fixture()) { + checkedBackend(h); when(h.statement.executeUpdate()).thenReturn(0); + assertTrue(h.io.submit(() -> { write.backend(h.backend, recreated(h)); return true; }).get(3, TimeUnit.SECONDS)); + verify(h.statement).executeUpdate(); verify(h.statement).close(); verify(h.connection).close(); + } + } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.EnumSource(Write.class) + void statementCleanupFailureIsNotAcknowledgedAsAConfirmedWrite(Write write) throws Exception { + try (var h = new Fixture()) { + checkedBackend(h); when(h.statement.executeUpdate()).thenReturn(1); + doThrow(new SQLException("fixture cleanup failure")).when(h.statement).close(); + assertThrows(ExecutionException.class, () -> h.io.submit(() -> { write.backend(h.backend, recreated(h)); return true; }).get(3, TimeUnit.SECONDS)); + verify(h.statement).executeUpdate(); verify(h.statement).close(); verify(h.connection).close(); + } + } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.EnumSource(Write.class) + void uninitializedBackendCannotReportAWriteAsSuccessful(Write write) throws Exception { + try (var h = new Fixture()) { + checkedBackend(h); field(h.backend, MySQLGraveStorageBackend.class, "table", null); + assertThrows(IllegalStateException.class, () -> write.backend(h.backend, recreated(h))); + verify(h.connection, never()).prepareStatement(anyString()); + } + } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void failedIncrementalSqlWriteRetainsCapturedStateUntilConfirmedRetry(boolean update) throws Exception { + try (var h = new Fixture()) { + recreation(h); + doNothing().when(h.table).debug(any(Throwable.class)); + doCallRealMethod().when(h.backend).upsertGrave(any(), anyBoolean()); + doCallRealMethod().when(h.backend).updateExistingGrave(any(), anyBoolean()); + var config = recreated(h); + var grave = mock(com.bencodez.gravestonesplus.graves.Grave.class); + when(grave.getGravesConfig()).thenReturn(config); + var owner = Thread.currentThread(); + when(config.isDestroyed()).thenReturn(update); + when(h.plugin.activeGraveSnapshot()).thenAnswer(call -> { + assertSame(owner, Thread.currentThread(), "recovery must not rescan gameplay state on the disk worker"); + return update ? List.of() : List.of(grave); + }); + when(h.plugin.brokenGraveSnapshot()).thenReturn(update ? List.of(grave) : List.of()); + when(h.statement.executeUpdate()).thenThrow(new SQLException("fixture unavailable")); + + if (update) h.storage.updateExistingGrave(config, true); + else h.storage.upsertGrave(config, false); + h.io.barrier().get(3, TimeUnit.SECONDS); + + var retained = GraveStorageManager.class.getDeclaredField("retained"); retained.setAccessible(true); + synchronized (h.storage) { assertNotNull(retained.get(h.storage), "failed SQL must retain the captured grave"); } + verify(h.statement, atLeastOnce()).close(); verify(h.connection, atLeastOnce()).close(); + doReturn(1).when(h.statement).executeUpdate(); + h.io.barrier().get(3, TimeUnit.SECONDS); + synchronized (h.storage) { assertNull(retained.get(h.storage), "confirmed same-identity recovery can retire the snapshot"); } + verify(h.statement, atLeast(2)).executeUpdate(); + var replay = org.mockito.ArgumentCaptor.forClass(GravesConfig.class); + verify(h.table, atLeastOnce()).upsertGraveChecked(replay.capture(), eq(update)); + for (var captured : replay.getAllValues()) { + assertEquals(config.getUuid(), captured.getUuid()); assertEquals(42L, captured.getTime()); + assertEquals(update, captured.isDestroyed(), "recovery must not revive a broken transition"); + } + } + } + + @Test void missingDatabaseFallsBackToFlatFileForAllLaterOperations() throws Exception { + var active = mock(GravesConfig.class); var broken = mock(GravesConfig.class); + try (var io = new FeatureIO(); var files = mockConstruction(GraveLocations.class, (file, context) -> { + when(file.loadGraves()).thenReturn(List.of(active)); + when(file.loadBrokenGraves()).thenReturn(List.of(broken)); + })) { + var plugin = mock(GraveStonesPlus.class); + var config = mock(Config.class); + var data = new YamlConfiguration(); data.set("DataStorage", "MYSQL"); + when(plugin.getFeatureIO()).thenReturn(io); when(plugin.getConfigFile()).thenReturn(config); + when(config.getData()).thenReturn(data); when(plugin.getDataFolder()).thenReturn(new java.io.File("target/storage-init-regression")); + when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger()); + var manager = new GraveStorageManager(plugin); + manager.init(); + assertFalse(manager.isMySQL()); + assertEquals(List.of(active), manager.loadGraves()); + assertEquals(List.of(broken), manager.loadBrokenGraves()); + verify(files.constructed().getFirst()).reloadData(); + assertTrue(manager.loadMySQLForRestoration().isCompletedExceptionally()); + } + } + @Test void shutdownDrainsAcceptedSqlBeforeClosingItsOwnPoolOnce() throws Exception { + try (var h = new Fixture()) { + when(h.plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger()); + doCallRealMethod().when(h.backend).close(); + doCallRealMethod().when(h.backend).upsertGrave(any(), anyBoolean()); + var mysqlField = AbstractSqlTable.class.getDeclaredField("mysql"); mysqlField.setAccessible(true); + var mysql = (MySQL) mysqlField.get(h.table); + var writes = new java.util.concurrent.atomic.AtomicInteger(); + doAnswer(call -> { + assertTrue(Thread.currentThread().getName().startsWith("gsp-feature-io")); + assertEquals(1, writes.get(), "accepted SQL must finish before pool retirement"); + return null; + }).when(mysql).disconnect(); + h.io.submit(() -> { writes.incrementAndGet(); return null; }); + h.storage.close(); h.storage.close(); + h.io.barrier().get(3, TimeUnit.SECONDS); + verify(mysql, times(1)).disconnect(); + h.backend.close(); + verify(mysql, times(1)).disconnect(); + assertThrows(IllegalStateException.class, () -> h.backend.upsertGrave(recreated(h), false)); + } + } + @Test void partialInitializationCloseIsSafeAndDoesNotCloseAnUnrelatedPool() throws Exception { + try (var h = new Fixture()) { + doCallRealMethod().when(h.backend).close(); + var mysqlField = AbstractSqlTable.class.getDeclaredField("mysql"); mysqlField.setAccessible(true); + var mysql = (MySQL) mysqlField.get(h.table); + field(h.backend, MySQLGraveStorageBackend.class, "table", null); + h.backend.close(); h.backend.close(); + verify(mysql, never()).disconnect(); + } + } + + private static void field(Object target, Class type, String name, Object value) throws Exception { + var field = type.getDeclaredField(name); field.setAccessible(true); field.set(target, value); + } + static final class Fixture implements AutoCloseable { + final FeatureIO io = new FeatureIO(); + final GraveStonesPlus plugin = mock(GraveStonesPlus.class); + final BukkitScheduler scheduler = mock(BukkitScheduler.class); + final Queue ownerTasks = new ConcurrentLinkedQueue<>(); + final GravesMySQLTable table = mock(GravesMySQLTable.class, CALLS_REAL_METHODS); + final Connection connection = mock(Connection.class); + final PreparedStatement statement = mock(PreparedStatement.class); + final ResultSet rows = mock(ResultSet.class); + final MySQLGraveStorageBackend backend = mock(MySQLGraveStorageBackend.class); + final GraveStorageManager storage; + final UUID player = UUID.randomUUID(), world = UUID.randomUUID(); + Fixture() throws Exception { + when(plugin.getFeatureIO()).thenReturn(io); when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(plugin.isEnabled()).thenReturn(true); + doAnswer(call -> { ownerTasks.add(call.getArgument(1)); return null; }) + .when(scheduler).runTask(eq(plugin), any(Runnable.class)); + MySQL mysql = mock(MySQL.class); ConnectionManager connections = mock(ConnectionManager.class); + when(mysql.getConnectionManager()).thenReturn(connections); + when(connections.getConnection()).thenAnswer(call -> { + assertTrue(Thread.currentThread().getName().startsWith("gsp-feature-io")); return connection; + }); + when(connection.prepareStatement(anyString())).thenReturn(statement); + when(statement.executeQuery()).thenReturn(rows); + field(table, AbstractSqlTable.class, "mysql", mysql); + field(table, AbstractSqlTable.class, "tableName", "graves"); + field(table, AbstractSqlTable.class, "dbType", DbType.MYSQL); + doCallRealMethod().when(table).loadDeferred(anyBoolean()); + field(backend, MySQLGraveStorageBackend.class, "table", table); + doCallRealMethod().when(backend).loadDeferred(anyBoolean()); + storage = new GraveStorageManager(plugin); + field(storage, GraveStorageManager.class, "backend", backend); + when(rows.next()).thenReturn(true, false, false); + when(rows.getString("player_uuid")).thenReturn(player.toString()); + when(rows.getString("world_uuid")).thenReturn(world.toString()); + when(rows.getString("items_encoded")).thenReturn("encoded"); + when(rows.getInt("x")).thenReturn(11); when(rows.getInt("y")).thenReturn(64); + when(rows.getInt("z")).thenReturn(-9); when(rows.getLong("time")).thenReturn(42L); + } + public void close() { io.close(); } + } + private static GravesConfig recreated(Fixture h) { + GravesConfig grave = mock(GravesConfig.class); + when(grave.getUuid()).thenReturn(h.player); + when(grave.getWorldId()).thenReturn(h.world); + when(grave.getLocation()).thenReturn(new Location(null, 11, 64, -9)); + when(grave.getItems()).thenReturn(new HashMap<>()); + when(grave.getTime()).thenReturn(42L); + when(grave.getExp()).thenReturn(17); + when(grave.getGraveDisplayType()).thenReturn("PLAYER_HEAD"); + return grave; + } + private static void recreation(Fixture h) throws Exception { + when(h.plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger()); + when(h.plugin.activeGraveSnapshot()).thenReturn(List.of()); + when(h.plugin.brokenGraveSnapshot()).thenReturn(List.of()); + field(h.storage, GraveStorageManager.class, "type", DataStorageType.MYSQL); + doCallRealMethod().when(h.table).saveRecreatedGrave(any()); + doCallRealMethod().when(h.backend).saveRecreatedGrave(any()); + when(h.connection.getAutoCommit()).thenReturn(true); + } + @Test void displayRepairUpdatesOnlyExactActiveMetadataAndRequiresAcknowledgement() throws Exception { + for (int affected : List.of(0, 1, 2)) try (var h = new Fixture()) { + recreation(h); + doCallRealMethod().when(h.table).saveDisplayIdentity(any()); + doCallRealMethod().when(h.backend).saveDisplayIdentity(any()); + when(h.statement.executeUpdate()).thenReturn(affected); + var grave = recreated(h); UUID display = UUID.randomUUID(); when(grave.getDisplayUUID()).thenReturn(display); + var saved = h.storage.saveDisplayIdentity(grave); + if (affected == 1) assertTrue(saved.get(3, TimeUnit.SECONDS)); + else assertThrows(ExecutionException.class, () -> saved.get(3, TimeUnit.SECONDS)); + verify(h.statement).setString(1, display.toString()); verify(h.statement).setString(3, h.player.toString()); + verify(h.statement).setString(4, h.world.toString()); verify(h.statement).setLong(8, 42L); + var sql = org.mockito.ArgumentCaptor.forClass(String.class); verify(h.connection).prepareStatement(sql.capture()); + assertTrue(sql.getValue().replace("`", "").replace("\"", "").contains("broken=0 AND destroyed=0"), sql.getValue()); + String assignments = sql.getValue().split(" WHERE ")[0]; + assertFalse(assignments.contains("items_encoded")); assertFalse(assignments.contains("`exp`")); + assertFalse(assignments.contains("`broken`")); assertFalse(assignments.contains("`destroyed`")); + verify(h.statement).close(); verify(h.connection).close(); + } + } + @Test void recreationWaitsForTheExactExistingRowWriteAndPreservesContents() throws Exception { + try (var h = new Fixture()) { + recreation(h); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + when(h.statement.executeUpdate()).thenAnswer(call -> { + entered.countDown(); assertTrue(release.await(3, TimeUnit.SECONDS)); return 1; + }); + var result = h.storage.saveRecreatedGrave(recreated(h)); + try { + assertTrue(entered.await(3, TimeUnit.SECONDS)); assertFalse(result.isDone()); + } finally { release.countDown(); } + assertTrue(result.get(3, TimeUnit.SECONDS)); + verify(h.statement).setInt(1, 0); verify(h.statement).setInt(2, 0); + verify(h.statement).setString(7, h.player.toString()); + verify(h.statement).setString(8, h.world.toString()); + verify(h.statement).setLong(12, 42L); + var sql = org.mockito.ArgumentCaptor.forClass(String.class); + verify(h.connection).prepareStatement(sql.capture()); + assertFalse(sql.getValue().contains("items_encoded")); + assertFalse(sql.getValue().contains("`exp`")); + verify(h.statement).close(); verify(h.connection).close(); + } + } + @Test void recreationDoesNotAcknowledgeMissingOrMultipleRows() throws Exception { + for (int rows : List.of(0, 2)) try (var h = new Fixture()) { + recreation(h); when(h.statement.executeUpdate()).thenReturn(rows); + assertThrows(ExecutionException.class, () -> h.storage.saveRecreatedGrave(recreated(h)).get(3, TimeUnit.SECONDS)); + verify(h.statement).close(); verify(h.connection).close(); + } + } + @Test void recreationSurfacesSqlFailureAndClosesResources() throws Exception { + try (var h = new Fixture()) { + recreation(h); when(h.statement.executeUpdate()).thenThrow(new SQLException("fixture write failed")); + assertThrows(ExecutionException.class, () -> h.storage.saveRecreatedGrave(recreated(h)).get(3, TimeUnit.SECONDS)); + verify(h.statement).close(); verify(h.connection).close(); + } + } + @Test void recreationCannotAcknowledgeAnUncommittedBorrowedTransaction() throws Exception { + try (var h = new Fixture()) { + recreation(h); when(h.connection.getAutoCommit()).thenReturn(false); + assertThrows(ExecutionException.class, () -> h.storage.saveRecreatedGrave(recreated(h)).get(3, TimeUnit.SECONDS)); + verify(h.statement, never()).executeUpdate(); + verify(h.statement).close(); verify(h.connection).close(); + } + } + + @Test void actualJdbcReadDefersWorldAndItemsUntilServerOwnerThenRetainsIdentity() throws Exception { + try (var h = new Fixture(); var bukkit = mockStatic(Bukkit.class); var inventory = mockStatic(InventorySerializer.class)) { + Thread owner = Thread.currentThread(); World world = mock(World.class); when(world.getUID()).thenReturn(h.world); + bukkit.when(() -> Bukkit.getWorld(h.world)).thenAnswer(call -> { assertSame(owner, Thread.currentThread()); return world; }); + var item = mock(ItemStack.class); HashMap items = new HashMap<>(); items.put(3, item); + inventory.when(() -> InventorySerializer.decode("encoded")).thenAnswer(call -> { assertSame(owner, Thread.currentThread()); return items; }); + var result = h.storage.loadMySQLForRestoration(); + h.io.barrier().get(5, TimeUnit.SECONDS); + assertFalse(result.isDone()); assertEquals(1, h.ownerTasks.size()); + bukkit.verifyNoInteractions(); inventory.verifyNoInteractions(); verify(h.connection, times(2)).close(); + h.ownerTasks.remove().run(); + var records = result.get(5, TimeUnit.SECONDS); assertEquals(1, records.active().size()); assertTrue(records.broken().isEmpty()); + var record = records.active().getFirst(); assertEquals(h.world, record.getWorldId()); assertSame(world, record.getLocation().getWorld()); + assertSame(item, record.getItems().get(3)); assertEquals(42, record.getTime()); assertEquals(11, record.getLocation().getBlockX()); + } + } + @Test void largeSqlResultUsesBoundedOwnerBatchesWithoutLoadingChunks() throws Exception { + try (var h = new Fixture(); var bukkit = mockStatic(Bukkit.class); var inventory = mockStatic(InventorySerializer.class)) { + var calls = new java.util.concurrent.atomic.AtomicInteger(); + when(h.rows.next()).thenAnswer(call -> calls.incrementAndGet() <= 260); + World world = mock(World.class); when(world.getUID()).thenReturn(h.world); + bukkit.when(() -> Bukkit.getWorld(h.world)).thenReturn(world); + inventory.when(() -> InventorySerializer.decode("encoded")).thenReturn(new HashMap<>()); + var result = h.storage.loadMySQLForRestoration(); h.io.barrier().get(5, TimeUnit.SECONDS); + assertEquals(1, h.ownerTasks.size()); + h.ownerTasks.remove().run(); assertFalse(result.isDone()); assertEquals(1, h.ownerTasks.size()); + inventory.verify(() -> InventorySerializer.decode("encoded"), times(128)); + h.ownerTasks.remove().run(); assertFalse(result.isDone()); assertEquals(1, h.ownerTasks.size()); + inventory.verify(() -> InventorySerializer.decode("encoded"), times(256)); + h.ownerTasks.remove().run(); assertEquals(260, result.get(5, TimeUnit.SECONDS).active().size()); + assertTrue(h.ownerTasks.isEmpty()); verify(world, never()).getChunkAt(anyInt(), anyInt()); + } + } + @Test void actualRestorationRetainsLargeSqlInventoryInBoundedOwnerBatches() throws Exception { + try (var h = new Fixture(); var bukkit = mockStatic(Bukkit.class); var inventory = mockStatic(InventorySerializer.class)) { + field(h.storage, GraveStorageManager.class, "type", DataStorageType.MYSQL); + when(h.plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger()); + var next = new java.util.concurrent.atomic.AtomicInteger(); + when(h.rows.next()).thenAnswer(call -> next.incrementAndGet() <= 260); + when(h.rows.getLong("time")).thenAnswer(call -> (long) next.get()); + World world = mock(World.class); when(world.getUID()).thenReturn(h.world); + bukkit.when(() -> Bukkit.getWorld(h.world)).thenReturn(world); + inventory.when(() -> InventorySerializer.decode("encoded")).thenReturn(new HashMap<>()); + var encodes = new java.util.concurrent.atomic.AtomicInteger(); Thread owner = Thread.currentThread(); + inventory.when(() -> InventorySerializer.encode(any())).thenAnswer(call -> { + assertSame(owner, Thread.currentThread(), "snapshot encoded off owner"); encodes.incrementAndGet(); return "prepared"; + }); + var ledger = mock(com.bencodez.gravestonesplus.graves.GraveClaimLedger.class); + when(ledger.restoration(any())).thenReturn(CompletableFuture.completedFuture( + com.bencodez.gravestonesplus.graves.GraveClaimLedger.Restoration.UNAVAILABLE)); + when(h.plugin.getGraveClaimLedger()).thenReturn(ledger); + var restoration = new com.bencodez.gravestonesplus.graves.GraveRestoration(h.plugin, h.storage); + h.storage.beginRestoration(); restoration.retry(); h.io.barrier().get(5, TimeUnit.SECONDS); + for (int i = 0; i < 3; i++) { assertEquals(1, h.ownerTasks.size()); h.ownerTasks.remove().run(); } + assertTrue(restoration.isLoading()); assertEquals(0, encodes.get()); + h.ownerTasks.remove().run(); + assertEquals(128, encodes.get(), "retention bypassed the per-owner-task item budget"); + assertTrue(restoration.isLoading()); verify(ledger, never()).restoration(any()); + h.ownerTasks.remove().run(); assertEquals(256, encodes.get()); assertTrue(restoration.isLoading()); + h.ownerTasks.remove().run(); assertEquals(260, encodes.get()); assertFalse(restoration.isLoading()); + assertTrue(h.ownerTasks.isEmpty()); + var field = GraveStorageManager.class.getDeclaredField("restoringActive"); field.setAccessible(true); + assertEquals(260, ((Map) field.get(h.storage)).size()); + verify(world, never()).getChunkAt(anyInt(), anyInt()); restoration.retire(); + } + } + @Test void incompleteSqlReadFailsRatherThanPublishingAnEmptyOrPartialRestoration() throws Exception { + try (var h = new Fixture()) { + when(h.rows.next()).thenReturn(true).thenThrow(new SQLException("fixture interrupted read")); + var result = h.storage.loadMySQLForRestoration(); + assertThrows(ExecutionException.class, () -> result.get(5, TimeUnit.SECONDS)); + assertTrue(h.ownerTasks.isEmpty()); verify(h.rows).close(); verify(h.statement).close(); verify(h.connection).close(); + } + } + @Test void disableBeforeOwnerMaterializationRetainsBackendAndFailsThePendingResult() throws Exception { + try (var h = new Fixture(); var bukkit = mockStatic(Bukkit.class); var inventory = mockStatic(InventorySerializer.class)) { + var result = h.storage.loadMySQLForRestoration(); h.io.barrier().get(5, TimeUnit.SECONDS); + when(h.plugin.isEnabled()).thenReturn(false); h.ownerTasks.remove().run(); + assertThrows(ExecutionException.class, () -> result.get(5, TimeUnit.SECONDS)); + bukkit.verifyNoInteractions(); inventory.verifyNoInteractions(); + } + } +} diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/PreparedStorageSnapshotTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/PreparedStorageSnapshotTest.java new file mode 100644 index 0000000..a508aa6 --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/PreparedStorageSnapshotTest.java @@ -0,0 +1,122 @@ +package com.bencodez.gravestonesplus.storage; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.util.*; +import java.util.concurrent.*; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.Test; +import com.bencodez.gravestonesplus.GraveStonesPlus; +import com.bencodez.gravestonesplus.graves.FeatureIO; +import com.bencodez.gravestonesplus.graves.Grave; + +class PreparedStorageSnapshotTest { + private static GravesConfig source() { + var items = new HashMap(); + items.put(0, new ItemStack(Material.STONE, 2)); + var grave = new GravesConfig(UUID.randomUUID(), "owner", new Location(null, 1, 64, 2), + items, 3, "death", 4, false, 0, null, null, "PLAYER_HEAD"); + grave.setWorldId(UUID.randomUUID()); return grave; + } + @Test void preparedSnapshotUsesCapturedEncodingOnAnotherThread() throws Exception { + var source = source(); + try (var encoding = mockStatic(InventorySerializer.class)) { + encoding.when(() -> InventorySerializer.encode(any())).thenReturn("captured"); + var prepared = GraveStorageManager.snapshot(source); + source.getItems().get(0).setAmount(7); + assertEquals(2, prepared.getItems().get(0).getAmount()); + try (var io = new FeatureIO()) { + assertEquals("captured", io.submit(() -> prepared.serialize().get("ItemsEncoded")).get(2, TimeUnit.SECONDS)); + assertEquals("captured", io.submit(prepared::encodedItemsForStorage).get(2, TimeUnit.SECONDS)); + } + encoding.verify(() -> InventorySerializer.encode(any()), times(1)); + } + } + @Test void failedWorkerNeverRescansLiveItemsForRecovery() throws Exception { + var plugin = mock(GraveStonesPlus.class); + try (var io = new FeatureIO(); var encoding = mockStatic(InventorySerializer.class)) { + encoding.when(() -> InventorySerializer.encode(any())).thenReturn("captured"); + when(plugin.getFeatureIO()).thenReturn(io); + when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger()); + var grave = mock(Grave.class); when(grave.getGravesConfig()).thenReturn(source()); + var owner = Thread.currentThread(); + when(plugin.activeGraveSnapshot()).thenAnswer(call -> { + assertSame(owner, Thread.currentThread(), "live grave rescan on I/O worker"); return List.of(grave); + }); + when(plugin.brokenGraveSnapshot()).thenReturn(List.of()); + var backend = mock(GraveStorageBackend.class); + doThrow(new IllegalStateException("fixture storage failure")).when(backend).saveSnapshot(any(), any()); + var manager = new GraveStorageManager(plugin); + var field = GraveStorageManager.class.getDeclaredField("backend"); field.setAccessible(true); field.set(manager, backend); + var result = manager.saveSnapshotFromObjects(List.of(grave), List.of()); + assertThrows(ExecutionException.class, () -> result.get(2, TimeUnit.SECONDS)); + var retained = GraveStorageManager.class.getDeclaredField("retained"); retained.setAccessible(true); + assertNotNull(retained.get(manager)); + verify(plugin, times(1)).activeGraveSnapshot(); + } + } + @Test void olderFailedWriteRetainsTheNewerRemovalSnapshot() throws Exception { + var plugin = mock(GraveStonesPlus.class); + try (var io = new FeatureIO(); var encoding = mockStatic(InventorySerializer.class)) { + encoding.when(() -> InventorySerializer.encode(any())).thenReturn("captured"); + when(plugin.getFeatureIO()).thenReturn(io); + when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger()); + var grave = mock(Grave.class); when(grave.getGravesConfig()).thenReturn(source()); + var current = new java.util.concurrent.atomic.AtomicReference>(List.of(grave)); + when(plugin.activeGraveSnapshot()).thenAnswer(call -> current.get()); + when(plugin.brokenGraveSnapshot()).thenReturn(List.of()); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + var writes = new java.util.concurrent.atomic.AtomicInteger(); + var backend = mock(GraveStorageBackend.class); + doAnswer(call -> { + if (writes.getAndIncrement() == 0) { + entered.countDown(); assertTrue(release.await(2, TimeUnit.SECONDS)); + throw new IllegalStateException("older write failed"); + } + List records = call.getArgument(0); + assertTrue(records.isEmpty(), "recovery resurrected the removed grave"); return null; + }).when(backend).saveSnapshot(any(), any()); + var manager = new GraveStorageManager(plugin); + var field = GraveStorageManager.class.getDeclaredField("backend"); field.setAccessible(true); field.set(manager, backend); + var old = manager.saveSnapshotFromObjects(List.of(grave), List.of()); + try { + assertTrue(entered.await(2, TimeUnit.SECONDS)); current.set(List.of()); + var removal = manager.saveSnapshotFromObjects(List.of(), List.of()); + release.countDown(); + assertThrows(ExecutionException.class, () -> old.get(2, TimeUnit.SECONDS)); + assertTrue(removal.get(2, TimeUnit.SECONDS)); + assertTrue(writes.get() >= 3, "retained latest state was not flushed"); + } finally { release.countDown(); } + } + } + @Test void failureOfBothBinaryAndLegacyEncodingRemainsRetainedThroughWorkerRecovery() throws Exception { + var plugin = mock(GraveStonesPlus.class); + try (var io = new FeatureIO(); var encoding = mockStatic(InventorySerializer.class)) { + encoding.when(() -> InventorySerializer.encode(any())).thenThrow(new java.io.IOException("fixture")); + when(plugin.getFeatureIO()).thenReturn(io); + when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger()); + var unsupported = source(); + var item = mock(ItemStack.class); when(item.clone()).thenReturn(item); + when(item.serialize()).thenReturn(Map.of("unsupported", new Object())); + unsupported.getItems().put(0, item); + var grave = mock(Grave.class); when(grave.getGravesConfig()).thenReturn(unsupported); + when(plugin.activeGraveSnapshot()).thenReturn(List.of(grave)); + when(plugin.brokenGraveSnapshot()).thenReturn(List.of()); + var backend = mock(GraveStorageBackend.class); + doAnswer(call -> { + List records = call.getArgument(0); + for (GravesConfig record : records) record.serialize(); return null; + }).when(backend).saveSnapshot(any(), any()); + var manager = new GraveStorageManager(plugin); + var field = GraveStorageManager.class.getDeclaredField("backend"); field.setAccessible(true); field.set(manager, backend); + var result = manager.saveSnapshotFromObjects(List.of(grave), List.of()); + var failure = assertThrows(ExecutionException.class, () -> result.get(2, TimeUnit.SECONDS)); + assertInstanceOf(java.io.UncheckedIOException.class, failure.getCause()); + var retained = GraveStorageManager.class.getDeclaredField("retained"); retained.setAccessible(true); + assertNotNull(retained.get(manager)); + encoding.verify(() -> InventorySerializer.encode(any()), times(2)); + } + } +} diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/StartupGraveDocumentTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/StartupGraveDocumentTest.java new file mode 100644 index 0000000..283023f --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/StartupGraveDocumentTest.java @@ -0,0 +1,63 @@ +package com.bencodez.gravestonesplus.storage; + +import static org.junit.jupiter.api.Assertions.*; +import java.util.*; +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +class StartupGraveDocumentTest { + private Map record(long time, String items) { + Map record = new LinkedHashMap<>(); + record.put("==", GravesConfig.class.getName()); + record.put("UUID", "4c8293df-0d49-4d9b-895d-ea405b98b929"); + record.put("Time", time); + record.put("World", "67eaf30b-0bcf-4392-9352-0e703a3d9d56"); + record.put("X", -12); record.put("Y", 70); record.put("Z", 9); + record.put("ItemsEncoded", items); record.put("EXP", 17); + return record; + } + + private Object yamlValue(Object value) { return new Yaml().load(new Yaml().dump(value)); } + + @Test void preservesExistingAndAddsNewCaptureWithoutDeserializingBukkit() { + Map original = record(1, "old contents"); + original.put("Items", Map.of("0", Map.of("==", "org.bukkit.inventory.ItemStack", "type", "DIAMOND"))); + String document = new Yaml().dump(Map.of("graves", List.of(original), "brokengraves", List.of(), + "owner-extension", "retain this")); + String merged = StartupGraveDocument.merge(document, List.of(record(2, "new contents")), List.of()); + Map loaded = new Yaml().load(merged); + assertEquals("retain this", loaded.get("owner-extension")); + assertEquals(yamlValue(List.of(original, record(2, "new contents"))), loaded.get("graves")); + } + + @Test void movesSameIdentityBetweenSectionsWithoutDuplicatingContents() { + String document = new Yaml().dump(Map.of("graves", List.of(record(1, "old")), + "brokengraves", List.of(record(2, "other")))); + Map merged = new Yaml().load(StartupGraveDocument.merge(document, + List.of(), List.of(record(1, "updated")))); + assertEquals(List.of(), merged.get("graves")); + assertEquals(yamlValue(List.of(record(2, "other"), record(1, "updated"))), merged.get("brokengraves")); + } + + @Test void repeatedMergeDoesNotDuplicateCapturedGrave() { + String once = StartupGraveDocument.merge("", List.of(record(1, "items")), List.of()); + Map twice = new Yaml().load(StartupGraveDocument.merge(once, List.of(record(1, "items")), List.of())); + assertEquals(yamlValue(List.of(record(1, "items"))), twice.get("graves")); + } + + @Test void rejectsAmbiguousPreparedStateAndInvalidDocument() { + assertThrows(IllegalArgumentException.class, () -> StartupGraveDocument.merge("", + List.of(record(1, "items")), List.of(record(1, "items")))); + assertThrows(IllegalArgumentException.class, () -> StartupGraveDocument.merge("graves: invalid", + List.of(record(1, "items")), List.of())); + assertThrows(IllegalArgumentException.class, () -> StartupGraveDocument.merge("graves: []\ngraves: []", + List.of(record(1, "items")), List.of())); + } + + @Test void preservesUnknownTaggedLegacyRecordsWithoutConstructingTheirClasses() { + String merged = StartupGraveDocument.merge("graves:\n- !unavailable.LegacyRecord {payload: retained}\n", + List.of(record(1, "items")), List.of()); + assertTrue(merged.contains("!unavailable.LegacyRecord")); + assertTrue(merged.contains("retained")); + } +} diff --git a/README.md b/README.md index 084a8f5..4f866a1 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,246 @@ # GraveStonesPlus Get builds here: http://bencodez.com/job/GraveStonesPlus/ + +## Per-grave helpers + +Owners use a selected grave from `/gravestonesplus graves`, or look at their marker: + +- `/gravestonesplus trust track ` allows navigation only. +- `/gravestonesplus trust recover ` allows full recovery to the helper's inventory, including the grave's XP. RECOVER does not grant TRACK; grant both when both are wanted. +- `/gravestonesplus trustlist` lists active helper UUIDs, kinds, and deadlines. +- `/gravestonesplus revoketrust ` revokes one kind. + +Durations must be positive and at most 90 days (for example `30m`, `2h`, `7d`). Online names avoid profile or network lookup in commands; offline players can be addressed by UUID. Grants use owner UUID, death time, world UUID and block coordinates, rather than names or marker entity IDs. New grants become active only after an atomic file publication is acknowledged. Revocation denies access immediately in this process, and reports success only after persistence. A failed revocation is **not** durable across restart; repair storage and retry before restarting. + +Permissions: `GraveStonesPlus.Trust` and `GraveStonesPlus.Trust.Revoke` default to false; `GraveStonesPlus.Track` defaults to true. Existing Graves, Compass, ClaimGrave and staff menu permissions still apply. Owners and helpers are rechecked at the final action, including after claim/removal event callbacks. Existing `Breaking.Other` enablement, permission and protection-delay rules continue to authorize non-helper recovery independently; TRACK cannot bypass them. Staff menu authorities (`AllGraves`, `AllGraves.Player`, `AllBrokenGraves`, `AllBrokenGraves.Player`) retain their administrative actions. + +## Navigation and recovery + +`/gravestonesplus graves` opens the existing grave inventory with owned and TRACK-authorized graves. Left click selects a grave compass with `GraveStonesPlus.Compass`; right click requires `GraveStonesPlus.ClaimGrave` and recovers only within `GraveClaimDistance`; shift left views contents with recovery authority; shift right removes only as owner/staff. `/gravestonesplus track` selects the accessible marker being looked at and reports its navigation details without issuing an item; it requires `GraveStonesPlus.Track`. `/gravestonesplus compass` uses the selected target, falling back to the owner's latest grave. `/gravestonesplus claimgrave` also finds nearby RECOVER-authorized graves. + +Every compass delivery, including menu clicks and `GiveCompassOnRespawn`, rechecks `GraveStonesPlus.Compass`, online state and current grave access on the player's entity owner immediately before inventory insertion. Tracking selection remains available when Compass is denied. Compass issuance does not queue an unchecked later inventory delivery. A full inventory follows AdvancedCore's existing `DropOnFullInv` policy: enabled drops the compass at the player; disabled asks the player to make room and request it again instead of retaining a pending compass. Captured grave contents continue to use the existing recovery overflow handling. + +Navigation shows the target world, horizontal distance and above/below/level position. Cross-world targets are labeled rather than given a misleading distance. Selection and compass validation use stored identities and locations and never load chunks. Revocation, elapsed grants, expiry, claim and removal invalidate access; online compasses are refreshed by bounded periodic entity tasks and on login. Invalid grave compasses become ordinary inactive compasses. Legacy owner compasses are recognized by their death time and exact location. Staff menus retain teleport actions. + +Recovery retains the existing configured `PercentageDrops` chance per stored stack; 100 skips RNG. Selected stacks restore every available original inventory/armor/offhand slot before displaced stacks are handled, then use free inventory space; overflow is handed to AdvancedCore's durable full-inventory delivery queue so `DropOnFullInv=false` never loses items (legacy direct delivery still drops when configured). The full stored XP goes to that recipient. Competing owner/helper/direct/menu attempts share one grave reservation and cannot pay the same record again. After all selected items and XP are acknowledged, a completed identity is archived before its active reservation is retired, preserving replay protection while allowing the bounded ledger to accept new graves. Overflow failure or recipient retirement keeps the reservation and exact recovery evidence for administrator reconciliation; successful recovery is not advertised while delivery is unacknowledged. Claim and removal cancellation occur before payout. Configured grave-limit ground payouts use the same reservation and chance rules, drop selected items plus the full XP at the grave on its region owner, and remove only after payout; cancelled or failed reservations preserve the grave. Unavailable chunks retain a durable `QUEUED_LIMIT` intent, retried through the existing restoration timer/chunk index after chunk load or restart without loading chunks. Each pending limit removal is skipped when selecting the next oldest grave, so repeated deaths do not repeatedly select the same unavailable grave. Queued requests cannot be claimed; only a persisted transition to `RESERVED` permits drops. If the chunk unloads before any drop, the request returns to queued state. Cancellation or ordinary manual/expiry removal retires an unstarted request; partial payouts remain reserved for administrator reconciliation. A recipient whose Folia scheduler retires before any payout has its reservation cancelled; expiration overlapping that reservation is retried when the claim releases. + +## Reminders + +Optional `Reminders.ProtectionMinutes` and `Reminders.ExpiryMinutes` default to 0, so warnings are opt-in; set either to 60 for a warning within the last hour. Zero disables the respective warning. Protection reminders require `Breaking.Other.Enabled` and a positive actual protection delay. Expiry reminders use the grave's scheduled expiration deadline, including legacy numeric minutes and duration strings; disabled and zero lifetimes have no expiry warning. Invalid or overflowing replacement lifetimes retain the current expiry deadline and timer; at initial creation there is no previous timer to retain. Reload reschedules the existing grave timers, so changed deadlines have distinct reminder identities. + +Login sends one useful summary; eligible protection/expiry warnings are combined in that message. Periodic checks batch at most 16 online players per five-second pass, 256 owner graves per player pass (rotating through larger collections), and 64 new deadline warnings per summary. Delivered warnings are suppressed by recipient/grave/kind/deadline across restart. Players are checked and messaged on their entity owner; offline players are never marked. Publication capacity is reserved before sending, and suppression is persisted only after send acceptance. If persistence fails after delivery, local repeats are suppressed and storage warnings are logged; a restart can repeat that message because chat delivery and disk persistence are not atomic. + +Grave removal clears the selected navigation identity immediately; compass inventory cleanup is handled by the same bounded periodic or login refresh, so bursts of removals cannot fan out one owner task per grave. + +## Held-item inspection + +`/gravestonesplus inspectitem` requires `GraveStonesPlus.InspectItem` (default false). It clones the held item and calls the same ordered evaluator as death capture: Ignore NBT, Slimefun soulbound, player-placeholder-expanded exact lore, Keep NBT, Grave NBT, Curse of Vanishing, then the default grave rule. It lists available NBT matches and metadata; NBT stages stay inactive when NBTAPI is absent. It describes later recovery/drop chance without sampling RNG. It does not mutate inventory, item metadata, drops, experience or a death event. + +NBT routing lists and the configured keep-lore text publish as one immutable reload generation; death evaluation and each inspection retain a single generation across all rule stages. The routing prediction applies when grave creation proceeds. World, permissions, keep-inventory, combat, marker placement and other death hooks can affect that eligibility. Lore matching is case-insensitive but whole-line exact, including colors. The existing NBT matcher does not support list-index paths. Metadata is displayed as context; only the actual configured matcher/integrations determine routing. + +### Integration API compatibility + +`Grave.removeGrave(reason)` keeps its cancellable state transition synchronous when called on the Bukkit main thread; marker cleanup remains scheduled. Calls from other threads and all Folia calls hand off to the grave region. Persistence continues through the existing asynchronous storage owners. + +`Grave.dropItemsOnGround(Player)` retains its item-only contract: it schedules a snapshot of chance-selected items at the grave location, leaves the grave and stored experience unchanged, and does not reserve or complete a recovery. The player argument remains optional and is not a payout recipient. Integrations using this method still own their removal/claim lifecycle; repeated explicit calls can drop items repeatedly, as before. Normal death/admin grave-limit handling uses the separate `dropAndRemoveForLimit(Player)` operation, whose durable reservation prevents duplicate removal payouts. Neither helper permissions nor player commands expose the item-only API. + +## Storage and operational limits + +Existing `Graves.yml`, MySQL schemas, configuration aliases and grave records remain unchanged. Selected feature state uses `selected-access.yml`, `selected-claims.yml`, `selected-claim-archive/` and `grave-reminders.yml`, with a strict acknowledged cache and atomic replacement and file sync, plus directory sync where the filesystem provider supports it. On the native Windows provider, Java cannot open a directory for this sync: the file is still forced before atomic replacement, but directory durability across an OS crash or power loss cannot be promised. Only that specific unsupported directory-open failure is tolerated; other publication failures leave the store unavailable until restart reconciliation. Keep these files alongside the existing data during upgrades or restores; deleting the claim ledger or its archive can re-enable old payouts. The initial uncommitted reminder `sent` list remains readable. The earlier experimental `trusted-graves.yml` was never a released format; new identity-bound grants use `selected-access.yml`. + +Selected and gameplay-triggered backend writes use one private bounded serial I/O owner, with at most 129 admitted operations and a 129-slot physical queue (including a spare slot while the prior worker frame completes its drain). Limits are 4,096 active grants, 8,192 unresolved claim records and a 512-entry recent-completion lookup cache, 8,192 retained reminder identities, 4 MiB per feature file, and 256 KiB of captured evidence per claim. Expired grants and elapsed reminders reclaim space; replay guards are not silently evicted. Capacity, unreadable state, publication uncertainty, executor rejection and incomplete shutdown fail conservatively. A failed feature-file publication requires storage repair/restart reconciliation before further operations in that store. Rejected backend admissions retain one coalesced latest active/broken snapshot and flush it on the disk owner after older queued writes. Shutdown seals new admissions, honors existing unused publication tickets, and waits for accepted writes and that recovery pass to drain; slow storage can therefore delay shutdown. Interrupted waits restore interruption only after the owner has terminated. Overlapping initialization on the same plugin instance is refused. + +Bukkit inventory/world/XP effects cannot be transactionally committed with files. Before payout, a durable `RESERVED` record stores recipient, exact slot contents and XP. A crash or partial payout leaves that record held for administrator reconciliation and **never automatically replays it**; `PAID` records also block administrative recreation for another payout. This prevents duplicate payment but is not an automatic exactly-once crash recovery guarantee. Ordinary startup/backend loading and the pre-existing death-capture failure policy remain separate from this claim journal; server-process failure during an unflushed death capture still needs backend/lifecycle work. These limits must be considered before publication. + +## Verification + +Use JDK 21+ and `mvn -B -f GraveStonesPlus/pom.xml test`, then `mvn -B -f GraveStonesPlus/pom.xml package`. Tests exercise production stores, claim/action and reminder entry points, delayed/failed persistence, restart suppression, access distinctions, deadlines, legacy grave shapes, overflow and the shared item evaluator. Bukkit/Folia ownership is checked through scheduler seams; this checkout does not provide a running Paper/Folia/MySQL integration server. + +Completed claims are archived as one compact durable tombstone per grave identity before their active reservation is retired. The archive deliberately grows with recovered-grave history to preserve replay protection; no full directory scan or gameplay-thread disk lookup is performed. An evicted cache entry is checked asynchronously before another reservation or broken-grave recreation can succeed. Archived IDs remain blocked across restart or backend write failure, including administrative recreation of the same grave. Failed archive publication retains the existing reservation and cannot re-enable payout. Existing PAID journal entries are migrated on the I/O owner at initialization. Back up both the active journal and archive together; there is no automatic unsafe purge. + +Startup restores at most 128 records per timer or chunk-load pass and admits one archive lookup/region callback at a time. Saturated archive admission leaves the remaining chunk identities indexed and releases the batch for a later retry. Per-identity decisions distinguish available, reserved, paid and unknown; admission failure, corrupt archive or unavailable storage leaves the backend record withheld and retries later. No restored marker metadata, navigation, hologram or expiration timer is exposed before its archive allows admission. Unresolved backend records are preserved when a partial startup or shutdown writes current lists. Bulk snapshots capture their unresolved-record union before queue admission, so later region admission cannot erase a restored record from an older queued write; a following removal snapshot remains authoritative. After archive admission, unloaded active graves remain visible to authorized owners/helpers and preserve compass identity; only native marker initialization waits for a chunk-load event or bounded retry. Pending archive decisions preserve existing compass targets without granting access. Startup and glow refresh do not force-load chunks. + +`selected-marker-cleanup.yml` retains at most 8,192 removal identities with a per-chunk index, independently of broken-history eviction. Cleanup capacity remains reserved through durable publication acknowledgment, including a queued commit. Stored marker types use the same case/whitespace normalization and configuration fallback as live markers before reserving capacity; invalid fallback settings reject cleanup admission without retaining a disk reservation. Expiration retries transient cleanup admission pressure every five seconds with one current task; changed/disabled deadlines, removal and shutdown invalidate stale retries. Cleanup capacity is reserved before a removal or payout proceeds; unavailable/full cleanup storage rejects admission rather than dropping that work. New markers carry exact grave identity in tile, display/hologram and chunk PDC (including barriers). Cleanup runs only on the loaded region and removes only that identity, preserving a successor at the same coordinates. Untagged legacy markers without surviving exact metadata remain for conservative administrator reconciliation; no type/name guess deletes a replacement. Back up this file with grave storage. Confirmed intents retry after chunk load/restart without a world/chunk scan or forced load. Historical staff recreation and teleport use their historical permissions at the final actor-owned state mutation; region checks do not read the actor's permissions from another Folia region. + +Removal and payout cleanup also unregister the grave's own top, middle, bottom and glow hologram wrappers from the bundled AdvancedCore handler. Replacement, glow retirement, nearby restoration cleanup and late entity creation use the same release path. Invalid glow refresh waits for the cancellable removal transition before releasing wrappers or entities. Registry release uses `remove(wrapper, false)` rather than AdvancedCore's global-sync `kill()` handoff; physical removal of those known stands is dispatched to each stand's entity owner. Durable identity cleanup remains responsible for unavailable/unloaded entities and persisted markers, without deleting another grave's registered wrappers or unrelated entities. + +Startup reconciles surviving exact-identity hologram/display entities in the loaded grave region before creating replacements. Untagged legacy holograms cannot be safely distinguished from unrelated stands and require administrator reconciliation; they are never deleted by proximity or name alone. + +Restoration preserves original world UUIDs when a saved world is unavailable; those records remain serializable alongside new graves and are retried when the world returns. An unavailable archive identity withholds that grave while other identities continue restoring. Invalid loaded markers still fire the cancellable `GraveRemoveEvent(INVALID)` before a durable transition, allowing integrations to cancel or repair the marker. Recreation also waits for durable removal of both helper grant kinds before changing a historical grave back to live, including after a failed or rejected removal write and restart. Queued grave-limit payouts restore their original lifetime before publication and recheck expiry after disk reservation and event callbacks; elapsed graves follow normal cancellable expiration without dropping queued contents. Invalid startup removal durably revokes both helper grant kinds before historical publication; failed revocation leaves that record withheld, and the final region callback rechecks the marker before removal. Startup cleanup waits for unresolved restoration identities. For tile markers, a stale positional chunk tag alone never authorizes deletion of an untagged replacement chest/head. + +Nearby menu and command recovery recheck the claim permission, world and configured distance after durable reservation, before payout. Navigation menu rendering and click handling run on the viewer’s entity owner and recheck current live/access state after dispatch; distance uses one player-location snapshot, so a cross-world teleport cannot mix separate world and position reads. The nearby command skips inactive or ledger-blocked graves when selecting a target, so a retained uncertain payout cannot hide another eligible nearby grave. Historical records whose world is unavailable remain pending alongside active records until that world returns; one failed cleanup admission does not withhold following healthy records. + +Upgrade restoration automatically reconciles surviving legacy hologram stands carrying GraveStonesPlus’s old integer tag at the exact native text/glow positions of a currently owned marker. Reconciliation runs on each entity owner and rechecks marker ownership; foreign, untagged, shifted, and successor-identity entities remain untouched. No manual cleanup is needed for native surviving legacy lines. + +Persisted markers remain protected at their exact saved world/block position while archive or chunk restoration is pending, including native breaking, explosions, fluids, buckets, pistons, physics and block drops. Pending protection does not expose a claimable grave or grant access before archive admission. Destroyed, removed, moved, resolved and retired pending records cease providing that protection; material and successor identity checks keep unrelated blocks outside it. Delayed physics repairs recheck the original identity. Restoration's delayed entity cleanup preserves the visible display adopted by the live grave, including legacy displays and a replacement live instance, while removing stale owned holograms/displays. + +Reload acknowledgements wait for asynchronous configuration reads and owner-side application. Java integrations that need completion should use the additive `reloadAsync()` future; the legacy `void reload()` wrapper starts the same work without waiting. A second reload is rejected while one is pending, and shutdown retires pending acknowledgements. A failed reload is reported as failure, not success; file reloads are not a cross-file transaction, so inspect the configuration before retrying after a partial read failure. Completion confirms owner-side application and admission of region-local timer checks, not completion of every timer callback. + +Historical grave-menu actions capture click intent and perform permission/online/plugin checks on the acting player’s owner before recreation, item viewing or teleport dispatch. Delayed actions retain their existing final authorization checks. + +Backend shutdown is a terminal request on the existing ordered feature worker. Accepted writes and retained recovery run before the backend closes once; an incomplete startup index is never published as an empty recovery snapshot. A permanently failing recovery logs failure and retains its in-memory snapshot, then closes without retrying against the closed backend. This is not durable preservation of an otherwise unwritable new snapshot across process termination; repair storage using the recorded grave/recovery data, and do not treat the diagnostic as a successful flush. + +Historical recreation checks the administrator on the player’s owner and schedules marker and hologram world access separately on the saved grave location. Remote administrators do not need to stand in the grave’s Folia region. Location-only Java requests retain their location-scheduled path. + +### Historical recreation persistence + +Historical recreation moves the grave from the broken list to the active list only after the existing guarded +archive/access checks. Flat-file recovery publishes both sections of `Graves.yml` together through a checked +atomic replacement; the future-returning recreation API reports success after that write. Storage rejection or +failure is reported rather than acknowledged as saved. Recoverable in-memory state is retained for the existing +bounded storage recovery path, but unwritable storage cannot guarantee survival across process termination. +The legacy `Grave.createBrokenGrave()` still creates a marker and now uses the same list/persistence transition; +void recreation APIs remain asynchronous. MySQL recreation acknowledges one update to the exact existing row, +including restored destroyed/marker metadata; it leaves items and XP untouched. Missing rows, SQL errors and +uncommitted connections fail the acknowledgement. Existing YAML record keys and configuration are unchanged. + +Pre-upgrade compasses with only the legacy time/lodestone tags retain their target while their exact saved grave +is awaiting restoration admission. Once that record is retired without admission, stale compass cleanup resumes. + +Storage snapshots prepare encoded items before I/O admission. The worker reuses that captured payload for YAML/SQL and recovery; it never re-encodes the snapshot's Bukkit items. A preparation failure fails the write acknowledgement and retains repair state in memory, rather than publishing an empty inventory. Repair/retry must prepare a new snapshot on the owner; failed storage is not durable across a crash. Later captured removal state supersedes an older failed write. Existing persisted fields and schemas are unchanged. + +The MySQL backend uses checked incremental writes for creation, state updates, broken flags and deletion. SQL or resource-cleanup failure propagates to the storage worker so the latest captured recovery snapshot remains available for a later confirmed retry. Writes require auto-commit; the plugin does not commit a caller-owned transaction. Zero affected rows remain valid for idempotent writes and the existing update-only/missing-row behavior. The direct public table methods retain their legacy logging-only SQL failure behavior; plugin-managed writes use the checked path. No database schema or manual migration is added. Recovery retained only in memory cannot survive process termination while storage remains unwritable. + +The grave MySQL table owns a separate connection pool from AdvancedCore user storage. Shutdown drains accepted grave writes and retained recovery work before closing that pool once. Partial initialization without a grave table is safe to close; repeated close requests cannot retire a different pool. Constructor-time setup failures also close the opened pool and workers while preserving the setup error, including when pool cleanup itself fails. Older compatible SimpleAPI builds may call table logging before the plugin field is assigned; those bootstrap callbacks use a field-independent logger. A closed/uninitialized pool is rejected before table setup. These guards use existing SimpleAPI APIs and do not require a coordinated dependency upgrade. The corrected SimpleAPI SQL lifecycle additionally aborts at the original connection failure before issuing constructor callbacks or queries. + +For the default flat-file backend, startup on the server owner indexes the already-loaded record positions before protection listeners are registered; blocked claim/archive work does not delay that protection. Startup from other contexts hands preparation to the server owner, and protection begins when that handoff completes. In-memory record preparation and retries run on the server owner. MySQL continues to fetch records asynchronously: exact positions cannot be protected before the initial backend read completes. Ordinary blocks are never globally cancelled during that interval. + +Java helper-access overloads taking owner UUID and death timestamp resolve a unique +location-bound grave identity from the active/historical index and canonical stored +grants. Missing or ambiguous references return no access and do not write or revoke +a grant. Prefer the full `GraveIdentity` overload when two graves share the same death +timestamp. The whole-grave compatibility revocation future reports unresolved identity +or persistence failure instead of silently acknowledging it. Shortened experimental +owner/time grant rows cannot establish a location and are never promoted into access; +existing command-created canonical rows continue to work across restart. + +A restored grave first published while its chunk is unloaded is withheld again if +chunk admission observes a successor identity at that location. Its exact stored +record and helper grants remain retained, but navigation and recovery are unavailable; +the successor marker and data are untouched. Unloading the chunk again does not +clear that conflict. Only a loaded region proving the conflict gone can re-admit +the original record, using its original absolute expiry rather than extending time. + +Historical restoration publishes broken records and prepares item snapshots on +an appropriate Bukkit owner after background archive persistence completes. +Remote staff recreation checks the actor's current authority on their entity +owner, then commits marker-related state on the grave's location owner. Graves +expired during startup admission are never republished as active. Navigation +refresh pauses whenever helper or claim state is unavailable, including uncertain +writes after a successful initial load, preserving compass metadata for recovery. + +On Folia, grave chunk preparation loads on the location owner and retains a +six-second location-scheduler task. Folia owns that task's temporary region ticket; +the plugin does not read or change the world's persistent force-loaded flag from +a region thread. This keeps marker creation on its correct owner without leaving +a persistent force-load behind. Bukkit/Paper retain the existing force-load and +release behavior. This change does not make every legacy death/respawn path or +cross-region recovery scenario verified on Folia. + +Reload reads bounded file contents on the I/O worker, then prepares general +settings, grave YAML and NBT rules on the Bukkit owner. All three publish as one +configuration generation only after preparation succeeds; a failed preparation +keeps the previous objects and rules. Reload uses the captured file contents rather +than rereading files during publication. The read limit is 16 MiB per file, in +addition to Bukkit's YAML parser limits. Legacy key migration still runs during +preparation but writes no file from the owner callback; those migrated keys persist +when the configuration is subsequently saved. Initial startup retains its existing +migration behavior. Backend selection and arbitrary AdvancedCore runtime side +effects are not a transactional storage switch; changing database settings still +requires a restart. + +Delayed death notifications and kept-item respawn restoration use the moving +player's entity scheduler; marker creation stays on the grave's location scheduler. +Kept items are detached snapshots associated with one player and death generation; +successive deaths retain separate entitlements even when their items and slots are +identical. Pending snapshots are bounded to eight per player. If that bound is +full, the new death follows the existing no-grave failure policy and its drops and +experience remain untouched. Inventory insertion leftovers are dropped at the +owner's world location. +Repeated or stale respawn callbacks cannot consume a newer generation, and rejected, +retired, dead or offline player callbacks leave the pending snapshot untouched for a later +living respawn. A subsequent death fences older scheduled callbacks even when that +death is ineligible for a grave or captures no kept items; an undelivered snapshot is +retained rather than discarded solely to invalidate the callback. The existing five-tick compass delay is unchanged. These kept-item snapshots +remain session-local; this change does not add restart recovery for legacy death +capture or make its persistence atomic with the death event. + +Death replacement preflight resolves the location, evaluates/copies items and +reserves kept-item capacity before enforcing the configured grave limit. A missing +location or full kept-item backlog leaves the existing oldest grave untouched and +preserves native death drops and XP. A kept-item reservation cannot be paid by a +respawn callback before those drops are consumed. If limit preflight throws, only +that death's reservation is withdrawn; older and successor entitlements remain. +Successful replacement still uses the existing oldest-grave removal policy. +Death and administrative test-grave creation obtain a cancellable location task +before removing an old grave. The death handler also admits that task before +consuming drops. Rejected scheduling preserves the native drops and XP and +withdraws only the new kept-item reservation. A callback on a faster Folia region +waits until its caller accepts creation, then creates once and cancels its task. +Failed limit preflight also cancels that callback in both entry points. The existing two-second +creation delay is preserved; the task repeats only while capture is being prepared. +Unpublished creation reservations and restoring active records count toward the +owner's limit; replacement selection uses their original creation order. These +reservations remain process-local rather than durable creation intents: failure +or shutdown after death capture remains a separate legacy lifecycle limitation. + + +Flat-file startup protects exact persisted marker positions from the already-decoded +Graves.yml records before deferred Folia item preparation runs. This early index +neither exposes claims nor resolves worlds, clones items, or scans blocks. Chunk +admission waits for owner preparation and retained storage snapshots. MySQL still +requires its initial asynchronous record read before exact positions are known. + +Startup display repairs remain in the restoration index until the replacement +display/interaction identity is acknowledged by storage. Failed writes reuse the +existing repaired entities and retry through the bounded restoration pass. MySQL +repair updates only metadata for the exact active row, preserving contents and XP. +Login summaries defer while reminder/access/claim stores or grave record loading +are unavailable, and retry on the existing periodic player-owner pass. Disconnect +and listener shutdown discard the pending login request. + +Detached flat-file snapshots preserve the existing `Items` YAML fallback when +binary inventory encoding fails. Item and nested metadata serialization happen +on the owning thread; disk workers receive plain captured values. Mutating live +items later does not change that snapshot. Cyclic, unsupported, or more than +64-level nested fallback data fails explicitly and retains the failed snapshot +for reconciliation instead of writing an empty inventory. MySQL continues to +require binary inventory encoding and reports that failure rather than storing +YAML in its existing item column. No grave-file migration is required. + +After an acknowledged claim or removal, a rejected marker-region handoff commits +the already-reserved cleanup journal intent. Loaded-chunk cleanup can retry +without repeating item/XP payout or accessing the marker from the player's +thread. This retains the existing conservative behavior for untagged markers +and markers replaced by another grave. + +Startup record retention clones and prepares inventories in batches of at most +128 active/broken records per owner callback, following the existing bounded +MySQL decoding phase. Partial retention keeps restoration readiness and normal +startup snapshot publication closed. Retirement or scheduler rejection leaves +already-captured records retained and unread records authoritative in the +existing YAML/MySQL backend; shutdown does not publish an incomplete replacement +of the full store. This is an item-work budget, not a fixed elapsed-time guarantee +for large/custom inventories. + +Claim admission checks the actor on their entity owner. Folia claim/removal +callbacks and grave inventory capture run on the grave region, then authorization +is rechecked on the actor before durable reservation and again before payout. +Callback handlers must schedule mutable access to a distant claimant through +their entity scheduler. Ordinary Bukkit callers already on the primary thread +retain synchronous cancellable callbacks; durable storage and payout completion +remain asynchronous. No public claim method waits for another region. + +Grave menus, location messages and deadline reminders label an unavailable world with its stored UUID and retain the coordinates. They do not resolve or load that world to render the message. Navigation and recovery still require an available world; a reminder never grants access or changes the deadline. + +When `DropItemsOnGraveRemoval` is enabled, creation at `GraveLimit` first reserves +capacity for the old grave's asynchronous payout record. If that admission is +unavailable, the new death keeps its normal drops and experience; an admin test +creation is declined. No storage wait runs on the gameplay thread. One replacement +may temporarily coexist with the old grave until its payout/removal completes. +Further replacements for that owner are declined while creation is pending or +failed/cancelled eviction leaves the owner above the limit. A durably queued old +grave remains retiring under the existing logical-limit policy. Both +records are retained on uncertain storage failure; resolve that failure rather than +retrying a payout whose outcome is unknown. Other owners remain independent.