From 44633bb27646b9818b16367059ca92994ca55c1e Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:29:28 -0600 Subject: [PATCH 01/46] Add trusted grave helpers, navigation, reminders and rule inspection --- AGENTS.md | 4 + GraveStonesPlus/pom.xml | 23 +- .../gravestonesplus/GraveStonesPlus.java | 241 ++-- .../breaking/PlayerBreakManager.java | 185 ++- .../commands/CommandLoader.java | 159 ++- .../gravestonesplus/config/Config.java | 13 +- .../graves/DurableFeatureState.java | 138 +++ .../gravestonesplus/graves/FeatureIO.java | 87 ++ .../gravestonesplus/graves/Grave.java | 1022 +++++++++++------ .../graves/GraveClaimLedger.java | 172 +++ .../graves/GraveDeadlines.java | 25 + .../gravestonesplus/graves/GraveIdentity.java | 15 + .../graves/GraveMarkerCleanup.java | 170 +++ .../graves/GraveNavigation.java | 78 ++ .../gravestonesplus/graves/GravePayout.java | 42 + .../graves/GraveReminderKey.java | 12 + .../graves/GraveReminderStore.java | 55 + .../graves/GraveRestoration.java | 220 ++++ .../graves/TrustedGraveAccess.java | 27 + .../graves/TrustedGraveAccessStore.java | 86 ++ .../GraveBlockProtectionListener.java | 20 +- .../listeners/GraveClaimListener.java | 43 +- .../listeners/GraveCleanupListener.java | 13 + .../listeners/GraveReminderListener.java | 83 ++ .../listeners/ItemRuleEvaluator.java | 90 ++ .../listeners/PlayerDeathListener.java | 126 +- .../listeners/PlayerDeathNBTHandle.java | 8 + .../gravestonesplus/nbt/NBTConfigManager.java | 34 +- .../storage/GraveStorageManager.java | 134 ++- .../gravestonesplus/storage/GravesConfig.java | 20 +- .../storage/GravesMySQLTable.java | 23 +- GraveStonesPlus/src/main/resources/Config.yml | 14 +- GraveStonesPlus/src/main/resources/plugin.yml | 13 +- .../graves/ClaimArchiveTest.java | 88 ++ .../CompassPermissionRegressionTest.java | 152 +++ .../graves/DurableFeaturePublicationTest.java | 99 ++ .../graves/FeaturePersistenceTest.java | 261 +++++ .../graves/GraveActionsTest.java | 594 ++++++++++ .../graves/GraveReminderDeliveryTest.java | 82 ++ .../graves/GraveReviewRegressionTest.java | 180 +++ .../graves/HologramCleanupRegressionTest.java | 163 +++ .../MarkerRestorationRegressionTest.java | 825 +++++++++++++ .../graves/RecoveryCompletionTest.java | 266 +++++ .../graves/RemovalRecoveryRegressionTest.java | 211 ++++ .../RespawnSchedulingRegressionTest.java | 142 +++ .../graves/SelectedCommandDispatchTest.java | 344 ++++++ .../graves/TrustedGraveAccessTest.java | 39 + .../listeners/ItemRuleEvaluatorTest.java | 102 ++ .../nbt/NBTConfigManagerTest.java | 62 + README.md | 65 ++ 50 files changed, 6313 insertions(+), 757 deletions(-) create mode 100644 GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/DurableFeatureState.java create mode 100644 GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/FeatureIO.java create mode 100644 GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveClaimLedger.java create mode 100644 GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveDeadlines.java create mode 100644 GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveIdentity.java create mode 100644 GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveMarkerCleanup.java create mode 100644 GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveNavigation.java create mode 100644 GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GravePayout.java create mode 100644 GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveReminderKey.java create mode 100644 GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveReminderStore.java create mode 100644 GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java create mode 100644 GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/TrustedGraveAccess.java create mode 100644 GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/TrustedGraveAccessStore.java create mode 100644 GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/GraveCleanupListener.java create mode 100644 GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/GraveReminderListener.java create mode 100644 GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/ItemRuleEvaluator.java create mode 100644 GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/ClaimArchiveTest.java create mode 100644 GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/CompassPermissionRegressionTest.java create mode 100644 GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/DurableFeaturePublicationTest.java create mode 100644 GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/FeaturePersistenceTest.java create mode 100644 GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java create mode 100644 GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveReminderDeliveryTest.java create mode 100644 GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveReviewRegressionTest.java create mode 100644 GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/HologramCleanupRegressionTest.java create mode 100644 GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java create mode 100644 GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RecoveryCompletionTest.java create mode 100644 GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RemovalRecoveryRegressionTest.java create mode 100644 GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RespawnSchedulingRegressionTest.java create mode 100644 GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/SelectedCommandDispatchTest.java create mode 100644 GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/TrustedGraveAccessTest.java create mode 100644 GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/listeners/ItemRuleEvaluatorTest.java create mode 100644 GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/nbt/NBTConfigManagerTest.java 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..c9325a1 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/GraveStonesPlus.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/GraveStonesPlus.java @@ -26,8 +26,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; @@ -86,6 +90,22 @@ public class GraveStonesPlus extends AdvancedCorePlugin { @Getter private PlayerBreakManager playerBreakManager; + @Getter + private TrustedGraveAccessStore trustedGraveAccessStore; + @Getter private com.bencodez.gravestonesplus.graves.FeatureIO featureIO; + @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); } + public void addGrave(Grave grave) { if (grave == null || grave.getGravesConfig() == null) { return; @@ -121,12 +141,16 @@ public void removeGrave(Grave grave) { } public void recreateBrokenGrave(Grave grave) { + recreateBrokenGrave(grave, null); + } + + public java.util.concurrent.CompletableFuture recreateBrokenGrave(Grave grave, Player actor) { if (grave == null || grave.getGravesConfig() == null) { - return; + return java.util.concurrent.CompletableFuture.completedFuture(false); } - grave.getGravesConfig().setDestroyed(false); - + return grave.recreateAsync(actor, actor != null).thenApply(restored -> { + if (!restored) return false; if (!containsByKey(graves, grave)) { graves.add(grave); } @@ -138,16 +162,18 @@ public void recreateBrokenGrave(Grave grave) { storageManager.saveBrokenGravesFromObjects(brokenGraves); storageManager.saveGravesFromObjects(graves); } + return true; + }); } 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 +184,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 +204,25 @@ 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(); + 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 +231,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 +242,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 +258,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 +326,10 @@ public void onPreLoad() { @Override public void onUnLoad() { + 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 +344,8 @@ public void onUnLoad() { storageManager.close(); } - for (Grave grave : graves) { + if (featureIO != null) featureIO.close(); + for (Grave grave : activeGraveSnapshot()) { grave.removeHologram(); grave.removeTimer(); } @@ -403,13 +354,19 @@ public void onUnLoad() { } @Override - public void reload() { - configFile.reloadData(); - gravesConfig.reloadData(); - nbtConfigManager.reloadConfig(); - updateAdvancedCoreHook(); - reloadAdvancedCore(false); - } + public void reload() { + if (playerBreakManager != null) playerBreakManager.invalidateAuthorization(); + featureIO.submit(() -> { + configFile.reloadData(); gravesConfig.reloadData(); nbtConfigManager.reloadConfig(); return true; + }).whenComplete((loaded, failure) -> { + if (failure != null) { getLogger().severe("Grave configuration reload failed; retaining current lifecycle state."); return; } + getBukkitScheduler().runTask(this, () -> { + updateAdvancedCoreHook(); reloadAdvancedCore(false); + for (Grave grave : activeGraveSnapshot()) getBukkitScheduler().runTask(this, + () -> grave.checkTimeLimit(getConfigFile().getGraveTimeLimit()), grave.getGravesConfig().getLocation()); + }); + }); + } private void updateAdvancedCoreHook() { getJavascriptEngine().put("GraveStonesPlus", this); @@ -441,7 +398,7 @@ public void createSection(String key) { public int numberOfGraves(UUID uuid) { int num = 0; - for (Grave grave : getGraves()) { + for (Grave grave : activeGraveSnapshot()) { if (grave.getGravesConfig().getUuid().equals(uuid)) { num++; } @@ -451,7 +408,8 @@ public int numberOfGraves(UUID uuid) { public Grave getOldestGrave(UUID uuid) { Grave oldest = null; - for (Grave grave : getGraves()) { + for (Grave grave : activeGraveSnapshot()) { + if (grave.isLimitPayoutPending()) continue; if (grave.getGravesConfig().getUuid().equals(uuid)) { if (oldest == null) { oldest = grave; @@ -466,7 +424,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 +432,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 +451,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 +471,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..da5ae20 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,15 @@ 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.TrustedGraveAccess; import com.bencodez.gravestonesplus.storage.GravesConfig; import net.md_5.bungee.api.chat.TextComponent; @@ -61,6 +67,17 @@ 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") { @@ -131,13 +148,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 +324,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 +337,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 +357,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 +417,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, @@ -422,9 +546,8 @@ public void createTestGrave(UUID uuid, String name, Location deathLocation, 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); + oldest.dropItemsOnGround(null); + } else oldest.removeGrave(GraveRemoveReason.LIMIT_REACHED); } } 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..24ef952 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/config/Config.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/config/Config.java @@ -179,6 +179,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 +308,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/graves/DurableFeatureState.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/DurableFeatureState.java new file mode 100644 index 0000000..27465fe --- /dev/null +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/DurableFeatureState.java @@ -0,0 +1,138 @@ +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"); + 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..cd14fd9 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)); } /** @@ -248,6 +257,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 +329,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 +388,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. * @@ -425,6 +493,19 @@ public String getGraveMessage() { + loc.getBlockZ() + ") 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) { return gravesConfig.getPlayerName().equalsIgnoreCase(player); } @@ -434,48 +515,87 @@ 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) { + plugin.getBukkitScheduler().runTask(plugin, () -> removeOwned(reason), gravesConfig.getLocation()); + } + private void removeOwned(GraveRemoveReason reason) { + if (removeState(reason, () -> true)) cleanupMarker(); + } + /** Player actions recheck permission and online state on the entity owner. */ + public void removeByPlayer(Player player, GraveRemoveReason reason) { + if (player == null) return; + plugin.getBukkitScheduler().runTask(plugin, () -> { + if (removeState(reason, () -> player.isOnline() && plugin.isLiveGrave(this) + && (isOwner(player) || canAdminister(player)))) { + plugin.getBukkitScheduler().runTask(plugin, this::cleanupMarker, gravesConfig.getLocation()); + player.sendMessage(MessageAPI.colorize("&cGrave removed")); + } + }, 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) { + return reason != GraveRemoveReason.EXPIRED || (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}. @@ -501,7 +621,9 @@ private void schedule(ParsedDuration duration) { final long durationMillis = duration.getMillis(); long deathTime = gravesConfig.getTime(); - long expireTime = deathTime + durationMillis; + long expireTime = GraveDeadlines.deadline(deathTime, durationMillis); + expiryDeadline = expireTime; + if (expireTime <= 0) return; long delay = expireTime - System.currentTimeMillis(); if (delay <= 0) { @@ -513,8 +635,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 +650,7 @@ public void run() { */ public void checkTimeLimit(int timeLimit) { if (timeLimit <= 0) { + removeTimer(); expiryDeadline = 0; return; } @@ -542,7 +665,8 @@ public void checkTimeLimit(int timeLimit) { * as {@code 30m}, {@code 2h}, or {@code 7d} */ public void checkTimeLimit(String timeLimitRaw) { - if (timeLimitRaw == null || timeLimitRaw.trim().isEmpty()) { + removeTimer(); expiryDeadline = 0; + if (timeLimitRaw == null || timeLimitRaw.trim().isEmpty()) { return; } @@ -571,25 +695,33 @@ 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; + long expireTime = GraveDeadlines.deadline(deathTime, durationMillis); + expiryDeadline = expireTime; - if (expireTime < System.currentTimeMillis()) { + if (expireTime > 0 && expireTime <= System.currentTimeMillis()) { removeGrave(GraveRemoveReason.EXPIRED); } else { schedule(timeLimit); } } - public BInventoryButton getGUIItem() { + 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) { Location loc = gravesConfig.getLocation(); BInventoryButton b = new BInventoryButton( new ItemBuilder(Material.PLAYER_HEAD).setSkullOwner(Bukkit.getOfflinePlayer(gravesConfig.getUuid())) @@ -597,29 +729,54 @@ public BInventoryButton getGUIItem() { .addLoreLine("&3" + "Location: " + loc.getWorld().getName() + " (" + loc.getBlockX() + "," + loc.getBlockY() + "," + loc.getBlockZ() + ")") .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; @@ -640,30 +797,19 @@ 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")); + if (!canAdministerHistorical(clickEvent.getPlayer())) return; + if (clickEvent.getClick().equals(ClickType.SHIFT_RIGHT)) { + Player actor = clickEvent.getPlayer(); + plugin.recreateBrokenGrave(grave, actor).whenComplete((restored, failure) -> + plugin.getBukkitScheduler().runTask(plugin, () -> { + if (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 (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); + grave.teleportHistorical(clickEvent.getWhoClicked()); } } @@ -679,26 +825,24 @@ public String getAllGraveMessage() { + 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; - } + /** 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 +885,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 +931,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 +1021,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 +1033,179 @@ 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; } + public void createBrokenGrave() { recreateAsync(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; } + try { + plugin.getBukkitScheduler().getFoliaLib().getImpl().runAtLocation(gravesConfig.getLocation(), task -> { + synchronized (this) { + if (!recreationStateAllowed(identity)) { result.complete(false); return; } + } + Runnable commit = () -> { + synchronized (this) { + if (!recreationStateAllowed(identity) || actor != null && (!actor.isOnline() || !canAdministerHistorical(actor))) { 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); + } + }; + if (actor == null) commit.run(); + else try { plugin.getBukkitScheduler().getFoliaLib().getImpl().runAtEntityWithFallback(actor, ignored -> commit.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); } + }).whenComplete((unused, error) -> { if (error != null) result.completeExceptionally(error); }); + } 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); + } + + /** Limit-removal payout shares the claim ledger, so queued drops cannot race recovery. */ + public void dropItemsOnGround(Player recipient) { + String identity = GraveIdentity.of(gravesConfig); + synchronized (this) { + if (remove || claimInProgress || limitPayoutPending || !plugin.isLiveGrave(this) || plugin.getGraveClaimLedger().isBlocked(identity)) return; + 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; } + plugin.getGraveClaimLedger().queueLimit(identity, gravesConfig.getUuid(), evidence).whenComplete((saved, failure) -> { + if (failure == null && Boolean.TRUE.equals(saved)) { + limitPayoutPending = true; + plugin.getGraveRestoration().retainLimit(this); + } + releaseClaim(); + if (limitPayoutPending) plugin.getGraveRestoration().retry(); + }); + } + 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 +1288,190 @@ 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; + plugin.getBukkitScheduler().runTask(plugin, () -> beginClaim(player, null, false), player); + } + /** Menu/command recovery retains its permission and nearby requirement through payout. */ + public void claimNearby(final Player player) { + if (player != null) plugin.getBukkitScheduler().runTask(plugin, () -> beginClaim(player, null, true), player); + } + 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) plugin.getBukkitScheduler().runTask(plugin, () -> beginClaim(player, proof, false), player); + } + 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 beginClaim(Player player, com.bencodez.gravestonesplus.breaking.PlayerBreakManager.CompletedBreak proof, boolean nearby) { + final String identity = GraveIdentity.of(gravesConfig); + synchronized (this) { + if (claimInProgress || !player.isOnline() || !plugin.isLiveGrave(this) + || (!claimAllowed(player, proof) || (nearby && !nearbyClaimAllowed(player)))) return; + if (plugin.getGraveClaimLedger().isBlocked(identity)) { + player.sendMessage("\u00a7cThis grave has a pending or completed recovery record; it cannot be paid again."); 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, isOwner(player)); + 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() || !player.isOnline() + || !plugin.isEnabled() || !plugin.isLiveGrave(this) || (!claimAllowed(player, proof) || (nearby && !nearbyClaimAllowed(player)))) { + 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(); + player.sendMessage("\u00a7cGrave recovery evidence could not be captured. No payout was made."); return; + } + plugin.getGraveClaimLedger().reserve(identity, player.getUniqueId(), 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(); + plugin.getBukkitScheduler().runTask(plugin, this::cleanupMarker, gravesConfig.getLocation()); + 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(); } + } + 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 +1521,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..7d12dfb --- /dev/null +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveClaimLedger.java @@ -0,0 +1,172 @@ +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 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 synchronized CompletableFuture queueLimit(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); + return state.update(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/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..86c0feb --- /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"); } + private 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..76ba276 --- /dev/null +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveNavigation.java @@ -0,0 +1,78 @@ +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, not the grave region. + for (Player player : Bukkit.getOnlinePlayers()) plugin.getBukkitScheduler().runTask(plugin, () -> refresh(player), player); + } + private boolean loading() { + return plugin.getGraveRestoration() != null && plugin.getGraveRestoration().isLoading() + || !plugin.getTrustedGraveAccessStore().loaded().isDone() || !plugin.getGraveClaimLedger().loaded().isDone(); + } + private boolean pending(String identity) { + return plugin.getGraveRestoration() != null && plugin.getGraveRestoration().hasPending(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; + Grave grave = identity == null ? legacyTarget(player, meta, legacy) : resolve(identity); + if (grave == null && pending(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; + } +} 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..a20eea3 --- /dev/null +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveReminderStore.java @@ -0,0 +1,55 @@ +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(); } + /** 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..b1704c4 --- /dev/null +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java @@ -0,0 +1,220 @@ +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 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 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; } + /** 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); + pending.put(identity, record); admitted.put(identity, grave); published.add(identity); + index(identity, record); + } + public void start() { + storage.beginRestoration(); + // 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) plugin.getFeatureIO().submit(() -> { + List active = storage.loadGraves(), broken = storage.loadBrokenGraves(); + storage.retainRestoration(active, broken); + synchronized (this) { + int invalid = 0; + 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); + } + recordsLoaded = true; + } + return true; + }).whenComplete((loaded, error) -> { + if (error != null) { running.set(false); plugin.getLogger().warning("Stored graves remain withheld until backend restoration can retry."); } + else cycle(); + }); + else cycle(); + } + 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 = 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) loc.setWorld(org.bukkit.Bukkit.getWorld(record.getWorldId())); + // 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; } + 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); process(batch); + }); + 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; + Grave grave; + synchronized (this) { + grave = admitted.get(key); + if (grave == null) { + grave = new Grave(plugin, record); admitted.put(key, grave); + } + } + if (grave.isRemove() || record.isDestroyed()) { resolved(key, record); return; } + boolean loaded = loc.getWorld().isChunkLoaded(loc.getBlockX() >> 4, loc.getBlockZ() >> 4); + 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()); + plugin.restoreActiveRecord(grave); published.add(key); + } + } + if (!loaded) return; + grave.loadBlockMeta(loc.getBlock()); grave.removeHologramsAround(); grave.checkBlockDisplayAndFixIfMissing(); grave.createHologram(); + resolved(key, record); + }).whenComplete((unused, failure) -> process(batch)); + } catch (RuntimeException rejected) { running.set(false); } + }); + } + 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); + // 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 || !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; + } + return null; + } + 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 || !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 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/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..8839064 --- /dev/null +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/TrustedGraveAccessStore.java @@ -0,0 +1,86 @@ +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 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); + } + public TrustedGraveAccessStore(DurableFeatureState state, LongSupplier clock) { this.state = state; this.clock = clock; } + public CompletableFuture loaded() { return state.loaded(); } + 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; + }); + } + // Preserve the initially introduced Java signatures for callers using owner/death keys. + public CompletableFuture grantAsync(UUID owner, long time, UUID helper, TrustedGraveAccess.Kind kind, long expires) { return grantAsync(owner + "/" + time, helper, kind, expires); } + public CompletableFuture revokeAsync(UUID owner, long time, UUID helper, TrustedGraveAccess.Kind kind) { return revokeAsync(owner + "/" + time, helper, kind); } + public boolean allows(UUID owner, long time, UUID helper, TrustedGraveAccess.Kind kind) { return allows(owner + "/" + time, helper, kind); } + public List list(UUID owner, long time) { return list(owner + "/" + time); } + public CompletableFuture revokeForGraveAsync(UUID owner, long time) { return revokeForGraveAsync(owner + "/" + time).thenApply(saved -> null); } +} 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..8a1cde7 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,21 @@ 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); } }, 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..0bb8488 --- /dev/null +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/GraveReminderListener.java @@ -0,0 +1,83 @@ +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 Set scheduled = java.util.concurrent.ConcurrentHashMap.newKeySet(); + 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++) schedule(online.get(Math.floorMod(cursor++, online.size())), expected, false); + }, 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) { schedule(event.getPlayer(), generation, true); } + private void schedule(Player player, long expected, boolean login) { + if (scheduled.size() >= 128 || !scheduled.add(player.getUniqueId())) return; + plugin.getBukkitScheduler().runTask(plugin, () -> { + try { if (running && generation == expected && player.isOnline()) checkPlayer(player, login); } + finally { scheduled.remove(player.getUniqueId()); } + }, player); + } + @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) { checkPlayer(player, false); } + private void checkPlayer(Player player, boolean login) { + if (!running || !player.isOnline()) return; + plugin.getGraveNavigation().refresh(player); + long now = System.currentTimeMillis(); + List notices = new ArrayList<>(); + List owned = plugin.getGraves(player); + int start = graveCursors.getOrDefault(player.getUniqueId(), 0); + int examined = Math.min(256, owned.size()); + if (graveCursors.size() < 4096 || graveCursors.containsKey(player.getUniqueId())) graveCursors.put(player.getUniqueId(), start + examined); + 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 (login && 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"); + } + } + 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.getGravesConfig().getLocation().getWorld().getName() + " (" + grave.getGravesConfig().getLocation().getBlockX() + "," + grave.getGravesConfig().getLocation().getBlockY() + "," + grave.getGravesConfig().getLocation().getBlockZ() + ") " + 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..b9d1f01 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/PlayerDeathListener.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/PlayerDeathListener.java @@ -3,6 +3,7 @@ import java.util.HashMap; import java.util.List; import java.util.UUID; +import java.util.concurrent.TimeUnit; import java.util.function.BiConsumer; import org.bukkit.Location; @@ -83,9 +84,10 @@ public void onPlayerDeath(PlayerDeathEvent event) { if (oldest != null) { if (plugin.getConfigFile().isDropItemsOnGraveRemoval()) { oldest.dropItemsOnGround(entity); - } - oldest.removeGrave(GraveRemoveReason.LIMIT_REACHED); - entity.sendMessage(MessageAPI.colorize(plugin.getConfigFile().getFormatGraveLimitBreak())); + } else { + oldest.removeGrave(GraveRemoveReason.LIMIT_REACHED); + entity.sendMessage(MessageAPI.colorize(plugin.getConfigFile().getFormatGraveLimitBreak())); + } } } @@ -127,73 +129,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); + default -> { } + } + }; // Iterate through player inventory slots and process items for (int i = 0; i < 36; i++) { // Main inventory (0-35) @@ -307,12 +250,19 @@ public void onRespawn(PlayerRespawnEvent event) { } 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(); + // SimpleAPI's original delay is five seconds. 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, () -> {}, 100L); + else folia.getImpl().runLater(deliverCompass, 5L, TimeUnit.SECONDS); } } @@ -373,31 +323,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 +368,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..f6f3e05 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/nbt/NBTConfigManager.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/nbt/NBTConfigManager.java @@ -12,9 +12,7 @@ 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; @@ -29,13 +27,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 +62,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/GraveStorageManager.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GraveStorageManager.java index badd9fa..c42e1bf 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,33 @@ 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 boolean recovering; + private boolean restorationLoading; + 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) { + for (GravesConfig grave : active) restoringActive.put(restorationKey(grave), snapshot(grave)); + for (GravesConfig grave : broken) restoringBroken.put(restorationKey(grave), snapshot(grave)); + 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 +51,7 @@ public class GraveStorageManager { */ public GraveStorageManager(GraveStonesPlus plugin) { this.plugin = plugin; + plugin.getFeatureIO().onDrain(this::flushRetained); } /** @@ -73,9 +101,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 +113,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 +145,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 +158,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 +172,91 @@ 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)); } } /** * Close backend. */ - public void close() { + public synchronized void close() { if (backend != null) { - backend.close(); + enqueue(() -> backend.close()); } } -} \ No newline at end of file + 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()); 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 (retained != null || recovering) { retained = recoverySnapshot(); return; } + com.bencodez.gravestonesplus.graves.FeatureIO.Admission admitted = plugin.getFeatureIO().admit(); + if (admitted == null) { retained = recoverySnapshot(); return; } + admitted.submit(() -> { + try { write.run(); return true; } + catch (RuntimeException failure) { + synchronized (this) { if (retained == null) retained = recoverySnapshot(); } + throw failure; + } + }).whenComplete((saved, failure) -> { + if (failure != null) { + synchronized (this) { if (retained == null) retained = recoverySnapshot(); } + plugin.getLogger().severe("Grave backend write failed; latest grave snapshot is retained for storage recovery."); + } + }); + } + 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; + synchronized (this) { + if (restorationLoading) return; + recovery = retained; retained = null; recovering = recovery != null; + if (recovery != null) recovery = new Recovery(preserving(recovery.active(), false), preserving(recovery.broken(), true)); + } + if (recovery == null) 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 { + backend.saveBrokenGraves(recovery.broken()); backend.saveGraves(recovery.active()); + } + } 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()) return; + } + } +} 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..973ab85 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GravesConfig.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GravesConfig.java @@ -31,6 +31,11 @@ public class GravesConfig implements ConfigurationSerializable { @Getter private Location location; + /** Original UUID remains serializable while the world is unavailable. */ + @Getter + @Setter + private UUID worldId; + @Getter private UUID uuid; @@ -45,11 +50,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 +93,7 @@ public GravesConfig(UUID uuid, String playerName, Location loc, HashMap(); this.exp = exp; this.playerName = playerName; @@ -116,7 +122,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()); @@ -152,7 +158,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 +212,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..beb3b77 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GravesMySQLTable.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GravesMySQLTable.java @@ -116,7 +116,7 @@ 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) { + if (grave == null || grave.getUuid() == null || grave.getLocation() == null || grave.getWorldId() == null) { return; } @@ -154,7 +154,7 @@ 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) { + if (grave == null || grave.getUuid() == null || grave.getLocation() == null || grave.getWorldId() == null) { return; } @@ -191,7 +191,7 @@ public void updateExistingGrave(GravesConfig grave, boolean broken) { ps.setString(10, encodeItemsSafe(grave.getItems())); 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()); @@ -211,7 +211,7 @@ 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) { + if (grave == null || grave.getUuid() == null || grave.getLocation() == null || grave.getWorldId() == null) { return; } @@ -228,7 +228,7 @@ public void setBroken(GravesConfig grave, boolean broken) { 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()); @@ -247,7 +247,7 @@ 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) { + if (grave == null || grave.getUuid() == null || grave.getLocation() == null || grave.getWorldId() == null) { return; } @@ -263,7 +263,7 @@ public void deleteGrave(GravesConfig grave) { PreparedStatement ps = conn.prepareStatement(sql)) { 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()); @@ -300,7 +300,7 @@ public List loadAll(boolean broken) { UUID worldUuid = safeUuid(rs.getString("world_uuid")); World world = worldUuid != null ? Bukkit.getWorld(worldUuid) : null; - if (world == null) { + if (worldUuid == null) { continue; } @@ -321,7 +321,7 @@ public List loadAll(boolean broken) { // NEW: may be null for older rows String graveDisplayType = rs.getString("grave_display_type"); - out.add(new GravesConfig(playerUuid, + GravesConfig record = new GravesConfig(playerUuid, playerName != null ? playerName : "", loc, items, @@ -332,7 +332,8 @@ public List loadAll(boolean broken) { destroyedTime, display, interact, - graveDisplayType)); + graveDisplayType); + record.setWorldId(worldUuid); out.add(record); } } @@ -352,7 +353,7 @@ 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()); diff --git a/GraveStonesPlus/src/main/resources/Config.yml b/GraveStonesPlus/src/main/resources/Config.yml index 4915a9f..01b4fff 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 @@ -187,4 +199,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/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..71be840 --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/CompassPermissionRegressionTest.java @@ -0,0 +1,152 @@ +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.TimeUnit; +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), eq(TimeUnit.SECONDS))).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/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..5a3156d --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/FeaturePersistenceTest.java @@ -0,0 +1,261 @@ +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 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..a0bcb14 --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java @@ -0,0 +1,594 @@ +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 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<>(); + 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()); + 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 = 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 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 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()) { + 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()); + } + } + + @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 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); + 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()); + 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 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"); + } + } + + 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 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.dropItemsOnGround(h.owner); h.grave.claim(h.helper); h.grave.claim(h.owner); h.grave.dropItemsOnGround(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.dropItemsOnGround(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.dropItemsOnGround(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.dropItemsOnGround(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.dropItemsOnGround(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.dropItemsOnGround(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.dropItemsOnGround(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.dropItemsOnGround(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.dropItemsOnGround(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); + restarted.retry(); 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.dropItemsOnGround(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/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/HologramCleanupRegressionTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/HologramCleanupRegressionTest.java new file mode 100644 index 0000000..9e6e26b --- /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); h.regionWork.remove().run(); + 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())); + h.regionWork.remove().run(); 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..2eba362 --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java @@ -0,0 +1,825 @@ +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.listeners.GraveBlockProtectionListener; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +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()); + } + } + 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(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; + } + 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 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); + restoration.retry(); 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); + restoration.retry(); 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); + restoration.retry(); 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); + restoration.retry(); 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 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); + restoration.retry(); 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); + restoration.retry(); 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); restoration.retry(); 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()); + 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 historicalOnlyStaffRecreationAndTeleportRecheckActualActorAuthority() throws Exception { + for(String permission:List.of("GraveStonesPlus.AllBrokenGraves","GraveStonesPlus.AllBrokenGraves.Player")) try(var h=new GraveActionsTest.Harness()) { + h.grave.getGravesConfig().setDestroyed(true); when(h.helper.hasPermission(permission)).thenReturn(true); + var recreated=h.grave.recreateAsync(h.helper,false); h.drain(); h.regionWork.remove().run(); h.entityWork.remove().run(); assertTrue(FeaturePersistenceTest.await(recreated)); + h.grave.teleportHistorical(h.helper); h.entityWork.remove().run(); verify(h.platform).teleportAsync(eq(h.helper),any(Location.class)); + h.grave.getGravesConfig().setDestroyed(true); var revoked=h.grave.recreateAsync(h.helper,false); h.drain(); h.regionWork.remove().run(); when(h.helper.hasPermission(permission)).thenReturn(false); h.entityWork.remove().run(); assertFalse(FeaturePersistenceTest.await(revoked)); assertTrue(h.grave.getGravesConfig().isDestroyed()); + 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 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();FeaturePersistenceTest.await(h.io.barrier());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).saveGraves(written.capture());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. + restoration.retry(); 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 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); restoration.retry(); 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()); + 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);restoration.retry(); + Objects.requireNonNull(decisions.poll(5,TimeUnit.SECONDS)).complete(GraveClaimLedger.Restoration.AVAILABLE); + 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)).thenReturn(h.world);restoration.retry(); + Objects.requireNonNull(decisions.poll(5,TimeUnit.SECONDS)).complete(GraveClaimLedger.Restoration.AVAILABLE);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())); + restoration.retry();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);restoration.retry();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);restoration.retry();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); + restoration.retry(); 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);restoration.retry();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()) { + 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);restoration.retry(); + 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); + 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)).thenReturn(h.world);restoration.retry(); + Objects.requireNonNull(decisions.poll(5,TimeUnit.SECONDS)).complete(status);FeaturePersistenceTest.await(h.io.barrier()); + 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/RecoveryCompletionTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RecoveryCompletionTest.java new file mode 100644 index 0000000..99355cf --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RecoveryCompletionTest.java @@ -0,0 +1,266 @@ +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()) { + 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)); + doCallRealMethod().when(h.plugin).reload(); h.plugin.reload(); + } + else if (change == 1) when(h.helper.hasPermission("GraveStonesPlus.BreakOtherGraves")).thenReturn(false); + else when(h.config.getBreakOtherGravesTime()).thenReturn(duration("1m")); + h.drain(); verify(h.helper, never()).giveExp(anyInt()); assertFalse(h.ledger.isBlocked(h.identity())); + assertFalse(h.grave.getGravesConfig().isDestroyed()); + } + } + + @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()); + 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); h.drain(); + when(h.helper.hasPermission("GraveStonesPlus.Admin")).thenReturn(false); h.regionWork.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/RemovalRecoveryRegressionTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RemovalRecoveryRegressionTest.java new file mode 100644 index 0000000..0e90720 --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RemovalRecoveryRegressionTest.java @@ -0,0 +1,211 @@ +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); + restoration.retry(); assertTrue(published.await(2, TimeUnit.SECONDS)); h.drain(); + 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); + restoration.retry(); 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()) { + 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(); + } + } +} 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..a8d300a --- /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(100L)); + 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(100L))).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(100L)); + 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..1e46d2f --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/SelectedCommandDispatchTest.java @@ -0,0 +1,344 @@ +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.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)); + } + } +} 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/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/README.md b/README.md index 084a8f5..37848f9 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,68 @@ # 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, invalid and zero lifetimes have no expiry warning. 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. + +## 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. + +## 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. From 4ab495281f531239cec0fc14cdbd8bba9e23bf00 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:47:30 -0600 Subject: [PATCH 02/46] Decode restored MySQL graves on the server owner --- .../graves/GraveRestoration.java | 27 ++++- .../storage/GraveStorageManager.java | 35 ++++++ .../storage/GravesMySQLTable.java | 103 +++++++--------- .../storage/MySQLGraveStorageBackend.java | 4 + .../MarkerRestorationRegressionTest.java | 17 ++- .../MySQLRestorationOwnershipTest.java | 110 ++++++++++++++++++ 6 files changed, 225 insertions(+), 71 deletions(-) create mode 100644 GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/MySQLRestorationOwnershipTest.java diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java index b1704c4..98fb3fb 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java @@ -44,8 +44,12 @@ public void start() { } public void retry() { if (retired || !plugin.isEnabled() || !running.compareAndSet(false, true)) return; - if (!recordsLoaded) plugin.getFeatureIO().submit(() -> { - List active = storage.loadGraves(), broken = storage.loadBrokenGraves(); + if (!recordsLoaded) { + CompletableFuture read = storage.isMySQL() + ? storage.loadMySQLForRestoration() + : plugin.getFeatureIO().submit(() -> new GraveStorageManager.RestorationRecords(storage.loadGraves(), storage.loadBrokenGraves())); + read.thenApply(records -> { + List active = records.active(), broken = records.broken(); storage.retainRestoration(active, broken); synchronized (this) { int invalid = 0; @@ -61,7 +65,7 @@ public void retry() { if (error != null) { running.set(false); plugin.getLogger().warning("Stored graves remain withheld until backend restoration can retry."); } else cycle(); }); - else cycle(); + } else cycle(); } private static List concat(List active, List broken) { List result = new ArrayList<>(active); result.addAll(broken); return result; @@ -83,7 +87,21 @@ private void process(Iterator batch) { 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) loc.setWorld(org.bukkit.Bukkit.getWorld(record.getWorldId())); + 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; } @@ -158,7 +176,6 @@ private void process(Iterator batch) { resolved(key, record); }).whenComplete((unused, failure) -> process(batch)); } catch (RuntimeException rejected) { running.set(false); } - }); } private void persistResolvedRemoval(GravesConfig record) { if (storage.isMySQL()) storage.updateExistingGrave(record, true); 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 c42e1bf..ca16c7a 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GraveStorageManager.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GraveStorageManager.java @@ -94,6 +94,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). 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 beb3b77..b3dfe41 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GravesMySQLTable.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GravesMySQLTable.java @@ -282,67 +282,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 (worldUuid == 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"); - - GravesConfig record = new GravesConfig(playerUuid, - playerName != null ? playerName : "", - loc, - items, - exp, - deathMessage != null ? deathMessage : "", - time, - destroyed, - destroyedTime, - display, - interact, - graveDisplayType); - record.setWorldId(worldUuid); out.add(record); - } - } - - } 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); 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..bd67d69 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. diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java index 2eba362..34d123a 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java @@ -662,6 +662,9 @@ private void expirationAdmissionRetry(boolean invalidate) throws Exception { @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()); @@ -689,10 +692,11 @@ private void expirationAdmissionRetry(boolean invalidate) throws Exception { }); var restoration=new GraveRestoration(h.plugin,manager);restoration.retry(); 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)).thenReturn(h.world);restoration.retry(); - Objects.requireNonNull(decisions.poll(5,TimeUnit.SECONDS)).complete(GraveClaimLedger.Restoration.AVAILABLE);h.regionWork.remove().run(); + h.bukkit.when(() -> Bukkit.getWorld(missing)).thenAnswer(call -> { assertSame(ownerThread, Thread.currentThread()); return h.world; });restoration.retry(); + 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(); } } @@ -802,6 +806,9 @@ private void expirationAdmissionRetry(boolean invalidate) throws Exception { } @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)); @@ -812,11 +819,11 @@ private void expirationAdmissionRetry(boolean invalidate) throws Exception { 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); + 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)).thenReturn(h.world);restoration.retry(); - Objects.requireNonNull(decisions.poll(5,TimeUnit.SECONDS)).complete(status);FeaturePersistenceTest.await(h.io.barrier()); + h.bukkit.when(()->Bukkit.getWorld(missing)).thenAnswer(call -> { assertSame(ownerThread, Thread.currentThread()); return h.world; });restoration.retry(); + Objects.requireNonNull(decisions.poll(5,TimeUnit.SECONDS)).complete(status); Objects.requireNonNull(globalTasks.poll(5,TimeUnit.SECONDS)).run(); FeaturePersistenceTest.await(h.io.barrier()); 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/storage/MySQLRestorationOwnershipTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/MySQLRestorationOwnershipTest.java new file mode 100644 index 0000000..0fb5eb6 --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/MySQLRestorationOwnershipTest.java @@ -0,0 +1,110 @@ +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 com.bencodez.gravestonesplus.GraveStonesPlus; +import com.bencodez.gravestonesplus.graves.FeatureIO; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; +import com.bencodez.simpleapi.sql.mysql.*; + +class MySQLRestorationOwnershipTest { + 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); + final Connection connection = mock(Connection.class); + final PreparedStatement statement = mock(PreparedStatement.class); + final ResultSet rows = mock(ResultSet.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()); + var backend = mock(MySQLGraveStorageBackend.class); + 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(); } + } + @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 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(); + } + } +} From 58e1cd07a6eff3882b8ae01b51fb54e6aa1082ae Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:57:23 -0600 Subject: [PATCH 03/46] Keep grave recreation commits independent of caller futures --- .../gravestonesplus/GraveStonesPlus.java | 5 +- .../graves/GraveActionsTest.java | 59 +++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/GraveStonesPlus.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/GraveStonesPlus.java index c9325a1..fcfdec3 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/GraveStonesPlus.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/GraveStonesPlus.java @@ -149,7 +149,7 @@ public java.util.concurrent.CompletableFuture recreateBrokenGrave(Grave return java.util.concurrent.CompletableFuture.completedFuture(false); } - return grave.recreateAsync(actor, actor != null).thenApply(restored -> { + var completion = grave.recreateAsync(actor, actor != null).thenApply(restored -> { if (!restored) return false; if (!containsByKey(graves, grave)) { graves.add(grave); @@ -164,6 +164,9 @@ public java.util.concurrent.CompletableFuture recreateBrokenGrave(Grave } return true; }); + // 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) { diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java index a0bcb14..86ba153 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java @@ -429,6 +429,65 @@ void drain() throws Exception { } } + @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); + 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()); + + 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).setBroken(h.grave.getGravesConfig(), false); + verify(storage, never()).saveGravesFromObjects(any()); + verify(storage, never()).saveBrokenGravesFromObjects(any()); + } else { + verify(storage).saveGravesFromObjects(h.active); + verify(storage).saveBrokenGravesFromObjects(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); From 57b687109ae78b2b8ab74f42776c4fd142e9abc0 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:57:58 -0600 Subject: [PATCH 04/46] Cover renamed players and offline helper grant recovery --- .../graves/FeaturePersistenceTest.java | 21 +++++++++++++++++++ .../graves/GraveActionsTest.java | 17 +++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/FeaturePersistenceTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/FeaturePersistenceTest.java index 5a3156d..6ac3c54 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/FeaturePersistenceTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/FeaturePersistenceTest.java @@ -91,6 +91,27 @@ public void save(Map next) throws Exception { 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()) { diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java index 86ba153..606a3d3 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java @@ -110,6 +110,23 @@ void drain() throws Exception { 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); From 748e8d4073a81738bc29355b07756aa9b25912ee Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:33:15 -0600 Subject: [PATCH 05/46] Restore five-tick respawn compass delay --- .../gravestonesplus/listeners/PlayerDeathListener.java | 7 +++---- .../graves/CompassPermissionRegressionTest.java | 3 +-- .../graves/RespawnSchedulingRegressionTest.java | 6 +++--- 3 files changed, 7 insertions(+), 9 deletions(-) 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 b9d1f01..4d628aa 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/PlayerDeathListener.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/PlayerDeathListener.java @@ -3,7 +3,6 @@ import java.util.HashMap; import java.util.List; import java.util.UUID; -import java.util.concurrent.TimeUnit; import java.util.function.BiConsumer; import org.bukkit.Location; @@ -258,11 +257,11 @@ public void onRespawn(PlayerRespawnEvent event) { } }; var folia = plugin.getBukkitScheduler().getFoliaLib(); - // SimpleAPI's original delay is five seconds. Its Spigot/Paper entity-delay + // 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, () -> {}, 100L); - else folia.getImpl().runLater(deliverCompass, 5L, TimeUnit.SECONDS); + if (folia.isFolia()) folia.getImpl().runAtEntityLater(player, deliverCompass, () -> {}, 5L); + else folia.getImpl().runLater(deliverCompass, 5L); } } diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/CompassPermissionRegressionTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/CompassPermissionRegressionTest.java index 71be840..ea32bef 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/CompassPermissionRegressionTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/CompassPermissionRegressionTest.java @@ -5,7 +5,6 @@ import java.util.*; import java.util.concurrent.ConcurrentLinkedQueue; -import java.util.concurrent.TimeUnit; import com.bencodez.advancedcore.AdvancedCoreConfigOptions; import com.bencodez.advancedcore.api.command.CommandHandler; import com.bencodez.gravestonesplus.commands.CommandLoader; @@ -116,7 +115,7 @@ private static MockedConstruction 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), eq(TimeUnit.SECONDS))).thenAnswer(call -> { + 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); diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RespawnSchedulingRegressionTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RespawnSchedulingRegressionTest.java index a8d300a..40c06c7 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RespawnSchedulingRegressionTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RespawnSchedulingRegressionTest.java @@ -70,7 +70,7 @@ void bundledSpigotAndPaperDeliverOnlyAfterTheOriginalFiveSecondOwnerDelay(boolea 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(100L)); + 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()); @@ -100,7 +100,7 @@ void bundledDelayedCallbackRejectsPermissionOfflineDisableAndGraveAccessChanges( 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(100L))).thenAnswer(call -> { + 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(); @@ -110,7 +110,7 @@ void bundledDelayedCallbackRejectsPermissionOfflineDisableAndGraveAccessChanges( 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(100L)); + 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()); } From da47b39e5a8db9cdd3d44b3ea7c49ef5bee563d9 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:12:16 -0600 Subject: [PATCH 06/46] Confirm grave reload before acknowledging command success --- .../gravestonesplus/GraveStonesPlus.java | 86 +++++++++++++-- .../commands/CommandLoader.java | 12 ++- .../graves/RecoveryCompletionTest.java | 102 +++++++++++++++++- .../graves/SelectedCommandDispatchTest.java | 31 ++++++ README.md | 2 + 5 files changed, 219 insertions(+), 14 deletions(-) diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/GraveStonesPlus.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/GraveStonesPlus.java index fcfdec3..9720944 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; @@ -93,6 +94,8 @@ public class GraveStonesPlus extends AdvancedCorePlugin { @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; @@ -216,6 +219,7 @@ private String buildGraveKey(Grave grave) { 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), @@ -329,6 +333,12 @@ 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(); @@ -358,17 +368,73 @@ public void onUnLoad() { @Override public void reload() { - if (playerBreakManager != null) playerBreakManager.invalidateAuthorization(); - featureIO.submit(() -> { - configFile.reloadData(); gravesConfig.reloadData(); nbtConfigManager.reloadConfig(); return true; - }).whenComplete((loaded, failure) -> { - if (failure != null) { getLogger().severe("Grave configuration reload failed; retaining current lifecycle state."); return; } - getBukkitScheduler().runTask(this, () -> { - updateAdvancedCoreHook(); reloadAdvancedCore(false); - for (Grave grave : activeGraveSnapshot()) getBukkitScheduler().runTask(this, - () -> grave.checkTimeLimit(getConfigFile().getGraveTimeLimit()), grave.getGravesConfig().getLocation()); + 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(() -> { + validateReloadFile(configFile.getdFile()); + validateReloadFile(gravesConfig.getdFile()); + java.io.File nbtRules = new java.io.File(getDataFolder(), "nbt_rules.yml"); + if (!nbtRules.exists()) saveResource("nbt_rules.yml", false); + validateReloadFile(nbtRules); + configFile.reloadData(); gravesConfig.reloadData(); nbtConfigManager.reloadConfig(); return true; + }).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; + } + try { + updateAdvancedCoreHook(); reloadAdvancedCore(false); + for (Grave grave : activeGraveSnapshot()) getBukkitScheduler().runTask(this, + () -> grave.checkTimeLimit(getConfigFile().getGraveTimeLimit()), grave.getGravesConfig().getLocation()); + completion.complete(null); + } catch (RuntimeException ownerFailure) { + 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; + } + + /** Bukkit's convenience YAML loader logs parse failures and returns an empty configuration. */ + private static void validateReloadFile(java.io.File file) { + try { new org.bukkit.configuration.file.YamlConfiguration().load(file); } + catch (java.io.IOException | org.bukkit.configuration.InvalidConfigurationException failure) { + throw new IllegalStateException("Grave configuration file cannot be read", failure); + } } private void updateAdvancedCoreHook() { 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 da5ae20..1e7f836 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/commands/CommandLoader.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/commands/CommandLoader.java @@ -83,8 +83,16 @@ public void loadCommands() { @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); + }); } }); diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RecoveryCompletionTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RecoveryCompletionTest.java index 99355cf..1bfe9e4 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RecoveryCompletionTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RecoveryCompletionTest.java @@ -151,6 +151,7 @@ static void tick(PlayerBreakManager manager, GraveActionsTest.Harness h) throws @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) { @@ -159,15 +160,112 @@ static void tick(PlayerBreakManager manager, GraveActionsTest.Harness h) throws 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)); - doCallRealMethod().when(h.plugin).reload(); h.plugin.reload(); + 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(); verify(h.helper, never()).giveExp(anyInt()); assertFalse(h.ledger.isBlocked(h.identity())); + 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(); + assertThrows(ExecutionException.class, () -> FeaturePersistenceTest.await(h.plugin.reloadAsync())); + verify(h.config, never()).reloadData(); + verify(h.scheduler, never()).runTask(eq(h.plugin), any(Runnable.class)); + } + } + + @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)); + doThrow(new IllegalStateException("read failed")).when(h.config).reloadData(); + reloadPaths(h); + 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 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); diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/SelectedCommandDispatchTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/SelectedCommandDispatchTest.java index 1e46d2f..d839bf8 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/SelectedCommandDispatchTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/SelectedCommandDispatchTest.java @@ -4,6 +4,7 @@ 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; @@ -341,4 +342,34 @@ private static String[] args(String command, String helper) { 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/README.md b/README.md index 37848f9..a16a43f 100644 --- a/README.md +++ b/README.md @@ -66,3 +66,5 @@ Nearby menu and command recovery recheck the claim permission, world and configu 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. From a499aae8a1335c8138c35f0c007a741a99da068c Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:29:27 -0600 Subject: [PATCH 07/46] Authorize historical actions on player owner and drain storage before close --- .../gravestonesplus/graves/Grave.java | 33 ++-- .../storage/GraveStorageManager.java | 64 +++++++- .../graves/HistoricalMenuOwnershipTest.java | 65 ++++++++ .../GraveStorageManagerTerminalCloseTest.java | 154 ++++++++++++++++++ README.md | 4 + 5 files changed, 297 insertions(+), 23 deletions(-) create mode 100644 GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/HistoricalMenuOwnershipTest.java create mode 100644 GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/GraveStorageManagerTerminalCloseTest.java 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 cd14fd9..7748201 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/Grave.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/Grave.java @@ -797,20 +797,25 @@ public BInventoryButton getGUIItemBroken() { @Override public void onClick(ClickEvent clickEvent) { Grave grave = (Grave) getData("grave"); - if (!canAdministerHistorical(clickEvent.getPlayer())) return; - if (clickEvent.getClick().equals(ClickType.SHIFT_RIGHT)) { - Player actor = clickEvent.getPlayer(); - plugin.recreateBrokenGrave(grave, actor).whenComplete((restored, failure) -> - plugin.getBukkitScheduler().runTask(plugin, () -> { - if (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 (clickEvent.getClick().equals(ClickType.SHIFT_LEFT)) { - openGUIWithItems(clickEvent.getPlayer()); - } else { - grave.teleportHistorical(clickEvent.getWhoClicked()); - } + 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); } }; 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 ca16c7a..d97a64f 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GraveStorageManager.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GraveStorageManager.java @@ -20,6 +20,8 @@ private record Recovery(List active, List broken) { private Recovery retained; 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<>(); @@ -229,9 +231,13 @@ public synchronized void saveBrokenGravesFromObjects(List brokenGraves) { * Close backend. */ public synchronized void close() { - if (backend != null) { - enqueue(() -> backend.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) { @@ -245,10 +251,13 @@ public static GravesConfig snapshot(GravesConfig grave) { /** 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; } com.bencodez.gravestonesplus.graves.FeatureIO.Admission admitted = plugin.getFeatureIO().admit(); if (admitted == null) { retained = recoverySnapshot(); 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 = recoverySnapshot(); } @@ -256,8 +265,10 @@ private synchronized void enqueue(Runnable write) { } }).whenComplete((saved, failure) -> { if (failure != null) { - synchronized (this) { if (retained == null) retained = recoverySnapshot(); } - plugin.getLogger().severe("Grave backend write failed; latest grave snapshot is retained for storage recovery."); + // Action failure already retained before FeatureIO's owner drain. Only + // rejected execution needs a new snapshot; do not resurrect recovered work. + if (!started.get()) synchronized (this) { if (!backendClosed && retained == null) retained = recoverySnapshot(); } + plugin.getLogger().severe("Grave backend write failed; storage recovery was required."); } }); } @@ -271,12 +282,24 @@ private Recovery recoverySnapshot() { private void flushRetained() { while (true) { Recovery recovery; + boolean loading; synchronized (this) { - if (restorationLoading) return; - recovery = retained; retained = null; recovering = recovery != null; + if (backendClosed) return; + loading = restorationLoading; + recovery = loading ? null : retained; + if (!loading) retained = null; + recovering = recovery != null; if (recovery != null) recovery = new Recovery(preserving(recovery.active(), false), preserving(recovery.broken(), true)); } - if (recovery == null) return; + if (loading) { + // Shutdown during restoration must not publish an incomplete grave index. + closeBackendIfRequested(true); + return; + } + if (recovery == null) { + closeBackendIfRequested(); + return; + } boolean failed = false; try { if (isMySQL()) { @@ -291,7 +314,30 @@ private void flushRetained() { 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()) return; + 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/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/storage/GraveStorageManagerTerminalCloseTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/GraveStorageManagerTerminalCloseTest.java new file mode 100644 index 0000000..2e288de --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/GraveStorageManagerTerminalCloseTest.java @@ -0,0 +1,154 @@ +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()); + 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)).saveGraves(any()); + var order = inOrder(backend); + order.verify(backend).saveBrokenGraves(any()); + order.verify(backend).saveGraves(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 -> { 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 -> { 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/README.md b/README.md index a16a43f..266eec8 100644 --- a/README.md +++ b/README.md @@ -68,3 +68,7 @@ Upgrade restoration automatically reconciles surviving legacy hologram stands ca 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. From dd2710a653fcb84af296e2dcc5c9855bce40e556 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:39:13 -0600 Subject: [PATCH 08/46] Require Folia location ownership before historical recreation --- .../gravestonesplus/graves/Grave.java | 26 +++++++ .../graves/GraveRegionOwnershipTest.java | 71 +++++++++++++++++++ README.md | 2 + 3 files changed, 99 insertions(+) create mode 100644 GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveRegionOwnershipTest.java 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 7748201..bcc29d2 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/Grave.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/Grave.java @@ -5,6 +5,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map.Entry; +import java.lang.reflect.Method; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; @@ -1070,6 +1071,11 @@ public java.util.concurrent.CompletableFuture recreateAsync(Player acto } Runnable commit = () -> { synchronized (this) { + if (actor != null && plugin.getBukkitScheduler().getFoliaLib().isFolia() + && !isOwnedByCurrentRegion(gravesConfig.getLocation())) { + result.complete(false); + return; + } if (!recreationStateAllowed(identity) || actor != null && (!actor.isOnline() || !canAdministerHistorical(actor))) { result.complete(false); return; } markRecreated(); gravesConfig.setDestroyed(false); gravesConfig.setDestroyedTime(0); if (createMarker) { @@ -1089,6 +1095,26 @@ public java.util.concurrent.CompletableFuture recreateAsync(Player acto return result; } + /** Check the actual Folia region owner before mutating this grave's location. */ + static boolean isOwnedByCurrentRegion(Location location) { + if (location == null) return false; + try { + Method method = Bukkit.class.getMethod("isOwnedByCurrentRegion", Location.class); + return isOwnedByCurrentRegion(location, method); + } catch (ReflectiveOperationException | RuntimeException unavailable) { + return false; + } + } + + static boolean isOwnedByCurrentRegion(Location location, Method method) { + if (location == null || method == null) return false; + try { + return Boolean.TRUE.equals(method.invoke(null, location)); + } catch (ReflectiveOperationException | RuntimeException unavailable) { + return false; + } + } + private boolean recreationStateAllowed(String identity) { return identity != null && plugin.isEnabled() && gravesConfig.isDestroyed() && !claimInProgress && identity.equals(GraveIdentity.of(gravesConfig)) && !plugin.getGraveClaimLedger().isBlocked(identity); 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..cc979ad --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveRegionOwnershipTest.java @@ -0,0 +1,71 @@ +package com.bencodez.gravestonesplus.graves; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.lang.reflect.Method; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import org.bukkit.Location; +import org.junit.jupiter.api.Test; + +class GraveRegionOwnershipTest { + static boolean owned(Location ignored) { return true; } + static boolean remote(Location ignored) { return false; } + static boolean unavailable(Location ignored) { throw new IllegalStateException("unavailable"); } + + @Test void nativeOwnershipMustBeTrueAndMissingOrFailedCapabilityRejects() throws Exception { + Location location = new Location(null, 0, 0, 0); + for (String method : List.of("owned", "remote", "unavailable")) { + Method check = GraveRegionOwnershipTest.class.getDeclaredMethod(method, Location.class); + assertEquals(method.equals("owned"), Grave.isOwnedByCurrentRegion(location, check)); + } + assertFalse(Grave.isOwnedByCurrentRegion(location, null)); + assertFalse(Grave.isOwnedByCurrentRegion(null)); + } + + @Test void actorRecreationRequiresNativeLocationOwnershipOnItsFinalEntityCallback() throws Exception { + for (boolean owned : List.of(false, true)) { + try (var h = new GraveActionsTest.Harness(); var checks = mockStatic(Grave.class, CALLS_REAL_METHODS)) { + when(h.folia.isFolia()).thenReturn(true); + var actorOwner = new AtomicBoolean(); + when(h.helper.hasPermission(anyString())).thenAnswer(call -> { + assertTrue(actorOwner.get(), "authorization must use the actor owner"); + return call.getArgument(0).equals("GraveStonesPlus.Admin"); + }); + var grave = spy(h.grave); + doAnswer(call -> { assertTrue(actorOwner.get()); assertTrue(owned); return null; }).when(grave).createGrave(); + doNothing().when(grave).createHologram(); + h.active.clear(); grave.getGravesConfig().setDestroyed(true); + checks.when(() -> Grave.isOwnedByCurrentRegion(grave.getGravesConfig().getLocation())).thenAnswer(call -> { + assertTrue(actorOwner.get(), "location proof must use the current actor region"); return owned; + }); + var restored = grave.recreateAsync(h.helper, true); h.drain(); + assertEquals(1, h.regionWork.size()); assertFalse(restored.isDone()); + h.regionWork.remove().run(); + assertEquals(1, h.entityWork.size()); assertTrue(grave.getGravesConfig().isDestroyed()); + verify(grave, never()).createGrave(); + actorOwner.set(true); h.entityWork.remove().run(); actorOwner.set(false); + assertEquals(owned, FeaturePersistenceTest.await(restored)); + assertEquals(!owned, grave.getGravesConfig().isDestroyed()); + verify(grave, times(owned ? 1 : 0)).markRecreated(); + verify(grave, times(owned ? 1 : 0)).createGrave(); + } + } + } + + @Test void locationOnlyRecreationAndNonFoliaActorKeepTheirEstablishedOwnerPaths() throws Exception { + for (boolean locationOnly : List.of(false, true)) { + try (var h = new GraveActionsTest.Harness(); var checks = mockStatic(Grave.class, CALLS_REAL_METHODS)) { + when(h.folia.isFolia()).thenReturn(locationOnly); + when(h.helper.hasPermission("GraveStonesPlus.Admin")).thenReturn(true); + var grave = spy(h.grave); doNothing().when(grave).createGrave(); doNothing().when(grave).createHologram(); + grave.getGravesConfig().setDestroyed(true); + var restored = grave.recreateAsync(locationOnly ? null : h.helper, true); h.drain(); + h.regionWork.remove().run(); + if (!locationOnly) h.entityWork.remove().run(); + assertTrue(FeaturePersistenceTest.await(restored)); verify(grave).createGrave(); + checks.verify(() -> Grave.isOwnedByCurrentRegion(grave.getGravesConfig().getLocation()), never()); + } + } + } +} diff --git a/README.md b/README.md index 266eec8..2322154 100644 --- a/README.md +++ b/README.md @@ -72,3 +72,5 @@ Reload acknowledgements wait for asynchronous configuration reads and owner-side 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. + +On Folia, a player-authorized historical recreation must run on an actor region that also owns the saved grave location. Remote recreation fails safely before state or marker changes; move to the grave and retry. The native ownership check fails closed if unavailable. Location-only Java requests retain their location-scheduled path. Spigot/Paper recreation behavior is unchanged. From e8c13f51a6f47105d27d6875e1acd15b25ad00ad Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:48:02 -0600 Subject: [PATCH 09/46] Preserve remote historical recreation with region-scheduled marker writes --- .../gravestonesplus/graves/Grave.java | 26 ----- .../graves/GraveRegionOwnershipTest.java | 105 +++++++++++------- README.md | 2 +- 3 files changed, 64 insertions(+), 69 deletions(-) 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 bcc29d2..7748201 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/Grave.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/Grave.java @@ -5,7 +5,6 @@ import java.util.HashMap; import java.util.List; import java.util.Map.Entry; -import java.lang.reflect.Method; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; @@ -1071,11 +1070,6 @@ public java.util.concurrent.CompletableFuture recreateAsync(Player acto } Runnable commit = () -> { synchronized (this) { - if (actor != null && plugin.getBukkitScheduler().getFoliaLib().isFolia() - && !isOwnedByCurrentRegion(gravesConfig.getLocation())) { - result.complete(false); - return; - } if (!recreationStateAllowed(identity) || actor != null && (!actor.isOnline() || !canAdministerHistorical(actor))) { result.complete(false); return; } markRecreated(); gravesConfig.setDestroyed(false); gravesConfig.setDestroyedTime(0); if (createMarker) { @@ -1095,26 +1089,6 @@ public java.util.concurrent.CompletableFuture recreateAsync(Player acto return result; } - /** Check the actual Folia region owner before mutating this grave's location. */ - static boolean isOwnedByCurrentRegion(Location location) { - if (location == null) return false; - try { - Method method = Bukkit.class.getMethod("isOwnedByCurrentRegion", Location.class); - return isOwnedByCurrentRegion(location, method); - } catch (ReflectiveOperationException | RuntimeException unavailable) { - return false; - } - } - - static boolean isOwnedByCurrentRegion(Location location, Method method) { - if (location == null || method == null) return false; - try { - return Boolean.TRUE.equals(method.invoke(null, location)); - } catch (ReflectiveOperationException | RuntimeException unavailable) { - return false; - } - } - private boolean recreationStateAllowed(String identity) { return identity != null && plugin.isEnabled() && gravesConfig.isDestroyed() && !claimInProgress && identity.equals(GraveIdentity.of(gravesConfig)) && !plugin.getGraveClaimLedger().isBlocked(identity); diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveRegionOwnershipTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveRegionOwnershipTest.java index cc979ad..97a87ce 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveRegionOwnershipTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveRegionOwnershipTest.java @@ -2,70 +2,91 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; -import java.lang.reflect.Method; 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 { - static boolean owned(Location ignored) { return true; } - static boolean remote(Location ignored) { return false; } - static boolean unavailable(Location ignored) { throw new IllegalStateException("unavailable"); } - - @Test void nativeOwnershipMustBeTrueAndMissingOrFailedCapabilityRejects() throws Exception { - Location location = new Location(null, 0, 0, 0); - for (String method : List.of("owned", "remote", "unavailable")) { - Method check = GraveRegionOwnershipTest.class.getDeclaredMethod(method, Location.class); - assertEquals(method.equals("owned"), Grave.isOwnedByCurrentRegion(location, check)); - } - assertFalse(Grave.isOwnedByCurrentRegion(location, null)); - assertFalse(Grave.isOwnedByCurrentRegion(null)); - } - - @Test void actorRecreationRequiresNativeLocationOwnershipOnItsFinalEntityCallback() throws Exception { - for (boolean owned : List.of(false, true)) { - try (var h = new GraveActionsTest.Harness(); var checks = mockStatic(Grave.class, CALLS_REAL_METHODS)) { + @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); - doAnswer(call -> { assertTrue(actorOwner.get()); assertTrue(owned); return null; }).when(grave).createGrave(); - doNothing().when(grave).createHologram(); - h.active.clear(); grave.getGravesConfig().setDestroyed(true); - checks.when(() -> Grave.isOwnedByCurrentRegion(grave.getGravesConfig().getLocation())).thenAnswer(call -> { - assertTrue(actorOwner.get(), "location proof must use the current actor region"); return owned; + // 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); h.drain(); assertEquals(1, h.regionWork.size()); assertFalse(restored.isDone()); - h.regionWork.remove().run(); + graveOwner.set(true); h.regionWork.remove().run(); graveOwner.set(false); assertEquals(1, h.entityWork.size()); assertTrue(grave.getGravesConfig().isDestroyed()); - verify(grave, never()).createGrave(); + verify(h.world, never()).getBlockAt(any(Location.class)); actorOwner.set(true); h.entityWork.remove().run(); actorOwner.set(false); - assertEquals(owned, FeaturePersistenceTest.await(restored)); - assertEquals(!owned, grave.getGravesConfig().isDestroyed()); - verify(grave, times(owned ? 1 : 0)).markRecreated(); - verify(grave, times(owned ? 1 : 0)).createGrave(); + 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 locationOnlyRecreationAndNonFoliaActorKeepTheirEstablishedOwnerPaths() throws Exception { - for (boolean locationOnly : List.of(false, true)) { - try (var h = new GraveActionsTest.Harness(); var checks = mockStatic(Grave.class, CALLS_REAL_METHODS)) { - when(h.folia.isFolia()).thenReturn(locationOnly); - when(h.helper.hasPermission("GraveStonesPlus.Admin")).thenReturn(true); - var grave = spy(h.grave); doNothing().when(grave).createGrave(); doNothing().when(grave).createHologram(); - grave.getGravesConfig().setDestroyed(true); - var restored = grave.recreateAsync(locationOnly ? null : h.helper, true); h.drain(); - h.regionWork.remove().run(); - if (!locationOnly) h.entityWork.remove().run(); - assertTrue(FeaturePersistenceTest.await(restored)); verify(grave).createGrave(); - checks.verify(() -> Grave.isOwnedByCurrentRegion(grave.getGravesConfig().getLocation()), never()); - } + @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); h.drain(); + h.regionWork.remove().run(); 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/README.md b/README.md index 2322154..b9420f7 100644 --- a/README.md +++ b/README.md @@ -73,4 +73,4 @@ Historical grave-menu actions capture click intent and perform permission/online 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. -On Folia, a player-authorized historical recreation must run on an actor region that also owns the saved grave location. Remote recreation fails safely before state or marker changes; move to the grave and retry. The native ownership check fails closed if unavailable. Location-only Java requests retain their location-scheduled path. Spigot/Paper recreation behavior is unchanged. +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. From c12ad4c8599f2be0c95df0223115504b746fbb42 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:53:12 -0600 Subject: [PATCH 10/46] Preserve legacy compass targets during pending restoration --- .../graves/GraveNavigation.java | 14 ++- ...NavigationLegacyCompassRegressionTest.java | 95 +++++++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/NavigationLegacyCompassRegressionTest.java diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveNavigation.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveNavigation.java index 76ba276..34c8d20 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveNavigation.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveNavigation.java @@ -56,8 +56,13 @@ public void refresh(Player player) { 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); - if (grave == null && pending(identity)) continue; + // 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"); @@ -75,4 +80,11 @@ private Grave legacyTarget(Player player, CompassMeta meta, NamespacedKey key) { && 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/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..b9c2592 --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/NavigationLegacyCompassRegressionTest.java @@ -0,0 +1,95 @@ +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 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); + } + } + + 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) { } +} From ce9c0b3d5d690a999d0b0f476db670b0a8d3a978 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:55:24 -0600 Subject: [PATCH 11/46] Acknowledge grave recreation after checked backend publication --- .../gravestonesplus/GraveStonesPlus.java | 23 +++-- .../graves/DurableFeatureState.java | 35 +++++--- .../gravestonesplus/graves/Grave.java | 8 +- .../storage/FlatFileGraveStorageBackend.java | 7 +- .../storage/GraveLocations.java | 12 +++ .../storage/GraveStorageBackend.java | 12 ++- .../storage/GraveStorageManager.java | 49 ++++++++++- .../storage/GravesMySQLTable.java | 31 +++++++ .../storage/MySQLGraveStorageBackend.java | 5 ++ .../graves/GraveActionsTest.java | 64 +++++++++++++- .../MarkerRestorationRegressionTest.java | 2 +- .../graves/RecoveryCompletionTest.java | 1 + .../FlatFileSnapshotPublicationTest.java | 86 +++++++++++++++++++ .../GraveStorageManagerTerminalCloseTest.java | 14 ++- .../MySQLRestorationOwnershipTest.java | 68 ++++++++++++++- README.md | 15 ++++ 16 files changed, 402 insertions(+), 30 deletions(-) create mode 100644 GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/FlatFileSnapshotPublicationTest.java diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/GraveStonesPlus.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/GraveStonesPlus.java index 9720944..16caac6 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/GraveStonesPlus.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/GraveStonesPlus.java @@ -148,25 +148,34 @@ public void recreateBrokenGrave(Grave grave) { } 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 java.util.concurrent.CompletableFuture.completedFuture(false); } - var completion = grave.recreateAsync(actor, actor != null).thenApply(restored -> { - if (!restored) return 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 true; + 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(); diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/DurableFeatureState.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/DurableFeatureState.java index 27465fe..f535689 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/DurableFeatureState.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/DurableFeatureState.java @@ -107,21 +107,34 @@ public void save(Map state) throws Exception { } byte[] bytes = yaml.saveToString().getBytes(StandardCharsets.UTF_8); if (bytes.length > 4_194_304) throw new IOException("feature file too large"); - 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); } + 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); } 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 7748201..eecaa0d 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/Grave.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/Grave.java @@ -1044,7 +1044,13 @@ private void removeGlowingHologram() { } public void markRecreated() { removeTimer(); expiryDeadline = 0; remove = false; } - public void createBrokenGrave() { recreateAsync(null, true); } + /** + * 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); 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..e9c3b60 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,13 @@ public void saveBrokenGraves(List graves) { graveLocations.saveData(); } + @Override + public void saveSnapshot(List active, List broken) { + graveLocations.saveSnapshot(active, broken); + } + @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..cb43cf6 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GraveLocations.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GraveLocations.java @@ -38,6 +38,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..96366e1 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,16 @@ public interface GraveStorageBackend { */ void saveBrokenGraves(List graves); + default void saveSnapshot(List active, List broken) { + saveBrokenGraves(broken); + saveGraves(active); + } + + /** 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 +97,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 d97a64f..daf3b8c 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GraveStorageManager.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GraveStorageManager.java @@ -227,6 +227,37 @@ public synchronized void saveBrokenGravesFromObjects(List brokenGraves) { } } + /** 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. */ @@ -272,6 +303,22 @@ private synchronized void enqueue(Runnable write) { } }); } + + 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")); } + var admitted = plugin.getFeatureIO().admit(); + if (admitted == null) { retained = recoverySnapshot(); 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 = recoverySnapshot(); } throw failure; + } }); + result.whenComplete((saved, failure) -> { if (failure != null) { + if (!started.get()) synchronized (this) { if (!backendClosed && retained == null) retained = recoverySnapshot(); } + plugin.getLogger().severe("Grave backend snapshot write failed; storage recovery was required."); + } }); + return result; + } private Recovery recoverySnapshot() { List active = new ArrayList<>(), broken = new ArrayList<>(); for (Grave grave : plugin.activeGraveSnapshot()) active.add(snapshot(grave.getGravesConfig())); @@ -306,7 +353,7 @@ private void flushRetained() { for (GravesConfig grave : recovery.active()) backend.upsertGrave(grave, false); for (GravesConfig grave : recovery.broken()) backend.upsertGrave(grave, true); } else { - backend.saveBrokenGraves(recovery.broken()); backend.saveGraves(recovery.active()); + backend.saveSnapshot(recovery.active(), recovery.broken()); } } catch (RuntimeException failure) { synchronized (this) { if (retained == null) retained = recovery; } 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 b3dfe41..76f33e6 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GravesMySQLTable.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GravesMySQLTable.java @@ -204,6 +204,37 @@ public void updateExistingGrave(GravesConfig grave, boolean broken) { } } + /** 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). * 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 bd67d69..69345fa 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/MySQLGraveStorageBackend.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/MySQLGraveStorageBackend.java @@ -71,6 +71,11 @@ public void upsertGrave(GravesConfig grave, boolean broken) { } } + @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) { diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java index 606a3d3..9e1aed2 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java @@ -437,15 +437,71 @@ void drain() throws Exception { 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); } @@ -474,9 +530,12 @@ private void assertRecreationObserverIsolation( 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()); @@ -493,12 +552,11 @@ private void assertRecreationObserverIsolation( assertEquals(List.of(h.grave), h.active); assertTrue(broken.isEmpty()); assertTrue(h.plugin.canTrack(h.grave, h.owner)); if (mysql) { - verify(storage).setBroken(h.grave.getGravesConfig(), false); + verify(storage).saveRecreatedGrave(h.grave.getGravesConfig()); verify(storage, never()).saveGravesFromObjects(any()); verify(storage, never()).saveBrokenGravesFromObjects(any()); } else { - verify(storage).saveGravesFromObjects(h.active); - verify(storage).saveBrokenGravesFromObjects(broken); + verify(storage).saveSnapshotFromObjects(h.active, broken); verify(storage, never()).setBroken(any(), anyBoolean()); } verify(h.owner, never()).giveExp(anyInt()); verify(h.helper, never()).giveExp(anyInt()); diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java index 34d123a..7ed1148 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java @@ -427,7 +427,7 @@ void actualRestorationPreservesAdoptedLegacyAndIdentityTaggedDisplaysAfterDelaye 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).saveGraves(written.capture());for (var snapshot : written.getAllValues()) { assertEquals(1,snapshot.size()); assertEquals(17,snapshot.getFirst().getExp()); } + 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()); } } } diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RecoveryCompletionTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RecoveryCompletionTest.java index 1bfe9e4..3b583b1 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RecoveryCompletionTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RecoveryCompletionTest.java @@ -300,6 +300,7 @@ private void reloadPaths(GraveActionsTest.Harness h) throws Exception { 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))); 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/GraveStorageManagerTerminalCloseTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/GraveStorageManagerTerminalCloseTest.java index 2e288de..2070f4a 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/GraveStorageManagerTerminalCloseTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/GraveStorageManagerTerminalCloseTest.java @@ -24,6 +24,7 @@ void failedWriteIsRetainedBeforeExactlyOneTerminalClose() throws Exception { 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()); @@ -62,10 +63,11 @@ void closeRequestSurvivesSaturatedQueueAndClosesOnceAfterRetainedDrain() throws manager.saveGravesFromObjects(List.of()); } verify(backend, times(1)).close(); - verify(backend, times(1)).saveGraves(any()); + verify(backend, times(1)).saveSnapshot(any(), any()); + verify(backend, never()).saveBrokenGraves(any()); + verify(backend, never()).saveGraves(any()); var order = inOrder(backend); - order.verify(backend).saveBrokenGraves(any()); - order.verify(backend).saveGraves(any()); + order.verify(backend).saveSnapshot(any(), any()); order.verify(backend).close(); } @@ -101,6 +103,10 @@ void closeRequestSurvivesSaturatedQueueAndClosesOnceAfterRetainedDrain() throws } 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)) { @@ -123,6 +129,8 @@ void closeRequestSurvivesSaturatedQueueAndClosesOnceAfterRetainedDrain() throws 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)) { diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/MySQLRestorationOwnershipTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/MySQLRestorationOwnershipTest.java index 0fb5eb6..45397f7 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/MySQLRestorationOwnershipTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/MySQLRestorationOwnershipTest.java @@ -26,6 +26,7 @@ static final class Fixture implements AutoCloseable { 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 { @@ -44,7 +45,6 @@ static final class Fixture implements AutoCloseable { field(table, AbstractSqlTable.class, "tableName", "graves"); field(table, AbstractSqlTable.class, "dbType", DbType.MYSQL); doCallRealMethod().when(table).loadDeferred(anyBoolean()); - var backend = mock(MySQLGraveStorageBackend.class); field(backend, MySQLGraveStorageBackend.class, "table", table); doCallRealMethod().when(backend).loadDeferred(anyBoolean()); storage = new GraveStorageManager(plugin); @@ -58,6 +58,72 @@ static final class Fixture implements AutoCloseable { } 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 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); diff --git a/README.md b/README.md index b9420f7..7d8b23e 100644 --- a/README.md +++ b/README.md @@ -74,3 +74,18 @@ Historical grave-menu actions capture click intent and perform permission/online 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. From c065528deb0b0af8273f6bbe1bfe5b41b35930a1 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:02:06 -0600 Subject: [PATCH 12/46] Prepare grave item payloads before asynchronous storage writes --- .../storage/GraveStorageManager.java | 19 +-- .../gravestonesplus/storage/GravesConfig.java | 21 +++- .../storage/GravesMySQLTable.java | 12 +- .../storage/PreparedStorageSnapshotTest.java | 118 ++++++++++++++++++ README.md | 2 + 5 files changed, 153 insertions(+), 19 deletions(-) create mode 100644 GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/PreparedStorageSnapshotTest.java 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 daf3b8c..ace36eb 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GraveStorageManager.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GraveStorageManager.java @@ -18,6 +18,7 @@ public class GraveStorageManager { 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; @@ -277,28 +278,29 @@ public static GravesConfig snapshot(GravesConfig grave) { 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()); return copy; + 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 = recoverySnapshot(); return; } + 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 = recoverySnapshot(); } + 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 needs a new snapshot; do not resurrect recovered work. - if (!started.get()) synchronized (this) { if (!backendClosed && retained == null) retained = recoverySnapshot(); } + // 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."); } }); @@ -307,14 +309,15 @@ private synchronized void enqueue(Runnable write) { 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 = recoverySnapshot(); return java.util.concurrent.CompletableFuture.failedFuture(new java.util.concurrent.RejectedExecutionException("feature worker unavailable")); } + 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 = recoverySnapshot(); } throw 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 = recoverySnapshot(); } + 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; 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 973ab85..c901770 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GravesConfig.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GravesConfig.java @@ -25,6 +25,24 @@ 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; + + void prepareItemsForStorage() { + try { preparedItems = InventorySerializer.encode(items); } + catch (IOException failure) { preparationFailure = new java.io.UncheckedIOException("Could not prepare grave items", failure); } + catch (RuntimeException failure) { preparationFailure = failure; } + } + + String encodedItemsForStorage() { + 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); } + } + @Getter private int exp; @@ -108,6 +126,7 @@ public GravesConfig(UUID uuid, String playerName, Location loc, HashMap serialize() { + if (preparationFailure != null) throw preparationFailure; Map serialized = new HashMap(); serialized.put("DeathMessage", deathMessage); @@ -135,7 +154,7 @@ public Map serialize() { // 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); 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 76f33e6..7753280 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GravesMySQLTable.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GravesMySQLTable.java @@ -188,7 +188,7 @@ 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.getWorldId().toString()); @@ -372,7 +372,7 @@ private void bindInsert(PreparedStatement ps, GravesConfig g, boolean broken) th 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) { @@ -390,14 +390,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); 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..08cd048 --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/PreparedStorageSnapshotTest.java @@ -0,0 +1,118 @@ +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 encodingFailureRemainsAcknowledgedAndRetainedThroughWorkerRecovery() 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 grave = mock(Grave.class); when(grave.getGravesConfig()).thenReturn(source()); + 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/README.md b/README.md index 7d8b23e..60a9ff1 100644 --- a/README.md +++ b/README.md @@ -89,3 +89,5 @@ uncommitted connections fail the acknowledgement. Existing YAML record keys and 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. From 948c0e9f43fcdabf3abb4eede5a77a454a5be405 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:13:54 -0600 Subject: [PATCH 13/46] Index cached grave markers before asynchronous restoration admission --- .../graves/GraveRestoration.java | 62 +++++++++--- .../graves/GraveActionsTest.java | 19 +++- .../MarkerRestorationRegressionTest.java | 99 +++++++++++++++---- .../graves/RemovalRecoveryRegressionTest.java | 4 +- README.md | 2 + 5 files changed, 146 insertions(+), 40 deletions(-) diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java index 98fb3fb..d023ae8 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java @@ -26,7 +26,7 @@ private record PositionKey(UUID world, int x, int y, int z) { } private final AtomicBoolean running = new AtomicBoolean(); private volatile boolean retired; - private boolean recordsLoaded; + 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; } @@ -38,6 +38,13 @@ public synchronized void retainLimit(Grave grave) { } public void start() { storage.beginRestoration(); + // Flat-file initialization already loaded/decoded the document. + // Only prepare it inline when lifecycle startup is on the server owner; other contexts use retry's handoff. + // Publish its exact-position index before listeners are registered, without another disk read. + if (!storage.isMySQL() && org.bukkit.Bukkit.isPrimaryThread()) { + try { acceptRecords(cachedFlatRecords()); } + 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); @@ -47,26 +54,49 @@ public void retry() { if (!recordsLoaded) { CompletableFuture read = storage.isMySQL() ? storage.loadMySQLForRestoration() - : plugin.getFeatureIO().submit(() -> new GraveStorageManager.RestorationRecords(storage.loadGraves(), storage.loadBrokenGraves())); - read.thenApply(records -> { - List active = records.active(), broken = records.broken(); - storage.retainRestoration(active, broken); - synchronized (this) { - int invalid = 0; - 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); - } - recordsLoaded = true; - } - return true; - }).whenComplete((loaded, error) -> { + : loadCachedFlatOnOwner(); + read.thenCompose(this::acceptRecordsOnOwner).whenComplete((loaded, error) -> { if (error != null) { running.set(false); plugin.getLogger().warning("Stored graves remain withheld until backend restoration can retry."); } else cycle(); }); } 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) { + var result = new CompletableFuture(); + Runnable accept = () -> { + if (retired || !plugin.isEnabled()) { result.completeExceptionally(new IllegalStateException("Grave restoration retired")); return; } + try { acceptRecords(records); result.complete(null); } + catch (RuntimeException failure) { result.completeExceptionally(failure); } + }; + try { plugin.getBukkitScheduler().runTask(plugin, accept); } + catch (RuntimeException rejected) { result.completeExceptionally(rejected); } + return result; + } + private void acceptRecords(GraveStorageManager.RestorationRecords records) { + List active = records.active(), broken = records.broken(); + storage.retainRestoration(active, broken); + synchronized (this) { + int invalid = 0; + 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); + } + recordsLoaded = true; + } + } private static List concat(List active, List broken) { List result = new ArrayList<>(active); result.addAll(broken); return result; } diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java index 9e1aed2..9cd6c1c 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java @@ -24,6 +24,11 @@ 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(); @@ -38,7 +43,7 @@ static final class Harness implements AutoCloseable { 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<>(); + 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); @@ -87,12 +92,20 @@ static final class Harness implements AutoCloseable { 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 = entityWork.poll()) != null) task.run(); + 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()); } } @@ -687,7 +700,7 @@ static void drainGround(Harness h) throws Exception { 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); - restarted.retry(); drainGround(h); assertEquals(1, h.active.size()); assertTrue(h.active.getFirst().isLimitPayoutPending()); + 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); diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java index 7ed1148..f88b8c5 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java @@ -84,6 +84,63 @@ private void assertNativeAndEnvironmentalProtection(GraveActionsTest.Harness h, 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); } + } + } + @Test void startupOffOwnerDefersCachedRecordPreparation() throws Exception { + try (var h = new GraveActionsTest.Harness()) { + 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(); + assertFalse(restoration.hasPending(h.identity())); + 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(); + } + } + @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()); @@ -94,7 +151,7 @@ void actualNativeAndEnvironmentalListenersProtectExactPendingMarkersAcrossArchiv 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); - restoration.retry(); FeaturePersistenceTest.await(h.io.barrier()); + 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); @@ -121,7 +178,7 @@ void actualNativeAndEnvironmentalListenersProtectExactPendingMarkersAcrossArchiv try (var h = new GraveActionsTest.Harness()) { var marker = new Marker(h); var admission = new CompletableFuture(); var restoration = restoration(h, h.grave.getGravesConfig(), admission, admission); - restoration.retry(); FeaturePersistenceTest.await(h.io.barrier()); assertTrue(restoration.protectsPendingMarker(marker.block)); + 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))); @@ -142,7 +199,7 @@ void actualNativeAndEnvironmentalListenersProtectExactPendingMarkersAcrossArchiv 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); - restoration.retry(); FeaturePersistenceTest.await(h.io.barrier()); + 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); @@ -156,7 +213,7 @@ void pendingPhysicsRepairRestoresOnlyItsStillOwnedMarker(boolean replaced) throw 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); - restoration.retry(); FeaturePersistenceTest.await(h.io.barrier()); + 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"); @@ -192,7 +249,7 @@ void actualRestorationPreservesAdoptedLegacyAndIdentityTaggedDisplaysAfterDelaye // 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); - restoration.retry(); FeaturePersistenceTest.await(h.io.barrier()); admission.complete(GraveClaimLedger.Restoration.AVAILABLE); h.regionWork.remove().run(); + 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(); @@ -303,7 +360,7 @@ void actualRestorationPreservesAdoptedLegacyAndIdentityTaggedDisplaysAfterDelaye 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); - restoration.retry(); FeaturePersistenceTest.await(h.io.barrier()); assertFalse(restoration.isLoading()); + 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); @@ -342,7 +399,7 @@ void actualRestorationPreservesAdoptedLegacyAndIdentityTaggedDisplaysAfterDelaye 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); restoration.retry(); FeaturePersistenceTest.await(h.io.barrier()); verify(marker.block,never()).getState(); assertTrue(h.active.isEmpty()); assertTrue(h.regionWork.isEmpty()); + 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()); 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()); } @@ -419,7 +476,7 @@ void actualRestorationPreservesAdoptedLegacyAndIdentityTaggedDisplaysAfterDelaye 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();FeaturePersistenceTest.await(h.io.barrier());manager.saveGravesFromObjects(h.active);FeaturePersistenceTest.await(h.io.barrier()); + 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(); } } @@ -576,7 +633,7 @@ void actualRestorationPreservesAdoptedLegacyAndIdentityTaggedDisplaysAfterDelaye 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. - restoration.retry(); FeaturePersistenceTest.await(h.io.barrier()); h.regionWork.remove().run(); + 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)); @@ -598,7 +655,7 @@ void actualRestorationPreservesAdoptedLegacyAndIdentityTaggedDisplaysAfterDelaye 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); restoration.retry(); FeaturePersistenceTest.await(h.io.barrier()); h.regionWork.remove().run(); FeaturePersistenceTest.await(h.io.barrier()); + 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(); @@ -690,12 +747,14 @@ private void expirationAdmissionRetry(boolean invalidate) throws Exception { return registered; } }); - var restoration=new GraveRestoration(h.plugin,manager);restoration.retry(); + 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; });restoration.retry(); + 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(); } @@ -718,7 +777,7 @@ private void expirationAdmissionRetry(boolean invalidate) throws Exception { 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())); - restoration.retry();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); + 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(); @@ -730,7 +789,7 @@ private void expirationAdmissionRetry(boolean invalidate) throws Exception { 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);restoration.retry();FeaturePersistenceTest.await(h.io.barrier());h.regionWork.remove().run(); + 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(); } } @@ -742,7 +801,7 @@ private void expirationAdmissionRetry(boolean invalidate) throws Exception { 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);restoration.retry();FeaturePersistenceTest.await(h.io.barrier());h.regionWork.remove().run();FeaturePersistenceTest.await(h.io.barrier()); + 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(); } } @@ -767,7 +826,7 @@ private void expirationAdmissionRetry(boolean invalidate) throws Exception { }).when(h.plugin).restoreBrokenRecord(any()); if (rejectRevocation) h.accessDisk.fail = true; var restoration = new GraveRestoration(h.plugin, backend); - restoration.retry(); FeaturePersistenceTest.await(h.io.barrier()); h.regionWork.remove().run(); + 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)); @@ -799,7 +858,7 @@ private void expirationAdmissionRetry(boolean invalidate) throws Exception { // 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);restoration.retry();FeaturePersistenceTest.await(h.io.barrier());h.regionWork.remove().run(); + 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(); } @@ -815,14 +874,16 @@ private void expirationAdmissionRetry(boolean invalidate) throws Exception { 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);restoration.retry(); + 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; });restoration.retry(); + 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()); 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))); diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RemovalRecoveryRegressionTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RemovalRecoveryRegressionTest.java index 0e90720..f58a390 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RemovalRecoveryRegressionTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RemovalRecoveryRegressionTest.java @@ -43,7 +43,7 @@ class RemovalRecoveryRegressionTest { doAnswer(call -> { restored.add(call.getArgument(0)); published.countDown(); return null; }) .when(h.plugin).restoreBrokenRecord(any()); var restoration = new GraveRestoration(h.plugin, backend); - restoration.retry(); assertTrue(published.await(2, TimeUnit.SECONDS)); h.drain(); + GraveActionsTest.retryOnOwner(h, restoration); assertTrue(published.await(2, TimeUnit.SECONDS)); h.drain(); Grave historical = restored.getFirst(); assertTrue(historical.getGravesConfig().isDestroyed()); assertFalse(h.plugin.canRecover(historical, h.helper)); @@ -138,7 +138,7 @@ var record = new GravesConfig(original.getUuid(), original.getPlayerName(), orig 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); - restoration.retry(); GraveActionsTest.drainGround(h); + 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()); diff --git a/README.md b/README.md index 60a9ff1..a745931 100644 --- a/README.md +++ b/README.md @@ -91,3 +91,5 @@ Pre-upgrade compasses with only the legacy time/lodestone tags retain their targ 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. + +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. From 0bcb10abdaec0a4884c4e23031af741c569c4395 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:28:43 -0600 Subject: [PATCH 14/46] Keep flat-file fallback storage routing consistent --- .../storage/GraveStorageManager.java | 1 + .../MySQLRestorationOwnershipTest.java | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+) 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 ace36eb..612f852 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GraveStorageManager.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GraveStorageManager.java @@ -67,6 +67,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"; diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/MySQLRestorationOwnershipTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/MySQLRestorationOwnershipTest.java index 45397f7..2f78318 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/MySQLRestorationOwnershipTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/MySQLRestorationOwnershipTest.java @@ -8,12 +8,35 @@ 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 { + @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()); + } + } 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); } From 6f6b6faca8a67107810f288d330c8bff48fd2cb4 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:35:57 -0600 Subject: [PATCH 15/46] Preserve successor grave markers during restoration --- .../graves/GraveRestoration.java | 3 +++ .../graves/GraveActionsTest.java | 3 +++ .../MarkerRestorationRegressionTest.java | 22 +++++++++++++++++++ 3 files changed, 28 insertions(+) diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java index d023ae8..123b165 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java @@ -160,6 +160,9 @@ private void processResolved(Iterator batch, String key, GravesConfig re } 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))) return; if (decision == GraveClaimLedger.Restoration.QUEUED_LIMIT) { grave.markLimitPayoutPending(); synchronized (this) { diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java index 9cd6c1c..5f23108 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java @@ -587,6 +587,9 @@ private void assertRecreationObserverIsolation( 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)); diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java index f88b8c5..5c89490 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java @@ -647,6 +647,28 @@ void actualRestorationPreservesAdoptedLegacyAndIdentityTaggedDisplaysAfterDelaye restoration.onChunk(h.world.getUID(), 0, 0); FeaturePersistenceTest.await(h.io.barrier()); assertTrue(h.regionWork.isEmpty()); 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); From 9f54a32345db8eaaf2125e32ac0be680c108f187 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:40:58 -0600 Subject: [PATCH 16/46] Preserve the public item-only ground drop API --- .../commands/CommandLoader.java | 2 +- .../gravestonesplus/graves/Grave.java | 20 +++++++- .../listeners/PlayerDeathListener.java | 2 +- .../graves/GraveActionsTest.java | 48 +++++++++++++++---- README.md | 4 ++ 5 files changed, 63 insertions(+), 13 deletions(-) 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 1e7f836..6c8c864 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/commands/CommandLoader.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/commands/CommandLoader.java @@ -554,7 +554,7 @@ public void createTestGrave(UUID uuid, String name, Location deathLocation, Grave oldest = plugin.getOldestGrave(uuid); if (oldest != null) { if (plugin.getConfigFile().isDropItemsOnGraveRemoval()) { - oldest.dropItemsOnGround(null); + oldest.dropAndRemoveForLimit(null); } else oldest.removeGrave(GraveRemoveReason.LIMIT_REACHED); } } 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 eecaa0d..1722cc6 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/Grave.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/Grave.java @@ -1107,8 +1107,26 @@ public void teleportHistorical(Player actor) { }, actor); } - /** Limit-removal payout shares the claim ledger, so queued drops cannot race recovery. */ + /** + * 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) { String identity = GraveIdentity.of(gravesConfig); synchronized (this) { if (remove || claimInProgress || limitPayoutPending || !plugin.isLiveGrave(this) || plugin.getGraveClaimLedger().isBlocked(identity)) return; 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 4d628aa..843463d 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/PlayerDeathListener.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/PlayerDeathListener.java @@ -82,7 +82,7 @@ public void onPlayerDeath(PlayerDeathEvent event) { Grave oldest = plugin.getOldestGrave(entity.getUniqueId()); if (oldest != null) { if (plugin.getConfigFile().isDropItemsOnGraveRemoval()) { - oldest.dropItemsOnGround(entity); + oldest.dropAndRemoveForLimit(entity); } else { oldest.removeGrave(GraveRemoveReason.LIMIT_REACHED); entity.sendMessage(MessageAPI.colorize(plugin.getConfigFile().getFormatGraveLimitBreak())); diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java index 5f23108..3cfd728 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java @@ -584,6 +584,34 @@ private void assertRecreationObserverIsolation( } } + @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); @@ -630,7 +658,7 @@ static void drainGround(Harness h) throws Exception { try (Harness h = new Harness()) { limitRestoration(h); ExperienceOrb orb = groundWorld(h); h.grant(TrustedGraveAccess.Kind.RECOVER); - h.grave.dropItemsOnGround(h.owner); h.grave.claim(h.helper); h.grave.claim(h.owner); h.grave.dropItemsOnGround(h.owner); + 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); @@ -638,13 +666,13 @@ static void drainGround(Harness h) throws Exception { 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.dropItemsOnGround(h.owner); h.grave.claim(h.helper); h.drain(); verify(h.world, times(1)).dropItem(any(), any()); + 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.dropItemsOnGround(h.owner); h.drain(); + 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()); } @@ -657,7 +685,7 @@ static void drainGround(Harness h) throws Exception { if (mode == 0) event.setCancelled(true); else when(h.world.isChunkLoaded(anyInt(), anyInt())).thenReturn(false); } return null; }).when(h.events).callEvent(any()); } - h.grave.dropItemsOnGround(h.owner); h.drain(); drainGround(h); + 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()); @@ -668,9 +696,9 @@ static void drainGround(Harness h) 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.dropItemsOnGround(h.owner); h.drain(); drainGround(h); + 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.dropItemsOnGround(h.owner); h.drain(); verify(h.world, times(1)).dropItem(any(), any()); verify(h.helper, never()).giveExp(anyInt()); + 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())); } } @@ -680,10 +708,10 @@ static void drainGround(Harness h) throws Exception { ExperienceOrb orb = groundWorld(h); when(h.world.isChunkLoaded(anyInt(), anyInt())).thenReturn(false); var restoration = limitRestoration(h); when(h.plugin.getOldestGrave(any())).thenCallRealMethod(); - h.grave.dropItemsOnGround(h.owner); drainGround(h); + 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.dropItemsOnGround(h.owner); h.grave.claim(h.owner); drainGround(h); + 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); @@ -694,7 +722,7 @@ static void drainGround(Harness h) throws Exception { } @Test void queuedLimitStateSurvivesRestartAndCannotBecomeAReplayableReservation() throws Exception { try (Harness h = new Harness()) { - var restoration = limitRestoration(h); h.grave.dropItemsOnGround(h.owner); drainGround(h); restoration.retire(); + 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()))); @@ -716,7 +744,7 @@ static void drainGround(Harness h) throws Exception { @Test void manualRemovalCancelsOnlyUnstartedLimitPayout() throws Exception { try (Harness h = new Harness()) { - var restoration = limitRestoration(h); h.grave.dropItemsOnGround(h.owner); drainGround(h); + 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()); diff --git a/README.md b/README.md index a745931..482fcb5 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,10 @@ Login sends one useful summary; eligible protection/expiry warnings are combined 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.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`. From 73fd60aec5526eda35ecb323ea397a9bf9241ae4 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:52:35 -0600 Subject: [PATCH 17/46] Preserve synchronous Bukkit grave removal state --- .../gravestonesplus/graves/Grave.java | 7 +++ .../graves/GraveActionsTest.java | 46 +++++++++++++++++++ .../graves/HologramCleanupRegressionTest.java | 4 +- .../graves/RemovalRecoveryRegressionTest.java | 10 ++++ README.md | 2 + 5 files changed, 67 insertions(+), 2 deletions(-) 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 1722cc6..bf60e67 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/Grave.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/Grave.java @@ -516,6 +516,13 @@ public boolean isOwner(String player) { * @param reason Removal reason */ public void removeGrave(final GraveRemoveReason reason) { + // 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)) + plugin.getBukkitScheduler().runTask(plugin, this::cleanupMarker, gravesConfig.getLocation()); + return; + } plugin.getBukkitScheduler().runTask(plugin, () -> removeOwned(reason), gravesConfig.getLocation()); } private void removeOwned(GraveRemoveReason reason) { diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java index 3cfd728..090b769 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java @@ -398,6 +398,14 @@ void drain() throws Exception { } @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); @@ -405,6 +413,44 @@ void drain() throws Exception { 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); } } diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/HologramCleanupRegressionTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/HologramCleanupRegressionTest.java index 9e6e26b..399b8cc 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/HologramCleanupRegressionTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/HologramCleanupRegressionTest.java @@ -76,7 +76,7 @@ private static Hologram create(GraveActionsTest.Harness h, ArmorStand stand) { 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); h.regionWork.remove().run(); + 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(); @@ -127,7 +127,7 @@ private static Hologram create(GraveActionsTest.Harness h, ArmorStand stand) { 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())); - h.regionWork.remove().run(); assertFalse(h.grave.isRemove()); + 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()); } diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RemovalRecoveryRegressionTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RemovalRecoveryRegressionTest.java index f58a390..759f7a2 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RemovalRecoveryRegressionTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RemovalRecoveryRegressionTest.java @@ -186,6 +186,13 @@ var record = new GravesConfig(original.getUuid(), original.getPlayerName(), orig @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()))))); @@ -206,6 +213,9 @@ var record = new GravesConfig(original.getUuid(), original.getPlayerName(), orig 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/README.md b/README.md index 482fcb5..55e2ea9 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,8 @@ NBT routing lists and the configured keep-lore text publish as one immutable rel ### 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 From e0840b83ae486f040422d045f861ecc575b02822 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:57:21 -0600 Subject: [PATCH 18/46] Retain grave expiry schedules for invalid replacement lifetimes --- .../gravestonesplus/graves/Grave.java | 29 ++++--------- .../graves/GraveTimeLimit.java | 41 +++++++++++++++++++ GraveStonesPlus/src/main/resources/Config.yml | 3 +- .../graves/GraveActionsTest.java | 23 +++++++++++ .../graves/GraveTimeLimitTest.java | 25 +++++++++++ README.md | 2 +- 6 files changed, 100 insertions(+), 23 deletions(-) create mode 100644 GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveTimeLimit.java create mode 100644 GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveTimeLimitTest.java 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 bf60e67..e39349d 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/Grave.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/Grave.java @@ -672,27 +672,14 @@ public void checkTimeLimit(int timeLimit) { * as {@code 30m}, {@code 2h}, or {@code 7d} */ public void checkTimeLimit(String timeLimitRaw) { - removeTimer(); expiryDeadline = 0; - 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 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/resources/Config.yml b/GraveStonesPlus/src/main/resources/Config.yml index 01b4fff..e67be58 100644 --- a/GraveStonesPlus/src/main/resources/Config.yml +++ b/GraveStonesPlus/src/main/resources/Config.yml @@ -153,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 diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java index 090b769..4569307 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java @@ -309,6 +309,29 @@ void drain() throws Exception { } } + @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")) { + 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); 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/README.md b/README.md index 55e2ea9..74117d5 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Recovery retains the existing configured `PercentageDrops` chance per stored sta ## 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, invalid and zero lifetimes have no expiry warning. Reload reschedules the existing grave timers, so changed deadlines have distinct reminder identities. +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. From 21337a2c80c981e249e53eab0b3e278b3c68274b Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:09:01 -0600 Subject: [PATCH 19/46] Resolve helper access overloads to exact grave identities --- .../graves/TrustedGraveAccessStore.java | 57 +++++++++++-- .../TrustedGraveLegacyIdentityTest.java | 80 +++++++++++++++++++ README.md | 9 +++ 3 files changed, 138 insertions(+), 8 deletions(-) create mode 100644 GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/TrustedGraveLegacyIdentityTest.java diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/TrustedGraveAccessStore.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/TrustedGraveAccessStore.java index 8839064..9d2f088 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/TrustedGraveAccessStore.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/TrustedGraveAccessStore.java @@ -11,14 +11,18 @@ 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); + 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 TrustedGraveAccessStore(DurableFeatureState state, LongSupplier clock) { this.state = state; this.clock = clock; } public CompletableFuture loaded() { return state.loaded(); } 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) { @@ -77,10 +81,47 @@ public synchronized CompletableFuture revokeForGraveAsync(String grave) return saved; }); } - // Preserve the initially introduced Java signatures for callers using owner/death keys. - public CompletableFuture grantAsync(UUID owner, long time, UUID helper, TrustedGraveAccess.Kind kind, long expires) { return grantAsync(owner + "/" + time, helper, kind, expires); } - public CompletableFuture revokeAsync(UUID owner, long time, UUID helper, TrustedGraveAccess.Kind kind) { return revokeAsync(owner + "/" + time, helper, kind); } - public boolean allows(UUID owner, long time, UUID helper, TrustedGraveAccess.Kind kind) { return allows(owner + "/" + time, helper, kind); } - public List list(UUID owner, long time) { return list(owner + "/" + time); } - public CompletableFuture revokeForGraveAsync(UUID owner, long time) { return revokeForGraveAsync(owner + "/" + time).thenApply(saved -> null); } + /** 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/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/README.md b/README.md index 74117d5..9d9c2e4 100644 --- a/README.md +++ b/README.md @@ -99,3 +99,12 @@ is awaiting restoration admission. Once that record is retired without admission 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. 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. From 0ee9bd2429da01934e525b1135dafef443d09aed Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:16:46 -0600 Subject: [PATCH 20/46] Withhold restored graves when later chunk admission finds a successor --- .../gravestonesplus/GraveStonesPlus.java | 5 ++ .../gravestonesplus/graves/Grave.java | 5 +- .../graves/GraveNavigation.java | 3 +- .../graves/GraveRestoration.java | 18 +++++- .../MarkerRestorationRegressionTest.java | 61 +++++++++++++++++++ README.md | 7 +++ 6 files changed, 95 insertions(+), 4 deletions(-) diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/GraveStonesPlus.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/GraveStonesPlus.java index 16caac6..9889d2e 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/GraveStonesPlus.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/GraveStonesPlus.java @@ -108,6 +108,11 @@ public class GraveStonesPlus extends AdvancedCorePlugin { /** 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) { 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 e39349d..35e0a23 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/Grave.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/Grave.java @@ -584,7 +584,10 @@ private synchronized void retryExpiration(long deadline) { }, 5L, TimeUnit.SECONDS); } private boolean expirationEligible(GraveRemoveReason reason) { - return reason != GraveRemoveReason.EXPIRED || (expiryDeadline > 0 && expiryDeadline <= System.currentTimeMillis()); + 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) { diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveNavigation.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveNavigation.java index 34c8d20..8db8564 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveNavigation.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveNavigation.java @@ -42,7 +42,8 @@ private boolean loading() { || !plugin.getTrustedGraveAccessStore().loaded().isDone() || !plugin.getGraveClaimLedger().loaded().isDone(); } private boolean pending(String identity) { - return plugin.getGraveRestoration() != null && plugin.getGraveRestoration().hasPending(identity); + return plugin.getGraveRestoration() != null && plugin.getGraveRestoration().hasPending(identity) + && !plugin.getGraveRestoration().isConflicted(identity); } public void refresh(Player player) { if (loading()) return; diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java index 123b165..14fc182 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java @@ -17,6 +17,7 @@ public final class GraveRestoration { private final Map pending = new LinkedHashMap<>(); private final Map admitted = new HashMap<>(); private final Set published = 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<>(); @@ -162,7 +163,19 @@ private void processResolved(Iterator batch, String key, GravesConfig re 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))) return; + 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) { @@ -215,7 +228,7 @@ private void persistResolvedRemoval(GravesConfig record) { 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); + pending.remove(key); admitted.remove(key); published.remove(key); conflicts.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); @@ -265,6 +278,7 @@ public synchronized void onChunk(UUID world, int x, int z) { 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/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java index 5c89490..2aca160 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java @@ -647,6 +647,67 @@ void actualRestorationPreservesAdoptedLegacyAndIdentityTaggedDisplaysAfterDelaye 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)); + 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()) { diff --git a/README.md b/README.md index 9d9c2e4..7ec97e5 100644 --- a/README.md +++ b/README.md @@ -108,3 +108,10 @@ timestamp. The whole-grave compatibility revocation future reports unresolved id 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. From 545662e11e33ffc4da5d4fb57d17e5daf9ab9510 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:25:24 -0600 Subject: [PATCH 21/46] Preserve scheduler ownership and unavailable-state navigation during restoration --- .../gravestonesplus/graves/Grave.java | 29 +++---- .../graves/GraveClaimLedger.java | 1 + .../graves/GraveNavigation.java | 2 +- .../graves/GraveRestoration.java | 15 +++- .../graves/TrustedGraveAccessStore.java | 1 + .../graves/GraveRegionOwnershipTest.java | 14 ++-- .../MarkerRestorationRegressionTest.java | 77 ++++++++++++++++--- ...NavigationLegacyCompassRegressionTest.java | 24 ++++++ .../graves/RecoveryCompletionTest.java | 5 +- .../graves/RemovalRecoveryRegressionTest.java | 2 +- README.md | 8 ++ 11 files changed, 143 insertions(+), 35 deletions(-) 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 35e0a23..793c712 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/Grave.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/Grave.java @@ -1066,14 +1066,11 @@ public java.util.concurrent.CompletableFuture recreateAsync(Player acto } }).whenComplete((allowed, failure) -> { if (failure != null || !Boolean.TRUE.equals(allowed)) { result.complete(false); return; } - try { - plugin.getBukkitScheduler().getFoliaLib().getImpl().runAtLocation(gravesConfig.getLocation(), task -> { - synchronized (this) { - if (!recreationStateAllowed(identity)) { result.complete(false); return; } - } - Runnable commit = () -> { + Runnable scheduleCommit = () -> { + try { + plugin.getBukkitScheduler().getFoliaLib().getImpl().runAtLocation(gravesConfig.getLocation(), task -> { synchronized (this) { - if (!recreationStateAllowed(identity) || actor != null && (!actor.isOnline() || !canAdministerHistorical(actor))) { result.complete(false); return; } + if (!recreationStateAllowed(identity)) { result.complete(false); return; } markRecreated(); gravesConfig.setDestroyed(false); gravesConfig.setDestroyedTime(0); if (createMarker) { createGrave(); @@ -1081,12 +1078,18 @@ public java.util.concurrent.CompletableFuture recreateAsync(Player acto } result.complete(true); } - }; - if (actor == null) commit.run(); - else try { plugin.getBukkitScheduler().getFoliaLib().getImpl().runAtEntityWithFallback(actor, ignored -> commit.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); } - }).whenComplete((unused, error) -> { if (error != null) result.completeExceptionally(error); }); + }).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; diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveClaimLedger.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveClaimLedger.java index 7d12dfb..62a61d2 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveClaimLedger.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveClaimLedger.java @@ -29,6 +29,7 @@ public GraveClaimLedger(DurableFeatureState state, java.nio.file.Path archive) { }).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); diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveNavigation.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveNavigation.java index 8db8564..2d53d0f 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveNavigation.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveNavigation.java @@ -39,7 +39,7 @@ public void invalidate(String identity) { } private boolean loading() { return plugin.getGraveRestoration() != null && plugin.getGraveRestoration().isLoading() - || !plugin.getTrustedGraveAccessStore().loaded().isDone() || !plugin.getGraveClaimLedger().loaded().isDone(); + || !plugin.getTrustedGraveAccessStore().isReady() || !plugin.getGraveClaimLedger().isReady(); } private boolean pending(String identity) { return plugin.getGraveRestoration() != null && plugin.getGraveRestoration().hasPending(identity) diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java index 14fc182..17cb59a 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java @@ -140,10 +140,15 @@ private void processResolved(Iterator batch, String key, GravesConfig re 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; } - 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); process(batch); + 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; } @@ -214,6 +219,8 @@ private void processResolved(Iterator batch, String key, GravesConfig re 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); } } diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/TrustedGraveAccessStore.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/TrustedGraveAccessStore.java index 9d2f088..557612f 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/TrustedGraveAccessStore.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/TrustedGraveAccessStore.java @@ -24,6 +24,7 @@ public TrustedGraveAccessStore(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(); diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveRegionOwnershipTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveRegionOwnershipTest.java index 97a87ce..c445622 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveRegionOwnershipTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveRegionOwnershipTest.java @@ -44,12 +44,15 @@ class GraveRegionOwnershipTest { }); h.active.clear(); grave.getGravesConfig().setDestroyed(true); grave.getGravesConfig().setGraveDisplayType(type); - var restored = grave.recreateAsync(h.helper, true); h.drain(); - assertEquals(1, h.regionWork.size()); assertFalse(restored.isDone()); - graveOwner.set(true); h.regionWork.remove().run(); graveOwner.set(false); + 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)); @@ -69,8 +72,9 @@ class GraveRegionOwnershipTest { 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); h.drain(); - h.regionWork.remove().run(); h.entityWork.remove().run(); + 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()); diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java index 2aca160..06bea42 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java @@ -42,7 +42,7 @@ private static final class Marker { 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); + 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()); @@ -401,6 +401,7 @@ void actualRestorationPreservesAdoptedLegacyAndIdentityTaggedDisplaysAfterDelaye 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(); @@ -414,13 +415,70 @@ void actualRestorationPreservesAdoptedLegacyAndIdentityTaggedDisplaysAfterDelaye 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()) { - h.grave.getGravesConfig().setDestroyed(true); when(h.helper.hasPermission(permission)).thenReturn(true); - var recreated=h.grave.recreateAsync(h.helper,false); h.drain(); h.regionWork.remove().run(); h.entityWork.remove().run(); assertTrue(FeaturePersistenceTest.await(recreated)); - h.grave.teleportHistorical(h.helper); h.entityWork.remove().run(); verify(h.platform).teleportAsync(eq(h.helper),any(Location.class)); - h.grave.getGravesConfig().setDestroyed(true); var revoked=h.grave.recreateAsync(h.helper,false); h.drain(); h.regionWork.remove().run(); when(h.helper.hasPermission(permission)).thenReturn(false); h.entityWork.remove().run(); assertFalse(FeaturePersistenceTest.await(revoked)); assertTrue(h.grave.getGravesConfig().isDestroyed()); - clearInvocations(h.platform); h.grave.teleportHistorical(h.helper); h.entityWork.remove().run(); verify(h.platform,never()).teleportAsync(any(),any()); + 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 { @@ -625,7 +683,7 @@ void actualRestorationPreservesAdoptedLegacyAndIdentityTaggedDisplaysAfterDelaye var marker = new Marker(h); when(h.world.isChunkLoaded(anyInt(), anyInt())).thenReturn(false); var ledger = mock(GraveClaimLedger.class); when(ledger.restoration(any())).thenReturn(CompletableFuture.completedFuture(GraveClaimLedger.Restoration.AVAILABLE)); - when(ledger.loaded()).thenReturn(CompletableFuture.completedFuture(true)); when(h.plugin.getGraveClaimLedger()).thenReturn(ledger); + when(ledger.loaded()).thenReturn(CompletableFuture.completedFuture(true)); when(ledger.isReady()).thenReturn(true); 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); when(h.plugin.getGraveRestoration()).thenReturn(restoration); @@ -654,7 +712,7 @@ void actualRestorationPreservesAdoptedLegacyAndIdentityTaggedDisplaysAfterDelaye 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)); + 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); @@ -968,6 +1026,7 @@ private void expirationAdmissionRetry(boolean invalidate) throws Exception { 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 index b9c2592..8dbae17 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/NavigationLegacyCompassRegressionTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/NavigationLegacyCompassRegressionTest.java @@ -77,6 +77,30 @@ class NavigationLegacyCompassRegressionTest { } } + @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); diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RecoveryCompletionTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RecoveryCompletionTest.java index 3b583b1..e1121e8 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RecoveryCompletionTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RecoveryCompletionTest.java @@ -318,8 +318,9 @@ private void reloadPaths(GraveActionsTest.Harness h) throws Exception { 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); h.drain(); - when(h.helper.hasPermission("GraveStonesPlus.Admin")).thenReturn(false); h.regionWork.remove().run(); h.drain(); + 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()); diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RemovalRecoveryRegressionTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RemovalRecoveryRegressionTest.java index 759f7a2..317d4b5 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RemovalRecoveryRegressionTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RemovalRecoveryRegressionTest.java @@ -43,7 +43,7 @@ class RemovalRecoveryRegressionTest { 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); assertTrue(published.await(2, TimeUnit.SECONDS)); h.drain(); + 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)); diff --git a/README.md b/README.md index 7ec97e5..36f128e 100644 --- a/README.md +++ b/README.md @@ -115,3 +115,11 @@ record and helper grants remain retained, but navigation and recovery are unavai 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. From 7aaa72473e87e53b8da70eba600f38ba233cf22a Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:48:30 -0600 Subject: [PATCH 22/46] Keep Folia grave chunk preparation on its region owner --- .../gravestonesplus/graves/Grave.java | 15 ++++++ .../graves/GraveRegionOwnershipTest.java | 49 +++++++++++++++++++ README.md | 8 +++ 3 files changed, 72 insertions(+) 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 793c712..70b72d5 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/Grave.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/Grave.java @@ -138,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() { diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveRegionOwnershipTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveRegionOwnershipTest.java index c445622..f33235b 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveRegionOwnershipTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveRegionOwnershipTest.java @@ -12,6 +12,55 @@ /** Exercises the real marker scheduler, including actors in a different region. */ class GraveRegionOwnershipTest { + @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()) { diff --git a/README.md b/README.md index 36f128e..e96d0d4 100644 --- a/README.md +++ b/README.md @@ -123,3 +123,11 @@ 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. From 430bd581cc7e03b40ac4e95da3bea955866b69a7 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:06:04 -0600 Subject: [PATCH 23/46] Stage complete grave configuration before reload publication --- .../gravestonesplus/GraveStonesPlus.java | 64 +++++++++++---- .../gravestonesplus/config/Config.java | 15 +++- .../gravestonesplus/nbt/NBTConfigManager.java | 12 +++ .../storage/GraveLocations.java | 8 ++ .../graves/RecoveryCompletionTest.java | 78 ++++++++++++++++++- README.md | 12 +++ 6 files changed, 170 insertions(+), 19 deletions(-) diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/GraveStonesPlus.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/GraveStonesPlus.java index 9889d2e..be7499d 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/GraveStonesPlus.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/GraveStonesPlus.java @@ -82,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; @@ -405,12 +411,10 @@ public CompletableFuture reloadAsync() { } try { diskOwner.submit(() -> { - validateReloadFile(configFile.getdFile()); - validateReloadFile(gravesConfig.getdFile()); java.io.File nbtRules = new java.io.File(getDataFolder(), "nbt_rules.yml"); if (!nbtRules.exists()) saveResource("nbt_rules.yml", false); - validateReloadFile(nbtRules); - configFile.reloadData(); gravesConfig.reloadData(); nbtConfigManager.reloadConfig(); return true; + 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."); @@ -423,12 +427,25 @@ public CompletableFuture reloadAsync() { 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); } @@ -443,41 +460,58 @@ public CompletableFuture reloadAsync() { return completion; } - /** Bukkit's convenience YAML loader logs parse failures and returns an empty configuration. */ - private static void validateReloadFile(java.io.File file) { - try { new org.bukkit.configuration.file.YamlConfiguration().load(file); } - catch (java.io.IOException | org.bukkit.configuration.InvalidConfigurationException failure) { + /** 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(); + } public int numberOfGraves(UUID uuid) { int num = 0; 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 24ef952..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(); } } 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 f6f3e05..cda2313 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/nbt/NBTConfigManager.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/nbt/NBTConfigManager.java @@ -19,6 +19,18 @@ public NBTConfigManager(GraveStonesPlus 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()) { 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 cb43cf6..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() { } diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RecoveryCompletionTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RecoveryCompletionTest.java index e1121e8..ee39a13 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RecoveryCompletionTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/RecoveryCompletionTest.java @@ -193,9 +193,11 @@ private void reloadPaths(GraveActionsTest.Harness h) throws Exception { reloadPaths(h); Files.writeString(directory.resolve("Config.yml"), "broken: [\n"); doCallRealMethod().when(h.plugin).reloadAsync(); - assertThrows(ExecutionException.class, () -> FeaturePersistenceTest.await(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.scheduler, never()).runTask(eq(h.plugin), any(Runnable.class)); + verify(h.plugin, never()).reloadAdvancedCore(false); } } @@ -257,8 +259,8 @@ private void reloadPaths(GraveActionsTest.Harness h) throws Exception { 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)); - doThrow(new IllegalStateException("read failed")).when(h.config).reloadData(); reloadPaths(h); + Files.delete(directory.resolve("Config.yml")); doCallRealMethod().when(h.plugin).reloadAsync(); var reload = h.plugin.reloadAsync(); assertThrows(ExecutionException.class, () -> FeaturePersistenceTest.await(reload)); @@ -266,6 +268,76 @@ private void reloadPaths(GraveActionsTest.Harness h) throws Exception { } } + @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); diff --git a/README.md b/README.md index e96d0d4..9981e2d 100644 --- a/README.md +++ b/README.md @@ -131,3 +131,15 @@ 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. From 7ca70ec223f7392f4d8a32a2369cb1c0753bb256 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:49:49 -0600 Subject: [PATCH 24/46] Keep death and respawn callbacks on the player owner --- .../listeners/PlayerDeathListener.java | 50 +++++- .../graves/DeathPlayerOwnershipTest.java | 162 ++++++++++++++++++ README.md | 11 ++ 3 files changed, 214 insertions(+), 9 deletions(-) create mode 100644 GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/DeathPlayerOwnershipTest.java 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 843463d..8c02723 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/PlayerDeathListener.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/PlayerDeathListener.java @@ -3,6 +3,9 @@ import java.util.HashMap; 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; @@ -38,10 +41,25 @@ 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 KeptItems { + final Map slots; + // Carry an undelivered entitlement into the next death without letting an + // already queued respawn callback consume it during that newer death. + KeptItems(KeptItems previous) { slots = previous.slots; } + KeptItems(Map source) { + HashMap snapshot = new HashMap<>(); + source.forEach((slot, item) -> snapshot.put(slot, item.clone())); + slots = Map.copyOf(snapshot); + } + } + 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,6 +96,7 @@ public void onPlayerDeath(PlayerDeathEvent event) { } final Player entity = event.getEntity(); + final String playerName = entity.getName(); if (plugin.numberOfGraves(entity.getUniqueId()) >= plugin.getConfigFile().getGraveLimit()) { Grave oldest = plugin.getOldestGrave(entity.getUniqueId()); if (oldest != null) { @@ -149,7 +168,7 @@ public void onPlayerDeath(PlayerDeathEvent event) { processItem.accept(-5, inv.getItemInOffHand()); // Offhand if (!keepItems.isEmpty()) { - deathItems.put(event.getEntity().getUniqueId(), keepItems); + deathItems.put(playerId, new KeptItems(keepItems)); } final Location emptyBlockFinal = emptyBlock; @@ -158,7 +177,7 @@ public void onPlayerDeath(PlayerDeathEvent event) { @Override public void run() { Grave grave = new Grave(plugin, - new GravesConfig(entity.getUniqueId(), entity.getName(), emptyBlockFinal, itemsWithSlot, + new GravesConfig(playerId, playerName, emptyBlockFinal, itemsWithSlot, droppedExp, deathMessage, System.currentTimeMillis(), false, 0, null, null, plugin.getConfigFile().getGraveDisplayTypeEnum().name())); grave.loadChunk(false); @@ -178,7 +197,14 @@ 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()); @@ -198,11 +224,13 @@ 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() || !deathItems.remove(uuid, pending)) return; PlayerInventory inv = player.getInventory(); - items.forEach((slot, item) -> { + pending.slots.forEach((slot, item) -> { if (item == null || item.getType().isAir()) return; if (slot >= 0) { @@ -245,7 +273,11 @@ public void onRespawn(PlayerRespawnEvent event) { } } }); - }); + }, 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()) { 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..1e6be32 --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/DeathPlayerOwnershipTest.java @@ -0,0 +1,162 @@ +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 -> { h.regionWork.add(call.getArgument(1)); return null; }) + .when(h.scheduler).runTaskLater(eq(h.plugin), any(Runnable.class), eq(2L), any(Location.class)); + 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 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); + 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 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/README.md b/README.md index 9981e2d..1ade7bc 100644 --- a/README.md +++ b/README.md @@ -143,3 +143,14 @@ when the configuration is subsequently saved. Initial startup retains its existi 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. +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. From 36d5a66643c66406c130ab8df24f69400d2ced35 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:54:29 -0600 Subject: [PATCH 25/46] Protect cached grave markers before Folia startup admission --- .../graves/GraveRestoration.java | 27 ++++++++++++++----- .../MarkerRestorationRegressionTest.java | 14 ++++++++-- README.md | 6 +++++ 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java index 17cb59a..514704c 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java @@ -39,12 +39,17 @@ public synchronized void retainLimit(Grave grave) { } public void start() { storage.beginRestoration(); - // Flat-file initialization already loaded/decoded the document. - // Only prepare it inline when lifecycle startup is on the server owner; other contexts use retry's handoff. - // Publish its exact-position index before listeners are registered, without another disk read. - if (!storage.isMySQL() && org.bukkit.Bukkit.isPrimaryThread()) { - try { acceptRecords(cachedFlatRecords()); } - catch (RuntimeException failure) { plugin.getLogger().warning("Cached grave records remain withheld until owner preparation can retry."); } + // 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()) acceptRecords(cached); + } 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(); @@ -85,6 +90,14 @@ private CompletableFuture acceptRecordsOnOwner(GraveStorageManager.Restora catch (RuntimeException rejected) { result.completeExceptionally(rejected); } 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 : concat(records.active(), records.broken())) { + String identity = GraveIdentity.of(grave); + if (identity != null) { pending.put(identity, grave); index(identity, grave); } + } + } private void acceptRecords(GraveStorageManager.RestorationRecords records) { List active = records.active(), broken = records.broken(); storage.retainRestoration(active, broken); @@ -279,7 +292,7 @@ private Material markerMaterial(GravesConfig record) { 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 || !running.compareAndSet(false, true)) return; + 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()); diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java index 06bea42..75c8832 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java @@ -107,8 +107,10 @@ void startupIndexesCachedFlatRecordsBeforeTheDiskOwnerOrArchiveCanRun(Material t } finally { restoration.retire(); release.countDown(); blocked.get(2, TimeUnit.SECONDS); } } } - @Test void startupOffOwnerDefersCachedRecordPreparation() throws Exception { + @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)); @@ -117,7 +119,15 @@ void startupIndexesCachedFlatRecordsBeforeTheDiskOwnerOrArchiveCanRun(Material t restoration.start(); assertTrue(restoration.isLoading()); assertEquals(1, owners.size()); verify(h.grave.getGravesConfig().getItems().get(4), never()).serialize(); - assertFalse(restoration.hasPending(h.identity())); + 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); } diff --git a/README.md b/README.md index 1ade7bc..3621ab7 100644 --- a/README.md +++ b/README.md @@ -154,3 +154,9 @@ death is ineligible for a grave or captures no kept items; an undelivered snapsh 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. + +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. From c8ed7d65f65c5a3d21cf5fbe3d53684cfa931c51 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:29:24 -0600 Subject: [PATCH 26/46] Retain kept-item entitlements across successive deaths --- .../listeners/PlayerDeathListener.java | 63 ++++++++++++------- .../graves/DeathPlayerOwnershipTest.java | 40 ++++++++++++ README.md | 7 ++- 3 files changed, 87 insertions(+), 23 deletions(-) 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 8c02723..84debc4 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/PlayerDeathListener.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/PlayerDeathListener.java @@ -1,6 +1,7 @@ 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; @@ -44,16 +45,18 @@ public PlayerDeathListener(GraveStonesPlus plugin) { // 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 KeptItems { - final Map slots; - // Carry an undelivered entitlement into the next death without letting an - // already queued respawn callback consume it during that newer death. - KeptItems(KeptItems previous) { slots = previous.slots; } - KeptItems(Map source) { + final List> batches; + // A new identity fences callbacks even when the new death captures no items. + KeptItems(KeptItems previous) { batches = previous.batches; } + KeptItems(KeptItems previous, Map source) { HashMap snapshot = new HashMap<>(); source.forEach((slot, item) -> snapshot.put(slot, item.clone())); - slots = Map.copyOf(snapshot); + List> merged = previous == null ? new ArrayList<>() : new ArrayList<>(previous.batches); + merged.add(Map.copyOf(snapshot)); + batches = List.copyOf(merged); } } + private static final int MAX_PENDING_KEPT_SNAPSHOTS = 8; private final ConcurrentMap deathItems = new ConcurrentHashMap<>(); @EventHandler(priority = EventPriority.HIGHEST) @@ -134,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 @@ -168,8 +167,21 @@ public void onPlayerDeath(PlayerDeathEvent event) { processItem.accept(-5, inv.getItemInOffHand()); // Offhand if (!keepItems.isEmpty()) { - deathItems.put(playerId, new KeptItems(keepItems)); + boolean[] admitted = { false }; + 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] = true; + return merged; + }); + if (!admitted[0]) { + plugin.getLogger().warning("Kept items not retained: pending snapshot limit reached"); + return; + } } + if (plugin.getConfigFile().isKeepAllExp()) event.setDroppedExp(droppedExp); + event.setDroppedExp(0); // Clear Bukkit's default dropped experience + event.getDrops().clear(); // Clear Bukkit's default dropped items final Location emptyBlockFinal = emptyBlock; @@ -224,55 +236,55 @@ private boolean doesItemMatchAnyRule(ItemStack item, List keepNbtRules) public void onRespawn(PlayerRespawnEvent event) { Player player = event.getPlayer(); UUID uuid = player.getUniqueId(); - KeptItems pending = deathItems.get(uuid); + KeptItems pending = deathItems.get(uuid); if (pending != null) { try { plugin.getBukkitScheduler().runTask(plugin, () -> { if (!plugin.isEnabled() || !player.isOnline() || player.isDead() || !deathItems.remove(uuid, pending)) return; PlayerInventory inv = player.getInventory(); - pending.slots.forEach((slot, item) -> { + pending.batches.forEach(batch -> batch.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. @@ -297,6 +309,13 @@ public void onRespawn(PlayerRespawnEvent event) { } } + 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. diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/DeathPlayerOwnershipTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/DeathPlayerOwnershipTest.java index 1e6be32..95d4748 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/DeathPlayerOwnershipTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/DeathPlayerOwnershipTest.java @@ -99,10 +99,50 @@ void offlineOrDisabledCallbackRetainsSnapshotAndAnotherPlayerCannotConsumeIt(boo 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()); + } + } + @Test void snapshotBoundLeavesNewDeathDropsAndExperienceUntouchedAndRetainsEarlierEntitlements() 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); + 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()); + 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()); } } @Test void simultaneousPendingPlayersRestoreTheirOwnSnapshotsOnly() throws Exception { diff --git a/README.md b/README.md index 3621ab7..736dc93 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,12 @@ 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. +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 From 61ae24472ca9945d4f7c67242052602690b55b71 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:38:06 -0600 Subject: [PATCH 27/46] Persist restored display identities and defer login summaries until ready --- .../graves/GraveReminderStore.java | 1 + .../graves/GraveRestoration.java | 44 ++++++++- .../listeners/GraveReminderListener.java | 35 +++++-- .../storage/GraveStorageBackend.java | 5 + .../storage/GraveStorageManager.java | 8 ++ .../storage/GravesMySQLTable.java | 22 +++++ .../storage/MySQLGraveStorageBackend.java | 5 + .../graves/GraveLoginReadinessTest.java | 94 +++++++++++++++++++ .../MarkerRestorationRegressionTest.java | 37 ++++++++ .../MySQLRestorationOwnershipTest.java | 22 ++++- README.md | 8 ++ 11 files changed, 270 insertions(+), 11 deletions(-) create mode 100644 GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveLoginReadinessTest.java diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveReminderStore.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveReminderStore.java index a20eea3..3b396ec 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveReminderStore.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveReminderStore.java @@ -17,6 +17,7 @@ public GraveReminderStore(GraveStonesPlus plugin) { 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) { diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java index 514704c..5abee89 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java @@ -17,6 +17,8 @@ public final class GraveRestoration { private final Map pending = new LinkedHashMap<>(); private final Map admitted = new HashMap<>(); private final Set published = new HashSet<>(); + 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. @@ -124,7 +126,7 @@ private synchronized void cycle() { private void process(Iterator batch) { if (retired || !plugin.isEnabled() || !batch.hasNext()) { running.set(false); return; } String key = batch.next(); GravesConfig record; - synchronized (this) { record = pending.get(key); } + synchronized (this) { record = savingDisplays.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; } @@ -170,6 +172,7 @@ private void processResolved(Iterator batch, String key, GravesConfig re 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); @@ -239,16 +242,53 @@ private void processResolved(Iterator batch, String key, GravesConfig re } if (!loaded) return; grave.loadBlockMeta(loc.getBlock()); grave.removeHologramsAround(); grave.checkBlockDisplayAndFixIfMissing(); grave.createHologram(); - resolved(key, record); + 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); + 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); diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/GraveReminderListener.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/GraveReminderListener.java index 0bb8488..440e0f4 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/GraveReminderListener.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/GraveReminderListener.java @@ -18,6 +18,7 @@ public final class GraveReminderListener implements Listener { private com.bencodez.simpleapi.folialib.wrapper.task.WrappedTask task; private final Map graveCursors = new java.util.concurrent.ConcurrentHashMap<>(); private final Set scheduled = java.util.concurrent.ConcurrentHashMap.newKeySet(); + private static final int LOGIN_PENDING = Integer.MIN_VALUE; public GraveReminderListener(GraveStonesPlus plugin) { this.plugin = plugin; } public void start() { running = true; long expected = ++generation; @@ -26,15 +27,22 @@ public void start() { List online = new ArrayList<>(Bukkit.getOnlinePlayers()); if (online.isEmpty()) return; int count = Math.min(16, online.size()); - for (int i = 0; i < count; i++) schedule(online.get(Math.floorMod(cursor++, online.size())), expected, false); + 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) { schedule(event.getPlayer(), generation, true); } - private void schedule(Player player, long expected, boolean login) { + @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) { if (scheduled.size() >= 128 || !scheduled.add(player.getUniqueId())) return; plugin.getBukkitScheduler().runTask(plugin, () -> { - try { if (running && generation == expected && player.isOnline()) checkPlayer(player, login); } + try { if (running && generation == expected && player.isOnline()) checkPlayer(player); } finally { scheduled.remove(player.getUniqueId()); } }, player); } @@ -42,16 +50,17 @@ private void schedule(Player player, long expected, boolean login) { scheduled.remove(event.getPlayer().getUniqueId()); graveCursors.remove(event.getPlayer().getUniqueId()); plugin.getGraveNavigation().forget(event.getPlayer()); } - public void checkPlayer(Player player) { checkPlayer(player, false); } - private void checkPlayer(Player player, boolean login) { + public void checkPlayer(Player player) { if (!running || !player.isOnline()) return; + if (!readyForLoginSummary()) return; + boolean loginSummary = graveCursors.getOrDefault(player.getUniqueId(), 0) == LOGIN_PENDING; plugin.getGraveNavigation().refresh(player); 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()); - if (graveCursors.size() < 4096 || graveCursors.containsKey(player.getUniqueId())) graveCursors.put(player.getUniqueId(), start + examined); for (int i = 0; i < examined; i++) { Grave grave = owned.get(Math.floorMod(start + i, owned.size())); if (!plugin.isLiveGrave(grave)) continue; @@ -67,11 +76,21 @@ private void checkPlayer(Player player, boolean login) { player.sendMessage("\u00a7eGraves: " + details + (fresh.size() > 3 ? "; +" + (fresh.size() - 3) + " deadlines" : "") + ". /gravestonesplus graves"); return true; }); - if (login && sent.isEmpty() && player.isOnline()) { + 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; 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 96366e1..287b277 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GraveStorageBackend.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GraveStorageBackend.java @@ -45,6 +45,11 @@ default void saveSnapshot(List active, List broken) saveGraves(active); } + /** 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"); 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 612f852..911d3ae 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GraveStorageManager.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GraveStorageManager.java @@ -229,6 +229,14 @@ public synchronized void saveBrokenGravesFromObjects(List brokenGraves) { } } + /** 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( 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 7753280..0e1f328 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GravesMySQLTable.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GravesMySQLTable.java @@ -204,6 +204,28 @@ public void updateExistingGrave(GravesConfig grave, boolean broken) { } } + /** 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) 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 69345fa..a3cc206 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/MySQLGraveStorageBackend.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/MySQLGraveStorageBackend.java @@ -71,6 +71,11 @@ public void upsertGrave(GravesConfig grave, boolean 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); 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/MarkerRestorationRegressionTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java index 75c8832..4a8eab6 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java @@ -235,6 +235,43 @@ void pendingPhysicsRepairRestoresOnlyItsStillOwnedMarker(boolean replaced) throw } } @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()); diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/MySQLRestorationOwnershipTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/MySQLRestorationOwnershipTest.java index 2f78318..db6eaaf 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/MySQLRestorationOwnershipTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/MySQLRestorationOwnershipTest.java @@ -45,7 +45,7 @@ static final class Fixture implements AutoCloseable { final GraveStonesPlus plugin = mock(GraveStonesPlus.class); final BukkitScheduler scheduler = mock(BukkitScheduler.class); final Queue ownerTasks = new ConcurrentLinkedQueue<>(); - final GravesMySQLTable table = mock(GravesMySQLTable.class); + 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); @@ -101,6 +101,26 @@ private static void recreation(Fixture h) throws Exception { 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); diff --git a/README.md b/README.md index 736dc93..9c3def0 100644 --- a/README.md +++ b/README.md @@ -165,3 +165,11 @@ 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. From d9584d6543600f6e27c0d8407441a1edcd422fce Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:52:19 -0600 Subject: [PATCH 28/46] Validate grave replacement before enforcing death limits --- .../listeners/PlayerDeathListener.java | 84 ++++++---- .../graves/DeathPlayerOwnershipTest.java | 148 +++++++++++++++++- README.md | 12 ++ 3 files changed, 216 insertions(+), 28 deletions(-) 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 84debc4..ba9e7b9 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/PlayerDeathListener.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/PlayerDeathListener.java @@ -44,17 +44,27 @@ public PlayerDeathListener(GraveStonesPlus plugin) { // 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; + 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) { - HashMap snapshot = new HashMap<>(); - source.forEach((slot, item) -> snapshot.put(slot, item.clone())); - List> merged = previous == null ? new ArrayList<>() : new ArrayList<>(previous.batches); - merged.add(Map.copyOf(snapshot)); + 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<>(); @@ -100,17 +110,6 @@ public void onPlayerDeath(PlayerDeathEvent event) { final Player entity = event.getEntity(); final String playerName = entity.getName(); - if (plugin.numberOfGraves(entity.getUniqueId()) >= plugin.getConfigFile().getGraveLimit()) { - Grave oldest = plugin.getOldestGrave(entity.getUniqueId()); - if (oldest != null) { - if (plugin.getConfigFile().isDropItemsOnGraveRemoval()) { - oldest.dropAndRemoveForLimit(entity); - } else { - oldest.removeGrave(GraveRemoveReason.LIMIT_REACHED); - entity.sendMessage(MessageAPI.colorize(plugin.getConfigFile().getFormatGraveLimitBreak())); - } - } - } Location emptyBlock = null; if (deathLocation.getBlock().isEmpty() && deathLocation.getBlockY() > deathLocation.getWorld().getMinHeight() @@ -150,7 +149,7 @@ public void onPlayerDeath(PlayerDeathEvent event) { 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); + case GRAVE_NBT, GRAVE_DEFAULT -> itemsWithSlot.put(idx, item.clone()); default -> { } } }; @@ -166,25 +165,45 @@ public void onPlayerDeath(PlayerDeathEvent event) { processItem.accept(-4, inv.getBoots()); // Boots processItem.accept(-5, inv.getItemInOffHand()); // Offhand - if (!keepItems.isEmpty()) { - boolean[] admitted = { false }; + // 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] = true; + admitted[0] = merged; return merged; }); - if (!admitted[0]) { + if (admitted[0] == null) { plugin.getLogger().warning("Kept items not retained: pending snapshot limit reached"); return; } } - if (plugin.getConfigFile().isKeepAllExp()) event.setDroppedExp(droppedExp); - event.setDroppedExp(0); // Clear Bukkit's default dropped experience - event.getDrops().clear(); // Clear Bukkit's default dropped items final Location emptyBlockFinal = emptyBlock; + boolean limitChecked = false; + try { + if (plugin.numberOfGraves(playerId) >= plugin.getConfigFile().getGraveLimit()) { + Grave oldest = plugin.getOldestGrave(playerId); + if (oldest != null) { + if (plugin.getConfigFile().isDropItemsOnGraveRemoval()) { + oldest.dropAndRemoveForLimit(entity); + } else { + oldest.removeGrave(GraveRemoveReason.LIMIT_REACHED); + entity.sendMessage(MessageAPI.colorize(plugin.getConfigFile().getFormatGraveLimitBreak())); + } + } + } + limitChecked = true; + } finally { + if (!limitChecked && admitted[0] != null) rollbackKeptBatch(playerId, admitted[0]); + } + event.setDroppedExp(0); + event.getDrops().clear(); + if (admitted[0] != null) admitted[0].newest().accepted = true; + plugin.getBukkitScheduler().runTaskLater(plugin, new Runnable() { @Override public void run() { @@ -221,10 +240,21 @@ public void run() { plugin.getLogger().info("Grave: " + emptyBlockFinal.toString()); } - }, 2, emptyBlockFinal); + }, 2L, emptyBlockFinal); } + 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); @@ -240,9 +270,9 @@ public void onRespawn(PlayerRespawnEvent event) { if (pending != null) { try { plugin.getBukkitScheduler().runTask(plugin, () -> { - if (!plugin.isEnabled() || !player.isOnline() || player.isDead() || !deathItems.remove(uuid, pending)) return; + if (!plugin.isEnabled() || !player.isOnline() || player.isDead() || !pending.ready() || !deathItems.remove(uuid, pending)) return; PlayerInventory inv = player.getInventory(); - pending.batches.forEach(batch -> batch.forEach((slot, item) -> { + pending.batches.forEach(batch -> batch.items.forEach((slot, item) -> { if (item == null || item.getType().isAir()) return; if (slot >= 0) { diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/DeathPlayerOwnershipTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/DeathPlayerOwnershipTest.java index 95d4748..fb2a1ff 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/DeathPlayerOwnershipTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/DeathPlayerOwnershipTest.java @@ -128,16 +128,25 @@ void offlineOrDisabledCallbackRetainsSnapshotAndAnotherPlayerCannotConsumeIt(boo respawn(listener, h.owner); assertTrue(h.entityWork.isEmpty()); } } - @Test void snapshotBoundLeavesNewDeathDropsAndExperienceUntouchedAndRetainsEarlierEntitlements() throws Exception { + @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); @@ -145,6 +154,143 @@ void offlineOrDisabledCallbackRetainsSnapshotAndAnotherPlayerCannotConsumeIt(boo 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); + listener.onPlayerDeath(event); + assertTrue(drops.isEmpty()); verify(event).setDroppedExp(0); + if (dropOldContents) { + verify(oldest, times(1)).dropAndRemoveForLimit(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).dropAndRemoveForLimit(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); + } + } + @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); diff --git a/README.md b/README.md index 9c3def0..4011728 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,18 @@ retained rather than discarded solely to invalidate the callback. The existing f 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. +This preflight change does not yet count unpublished/restoring graves toward the +limit or add 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 From 4749375a326dc34838eb16c4fdf055e353b966da Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:04:07 -0600 Subject: [PATCH 29/46] Admit grave creation before consuming death drops --- .../listeners/PlayerDeathListener.java | 101 ++++++++++---- .../graves/DeathPlayerOwnershipTest.java | 123 +++++++++++++++++- README.md | 6 + 3 files changed, 206 insertions(+), 24 deletions(-) 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 ba9e7b9..6926307 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/PlayerDeathListener.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/PlayerDeathListener.java @@ -183,28 +183,8 @@ public void onPlayerDeath(PlayerDeathEvent event) { final Location emptyBlockFinal = emptyBlock; - boolean limitChecked = false; - try { - if (plugin.numberOfGraves(playerId) >= plugin.getConfigFile().getGraveLimit()) { - Grave oldest = plugin.getOldestGrave(playerId); - if (oldest != null) { - if (plugin.getConfigFile().isDropItemsOnGraveRemoval()) { - oldest.dropAndRemoveForLimit(entity); - } else { - oldest.removeGrave(GraveRemoveReason.LIMIT_REACHED); - entity.sendMessage(MessageAPI.colorize(plugin.getConfigFile().getFormatGraveLimitBreak())); - } - } - } - limitChecked = true; - } finally { - if (!limitChecked && admitted[0] != null) rollbackKeptBatch(playerId, admitted[0]); - } - event.setDroppedExp(0); - event.getDrops().clear(); - if (admitted[0] != null) admitted[0].newest().accepted = true; - plugin.getBukkitScheduler().runTaskLater(plugin, new Runnable() { + CreationHandoff handoff = new CreationHandoff(plugin, new Runnable() { @Override public void run() { Grave grave = new Grave(plugin, @@ -240,10 +220,87 @@ public void run() { plugin.getLogger().info("Grave: " + emptyBlockFinal.toString()); } - }, 2L, 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 (plugin.numberOfGraves(playerId) >= plugin.getConfigFile().getGraveLimit()) { + Grave oldest = plugin.getOldestGrave(playerId); + if (oldest != null) { + if (plugin.getConfigFile().isDropItemsOnGraveRemoval()) { + oldest.dropAndRemoveForLimit(entity); + } 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]); + } + } } + /** A location callback may run before the player region finishes the death event. + * 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. */ + private static final class CreationHandoff implements Runnable { + private static final int PREPARING = 0, ACCEPTED = 1, RUNNING = 2, FINISHED = 3; + private final GraveStonesPlus plugin; + private final Runnable creation; + 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<>(); + CreationHandoff(GraveStonesPlus plugin, Runnable creation) { + this.plugin = plugin; this.creation = creation; + } + void schedule(Location location) { + try { + // 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; } + } + void accept() { phase.compareAndSet(PREPARING, ACCEPTED); } + void cancel() { + phase.set(FINISHED); + 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(); } + } + } + 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. diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/DeathPlayerOwnershipTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/DeathPlayerOwnershipTest.java index fb2a1ff..8ab4133 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/DeathPlayerOwnershipTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/DeathPlayerOwnershipTest.java @@ -38,8 +38,13 @@ private static void capture(GraveActionsTest.Harness h, PlayerDeathListener list 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 -> { h.regionWork.add(call.getArgument(1)); return null; }) - .when(h.scheduler).runTaskLater(eq(h.plugin), any(Runnable.class), eq(2L), any(Location.class)); + 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); @@ -271,6 +276,120 @@ void failedLimitPreflightRollsBackOnlyNewKeptBatchAndCannotPayBeforeDropsClear(b 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); diff --git a/README.md b/README.md index 4011728..049a78a 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,12 @@ 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. +The death handler obtains a cancellable location task before removing an old grave +or 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 the death handler accepts the capture, then creates once and cancels +its task. Failed limit preflight also cancels that callback. The existing two-second +creation delay is preserved; the task repeats only while capture is being prepared. This preflight change does not yet count unpublished/restoring graves toward the limit or add durable creation intents: failure or shutdown after death capture remains a separate legacy lifecycle limitation. From eadac739fd173e0338c545ac5d1c873016bb41f4 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:18:58 -0600 Subject: [PATCH 30/46] Admit administrative grave creation before limit removal --- .../commands/CommandLoader.java | 31 ++++--- .../graves/GraveCreationTask.java | 50 +++++++++++ .../listeners/PlayerDeathListener.java | 48 +--------- .../graves/AdminCreationAdmissionTest.java | 90 +++++++++++++++++++ README.md | 9 +- 5 files changed, 166 insertions(+), 62 deletions(-) create mode 100644 GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveCreationTask.java create mode 100644 GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/AdminCreationAdmissionTest.java 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 6c8c864..fb7b1e5 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/commands/CommandLoader.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/commands/CommandLoader.java @@ -32,6 +32,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.graves.TrustedGraveAccess; import com.bencodez.gravestonesplus.storage.GravesConfig; @@ -549,19 +550,9 @@ 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.dropAndRemoveForLimit(null); - } else oldest.removeGrave(GraveRemoveReason.LIMIT_REACHED); - } - } - final Location emptyBlockFinal = emptyBlock; - plugin.getBukkitScheduler().runTaskLater(plugin, () -> { + GraveCreationTask creation = new GraveCreationTask(plugin, () -> { Grave grave = new Grave(plugin, new GravesConfig(uuid, name, emptyBlockFinal, itemsWithSlot, droppedExp, deathMessage, System.currentTimeMillis(), false, 0, null, null, @@ -576,7 +567,23 @@ 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 (plugin.numberOfGraves(uuid) >= plugin.getConfigFile().getGraveLimit()) { + Grave oldest = plugin.getOldestGrave(uuid); + if (oldest != null) { + if (plugin.getConfigFile().isDropItemsOnGraveRemoval()) { + oldest.dropAndRemoveForLimit(null); + } 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/graves/GraveCreationTask.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveCreationTask.java new file mode 100644 index 0000000..0b52477 --- /dev/null +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveCreationTask.java @@ -0,0 +1,50 @@ +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 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; + } + public void schedule(Location location) { + try { + // 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); + 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/listeners/PlayerDeathListener.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/PlayerDeathListener.java index 6926307..c9ee73a 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/PlayerDeathListener.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/PlayerDeathListener.java @@ -26,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; @@ -184,7 +185,7 @@ public void onPlayerDeath(PlayerDeathEvent event) { final Location emptyBlockFinal = emptyBlock; - CreationHandoff handoff = new CreationHandoff(plugin, new Runnable() { + GraveCreationTask handoff = new GraveCreationTask(plugin, new Runnable() { @Override public void run() { Grave grave = new Grave(plugin, @@ -256,51 +257,6 @@ public void run() { } - /** A location callback may run before the player region finishes the death event. - * 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. */ - private static final class CreationHandoff implements Runnable { - private static final int PREPARING = 0, ACCEPTED = 1, RUNNING = 2, FINISHED = 3; - private final GraveStonesPlus plugin; - private final Runnable creation; - 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<>(); - CreationHandoff(GraveStonesPlus plugin, Runnable creation) { - this.plugin = plugin; this.creation = creation; - } - void schedule(Location location) { - try { - // 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; } - } - void accept() { phase.compareAndSet(PREPARING, ACCEPTED); } - void cancel() { - phase.set(FINISHED); - 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(); } - } - } - 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. 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..c2d41f3 --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/AdminCreationAdmissionTest.java @@ -0,0 +1,90 @@ +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.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); + } + @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).dropAndRemoveForLimit(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; + }); + 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).dropAndRemoveForLimit(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/README.md b/README.md index 049a78a..96df932 100644 --- a/README.md +++ b/README.md @@ -167,11 +167,12 @@ 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. -The death handler obtains a cancellable location task before removing an old grave -or consuming drops. Rejected scheduling preserves the native drops and XP and +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 the death handler accepts the capture, then creates once and cancels -its task. Failed limit preflight also cancels that callback. The existing two-second +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. This preflight change does not yet count unpublished/restoring graves toward the limit or add durable creation intents: failure or shutdown after death capture From e141b290879e2cf9f67b1698a12decc093b7737a Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:26:58 -0600 Subject: [PATCH 31/46] Keep compass refresh failures from interrupting grave removal --- .../graves/GraveNavigation.java | 14 ++++++- ...NavigationLegacyCompassRegressionTest.java | 40 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveNavigation.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveNavigation.java index 2d53d0f..8916487 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveNavigation.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveNavigation.java @@ -35,7 +35,19 @@ public void invalidate(String identity) { if (identity == null) return; selected.values().removeIf(identity::equals); // Inventory changes belong to each player's entity owner, not the grave region. - for (Player player : Bukkit.getOnlinePlayers()) plugin.getBukkitScheduler().runTask(plugin, () -> refresh(player), player); + for (Player player : Bukkit.getOnlinePlayers()) { + try { + plugin.getBukkitScheduler().runTask(plugin, () -> { + try { refresh(player); } + catch (RuntimeException failure) { reportRefreshFailure(); } + }, player); + } catch (RuntimeException failure) { reportRefreshFailure(); } + } + } + private void reportRefreshFailure() { + // Disconnect/shutdown can reject an owner task. Compass maintenance must + // never interrupt the authoritative removal or another player's refresh. + plugin.getLogger().fine("Grave compass refresh unavailable; deferred to the next player refresh"); } private boolean loading() { return plugin.getGraveRestoration() != null && plugin.getGraveRestoration().isLoading() diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/NavigationLegacyCompassRegressionTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/NavigationLegacyCompassRegressionTest.java index 8dbae17..de84394 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/NavigationLegacyCompassRegressionTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/NavigationLegacyCompassRegressionTest.java @@ -13,6 +13,46 @@ /** 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).runTask(eq(h.plugin), any(Runnable.class), eq(h.helper)); + verify(h.plugin).removeGrave(h.grave); + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void refreshFailureCannotAbortIdentityInvalidation(boolean inline) 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)); + when(h.ownerInventory.getContents()).thenThrow(new IllegalStateException("retired inventory")); + if (inline) doAnswer(call -> { ((Runnable) call.getArgument(1)).run(); return null; }) + .when(h.scheduler).runTask(eq(h.plugin), any(Runnable.class), any(org.bukkit.entity.Entity.class)); + + assertDoesNotThrow(() -> h.navigation.invalidate(h.identity())); + if (!inline) while (!h.entityWork.isEmpty()) assertDoesNotThrow(h.entityWork.remove()::run); + + assertNull(h.navigation.target(h.owner)); + verify(h.helperInventory).getContents(); + } + } + @Test void exactPendingLegacyCompassSurvivesAfterBackendLoadingCompletes() throws Exception { try (var h = new GraveActionsTest.Harness()) { h.active.clear(); From 21db06043c49301bca20a568607b3943159b9b7f Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:35:03 -0600 Subject: [PATCH 32/46] Retain recovery snapshots when MySQL grave writes fail --- .../storage/GravesMySQLTable.java | 48 +++++++ .../storage/MySQLGraveStorageBackend.java | 20 ++- .../MySQLRestorationOwnershipTest.java | 119 ++++++++++++++++++ README.md | 2 + 4 files changed, 177 insertions(+), 12 deletions(-) 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 0e1f328..b520e3a 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GravesMySQLTable.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GravesMySQLTable.java @@ -116,7 +116,17 @@ public void debug(String msg) { * @param broken whether grave is broken */ public void upsertGrave(GravesConfig grave, boolean broken) { + 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 +147,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 +166,17 @@ public void upsertGrave(GravesConfig grave, boolean broken) { * @param broken whether grave is broken */ public void updateExistingGrave(GravesConfig grave, boolean broken) { + 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 +200,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())); @@ -200,6 +223,7 @@ 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); } } @@ -264,7 +288,17 @@ public void saveRecreatedGrave(GravesConfig grave) { * @param broken new broken flag */ public void setBroken(GravesConfig grave, boolean broken) { + 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; } @@ -278,6 +312,7 @@ 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()); @@ -290,6 +325,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); } } @@ -300,7 +336,17 @@ public void setBroken(GravesConfig grave, boolean broken) { * @param grave grave config */ public void deleteGrave(GravesConfig grave) { + 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; } @@ -314,6 +360,7 @@ 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.getWorldId().toString()); @@ -325,6 +372,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); } } 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 a3cc206..5d0a387 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/MySQLGraveStorageBackend.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/MySQLGraveStorageBackend.java @@ -66,9 +66,8 @@ 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) { @@ -83,23 +82,20 @@ public void upsertGrave(GravesConfig grave, boolean broken) { @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 diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/MySQLRestorationOwnershipTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/MySQLRestorationOwnershipTest.java index db6eaaf..58ec31c 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/MySQLRestorationOwnershipTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/MySQLRestorationOwnershipTest.java @@ -16,6 +16,125 @@ 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) -> { diff --git a/README.md b/README.md index 96df932..93298f9 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,8 @@ is awaiting restoration admission. Once that record is retired without admission 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. + 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 From 8cbb0d1efe3f8dd534a43518e9c058ae6ae6b6f8 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:55:34 -0600 Subject: [PATCH 33/46] Preserve newly captured graves when shutdown interrupts restoration --- .../storage/FlatFileGraveStorageBackend.java | 15 +++ .../storage/GraveStorageBackend.java | 5 + .../storage/GraveStorageManager.java | 12 +-- .../gravestonesplus/storage/GravesConfig.java | 8 ++ .../storage/StartupGraveDocument.java | 99 +++++++++++++++++++ .../MarkerRestorationRegressionTest.java | 35 +++++++ .../storage/StartupGraveDocumentTest.java | 63 ++++++++++++ 7 files changed, 231 insertions(+), 6 deletions(-) create mode 100644 GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/StartupGraveDocument.java create mode 100644 GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/StartupGraveDocumentTest.java 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 e9c3b60..fd4d72b 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/FlatFileGraveStorageBackend.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/FlatFileGraveStorageBackend.java @@ -53,6 +53,21 @@ 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 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 287b277..215355a 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GraveStorageBackend.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GraveStorageBackend.java @@ -45,6 +45,11 @@ default void saveSnapshot(List active, List 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"); 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 911d3ae..84a74e5 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GraveStorageManager.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GraveStorageManager.java @@ -345,14 +345,13 @@ private void flushRetained() { synchronized (this) { if (backendClosed) return; loading = restorationLoading; - recovery = loading ? null : retained; - if (!loading) retained = null; + 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) { - // Shutdown during restoration must not publish an incomplete grave index. - closeBackendIfRequested(true); + if (loading && recovery == null) { + closeBackendIfRequested(); return; } if (recovery == null) { @@ -365,7 +364,8 @@ private void flushRetained() { for (GravesConfig grave : recovery.active()) backend.upsertGrave(grave, false); for (GravesConfig grave : recovery.broken()) backend.upsertGrave(grave, true); } else { - backend.saveSnapshot(recovery.active(), recovery.broken()); + 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; } 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 c901770..ac7280d 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GravesConfig.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GravesConfig.java @@ -43,6 +43,14 @@ String encodedItemsForStorage() { catch (IOException failure) { throw new java.io.UncheckedIOException("Could not encode grave items", failure); } } + Map preparedRecordForStorage() { + if (preparationFailure != null) throw preparationFailure; + if (preparedItems == 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; 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/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java index 4a8eab6..441fc4c 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java @@ -576,6 +576,41 @@ var record = h.grave.getGravesConfig(); record.setDestroyed(true); } } + @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()); 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")); + } +} From 8b81ac4058d64b3f0d6a4cdd7d1776a83459b929 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:00:57 -0600 Subject: [PATCH 34/46] Retain grave expiry timers when replacement deadlines overflow --- .../com/bencodez/gravestonesplus/graves/Grave.java | 14 +++++++++----- .../gravestonesplus/graves/GraveActionsTest.java | 2 +- 2 files changed, 10 insertions(+), 6 deletions(-) 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 70b72d5..295e5ab 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/Grave.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/Grave.java @@ -640,15 +640,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 = GraveDeadlines.deadline(deathTime, durationMillis); + if (expireTime <= 0) { + plugin.debug("Invalid grave expiry deadline; retaining the existing expiry schedule."); + return; + } expiryDeadline = expireTime; - if (expireTime <= 0) return; + if (expirationTask != null && !expirationTask.isDone()) expirationTask.cancel(false); long delay = expireTime - System.currentTimeMillis(); if (delay <= 0) { @@ -719,6 +719,10 @@ public void checkTimeLimit(ParsedDuration timeLimit) { long deathTime = gravesConfig.getTime(); 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()) { diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java index 4569307..c25a697 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java @@ -318,7 +318,7 @@ void drain() throws Exception { 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")) { + 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()); From 182b82a5c928c4588e0d9ca28d258f849ed648c1 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:08:44 -0600 Subject: [PATCH 35/46] Dispatch distant grave removal on the marker region --- .../gravestonesplus/graves/Grave.java | 24 ++++++++--- .../graves/GraveActionsTest.java | 42 +++++++++++++++++++ 2 files changed, 60 insertions(+), 6 deletions(-) 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 295e5ab..4cb7220 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/Grave.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/Grave.java @@ -543,15 +543,27 @@ public void removeGrave(final GraveRemoveReason reason) { private void removeOwned(GraveRemoveReason reason) { if (removeState(reason, () -> true)) cleanupMarker(); } - /** Player actions recheck permission and online state on the entity owner. */ + /** 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 (removeState(reason, () -> player.isOnline() && plugin.isLiveGrave(this) - && (isOwner(player) || canAdminister(player)))) { - plugin.getBukkitScheduler().runTask(plugin, this::cleanupMarker, gravesConfig.getLocation()); - player.sendMessage(MessageAPI.colorize("&cGrave removed")); - } + 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) { diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java index c25a697..40d4c46 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveActionsTest.java @@ -490,6 +490,48 @@ void publicRemovalOnFoliaAlwaysHandsOffToTheMarkerRegion(boolean primary) throws 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); From dfe4b6009338ea4dbba4a79dbc9255833bb2440a Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:35:55 -0600 Subject: [PATCH 36/46] Reserve grave capacity before delayed creation and restoration --- .../gravestonesplus/GraveStonesPlus.java | 22 ++- .../commands/CommandLoader.java | 13 +- .../gravestonesplus/graves/Grave.java | 21 ++- .../graves/GraveCreationTask.java | 27 +++- .../graves/GraveRestoration.java | 109 ++++++++++++++- .../listeners/PlayerDeathListener.java | 22 +-- .../graves/AdminCreationAdmissionTest.java | 19 +++ .../graves/CreationCapacityTest.java | 95 +++++++++++++ .../MarkerRestorationRegressionTest.java | 130 ++++++++++++++++++ 9 files changed, 432 insertions(+), 26 deletions(-) create mode 100644 GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/CreationCapacityTest.java diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/GraveStonesPlus.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/GraveStonesPlus.java index be7499d..a07c167 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/GraveStonesPlus.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/GraveStonesPlus.java @@ -513,9 +513,23 @@ public GraveLocations getGravesConfig() { 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 : activeGraveSnapshot()) { + 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++; } @@ -525,8 +539,10 @@ public int numberOfGraves(UUID uuid) { public Grave getOldestGrave(UUID uuid) { Grave oldest = null; - for (Grave grave : activeGraveSnapshot()) { - if (grave.isLimitPayoutPending()) continue; + 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; 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 fb7b1e5..ad69093 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/commands/CommandLoader.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/commands/CommandLoader.java @@ -552,12 +552,9 @@ public void createTestGrave(UUID uuid, String name, Location deathLocation, } final Location emptyBlockFinal = emptyBlock; - GraveCreationTask creation = new GraveCreationTask(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(); @@ -571,8 +568,8 @@ public void createTestGrave(UUID uuid, String name, Location deathLocation, creation.schedule(emptyBlockFinal); boolean accepted = false; try { - if (plugin.numberOfGraves(uuid) >= plugin.getConfigFile().getGraveLimit()) { - Grave oldest = plugin.getOldestGrave(uuid); + if (creation.priorGraves() >= plugin.getConfigFile().getGraveLimit()) { + Grave oldest = creation.replacement(); if (oldest != null) { if (plugin.getConfigFile().isDropItemsOnGraveRemoval()) { oldest.dropAndRemoveForLimit(null); 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 4cb7220..4c90252 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/Grave.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/Grave.java @@ -531,6 +531,12 @@ public boolean isOwner(String player) { * @param reason Removal reason */ 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()) { @@ -1158,9 +1164,22 @@ public void dropItemsOnGround(Player recipient) { /** 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); + } + /** Restoration invokes this only after AVAILABLE archive admission on the marker owner. */ + void queuePendingLimitPayout() { queueLimitPayout(null, true); } + void removePendingForLimit() { removeOwned(GraveRemoveReason.LIMIT_REACHED); } + private void 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().hasPendingLimitRemoval(this); synchronized (this) { - if (remove || claimInProgress || limitPayoutPending || !plugin.isLiveGrave(this) || plugin.getGraveClaimLedger().isBlocked(identity)) return; + if (remove || claimInProgress || limitPayoutPending + || (!plugin.isLiveGrave(this) && !pendingAdmission) + || plugin.getGraveClaimLedger().isBlocked(identity)) return; claimInProgress = true; limitRecipient = new java.lang.ref.WeakReference<>(recipient); } diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveCreationTask.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveCreationTask.java index 0b52477..c0c1c80 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveCreationTask.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveCreationTask.java @@ -12,13 +12,37 @@ 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.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( @@ -32,6 +56,7 @@ public void schedule(Location location) { 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) { diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java index 5abee89..01f9386 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java @@ -17,6 +17,8 @@ public final class GraveRestoration { 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(); @@ -33,12 +35,91 @@ private record PositionKey(UUID world, int x, int y, int z) { 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()); + 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); - pending.put(identity, record); admitted.put(identity, grave); published.add(identity); + if (retired) return; + 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 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 @@ -95,6 +176,10 @@ private CompletableFuture acceptRecordsOnOwner(GraveStorageManager.Restora 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); } @@ -105,6 +190,10 @@ private void acceptRecords(GraveStorageManager.RestorationRecords records) { storage.retainRestoration(active, broken); synchronized (this) { int invalid = 0; + 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); @@ -126,7 +215,7 @@ private synchronized void cycle() { 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) ? null : pending.get(key); } + 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; } @@ -176,11 +265,24 @@ private void processResolved(Iterator batch, String key, GravesConfig re Grave grave; synchronized (this) { grave = admitted.get(key); - if (grave == null) { + 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. @@ -288,6 +390,7 @@ private void persistResolvedRemoval(GravesConfig record) { } 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-")) { 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 c9ee73a..2f645df 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/PlayerDeathListener.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/PlayerDeathListener.java @@ -185,13 +185,16 @@ public void onPlayerDeath(PlayerDeathEvent event) { final Location emptyBlockFinal = emptyBlock; - GraveCreationTask handoff = new GraveCreationTask(plugin, new Runnable() { - @Override - public void run() { - Grave grave = new Grave(plugin, - new GravesConfig(playerId, playerName, 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(); @@ -220,7 +223,6 @@ public void run() { } plugin.getLogger().info("Grave: " + emptyBlockFinal.toString()); - } }); boolean scheduled = false; try { @@ -232,8 +234,8 @@ public void run() { boolean accepted = false; try { - if (plugin.numberOfGraves(playerId) >= plugin.getConfigFile().getGraveLimit()) { - Grave oldest = plugin.getOldestGrave(playerId); + if (handoff.priorGraves() >= plugin.getConfigFile().getGraveLimit()) { + Grave oldest = handoff.replacement(); if (oldest != null) { if (plugin.getConfigFile().isDropItemsOnGraveRemoval()) { oldest.dropAndRemoveForLimit(entity); diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/AdminCreationAdmissionTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/AdminCreationAdmissionTest.java index c2d41f3..84634ae 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/AdminCreationAdmissionTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/AdminCreationAdmissionTest.java @@ -7,6 +7,7 @@ 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; @@ -26,6 +27,24 @@ private static Location prepare(GraveActionsTest.Harness h, Grave oldest, boolea 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"); + } + } @ParameterizedTest @ValueSource(booleans = { false, true }) void rejectedCreationKeepsExistingGraveAndNeverPublishesReplacement(boolean drop) throws Exception { try (var h = new GraveActionsTest.Harness()) { 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..f82c9d0 --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/CreationCapacityTest.java @@ -0,0 +1,95 @@ +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 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/MarkerRestorationRegressionTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java index 441fc4c..e6a386a 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java @@ -135,6 +135,136 @@ void startupOffOwnerProtectsCachedMarkersBeforePreparation(Material type) throws assertTrue(owners.isEmpty()); 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()).queueLimit(any(), any(), any()); + restoration.retire(); + assertFalse(restoration.hasPendingLimitRemoval(pending)); + pending.queuePendingLimitPayout(); + verify(h.plugin.getGraveClaimLedger(), never()).queueLimit(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.queueLimit(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()).queueLimit(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).queueLimit(eq(identity), eq(ownerId), anyString()); + verify(h.events, never()).callEvent(any()); + } else { + verify(ledger, never()).queueLimit(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()).queueLimit(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); From 59426bbe91004ad4e833b2638c92e5d1c34e523c Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:45:53 -0600 Subject: [PATCH 37/46] Preserve legacy item snapshots and rejected marker cleanup --- .../gravestonesplus/graves/Grave.java | 16 ++- .../gravestonesplus/storage/GravesConfig.java | 19 ++- .../storage/PreparedLegacyItems.java | 54 +++++++++ .../MarkerRestorationRegressionTest.java | 43 +++++++ .../storage/LegacyPreparedItemsTest.java | 109 ++++++++++++++++++ .../storage/PreparedStorageSnapshotTest.java | 8 +- README.md | 15 +++ 7 files changed, 257 insertions(+), 7 deletions(-) create mode 100644 GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/PreparedLegacyItems.java create mode 100644 GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/LegacyPreparedItemsTest.java 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 4c90252..b995392 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/Grave.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/Grave.java @@ -541,7 +541,7 @@ public void removeGrave(final GraveRemoveReason reason) { // returning. Folia's global thread is not the grave region owner. if (Bukkit.isPrimaryThread() && !plugin.getBukkitScheduler().getFoliaLib().isFolia()) { if (removeState(reason, () -> true)) - plugin.getBukkitScheduler().runTask(plugin, this::cleanupMarker, gravesConfig.getLocation()); + scheduleMarkerCleanup(); return; } plugin.getBukkitScheduler().runTask(plugin, () -> removeOwned(reason), gravesConfig.getLocation()); @@ -1494,13 +1494,25 @@ private void completeClaim(Player player, String identity, boolean displaced) { plugin.removeGrave(this); plugin.getTrustedGraveAccessStore().revokeForGraveAsync(identity); plugin.getGraveNavigation().invalidate(identity); removeTimer(); - plugin.getBukkitScheduler().runTask(plugin, this::cleanupMarker, gravesConfig.getLocation()); + 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; 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 ac7280d..20fbfba 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GravesConfig.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GravesConfig.java @@ -28,14 +28,23 @@ public class GravesConfig implements ConfigurationSerializable { // 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) { preparationFailure = new java.io.UncheckedIOException("Could not prepare grave items", failure); } + 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. @@ -45,7 +54,7 @@ String encodedItemsForStorage() { Map preparedRecordForStorage() { if (preparationFailure != null) throw preparationFailure; - if (preparedItems == null) throw new IllegalStateException("Grave items were not owner-prepared"); + 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; @@ -160,7 +169,11 @@ 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", preparedItems != null ? preparedItems : InventorySerializer.encode(items)); } catch (IOException e) { 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/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java index e6a386a..789c8ce 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java @@ -14,6 +14,7 @@ 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; @@ -39,6 +40,48 @@ private static final class Marker { doAnswer(i->{values.remove(i.getArgument(0));return null;}).when(chunkData).remove(any()); } } + @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"); 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/PreparedStorageSnapshotTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/PreparedStorageSnapshotTest.java index 08cd048..a508aa6 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/PreparedStorageSnapshotTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/PreparedStorageSnapshotTest.java @@ -91,13 +91,17 @@ private static GravesConfig source() { } finally { release.countDown(); } } } - @Test void encodingFailureRemainsAcknowledgedAndRetainedThroughWorkerRecovery() throws Exception { + @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 grave = mock(Grave.class); when(grave.getGravesConfig()).thenReturn(source()); + 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); diff --git a/README.md b/README.md index 93298f9..2fd780b 100644 --- a/README.md +++ b/README.md @@ -194,3 +194,18 @@ repair updates only metadata for the exact active row, preserving contents and X 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. From 289ffbe3ef349fc87a15ed1fcfae7c76693ff1c6 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:53:33 -0600 Subject: [PATCH 38/46] Bound startup grave inventory retention by owner callback --- .../graves/GraveRestoration.java | 56 +++++++++++++------ .../storage/GraveStorageManager.java | 9 +++ .../MarkerRestorationRegressionTest.java | 37 ++++++++++++ .../MySQLRestorationOwnershipTest.java | 33 +++++++++++ README.md | 16 +++++- 5 files changed, 131 insertions(+), 20 deletions(-) diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java index 01f9386..8777ab9 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java @@ -129,7 +129,10 @@ public void start() { try { var cached = cachedFlatRecords(); indexCachedRecords(cached); - if (org.bukkit.Bukkit.isPrimaryThread()) acceptRecords(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."); } @@ -144,12 +147,13 @@ public void retry() { CompletableFuture read = storage.isMySQL() ? storage.loadMySQLForRestoration() : loadCachedFlatOnOwner(); - read.thenCompose(this::acceptRecordsOnOwner).whenComplete((loaded, error) -> { - if (error != null) { running.set(false); plugin.getLogger().warning("Stored graves remain withheld until backend restoration can retry."); } - else cycle(); - }); + 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()); } @@ -163,14 +167,34 @@ private CompletableFuture loadCachedFlat return result; } private CompletableFuture acceptRecordsOnOwner(GraveStorageManager.RestorationRecords records) { + return acceptRecordsOnOwner(records, false); + } + private CompletableFuture acceptRecordsOnOwner(GraveStorageManager.RestorationRecords records, boolean inline) { var result = new CompletableFuture(); - Runnable accept = () -> { - if (retired || !plugin.isEnabled()) { result.completeExceptionally(new IllegalStateException("Grave restoration retired")); return; } - try { acceptRecords(records); result.complete(null); } - catch (RuntimeException failure) { result.completeExceptionally(failure); } - }; - try { plugin.getBukkitScheduler().runTask(plugin, accept); } - catch (RuntimeException rejected) { result.completeExceptionally(rejected); } + 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) { @@ -185,11 +209,9 @@ private synchronized void indexCachedRecords(GraveStorageManager.RestorationReco if (identity != null) { pending.put(identity, grave); index(identity, grave); } } } - private void acceptRecords(GraveStorageManager.RestorationRecords records) { - List active = records.active(), broken = records.broken(); - storage.retainRestoration(active, broken); + private int acceptRecordBatch(List active, List broken, int invalid) { + storage.retainRestorationBatch(active, broken); synchronized (this) { - int invalid = 0; for (GravesConfig grave : active) { String identity = GraveIdentity.of(grave); if (identity != null && !grave.isDestroyed()) activeRecords.add(identity); @@ -199,7 +221,7 @@ private void acceptRecords(GraveStorageManager.RestorationRecords records) { pending.put(key, grave); if (identity != null) index(key, grave); } - recordsLoaded = true; + return invalid; } } private static List concat(List active, List broken) { 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 84a74e5..64cc52b 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GraveStorageManager.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/GraveStorageManager.java @@ -28,8 +28,17 @@ private record Recovery(List active, List broken) { 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(); } diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java index 789c8ce..cae9862 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java @@ -178,6 +178,43 @@ void startupOffOwnerProtectsCachedMarkersBeforePreparation(Material type) throws 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(); diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/MySQLRestorationOwnershipTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/MySQLRestorationOwnershipTest.java index 58ec31c..24a8eb9 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/MySQLRestorationOwnershipTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/MySQLRestorationOwnershipTest.java @@ -319,6 +319,39 @@ var record = records.active().getFirst(); assertEquals(h.world, record.getWorldI 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")); diff --git a/README.md b/README.md index 2fd780b..faecb43 100644 --- a/README.md +++ b/README.md @@ -176,9 +176,10 @@ withdraws only the new kept-item reservation. A callback on a faster Folia regio 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. -This preflight change does not yet count unpublished/restoring graves toward the -limit or add durable creation intents: failure or shutdown after death capture -remains a separate legacy lifecycle limitation. +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 @@ -209,3 +210,12 @@ 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. From c577b1b673b9704465fed7e35c42d6a48770bd26 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:57:53 -0600 Subject: [PATCH 39/46] Dispatch grave claim callbacks on the Folia grave region --- .../events/GraveClaimEvent.java | 5 +- .../gravestonesplus/graves/Grave.java | 70 ++++++++++++--- .../graves/GraveRegionOwnershipTest.java | 85 +++++++++++++++++++ README.md | 8 ++ 4 files changed, 153 insertions(+), 15 deletions(-) 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/Grave.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/Grave.java index b995392..2f9a3a2 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/Grave.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/Grave.java @@ -1369,11 +1369,11 @@ public void onClick(ClickEvent clickEvent) { public void claim(final Player player) { if (player == null) return; - plugin.getBukkitScheduler().runTask(plugin, () -> beginClaim(player, null, false), player); + dispatchClaim(player, null, false); } /** Menu/command recovery retains its permission and nearby requirement through payout. */ public void claimNearby(final Player player) { - if (player != null) plugin.getBukkitScheduler().runTask(plugin, () -> beginClaim(player, null, true), player); + if (player != null) dispatchClaim(player, null, true); } private boolean nearbyClaimAllowed(Player player) { if (!player.hasPermission("GraveStonesPlus.ClaimGrave")) return false; @@ -1381,25 +1381,46 @@ private boolean nearbyClaimAllowed(Player 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) plugin.getBukkitScheduler().runTask(plugin, () -> beginClaim(player, proof, false), player); + 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) { - final String identity = GraveIdentity.of(gravesConfig); + 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 || !player.isOnline() || !plugin.isLiveGrave(this) - || (!claimAllowed(player, proof) || (nearby && !nearbyClaimAllowed(player)))) return; - if (plugin.getGraveClaimLedger().isBlocked(identity)) { - player.sendMessage("\u00a7cThis grave has a pending or completed recovery record; it cannot be paid again."); return; - } + 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, isOwner(player)); + GraveClaimEvent claimEvent = new GraveClaimEvent(this, player, owner); GraveRemoveEvent removeEvent = new GraveRemoveEvent(this, GraveRemoveReason.CLAIMED); try { Bukkit.getPluginManager().callEvent(claimEvent); @@ -1409,8 +1430,8 @@ private void beginClaim(Player player, com.bencodez.gravestonesplus.breaking.Pla plugin.getLogger().warning("Grave claim callback failed; claim remains available without payout."); return; } - if (claimEvent.isCancelled() || removeEvent.isCancelled() || !player.isOnline() - || !plugin.isEnabled() || !plugin.isLiveGrave(this) || (!claimAllowed(player, proof) || (nearby && !nearbyClaimAllowed(player)))) { + if (claimEvent.isCancelled() || removeEvent.isCancelled() + || !plugin.isEnabled() || !plugin.isLiveGrave(this)) { releaseClaim(); return; } final com.bencodez.gravestonesplus.storage.GravesConfig captured; @@ -1420,9 +1441,30 @@ private void beginClaim(Player player, com.bencodez.gravestonesplus.breaking.Pla evidence = GraveClaimLedger.evidence(captured); } catch (RuntimeException invalid) { releaseClaim(); - player.sendMessage("\u00a7cGrave recovery evidence could not be captured. No payout was made."); return; + plugin.getLogger().warning("Grave recovery evidence could not be captured; no payout was made."); return; } - plugin.getGraveClaimLedger().reserve(identity, player.getUniqueId(), evidence).whenComplete((saved, failure) -> { + 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)) { diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveRegionOwnershipTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveRegionOwnershipTest.java index f33235b..64726af 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveRegionOwnershipTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/GraveRegionOwnershipTest.java @@ -12,6 +12,91 @@ /** 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()) { diff --git a/README.md b/README.md index faecb43..c3fa9ba 100644 --- a/README.md +++ b/README.md @@ -219,3 +219,11 @@ 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. From 0d731b81f035004133b94814ee9956ebae9f404e Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:05:35 -0600 Subject: [PATCH 40/46] Release reminder admissions on retired entity scheduling --- .../listeners/GraveReminderListener.java | 28 ++++++-- .../ReminderSchedulingAdmissionTest.java | 67 +++++++++++++++++++ 2 files changed, 89 insertions(+), 6 deletions(-) create mode 100644 GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/ReminderSchedulingAdmissionTest.java diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/GraveReminderListener.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/GraveReminderListener.java index 440e0f4..5e58911 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/GraveReminderListener.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/GraveReminderListener.java @@ -17,7 +17,7 @@ public final class GraveReminderListener implements Listener { private int cursor; private com.bencodez.simpleapi.folialib.wrapper.task.WrappedTask task; private final Map graveCursors = new java.util.concurrent.ConcurrentHashMap<>(); - private final Set scheduled = java.util.concurrent.ConcurrentHashMap.newKeySet(); + 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() { @@ -40,11 +40,27 @@ public void start() { schedule(event.getPlayer(), generation); } private void schedule(Player player, long expected) { - if (scheduled.size() >= 128 || !scheduled.add(player.getUniqueId())) return; - plugin.getBukkitScheduler().runTask(plugin, () -> { - try { if (running && generation == expected && player.isOnline()) checkPlayer(player); } - finally { scheduled.remove(player.getUniqueId()); } - }, player); + 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()); 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(); + } + } +} From 7cfc9700b665b839248cd41e518998e9d012f7b4 Mon Sep 17 00:00:00 2001 From: BenCodez Date: Sun, 11 Oct 2026 00:04:07 -0600 Subject: [PATCH 41/46] Close owned grave SQL resources after shutdown and failed initialization --- .../storage/GravesMySQLTable.java | 23 +++++++- .../storage/MySQLGraveStorageBackend.java | 10 ++-- .../storage/GraveMySQLInitializationTest.java | 52 +++++++++++++++++++ .../MySQLRestorationOwnershipTest.java | 33 ++++++++++++ README.md | 2 + 5 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/GraveMySQLInitializationTest.java 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 b520e3a..94a5703 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,28 @@ 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 { 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 { if (mysql.getThreadPool() != null) mysql.getThreadPool().shutdown(); } + catch (RuntimeException | Error cleanup) { if (cleanup != failure) failure.addSuppressed(cleanup); } + throw failure; + } + } + @Override public String getPrimaryKeyColumn() { return "id"; @@ -467,4 +486,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 5d0a387..ecc2553 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/MySQLGraveStorageBackend.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/storage/MySQLGraveStorageBackend.java @@ -99,7 +99,11 @@ public void deleteGrave(GravesConfig 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/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..2813b64 --- /dev/null +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/GraveMySQLInitializationTest.java @@ -0,0 +1,52 @@ +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 { + @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); 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/MySQLRestorationOwnershipTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/MySQLRestorationOwnershipTest.java index 24a8eb9..a8fc912 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/MySQLRestorationOwnershipTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/MySQLRestorationOwnershipTest.java @@ -156,6 +156,39 @@ void failedIncrementalSqlWriteRetainsCapturedStateUntilConfirmedRetry(boolean up 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); } diff --git a/README.md b/README.md index c3fa9ba..3db8e38 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,8 @@ Storage snapshots prepare encoded items before I/O admission. The worker reuses 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. + 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 From 11f35da2744cb9faeda3c614ad3b23822a253c2f Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:07:12 -0600 Subject: [PATCH 42/46] Guard grave SQL bootstrap callbacks on compatible older libraries --- .../storage/GravesMySQLTable.java | 30 ++++++++-- .../storage/GraveMySQLInitializationTest.java | 57 ++++++++++++++++++- README.md | 2 +- 3 files changed, 81 insertions(+), 8 deletions(-) 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 94a5703..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,7 +43,14 @@ 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; - try { 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(); } @@ -59,7 +66,10 @@ private void closeOwnedPool() { 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 { if (mysql.getThreadPool() != null) mysql.getThreadPool().shutdown(); } + try { + var worker = mysql.getThreadPool(); + if (worker != null && !worker.isShutdown()) worker.shutdown(); + } catch (RuntimeException | Error cleanup) { if (cleanup != failure) failure.addSuppressed(cleanup); } throw failure; } @@ -106,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); } /** diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/GraveMySQLInitializationTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/GraveMySQLInitializationTest.java index 2813b64..f8c4179 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/GraveMySQLInitializationTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/storage/GraveMySQLInitializationTest.java @@ -16,6 +16,57 @@ 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); @@ -35,7 +86,11 @@ void failedPostConnectInitializationReleasesPoolAndWorkersPreservingFailure(bool if (cleanupFails) doThrow(cleanup).when(manager).close(); })) { executors.when(() -> Executors.newFixedThreadPool(anyInt())).thenAnswer(call -> { - var worker = mock(ExecutorService.class); workers.add(worker); return worker; + 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) { diff --git a/README.md b/README.md index 3db8e38..2eb538d 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ Storage snapshots prepare encoded items before I/O admission. The worker reuses 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. +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. From b76855358e69579531f8f9ebcc6ad78a3d1ddb77 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:14:15 -0600 Subject: [PATCH 43/46] Render retained graves safely when their world is unavailable --- .../gravestonesplus/graves/Grave.java | 35 +++++----- .../listeners/GraveReminderListener.java | 2 +- .../graves/UnavailableWorldRenderingTest.java | 67 +++++++++++++++++++ README.md | 2 + 4 files changed, 88 insertions(+), 18 deletions(-) create mode 100644 GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/UnavailableWorldRenderingTest.java 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 2f9a3a2..ce04cb3 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/Grave.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/Grave.java @@ -502,11 +502,19 @@ 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(); @@ -756,12 +764,10 @@ public void checkTimeLimit(ParsedDuration timeLimit) { public BInventoryButton getNavigationGUIItem(Player viewer) { return guiItem(true, viewer); } public BInventoryButton getGUIItem() { return guiItem(false, null); } private BInventoryButton guiItem(boolean navigation, Player menuViewer) { - 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(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") @@ -817,12 +823,10 @@ public void onClick(ClickEvent clickEvent) { } 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") @@ -857,12 +861,9 @@ public void onClick(ClickEvent clickEvent) { 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 String getAllGraveMessage() { + return "Player: " + gravesConfig.getPlayerName() + ", " + getGraveMessage(); + } /** Caller owns the player; one location snapshot keeps world and distance consistent. */ public double getDistance(Player p) { diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/GraveReminderListener.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/GraveReminderListener.java index 5e58911..49e879e 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/GraveReminderListener.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/GraveReminderListener.java @@ -113,6 +113,6 @@ private void add(List notices, Player player, Grave g 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.getGravesConfig().getLocation().getWorld().getName() + " (" + grave.getGravesConfig().getLocation().getBlockX() + "," + grave.getGravesConfig().getLocation().getBlockY() + "," + grave.getGravesConfig().getLocation().getBlockZ() + ") " + label + " in " + Math.max(1, (deadline - now + 59_999) / 60_000) + "m")); + grave.getLocationDescription() + " " + label + " in " + Math.max(1, (deadline - now + 59_999) / 60_000) + "m")); } } 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/README.md b/README.md index 2eb538d..31483fb 100644 --- a/README.md +++ b/README.md @@ -229,3 +229,5 @@ 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. From 2c6a5940e56e9c03eb53390dae8ea4658692039f Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:40:53 -0600 Subject: [PATCH 44/46] Require grave-limit payout admission before replacement capture --- .../commands/CommandLoader.java | 5 ++- .../gravestonesplus/graves/Grave.java | 21 ++++++++--- .../graves/GraveClaimLedger.java | 14 ++++++-- .../graves/GraveRestoration.java | 18 ++++++++++ .../listeners/PlayerDeathListener.java | 5 ++- .../graves/AdminCreationAdmissionTest.java | 19 ++++++++-- .../graves/CreationCapacityTest.java | 36 +++++++++++++++++++ .../graves/DeathPlayerOwnershipTest.java | 26 ++++++++++++-- .../MarkerRestorationRegressionTest.java | 14 ++++---- README.md | 11 ++++++ 10 files changed, 148 insertions(+), 21 deletions(-) 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 ad69093..e68af25 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/commands/CommandLoader.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/commands/CommandLoader.java @@ -572,7 +572,10 @@ public void createTestGrave(UUID uuid, String name, Location deathLocation, Grave oldest = creation.replacement(); if (oldest != null) { if (plugin.getConfigFile().isDropItemsOnGraveRemoval()) { - oldest.dropAndRemoveForLimit(null); + if (!oldest.admitLimitRemoval(null)) { + sender.sendMessage("Grave creation declined: limit payout admission unavailable"); + return; + } } else oldest.removeGrave(GraveRemoveReason.LIMIT_REACHED); } } 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 ce04cb3..067272d 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/Grave.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/Grave.java @@ -1169,25 +1169,35 @@ public void dropAndRemoveForLimit(Player recipient) { && 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 void queueLimitPayout(Player recipient, boolean pendingRecord) { + 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().hasPendingLimitRemoval(this); + boolean pendingAdmission = pendingRecord && plugin.getGraveRestoration().canQueuePendingLimit(this); synchronized (this) { if (remove || claimInProgress || limitPayoutPending || (!plugin.isLiveGrave(this) && !pendingAdmission) - || plugin.getGraveClaimLedger().isBlocked(identity)) return; + || 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; } - plugin.getGraveClaimLedger().queueLimit(identity, gravesConfig.getUuid(), evidence).whenComplete((saved, failure) -> { + 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); @@ -1195,6 +1205,7 @@ private void queueLimitPayout(Player recipient, boolean pendingRecord) { releaseClaim(); if (limitPayoutPending) plugin.getGraveRestoration().retry(); }); + return true; } void markLimitPayoutPending() { limitPayoutPending = true; } /** Invoked by restoration only on a loaded chunk's owning scheduler. */ diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveClaimLedger.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveClaimLedger.java index 62a61d2..a8c4a47 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveClaimLedger.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveClaimLedger.java @@ -69,11 +69,19 @@ public synchronized CompletableFuture reserve(String grave, UUID recipi }); } /** A queued ground payout has not performed any gameplay side effect and may resume. */ - public synchronized CompletableFuture queueLimit(String grave, UUID recipient, String evidence) { + 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 CompletableFuture.completedFuture(false); + return null; + FeatureIO.Admission admission = state.admit(); + if (admission == null) return null; pending.add(grave); - return state.update(next -> { + return state.update(admission, next -> { if (next.containsKey(grave) || archived(grave)) return false; next.put(grave, "QUEUED_LIMIT:" + recipient + ":" + evidence); return true; }).thenApply(saved -> { diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java index 8777ab9..da4f4bb 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java @@ -49,6 +49,15 @@ public synchronized CreationAdmission reserveCreation(GravesConfig 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); @@ -83,6 +92,7 @@ public synchronized void releaseCreation(GravesConfig record) { 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); @@ -102,6 +112,14 @@ public synchronized List pendingActiveForLimits() { } 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()); 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 2f645df..92d8553 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/PlayerDeathListener.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/PlayerDeathListener.java @@ -238,7 +238,10 @@ record = new GravesConfig(playerId, playerName, emptyBlockFinal, itemsWithSlot, Grave oldest = handoff.replacement(); if (oldest != null) { if (plugin.getConfigFile().isDropItemsOnGraveRemoval()) { - oldest.dropAndRemoveForLimit(entity); + 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())); diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/AdminCreationAdmissionTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/AdminCreationAdmissionTest.java index 84634ae..18194b6 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/AdminCreationAdmissionTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/AdminCreationAdmissionTest.java @@ -45,6 +45,20 @@ private static Location prepare(GraveActionsTest.Harness h, Grave oldest, boolea "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()) { @@ -69,7 +83,7 @@ void failedLimitRemovalCancelsAdmittedCreationEvenWhenCallbackArrivesEarly(boole 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).dropAndRemoveForLimit(null); + 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)); @@ -89,10 +103,11 @@ void admittedCreationRunsOnceAfterPreflightAndPreservesLimitPolicy(boolean drop) 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).dropAndRemoveForLimit(null); verify(oldest, never()).removeGrave(any()); + 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()); diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/CreationCapacityTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/CreationCapacityTest.java index f82c9d0..01c1fa8 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/CreationCapacityTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/CreationCapacityTest.java @@ -41,6 +41,42 @@ private GraveRestoration restoration(GraveActionsTest.Harness h) { 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); diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/DeathPlayerOwnershipTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/DeathPlayerOwnershipTest.java index 8ab4133..ef0fdfe 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/DeathPlayerOwnershipTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/DeathPlayerOwnershipTest.java @@ -56,6 +56,27 @@ 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); @@ -203,10 +224,11 @@ void acceptedReplacementStillUsesConfiguredLimitRemoval(boolean dropOldContents) 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)).dropAndRemoveForLimit(h.owner); + 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); @@ -232,7 +254,7 @@ void failedLimitPreflightRollsBackOnlyNewKeptBatchAndCannotPayBeforeDropsClear(b verify(h.ownerInventory, never()).setItem(anyInt(), any()); throw new IllegalStateException("limit removal rejected"); }; - if (dropOldContents) doAnswer(fail).when(oldest).dropAndRemoveForLimit(any()); + 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); diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java index cae9862..6bf92d0 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java @@ -284,11 +284,11 @@ void largeCachedStartupBudgetsItemRetentionAndRetiredCallbacksCannotFinish(boole assertFalse(restoration.hasPendingLimitRemoval(impostor)); assertFalse(restoration.requestLimitRemoval(impostor, true)); impostor.queuePendingLimitPayout(); - verify(h.plugin.getGraveClaimLedger(), never()).queueLimit(any(), any(), any()); + verify(h.plugin.getGraveClaimLedger(), never()).admitLimit(any(), any(), any()); restoration.retire(); assertFalse(restoration.hasPendingLimitRemoval(pending)); pending.queuePendingLimitPayout(); - verify(h.plugin.getGraveClaimLedger(), never()).queueLimit(any(), any(), any()); + verify(h.plugin.getGraveClaimLedger(), never()).admitLimit(any(), any(), any()); } finally { restoration.retire(); } } } @@ -299,7 +299,7 @@ void pendingLimitIntentWaitsForArchiveAndFirstRequestWins(boolean drop) throws E var restoration = restoration(h, h.grave.getGravesConfig(), held, held); when(h.plugin.getOldestGrave(any())).thenCallRealMethod(); var ledger = h.plugin.getGraveClaimLedger(); - when(ledger.queueLimit(any(), any(), any())).thenReturn(CompletableFuture.completedFuture(false)); + 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); @@ -309,17 +309,17 @@ void pendingLimitIntentWaitsForArchiveAndFirstRequestWins(boolean drop) throws E 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()).queueLimit(any(), any(), any()); verify(h.events, never()).callEvent(any()); + 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).queueLimit(eq(identity), eq(ownerId), anyString()); + verify(ledger).admitLimit(eq(identity), eq(ownerId), anyString()); verify(h.events, never()).callEvent(any()); } else { - verify(ledger, never()).queueLimit(any(), any(), any()); + 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()); @@ -339,7 +339,7 @@ void reservedOrUnavailableArchiveCannotExecutePendingLimitIntent(GraveClaimLedge 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()).queueLimit(any(), any(), any()); + assertTrue(h.active.isEmpty()); verify(ledger, never()).admitLimit(any(), any(), any()); verify(h.events, never()).callEvent(any()); assertFalse(pending.isRemove()); } finally { restoration.retire(); } } diff --git a/README.md b/README.md index 31483fb..ab90f5c 100644 --- a/README.md +++ b/README.md @@ -231,3 +231,14 @@ 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. From 34814c613dd6dba9e558db7928bcd6f4d4b7f31e Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:26:45 -0600 Subject: [PATCH 45/46] Protect exact grave markers while startup records are loading --- .../graves/GraveMarkerCleanup.java | 2 +- .../graves/GraveRestoration.java | 45 ++++- .../GraveBlockProtectionListener.java | 1 + .../MarkerRestorationRegressionTest.java | 172 ++++++++++++++++++ 4 files changed, 218 insertions(+), 2 deletions(-) diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveMarkerCleanup.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveMarkerCleanup.java index 86c0feb..1c98d7c 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveMarkerCleanup.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveMarkerCleanup.java @@ -33,7 +33,7 @@ public GraveMarkerCleanup(GraveStonesPlus plugin, DurableFeatureState state) { } public CompletableFuture loaded() { return loaded; } public static NamespacedKey identityKey(GraveStonesPlus plugin) { return new NamespacedKey(plugin, "grave_marker_identity"); } - private static NamespacedKey positionKey(GraveStonesPlus plugin, Block block) { + 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. */ diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java index da4f4bb..589dc53 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveRestoration.java @@ -457,6 +457,7 @@ 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); @@ -465,7 +466,49 @@ public synchronized String pendingMarkerIdentity(Block block, Material expected) || GraveMarkerCleanup.hasConflictingIdentity(plugin, block, identity)) continue; return identity; } - return null; + // 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; 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 8a1cde7..e6fecdb 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/GraveBlockProtectionListener.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/GraveBlockProtectionListener.java @@ -326,6 +326,7 @@ private void restoreIfRemovedNextTick(final Block block, final Material expected 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()); } diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java index 6bf92d0..988b4c3 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/MarkerRestorationRegressionTest.java @@ -18,6 +18,7 @@ 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 { @@ -39,6 +40,18 @@ private static final class Marker { 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()) { @@ -95,6 +108,165 @@ 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()); From 1564cf94621b9557961fc8ced5b293769f427f5a Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:14:52 -0600 Subject: [PATCH 46/46] Bound compass invalidation through existing player maintenance --- .../graves/GraveNavigation.java | 18 ++----- .../listeners/GraveReminderListener.java | 4 +- ...NavigationLegacyCompassRegressionTest.java | 47 +++++++++++++++---- README.md | 2 + 4 files changed, 47 insertions(+), 24 deletions(-) diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveNavigation.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveNavigation.java index 8916487..35083e6 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveNavigation.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/graves/GraveNavigation.java @@ -34,20 +34,10 @@ public boolean select(Player player, Grave grave) { public void invalidate(String identity) { if (identity == null) return; selected.values().removeIf(identity::equals); - // Inventory changes belong to each player's entity owner, not the grave region. - for (Player player : Bukkit.getOnlinePlayers()) { - try { - plugin.getBukkitScheduler().runTask(plugin, () -> { - try { refresh(player); } - catch (RuntimeException failure) { reportRefreshFailure(); } - }, player); - } catch (RuntimeException failure) { reportRefreshFailure(); } - } - } - private void reportRefreshFailure() { - // Disconnect/shutdown can reject an owner task. Compass maintenance must - // never interrupt the authoritative removal or another player's refresh. - plugin.getLogger().fine("Grave compass refresh unavailable; deferred to the next player refresh"); + // 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() diff --git a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/GraveReminderListener.java b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/GraveReminderListener.java index 49e879e..ccb2024 100644 --- a/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/GraveReminderListener.java +++ b/GraveStonesPlus/src/main/java/com/bencodez/gravestonesplus/listeners/GraveReminderListener.java @@ -68,9 +68,11 @@ private void schedule(Player player, long expected) { } 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; - plugin.getGraveNavigation().refresh(player); long now = System.currentTimeMillis(); List notices = new ArrayList<>(); List owned = plugin.getGraves(player); diff --git a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/NavigationLegacyCompassRegressionTest.java b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/NavigationLegacyCompassRegressionTest.java index de84394..4161bea 100644 --- a/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/NavigationLegacyCompassRegressionTest.java +++ b/GraveStonesPlus/src/test/java/com/bencodez/gravestonesplus/graves/NavigationLegacyCompassRegressionTest.java @@ -30,26 +30,55 @@ class NavigationLegacyCompassRegressionTest { 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).runTask(eq(h.plugin), any(Runnable.class), eq(h.helper)); + verify(h.scheduler, never()).runTask(eq(h.plugin), any(Runnable.class), any(org.bukkit.entity.Entity.class)); verify(h.plugin).removeGrave(h.grave); } } - @org.junit.jupiter.params.ParameterizedTest - @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) - void refreshFailureCannotAbortIdentityInvalidation(boolean inline) throws Exception { + @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)); - when(h.ownerInventory.getContents()).thenThrow(new IllegalStateException("retired inventory")); - if (inline) doAnswer(call -> { ((Runnable) call.getArgument(1)).run(); return null; }) - .when(h.scheduler).runTask(eq(h.plugin), any(Runnable.class), any(org.bukkit.entity.Entity.class)); assertDoesNotThrow(() -> h.navigation.invalidate(h.identity())); - if (!inline) while (!h.entityWork.isEmpty()) assertDoesNotThrow(h.entityWork.remove()::run); assertNull(h.navigation.target(h.owner)); - verify(h.helperInventory).getContents(); + 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(); } } diff --git a/README.md b/README.md index ab90f5c..4f866a1 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,8 @@ Optional `Reminders.ProtectionMinutes` and `Reminders.ExpiryMinutes` default to 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.