diff --git a/MCPerks/pom.xml b/MCPerks/pom.xml index b9e1093..e6f6c36 100644 --- a/MCPerks/pom.xml +++ b/MCPerks/pom.xml @@ -8,7 +8,7 @@ jar MCPerks - LATEST + 3.8.2-SNAPSHOT 21 21 diff --git a/MCPerks/src/main/java/com/bencodez/mcperks/MCPerksMain.java b/MCPerks/src/main/java/com/bencodez/mcperks/MCPerksMain.java index 8fa39b5..08cf368 100644 --- a/MCPerks/src/main/java/com/bencodez/mcperks/MCPerksMain.java +++ b/MCPerks/src/main/java/com/bencodez/mcperks/MCPerksMain.java @@ -60,6 +60,10 @@ * The Class Main. */ public class MCPerksMain extends AdvancedCorePlugin { + private volatile int featureLifecycle; + public int getFeatureLifecycle() { return featureLifecycle; } + private com.bencodez.mcperks.data.OrderedStorage storage; + public com.bencodez.mcperks.data.OrderedStorage getStorage() { return storage; } /** The plugin. */ public static MCPerksMain plugin; @@ -137,18 +141,95 @@ public void loadListeners() { * * @see org.bukkit.plugin.java.JavaPlugin#onDisable() */ + private volatile java.util.concurrent.CompletableFuture shutdownPreparation; + private volatile java.util.concurrent.CompletionStage shutdownFinalFiles; + private boolean shutdownCompletionHookDispatched; + + @Override + public void onDisable() { + prepareShutdown(); + super.onDisable(); + } + + /** Capture and remove native effects on the disable owner, before any asynchronous retirement. */ + private synchronized void prepareShutdown() { + if (shutdownPreparation != null) return; + shutdownPreparation = new java.util.concurrent.CompletableFuture<>(); + java.util.concurrent.CompletableFuture activations = java.util.concurrent.CompletableFuture.completedFuture(null); + java.util.concurrent.CompletableFuture saved; + try { + featureLifecycle++; + if (getPerkHandler() != null) activations = getPerkHandler().retireNativeActivations().toCompletableFuture(); + if (getMcperksServerData() != null) getMcperksServerData().retireActivationCallbacks(); + var legacy = new ArrayList(); + if (getPerkHandler() != null) for (Perk perk : getPerkHandler().getActivePerks()) legacy.add(perk); + if (getPerkHandler() != null) getPerkHandler().retireTimers(); + legacy.removeIf(perk -> !perk.detachForShutdown()); + if (legacy.isEmpty()) saved = java.util.concurrent.CompletableFuture.completedFuture(null); + else { + if (storage == null) throw new IllegalStateException("Shutdown persistence owner unavailable"); + saved = storage.runtimeStorage(() -> legacy.stream().map(Perk::persistForShutdown).toList()) + .thenCompose(stages -> java.util.concurrent.CompletableFuture.allOf( + stages.toArray(java.util.concurrent.CompletableFuture[]::new))); + } + } catch (Throwable failure) { saved = java.util.concurrent.CompletableFuture.failedFuture(failure); } + java.util.concurrent.CompletableFuture.allOf(activations, saved).whenComplete((ignored, failure) -> { + if (failure == null) shutdownPreparation.complete(null); + else shutdownPreparation.completeExceptionally(failure); + }); + } + + @Override public java.util.concurrent.CompletionStage onBeforeStorageShutdown() { + var preparation = shutdownPreparation; + return preparation == null ? java.util.concurrent.CompletableFuture.failedFuture( + new IllegalStateException("Shutdown owner preparation was not admitted")) : preparation.minimalCompletionStage(); + } + + @Override public void onShutdownComplete(java.util.concurrent.CompletionStage finalFilesStage) { + synchronized (this) { + // A duplicate/reentrant hook cannot replace the admitted final-file fence. + if (shutdownFinalFiles == null) shutdownFinalFiles = java.util.Objects.requireNonNull(finalFilesStage, "finalFilesStage"); + if (shutdownCompletionHookDispatched) return; + shutdownCompletionHookDispatched = true; + } + onShutdownComplete(); // Preserve virtual dispatch to existing subclass hooks once. + } + + @Override public void onShutdownComplete() { + var owner = storage; + var preparation = shutdownPreparation; + var files = shutdownFinalFiles; + if (owner != null) { + var prepared = preparation == null ? java.util.concurrent.CompletableFuture.completedFuture(null) + : preparation; + var finalFiles = files == null ? java.util.concurrent.CompletableFuture.completedFuture(null) + : files.toCompletableFuture(); + // allOf waits for every settlement, including exceptional ones. Termination is + // deliberately outside these stages: closing the worker must not wait on itself. + java.util.concurrent.CompletableFuture.allOf(prepared, finalFiles) + .whenComplete((ignored, failure) -> owner.close()); + } + } + @Override public void onUnLoad() { - for (Perk perk : getPerkHandler().getActivePerks()) { - getMcperksServerData().saveOfflinePerk(perk); - perk.deactivatePerk(perk.getActivater()); - } + com.bencodez.mcperks.scheduling.OwnerTasks.shutdown(this); + // Native user storage is retired here; all user work belongs to the earlier stage. + if (getMcperksServerData() != null) getMcperksServerData().saveData(); HandlerList.unregisterAll(plugin); plugin = null; } @Override public void onPostLoad() { + com.bencodez.mcperks.scheduling.OwnerTasks.initialize(this); + shutdownPreparation = null; + shutdownFinalFiles = null; + shutdownCompletionHookDispatched = false; + int featureEpoch = ++featureLifecycle; + storage = new com.bencodez.mcperks.data.OrderedStorage(getDataFolder().toPath().resolve("perk-features.dat")); + var runtimeReady = mcperksServerData.initialize(storage); + runtimeReady.exceptionally(error -> { getLogger().severe("ServerData startup reconciliation failed; activations remain unavailable"); return null; }); loadListeners(); perkHandler = new PerkHandler(this); effectHandler = new EffectHandler(this); @@ -172,48 +253,16 @@ public void onPostLoad() { versionHandle = new Post123VersionHandle(); } - getBukkitScheduler().runTaskAsynchronously(this, new Runnable() { - - @Override - public void run() { - if (getMcperksServerData().getData().isConfigurationSection("OfflinePerk")) { - for (String uuid : getMcperksServerData().getData().getConfigurationSection("OfflinePerk") - .getKeys(false)) { - if (getMcperksServerData().getData().isConfigurationSection("OfflinePerk." + uuid)) { - for (String perk : getMcperksServerData().getData() - .getConfigurationSection("OfflinePerk." + uuid).getKeys(false)) { - - long timeLast = getMcperksServerData().getData() - .getLong("OfflinePerk." + uuid + "." + perk + ".Experiaton"); - debug(uuid + "/" + perk + "/" + timeLast); - Timestamp timestamp = null; - if (timeLast > 0) { - timestamp = new Timestamp(timeLast); - timeLast = 0; - } - boolean activate = true; - if (timestamp != null) { - if (timestamp.after(new Timestamp(Calendar.getInstance().getTime().getTime()))) { - // not activating - activate = false; - } - } - if (activate) { - debug("Activating perk from shutdown " + perk + "/" + uuid + "/" + timeLast); - getPerkHandler().activePerk(getPerkHandler().getPerk(perk), - getMcperksUserManager().getMCPerksUser(UUID.fromString(uuid)), - (int) timeLast, timestamp); - } - - } - } - } - } - - getMcperksServerData().getData().set("OfflinePerk", null); - getMcperksServerData().saveData(); - } - }); + runtimeReady.thenRun(() -> { + if (featureEpoch != featureLifecycle || !isEnabled()) return; + getBukkitScheduler().runTask(this, () -> { + if (featureEpoch != featureLifecycle || !isEnabled()) return; + perkHandler.loadActivePerksConfirmed(this::restoreOfflinePerks).exceptionally(error -> { + getLogger().severe("Legacy perk restoration failed; activations remain unavailable: " + error.getMessage()); + return null; + }); + }); + }); getJavascriptEngineRequests().add(new JavascriptPlaceholderRequest("MCPerks") { @@ -278,6 +327,44 @@ public void run() { } plugin.getRewardHandler().addInjectedReward(new RewardInjectInt("Activations") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public boolean requiresConfiguredDataForAsync() { return true; } + @Override public java.util.concurrent.CompletionStage onReplayCheckpointPersisted(Reward reward, AdvancedCoreUser user, + String occurrenceId, String injectionKey, boolean selectionPresent) { + if (!selectionPresent) return java.util.concurrent.CompletableFuture.completedFuture(null); + return onReplayCheckpointPersisted(reward, user, occurrenceId, injectionKey); + } + @Override public boolean hasPendingReplayWork(HashMap placeholders) { + return Reward.hasReplaySelection(placeholders); + } + @Override public java.util.concurrent.CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, + org.bukkit.configuration.ConfigurationSection data, HashMap placeholders) { + if (data.isInt(getPath()) || (isAlwaysForce() && data.contains(getPath(), true)) || isAlwaysForceNoData()) + return super.onRewardRequestAsync(reward, user, data, placeholders); + if (!hasPendingReplayWork(placeholders)) return java.util.concurrent.CompletableFuture.completedFuture(null); + return onRewardRequestAsync(reward, user, 0, placeholders).thenApply(result -> (Object) result); + } + + @Override + public java.util.concurrent.CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, int value, + HashMap placeholders) { + String identity = Reward.currentReplaySideEffectOccurrenceId(); + int selected = Integer.parseInt(Reward.replaySelection(placeholders, () -> Integer.toString(value))); + var target = getMcperksUserManager().getMCPerksUser(user.getPlayerName()); + boolean shared = getUserManager().getDataManager().usesSharedSqlStorage(getStorageType()); + return Reward.persistReplayMetadataAsync(plugin, placeholders).thenCompose(ignored -> + shared && identity != null ? target.addReplayActivationAsync(null, selected, "mcperks:" + identity) + : target.addActivationAsync(selected)).thenApply(ignored -> null); + } + @Override + public java.util.concurrent.CompletionStage onReplayCheckpointPersisted(Reward reward, AdvancedCoreUser user, + String occurrenceId, String injectionKey) { + if (!getUserManager().getDataManager().usesSharedSqlStorage(getStorageType())) + return super.onReplayCheckpointPersisted(reward, user, occurrenceId, injectionKey); + String identity = Reward.replaySideEffectOccurrenceId(occurrenceId, injectionKey); + if (identity == null) return super.onReplayCheckpointPersisted(reward, user, occurrenceId, injectionKey); + return getMcperksUserManager().getMCPerksUser(user.getPlayerName()).checkpointReplayActivationAsync("mcperks:" + identity); + } @Override public String onRewardRequest(Reward reward, AdvancedCoreUser user, int value, @@ -304,6 +391,44 @@ public void setVal(String key, Object value) { for (final String perk : MCPerksMain.plugin.getPerkHandler().getLoadedPerks().keySet()) { plugin.getRewardHandler().addInjectedReward(new RewardInjectInt("PerkActivations." + perk) { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public boolean requiresConfiguredDataForAsync() { return true; } + @Override public java.util.concurrent.CompletionStage onReplayCheckpointPersisted(Reward reward, AdvancedCoreUser user, + String occurrenceId, String injectionKey, boolean selectionPresent) { + if (!selectionPresent) return java.util.concurrent.CompletableFuture.completedFuture(null); + return onReplayCheckpointPersisted(reward, user, occurrenceId, injectionKey); + } + @Override public boolean hasPendingReplayWork(HashMap placeholders) { + return Reward.hasReplaySelection(placeholders); + } + @Override public java.util.concurrent.CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, + org.bukkit.configuration.ConfigurationSection data, HashMap placeholders) { + if (data.isInt(getPath()) || (isAlwaysForce() && data.contains(getPath(), true)) || isAlwaysForceNoData()) + return super.onRewardRequestAsync(reward, user, data, placeholders); + if (!hasPendingReplayWork(placeholders)) return java.util.concurrent.CompletableFuture.completedFuture(null); + return onRewardRequestAsync(reward, user, 0, placeholders).thenApply(result -> (Object) result); + } + + @Override + public java.util.concurrent.CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, int value, + HashMap placeholders) { + String identity = Reward.currentReplaySideEffectOccurrenceId(); + int selected = Integer.parseInt(Reward.replaySelection(placeholders, () -> Integer.toString(value))); + var target = getMcperksUserManager().getMCPerksUser(user.getPlayerName()); + boolean shared = getUserManager().getDataManager().usesSharedSqlStorage(getStorageType()); + return Reward.persistReplayMetadataAsync(plugin, placeholders).thenCompose(ignored -> + shared && identity != null ? target.addReplayActivationAsync(perk, selected, "mcperks:" + identity) + : target.addActivationAsync(perk, selected)).thenApply(ignored -> null); + } + @Override + public java.util.concurrent.CompletionStage onReplayCheckpointPersisted(Reward reward, AdvancedCoreUser user, + String occurrenceId, String injectionKey) { + if (!getUserManager().getDataManager().usesSharedSqlStorage(getStorageType())) + return super.onReplayCheckpointPersisted(reward, user, occurrenceId, injectionKey); + String identity = Reward.replaySideEffectOccurrenceId(occurrenceId, injectionKey); + if (identity == null) return super.onReplayCheckpointPersisted(reward, user, occurrenceId, injectionKey); + return getMcperksUserManager().getMCPerksUser(user.getPlayerName()).checkpointReplayActivationAsync("mcperks:" + identity); + } @Override public String onRewardRequest(Reward reward, AdvancedCoreUser user, int value, @@ -476,22 +601,142 @@ public String placeHolder(OfflinePlayer p, String identifier) { } - @Override - public void reload() { + private volatile com.bencodez.mcperks.data.OrderedServerDataFile orderedServerData; + @Override public com.bencodez.advancedcore.data.ServerData getServerDataFile() { + var original = super.getServerDataFile(); + if (storage == null && mcperksServerData != null && original != null) + mcperksServerData.observeNativePermissionSource(original.getData()); + if (mcperksServerData == null || original == null || original.getData() == null) return original; + synchronized (mcperksServerData) { + if (orderedServerData == null) orderedServerData = new com.bencodez.mcperks.data.OrderedServerDataFile(this, original, mcperksServerData); + return orderedServerData; + } + } + @Override public com.bencodez.advancedcore.api.permissions.PermissionHandler getPermissionHandler() { + var handler = super.getPermissionHandler(); + if (mcperksServerData != null && handler != null) mcperksServerData.observeNativePermissionHandler(handler); + return handler; + } + private void restoreOfflinePerks() { + if (getMcperksServerData().getData().isConfigurationSection("OfflinePerk")) { + for (String uuid : getMcperksServerData().getData().getConfigurationSection("OfflinePerk") + .getKeys(false)) { + if (getMcperksServerData().getData().isConfigurationSection("OfflinePerk." + uuid)) { + for (String perk : getMcperksServerData().getData() + .getConfigurationSection("OfflinePerk." + uuid).getKeys(false)) { + + long timeLast = getMcperksServerData().getData() + .getLong("OfflinePerk." + uuid + "." + perk + ".Experiaton"); + long persistedExpiration = timeLast; + debug(uuid + "/" + perk + "/" + timeLast); + Timestamp timestamp = null; + if (timeLast > 0) { + timestamp = new Timestamp(timeLast); + timeLast = 0; + } + boolean activate = timestamp == null || timestamp.after(new Timestamp(Calendar.getInstance().getTime().getTime())); + var definition = getPerkHandler().getPerk(perk); + if (definition == null || !definition.isEnabled()) { + getLogger().warning("Skipping shutdown record for unavailable perk: " + perk); + continue; + } + if (activate) { + debug("Activating perk from shutdown " + perk + "/" + uuid + "/" + timeLast); + getPerkHandler().restoreActivePerk(definition, + getMcperksUserManager().getMCPerksUser(UUID.fromString(uuid)), timestamp); + } + getMcperksServerData().removeOfflinePerkIfMatches(uuid, perk, persistedExpiration); + + } + } + } + } + + getMcperksServerData().saveData(); + } + + private final java.util.concurrent.atomic.AtomicBoolean reloadPending = new java.util.concurrent.atomic.AtomicBoolean(); + private volatile java.util.concurrent.CompletableFuture reloadOperation; + private volatile java.util.concurrent.CompletableFuture reloadObservation; + @Override + public void reload() { + reloadAsync(); + } + /** Starts (or joins) the asynchronous reload and completes after the new lifecycle is installed. */ + public synchronized java.util.concurrent.CompletableFuture reloadAsync() { + java.util.concurrent.CompletableFuture existing = reloadOperation; + if (existing != null && !existing.isDone()) return reloadObservation; + if (!reloadPending.compareAndSet(false, true)) return reloadObservation; + var operation = new java.util.concurrent.CompletableFuture(); + reloadOperation = operation; + var observation = operation.copy(); + reloadObservation = observation; + try { + if (perkHandler != null) perkHandler.pauseNativeActivationsForReload(); + } catch (RuntimeException unavailable) { + finishReload(operation, unavailable); + return observation; + } + // ServerData snapshots accepted before reload must publish before AdvancedCore reads its file. + var barrier = storage == null ? java.util.concurrent.CompletableFuture.completedFuture(null) : storage.runtimeStorage(() -> null); + var nativeBarrier = barrier.thenCompose(ignored -> mcperksServerData == null + ? java.util.concurrent.CompletableFuture.completedFuture(null) : mcperksServerData.reloadDataAsync()); + var ownerStarted = new java.util.concurrent.atomic.AtomicBoolean(); + nativeBarrier.whenComplete((ignored, error) -> { + if (error != null || !isEnabled()) { + if (error != null) getLogger().warning("Reload postponed: pending ServerData publication could not drain"); + finishReload(operation, error == null ? new IllegalStateException("Plugin is disabled") : error); + return; + } + try { + var dispatch = getBukkitScheduler().getFoliaLib().getImpl().runNextTick(scheduledTask -> { + if (operation.isDone() || !ownerStarted.compareAndSet(false, true)) return; + if (!isEnabled()) { finishReload(operation, new IllegalStateException("Plugin is disabled")); return; } + try { + reloadAfterDrain(); + reloadAdvancedCoreAsync(true).whenComplete((value, nativeFailure) -> { + if (nativeFailure != null) { finishReload(operation, nativeFailure); return; } + var files = mcperksServerData == null ? java.util.concurrent.CompletableFuture.completedFuture(null) + : mcperksServerData.reloadCompletion(); + files.whenComplete((done, fileFailure) -> finishReload(operation, fileFailure)); + }); + } + catch (Throwable failure) { finishReload(operation, failure); } + }); + dispatch.whenComplete((dispatchResult, dispatchFailure) -> { + if (dispatchFailure != null && ownerStarted.compareAndSet(false, true)) finishReload(operation, dispatchFailure); + }); + } catch (Throwable failure) { + finishReload(operation, failure); + } + }); + return observation; + } + private synchronized void finishReload(java.util.concurrent.CompletableFuture operation, Throwable failure) { + if (operation.isDone()) return; + if (perkHandler != null) perkHandler.resumeNativeActivationsAfterReload(); + reloadPending.set(false); + if (failure == null) operation.complete(null); else { + getLogger().log(java.util.logging.Level.WARNING, "Reload failed", failure); + operation.completeExceptionally(failure); + } + } + private void reloadAfterDrain() { + // Retire direct activations admitted by the previous configuration generation. + featureLifecycle++; configFile.reloadData(); perkSystemType = configFile.getPerkSystemType(); Lang.getInstance().reloadData(); - mcperksServerData.reloadData(); getPerkHandler().reload(); commandLoader.loadTabComplete(); updateHook(); - reloadAdvancedCore(true); } /** * Setup files. */ private void setupFiles() { + orderedServerData = null; configFile = new Config(this); configFile.setup(); Lang.getInstance().setup(this); diff --git a/MCPerks/src/main/java/com/bencodez/mcperks/VotingPluginHook.java b/MCPerks/src/main/java/com/bencodez/mcperks/VotingPluginHook.java index a60127a..28db2e4 100644 --- a/MCPerks/src/main/java/com/bencodez/mcperks/VotingPluginHook.java +++ b/MCPerks/src/main/java/com/bencodez/mcperks/VotingPluginHook.java @@ -33,6 +33,53 @@ public Object getObject(OfflinePlayer player) { }); VotingPluginHooks.getInstance().addCustomReward(new RewardInjectInt("Activations") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public boolean requiresConfiguredDataForAsync() { return true; } + public java.util.concurrent.CompletionStage onReplayCheckpointPersisted(Reward reward, AdvancedCoreUser user, + String occurrenceId, String injectionKey, boolean selectionPresent) { + if (!selectionPresent) return java.util.concurrent.CompletableFuture.completedFuture(null); + return onReplayCheckpointPersisted(reward, user, occurrenceId, injectionKey); + } + @Override public boolean hasPendingReplayWork(HashMap placeholders) { + try { return Reward.hasReplaySelection(placeholders); } + catch (NoSuchMethodError unavailable) { return false; } + } + @Override public java.util.concurrent.CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, + org.bukkit.configuration.ConfigurationSection data, HashMap placeholders) { + if (data.isInt(getPath()) || (isAlwaysForce() && data.contains(getPath(), true)) || isAlwaysForceNoData()) + return super.onRewardRequestAsync(reward, user, data, placeholders); + if (!hasPendingReplayWork(placeholders)) return java.util.concurrent.CompletableFuture.completedFuture(null); + return onRewardRequestAsync(reward, user, 0, placeholders).thenApply(result -> (Object) result); + } + + @Override + public java.util.concurrent.CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, int value, + HashMap placeholders) { + var plugin = MCPerksMain.plugin; + var target = plugin.getMcperksUserManager().getMCPerksUser(user.getPlayerName()); + String identity; int selected; java.util.concurrent.CompletionStage metadata; + try { + identity = Reward.currentReplaySideEffectOccurrenceId(); + selected = Integer.parseInt(Reward.replaySelection(placeholders, () -> Integer.toString(value))); + metadata = Reward.persistReplayMetadataAsync(VotingPluginMain.plugin, placeholders); + } catch (NoSuchMethodError unavailable) { return target.addActivationAsync(value).thenApply(ignored -> null); } + boolean shared = plugin.getUserManager().getDataManager().usesSharedSqlStorage(plugin.getStorageType()); + return metadata.thenCompose(ignored -> + shared && identity != null ? target.addReplayActivationAsync(null, selected, "votingplugin:" + identity) + : target.addActivationAsync(selected)).thenApply(ignored -> null); + } + @Override + public java.util.concurrent.CompletionStage onReplayCheckpointPersisted(Reward reward, AdvancedCoreUser user, + String occurrenceId, String injectionKey) { + var plugin = MCPerksMain.plugin; + if (!plugin.getUserManager().getDataManager().usesSharedSqlStorage(plugin.getStorageType())) + return super.onReplayCheckpointPersisted(reward, user, occurrenceId, injectionKey); + String identity; + try { identity = Reward.replaySideEffectOccurrenceId(occurrenceId, injectionKey); } + catch (NoSuchMethodError unavailable) { return java.util.concurrent.CompletableFuture.completedFuture(null); } + if (identity == null) return super.onReplayCheckpointPersisted(reward, user, occurrenceId, injectionKey); + return plugin.getMcperksUserManager().getMCPerksUser(user.getPlayerName()).checkpointReplayActivationAsync("votingplugin:" + identity); + } @Override public String onRewardRequest(Reward reward, AdvancedCoreUser user, int value, @@ -59,6 +106,53 @@ public void setVal(String key, Object value) { for (final String perk : MCPerksMain.plugin.getPerkHandler().getLoadedPerks().keySet()) { VotingPluginHooks.getInstance().addCustomReward(new RewardInjectInt("PerkActivations." + perk) { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public boolean requiresConfiguredDataForAsync() { return true; } + public java.util.concurrent.CompletionStage onReplayCheckpointPersisted(Reward reward, AdvancedCoreUser user, + String occurrenceId, String injectionKey, boolean selectionPresent) { + if (!selectionPresent) return java.util.concurrent.CompletableFuture.completedFuture(null); + return onReplayCheckpointPersisted(reward, user, occurrenceId, injectionKey); + } + @Override public boolean hasPendingReplayWork(HashMap placeholders) { + try { return Reward.hasReplaySelection(placeholders); } + catch (NoSuchMethodError unavailable) { return false; } + } + @Override public java.util.concurrent.CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, + org.bukkit.configuration.ConfigurationSection data, HashMap placeholders) { + if (data.isInt(getPath()) || (isAlwaysForce() && data.contains(getPath(), true)) || isAlwaysForceNoData()) + return super.onRewardRequestAsync(reward, user, data, placeholders); + if (!hasPendingReplayWork(placeholders)) return java.util.concurrent.CompletableFuture.completedFuture(null); + return onRewardRequestAsync(reward, user, 0, placeholders).thenApply(result -> (Object) result); + } + + @Override + public java.util.concurrent.CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, int value, + HashMap placeholders) { + var plugin = MCPerksMain.plugin; + var target = plugin.getMcperksUserManager().getMCPerksUser(user.getPlayerName()); + String identity; int selected; java.util.concurrent.CompletionStage metadata; + try { + identity = Reward.currentReplaySideEffectOccurrenceId(); + selected = Integer.parseInt(Reward.replaySelection(placeholders, () -> Integer.toString(value))); + metadata = Reward.persistReplayMetadataAsync(VotingPluginMain.plugin, placeholders); + } catch (NoSuchMethodError unavailable) { return target.addActivationAsync(perk, value).thenApply(ignored -> null); } + boolean shared = plugin.getUserManager().getDataManager().usesSharedSqlStorage(plugin.getStorageType()); + return metadata.thenCompose(ignored -> + shared && identity != null ? target.addReplayActivationAsync(perk, selected, "votingplugin:" + identity) + : target.addActivationAsync(perk, selected)).thenApply(ignored -> null); + } + @Override + public java.util.concurrent.CompletionStage onReplayCheckpointPersisted(Reward reward, AdvancedCoreUser user, + String occurrenceId, String injectionKey) { + var plugin = MCPerksMain.plugin; + if (!plugin.getUserManager().getDataManager().usesSharedSqlStorage(plugin.getStorageType())) + return super.onReplayCheckpointPersisted(reward, user, occurrenceId, injectionKey); + String identity; + try { identity = Reward.replaySideEffectOccurrenceId(occurrenceId, injectionKey); } + catch (NoSuchMethodError unavailable) { return java.util.concurrent.CompletableFuture.completedFuture(null); } + if (identity == null) return super.onReplayCheckpointPersisted(reward, user, occurrenceId, injectionKey); + return plugin.getMcperksUserManager().getMCPerksUser(user.getPlayerName()).checkpointReplayActivationAsync("votingplugin:" + identity); + } @Override public String onRewardRequest(Reward reward, AdvancedCoreUser user, int value, diff --git a/MCPerks/src/main/java/com/bencodez/mcperks/commands/CommandLoader.java b/MCPerks/src/main/java/com/bencodez/mcperks/commands/CommandLoader.java index 9d239e9..c6c0e74 100644 --- a/MCPerks/src/main/java/com/bencodez/mcperks/commands/CommandLoader.java +++ b/MCPerks/src/main/java/com/bencodez/mcperks/commands/CommandLoader.java @@ -51,6 +51,32 @@ public class CommandLoader { private HashMap commands; private Object activationsLock = new Object(); + private void reportActivationResult(CommandSender sender, java.util.concurrent.CompletionStage operation, String success) { + operation.whenComplete((ignored, failure) -> { + Runnable response = () -> sender.sendMessage(MessageAPI.colorize(failure == null ? success : "&cActivation update could not be confirmed; check the server log before retrying.")); + if (failure != null) plugin.getLogger().warning("Activation update could not be confirmed"); + if (sender instanceof Player player) + com.bencodez.mcperks.scheduling.OwnerTasks.run(plugin, player, () -> { if (player.isOnline()) response.run(); }, () -> {}); + else plugin.getBukkitScheduler().runTask(plugin, response); + }); + } + private void loadReloadCommand() { + plugin.getCommands().add(new CommandHandler(plugin, new String[] { "Reload" }, "MCPerks.Reload", "Reloads the plugin") { + @Override public void execute(CommandSender sender, String[] args) { + plugin.reloadAsync().whenComplete((ignored, error) -> { + Runnable response = () -> sender.sendMessage(MessageAPI.colorize(error == null + ? "&c" + plugin.getName() + " v" + plugin.getDescription().getVersion() + " reloaded!" + : "&c" + plugin.getName() + " reload failed; check the server log.")); + if (sender instanceof Player player) + com.bencodez.mcperks.scheduling.OwnerTasks.run(plugin, player, + () -> { if (player.isOnline()) response.run(); }, () -> {}); + else plugin.getBukkitScheduler().runTask(plugin, response); + }); + } + }); + } + + /** * Instantiates a new command loader. @@ -157,16 +183,7 @@ public void execute(CommandSender sender, String[] args) { } }); - plugin.getCommands() - .add(new CommandHandler(plugin, new String[] { "Reload" }, "MCPerks.Reload", "Reloads the plugin") { - - @Override - public void execute(CommandSender sender, String[] args) { - plugin.reload(); - sender.sendMessage(MessageAPI.colorize( - "&c" + plugin.getName() + " v" + plugin.getDescription().getVersion() + " reloaded!")); - } - }); + loadReloadCommand(); plugin.getCommands() .add(new CommandHandler(plugin, new String[] { "Version" }, "MCPerks.Version", "List version info") { @@ -588,8 +605,7 @@ public void execute(CommandSender sender, String[] args) { public void execute(CommandSender sender, String[] args) { synchronized (activationsLock) { MCPerksUser user = plugin.getMcperksUserManager().getMCPerksUser(args[1]); - user.setActivations(Integer.parseInt(args[2])); - sendMessage(sender, "&cSet activations to " + args[2]); + reportActivationResult(sender, user.setActivationsAsync(Integer.parseInt(args[2])), "&cSet activations to " + args[2]); } } }); @@ -601,15 +617,15 @@ public void execute(CommandSender sender, String[] args) { public void execute(CommandSender sender, String[] args) { synchronized (activationsLock) { if (args[1].equals("*")) { + var pending = new java.util.ArrayList>(); for (Player p : Bukkit.getOnlinePlayers()) { MCPerksUser user = plugin.getMcperksUserManager().getMCPerksUser(p); - user.addActivation(Integer.parseInt(args[2])); + pending.add(user.addActivationAsync(Integer.parseInt(args[2])).toCompletableFuture()); } - sendMessage(sender, "&cAdded activations to " + args[2]); + reportActivationResult(sender, java.util.concurrent.CompletableFuture.allOf(pending.toArray(new java.util.concurrent.CompletableFuture[0])), "&cAdded activations: " + args[2]); } else { MCPerksUser user = plugin.getMcperksUserManager().getMCPerksUser(args[1]); - user.addActivation(Integer.parseInt(args[2])); - sendMessage(sender, "&cSet activations to " + user.getActivations()); + reportActivationResult(sender, user.addActivationAsync(Integer.parseInt(args[2])), "&cAdded activations: " + args[2]); } } } @@ -666,11 +682,7 @@ public void execute(CommandSender sender, String[] args) { @Override public void execute(CommandSender sender, String[] args) { MCPerksUser user = plugin.getMcperksUserManager().getMCPerksUser(args[1]); - user.addActivation(-Integer.parseInt(args[2])); - if (user.getActivations() < 0) { - user.setActivations(0); - } - sendMessage(sender, "&cSet activations to " + user.getActivations()); + reportActivationResult(sender, user.removeActivationClampedAsync(Integer.parseInt(args[2])), "&cRemoved activations: " + args[2]); } }); @@ -682,8 +694,7 @@ public void execute(CommandSender sender, String[] args) { public void execute(CommandSender sender, String[] args) { synchronized (activationsLock) { MCPerksUser user = plugin.getMcperksUserManager().getMCPerksUser(args[1]); - user.setActivations(args[2], Integer.parseInt(args[3])); - sendMessage(sender, "&cSet activations to " + args[3]); + reportActivationResult(sender, user.setActivationsAsync(args[2], Integer.parseInt(args[3])), "&cSet activations to " + args[3]); } } }); @@ -696,15 +707,15 @@ public void execute(CommandSender sender, String[] args) { public void execute(CommandSender sender, String[] args) { synchronized (activationsLock) { if (args[1].equals("*")) { + var pending = new java.util.ArrayList>(); for (Player p : Bukkit.getOnlinePlayers()) { MCPerksUser user = plugin.getMcperksUserManager().getMCPerksUser(p); - user.addActivation(args[2], Integer.parseInt(args[3])); + pending.add(user.addActivationAsync(args[2], Integer.parseInt(args[3])).toCompletableFuture()); } - sendMessage(sender, "&cAdded activations to " + args[3]); + reportActivationResult(sender, java.util.concurrent.CompletableFuture.allOf(pending.toArray(new java.util.concurrent.CompletableFuture[0])), "&cAdded activations: " + args[3]); } else { MCPerksUser user = plugin.getMcperksUserManager().getMCPerksUser(args[1]); - user.addActivation(args[2], Integer.parseInt(args[3])); - sendMessage(sender, "&cSet activations to " + user.getActivations(args[2])); + reportActivationResult(sender, user.addActivationAsync(args[2], Integer.parseInt(args[3])), "&cAdded activations: " + args[3]); } } } @@ -719,11 +730,7 @@ public void execute(CommandSender sender, String[] args) { public void execute(CommandSender sender, String[] args) { synchronized (activationsLock) { MCPerksUser user = plugin.getMcperksUserManager().getMCPerksUser(args[1]); - user.addActivation(-Integer.parseInt(args[3])); - if (user.getActivations(args[2]) < 0) { - user.setActivations(args[2], 0); - } - sendMessage(sender, "&cSet activations to " + user.getActivations()); + reportActivationResult(sender, user.removeActivationClampedAsync(args[2], Integer.parseInt(args[3])), "&cRemoved activations: " + args[3]); } } }); diff --git a/MCPerks/src/main/java/com/bencodez/mcperks/data/DurableFeatureFiles.java b/MCPerks/src/main/java/com/bencodez/mcperks/data/DurableFeatureFiles.java new file mode 100644 index 0000000..9f31842 --- /dev/null +++ b/MCPerks/src/main/java/com/bencodez/mcperks/data/DurableFeatureFiles.java @@ -0,0 +1,24 @@ +package com.bencodez.mcperks.data; +import java.io.IOException; +import java.nio.channels.FileChannel; +import java.nio.file.*; +/** Forces staged contents and atomic publication. A post-move error has an uncertain outcome. */ +public final class DurableFeatureFiles { + private DurableFeatureFiles() {} + @FunctionalInterface public interface Publisher { void publish(Path staged, Path file) throws IOException; } + /** Reconfirm an already replaced file without executing its mutation or moving it again. */ + public static void settle(Path file) throws IOException { + try (FileChannel channel = FileChannel.open(file, StandardOpenOption.WRITE)) { channel.force(true); } + forceDirectory(file); + } + private static void forceDirectory(Path file) throws IOException { + try (FileChannel channel = FileChannel.open(file.toAbsolutePath().getParent(), StandardOpenOption.READ)) { channel.force(true); } + catch (AccessDeniedException error) { if (!System.getProperty("os.name", "").startsWith("Windows")) throw error; } + catch (UnsupportedOperationException ignored) { /* Provider does not expose directory forcing. */ } + } + public static void publish(Path staged, Path file) throws IOException { + try (FileChannel channel = FileChannel.open(staged, StandardOpenOption.WRITE)) { channel.force(true); } + Files.move(staged, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + forceDirectory(file); + } +} diff --git a/MCPerks/src/main/java/com/bencodez/mcperks/data/FeatureFileOwnership.java b/MCPerks/src/main/java/com/bencodez/mcperks/data/FeatureFileOwnership.java new file mode 100644 index 0000000..ef3312a --- /dev/null +++ b/MCPerks/src/main/java/com/bencodez/mcperks/data/FeatureFileOwnership.java @@ -0,0 +1,47 @@ +package com.bencodez.mcperks.data; +import java.nio.file.*; +import java.nio.channels.*; +import java.io.*; +import java.util.*; +import java.util.concurrent.*; +/** Storage workers retain an OS lock through drain, including across plugin classloader replacement. */ +public final class FeatureFileOwnership { + private static final Map> owners = new HashMap<>(); + private final Path path; + private final CompletableFuture predecessor, drained = new CompletableFuture<>(); + private FileChannel channel; + private FileLock lock; + public FeatureFileOwnership(Path file) { + path = file.toAbsolutePath().normalize(); + synchronized (owners) { predecessor = owners.put(path, drained); } + } + public synchronized void awaitPredecessor() { + if (predecessor != null) predecessor.join(); + try { + Files.createDirectories(path.getParent()); + Path guard = path.resolveSibling(path.getFileName() + ".lock"); + channel = FileChannel.open(guard, StandardOpenOption.CREATE, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30); + while (lock == null) { + try { lock = channel.tryLock(); } catch (OverlappingFileLockException busy) { } + if (lock != null) return; + if (System.nanoTime() >= deadline) throw new IOException("Predecessor storage owner has not drained"); + try { TimeUnit.MILLISECONDS.sleep(25); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new IOException("Storage ownership interrupted", interrupted); } + } + } catch (IOException failure) { + try { if (channel != null) channel.close(); } catch (IOException cleanup) { failure.addSuppressed(cleanup); } + channel = null; + throw new UncheckedIOException(failure); + } + } + public synchronized void drained() { + if (drained.isDone()) return; + try { + if (channel != null) channel.close(); // Releases the lock before publishing drain completion. + lock = null; channel = null; drained.complete(null); + } catch (IOException failure) { drained.completeExceptionally(failure); } + synchronized (owners) { owners.remove(path, drained); } + // Keep the empty lock file: unlinking it could let another classloader lock a different inode. + } +} diff --git a/MCPerks/src/main/java/com/bencodez/mcperks/data/OrderedServerDataFile.java b/MCPerks/src/main/java/com/bencodez/mcperks/data/OrderedServerDataFile.java new file mode 100644 index 0000000..772eef6 --- /dev/null +++ b/MCPerks/src/main/java/com/bencodez/mcperks/data/OrderedServerDataFile.java @@ -0,0 +1,80 @@ +package com.bencodez.mcperks.data; + +import java.io.File; +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.plugin.Plugin; +import com.bencodez.mcperks.MCPerksMain; +import com.bencodez.advancedcore.api.time.TimeType; + +/** Shares AdvancedCore's initialized YAML while keeping calendar and perk writes ordered together. */ +public final class OrderedServerDataFile extends com.bencodez.advancedcore.data.ServerData { + private final com.bencodez.advancedcore.data.ServerData delegate; + private final ServerData runtime; + private final TransitionFile transitions; + public OrderedServerDataFile(MCPerksMain plugin, com.bencodez.advancedcore.data.ServerData delegate, ServerData runtime) { + super(plugin); this.delegate = delegate; this.runtime = runtime; transitions = new TransitionFile(plugin); + // Native TimeChecker saves LastUpdated before onPostLoad creates OrderedStorage. + // Keep that mutation in memory until the original disk source is strictly reconciled. + if (plugin.getStorage() == null) runtime.deferNativeSaves(delegate.getData(), delegate.isJustCreated()); + } + @Override public FileConfiguration getData() { return delegate.getData(); } + @Override public File getdFile() { return delegate.getdFile(); } + @Override public boolean isFailedToRead() { return delegate.isFailedToRead(); } + @Override public Object publicationMonitor() { return runtime; } + @Override public void saveData() { runtime.saveData(); } + @Override public java.util.concurrent.CompletionStage mutateFinalData( + java.util.function.Consumer mutation) { + return runtime.confirmedFinalMutation(mutation); + } + + // Only calendar callers share this monitor. Inherited mutation/rollback operates on a private + // view; gameplay never acquires this monitor or waits for calendar file publication. + private T calendar(java.util.function.Function operation) { + runtime.ready().join(); + transitions.view = runtime.timeTransitionView(); + try { return operation.apply(transitions); } + finally { transitions.view = null; } + } + private final class TransitionFile extends com.bencodez.advancedcore.data.ServerData { + private ServerData.TimeTransitionView view; + private TransitionFile(MCPerksMain plugin) { super(plugin); } + @Override public FileConfiguration getData() { return view.data(); } + @Override protected synchronized void saveTimeChangeTransitionData() { + // TimeChecker invokes this synchronous durability API off the gameplay owner. + runtime.confirmedTimeTransition(view).join(); + } + } + @Override public synchronized TimeChangeTransitionState beginTimeChangeTransition(TimeType type, String period, String marker) { + runtime.requireCalendarAdmission(); + return calendar(file -> file.beginTimeChangeTransition(type, period, marker)); + } + @Override public synchronized TimeChangeTransitionState getPendingTimeChangeTransition(TimeType type) { + return calendar(file -> file.getPendingTimeChangeTransition(type)); + } + @Override public synchronized void completeTimeChangeTransition(TimeChangeTransitionState transition) { + calendar(file -> { file.completeTimeChangeTransition(transition); return null; }); + } + @Override public synchronized void failTimeChangeTransition(TimeChangeTransitionState transition) { + calendar(file -> { file.failTimeChangeTransition(transition); return null; }); + } + @Override public synchronized boolean isPendingTimeChangeTransition(TimeChangeTransitionState transition) { + return calendar(file -> file.isPendingTimeChangeTransition(transition)); + } + @Override public void reloadData() { runtime.reloadData(); } + @Override public void setData(String path, Object value) { synchronized (runtime) { runtime.calendarEdited(path); super.setData(path, value); } } + @Override public void setData(FileConfiguration data) { synchronized (runtime) { runtime.calendarEdited(""); delegate.setData(data); } } + @Override public void setValue(String path, Object value) { synchronized (runtime) { runtime.calendarEdited(path); super.setValue(path, value); } } + @Override public void createSection(String path) { synchronized (runtime) { runtime.calendarEdited(path); super.createSection(path); } } + @Override public void setIgnoreTime(boolean value) { synchronized (runtime) { super.setIgnoreTime(value); } } + @Override public void setLastUpdated() { synchronized (runtime) { super.setLastUpdated(); } } + @Override public void setPluginVersion(Plugin plugin) { synchronized (runtime) { super.setPluginVersion(plugin); } } + @Override public void setPrevDay(int value) { synchronized (runtime) { runtime.calendarEdited("PrevDay"); super.setPrevDay(value); } } + @Override public void setPrevMonth(String value) { synchronized (runtime) { runtime.calendarEdited("Month"); super.setPrevMonth(value); } } + @Override public void setPrevWeekDay(int value) { synchronized (runtime) { runtime.calendarEdited("PrevWeek"); super.setPrevWeekDay(value); } } + @Override public long getLastUpdated() { synchronized (runtime) { return super.getLastUpdated(); } } + @Override public String getPluginVersion(Plugin plugin) { synchronized (runtime) { return super.getPluginVersion(plugin); } } + @Override public int getPrevDay() { synchronized (runtime) { return super.getPrevDay(); } } + @Override public String getPrevMonth() { synchronized (runtime) { return super.getPrevMonth(); } } + @Override public int getPrevWeekDay() { synchronized (runtime) { return super.getPrevWeekDay(); } } + @Override public boolean isIgnoreTime() { synchronized (runtime) { return super.isIgnoreTime(); } } +} diff --git a/MCPerks/src/main/java/com/bencodez/mcperks/data/OrderedStorage.java b/MCPerks/src/main/java/com/bencodez/mcperks/data/OrderedStorage.java new file mode 100644 index 0000000..2b19f8d --- /dev/null +++ b/MCPerks/src/main/java/com/bencodez/mcperks/data/OrderedStorage.java @@ -0,0 +1,59 @@ +package com.bencodez.mcperks.data; + +import java.nio.file.Path; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +/** One bounded file owner, retained until all admitted storage work has drained. */ +public final class OrderedStorage implements AutoCloseable { + private final FeatureFileOwnership ownership; + private final ThreadPoolExecutor worker; + private final CompletableFuture ready; + private volatile Thread ownerThread; + private volatile boolean ownershipFailed; + + public OrderedStorage(Path ownershipFile) { + ownership = new FeatureFileOwnership(ownershipFile); + worker = new ThreadPoolExecutor(1, 1, 0, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(256), task -> { + Thread thread = new Thread(task, "MCPerks-feature-store"); + thread.setDaemon(true); + ownerThread = thread; + return thread; + }, new ThreadPoolExecutor.AbortPolicy()) { + @Override protected void terminated() { ownership.drained(); } + }; + // This is the first admitted operation. Later work cannot overtake ownership acquisition. + ready = runtimeStorage(() -> { + try { ownership.awaitPredecessor(); } + catch (RuntimeException | Error failure) { ownershipFailed = true; throw failure; } + return null; + }); + } + + public CompletableFuture ready() { return ready; } + + public boolean isOwnerThread() { return Thread.currentThread() == ownerThread; } + + /** Independent native storage remains usable when optional feature data cannot be decoded. */ + public CompletableFuture runtimeStorage(Supplier operation) { + CompletableFuture future = new CompletableFuture<>(); + try { + worker.execute(() -> { + if (ownershipFailed) { + future.completeExceptionally(new IllegalStateException("Storage owner unavailable until restart")); + return; + } + try { future.complete(operation.get()); } + catch (Throwable failure) { future.completeExceptionally(failure); } + }); + } catch (RejectedExecutionException failure) { future.completeExceptionally(failure); } + return future; + } + + /** Stops admission; queued work retains the OS lock until the worker actually terminates. */ + @Override public void close() { worker.shutdown(); } +} diff --git a/MCPerks/src/main/java/com/bencodez/mcperks/data/ServerData.java b/MCPerks/src/main/java/com/bencodez/mcperks/data/ServerData.java index 7eed851..2714ad9 100644 --- a/MCPerks/src/main/java/com/bencodez/mcperks/data/ServerData.java +++ b/MCPerks/src/main/java/com/bencodez/mcperks/data/ServerData.java @@ -1,70 +1,1010 @@ -/* - * - */ package com.bencodez.mcperks.data; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.*; import org.bukkit.configuration.file.FileConfiguration; - import com.bencodez.mcperks.MCPerksMain; import com.bencodez.mcperks.perk.Perk; -// TODO: Auto-generated Javadoc -/** - * The Class ServerData. - */ +/** Legacy runtime YAML. Owner mutations produce immutable, ordered worker snapshots. */ public class ServerData { + private final MCPerksMain plugin; + private final DurableFeatureFiles.Publisher publisher; + private CalendarUpdate pendingCalendar; + private volatile CompletableFuture initialization = CompletableFuture.completedFuture(null); + private boolean canonicalLoaded = true; + private boolean nativeTimedPermissionsObserved; + private boolean nativeStartupDeferred; + private String nativeStartupBaseline; + private boolean nativeCreatedEmptySource; + private Map> nativePermissionSource = Map.of(); + private com.bencodez.advancedcore.api.permissions.PermissionHandler observedNativeHandler; + private Map nativePermissionBaseline = Map.of(); + private RuntimeException nativePermissionSnapshotFailure; + /** Early native writes cannot overwrite a source that has not been strictly read. */ + public synchronized void deferNativeSaves(FileConfiguration original, boolean justCreated) { + if (!nativeStartupDeferred) { + nativeStartupBaseline = original.saveToString(); + nativeCreatedEmptySource = justCreated && original.getKeys(false).isEmpty(); + nativeStartupDeferred = true; canonicalLoaded = false; + initialization = new CompletableFuture<>(); + } + } + /** The plugin getter captures state before the first native grant/revoke/login call. */ + public synchronized void observeNativePermissionHandler(com.bencodez.advancedcore.api.permissions.PermissionHandler handler) { + if (observedNativeHandler == null) { + observedNativeHandler = handler; + try { nativePermissionBaseline = nativePermissionSnapshot(handler); } + catch (RuntimeException failure) { nativePermissionSnapshotFailure = failure; } + } + } + private final java.util.concurrent.atomic.AtomicInteger pendingSnapshots = new java.util.concurrent.atomic.AtomicInteger(); + private long runtimeEdits; + private final ThreadLocal> activationWrites = new ThreadLocal<>(); + public ServerData(MCPerksMain plugin) { this(plugin, DurableFeatureFiles::publish); } + ServerData(MCPerksMain plugin, DurableFeatureFiles.Publisher publisher) { this.plugin = plugin; this.publisher = publisher; } - private MCPerksMain plugin; - - public ServerData(MCPerksMain plugin) { - this.plugin = plugin; - } - - public void addPerkHistory(String data) { - String path = "PerkHistory." + plugin.getTimeChecker().getTime().getMonth().toString(); - getData().set(path, getData().getStringList(path).add(data)); - saveData(); - } + public CompletableFuture ready() { return initialization.copy(); } + public boolean isReady() { return initialization.isDone() && !initialization.isCompletedExceptionally(); } + /** Called by the early native-file hook before AdvancedCore constructs its handler. */ + public synchronized void observeNativePermissionSource(FileConfiguration data) { + if (plugin.getPermissionHandler() == null && data != null) { + var section = data.getConfigurationSection("TimedPermissions"); + if (!nativeTimedPermissionsObserved && section != null && !section.getKeys(false).isEmpty()) { + nativeTimedPermissionsObserved = true; nativePermissionSource = permissionRows(data); + } + } + } + // These are the scalar fields mutated by AdvancedCore's transition API, verified against its + // actual implementation. Copies/changes are bounded independently of perk/history contents. + private static final List CALENDAR_PATHS = calendarPaths(); + private final long[] calendarEdits = new long[CALENDAR_PATHS.size()]; + synchronized void calendarEdited(String path) { + activationEdited(path); + for (int i = 0; i < CALENDAR_PATHS.size(); i++) + if (path.isEmpty() || CALENDAR_PATHS.get(i).equals(path) || CALENDAR_PATHS.get(i).startsWith(path + ".")) calendarEdits[i]++; + } + private static List calendarPaths() { + var paths = new ArrayList<>(List.of("PrevDay", "PrevWeek", "Month")); + for (var type : com.bencodez.advancedcore.api.time.TimeType.values()) + for (String field : List.of("Id", "Period", "Marker", "Pending")) paths.add("TimeTransitions." + type.name() + "." + field); + return List.copyOf(paths); + } + record TimeTransitionView(FileConfiguration data, Map before) {} + synchronized TimeTransitionView timeTransitionView() { + try { + var copy = parse(getData().saveToString()); + var before = new LinkedHashMap(); + for (String path : CALENDAR_PATHS) before.put(path, snapshotValue(copy.get(path))); + return new TimeTransitionView(copy, Collections.unmodifiableMap(before)); + } catch (org.bukkit.configuration.InvalidConfigurationException error) { throw new CompletionException(error); } + } + private static Object snapshotValue(Object value) { + if (value instanceof org.bukkit.configuration.ConfigurationSection section) return snapshotValue(tree(section)); + if (value instanceof Map map) { + var copy = new LinkedHashMap(); map.forEach((key, child) -> copy.put(key, snapshotValue(child))); + return Collections.unmodifiableMap(copy); + } + if (value instanceof List list) return Collections.unmodifiableList(list.stream().map(ServerData::snapshotValue).toList()); + // YAML narrows small integral values to Integer. Compare their exact + // 64-bit meaning without coercing floating-point values or large Longs. + if (value instanceof Byte || value instanceof Short || value instanceof Integer || value instanceof Long) + return ((Number) value).longValue(); + if (value instanceof java.util.Date date) return new java.util.Date(date.getTime()); + return value; + } + private record CalendarChange(int field, long edit, Object after) {} + private static final class CalendarUpdate { + private final Map changes; + private final CalendarUpdate previous; + private volatile boolean published; + private CalendarUpdate(Map changes, CalendarUpdate previous) { + this.changes = Collections.unmodifiableMap(changes); this.previous = previous; + } + private void applyPublishedChain(FileConfiguration data, long[] edits, Set excluded) { + var chain = new ArrayList(); + for (CalendarUpdate item = this; item != null; item = item.previous) chain.add(item); + for (int i = chain.size() - 1; i >= 0; i--) { + var item = chain.get(i); + if (!item.published) continue; + item.changes.forEach((path, change) -> { + if (!excluded.contains(path) && (edits == null || edits[change.field()] == change.edit())) data.set(path, change.after()); + }); + } + } + private void apply(FileConfiguration data, long[] edits) { + changes.forEach((path, change) -> { + if (edits == null || edits[change.field()] == change.edit()) data.set(path, change.after()); + }); + } + } + /** Admit a private calendar mutation and immutable snapshot, then release ownership before waiting. */ + synchronized CompletableFuture confirmedTimeTransition(TimeTransitionView view) { + if (!isReady()) return CompletableFuture.failedFuture(new IllegalStateException("ServerData initialization unavailable")); + try { + var changes = new LinkedHashMap(); + for (int i = 0; i < CALENDAR_PATHS.size(); i++) { + String path = CALENDAR_PATHS.get(i); Object next = snapshotValue(view.data().get(path)); + if (!Objects.equals(view.before().get(path), next)) changes.put(path, new CalendarChange(i, calendarEdits[i], next)); + } + var predecessor = pendingCalendar; + var update = new CalendarUpdate(changes, predecessor); + var copy = parse(getData().saveToString()); update.apply(copy, null); + byte[] snapshot = snapshotBytes(copy); + long[] edits = calendarEdits.clone(); + pendingCalendar = update; + return publishSnapshot(snapshot, List.of(), predecessor, edits, update); + } catch (org.bukkit.configuration.InvalidConfigurationException | RuntimeException error) { return CompletableFuture.failedFuture(error); } + } + /** Reconcile the cache loaded by AdvancedCore before a prior feature worker finished shutdown. */ + public synchronized CompletableFuture initialize(OrderedStorage state) { + String baseline = nativeStartupDeferred ? nativeStartupBaseline : getData().saveToString(); + var nativePermissions = plugin.getPermissionHandler(); + if (nativePermissions != null) observeNativePermissionHandler(nativePermissions); + Path file = plugin.getServerDataFile().getdFile().toPath(); + CompletableFuture initialized = nativeStartupDeferred && !initialization.isDone() + ? initialization : new CompletableFuture<>(); + initialization = initialized; canonicalLoaded = false; + state.runtimeStorage(() -> { + try { + if (nativePermissionSnapshotFailure != null) throw nativePermissionSnapshotFailure; + boolean nativeReadFailed = plugin.getServerDataFile().isFailedToRead(); + boolean sourceExists = Files.exists(file, LinkOption.NOFOLLOW_LINKS); + var loaded = sourceExists ? readSnapshot(file) : parse(baseline); + // Early native saves are deferred: even malformed native empty fallbacks + // still face the unchanged regular-file check and strict disk parser. + // A native-created empty source may now contain a drained predecessor's + // valid snapshot; existing nonempty native read failures remain fatal. + if (nativeReadFailed && (!sourceExists || !loaded.getKeys(false).isEmpty() && !nativeCreatedEmptySource)) + throw new IOException("Native ServerData could not be read"); + return loaded; + } catch (IOException | org.bukkit.configuration.InvalidConfigurationException error) { throw new CompletionException(error); } + }).thenCompose(loaded -> reconcileNativePermissions(state, nativePermissions, loaded).thenCompose(ignored -> + state.runtimeStorage(() -> { + try { synchronized (ServerData.this) { + var old = parse(baseline); var current = parse(getData().saveToString()); + var previousValues = tree(old); var currentValues = tree(current); + Set keys = new HashSet<>(previousValues.keySet()); keys.addAll(currentValues.keySet()); + // Changes made while predecessor/owner work drained remain authoritative. + for (String key : keys) if (!Objects.equals(previousValues.get(key), currentValues.get(key))) + loaded.set(key, currentValues.get(key)); + if (nativePermissions != null) { + requireNativeLifecycle(state, nativePermissions); + loaded.set("TimedPermissions", currentValues.get("TimedPermissions")); + } + getData().loadFromString(loaded.saveToString()); canonicalLoaded = true; + return persistSnapshot(List.of()); + } } catch (org.bukkit.configuration.InvalidConfigurationException failure) { throw new CompletionException(failure); } + }).thenCompose(future -> future))) + .whenComplete((ignored, error) -> { + if (error == null) initialized.complete(null); + else { + for (Throwable cause = error; cause != null; cause = cause.getCause()) + if (UNSUPPORTED_LEGACY.equals(cause.getMessage())) { plugin.getLogger().severe(UNSUPPORTED_LEGACY); break; } + initialized.completeExceptionally(error); + } + }); + return initialized.copy(); + } + private static org.bukkit.configuration.file.YamlConfiguration parse(String text) throws org.bukkit.configuration.InvalidConfigurationException { + var yaml = new org.bukkit.configuration.file.YamlConfiguration(); yaml.loadFromString(text); return yaml; + } + private static final String SNAPSHOT_HEADER = "# MCPerks ordered ServerData v1\n"; + private static final String UNSUPPORTED_LEGACY = "Legacy ServerData indispensable payload (including still-active TimedPermissions) exceeds four MB; original file retained. Revoke or expire excess timed permissions before upgrading, or reduce other indispensable legacy payload."; + private record NativePermissionNodes(Map timed, Map pending, Set permanent) { + private static final NativePermissionNodes EMPTY = new NativePermissionNodes(Map.of(), Map.of(), Set.of()); + private Object value(String node) { + if (permanent.contains(node)) return "permanent"; + if (timed.containsKey(node)) return List.of("timed", timed.get(node)); + return pending.containsKey(node) ? List.of("pending", pending.get(node)) : null; + } + private Set keys() { + Set keys = new HashSet<>(timed.keySet()); keys.addAll(pending.keySet()); keys.addAll(permanent); return keys; + } + } + /** Only these exact synchronized native snapshot methods are accessed, with no field fallback. */ + private static Map nativeSnapshot(com.bencodez.advancedcore.api.permissions.PlayerPermissionHandler handle, String method) { + try { + var getter = com.bencodez.advancedcore.api.permissions.PlayerPermissionHandler.class.getDeclaredMethod(method); + if (getter.getReturnType() != Map.class || !java.lang.reflect.Modifier.isSynchronized(getter.getModifiers()) + || !getter.trySetAccessible()) throw new IllegalStateException("Unsupported native permission snapshot API: " + method); + Object result = getter.invoke(handle); + if (!(result instanceof Map values)) throw new IllegalStateException("Invalid native permission snapshot: " + method); + Map copy = new HashMap<>(); + for (var entry : values.entrySet()) { + if (!(entry.getKey() instanceof String key) || !(entry.getValue() instanceof Long value)) + throw new IllegalStateException("Invalid native permission snapshot entry: " + method); + copy.put(key, value); + } + return Map.copyOf(copy); + } catch (ReflectiveOperationException failure) { throw new IllegalStateException("Native permission snapshot unavailable: " + method, failure); } + } + private static Map nativePermissionSnapshot(com.bencodez.advancedcore.api.permissions.PermissionHandler handler) { + Map values = new HashMap<>(); + handler.getPermsToAdd().forEach((owner, handle) -> values.put(owner, nativeNodes(handle))); + handler.getPerms().forEach((owner, handle) -> values.put(owner, nativeNodes(handle))); + return Map.copyOf(values); + } + private static NativePermissionNodes nativePermissionOwnerSnapshot(com.bencodez.advancedcore.api.permissions.PermissionHandler handler, UUID owner) { + var handle = handler.getPerms().get(owner); + if (handle == null) handle = handler.getPermsToAdd().get(owner); + return handle == null ? NativePermissionNodes.EMPTY : nativeNodes(handle); + } + private static NativePermissionNodes nativeNodes(com.bencodez.advancedcore.api.permissions.PlayerPermissionHandler handle) { + synchronized (handle) { + return new NativePermissionNodes(nativeSnapshot(handle, "timedPermissionSnapshot"), + nativeSnapshot(handle, "offlinePermissionSnapshot"), Set.copyOf(handle.getPersistentPermissions())); + } + } + private static Map> permissionRows(FileConfiguration data) { + Map> result = new HashMap<>(); + var section = data.getConfigurationSection("TimedPermissions"); + if (section != null) for (String owner : section.getKeys(false)) { + Map nodes = new HashMap<>(); + for (String row : section.getStringList(owner)) { + String[] fields = row.split(java.util.regex.Pattern.quote("%line%")); + if (fields.length != 2) continue; + try { + long expiry = Long.parseLong(fields[1]); + // Match the native grant API's supported multi-node separator. + for (String node : fields[0].split(java.util.regex.Pattern.quote("|"))) + if (!node.isEmpty()) nodes.put(node, expiry); + } catch (NumberFormatException ignored) { } + } + result.put(UUID.fromString(owner), Map.copyOf(nodes)); + } + return Map.copyOf(result); + } + private void requireNativeLifecycle(OrderedStorage state, com.bencodez.advancedcore.api.permissions.PermissionHandler handler) { + if (!plugin.isEnabled() || plugin.getStorage() != state || plugin.getPermissionHandler() != handler + || handler.getTimer().isShutdown()) throw new IllegalStateException("Native permission lifecycle retired during reconciliation"); + } + private CompletableFuture reconcileNativePermissions(OrderedStorage state, + com.bencodez.advancedcore.api.permissions.PermissionHandler handler, org.bukkit.configuration.file.YamlConfiguration loaded) { + if (handler == null) return CompletableFuture.completedFuture(null); + Map> recovered = permissionRows(loaded); + CompletableFuture result = new CompletableFuture<>(); + try { plugin.getBukkitScheduler().runTask(plugin, () -> { + try { + requireNativeLifecycle(state, handler); + var current = nativePermissionSnapshot(handler); + Set owners = new HashSet<>(recovered.keySet()); owners.addAll(nativePermissionSource.keySet()); + owners.addAll(current.keySet()); + // Check the effective indispensable rows before any native effects or row clearing. + var projection = parse(loaded.saveToString()); projection.set("TimedPermissions", null); + long now = System.currentTimeMillis(); + for (UUID owner : owners) { + var base = nativePermissionBaseline.getOrDefault(owner, NativePermissionNodes.EMPTY); + var live = current.getOrDefault(owner, NativePermissionNodes.EMPTY); + var source = nativePermissionSource.getOrDefault(owner, Map.of()); + var disk = recovered.getOrDefault(owner, Map.of()); + Set nodes = new HashSet<>(source.keySet()); nodes.addAll(disk.keySet()); nodes.addAll(live.keys()); + List rows = new ArrayList<>(); + for (String node : nodes) { + Long expiry = disk.get(node); + if (!Objects.equals(base.value(node), live.value(node))) { + expiry = live.timed().get(node); + if (live.pending().containsKey(node)) expiry = live.pending().get(node) > 0 ? Math.addExact(now, live.pending().get(node)) : null; + } else if (Objects.equals(source.get(node), expiry) && live.value(node) == null) expiry = null; + if (expiry != null && expiry > now && !live.permanent().contains(node)) rows.add(node + "%line%" + expiry); + } + if (!rows.isEmpty()) projection.set("TimedPermissions." + owner, rows); + } + try { snapshotBytes(projection); } catch (IllegalStateException unsupported) { throw new IllegalStateException(UNSUPPORTED_LEGACY, unsupported); } + reconcileNativeOwner(owners.iterator(), recovered, state, handler, result); + } catch (Throwable failure) { result.completeExceptionally(failure); } + }); } catch (RuntimeException failure) { result.completeExceptionally(failure); } + return result; + } + /** At most one global/entity callback is admitted at a time; no callback collection is retained. */ + private void reconcileNativeOwner(Iterator owners, Map> recovered, OrderedStorage state, + com.bencodez.advancedcore.api.permissions.PermissionHandler handler, CompletableFuture result) { + if (!owners.hasNext()) { result.complete(null); return; } + UUID owner = owners.next(); + try { plugin.getBukkitScheduler().runTask(plugin, () -> { + try { + requireNativeLifecycle(state, handler); + var player = org.bukkit.Bukkit.getPlayer(owner); + Runnable restore = () -> { + try { + requireNativeLifecycle(state, handler); + if (player != null && (!player.isValid() || org.bukkit.Bukkit.getPlayer(owner) != player)) + throw new IllegalStateException("Native permission player owner changed"); + var live = nativePermissionOwnerSnapshot(handler, owner); + var base = nativePermissionBaseline.getOrDefault(owner, NativePermissionNodes.EMPTY); + var source = nativePermissionSource.getOrDefault(owner, Map.of()); + var disk = recovered.getOrDefault(owner, Map.of()); + Set nodes = new HashSet<>(source.keySet()); nodes.addAll(disk.keySet()); + for (String node : nodes) { + if (!Objects.equals(base.value(node), live.value(node)) || live.permanent().contains(node)) continue; + // Already-restored rows include subsequently expired/revoked initial nodes. + if (Objects.equals(source.get(node), disk.get(node))) continue; + if (player == null && (handler.getPerms().containsKey(owner) || org.bukkit.Bukkit.getPlayer(owner) != null)) + throw new IllegalStateException("Native permission attached owner unavailable"); + Long expiry = disk.get(node); long remaining = expiry == null ? 0 : expiry - System.currentTimeMillis(); + if (remaining <= 0) handler.removePermission(owner, null, node); + else handler.addPermission(owner, node, com.bencodez.simpleapi.time.ParsedDuration.ofMillis(remaining)); + } + reconcileNativeOwner(owners, recovered, state, handler, result); + } catch (Throwable failure) { result.completeExceptionally(failure); } + }; + if (player == null) restore.run(); + else com.bencodez.mcperks.scheduling.OwnerTasks.run(plugin, player, restore, + () -> result.completeExceptionally(new IllegalStateException("Native permission player owner retired"))); + } catch (Throwable failure) { result.completeExceptionally(failure); } + }); } catch (RuntimeException failure) { result.completeExceptionally(failure); } + } + private static org.bukkit.configuration.file.YamlConfiguration readSnapshot(Path file) + throws IOException, org.bukkit.configuration.InvalidConfigurationException { + if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Invalid ServerData file"); + byte[] header = SNAPSHOT_HEADER.getBytes(StandardCharsets.UTF_8); + boolean ordered; + try (var input = Files.newInputStream(file)) { ordered = Arrays.equals(header, input.readNBytes(header.length)); } + if (ordered) { + // Count actual bytes again while reading, including a concurrent file growth. + try (var input = Files.newInputStream(file)) { + byte[] bytes = input.readNBytes(MAX_SNAPSHOT_BYTES + 1); + if (bytes.length > MAX_SNAPSHOT_BYTES) throw new IOException("Ordered ServerData exceeds four MB"); + return parse(new String(bytes, StandardCharsets.UTF_8)); + } + } + // A native legacy file has already been parsed by AdvancedCore. Admit that + // old format once, after predecessor drain, without inventing a larger legacy + // size ceiling. Reconciliation and advisory-history pruning must yield the + // bounded new format; indispensable oversized payloads leave this file intact. + var legacy = new org.bukkit.configuration.file.YamlConfiguration(); + try (var reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) { legacy.load(reader); } + return legacy; + } + private static Map tree(org.bukkit.configuration.ConfigurationSection section) { + Map values = new HashMap<>(); + for (String key : section.getKeys(false)) { Object value = section.get(key); + values.put(key, value instanceof org.bukkit.configuration.ConfigurationSection child ? tree(child) : value); } + return values; + } + public synchronized void addPerkHistory(String value) { + String path = "PerkHistory." + plugin.getTimeChecker().getTime().getMonth().toString(); + var history = new ArrayList<>(getData().getStringList(path)); history.add(value); + // Bound each retained month's activation history, including when the optional file log is enabled. + getData().set(path, history.subList(Math.max(0, history.size() - 256), history.size())); + saveData(); + } + public FileConfiguration getData() { return plugin.getServerDataFile().getData(); } + public synchronized long getPerkExperation(Perk perk) { return getData().getLong("Experation." + perk.getPerk()); } + public synchronized void setServerCooldown(String perk, long time) { activationEdited(perk + ".CoolDown"); getData().set(perk + ".CoolDown", time); } + public synchronized long getServerCooldown(String perk) { return getData().getLong(perk + ".CoolDown", 0); } + private CompletableFuture nativeReload = CompletableFuture.completedFuture(null); + public void reloadData() { + reloadDataAsync().exceptionally(error -> { + plugin.getLogger().warning("ServerData reload failed; current state retained: " + error.getMessage()); return null; + }); + } + public CompletableFuture reloadDataAsync() { return reloadFrom(plugin.getServerDataFile()); } + public synchronized CompletableFuture reloadCompletion() { return nativeReload.copy(); } + synchronized CompletableFuture reloadFrom(com.bencodez.advancedcore.data.ServerData file) { + if (!nativeReload.isDone()) return nativeReload.copy(); + // A later calendar/perk mutation remains authoritative. Read a private YAML on the + // ordered worker, then install it only if the observed live generation still matches. + if (pendingSnapshots.get() != 0) return CompletableFuture.completedFuture(null); + if (!isReady() || plugin.getStorage() == null) return CompletableFuture.failedFuture( + new IllegalStateException("ServerData reload storage unavailable")); + String baseline = getData().saveToString(); + long edit = runtimeEdits; + Path path = file.getdFile().toPath(); + var operation = new CompletableFuture(); nativeReload = operation; + plugin.getStorage().runtimeStorage(() -> { + try { var loaded = readSnapshot(path); snapshotBytes(loaded); return loaded; } + catch (IOException | org.bukkit.configuration.InvalidConfigurationException error) { throw new CompletionException(error); } + }).whenComplete((loaded, error) -> { + if (error != null) { operation.completeExceptionally(error); return; } + try { synchronized (ServerData.this) { + if (pendingSnapshots.get() == 0 && edit == runtimeEdits && baseline.equals(getData().saveToString())) + getData().loadFromString(loaded.saveToString()); + } operation.complete(null); } + catch (Throwable failure) { operation.completeExceptionally(failure); } + }); + return operation.copy(); + } - /** - * Gets the data. - * - * @return the data - */ - public FileConfiguration getData() { - return plugin.getServerDataFile().getData(); - } + public synchronized void saveData() { + if (activationWrites.get() != null) return; + if (!canonicalLoaded) { + if (initialization.isCompletedExceptionally()) plugin.getLogger().warning("ServerData persistence unavailable after startup reconciliation failure; original file retained"); + return; + } + if (plugin.getStorage() == null) { plugin.getServerDataFile().saveData(); return; } + confirmedFinalMutation(data -> {}).exceptionally(error -> { + plugin.getLogger().warning("ServerData persistence failed: " + error.getMessage()); return null; + }); + } - public long getPerkExperation(Perk perk) { - return getData().getLong("Experation." + perk.getPerk()); - } + /** Stages private mutations; only successful ordered publication changes the live YAML. */ + public synchronized CompletableFuture confirmedFinalMutation( + java.util.function.Consumer mutation) { + if (!canonicalLoaded || plugin.getStorage() == null) + return CompletableFuture.failedFuture(new IllegalStateException("ServerData persistence owner unavailable")); + if (activationWrites.get() != null) + return CompletableFuture.failedFuture(new IllegalStateException("Final save inside activation persistence")); + if (pendingUpdates.size() >= 256) + return CompletableFuture.failedFuture(new IllegalStateException("Snapshot staging capacity exceeded")); + try { + String serialized = getData().saveToString(); + var baseline = parse(serialized); + var candidate = new FinalMutationYaml(); + candidate.loadFromString(serialized); + candidate.recording = true; + try { + java.util.Objects.requireNonNull(mutation, "mutation").accept(candidate); + reconcileFinalPermissions(candidate); + } finally { candidate.recording = false; } + byte[] snapshot = snapshotBytes(candidate); + var values = new LinkedHashMap(); + // An explicit same-value write still owns its path. Ordinary empty saves + // own no paths, so confirmed predecessors remain available for rebasing. + candidate.writtenPaths.forEach(path -> values.put(path, snapshotValue(candidate.get(path)))); + var paths = new HashSet<>(baseline.getKeys(false)); paths.addAll(candidate.getKeys(false)); + for (String path : paths) collectFinalChanges(path, snapshotValue(baseline.get(path)), + snapshotValue(candidate.get(path)), candidate.writtenPaths, values); + var update = new SnapshotUpdate(values, getData(), true); + pendingUpdates.add(update); + return publishSnapshot(snapshot, List.of(), pendingCalendar, calendarEdits.clone(), null, update); + } catch (Throwable failure) { return CompletableFuture.failedFuture(failure); } + } - /** - * Reload data. - */ - public void reloadData() { - plugin.getServerDataFile().reloadData(); - } + /** Mutation intent is local to one private candidate, never another storage owner. */ + private static final class FinalMutationYaml extends org.bukkit.configuration.file.YamlConfiguration { + private final NavigableSet writtenPaths = new TreeSet<>(); + private long writtenPathBytes; + private boolean recording; + private int internalWrites; + private T write(org.bukkit.configuration.ConfigurationSection section, String path, + java.util.function.Supplier action) { + if (recording && internalWrites == 0) { + String fullPath = org.bukkit.configuration.MemorySection.createPath(section, path); + recordPath(fullPath); + } + internalWrites++; + try { return action.get(); } + finally { internalWrites--; } + } + private void recordPath(String path) { + writtenPathBytes = captureMutationPath(writtenPaths, writtenPathBytes, path, true); + } + private org.bukkit.configuration.ConfigurationSection trackedSection(org.bukkit.configuration.ConfigurationSection section) { + if (section instanceof FinalMutationSection) return section; + var tracked = new FinalMutationSection(section.getParent(), section.getName(), this); + section.getParent().set(section.getName(), tracked); + return tracked; + } + @Override public void set(String path, Object value) { + write(this, path, () -> { super.set(path, value); return null; }); + } + @Override public org.bukkit.configuration.ConfigurationSection createSection(String path) { + return write(this, path, () -> trackedSection(super.createSection(path))); + } + } + private static long captureMutationPath(NavigableSet paths, long bytes, String path, boolean record) { + if (ownsPath(paths, path)) return bytes; + String prefix = path.isEmpty() ? "" : path + "."; + long retainedBytes = bytes; + for (String field : paths.tailSet(prefix, true)) { + if (!field.startsWith(prefix)) break; + retainedBytes -= mutationPathBytes(field); + } + long nextBytes = retainedBytes + mutationPathBytes(path); + // Bound even repeated absent clears, whose final YAML contains no values. + if (nextBytes > MAX_SNAPSHOT_BYTES) throw new IllegalStateException("Mutation path capacity exceeded"); + if (record) { + var descendants = paths.tailSet(prefix, true).iterator(); + while (descendants.hasNext()) { + if (!descendants.next().startsWith(prefix)) break; + descendants.remove(); + } + paths.add(path); + } + return nextBytes; + } + private static long mutationPathBytes(String path) { + return (long) path.getBytes(StandardCharsets.UTF_8).length + 16; + } + private static boolean ownsPath(Set paths, String path) { + if (paths.contains("") || paths.contains(path)) return true; + for (int end = path.indexOf('.'); end >= 0; end = path.indexOf('.', end + 1)) + if (paths.contains(path.substring(0, end))) return true; + return false; + } - /** - * Save data. - */ - public void saveData() { - plugin.getServerDataFile().saveData(); - } + private static final class FinalMutationSection extends org.bukkit.configuration.MemorySection { + private final FinalMutationYaml owner; + private FinalMutationSection(org.bukkit.configuration.ConfigurationSection parent, String path, FinalMutationYaml owner) { + super(parent, path); this.owner = owner; + } + @Override public void set(String path, Object value) { + owner.write(this, path, () -> { super.set(path, value); return null; }); + } + @Override public org.bukkit.configuration.ConfigurationSection createSection(String path) { + return owner.write(this, path, () -> owner.trackedSection(super.createSection(path))); + } + } + private static void collectFinalChanges(String path, Object before, Object after, + Set explicitPaths, Map values) { + if (Objects.equals(before, after) || explicitPaths.stream() + .anyMatch(field -> path.equals(field) || path.startsWith(field + "."))) return; + // Descend only where the consumer made narrower writes. In particular, + // advisory-history pruning remains one bounded section replacement. + if (explicitPaths.stream().anyMatch(field -> field.startsWith(path + ".")) + && (before == null || before instanceof Map) && after instanceof Map next) { + Map previous = before instanceof Map map ? map : Map.of(); + var keys = new LinkedHashSet(previous.keySet()); keys.addAll(next.keySet()); + for (Object key : keys) collectFinalChanges(path + "." + key, previous.get(key), next.get(key), explicitPaths, values); + } else values.put(path, after); + } - public void setPerkExperation(Perk perk, long time) { - getData().set("Experation." + perk.getPerk(), time); - saveData(); - } + private void reconcileFinalPermissions(FileConfiguration candidate) { + // Native final save omits still-offline relative reservations from its timed-only snapshot. + // Preserve those native-owned rows (and remove revoked ones) after its timer is retired. + var handler = plugin.getPermissionHandler(); + if (handler != null && handler == observedNativeHandler && handler.getTimer().isShutdown()) { + try { + var snapshot = nativePermissionSnapshot(handler); long now = System.currentTimeMillis(); + candidate.set("TimedPermissions", null); + for (var owner : snapshot.entrySet()) { + Map timed = new HashMap<>(owner.getValue().timed()); + owner.getValue().pending().forEach((node, duration) -> { + if (duration > 0) timed.put(node, Math.addExact(now, duration)); + }); + List rows = new ArrayList<>(); + timed.forEach((node, expiry) -> { if (expiry > now) rows.add(node + "%line%" + expiry); }); + if (!rows.isEmpty()) candidate.set("TimedPermissions." + owner.getKey(), rows); + } + } catch (RuntimeException failure) { + throw new IllegalStateException("Native final permission snapshot unavailable", failure); + } + } + } + /** Gameplay stays on the caller's owner. No mutable YAML reaches the worker. */ + public synchronized CompletableFuture confirmedActivation(Runnable gameplay) { + if (!isReady()) return CompletableFuture.failedFuture(new IllegalStateException("ServerData initialization unavailable")); + if (activationWrites.get() != null) throw new IllegalStateException("Nested activation persistence"); + var writes = new ArrayList(); activationWrites.set(writes); + try { gameplay.run(); return persistSnapshot(List.copyOf(writes)); } + catch (Throwable error) { return CompletableFuture.failedFuture(error); } + finally { activationWrites.remove(); } + } - public void saveOfflinePerk(Perk perk) { - if (perk.isLastForever()) { - getData().set("OfflinePerk." + perk.getActivater().getUUID() + "." + perk.getPerk() + ".Experiaton", -1); - } else { - getData().set("OfflinePerk." + perk.getActivater().getUUID() + "." + perk.getPerk() + ".Experiaton", - perk.getExperation(perk.getActivater())); - } - } + // Admission metadata for this owner's existing ordered worker, shared by + // activations and final mutations. It is not another persistence queue. + private final List pendingUpdates = new ArrayList<>(); + private static final class SnapshotUpdate { + private final Map values; + private final Map before; + private final boolean candidateIncludesChanges; + private final NavigableSet edited = new TreeSet<>(); + private long editedPathBytes; + private volatile boolean published; + private SnapshotUpdate(Map values, FileConfiguration live, boolean candidateIncludesChanges) { + var copied = new LinkedHashMap(); + values.forEach((path, value) -> copied.put(path, snapshotValue(value))); + this.values = Collections.unmodifiableMap(copied); + this.before = new HashMap<>(); + this.candidateIncludesChanges = candidateIncludesChanges; + values.keySet().forEach(path -> before.put(path, snapshotValue(live.get(path)))); + } + private void apply(FileConfiguration target, Set capturedEdits) { + values.forEach((path, value) -> { + if (!ownsPath(capturedEdits, path) && Objects.equals(snapshotValue(target.get(path)), before.get(path))) { + target.set(path, null); + if (value instanceof Map map) setSection(target.createSection(path), map); + else if (value != null) target.set(path, value); + } + }); + } + private void commit(FileConfiguration target, List predecessors) { + values.forEach((path, value) -> { + if (ownsPath(edited, path)) return; + Set protectedPaths = descendantsOf(edited, path); + // A newer child edit protects only that child; the acknowledged + // parent can still publish all of its unrelated siblings. + if (matchesBeforeOrPredecessor(target, path, predecessors, protectedPaths)) + putRetainingPaths(target, path, value, protectedPaths, target); + }); + } + private void rebase(FileConfiguration target, Set capturedEdits, + List predecessors, SnapshotUpdate candidate) { + values.forEach((path, value) -> { + if (ownsPath(capturedEdits, path)) return; + Set protectedPaths = descendantsOf(capturedEdits, path); + if (candidate != null && candidate.candidateIncludesChanges) { + // A later parent replacement owns this whole field. Narrower later + // writes protect only their own paths, retaining unrelated siblings. + if (ownsPath(candidate.values.keySet(), path)) return; + protectedPaths.addAll(descendantsOf(candidate.values.keySet(), path)); + } + if (matchesBeforeOrPredecessor(target, path, predecessors, protectedPaths)) + putRetainingPaths(target, path, value, protectedPaths, target); + }); + } + private static Set descendantsOf(Set paths, String path) { + return paths.stream().filter(field -> field.startsWith(path + ".")) + .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)); + } + private static void putRetainingPaths(FileConfiguration target, String path, Object value, + Set protectedPaths, FileConfiguration source) { + var retained = new LinkedHashMap(); + protectedPaths.forEach(field -> retained.put(field, snapshotValue(source.get(field)))); + putValue(target, path, value); + retained.forEach((field, retainedValue) -> putValue(target, field, retainedValue)); + } + private boolean matchesBeforeOrPredecessor(FileConfiguration target, String path, List predecessors) { + return matchesBeforeOrPredecessor(target, path, predecessors, Set.of()); + } + private boolean matchesBeforeOrPredecessor(FileConfiguration target, String path, + List predecessors, Set protectedPaths) { + Object current = withoutProtectedPaths(snapshotValue(target.get(path)), path, protectedPaths); + if (Objects.equals(current, withoutProtectedPaths(before.get(path), path, protectedPaths))) return true; + // Parent and child mutations can overlap without sharing an exact map key. + // Project only successfully published predecessors over our original value. + var expected = new org.bukkit.configuration.file.YamlConfiguration(); + putValue(expected, path, before.get(path)); + for (SnapshotView view : predecessors) { + if (view.update() == this) break; + if (!view.update().published) continue; + view.update().values.forEach((field, value) -> { + if (!ownsPath(view.edits(), field) && (field.equals(path) + || field.startsWith(path + ".") || path.startsWith(field + "."))) + putRetainingPaths(expected, field, value, descendantsOf(view.edits(), field), target); + }); + if (Objects.equals(current, withoutProtectedPaths(snapshotValue(expected.get(path)), path, protectedPaths))) return true; + } + return false; + } + private static Object withoutProtectedPaths(Object value, String path, Set protectedPaths) { + if (protectedPaths.isEmpty()) return value; + if (ownsPath(protectedPaths, path)) return null; + if (!(value instanceof Map map)) return value; + var remaining = new LinkedHashMap(); + map.forEach((key, child) -> { + Object retained = withoutProtectedPaths(child, path + "." + key, protectedPaths); + if (retained != null) remaining.put(key, retained); + }); + // Empty parents created only for a protected child do not change its siblings. + return remaining.isEmpty() ? null : remaining; + } + private static void putValue(FileConfiguration target, String path, Object value) { + target.set(path, null); + if (value instanceof Map map) setSection(target.createSection(path), map); + else if (value != null) target.set(path, value); + } + private static void setSection(org.bukkit.configuration.ConfigurationSection target, Map values) { + values.forEach((key, value) -> { + String path = key.toString(); + if (value instanceof Map map) setSection(target.createSection(path), map); + else target.set(path, value); + }); + } + } + private record SnapshotView(SnapshotUpdate update, Set edits) {} + private void activationEdited(String path) { + // Check every owner before recording intent: rejected writes cannot leave + // partial edit fences on an earlier acknowledged publication. + for (SnapshotUpdate update : pendingUpdates) { + if (update.values.keySet().stream().anyMatch(field -> path.isEmpty() || field.equals(path) + || field.startsWith(path + ".") || path.startsWith(field + "."))) + captureMutationPath(update.edited, update.editedPathBytes, path, false); + } + for (SnapshotUpdate update : pendingUpdates) { + if (update.values.keySet().stream().anyMatch(field -> path.isEmpty() || field.equals(path) + || field.startsWith(path + ".") || path.startsWith(field + "."))) + update.editedPathBytes = captureMutationPath(update.edited, update.editedPathBytes, path, true); + } + runtimeEdits++; + } + private List snapshotViews() { + return pendingUpdates.stream().map(update -> new SnapshotView(update, Set.copyOf(update.edited))).toList(); + } + /** + * Publishes activation admission before running any effect, reward, or command hook. + * The gameplay callback is returned to the Bukkit/Folia owner after storage has + * acknowledged both the shared snapshot and any caller-owned state. + */ + public synchronized CompletableFuture confirmedActivationBeforeGameplay(Map fields, + java.util.function.Supplier> callerStorage, Runnable gameplay, + org.bukkit.entity.Entity owner) { + return prepareActivation(fields, callerStorage, owner) + .thenCompose(ignored -> ownerStep(owner, () -> confirmedActivation(gameplay))).copy(); + } + /** Same admission boundary, with an asynchronous owner work completion (bank attachment). */ + public CompletableFuture confirmedActivationBeforeWork(Map fields, + java.util.function.Supplier> callerStorage, + java.util.function.Supplier> gameplay, org.bukkit.entity.Entity owner) { + return prepareActivation(fields, callerStorage, owner).thenCompose(ignored -> ownerStep(owner, gameplay)).copy(); + } + private synchronized CompletableFuture prepareActivation(Map fields, + java.util.function.Supplier> callerStorage, org.bukkit.entity.Entity owner) { + if (!isReady() || activationCallbacksRetired) return CompletableFuture.failedFuture(new IllegalStateException("ServerData activation lifecycle unavailable")); + if (pendingUpdates.size() >= 256 || fields.size() > 2) + return CompletableFuture.failedFuture(new IllegalStateException("Activation staging capacity exceeded")); + final SnapshotUpdate update; + final CompletableFuture published; + try { + update = new SnapshotUpdate(fields, getData(), false); + // Validate size on a private copy. A rejected admission changes neither live store. + var copy = parse(getData().saveToString()); update.apply(copy, Set.of()); snapshotBytes(copy); + byte[] snapshot = snapshotBytes(getData()); + pendingUpdates.add(update); + published = publishSnapshot(snapshot, List.of(), pendingCalendar, + calendarEdits.clone(), null, update); + } catch (Throwable error) { return CompletableFuture.failedFuture(error); } + return published.thenCompose(ignored -> ownerStep(owner, callerStorage)); + } + /** Each phase runs once on its owner; retired or timed-out callbacks cannot execute later. */ + private boolean activationCallbacksRetired; + private final Set activationRetirements = new HashSet<>(); + /** Retire uninvoked owner callbacks without expiring already accepted storage work. */ + public void retireActivationCallbacks() { + List pending; + synchronized (this) { + activationCallbacksRetired = true; + pending = List.copyOf(activationRetirements); + activationRetirements.clear(); + } + pending.forEach(Runnable::run); + } + private CompletableFuture ownerStep(org.bukkit.entity.Entity owner, + java.util.function.Supplier> work) { + var completed = new CompletableFuture(); + var invoked = new java.util.concurrent.atomic.AtomicBoolean(); + Runnable retire = () -> { + if (invoked.compareAndSet(false, true)) completed.completeExceptionally( + new IllegalStateException("Activation owner dispatch retired")); + }; + synchronized (this) { + if (activationCallbacksRetired) { retire.run(); return completed; } + activationRetirements.add(retire); + } + completed.whenComplete((value, error) -> { synchronized (this) { activationRetirements.remove(retire); } }); + Runnable run = () -> { + if (!invoked.compareAndSet(false, true) || completed.isDone()) return; + try { work.get().whenComplete((value, error) -> { + if (error == null) completed.complete(null); else completed.completeExceptionally(error); + }); } + catch (Throwable error) { completed.completeExceptionally(error); } + }; + try { + if (owner == null) plugin.getBukkitScheduler().runTask(plugin, run); + else plugin.getBukkitScheduler().runTask(plugin, run, owner); + } catch (Throwable error) { completed.completeExceptionally(error); } + // Bound dispatch, but never declare accepted work finished while its storage callback + // can still commit. Its own completion owns the activation fence until it settles. + CompletableFuture.delayedExecutor(30, TimeUnit.SECONDS).execute(() -> { + if (invoked.compareAndSet(false, true)) completed.completeExceptionally( + new TimeoutException("Activation owner dispatch timed out")); + }); + return completed; + } + /** The callback must contain only an already captured log message and its file writer. */ + public synchronized void activationLog(Runnable write) { + var batch = activationWrites.get(); + if (batch != null) batch.add(write); + else plugin.getStorage().runtimeStorage(() -> { write.run(); return null; }); + } + private static final int MAX_SNAPSHOT_BYTES = 4_000_000; + private static final int MAX_HISTORY_BYTES = 1_000_000; + private static byte[] snapshotBytes(FileConfiguration data) { + trimHistory(data, MAX_HISTORY_BYTES); + byte[] snapshot = encodedSnapshot(data); + if (snapshot.length > MAX_SNAPSHOT_BYTES && data.isConfigurationSection("PerkHistory")) { + // History is advisory. Reclaim it before rejecting an authoritative timer/calendar save. + var history = new org.bukkit.configuration.file.YamlConfiguration(); + history.set("PerkHistory", tree(data.getConfigurationSection("PerkHistory"))); + int historyBytes = history.saveToString().getBytes(StandardCharsets.UTF_8).length; + trimHistory(data, Math.max(0, historyBytes - (snapshot.length - MAX_SNAPSHOT_BYTES))); + snapshot = encodedSnapshot(data); + } + if (snapshot.length > MAX_SNAPSHOT_BYTES) throw new IllegalStateException("ServerData snapshot exceeds four MB"); + return snapshot; + } + private static byte[] encodedSnapshot(FileConfiguration data) { + return (SNAPSHOT_HEADER + data.saveToString()).getBytes(StandardCharsets.UTF_8); + } + /** Keep complete recent entries in the existing monthly lists, using actual encoded YAML bytes. */ + private static void trimHistory(FileConfiguration data, int budget) { + var section = data.getConfigurationSection("PerkHistory"); + if (section == null) return; + var retained = new org.bukkit.configuration.file.YamlConfiguration(); + var months = new ArrayList<>(section.getKeys(false)); + if (months.isEmpty()) { data.set("PerkHistory", null); return; } + for (String month : months) { + var entries = section.getStringList(month); + retained.set("PerkHistory." + month, new ArrayList<>(entries.subList(Math.max(0, entries.size() - 256), entries.size()))); + } + if (retained.saveToString().getBytes(StandardCharsets.UTF_8).length <= budget) { + data.createSection("PerkHistory", tree(retained.getConfigurationSection("PerkHistory"))); return; + } + retained = new org.bukkit.configuration.file.YamlConfiguration(); + Collections.reverse(months); + // Prefer the last retained month, then fit suffixes of preceding months in the aggregate budget. + for (String month : months) { + var entries = section.getStringList(month); + String path = "PerkHistory." + month; + int low = 0, high = Math.min(256, entries.size()); + while (low < high) { + int count = (low + high + 1) / 2; + retained.set(path, entries.subList(entries.size() - count, entries.size())); + if (retained.saveToString().getBytes(StandardCharsets.UTF_8).length <= budget) low = count; + else high = count - 1; + } + retained.set(path, low == 0 ? null : new ArrayList<>(entries.subList(entries.size() - low, entries.size()))); + } + var history = retained.getConfigurationSection("PerkHistory"); + if (history == null || history.getKeys(false).isEmpty()) data.set("PerkHistory", null); + else data.createSection("PerkHistory", tree(history)); + } + // Only the serial storage worker reads or replaces this fence. It retains the + // exact private snapshot and staged intent until an uncertain move is settled. + private volatile SnapshotFence uncertainSnapshot; + private CompletableFuture calendarFenceSettlement; + /** Never admit a recovered transition while a previous replacement is uncertain. */ + synchronized void requireCalendarAdmission() { + if (uncertainSnapshot == null) return; + if ((calendarFenceSettlement == null || calendarFenceSettlement.isDone()) && plugin.getStorage() != null) { + Path file = plugin.getServerDataFile().getdFile().toPath(); + calendarFenceSettlement = plugin.getStorage().runtimeStorage(() -> { + try { settleUncertainSnapshot(file); return null; } + catch (IOException failure) { throw new CompletionException(failure); } + }); + } + throw new IllegalStateException("ServerData publication must settle before calendar admission"); + } + private record SnapshotFence(byte[] bytes, List views, SnapshotUpdate update, CalendarUpdate calendar) { } + private static java.security.MessageDigest snapshotDigest() { + try { return java.security.MessageDigest.getInstance("SHA-256"); } + catch (java.security.NoSuchAlgorithmException impossible) { throw new IllegalStateException(impossible); } + } + private static byte[] fileFingerprint(Path file) throws IOException { + if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) return null; + if (Files.isSymbolicLink(file) || !Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Invalid ServerData destination"); + var digest = snapshotDigest(); + try (var input = Files.newInputStream(file)) { + byte[] buffer = new byte[8192]; + for (int count; (count = input.read(buffer)) != -1;) digest.update(buffer, 0, count); + } + return digest.digest(); + } + private void settleUncertainSnapshot(Path file) throws IOException { + SnapshotFence fence = uncertainSnapshot; + if (fence == null) return; + // File presence is not confirmation. Match the complete replacement and + // explicitly force both the file and directory before allowing a new save. + if (!Arrays.equals(snapshotDigest().digest(fence.bytes()), fileFingerprint(file))) throw new IOException("ServerData publication remains uncertain"); + DurableFeatureFiles.settle(file); + synchronized (this) { + if (fence.calendar() != null) { + fence.calendar().apply(getData(), calendarEdits); fence.calendar().published = true; + if (pendingCalendar == fence.calendar()) pendingCalendar = null; + } + if (fence.update() != null) { + fence.update().commit(getData(), fence.views()); fence.update().published = true; + pendingUpdates.remove(fence.update()); + } + uncertainSnapshot = null; + } + } + private CompletableFuture persistSnapshot(List writes) { + // Serialization is confined to the mutation owner; publication alone runs on storage. + try { return publishSnapshot(snapshotBytes(getData()), writes, pendingCalendar, calendarEdits.clone(), null); } + catch (RuntimeException error) { return CompletableFuture.failedFuture(error); } + } + private CompletableFuture publishSnapshot(byte[] snapshot, List writes, CalendarUpdate overlay, long[] edits, CalendarUpdate commit) { + return publishSnapshot(snapshot, writes, overlay, edits, commit, null); + } + private CompletableFuture publishSnapshot(byte[] snapshot, List writes, CalendarUpdate overlay, + long[] edits, CalendarUpdate commit, SnapshotUpdate update) { + List updates = snapshotViews(); + Path file = plugin.getServerDataFile().getdFile().toPath(); + pendingSnapshots.incrementAndGet(); + return plugin.getStorage().runtimeStorage(() -> { + Path staged = null; + try { + settleUncertainSnapshot(file); + byte[] confirmed = snapshot; + // This turn follows the calendar publication. Rebase only its confirmed fields in + // a private worker YAML, never the live cache; failed transitions contribute nothing. + // Per-field edit fences preserve even a later explicit write of the same old value. + if (overlay != null) { + var copy = parse(new String(snapshot, StandardCharsets.UTF_8)); + overlay.applyPublishedChain(copy, edits, commit == null ? Set.of() : commit.changes.keySet()); + confirmed = snapshotBytes(copy); + } + if (!updates.isEmpty()) { + var copy = parse(new String(confirmed, StandardCharsets.UTF_8)); + for (SnapshotView view : updates) { + // The worker is serial: a following snapshot observes the prior publication result. + if (view.update() == update || view.update().published) + view.update().rebase(copy, view.edits(), updates, update); + } + confirmed = snapshotBytes(copy); + } + Files.createDirectories(file.toAbsolutePath().getParent()); + if (Files.isSymbolicLink(file)) throw new IOException("ServerData is a symlink"); + staged = Files.createTempFile(file.toAbsolutePath().getParent(), ".server-data-", ".tmp"); + Files.write(staged, confirmed); + byte[] before = fileFingerprint(file); + try { publisher.publish(staged, file); } + catch (IOException | RuntimeException | Error publicationFailure) { + boolean unchanged = false; + try { + // A still-staged candidate and unchanged, different destination + // prove rejection before replacement under the file owner. + unchanged = Files.exists(staged, LinkOption.NOFOLLOW_LINKS) + && !Arrays.equals(before, snapshotDigest().digest(confirmed)) + && Arrays.equals(before, fileFingerprint(file)); + } catch (IOException inspectionFailure) { publicationFailure.addSuppressed(inspectionFailure); } + if (unchanged) throw publicationFailure; + uncertainSnapshot = new SnapshotFence(confirmed, updates, update, commit); + // A calendar failure callback can reopen a completed transition. + // Settle the already-replaced snapshot before reporting failure; + // if settlement cannot be established the fence remains active. + if (commit == null) throw publicationFailure; + try { settleUncertainSnapshot(file); } + catch (IOException | RuntimeException | Error settlementFailure) { + if (settlementFailure != publicationFailure) publicationFailure.addSuppressed(settlementFailure); + throw publicationFailure; + } + } + if (commit != null) synchronized (ServerData.this) { + commit.apply(getData(), calendarEdits); commit.published = true; + if (pendingCalendar == commit) pendingCalendar = null; + } + if (update != null) synchronized (ServerData.this) { + update.commit(getData(), updates); + update.published = true; + } + for (Runnable write : writes) write.run(); + return null; + } catch (IOException | org.bukkit.configuration.InvalidConfigurationException error) { throw new CompletionException(error); } + finally { if (staged != null) try { Files.deleteIfExists(staged); } catch (IOException ignored) {} } + }).whenComplete((ignored, error) -> { + pendingSnapshots.decrementAndGet(); + if (update != null) synchronized (ServerData.this) { + if (uncertainSnapshot == null || uncertainSnapshot.update() != update) pendingUpdates.remove(update); + } + if (commit != null) synchronized (ServerData.this) { + if ((uncertainSnapshot == null || uncertainSnapshot.calendar() != commit) && pendingCalendar == commit) + pendingCalendar = uncertainSnapshot == null ? null : uncertainSnapshot.calendar(); + } + }).copy(); + } + public synchronized void setPerkExperation(Perk perk, long time) { + activationEdited("Experation." + perk.getPerk()); + getData().set("Experation." + perk.getPerk(), time); saveData(); + } + public synchronized void clearOfflinePerks() { getData().set("OfflinePerk", null); } + /** Removes one shutdown record only while it still contains the value read by the restorer. */ + public synchronized boolean removeOfflinePerkIfMatches(String uuid, String perk, long expectedExpiration) { + String path = "OfflinePerk." + uuid + "." + perk + ".Experiaton"; + if (!getData().contains(path) || getData().getLong(path) != expectedExpiration) return false; + getData().set(path, null); + if (getData().isConfigurationSection("OfflinePerk." + uuid + "." + perk) + && getData().getConfigurationSection("OfflinePerk." + uuid + "." + perk).getKeys(false).isEmpty()) { + getData().set("OfflinePerk." + uuid + "." + perk, null); + } + if (getData().isConfigurationSection("OfflinePerk." + uuid) + && getData().getConfigurationSection("OfflinePerk." + uuid).getKeys(false).isEmpty()) { + getData().set("OfflinePerk." + uuid, null); + } + if (getData().isConfigurationSection("OfflinePerk") + && getData().getConfigurationSection("OfflinePerk").getKeys(false).isEmpty()) { + getData().set("OfflinePerk", null); + } + return true; + } + public synchronized void saveOfflinePerk(Perk perk) { + getData().set("OfflinePerk." + perk.getActivater().getUUID() + "." + perk.getPerk() + ".Experiaton", + perk.isLastForever() ? -1 : perk.getExperation(perk.getActivater())); + } } diff --git a/MCPerks/src/main/java/com/bencodez/mcperks/listeners/EffectListeners.java b/MCPerks/src/main/java/com/bencodez/mcperks/listeners/EffectListeners.java index c0fcdf5..4df0125 100644 --- a/MCPerks/src/main/java/com/bencodez/mcperks/listeners/EffectListeners.java +++ b/MCPerks/src/main/java/com/bencodez/mcperks/listeners/EffectListeners.java @@ -344,6 +344,8 @@ public void onBlockPlaceEvent(BlockPlaceEvent event) { public void onDisconnect(PlayerQuitEvent event) { Player player = event.getPlayer(); if (player != null) { + if (plugin.getConfigFile().getDeactivateOnLogout()) + plugin.getPerkHandler().retirePendingPlayerSession(player); if (plugin.getPerkHandler().effectActive(Effect.Fly, player.getUniqueId().toString(), player.getWorld().getName())) { diff --git a/MCPerks/src/main/java/com/bencodez/mcperks/perk/Effect.java b/MCPerks/src/main/java/com/bencodez/mcperks/perk/Effect.java index 5eb9598..2e689e7 100644 --- a/MCPerks/src/main/java/com/bencodez/mcperks/perk/Effect.java +++ b/MCPerks/src/main/java/com/bencodez/mcperks/perk/Effect.java @@ -12,6 +12,7 @@ import org.bukkit.potion.PotionEffectType; import com.bencodez.advancedcore.api.misc.MiscUtils; +import com.bencodez.advancedcore.api.messages.PlaceholderUtils; import com.bencodez.advancedcore.api.rewards.RewardBuilder; import com.bencodez.mcperks.MCPerksMain; import com.bencodez.mcperks.configs.ConfigPerks; @@ -126,6 +127,14 @@ public static Effect fromString(String str) { @SuppressWarnings({ "deprecation", "removal" }) public void removeEffect(MCPerksMain plugin, Perk perk, ArrayList players1) { + removeEffect(plugin, perk, players1, false); + } + public void removeEffectForShutdown(MCPerksMain plugin, Perk perk, ArrayList players) { + if (!Bukkit.isPrimaryThread()) throw new IllegalStateException("Effect shutdown requires the Bukkit owner"); + removeEffect(plugin, perk, players, true); + } + @SuppressWarnings({ "deprecation", "removal" }) + private void removeEffect(MCPerksMain plugin, Perk perk, ArrayList players1, boolean shutdown) { ArrayList players = new ArrayList(); for (String uuid : players1) { Player player = Bukkit.getPlayer(UUID.fromString(uuid)); @@ -138,7 +147,16 @@ public void removeEffect(MCPerksMain plugin, Perk perk, ArrayList player switch (this) { case Fly: - new FlyEffect().disableFly(perk.getFlyWorlds(), players); + if (shutdown) { + for (Player player : players) { + if (perk.getFlyWorlds().contains(player.getWorld().getName()) + && !player.hasPermission("MCPerks.ServerFly.Bypass")) { + player.setAllowFlight(false); + player.setFlying(false); + player.setFallDistance(-100000); + } + } + } else new FlyEffect().disableFly(perk.getFlyWorlds(), players); break; case IncreaseMaxHealth: for (Player p : players) { @@ -206,6 +224,7 @@ public void removeEffect(MCPerksMain plugin, Perk perk, ArrayList player for (String potion : perk.getPotions()) { try { if (p.hasPotionEffect(PotionEffectType.getByName(potion))) { + if (shutdown) { p.removePotionEffect(PotionEffectType.getByName(potion)); continue; } Bukkit.getScheduler().runTask(MCPerksMain.plugin, new Runnable() { @Override @@ -225,7 +244,8 @@ public void run() { case TempPermission: if (!getPermission().isEmpty()) { for (Player p : players) { - perk.getPlugin().getMcperksUserManager().getMCPerksUser(p).removePermission(getPermission()); + if (shutdown) plugin.getPermissionHandler().removePermission(p.getUniqueId(), p.getName(), getPermission()); + else perk.getPlugin().getMcperksUserManager().getMCPerksUser(p).removePermission(getPermission()); } } break; @@ -233,7 +253,21 @@ public void run() { for (Player p : players) { HashMap placeholders = new HashMap(); placeholders.put("player", p.getName()); - MiscUtils.getInstance().executeConsoleCommands(p, perk.getDisableCommands(), placeholders, false); + if (shutdown) { + // The plugin is already disabled during onDisable. Dispatch directly on + // the Bukkit owner instead of enqueueing work against the retiring plugin. + ArrayList configured = perk.getDisableCommands(); + if (configured == null || configured.isEmpty()) break; + ArrayList commands = PlaceholderUtils.replaceJavascriptOnly(p, configured); + commands = PlaceholderUtils.replacePlaceHolder(commands, placeholders); + commands = PlaceholderUtils.replacePlaceHolders(commands, p); + for (String command : commands) { + String rendered = command != null && command.startsWith("/") ? command.substring(1) : command; + Bukkit.getServer().dispatchCommand(Bukkit.getConsoleSender(), rendered); + } + } else { + MiscUtils.getInstance().executeConsoleCommands(p, perk.getDisableCommands(), placeholders, false); + } } break; default: diff --git a/MCPerks/src/main/java/com/bencodez/mcperks/perk/EffectHandler.java b/MCPerks/src/main/java/com/bencodez/mcperks/perk/EffectHandler.java index 25997b1..56bbbb5 100644 --- a/MCPerks/src/main/java/com/bencodez/mcperks/perk/EffectHandler.java +++ b/MCPerks/src/main/java/com/bencodez/mcperks/perk/EffectHandler.java @@ -46,7 +46,15 @@ public EffectHandler(MCPerksMain plugin) { .synchronizedList(new ArrayList()); public void activate(Perk perk, MCPerksUser user) { + activate(perk, user, true); + } + + /** Applies persistent effects while optionally suppressing activation-only hooks. */ + public void activate(Perk perk, MCPerksUser user, boolean runActivationHooks) { for (Effect effect : perk.getEffects()) { + if (!runActivationHooks && (effect == Effect.Rewards || effect == Effect.Commands + || effect == Effect.GlobalCommands || effect == Effect.CureHunger + || effect == Effect.CureSpells || effect == Effect.HealAll)) continue; effect.runEffect(plugin, perk, user, perk.getEffectedPlayers()); } if (!perk.isTimed() && !perk.isLastForever()) { diff --git a/MCPerks/src/main/java/com/bencodez/mcperks/perk/Perk.java b/MCPerks/src/main/java/com/bencodez/mcperks/perk/Perk.java index ec83550..9554939 100644 --- a/MCPerks/src/main/java/com/bencodez/mcperks/perk/Perk.java +++ b/MCPerks/src/main/java/com/bencodez/mcperks/perk/Perk.java @@ -10,6 +10,10 @@ import java.util.Date; import java.util.HashMap; import java.util.HashSet; +import java.sql.Timestamp; +import java.util.Map; +import java.util.LinkedHashMap; +import java.util.Objects; import java.util.Set; import java.util.Timer; import java.util.TimerTask; @@ -41,6 +45,9 @@ * The Class Perk. */ public class Perk { + /** Admission fence for direct starts; prevents duplicate delayed hooks. */ + private final Map activationAdmissions = new LinkedHashMap<>(); + private long activationSequence; /** The perk. */ private String perk; @@ -74,9 +81,10 @@ public class Perk { private ArrayList effects = new ArrayList(); - private boolean active; + private volatile boolean active; + private boolean shutdownPrepared; - private long experation; + private volatile long experation; private ArrayList specialDrops; @@ -142,6 +150,7 @@ public class Perk { @Getter private boolean clearModifiers; + public Perk(Perk perk) { this.perk = perk.perk; @@ -269,8 +278,12 @@ public Perk(String perk) { * @param length length */ public void activatePerk(MCPerksUser user, int length) { + activatePerk(user, length, null); + } + + void activatePerk(MCPerksUser user, int length, Timestamp timestamp) { activater = user; - plugin.getPerkHandler().activePerk(this, user, length, null); + plugin.getPerkHandler().activePerk(this, user, length, timestamp); } public void addEffectedPlayer(String uuid) { @@ -379,7 +392,55 @@ public Perk clone() { return new Perk(this); } + /** Disable-time game cleanup is synchronous on Bukkit's owner and performs no user-data lookup. */ + public synchronized boolean detachForShutdown() { + if (shutdownPrepared || !active) return false; + if (!Bukkit.isPrimaryThread()) throw new IllegalStateException("Perk shutdown requires the Bukkit owner"); + shutdownPrepared = true; + active = false; + for (Effect effect : getEffects()) { + try { + effect.removeEffectForShutdown(plugin, this, getEffectedPlayers()); + } catch (Throwable failure) { + plugin.getLogger().log(java.util.logging.Level.SEVERE, + "Failed to remove shutdown effect " + effect + " for perk " + getPerk(), failure); + } + } + if (getBossBar() != null) { + try { getBossBar().hide(); } + catch (Throwable failure) { + plugin.getLogger().log(java.util.logging.Level.SEVERE, + "Failed to hide shutdown boss bar for perk " + getPerk(), failure); + } finally { setBossBar(null); } + } + plugin.getPerkHandler().getActivePerks().remove(this); + return true; + } + + /** First acknowledge the legacy restart record; only then clear the native live expiration. */ + public java.util.concurrent.CompletableFuture persistForShutdown() { + var state = plugin.getStorage(); + var data = plugin.getMcperksServerData(); + return data.confirmedActivation(() -> data.saveOfflinePerk(this)) + .thenCompose(ignored -> state.runtimeStorage(() -> data.confirmedActivation( + () -> setExperation(getActivater(), 0))).thenCompose(stage -> stage)) + .thenCompose(ignored -> state.runtimeStorage(() -> { + if (getPerkType() == PerkSystemType.ALL) return java.util.concurrent.CompletableFuture.completedFuture(null); + var flushed = new java.util.concurrent.CompletableFuture(); + var manager = plugin.getUserManager().getDataManager(); + var cache = manager.getCache(UUID.fromString(getActivater().getUUID())); + if (cache == null) { + if (manager.hasSharedSqlBackend()) throw new IllegalStateException("Shared user cache unavailable during shutdown"); + // Legacy uncached setters completed their direct storage write above. + return java.util.concurrent.CompletableFuture.completedFuture(null); + } + cache.flushChangesAndRun(() -> flushed.complete(null)); + return flushed; + }).thenCompose(stage -> stage)); + } + public synchronized void deactivatePerk(MCPerksUser user) { + if (shutdownPrepared) return; setExperation(user, 0); setActive(false); // Main.plugin.getEffectHandler().deactivate(this); @@ -429,16 +490,194 @@ public void run() { } - public void execute(MCPerksUser user, int length) { - user.setPerkCoolDown(this, MiscUtils.getInstance().addSeconds(new Date(), getCoolDown()).getTime()); - if (getServerWideCoolDown() > 0) { - setServerCoolDownTime(MiscUtils.getInstance().addSeconds(new Date(), getServerWideCoolDown()).getTime()); - } - activatePerk(user, length); - } - + public void execute(MCPerksUser user, int length) { + executeConfirmed(user, length).exceptionally(error -> { plugin.getLogger().warning("Perk execution could not be confirmed: " + getPerk()); return null; }); + } + public java.util.concurrent.CompletableFuture executeConfirmed(MCPerksUser user, int length) { + return executeConfirmed(user, length, false); + } + private java.util.concurrent.CompletableFuture executeConfirmed(MCPerksUser user, int length, boolean charge) { + long admission = acquireActivationAdmission(user); + if (admission == 0) return java.util.concurrent.CompletableFuture.failedFuture( + new IllegalStateException("Perk activation already pending or admissions busy")); + return executeAdmitted(user, length, admission, charge); + } + private java.util.concurrent.CompletableFuture executeAdmitted(MCPerksUser user, int length, long admission, boolean charge) { + final String admissionKey = activationKey(user); + final PerkHandler handler = plugin.getPerkHandler(); + var attempt = new ActivationAttempt(user, charge, handler); + boolean started = false; + try { + if (handler != null) { attempt.nativeAdmission = handler.beginNativeActivation(user, charge); started = true; } + return executeAdmittedWork(user, length, admissionKey, admission, attempt) + .exceptionallyCompose(error -> { + java.util.concurrent.CompletableFuture rollback; + try { rollback = attempt.rollback(); } + catch (Throwable failure) { rollback = java.util.concurrent.CompletableFuture.failedFuture(failure); } + return rollback.handle((ignored, rollbackFailure) -> { + if (rollbackFailure != null) { + attempt.uncertain = true; + plugin.getLogger().severe("Perk activation rollback is unconfirmed; admission retained for " + getPerk()); + if (error != rollbackFailure) error.addSuppressed(rollbackFailure); + } + throw new java.util.concurrent.CompletionException(error); + }); + }).whenComplete((ignored, error) -> { + if (!attempt.uncertain) { + releaseActivationAdmission(admissionKey, admission); + if (handler != null) handler.finishNativeActivation(attempt.nativeAdmission); + } + }).copy(); + } catch (RuntimeException failure) { + releaseActivationAdmission(admissionKey, admission); + if (started) handler.finishNativeActivation(attempt.nativeAdmission); + return java.util.concurrent.CompletableFuture.failedFuture(failure); + } + } + private java.util.concurrent.CompletableFuture executeAdmittedWork(MCPerksUser user, int length, String admissionKey, long admission, ActivationAttempt attempt) { + if (!plugin.getMcperksServerData().isReady() || plugin.getPerkHandler() == null + || !plugin.getPerkHandler().isRestorationReady()) return java.util.concurrent.CompletableFuture.failedFuture( + new IllegalStateException("Perk runtime restoration unavailable")); + return executeMetadataAndGameplay(user, length, admissionKey, admission, user == null ? null : user.getPlayer(), attempt); + } + private Runnable activationVerifier(String admissionKey, long admission, ActivationAttempt attempt) { + final PerkHandler ownerHandler = plugin.getPerkHandler(); + final int lifecycle = plugin.getFeatureLifecycle(); + return () -> { + synchronized (this) { + if (!Objects.equals(activationAdmissions.get(admissionKey), admission) || !plugin.isEnabled() + || plugin.getFeatureLifecycle() != lifecycle || plugin.getPerkHandler() != ownerHandler + || (attempt.nativeAdmission != null && attempt.nativeAdmission.sessionRetired)) + throw new IllegalStateException("Perk activation lifecycle retired"); + } + }; + } + private java.util.concurrent.CompletableFuture executeMetadataAndGameplay(MCPerksUser user, int length, + String admissionKey, long admission, org.bukkit.entity.Player owner, ActivationAttempt attempt) { + long admissionExpiration = activationExpiration(length); + final long personalCooldown = MiscUtils.getInstance().addSeconds(new Date(), getCoolDown()).getTime(); + Map sharedFields = new LinkedHashMap<>(); + if (getServerWideCoolDown() > 0) + sharedFields.put(getPerk() + ".CoolDown", MiscUtils.getInstance().addSeconds(new Date(), getServerWideCoolDown()).getTime()); + if (isTimed() && getPerkType() == PerkSystemType.ALL) + sharedFields.put("Experation." + getPerk(), admissionExpiration); + attempt.sharedFields = sharedFields; + synchronized (plugin.getMcperksServerData()) { + for (String field : sharedFields.keySet()) + attempt.sharedBefore.put(field, plugin.getMcperksServerData().getData().get(field)); + } + if (attempt.handler != null) attempt.handler.bindNativeSession(attempt.nativeAdmission, owner); + Runnable verifyAdmission = activationVerifier(admissionKey, admission, attempt); + java.util.function.Supplier> callerStorage = () -> plugin.getStorage().runtimeStorage(() -> { + // Native debits and compensation share this existing ordered worker, across perk definitions. + verifyAdmission.run(); + attempt.reserveCredits(); + attempt.oldCooldown = user.getPerkCoolDown(this); + attempt.oldExpiration = user.getPerkExperation(this); + attempt.cooldown = personalCooldown; attempt.expiration = admissionExpiration; + attempt.userChanged = true; + user.setPerkCoolDown(this, personalCooldown); + if (isTimed()) { + if (getPerkType() != PerkSystemType.ALL) user.setPerkExperation(this, admissionExpiration); + this.experation = admissionExpiration; + } + return com.bencodez.mcperks.userapi.UserStorageConfirmation.confirm(plugin, user); + }).thenCompose(stored -> stored); + var confirmed = plugin.getMcperksServerData().confirmedActivationBeforeGameplay(sharedFields, callerStorage, () -> { + verifyAdmission.run(); + // This callback is on the captured player owner, not the storage worker. + if (owner != null && plugin.getConfigFile().getDeactivateOnLogout() && !owner.isOnline()) + throw new IllegalStateException("Perk activation player session retired"); + attempt.gameplayStarted = attempt.nativeAdmission == null || !attempt.handler.usesManagedGameplayBoundary(); + var previous = attempt.handler == null ? null : attempt.handler.enterNativeGameplay(attempt.nativeAdmission); + try { activatePerk(user, length, isTimed() && !isLastForever() ? new Timestamp(admissionExpiration) : null); } + finally { if (attempt.handler != null) attempt.handler.leaveNativeGameplay(previous); } + // Instantaneous perks have no active clone; their capacity ends with gameplay too. + if (attempt.handler != null) attempt.handler.releaseNativeCapacity(attempt.nativeAdmission); + }, owner); + return confirmed; + } + /** Compensation remains within this admission; uncertain storage never reopens it. */ + private final class ActivationAttempt { + private final MCPerksUser user; + private final boolean charge; + private final PerkHandler handler; + private PerkHandler.NativeActivation nativeAdmission; + private boolean personalDebited, globalDebited, userChanged, gameplayStarted, uncertain; + private long oldCooldown, oldExpiration, cooldown, expiration; + private Map sharedFields = Map.of(); + private final Map sharedBefore = new LinkedHashMap<>(); + private ActivationAttempt(MCPerksUser user, boolean charge, PerkHandler handler) { + this.user = user; this.charge = charge; this.handler = handler; + } + private void reserveCredits() { + if (!charge || !plugin.getConfigFile().getLimitActivations()) return; + var shared = user.reserveSharedActivations(getPerk()); + if (shared != null) { + if (!shared.applied()) throw new IllegalStateException("No perk activations available"); + personalDebited = shared.previous().get("PerkActivations_" + getPerk()) > 0; + globalDebited = shared.previous().get("Activations") > 0; + return; + } + boolean personal = user.getActivations(getPerk()) > 0; + boolean global = user.getActivations() > 0; + if (!personal && !global) throw new IllegalStateException("No perk activations available"); + // Preserve the established credit-pool policy; only the timing/compensation changes here. + if (personal) { user.addActivation(getPerk(), -1); personalDebited = true; } + if (global) { user.addActivation(-1); globalDebited = true; } + } + private java.util.concurrent.CompletableFuture rollback() { + if (gameplayStarted || (nativeAdmission != null && nativeAdmission.gameplayStarted)) + return java.util.concurrent.CompletableFuture.completedFuture(null); + var runtime = plugin.getMcperksServerData(); + boolean sharedChanged; + synchronized (runtime) { + sharedChanged = sharedFields.entrySet().stream() + .anyMatch(field -> runtime.getData().getLong(field.getKey()) == field.getValue()); + } + var shared = sharedChanged ? runtime.confirmedFinalMutation(data -> { + sharedFields.forEach((path, value) -> { + if (data.getLong(path) == value) data.set(path, sharedBefore.get(path)); + }); + }) : java.util.concurrent.CompletableFuture.completedFuture(null); + return shared.thenCompose(ignored -> { + if (!userChanged && !personalDebited && !globalDebited) + return java.util.concurrent.CompletableFuture.completedFuture(null); + return plugin.getStorage().runtimeStorage(() -> { + if (userChanged) { + if (user.getPerkCoolDown(Perk.this) == cooldown) user.setPerkCoolDown(Perk.this, oldCooldown); + if (isTimed() && getPerkType() != PerkSystemType.ALL + && user.getPerkExperation(Perk.this) == expiration) + user.setPerkExperation(Perk.this, oldExpiration); + } + if (personalDebited) user.addActivation(getPerk(), 1); + if (globalDebited) user.addActivation(1); + return com.bencodez.mcperks.userapi.UserStorageConfirmation.confirm(plugin, user); + }).thenCompose(confirmed -> confirmed); + }); + } + } + private String activationKey(MCPerksUser user) { + return getPerkType() == PerkSystemType.PLAYER && getServerWideCoolDown() <= 0 + ? "PLAYER:" + user.getUUID() : getPerkType().name() + ":" + getPerk(); + } + private synchronized long acquireActivationAdmission(MCPerksUser user) { + String key = activationKey(user); + if (activationAdmissions.size() >= 256 || activationAdmissions.containsKey(key)) return 0; + long admission = ++activationSequence; + activationAdmissions.put(key, admission); + return admission; + } + private synchronized void releaseActivationAdmission(String key, long admission) { + if (Objects.equals(activationAdmissions.get(key), admission)) activationAdmissions.remove(key); + } + private long activationExpiration(int length) { + if (!isTimed()) return 0; + if (isLastForever()) return -1; + return Math.addExact(System.currentTimeMillis(), Math.multiplyExact(1000L, length)); + } public void setServerCoolDownTime(long time) { - MCPerksMain.plugin.getMcperksServerData().getData().set(getPerk() + ".CoolDown", time); + MCPerksMain.plugin.getMcperksServerData().setServerCooldown(getPerk(), time); } public void forcePerk(String name) { @@ -507,9 +746,15 @@ public ArrayList getEffects() { return effects; } + /** Last expiration published with the active grant; reading does not touch user storage. */ + public long getKnownExpiration() { return experation; } + + /** Remember an expiration already durably published by the activation worker. */ + void setKnownExpiration(long value) { experation = value; } + public long getExperation(MCPerksUser user) { if (getPerkType().equals(PerkSystemType.ALL)) { - experation = MCPerksMain.plugin.getMcperksServerData().getPerkExperation(this); + experation = plugin.getMcperksServerData().getPerkExperation(this); } else { experation = user.getPerkExperation(this); } @@ -748,6 +993,9 @@ public boolean isNotInDisabledWorld(String world) { } public void runPerk(MCPerksUser user) { + if (!plugin.getMcperksServerData().isReady() || !plugin.getPerkHandler().isRestorationReady()) { + user.sendMessage("&cPerk runtime state is unavailable; no activation consumed."); return; + } if (user.isInWorld(disabledWorlds)) { user.sendMessage(Lang.getInstance().getDisabledWorlds()); @@ -802,13 +1050,11 @@ public void runPerk(MCPerksUser user) { if (user.getActivations(getPerk()) > 0) { globalActivation = false; passed = true; - user.addActivation(getPerk(), -1); } if (user.getActivations() > 0) { globalActivation = true; passed = true; - user.addActivation(-1); } if (!passed) { @@ -817,12 +1063,20 @@ public void runPerk(MCPerksUser user) { } } - runPerkPerk(user, globalActivation); + if (plugin.getConfigFile().getPerkQue() && (!plugin.getPerkHandler().getActivePerks().isEmpty() + || plugin.getPerkHandler().hasPendingNativeActivations())) { + runPerkPerk(user, globalActivation); + return; + } + executeConfirmed(user, getTime(), true).exceptionally(error -> { + plugin.getLogger().warning("Perk execution could not be confirmed: " + getPerk()); return null; + }); } public void runPerkPerk(MCPerksUser user, boolean globalActivation) { - if (MCPerksMain.plugin.getPerkHandler().getActivePerks().size() != 0 && plugin.getConfigFile().getPerkQue()) { + if (plugin.getConfigFile().getPerkQue() && (!plugin.getPerkHandler().getActivePerks().isEmpty() + || plugin.getPerkHandler().hasPendingNativeActivations())) { user.sendMessage(ConfigPerks.getInstance().getPerkAddedToQue(perk)); MCPerksMain.plugin.getPerkHandler().addQue(user, this); return; @@ -831,19 +1085,21 @@ public void runPerkPerk(MCPerksUser user, boolean globalActivation) { execute(user, getTime()); } - public void scheduleDeactivation(final MCPerksUser user) { - if (getExperation(user) > 0 && !isLastForever()) { + public void scheduleDeactivation(final MCPerksUser user) { + scheduleDeactivation(user, getExperation(user)); + } + void scheduleDeactivation(final MCPerksUser user, long deadline) { + if (deadline > 0 && !isLastForever()) { TimerTask deactivate = new TimerTask() { @Override public void run() { - if (isActive()) { - deactivatePerk(user); + synchronized (Perk.this) { + if (isActive()) deactivatePerk(user); } } }; - long timemill = getExperation(user); - new Timer().schedule(deactivate, new Date(timemill)); + new Timer().schedule(deactivate, new Date(deadline)); } } @@ -907,7 +1163,7 @@ public void setEnabled(boolean isEnabled) { public void setExperation(MCPerksUser user, long experation) { if (getPerkType().equals(PerkSystemType.ALL)) { - MCPerksMain.plugin.getMcperksServerData().setPerkExperation(this, experation); + plugin.getMcperksServerData().setPerkExperation(this, experation); } else { user.setPerkExperation(this, experation); } diff --git a/MCPerks/src/main/java/com/bencodez/mcperks/perk/PerkHandler.java b/MCPerks/src/main/java/com/bencodez/mcperks/perk/PerkHandler.java index a12c7b1..b60d363 100644 --- a/MCPerks/src/main/java/com/bencodez/mcperks/perk/PerkHandler.java +++ b/MCPerks/src/main/java/com/bencodez/mcperks/perk/PerkHandler.java @@ -64,6 +64,8 @@ public class PerkHandler { private MCPerksMain plugin; private Timer timer = new Timer(); + private volatile java.util.concurrent.CompletableFuture restoration = new java.util.concurrent.CompletableFuture<>(); + public boolean isRestorationReady() { var current = restoration; return current.isDone() && !current.isCompletedExceptionally(); } public Map invPerks = new HashMap(); @@ -84,18 +86,34 @@ public PerkHandler(MCPerksMain plugin) { e.printStackTrace(); } } - loadEnabledPerks(true); + loadEnabledPerks(false); if (plugin.getConfigFile().getLogActivation()) { activationLog = new Logger(plugin, new File(plugin.getDataFolder(), "ActivationLog.txt")); } } + PerkHandler(MCPerksMain plugin, List initial) { + this.plugin = plugin; activePerks.addAll(initial); restoration.complete(null); + } + public void activePerk(Perk perk, MCPerksUser user, int length, Timestamp timestamp) { + activePerk(perk, user, length, timestamp, true); + } + + /** Restores the timer/effects for a persisted grant without replaying activation-only effects/hooks. */ + public void restoreActivePerk(Perk perk, MCPerksUser user, Timestamp expiration) { + int remaining = expiration == null ? 0 : (int) Math.min(Integer.MAX_VALUE, Math.max(0L, + (expiration.getTime() - System.currentTimeMillis()) / 1000L)); + activePerk(perk, user, remaining, expiration, false); + } + + private void activePerk(Perk perk, MCPerksUser user, int length, Timestamp timestamp, boolean runActivationHooks) { final Perk clone = perk.clone(); + if (!runActivationHooks) clone.setTime(timestamp == null ? -1 : Math.max(1, length)); clone.setActivater(user); clone.getEffectedPlayers().clear(); ArrayList times = new ArrayList(); - if (perk.getTime() > 0) { + if (runActivationHooks && perk.getTime() > 0) { for (String str : ConfigPerks.getInstance().getCountDownTimes(perk.getPerk())) { if (MessageAPI.isInt(str)) { int num = Integer.parseInt(str); @@ -199,24 +217,46 @@ public void run() { } } - plugin.getEffectHandler().activate(clone, user); + NativeActivation currentActivation = publishingActivation.get(); + if (currentActivation != null) currentActivation.gameplayStarted = true; + plugin.getEffectHandler().activate(clone, user, runActivationHooks); + Timestamp exp = null; if (clone.isTimed()) { + clone.setActive(true); if (!clone.isLastForever()) { - Timestamp exp = new Timestamp(Calendar.getInstance().getTime().getTime() + 1000 * length); + exp = new Timestamp(Calendar.getInstance().getTime().getTime() + 1000 * length); if (timestamp != null) { exp = timestamp; } - clone.setExperation(user, exp.getTime()); + if (publishingActivation.get() != null) clone.setKnownExpiration(exp.getTime()); + else clone.setExperation(user, exp.getTime()); - clone.scheduleDeactivation(user); } else { - clone.setExperation(user, -1); + if (publishingActivation.get() != null) clone.setKnownExpiration(-1); + else clone.setExperation(user, -1); } - activePerks.add(clone); + try { + synchronized (this) { + activePerks.add(clone); + // Publish capacity before reentrant hooks, without releasing the storage drain. + releaseNativeCapacity(publishingActivation.get()); + } + } catch (RuntimeException failure) { + synchronized (this) { activePerks.remove(clone); } + clone.setActive(false); + // Activation hooks may already have run. Retire applied persistent effects + // without replaying deactivation rewards or pretending hooks were undone. + for (Effect effect : clone.getEffects()) { + try { effect.removeEffect(plugin, clone, clone.getEffectedPlayers()); } + catch (RuntimeException cleanupFailure) { failure.addSuppressed(cleanupFailure); } + } + throw failure; + } } + try { if (clone.getReActivateEffectsTimer() > 0) { final Perk clonedFinal = clone; timer.scheduleAtFixedRate(new TimerTask() { @@ -354,16 +394,31 @@ public void run() { }, 1000, (long) timing); } - boolean perkRewards = plugin.getRewardHandler().hasRewards(ConfigPerks.getInstance().getData(perk.getPerk()), - ConfigPerks.getInstance().getActivationEffectPath()); - for (String uuid : clone.getEffectedPlayers()) { - if (perkRewards) { - new RewardBuilder(ConfigPerks.getInstance().getData(perk.getPerk()), - ConfigPerks.getInstance().getActivationEffectPath()).setGiveOffline(false) - .send(plugin.getUserManager().getUser(UUID.fromString(uuid))); - } else { - new RewardBuilder(plugin.getConfigFile().getData(), plugin.getConfigFile().getActivationEffectPath()) - .setGiveOffline(false).send(plugin.getUserManager().getUser(UUID.fromString(uuid))); + if (clone.isTimed() && !clone.isLastForever()) { + synchronized (this) { clone.scheduleDeactivation(user, exp.getTime()); } + } + } catch (RuntimeException failure) { + synchronized (this) { activePerks.remove(clone); } + clone.setActive(false); + for (Effect effect : clone.getEffects()) { + try { effect.removeEffect(plugin, clone, clone.getEffectedPlayers()); } + catch (RuntimeException cleanupFailure) { failure.addSuppressed(cleanupFailure); } + } + throw failure; + } + + if (runActivationHooks) { + boolean perkRewards = plugin.getRewardHandler().hasRewards(ConfigPerks.getInstance().getData(perk.getPerk()), + ConfigPerks.getInstance().getActivationEffectPath()); + for (String uuid : clone.getEffectedPlayers()) { + if (perkRewards) { + new RewardBuilder(ConfigPerks.getInstance().getData(perk.getPerk()), + ConfigPerks.getInstance().getActivationEffectPath()).setGiveOffline(false) + .send(plugin.getUserManager().getUser(UUID.fromString(uuid))); + } else { + new RewardBuilder(plugin.getConfigFile().getData(), plugin.getConfigFile().getActivationEffectPath()) + .setGiveOffline(false).send(plugin.getUserManager().getUser(UUID.fromString(uuid))); + } } } @@ -428,23 +483,24 @@ public void run() { } } - clone.announcePerk(user.getPlayerName(), length); + if (runActivationHooks) clone.announcePerk(user.getPlayerName(), length); - plugin.getMcperksServerData() + if (runActivationHooks) plugin.getMcperksServerData() .addPerkHistory(user.getPlayerName() + ":" + ArrayUtils.makeStringList(clone.getEffectedPlayers()) + ":" + perk.getName() + ":" + perk.getPerkType().toString() + ":" + System.currentTimeMillis()); printActivePerks(); - if (plugin.getConfigFile().getLogActivation() && activationLog != null) + if (runActivationHooks && plugin.getConfigFile().getLogActivation() && activationLog != null) { String str = new SimpleDateFormat("EEE, d MMM yyyy HH:mm").format(Calendar.getInstance().getTime()); - activationLog - .logToFile(str + " Activated perk " + perk.getName() + " by " + clone.getActivater().getPlayerName() - + ". Affected users: " + ArrayUtils.makeStringList(clone.getEffectedPlayers()) - + ". Lasted for " + length + " Perk type: " + clone.getPerkType().toString()); + String message = str + " Activated perk " + perk.getName() + " by " + clone.getActivater().getPlayerName() + + ". Affected users: " + ArrayUtils.makeStringList(clone.getEffectedPlayers()) + + ". Lasted for " + length + " Perk type: " + clone.getPerkType().toString(); + Logger writer = activationLog; + plugin.getMcperksServerData().activationLog(() -> writer.logToFile(message)); } } @@ -462,8 +518,111 @@ private void addToList(String key, Perk p) { loadedPerks.put(key, p); } + private final java.util.concurrent.atomic.AtomicInteger pendingNativeActivations = new java.util.concurrent.atomic.AtomicInteger(); + private boolean nativeReloadPending; + private boolean nativeActivationsRetired; + static final class NativeActivation { + private final String owner; + private boolean capacityPending = true; + private Player session; + private final java.util.Set earlyQuits = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); + private boolean earlyQuitOverflow; + volatile boolean sessionRetired; + volatile boolean gameplayStarted; + private NativeActivation(String owner) { this.owner = owner; } + } + private final java.util.Set nativeSessions = new java.util.HashSet<>(); + synchronized void bindNativeSession(NativeActivation activation, Player session) { + if (activation == null) return; + activation.session = session; + if (session != null && (activation.earlyQuitOverflow || activation.earlyQuits.contains(session))) + activation.sessionRetired = true; + activation.earlyQuits.clear(); + } + /** Called by the quitting player owner; only the exact captured session is retired. */ + public synchronized void retirePendingPlayerSession(Player session) { + var id = session.getUniqueId(); + for (var activation : nativeSessions) { + if (activation.session == session) activation.sessionRetired = true; + else if (activation.session == null && id != null && id.toString().equals(activation.owner)) { + if (activation.earlyQuits.size() < 8) activation.earlyQuits.add(session); + else activation.earlyQuitOverflow = true; // Fail closed if identity history is uncertain. + } + } + } + /** Unknown overrides may run side effects before the standard effect boundary. */ + boolean usesManagedGameplayBoundary() { + try { + return getClass().getMethod("activePerk", Perk.class, MCPerksUser.class, int.class, Timestamp.class) + .getDeclaringClass() == PerkHandler.class; + } catch (ReflectiveOperationException | SecurityException unavailable) { return false; } + } + private final ThreadLocal publishingActivation = new ThreadLocal<>(); + NativeActivation enterNativeGameplay(NativeActivation activation) { + NativeActivation previous = publishingActivation.get(); + if (activation == null) publishingActivation.remove(); else publishingActivation.set(activation); + return previous; + } + void leaveNativeGameplay(NativeActivation previous) { + if (previous == null) publishingActivation.remove(); else publishingActivation.set(previous); + } + synchronized void releaseNativeCapacity(NativeActivation activation) { + if (activation == null || !activation.capacityPending) return; + activation.capacityPending = false; + pendingPlayerActivations.computeIfPresent(activation.owner, (key, count) -> count == 1 ? null : count - 1); + } + private final Map pendingPlayerActivations = new HashMap<>(); + private java.util.concurrent.CompletableFuture nativeActivationDrain = java.util.concurrent.CompletableFuture.completedFuture(null); + /** Close new admissions; the private drain includes accepted storage and compensation. */ + public synchronized java.util.concurrent.CompletionStage retireNativeActivations() { + nativeActivationsRetired = true; + return nativeActivationDrain.minimalCompletionStage(); + } + boolean hasPendingNativeActivations() { return pendingNativeActivations.get() != 0; } + /** A reload may retire definitions only when accepted work and startup restoration are settled. */ + public synchronized void pauseNativeActivationsForReload() { + if (!isRestorationReady() || hasPendingNativeActivations()) + throw new IllegalStateException("Wait for perk activation or startup restoration to finish before reloading"); + nativeReloadPending = true; + } + public synchronized void resumeNativeActivationsAfterReload() { nativeReloadPending = false; } + synchronized NativeActivation beginNativeActivation(MCPerksUser user, boolean enforcePlayerLimit) { + if (nativeReloadPending || nativeActivationsRetired || !isRestorationReady()) + throw new IllegalStateException("Perk lifecycle is unavailable"); + if (pendingNativeActivations.get() >= 256) throw new IllegalStateException("Perk admissions are full"); + String owner = user == null ? null : user.getUUID(); + int pending = pendingPlayerActivations.getOrDefault(owner, 0); + if (enforcePlayerLimit && user != null && plugin.getConfigFile().getLimitPermission()) { + int limit = user.getPerkLimit(); + if (limit != -1 && user.getActivePerks() + pending >= limit) + throw new IllegalStateException("Player perk capacity is reserved"); + } + pendingPlayerActivations.put(owner, pending + 1); + if (pendingNativeActivations.getAndIncrement() == 0) nativeActivationDrain = new java.util.concurrent.CompletableFuture<>(); + var activation = new NativeActivation(owner); + nativeSessions.add(activation); + return activation; + } + void finishNativeActivation(NativeActivation activation) { + java.util.concurrent.CompletableFuture drained; + synchronized (this) { + nativeSessions.remove(activation); + releaseNativeCapacity(activation); + if (pendingNativeActivations.decrementAndGet() != 0) return; + drained = nativeActivationDrain; + } + // Dependent shutdown callbacks must not run while holding the admission monitor. + drained.complete(null); + if (!plugin.isEnabled() + || plugin.getPerkHandler() != this || !plugin.getConfigFile().getPerkQue()) return; + try { plugin.getBukkitScheduler().runTask(plugin, () -> { + if (plugin.isEnabled() && plugin.getPerkHandler() == this) checkQue(); + }); } + catch (RuntimeException failure) { plugin.getLogger().warning("Legacy perk queue owner dispatch failed"); } + } + public void checkQue() { - if (!que.isEmpty() && activePerks.size() == 0) { + if (!hasPendingNativeActivations() && !que.isEmpty() && activePerks.size() == 0) { for (Entry entry : que.entrySet()) { entry.getValue().runPerk(entry.getKey()); que.remove(entry.getKey()); @@ -587,31 +746,98 @@ public Perk getActivePerk(MCPerksUser user, Perk p) { return p; } - public void loadActivePerks() { - Bukkit.getScheduler().runTaskAsynchronously(plugin, new Runnable() { - - @Override - public void run() { - ArrayList users = new ArrayList(); - for (String uuid : plugin.getUserManager().getAllUUIDs()) { - MCPerksUser user = plugin.getMcperksUserManager().getMCPerksUser(UUID.fromString(uuid)); - user.userDataFetechMode(UserDataFetchMode.NO_CACHE); - users.add(user); - } - for (Perk perk : getLoadedPerks().values()) { - for (MCPerksUser user : users) { - user.userDataFetechMode(UserDataFetchMode.NO_CACHE); - if (perk.getExperation(user) < 0) { - perk.activatePerk(user, perk.getTime()); - } else { - perk.scheduleDeactivation(user); - } - - } - } - } - }); - } + public void loadActivePerks() { loadActivePerksConfirmed(() -> {}); } + + /** Completes only after legacy active state and the caller's shutdown records are restored. */ + public synchronized java.util.concurrent.CompletableFuture loadActivePerksConfirmed(Runnable shutdownRecords) { + var completed = new java.util.concurrent.CompletableFuture(); + restoration = completed; + try { + plugin.getBukkitScheduler().runTaskAsynchronously(plugin, () -> { + try { + requireCurrentRestoration(completed); + var users = new ArrayList(); + for (String uuid : plugin.getUserManager().getAllUUIDs()) { + requireCurrentRestoration(completed); + MCPerksUser user = plugin.getMcperksUserManager().getMCPerksUser(UUID.fromString(uuid)); + user.userDataFetechMode(UserDataFetchMode.NO_CACHE); users.add(user); + } + var nativeRecords = new ArrayList(); + var shutdownRows = new java.util.HashSet(); + var shutdownNames = new java.util.HashSet(); + var restorationOwners = new java.util.HashSet(); + var runtime = plugin.getMcperksServerData(); + synchronized (runtime) { + var shutdown = runtime.getData().getConfigurationSection("OfflinePerk"); + if (shutdown != null) for (String owner : shutdown.getKeys(false)) { + var records = shutdown.getConfigurationSection(owner); + if (records != null) for (String name : records.getKeys(false)) { + shutdownRows.add(owner + ":" + name); shutdownNames.add(name); + var definition = getPerk(name); + long expiration = records.getLong(name + ".Experiaton"); + if (definition != null && definition.isEnabled() + && (expiration <= 0 || expiration > System.currentTimeMillis())) + restorationOwners.add(UUID.fromString(owner)); + } + } + } + var restoredGlobals = new java.util.HashSet(); + for (Perk perk : List.copyOf(getLoadedPerks().values())) { + if (!perk.isEnabled()) continue; + for (MCPerksUser user : users) { + requireCurrentRestoration(completed); + user.userDataFetechMode(UserDataFetchMode.NO_CACHE); + boolean global = perk.getPerkType() == PerkSystemType.ALL; + if (global ? shutdownNames.contains(perk.getPerk()) + : shutdownRows.contains(user.getUUID() + ":" + perk.getPerk())) continue; + long expiration = perk.getExperation(user); + if (expiration < 0 || expiration > System.currentTimeMillis()) { + if (!global || restoredGlobals.add(perk.getPerk())) { + nativeRecords.add(new NativeRestoration(perk, user, expiration)); + if (perk.getPerkType() == PerkSystemType.PLAYER) + restorationOwners.add(UUID.fromString(user.getUUID())); + } + } + } + } + // New wrappers used during restoration need the canonical shared snapshot. + // Populate it here on the storage-safe worker, not from the owner callback. + var manager = plugin.getUserManager().getDataManager(); + if (manager.usesSharedSqlStorage(plugin.getStorageType())) for (UUID owner : restorationOwners) { + requireCurrentRestoration(completed); + if (manager.getPublishedCache(owner) == null) manager.cacheUser(owner); + if (manager.getPublishedCache(owner) == null) + throw new IllegalStateException("Restoration user cache was not published"); + } + var invoked = new java.util.concurrent.atomic.AtomicBoolean(); + plugin.getBukkitScheduler().runTask(plugin, () -> { + if (!invoked.compareAndSet(false, true)) return; + try { + requireCurrentRestoration(completed); + for (var record : nativeRecords) { + requireCurrentRestoration(completed); + if (record.expiration() < 0 || record.expiration() > System.currentTimeMillis()) + restoreActivePerk(record.perk(), record.user(), record.expiration() < 0 + ? null : new Timestamp(record.expiration())); + } + shutdownRecords.run(); + completed.complete(null); + } catch (Throwable failure) { completed.completeExceptionally(failure); } + }); + java.util.concurrent.CompletableFuture.delayedExecutor(30, java.util.concurrent.TimeUnit.SECONDS).execute(() -> { + if (invoked.compareAndSet(false, true)) completed.completeExceptionally( + new java.util.concurrent.TimeoutException("Perk restoration owner dispatch timed out")); + }); + } catch (Throwable error) { completed.completeExceptionally(error); } + }); + } catch (RuntimeException error) { completed.completeExceptionally(error); } + return completed.copy(); + } + private record NativeRestoration(Perk perk, MCPerksUser user, long expiration) {} + private void requireCurrentRestoration(java.util.concurrent.CompletableFuture completed) { + if (restoration != completed || plugin.getPerkHandler() != this || !plugin.isEnabled()) + throw new IllegalStateException("Legacy restoration lifecycle retired"); + } /** * Load enabled perks. @@ -704,6 +930,8 @@ public void reload() { } + public synchronized void retireTimers() { timer.cancel(); } + public synchronized void remove(Perk pe, MCPerksUser user) { getActivePerks().remove(pe); checkQue(); diff --git a/MCPerks/src/main/java/com/bencodez/mcperks/userapi/MCPerksUser.java b/MCPerks/src/main/java/com/bencodez/mcperks/userapi/MCPerksUser.java index 7e91009..0be36f2 100644 --- a/MCPerks/src/main/java/com/bencodez/mcperks/userapi/MCPerksUser.java +++ b/MCPerks/src/main/java/com/bencodez/mcperks/userapi/MCPerksUser.java @@ -1,160 +1,443 @@ -package com.bencodez.mcperks.userapi; - -import java.util.UUID; - -import org.bukkit.entity.Player; - -import com.bencodez.advancedcore.api.user.AdvancedCoreUser; -import com.bencodez.mcperks.MCPerksMain; -import com.bencodez.mcperks.perk.Perk; - -// TODO: Auto-generated Javadoc -/** - * The Class User. - */ -public class MCPerksUser extends com.bencodez.advancedcore.api.user.AdvancedCoreUser { - - @SuppressWarnings("unused") - private MCPerksMain plugin; - - @Deprecated - public MCPerksUser(MCPerksMain plugin, Player player) { - super(plugin, player); - this.plugin = plugin; - } - - @Deprecated - public MCPerksUser(MCPerksMain plugin, String playerName) { - super(plugin, playerName); - this.plugin = plugin; - } - - @Deprecated - public MCPerksUser(MCPerksMain plugin, UUID uuid) { - super(plugin, uuid); - this.plugin = plugin; - } - - @Deprecated - public MCPerksUser(MCPerksMain plugin, UUID uuid, boolean loadName) { - super(plugin, uuid, loadName); - this.plugin = plugin; - } - - @Deprecated - public MCPerksUser(MCPerksMain plugin, UUID uuid, String playerName) { - super(plugin, uuid, playerName); - this.plugin = plugin; - } - - public MCPerksUser(MCPerksMain plugin, AdvancedCoreUser user) { - super(plugin, user); - this.plugin = plugin; - } - - public long getPerkCoolDown(Perk perk) { - try { - return Long.valueOf(getUserData().getString(perk.getPerk() + "_CoolDown")); - } catch (NumberFormatException ex) { - return 0; - } - } - - public long getPerkExperation(Perk perk) { - try { - return Long.parseLong(getData().getString("Experation_" + perk.getPerk())); - } catch (NumberFormatException e) { - return 0; - } - } - - public int getPerkTimesUsed(Perk perk) { - return getUserData().getInt(perk.getPerk() + "_TimesUsed"); - } - - public boolean isUseBossBar() { - String str = getData().getString("UseBossBar"); - if (str == null || str.isEmpty()) { - return true; - } - return Boolean.valueOf(str); - } - - public void setPerkCoolDown(Perk perk, long value) { - getUserData().setString(perk.getPerk() + "_CoolDown", "" + value); - } - - public void setPerkExperation(Perk perk, long time) { - getData().setString("Experation_" + perk.getPerk(), "" + time); - } - - public void setPerkTimesUsed(Perk perk, int value) { - getUserData().setInt(perk.getPerk() + "_TimesUsed", value); - } - - public int getPerkLimit() { - for (int i = MCPerksMain.plugin.getPerkHandler().getLoadedPerks().size(); i > 0; i--) { - if (hasPermission("MCPerks.Limit." + i)) { - return i; - } - } - return -1; - } - - public int getActivePerks() { - int perks = 0; - for (Perk perk : MCPerksMain.plugin.getPerkHandler().getActivePerks()) { - if (perk.getActivater().getPlayerName().equals(getPlayerName())) { - perks++; - } else if (perk.getEffectedPlayers().contains(getUUID())) { - perks++; - } - } - return perks; - } - - public void setUseBossBar(boolean b) { - getData().setString("UseBossBar", "" + b); - } - - public int getActivations() { - return getData().getInt("Activations"); - } - - public void setActivations(int value) { - getData().setInt("Activations", value); - } - - public void addActivation(int add) { - setActivations(getActivations() + add); - } - - public void addActivation() { - addActivation(1); - } - - public int getActivations(String perk) { - return getData().getInt("PerkActivations_" + perk); - } - - public void setActivations(String perk, int value) { - getData().setInt("PerkActivations_" + perk, value); - } - - public void addActivation(String perk, int add) { - setActivations(perk, getActivations(perk) + add); - } - - public void addActivation(String perk) { - addActivation(perk, 1); - } - - public boolean isIgnorePerkEffects() { - return getData().getBoolean("IgnoreEffects"); - } - - public void setIgnoreEffects(boolean value) { - getData().setBoolean("IgnoreEffects", value); - } - -} +package com.bencodez.mcperks.userapi; + +import java.util.UUID; + +import org.bukkit.entity.Player; + +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.mcperks.MCPerksMain; +import com.bencodez.mcperks.perk.Perk; + +// TODO: Auto-generated Javadoc +/** + * The Class User. + */ +public class MCPerksUser extends com.bencodez.advancedcore.api.user.AdvancedCoreUser { + + @SuppressWarnings("unused") + private MCPerksMain plugin; + + @Deprecated + public MCPerksUser(MCPerksMain plugin, Player player) { + super(plugin, player); + this.plugin = plugin; + } + + @Deprecated + public MCPerksUser(MCPerksMain plugin, String playerName) { + super(plugin, playerName); + this.plugin = plugin; + } + + @Deprecated + public MCPerksUser(MCPerksMain plugin, UUID uuid) { + super(plugin, uuid); + this.plugin = plugin; + } + + @Deprecated + public MCPerksUser(MCPerksMain plugin, UUID uuid, boolean loadName) { + super(plugin, uuid, loadName); + this.plugin = plugin; + } + + @Deprecated + public MCPerksUser(MCPerksMain plugin, UUID uuid, String playerName) { + super(plugin, uuid, playerName); + this.plugin = plugin; + } + + public MCPerksUser(MCPerksMain plugin, AdvancedCoreUser user) { + super(plugin, user); + this.plugin = plugin; + } + + public long getPerkCoolDown(Perk perk) { + try { + return Long.valueOf(getUserData().getString(perk.getPerk() + "_CoolDown")); + } catch (NumberFormatException ex) { + return 0; + } + } + + public long getPerkExperation(Perk perk) { + try { + return Long.parseLong(getData().getString("Experation_" + perk.getPerk())); + } catch (NumberFormatException e) { + return 0; + } + } + + public int getPerkTimesUsed(Perk perk) { + return getUserData().getInt(perk.getPerk() + "_TimesUsed"); + } + + public boolean isUseBossBar() { + String str = getData().getString("UseBossBar"); + if (str == null || str.isEmpty()) { + return true; + } + return Boolean.valueOf(str); + } + + public void setPerkCoolDown(Perk perk, long value) { + getUserData().setString(perk.getPerk() + "_CoolDown", "" + value); + } + + public void setPerkExperation(Perk perk, long time) { + getData().setString("Experation_" + perk.getPerk(), "" + time); + } + + public void setPerkTimesUsed(Perk perk, int value) { + getUserData().setInt(perk.getPerk() + "_TimesUsed", value); + } + + public int getPerkLimit() { + for (int i = MCPerksMain.plugin.getPerkHandler().getLoadedPerks().size(); i > 0; i--) { + if (hasPermission("MCPerks.Limit." + i)) { + return i; + } + } + return -1; + } + + public int getActivePerks() { + int perks = 0; + for (Perk perk : MCPerksMain.plugin.getPerkHandler().getActivePerks()) { + if (perk.getActivater().getPlayerName().equals(getPlayerName())) { + perks++; + } else if (perk.getEffectedPlayers().contains(getUUID())) { + perks++; + } + } + return perks; + } + + public void setUseBossBar(boolean b) { + getData().setString("UseBossBar", "" + b); + } + + public int getActivations() { + return getData().getInt("Activations"); + } + + public void setActivations(int value) { + if (!mutateSharedCredit("Activations", value, com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.SET)) + getData().setInt("Activations", value); + } + + public void addActivation(int add) { + if (!mutateSharedCredit("Activations", add, com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD)) + setActivations(getActivations() + add); + } + + public void addActivation() { + addActivation(1); + } + + public int getActivations(String perk) { + return getData().getInt("PerkActivations_" + perk); + } + + public void setActivations(String perk, int value) { + if (!mutateSharedCredit("PerkActivations_" + perk, value, com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.SET)) + getData().setInt("PerkActivations_" + perk, value); + } + + public void addActivation(String perk, int add) { + if (!mutateSharedCredit("PerkActivations_" + perk, add, com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD)) + setActivations(perk, getActivations(perk) + add); + } + + public void addActivation(String perk) { + addActivation(perk, 1); + } + + private static final class AwardReceipt extends java.util.concurrent.CompletableFuture { + @Override public boolean cancel(boolean interrupt) { return false; } + @Override public boolean complete(Void value) { return false; } + @Override public boolean completeExceptionally(Throwable failure) { return false; } + @Override public void obtrudeValue(Void value) { throw new UnsupportedOperationException("read-only receipt"); } + @Override public void obtrudeException(Throwable failure) { throw new UnsupportedOperationException("read-only receipt"); } + @Override public java.util.concurrent.CompletableFuture completeAsync(java.util.function.Supplier supplier) { throw new UnsupportedOperationException("read-only receipt"); } + @Override public java.util.concurrent.CompletableFuture completeAsync(java.util.function.Supplier supplier, java.util.concurrent.Executor executor) { throw new UnsupportedOperationException("read-only receipt"); } + @Override public java.util.concurrent.CompletableFuture orTimeout(long timeout, java.util.concurrent.TimeUnit unit) { throw new UnsupportedOperationException("read-only receipt"); } + @Override public java.util.concurrent.CompletableFuture completeOnTimeout(Void value, long timeout, java.util.concurrent.TimeUnit unit) { throw new UnsupportedOperationException("read-only receipt"); } + private void succeed() { super.complete(null); } + private void fail(Throwable failure) { super.completeExceptionally(failure); } + } + /** Apply an award on a storage worker. Retain the non-cancellable receipt for its actual outcome. + * Custom integer callbacks require an explicit compatible receipt contract. */ + public java.util.concurrent.CompletionStage addActivationAsync(int amount) { + return mutateActivationAsyncInternal(null, amount, com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD); + } + + /** Apply a perk-specific award and expose its completion to asynchronous callers. */ + public java.util.concurrent.CompletionStage addActivationAsync(String perk, int amount) { + return mutateActivationAsyncInternal(java.util.Objects.requireNonNull(perk, "perk"), amount, com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD); + } + + public java.util.concurrent.CompletionStage setActivationsAsync(int value) { + return mutateActivationAsyncInternal(null, value, com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.SET); + } + public java.util.concurrent.CompletionStage setActivationsAsync(String perk, int value) { + return mutateActivationAsyncInternal(java.util.Objects.requireNonNull(perk, "perk"), value, com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.SET); + } + public java.util.concurrent.CompletionStage removeActivationClampedAsync(int amount) { + try { return mutateActivationAsyncInternal(null, Math.negateExact(amount), com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD_FLOOR_ZERO); } + catch (ArithmeticException invalid) { return java.util.concurrent.CompletableFuture.failedFuture(invalid); } + } + public java.util.concurrent.CompletionStage removeActivationClampedAsync(String perk, int amount) { + try { return mutateActivationAsyncInternal(java.util.Objects.requireNonNull(perk, "perk"), Math.negateExact(amount), com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD_FLOOR_ZERO); } + catch (ArithmeticException invalid) { return java.util.concurrent.CompletableFuture.failedFuture(invalid); } + } + + /** + * Commit an award and its replay identity together when shared SQL owns the user. + * The identity is retained until checkpoint cleanup; callers must not replay + * that identity after their durable checkpoint has completed. + */ + public java.util.concurrent.CompletionStage addReplayActivationAsync(String perk, int amount, String identity) { + try { + var owner = getPlugin(); var users = owner == null ? null : owner.getUserManager(); + var manager = users == null ? null : users.getDataManager(); + if (manager == null || !manager.usesSharedSqlStorage(owner.getStorageType())) + return java.util.concurrent.CompletableFuture.failedFuture(new UnsupportedOperationException("Replay award transactions require shared SQL")); + if (!hasCanonicalUserLookup(users)) + return java.util.concurrent.CompletableFuture.failedFuture(new UnsupportedOperationException("Replay awards require a canonical user manager")); + if (!getData().supportsAtomicReplayTransactions()) + return java.util.concurrent.CompletableFuture.failedFuture(new UnsupportedOperationException("User data does not support replay transactions")); + String key = perk == null ? "Activations" : "PerkActivations_" + perk; + var initial = java.util.Map.of( + key, new com.bencodez.simpleapi.sql.data.DataValueInt(0), ReplayAwardLedger.COLUMN, + new com.bencodez.simpleapi.sql.data.DataValueString("")); + UUID uuid = UUID.fromString(getUUID()); var receipt = new AwardReceipt(); + manager.acquireSharedReplayLease(uuid, identity); + try { var operation = manager.submitSharedReplayTransaction(uuid, owner.getStorageType(), initial, + scope -> { manager.requireSharedReplayAwardActive(uuid, identity); return ReplayAwardLedger.apply(scope, key, amount, identity); }); + var settled = new AwardReceipt(); + operation.whenComplete((applied, failure) -> { + try { manager.settleSharedReplayLease(uuid, identity, failure == null, false); } + catch (RuntimeException | Error uncertain) { try { manager.recordSharedStorageFailure(uncertain); } catch (RuntimeException | Error ignored) { } return; } + if (failure != null) { settled.fail(failure); return; } + if (Boolean.TRUE.equals(applied)) try { + manager.dispatchSharedUserDataNotification(() -> users.onChange(this, key)); + } catch (RuntimeException | Error notificationFailure) { + try { manager.recordSharedStorageFailure(notificationFailure); } catch (RuntimeException | Error ignored) { } + } + settled.succeed(); + }); + settled.whenComplete((ignored, failure) -> manager.afterSharedMutationFinalization(operation).whenComplete((done, finalizationFailure) -> { + if (failure != null) receipt.fail(failure); else if (finalizationFailure != null) receipt.fail(finalizationFailure); else receipt.succeed(); + })); + } catch (RuntimeException | Error failure) { manager.settleSharedReplayLease(uuid, identity, false, false); throw failure; } + return receipt; + } catch (RuntimeException | Error failure) { return java.util.concurrent.CompletableFuture.failedFuture(failure); } + } + + /** Retire an award identity only after the reward replay checkpoint is durable. */ + public java.util.concurrent.CompletionStage checkpointReplayActivationAsync(String identity) { + try { + var owner = getPlugin(); var users = owner == null ? null : owner.getUserManager(); + var manager = users == null ? null : users.getDataManager(); + if (manager == null || !manager.usesSharedSqlStorage(owner.getStorageType())) + return java.util.concurrent.CompletableFuture.failedFuture(new UnsupportedOperationException("Replay award transactions require shared SQL")); + if (!hasCanonicalUserLookup(users)) + return java.util.concurrent.CompletableFuture.failedFuture(new UnsupportedOperationException("Replay checkpoints require a canonical user manager")); + if (!getData().supportsAtomicReplayTransactions()) + return java.util.concurrent.CompletableFuture.failedFuture(new UnsupportedOperationException("User data does not support replay transactions")); + UUID uuid = UUID.fromString(getUUID()); var receipt = new AwardReceipt(); + manager.acquireSharedReplayCheckpointLease(uuid, identity); + try { var operation = manager.submitSharedReplayCheckpoint(uuid, owner.getStorageType(), identity, + java.util.Map.of(ReplayAwardLedger.COLUMN, new com.bencodez.simpleapi.sql.data.DataValueString("")), + scope -> { ReplayAwardLedger.retire(scope, identity); return null; }); + var settled = new AwardReceipt(); + operation.whenComplete((ignored, failure) -> { + try { manager.settleSharedReplayLease(uuid, identity, false, failure == null); } + catch (RuntimeException | Error uncertain) { try { manager.recordSharedStorageFailure(uncertain); } catch (RuntimeException | Error ignoredFailure) { } return; } + if (failure == null) settled.succeed(); else settled.fail(failure); + }); + settled.whenComplete((ignored, failure) -> manager.afterSharedMutationFinalization(operation).whenComplete((done, finalizationFailure) -> { + if (failure != null) receipt.fail(failure); else if (finalizationFailure != null) receipt.fail(finalizationFailure); else receipt.succeed(); + })); + } catch (RuntimeException | Error failure) { manager.settleSharedReplayLease(uuid, identity, false, false); throw failure; } + return receipt; + } catch (RuntimeException | Error failure) { return java.util.concurrent.CompletableFuture.failedFuture(failure); } + } + + private java.util.concurrent.CompletionStage mutateActivationAsyncInternal(String perk, int amount, + com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation operation) { + try { + var owner = getPlugin(); var users = owner == null ? null : owner.getUserManager(); + var manager = users == null ? null : users.getDataManager(); + if (manager == null) return java.util.concurrent.CompletableFuture.failedFuture(new IllegalStateException("User storage is unavailable")); + if (!usesCanonicalCreditCallbacks(users)) + return java.util.concurrent.CompletableFuture.failedFuture(new UnsupportedOperationException("Observable awards require canonical user and integer callbacks")); + // Strict row reads do not authorize bypassing custom integer callbacks. + // This new receipt API can reject before admission when that contract is unknown. + var data = getData(); + if (!hasCanonicalIntegerCallbacks(data)) + return java.util.concurrent.CompletableFuture.failedFuture(new UnsupportedOperationException("Observable awards require canonical integer callbacks")); + String key = perk == null ? "Activations" : "PerkActivations_" + perk; + if (manager.usesSharedSqlStorage(owner.getStorageType())) { + var receipt = new AwardReceipt(); + manager.submitSharedIntegerMutation(UUID.fromString(getUUID()), java.util.Map.of(key, amount), + operation, + keys -> users.onChange(this, keys.toArray(String[]::new))).whenComplete((ignored, failure) -> { + if (failure == null) receipt.succeed(); else receipt.fail(failure); + }); + return receipt; + } + if (!manager.usesSharedSqlStorage(owner.getStorageType()) && getData().supportsStrictCompleteValues()) { + var receipt = new AwardReceipt(); + manager.submitLegacyIntegerMutation(UUID.fromString(getUUID()), java.util.Map.of(key, amount), + operation, + keys -> users.onChange(this, keys.toArray(String[]::new))).whenComplete((ignored, failure) -> { + if (failure == null) receipt.succeed(); else receipt.fail(failure); + }); + return receipt; + } + // Opaque callbacks may throw after changing data. Do not report a + // terminal failure for an operation that may already have applied. + // Existing synchronous calls remain available. + return java.util.concurrent.CompletableFuture.failedFuture( + new UnsupportedOperationException("Observable award receipts are unavailable for this storage provider")); + } catch (RuntimeException | Error failure) { return java.util.concurrent.CompletableFuture.failedFuture(failure); } + } + + private static boolean hasCanonicalUserLookup(com.bencodez.advancedcore.api.user.UserManager users) { + try { return users.getClass().getMethod("getUser", UUID.class, boolean.class).getDeclaringClass() + == com.bencodez.advancedcore.api.user.UserManager.class; } + catch (NoSuchMethodException | SecurityException unavailable) { return false; } + } + + private static boolean hasCanonicalIntegerCallbacks(com.bencodez.advancedcore.api.user.UserData data) { + if (data == null) return false; + try { + var type = data.getClass(); var base = com.bencodez.advancedcore.api.user.UserData.class; + var mode = com.bencodez.advancedcore.api.user.UserDataFetchMode.class; + var storage = com.bencodez.advancedcore.api.user.UserStorage.class; + return type.getMethod("getInt", String.class).getDeclaringClass() == base + && type.getMethod("getInt", String.class, int.class, mode).getDeclaringClass() == base + && type.getMethod("getInt", storage, String.class, int.class, mode).getDeclaringClass() == base + && type.getMethod("setInt", String.class, int.class).getDeclaringClass() == base + && type.getMethod("setInt", String.class, int.class, boolean.class).getDeclaringClass() == base + && type.getMethod("setInt", storage, String.class, int.class, boolean.class).getDeclaringClass() == base + && type.getMethod("setInt", storage, String.class, int.class, boolean.class, boolean.class).getDeclaringClass() == base; + } catch (NoSuchMethodException | SecurityException unavailable) { return false; } + } + + private boolean usesCanonicalCreditCallbacks(com.bencodez.advancedcore.api.user.UserManager users) { + try { + return hasCanonicalUserLookup(users) + && getClass().getMethod("getData").getDeclaringClass() + == com.bencodez.advancedcore.api.user.AdvancedCoreUser.class + && hasCanonicalIntegerCallbacks(getData()); + } catch (NoSuchMethodException | SecurityException unavailable) { return false; } + } + + /** Reserve both positive pools together when the shared cache owns storage. */ + public com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutationResult reserveSharedActivations(String perk) { + var owner = getPlugin(); var users = owner == null ? null : owner.getUserManager(); + var manager = users == null ? null : users.getDataManager(); + if (manager == null || !usesCanonicalCreditCallbacks(users)) return null; + if (!manager.usesSharedSqlStorage(owner.getStorageType())) { + if (!getData().supportsStrictCompleteValues()) return null; + UUID id = UUID.fromString(getUUID()); + var values = java.util.Map.of("Activations", -1, "PerkActivations_" + perk, -1); + java.util.function.Consumer> notice = keys -> users.onChange(this, keys.toArray(String[]::new)); + var result = manager.tryMutateLegacyIntegers(id, values, + com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.DEBIT_POSITIVE, notice); + if (result == null) { + var population = manager.populateCompleteLegacyCacheIfSupported(id); + if (population == com.bencodez.advancedcore.api.user.usercache.UserDataManager.LegacyPopulationResult.UNSUPPORTED) return null; + if (population == com.bencodez.advancedcore.api.user.usercache.UserDataManager.LegacyPopulationResult.PUBLISHED) + result = manager.tryMutateLegacyIntegers(id, values, + com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.DEBIT_POSITIVE, notice); + } + if (result == null) throw new IllegalStateException("Activation credit balance is unavailable"); + return result; + } + if (manager.mustDeferSharedStorageAccess()) throw new IllegalStateException("Activation reservation requires a storage worker"); + UUID id = UUID.fromString(getUUID()); var cache = manager.getPublishedCache(id); + if (cache == null) { manager.cacheUser(id); cache = manager.getPublishedCache(id); } + if (cache == null) throw new IllegalStateException("Activation credit cache is unavailable"); + if (cache.getIfPresent("Activations") == null) cache.seedMissingIntegerFromCompleteSnapshot("Activations"); + if (cache.getIfPresent("PerkActivations_" + perk) == null) cache.seedMissingIntegerFromCompleteSnapshot("PerkActivations_" + perk); + var result = cache.tryMutateIntegers(java.util.Map.of("Activations", -1, "PerkActivations_" + perk, -1), + com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.DEBIT_POSITIVE, + keys -> users.onChange(this, keys.toArray(String[]::new))); + if (result == null) throw new IllegalStateException("Activation credit balance is unavailable"); + return result; + } + + public void removeActivationClamped(int amount) { + int delta = Math.negateExact(amount); + if (!mutateSharedCredit("Activations", delta, com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD_FLOOR_ZERO)) { + addActivation(delta); if (getActivations() < 0) setActivations(0); + } + } + + public void removeActivationClamped(String perk, int amount) { + int delta = Math.negateExact(amount); + if (!mutateSharedCredit("PerkActivations_" + perk, delta, com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD_FLOOR_ZERO)) { + addActivation(perk, delta); if (getActivations(perk) < 0) setActivations(perk, 0); + } + } + + /** Apply shared credit arithmetic in the canonical cache, never by reading then writing. */ + private boolean mutateSharedCredit(String key, int amount, + com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation operation) { + var owner = getPlugin(); + var users = owner == null ? null : owner.getUserManager(); + var manager = users == null ? null : users.getDataManager(); + if (manager == null || !usesCanonicalCreditCallbacks(users)) return false; + if (!manager.usesSharedSqlStorage(owner.getStorageType())) { + if (!getData().supportsStrictCompleteValues()) return false; + UUID id = UUID.fromString(getUUID()); + var result = manager.tryMutateLegacyIntegers(id, java.util.Map.of(key, amount), operation, + keys -> users.onChange(this, keys.toArray(String[]::new))); + if (result == null) { + manager.submitLegacyIntegerMutation(id, java.util.Map.of(key, amount), operation, + keys -> users.onChange(this, keys.toArray(String[]::new))).whenComplete((ignored, failure) -> { + if (failure != null) try { manager.recordSharedStorageFailure(failure); } + catch (RuntimeException | Error ignoredFailure) { } + }); + } + return true; + } + UUID id = UUID.fromString(getUUID()); + java.util.function.Consumer> notice = keys -> users.onChange(this, keys.toArray(String[]::new)); + var cache = manager.getPublishedCache(id); + if (cache != null && cache.getIfPresent(key) == null) cache.seedMissingIntegerFromCompleteSnapshot(key); + if (cache != null && cache.tryMutateIntegers(java.util.Map.of(key, amount), operation, notice) != null) return true; + Runnable deferred = () -> { + manager.cacheUser(id); + var ready = manager.getPublishedCache(id); + if (ready != null && ready.getIfPresent(key) == null) ready.seedMissingIntegerFromCompleteSnapshot(key); + if (ready == null || ready.tryMutateIntegers(java.util.Map.of(key, amount), operation, notice) == null) + throw new IllegalStateException("Activation credit cache is unavailable for the requested key"); + }; + if (manager.mustDeferSharedStorageAccess()) { + // The legacy void method acknowledges admission, not durable completion. + // Keep one producer-owned operation and record failure without resubmission. + manager.submitSharedIntegerMutation(id, java.util.Map.of(key, amount), operation, notice) + .whenComplete((ignored, failure) -> { + if (failure != null) try { manager.recordSharedStorageFailure(failure); } + catch (RuntimeException | Error ignoredFailure) { } + }); + return true; + } + deferred.run(); + return true; + } + + public boolean isIgnorePerkEffects() { + return getData().getBoolean("IgnoreEffects"); + } + + public void setIgnoreEffects(boolean value) { + getData().setBoolean("IgnoreEffects", value); + } + +} diff --git a/MCPerks/src/main/java/com/bencodez/mcperks/userapi/ReplayAwardLedger.java b/MCPerks/src/main/java/com/bencodez/mcperks/userapi/ReplayAwardLedger.java new file mode 100644 index 0000000..7954e91 --- /dev/null +++ b/MCPerks/src/main/java/com/bencodez/mcperks/userapi/ReplayAwardLedger.java @@ -0,0 +1,93 @@ +package com.bencodez.mcperks.userapi; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.sql.SQLException; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import com.bencodez.advancedcore.core.user.storage.SqlUserStorage; +import com.bencodez.simpleapi.sql.Column; +import com.bencodez.simpleapi.sql.data.DataValue; +import com.bencodez.simpleapi.sql.data.DataValueInt; +import com.bencodez.simpleapi.sql.data.DataValueString; + +/** Per-user receipt and balance updates must commit in the same SQL transaction. */ +final class ReplayAwardLedger { + static final String COLUMN = "MCPerksAwardReceipts"; + private static final int MAX_ENCODED = 60000; + private ReplayAwardLedger() { } + + static boolean apply(SqlUserStorage.TransactionScope scope, String balanceKey, int amount, String identity) throws SQLException { + String receipt = receipt(identity); + var row = scope.readRow(); + if (!hasColumn(row, COLUMN)) throw rejected("Award receipt column is unavailable"); + if (!hasColumn(row, balanceKey)) throw rejected("Award balance column is unavailable"); + DataValue balance = find(row, balanceKey); + DataValue stored = find(row, COLUMN); + Set receipts = decode(stored == null ? "" : stored.getString()); + String entry = receipt + ":" + receipt(balanceKey + "\0" + amount); + for (String existing : receipts) if (existing.startsWith(receipt + ":")) { + if (!existing.equals(entry)) throw rejected("Replay award identity has a different intent"); + return false; + } + int updated; + try { updated = Math.addExact(balance == null ? 0 : balance.getInt(), amount); } + catch (IllegalArgumentException | ArithmeticException failure) { throw rejected("Award balance is invalid or outside the supported range"); } + receipts.add(entry); + String encoded = String.join("\n", receipts); + if (encoded.length() > MAX_ENCODED) throw rejected("Award receipt capacity reached"); + scope.writeValues(Map.of(balanceKey, new DataValueInt(updated), COLUMN, new DataValueString(encoded))); + return true; + } + + static void retire(SqlUserStorage.TransactionScope scope, String identity) throws SQLException { + if (scope.createdUserRow()) throw rejected("Replay checkpoint user is unavailable"); + var row = scope.readRow(); + if (!hasColumn(row, COLUMN)) throw rejected("Award receipt column is unavailable"); + var stored = find(row, COLUMN); + if (stored == null) return; + Set receipts = decode(stored.getString()); + String prefix = receipt(identity) + ":"; + if (receipts.removeIf(value -> value.startsWith(prefix))) scope.writeValues(Map.of(COLUMN, new DataValueString(String.join("\n", receipts)))); + } + + private static DataValue find(java.util.List row, String key) { + if (row == null) return null; + DataValue found = null; boolean matched = false; + for (Column column : row) if (key.equalsIgnoreCase(column.getName())) { + if (matched) throw rejected("Ambiguous award column"); + matched = true; found = column.getValue(); + } + return found; + } + + private static boolean hasColumn(java.util.List row, String key) { + if (row == null) return false; + int matches = 0; + for (Column column : row) if (key.equalsIgnoreCase(column.getName())) matches++; + if (matches > 1) throw rejected("Ambiguous award column"); + return matches == 1; + } + + private static Set decode(String text) { + Set receipts = new LinkedHashSet<>(); + if (text == null || text.isEmpty()) return receipts; + if (text.length() > MAX_ENCODED) throw rejected("Award receipt capacity reached"); + for (String value : text.split("\n", -1)) { + if (!value.matches("[0-9a-f]{64}:[0-9a-f]{64}") || !receipts.add(value)) throw rejected("Invalid award receipt data"); + } + return receipts; + } + + private static String receipt(String identity) { + if (identity == null || identity.isEmpty()) throw rejected("Replay identity is required"); + try { return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(identity.getBytes(StandardCharsets.UTF_8))); } + catch (NoSuchAlgorithmException impossible) { throw new IllegalStateException(impossible); } + } + + private static SqlUserStorage.ReplayPreconditionException rejected(String message) { + return new SqlUserStorage.ReplayPreconditionException(message); + } +} diff --git a/MCPerks/src/main/java/com/bencodez/mcperks/userapi/UserManager.java b/MCPerks/src/main/java/com/bencodez/mcperks/userapi/UserManager.java index d9802e0..5efe1d4 100644 --- a/MCPerks/src/main/java/com/bencodez/mcperks/userapi/UserManager.java +++ b/MCPerks/src/main/java/com/bencodez/mcperks/userapi/UserManager.java @@ -46,6 +46,7 @@ public void addCachingKeys() { manager.addKey(new UserDataKeyString("UseBossBar").setColumnType("VARCHAR(5)")); manager.addKey(new UserDataKeyString("IgnoreEffects").setColumnType("VARCHAR(5)")); manager.addKey(new UserDataKeyInt("Activations")); + manager.addKey(new UserDataKeyString(ReplayAwardLedger.COLUMN).setColumnType("TEXT")); for (String perk : ConfigPerks.getInstance().getPerksNames()) { manager.addKey(new UserDataKeyInt("PerkActivations_" + perk)); manager.addKey(new UserDataKeyInt(perk + "_TimesUsed")); diff --git a/MCPerks/src/main/java/com/bencodez/mcperks/userapi/UserStorageConfirmation.java b/MCPerks/src/main/java/com/bencodez/mcperks/userapi/UserStorageConfirmation.java new file mode 100644 index 0000000..658b9f4 --- /dev/null +++ b/MCPerks/src/main/java/com/bencodez/mcperks/userapi/UserStorageConfirmation.java @@ -0,0 +1,41 @@ +package com.bencodez.mcperks.userapi; + +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import com.bencodez.mcperks.MCPerksMain; + +/** + * Confirms the current canonical user cache after operations already pending at + * observation have settled. Callers must separately observe their operation's + * receipt: a failure that completed before this snapshot cannot be reconstructed. + */ +public final class UserStorageConfirmation { + private UserStorageConfirmation() {} + public static CompletableFuture confirm(MCPerksMain plugin, MCPerksUser user) { + var confirmed = new CompletableFuture(); + plugin.getStorage().runtimeStorage(() -> { + var manager = plugin.getUserManager().getDataManager(); + UUID owner = UUID.fromString(user.getUUID()); + // Owner-thread setters can defer the entire mutation while their canonical + // cache is still a placeholder. Join that same ordered worker before flushing; + // a flush on our independent file worker can otherwise confirm an empty cache. + manager.pendingLegacyIntegerMutations(owner).thenCompose(ignored -> manager.pendingSharedIntegerMutations(owner)).whenComplete((ignored, pendingFailure) -> { + if (pendingFailure != null) { confirmed.completeExceptionally(pendingFailure); return; } + try { + manager.getTimer().execute(() -> { + try { + var cache = manager.getCache(owner); + if (cache == null) throw new IllegalStateException("Activation user cache unavailable"); + cache.flushChangesAndRun(() -> confirmed.complete(null)); + } catch (RuntimeException | Error failure) { + confirmed.completeExceptionally(failure); + } + }); + } catch (RuntimeException | Error failure) { confirmed.completeExceptionally(failure); } + }); + return null; + }).whenComplete((ignored, error) -> { if (error != null) confirmed.completeExceptionally(error); }); + // A deferred backend that cannot report failure must retain the uncertain start fence. + return confirmed.copy(); + } +} diff --git a/MCPerks/src/test/java/com/bencodez/mcperks/tests/NativeOwnerLifecycleIntegrationTest.java b/MCPerks/src/test/java/com/bencodez/mcperks/tests/NativeOwnerLifecycleIntegrationTest.java new file mode 100644 index 0000000..de18d7d --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/NativeOwnerLifecycleIntegrationTest.java @@ -0,0 +1,94 @@ +package com.bencodez.mcperks.tests; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.bencodez.mcperks.MCPerksMain; +import com.bencodez.mcperks.data.ServerData; +import com.bencodez.mcperks.scheduling.OwnerTasks; +import com.bencodez.mcperks.tests.scheduling.OwnerTaskHarness; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +class NativeOwnerLifecycleIntegrationTest { + @Test void postLoadOpensOwnerAdmissionBeforeConstructingNativeStorage() throws Exception { + var plugin = mock(MCPerksMain.class); + var player = mock(Player.class); + // Constructing the harness while disabled cannot initialize an active epoch. + var harness = new OwnerTaskHarness(plugin, player); + var retired = new AtomicInteger(); + OwnerTasks.run(plugin, player, () -> fail("uninitialized admission"), retired::incrementAndGet); + assertEquals(1, retired.get()); + when(plugin.isEnabled()).thenReturn(true); + var stopBeforeStorageConstruction = new IllegalStateException("stop at native storage boundary"); + doThrow(stopBeforeStorageConstruction).when(plugin).getDataFolder(); + doCallRealMethod().when(plugin).onPostLoad(); + try { + assertSame(stopBeforeStorageConstruction, assertThrows(IllegalStateException.class, plugin::onPostLoad)); + var ran = new AtomicInteger(); + OwnerTasks.run(plugin, player, ran::incrementAndGet, () -> fail("startup did not initialize owner epoch")); + var callback = harness.tasks.poll(5, TimeUnit.SECONDS); + assertNotNull(callback); + callback.run(); + assertEquals(1, ran.get()); + } finally { OwnerTasks.shutdown(plugin); } + } + + @Test void unloadRetiresPendingOwnerCallbacksBeforeNativeSaveAndRejectsLateCallbacks() throws Exception { + var plugin = mock(MCPerksMain.class); + when(plugin.isEnabled()).thenReturn(true); + var player = mock(Player.class); + var harness = new OwnerTaskHarness(plugin, player); + var retired = new AtomicInteger(); + var ran = new AtomicInteger(); + OwnerTasks.run(plugin, player, ran::incrementAndGet, retired::incrementAndGet); + var lateCallback = harness.tasks.poll(5, TimeUnit.SECONDS); + assertNotNull(lateCallback); + var data = mock(ServerData.class); + when(plugin.getMcperksServerData()).thenReturn(data); + doAnswer(ignored -> { + assertEquals(1, retired.get(), "native save must follow retirement of existing admissions"); + assertEquals(0, ran.get()); + return null; + }).when(data).saveData(); + doCallRealMethod().when(plugin).onUnLoad(); + MCPerksMain previous = MCPerksMain.plugin; + MCPerksMain.plugin = plugin; + try { + plugin.onUnLoad(); + lateCallback.run(); + lateCallback.run(); + assertEquals(1, retired.get()); + assertEquals(0, ran.get()); + verify(data).saveData(); + OwnerTasks.run(plugin, player, ran::incrementAndGet, retired::incrementAndGet); + assertEquals(2, retired.get()); + assertEquals(0, ran.get()); + } finally { + OwnerTasks.shutdown(plugin); + MCPerksMain.plugin = previous; + } + } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"cancel", "success", "failure"}) + void shutdownPreparationObservationCannotAdvanceThePrivateOwner(String mutation) throws Exception { + var plugin = mock(MCPerksMain.class); + var preparation = new java.util.concurrent.CompletableFuture(); + var field = MCPerksMain.class.getDeclaredField("shutdownPreparation"); + field.setAccessible(true); field.set(plugin, preparation); + doCallRealMethod().when(plugin).onBeforeStorageShutdown(); + var observed = plugin.onBeforeStorageShutdown().toCompletableFuture(); + switch (mutation) { + case "cancel" -> observed.cancel(false); + case "success" -> observed.complete(null); + case "failure" -> observed.completeExceptionally(new IllegalStateException("caller observation")); + default -> fail("unknown mutation"); + } + assertFalse(preparation.isDone()); + assertFalse(plugin.onBeforeStorageShutdown().toCompletableFuture().isDone()); + preparation.complete(null); + plugin.onBeforeStorageShutdown().toCompletableFuture().get(5, TimeUnit.SECONDS); + } +} diff --git a/MCPerks/src/test/java/com/bencodez/mcperks/tests/NativeReloadLifecycleTest.java b/MCPerks/src/test/java/com/bencodez/mcperks/tests/NativeReloadLifecycleTest.java new file mode 100644 index 0000000..83f67ab --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/NativeReloadLifecycleTest.java @@ -0,0 +1,209 @@ +package com.bencodez.mcperks.tests; +import com.bencodez.mcperks.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.atomic.AtomicReference; +import java.util.logging.Logger; +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; + +import com.bencodez.mcperks.data.OrderedStorage; + +/** Production reload admission remains pending until the ordered snapshot settles. */ +@org.junit.jupiter.api.Timeout(10) +class NativeReloadLifecycleTest { + @Test + void storageFailureJoinsPendingReloadAndPermitsRetry() throws Exception { + MCPerksMain plugin = mock(MCPerksMain.class, CALLS_REAL_METHODS); + OrderedStorage state = mock(OrderedStorage.class); + CompletableFuture barrier = new CompletableFuture<>(); + when(plugin.getStorage()).thenReturn(state); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getLogger()).thenReturn(Logger.getLogger("reload-test")); + set(plugin, "storage", state); + set(plugin, "reloadPending", new java.util.concurrent.atomic.AtomicBoolean()); + when(plugin.getLogger()).thenReturn(Logger.getLogger("reload-test")); + doReturn(barrier).when(state).runtimeStorage(any()); + + CompletableFuture first = plugin.reloadAsync(); + assertFalse(first.isDone(), "reload must not report success before the storage barrier"); + assertSame(first, plugin.reloadAsync(), "concurrent reload callers must join one lifecycle operation"); + + barrier.completeExceptionally(new IllegalStateException("fixture publication failure")); + ExecutionException failure = assertThrows(ExecutionException.class, () -> first.get(5, java.util.concurrent.TimeUnit.SECONDS)); + assertEquals("fixture publication failure", failure.getCause().getMessage()); + + CompletableFuture successor = plugin.reloadAsync(); + assertNotSame(first, successor, "a completed failure must not poison a later reload request"); + } + + @Test + void storageBarrierDoesNotCompleteReloadBeforeItsOwnerCallback() throws Exception { + MCPerksMain plugin = mock(MCPerksMain.class, CALLS_REAL_METHODS); + OrderedStorage state = mock(OrderedStorage.class); + CompletableFuture barrier = new CompletableFuture<>(); + AtomicReference scheduled = new AtomicReference<>(); + var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + var folia = mock(com.bencodez.simpleapi.folialib.FoliaLib.class); + var implementation = mock(com.bencodez.simpleapi.folialib.impl.PlatformScheduler.class); + CompletableFuture dispatch = new CompletableFuture<>(); + when(scheduler.getFoliaLib()).thenReturn(folia); + when(folia.getImpl()).thenReturn(implementation); + doAnswer(invocation -> { scheduled.set(() -> invocation.>getArgument(0) + .accept(mock(com.bencodez.simpleapi.folialib.wrapper.task.WrappedTask.class))); return dispatch; }) + .when(implementation).runNextTick(any()); + when(plugin.getStorage()).thenReturn(state); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getLogger()).thenReturn(Logger.getLogger("reload-test")); + set(plugin, "storage", state); + set(plugin, "reloadPending", new java.util.concurrent.atomic.AtomicBoolean()); + doReturn(barrier).when(state).runtimeStorage(any()); + + CompletableFuture operation = plugin.reloadAsync(); + barrier.complete(null); + long deadline = System.nanoTime() + 5_000_000_000L; + while (scheduled.get() == null && System.nanoTime() < deadline) Thread.yield(); + assertNotNull(scheduled.get(), "reload must dispatch its lifecycle work to Bukkit"); + assertFalse(operation.isDone(), "reload must remain pending until scheduled lifecycle work completes"); + } + + @Test + void rejectedOwnerDispatchFailsReloadWithoutPoisoningRetry() throws Exception { + MCPerksMain plugin = mock(MCPerksMain.class, CALLS_REAL_METHODS); + OrderedStorage state = mock(OrderedStorage.class); + CompletableFuture barrier = CompletableFuture.completedFuture(null); + CompletableFuture dispatch = new CompletableFuture<>(); + var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + var folia = mock(com.bencodez.simpleapi.folialib.FoliaLib.class); + var implementation = mock(com.bencodez.simpleapi.folialib.impl.PlatformScheduler.class); + when(scheduler.getFoliaLib()).thenReturn(folia); + when(folia.getImpl()).thenReturn(implementation); + when(implementation.runNextTick(any())).thenReturn(dispatch); + when(plugin.getStorage()).thenReturn(state); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getLogger()).thenReturn(Logger.getLogger("reload-test")); + set(plugin, "storage", state); + set(plugin, "reloadPending", new java.util.concurrent.atomic.AtomicBoolean()); + doReturn(barrier).when(state).runtimeStorage(any()); + + CompletableFuture operation = plugin.reloadAsync(); + dispatch.completeExceptionally(new IllegalStateException("owner dispatch rejected")); + ExecutionException failure = assertThrows(ExecutionException.class, () -> operation.get(5, java.util.concurrent.TimeUnit.SECONDS)); + assertEquals("owner dispatch rejected", failure.getCause().getMessage()); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"none", "cancel", "success", "failure"}) + void successfulOwnerCallbackCompletesReloadOnceAfterNativeWork(String mutation) throws Exception { + var plugin = mock(MCPerksMain.class, CALLS_REAL_METHODS); + var storage = mock(OrderedStorage.class); + var barrier = new CompletableFuture(); + doReturn(barrier).when(storage).runtimeStorage(any()); + set(plugin, "storage", storage); + set(plugin, "reloadPending", new java.util.concurrent.atomic.AtomicBoolean()); + when(plugin.isEnabled()).thenReturn(true); + var config = mock(com.bencodez.mcperks.configs.Config.class); + var data = mock(com.bencodez.mcperks.data.ServerData.class); + var perks = mock(com.bencodez.mcperks.perk.PerkHandler.class); + var commands = mock(com.bencodez.mcperks.commands.CommandLoader.class); + set(plugin, "configFile", config); set(plugin, "mcperksServerData", data); + set(plugin, "perkHandler", perks); set(plugin, "commandLoader", commands); + doNothing().when(plugin).updateHook(); + var coreReload = new CompletableFuture(); + doReturn(coreReload).when(plugin).reloadAdvancedCoreAsync(true); + when(data.reloadDataAsync()).thenReturn(CompletableFuture.completedFuture(null)); + var finalFiles = new CompletableFuture(); + when(data.reloadCompletion()).thenReturn(finalFiles); + var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + var folia = mock(com.bencodez.simpleapi.folialib.FoliaLib.class); + var implementation = mock(com.bencodez.simpleapi.folialib.impl.PlatformScheduler.class); + var dispatch = new CompletableFuture(); + AtomicReference scheduled = new AtomicReference<>(); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(scheduler.getFoliaLib()).thenReturn(folia); when(folia.getImpl()).thenReturn(implementation); + when(implementation.runNextTick(any())).thenAnswer(invocation -> { + scheduled.set(() -> invocation.>getArgument(0).accept(null)); + return dispatch; + }); + var language = mock(com.bencodez.mcperks.configs.Lang.class); + try (var languages = mockStatic(com.bencodez.mcperks.configs.Lang.class)) { + languages.when(com.bencodez.mcperks.configs.Lang::getInstance).thenReturn(language); + var operation = plugin.reloadAsync(); + verifyNoInteractions(config, data, commands, language); + verify(perks).pauseNativeActivationsForReload(); + verify(perks, never()).reload(); + barrier.complete(null); + assertFalse(operation.isDone()); + switch (mutation) { + case "none" -> { } + case "cancel" -> operation.cancel(false); + case "success" -> operation.complete(null); + case "failure" -> operation.completeExceptionally(new IllegalStateException("caller observation")); + default -> fail("unknown mutation"); + } + scheduled.get().run(); + scheduled.get().run(); + assertSame(operation, plugin.reloadAsync(), "native storage replacement still owns the reload admission"); + verify(perks, never()).resumeNativeActivationsAfterReload(); + dispatch.completeExceptionally(new IllegalStateException("late dispatch failure after owner admission")); + coreReload.complete(null); + verify(perks, never()).resumeNativeActivationsAfterReload(); + finalFiles.complete(null); + if (mutation.equals("none")) operation.get(5, java.util.concurrent.TimeUnit.SECONDS); + verify(perks).resumeNativeActivationsAfterReload(); + verify(config, times(1)).reloadData(); verify(data, times(1)).reloadDataAsync(); + verify(data).reloadCompletion(); + verify(perks, times(1)).reload(); verify(commands, times(1)).loadTabComplete(); + verify(language, times(1)).reloadData(); verify(plugin, times(1)).reloadAdvancedCoreAsync(true); + dispatch.completeExceptionally(new IllegalStateException("late dispatch failure")); + if (mutation.equals("none") || mutation.equals("success")) assertFalse(operation.isCompletedExceptionally()); + assertNotSame(operation, plugin.reloadAsync(), "actual owner completion must release reload admission"); + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {true, false}) + void reloadPostponesWithoutRetiringAcceptedWorkOrStartupRestoration(boolean activationPending) throws Exception { + var plugin = mock(MCPerksMain.class, CALLS_REAL_METHODS); + var storage = mock(OrderedStorage.class); + var perks = com.bencodez.mcperks.tests.TestAccess.newPerkHandler(plugin, java.util.List.of()); + when(plugin.getPerkHandler()).thenReturn(perks); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getLogger()).thenReturn(Logger.getLogger("reload-test")); + set(plugin, "storage", storage); + set(plugin, "perkHandler", perks); + set(plugin, "reloadPending", new java.util.concurrent.atomic.AtomicBoolean()); + var outstanding = new CompletableFuture(); + doReturn(new CompletableFuture()).when(storage).runtimeStorage(any()); + var pending = com.bencodez.mcperks.perk.PerkHandler.class.getDeclaredField("pendingNativeActivations"); + pending.setAccessible(true); + var count = (java.util.concurrent.atomic.AtomicInteger) pending.get(perks); + var restoration = com.bencodez.mcperks.perk.PerkHandler.class.getDeclaredField("restoration"); + restoration.setAccessible(true); + if (activationPending) count.incrementAndGet(); else restoration.set(perks, outstanding); + try { + var reload = plugin.reloadAsync(); + assertTrue(reload.isCompletedExceptionally(), "reload must explicitly postpone while accepted work is pending"); + assertEquals(0, plugin.getFeatureLifecycle()); + verifyNoInteractions(storage); + if (activationPending) assertEquals(1, count.get()); else assertSame(outstanding, restoration.get(perks)); + assertFalse(outstanding.isDone()); + // Completing the same startup owner must still establish readiness; reload installs no competing one. + if (activationPending) count.decrementAndGet(); else outstanding.complete(null); + assertTrue(perks.isRestorationReady()); + } finally { perks.retireTimers(); } + } + + private static void set(Object target, String name, Object value) throws Exception { + var field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } +} diff --git a/MCPerks/src/test/java/com/bencodez/mcperks/tests/TestAccess.java b/MCPerks/src/test/java/com/bencodez/mcperks/tests/TestAccess.java new file mode 100644 index 0000000..981031a --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/TestAccess.java @@ -0,0 +1,56 @@ +package com.bencodez.mcperks.tests; + +import java.lang.reflect.InvocationTargetException; +import java.sql.Timestamp; +import java.util.List; +import com.bencodez.mcperks.MCPerksMain; +import com.bencodez.mcperks.data.DurableFeatureFiles; +import com.bencodez.mcperks.data.ServerData; +import com.bencodez.mcperks.perk.Perk; +import com.bencodez.mcperks.perk.PerkHandler; +import com.bencodez.mcperks.userapi.MCPerksUser; + +/** Narrow access to existing package-private test seams without changing production APIs. */ +public final class TestAccess { + private TestAccess() {} + + + + public static ServerData newServerData(MCPerksMain plugin, DurableFeatureFiles.Publisher publisher) { + return construct(ServerData.class, new Class[] {MCPerksMain.class, DurableFeatureFiles.Publisher.class}, plugin, publisher); + } + + public static PerkHandler newPerkHandler(MCPerksMain plugin, List perks) { + return construct(PerkHandler.class, new Class[] {MCPerksMain.class, List.class}, plugin, perks); + } + public static void activatePerk(Perk perk, MCPerksUser user, int length, Timestamp timestamp) { + try { + var method = Perk.class.getDeclaredMethod("activatePerk", MCPerksUser.class, int.class, Timestamp.class); + method.setAccessible(true); + method.invoke(perk, user, length, timestamp); + } catch (InvocationTargetException error) { + throw unchecked(error.getCause()); + } catch (ReflectiveOperationException error) { + throw new AssertionError("Cannot access existing perk activation seam", error); + } + } + private static T construct(Class type, Class[] parameters, Object... arguments) { + try { + var constructor = type.getDeclaredConstructor(parameters); + constructor.setAccessible(true); + return constructor.newInstance(arguments); + } catch (InvocationTargetException error) { + throw unchecked(error.getCause()); + } catch (ReflectiveOperationException error) { + throw new AssertionError("Cannot access existing " + type.getSimpleName() + " constructor", error); + } + } + // Preserve the original exception type so assertThrows and Mockito observe the same failure. + private static RuntimeException unchecked(Throwable error) { + return TestAccess.rethrow(error); + } + @SuppressWarnings("unchecked") + private static RuntimeException rethrow(Throwable error) throws T { + throw (T) error; + } +} diff --git a/MCPerks/src/test/java/com/bencodez/mcperks/tests/commands/ActivationCommandReceiptTest.java b/MCPerks/src/test/java/com/bencodez/mcperks/tests/commands/ActivationCommandReceiptTest.java new file mode 100644 index 0000000..0960fc4 --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/commands/ActivationCommandReceiptTest.java @@ -0,0 +1,137 @@ +package com.bencodez.mcperks.tests.commands; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; +import org.bukkit.command.CommandSender; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import com.bencodez.mcperks.MCPerksMain; +import com.bencodez.mcperks.commands.CommandLoader; + +class ActivationCommandReceiptTest { + @ParameterizedTest @ValueSource(booleans = {true, false}) + void playerReceiptReplyRunsOnOwnerAndSkipsOfflinePlayer(boolean online) throws Exception { + var plugin = mock(MCPerksMain.class); var player = mock(org.bukkit.entity.Player.class); when(player.isOnline()).thenReturn(online); + var receipt = new CompletableFuture(); var action = new AtomicReference(); + var method = CommandLoader.class.getDeclaredMethod("reportActivationResult", CommandSender.class, java.util.concurrent.CompletionStage.class, String.class); + method.setAccessible(true); + try (var owner = mockStatic(com.bencodez.mcperks.scheduling.OwnerTasks.class)) { + owner.when(() -> com.bencodez.mcperks.scheduling.OwnerTasks.run(eq(plugin), eq(player), any(), any())) + .thenAnswer(call -> { action.set(call.getArgument(2)); return null; }); + method.invoke(new CommandLoader(plugin), player, receipt, "&cUpdated activations"); + assertNull(action.get()); receipt.complete(null); assertNotNull(action.get()); + verify(player, never()).sendMessage(anyString()); action.get().run(); + verify(player, times(online ? 1 : 0)).sendMessage(anyString()); + } + } + @org.junit.jupiter.api.Test void wildcardAddWaitsForEveryReceiptAndReportsPartialFailure() throws Exception { + var plugin = mock(MCPerksMain.class); when(plugin.getOptions()).thenReturn(mock(com.bencodez.advancedcore.AdvancedCoreConfigOptions.class)); + when(plugin.getName()).thenReturn("MCPerks"); var commands = new java.util.ArrayList(); + when(plugin.getCommands()).thenReturn(commands); var perks = mock(com.bencodez.mcperks.perk.PerkHandler.class); + when(perks.getLoadedPerks()).thenReturn(new java.util.HashMap<>()); when(plugin.getPerkHandler()).thenReturn(perks); + var users = mock(com.bencodez.mcperks.userapi.UserManager.class); when(plugin.getMcperksUserManager()).thenReturn(users); + var one = mock(org.bukkit.entity.Player.class); var two = mock(org.bukkit.entity.Player.class); + var first = mock(com.bencodez.mcperks.userapi.MCPerksUser.class); var second = mock(com.bencodez.mcperks.userapi.MCPerksUser.class); + when(users.getMCPerksUser(one)).thenReturn(first); when(users.getMCPerksUser(two)).thenReturn(second); + var firstReceipt = new CompletableFuture(); var secondReceipt = new CompletableFuture(); + when(first.addActivationAsync(3)).thenReturn(firstReceipt); when(second.addActivationAsync(3)).thenReturn(secondReceipt); + var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class)); var reply = new AtomicReference(); + doAnswer(call -> { reply.set(call.getArgument(1)); return null; }).when(scheduler).runTask(eq(plugin), any(Runnable.class)); + var loader = spy(new CommandLoader(plugin)); doNothing().when(loader).loadAliases(); doNothing().when(loader).loadTabComplete(); + var core = mock(com.bencodez.advancedcore.command.CommandLoader.class); + when(core.getBasicAdminCommands(anyString())).thenReturn(new java.util.ArrayList<>()); when(core.getBasicCommands(anyString())).thenReturn(new java.util.ArrayList<>()); + var previous = MCPerksMain.plugin; MCPerksMain.plugin = plugin; + try (var coreStatic = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class); var bukkit = mockStatic(org.bukkit.Bukkit.class)) { + coreStatic.when(com.bencodez.advancedcore.command.CommandLoader::getInstance).thenReturn(core); + bukkit.when(org.bukkit.Bukkit::getOnlinePlayers).thenReturn(java.util.List.of(one, two)); loader.loadCommands(); + var command = commands.stream().filter(c -> c.getArgs().length > 0 && c.getArgs()[0].equals("AddActivations")).findFirst().orElseThrow(); + var sender = mock(CommandSender.class); command.execute(sender, new String[] {"AddActivations", "*", "3"}); + firstReceipt.complete(null); assertNull(reply.get()); secondReceipt.completeExceptionally(new IllegalStateException("fixture")); + assertNotNull(reply.get()); reply.get().run(); verify(sender).sendMessage(contains("could not be confirmed")); + verify(first).addActivationAsync(3); verify(second).addActivationAsync(3); + } finally { MCPerksMain.plugin = previous; } + } + @ParameterizedTest @ValueSource(strings = {"SetActivations", "RemoveActivations", "SetPerkActivations", "AddPerkActivations", "RemovePerkActivations"}) + void registeredMutationCommandsObserveTheirSpecificReceipts(String operation) throws Exception { + var plugin = mock(MCPerksMain.class); when(plugin.getOptions()).thenReturn(mock(com.bencodez.advancedcore.AdvancedCoreConfigOptions.class)); + when(plugin.getName()).thenReturn("MCPerks"); var commands = new java.util.ArrayList(); + when(plugin.getCommands()).thenReturn(commands); var perks = mock(com.bencodez.mcperks.perk.PerkHandler.class); + when(perks.getLoadedPerks()).thenReturn(new java.util.HashMap<>()); when(plugin.getPerkHandler()).thenReturn(perks); + var users = mock(com.bencodez.mcperks.userapi.UserManager.class); var user = mock(com.bencodez.mcperks.userapi.MCPerksUser.class); + when(plugin.getMcperksUserManager()).thenReturn(users); when(users.getMCPerksUser("example")).thenReturn(user); + var receipt = new CompletableFuture(); + when(user.setActivationsAsync(3)).thenReturn(receipt); when(user.removeActivationClampedAsync(3)).thenReturn(receipt); + when(user.setActivationsAsync("vote", 3)).thenReturn(receipt); when(user.addActivationAsync("vote", 3)).thenReturn(receipt); + when(user.removeActivationClampedAsync("vote", 3)).thenReturn(receipt); + var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); when(plugin.getBukkitScheduler()).thenReturn(scheduler); + var reply = new AtomicReference(); doAnswer(call -> { reply.set(call.getArgument(1)); return null; }).when(scheduler).runTask(eq(plugin), any(Runnable.class)); + var loader = spy(new CommandLoader(plugin)); doNothing().when(loader).loadAliases(); doNothing().when(loader).loadTabComplete(); + var core = mock(com.bencodez.advancedcore.command.CommandLoader.class); + when(core.getBasicAdminCommands(anyString())).thenReturn(new java.util.ArrayList<>()); when(core.getBasicCommands(anyString())).thenReturn(new java.util.ArrayList<>()); + var previous = MCPerksMain.plugin; MCPerksMain.plugin = plugin; + try (var coreStatic = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class)) { + coreStatic.when(com.bencodez.advancedcore.command.CommandLoader::getInstance).thenReturn(core); loader.loadCommands(); + var command = commands.stream().filter(c -> c.getArgs().length > 0 && c.getArgs()[0].equals(operation)).findFirst().orElseThrow(); + var sender = mock(CommandSender.class); + String[] args = operation.contains("Perk") ? new String[] {operation, "example", "vote", "3"} : new String[] {operation, "example", "3"}; + command.execute(sender, args); assertNull(reply.get()); + switch (operation) { + case "SetActivations" -> verify(user).setActivationsAsync(3); + case "RemoveActivations" -> verify(user).removeActivationClampedAsync(3); + case "SetPerkActivations" -> verify(user).setActivationsAsync("vote", 3); + case "AddPerkActivations" -> verify(user).addActivationAsync("vote", 3); + case "RemovePerkActivations" -> verify(user).removeActivationClampedAsync("vote", 3); + default -> fail("unexpected command"); + } + receipt.complete(null); assertNotNull(reply.get()); verifyNoInteractions(sender); reply.get().run(); verify(sender).sendMessage(anyString()); + } finally { MCPerksMain.plugin = previous; } + } + @ParameterizedTest @ValueSource(booleans = {true, false}) + void registeredAddCommandObservesTheAwardReceipt(boolean failure) throws Exception { + var plugin = mock(MCPerksMain.class); var options = mock(com.bencodez.advancedcore.AdvancedCoreConfigOptions.class); + when(plugin.getOptions()).thenReturn(options); when(plugin.getName()).thenReturn("MCPerks"); + var commands = new java.util.ArrayList(); when(plugin.getCommands()).thenReturn(commands); + var perks = mock(com.bencodez.mcperks.perk.PerkHandler.class); when(perks.getLoadedPerks()).thenReturn(new java.util.HashMap<>()); when(plugin.getPerkHandler()).thenReturn(perks); + var users = mock(com.bencodez.mcperks.userapi.UserManager.class); var user = mock(com.bencodez.mcperks.userapi.MCPerksUser.class); + when(plugin.getMcperksUserManager()).thenReturn(users); when(users.getMCPerksUser("example")).thenReturn(user); + var receipt = new CompletableFuture(); when(user.addActivationAsync(3)).thenReturn(receipt); + var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class)); var reply = new AtomicReference(); + doAnswer(call -> { reply.set(call.getArgument(1)); return null; }).when(scheduler).runTask(eq(plugin), any(Runnable.class)); + var loader = spy(new CommandLoader(plugin)); doNothing().when(loader).loadAliases(); doNothing().when(loader).loadTabComplete(); + var core = mock(com.bencodez.advancedcore.command.CommandLoader.class); + when(core.getBasicAdminCommands(anyString())).thenReturn(new java.util.ArrayList<>()); + when(core.getBasicCommands(anyString())).thenReturn(new java.util.ArrayList<>()); + var previous = MCPerksMain.plugin; MCPerksMain.plugin = plugin; + try (var coreStatic = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class)) { + coreStatic.when(com.bencodez.advancedcore.command.CommandLoader::getInstance).thenReturn(core); + loader.loadCommands(); + var command = commands.stream().filter(c -> c.getArgs().length > 0 && c.getArgs()[0].equals("AddActivations")).findFirst().orElseThrow(); + var sender = mock(CommandSender.class); command.execute(sender, new String[] {"AddActivations", "example", "3"}); + verify(user).addActivationAsync(3); verify(user, never()).addActivation(3); assertNull(reply.get()); + if (failure) receipt.completeExceptionally(new IllegalStateException("private fixture detail")); else receipt.complete(null); + assertNotNull(reply.get()); verifyNoInteractions(sender); reply.get().run(); + verify(sender).sendMessage(contains(failure ? "could not be confirmed" : "Added activations")); + } finally { MCPerksMain.plugin = previous; } + } + @ParameterizedTest @ValueSource(booleans = {true, false}) + void commandResponseWaitsForReceiptAndDoesNotReportFailureAsSuccess(boolean failure) throws Exception { + var plugin = mock(MCPerksMain.class); var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); when(plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class)); + var reply = new AtomicReference(); + doAnswer(call -> { reply.set(call.getArgument(1)); return null; }).when(scheduler).runTask(eq(plugin), any(Runnable.class)); + var sender = mock(CommandSender.class); var receipt = new CompletableFuture(); + var method = CommandLoader.class.getDeclaredMethod("reportActivationResult", CommandSender.class, java.util.concurrent.CompletionStage.class, String.class); + method.setAccessible(true); method.invoke(new CommandLoader(plugin), sender, receipt, "&cUpdated activations"); + assertNull(reply.get()); verifyNoInteractions(sender); + if (failure) receipt.completeExceptionally(new IllegalStateException("private fixture detail")); else receipt.complete(null); + assertNotNull(reply.get()); verifyNoInteractions(sender); reply.get().run(); + verify(sender).sendMessage(contains(failure ? "could not be confirmed" : "Updated activations")); + verify(sender, never()).sendMessage(contains(failure ? "Updated activations" : "could not be confirmed")); + verify(sender, never()).sendMessage(contains("private fixture detail")); + } +} diff --git a/MCPerks/src/test/java/com/bencodez/mcperks/tests/commands/ReloadCommandTest.java b/MCPerks/src/test/java/com/bencodez/mcperks/tests/commands/ReloadCommandTest.java new file mode 100644 index 0000000..6e675ae --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/commands/ReloadCommandTest.java @@ -0,0 +1,63 @@ +package com.bencodez.mcperks.tests.commands; +import com.bencodez.mcperks.commands.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.util.ArrayList; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.bukkit.plugin.PluginDescriptionFile; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import com.bencodez.advancedcore.api.command.CommandHandler; +import com.bencodez.mcperks.MCPerksMain; +import com.bencodez.mcperks.scheduling.OwnerTasks; + +class ReloadCommandTest { + @ParameterizedTest @ValueSource(booleans = {true, false}) + void actualRegisteredReloadReportsOnlyCompletedOutcome(boolean fails) throws Exception { + var plugin = mock(MCPerksMain.class); + var commands = new ArrayList(); when(plugin.getCommands()).thenReturn(commands); + when(plugin.getOptions()).thenReturn(mock(com.bencodez.advancedcore.AdvancedCoreConfigOptions.class)); + when(plugin.getName()).thenReturn("MCPerks"); + when(plugin.getDescription()).thenReturn(new PluginDescriptionFile("MCPerks", "test", "fixture.Main")); + var completion = new CompletableFuture(); when(plugin.reloadAsync()).thenReturn(completion); + var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + var reply = new AtomicReference(); + doAnswer(i -> { reply.set(i.getArgument(1)); return null; }).when(scheduler).runTask(eq(plugin), any(Runnable.class)); + var loader = new CommandLoader(plugin); + var register = CommandLoader.class.getDeclaredMethod("loadReloadCommand"); register.setAccessible(true); register.invoke(loader); + assertEquals(1, commands.size()); assertEquals("MCPerks.Reload", commands.getFirst().getPerm()); + var sender = mock(CommandSender.class); + commands.getFirst().execute(sender, new String[] {"Reload"}); + verifyNoInteractions(sender); assertNull(reply.get()); + if (fails) completion.completeExceptionally(new IllegalStateException("private fixture detail")); else completion.complete(null); + verifyNoInteractions(sender); assertNotNull(reply.get()); reply.get().run(); + verify(sender).sendMessage(contains(fails ? "reload failed" : "reloaded!")); + verify(sender, never()).sendMessage(contains(fails ? "reloaded!" : "reload failed")); + verify(sender, never()).sendMessage(contains("private fixture detail")); + } + + @ParameterizedTest @ValueSource(booleans = {true, false}) + void playerReplyUsesItsOwnerAndDropsDisconnectedRecipients(boolean online) throws Exception { + var plugin = mock(MCPerksMain.class); + var commands = new ArrayList(); when(plugin.getCommands()).thenReturn(commands); + when(plugin.getOptions()).thenReturn(mock(com.bencodez.advancedcore.AdvancedCoreConfigOptions.class)); + when(plugin.getName()).thenReturn("MCPerks"); + when(plugin.getDescription()).thenReturn(new PluginDescriptionFile("MCPerks", "test", "fixture.Main")); + var completion = new CompletableFuture(); when(plugin.reloadAsync()).thenReturn(completion); + var player = mock(Player.class); when(player.isOnline()).thenReturn(online); + var loader = new CommandLoader(plugin); + var register = CommandLoader.class.getDeclaredMethod("loadReloadCommand"); register.setAccessible(true); register.invoke(loader); + var action = new AtomicReference(); + try (var owners = mockStatic(OwnerTasks.class)) { + owners.when(() -> OwnerTasks.run(eq(plugin), eq(player), any(), any())).thenAnswer(i -> { action.set(i.getArgument(2)); return null; }); + commands.getFirst().execute(player, new String[] {"Reload"}); completion.complete(null); + verify(player, never()).sendMessage(anyString()); assertNotNull(action.get()); action.get().run(); + verify(player, times(online ? 1 : 0)).sendMessage(anyString()); + } + } +} diff --git a/MCPerks/src/test/java/com/bencodez/mcperks/tests/data/FeatureFileOwnershipTest.java b/MCPerks/src/test/java/com/bencodez/mcperks/tests/data/FeatureFileOwnershipTest.java new file mode 100644 index 0000000..7477192 --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/data/FeatureFileOwnershipTest.java @@ -0,0 +1,46 @@ +package com.bencodez.mcperks.tests.data; +import static com.bencodez.mcperks.tests.TestAccess.*; +import com.bencodez.mcperks.data.*; +import static org.junit.jupiter.api.Assertions.*; +import java.net.URLClassLoader; +import java.nio.file.*; +import java.util.concurrent.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +class FeatureFileOwnershipTest { + @TempDir Path root; + @Test void freshClassloadersCannotReadOrPublishUntilPredecessorReleasesItsOsLock() throws Exception { + var url = FeatureFileOwnership.class.getProtectionDomain().getCodeSource().getLocation(); + try (var first = new URLClassLoader(new java.net.URL[] {url}, ClassLoader.getPlatformClassLoader()); + var second = new URLClassLoader(new java.net.URL[] {url}, ClassLoader.getPlatformClassLoader())) { + var a = first.loadClass(FeatureFileOwnership.class.getName()); var b = second.loadClass(FeatureFileOwnership.class.getName()); + assertNotSame(a,b); Path file=root.resolve("balances.dat"); + var old=a.getConstructor(Path.class).newInstance(file); var next=b.getConstructor(Path.class).newInstance(file); + a.getMethod("awaitPredecessor").invoke(old); + var entered=new CountDownLatch(1); + var successor=CompletableFuture.runAsync(() -> { + entered.countDown(); + try { b.getMethod("awaitPredecessor").invoke(next); assertEquals("old-drained",Files.readString(file)); Files.writeString(file,"successor-acknowledged"); } + catch(Exception error) { throw new CompletionException(error); } + }); + try { + assertTrue(entered.await(5,TimeUnit.SECONDS)); + assertThrows(TimeoutException.class,()->successor.get(100,TimeUnit.MILLISECONDS)); + assertFalse(Files.exists(file)); + Files.writeString(file,"old-drained"); a.getMethod("drained").invoke(old); + successor.get(5,TimeUnit.SECONDS); assertEquals("successor-acknowledged",Files.readString(file)); + a.getMethod("drained").invoke(old); assertEquals("successor-acknowledged",Files.readString(file)); + } finally { a.getMethod("drained").invoke(old); b.getMethod("drained").invoke(next); } + } + } + @Test void lockGuardSymlinkIsRejectedWithoutReadingItsTarget() throws Exception { + Path file=root.resolve("features.dat"), target=root.resolve("other"); Files.writeString(target,"untouched"); + Files.createSymbolicLink(root.resolve("features.dat.lock"),target); + var owner=new FeatureFileOwnership(file); + try { assertThrows(java.io.UncheckedIOException.class,owner::awaitPredecessor); assertEquals("untouched",Files.readString(target)); } + finally { owner.drained(); } + } + + + +} diff --git a/MCPerks/src/test/java/com/bencodez/mcperks/tests/data/OrderedServerDataFileTest.java b/MCPerks/src/test/java/com/bencodez/mcperks/tests/data/OrderedServerDataFileTest.java new file mode 100644 index 0000000..ff181ee --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/data/OrderedServerDataFileTest.java @@ -0,0 +1,1148 @@ +package com.bencodez.mcperks.tests.data; +import static com.bencodez.mcperks.tests.TestAccess.*; +import com.bencodez.mcperks.data.*; + +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.*; +import java.util.concurrent.atomic.*; +import java.util.logging.Logger; +import org.bukkit.Bukkit; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import com.bencodez.advancedcore.api.time.TimeType; +import com.bencodez.advancedcore.data.ServerData.TimeChangeTransitionState; +import com.bencodez.mcperks.MCPerksMain; +import com.bencodez.mcperks.perk.Perk; + +class OrderedServerDataFileTest { + @TempDir Path directory; + private MCPerksMain plugin; + private com.bencodez.advancedcore.data.ServerData original; + private ServerData runtime; + private OrderedServerDataFile ordered; + private OrderedStorage state; + private Perk perk; + private final AtomicReference publication = new AtomicReference<>(DurableFeatureFiles::publish); + private final ExecutorService calendar = Executors.newSingleThreadExecutor(task -> { + Thread thread = new Thread(task, "test-calendar"); thread.setDaemon(true); return thread; + }); + private static T await(CompletableFuture future) throws Exception { return future.get(5, TimeUnit.SECONDS); } + @BeforeEach void setup() throws Exception { + if (Bukkit.getServer() == null) { + var server = mock(org.bukkit.Server.class); when(server.getLogger()).thenReturn(Logger.getLogger("test")); Bukkit.setServer(server); + } + plugin = mock(MCPerksMain.class); when(plugin.getDataFolder()).thenReturn(directory.toFile()); + when(plugin.getLogger()).thenReturn(Logger.getLogger("test")); + original = mock(com.bencodez.advancedcore.data.ServerData.class); + var live = spy(new YamlConfiguration()); live.set("PrevDay", 8); + when(original.getData()).thenReturn(live); when(original.getdFile()).thenReturn(directory.resolve("ServerData.yml").toFile()); + state = new OrderedStorage(directory.resolve("features.dat")); await(state.ready()); when(plugin.getStorage()).thenReturn(state); + runtime = newServerData(plugin, (staged, file) -> { + assertFalse(Thread.holdsLock(runtime), "file I/O must not own the runtime monitor"); + publication.get().publish(staged, file); + }); + when(plugin.getMcperksServerData()).thenReturn(runtime); + try (var constructors = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) { + ordered = new OrderedServerDataFile(plugin, original, runtime); + } + when(plugin.getServerDataFile()).thenReturn(ordered); + perk = mock(Perk.class); when(perk.getPerk()).thenReturn("legacy"); + // All live serialization and mutations must keep their existing owner. Worker rebasing + // uses freshly parsed private YAML; it cannot evade this assertion with a shared cache. + doAnswer(call -> { assertTrue(Thread.holdsLock(runtime)); return call.callRealMethod(); }).when(live).saveToString(); + doAnswer(call -> { assertTrue(Thread.holdsLock(runtime)); return call.callRealMethod(); }).when(live).set(anyString(), any()); + await(runtime.initialize(state)); + } + @AfterEach void close() { state.close(); calendar.shutdownNow(); } + private CompletableFuture begin() { + return CompletableFuture.supplyAsync(() -> ordered.beginTimeChangeTransition(TimeType.DAY, "2026-10-09", "9"), calendar); + } + private CompletableFuture complete(TimeChangeTransitionState transition) { + return CompletableFuture.runAsync(() -> ordered.completeTimeChangeTransition(transition), calendar); + } + private YamlConfiguration disk() { return YamlConfiguration.loadConfiguration(directory.resolve("ServerData.yml").toFile()); } + + @Test void postMoveFailureCannotBeOverwrittenByLaterFinalSnapshot() throws Exception { + var first = new AtomicBoolean(true); + publication.set((staged, file) -> { + DurableFeatureFiles.publish(staged, file); + if (first.getAndSet(false)) throw new IOException("synthetic failure after destination replacement"); + }); + var uncertain = runtime.confirmedFinalMutation(data -> data.set("Inventory.pending", "retained")); + assertThrows(ExecutionException.class, () -> await(uncertain)); + assertEquals("retained", disk().getString("Inventory.pending")); + var later = runtime.confirmedFinalMutation(data -> data.set("Timestamp", 42)); + try { + await(later); + assertEquals("retained", disk().getString("Inventory.pending"), "later publication must retain a replaced predecessor"); + assertEquals(42, disk().getInt("Timestamp")); + } catch (ExecutionException fenced) { + assertEquals("retained", disk().getString("Inventory.pending"), "an uncertain predecessor must remain fenced"); + } + } + @Test void uncertainSnapshotStaysFencedUntilExactReplacementCanBeSettled() throws Exception { + var first = new AtomicBoolean(true); var file = directory.resolve("ServerData.yml"); + publication.set((staged, destination) -> { + DurableFeatureFiles.publish(staged, destination); + if (first.getAndSet(false)) throw new IOException("synthetic post-move failure"); + }); + assertThrows(ExecutionException.class, () -> await(runtime.confirmedFinalMutation(data -> data.set("Inventory.pending", "retained")))); + byte[] replacement = Files.readAllBytes(file); + Files.writeString(file, "External: retained\n"); + assertThrows(ExecutionException.class, () -> await(runtime.confirmedFinalMutation(data -> data.set("Timestamp", 42)))); + assertEquals("retained", disk().getString("External")); assertFalse(disk().contains("Timestamp")); + Files.write(file, replacement); + await(runtime.confirmedFinalMutation(data -> data.set("Timestamp", 42))); + assertEquals("retained", disk().getString("Inventory.pending")); assertEquals(42, disk().getInt("Timestamp")); + } + @Test void postMoveSettlementPreservesLaterExplicitEditAndUnrelatedPredecessor() throws Exception { + var first = new AtomicBoolean(true); + publication.set((staged, file) -> { + DurableFeatureFiles.publish(staged, file); + if (first.getAndSet(false)) throw new IOException("synthetic failure after replacement"); + }); + var pending = runtime.confirmedFinalMutation(data -> { data.set("Inventory.first", "before"); data.set("Inventory.sibling", "keep"); }); + assertThrows(ExecutionException.class, () -> await(pending)); + await(runtime.confirmedFinalMutation(data -> { data.set("Inventory.first", "later"); data.set("Timestamp", 77); })); + assertEquals("later", disk().getString("Inventory.first")); + assertEquals("keep", disk().getString("Inventory.sibling")); assertEquals(77, disk().getInt("Timestamp")); + } + @Test void nativePublicationMonitorIsTheActualBackingOwner() { + assertSame(runtime, ordered.publicationMonitor()); + } + + @Test void ordinarySnapshotDoesNotRewriteUnchangedLiveLongSubtrees() throws Exception { + var live = original.getData(); + synchronized (runtime) { + live.set("OfflinePerk.test-owner.legacy.Experiaton", -1L); + live.set("PerkHistory.OCTOBER", List.of("unchanged")); + } + var section = live.getConfigurationSection("OfflinePerk"); + clearInvocations(live); + await(runtime.confirmedFinalMutation(data -> {})); + assertSame(section, live.getConfigurationSection("OfflinePerk"), + "a YAML numeric representation change is not an owner mutation"); + verify(live, never()).set(eq("OfflinePerk"), isNull()); + assertEquals(-1L, live.getLong("OfflinePerk.test-owner.legacy.Experiaton")); + assertEquals(-1L, disk().getLong("OfflinePerk.test-owner.legacy.Experiaton")); + } + + @Test void activationAdmissionPublishesBeforeGameplayOnOwnerScheduler() throws Exception { + var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); call.getArgument(1, Runnable.class).run(); return null; }) + .when(scheduler).runTask(eq(plugin), any(Runnable.class)); + var gameplay = new AtomicInteger(); + await(runtime.confirmedActivationBeforeGameplay( + Map.of("admission.CoolDown", 1234L), + () -> CompletableFuture.completedFuture(null), + gameplay::incrementAndGet, null)); + assertEquals(1, gameplay.get()); + assertEquals(1234, disk().getLong("admission.CoolDown")); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void retiringActivationCallbacksCancelsOnlyUninvokedOwnerPhases(boolean gameplayPhase) throws Exception { + var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + var tasks = new LinkedBlockingQueue(); + doAnswer(call -> { tasks.add(call.getArgument(1)); return null; }) + .when(scheduler).runTask(eq(plugin), any(Runnable.class)); + var calls = new AtomicInteger(); var effects = new AtomicInteger(); + var result = runtime.confirmedActivationBeforeGameplay(Map.of(), () -> { + calls.incrementAndGet(); return CompletableFuture.completedFuture(null); + }, effects::incrementAndGet, null); + Runnable queued = tasks.poll(5, TimeUnit.SECONDS); assertNotNull(queued); + if (gameplayPhase) { queued.run(); queued = tasks.poll(5, TimeUnit.SECONDS); assertNotNull(queued); } + runtime.retireActivationCallbacks(); + assertThrows(ExecutionException.class, () -> await(result)); + queued.run(); queued.run(); + assertEquals(gameplayPhase ? 1 : 0, calls.get()); assertEquals(0, effects.get()); + assertThrows(ExecutionException.class, () -> await(runtime.confirmedActivationBeforeGameplay( + Map.of(), () -> fail("retired admission"), () -> fail("retired gameplay"), null))); + await(runtime.confirmedFinalMutation(data -> data.set("final-save", 19))); + assertEquals(19, disk().getInt("final-save")); + } + + @Test void retiringDispatchDoesNotExpireAnAlreadyInvokedUserWrite() throws Exception { + var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + var tasks = new LinkedBlockingQueue(); + doAnswer(call -> { tasks.add(call.getArgument(1)); return null; }) + .when(scheduler).runTask(eq(plugin), any(Runnable.class)); + var stored = new CompletableFuture(); var effects = new AtomicInteger(); + var result = runtime.confirmedActivationBeforeGameplay(Map.of(), () -> stored, effects::incrementAndGet, null); + var caller = tasks.poll(5, TimeUnit.SECONDS); assertNotNull(caller); caller.run(); + runtime.retireActivationCallbacks(); assertFalse(result.isDone()); + stored.complete(null); + assertThrows(ExecutionException.class, () -> await(result)); + assertEquals(0, effects.get()); assertTrue(tasks.isEmpty()); + } + + @Test void failedActivationAdmissionDoesNotRunGameplay() throws Exception { + var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; }) + .when(scheduler).runTask(eq(plugin), any(Runnable.class)); + publication.set((staged, file) -> { throw new IOException("activation publication rejected"); }); + var gameplay = new AtomicInteger(); + var result = runtime.confirmedActivationBeforeGameplay( + Map.of("rejected.CoolDown", 5678L), + () -> CompletableFuture.completedFuture(null), + gameplay::incrementAndGet, null); + assertThrows(ExecutionException.class, () -> await(result)); + assertEquals(0, gameplay.get()); + assertEquals(0, disk().getLong("rejected.CoolDown")); + assertEquals(0, runtime.getServerCooldown("rejected")); + } + @Test void oversizedAdmissionFailsBeforeAnyGameplayOrUserFlush() throws Exception { + synchronized (runtime) { runtime.getData().set("RequiredLargePayload", "x".repeat(4_000_001)); } + var gameplay = new AtomicInteger(); var caller = new AtomicInteger(); + var result = runtime.confirmedActivationBeforeGameplay(Map.of("tooLarge.CoolDown", 55L), + () -> { caller.incrementAndGet(); return CompletableFuture.completedFuture(null); }, gameplay::incrementAndGet, null); + assertThrows(ExecutionException.class, () -> await(result)); + assertEquals(0, gameplay.get()); assertEquals(0, caller.get()); + assertEquals(0, disk().getLong("tooLarge.CoolDown")); + assertEquals(0, runtime.getServerCooldown("tooLarge")); + } + @Test void userStorageFailureRetainsPublishedAdmissionButNeverSchedulesGameplay() throws Exception { + var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; }) + .when(scheduler).runTask(eq(plugin), any(Runnable.class)); + var gameplay = new AtomicInteger(); + var result = runtime.confirmedActivationBeforeGameplay(Map.of("userFailure.CoolDown", 66L), + () -> CompletableFuture.failedFuture(new IOException("user flush failed")), gameplay::incrementAndGet, null); + assertThrows(ExecutionException.class, () -> await(result)); + assertEquals(0, gameplay.get()); assertEquals(66, disk().getLong("userFailure.CoolDown")); + verify(scheduler, times(1)).runTask(eq(plugin), any(Runnable.class)); + } + private void immediateOwnerScheduler() { + var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; }) + .when(scheduler).runTask(eq(plugin), any(Runnable.class)); + } + @Test void failedStagedAdmissionCannotLeakIntoQueuedOrdinarySnapshot() throws Exception { + immediateOwnerScheduler(); CompletableFuture admission; + var caller = new AtomicInteger(); + try (Gate gate = new Gate(true)) { + admission = runtime.confirmedActivationBeforeGameplay(Map.of("staged.CoolDown", 50L, "Experation.staged", 60L), + () -> { caller.incrementAndGet(); return CompletableFuture.completedFuture(null); }, () -> fail("gameplay"), null); + gate.entered(); assertEquals(0, runtime.getServerCooldown("staged")); + synchronized (runtime) { assertNull(runtime.getData().get("Experation.staged")); } + runtime.saveData(); + } + assertThrows(ExecutionException.class, () -> await(admission)); await(state.runtimeStorage(() -> null)); + assertEquals(0, caller.get()); assertNull(disk().get("staged.CoolDown")); assertNull(disk().get("Experation.staged")); + assertEquals(0, runtime.getServerCooldown("staged")); + } + @Test void confirmedAdmissionRebasesCalendarAndOrdinarySnapshotsWithoutLosingNewerSameValueEdit() throws Exception { + immediateOwnerScheduler(); CompletableFuture admission; CompletableFuture calendarWrite; + try (Gate gate = new Gate(false)) { + admission = runtime.confirmedActivationBeforeGameplay(Map.of("staged.CoolDown", 50L, "Experation.staged", 60L), + () -> CompletableFuture.completedFuture(null), () -> {}, null); + gate.entered(); + synchronized (runtime) { assertNull(runtime.getData().get("Experation.staged")); } + // Explicitly setting the old zero is still a newer update, not an absent edit. + ordered.setData("staged.CoolDown", 0L); + ordered.setData("TimedPermissions.new-owner", List.of("permission.test%line%12345")); + calendarWrite = begin(); runtime.saveData(); + } + await(admission); await(calendarWrite); await(state.runtimeStorage(() -> null)); + assertEquals(0, disk().getLong("staged.CoolDown")); assertEquals(60, disk().getLong("Experation.staged")); + assertEquals(0, runtime.getServerCooldown("staged")); + synchronized (runtime) { assertEquals(60, runtime.getData().getLong("Experation.staged")); } + assertTrue(disk().getBoolean("TimeTransitions.DAY.Pending")); + assertEquals(List.of("permission.test%line%12345"), disk().getStringList("TimedPermissions.new-owner")); + } + private CompletableFuture ownerSnapshot(long expiration) throws Exception { + // Waiting for this outer future checks monitor responsiveness, without waiting for storage. + return await(CompletableFuture.supplyAsync(() -> runtime.confirmedActivation(() -> { + assertNotEquals("MCPerks-feature-store", Thread.currentThread().getName()); + runtime.setPerkExperation(perk, expiration); runtime.setServerCooldown("legacy", expiration + 1); + }))); + } + @Test void reloadReadsOnTheOrderedWorkerWithoutHoldingTheLiveRuntimeMonitor() throws Exception { + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + var pending = state.runtimeStorage(() -> { + entered.countDown(); + try { if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("worker release timed out"); } + catch (InterruptedException error) { throw new RuntimeException(error); } + return null; + }); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + Files.writeString(directory.resolve("ServerData.yml"), "PrevDay: 12\n"); + try { + ordered.reloadData(); + assertFalse(runtime.reloadCompletion().isDone()); + synchronized (runtime) { assertEquals(8, ordered.getPrevDay()); } + verify(original, never()).reloadData(); + } finally { release.countDown(); } + await(pending); await(runtime.reloadCompletion()); + assertEquals(12, ordered.getPrevDay()); + } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"cancel", "success", "failure"}) + void reloadObservationCannotCompleteOrCancelTheAcceptedDiskRead(String mutation) throws Exception { + var release = new CountDownLatch(1); + state.runtimeStorage(() -> { + try { if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("worker release timed out"); } + catch (InterruptedException error) { throw new RuntimeException(error); } + return null; + }); + Files.writeString(directory.resolve("ServerData.yml"), "PrevDay: 12\n"); + try { + var observation = runtime.reloadDataAsync(); + switch (mutation) { + case "cancel" -> observation.cancel(false); + case "success" -> observation.complete(null); + case "failure" -> observation.completeExceptionally(new IllegalStateException("caller observation")); + default -> fail("unexpected mutation"); + } + assertFalse(runtime.reloadCompletion().isDone()); assertEquals(8, ordered.getPrevDay()); + } finally { release.countDown(); } + await(runtime.reloadCompletion()); assertEquals(12, ordered.getPrevDay()); + } + @Test void malformedReloadRetainsLiveDataAndAllowsAValidRetry() throws Exception { + Files.writeString(directory.resolve("ServerData.yml"), "broken: [\n"); + ordered.reloadData(); + assertThrows(ExecutionException.class, () -> await(runtime.reloadCompletion())); + assertEquals(8, ordered.getPrevDay()); + Files.writeString(directory.resolve("ServerData.yml"), "PrevDay: 12\n"); + ordered.reloadData(); await(runtime.reloadCompletion()); assertEquals(12, ordered.getPrevDay()); + } + @Test void liveMutationDuringAnAdmittedReloadCannotBeOverwrittenByTheEarlierDiskRead() throws Exception { + var release = new CountDownLatch(1); + state.runtimeStorage(() -> { + try { if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("worker release timed out"); } + catch (InterruptedException error) { throw new RuntimeException(error); } + return null; + }); + Files.writeString(directory.resolve("ServerData.yml"), "PrevDay: 12\n"); + try { + ordered.reloadData(); + assertFalse(runtime.reloadCompletion().isDone()); + ordered.setPrevDay(9); + assertEquals(9, ordered.getPrevDay()); + } finally { release.countDown(); } + await(runtime.reloadCompletion()); assertEquals(9, ordered.getPrevDay()); + } + private final class Gate implements AutoCloseable { + private final CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + private final AtomicBoolean first = new AtomicBoolean(true); + private Gate(boolean fail) { this(fail, false); } + private Gate(boolean fail, boolean visible) { + publication.set((staged, file) -> { + if (first.compareAndSet(true, false)) { + entered.countDown(); + try { if (!release.await(5, TimeUnit.SECONDS)) throw new IOException("test publication timeout"); } + catch (InterruptedException error) { Thread.currentThread().interrupt(); throw new IOException(error); } + if (fail) { + if (visible) DurableFeatureFiles.publish(staged, file); + throw new IOException("calendar publication rejected"); + } + } + DurableFeatureFiles.publish(staged, file); + }); + } + private void entered() throws Exception { assertTrue(entered.await(5, TimeUnit.SECONDS)); } + @Override public void close() { release.countDown(); } + } + @Test void confirmedBeginAndCompletionRebaseLaterPerkSnapshotsWithoutBlockingOwner() throws Exception { + CompletableFuture beginning; CompletableFuture activation; + try (Gate gate = new Gate(false)) { + beginning = begin(); gate.entered(); assertFalse(beginning.isDone()); + activation = ownerSnapshot(1234); assertFalse(activation.isDone()); assertEquals(1235, runtime.getServerCooldown("legacy")); + synchronized (runtime) { assertFalse(runtime.getData().getBoolean("TimeTransitions.DAY.Pending")); } + ordered.reloadData(); verify(original, never()).reloadData(); + } + var transition = await(beginning); await(activation); + assertTrue(disk().getBoolean("TimeTransitions.DAY.Pending")); assertEquals(1234, disk().getLong("Experation.legacy")); + CompletableFuture completion; + try (Gate gate = new Gate(false)) { + completion = complete(transition); gate.entered(); activation = ownerSnapshot(5678); + assertEquals(8, ordered.getPrevDay()); + synchronized (runtime) { assertTrue(runtime.getData().getBoolean("TimeTransitions.DAY.Pending")); } + } + await(completion); await(activation); + assertEquals(9, ordered.getPrevDay()); assertEquals(9, disk().getInt("PrevDay")); + assertFalse(disk().getBoolean("TimeTransitions.DAY.Pending")); assertEquals(5678, disk().getLong("Experation.legacy")); + ordered.reloadData(); await(runtime.reloadCompletion()); verify(original, never()).reloadData(); + verify(original, never()).saveData(); + verify((YamlConfiguration) original.getData(), never()).save(any(java.io.File.class)); + } + @Test void visibleCompletedCalendarTransitionSettlesWithoutReopeningOnFailureHandler() throws Exception { + var transition = await(begin()); var first = new AtomicBoolean(true); + publication.set((staged, file) -> { + DurableFeatureFiles.publish(staged, file); + if (first.getAndSet(false)) throw new IOException("after replacement"); + }); + await(complete(transition)); + assertEquals(9, disk().getInt("PrevDay")); + assertFalse(disk().getBoolean("TimeTransitions.DAY.Pending")); + assertEquals(9, ordered.getPrevDay()); + assertFalse(ordered.isPendingTimeChangeTransition(transition)); + } + @Test void calendarFailureRetryCannotReopenSettledCompletion() throws Exception { + var transition = await(begin()); var first = new AtomicBoolean(true); var replacement = new AtomicReference(); + var file = directory.resolve("ServerData.yml"); + publication.set((staged, destination) -> { + DurableFeatureFiles.publish(staged, destination); + if (first.getAndSet(false)) { + replacement.set(Files.readAllBytes(destination)); + Files.writeString(destination, "unverified: true\n"); + throw new IOException("after replacement with unresolved destination"); + } + }); + assertThrows(ExecutionException.class, () -> await(complete(transition))); + assertThrows(CompletionException.class, () -> ordered.failTimeChangeTransition(transition)); + synchronized (runtime) { + assertThrows(IllegalStateException.class, () -> ordered.beginTimeChangeTransition(TimeType.DAY, "2026-10-09", "9"), + "a pending transition cannot be readmitted while completion is uncertain"); + } + Files.write(file, replacement.get()); + ordered.failTimeChangeTransition(transition); + assertEquals(9, disk().getInt("PrevDay")); assertFalse(disk().getBoolean("TimeTransitions.DAY.Pending")); + } + @Test void visibleCalendarFailureSettlesBeforeQueuedFinalSnapshot() throws Exception { + CompletableFuture beginning; CompletableFuture later; + try (Gate gate = new Gate(true, true)) { + beginning = begin(); gate.entered(); later = ownerSnapshot(2468); + } + await(beginning); + await(later); + assertTrue(disk().getBoolean("TimeTransitions.DAY.Pending")); + assertEquals(2468, disk().getLong("Experation.legacy")); + } + @Test void failedBeginNeverLeaksIntoLiveOrLaterPerkSnapshotAndRetryIsDurable() throws Exception { + CompletableFuture beginning; CompletableFuture activation; + try (Gate gate = new Gate(true)) { beginning = begin(); gate.entered(); activation = ownerSnapshot(1234); } + assertThrows(ExecutionException.class, () -> await(beginning)); await(activation); + synchronized (runtime) { assertNull(runtime.getData().get("TimeTransitions.DAY.Id")); } + assertNull(disk().get("TimeTransitions.DAY.Id")); assertEquals(8, disk().getInt("PrevDay")); + assertEquals(1234, disk().getLong("Experation.legacy")); + var transition = await(begin()); ordered.failTimeChangeTransition(transition); ordered.completeTimeChangeTransition(transition); + assertEquals(9, disk().getInt("PrevDay")); assertFalse(disk().getBoolean("TimeTransitions.DAY.Pending")); + } + @Test void failedCompletionLeavesPendingMarkerInLiveAndLaterSnapshotForRetry() throws Exception { + var transition = await(begin()); CompletableFuture completion, activation; + try (Gate gate = new Gate(true)) { completion = complete(transition); gate.entered(); activation = ownerSnapshot(9876); } + assertThrows(ExecutionException.class, () -> await(completion)); await(activation); + assertEquals(8, ordered.getPrevDay()); assertTrue(ordered.isPendingTimeChangeTransition(transition)); + assertEquals(8, disk().getInt("PrevDay")); assertTrue(disk().getBoolean("TimeTransitions.DAY.Pending")); + assertEquals(9876, disk().getLong("Experation.legacy")); + await(complete(transition)); assertNull(ordered.getPendingTimeChangeTransition(TimeType.DAY)); + assertFalse(ordered.isPendingTimeChangeTransition(transition)); assertEquals(9, disk().getInt("PrevDay")); + } + @Test void laterExplicitMarkerEditWinsWhileConfirmedPendingFlagSurvivesItsSnapshot() throws Exception { + var transition = await(begin()); CompletableFuture completion, activation; + try (Gate gate = new Gate(false)) { + completion = complete(transition); gate.entered(); ordered.setPrevDay(8); activation = ownerSnapshot(5678); + } + await(completion); await(activation); + assertEquals(8, ordered.getPrevDay()); assertEquals(8, disk().getInt("PrevDay")); + assertFalse(disk().getBoolean("TimeTransitions.DAY.Pending")); assertEquals(5678, disk().getLong("Experation.legacy")); + } + @Test void shutdownAndSuccessorDrainAcceptedCalendarAndFinalPerkSnapshotBeforeReconciliation() throws Exception { + var transition = await(begin()); CompletableFuture completion, activation; + try (Gate gate = new Gate(false)) { + completion = complete(transition); gate.entered(); activation = ownerSnapshot(7654); + runtime.saveData(); state.close(); + try (OrderedStorage successor = new OrderedStorage(directory.resolve("features.dat"))) { + assertFalse(successor.ready().isDone()); gate.close(); await(completion); await(activation); await(successor.ready()); + assertThrows(CompletionException.class, () -> ordered.beginTimeChangeTransition(TimeType.WEEK, "2026-41", "41")); + synchronized (runtime) { assertNull(runtime.getData().get("TimeTransitions.WEEK.Id")); } + when(plugin.getStorage()).thenReturn(successor); + var stale = new YamlConfiguration(); stale.set("PrevDay", 7); when(original.getData()).thenReturn(stale); + var restored = new ServerData(plugin); await(restored.initialize(successor)); + assertEquals(7654, restored.getPerkExperation(perk)); assertEquals(9, stale.getInt("PrevDay")); + assertFalse(stale.getBoolean("TimeTransitions.DAY.Pending")); assertEquals(7654, disk().getLong("Experation.legacy")); + } + } + } + @Test void fullWorkerRejectsCalendarAdmissionWithoutChangingRuntimeAndRemainsBounded() throws Exception { + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + var blocked = state.runtimeStorage(() -> { + entered.countDown(); + try { if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("test timeout"); } + catch (InterruptedException error) { throw new CompletionException(error); } return null; + }); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); + var accepted = new ArrayList>(); + for (int i = 0; i < 256; i++) accepted.add(state.runtimeStorage(() -> null)); + assertThrows(ExecutionException.class, () -> await(begin())); + synchronized (runtime) { assertNull(runtime.getData().get("TimeTransitions.DAY.Id")); } + release.countDown(); await(blocked); for (var future : accepted) await(future); + await(begin()); assertTrue(disk().getBoolean("TimeTransitions.DAY.Pending")); + } finally { release.countDown(); } + } + @Test void weekMonthAndInvalidPendingCleanupShareTheSameDurablePrivateView() throws Exception { + var week = ordered.beginTimeChangeTransition(TimeType.WEEK, "2026-41", "41"); + ordered.completeTimeChangeTransition(week); + var month = ordered.beginTimeChangeTransition(TimeType.MONTH, "2026-10", "OCTOBER"); + CompletableFuture completion, activation; + try (Gate gate = new Gate(false)) { + completion = complete(month); gate.entered(); activation = ownerSnapshot(4567); + } + await(completion); await(activation); + assertEquals(41, disk().getInt("PrevWeek")); assertEquals("OCTOBER", disk().getString("Month")); + assertFalse(disk().getBoolean("TimeTransitions.WEEK.Pending")); assertFalse(disk().getBoolean("TimeTransitions.MONTH.Pending")); + ordered.setData("TimeTransitions.DAY.Id", "legacy-invalid"); ordered.setData("TimeTransitions.DAY.Period", "2026-10-09"); + ordered.setData("TimeTransitions.DAY.Marker", "invalid"); ordered.setData("TimeTransitions.DAY.Pending", true); + await(state.runtimeStorage(() -> null)); + CompletableFuture cleanup; + try (Gate gate = new Gate(true)) { + cleanup = CompletableFuture.supplyAsync(() -> ordered.getPendingTimeChangeTransition(TimeType.DAY), calendar); + gate.entered(); activation = ownerSnapshot(8910); + } + assertThrows(ExecutionException.class, () -> await(cleanup)); await(activation); + synchronized (runtime) { assertTrue(runtime.getData().getBoolean("TimeTransitions.DAY.Pending")); } + assertTrue(disk().getBoolean("TimeTransitions.DAY.Pending")); + assertNull(ordered.getPendingTimeChangeTransition(TimeType.DAY)); + assertFalse(disk().getBoolean("TimeTransitions.DAY.Pending")); assertEquals(8910, disk().getLong("Experation.legacy")); + } + @Test void unrelatedOptionalPayloadDoesNotPreventInitializedCalendarAndLegacySnapshots() throws Exception { + state.close(); Path broken = directory.resolve("broken-features.dat"); Files.writeString(broken, "invalid features"); + try (OrderedStorage unavailable = new OrderedStorage(broken)) { + await(unavailable.ready()); when(plugin.getStorage()).thenReturn(unavailable); + await(runtime.initialize(unavailable)); assertTrue(runtime.isReady()); + var transition = await(begin()); ordered.completeTimeChangeTransition(transition); await(ownerSnapshot(3456)); + assertEquals(9, disk().getInt("PrevDay")); assertEquals(3456, disk().getLong("Experation.legacy")); + assertEquals("invalid features", Files.readString(broken)); + } + } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void finalMutationReportsActualOrderedPublicationAndShutdownWaitsForBothStages(boolean fail) throws Exception { + var ownerField = MCPerksMain.class.getDeclaredField("storage"); ownerField.setAccessible(true); ownerField.set(plugin, state); + var preparationField = MCPerksMain.class.getDeclaredField("shutdownPreparation"); preparationField.setAccessible(true); + var preparation = new CompletableFuture(); preparationField.set(plugin, preparation); + doCallRealMethod().when(plugin).onShutdownComplete(any(java.util.concurrent.CompletionStage.class)); + doCallRealMethod().when(plugin).onShutdownComplete(); + byte[] previous = Files.readAllBytes(directory.resolve("ServerData.yml")); + CompletableFuture finalFiles; + try (Gate gate = new Gate(fail)) { + finalFiles = ordered.mutateFinalData(data -> { + assertTrue(Thread.holdsLock(runtime), "final YAML mutation belongs to runtime owner"); + data.set("LastUpdated", 2468L); + }).toCompletableFuture(); + gate.entered(); + plugin.onShutdownComplete(finalFiles); + assertFalse(finalFiles.isDone()); + assertArrayEquals(previous, Files.readAllBytes(directory.resolve("ServerData.yml"))); + // Work is still admissible after the lifecycle hook; close cannot discard + // either preparation continuations or the actual adapter publication. + var admitted = state.runtimeStorage(() -> null); + gate.close(); + if (fail) assertThrows(ExecutionException.class, () -> await(finalFiles)); + else await(finalFiles); + await(admitted); + await(state.runtimeStorage(() -> null)); // Final files alone cannot close preparation's owner. + if (fail) preparation.completeExceptionally(new IllegalStateException("preparation failed")); + else preparation.complete(null); + assertThrows(ExecutionException.class, () -> await(state.runtimeStorage(() -> null))); + } + if (fail) assertArrayEquals(previous, Files.readAllBytes(directory.resolve("ServerData.yml"))); + else { + assertEquals(2468L, disk().getLong("LastUpdated")); + assertTrue(Files.readString(directory.resolve("ServerData.yml")).startsWith("# MCPerks ordered ServerData v1")); + } + verify(original, never()).saveData(); + verify(original, never()).saveDataChecked(); + } + + @Test void completedPreparationCannotCloseOwnerBeforeDelayedFinalMutationIsAdmitted() throws Exception { + var ownerField = MCPerksMain.class.getDeclaredField("storage"); ownerField.setAccessible(true); ownerField.set(plugin, state); + var preparationField = MCPerksMain.class.getDeclaredField("shutdownPreparation"); preparationField.setAccessible(true); + preparationField.set(plugin, CompletableFuture.completedFuture(null)); + doCallRealMethod().when(plugin).onShutdownComplete(any(java.util.concurrent.CompletionStage.class)); + doCallRealMethod().when(plugin).onShutdownComplete(); + var permissionFiles = new CompletableFuture(); + plugin.onShutdownComplete(permissionFiles); + await(state.runtimeStorage(() -> null)); + // Native permission snapshot may be queued on AdvancedCore's shutdown worker + // after lifecycle cleanup has returned. It must still reach this exact owner. + var actual = ordered.mutateFinalData(data -> data.set("LastUpdated", 1357L)); + actual.whenComplete((ignored, failure) -> { + if (failure == null) permissionFiles.complete(null); + else permissionFiles.completeExceptionally(failure); + }); + await(permissionFiles); + assertEquals(1357L, disk().getLong("LastUpdated")); + try (OrderedStorage successor = new OrderedStorage(directory.resolve("features.dat"))) { + await(successor.ready()); + assertThrows(ExecutionException.class, () -> await(state.runtimeStorage(() -> null))); + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void replayOverflowPublicationCannotLeakOrDisappearThroughQueuedWholeFileSaves(boolean fail) throws Exception { + org.bukkit.configuration.serialization.ConfigurationSerialization.registerClass( + PersistedOverflowItem.class, "MCFinalOverflowTestItem"); + var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); when(plugin.isEnabled()).thenReturn(true); + var options = mock(com.bencodez.advancedcore.AdvancedCoreConfigOptions.class); + when(plugin.getOptions()).thenReturn(options); + var uuid = UUID.randomUUID(); var player = mock(org.bukkit.entity.Player.class); + var playerInventory = mock(org.bukkit.inventory.PlayerInventory.class); + when(player.getUniqueId()).thenReturn(uuid); when(player.isOnline()).thenReturn(true); + when(player.getInventory()).thenReturn(playerInventory); + var item = new PersistedOverflowItem(); + when(playerInventory.addItem(item)).thenReturn(new HashMap<>(Map.of(0, item))); + var ownerTask = new AtomicReference(); + doAnswer(call -> { ownerTask.set(call.getArgument(1)); return null; }) + .when(scheduler).runTask(eq(plugin), any(Runnable.class), eq(player)); + var inventory = new com.bencodez.advancedcore.api.item.FullInventoryHandler(plugin); + inventory.getLastMessageTime().put(uuid, System.currentTimeMillis()); + var timerEntered = new CountDownLatch(1); var releaseTimer = new CountDownLatch(1); + inventory.getTimer().execute(() -> { + timerEntered.countDown(); + try { releaseTimer.await(5, TimeUnit.SECONDS); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } + }); + assertTrue(timerEntered.await(2, TimeUnit.SECONDS)); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(player); + var receipt = inventory.giveItemAsync(player, item).toCompletableFuture(); ownerTask.get().run(); + assertFalse(receipt.isDone()); + CompletableFuture finalInventory, timestamp, permissions; + try (Gate gate = new Gate(fail)) { + finalInventory = inventory.shutdownAsync(Runnable::run).toCompletableFuture(); + gate.entered(); + synchronized (runtime) { + assertFalse(runtime.getData().contains("FullInventory." + uuid), + "pending overflow must remain outside live snapshots"); + } + timestamp = ordered.mutateFinalData(data -> data.set("LastUpdated", 2468L)).toCompletableFuture(); + permissions = ordered.mutateFinalData(data -> data.set("TimedPermissions.example", + List.of("example.use%line%12345"))).toCompletableFuture(); + runtime.saveData(); // Capture another ordinary snapshot before the first publication settles. + assertFalse(receipt.isDone()); + } + if (fail) assertThrows(ExecutionException.class, () -> await(finalInventory)); + else await(finalInventory); + await(timestamp); await(permissions); await(state.runtimeStorage(() -> null)); + assertEquals(!fail, receipt.isDone()); + assertEquals(!fail, inventory.getItems().containsKey(uuid)); + var recovered = disk(); + assertEquals(!fail, recovered.contains("FullInventory." + uuid)); + assertEquals(2468L, recovered.getLong("LastUpdated")); + assertEquals(List.of("example.use%line%12345"), recovered.getStringList("TimedPermissions.example")); + synchronized (runtime) { assertEquals(!fail, runtime.getData().contains("FullInventory." + uuid)); } + var successorFile = mock(com.bencodez.advancedcore.data.ServerData.class); + when(successorFile.getData()).thenReturn(recovered); + when(plugin.getServerDataFile()).thenReturn(successorFile); + var successor = new com.bencodez.advancedcore.api.item.FullInventoryHandler(plugin); + try { + assertEquals(!fail, successor.getItems().containsKey(uuid), + "restart cannot recover overflow whose replay receipt is still pending"); + if (!fail) assertEquals(1, successor.getItems().get(uuid).size()); + } finally { successor.getTimer().shutdownNow(); } + verify(playerInventory, times(1)).addItem(item); + } finally { + releaseTimer.countDown(); inventory.getTimer().shutdownNow(); + org.bukkit.configuration.serialization.ConfigurationSerialization.unregisterClass(PersistedOverflowItem.class); + } + } + + @Test void publishedFinalMutationCannotOverwriteALaterExplicitSectionEdit() throws Exception { + CompletableFuture finalInventory; + try (Gate gate = new Gate(false)) { + finalInventory = ordered.mutateFinalData(data -> data.set("FullInventory.old.Time", 123L)).toCompletableFuture(); + gate.entered(); + // Clearing an absent section is still an explicit later write. + ordered.setData("FullInventory", null); + ordered.setData("UnrelatedValue", "retained"); + } + await(finalInventory); await(state.runtimeStorage(() -> null)); + synchronized (runtime) { assertFalse(runtime.getData().contains("FullInventory")); } + assertFalse(disk().contains("FullInventory")); assertEquals("retained", disk().getString("UnrelatedValue")); + } + + @Test void laterConfirmedFinalMutationOfSameSectionRemainsTheLiveAndDiskOwner() throws Exception { + CompletableFuture first, second; + try (Gate gate = new Gate(false)) { + first = ordered.mutateFinalData(data -> data.set("TimedPermissions.example", List.of("first%line%123"))) + .toCompletableFuture(); + gate.entered(); + second = ordered.mutateFinalData(data -> data.set("TimedPermissions.example", List.of("second%line%456"))) + .toCompletableFuture(); + runtime.saveData(); + } + await(first); await(second); await(state.runtimeStorage(() -> null)); + synchronized (runtime) { assertEquals(List.of("second%line%456"), runtime.getData().getStringList("TimedPermissions.example")); } + assertEquals(List.of("second%line%456"), disk().getStringList("TimedPermissions.example")); + } + + @Test void laterFinalClearOfUnpublishedSectionRemainsAbsentInLiveAndDisk() throws Exception { + CompletableFuture first, cleared; + var calls = new AtomicInteger(); + try (Gate gate = new Gate(false)) { + first = ordered.mutateFinalData(data -> { + calls.incrementAndGet(); data.set("FullInventory.old.Time", 111_111_111_111L); + }).toCompletableFuture(); + gate.entered(); + synchronized (runtime) { assertFalse(runtime.getData().contains("FullInventory")); } + cleared = ordered.mutateFinalData(data -> { + calls.incrementAndGet(); data.set("FullInventory", null); + }).toCompletableFuture(); + runtime.saveData(); // An ordinary later snapshot must retain the acknowledged clear. + } + await(first); await(cleared); await(state.runtimeStorage(() -> null)); + assertEquals(2, calls.get(), "consumer mutations must never be replayed"); + synchronized (runtime) { assertFalse(runtime.getData().contains("FullInventory")); } + assertFalse(disk().contains("FullInventory")); + } + + @Test void laterSameBaselineNestedFinalWriteFencesAnUnpublishedMutation() throws Exception { + long originalTime = 111_111_111_111L; + synchronized (runtime) { runtime.getData().set("FullInventory.owner.Time", originalTime); } + CompletableFuture first, restored; + try (Gate gate = new Gate(false)) { + first = ordered.mutateFinalData(data -> data.getConfigurationSection("FullInventory.owner") + .set("Time", 222_222_222_222L)).toCompletableFuture(); + gate.entered(); + restored = ordered.mutateFinalData(data -> data.getConfigurationSection("FullInventory.owner") + .set("Time", originalTime)).toCompletableFuture(); + runtime.saveData(); + } + await(first); await(restored); await(state.runtimeStorage(() -> null)); + synchronized (runtime) { assertEquals(originalTime, runtime.getData().getLong("FullInventory.owner.Time")); } + assertEquals(originalTime, disk().getLong("FullInventory.owner.Time")); + } + + @Test void laterNestedFinalWriteRetainsAnUnpublishedDifferentSibling() throws Exception { + synchronized (runtime) { runtime.getData().set("FullInventory.seed.Time", 111_111_111_111L); } + CompletableFuture first, second; + try (Gate gate = new Gate(false)) { + first = ordered.mutateFinalData(data -> data.set("FullInventory.first.Time", 222_222_222_222L)) + .toCompletableFuture(); + gate.entered(); + second = ordered.mutateFinalData(data -> data.getConfigurationSection("FullInventory") + .createSection("second", Map.of("Time", 333_333_333_333L))).toCompletableFuture(); + runtime.saveData(); + } + await(first); await(second); await(state.runtimeStorage(() -> null)); + synchronized (runtime) { + assertEquals(111_111_111_111L, runtime.getData().getLong("FullInventory.seed.Time")); + assertEquals(222_222_222_222L, runtime.getData().getLong("FullInventory.first.Time")); + assertEquals(333_333_333_333L, runtime.getData().getLong("FullInventory.second.Time")); + } + var written = disk(); + assertEquals(111_111_111_111L, written.getLong("FullInventory.seed.Time")); + assertEquals(222_222_222_222L, written.getLong("FullInventory.first.Time")); + assertEquals(333_333_333_333L, written.getLong("FullInventory.second.Time")); + } + + @Test void laterNestedWritePreservesDifferentSiblingFromUnpublishedParentReplacement() throws Exception { + CompletableFuture replacement, nested; + try (Gate gate = new Gate(false)) { + replacement = ordered.mutateFinalData(data -> data.createSection("FullInventory", Map.of( + "first", Map.of("Time", 222_222_222_222L)))).toCompletableFuture(); + gate.entered(); + nested = ordered.mutateFinalData(data -> data.set("FullInventory.second.Time", 333_333_333_333L)) + .toCompletableFuture(); + runtime.saveData(); + } + await(replacement); await(nested); await(state.runtimeStorage(() -> null)); + synchronized (runtime) { + assertEquals(222_222_222_222L, runtime.getData().getLong("FullInventory.first.Time")); + assertEquals(333_333_333_333L, runtime.getData().getLong("FullInventory.second.Time")); + } + assertEquals(222_222_222_222L, disk().getLong("FullInventory.first.Time")); + assertEquals(333_333_333_333L, disk().getLong("FullInventory.second.Time")); + } + + @Test void laterSameBaselineCreateSectionClearsAnUnpublishedChild() throws Exception { + synchronized (runtime) { runtime.getData().createSection("FullInventory"); } + CompletableFuture child, replacement; + try (Gate gate = new Gate(false)) { + child = ordered.mutateFinalData(data -> data.set("FullInventory.old.Time", 222_222_222_222L)) + .toCompletableFuture(); + gate.entered(); + replacement = ordered.mutateFinalData(data -> data.createSection("FullInventory")) + .toCompletableFuture(); + runtime.saveData(); + } + await(child); await(replacement); await(state.runtimeStorage(() -> null)); + synchronized (runtime) { + assertNotNull(runtime.getData().getConfigurationSection("FullInventory")); + assertTrue(runtime.getData().getConfigurationSection("FullInventory").getKeys(false).isEmpty()); + } + assertNotNull(disk().getConfigurationSection("FullInventory")); + assertTrue(disk().getConfigurationSection("FullInventory").getKeys(false).isEmpty()); + } + + @Test void finalMutationSupportsMoreThan256DistinctPermissionOwners() throws Exception { + await(ordered.mutateFinalData(data -> { + for (int i = 0; i < 300; i++) data.set("TimedPermissions." + new UUID(0, i), + List.of("permission.example%line%111111111111")); + }).toCompletableFuture()); + synchronized (runtime) { + assertEquals(300, runtime.getData().getConfigurationSection("TimedPermissions").getKeys(false).size()); + } + assertEquals(300, disk().getConfigurationSection("TimedPermissions").getKeys(false).size()); + } + + @Test void excessiveFinalMutationPathsRejectBeforePublicationAndLeaveOwnerUsable() throws Exception { + byte[] previous = Files.readAllBytes(directory.resolve("ServerData.yml")); + var rejected = ordered.mutateFinalData(data -> { + data.set("FinalField" + "x".repeat(4_000_001), null); + }).toCompletableFuture(); + assertThrows(ExecutionException.class, () -> await(rejected)); + assertArrayEquals(previous, Files.readAllBytes(directory.resolve("ServerData.yml"))); + synchronized (runtime) { assertTrue(runtime.getData().getKeys(false).stream().noneMatch(path -> path.startsWith("FinalField"))); } + await(ordered.mutateFinalData(data -> data.set("LastUpdated", 111_111_111_111L)).toCompletableFuture()); + assertEquals(111_111_111_111L, disk().getLong("LastUpdated")); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(longs = {-1L, 9_007_199_254_740_993L}) + void ordinarySnapshotRetainsPublishedIntegralValuesAcrossYamlNumericTypes(long previousTime) throws Exception { + long preciseLargeValue = 9_007_199_254_740_993L; + var original = Map.of("Time", previousTime, "Numbers", List.of(-1L, preciseLargeValue), + "Nested", Map.of("Whole", 7L, "Fraction", 0.125d)); + synchronized (runtime) { runtime.getData().createSection("NumericState", original); } + CompletableFuture mutation; + try (Gate gate = new Gate(false)) { + mutation = ordered.mutateFinalData(data -> data.createSection("NumericState", Map.of( + "Time", 5L, "Numbers", List.of(-1L, preciseLargeValue), + "Nested", Map.of("Whole", 8L, "Fraction", 0.125d)))).toCompletableFuture(); + gate.entered(); + runtime.saveData(); + } + await(mutation); await(state.runtimeStorage(() -> null)); + synchronized (runtime) { + assertEquals(5L, runtime.getData().getLong("NumericState.Time")); + assertEquals(8L, runtime.getData().getLong("NumericState.Nested.Whole")); + assertEquals(preciseLargeValue, ((Number) runtime.getData().getList("NumericState.Numbers").get(1)).longValue()); + assertEquals(0.125d, runtime.getData().getDouble("NumericState.Nested.Fraction")); + } + var written = disk(); + assertEquals(5L, written.getLong("NumericState.Time")); + assertEquals(8L, written.getLong("NumericState.Nested.Whole")); + assertEquals(-1L, ((Number) written.getList("NumericState.Numbers").get(0)).longValue()); + assertEquals(preciseLargeValue, ((Number) written.getList("NumericState.Numbers").get(1)).longValue()); + assertEquals(0.125d, written.getDouble("NumericState.Nested.Fraction")); + } + + @Test void ordinaryChildEditRetainsOtherOwnerFromUnpublishedParentReplacement() throws Exception { + CompletableFuture replacement; + try (Gate gate = new Gate(false)) { + replacement = ordered.mutateFinalData(data -> data.createSection("FullInventory", Map.of( + "first", Map.of("Time", 111_111_111_111L)))).toCompletableFuture(); + gate.entered(); + ordered.setData("FullInventory.second.Time", 222_222_222_222L); + runtime.saveData(); + } + await(replacement); await(state.runtimeStorage(() -> null)); + synchronized (runtime) { + assertEquals(111_111_111_111L, runtime.getData().getLong("FullInventory.first.Time")); + assertEquals(222_222_222_222L, runtime.getData().getLong("FullInventory.second.Time")); + } + assertEquals(111_111_111_111L, disk().getLong("FullInventory.first.Time")); + assertEquals(222_222_222_222L, disk().getLong("FullInventory.second.Time")); + } + + @Test void ordinaryEditCapacityRejectionCannotDiscardPendingParentPublication() throws Exception { + CompletableFuture replacement; + try (Gate gate = new Gate(false)) { + replacement = ordered.mutateFinalData(data -> data.createSection("FullInventory", Map.of( + "first", Map.of("Time", 111_111_111_111L)))).toCompletableFuture(); + gate.entered(); + assertThrows(IllegalStateException.class, () -> ordered.setData( + "FullInventory." + "x".repeat(4_000_001), null)); + } + await(replacement); await(state.runtimeStorage(() -> null)); + synchronized (runtime) { assertEquals(111_111_111_111L, runtime.getData().getLong("FullInventory.first.Time")); } + assertEquals(111_111_111_111L, disk().getLong("FullInventory.first.Time")); + await(ordered.mutateFinalData(data -> data.set("LastUpdated", 222_222_222_222L)).toCompletableFuture()); + assertEquals(222_222_222_222L, disk().getLong("LastUpdated")); + } + + @org.bukkit.configuration.serialization.SerializableAs("MCFinalOverflowTestItem") + public static final class PersistedOverflowItem extends org.bukkit.inventory.ItemStack { + public PersistedOverflowItem() { super(org.bukkit.Material.STONE, 1); } + @Override public Map serialize() { return Map.of("amount", 1); } + public static PersistedOverflowItem deserialize(Map values) { return new PersistedOverflowItem(); } + @Override public boolean equals(Object value) { return value instanceof PersistedOverflowItem; } + @Override public int hashCode() { return 1; } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"success", "before", "after"}) + void actualRuntimeOverflowAcknowledgesOnlyConfirmedDiskPublication(String outcome) throws Exception { + boolean fail = !outcome.equals("success"), visible = outcome.equals("after"); + org.bukkit.configuration.serialization.ConfigurationSerialization.registerClass(PersistedOverflowItem.class, "MCFinalOverflowTestItem"); + var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); when(plugin.isEnabled()).thenReturn(true); + when(plugin.getOptions()).thenReturn(mock(com.bencodez.advancedcore.AdvancedCoreConfigOptions.class)); + UUID owner = UUID.randomUUID(); var player = mock(org.bukkit.entity.Player.class); + var slots = mock(org.bukkit.inventory.PlayerInventory.class); var item = new PersistedOverflowItem(); + when(player.getUniqueId()).thenReturn(owner); when(player.isOnline()).thenReturn(true); when(player.getInventory()).thenReturn(slots); + when(slots.addItem(item)).thenReturn(new HashMap<>(Map.of(0, item))); + var ownerTasks = new LinkedBlockingQueue(); + doAnswer(call -> { ownerTasks.add(call.getArgument(1)); return null; }) + .when(scheduler).runTask(eq(plugin), any(Runnable.class), eq(player)); + var inventory = new com.bencodez.advancedcore.api.item.FullInventoryHandler(plugin); + inventory.getLastMessageTime().put(owner, System.currentTimeMillis()); + byte[] previous = Files.readAllBytes(directory.resolve("ServerData.yml")); + try (var bukkit = mockStatic(Bukkit.class); Gate gate = new Gate(fail, visible)) { + bukkit.when(() -> Bukkit.getPlayer(owner)).thenReturn(player); + var receipt = inventory.giveItemAsync(player, item).toCompletableFuture(); + Objects.requireNonNull(ownerTasks.poll(5, TimeUnit.SECONDS)).run(); gate.entered(); + assertFalse(receipt.isDone(), "native delivery cannot acknowledge a queued file save"); + assertFalse(inventory.getItems().containsKey(owner), "unpublished overflow is not sweepable"); + assertArrayEquals(previous, Files.readAllBytes(directory.resolve("ServerData.yml"))); + if (!fail) assertFalse(inventory.saveDurably(), "a second queued snapshot is not a durable acknowledgement"); + assertFalse(receipt.isDone()); gate.close(); + if (fail) { + await(state.runtimeStorage(() -> null)); + inventory.getTimer().submit(() -> {}).get(5, TimeUnit.SECONDS); + assertFalse(receipt.isDone()); assertFalse(inventory.getItems().containsKey(owner)); + assertTrue(ownerTasks.isEmpty(), "an uncertain visible save cannot fall back to dropping the same item"); + if (visible) assertEquals(item, disk().getItemStack("FullInventory." + owner + ".Items.0")); + else assertArrayEquals(previous, Files.readAllBytes(directory.resolve("ServerData.yml"))); + publication.set(DurableFeatureFiles::publish); + inventory.saveDurably(); await(state.runtimeStorage(() -> null)); await(receipt); + assertEquals(List.of(item), inventory.getItems().get(owner)); + assertEquals(item, disk().getItemStack("FullInventory." + owner + ".Items.0")); + verify(slots, times(1)).addItem(item); + } else { + await(receipt); await(state.runtimeStorage(() -> null)); + assertEquals(List.of(item), inventory.getItems().get(owner)); + assertEquals(item, disk().getItemStack("FullInventory." + owner + ".Items.0")); + assertFalse(disk().contains("FullInventory." + owner + ".Items.1")); + } + } finally { + inventory.getTimer().shutdownNow(); + org.bukkit.configuration.serialization.ConfigurationSerialization.unregisterClass(PersistedOverflowItem.class); + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void actualInventoryFinalSaveWaitsForOrderedAdapterPublicationAndReportsFailure(boolean fail) throws Exception { + var inventory = new com.bencodez.advancedcore.api.item.FullInventoryHandler(plugin); + CompletableFuture finalFiles; + byte[] previous = Files.readAllBytes(directory.resolve("ServerData.yml")); + try (Gate gate = new Gate(fail)) { + finalFiles = inventory.shutdownAsync(Runnable::run).toCompletableFuture(); + gate.entered(); + assertFalse(finalFiles.isDone()); assertTrue(inventory.getTimer().isShutdown()); + assertArrayEquals(previous, Files.readAllBytes(directory.resolve("ServerData.yml"))); + gate.close(); + if (fail) assertThrows(ExecutionException.class, () -> await(finalFiles)); + else await(finalFiles); + } finally { inventory.getTimer().shutdownNow(); } + if (fail) assertArrayEquals(previous, Files.readAllBytes(directory.resolve("ServerData.yml"))); + else assertTrue(Files.readString(directory.resolve("ServerData.yml")).startsWith("# MCPerks ordered ServerData v1")); + verify(original, never()).saveDataChecked(); + } + + @Test void actualTimestampFinalCallerUsesOrderedAdapterAndExposesPendingPublicationToHook() throws Exception { + var capturedFiles = new AtomicReference>(); + Thread lifecycleThread = Thread.currentThread(); + doAnswer(call -> { + assertSame(lifecycleThread, Thread.currentThread()); capturedFiles.set(call.getArgument(0)); return null; + }).when(plugin).onShutdownComplete(any(java.util.concurrent.CompletionStage.class)); + var platform = new com.bencodez.advancedcore.bukkit.runtime.BukkitRuntimePlatform(plugin); + byte[] previous = Files.readAllBytes(directory.resolve("ServerData.yml")); + try (Gate gate = new Gate(false)) { + platform.beforeExecutorShutdown().stream().filter(cleanup -> cleanup.name().equals("server data timestamp")) + .findFirst().orElseThrow().action().run(); + gate.entered(); + platform.afterExecutorShutdown().forEach(cleanup -> cleanup.action().run()); + assertNotNull(capturedFiles.get()); assertFalse(capturedFiles.get().toCompletableFuture().isDone()); + assertArrayEquals(previous, Files.readAllBytes(directory.resolve("ServerData.yml"))); + gate.close(); await(capturedFiles.get().toCompletableFuture()); + } + assertTrue(disk().getLong("LastUpdated") > 0); + assertTrue(Files.readString(directory.resolve("ServerData.yml")).startsWith("# MCPerks ordered ServerData v1")); + verify(original, never()).saveDataChecked(); + } + + private static class LegacyHookObserver extends MCPerksMain { + private int hookCalls; + private boolean reenter; + private Thread hookThread; + @Override public void onShutdownComplete() { + hookCalls++; hookThread = Thread.currentThread(); + if (reenter) onShutdownComplete(CompletableFuture.completedFuture(null)); + super.onShutdownComplete(); + } + } + + @Test void completionStageHookPreservesVirtualLegacyDispatchAndReentrantCallsCannotEraseFence() throws Exception { + var observer = mock(LegacyHookObserver.class, CALLS_REAL_METHODS); observer.reenter = true; + var ownerField = MCPerksMain.class.getDeclaredField("storage"); ownerField.setAccessible(true); ownerField.set(observer, state); + var preparationField = MCPerksMain.class.getDeclaredField("shutdownPreparation"); preparationField.setAccessible(true); + preparationField.set(observer, CompletableFuture.completedFuture(null)); + CompletableFuture actual; + try (Gate gate = new Gate(false)) { + actual = ordered.mutateFinalData(data -> data.set("LastUpdated", 9753L)).toCompletableFuture(); + gate.entered(); + observer.onShutdownComplete(actual); + observer.onShutdownComplete(CompletableFuture.completedFuture(null)); + assertEquals(1, observer.hookCalls); assertSame(Thread.currentThread(), observer.hookThread); + assertFalse(actual.isDone()); + var accepted = state.runtimeStorage(() -> null); + observer.onShutdownComplete(); // Direct old hook still retains the already admitted fence. + assertEquals(2, observer.hookCalls); + gate.close(); await(actual); await(accepted); + } + try (OrderedStorage successor = new OrderedStorage(directory.resolve("features.dat"))) { await(successor.ready()); } + assertEquals(9753L, disk().getLong("LastUpdated")); + assertThrows(ExecutionException.class, () -> await(state.runtimeStorage(() -> null))); + } + + private void historyMonth() { + var checker = mock(com.bencodez.advancedcore.api.time.TimeChecker.class); + when(checker.getTime()).thenReturn(java.time.LocalDateTime.of(2026, 10, 8, 12, 0)); + when(plugin.getTimeChecker()).thenReturn(checker); + } + @Test void globalAudienceHistoryCannotPoisonSubsequentTimerAndCalendarSnapshots() throws Exception { + historyMonth(); + String audience = String.join(",", java.util.stream.IntStream.range(0, 500) + .mapToObj(i -> new UUID(0, i).toString()).toList()); + await(runtime.confirmedActivation(() -> { + for (int i = 0; i < 224; i++) runtime.addPerkHistory("activation-" + i + ":" + audience); + runtime.setPerkExperation(perk, 123456); runtime.setServerCooldown("legacy", 789012); + })); + assertTrue(Files.size(directory.resolve("ServerData.yml")) <= 4_000_000); + var entries = disk().getStringList("PerkHistory.OCTOBER"); + assertFalse(entries.isEmpty()); assertTrue(entries.size() < 224); + assertEquals("activation-223:" + audience, entries.getLast()); + assertEquals(123456, disk().getLong("Experation.legacy")); assertEquals(789012, disk().getLong("legacy.CoolDown")); + await(ownerSnapshot(456789)); var transition = await(begin()); await(complete(transition)); + await(runtime.initialize(state)); + assertEquals(456789, runtime.getPerkExperation(perk)); assertEquals(456790, runtime.getServerCooldown("legacy")); + assertEquals(9, disk().getInt("PrevDay")); + } + @Test void completeEncodedHistoryEntriesShareOneBudgetAcrossAllMonths() throws Exception { + String text = "雪\n'quoted': ".repeat(300); + synchronized (runtime) { + for (java.time.Month month : java.time.Month.values()) { + var entries = new ArrayList(); + for (int i = 0; i < 80; i++) entries.add(i + text); + runtime.getData().set("PerkHistory." + month, entries); + } + } + await(ownerSnapshot(42)); + var written = disk(); var history = new YamlConfiguration(); + history.set("PerkHistory", written.getConfigurationSection("PerkHistory").getValues(false)); + assertTrue(history.saveToString().getBytes(java.nio.charset.StandardCharsets.UTF_8).length <= 1_000_000); + assertTrue(written.getStringList("PerkHistory.DECEMBER").contains("79" + text)); + int count = 0; + for (java.time.Month month : java.time.Month.values()) { + var entries = written.getStringList("PerkHistory." + month); count += entries.size(); + for (int i = 0; i < entries.size(); i++) assertEquals((80 - entries.size() + i) + text, entries.get(i)); + } + assertTrue(count < 960); assertEquals(42, written.getLong("Experation.legacy")); + } + @Test void trimmedLiveHistoryRemainsASectionAcrossSuccessiveActivations() throws Exception { + historyMonth(); + await(runtime.confirmedActivation(() -> runtime.addPerkHistory("first"))); + synchronized (runtime) { + assertTrue(runtime.getData().isConfigurationSection("PerkHistory")); + assertEquals(List.of("first"), runtime.getData().getStringList("PerkHistory.OCTOBER")); + } + await(runtime.confirmedActivation(() -> runtime.addPerkHistory("second"))); + assertEquals(List.of("first", "second"), disk().getStringList("PerkHistory.OCTOBER")); + } + @Test void historyYieldsRemainingSnapshotCapacityToAuthoritativeRuntimeState() throws Exception { + historyMonth(); + synchronized (runtime) { runtime.getData().set("LegacyPayload", "x".repeat(3_950_000)); } + await(runtime.confirmedActivation(() -> { + runtime.addPerkHistory("audience:" + "y".repeat(100_000)); runtime.setPerkExperation(perk, 777); + })); + assertTrue(Files.size(directory.resolve("ServerData.yml")) <= 4_000_000); + assertTrue(disk().getStringList("PerkHistory.OCTOBER").isEmpty()); + assertEquals(3_950_000, disk().getString("LegacyPayload").length()); + await(ownerSnapshot(888)); assertEquals(888, disk().getLong("Experation.legacy")); + } + @Test void monthlyCountCompatibilityAndOversizedSingleHistoryEntryCannotPoisonSaves() throws Exception { + historyMonth(); + await(runtime.confirmedActivation(() -> { + for (int i = 0; i < 257; i++) runtime.addPerkHistory("small-" + i); + })); + var entries = disk().getStringList("PerkHistory.OCTOBER"); + assertEquals(256, entries.size()); assertEquals("small-1", entries.getFirst()); assertEquals("small-256", entries.getLast()); + await(runtime.confirmedActivation(() -> runtime.addPerkHistory("huge:" + "雪".repeat(400_000)))); + assertTrue(disk().getStringList("PerkHistory.OCTOBER").isEmpty()); + await(ownerSnapshot(999)); assertEquals(999, disk().getLong("Experation.legacy")); + } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"cancel", "success", "failure"}) + void callerCannotForgeNativeInitializationReadiness(String mutation) throws Exception { + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + state.runtimeStorage(() -> { + entered.countDown(); + try { if (!release.await(5, TimeUnit.SECONDS)) throw new AssertionError("release timed out"); } + catch (InterruptedException failure) { throw new AssertionError(failure); } + return null; + }); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); + var initializing = runtime.initialize(state); + var readiness = runtime.ready(); + for (var observation : List.of(initializing, readiness)) switch (mutation) { + case "cancel" -> observation.cancel(false); + case "success" -> observation.complete(null); + case "failure" -> observation.completeExceptionally(new IllegalStateException("caller observation")); + default -> fail("unknown mutation"); + } + assertFalse(runtime.isReady(), "only actual reconciliation can open native admission"); + } finally { release.countDown(); } + await(state.runtimeStorage(() -> null)); + await(runtime.ready()); + assertTrue(runtime.isReady()); + } + + @Test void cancelledFinalMutationObservationsCannotExhaustNativeStagingCapacity() throws Exception { + try (Gate gate = new Gate(false)) { + for (int index = 0; index < 256; index++) { + int value = index; + var observation = ordered.mutateFinalData(data -> data.set("ObservedPublication", value)).toCompletableFuture(); + if (index == 0) gate.entered(); + assertTrue(observation.cancel(false)); + } + } + await(state.runtimeStorage(() -> null)); + await(ordered.mutateFinalData(data -> data.set("ObservedPublication", 999L)).toCompletableFuture()); + assertEquals(999L, disk().getLong("ObservedPublication")); + } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"cancel", "success", "failure"}) + void callerObservationCannotSkipGameplayAfterAcceptedPublication(String mutation) throws Exception { + immediateOwnerScheduler(); + var caller = new AtomicInteger(); var gameplay = new AtomicInteger(); + var played = new CountDownLatch(1); + try (Gate gate = new Gate(false)) { + var observation = runtime.confirmedActivationBeforeGameplay(Map.of("observed.CoolDown", 123L), + () -> { caller.incrementAndGet(); return CompletableFuture.completedFuture(null); }, + () -> { gameplay.incrementAndGet(); played.countDown(); }, null); + gate.entered(); + switch (mutation) { + case "cancel" -> observation.cancel(false); + case "success" -> observation.complete(null); + case "failure" -> observation.completeExceptionally(new IllegalStateException("caller observation")); + default -> fail("unknown mutation"); + } + assertEquals(0, caller.get()); assertEquals(0, gameplay.get()); + } + assertTrue(played.await(5, TimeUnit.SECONDS)); + await(runtime.confirmedFinalMutation(data -> {})); + assertEquals(1, caller.get()); assertEquals(1, gameplay.get()); + assertEquals(123L, disk().getLong("observed.CoolDown")); + } +} diff --git a/MCPerks/src/test/java/com/bencodez/mcperks/tests/data/OrderedStorageTest.java b/MCPerks/src/test/java/com/bencodez/mcperks/tests/data/OrderedStorageTest.java new file mode 100644 index 0000000..ed9dcc2 --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/data/OrderedStorageTest.java @@ -0,0 +1,136 @@ +package com.bencodez.mcperks.tests.data; + +import static org.junit.jupiter.api.Assertions.*; + +import com.bencodez.mcperks.data.OrderedStorage; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class OrderedStorageTest { + @TempDir Path root; + + @Test void operationsUseOneWorkerAndRetainAdmissionOrder() throws Exception { + try (var storage = new OrderedStorage(root.resolve("state.dat"))) { + storage.ready().get(5, TimeUnit.SECONDS); + assertFalse(storage.isOwnerThread()); + var observed = new ArrayList(); + var operations = new ArrayList>(); + for (int i = 0; i < 20; i++) { + int position = i; + operations.add(storage.runtimeStorage(() -> { + assertTrue(storage.isOwnerThread()); + observed.add(position); + return null; + })); + } + CompletableFuture.allOf(operations.toArray(CompletableFuture[]::new)).get(5, TimeUnit.SECONDS); + assertEquals(java.util.stream.IntStream.range(0, 20).boxed().toList(), observed); + } + } + + @Test void saturatedAdmissionFailsWithoutDroppingAcceptedWork() throws Exception { + var release = new CountDownLatch(1); + try (var storage = new OrderedStorage(root.resolve("state.dat"))) { + storage.ready().get(5, TimeUnit.SECONDS); + var entered = new CountDownLatch(1); + var blocker = storage.runtimeStorage(() -> { entered.countDown(); await(release); return null; }); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + var queued = new ArrayList>(); + for (int i = 0; i < 256; i++) { int value = i; queued.add(storage.runtimeStorage(() -> value)); } + var overflowRan = new AtomicBoolean(); + var overflow = storage.runtimeStorage(() -> { overflowRan.set(true); return null; }); + assertInstanceOf(RejectedExecutionException.class, + assertThrows(ExecutionException.class, () -> overflow.get(5, TimeUnit.SECONDS)).getCause()); + assertFalse(overflowRan.get()); + release.countDown(); + blocker.get(5, TimeUnit.SECONDS); + for (int i = 0; i < 256; i++) assertEquals(i, queued.get(i).get(5, TimeUnit.SECONDS)); + } finally { release.countDown(); } + } + + @Test void operationFailureDoesNotDisableIndependentNativeStorage() throws Exception { + try (var storage = new OrderedStorage(root.resolve("state.dat"))) { + var failure = new IllegalArgumentException("invalid optional payload"); + var rejected = storage.runtimeStorage(() -> { throw failure; }); + assertSame(failure, assertThrows(ExecutionException.class, + () -> rejected.get(5, TimeUnit.SECONDS)).getCause()); + assertEquals("native", storage.runtimeStorage(() -> "native").get(5, TimeUnit.SECONDS)); + } + } + + @Test void ownershipFailureFreezesAllSubsequentPublication() throws Exception { + Path file = root.resolve("state.dat"), unrelated = root.resolve("unrelated"); + Files.writeString(unrelated, "unchanged"); + Files.createSymbolicLink(root.resolve("state.dat.lock"), unrelated); + try (var storage = new OrderedStorage(file)) { + assertThrows(ExecutionException.class, () -> storage.ready().get(5, TimeUnit.SECONDS)); + var invoked = new AtomicBoolean(); + assertThrows(ExecutionException.class, () -> storage.runtimeStorage(() -> { + invoked.set(true); return null; + }).get(5, TimeUnit.SECONDS)); + assertFalse(invoked.get()); + assertEquals("unchanged", Files.readString(unrelated)); + assertFalse(Files.exists(file)); + } + } + + @Test void closeRejectsNewWorkButRetainsOwnershipThroughActualDrain() throws Exception { + Path file = root.resolve("state.dat"); + var release = new CountDownLatch(1); + try (var first = new OrderedStorage(file)) { + first.ready().get(5, TimeUnit.SECONDS); + var entered = new CountDownLatch(1); + var writing = first.runtimeStorage(() -> { + entered.countDown(); await(release); + try { Files.writeString(file, "old-drained"); } + catch (java.io.IOException error) { throw new java.io.UncheckedIOException(error); } + return null; + }); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + first.close(); + assertThrows(ExecutionException.class, () -> first.runtimeStorage(() -> null).get(5, TimeUnit.SECONDS)); + try (var successor = new OrderedStorage(file)) { + assertThrows(TimeoutException.class, () -> successor.ready().get(100, TimeUnit.MILLISECONDS)); + assertFalse(Files.exists(file)); + release.countDown(); + writing.get(5, TimeUnit.SECONDS); + successor.ready().get(5, TimeUnit.SECONDS); + assertEquals("old-drained", Files.readString(file)); + } + } finally { release.countDown(); } + } + + @Test void cancellingAnObservationDoesNotReleasePendingWriterOwnership() throws Exception { + var release = new CountDownLatch(1); + try (var storage = new OrderedStorage(root.resolve("state.dat"))) { + storage.ready().get(5, TimeUnit.SECONDS); + var entered = new CountDownLatch(1); + var published = new AtomicBoolean(); + var writing = storage.runtimeStorage(() -> { + entered.countDown(); await(release); published.set(true); return null; + }); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + assertTrue(writing.cancel(false)); + var barrier = storage.runtimeStorage(() -> null); + assertFalse(barrier.isDone()); + release.countDown(); + barrier.get(5, TimeUnit.SECONDS); + assertTrue(published.get()); + } finally { release.countDown(); } + } + + private static void await(CountDownLatch latch) { + try { if (!latch.await(5, TimeUnit.SECONDS)) throw new AssertionError("Worker release timed out"); } + catch (InterruptedException error) { Thread.currentThread().interrupt(); throw new AssertionError(error); } + } +} diff --git a/MCPerks/src/test/java/com/bencodez/mcperks/tests/data/ServerDataNativeMigrationTest.java b/MCPerks/src/test/java/com/bencodez/mcperks/tests/data/ServerDataNativeMigrationTest.java new file mode 100644 index 0000000..ca04db4 --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/data/ServerDataNativeMigrationTest.java @@ -0,0 +1,433 @@ +package com.bencodez.mcperks.tests.data; +import static com.bencodez.mcperks.tests.TestAccess.*; +import com.bencodez.mcperks.data.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.logging.*; +import org.bukkit.Bukkit; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.plugin.PluginDescriptionFile; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import com.bencodez.advancedcore.api.permissions.PermissionHandler; +import com.bencodez.mcperks.MCPerksMain; + +/** Uses the real shaded-library permission lifecycle and actual ordered YAML publication. */ +class ServerDataNativeMigrationTest { + @TempDir Path directory; + private MCPerksMain plugin; + private OrderedStorage state; + private ServerData runtime; + private OrderedServerDataFile ordered; + private com.bencodez.advancedcore.data.ServerData originalFile; + private YamlConfiguration live; + private com.bencodez.mcperks.tests.scheduling.BundledOwnerTaskHarness owners; + private boolean ownerContext; + private int maximumOwnerCallbacks; + private final List nativeHandlers = new ArrayList<>(); + private Path file() { return directory.resolve("ServerData.yml"); } + private T await(CompletableFuture future) throws Exception { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + while (!future.isDone() && System.nanoTime() < deadline) { + if (owners != null) maximumOwnerCallbacks = Math.max(maximumOwnerCallbacks, owners.tasks.size()); + Runnable task = owners == null ? null : owners.tasks.poll(1, TimeUnit.MILLISECONDS); + if (task != null) onOwner(task); + } + return future.get(1, TimeUnit.SECONDS); + } + private void onOwner(Runnable task) { + boolean previous = ownerContext; ownerContext = true; + try { task.run(); } finally { ownerContext = previous; } + } + @BeforeEach void setup() throws Exception { + if (Bukkit.getServer() == null) { + var server = mock(org.bukkit.Server.class); when(server.getLogger()).thenReturn(Logger.getLogger("test")); Bukkit.setServer(server); + } + plugin = mock(MCPerksMain.class); when(plugin.getDataFolder()).thenReturn(directory.toFile()); + when(plugin.getName()).thenReturn("MCPerks"); when(plugin.getDescription()).thenReturn(new PluginDescriptionFile("MCPerks", "next", "test.Main")); + when(plugin.getLogger()).thenReturn(Logger.getLogger("test")); when(plugin.isEnabled()).thenReturn(true); + owners = new com.bencodez.mcperks.tests.scheduling.BundledOwnerTaskHarness(plugin, false); + var scheduler = plugin.getBukkitScheduler(); + doAnswer(call -> { owners.tasks.add(call.getArgument(1)); return null; }).when(scheduler).runTask(eq(plugin), any(Runnable.class)); + live = new YamlConfiguration(); + var original = mock(com.bencodez.advancedcore.data.ServerData.class); originalFile = original; + when(original.getData()).thenReturn(live); when(original.getdFile()).thenReturn(file().toFile()); + state = new OrderedStorage(directory.resolve("features.dat")); await(state.ready()); when(plugin.getStorage()).thenReturn(state); + runtime = new ServerData(plugin); when(plugin.getMcperksServerData()).thenReturn(runtime); + try (var constructors = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) { + ordered = new OrderedServerDataFile(plugin, original, runtime); + } + when(plugin.getServerDataFile()).thenAnswer(call -> { + runtime.observeNativePermissionSource(live); return ordered; + }); + } + @AfterEach void close() { + for (var handler : nativeHandlers) handler.getTimer().shutdownNow(); + state.close(); + } + /** Run bundled setup and retain its actual failedToRead flag; only timer scheduling is stubbed. */ + private com.bencodez.advancedcore.data.ServerData setupNativeFile() { return setupNativeFile(false); } + private com.bencodez.advancedcore.data.ServerData setupNativeFile(boolean beforeOrderedStorage) { + com.bencodez.advancedcore.data.ServerData nativeFile; + try (var constructors = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) { + nativeFile = new com.bencodez.advancedcore.data.ServerData(plugin); + nativeFile.setup(); + originalFile = nativeFile; live = (YamlConfiguration) nativeFile.getData(); + runtime = new ServerData(plugin); when(plugin.getMcperksServerData()).thenReturn(runtime); + if (beforeOrderedStorage) when(plugin.getStorage()).thenReturn(null); + ordered = new OrderedServerDataFile(plugin, nativeFile, runtime); + if (beforeOrderedStorage) when(plugin.getStorage()).thenReturn(state); + } + return nativeFile; + } + @ParameterizedTest @ValueSource(strings = {"new", "existing-empty", "comment-only", "empty-map"}) + void actualNativeEmptySetupInitializesAndRestartsWithConfirmedPersistence(String source) throws Exception { + switch (source) { + case "new" -> assertFalse(Files.exists(file())); + case "existing-empty" -> Files.writeString(file(), ""); + case "comment-only" -> Files.writeString(file(), "# Valid empty ServerData\n"); + case "empty-map" -> Files.writeString(file(), "{}\n"); + default -> fail("unknown source"); + } + var nativeFile = setupNativeFile(true); + byte[] initialBytes = Files.readAllBytes(file()); + ordered.setLastUpdated(); // Real native TimeChecker order: mutation/save before initialize. + assertTrue(live.getLong("LastUpdated") > 0); assertArrayEquals(initialBytes, Files.readAllBytes(file())); + assertEquals(source.equals("new"), nativeFile.isJustCreated()); + assertTrue(nativeFile.isFailedToRead(), "Native setup flags a valid empty root as unreadable"); + assertEquals(Set.of("LastUpdated"), nativeFile.getData().getKeys(false)); + await(runtime.initialize(state)); + assertTrue(disk().getLong("LastUpdated") > 0); assertTrue(runtime.isReady()); + // Real wrapper writes after startup must now reach the ordered file owner. + ordered.setPluginVersion(plugin); + await(runtime.confirmedActivation(() -> runtime.setServerCooldown("fresh", 1234))); + assertEquals("next", disk().getString("PluginVersions.MCPerks")); + assertEquals(1234, disk().getLong("fresh.CoolDown")); + assertTrue(Files.readString(file()).startsWith("# MCPerks ordered ServerData v1\n")); + state.close(); state = new OrderedStorage(directory.resolve("features.dat")); + await(state.ready()); when(plugin.getStorage()).thenReturn(state); + var restarted = setupNativeFile(); + assertFalse(restarted.isJustCreated()); assertFalse(restarted.isFailedToRead()); + await(runtime.initialize(state)); assertTrue(runtime.isReady()); + assertEquals(1234, runtime.getServerCooldown("fresh")); + await(runtime.confirmedActivation(() -> runtime.setServerCooldown("fresh", 5678))); + assertEquals(5678, disk().getLong("fresh.CoolDown")); + } + @Test void actualNativeReadableNonemptyLegacySetupRetainsFields() throws Exception { + Files.writeString(file(), "PrevDay: 9\nlegacy:\n CoolDown: 5678\n"); + var nativeFile = setupNativeFile(); assertFalse(nativeFile.isFailedToRead()); + await(runtime.initialize(state)); assertTrue(runtime.isReady()); + assertEquals(9, disk().getInt("PrevDay")); assertEquals(5678, runtime.getServerCooldown("legacy")); + } + @ParameterizedTest @ValueSource(strings = {"malformed", "unreadable-directory", "symlink"}) + void actualNativeReadFailureCannotBecomeAnEmptySuccessfulMigration(String source) throws Exception { + Path target = directory.resolve("target.yml"); + switch (source) { + case "malformed" -> Files.writeString(file(), "broken: [unterminated\n"); + case "unreadable-directory" -> Files.createDirectory(file()); + case "symlink" -> { Files.writeString(target, "PrevDay: 9\n"); Files.createSymbolicLink(file(), target); } + default -> fail("unknown source"); + } + byte[] original = source.equals("unreadable-directory") ? null : Files.readAllBytes(file()); + var nativeFile = setupNativeFile(true); + ordered.setLastUpdated(); + if (!source.equals("symlink")) { + assertTrue(nativeFile.isFailedToRead()); assertEquals(Set.of("LastUpdated"), nativeFile.getData().getKeys(false)); + } + assertThrows(ExecutionException.class, () -> await(runtime.initialize(state))); assertFalse(runtime.isReady()); + runtime.saveData(); + if (source.equals("unreadable-directory")) assertTrue(Files.isDirectory(file())); + else assertArrayEquals(original, Files.readAllBytes(file())); + if (source.equals("symlink")) assertTrue(Files.isSymbolicLink(file())); + } + @SuppressWarnings("unchecked") + private Map nativePending(PermissionHandler handler, UUID owner) throws Exception { + var handle = handler.getPermsToAdd().get(owner); + if (handle == null) return Map.of(); + var method = com.bencodez.advancedcore.api.permissions.PlayerPermissionHandler.class.getDeclaredMethod("offlinePermissionSnapshot"); + assertTrue(method.trySetAccessible()); return (Map) method.invoke(handle); + } + private void legacy(YamlConfiguration yaml) throws Exception { + String text = yaml.saveToString(); Files.writeString(file(), text, StandardCharsets.UTF_8); live.loadFromString(text); + } + private PermissionHandler restoreNative() { + var handler = new PermissionHandler(plugin); nativeHandlers.add(handler); + runtime.observeNativePermissionHandler(handler); // Actual MCPerks getter captures before its first external mutation. + when(plugin.getPermissionHandler()).thenReturn(handler); return handler; + } + private void login(PermissionHandler handler, UUID owner) { + var player = mock(org.bukkit.entity.Player.class); when(player.getUniqueId()).thenReturn(owner); when(player.isValid()).thenReturn(true); + var attachment = mock(org.bukkit.permissions.PermissionAttachment.class); + when(attachment.getPermissions()).thenReturn(new HashMap<>()); when(player.addAttachment(plugin)).thenReturn(attachment); + handler.login(player); assertTrue(handler.getPerms().containsKey(owner)); + } + private YamlConfiguration disk() { return YamlConfiguration.loadConfiguration(file().toFile()); } + private YamlConfiguration permissionRows(UUID owner, String permission, long expiry) { + var yaml = new YamlConfiguration(); yaml.set("TimedPermissions." + owner, List.of(permission + "%line%" + expiry)); + yaml.set("Experation.legacy", 1234); yaml.set("legacy.CoolDown", 5678); yaml.set("PrevDay", 9); return yaml; + } + @Test void nativeRestoreDelayedVersionSaveRevocationAndShutdownNeverRegrantClearedDiskRows() throws Exception { + UUID revoked = UUID.randomUUID(), retained = UUID.randomUUID(); + var yaml = permissionRows(revoked, "perk.revoked", System.currentTimeMillis() + 600_000); + yaml.set("TimedPermissions." + retained, List.of("perk.retained%line%" + (System.currentTimeMillis() + 600_000))); + legacy(yaml); var handler = restoreNative(); + assertTrue(handler.getPermsToAdd().containsKey(revoked)); assertNull(live.get("TimedPermissions")); + login(handler, retained); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + var blocker = state.runtimeStorage(() -> { + entered.countDown(); + try { if (!release.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("test timeout"); } + catch (InterruptedException failure) { throw new CompletionException(failure); } return null; + }); + try { + assertTrue(entered.await(10, TimeUnit.SECONDS)); var initialization = runtime.initialize(state); + ordered.setPluginVersion(plugin); // Actual native version mutation/save while canonical load is pending. + assertTrue(disk().isConfigurationSection("TimedPermissions")); assertFalse(initialization.isDone()); + release.countDown(); await(blocker); await(initialization); + } finally { release.countDown(); } + assertNull(disk().get("TimedPermissions")); assertEquals("next", disk().getString("PluginVersions.MCPerks")); + handler.removePermission(revoked, null, "perk.revoked"); + assertFalse(handler.getPermsToAdd().containsKey(revoked)); handler.shutDown(); await(state.runtimeStorage(() -> null)); + assertTrue(disk().getStringList("TimedPermissions." + revoked).isEmpty()); + assertFalse(disk().getStringList("TimedPermissions." + retained).isEmpty()); + live.loadFromString(Files.readString(file())); var restarted = restoreNative(); + assertFalse(restarted.getPermsToAdd().containsKey(revoked)); assertTrue(restarted.getPermsToAdd().containsKey(retained)); + } + @Test void actualAsyncFinalPermissionSaveUsesOrderedOwnerAndRetainsPendingNodesWithoutRevocationReplay() throws Exception { + UUID retained = UUID.randomUUID(), revoked = UUID.randomUUID(), online = UUID.randomUUID(); + long now = System.currentTimeMillis(); + var yaml = permissionRows(retained, "perk.retained", now + 600_000); + yaml.set("TimedPermissions." + revoked, List.of("perk.revoked%line%" + (now + 600_000))); + yaml.set("TimedPermissions." + online, List.of("perk.online%line%" + (now + 600_000))); + legacy(yaml); var handler = restoreNative(); login(handler, online); + await(runtime.initialize(state)); + handler.removePermission(revoked, null, "perk.revoked"); + handler.addPermission(retained, "perk.retained", com.bencodez.simpleapi.time.ParsedDuration.ofMillis(1_200_000)); + assertTrue(nativePending(handler, retained).containsKey("perk.retained")); + byte[] before = Files.readAllBytes(file()); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + var blocker = state.runtimeStorage(() -> { + entered.countDown(); + try { if (!release.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("test timeout"); } + catch (InterruptedException failure) { throw new CompletionException(failure); } + return null; + }); + try { + assertTrue(entered.await(10, TimeUnit.SECONDS)); + var finalWorker = new ArrayDeque(); + var finalFiles = handler.shutDownAsync(finalWorker::add).toCompletableFuture(); + assertTrue(handler.getTimer().isShutdown()); + assertFalse(finalFiles.isDone()); assertEquals(1, finalWorker.size()); + var ownerField = MCPerksMain.class.getDeclaredField("storage"); ownerField.setAccessible(true); ownerField.set(plugin, state); + var preparationField = MCPerksMain.class.getDeclaredField("shutdownPreparation"); preparationField.setAccessible(true); + preparationField.set(plugin, CompletableFuture.completedFuture(null)); + doCallRealMethod().when(plugin).onShutdownComplete(any(java.util.concurrent.CompletionStage.class)); + doCallRealMethod().when(plugin).onShutdownComplete(); + plugin.onShutdownComplete(finalFiles); + finalWorker.remove().run(); // Production PermissionHandler -> OrderedServerDataFile -> runtime owner. + assertFalse(finalFiles.isDone()); assertArrayEquals(before, Files.readAllBytes(file())); + var laterAccepted = state.runtimeStorage(() -> null); + release.countDown(); await(blocker); await(finalFiles); await(laterAccepted); + var written = disk(); + assertTrue(written.getStringList("TimedPermissions." + revoked).isEmpty()); + var pending = written.getStringList("TimedPermissions." + retained); + assertEquals(1, pending.size()); assertTrue(pending.getFirst().startsWith("perk.retained%line%")); + assertTrue(Long.parseLong(pending.getFirst().split("%line%")[1]) >= now + 1_200_000); + assertEquals(1, written.getStringList("TimedPermissions." + online).size()); + assertTrue(Files.readString(file()).startsWith("# MCPerks ordered ServerData v1")); + assertEquals(1234, written.getLong("Experation.legacy")); + assertEquals(5678, written.getLong("legacy.CoolDown")); + try (OrderedStorage successor = new OrderedStorage(directory.resolve("features.dat"))) { await(successor.ready()); } + live.loadFromString(Files.readString(file())); var restarted = restoreNative(); + assertFalse(restarted.getPermsToAdd().containsKey(revoked)); + assertTrue(nativePending(restarted, retained).containsKey("perk.retained")); + assertTrue(nativePending(restarted, online).containsKey("perk.online")); + verify(originalFile, never()).saveData(); verify(originalFile, never()).saveDataChecked(); + } finally { release.countDown(); } + } + + @Test void genericEmptyCacheWithoutNativeHandlerDoesNotClearRecoveredPermissions() throws Exception { + UUID owner = UUID.randomUUID(); legacy(permissionRows(owner, "perk.recovered", System.currentTimeMillis() + 600_000)); + live.set("TimedPermissions", null); await(runtime.initialize(state)); + assertFalse(disk().getStringList("TimedPermissions." + owner).isEmpty()); + assertTrue(restoreNative().getPermsToAdd().containsKey(owner)); + } + @Test void nativeHandlerConstructedOverAnEmptyCacheDoesNotClaimUnobservedDiskPermissions() throws Exception { + var emptyHandler = restoreNative(); assertTrue(emptyHandler.getPermsToAdd().isEmpty()); + UUID owner = UUID.randomUUID(); Files.writeString(file(), permissionRows(owner, "perk.recovered", System.currentTimeMillis() + 600_000).saveToString()); + await(runtime.initialize(state)); + assertTrue(emptyHandler.getPermsToAdd().containsKey(owner)); + assertTrue(nativePending(emptyHandler, owner).containsKey("perk.recovered")); + assertNull(disk().get("TimedPermissions"), "Rows may clear only after the real native pending owner restored them"); + } + @ParameterizedTest @ValueSource(strings = {"empty", "older"}) + void drainedPredecessorRowsRestoreRenewRemoveAndPreserveSuccessorChanges(String initial) throws Exception { + UUID renewed = UUID.randomUUID(), fresh = UUID.randomUUID(), revoked = UUID.randomUUID(), removed = UUID.randomUUID(), local = UUID.randomUUID(); + long now = System.currentTimeMillis(); var older = new YamlConfiguration(); older.set("PrevDay", 9); + if (initial.equals("older")) { + older.set("TimedPermissions." + renewed, List.of("perk.renewed%line%" + (now + 600_000))); + older.set("TimedPermissions." + revoked, List.of("perk.revoked|perk.retained%line%" + (now + 600_000))); + older.set("TimedPermissions." + removed, List.of("perk.removed%line%" + (now + 600_000))); + older.set("TimedPermissions." + local, List.of("perk.local%line%" + (now + 600_000))); + } + if (initial.equals("empty")) { setupNativeFile(true); ordered.setLastUpdated(); } + else legacy(older); + var handler = restoreNative(); + if (initial.equals("older")) { + handler.removePermission(revoked, null, "perk.revoked"); + handler.addPermission(local, "perk.local", com.bencodez.simpleapi.time.ParsedDuration.ofMillis(1_200_000)); + assertTrue(handler.getPermsToAdd().containsKey(revoked), "Keep another node so owner-presence alone cannot detect revocation"); + } + var newest = new YamlConfiguration(); newest.set("PrevDay", 10); + newest.set("TimedPermissions." + renewed, List.of("perk.renewed%line%" + (now + 900_000))); + newest.set("TimedPermissions." + fresh, List.of("perk.fresh%line%" + (now + 900_000))); + if (initial.equals("older")) { + newest.set("TimedPermissions." + revoked, List.of("perk.revoked|perk.retained%line%" + (now + 900_000))); + newest.set("TimedPermissions." + local, List.of("perk.local%line%" + (now + 900_000))); + } + OrderedStorage predecessor = state; + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + var published = predecessor.runtimeStorage(() -> { + entered.countDown(); + try { + if (!release.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("test timeout"); + Path staged = Files.createTempFile(directory, "predecessor-final-", ".tmp"); + Files.writeString(staged, newest.saveToString()); DurableFeatureFiles.publish(staged, file()); + return null; + } catch (Exception failure) { throw new CompletionException(failure); } + }); + assertTrue(entered.await(10, TimeUnit.SECONDS)); + var retiring = CompletableFuture.runAsync(predecessor::close); + state = new OrderedStorage(directory.resolve("features.dat")); when(plugin.getStorage()).thenReturn(state); + var initialization = runtime.initialize(state); + assertFalse(initialization.isDone()); assertFalse(runtime.isReady()); + try { release.countDown(); await(retiring); await(published); await(initialization); } + finally { release.countDown(); } + assertTrue(nativePending(handler, renewed).get("perk.renewed") > 800_000); + assertTrue(nativePending(handler, fresh).get("perk.fresh") > 800_000); + if (initial.equals("older")) { + assertFalse(nativePending(handler, revoked).containsKey("perk.revoked")); + assertTrue(nativePending(handler, revoked).get("perk.retained") > 800_000); + assertFalse(handler.getPermsToAdd().containsKey(removed)); + assertEquals(1_200_000, nativePending(handler, local).get("perk.local")); + } + assertNull(disk().get("TimedPermissions")); assertEquals(10, disk().getInt("PrevDay")); + handler.shutDown(); await(state.runtimeStorage(() -> null)); + assertFalse(disk().getStringList("TimedPermissions." + fresh).isEmpty()); + assertFalse(disk().getStringList("TimedPermissions." + renewed).isEmpty()); + assertTrue(disk().getStringList("TimedPermissions." + revoked).stream().noneMatch(row -> row.startsWith("perk.revoked%line%"))); + live.loadFromString(Files.readString(file())); var restarted = restoreNative(); + assertTrue(nativePending(restarted, fresh).containsKey("perk.fresh")); + assertTrue(nativePending(restarted, renewed).containsKey("perk.renewed")); + assertFalse(nativePending(restarted, revoked).containsKey("perk.revoked")); + assertTrue(owners.tasks.isEmpty()); + } + @Test void reconciledOnlineAttachmentRunsOnRealNativeOwnerAndRetiredLifecyclePreservesDisk() throws Exception { + UUID owner = UUID.randomUUID(); var handler = restoreNative(); + long expiry = System.currentTimeMillis() + 600_000; + Files.writeString(file(), permissionRows(owner, "perk.recovered", expiry).saveToString()); + byte[] original = Files.readAllBytes(file()); + var player = mock(org.bukkit.entity.Player.class); when(player.getUniqueId()).thenReturn(owner); when(player.isValid()).thenReturn(true); + var attachment = mock(org.bukkit.permissions.PermissionAttachment.class); when(attachment.getPermissions()).thenReturn(new HashMap<>()); + when(player.addAttachment(plugin)).thenAnswer(call -> { assertTrue(ownerContext); return attachment; }); + doAnswer(call -> { assertTrue(ownerContext); return null; }).when(attachment).setPermission(anyString(), anyBoolean()); + var server = Bukkit.getServer(); + when(server.getPlayer(owner)).thenAnswer(call -> { assertTrue(ownerContext, "Player lookup must not run on feature storage"); return player; }); + try { + var initialization = runtime.initialize(state); + await(initialization); + assertTrue(handler.getPerms().containsKey(owner)); + verify(player).addAttachment(plugin); verify(attachment).setPermission("perk.recovered", true); + assertNull(disk().get("TimedPermissions")); + assertTrue(handler.getPerms().get(owner).getTimedPermissions().get("perk.recovered") >= expiry - 1000); + // A new lifecycle cannot inherit an in-flight restore callback or clear its predecessor evidence. + Files.write(file(), original); live.set("TimedPermissions", null); + var retired = runtime.initialize(state); + Runnable global = owners.tasks.poll(10, TimeUnit.SECONDS); assertNotNull(global); + when(plugin.isEnabled()).thenReturn(false); onOwner(global); + assertThrows(ExecutionException.class, () -> await(retired)); assertArrayEquals(original, Files.readAllBytes(file())); + } finally { doReturn(null).when(server).getPlayer(owner); } + } + @Test void nativeRecoveredOwnersAdmitOnlyOneCallbackAtATime() throws Exception { + var handler = restoreNative(); var yaml = new YamlConfiguration(); + long expiry = System.currentTimeMillis() + 600_000; + for (int i = 0; i < 1024; i++) yaml.set("TimedPermissions." + UUID.randomUUID(), List.of("perk.recovered%line%" + expiry)); + Files.writeString(file(), yaml.saveToString()); await(runtime.initialize(state)); + assertEquals(1024, handler.getPermsToAdd().size()); assertTrue(maximumOwnerCallbacks <= 1); + assertNull(disk().get("TimedPermissions")); assertTrue(owners.tasks.isEmpty()); + } + @Test void malformedNativeSnapshotSchemaFailsBeforeClearingValidRecoveredDisk() throws Exception { + var handler = restoreNative(); UUID owner = UUID.randomUUID(); + Files.writeString(file(), permissionRows(owner, "perk.recovered", System.currentTimeMillis() + 600_000).saveToString()); + byte[] original = Files.readAllBytes(file()); + var handle = mock(com.bencodez.advancedcore.api.permissions.PlayerPermissionHandler.class); + var method = com.bencodez.advancedcore.api.permissions.PlayerPermissionHandler.class.getDeclaredMethod("offlinePermissionSnapshot"); + assertTrue(method.trySetAccessible()); + // Invoke the real bundled method signature through Mockito's stub boundary, returning hostile schema. + doReturn(Map.of("perk.invalid", "not-a-long")).when(handle); method.invoke(handle); + handler.getPermsToAdd().put(owner, handle); + var failure = assertThrows(ExecutionException.class, () -> await(runtime.initialize(state))); + assertTrue(failure.getCause().toString().contains("Invalid native permission snapshot")); + assertFalse(runtime.isReady()); assertArrayEquals(original, Files.readAllBytes(file())); + } + @ParameterizedTest @ValueSource(strings = {"expired", "revoked"}) + void oversizedNativeLegacyConsumedPermissionsMigrateWithoutBlockingLegacyActivation(String consumed) throws Exception { + UUID owner = UUID.randomUUID(); String largePermission = "perk." + "雪".repeat(1_400_000); + var yaml = permissionRows(owner, largePermission, System.currentTimeMillis() + (consumed.equals("expired") ? -600_000 : 600_000)); + legacy(yaml); assertTrue(Files.size(file()) > 4_000_000); var handler = restoreNative(); + if (consumed.equals("revoked")) handler.removePermission(owner, null, largePermission); + assertFalse(handler.getPermsToAdd().containsKey(owner)); await(runtime.initialize(state)); + assertTrue(runtime.isReady()); assertTrue(Files.size(file()) <= 4_000_000); assertNull(disk().get("TimedPermissions")); + assertEquals(1234, disk().getLong("Experation.legacy")); assertEquals(5678, disk().getLong("legacy.CoolDown")); assertEquals(9, disk().getInt("PrevDay")); + await(runtime.confirmedActivation(() -> runtime.setServerCooldown("legacy", 6789))); + handler.shutDown(); await(state.runtimeStorage(() -> null)); + live.loadFromString(Files.readString(file())); assertFalse(restoreNative().getPermsToAdd().containsKey(owner)); + assertEquals(6789, disk().getLong("legacy.CoolDown")); + } + @Test void oversizedStillActivePermissionPayloadRetainsOriginalAndReportsShutdownFailure() throws Exception { + UUID owner = UUID.randomUUID(); legacy(permissionRows(owner, "perk." + "雪".repeat(1_400_000), System.currentTimeMillis() + 600_000)); + byte[] original = Files.readAllBytes(file()); var handler = restoreNative(); login(handler, owner); + var failure = assertThrows(ExecutionException.class, () -> await(runtime.initialize(state))); + assertTrue(failure.getCause().getMessage().contains("still-active TimedPermissions")); + assertFalse(runtime.isReady()); assertArrayEquals(original, Files.readAllBytes(file())); + var logger = mock(Logger.class); when(plugin.getLogger()).thenReturn(logger); + handler.shutDown(); verify(logger).warning(contains("original file retained")); + assertArrayEquals(original, Files.readAllBytes(file())); + } + @Test void oversizedLegacyAdvisoryHistoryMigratesWithIndispensableFieldsIntact() throws Exception { + var yaml = new YamlConfiguration(); yaml.set("PerkHistory.OCTOBER", List.of("雪".repeat(1_400_000))); + yaml.set("Experation.legacy", 1234); yaml.set("legacy.CoolDown", 5678); yaml.set("TimeTransitions.DAY.Pending", true); + legacy(yaml); assertTrue(Files.size(file()) > 4_000_000); await(runtime.initialize(state)); + assertTrue(runtime.isReady()); assertTrue(Files.size(file()) <= 4_000_000); + assertEquals(1234, disk().getLong("Experation.legacy")); assertEquals(5678, disk().getLong("legacy.CoolDown")); + assertTrue(disk().getBoolean("TimeTransitions.DAY.Pending")); assertTrue(disk().getStringList("PerkHistory.OCTOBER").isEmpty()); + await(runtime.initialize(state)); assertTrue(runtime.isReady()); + } + @Test void markedOversizedRecoveryAndIrreducibleLegacyPayloadStayUnchanged() throws Exception { + String payload = "LegacyPayload: '" + "雪".repeat(1_400_000) + "'\n"; + for (String header : List.of("# MCPerks ordered ServerData v1\n", "")) { + Files.writeString(file(), header + payload); byte[] original = Files.readAllBytes(file()); + assertThrows(ExecutionException.class, () -> await(runtime.initialize(state))); + assertFalse(runtime.isReady()); assertArrayEquals(original, Files.readAllBytes(file())); + } + } + @ParameterizedTest @ValueSource(strings = {"malformed", "symlink", "native-unreadable"}) + void legacyMigrationDoesNotWeakenReadFailures(String invalid) throws Exception { + Files.writeString(file(), "PrevDay: 9\n"); + switch (invalid) { + case "malformed" -> Files.writeString(file(), "broken: [unterminated\n"); + case "symlink" -> { Path target = directory.resolve("target.yml"); Files.move(file(), target); Files.createSymbolicLink(file(), target); } + case "native-unreadable" -> when(originalFile.isFailedToRead()).thenReturn(true); + default -> fail("unknown invalid case"); + } + byte[] original = Files.readAllBytes(file()); assertThrows(ExecutionException.class, () -> await(runtime.initialize(state))); + assertFalse(runtime.isReady()); assertArrayEquals(original, Files.readAllBytes(file())); + } +} diff --git a/MCPerks/src/test/java/com/bencodez/mcperks/tests/perk/EffectHandlerRestorationTest.java b/MCPerks/src/test/java/com/bencodez/mcperks/tests/perk/EffectHandlerRestorationTest.java new file mode 100644 index 0000000..ff20b97 --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/perk/EffectHandlerRestorationTest.java @@ -0,0 +1,91 @@ +package com.bencodez.mcperks.tests.perk; +import com.bencodez.mcperks.perk.*; +import com.bencodez.mcperks.perk.Effect; + +import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.*; +import java.util.*; +import org.bukkit.Bukkit; +import org.bukkit.Server; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; +import com.bencodez.mcperks.MCPerksMain; +import com.bencodez.mcperks.userapi.MCPerksUser; +import com.bencodez.advancedcore.api.rewards.RewardBuilder; +import com.bencodez.advancedcore.api.misc.MiscUtils; + +class EffectHandlerRestorationTest { + @Test void restorationSkipsOneShotEffectsButAppliesPersistentAudienceEffect() { + UUID id = UUID.randomUUID(); Player player = mock(Player.class); Server server = mock(Server.class); + when(player.getUniqueId()).thenReturn(id); when(player.getName()).thenReturn("owner"); + var world = mock(org.bukkit.World.class); when(world.getName()).thenReturn("world"); when(player.getWorld()).thenReturn(world); + var scheduler = mock(org.bukkit.scheduler.BukkitScheduler.class); + doAnswer(inv -> { inv.getArgument(1, Runnable.class).run(); return null; }).when(scheduler).runTask(any(), any(Runnable.class)); + MCPerksMain plugin = mock(MCPerksMain.class); MCPerksMain.plugin = plugin; + var users = mock(com.bencodez.mcperks.userapi.UserManager.class); + when(plugin.getMcperksUserManager()).thenReturn(users); when(users.getMCPerksUser(player)).thenReturn(mock(MCPerksUser.class)); + Perk perk = mock(Perk.class); MCPerksUser user = mock(MCPerksUser.class); + when(perk.getEffects()).thenReturn(new ArrayList<>(List.of(Effect.Fly, Effect.Rewards, Effect.Commands, Effect.GlobalCommands, Effect.CureHunger, Effect.CureSpells, Effect.HealAll))); + when(perk.getEffectedPlayers()).thenReturn(new ArrayList<>(List.of(id.toString()))); + when(perk.getFlyWorlds()).thenReturn(new ArrayList<>(List.of("world"))); when(perk.getPerk()).thenReturn("restore"); + when(perk.getActivater()).thenReturn(user); when(user.getPlayerName()).thenReturn("owner"); + when(perk.getCommands()).thenReturn(new ArrayList<>(List.of("say %player%"))); when(perk.getGlobalCommands()).thenReturn(new ArrayList<>(List.of("say global"))); + try (var bukkit = mockStatic(Bukkit.class); var misc = mockStatic(MiscUtils.class); + var rewards = mockConstruction(RewardBuilder.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + bukkit.when(() -> Bukkit.getPlayer(id)).thenReturn(player); + var utils = mock(MiscUtils.class); misc.when(MiscUtils::getInstance).thenReturn(utils); + var config = mock(com.bencodez.mcperks.configs.ConfigPerks.class); + try (var configs = mockStatic(com.bencodez.mcperks.configs.ConfigPerks.class)) { + configs.when(com.bencodez.mcperks.configs.ConfigPerks::getInstance).thenReturn(config); + when(config.getData("restore")).thenReturn(new YamlConfiguration()); + when(config.getPerkRewardsPath("restore")).thenReturn("Rewards"); + var handler = new EffectHandler(plugin); + handler.activate(perk, user, false); + verify(player).setAllowFlight(true); + verify(utils, never()).executeConsoleCommands(any(Player.class), any(ArrayList.class), any(HashMap.class), eq(false)); + verify(utils, never()).executeConsoleCommands(any(ArrayList.class), any(HashMap.class), eq(false)); + assertTrue(rewards.constructed().isEmpty()); + verify(player, never()).setFoodLevel(anyInt()); + verify(player, never()).setHealth(anyDouble()); + verify(player, never()).removePotionEffect(any()); + when(perk.getEffects()).thenReturn(new ArrayList<>(List.of(Effect.Fly, Effect.Rewards, Effect.Commands, Effect.GlobalCommands))); + handler.activate(perk, user, true); + verify(utils, times(1)).executeConsoleCommands(any(Player.class), any(ArrayList.class), any(HashMap.class), eq(false)); + verify(utils, times(1)).executeConsoleCommands(any(ArrayList.class), any(HashMap.class), eq(false)); + assertEquals(1, rewards.constructed().size()); + } + } + } + @Test void normalActivationStillAppliesHungerHealingAndCures() { + UUID id = UUID.randomUUID(); Player player = mock(Player.class); + MCPerksMain plugin = mock(MCPerksMain.class); MCPerksMain.plugin = plugin; + Perk perk = mock(Perk.class); MCPerksUser user = mock(MCPerksUser.class); + var scheduler = mock(org.bukkit.scheduler.BukkitScheduler.class); + doAnswer(inv -> { inv.getArgument(1, Runnable.class).run(); return null; }) + .when(scheduler).runTask(any(), any(Runnable.class)); + var version = mock(com.bencodez.mcperks.version.VersionHandle.class); + when(plugin.getVersionHandle()).thenReturn(version); + var health = mock(org.bukkit.attribute.AttributeInstance.class); + when(player.getAttribute(any())).thenReturn(health); when(health.getValue()).thenReturn(20.0); + when(perk.getEffects()).thenReturn(new ArrayList<>(List.of(Effect.CureHunger, Effect.HealAll, Effect.CureSpells))); + when(perk.getEffectedPlayers()).thenReturn(new ArrayList<>(List.of(id.toString()))); + try (var bukkit = mockStatic(Bukkit.class); var misc = mockStatic(MiscUtils.class)) { + bukkit.when(() -> Bukkit.getRegistry(any())).thenAnswer(inv -> { + var registry = mock(org.bukkit.Registry.class); + when(registry.getOrThrow(any())).thenAnswer(key -> mock(org.bukkit.potion.PotionEffectType.class)); + return registry; + }); + when(player.hasPotionEffect(org.bukkit.potion.PotionEffectType.BLINDNESS)).thenReturn(true); + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + bukkit.when(() -> Bukkit.getPlayer(id)).thenReturn(player); + misc.when(MiscUtils::getInstance).thenReturn(mock(MiscUtils.class)); + new EffectHandler(plugin).activate(perk, user, true); + verify(player).setFoodLevel(20); + verify(player).setHealth(20.0); + verify(player).removePotionEffect(org.bukkit.potion.PotionEffectType.BLINDNESS); + } + } + +} diff --git a/MCPerks/src/test/java/com/bencodez/mcperks/tests/perk/NativePersistenceLifecycleTest.java b/MCPerks/src/test/java/com/bencodez/mcperks/tests/perk/NativePersistenceLifecycleTest.java new file mode 100644 index 0000000..db16223 --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/perk/NativePersistenceLifecycleTest.java @@ -0,0 +1,618 @@ +package com.bencodez.mcperks.tests.perk; +import static com.bencodez.mcperks.tests.TestAccess.*; +import com.bencodez.mcperks.perk.*; +import com.bencodez.mcperks.perk.Effect; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.nio.file.*; +import java.sql.Timestamp; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Logger; +import org.bukkit.*; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import com.bencodez.mcperks.MCPerksMain; +import com.bencodez.mcperks.configs.Config; +import com.bencodez.mcperks.data.*; +import com.bencodez.mcperks.userapi.*; +import com.bencodez.advancedcore.api.user.UserData; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.core.user.storage.SqlUserStorage; +import com.bencodez.mcperks.tests.scheduling.OwnerTaskHarness; +class NativePersistenceLifecycleTest { + @Test void uncachedLegacyShutdownCompletesAfterDirectExpirationWrite() throws Exception { + runtimeFile(); + when(perk.getActivater()).thenReturn(user); + when(perk.getPerkType()).thenReturn(PerkSystemType.PLAYER); + when(perk.getExperation(user)).thenReturn(987654L); + when(dataManager.getCache(owner)).thenReturn(null); + doCallRealMethod().when(perk).persistForShutdown(); + doCallRealMethod().when(perk).setExperation(user, 0); + perk.persistForShutdown().get(5, TimeUnit.SECONDS); + verify(user).setPerkExperation(perk, 0); + assertEquals(987654L, plugin.getMcperksServerData().getData().getLong( + "OfflinePerk." + owner + ".vein.Experiaton")); + } + @Test void missingSharedShutdownCacheCannotBeAcknowledgedAsDurable() throws Exception { + runtimeFile(); + when(perk.getActivater()).thenReturn(user); + when(perk.getPerkType()).thenReturn(PerkSystemType.PLAYER); + when(perk.getExperation(user)).thenReturn(987654L); + when(dataManager.hasSharedSqlBackend()).thenReturn(true); + when(dataManager.getCache(owner)).thenReturn(null); + doCallRealMethod().when(perk).persistForShutdown(); + doCallRealMethod().when(perk).setExperation(user, 0); + var failure = assertThrows(ExecutionException.class, () -> perk.persistForShutdown().get(5, TimeUnit.SECONDS)); + assertInstanceOf(IllegalStateException.class, failure.getCause()); + } + @TempDir Path directory; + private UUID owner; private Player player; private MCPerksMain plugin; private MCPerksUser user; + private Perk perk; private Config config; private PerkHandler handler; private UserDataManager dataManager; + private OrderedStorage state; private OwnerTaskHarness scheduler; + private AtomicInteger personal, global, activated; private Runnable afterDebit = () -> {}; + @BeforeEach void setup() throws Exception { + if (Bukkit.getServer() == null) { Server server = mock(Server.class); when(server.getLogger()).thenReturn(Logger.getLogger("test")); Bukkit.setServer(server); } + owner = UUID.randomUUID(); player = mock(Player.class); when(Bukkit.getServer().getPlayer(owner)).thenReturn(player); when(player.getUniqueId()).thenReturn(owner); when(player.isOnline()).thenReturn(true); when(player.hasPermission(anyString())).thenReturn(true); + World world = mock(World.class); when(world.getName()).thenReturn("world"); when(player.getWorld()).thenReturn(world); + plugin = mock(MCPerksMain.class); when(plugin.isEnabled()).thenReturn(true); when(plugin.getLogger()).thenReturn(Logger.getLogger("test")); + initializeAdvancedCoreLifecycleLock(plugin); + ServerData serverData = mock(ServerData.class); when(serverData.isReady()).thenReturn(true); when(serverData.getData()).thenReturn(new org.bukkit.configuration.file.YamlConfiguration()); when(plugin.getMcperksServerData()).thenReturn(serverData); + config = mock(Config.class); when(config.getLimitActivations()).thenReturn(true); when(plugin.getConfigFile()).thenReturn(config); + user = mock(MCPerksUser.class); when(user.getUUID()).thenReturn(owner.toString()); when(user.getActivations("vein")).thenReturn(1); when(user.getActivations()).thenReturn(5); when(user.getPerkLimit()).thenReturn(-1); + com.bencodez.mcperks.userapi.UserManager manager = mock(com.bencodez.mcperks.userapi.UserManager.class); when(plugin.getMcperksUserManager()).thenReturn(manager); when(manager.getMCPerksUser(owner)).thenReturn(user); when(manager.getMCPerksUser(player)).thenReturn(user); + com.bencodez.advancedcore.api.user.UserManager baseManager = mock(com.bencodez.advancedcore.api.user.UserManager.class); when(plugin.getUserManager()).thenReturn(baseManager); + dataManager = mock(UserDataManager.class); when(baseManager.getDataManager()).thenReturn(dataManager); when(dataManager.hasSharedRuntime()).thenReturn(true); var cache = mock(com.bencodez.advancedcore.api.user.usercache.UserDataCache.class); + var storageWorker = mock(java.util.concurrent.ScheduledExecutorService.class); + doAnswer(call -> { call.getArgument(0, Runnable.class).run(); return null; }) + .when(storageWorker).execute(any(Runnable.class)); + when(dataManager.getTimer()).thenReturn(storageWorker); + when(dataManager.getPublishedCache(owner)).thenReturn(cache); when(dataManager.getCache(owner)).thenReturn(cache); + doAnswer(i -> { i.getArgument(0, Runnable.class).run(); return null; }).when(cache).flushChangesAndRun(any(Runnable.class)); + personal = new AtomicInteger(1); global = new AtomicInteger(5); activated = new AtomicInteger(); when(user.getData()).thenReturn(mock(UserData.class)); + when(dataManager.withAtomicUserTransaction(eq(owner), any(), anyMap(), any())).thenAnswer(invocation -> { + SqlUserStorage.TransactionScope transaction = mock(SqlUserStorage.TransactionScope.class); com.bencodez.simpleapi.sql.Column column = mock(com.bencodez.simpleapi.sql.Column.class); + when(column.getName()).thenReturn("PerkActivations_vein"); when(column.getValue()).thenAnswer(i -> new com.bencodez.simpleapi.sql.data.DataValueInt(personal.get())); when(transaction.readRow()).thenReturn(List.of(column)); + doAnswer(i -> { Map values = i.getArgument(0); personal.set(values.get("PerkActivations_vein").getInt()); afterDebit.run(); return null; }).when(transaction).writeValues(anyMap()); + SqlUserStorage.TransactionWork work = invocation.getArgument(3); return work.run(transaction); + }); + perk = mock(Perk.class); when(perk.getPerk()).thenReturn("vein"); when(perk.getPerkType()).thenReturn(PerkSystemType.PLAYER); when(perk.getTime()).thenReturn(10); when(perk.isNotInDisabledWorld("world")).thenReturn(true); + when(perk.executeConfirmed(user,10)).thenAnswer(i -> { activated.incrementAndGet(); return CompletableFuture.completedFuture(null); }); + handler = mock(PerkHandler.class); when(handler.isRestorationReady()).thenReturn(true); when(handler.retireNativeActivations()).thenReturn(CompletableFuture.completedFuture(null)); handler.que = new HashMap<>(); when(plugin.getPerkHandler()).thenReturn(handler); when(handler.getPerk("vein")).thenReturn(perk); when(handler.getActivePerks()).thenReturn(new CopyOnWriteArrayList<>()); + scheduler = new OwnerTaskHarness(plugin, player); + state = new OrderedStorage(directory.resolve("features.dat")); state.ready().get(5,TimeUnit.SECONDS); when(plugin.getStorage()).thenReturn(state); + } + + private static void initializeAdvancedCoreLifecycleLock(MCPerksMain plugin) throws Exception { + try { + var lock = com.bencodez.advancedcore.AdvancedCorePlugin.class.getDeclaredField("lifecycleLock"); + lock.setAccessible(true); + lock.set(plugin, new Object()); + } catch (NoSuchFieldException ignored) { + // Older AdvancedCore artifacts do not have the split lifecycle lock. + } + } + @AfterEach void close() throws Exception { + state.close(); + // Closing admits worker retirement; await actual termination before JUnit removes its owned files. + var worker = OrderedStorage.class.getDeclaredField("worker"); worker.setAccessible(true); + assertTrue(((ExecutorService) worker.get(state)).awaitTermination(5, TimeUnit.SECONDS)); + } + private Runnable next() throws Exception { return Objects.requireNonNull(scheduler.tasks.poll(5, TimeUnit.SECONDS)); } + private void drainUntil(java.util.function.BooleanSupplier done) throws Exception { drainUntil(done, 5); } + private void drainUntil(java.util.function.BooleanSupplier done, int timeoutSeconds) throws Exception { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds); + while (!done.getAsBoolean() && System.nanoTime() < deadline) { Runnable task = scheduler.tasks.poll(20, TimeUnit.MILLISECONDS); if (task != null) { task.run(); task.run(); } } + assertTrue(done.getAsBoolean(), "condition not reached"); state.runtimeStorage(() -> null).get(5, TimeUnit.SECONDS); + } + + + + + + + + + + + + + + + + + + + + + private com.bencodez.advancedcore.data.ServerData runtimeFile() throws Exception { + var nativeFile=mock(com.bencodez.advancedcore.data.ServerData.class); + var yaml=spy(new org.bukkit.configuration.file.YamlConfiguration()); + when(nativeFile.getData()).thenReturn(yaml); when(nativeFile.getdFile()).thenReturn(directory.resolve("ServerData.yml").toFile()); + when(plugin.getDataFolder()).thenReturn(directory.toFile()); + when(plugin.getServerDataFile()).thenReturn(nativeFile); + var checker=mock(com.bencodez.advancedcore.api.time.TimeChecker.class); + when(checker.getTime()).thenReturn(java.time.LocalDateTime.of(2026,10,8,12,0)); when(plugin.getTimeChecker()).thenReturn(checker); + when(plugin.getMcperksServerData()).thenReturn(new ServerData(plugin)); + var field=Perk.class.getDeclaredField("plugin"); field.setAccessible(true); field.set(perk,plugin); + var admissions=Perk.class.getDeclaredField("activationAdmissions"); admissions.setAccessible(true); admissions.set(perk,new LinkedHashMap()); + doCallRealMethod().when(perk).executeConfirmed(user,10); + doAnswer(call -> { scheduler.tasks.add(call.getArgument(1, Runnable.class)); return null; }).when(scheduler.scheduler).runTask(eq(plugin), any(Runnable.class)); + return nativeFile; + } + + + + + + + + + + + + + + + + + + + + + + @Test void inheritedCalendarTransitionWaitsBehindOlderRuntimeSnapshotAndStaysCompleteAfterRestart() throws Exception { + var nativeFile=runtimeFile(); var runtime=plugin.getMcperksServerData(); OrderedServerDataFile ordered; + try (var constructors=mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) { + ordered=new OrderedServerDataFile(plugin,nativeFile,runtime); + } + when(plugin.getServerDataFile()).thenReturn(ordered); ordered.setPrevDay(8); + var entered=new CountDownLatch(1); var release=new CountDownLatch(1); var calendarEntered=new CountDownLatch(1); + state.runtimeStorage(() -> { entered.countDown(); try { if (!release.await(5,TimeUnit.SECONDS)) throw new IllegalStateException("timeout"); } + catch (InterruptedException error) { throw new RuntimeException(error); } return null; }); + assertTrue(entered.await(5,TimeUnit.SECONDS)); runtime.setPerkExperation(perk,123456); + var calendar=CompletableFuture.supplyAsync(() -> { + calendarEntered.countDown(); return ordered.beginTimeChangeTransition(com.bencodez.advancedcore.api.time.TimeType.DAY,"2026-10-09","9"); + }); + try { + assertTrue(calendarEntered.await(5,TimeUnit.SECONDS)); assertThrows(TimeoutException.class, () -> calendar.get(100,TimeUnit.MILLISECONDS)); + } finally { release.countDown(); } + var transition=calendar.get(5,TimeUnit.SECONDS); + ordered.failTimeChangeTransition(transition); ordered.completeTimeChangeTransition(transition); + state.runtimeStorage(() -> null).get(5,TimeUnit.SECONDS); + var written=org.bukkit.configuration.file.YamlConfiguration.loadConfiguration(directory.resolve("ServerData.yml").toFile()); + assertEquals(123456,written.getLong("Experation.vein")); assertEquals(9,written.getInt("PrevDay")); + when(nativeFile.getData()).thenReturn(written); + assertNull(ordered.getPendingTimeChangeTransition(com.bencodez.advancedcore.api.time.TimeType.DAY)); + assertFalse(ordered.isPendingTimeChangeTransition(transition)); verify(nativeFile,never()).saveData(); + } + @Test void inheritedCalendarCompletionRollsBackLiveMarkerWhenOrderedPublicationFails() throws Exception { + var nativeFile=runtimeFile(); var runtime=plugin.getMcperksServerData(); OrderedServerDataFile ordered; + try (var constructors=mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) { + ordered=new OrderedServerDataFile(plugin,nativeFile,runtime); + } + when(plugin.getServerDataFile()).thenReturn(ordered); ordered.setPrevDay(8); + var transition=ordered.beginTimeChangeTransition(com.bencodez.advancedcore.api.time.TimeType.DAY,"2026-10-09","9"); + Files.delete(directory.resolve("ServerData.yml")); Files.createDirectory(directory.resolve("ServerData.yml")); + assertThrows(CompletionException.class, () -> ordered.completeTimeChangeTransition(transition)); + assertEquals(8,ordered.getPrevDay()); assertTrue(ordered.isPendingTimeChangeTransition(transition)); + assertEquals(transition.id(),ordered.getPendingTimeChangeTransition(com.bencodez.advancedcore.api.time.TimeType.DAY).id()); + } + + + + + @Test void activeLegacyShutdownReadsExpirationAndDeactivatesBeforeNativeStorageRetirement() throws Exception { + runtimeFile(); + MCPerksMain previous = MCPerksMain.plugin; MCPerksMain.plugin = plugin; + var stateField = MCPerksMain.class.getDeclaredField("storage"); + stateField.setAccessible(true); stateField.set(plugin, state); + var retired = new java.util.concurrent.atomic.AtomicBoolean(); + var deactivated = new AtomicInteger(); + // Bukkit marks a plugin disabled before calling onDisable; do not rely on isEnabled. + when(plugin.isEnabled()).thenReturn(false); + when(perk.getActivater()).thenReturn(user); + when(perk.getKnownExpiration()).thenReturn(1000L); + when(perk.getExperation(user)).thenAnswer(call -> { + assertFalse(retired.get(), "expiration read after native storage retirement"); + return 987654L; // Authoritative extension, deliberately different from an old cached value. + }); + when(perk.detachForShutdown()).thenAnswer(call -> { + assertFalse(retired.get(), "effect cleanup admitted after native storage retirement"); + deactivated.incrementAndGet(); return true; + }); + doCallRealMethod().when(perk).persistForShutdown(); + doCallRealMethod().when(perk).setExperation(user, 0); + when(handler.getActivePerks()).thenReturn(new CopyOnWriteArrayList<>(List.of(perk))); + try (var lifecycle = mockConstruction(com.bencodez.advancedcore.lifecycle.AdvancedCoreLifecycle.class, + (cleanup, context) -> doAnswer(call -> { + plugin.onBeforeStorageShutdown().toCompletableFuture().get(5, TimeUnit.SECONDS); + retired.set(true); plugin.onUnLoad(); plugin.onShutdownComplete(); return null; + }).when(cleanup).shutdown(anyLong()))) { + doCallRealMethod().when(plugin).onUnLoad(); doCallRealMethod().when(plugin).onDisable(); + doCallRealMethod().when(plugin).onBeforeStorageShutdown(); doCallRealMethod().when(plugin).onShutdownComplete(); + assertDoesNotThrow(plugin::onDisable); + assertEquals(1, deactivated.get()); + assertEquals(987654L, plugin.getMcperksServerData().getData().getLong( + "OfflinePerk." + owner + ".vein.Experiaton")); + verify(perk, times(1)).detachForShutdown(); verify(perk, never()).deactivatePerk(any()); + verify(perk, times(1)).getExperation(user); verify(user).setPerkExperation(perk, 0); + } finally { MCPerksMain.plugin = previous; } + } + + @Test void deferredNativeCleanupKeepsFileOwnerOpenUntilLegacyShutdownPersistenceSettles() throws Exception { + verifyDeferredShutdownPersistence(false); + } + + @Test void deferredSharedPerkShutdownUsesCapturedOwnerAfterStaticPluginIsCleared() throws Exception { + verifyDeferredShutdownPersistence(true); + } + + private void verifyDeferredShutdownPersistence(boolean shared) throws Exception { + runtimeFile(); + MCPerksMain previous = MCPerksMain.plugin; MCPerksMain.plugin = plugin; + var stateField = MCPerksMain.class.getDeclaredField("storage"); + stateField.setAccessible(true); stateField.set(plugin, state); + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + state.runtimeStorage(() -> { + entered.countDown(); + try { if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("timeout"); } + catch (InterruptedException error) { throw new RuntimeException(error); } + return null; + }); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + when(plugin.isEnabled()).thenReturn(false); + when(perk.getActivater()).thenReturn(user); + if (shared) { + when(perk.getPerkType()).thenReturn(PerkSystemType.ALL); + plugin.getMcperksServerData().setPerkExperation(perk, 987654L); + doCallRealMethod().when(perk).getExperation(user); + } else when(perk.getExperation(user)).thenReturn(987654L); + when(perk.detachForShutdown()).thenReturn(true); + doCallRealMethod().when(perk).persistForShutdown(); + doCallRealMethod().when(perk).setExperation(user, 0); + when(handler.getActivePerks()).thenReturn(new CopyOnWriteArrayList<>(List.of(perk))); + try (var lifecycle = mockConstruction(com.bencodez.advancedcore.lifecycle.AdvancedCoreLifecycle.class, + (cleanup, context) -> doAnswer(call -> { + assertFalse(plugin.onBeforeStorageShutdown().toCompletableFuture().isDone()); + // Native primary-thread cleanup does not synchronously wait for storage. + plugin.onUnLoad(); plugin.onShutdownComplete(); return null; + }).when(cleanup).shutdown(anyLong()))) { + doCallRealMethod().when(plugin).onUnLoad(); doCallRealMethod().when(plugin).onDisable(); + doCallRealMethod().when(plugin).onBeforeStorageShutdown(); doCallRealMethod().when(plugin).onShutdownComplete(); + plugin.onDisable(); + release.countDown(); + plugin.onBeforeStorageShutdown().toCompletableFuture().get(5, TimeUnit.SECONDS); + assertEquals(987654L, plugin.getMcperksServerData().getData().getLong( + "OfflinePerk." + owner + ".vein.Experiaton")); + if (shared) { + assertEquals(0, plugin.getMcperksServerData().getPerkExperation(perk)); + verify(user, never()).setPerkExperation(any(), anyLong()); + } else verify(user).setPerkExperation(perk, 0); + } finally { release.countDown(); MCPerksMain.plugin = previous; } + } + + @Test void gracefulPluginDisableRetainsTheWorkerThroughInheritedFinalRuntimeSave() throws Exception { + var nativeFile = runtimeFile(); MCPerksMain previous=MCPerksMain.plugin; MCPerksMain.plugin=plugin; + var stateField=MCPerksMain.class.getDeclaredField("storage"); stateField.setAccessible(true); stateField.set(plugin,state); + var runtime=plugin.getMcperksServerData(); runtime.setServerCooldown("vein",1234); + try (var constructors = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) { + var ordered = new com.bencodez.mcperks.data.OrderedServerDataFile(plugin, nativeFile, runtime); + when(plugin.getServerDataFile()).thenReturn(ordered); + } + CountDownLatch entered=new CountDownLatch(1), release=new CountDownLatch(1); + state.runtimeStorage(()->{ entered.countDown(); try { if(!release.await(5,TimeUnit.SECONDS)) throw new IllegalStateException("timeout"); } + catch(InterruptedException e) { throw new RuntimeException(e); } return null; }); + try (var lifecycle = mockConstruction(com.bencodez.advancedcore.lifecycle.AdvancedCoreLifecycle.class, + (cleanup, context) -> doAnswer(call -> { + plugin.onUnLoad(); + // The native PermissionHandler has this additional save after onUnLoad. + runtime.setServerCooldown("vein", 5678); + plugin.getServerDataFile().saveData(); + plugin.onShutdownComplete(); + return null; + }).when(cleanup).shutdown(anyLong()))) { + assertTrue(entered.await(5,TimeUnit.SECONDS)); + doCallRealMethod().when(plugin).onUnLoad(); doCallRealMethod().when(plugin).onDisable(); + doCallRealMethod().when(plugin).onShutdownComplete(); plugin.onDisable(); + assertEquals(1, lifecycle.constructed().size()); + assertFalse(Files.exists(directory.resolve("ServerData.yml"))); + try(OrderedStorage successor=new OrderedStorage(directory.resolve("features.dat"))) { + assertFalse(successor.ready().isDone()); release.countDown(); successor.ready().get(5,TimeUnit.SECONDS); + var written=org.bukkit.configuration.file.YamlConfiguration.loadConfiguration(directory.resolve("ServerData.yml").toFile()); + assertEquals(5678,written.getLong("vein.CoolDown")); + } + } finally { release.countDown(); MCPerksMain.plugin=previous; } + } + + @Test void successorRuntimeReconcilesConfirmedPredecessorDataAndCalendarChangesMadeDuringDrain() throws Exception { + var nativeFile=runtimeFile(); var entered=new CountDownLatch(1); var release=new CountDownLatch(1); + Path runtimePath=directory.resolve("ServerData.yml"); + state.runtimeStorage(()->{ entered.countDown(); try { if(!release.await(5,TimeUnit.SECONDS)) throw new IllegalStateException("timeout"); } + catch(InterruptedException e) { throw new RuntimeException(e); } return null; }); + assertTrue(entered.await(5,TimeUnit.SECONDS)); + plugin.getMcperksServerData().setPerkExperation(perk,987654); state.close(); + try(OrderedStorage successor=new OrderedStorage(directory.resolve("features.dat"))) { + when(plugin.getStorage()).thenReturn(successor); + // This is the stale cache AdvancedCore can read before the predecessor's final snapshot publishes. + var stale=new org.bukkit.configuration.file.YamlConfiguration(); stale.set("PrevDay",7); when(nativeFile.getData()).thenReturn(stale); + var restored=new ServerData(plugin); when(plugin.getMcperksServerData()).thenReturn(restored); + var initialized=restored.initialize(successor); assertFalse(initialized.isDone()); + OrderedServerDataFile ordered; + try(var constructors=mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) { ordered=new OrderedServerDataFile(plugin,nativeFile,restored); } + ordered.setPrevDay(9); assertFalse(restored.isReady()); + var premature=new AtomicInteger(); assertThrows(ExecutionException.class,()->restored.confirmedActivation(premature::incrementAndGet).get()); assertEquals(0,premature.get()); + release.countDown(); initialized.get(5,TimeUnit.SECONDS); assertTrue(restored.isReady()); + assertEquals(987654,restored.getPerkExperation(perk)); assertEquals(9,ordered.getPrevDay()); + var written=org.bukkit.configuration.file.YamlConfiguration.loadConfiguration(runtimePath.toFile()); + assertEquals(987654,written.getLong("Experation.vein")); assertEquals(9,written.getInt("PrevDay")); verify(nativeFile,never()).saveData(); + } finally { release.countDown(); } + } + + + private PerkHandler restorer(BlockingQueue restorationTasks, Perk legacy) throws Exception { + var actual = spy(newPerkHandler(plugin, List.of())); + var timer = PerkHandler.class.getDeclaredField("timer"); timer.setAccessible(true); ((Timer) timer.get(actual)).cancel(); + when(plugin.getPerkHandler()).thenReturn(actual); + actual.setPerk("vein", perk); actual.setPerk("permanent", legacy); + doNothing().when(actual).restoreActivePerk(any(), any(), any()); + when(plugin.getUserManager().getAllUUIDs()).thenReturn(new ArrayList<>(List.of(owner.toString()))); + doAnswer(i -> { restorationTasks.add(i.getArgument(1)); return null; }) + .when(scheduler.scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class)); + doAnswer(i -> { restorationTasks.add(i.getArgument(1)); return null; }) + .when(scheduler.scheduler).runTask(eq(plugin), any(Runnable.class)); + return actual; + } + private static void completeRestoration(BlockingQueue tasks, CompletableFuture restored) throws Exception { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (!restored.isDone() && System.nanoTime() < deadline) { + var task = tasks.poll(100, TimeUnit.MILLISECONDS); if (task != null) task.run(); + } + restored.get(5, TimeUnit.SECONDS); + } + private Perk permanent() { + var legacy = mock(Perk.class); when(legacy.getPerk()).thenReturn("permanent"); when(legacy.isEnabled()).thenReturn(true); + when(legacy.getExperation(user)).thenReturn(-1L); when(legacy.isTimed()).thenReturn(true); return legacy; + } + + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(longs = {-1L, -7L, 60_000L, 0L, -60_000L}) + void nativeExpirationWithoutShutdownRecordRestoresWithoutActivationReplay(long remaining) throws Exception { + var tasks = new LinkedBlockingQueue(); var legacy = permanent(); + long expiration = remaining > 0 ? System.currentTimeMillis() + remaining + : remaining == -60_000L ? System.currentTimeMillis() + remaining : remaining; + when(legacy.getExperation(user)).thenReturn(expiration); + var actual = restorer(tasks, legacy); + try { + var restoration = actual.loadActivePerksConfirmed(() -> {}); + completeRestoration(tasks, restoration); + if (remaining < 0 && remaining != -60_000L) verify(actual).restoreActivePerk(legacy, user, null); + else if (remaining > 0) verify(actual).restoreActivePerk(eq(legacy), eq(user), + argThat(saved -> saved != null && saved.getTime() == expiration)); + else verify(actual, never()).restoreActivePerk(eq(legacy), any(), any()); + verify(legacy, never()).activatePerk(any(), anyInt()); + verify(legacy, never()).scheduleDeactivation(any()); + } finally { actual.retireTimers(); } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void offlineRestorationDoesNotAccessUnpublishedCanonicalCacheOnOwner(boolean shutdownRecord) throws Exception { + var tasks = new LinkedBlockingQueue(); var legacy = permanent(); + when(legacy.getPerkType()).thenReturn(PerkSystemType.PLAYER); + var actual = restorer(tasks, legacy); + var published = new java.util.concurrent.atomic.AtomicBoolean(); + var cache = mock(com.bencodez.advancedcore.api.user.usercache.UserDataCache.class); + when(dataManager.usesSharedSqlStorage(any())).thenReturn(true); + when(plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.SQLITE); + when(dataManager.getPublishedCache(owner)).thenAnswer(i -> published.get() ? cache : null); + doAnswer(i -> { published.set(true); return null; }).when(dataManager).cacheUser(owner); + if (shutdownRecord) plugin.getMcperksServerData().getData().set("OfflinePerk." + owner + ".permanent.Experiaton", -1L); + doAnswer(i -> { + if (!published.get()) throw new IllegalStateException("owner read on unpublished canonical cache"); + return null; + }).when(actual).restoreActivePerk(eq(legacy), eq(user), nullable(Timestamp.class)); + try { + var restoration = actual.loadActivePerksConfirmed(() -> { + if (shutdownRecord) actual.restoreActivePerk(legacy, user, null); + }); + completeRestoration(tasks, restoration); + verify(actual).restoreActivePerk(eq(legacy), eq(user), nullable(Timestamp.class)); + } finally { actual.retireTimers(); } + } + + @Test void restorationDoesNotRefreshAnAlreadyPublishedCanonicalCache() throws Exception { + var tasks = new LinkedBlockingQueue(); var legacy = permanent(); + when(legacy.getPerkType()).thenReturn(PerkSystemType.PLAYER); + var actual = restorer(tasks, legacy); + var cache = mock(com.bencodez.advancedcore.api.user.usercache.UserDataCache.class); + when(dataManager.usesSharedSqlStorage(any())).thenReturn(true); + when(plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.SQLITE); + when(dataManager.getPublishedCache(owner)).thenReturn(cache); + try { + completeRestoration(tasks, actual.loadActivePerksConfirmed(() -> {})); + verify(dataManager, never()).cacheUser(owner); + verify(actual).restoreActivePerk(legacy, user, null); + } finally { actual.retireTimers(); } + } + + @Test void restorationDoesNotDispatchOwnerWorkWhenCachePublicationFails() throws Exception { + var tasks = new LinkedBlockingQueue(); var legacy = permanent(); + when(legacy.getPerkType()).thenReturn(PerkSystemType.PLAYER); + var actual = restorer(tasks, legacy); + when(dataManager.usesSharedSqlStorage(any())).thenReturn(true); + when(plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.SQLITE); + when(dataManager.getPublishedCache(owner)).thenReturn(null); + var shutdown = new AtomicInteger(); + try { + var restored = actual.loadActivePerksConfirmed(shutdown::incrementAndGet); + var task = tasks.poll(5, TimeUnit.SECONDS); assertNotNull(task); task.run(); + assertTrue(restored.isCompletedExceptionally()); + assertEquals(0, shutdown.get()); + verify(actual, never()).restoreActivePerk(any(), any(), any()); + } finally { actual.retireTimers(); } + } + + @Test void expiredOrUnavailableShutdownRecordsDoNotRequireAnOwnerCache() throws Exception { + var tasks = new LinkedBlockingQueue(); var legacy = permanent(); + var actual = restorer(tasks, legacy); + var data = plugin.getMcperksServerData().getData(); + data.set("OfflinePerk." + owner + ".permanent.Experiaton", System.currentTimeMillis() - 10_000); + data.set("OfflinePerk.invalid-owner.missing.Experiaton", -1L); + when(plugin.getUserManager().getAllUUIDs()).thenReturn(new ArrayList<>()); + when(dataManager.usesSharedSqlStorage(any())).thenReturn(true); + when(plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.SQLITE); + try { + completeRestoration(tasks, actual.loadActivePerksConfirmed(() -> {})); + verify(dataManager, never()).cacheUser(any(UUID.class)); + } finally { actual.retireTimers(); } + } + + @Test void globalNativeExpirationRestoresOnceAcrossStoredUsers() throws Exception { + var tasks = new LinkedBlockingQueue(); var legacy = permanent(); + when(legacy.getPerkType()).thenReturn(PerkSystemType.ALL); + var other = UUID.randomUUID(); var otherUser = mock(MCPerksUser.class); + when(otherUser.getUUID()).thenReturn(other.toString()); + when(plugin.getMcperksUserManager().getMCPerksUser(other)).thenReturn(otherUser); + when(legacy.getExperation(otherUser)).thenReturn(-1L); + var actual = restorer(tasks, legacy); + when(plugin.getUserManager().getAllUUIDs()).thenReturn(new ArrayList<>(List.of(owner.toString(), other.toString()))); + try { + var restored = actual.loadActivePerksConfirmed(() -> {}); completeRestoration(tasks, restored); + verify(actual, times(1)).restoreActivePerk(eq(legacy), any(), isNull()); + verify(legacy, never()).activatePerk(any(), anyInt()); + } finally { actual.retireTimers(); } + } + @Test void shutdownRecordOwnsRestorationWhenTheNativeExpirationWasNotYetCleared() throws Exception { + var tasks = new LinkedBlockingQueue(); var legacy = permanent(); var actual = restorer(tasks, legacy); + plugin.getMcperksServerData().getData().set("OfflinePerk." + owner + ".permanent.Experiaton", -1L); + var shutdownCalls = new AtomicInteger(); + try { + var restored = actual.loadActivePerksConfirmed(() -> { + shutdownCalls.incrementAndGet(); actual.restoreActivePerk(legacy, user, null); + }); + completeRestoration(tasks, restored); + assertEquals(1, shutdownCalls.get()); + verify(actual, times(1)).restoreActivePerk(legacy, user, null); + } finally { actual.retireTimers(); } + } + + @Test void unavailableShutdownDefinitionsDoNotBlockHealthyRestorationOrQueueAdmission() throws Exception { + var tasks = new LinkedBlockingQueue(); var legacy = permanent(); var actual = restorer(tasks, legacy); + when(legacy.getExperation(user)).thenReturn(0L); + var disabled = mock(Perk.class); when(actual.getPerk("disabled")).thenReturn(disabled); + for (String key : List.of("missing", "disabled", "permanent")) plugin.getMcperksServerData().getData().set("OfflinePerk." + owner + "." + key + ".Experiaton", -1); + doAnswer(i -> { actual.getActivePerks().add(legacy); return null; }).when(actual).restoreActivePerk(eq(legacy), eq(user), isNull()); + var shutdown = MCPerksMain.class.getDeclaredMethod("restoreOfflinePerks"); shutdown.setAccessible(true); + var restored = actual.loadActivePerksConfirmed(() -> { + try { shutdown.invoke(plugin); } catch (ReflectiveOperationException error) { throw new RuntimeException(error); } + }); + completeRestoration(tasks, restored); + assertTrue(actual.isRestorationReady()); assertEquals(List.of(legacy),actual.getActivePerks()); + verify(actual).restoreActivePerk(legacy,user,null); verify(actual,never()).restoreActivePerk(eq(disabled),any(),any()); + verify(plugin.getMcperksServerData(), never()).removeOfflinePerkIfMatches(owner.toString(), "missing", -1L); + verify(plugin.getMcperksServerData(), never()).removeOfflinePerkIfMatches(owner.toString(), "disabled", -1L); + verify(plugin.getMcperksServerData()).removeOfflinePerkIfMatches(owner.toString(), "permanent", -1L); + } + @Test void skippedShutdownRecordsSurviveSerializedRestartAndRestoreAfterDefinitionsReturn() throws Exception { + var nativeFile = runtimeFile(); + var runtime = plugin.getMcperksServerData(); + var yaml = runtime.getData(); + for (String key : List.of("missing", "disabled", "permanent")) + yaml.set("OfflinePerk." + owner + "." + key + ".Experiaton", -1L); + var legacy = permanent(); var actual = restorer(new LinkedBlockingQueue<>(), legacy); + var disabled = mock(Perk.class); doReturn(disabled).when(actual).getPerk("disabled"); + doNothing().when(actual).restoreActivePerk(any(), any(), any()); + var shutdown = MCPerksMain.class.getDeclaredMethod("restoreOfflinePerks"); shutdown.setAccessible(true); + shutdown.invoke(plugin); + assertFalse(yaml.contains("OfflinePerk." + owner + ".permanent")); + assertEquals(-1L, yaml.getLong("OfflinePerk." + owner + ".missing.Experiaton")); + assertEquals(-1L, yaml.getLong("OfflinePerk." + owner + ".disabled.Experiaton")); + state.runtimeStorage(() -> null).get(5, TimeUnit.SECONDS); + var recovered = new org.bukkit.configuration.file.YamlConfiguration(); + recovered.loadFromString(Files.readString(directory.resolve("ServerData.yml"))); + when(nativeFile.getData()).thenReturn(recovered); + when(disabled.isEnabled()).thenReturn(true); doReturn(disabled).when(actual).getPerk("missing"); + shutdown.invoke(plugin); + assertFalse(recovered.contains("OfflinePerk")); + verify(actual, times(1)).restoreActivePerk(legacy, user, null); + verify(actual, times(2)).restoreActivePerk(disabled, user, null); + shutdown.invoke(plugin); + verify(actual, times(2)).restoreActivePerk(disabled, user, null); + } + @Test void shutdownRecordRetirementCannotEraseAReplacementOrUnrelatedMetadata() throws Exception { + runtimeFile(); var runtime = plugin.getMcperksServerData(); var yaml = runtime.getData(); + String record = "OfflinePerk." + owner + ".permanent"; + yaml.set(record + ".Experiaton", 123L); + assertFalse(runtime.removeOfflinePerkIfMatches(owner.toString(), "permanent", -1L)); + assertEquals(123L, yaml.getLong(record + ".Experiaton")); + yaml.set(record + ".FutureMetadata", "preserved"); + assertTrue(runtime.removeOfflinePerkIfMatches(owner.toString(), "permanent", 123L)); + assertEquals("preserved", yaml.getString(record + ".FutureMetadata")); + assertFalse(runtime.removeOfflinePerkIfMatches(owner.toString(), "permanent", 123L)); + } + @Test void futureShutdownExpirationRestoresWithOriginalExpirationAndExpiredRecordIsRetired() throws Exception { + long future = System.currentTimeMillis() + 60_000L; + plugin.getMcperksServerData().getData().set("OfflinePerk." + owner + ".permanent.Experiaton", future); + var tasks = new LinkedBlockingQueue(); var legacy = permanent(); var actual = restorer(tasks, legacy); + when(actual.getPerk("permanent")).thenReturn(legacy); + doAnswer(i -> { actual.getActivePerks().add(legacy); return null; }).when(actual) + .restoreActivePerk(eq(legacy), eq(user), nullable(Timestamp.class)); + var shutdown = MCPerksMain.class.getDeclaredMethod("restoreOfflinePerks"); shutdown.setAccessible(true); + shutdown.invoke(plugin); + verify(actual).restoreActivePerk(eq(legacy), eq(user), argThat(value -> value != null && value.getTime() == future)); + verify(plugin.getMcperksServerData()).removeOfflinePerkIfMatches(owner.toString(), "permanent", future); + + reset(actual, plugin.getMcperksServerData()); + when(plugin.getMcperksServerData().getData()).thenReturn(new org.bukkit.configuration.file.YamlConfiguration()); + long expired = System.currentTimeMillis() - 60_000L; + plugin.getMcperksServerData().getData().set("OfflinePerk." + owner + ".permanent.Experiaton", expired); + when(actual.getPerk("permanent")).thenReturn(legacy); + shutdown.invoke(plugin); + verify(actual, never()).activePerk(any(), any(), anyInt(), any()); + verify(plugin.getMcperksServerData()).removeOfflinePerkIfMatches(owner.toString(), "permanent", expired); + } + @Test void failedShutdownActivationRetainsRecordForRetry() throws Exception { + plugin.getMcperksServerData().getData().set("OfflinePerk." + owner + ".permanent.Experiaton", -1L); + var legacy = permanent(); var actual = restorer(new LinkedBlockingQueue<>(), legacy); + when(actual.getPerk("permanent")).thenReturn(legacy); + doThrow(new IllegalStateException("activation unavailable")).when(actual) + .restoreActivePerk(eq(legacy), eq(user), isNull()); + var shutdown = MCPerksMain.class.getDeclaredMethod("restoreOfflinePerks"); shutdown.setAccessible(true); + assertThrows(java.lang.reflect.InvocationTargetException.class, () -> shutdown.invoke(plugin)); + verify(plugin.getMcperksServerData(), never()).removeOfflinePerkIfMatches(owner.toString(), "permanent", -1L); + } + + @Test void retiredLegacyRestorationCannotMutateTheSuccessorHandler() throws Exception { + var tasks = new LinkedBlockingQueue(); var legacy = permanent(); var actual = restorer(tasks, legacy); + var restored = actual.loadActivePerksConfirmed(() -> fail("retired shutdown callback")); + when(plugin.getPerkHandler()).thenReturn(handler); + Objects.requireNonNull(tasks.poll(5, TimeUnit.SECONDS)).run(); + assertThrows(ExecutionException.class, () -> restored.get(5, TimeUnit.SECONDS)); assertTrue(actual.getActivePerks().isEmpty()); + verify(legacy, never()).activatePerk(any(), anyInt()); + } + + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"cancel", "success", "failure"}) + void callerObservationCannotChangeActualRestorationReadiness(String mutation) throws Exception { + var tasks = new LinkedBlockingQueue(); var legacy = permanent(); var actual = restorer(tasks, legacy); + var restored = actual.loadActivePerksConfirmed(() -> {}); + switch (mutation) { + case "cancel" -> restored.cancel(false); + case "success" -> restored.complete(null); + case "failure" -> restored.completeExceptionally(new IllegalStateException("caller observation")); + default -> fail("unknown mutation"); + } + assertFalse(actual.isRestorationReady()); + Objects.requireNonNull(tasks.poll(5, TimeUnit.SECONDS)).run(); + assertFalse(actual.isRestorationReady(), "the owner callback must still run after the storage scan"); + Objects.requireNonNull(tasks.poll(5, TimeUnit.SECONDS)).run(); + assertTrue(actual.isRestorationReady()); + verify(actual).restoreActivePerk(legacy, user, null); + verify(legacy, never()).activatePerk(any(), anyInt()); + } +} diff --git a/MCPerks/src/test/java/com/bencodez/mcperks/tests/perk/PerkActivationAdmissionTest.java b/MCPerks/src/test/java/com/bencodez/mcperks/tests/perk/PerkActivationAdmissionTest.java new file mode 100644 index 0000000..6cf934f --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/perk/PerkActivationAdmissionTest.java @@ -0,0 +1,713 @@ +package com.bencodez.mcperks.tests.perk; +import com.bencodez.mcperks.perk.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.sql.Timestamp; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; + +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; + +import com.bencodez.mcperks.MCPerksMain; +import com.bencodez.mcperks.data.ServerData; +import com.bencodez.mcperks.userapi.MCPerksUser; +import com.bencodez.mcperks.userapi.UserStorageConfirmation; + +class PerkActivationAdmissionTest { + @Test void nativeDebitAndRefundMutationsUseTheSameOrderedStorageOwner() { + var f = fixture(false); var balance = paidFixture(f); + var inside = new ThreadLocal(); var owners = new java.util.ArrayList(); + var storage = f.plugin().getStorage(); + doAnswer(call -> { + Boolean previous = inside.get(); inside.set(true); + try { return CompletableFuture.completedFuture(call.>getArgument(0).get()); } + catch (Throwable error) { return CompletableFuture.failedFuture(error); } + finally { if (previous == null) inside.remove(); else inside.set(previous); } + }).when(storage).runtimeStorage(any()); + doAnswer(call -> { owners.add(Boolean.TRUE.equals(inside.get())); balance.addAndGet(call.getArgument(1)); return null; }) + .when(f.user()).addActivation(eq("admission"), anyInt()); + try (var confirmation = mockStatic(UserStorageConfirmation.class)) { + confirmation.when(() -> UserStorageConfirmation.confirm(f.plugin(), f.user())) + .thenReturn(f.callerStorage(), CompletableFuture.completedFuture(null)); + f.perk().runPerk(f.user()); f.published().complete(null); + f.callerStorage().completeExceptionally(new IllegalStateException("synthetic caller failure")); + assertEquals(2, balance.get()); assertEquals(2, owners.size()); + assertTrue(owners.stream().allMatch(Boolean.TRUE::equals), "debit and compensation cannot race on different owners"); + } + } + + @Test void activeCapacityIsNotCountedAgainWhileItsFinalPublicationIsPending() throws Exception { + var f = fixture(true); paidFixture(f); + var actual = spy(com.bencodez.mcperks.tests.TestAccess.newPerkHandler(f.plugin(), java.util.List.of())); + when(f.plugin().getPerkHandler()).thenReturn(actual); actual.setPerk("admission", f.perk()); + when(f.user().getPerkLimit()).thenReturn(2); + when(f.user().getActivePerks()).thenAnswer(call -> actual.getActivePerks().size()); + when(f.plugin().getConfigFile().getLimitPermission()).thenReturn(true); + doAnswer(call -> { actual.getActivePerks().add(f.perk()); return null; }) + .when(actual).activePerk(eq(f.perk()), eq(f.user()), eq(30), any(Timestamp.class)); + var publication = new CompletableFuture(); + doAnswer(call -> { + Supplier> caller = call.getArgument(1); Runnable gameplay = call.getArgument(2); + return f.published().thenCompose(ignored -> caller.get()).thenCompose(ignored -> { + gameplay.run(); return publication; + }); + }).when(f.data()).confirmedActivationBeforeGameplay(any(), any(), any(), isNull()); + var other = fixture(true).perk(); + var field = Perk.class.getDeclaredField("plugin"); field.setAccessible(true); field.set(other, f.plugin()); + doReturn("other").when(other).getPerk(); + var method = Perk.class.getDeclaredMethod("executeConfirmed", MCPerksUser.class, int.class, boolean.class); + method.setAccessible(true); + try (var confirmation = mockStatic(UserStorageConfirmation.class)) { + confirmation.when(() -> UserStorageConfirmation.confirm(f.plugin(), f.user())).thenReturn(f.callerStorage()); + f.perk().runPerk(f.user()); f.published().complete(null); f.callerStorage().complete(null); + assertEquals(1, actual.getActivePerks().size()); assertFalse(publication.isDone()); + // Hold the next admission at sampling, before it can create a second clone. + var next = new CompletableFuture(); + doReturn(next).when(f.data()).confirmedActivationBeforeGameplay(any(), any(), any(), isNull()); + @SuppressWarnings("unchecked") var second = (CompletableFuture) method.invoke(other, f.user(), 30, true); + assertFalse(second.isDone(), "one active grant must leave the second permitted slot available"); + verify(f.data(), times(2)).confirmedActivationBeforeGameplay(any(), any(), any(), isNull()); + var drain = actual.retireNativeActivations().toCompletableFuture(); assertFalse(drain.isDone()); + publication.complete(null); assertFalse(drain.isDone(), "the second operation still owns settlement"); + next.completeExceptionally(new IllegalStateException("synthetic next failure")); + drain.get(5, java.util.concurrent.TimeUnit.SECONDS); + } finally { actual.retireTimers(); } + } + @Test void directStartsWaitForTheActualRestorationOwner() throws Exception { + var f = fixture(false); + var actual = com.bencodez.mcperks.tests.TestAccess.newPerkHandler(f.plugin(), java.util.List.of()); + var restored = new CompletableFuture(); + var field = PerkHandler.class.getDeclaredField("restoration"); field.setAccessible(true); field.set(actual, restored); + when(f.plugin().getPerkHandler()).thenReturn(actual); + try { + assertTrue(f.perk().executeConfirmed(f.user(), 30).isCompletedExceptionally()); + verify(f.data(), never()).confirmedActivationBeforeGameplay(any(), any(), any(), isNull()); + restored.complete(null); + assertFalse(f.perk().executeConfirmed(f.user(), 30).isDone()); + verify(f.data()).confirmedActivationBeforeGameplay(any(), any(), any(), isNull()); + f.published().completeExceptionally(new IllegalStateException("synthetic cleanup")); + } finally { actual.retireTimers(); } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"cancel", "success", "failure"}) + void shutdownPreparationWaitsForAnInstantaneousActivationRefund(String observation) throws Exception { + var f = fixture(false); var balance = paidFixture(f); + var actual = com.bencodez.mcperks.tests.TestAccess.newPerkHandler(f.plugin(), java.util.List.of()); + when(f.plugin().getPerkHandler()).thenReturn(actual); actual.setPerk("admission", f.perk()); + doCallRealMethod().when(f.plugin()).getFeatureLifecycle(); + doCallRealMethod().when(f.plugin()).onBeforeStorageShutdown(); + var refund = new AtomicReference>(); + var refundDispatch = new CompletableFuture(); + var storage = f.plugin().getStorage(); + try (var confirmation = mockStatic(UserStorageConfirmation.class)) { + confirmation.when(() -> UserStorageConfirmation.confirm(f.plugin(), f.user())) + .thenReturn(f.callerStorage(), CompletableFuture.completedFuture(null)); + f.perk().runPerk(f.user()); f.published().complete(null); assertEquals(1, balance.get()); + // Block compensation after the debit has actually run on this same storage owner. + doAnswer(call -> { refund.set(call.getArgument(0)); return refundDispatch; }) + .when(storage).runtimeStorage(any()); + var prepare = MCPerksMain.class.getDeclaredMethod("prepareShutdown"); prepare.setAccessible(true); prepare.invoke(f.plugin()); + assertFalse(f.plugin().onBeforeStorageShutdown().toCompletableFuture().isDone(), + "user storage must remain alive while the accepted debit is unsettled"); + var observed = actual.retireNativeActivations().toCompletableFuture(); + switch (observation) { + case "cancel" -> observed.cancel(false); + case "success" -> observed.complete(null); + case "failure" -> observed.completeExceptionally(new IllegalStateException("caller observation")); + default -> fail("unknown observation"); + } + assertFalse(f.plugin().onBeforeStorageShutdown().toCompletableFuture().isDone()); + f.callerStorage().complete(null); + assertNotNull(refund.get()); + assertFalse(f.plugin().onBeforeStorageShutdown().toCompletableFuture().isDone()); + assertEquals(1, balance.get(), "delayed file owner has not dispatched the refund yet"); + refundDispatch.complete(refund.get().get()); + f.plugin().onBeforeStorageShutdown().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + assertEquals(2, balance.get()); + assertTrue(f.perk().executeConfirmed(f.user(), 30).isCompletedExceptionally(), "shutdown closes new admissions"); + assertTrue(actual.getActivePerks().isEmpty()); + } finally { actual.retireTimers(); } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(ints = {1, -1}) + void playerCapacityIncludesOtherPendingPerksAndReleasesFailedStarts(int limit) throws Exception { + var f = fixture(false); paidFixture(f); + var other = fixture(false).perk(); + var field = Perk.class.getDeclaredField("plugin"); field.setAccessible(true); field.set(other, f.plugin()); + doReturn("other").when(other).getPerk(); + doReturn(false).when(other).isPerkActive(f.user()); + doReturn(true).when(other).checkTimesUsed(f.user()); + doReturn(true).when(other).checkCoolDown(f.user()); + doReturn(true).when(other).checkServerWideCoolDown(f.user()); + when(f.user().getActivations("other")).thenReturn(1); + when(f.user().getPerkLimit()).thenReturn(limit); + when(f.plugin().getConfigFile().getLimitPermission()).thenReturn(true); + var actual = com.bencodez.mcperks.tests.TestAccess.newPerkHandler(f.plugin(), java.util.List.of()); + when(f.plugin().getPerkHandler()).thenReturn(actual); + actual.setPerk("admission", f.perk()); actual.setPerk("other", other); + try { + f.perk().runPerk(f.user()); other.runPerk(f.user()); + verify(f.data(), times(limit == 1 ? 1 : 2)).confirmedActivationBeforeGameplay(any(), any(), any(), isNull()); + f.published().completeExceptionally(new IllegalStateException("synthetic rejected publication")); + other.runPerk(f.user()); + verify(f.data(), times(limit == 1 ? 2 : 3)).confirmedActivationBeforeGameplay(any(), any(), any(), isNull()); + } finally { actual.retireTimers(); } + } + + @Test void playerCapacityTransfersFromPendingReservationToTheActiveGrant() throws Exception { + var f = fixture(true); paidFixture(f); + var actual = spy(com.bencodez.mcperks.tests.TestAccess.newPerkHandler(f.plugin(), java.util.List.of())); + when(f.plugin().getPerkHandler()).thenReturn(actual); actual.setPerk("admission", f.perk()); + when(f.user().getPerkLimit()).thenReturn(1); + when(f.user().getActivePerks()).thenAnswer(call -> actual.getActivePerks().size()); + when(f.plugin().getConfigFile().getLimitPermission()).thenReturn(true); + doAnswer(call -> { actual.getActivePerks().add(f.perk()); return null; }) + .when(actual).activePerk(eq(f.perk()), eq(f.user()), eq(30), any(Timestamp.class)); + try (var confirmation = mockStatic(UserStorageConfirmation.class)) { + confirmation.when(() -> UserStorageConfirmation.confirm(f.plugin(), f.user())).thenReturn(f.callerStorage()); + var method = Perk.class.getDeclaredMethod("executeConfirmed", MCPerksUser.class, int.class, boolean.class); + method.setAccessible(true); + @SuppressWarnings("unchecked") var first = (CompletableFuture) method.invoke(f.perk(), f.user(), 30, true); + f.published().complete(null); f.callerStorage().complete(null); first.get(); + @SuppressWarnings("unchecked") var limited = (CompletableFuture) method.invoke(f.perk(), f.user(), 30, true); + assertTrue(limited.isCompletedExceptionally()); + verify(f.data()).confirmedActivationBeforeGameplay(any(), any(), any(), isNull()); + // An explicitly forced API call retains its established permission-limit bypass. + f.perk().executeConfirmed(f.user(), 30).get(); + verify(f.data(), times(2)).confirmedActivationBeforeGameplay(any(), any(), any(), isNull()); + } finally { actual.retireTimers(); } + } + + private record Fixture(Perk perk, MCPerksMain plugin, ServerData data, + PerkHandler handler, MCPerksUser user, AtomicReference gameplay, + AtomicReference> state, CompletableFuture published, + CompletableFuture callerStorage) {} + + private Fixture fixture(boolean timed) { + MCPerksMain plugin = mock(MCPerksMain.class); + ServerData data = mock(ServerData.class); + PerkHandler handler = mock(PerkHandler.class); + MCPerksUser user = mock(MCPerksUser.class); + Perk perk = mock(Perk.class, CALLS_REAL_METHODS); + AtomicReference gameplay = new AtomicReference<>(); + AtomicReference> state = new AtomicReference<>(); + CompletableFuture published = new CompletableFuture<>(); + CompletableFuture callerStorage = new CompletableFuture<>(); + try { + var field = Perk.class.getDeclaredField("plugin"); field.setAccessible(true); field.set(perk, plugin); + field = Perk.class.getDeclaredField("activationAdmissions"); field.setAccessible(true); field.set(perk, new java.util.LinkedHashMap()); + } catch (ReflectiveOperationException error) { throw new AssertionError(error); } + when(plugin.getMcperksServerData()).thenReturn(data); + when(plugin.getPerkHandler()).thenReturn(handler); + when(handler.isRestorationReady()).thenReturn(true); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getLogger("activation-test")); + var storage = mock(com.bencodez.mcperks.data.OrderedStorage.class); + when(plugin.getStorage()).thenReturn(storage); + doAnswer(call -> { + try { return CompletableFuture.completedFuture(call.>getArgument(0).get()); } + catch (Throwable failure) { return CompletableFuture.failedFuture(failure); } + }).when(storage).runtimeStorage(any()); + when(data.isReady()).thenReturn(true); + doReturn("admission").when(perk).getPerk(); + doReturn(PerkSystemType.PLAYER).when(perk).getPerkType(); + doReturn(timed).when(perk).isTimed(); + doReturn(false).when(perk).isLastForever(); + doReturn(0).when(perk).getCoolDown(); + doReturn(0).when(perk).getServerWideCoolDown(); + doReturn(30).when(perk).getTime(); + when(user.getPlayer()).thenReturn((Player) null); + when(user.getUUID()).thenReturn(UUID.randomUUID().toString()); + doAnswer(invocation -> { + state.set(invocation.getArgument(0)); + gameplay.set(invocation.getArgument(2)); + @SuppressWarnings("unchecked") Supplier> caller = invocation.getArgument(1); + return published.thenCompose(ignored -> caller.get()).thenCompose(ignored -> { + try { gameplay.get().run(); return CompletableFuture.completedFuture(null); } + catch (Throwable error) { return CompletableFuture.failedFuture(error); } + }); + }).when(data).confirmedActivationBeforeGameplay(any(), any(), any(), isNull()); + return new Fixture(perk, plugin, data, handler, user, gameplay, state, published, callerStorage); + } + + private java.util.concurrent.atomic.AtomicInteger paidFixture(Fixture f) { + var balance = new java.util.concurrent.atomic.AtomicInteger(2); + when(f.handler().isRestorationReady()).thenReturn(true); + when(f.handler().getPerk("admission")).thenReturn(f.perk()); + when(f.data().getData()).thenReturn(new org.bukkit.configuration.file.YamlConfiguration()); + var config = mock(com.bencodez.mcperks.configs.Config.class); + when(f.plugin().getConfigFile()).thenReturn(config); when(config.getLimitActivations()).thenReturn(true); + doReturn(false).when(f.perk()).isPerkActive(f.user()); + doReturn(true).when(f.perk()).checkTimesUsed(f.user()); + doReturn(true).when(f.perk()).checkCoolDown(f.user()); + doReturn(true).when(f.perk()).checkServerWideCoolDown(f.user()); + when(f.user().getActivations("admission")).thenAnswer(call -> balance.get()); + doAnswer(call -> { balance.addAndGet(call.getArgument(1)); return null; }) + .when(f.user()).addActivation(eq("admission"), anyInt()); + return balance; + } + + @Test void playerLogoutBeforeOwnerGameplayRollsBackPendingPaidStart() throws Exception { + var f = fixture(false); var balance = paidFixture(f); var player = mock(Player.class); + when(player.isOnline()).thenReturn(true); + when(f.user().getPlayer()).thenReturn(player); + when(f.plugin().getConfigFile().getDeactivateOnLogout()).thenReturn(true); + doAnswer(invocation -> { + @SuppressWarnings("unchecked") Supplier> caller = invocation.getArgument(1); + Runnable gameplay = invocation.getArgument(2); + return f.published().thenCompose(ignored -> caller.get()).thenCompose(ignored -> { + try { gameplay.run(); return CompletableFuture.completedFuture(null); } + catch (Throwable error) { return CompletableFuture.failedFuture(error); } + }); + }).when(f.data()).confirmedActivationBeforeGameplay(any(), any(), any(), eq(player)); + try (var confirmation = mockStatic(UserStorageConfirmation.class)) { + confirmation.when(() -> UserStorageConfirmation.confirm(f.plugin(), f.user())) + .thenReturn(f.callerStorage(), CompletableFuture.completedFuture(null)); + f.perk().runPerk(f.user()); f.published().complete(null); + assertEquals(1, balance.get()); + when(player.isOnline()).thenReturn(false); + f.callerStorage().complete(null); + assertEquals(2, balance.get(), "a retired player session must refund the pending debit"); + verify(f.handler(), never()).activePerk(any(), any(), anyInt(), any()); + } + } + + @Test void disabledLogoutPolicyDoesNotCancelPendingStartForAnOfflinePlayer() { + var f = fixture(false); var balance = paidFixture(f); var player = mock(Player.class); + when(player.isOnline()).thenReturn(false); when(f.user().getPlayer()).thenReturn(player); + when(f.plugin().getConfigFile().getDeactivateOnLogout()).thenReturn(false); + doAnswer(invocation -> { + @SuppressWarnings("unchecked") Supplier> caller = invocation.getArgument(1); + Runnable gameplay = invocation.getArgument(2); + return f.published().thenCompose(ignored -> caller.get()).thenCompose(ignored -> { + try { gameplay.run(); return CompletableFuture.completedFuture(null); } + catch (Throwable error) { return CompletableFuture.failedFuture(error); } + }); + }).when(f.data()).confirmedActivationBeforeGameplay(any(), any(), any(), eq(player)); + try (var confirmation = mockStatic(UserStorageConfirmation.class)) { + confirmation.when(() -> UserStorageConfirmation.confirm(f.plugin(), f.user())).thenReturn(f.callerStorage()); + f.perk().runPerk(f.user()); f.published().complete(null); f.callerStorage().complete(null); + assertEquals(1, balance.get()); + verify(f.handler()).activePerk(f.perk(), f.user(), 30, null); + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {true, false}) + void quitFenceRetiresOnlyTheCapturedPlayerSession(boolean matchingSession) throws Exception { + var f = fixture(false); var balance = paidFixture(f); var player = mock(Player.class); + when(player.isOnline()).thenReturn(true); when(f.user().getPlayer()).thenReturn(player); + when(f.plugin().getConfigFile().getDeactivateOnLogout()).thenReturn(true); + var handler = spy(com.bencodez.mcperks.tests.TestAccess.newPerkHandler(f.plugin(), java.util.List.of())); + handler.setPerk("admission", f.perk()); when(f.plugin().getPerkHandler()).thenReturn(handler); + doNothing().when(handler).activePerk(any(), any(), anyInt(), any()); + doAnswer(invocation -> { + @SuppressWarnings("unchecked") Supplier> caller = invocation.getArgument(1); + Runnable gameplay = invocation.getArgument(2); + return f.published().thenCompose(ignored -> caller.get()).thenCompose(ignored -> { + try { gameplay.run(); return CompletableFuture.completedFuture(null); } + catch (Throwable error) { return CompletableFuture.failedFuture(error); } + }); + }).when(f.data()).confirmedActivationBeforeGameplay(any(), any(), any(), eq(player)); + try (var confirmation = mockStatic(UserStorageConfirmation.class)) { + confirmation.when(() -> UserStorageConfirmation.confirm(f.plugin(), f.user())) + .thenReturn(f.callerStorage(), CompletableFuture.completedFuture(null)); + f.perk().runPerk(f.user()); f.published().complete(null); + handler.retirePendingPlayerSession(matchingSession ? player : mock(Player.class)); + f.callerStorage().complete(null); + assertEquals(matchingSession ? 2 : 1, balance.get()); + verify(handler, times(matchingSession ? 0 : 1)).activePerk(any(), any(), anyInt(), any()); + } finally { handler.retireTimers(); } + } + + @Test void paidStartsDoNotConsumeCreditsBeforeAdmissionAndPublication() throws Exception { + var f = fixture(false); var balance = paidFixture(f); + try (var confirmation = mockStatic(UserStorageConfirmation.class)) { + confirmation.when(() -> UserStorageConfirmation.confirm(f.plugin(), f.user())).thenReturn(f.callerStorage()); + f.perk().runPerk(f.user()); f.perk().runPerk(f.user()); + assertEquals(2, balance.get(), "rejected duplicate and unconfirmed publication must consume no credit"); + f.published().complete(null); assertEquals(1, balance.get()); + f.callerStorage().complete(null); + verify(f.handler()).activePerk(f.perk(), f.user(), 30, null); + assertEquals(1, balance.get()); + } + } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {true, false}) + void failedPaidPublicationOrCallerWriteRestoresCreditsBeforeAnotherAdmission(boolean sharedFailure) { + var f = fixture(false); var balance = paidFixture(f); + try (var confirmation = mockStatic(UserStorageConfirmation.class)) { + confirmation.when(() -> UserStorageConfirmation.confirm(f.plugin(), f.user())) + .thenReturn(f.callerStorage(), CompletableFuture.completedFuture(null)); + f.perk().runPerk(f.user()); + if (sharedFailure) f.published().completeExceptionally(new IllegalStateException("file unavailable")); + else { + f.published().complete(null); assertEquals(1, balance.get()); + f.callerStorage().completeExceptionally(new IllegalStateException("user write failed")); + } + assertEquals(2, balance.get()); + verify(f.handler(), never()).activePerk(any(), any(), anyInt(), any()); + f.perk().executeConfirmed(f.user(), 30); + verify(f.data(), times(2)).confirmedActivationBeforeGameplay(any(), any(), any(), isNull()); + } + } + @Test void failedCallerWriteCompensatesBothRealSharedCachePools() { + var f = fixture(false); paidFixture(f); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var manager = mock(com.bencodez.advancedcore.api.user.usercache.UserDataManager.class); + when(f.plugin().getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(f.plugin().getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + when(manager.usesSharedSqlStorage(com.bencodez.advancedcore.api.user.UserStorage.MYSQL)).thenReturn(true); + when(manager.getPlugin()).thenReturn(f.plugin()); + when(manager.getTimer()).thenReturn(mock(java.util.concurrent.ScheduledExecutorService.class)); + UUID id = UUID.fromString(f.user().getUUID()); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(manager, id); + cache.configureSharedStorage(values -> {}, Runnable::run); + cache.updateCompleteCachePreservingPending(new java.util.HashMap<>(java.util.Map.of( + "Activations", new com.bencodez.simpleapi.sql.data.DataValueInt(1), + "PerkActivations_admission", new com.bencodez.simpleapi.sql.data.DataValueInt(2)))); + when(manager.getPublishedCache(id)).thenReturn(cache); + when(f.user().getPlugin()).thenReturn(f.plugin()); + // This fixture exercises canonical cache admission, not an opaque UserData override. + when(f.user().getData()).thenReturn(mock(com.bencodez.advancedcore.api.user.UserData.class)); + doCallRealMethod().when(f.user()).reserveSharedActivations("admission"); + doCallRealMethod().when(f.user()).addActivation(eq("admission"), anyInt()); + doCallRealMethod().when(f.user()).addActivation(anyInt()); + try (var confirmation = mockStatic(UserStorageConfirmation.class)) { + confirmation.when(() -> UserStorageConfirmation.confirm(f.plugin(), f.user())) + .thenReturn(f.callerStorage(), CompletableFuture.completedFuture(null)); + f.perk().runPerk(f.user()); f.published().complete(null); + assertEquals(0, cache.getIfPresent("Activations").getInt()); + assertEquals(1, cache.getIfPresent("PerkActivations_admission").getInt()); + f.callerStorage().completeExceptionally(new IllegalStateException("synthetic caller failure")); + assertEquals(1, cache.getIfPresent("Activations").getInt()); + assertEquals(2, cache.getIfPresent("PerkActivations_admission").getInt()); + verify(f.handler(), never()).activePerk(any(), any(), anyInt(), any()); + } + } + + @Test void countdownRegistrationFailureBeforeEffectsRefundsThePaidStart() throws Exception { + var f = fixture(true); var balance = paidFixture(f); + var expiration = new java.util.concurrent.atomic.AtomicLong(12345L); + when(f.user().getPerkExperation(f.perk())).thenAnswer(call -> expiration.get()); + doAnswer(call -> { expiration.set(call.getArgument(1)); return null; }) + .when(f.user()).setPerkExperation(eq(f.perk()), anyLong()); + var actual = com.bencodez.mcperks.tests.TestAccess.newPerkHandler(f.plugin(), java.util.List.of()); + when(f.plugin().getPerkHandler()).thenReturn(actual); actual.setPerk("admission", f.perk()); + var effects = mock(com.bencodez.mcperks.perk.EffectHandler.class); when(f.plugin().getEffectHandler()).thenReturn(effects); + var clone = mock(Perk.class); doReturn(clone).when(f.perk()).clone(); + when(clone.getEffectedPlayers()).thenReturn(new java.util.ArrayList<>()); + when(clone.getPerkType()).thenReturn(PerkSystemType.PLAYER); + var timer = mock(java.util.Timer.class); + doThrow(new IllegalStateException("synthetic countdown registration failure")) + .when(timer).schedule(any(java.util.TimerTask.class), anyLong()); + var timerField = PerkHandler.class.getDeclaredField("timer"); timerField.setAccessible(true); + var originalTimer = (java.util.Timer) timerField.get(actual); originalTimer.cancel(); timerField.set(actual, timer); + var lang = mock(com.bencodez.mcperks.configs.Lang.class); when(lang.getCountDownTimer()).thenReturn(""); + var perkConfig = mock(com.bencodez.mcperks.configs.ConfigPerks.class); + when(perkConfig.getCountDownTimes("admission")).thenReturn(new java.util.ArrayList<>(java.util.List.of("5"))); + try (var langs = mockStatic(com.bencodez.mcperks.configs.Lang.class); + var configs = mockStatic(com.bencodez.mcperks.configs.ConfigPerks.class); + var confirmation = mockStatic(UserStorageConfirmation.class)) { + langs.when(com.bencodez.mcperks.configs.Lang::getInstance).thenReturn(lang); + configs.when(com.bencodez.mcperks.configs.ConfigPerks::getInstance).thenReturn(perkConfig); + confirmation.when(() -> UserStorageConfirmation.confirm(f.plugin(), f.user())) + .thenReturn(f.callerStorage(), CompletableFuture.completedFuture(null)); + f.perk().runPerk(f.user()); f.published().complete(null); + assertEquals(1, balance.get()); + f.callerStorage().complete(null); + verify(timer).schedule(any(java.util.TimerTask.class), anyLong()); + verify(effects, never()).activate(any(), any(), anyBoolean()); + assertEquals(2, balance.get(), "a registration failure before effects must refund the debit"); + assertEquals(12345L, expiration.get(), "the unstarted grant must restore its previous expiration"); + } finally { actual.retireTimers(); } + } + + @Test void effectFailureAfterGameplayBeginsNeverRefundsAnUncertainSideEffect() throws Exception { + var f = fixture(true); var balance = paidFixture(f); + var actual = com.bencodez.mcperks.tests.TestAccess.newPerkHandler(f.plugin(), java.util.List.of()); + when(f.plugin().getPerkHandler()).thenReturn(actual); actual.setPerk("admission", f.perk()); + var effects = mock(com.bencodez.mcperks.perk.EffectHandler.class); when(f.plugin().getEffectHandler()).thenReturn(effects); + doThrow(new IllegalStateException("synthetic effect failure")).when(effects).activate(any(), any(), anyBoolean()); + var clone = mock(Perk.class); doReturn(clone).when(f.perk()).clone(); + when(clone.getEffectedPlayers()).thenReturn(new java.util.ArrayList<>()); + when(clone.getPerkType()).thenReturn(PerkSystemType.PLAYER); + var lang = mock(com.bencodez.mcperks.configs.Lang.class); when(lang.getCountDownTimer()).thenReturn(""); + var perkConfig = mock(com.bencodez.mcperks.configs.ConfigPerks.class); + when(perkConfig.getCountDownTimes("admission")).thenReturn(new java.util.ArrayList<>()); + try (var langs = mockStatic(com.bencodez.mcperks.configs.Lang.class); + var configs = mockStatic(com.bencodez.mcperks.configs.ConfigPerks.class); + var confirmation = mockStatic(UserStorageConfirmation.class)) { + langs.when(com.bencodez.mcperks.configs.Lang::getInstance).thenReturn(lang); + configs.when(com.bencodez.mcperks.configs.ConfigPerks::getInstance).thenReturn(perkConfig); + confirmation.when(() -> UserStorageConfirmation.confirm(f.plugin(), f.user())) + .thenReturn(f.callerStorage(), CompletableFuture.completedFuture(null)); + f.perk().runPerk(f.user()); f.published().complete(null); + assertEquals(1, balance.get()); f.callerStorage().complete(null); + verify(effects).activate(any(), any(), eq(true)); + assertEquals(1, balance.get(), "an effect failure may have produced side effects and must not refund"); + } finally { actual.retireTimers(); } + } + + private static final class CustomActivationHandler extends PerkHandler { + CustomActivationHandler() { super(mock(MCPerksMain.class)); } + @Override public void activePerk(Perk perk, MCPerksUser user, int length, Timestamp timestamp) { } + } + + @Test void customActivationOverrideRetainsConservativeCompensationBoundary() throws Exception { + var handler = mock(CustomActivationHandler.class, CALLS_REAL_METHODS); + var method = PerkHandler.class.getDeclaredMethod("usesManagedGameplayBoundary"); method.setAccessible(true); + assertEquals(false, method.invoke(handler)); + } + + @Test void unconfirmedPaidRefundRetainsThePrivateAdmissionEvenAfterAnErrorIsReported() { + var f = fixture(false); var balance = paidFixture(f); + try (var confirmation = mockStatic(UserStorageConfirmation.class)) { + confirmation.when(() -> UserStorageConfirmation.confirm(f.plugin(), f.user())).thenReturn(f.callerStorage()); + f.perk().runPerk(f.user()); f.published().complete(null); + f.callerStorage().completeExceptionally(new IllegalStateException("backend unavailable")); + assertEquals(2, balance.get(), "the cache must contain the refund even while its durability is uncertain"); + assertTrue(f.perk().executeConfirmed(f.user(), 30).isCompletedExceptionally()); + verify(f.data(), times(1)).confirmedActivationBeforeGameplay(any(), any(), any(), isNull()); + verify(f.handler(), never()).activePerk(any(), any(), anyInt(), any()); + } + } + @Test void reloadAdmissionRejectsPaidStartWithoutTakingCredits() throws Exception { + var f = fixture(false); var balance = paidFixture(f); + var actual = com.bencodez.mcperks.tests.TestAccess.newPerkHandler(f.plugin(), java.util.List.of()); + when(f.plugin().getPerkHandler()).thenReturn(actual); actual.setPerk("admission", f.perk()); + try { + actual.pauseNativeActivationsForReload(); f.perk().runPerk(f.user()); + assertEquals(2, balance.get()); + verify(f.data(), never()).confirmedActivationBeforeGameplay(any(), any(), any(), isNull()); + } finally { actual.retireTimers(); } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {true, false}) + void paidRollbackRestoresOnlyItsOwnSharedMetadata(boolean newerExpiration) { + var f = fixture(true); var balance = paidFixture(f); + doReturn(PerkSystemType.ALL).when(f.perk()).getPerkType(); + doReturn(30).when(f.perk()).getServerWideCoolDown(); + var yaml = f.data().getData(); yaml.set("Experation.admission", 10L); yaml.set("admission.CoolDown", 20L); + when(f.data().confirmedFinalMutation(any())).thenAnswer(call -> { + call.>getArgument(0).accept(yaml); + return CompletableFuture.completedFuture(null); + }); + try (var confirmation = mockStatic(UserStorageConfirmation.class)) { + confirmation.when(() -> UserStorageConfirmation.confirm(f.plugin(), f.user())) + .thenReturn(f.callerStorage(), CompletableFuture.completedFuture(null)); + f.perk().runPerk(f.user()); + f.state().get().forEach(yaml::set); + long expiration = f.state().get().get("Experation.admission"); + f.published().complete(null); + if (newerExpiration) yaml.set("Experation.admission", expiration + 123L); + f.callerStorage().completeExceptionally(new IllegalStateException("user write failed")); + assertEquals(2, balance.get()); + assertEquals(newerExpiration ? expiration + 123L : 10L, yaml.getLong("Experation.admission")); + assertEquals(20L, yaml.getLong("admission.CoolDown")); + verify(f.data()).confirmedFinalMutation(any()); + verify(f.handler(), never()).activePerk(any(), any(), anyInt(), any()); + } + } + @Test void queuedPaidPerkConsumesNothingUntilItsSingleAdmittedStart() { + var f = fixture(false); var balance = paidFixture(f); + when(f.plugin().getConfigFile().getPerkQue()).thenReturn(true); + var active = new java.util.concurrent.CopyOnWriteArrayList(); active.add(mock(Perk.class)); + when(f.handler().getActivePerks()).thenReturn(active); + var previous = MCPerksMain.plugin; MCPerksMain.plugin = f.plugin(); + var perkConfig = mock(com.bencodez.mcperks.configs.ConfigPerks.class); + try (var configs = mockStatic(com.bencodez.mcperks.configs.ConfigPerks.class); + var confirmation = mockStatic(UserStorageConfirmation.class)) { + configs.when(com.bencodez.mcperks.configs.ConfigPerks::getInstance).thenReturn(perkConfig); + confirmation.when(() -> UserStorageConfirmation.confirm(f.plugin(), f.user())).thenReturn(f.callerStorage()); + f.perk().runPerk(f.user()); assertEquals(2, balance.get()); + verify(f.handler()).addQue(f.user(), f.perk()); + active.clear(); f.perk().runPerk(f.user()); assertEquals(2, balance.get()); + f.published().complete(null); f.callerStorage().complete(null); + assertEquals(1, balance.get()); + verify(f.handler()).activePerk(f.perk(), f.user(), 30, null); + } finally { MCPerksMain.plugin = previous; } + } + + @Test void actualExecuteWaitsForPublicationAndUserStorageBeforeHooks() throws Exception { + Fixture f = fixture(true); + CompletableFuture userStorage = new CompletableFuture<>(); + try (MockedStatic confirmation = mockStatic(UserStorageConfirmation.class)) { + confirmation.when(() -> UserStorageConfirmation.confirm(f.plugin(), f.user())).thenReturn(userStorage); + CompletableFuture execution = f.perk().executeConfirmed(f.user(), 30); + assertFalse(execution.isDone()); + verify(f.handler(), never()).activePerk(any(), any(), anyInt(), any()); + f.published().complete(null); + assertFalse(execution.isDone()); + verify(f.handler(), never()).activePerk(any(), any(), anyInt(), any()); + userStorage.complete(null); + execution.get(); + verify(f.handler()).activePerk(eq(f.perk()), eq(f.user()), eq(30), any(Timestamp.class)); + } + } + + @Test void actualExecuteRejectsASecondStartWhileFirstAdmissionIsPending() { + Fixture f = fixture(false); + try (MockedStatic confirmation = mockStatic(UserStorageConfirmation.class)) { + confirmation.when(() -> UserStorageConfirmation.confirm(f.plugin(), f.user())).thenReturn(f.callerStorage()); + assertFalse(f.perk().executeConfirmed(f.user(), 30).isDone()); + CompletableFuture second = f.perk().executeConfirmed(f.user(), 30); + assertTrue(second.isCompletedExceptionally()); + verify(f.data(), times(1)).confirmedActivationBeforeGameplay(any(), any(), any(), isNull()); + } + } + + + + @Test void differentPersonalOwnersDoNotShareAnAdmissionFence() { + Fixture f = fixture(false); + MCPerksUser other = mock(MCPerksUser.class); + when(other.getUUID()).thenReturn(UUID.randomUUID().toString()); + assertFalse(f.perk().executeConfirmed(f.user(), 30).isDone()); + assertFalse(f.perk().executeConfirmed(other, 30).isDone()); + verify(f.data(), times(2)).confirmedActivationBeforeGameplay(any(), any(), any(), isNull()); + } + + @Test void actualReloadGenerationChangeRetiresPendingUserConfirmation() throws Exception { + Fixture f = fixture(false); + doCallRealMethod().when(f.plugin()).getFeatureLifecycle(); + var config = mock(com.bencodez.mcperks.configs.Config.class); + var field = MCPerksMain.class.getDeclaredField("configFile"); field.setAccessible(true); field.set(f.plugin(), config); + doThrow(new IllegalStateException("stop fixture after configuration generation change")) + .when(config).reloadData(); + try (MockedStatic confirmation = mockStatic(UserStorageConfirmation.class)) { + confirmation.when(() -> UserStorageConfirmation.confirm(f.plugin(), f.user())).thenReturn(f.callerStorage()); + var execution = f.perk().executeConfirmed(f.user(), 30); + f.published().complete(null); + var reload = MCPerksMain.class.getDeclaredMethod("reloadAfterDrain"); reload.setAccessible(true); + assertThrows(java.lang.reflect.InvocationTargetException.class, () -> reload.invoke(f.plugin())); + assertEquals(1, f.plugin().getFeatureLifecycle()); + f.callerStorage().complete(null); + assertThrows(ExecutionException.class, execution::get); + verify(f.handler(), never()).activePerk(any(), any(), anyInt(), any()); + } + } + + @Test void actualExecuteDoesNotRunHooksWhenAdmissionStorageFails() throws Exception { + Fixture f = fixture(false); + try (MockedStatic confirmation = mockStatic(UserStorageConfirmation.class)) { + confirmation.when(() -> UserStorageConfirmation.confirm(f.plugin(), f.user())).thenReturn(f.callerStorage()); + CompletableFuture execution = f.perk().executeConfirmed(f.user(), 30); + f.published().completeExceptionally(new IllegalStateException("snapshot exceeds four MB")); + assertThrows(ExecutionException.class, execution::get); + verify(f.handler(), never()).activePerk(any(), any(), anyInt(), any()); + verify(f.user(), never()).setPerkCoolDown(any(), anyLong()); + verify(f.user(), never()).setPerkExperation(any(), anyLong()); + } + } + + @Test void actualExecuteDoesNotRunHooksWhenCallerStorageFails() throws Exception { + Fixture f = fixture(false); + try (MockedStatic confirmation = mockStatic(UserStorageConfirmation.class)) { + confirmation.when(() -> UserStorageConfirmation.confirm(f.plugin(), f.user())).thenReturn(f.callerStorage()); + CompletableFuture execution = f.perk().executeConfirmed(f.user(), 30); + f.published().complete(null); + f.callerStorage().completeExceptionally(new IllegalStateException("user cache unavailable")); + assertThrows(ExecutionException.class, execution::get); + verify(f.handler(), never()).activePerk(any(), any(), anyInt(), any()); + } + } + + @Test void actualExecuteSuppressesAStaleLifecycleCallback() throws Exception { + Fixture f = fixture(false); + PerkHandler replacement = mock(PerkHandler.class); + AtomicReference current = new AtomicReference<>(f.handler()); + java.util.concurrent.atomic.AtomicInteger epoch = new java.util.concurrent.atomic.AtomicInteger(); + when(f.plugin().getPerkHandler()).thenAnswer(invocation -> current.get()); + when(f.plugin().getFeatureLifecycle()).thenAnswer(invocation -> epoch.get()); + try (MockedStatic confirmation = mockStatic(UserStorageConfirmation.class)) { + confirmation.when(() -> UserStorageConfirmation.confirm(f.plugin(), f.user())).thenReturn(f.callerStorage()); + CompletableFuture execution = f.perk().executeConfirmed(f.user(), 30); + f.published().complete(null); current.set(replacement); epoch.incrementAndGet(); f.callerStorage().complete(null); + assertThrows(ExecutionException.class, execution::get); + verify(f.handler(), never()).activePerk(any(), any(), anyInt(), any()); + } + } + + @Test void actualExecutePersistsTheSameDeadlineUsedByRuntimeActivation() throws Exception { + Fixture f = fixture(true); + AtomicReference persisted = new AtomicReference<>(); + doAnswer(invocation -> { persisted.set(invocation.getArgument(1)); return null; }) + .when(f.user()).setPerkExperation(eq(f.perk()), anyLong()); + try (MockedStatic confirmation = mockStatic(UserStorageConfirmation.class)) { + confirmation.when(() -> UserStorageConfirmation.confirm(f.plugin(), f.user())).thenReturn(f.callerStorage()); + CompletableFuture execution = f.perk().executeConfirmed(f.user(), 30); + f.published().complete(null); f.callerStorage().complete(null); execution.get(); + var timestamp = ArgumentCaptor.forClass(Timestamp.class); + verify(f.handler()).activePerk(eq(f.perk()), eq(f.user()), eq(30), timestamp.capture()); + assertEquals(persisted.get(), timestamp.getValue().getTime()); + } + } + @Test void pendingNativePublicationPreventsLegacyQueueFromStartingAnotherPerk() throws Exception { + Fixture f = fixture(false); + var actual = com.bencodez.mcperks.tests.TestAccess.newPerkHandler(f.plugin(), java.util.List.of()); + var config = mock(com.bencodez.mcperks.configs.Config.class); + when(config.getPerkQue()).thenReturn(true); + when(f.plugin().getConfigFile()).thenReturn(config); + when(f.plugin().getPerkHandler()).thenReturn(actual); + when(f.plugin().getBukkitScheduler()).thenReturn(mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)); + var nextUser = mock(MCPerksUser.class); + var nextPerk = mock(Perk.class); + try { + var first = f.perk().executeConfirmed(f.user(), 30); + assertFalse(first.isDone()); + actual.addQue(nextUser, nextPerk); + actual.checkQue(); + actual.checkQue(); + verify(nextPerk, never()).runPerk(nextUser); + assertEquals(nextPerk, actual.que.get(nextUser)); + f.published().completeExceptionally(new IllegalStateException("first publication failed")); + assertThrows(ExecutionException.class, () -> first.get(5, java.util.concurrent.TimeUnit.SECONDS)); + actual.checkQue(); + verify(nextPerk, times(1)).runPerk(nextUser); + } finally { actual.retireTimers(); } + } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"cancel", "success", "failure"}) + void callerObservationCannotRetireOrStrandActualNativeAdmission(String mutation) throws Exception { + Fixture f = fixture(false); + var actual = com.bencodez.mcperks.tests.TestAccess.newPerkHandler(f.plugin(), java.util.List.of()); + var config = mock(com.bencodez.mcperks.configs.Config.class); + when(config.getPerkQue()).thenReturn(true); + when(f.plugin().getConfigFile()).thenReturn(config); + when(f.plugin().getPerkHandler()).thenReturn(actual); + when(f.plugin().getBukkitScheduler()).thenReturn(mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)); + var nextUser = mock(MCPerksUser.class); + var nextPerk = mock(Perk.class); + try { + var first = f.perk().executeConfirmed(f.user(), 30); + assertFalse(first.isDone()); + switch (mutation) { + case "cancel" -> first.cancel(false); + case "success" -> first.complete(null); + case "failure" -> first.completeExceptionally(new IllegalStateException("caller observation")); + default -> fail("unknown mutation"); + } + actual.addQue(nextUser, nextPerk); + actual.checkQue(); + actual.checkQue(); + verify(nextPerk, never()).runPerk(nextUser); + assertEquals(nextPerk, actual.que.get(nextUser)); + f.published().completeExceptionally(new IllegalStateException("first publication failed")); + actual.checkQue(); + verify(nextPerk, times(1)).runPerk(nextUser); + } finally { actual.retireTimers(); } + } +} diff --git a/MCPerks/src/test/java/com/bencodez/mcperks/tests/perk/PerkDecisionTest.java b/MCPerks/src/test/java/com/bencodez/mcperks/tests/perk/PerkDecisionTest.java new file mode 100644 index 0000000..7945a2f --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/perk/PerkDecisionTest.java @@ -0,0 +1,255 @@ +package com.bencodez.mcperks.tests.perk; +import static com.bencodez.mcperks.tests.TestAccess.*; +import com.bencodez.mcperks.perk.*; +import com.bencodez.mcperks.perk.Effect; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.util.*; +import org.junit.jupiter.api.Test; +import com.bencodez.mcperks.MCPerksMain; +import com.bencodez.mcperks.userapi.MCPerksUser; +import com.bencodez.mcperks.configs.Config; +import com.bencodez.advancedcore.api.rewards.RewardBuilder; +class PerkDecisionTest { + + @Test void persistedRestoreUsesSavedExpirationAndSuppressesActivationHooks() { verifyRestore(false); } + @Test void persistedPermanentRestoreRemainsPermanentWithoutReplayingHooks() { verifyRestore(true); } + @Test void capturedRestoreDeadlineDoesNotRereadNoCacheUserOnTheOwner() { + try (var timers = mockConstruction(java.util.Timer.class)) { verifyRestore(false, true); } + } + @Test void publishedCloneReleasesCapacityBeforeReentrantActivationHooks() { + try (var timers = mockConstruction(java.util.Timer.class)) { verifyRestore(false, false, true); } + } + @Test void timedGrantIsPublishedBeforeExpirationCanRun() { verifyRestore(false, false, false, true); } + @Test void timerScheduleFailureDoesNotLeaveAnUnexpiringActiveClone() { verifyRestore(false, false, false, true, true); } + @Test void expirationAfterRetirementDoesNotRunDeactivation() throws Exception { + var scheduled = new java.util.concurrent.atomic.AtomicReference(); + var entered = new java.util.concurrent.CountDownLatch(1); + var done = new java.util.concurrent.CountDownLatch(1); + var failure = new java.util.concurrent.atomic.AtomicReference(); + var perk = mock(Perk.class, CALLS_REAL_METHODS); var user = mock(MCPerksUser.class); + doReturn(System.currentTimeMillis() + 60000).when(perk).getExperation(user); + perk.setActive(true); + doReturn(false).when(perk).isLastForever(); + try (var timers = mockConstruction(java.util.Timer.class, (timer, context) -> + doAnswer(call -> { scheduled.set(call.getArgument(0)); return null; }) + .when(timer).schedule(any(java.util.TimerTask.class), any(java.util.Date.class)))) { + perk.scheduleDeactivation(user); assertNotNull(scheduled.get()); + Thread expiration = new Thread(() -> { entered.countDown(); try { scheduled.get().run(); } catch (Throwable error) { failure.set(error); } finally { done.countDown(); } }); + synchronized (perk) { + expiration.start(); assertTrue(entered.await(2, java.util.concurrent.TimeUnit.SECONDS)); + assertFalse(done.await(250, java.util.concurrent.TimeUnit.MILLISECONDS), "expiration must wait for retirement under the same monitor"); + perk.setActive(false); + } + assertTrue(done.await(2, java.util.concurrent.TimeUnit.SECONDS)); + assertNull(failure.get(), "retired expiration must not perform deactivation side effects"); + } + } + @Test void periodicTimerFailureDoesNotLeaveAnActiveClone() { + try (var timers = mockConstruction(java.util.Timer.class, (timer, context) -> + doThrow(new IllegalStateException("periodic scheduler failure")).when(timer) + .scheduleAtFixedRate(any(java.util.TimerTask.class), anyLong(), anyLong()))) { + verifyRestore(false, false, false, false, false, true); + } + } + @Test void laterPeriodicTimerFailureDoesNotLeaveAnActiveClone() { + var firstTask = new java.util.concurrent.atomic.AtomicReference(); + var registrations = new java.util.concurrent.atomic.AtomicInteger(); + try (var timers = mockConstruction(java.util.Timer.class, (timer, context) -> + doAnswer(call -> { + if (registrations.getAndIncrement() == 0) { firstTask.set(call.getArgument(0)); return null; } + throw new IllegalStateException("later periodic scheduler failure"); + }).when(timer).scheduleAtFixedRate(any(java.util.TimerTask.class), anyLong(), anyLong()))) { + verifyRestore(false, false, false, false, false, true, true); + assertNotNull(firstTask.get()); assertDoesNotThrow(() -> firstTask.get().run()); + } + } + private void verifyRestore(boolean permanent) { verifyRestore(permanent, false); } + private void verifyRestore(boolean permanent, boolean ownerNoCache) { verifyRestore(permanent, ownerNoCache, false); } + private void verifyRestore(boolean permanent, boolean ownerNoCache, boolean reentrant) { verifyRestore(permanent, ownerNoCache, reentrant, false); } + private void verifyRestore(boolean permanent, boolean ownerNoCache, boolean reentrant, boolean checkTimerOrder) { verifyRestore(permanent, ownerNoCache, reentrant, checkTimerOrder, false); } + private void verifyRestore(boolean permanent, boolean ownerNoCache, boolean reentrant, boolean checkTimerOrder, boolean timerFailure) { verifyRestore(permanent, ownerNoCache, reentrant, checkTimerOrder, timerFailure, false); } + private void verifyRestore(boolean permanent, boolean ownerNoCache, boolean reentrant, boolean checkTimerOrder, boolean timerFailure, boolean periodicFailure) { verifyRestore(permanent, ownerNoCache, reentrant, checkTimerOrder, timerFailure, periodicFailure, false); } + private void verifyRestore(boolean permanent, boolean ownerNoCache, boolean reentrant, boolean checkTimerOrder, boolean timerFailure, boolean periodicFailure, boolean laterPeriodicFailure) { + var handlerRef = new java.util.concurrent.atomic.AtomicReference(); + UUID owner = UUID.randomUUID(); MCPerksMain plugin = mock(MCPerksMain.class); + MCPerksUser user = mock(MCPerksUser.class); when(user.getUUID()).thenReturn(owner.toString()); + EffectHandler effects = mock(EffectHandler.class); when(plugin.getEffectHandler()).thenReturn(effects); + Config config = mock(Config.class); when(plugin.getConfigFile()).thenReturn(config); + when(config.getBossBarEnabled()).thenReturn(false); when(config.getLogActivation()).thenReturn(false); + var serverData = mock(com.bencodez.mcperks.data.ServerData.class); when(plugin.getMcperksServerData()).thenReturn(serverData); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); when(plugin.getUserManager()).thenReturn(users); + when(users.getUser(owner)).thenReturn(mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class)); + var rewardHandler = mock(com.bencodez.advancedcore.api.rewards.RewardHandler.class); when(plugin.getRewardHandler()).thenReturn(rewardHandler); + when(rewardHandler.hasRewards(any(), anyString())).thenReturn(false); + if (ownerNoCache) { + when(user.getPlugin()).thenReturn(plugin); + when(plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.SQLITE); + when(user.getUserDataFetchMode()).thenReturn(com.bencodez.advancedcore.api.user.UserDataFetchMode.NO_CACHE); + var manager = mock(com.bencodez.advancedcore.api.user.usercache.UserDataManager.class); + when(users.getDataManager()).thenReturn(manager); + when(manager.effectiveStorageType(any())).thenAnswer(call -> call.getArgument(0)); + when(manager.usesSharedSqlStorage(any())).thenReturn(true); + when(manager.mustDeferSharedStorageAccess()).thenReturn(true); + var data = new com.bencodez.advancedcore.api.user.UserData(user); + when(user.getData()).thenReturn(data); + doCallRealMethod().when(user).getPerkExperation(any()); + // Exercise the selected SDK's real rejection, rather than assuming NO_CACHE is safe. + assertThrows(IllegalStateException.class, () -> data.getString( + com.bencodez.advancedcore.api.user.UserStorage.SQLITE, "Experation_restore", + com.bencodez.advancedcore.api.user.UserDataFetchMode.NO_CACHE)); + } + Perk definition = mock(Perk.class), clone = mock(Perk.class, call -> { + if (reentrant && call.getMethod().getName().equals("setKnownExpiration")) return call.callRealMethod(); + if (reentrant && call.getMethod().getName().equals("getKnownExpiration")) return call.callRealMethod(); + if (checkTimerOrder && call.getMethod().getName().equals("scheduleDeactivation")) { + assertTrue(handlerRef.get().getActivePerks().contains(call.getMock()), + "timed clone must be visible before expiration can run"); + if (timerFailure) throw new IllegalStateException("synthetic scheduler failure"); + return null; + } + if (ownerNoCache && Set.of("scheduleDeactivation", "getExperation").contains(call.getMethod().getName())) + return call.callRealMethod(); + return RETURNS_DEFAULTS.answer(call); + }); + if (ownerNoCache) try { + var field = Perk.class.getDeclaredField("plugin"); field.setAccessible(true); field.set(clone, plugin); + } catch (ReflectiveOperationException error) { throw new AssertionError(error); } + when(definition.clone()).thenReturn(clone); when(definition.getPerk()).thenReturn("restore"); + when(definition.getPerkType()).thenReturn(PerkSystemType.PLAYER); when(definition.getTime()).thenReturn(30); + when(definition.isTimed()).thenReturn(true); when(definition.isLastForever()).thenReturn(false); + when(definition.getEffects()).thenReturn(laterPeriodicFailure ? new ArrayList<>(List.of(Effect.ToolRepair)) : new ArrayList<>()); + var audience = new ArrayList(); + when(clone.getEffectedPlayers()).thenReturn(audience); doAnswer(i -> { audience.add(i.getArgument(0)); return null; }).when(clone).addEffectedPlayer(anyString()); + when(clone.getPerk()).thenReturn("restore"); + var cloneTime = new java.util.concurrent.atomic.AtomicInteger(30); + when(clone.getPerkType()).thenReturn(PerkSystemType.PLAYER); when(clone.getTime()).thenAnswer(call -> cloneTime.get()); + doAnswer(call -> { cloneTime.set(call.getArgument(0)); return null; }).when(clone).setTime(anyInt()); + when(clone.isTimed()).thenReturn(true); when(clone.isLastForever()).thenAnswer(call -> cloneTime.get() < 0); + var persistentEffect = timerFailure || periodicFailure ? mock(Effect.class) : null; + when(clone.getEffects()).thenReturn(timerFailure || periodicFailure ? new ArrayList<>(List.of(persistentEffect)) : new ArrayList<>()); + if (periodicFailure) when(clone.getReActivateEffectsTimer()).thenReturn(1); + if (laterPeriodicFailure) when(clone.getIncreasePercent(Effect.ToolRepair)).thenReturn(1000.0); + PerkHandler handler = newPerkHandler(plugin, List.of()); handlerRef.set(handler); + when(plugin.getPerkHandler()).thenReturn(handler); + var nested = new java.util.concurrent.atomic.AtomicReference(); + var rewardUser = users.getUser(owner); + if (reentrant) { + when(config.getLimitPermission()).thenReturn(true); when(user.getPerkLimit()).thenReturn(3); + when(user.getActivePerks()).thenAnswer(call -> handler.getActivePerks().size()); + } + long expiration = System.currentTimeMillis() + 20_000L; + var language = mock(com.bencodez.mcperks.configs.Lang.class); when(language.getCountDownTimer()).thenReturn(""); + try (var langs = mockStatic(com.bencodez.mcperks.configs.Lang.class); + var configs = mockStatic(com.bencodez.mcperks.configs.ConfigPerks.class); + var rewards = mockConstruction(RewardBuilder.class, (mock, context) -> { + when(mock.setGiveOffline(false)).thenReturn(mock); + if (reentrant) doAnswer(call -> { + nested.set(nativeCall(handler, "beginNativeActivation", + new Class[] {MCPerksUser.class, boolean.class}, user, true)); return null; + }).when(mock).send(rewardUser); + })) { + var perkConfig = mock(com.bencodez.mcperks.configs.ConfigPerks.class); + configs.when(com.bencodez.mcperks.configs.ConfigPerks::getInstance).thenReturn(perkConfig); + when(perkConfig.getData("restore")).thenReturn(new org.bukkit.configuration.file.YamlConfiguration()); + when(perkConfig.getActivationEffectPath()).thenReturn("ActivationEffect"); + langs.when(com.bencodez.mcperks.configs.Lang::getInstance).thenReturn(language); + if (timerFailure || periodicFailure) { + assertThrows(IllegalStateException.class, () -> handler.restoreActivePerk(definition, user, new java.sql.Timestamp(expiration))); + assertTrue(handler.getActivePerks().isEmpty(), "a failed expiration schedule must not retain an unexpiring clone"); + verify(clone).setActive(false); + if (periodicFailure) assertTrue(mockingDetails(clone).getInvocations().stream().noneMatch( + call -> call.getMethod().getName().equals("scheduleDeactivation")), + "expiration must not be registered after periodic registration fails"); + verify(persistentEffect).removeEffect(plugin, clone, audience); + return; + } + handler.restoreActivePerk(definition, user, permanent ? null : new java.sql.Timestamp(expiration)); + verify(effects).activate(any(Perk.class), eq(user), eq(false)); + assertEquals(1, handler.getActivePerks().size()); + verify(clone).setExperation(eq(user), longThat(value -> value == (permanent ? -1L : expiration))); + if (permanent) verify(clone).setTime(-1); + verify(definition, never()).setTime(anyInt()); + assertTrue(rewards.constructed().isEmpty()); verify(serverData, never()).addPerkHistory(anyString()); + verify(clone, never()).announcePerk(any(), anyInt()); + Object outer = reentrant ? nativeCall(handler, "beginNativeActivation", + new Class[] {MCPerksUser.class, boolean.class}, user, true) : null; + Class admission = reentrant ? outer.getClass() : null; + if (reentrant) nativeCall(handler, "enterNativeGameplay", new Class[] {admission}, outer); + try { cloneTime.set(30); reset(effects); handler.activePerk(definition, user, 30, null); } + finally { if (reentrant) nativeCall(handler, "leaveNativeGameplay", new Class[] {admission}, new Object[] {null}); } + verify(effects).activate(any(Perk.class), eq(user), eq(true)); + assertEquals(1, rewards.constructed().size()); + verify(rewards.constructed().get(0)).send(users.getUser(owner)); + verify(clone).announcePerk(any(), eq(30)); + verify(serverData).addPerkHistory(anyString()); + if (reentrant) { + verify(clone, times(1)).setExperation(eq(user), anyLong()); + assertTrue(mockingDetails(clone).getInvocations().stream().anyMatch(call -> + call.getMethod().getName().equals("setKnownExpiration")), + "confirmed native activation must not rewrite user storage on the owner"); + assertTrue(clone.getKnownExpiration() > System.currentTimeMillis(), + "the live clone must retain the confirmed expiration"); + assertNotNull(nested.get(), "the activation hook must observe free capacity after clone publication"); + var drain = handler.retireNativeActivations().toCompletableFuture(); assertFalse(drain.isDone()); + nativeCall(handler, "finishNativeActivation", new Class[] {admission}, outer); + assertFalse(drain.isDone(), "the nested operation still owns its settlement"); + nativeCall(handler, "finishNativeActivation", new Class[] {admission}, nested.get()); + assertTrue(drain.isDone()); + } + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {true, false}) + void quitBeforeSessionBindingIsScopedToExactSession(boolean sameSession) throws Exception { + var plugin = mock(MCPerksMain.class); var config = mock(Config.class); + when(plugin.getConfigFile()).thenReturn(config); + var handler = newPerkHandler(plugin, List.of()); + var user = mock(MCPerksUser.class); var player = mock(org.bukkit.entity.Player.class); + UUID uuid = UUID.randomUUID(); when(user.getUUID()).thenReturn(uuid.toString()); when(player.getUniqueId()).thenReturn(uuid); + try { + Object activation = nativeCall(handler, "beginNativeActivation", new Class[] {MCPerksUser.class, boolean.class}, user, false); + handler.retirePendingPlayerSession(player); + var captured = sameSession ? player : mock(org.bukkit.entity.Player.class); + nativeCall(handler, "bindNativeSession", new Class[] {activation.getClass(), org.bukkit.entity.Player.class}, activation, captured); + var field = activation.getClass().getDeclaredField("sessionRetired"); field.setAccessible(true); + assertEquals(sameSession, field.getBoolean(activation), "quit must retire only the exact captured session"); + nativeCall(handler, "finishNativeActivation", new Class[] {activation.getClass()}, activation); + } finally { handler.retireTimers(); } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {true, false}) + void actualQuitListenerRespectsLogoutPolicyBeforeSessionBinding(boolean enabled) throws Exception { + var plugin = mock(MCPerksMain.class); var config = mock(Config.class); + when(plugin.getConfigFile()).thenReturn(config); when(config.getDeactivateOnLogout()).thenReturn(enabled); + var handler = newPerkHandler(plugin, List.of()); when(plugin.getPerkHandler()).thenReturn(handler); + var user = mock(MCPerksUser.class); var player = mock(org.bukkit.entity.Player.class); + var world = mock(org.bukkit.World.class); when(world.getName()).thenReturn("test-world"); when(player.getWorld()).thenReturn(world); + UUID uuid = UUID.randomUUID(); when(user.getUUID()).thenReturn(uuid.toString()); when(player.getUniqueId()).thenReturn(uuid); + var event = mock(org.bukkit.event.player.PlayerQuitEvent.class); when(event.getPlayer()).thenReturn(player); + try { + Object activation = nativeCall(handler, "beginNativeActivation", new Class[] {MCPerksUser.class, boolean.class}, user, false); + new com.bencodez.mcperks.listeners.EffectListeners(plugin).onDisconnect(event); + nativeCall(handler, "bindNativeSession", new Class[] {activation.getClass(), org.bukkit.entity.Player.class}, activation, player); + var field = activation.getClass().getDeclaredField("sessionRetired"); field.setAccessible(true); + assertEquals(enabled, field.getBoolean(activation)); + nativeCall(handler, "finishNativeActivation", new Class[] {activation.getClass()}, activation); + } finally { handler.retireTimers(); } + } + + private static Object nativeCall(PerkHandler handler, String name, Class[] types, Object... arguments) { + try { + var method = PerkHandler.class.getDeclaredMethod(name, types); method.setAccessible(true); + return method.invoke(handler, arguments); + } catch (ReflectiveOperationException error) { throw new AssertionError(error); } + } + + + + + + + +} diff --git a/MCPerks/src/test/java/com/bencodez/mcperks/tests/perk/ShutdownEffectCleanupTest.java b/MCPerks/src/test/java/com/bencodez/mcperks/tests/perk/ShutdownEffectCleanupTest.java new file mode 100644 index 0000000..a6cb36d --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/perk/ShutdownEffectCleanupTest.java @@ -0,0 +1,115 @@ +package com.bencodez.mcperks.tests.perk; +import com.bencodez.mcperks.perk.*; +import com.bencodez.mcperks.perk.Effect; + +import com.bencodez.mcperks.MCPerksMain; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ShutdownEffectCleanupTest { + @Test void shutdownFlightRemovalRunsOnOwnerWithoutSchedulingDisabledPluginWork() { + MCPerksMain plugin = mock(MCPerksMain.class); + Perk perk = mock(Perk.class); + Player player = mock(Player.class); + World world = mock(World.class); + UUID owner = UUID.randomUUID(); + when(player.getWorld()).thenReturn(world); + when(world.getName()).thenReturn("world"); + when(perk.getFlyWorlds()).thenReturn(new ArrayList<>(List.of("world"))); + when(plugin.isEnabled()).thenReturn(false); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + bukkit.when(() -> Bukkit.getPlayer(owner)).thenReturn(player); + Effect.Fly.removeEffectForShutdown(plugin, perk, new ArrayList<>(List.of(owner.toString()))); + verify(player).setAllowFlight(false); + verify(player).setFlying(false); + verify(player).setFallDistance(-100000); + bukkit.verify(Bukkit::getScheduler, never()); + } + } + + @Test void shutdownFlightRemovalPreservesBypassAndRejectsOffOwnerAccess() { + MCPerksMain plugin = mock(MCPerksMain.class); + Perk perk = mock(Perk.class); + Player player = mock(Player.class); + World world = mock(World.class); + UUID owner = UUID.randomUUID(); + when(player.getWorld()).thenReturn(world); + when(world.getName()).thenReturn("world"); + when(perk.getFlyWorlds()).thenReturn(new ArrayList<>(List.of("world"))); + when(player.hasPermission("MCPerks.ServerFly.Bypass")).thenReturn(true); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + bukkit.when(() -> Bukkit.getPlayer(owner)).thenReturn(player); + Effect.Fly.removeEffectForShutdown(plugin, perk, new ArrayList<>(List.of(owner.toString()))); + verify(player, never()).setAllowFlight(anyBoolean()); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(false); + assertThrows(IllegalStateException.class, () -> Effect.Fly.removeEffectForShutdown( + plugin, perk, new ArrayList<>(List.of(owner.toString())))); + bukkit.verify(Bukkit::getScheduler, never()); + } + } + @Test void shutdownCommandsRenderAndDispatchOnOwnerWithoutDisabledPluginScheduling() { + MCPerksMain plugin = mock(MCPerksMain.class); + Perk perk = mock(Perk.class); + Player player = mock(Player.class); + org.bukkit.Server server = mock(org.bukkit.Server.class); + org.bukkit.command.ConsoleCommandSender console = mock(org.bukkit.command.ConsoleCommandSender.class); + UUID owner = UUID.randomUUID(); + when(player.getName()).thenReturn("Tester"); + var configured = new ArrayList<>(List.of("/cleanup %player%")); + var rendered = new ArrayList<>(List.of("/cleanup Tester")); + when(perk.getDisableCommands()).thenReturn(configured); + try (var bukkit = mockStatic(Bukkit.class); + var placeholders = mockStatic(com.bencodez.advancedcore.api.messages.PlaceholderUtils.class)) { + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + bukkit.when(() -> Bukkit.getPlayer(owner)).thenReturn(player); + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getConsoleSender).thenReturn(console); + placeholders.when(() -> com.bencodez.advancedcore.api.messages.PlaceholderUtils + .replaceJavascriptOnly(player, configured)).thenReturn(configured); + placeholders.when(() -> com.bencodez.advancedcore.api.messages.PlaceholderUtils + .replacePlaceHolder(eq(configured), any(java.util.HashMap.class))).thenReturn(rendered); + placeholders.when(() -> com.bencodez.advancedcore.api.messages.PlaceholderUtils + .replacePlaceHolders(rendered, player)).thenReturn(rendered); + Effect.Commands.removeEffectForShutdown(plugin, perk, new ArrayList<>(List.of(owner.toString()))); + verify(server).dispatchCommand(console, "cleanup Tester"); + bukkit.verify(Bukkit::getScheduler, never()); + } + } + + @Test void bossBarCleanupFailureDoesNotAbortDetachOrRepeatCleanup() throws Exception { + MCPerksMain plugin = mock(MCPerksMain.class); + PerkHandler handler = mock(PerkHandler.class); + Perk perk = mock(Perk.class, CALLS_REAL_METHODS); + var bossBar = mock(com.bencodez.advancedcore.api.misc.effects.BossBar.class); + var active = new java.util.concurrent.CopyOnWriteArrayList(List.of(perk)); + when(plugin.getPerkHandler()).thenReturn(handler); + when(handler.getActivePerks()).thenReturn(active); + when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger()); + var pluginField = Perk.class.getDeclaredField("plugin"); + pluginField.setAccessible(true); + pluginField.set(perk, plugin); + perk.setActive(true); + perk.setBossBar(bossBar); + doReturn(new ArrayList()).when(perk).getEffects(); + doThrow(new IllegalStateException("fixture hide failure")).when(bossBar).hide(); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + assertTrue(perk.detachForShutdown()); + assertFalse(perk.isActive()); + assertNull(perk.getBossBar()); + assertTrue(active.isEmpty()); + assertFalse(perk.detachForShutdown()); + verify(bossBar).hide(); + } + } + +} diff --git a/MCPerks/src/test/java/com/bencodez/mcperks/tests/userapi/ActivationCreditConcurrencyTest.java b/MCPerks/src/test/java/com/bencodez/mcperks/tests/userapi/ActivationCreditConcurrencyTest.java new file mode 100644 index 0000000..74a1634 --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/userapi/ActivationCreditConcurrencyTest.java @@ -0,0 +1,824 @@ +package com.bencodez.mcperks.tests.userapi; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.util.UUID; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import org.junit.jupiter.api.Test; +import com.bencodez.advancedcore.api.user.UserData; +import com.bencodez.mcperks.userapi.MCPerksUser; +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.user.usercache.UserDataCache; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.api.user.UserManager; +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.simpleapi.sql.data.DataValue; +import com.bencodez.simpleapi.sql.data.DataValueInt; +import java.util.HashMap; + +class ActivationCreditConcurrencyTest { + private static class CustomUserManager extends UserManager { + CustomUserManager(AdvancedCorePlugin plugin) { super(plugin); } + @Override public com.bencodez.advancedcore.api.user.AdvancedCoreUser getUser(UUID uuid, boolean loadName) { + return super.getUser(uuid, loadName); + } + } + private static class InheritedUserManager extends UserManager { + InheritedUserManager(AdvancedCorePlugin plugin) { super(plugin); } + } + private static class CustomDataUser extends MCPerksUser { + CustomDataUser(com.bencodez.mcperks.MCPerksMain plugin, UUID id) { super(plugin, id); } + @Override public UserData getData() { return super.getData(); } + } + @Test void asynchronousAwardRetainsOriginalOperationAcrossDirectWrite() throws Exception { + UUID id = UUID.randomUUID(); var plugin = mock(AdvancedCorePlugin.class); var users = mock(UserManager.class); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(plugin.getSQLiteUserTable()).thenReturn(table); + when(plugin.getNativeUserStorageOwner()).thenReturn(new AdvancedCorePlugin.UserStorageOwner(UserStorage.SQLITE, null, table)); + var manager = new UserDataManager(plugin); when(users.getDataManager()).thenReturn(manager); + var stored = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); var data = mock(UserData.class); + when(users.getUser(id, false)).thenReturn(stored); when(stored.getUserData()).thenReturn(data); + when(data.supportsStrictCompleteValues()).thenReturn(true); + when(data.getValuesIfStrictComplete()).thenReturn(new HashMap<>(java.util.Map.of("Activations", new DataValueInt(1)))); + var cache = new UserDataCache(manager, id); + cache.updateCompleteCachePreservingPending(new HashMap<>(java.util.Map.of("Activations", new DataValueInt(1)))); + manager.getUserDataCache().put(id, cache); + var user = mock(MCPerksUser.class, CALLS_REAL_METHODS); + doReturn(plugin).when(user).getPlugin(); doReturn(id.toString()).when(user).getUUID(); doReturn(data).when(user).getData(); + var writer = manager.beginLegacyDirectWrite(id); + try { + var award = user.addActivationAsync(1).toCompletableFuture(); + assertFalse(award.cancel(false), "an accepted award must retain its observable outcome"); + assertFalse(award.complete(null), "callers must not forge successful completion"); + assertFalse(award.isDone()); assertEquals(1, cache.getIfPresent("Activations").getInt()); + manager.endLegacyDirectWrite(writer); writer = null; + award.get(5, TimeUnit.SECONDS); + assertEquals(2, cache.getIfPresent("Activations").getInt()); + verify(data, never()).getInt(anyString()); verify(data, never()).setInt(anyString(), anyInt()); + } finally { if (writer != null) manager.endLegacyDirectWrite(writer); manager.getTimer().shutdownNow(); } + } + @Test void sharedAwardUsesObservableProducerReceipt() throws Exception { + UUID id = UUID.randomUUID(); var plugin = mock(AdvancedCorePlugin.class); var users = mock(UserManager.class); + var backend = mock(com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend.class); + var owner = mock(com.bencodez.advancedcore.core.user.runtime.UserCacheOwner.class); + when(backend.isOpen()).thenReturn(true); when(backend.storageType()).thenReturn(UserStorage.SQLITE); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + var manager = new UserDataManager(plugin); when(users.getDataManager()).thenReturn(manager); + manager.bindSharedSqlBackend(backend, (uuid, operation) -> operation.run()); + manager.bindSharedRuntime(new com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime(backend, owner)); + var cache = new UserDataCache(manager, id); cache.configureSharedStorage(values -> {}, Runnable::run); + cache.updateCompleteCachePreservingPending(new HashMap<>(java.util.Map.of("Activations", new DataValueInt(1)))); + manager.getUserDataCache().put(id, cache); + var user = mock(MCPerksUser.class, CALLS_REAL_METHODS); + doReturn(plugin).when(user).getPlugin(); doReturn(id.toString()).when(user).getUUID(); + doReturn(new UserData(mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class))).when(user).getData(); + try { + var receipt = user.addActivationAsync(1).toCompletableFuture(); + assertFalse(receipt.cancel(false)); receipt.get(5, TimeUnit.SECONDS); + assertEquals(2, cache.getIfPresent("Activations").getInt()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void asyncSetAndClampedRemoveUseObservedAtomicOperations() throws Exception { + UUID id = UUID.randomUUID(); var plugin = mock(AdvancedCorePlugin.class); var users = mock(UserManager.class); + var backend = mock(com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend.class); + when(backend.isOpen()).thenReturn(true); when(backend.storageType()).thenReturn(UserStorage.SQLITE); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + var manager = new UserDataManager(plugin); when(users.getDataManager()).thenReturn(manager); + manager.bindSharedSqlBackend(backend, (uuid, operation) -> operation.run()); + manager.bindSharedRuntime(new com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime(backend, + mock(com.bencodez.advancedcore.core.user.runtime.UserCacheOwner.class))); + var cache = new UserDataCache(manager, id); cache.configureSharedStorage(values -> {}, Runnable::run); + cache.updateCompleteCachePreservingPending(new HashMap<>(java.util.Map.of("Activations", new DataValueInt(3), "PerkActivations_vote", new DataValueInt(2)))); + manager.getUserDataCache().put(id, cache); + var user = mock(MCPerksUser.class, CALLS_REAL_METHODS); + doReturn(plugin).when(user).getPlugin(); doReturn(id.toString()).when(user).getUUID(); + doReturn(new UserData(mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class))).when(user).getData(); + try { + user.setActivationsAsync(10).toCompletableFuture().get(5, TimeUnit.SECONDS); + user.removeActivationClampedAsync(4).toCompletableFuture().get(5, TimeUnit.SECONDS); + user.setActivationsAsync("vote", 3).toCompletableFuture().get(5, TimeUnit.SECONDS); + user.removeActivationClampedAsync("vote", 9).toCompletableFuture().get(5, TimeUnit.SECONDS); + assertEquals(6, cache.getIfPresent("Activations").getInt()); assertEquals(0, cache.getIfPresent("PerkActivations_vote").getInt()); + var overflow = assertThrows(ExecutionException.class, () -> user.removeActivationClampedAsync(Integer.MIN_VALUE).toCompletableFuture().get(5, TimeUnit.SECONDS)); + assertInstanceOf(ArithmeticException.class, overflow.getCause()); assertEquals(6, cache.getIfPresent("Activations").getInt()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void coldSharedAsyncAwardWaitsForTheExistingBalance() throws Exception { + UUID id = UUID.randomUUID(); var plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + var users = mock(UserManager.class); var stored = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var data = mock(UserData.class); var backend = mock(com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend.class); + var owner = mock(com.bencodez.advancedcore.core.user.runtime.UserCacheOwner.class); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(plugin.getNativeUserStorageOwner()).thenReturn(null); + when(users.getUser(id, false)).thenReturn(stored); when(stored.getUserData()).thenReturn(data); + when(backend.isOpen()).thenReturn(true); when(backend.storageType()).thenReturn(UserStorage.SQLITE); + when(backend.hasAuthoritativeRowReads()).thenReturn(true); + when(data.getKeys()).thenReturn(new java.util.ArrayList<>()); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + when(data.getValuesIfComplete()).thenAnswer(call -> { + entered.countDown(); assertTrue(release.await(5, TimeUnit.SECONDS)); + return new HashMap<>(java.util.Map.of("Activations", new DataValueInt(1))); + }); + var manager = new UserDataManager(plugin); when(users.getDataManager()).thenReturn(manager); + manager.bindSharedCacheInitializer(cache -> cache.configureSharedStorage(values -> {}, Runnable::run)); + manager.bindSharedSqlBackend(backend, (uuid, operation) -> operation.run()); + manager.bindSharedRuntime(new com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime(backend, owner)); + var user = mock(MCPerksUser.class, CALLS_REAL_METHODS); + doReturn(plugin).when(user).getPlugin(); doReturn(id.toString()).when(user).getUUID(); + doReturn(new UserData(mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class))).when(user).getData(); + try { + var receipt = user.addActivationAsync(1).toCompletableFuture(); + if (!entered.await(5, TimeUnit.SECONDS)) { receipt.get(1, TimeUnit.SECONDS); fail("storage read did not begin"); } + assertFalse(receipt.isDone()); assertFalse(receipt.cancel(false)); + release.countDown(); receipt.get(5, TimeUnit.SECONDS); + assertEquals(2, manager.getPublishedCache(id).getIfPresent("Activations").getInt()); + verify(data, times(1)).getValuesIfComplete(); + } finally { release.countDown(); manager.getTimer().shutdownNow(); } + } + @Test void coldSharedAsyncAwardRejectsAnUnconfirmedBalance() throws Exception { + UUID id = UUID.randomUUID(); var plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + var users = mock(UserManager.class); var stored = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var data = mock(UserData.class); var backend = mock(com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend.class); + var owner = mock(com.bencodez.advancedcore.core.user.runtime.UserCacheOwner.class); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(plugin.getNativeUserStorageOwner()).thenReturn(null); + when(users.getUser(id, false)).thenReturn(stored); when(stored.getUserData()).thenReturn(data); + when(backend.isOpen()).thenReturn(true); when(backend.storageType()).thenReturn(UserStorage.SQLITE); + when(backend.hasAuthoritativeRowReads()).thenReturn(true); when(data.getKeys()).thenReturn(new java.util.ArrayList<>()); + when(data.getValuesIfComplete()).thenReturn(null); + var manager = new UserDataManager(plugin); when(users.getDataManager()).thenReturn(manager); + manager.bindSharedCacheInitializer(cache -> cache.configureSharedStorage(values -> {}, Runnable::run)); + manager.bindSharedSqlBackend(backend, (uuid, operation) -> operation.run()); + manager.bindSharedRuntime(new com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime(backend, owner)); + var user = mock(MCPerksUser.class, CALLS_REAL_METHODS); + doReturn(plugin).when(user).getPlugin(); doReturn(id.toString()).when(user).getUUID(); + doReturn(new UserData(mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class))).when(user).getData(); + try { + var receipt = user.addActivationAsync(1).toCompletableFuture(); + assertThrows(ExecutionException.class, () -> receipt.get(5, TimeUnit.SECONDS)); + var cache = manager.getPublishedCache(id); + if (cache != null) assertNull(cache.getIfPresent("Activations")); + verify(data, never()).setInt(anyString(), anyInt()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void supportedLegacyAwardDoesNotFallBackWhileDirectWriteIsAdmitted() throws Exception { + UUID id = UUID.randomUUID(); var plugin = mock(AdvancedCorePlugin.class); var users = mock(UserManager.class); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class); + when(plugin.getSQLiteUserTable()).thenReturn(table); + when(plugin.getNativeUserStorageOwner()).thenReturn(new AdvancedCorePlugin.UserStorageOwner(UserStorage.SQLITE, null, table)); + var manager = new UserDataManager(plugin); when(users.getDataManager()).thenReturn(manager); + var cache = new UserDataCache(manager, id); + cache.updateCompleteCachePreservingPending(new HashMap<>(java.util.Map.of("Activations", new DataValueInt(1)))); + manager.getUserDataCache().put(id, cache); + var user = mock(MCPerksUser.class, CALLS_REAL_METHODS); var data = mock(UserData.class); + when(data.supportsStrictCompleteValues()).thenReturn(true); + when(data.getValuesIfStrictComplete()).thenReturn(new HashMap<>(java.util.Map.of("Activations", new DataValueInt(1)))); + var stored = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + when(users.getUser(id, false)).thenReturn(stored); when(stored.getUserData()).thenReturn(data); + doReturn(plugin).when(user).getPlugin(); doReturn(id.toString()).when(user).getUUID(); doReturn(data).when(user).getData(); + var admission = manager.beginLegacyDirectWrite(id); + try { + user.addActivation(1); + var pending = manager.pendingLegacyIntegerMutations(id).toCompletableFuture(); assertFalse(pending.isDone()); + assertEquals(1, cache.getIfPresent("Activations").getInt()); + manager.endLegacyDirectWrite(admission); admission = null; pending.get(5, TimeUnit.SECONDS); + assertEquals(2, cache.getIfPresent("Activations").getInt()); + verify(data, never()).getInt(anyString()); verify(data, never()).setInt(anyString(), anyInt()); + } finally { if (admission != null) manager.endLegacyDirectWrite(admission); manager.getTimer().shutdownNow(); } + } + @Test void legacyVoidAdmissionReportsFailureWithoutRetryOrFallback() { + UUID id = UUID.randomUUID(); var plugin = mock(AdvancedCorePlugin.class); var users = mock(UserManager.class); + var manager = mock(UserDataManager.class); when(plugin.getUserManager()).thenReturn(users); + when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); when(users.getDataManager()).thenReturn(manager); + var data = mock(UserData.class); when(data.supportsStrictCompleteValues()).thenReturn(true); + var receipt = new CompletableFuture(); + when(manager.submitLegacyIntegerMutation(eq(id), anyMap(), eq(UserDataCache.IntegerMutation.ADD), any())).thenReturn(receipt); + var user = mock(MCPerksUser.class, CALLS_REAL_METHODS); + doReturn(plugin).when(user).getPlugin(); doReturn(id.toString()).when(user).getUUID(); doReturn(data).when(user).getData(); + user.addActivation(1); assertFalse(receipt.isDone()); + var failure = new IllegalStateException("unconfirmed"); receipt.completeExceptionally(failure); + verify(manager).recordSharedStorageFailure(failure); + verify(manager, times(1)).submitLegacyIntegerMutation(eq(id), anyMap(), eq(UserDataCache.IntegerMutation.ADD), any()); + verify(data, never()).getInt(anyString()); verify(data, never()).setInt(anyString(), anyInt()); + } + @Test void coldSharedSynchronousAwardAdmitsOneOperationAndReportsFailure() { + UUID id = UUID.randomUUID(); var plugin = mock(AdvancedCorePlugin.class); var users = mock(UserManager.class); + var manager = mock(UserDataManager.class); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(users.getDataManager()).thenReturn(manager); when(manager.usesSharedSqlStorage(UserStorage.SQLITE)).thenReturn(true); + when(manager.mustDeferSharedStorageAccess()).thenReturn(true); + var user = mock(MCPerksUser.class, CALLS_REAL_METHODS); + doReturn(plugin).when(user).getPlugin(); doReturn(id.toString()).when(user).getUUID(); + var receipt = new CompletableFuture(); + when(manager.submitSharedIntegerMutation(eq(id), anyMap(), eq(UserDataCache.IntegerMutation.ADD), any())).thenReturn(receipt); + user.addActivation(1); + assertFalse(receipt.isDone()); verify(manager, times(1)).submitSharedIntegerMutation(eq(id), anyMap(), eq(UserDataCache.IntegerMutation.ADD), any()); + verify(manager, never()).cacheUser(id); + var failure = new IllegalStateException("unconfirmed"); receipt.completeExceptionally(failure); + verify(manager).recordSharedStorageFailure(failure); + } + @Test void opaqueAsyncAwardFailsBeforeCallingAnUncertainSetter() throws Exception { + UUID id = UUID.randomUUID(); var plugin = mock(AdvancedCorePlugin.class); var users = mock(UserManager.class); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + var manager = new UserDataManager(plugin); when(users.getDataManager()).thenReturn(manager); + var user = mock(MCPerksUser.class, CALLS_REAL_METHODS); var data = mock(UserData.class); + doReturn(plugin).when(user).getPlugin(); doReturn(id.toString()).when(user).getUUID(); doReturn(data).when(user).getData(); + try { + var receipt = user.addActivationAsync(1).toCompletableFuture(); + var error = assertThrows(ExecutionException.class, () -> receipt.get(2, TimeUnit.SECONDS)); + assertInstanceOf(UnsupportedOperationException.class, error.getCause()); + verify(data, never()).getInt(anyString()); verify(data, never()).setInt(anyString(), anyInt()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void customReaderAwardKeepsLegacyCallbacksEvenWithPublishedCache() { + UUID id = UUID.randomUUID(); var plugin = mock(AdvancedCorePlugin.class); var users = mock(UserManager.class); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + var manager = new UserDataManager(plugin); when(users.getDataManager()).thenReturn(manager); + var cache = new UserDataCache(manager, id); + cache.updateCompleteCachePreservingPending(new HashMap<>(java.util.Map.of("Activations", new DataValueInt(1)))); + manager.getUserDataCache().put(id, cache); + var user = mock(MCPerksUser.class, CALLS_REAL_METHODS); var data = mock(UserData.class); + when(data.supportsStrictCompleteValues()).thenReturn(false); when(data.getInt("Activations")).thenReturn(1); + doReturn(plugin).when(user).getPlugin(); doReturn(id.toString()).when(user).getUUID(); doReturn(data).when(user).getData(); + try { + assertNull(user.reserveSharedActivations("vote")); + user.addActivation(1); + verify(data).setInt("Activations", 2); + assertEquals(1, cache.getIfPresent("Activations").getInt()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void strictReaderCapabilityDoesNotBypassCustomIntegerCallbacks() { + UUID id = UUID.randomUUID(); var plugin = mock(AdvancedCorePlugin.class); var users = mock(UserManager.class); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + var manager = new UserDataManager(plugin); when(users.getDataManager()).thenReturn(manager); + var cache = new UserDataCache(manager, id); + cache.updateCompleteCachePreservingPending(new HashMap<>(java.util.Map.of("Activations", new DataValueInt(1)))); + manager.getUserDataCache().put(id, cache); + var balance = new AtomicInteger(1); var writes = new AtomicInteger(); + var custom = new UserData(mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class)) { + @Override public boolean supportsStrictCompleteValues() { return true; } + @Override public int getInt(String key) { return balance.get(); } + @Override public void setInt(String key, int value) { writes.incrementAndGet(); balance.set(value); } + }; + var user = mock(MCPerksUser.class, CALLS_REAL_METHODS); + doReturn(plugin).when(user).getPlugin(); doReturn(id.toString()).when(user).getUUID(); doReturn(custom).when(user).getData(); + try { + user.addActivation(1); + assertEquals(2, balance.get()); assertEquals(1, writes.get()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void asyncReceiptRejectsCustomIntegerCallbacksBeforeAdmission() throws Exception { + UUID id = UUID.randomUUID(); var plugin = mock(AdvancedCorePlugin.class); var users = mock(UserManager.class); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + var manager = new UserDataManager(plugin); when(users.getDataManager()).thenReturn(manager); + var cache = new UserDataCache(manager, id); + cache.updateCompleteCachePreservingPending(new HashMap<>(java.util.Map.of("Activations", new DataValueInt(1)))); + manager.getUserDataCache().put(id, cache); + var writes = new AtomicInteger(); + var custom = new UserData(mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class)) { + @Override public boolean supportsStrictCompleteValues() { return true; } + @Override public int getInt(String key) { return 1; } + @Override public void setInt(String key, int value) { writes.incrementAndGet(); } + }; + var user = mock(MCPerksUser.class, CALLS_REAL_METHODS); + doReturn(plugin).when(user).getPlugin(); doReturn(id.toString()).when(user).getUUID(); doReturn(custom).when(user).getData(); + try { + var failure = assertThrows(ExecutionException.class, () -> user.addActivationAsync(1).toCompletableFuture().get(5, TimeUnit.SECONDS)); + assertInstanceOf(UnsupportedOperationException.class, failure.getCause()); + assertEquals(0, writes.get()); assertEquals(1, cache.getIfPresent("Activations").getInt()); + assertFalse(manager.hasUnsettledLegacyIntegerMutations()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void sharedAsyncReceiptRejectsCustomCallbacksWithoutEnqueuing() throws Exception { + UUID id = UUID.randomUUID(); var plugin = mock(AdvancedCorePlugin.class); var users = mock(UserManager.class); + var manager = mock(UserDataManager.class); when(plugin.getUserManager()).thenReturn(users); + when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); when(users.getDataManager()).thenReturn(manager); + when(manager.usesSharedSqlStorage(UserStorage.SQLITE)).thenReturn(true); + var custom = new UserData(mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class)) { + @Override public int getInt(String key) { throw new AssertionError("unexpected getter"); } + @Override public void setInt(String key, int value) { throw new AssertionError("unexpected setter"); } + }; + var user = mock(MCPerksUser.class, CALLS_REAL_METHODS); + doReturn(plugin).when(user).getPlugin(); doReturn(id.toString()).when(user).getUUID(); doReturn(custom).when(user).getData(); + var failure = assertThrows(ExecutionException.class, () -> user.addActivationAsync(1).toCompletableFuture().get(5, TimeUnit.SECONDS)); + assertInstanceOf(UnsupportedOperationException.class, failure.getCause()); + verify(manager, never()).submitSharedIntegerMutation(any(), anyMap(), any(), any()); + } + @Test void asyncReceiptRejectsCustomCanonicalUserManagerBeforeAdmission() throws Exception { + UUID id = UUID.randomUUID(); var plugin = mock(AdvancedCorePlugin.class); + var users = mock(CustomUserManager.class); when(plugin.getUserManager()).thenReturn(users); + when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + var backend = mock(com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend.class); + when(backend.isOpen()).thenReturn(true); when(backend.storageType()).thenReturn(UserStorage.SQLITE); + var manager = new UserDataManager(plugin); when(users.getDataManager()).thenReturn(manager); + var stored = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var customData = new UserData(stored) { + @Override public int getInt(String key) { throw new AssertionError("unexpected custom getter"); } + @Override public void setInt(String key, int value) { throw new AssertionError("unexpected custom setter"); } + }; + when(users.getUser(id, false)).thenReturn(stored); when(stored.getUserData()).thenReturn(customData); + manager.bindSharedSqlBackend(backend, (uuid, operation) -> operation.run()); + manager.bindSharedRuntime(new com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime(backend, + mock(com.bencodez.advancedcore.core.user.runtime.UserCacheOwner.class))); + var cache = new UserDataCache(manager, id); cache.configureSharedStorage(values -> {}, Runnable::run); + cache.updateCompleteCachePreservingPending(new HashMap<>(java.util.Map.of("Activations", new DataValueInt(1)))); + manager.getUserDataCache().put(id, cache); + var user = mock(MCPerksUser.class, CALLS_REAL_METHODS); + doReturn(plugin).when(user).getPlugin(); doReturn(id.toString()).when(user).getUUID(); + doReturn(new UserData(mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class))).when(user).getData(); + try { + var failure = assertThrows(ExecutionException.class, () -> user.addActivationAsync(1).toCompletableFuture().get(5, TimeUnit.SECONDS)); + assertInstanceOf(UnsupportedOperationException.class, failure.getCause()); + var replayFailure = assertThrows(ExecutionException.class, () -> user.addReplayActivationAsync(null, 1, "event:one").toCompletableFuture().get(5, TimeUnit.SECONDS)); + assertInstanceOf(UnsupportedOperationException.class, replayFailure.getCause()); + var checkpointFailure = assertThrows(ExecutionException.class, () -> user.checkpointReplayActivationAsync("event:one").toCompletableFuture().get(5, TimeUnit.SECONDS)); + assertInstanceOf(UnsupportedOperationException.class, checkpointFailure.getCause()); + assertEquals(1, cache.getIfPresent("Activations").getInt()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void asyncReceiptAllowsSubclassWithInheritedIntegerCallbacks() throws Exception { + UUID id = UUID.randomUUID(); var plugin = mock(AdvancedCorePlugin.class); var users = mock(UserManager.class); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + var backend = mock(com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend.class); + when(backend.isOpen()).thenReturn(true); when(backend.storageType()).thenReturn(UserStorage.SQLITE); + var manager = new UserDataManager(plugin); when(users.getDataManager()).thenReturn(manager); + manager.bindSharedSqlBackend(backend, (uuid, operation) -> operation.run()); + manager.bindSharedRuntime(new com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime(backend, + mock(com.bencodez.advancedcore.core.user.runtime.UserCacheOwner.class))); + var cache = new UserDataCache(manager, id); cache.configureSharedStorage(values -> {}, Runnable::run); + cache.updateCompleteCachePreservingPending(new HashMap<>(java.util.Map.of("Activations", new DataValueInt(1)))); + manager.getUserDataCache().put(id, cache); + var user = mock(MCPerksUser.class, CALLS_REAL_METHODS); + doReturn(plugin).when(user).getPlugin(); doReturn(id.toString()).when(user).getUUID(); + doReturn(new UserData(mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class)) { }).when(user).getData(); + try { + user.addActivationAsync(1).toCompletableFuture().get(5, TimeUnit.SECONDS); + assertEquals(2, cache.getIfPresent("Activations").getInt()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void strictLegacyReceiptAllowsSubclassWithInheritedIntegerCallbacks() throws Exception { + UUID id = UUID.randomUUID(); var plugin = mock(AdvancedCorePlugin.class); var users = mock(UserManager.class); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(plugin.getSQLiteUserTable()).thenReturn(table); + when(plugin.getNativeUserStorageOwner()).thenReturn(new AdvancedCorePlugin.UserStorageOwner(UserStorage.SQLITE, null, table)); + var manager = new UserDataManager(plugin); when(users.getDataManager()).thenReturn(manager); + var cache = new UserDataCache(manager, id); + cache.updateCompleteCachePreservingPending(new HashMap<>(java.util.Map.of("Activations", new DataValueInt(1)))); + manager.getUserDataCache().put(id, cache); + var user = mock(MCPerksUser.class, CALLS_REAL_METHODS); + doReturn(plugin).when(user).getPlugin(); doReturn(id.toString()).when(user).getUUID(); + doReturn(new UserData(mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class)) { + @Override public boolean supportsStrictCompleteValues() { return true; } + }).when(user).getData(); + try { user.addActivationAsync(1).toCompletableFuture().get(5, TimeUnit.SECONDS); assertEquals(2, cache.getIfPresent("Activations").getInt()); } + finally { manager.getTimer().shutdownNow(); } + } + @Test void asyncReceiptAllowsUserManagerWithInheritedLookup() throws Exception { + UUID id = UUID.randomUUID(); var plugin = mock(AdvancedCorePlugin.class); var users = mock(InheritedUserManager.class); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + var backend = mock(com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend.class); + when(backend.isOpen()).thenReturn(true); when(backend.storageType()).thenReturn(UserStorage.SQLITE); + var manager = new UserDataManager(plugin); when(users.getDataManager()).thenReturn(manager); + manager.bindSharedSqlBackend(backend, (uuid, operation) -> operation.run()); + manager.bindSharedRuntime(new com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime(backend, + mock(com.bencodez.advancedcore.core.user.runtime.UserCacheOwner.class))); + var cache = new UserDataCache(manager, id); cache.configureSharedStorage(values -> {}, Runnable::run); + cache.updateCompleteCachePreservingPending(new HashMap<>(java.util.Map.of("Activations", new DataValueInt(1)))); + manager.getUserDataCache().put(id, cache); var user = mock(MCPerksUser.class, CALLS_REAL_METHODS); + doReturn(plugin).when(user).getPlugin(); doReturn(id.toString()).when(user).getUUID(); + doReturn(new UserData(mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class))).when(user).getData(); + try { user.addActivationAsync(1).toCompletableFuture().get(5, TimeUnit.SECONDS); assertEquals(2, cache.getIfPresent("Activations").getInt()); } + finally { manager.getTimer().shutdownNow(); } + } + @Test void sharedSynchronousAwardDoesNotBypassCustomIntegerCallbacks() { + UUID id = UUID.randomUUID(); var plugin = mock(AdvancedCorePlugin.class); var users = mock(UserManager.class); + var manager = mock(UserDataManager.class); when(plugin.getUserManager()).thenReturn(users); + when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); when(users.getDataManager()).thenReturn(manager); + when(manager.usesSharedSqlStorage(UserStorage.SQLITE)).thenReturn(true); + when(manager.getPlugin()).thenReturn(plugin); when(manager.getTimer()).thenReturn(mock(ScheduledExecutorService.class)); + var cache = new UserDataCache(manager, id); cache.configureSharedStorage(values -> {}, Runnable::run); + cache.updateCompleteCachePreservingPending(new HashMap<>(java.util.Map.of("Activations", new DataValueInt(1)))); + when(manager.getPublishedCache(id)).thenReturn(cache); + var balance = new AtomicInteger(1); var writes = new AtomicInteger(); + var custom = new UserData(mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class)) { + @Override public int getInt(String key) { return balance.get(); } + @Override public void setInt(String key, int value) { writes.incrementAndGet(); balance.set(value); } + }; + var user = mock(MCPerksUser.class, CALLS_REAL_METHODS); + doReturn(plugin).when(user).getPlugin(); doReturn(id.toString()).when(user).getUUID(); doReturn(custom).when(user).getData(); + user.addActivation(1); + assertEquals(2, balance.get()); assertEquals(1, writes.get()); + } + @Test void legacyReservationPreservesExistingCustomReaderFallback() { + UUID id = UUID.randomUUID(); var plugin = mock(AdvancedCorePlugin.class); var users = mock(UserManager.class); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + var manager = new UserDataManager(plugin); when(users.getDataManager()).thenReturn(manager); + var stored = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); var custom = mock(UserData.class); + when(users.getUser(id, false)).thenReturn(stored); when(stored.getUserData()).thenReturn(custom); + var user = mock(MCPerksUser.class, CALLS_REAL_METHODS); + doReturn(plugin).when(user).getPlugin(); doReturn(id.toString()).when(user).getUUID(); doReturn(custom).when(user).getData(); + try { + assertNull(user.reserveSharedActivations("vote")); + verify(custom, never()).getValuesIfStrictComplete(); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void ordinaryLegacyCacheCannotFallBackToReadThenWriteForAwards() throws Exception { + UUID id = UUID.randomUUID(); + var plugin = mock(AdvancedCorePlugin.class); var users = mock(UserManager.class); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class); + when(plugin.getSQLiteUserTable()).thenReturn(table); + when(plugin.getNativeUserStorageOwner()).thenReturn(new AdvancedCorePlugin.UserStorageOwner(UserStorage.SQLITE, null, table)); + var manager = new UserDataManager(plugin); when(users.getDataManager()).thenReturn(manager); + var cache = new UserDataCache(manager, id); + cache.updateCache(new HashMap<>(java.util.Map.of("PerkActivations_vote", new DataValueInt(1)))); + manager.getUserDataCache().put(id, cache); + var user = mock(MCPerksUser.class, CALLS_REAL_METHODS); var data = mock(UserData.class); + when(data.supportsStrictCompleteValues()).thenReturn(true); + when(data.getValuesIfStrictComplete()).thenReturn(new HashMap<>(java.util.Map.of("PerkActivations_vote", new DataValueInt(1)))); + var stored = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + when(users.getUser(id, false)).thenReturn(stored); when(stored.getUserData()).thenReturn(data); + doReturn(plugin).when(user).getPlugin(); doReturn(id.toString()).when(user).getUUID(); doReturn(data).when(user).getData(); + try { + user.addActivation("vote", 1); + manager.pendingLegacyIntegerMutations(id).toCompletableFuture().get(5, TimeUnit.SECONDS); + assertEquals(2, cache.getIfPresent("PerkActivations_vote").getInt()); + verify(data, never()).getInt(anyString()); + verify(data, never()).setInt(anyString(), anyInt()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void incompleteLegacyRowDoesNotInventCredit() { + UUID id = UUID.randomUUID(); + var plugin = mock(AdvancedCorePlugin.class); var users = mock(UserManager.class); + var stored = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); var data = mock(UserData.class); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(users.getUser(id, false)).thenReturn(stored); when(stored.getUserData()).thenReturn(data); + when(data.supportsStrictCompleteValues()).thenReturn(true); + when(data.getValuesIfStrictComplete()).thenReturn(null); + var manager = new UserDataManager(plugin); when(users.getDataManager()).thenReturn(manager); + var user = mock(MCPerksUser.class, CALLS_REAL_METHODS); + doReturn(plugin).when(user).getPlugin(); doReturn(id.toString()).when(user).getUUID(); doReturn(data).when(user).getData(); + try { + assertThrows(IllegalStateException.class, () -> user.reserveSharedActivations("vote")); + assertNull(manager.getPublishedCache(id)); + verify(data, never()).setInt(anyString(), anyInt()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void coldLegacyReservationLoadsBothPoolsBeforeDebit() { + UUID id = UUID.randomUUID(); + var plugin = mock(AdvancedCorePlugin.class); var users = mock(UserManager.class); + var stored = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); var data = mock(UserData.class); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(users.getUser(id, false)).thenReturn(stored); when(stored.getUserData()).thenReturn(data); + when(data.supportsStrictCompleteValues()).thenReturn(true); + when(data.getValuesIfStrictComplete()).thenReturn(new HashMap<>(java.util.Map.of( + "Activations", new DataValueInt(2), "PerkActivations_vote", new DataValueInt(3)))); + var manager = new UserDataManager(plugin); when(users.getDataManager()).thenReturn(manager); + var user = mock(MCPerksUser.class, CALLS_REAL_METHODS); + doReturn(plugin).when(user).getPlugin(); doReturn(id.toString()).when(user).getUUID(); doReturn(data).when(user).getData(); + try { + var result = user.reserveSharedActivations("vote"); + assertTrue(result.applied()); assertEquals(2, result.previous().get("Activations")); + assertEquals(3, result.previous().get("PerkActivations_vote")); + var cache = manager.getPublishedCache(id); + assertEquals(1, cache.getIfPresent("Activations").getInt()); + assertEquals(2, cache.getIfPresent("PerkActivations_vote").getInt()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void publishedLegacyCacheSerializesAwardsAcrossWrappers() throws Exception { + UUID id = UUID.randomUUID(); + var plugin = mock(AdvancedCorePlugin.class); var users = mock(UserManager.class); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + var manager = new UserDataManager(plugin); when(users.getDataManager()).thenReturn(manager); + var cache = new UserDataCache(manager, id); + cache.updateCompleteCachePreservingPending(new HashMap<>(java.util.Map.of("PerkActivations_vote", new DataValueInt(1)))); + manager.getUserDataCache().put(id, cache); + var control = manager.tryMutateLegacyIntegers(id, java.util.Map.of("PerkActivations_vote", 1), + UserDataCache.IntegerMutation.ADD, null); + assertNotNull(control, "fixture must admit the supported legacy mutation"); + assertTrue(control.applied()); + assertEquals(2, cache.getIfPresent("PerkActivations_vote").getInt()); + assertTrue(manager.tryMutateLegacyIntegers(id, java.util.Map.of("PerkActivations_vote", 1), + UserDataCache.IntegerMutation.SET, null).applied()); + var first = mock(MCPerksUser.class, CALLS_REAL_METHODS); var second = mock(MCPerksUser.class, CALLS_REAL_METHODS); + doReturn(plugin).when(first).getPlugin(); doReturn(plugin).when(second).getPlugin(); + doReturn(id.toString()).when(first).getUUID(); doReturn(id.toString()).when(second).getUUID(); + var data = mock(UserData.class); + when(data.supportsStrictCompleteValues()).thenReturn(true); + doReturn(data).when(first).getData(); doReturn(data).when(second).getData(); + var start = new CountDownLatch(1); var pool = Executors.newFixedThreadPool(2); + try { + var a = pool.submit(() -> { start.await(); first.addActivation("vote", 1); return null; }); + var b = pool.submit(() -> { start.await(); second.addActivation("vote", 1); return null; }); + start.countDown(); a.get(5, TimeUnit.SECONDS); b.get(5, TimeUnit.SECONDS); + assertEquals(3, cache.getIfPresent("PerkActivations_vote").getInt()); + verify(data, never()).getInt(anyString()); + verify(data, never()).setInt(anyString(), anyInt()); + } finally { pool.shutdownNow(); manager.getTimer().shutdownNow(); } + } + @Test void overriddenDataLookupKeepsSynchronousCallbacksAndRejectsReceipt() throws Exception { + UUID id = UUID.randomUUID(); var plugin = mock(AdvancedCorePlugin.class); var users = mock(UserManager.class); + var manager = mock(UserDataManager.class); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(users.getDataManager()).thenReturn(manager); when(manager.usesSharedSqlStorage(UserStorage.SQLITE)).thenReturn(true); + var data = mock(UserData.class); when(data.getInt("Activations")).thenReturn(1); + var user = mock(CustomDataUser.class, CALLS_REAL_METHODS); + doReturn(plugin).when(user).getPlugin(); doReturn(id.toString()).when(user).getUUID(); doReturn(data).when(user).getData(); + assertNull(user.reserveSharedActivations("vote")); + user.addActivation(1); verify(data).setInt("Activations", 2); + var failure = assertThrows(ExecutionException.class, () -> user.addActivationAsync(1).toCompletableFuture().get(5, TimeUnit.SECONDS)); + assertInstanceOf(UnsupportedOperationException.class, failure.getCause()); + verify(data, times(1)).setInt("Activations", 2); + verify(manager, never()).getPublishedCache(any()); + verify(manager, never()).submitSharedIntegerMutation(any(), anyMap(), any(), any()); + } + @Test void supportedSetterAfterAwardHasSerialResultAcrossWrappers() throws Exception { + UUID id = UUID.randomUUID(); var plugin = mock(AdvancedCorePlugin.class); var users = mock(UserManager.class); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + var manager = spy(new UserDataManager(plugin)); when(users.getDataManager()).thenReturn(manager); + var cache = new UserDataCache(manager, id); + cache.updateCompleteCachePreservingPending(new HashMap<>(java.util.Map.of("Activations", new DataValueInt(1)))); + manager.getUserDataCache().put(id, cache); + var data = mock(UserData.class); when(data.supportsStrictCompleteValues()).thenReturn(true); + var award = mock(MCPerksUser.class, CALLS_REAL_METHODS); var setter = mock(MCPerksUser.class, CALLS_REAL_METHODS); + doReturn(plugin).when(award).getPlugin(); doReturn(plugin).when(setter).getPlugin(); + doReturn(id.toString()).when(award).getUUID(); doReturn(id.toString()).when(setter).getUUID(); + doReturn(data).when(award).getData(); doReturn(data).when(setter).getData(); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + doAnswer(call -> { + if (call.getArgument(2) == UserDataCache.IntegerMutation.SET) { + entered.countDown(); assertTrue(release.await(5, TimeUnit.SECONDS)); + } + return call.callRealMethod(); + }).when(manager).tryMutateLegacyIntegers(eq(id), anyMap(), any(), any()); + var pool = Executors.newSingleThreadExecutor(); + try { + var pending = pool.submit(() -> setter.setActivations(10)); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + award.addActivation(1); assertEquals(2, cache.getIfPresent("Activations").getInt()); + release.countDown(); pending.get(5, TimeUnit.SECONDS); + assertEquals(10, cache.getIfPresent("Activations").getInt()); + verify(data, never()).getInt(anyString()); verify(data, never()).setInt(anyString(), anyInt()); + } finally { release.countDown(); pool.shutdownNow(); manager.getTimer().shutdownNow(); } + } + @Test void opaqueSynchronousCallbacksRunOnceOnTheCallerThread() { + UUID id = UUID.randomUUID(); var plugin = mock(AdvancedCorePlugin.class); var users = mock(UserManager.class); + var manager = mock(UserDataManager.class); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(users.getDataManager()).thenReturn(manager); when(manager.usesSharedSqlStorage(UserStorage.SQLITE)).thenReturn(true); + var caller = Thread.currentThread(); var reads = new AtomicInteger(); var writes = new AtomicInteger(); var balance = new AtomicInteger(1); + var custom = new UserData(mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class)) { + @Override public int getInt(String key) { assertSame(caller, Thread.currentThread()); reads.incrementAndGet(); return balance.get(); } + @Override public void setInt(String key, int value) { assertSame(caller, Thread.currentThread()); writes.incrementAndGet(); balance.set(value); } + }; + var user = mock(MCPerksUser.class, CALLS_REAL_METHODS); + doReturn(plugin).when(user).getPlugin(); doReturn(id.toString()).when(user).getUUID(); doReturn(custom).when(user).getData(); + assertNull(user.reserveSharedActivations("vote")); + user.addActivation(1); + assertEquals(2, balance.get()); assertEquals(1, reads.get()); assertEquals(1, writes.get()); + verify(manager, never()).getPublishedCache(any()); + verify(manager, never()).submitSharedIntegerMutation(any(), anyMap(), any(), any()); + } + @Test void supportedWrappersOrderAnAwardAfterACompletedSetter() throws Exception { + UUID id = UUID.randomUUID(); var plugin = mock(AdvancedCorePlugin.class); var users = mock(UserManager.class); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + var manager = spy(new UserDataManager(plugin)); when(users.getDataManager()).thenReturn(manager); + var cache = new UserDataCache(manager, id); + cache.updateCompleteCachePreservingPending(new HashMap<>(java.util.Map.of("Activations", new DataValueInt(1)))); + manager.getUserDataCache().put(id, cache); + var data = mock(UserData.class); when(data.supportsStrictCompleteValues()).thenReturn(true); + var award = mock(MCPerksUser.class, CALLS_REAL_METHODS); var setter = mock(MCPerksUser.class, CALLS_REAL_METHODS); + doReturn(plugin).when(award).getPlugin(); doReturn(plugin).when(setter).getPlugin(); + doReturn(id.toString()).when(award).getUUID(); doReturn(id.toString()).when(setter).getUUID(); + doReturn(data).when(award).getData(); doReturn(data).when(setter).getData(); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + doAnswer(call -> { + if (call.getArgument(2) == UserDataCache.IntegerMutation.ADD) { + entered.countDown(); assertTrue(release.await(5, TimeUnit.SECONDS)); + } + return call.callRealMethod(); + }).when(manager).tryMutateLegacyIntegers(eq(id), anyMap(), any(), any()); + var pool = Executors.newSingleThreadExecutor(); + try { + var pending = pool.submit(() -> award.addActivation(1)); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + setter.setActivations(10); + assertEquals(10, cache.getIfPresent("Activations").getInt()); + release.countDown(); pending.get(5, TimeUnit.SECONDS); + assertEquals(11, cache.getIfPresent("Activations").getInt()); + verify(data, never()).getInt(anyString()); verify(data, never()).setInt(anyString(), anyInt()); + } finally { release.countDown(); pool.shutdownNow(); manager.getTimer().shutdownNow(); } + } + @Test void supportedWrappersRetainBothAwardAndConcurrentDebit() throws Exception { + UUID id = UUID.randomUUID(); var plugin = mock(AdvancedCorePlugin.class); var users = mock(UserManager.class); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + var manager = spy(new UserDataManager(plugin)); when(users.getDataManager()).thenReturn(manager); + var cache = new UserDataCache(manager, id); + cache.updateCompleteCachePreservingPending(new HashMap<>(java.util.Map.of("PerkActivations_vote", new DataValueInt(1)))); + manager.getUserDataCache().put(id, cache); + var data = mock(UserData.class); when(data.supportsStrictCompleteValues()).thenReturn(true); + var debit = mock(MCPerksUser.class, CALLS_REAL_METHODS); var award = mock(MCPerksUser.class, CALLS_REAL_METHODS); + doReturn(plugin).when(debit).getPlugin(); doReturn(plugin).when(award).getPlugin(); + doReturn(id.toString()).when(debit).getUUID(); doReturn(id.toString()).when(award).getUUID(); + doReturn(data).when(debit).getData(); doReturn(data).when(award).getData(); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + doAnswer(call -> { + java.util.Map values = call.getArgument(1); + if (values.get("PerkActivations_vote") == -1) { + entered.countDown(); assertTrue(release.await(5, TimeUnit.SECONDS)); + } + return call.callRealMethod(); + }).when(manager).tryMutateLegacyIntegers(eq(id), anyMap(), any(), any()); + var pool = Executors.newSingleThreadExecutor(); + try { + var pending = pool.submit(() -> debit.addActivation("vote", -1)); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + award.addActivation("vote", 1); + assertEquals(2, cache.getIfPresent("PerkActivations_vote").getInt()); + release.countDown(); pending.get(5, TimeUnit.SECONDS); + assertEquals(1, cache.getIfPresent("PerkActivations_vote").getInt()); + verify(data, never()).getInt(anyString()); verify(data, never()).setInt(anyString(), anyInt()); + } finally { release.countDown(); pool.shutdownNow(); manager.getTimer().shutdownNow(); } + } + @Test void legacyConcurrentPopulationCannotReplaceAnAcceptedCacheGeneration() throws Exception { + UUID id = UUID.randomUUID(); + var plugin = mock(AdvancedCorePlugin.class); var users = mock(UserManager.class); + var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); var data = mock(UserData.class); + when(plugin.getUserManager()).thenReturn(users); when(users.getUser(id, false)).thenReturn(user); + when(user.getUserData()).thenReturn(data); when(data.getKeys()).thenReturn(new java.util.ArrayList<>()); + var loading = new CountDownLatch(1); var finish = new CountDownLatch(1); + when(data.getValues()).thenAnswer(call -> { + loading.countDown(); if (!finish.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); + return new HashMap<>(java.util.Map.of("Activations", new DataValueInt(1))); + }); + var manager = new UserDataManager(plugin); var pool = Executors.newSingleThreadExecutor(); + try { + var population = pool.submit(() -> manager.cacheUser(id, null)); + assertTrue(loading.await(5, TimeUnit.SECONDS)); + var published = manager.getUserDataCache().get(id); + if (published == null) { + published = new UserDataCache(manager, id); + assertNull(manager.getUserDataCache().putIfAbsent(id, published)); + } + published.updateCompleteCachePreservingPending(new HashMap<>(java.util.Map.of("Activations", new DataValueInt(1)))); + assertTrue(published.tryMutateLegacyIntegers(java.util.Map.of("Activations", 1), + UserDataCache.IntegerMutation.ADD, null).applied()); + finish.countDown(); population.get(5, TimeUnit.SECONDS); + assertSame(published, manager.getUserDataCache().get(id), "an accepted generation must not be replaced by an older load"); + assertEquals(2, manager.getUserDataCache().get(id).getIfPresent("Activations").getInt()); + } finally { finish.countDown(); pool.shutdownNow(); manager.getTimer().shutdownNow(); } + } + @Test void minimumRemovalCannotWrapIntoAnAward() { + var user = mock(MCPerksUser.class, CALLS_REAL_METHODS); var data = mock(UserData.class); + doReturn(data).when(user).getData(); + assertThrows(ArithmeticException.class, () -> user.removeActivationClamped(Integer.MIN_VALUE)); + assertThrows(ArithmeticException.class, () -> user.removeActivationClamped("vote", Integer.MIN_VALUE)); + verifyNoInteractions(data); + } + @Test void coldSharedAwardIsAppliedOnceAfterWorkerPopulation() { + UUID id = UUID.randomUUID(); var plugin = mock(AdvancedCorePlugin.class); + var users = mock(UserManager.class); var manager = mock(UserDataManager.class); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(manager.usesSharedSqlStorage(UserStorage.MYSQL)).thenReturn(true); + when(manager.getPlugin()).thenReturn(plugin); when(manager.getTimer()).thenReturn(mock(ScheduledExecutorService.class)); + var cache = new UserDataCache(manager, id); cache.configureSharedStorage(values -> {}, Runnable::run); + cache.updateCache(new HashMap<>(java.util.Map.of("PerkActivations_vote", new DataValueInt(1)))); + when(manager.getPublishedCache(id)).thenReturn(null, cache); + var queued = new AtomicReference(); when(manager.mustDeferSharedStorageAccess()).thenReturn(true); + when(manager.submitSharedIntegerMutation(eq(id), anyMap(), eq(UserDataCache.IntegerMutation.ADD), any())).thenAnswer(call -> { + var receipt = new CompletableFuture(); + queued.set(() -> { manager.cacheUser(id); var ready = manager.getPublishedCache(id); + receipt.complete(ready.tryMutateIntegers(java.util.Map.of("PerkActivations_vote", 1), UserDataCache.IntegerMutation.ADD, null)); }); + return receipt; + }); + var user = mock(MCPerksUser.class, CALLS_REAL_METHODS); + doReturn(plugin).when(user).getPlugin(); doReturn(id.toString()).when(user).getUUID(); + user.addActivation("vote", 1); assertNotNull(queued.get()); + assertEquals(1, cache.getIfPresent("PerkActivations_vote").getInt()); + queued.get().run(); assertEquals(2, cache.getIfPresent("PerkActivations_vote").getInt()); + verify(manager).cacheUser(id); + } + @Test void awardForMissingDynamicKeyUsesCompleteStorageSnapshot() { + UUID id = UUID.randomUUID(); var plugin = mock(AdvancedCorePlugin.class); + var users = mock(UserManager.class); var manager = mock(UserDataManager.class); + var storedUser = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var storedData = mock(UserData.class); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(manager.usesSharedSqlStorage(UserStorage.MYSQL)).thenReturn(true); + when(manager.hasSharedSqlBackend()).thenReturn(true); + when(manager.getPlugin()).thenReturn(plugin); when(manager.getTimer()).thenReturn(mock(ScheduledExecutorService.class)); + when(users.getUser(id, false)).thenReturn(storedUser); when(storedUser.getUserData()).thenReturn(storedData); + when(storedData.getKeys()).thenReturn(new java.util.ArrayList<>()); + when(storedData.getValues()).thenReturn(new HashMap<>()); + var cache = new UserDataCache(manager, id); cache.configureSharedStorage(values -> {}, Runnable::run); + cache.updateCompleteCachePreservingPending(storedData.getValues()); when(manager.getPublishedCache(id)).thenReturn(cache); + var user = mock(MCPerksUser.class, CALLS_REAL_METHODS); + doReturn(plugin).when(user).getPlugin(); doReturn(id.toString()).when(user).getUUID(); + user.addActivation("new-perk", 1); + assertEquals(1, cache.getIfPresent("PerkActivations_new-perk").getInt()); + } + @Test void missingPersonalPoolDoesNotBlockGlobalReservationAndCompensation() { + UUID id = UUID.randomUUID(); var plugin = mock(AdvancedCorePlugin.class); + var users = mock(UserManager.class); var manager = mock(UserDataManager.class); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(manager.usesSharedSqlStorage(UserStorage.MYSQL)).thenReturn(true); + when(manager.getPlugin()).thenReturn(plugin); when(manager.getTimer()).thenReturn(mock(ScheduledExecutorService.class)); + var cache = new UserDataCache(manager, id); cache.configureSharedStorage(values -> {}, Runnable::run); + cache.updateCompleteCachePreservingPending(new HashMap<>(java.util.Map.of("Activations", new DataValueInt(2)))); + when(manager.getPublishedCache(id)).thenReturn(cache); + var user = mock(MCPerksUser.class, CALLS_REAL_METHODS); + doReturn(plugin).when(user).getPlugin(); doReturn(id.toString()).when(user).getUUID(); + var reserved = user.reserveSharedActivations("new-perk"); + assertTrue(reserved.applied()); assertEquals(2, reserved.previous().get("Activations")); + assertEquals(0, reserved.previous().get("PerkActivations_new-perk")); + assertEquals(1, cache.getIfPresent("Activations").getInt()); + user.addActivation(1); + assertEquals(2, cache.getIfPresent("Activations").getInt()); + assertEquals(0, cache.getIfPresent("PerkActivations_new-perk").getInt()); + } + @Test void partialPublishedCacheCannotInventMissingDynamicBalance() { + UUID id = UUID.randomUUID(); var plugin = mock(AdvancedCorePlugin.class); + var users = mock(UserManager.class); var manager = mock(UserDataManager.class); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(manager.usesSharedSqlStorage(UserStorage.MYSQL)).thenReturn(true); + when(manager.getPlugin()).thenReturn(plugin); when(manager.getTimer()).thenReturn(mock(ScheduledExecutorService.class)); + var cache = new UserDataCache(manager, id); cache.configureSharedStorage(values -> {}, Runnable::run); + cache.updateCache(new HashMap<>(java.util.Map.of("other", new DataValueInt(12)))); + when(manager.getPublishedCache(id)).thenReturn(cache); + var user = mock(MCPerksUser.class, CALLS_REAL_METHODS); + doReturn(plugin).when(user).getPlugin(); doReturn(id.toString()).when(user).getUUID(); + assertThrows(IllegalStateException.class, () -> user.addActivation("missing", 1)); + assertNull(cache.getIfPresent("PerkActivations_missing")); + assertEquals(12, cache.getIfPresent("other").getInt()); + } + @Test void coldSharedAwardDefersPopulationAndDoesNotInventABalance() { + UUID id = UUID.randomUUID(); var plugin = mock(AdvancedCorePlugin.class); + var users = mock(UserManager.class); var manager = mock(UserDataManager.class); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(manager.usesSharedSqlStorage(UserStorage.MYSQL)).thenReturn(true); + var queued = new AtomicReference(); when(manager.mustDeferSharedStorageAccess()).thenReturn(true); + when(manager.submitSharedIntegerMutation(eq(id), anyMap(), eq(UserDataCache.IntegerMutation.ADD), any())).thenAnswer(call -> { + var receipt = new CompletableFuture(); + queued.set(() -> { manager.cacheUser(id); var failure = new IllegalStateException("unconfirmed balance"); + receipt.completeExceptionally(failure); throw failure; }); return receipt; + }); + var user = mock(MCPerksUser.class, CALLS_REAL_METHODS); + doReturn(plugin).when(user).getPlugin(); doReturn(id.toString()).when(user).getUUID(); + user.addActivation("missing", 1); + assertNotNull(queued.get()); verify(manager, never()).cacheUser(id); + assertThrows(IllegalStateException.class, () -> queued.get().run()); + verify(manager).cacheUser(id); + } + @Test void publishedSharedCacheSerializesPublicCreditAwardsAcrossWrappers() throws Exception { + UUID id = UUID.randomUUID(); + var plugin = mock(AdvancedCorePlugin.class); var users = mock(UserManager.class); + var manager = mock(UserDataManager.class); var timer = mock(ScheduledExecutorService.class); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(manager.usesSharedSqlStorage(UserStorage.MYSQL)).thenReturn(true); + when(manager.getPlugin()).thenReturn(plugin); when(manager.getTimer()).thenReturn(timer); + doAnswer(call -> { call.getArgument(0).run(); return null; }).when(manager).dispatchSharedUserDataNotification(any()); + var cache = new UserDataCache(manager, id); cache.configureSharedStorage(values -> {}, Runnable::run); + var initial = new HashMap(); initial.put("PerkActivations_vote", new DataValueInt(1)); + initial.put("Activations", new DataValueInt(1)); + cache.updateCache(initial); when(manager.getPublishedCache(id)).thenReturn(cache); + var first = mock(MCPerksUser.class, CALLS_REAL_METHODS); var second = mock(MCPerksUser.class, CALLS_REAL_METHODS); + doReturn(plugin).when(first).getPlugin(); doReturn(plugin).when(second).getPlugin(); + doReturn(id.toString()).when(first).getUUID(); doReturn(id.toString()).when(second).getUUID(); + var data = mock(UserData.class); var bothRead = new CountDownLatch(2); + when(data.getInt("PerkActivations_vote")).thenAnswer(call -> { + int value = cache.getIfPresent("PerkActivations_vote").getInt(); + bothRead.countDown(); if (!bothRead.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); + return value; + }); + doAnswer(call -> { cache.tryAddChangeBeforeDeferredSharedFlush( + new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt(call.getArgument(0), call.getArgument(1))); return null; + }).when(data).setInt(eq("PerkActivations_vote"), anyInt()); + doReturn(data).when(first).getData(); doReturn(data).when(second).getData(); + var pool = Executors.newFixedThreadPool(2); var start = new CountDownLatch(1); + try { + var a = pool.submit(() -> { start.await(); first.addActivation("vote", 1); return null; }); + var b = pool.submit(() -> { start.await(); second.addActivation("vote", 1); return null; }); + start.countDown(); a.get(5, TimeUnit.SECONDS); b.get(5, TimeUnit.SECONDS); + assertEquals(3, cache.getIfPresent("PerkActivations_vote").getInt()); + var reserved = first.reserveSharedActivations("vote"); + assertTrue(reserved.applied()); + assertEquals(3, reserved.previous().get("PerkActivations_vote")); + assertEquals(1, reserved.previous().get("Activations")); + assertEquals(2, cache.getIfPresent("PerkActivations_vote").getInt()); + assertEquals(0, cache.getIfPresent("Activations").getInt()); + first.removeActivationClamped("vote", 9); + assertEquals(0, cache.getIfPresent("PerkActivations_vote").getInt()); + } finally { pool.shutdownNow(); } + } + +} diff --git a/MCPerks/src/test/java/com/bencodez/mcperks/tests/userapi/ReplayAwardLedgerTest.java b/MCPerks/src/test/java/com/bencodez/mcperks/tests/userapi/ReplayAwardLedgerTest.java new file mode 100644 index 0000000..07a5a35 --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/userapi/ReplayAwardLedgerTest.java @@ -0,0 +1,628 @@ +package com.bencodez.mcperks.tests.userapi; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.lang.reflect.InvocationTargetException; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.core.user.storage.SqlUserStorage; +import com.bencodez.advancedcore.core.user.storage.sql.SqlBackendLogger; +import com.bencodez.advancedcore.core.user.storage.sql.SqlUserSchema; +import com.bencodez.advancedcore.core.user.storage.sql.SqliteUserBackend; +import com.bencodez.simpleapi.sql.DataType; +import com.bencodez.simpleapi.sql.data.DataValueInt; +import com.bencodez.simpleapi.sql.data.DataValueString; + +class ReplayAwardLedgerTest { + @TempDir Path directory; + private SqliteUserBackend backend() { + return new SqliteUserBackend(directory, "Users", "Users", SqlUserSchema.builder() + .column("Points", "INTEGER", DataType.INTEGER) + .column("Activations", "INTEGER", DataType.INTEGER) + .column("PerkActivations_vote", "INTEGER", DataType.INTEGER) + .column("MCPerksAwardReceipts", "TEXT", DataType.STRING).build(), SqlBackendLogger.NO_OP); + } + private static Object call(String name, Class[] types, Object... args) { + try { + var method = Class.forName("com.bencodez.mcperks.userapi.ReplayAwardLedger").getDeclaredMethod(name, types); + method.setAccessible(true); return method.invoke(null, args); + } catch (InvocationTargetException error) { + if (error.getCause() instanceof RuntimeException runtime) throw runtime; + throw new AssertionError(error.getCause()); + } catch (ReflectiveOperationException error) { throw new AssertionError(error); } + } + private static boolean award(SqlUserStorage.TransactionScope scope, String id) { + return (boolean) call("apply", new Class[] { SqlUserStorage.TransactionScope.class, String.class, int.class, String.class }, scope, "Points", 1, id); + } + @Test void committedReplayAwardIsNotRepeatedAfterReopeningSqlite() { + UUID uuid = UUID.randomUUID(); + try (var backend = backend()) { + var initial = Map.of("Points", new DataValueInt(0), "MCPerksAwardReceipts", new DataValueString("")); + boolean first = backend.user(uuid).transaction(UserStorage.SQLITE, initial, scope -> award(scope, "event:one")); assertTrue(first); + assertThrows(IllegalStateException.class, () -> backend.user(uuid).transaction(UserStorage.SQLITE, scope -> { + award(scope, "event:two"); throw new IllegalStateException("rollback"); + })); + } + try (var reopened = backend()) { + boolean repeated = reopened.user(uuid).transaction(UserStorage.SQLITE, scope -> award(scope, "event:one")); assertFalse(repeated); + var row = reopened.user(uuid).readRow(UserStorage.SQLITE); + assertEquals(1, row.stream().filter(c -> c.getName().equals("Points")).findFirst().orElseThrow().getValue().getInt()); + assertThrows(IllegalStateException.class, () -> reopened.user(uuid).transaction(UserStorage.SQLITE, scope -> + call("apply", new Class[] { SqlUserStorage.TransactionScope.class, String.class, int.class, String.class }, scope, "Points", 2, "event:one"))); + boolean second = reopened.user(uuid).transaction(UserStorage.SQLITE, scope -> award(scope, "event:two")); assertTrue(second); + for (int i = 0; i < 2; i++) reopened.user(uuid).transaction(UserStorage.SQLITE, scope -> { + call("retire", new Class[] { SqlUserStorage.TransactionScope.class, String.class }, scope, "event:one"); return null; + }); + boolean secondAgain = reopened.user(uuid).transaction(UserStorage.SQLITE, scope -> award(scope, "event:two")); assertFalse(secondAgain); + } + } + @Test void separateRewardSourcesKeepSeparateReplayIdentities() { + UUID uuid = UUID.randomUUID(); + try (var backend = backend()) { + var initial = Map.of("Points", new DataValueInt(0), "MCPerksAwardReceipts", new DataValueString("")); + boolean first = backend.user(uuid).transaction(UserStorage.SQLITE, initial, scope -> award(scope, "mcperks:same-event")); assertTrue(first); + boolean second = backend.user(uuid).transaction(UserStorage.SQLITE, scope -> award(scope, "votingplugin:same-event")); assertTrue(second); + boolean duplicate = backend.user(uuid).transaction(UserStorage.SQLITE, scope -> award(scope, "mcperks:same-event")); assertFalse(duplicate); + var row = backend.user(uuid).readRow(UserStorage.SQLITE); + assertEquals(2, row.stream().filter(c -> c.getName().equals("Points")).findFirst().orElseThrow().getValue().getInt()); + } + } + @Test void corruptReceiptDataFailsClosedWithoutChangingTheBalance() { + UUID uuid = UUID.randomUUID(); + try (var backend = backend()) { + var values = new HashMap(); + values.put("Points", new DataValueInt(7)); values.put("MCPerksAwardReceipts", new DataValueString("malformed")); + backend.user(uuid).writeValues(UserStorage.SQLITE, values); + assertThrows(IllegalStateException.class, () -> backend.user(uuid).transaction(UserStorage.SQLITE, scope -> award(scope, "event:one"))); + var row = backend.user(uuid).readRow(UserStorage.SQLITE); + assertEquals(7, row.stream().filter(c -> c.getName().equals("Points")).findFirst().orElseThrow().getValue().getInt()); + } + } + @Test void checkpointCannotCreateAMissingUserInAnotherStorageOwner() { + UUID uuid = UUID.randomUUID(); + try (var backend = backend()) { + var initial = Map.of("MCPerksAwardReceipts", new DataValueString("")); + assertThrows(SqlUserStorage.ReplayPreconditionException.class, () -> backend.user(uuid).transaction(UserStorage.SQLITE, initial, scope -> { + call("retire", new Class[] { SqlUserStorage.TransactionScope.class, String.class }, scope, "event:one"); return null; + })); + assertFalse(backend.user(uuid).contains(UserStorage.SQLITE)); + } + } + @Test void sharedReplayAwardUsesProducerTransactionAndDoesNotRepeat() throws Exception { + UUID uuid = UUID.randomUUID(); var backend = backend(); + var plugin = mock(com.bencodez.advancedcore.AdvancedCorePlugin.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(plugin); when(users.getDataManager()).thenReturn(manager); + var owner = new com.bencodez.advancedcore.bukkit.user.runtime.BukkitUserCacheOwner(manager); + var runtime = new com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime(backend, owner); manager.bindSharedRuntime(runtime); + var user = mock(com.bencodez.mcperks.userapi.MCPerksUser.class, CALLS_REAL_METHODS); + doReturn(plugin).when(user).getPlugin(); doReturn(uuid.toString()).when(user).getUUID(); + doReturn(new com.bencodez.advancedcore.api.user.UserData(user)).when(user).getData(); + var notified = new java.util.concurrent.CountDownLatch(1); var logger = mock(java.util.logging.Logger.class); + when(plugin.getLogger()).thenReturn(logger); + doAnswer(call -> { notified.countDown(); return null; }).when(logger).log(any(java.util.logging.Level.class), anyString(), any(Throwable.class)); + doAnswer(call -> { throw new IllegalStateException("notification failure"); }) + .when(users).onChange(eq(user), any(String[].class)); + try { + var first = user.addReplayActivationAsync(null, 2, "event:one").toCompletableFuture(); + var duplicate = user.addReplayActivationAsync(null, 2, "event:one").toCompletableFuture(); + first.get(5, java.util.concurrent.TimeUnit.SECONDS); duplicate.get(5, java.util.concurrent.TimeUnit.SECONDS); + var row = backend.user(uuid).readRow(UserStorage.SQLITE); + assertEquals(2, row.stream().filter(c -> c.getName().equals("Activations")).findFirst().orElseThrow().getValue().getInt()); + assertTrue(notified.await(5, java.util.concurrent.TimeUnit.SECONDS)); + assertNotNull(manager.getLastDeferredStorageFailure(), "notification failure is recorded without failing the committed award"); + var conflict = user.addReplayActivationAsync(null, 3, "event:one").toCompletableFuture(); + var error = assertThrows(java.util.concurrent.ExecutionException.class, () -> conflict.get(5, java.util.concurrent.TimeUnit.SECONDS)); + assertInstanceOf(SqlUserStorage.ReplayPreconditionException.class, error.getCause()); + user.addReplayActivationAsync(null, 1, "event:two").toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + var later = backend.user(uuid).readRow(UserStorage.SQLITE); + assertEquals(3, later.stream().filter(c -> c.getName().equals("Activations")).findFirst().orElseThrow().getValue().getInt()); + assertThrows(IllegalStateException.class, () -> manager.withLegacyIntegerOwnerReplacement(() -> fail("owner changed before checkpoint"))); + var replacement = mock(com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend.class); + assertThrows(java.util.concurrent.ExecutionException.class, () -> manager.replaceSharedSqlBackendAsync(replacement).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS)); + verify(replacement, never()).close(); + user.checkpointReplayActivationAsync("event:one").toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + user.checkpointReplayActivationAsync("event:two").toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + assertDoesNotThrow(() -> manager.withLegacyIntegerOwnerReplacement(() -> {})); + manager.closeSharedRuntimeAsyncCompletion(() -> {}).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + } finally { manager.getTimer().shutdownNow(); if (backend.isOpen()) backend.close(); } + } + @Test void unsupportedSharedProviderRejectsAwardWithoutLeakingOwnerLease() throws Exception { + UUID uuid = UUID.randomUUID(); var backend = mock(com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend.class); + when(backend.isOpen()).thenReturn(true); when(backend.storageType()).thenReturn(UserStorage.SQLITE); + var plugin = mock(com.bencodez.advancedcore.AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(plugin); when(users.getDataManager()).thenReturn(manager); + var owner = new com.bencodez.advancedcore.bukkit.user.runtime.BukkitUserCacheOwner(manager); + manager.bindSharedRuntime(new com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime(backend, owner)); + var user = mock(com.bencodez.mcperks.userapi.MCPerksUser.class, CALLS_REAL_METHODS); + doReturn(plugin).when(user).getPlugin(); doReturn(uuid.toString()).when(user).getUUID(); + doReturn(new com.bencodez.advancedcore.api.user.UserData(user)).when(user).getData(); + try { + var receipt = user.addReplayActivationAsync(null, 1, "event:one").toCompletableFuture(); + var failure = assertThrows(java.util.concurrent.ExecutionException.class, () -> receipt.get(5, java.util.concurrent.TimeUnit.SECONDS)); + assertInstanceOf(UnsupportedOperationException.class, failure.getCause()); + assertFalse(manager.hasUnsettledLegacyIntegerMutations()); verify(backend, never()).user(any()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void checkpointCannotPermitAnAlreadyAdmittedDuplicateAward() throws Exception { + UUID uuid = UUID.randomUUID(); var backend = backend(); + var plugin = mock(com.bencodez.advancedcore.AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + var manager = spy(new com.bencodez.advancedcore.api.user.usercache.UserDataManager(plugin)); when(users.getDataManager()).thenReturn(manager); + manager.bindSharedRuntime(new com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime(backend, + new com.bencodez.advancedcore.bukkit.user.runtime.BukkitUserCacheOwner(manager))); + var user = mock(com.bencodez.mcperks.userapi.MCPerksUser.class, CALLS_REAL_METHODS); + doReturn(plugin).when(user).getPlugin(); doReturn(uuid.toString()).when(user).getUUID(); + doReturn(new com.bencodez.advancedcore.api.user.UserData(user)).when(user).getData(); + var pause = new java.util.concurrent.atomic.AtomicBoolean(); + var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1); + doAnswer(call -> { + if (pause.get()) { entered.countDown(); assertTrue(release.await(5, java.util.concurrent.TimeUnit.SECONDS)); } + return call.callRealMethod(); + }).when(manager).submitSharedReplayTransaction(any(), any(), anyMap(), any()); + var pool = java.util.concurrent.Executors.newSingleThreadExecutor(); + try { + user.addReplayActivationAsync(null, 2, "event:one").toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + pause.set(true); + var delayed = pool.submit(() -> user.addReplayActivationAsync(null, 2, "event:one").toCompletableFuture()); + assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS)); + user.checkpointReplayActivationAsync("event:one").toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + release.countDown(); + var pending = delayed.get(5, java.util.concurrent.TimeUnit.SECONDS); + var failure = assertThrows(java.util.concurrent.ExecutionException.class, () -> pending.get(5, java.util.concurrent.TimeUnit.SECONDS)); + assertInstanceOf(SqlUserStorage.ReplayPreconditionException.class, failure.getCause()); + var row = backend.user(uuid).readRow(UserStorage.SQLITE); + assertEquals(2, row.stream().filter(c -> c.getName().equals("Activations")).findFirst().orElseThrow().getValue().getInt()); + } finally { release.countDown(); pool.shutdownNow(); manager.getTimer().shutdownNow(); if (backend.isOpen()) backend.close(); } + } + @Test void customUserDataMustOptInBeforeReplayCanBypassOverrides() throws Exception { + UUID uuid = UUID.randomUUID(); var backend = mock(com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend.class); + when(backend.isOpen()).thenReturn(true); when(backend.storageType()).thenReturn(UserStorage.SQLITE); when(backend.supportsAtomicUserTransactions()).thenReturn(true); + var plugin = mock(com.bencodez.advancedcore.AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(plugin); when(users.getDataManager()).thenReturn(manager); + var owner = new com.bencodez.advancedcore.bukkit.user.runtime.BukkitUserCacheOwner(manager); + manager.bindSharedRuntime(new com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime(backend, owner)); + var user = mock(com.bencodez.mcperks.userapi.MCPerksUser.class, CALLS_REAL_METHODS); + doReturn(plugin).when(user).getPlugin(); doReturn(uuid.toString()).when(user).getUUID(); + doReturn(new com.bencodez.advancedcore.api.user.UserData(user) { }).when(user).getData(); + try { + var receipt = user.addReplayActivationAsync(null, 1, "event:one").toCompletableFuture(); + var failure = assertThrows(java.util.concurrent.ExecutionException.class, () -> receipt.get(5, java.util.concurrent.TimeUnit.SECONDS)); + assertInstanceOf(UnsupportedOperationException.class, failure.getCause()); + assertFalse(manager.hasUnsettledLegacyIntegerMutations()); verify(backend, never()).user(any()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void failedCheckpointKeepsOwnerUntilCleanupCanSucceed() throws Exception { + UUID uuid = UUID.randomUUID(); var backend = backend(); + var plugin = mock(com.bencodez.advancedcore.AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(plugin); when(users.getDataManager()).thenReturn(manager); + var owner = new com.bencodez.advancedcore.bukkit.user.runtime.BukkitUserCacheOwner(manager); + manager.bindSharedRuntime(new com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime(backend, owner)); + var user = mock(com.bencodez.mcperks.userapi.MCPerksUser.class, CALLS_REAL_METHODS); + doReturn(plugin).when(user).getPlugin(); doReturn(uuid.toString()).when(user).getUUID(); + doReturn(new com.bencodez.advancedcore.api.user.UserData(user)).when(user).getData(); + try { + user.addReplayActivationAsync(null, 2, "event:one").toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + var row = backend.user(uuid).readRow(UserStorage.SQLITE); + String saved = row.stream().filter(c -> c.getName().equals("MCPerksAwardReceipts")).findFirst().orElseThrow().getValue().getString(); + backend.user(uuid).writeValues(UserStorage.SQLITE, new HashMap<>(Map.of("MCPerksAwardReceipts", new DataValueString("invalid")))); + assertThrows(java.util.concurrent.ExecutionException.class, () -> user.checkpointReplayActivationAsync("event:one").toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS)); + assertTrue(manager.hasUnsettledLegacyIntegerMutations()); + assertThrows(IllegalStateException.class, () -> manager.withLegacyIntegerOwnerReplacement(() -> fail("owner changed"))); + backend.user(uuid).writeValues(UserStorage.SQLITE, new HashMap<>(Map.of("MCPerksAwardReceipts", new DataValueString(saved)))); + user.checkpointReplayActivationAsync("event:one").toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + assertDoesNotThrow(() -> manager.withLegacyIntegerOwnerReplacement(() -> {})); + manager.closeSharedRuntimeAsyncCompletion(() -> {}).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + } finally { manager.getTimer().shutdownNow(); if (backend.isOpen()) backend.close(); } + } + @Test void replayAfterAbruptRestartDoesNotRepeatCommittedAward() throws Exception { + UUID uuid = UUID.randomUUID(); var firstBackend = backend(); + var plugin = mock(com.bencodez.advancedcore.AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + var user = mock(com.bencodez.mcperks.userapi.MCPerksUser.class, CALLS_REAL_METHODS); + doReturn(plugin).when(user).getPlugin(); doReturn(uuid.toString()).when(user).getUUID(); + doReturn(new com.bencodez.advancedcore.api.user.UserData(user)).when(user).getData(); + var first = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(plugin); when(users.getDataManager()).thenReturn(first); + var firstOwner = new com.bencodez.advancedcore.bukkit.user.runtime.BukkitUserCacheOwner(first); + first.bindSharedRuntime(new com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime(firstBackend, firstOwner)); + try { + user.addReplayActivationAsync(null, 2, "event:one").toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + } finally { first.getTimer().shutdownNow(); firstBackend.close(); } + var secondBackend = backend(); var second = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(plugin); + when(users.getDataManager()).thenReturn(second); + var secondOwner = new com.bencodez.advancedcore.bukkit.user.runtime.BukkitUserCacheOwner(second); + second.bindSharedRuntime(new com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime(secondBackend, secondOwner)); + try { + user.addReplayActivationAsync(null, 2, "event:one").toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + var row = secondBackend.user(uuid).readRow(UserStorage.SQLITE); + assertEquals(2, row.stream().filter(c -> c.getName().equals("Activations")).findFirst().orElseThrow().getValue().getInt()); + user.checkpointReplayActivationAsync("event:one").toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + second.closeSharedRuntimeAsyncCompletion(() -> {}).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + } finally { second.getTimer().shutdownNow(); if (secondBackend.isOpen()) secondBackend.close(); } + } + @Test void shutdownWaitsForReplayCheckpointAndPermitsCleanupDuringDrain() throws Exception { + UUID uuid = UUID.randomUUID(); var backend = backend(); + var plugin = mock(com.bencodez.advancedcore.AdvancedCorePlugin.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(plugin); when(users.getDataManager()).thenReturn(manager); + var owner = new com.bencodez.advancedcore.bukkit.user.runtime.BukkitUserCacheOwner(manager); + var runtime = new com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime(backend, owner); manager.bindSharedRuntime(runtime); + var user = mock(com.bencodez.mcperks.userapi.MCPerksUser.class, CALLS_REAL_METHODS); + doReturn(plugin).when(user).getPlugin(); doReturn(uuid.toString()).when(user).getUUID(); + doReturn(new com.bencodez.advancedcore.api.user.UserData(user)).when(user).getData(); + try { + user.addReplayActivationAsync(null, 2, "event:one").toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + var shutdown = manager.closeSharedRuntimeAsyncCompletion(() -> {}).toCompletableFuture(); assertFalse(shutdown.isDone()); + user.checkpointReplayActivationAsync("event:one").toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + shutdown.get(5, java.util.concurrent.TimeUnit.SECONDS); assertFalse(backend.isOpen()); + } finally { manager.getTimer().shutdownNow(); if (backend.isOpen()) backend.close(); } + } + @Test void registeredRewardInjectorUsesReplayLedgerAndDurableCheckpoint() throws Exception { + UUID uuid = UUID.randomUUID(); var backend = backend(); + var plugin = mock(com.bencodez.mcperks.MCPerksMain.class, CALLS_REAL_METHODS); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var mcUsers = mock(com.bencodez.mcperks.userapi.UserManager.class); + var handler = mock(com.bencodez.advancedcore.api.rewards.RewardHandler.class); + var perks = mock(com.bencodez.mcperks.perk.PerkHandler.class); + var player = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var target = mock(com.bencodez.mcperks.userapi.MCPerksUser.class, CALLS_REAL_METHODS); + doReturn(users).when(plugin).getUserManager(); doReturn(UserStorage.SQLITE).when(plugin).getStorageType(); + doReturn(null).when(plugin).getNativeUserStorageOwner(); + doReturn(mock(com.bencodez.advancedcore.AdvancedCoreConfigOptions.class, RETURNS_DEEP_STUBS)).when(plugin).getOptions(); + doReturn(mcUsers).when(plugin).getMcperksUserManager(); doReturn(handler).when(plugin).getRewardHandler(); + doReturn(perks).when(plugin).getPerkHandler(); + var loaded = new HashMap(); loaded.put("vote", mock(com.bencodez.mcperks.perk.Perk.class)); + when(perks.getLoadedPerks()).thenReturn(loaded); + when(player.getPlayerName()).thenReturn("example"); when(mcUsers.getMCPerksUser("example")).thenReturn(target); + doReturn(plugin).when(target).getPlugin(); doReturn(uuid.toString()).when(target).getUUID(); + doReturn(new com.bencodez.advancedcore.api.user.UserData(target)).when(target).getData(); + when(users.getUser(uuid, false)).thenReturn(target); + doReturn(new com.bencodez.advancedcore.api.user.UserData(target)).when(target).getUserData(); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(plugin); when(users.getDataManager()).thenReturn(manager); + var owner = new com.bencodez.advancedcore.bukkit.user.runtime.BukkitUserCacheOwner(manager); + var runtime = new com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime(backend, owner); manager.bindSharedRuntime(runtime); + var previous = com.bencodez.mcperks.MCPerksMain.plugin; com.bencodez.mcperks.MCPerksMain.plugin = plugin; + var occurrenceField = com.bencodez.advancedcore.api.rewards.Reward.class.getDeclaredField("ACTIVE_REPLAY_OCCURRENCE_ID"); occurrenceField.setAccessible(true); + var keyField = com.bencodez.advancedcore.api.rewards.Reward.class.getDeclaredField("ACTIVE_REPLAY_KEY"); keyField.setAccessible(true); + @SuppressWarnings("unchecked") var occurrence = (ThreadLocal) occurrenceField.get(null); + @SuppressWarnings("unchecked") var key = (ThreadLocal) keyField.get(null); + try (var bukkit = mockStatic(org.bukkit.Bukkit.class)) { + bukkit.when(org.bukkit.Bukkit::getPluginManager).thenReturn(mock(org.bukkit.plugin.PluginManager.class)); + plugin.loadInjectedRewards(); + var captured = org.mockito.ArgumentCaptor.forClass(com.bencodez.advancedcore.api.rewards.injected.RewardInject.class); + verify(handler, times(2)).addInjectedReward(captured.capture()); + var inject = (com.bencodez.advancedcore.api.rewards.injected.RewardInjectInt) captured.getAllValues().stream() + .filter(value -> value.getPath().equals("Activations")).findFirst().orElseThrow(); + var perkInject = (com.bencodez.advancedcore.api.rewards.injected.RewardInjectInt) captured.getAllValues().stream() + .filter(value -> value.getPath().equals("PerkActivations.vote")).findFirst().orElseThrow(); + assertTrue(inject.supportsAsyncRequest(), "registered global reward must dispatch its real async receipt"); + assertTrue(perkInject.supportsAsyncRequest(), "registered perk reward must dispatch its real async receipt"); + occurrence.set("event-one"); key.set("root:activation"); + var reward = mock(com.bencodez.advancedcore.api.rewards.Reward.class); + var config = mock(com.bencodez.advancedcore.api.rewards.RewardFileData.class); + var configData = new org.bukkit.configuration.file.YamlConfiguration(); configData.set("Activations", 2); configData.set("PerkActivations.vote", 4); + when(reward.getConfig()).thenReturn(config); when(config.getConfigData()).thenReturn(configData); + when(player.endAsyncActionCollection(isNull())).thenReturn(java.util.concurrent.CompletableFuture.completedFuture(null)); + var state = com.bencodez.advancedcore.api.rewards.Reward.replayStateFor(new com.bencodez.advancedcore.api.rewards.RewardOptions()); + var dispatch = com.bencodez.advancedcore.api.rewards.Reward.class.getDeclaredMethod("requestInjectionAsync", + com.bencodez.advancedcore.api.rewards.injected.RewardInject.class, + com.bencodez.advancedcore.api.user.AdvancedCoreUser.class, HashMap.class, + com.bencodez.advancedcore.api.rewards.Reward.ReplayState.class, String.class, String.class, + com.bencodez.advancedcore.api.rewards.RewardOptions.class, org.bukkit.entity.Player.class); + dispatch.setAccessible(true); + try (var core = mockStatic(com.bencodez.advancedcore.AdvancedCorePlugin.class)) { + core.when(com.bencodez.advancedcore.AdvancedCorePlugin::getInstance).thenReturn(plugin); + for (int i = 0; i < 2; i++) { + @SuppressWarnings("unchecked") var award = (java.util.concurrent.CompletionStage) dispatch.invoke( + reward, inject, player, new HashMap<>(), state, "root:activation", "event-one", null, null); + award.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + if (i == 0) configData.set("Activations", null); + } + configData.set("PerkActivations.vote", 4); + for (int i = 0; i < 2; i++) { + @SuppressWarnings("unchecked") var award = (java.util.concurrent.CompletionStage) dispatch.invoke( + reward, perkInject, player, new HashMap<>(), state, "root:perk", "event-one", null, null); + award.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + if (i == 0) configData.set("PerkActivations.vote", null); + } + } + key.set("root:perk"); + var row = backend.user(uuid).readRow(UserStorage.SQLITE); + assertEquals(2, row.stream().filter(c -> c.getName().equals("Activations")).findFirst().orElseThrow().getValue().getInt()); + assertEquals(4, row.stream().filter(c -> c.getName().equals("PerkActivations_vote")).findFirst().orElseThrow().getValue().getInt()); + perkInject.onReplayCheckpointPersisted(reward, player, "event-one", "root:perk", true).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + inject.onReplayCheckpointPersisted(reward, player, "event-one", "root:activation", true).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + occurrence.remove(); key.remove(); + inject.onRewardRequestAsync(reward, player, 1, new HashMap<>()).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + assertEquals(3, manager.getPublishedCache(uuid).getIfPresent("Activations").getInt()); + var custom = new com.bencodez.advancedcore.api.user.UserData(target) { + @Override public int getInt(String name) { throw new AssertionError("unexpected custom getter"); } + @Override public void setInt(String name, int value) { throw new AssertionError("unexpected custom setter"); } + }; + doReturn(custom).when(target).getData(); + var unsupported = inject.onRewardRequestAsync(reward, player, 1, new HashMap<>()).toCompletableFuture(); + var failure = assertThrows(java.util.concurrent.ExecutionException.class, () -> unsupported.get(5, java.util.concurrent.TimeUnit.SECONDS)); + assertInstanceOf(UnsupportedOperationException.class, failure.getCause()); + assertEquals(3, manager.getPublishedCache(uuid).getIfPresent("Activations").getInt()); + manager.closeSharedRuntimeAsyncCompletion(() -> {}).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + } finally { + occurrence.remove(); key.remove(); com.bencodez.mcperks.MCPerksMain.plugin = previous; + manager.getTimer().shutdownNow(); if (backend.isOpen()) backend.close(); + } + } + @Test void unrelatedReplayCheckpointDoesNotCreateAnAbsentUserRow() throws Exception { + UUID id = UUID.randomUUID(); var backend = backend(); + var plugin = mock(com.bencodez.mcperks.MCPerksMain.class, CALLS_REAL_METHODS); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var mcUsers = mock(com.bencodez.mcperks.userapi.UserManager.class); + var handler = mock(com.bencodez.advancedcore.api.rewards.RewardHandler.class); + var perks = mock(com.bencodez.mcperks.perk.PerkHandler.class); + var player = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var target = mock(com.bencodez.mcperks.userapi.MCPerksUser.class, CALLS_REAL_METHODS); + doReturn(users).when(plugin).getUserManager(); doReturn(UserStorage.SQLITE).when(plugin).getStorageType(); + doReturn(null).when(plugin).getNativeUserStorageOwner(); + doReturn(mock(com.bencodez.advancedcore.AdvancedCoreConfigOptions.class, RETURNS_DEEP_STUBS)).when(plugin).getOptions(); + doReturn(mcUsers).when(plugin).getMcperksUserManager(); doReturn(handler).when(plugin).getRewardHandler(); + doReturn(perks).when(plugin).getPerkHandler(); + var loaded = new HashMap(); loaded.put("vote", mock(com.bencodez.mcperks.perk.Perk.class)); + when(perks.getLoadedPerks()).thenReturn(loaded); + when(player.getPlayerName()).thenReturn("unrelated"); when(mcUsers.getMCPerksUser("unrelated")).thenReturn(target); + doReturn(plugin).when(target).getPlugin(); doReturn(id.toString()).when(target).getUUID(); + doReturn(new com.bencodez.advancedcore.api.user.UserData(target)).when(target).getData(); + when(users.getUser(id, false)).thenReturn(target); + doReturn(new com.bencodez.advancedcore.api.user.UserData(target)).when(target).getUserData(); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(plugin); when(users.getDataManager()).thenReturn(manager); + var owner = new com.bencodez.advancedcore.bukkit.user.runtime.BukkitUserCacheOwner(manager); + manager.bindSharedRuntime(new com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime(backend, owner)); + var previous = com.bencodez.mcperks.MCPerksMain.plugin; com.bencodez.mcperks.MCPerksMain.plugin = plugin; + try (var bukkit = mockStatic(org.bukkit.Bukkit.class)) { + bukkit.when(org.bukkit.Bukkit::getPluginManager).thenReturn(mock(org.bukkit.plugin.PluginManager.class)); + plugin.loadInjectedRewards(); + var captured = org.mockito.ArgumentCaptor.forClass(com.bencodez.advancedcore.api.rewards.injected.RewardInject.class); + verify(handler, times(2)).addInjectedReward(captured.capture()); + var inject = captured.getAllValues().stream().filter(item -> item.getPath().equals("Activations")).findFirst().orElseThrow(); + var laterCalls = new java.util.concurrent.atomic.AtomicInteger(); + var registrations = new java.util.concurrent.CopyOnWriteArrayList<>(captured.getAllValues()); + registrations.add(new com.bencodez.advancedcore.api.rewards.injected.RewardInject("Later") { + @Override public Object onRewardRequest(com.bencodez.advancedcore.api.rewards.Reward r, + com.bencodez.advancedcore.api.user.AdvancedCoreUser u, + org.bukkit.configuration.ConfigurationSection data, HashMap placeholders) { + laterCalls.incrementAndGet(); return null; + } + }); + when(handler.getInjectedRewards()).thenReturn(registrations); + doReturn(true).when(plugin).isEnabled(); + var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); doReturn(scheduler).when(plugin).getBukkitScheduler(); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; }).when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class)); + when(player.endAsyncActionCollection(isNull())).thenReturn(java.util.concurrent.CompletableFuture.completedFuture(null)); + var data = new org.bukkit.configuration.file.YamlConfiguration(); + try (var core = mockStatic(com.bencodez.advancedcore.AdvancedCorePlugin.class)) { + core.when(com.bencodez.advancedcore.AdvancedCorePlugin::getInstance).thenReturn(plugin); + var actualReward = new com.bencodez.advancedcore.api.rewards.Reward("unrelated", data); + var pluginField = com.bencodez.advancedcore.api.rewards.Reward.class.getDeclaredField("plugin"); pluginField.setAccessible(true); pluginField.set(actualReward, plugin); + var replay = com.bencodez.advancedcore.api.rewards.Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + com.bencodez.advancedcore.api.user.AdvancedCoreUser.class, HashMap.class, int.class); + replay.setAccessible(true); + @SuppressWarnings("unchecked") var delivery = (java.util.concurrent.CompletionStage) replay.invoke(actualReward, player, new HashMap<>(), 0); + delivery.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + @SuppressWarnings("unchecked") var resumed = (java.util.concurrent.CompletionStage) replay.invoke(actualReward, player, new HashMap<>(), 2); + resumed.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + } + assertEquals(2, laterCalls.get(), "later injector also runs after resumed unselected credit stages"); + var reward = mock(com.bencodez.advancedcore.api.rewards.Reward.class); + inject.onReplayCheckpointPersisted(reward, player, "unrelated-event", "root:unrelated", false) + .toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + verify(mcUsers, never()).getMCPerksUser("unrelated"); + assertFalse(backend.user(id).contains(UserStorage.SQLITE), "unexecuted injection must not create a row"); + assertThrows(java.util.concurrent.ExecutionException.class, () -> inject.onReplayCheckpointPersisted( + reward, player, "unrelated-event", "root:unrelated", true).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS)); + assertThrows(java.util.concurrent.ExecutionException.class, () -> inject.onReplayCheckpointPersisted( + reward, player, "unrelated-event", "root:unrelated").toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS)); + assertFalse(backend.user(id).contains(UserStorage.SQLITE), "unknown or missing selected row must not be created"); + } finally { com.bencodez.mcperks.MCPerksMain.plugin = previous; manager.getTimer().shutdownNow(); if (backend.isOpen()) backend.close(); } + } + @Test void nonSharedRewardInjectorWaitsForTheActualAwardReceipt() throws Exception { + var plugin = mock(com.bencodez.mcperks.MCPerksMain.class, CALLS_REAL_METHODS); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var manager = mock(com.bencodez.advancedcore.api.user.usercache.UserDataManager.class); + var mcUsers = mock(com.bencodez.mcperks.userapi.UserManager.class); + var handler = mock(com.bencodez.advancedcore.api.rewards.RewardHandler.class); + var perks = mock(com.bencodez.mcperks.perk.PerkHandler.class); + var player = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var target = mock(com.bencodez.mcperks.userapi.MCPerksUser.class); + doReturn(users).when(plugin).getUserManager(); doReturn(UserStorage.SQLITE).when(plugin).getStorageType(); + doReturn(mock(com.bencodez.advancedcore.AdvancedCoreConfigOptions.class, RETURNS_DEEP_STUBS)).when(plugin).getOptions(); + doReturn(mcUsers).when(plugin).getMcperksUserManager(); doReturn(handler).when(plugin).getRewardHandler(); doReturn(perks).when(plugin).getPerkHandler(); + when(users.getDataManager()).thenReturn(manager); + var loaded = new HashMap(); loaded.put("vote", mock(com.bencodez.mcperks.perk.Perk.class)); + when(perks.getLoadedPerks()).thenReturn(loaded); + when(player.getPlayerName()).thenReturn("example"); when(mcUsers.getMCPerksUser("example")).thenReturn(target); + var receipt = new java.util.concurrent.CompletableFuture(); when(target.addActivationAsync(2)).thenReturn(receipt); + var previous = com.bencodez.mcperks.MCPerksMain.plugin; com.bencodez.mcperks.MCPerksMain.plugin = plugin; + try (var bukkit = mockStatic(org.bukkit.Bukkit.class)) { + bukkit.when(org.bukkit.Bukkit::getPluginManager).thenReturn(mock(org.bukkit.plugin.PluginManager.class)); + plugin.loadInjectedRewards(); + var captured = org.mockito.ArgumentCaptor.forClass(com.bencodez.advancedcore.api.rewards.injected.RewardInject.class); + verify(handler, times(2)).addInjectedReward(captured.capture()); + var inject = (com.bencodez.advancedcore.api.rewards.injected.RewardInjectInt) captured.getAllValues().stream() + .filter(item -> item.getPath().equals("Activations")).findFirst().orElseThrow(); + var perkInject = (com.bencodez.advancedcore.api.rewards.injected.RewardInjectInt) captured.getAllValues().stream() + .filter(item -> item.getPath().equals("PerkActivations.vote")).findFirst().orElseThrow(); + var reward = mock(com.bencodez.advancedcore.api.rewards.Reward.class); + var config = mock(com.bencodez.advancedcore.api.rewards.RewardFileData.class); + var data = new org.bukkit.configuration.file.YamlConfiguration(); data.set("Activations", 2); + when(reward.getConfig()).thenReturn(config); when(config.getConfigData()).thenReturn(data); + when(player.endAsyncActionCollection(isNull())).thenReturn(java.util.concurrent.CompletableFuture.completedFuture(null)); + var state = com.bencodez.advancedcore.api.rewards.Reward.replayStateFor(new com.bencodez.advancedcore.api.rewards.RewardOptions()); + var dispatch = com.bencodez.advancedcore.api.rewards.Reward.class.getDeclaredMethod("requestInjectionAsync", + com.bencodez.advancedcore.api.rewards.injected.RewardInject.class, + com.bencodez.advancedcore.api.user.AdvancedCoreUser.class, HashMap.class, + com.bencodez.advancedcore.api.rewards.Reward.ReplayState.class, String.class, String.class, + com.bencodez.advancedcore.api.rewards.RewardOptions.class, org.bukkit.entity.Player.class); + dispatch.setAccessible(true); + try (var core = mockStatic(com.bencodez.advancedcore.AdvancedCorePlugin.class)) { + core.when(com.bencodez.advancedcore.AdvancedCorePlugin::getInstance).thenReturn(plugin); + @SuppressWarnings("unchecked") var result = ((java.util.concurrent.CompletionStage) dispatch.invoke( + reward, inject, player, new HashMap<>(), state, "root:activation", null, null, null)).toCompletableFuture(); + assertFalse(result.isDone()); verify(target, never()).addActivation(2); + receipt.complete(null); result.get(5, java.util.concurrent.TimeUnit.SECONDS); verify(target).addActivationAsync(2); + data.set("Activations", null); + @SuppressWarnings("unchecked") var absent = (java.util.concurrent.CompletionStage) dispatch.invoke( + reward, inject, player, new HashMap<>(), state, "root:absent", null, null, null); + assertNull(absent.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS)); + verify(target, times(1)).addActivationAsync(2); + data.set("Activations", 2); + var failureReceipt = new java.util.concurrent.CompletableFuture(); when(target.addActivationAsync(2)).thenReturn(failureReceipt); + @SuppressWarnings("unchecked") var failed = (java.util.concurrent.CompletionStage) dispatch.invoke( + reward, inject, player, new HashMap<>(), state, "root:failure", null, null, null); + assertFalse(failed.toCompletableFuture().isDone()); + failureReceipt.completeExceptionally(new UnsupportedOperationException("award rejected before admission")); + assertThrows(java.util.concurrent.ExecutionException.class, () -> failed.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS)); + data.set("Activations", null); + var replayReceipt = new java.util.concurrent.CompletableFuture(); when(target.addActivationAsync(2)).thenReturn(replayReceipt); + @SuppressWarnings("unchecked") var resumed = (java.util.concurrent.CompletionStage) dispatch.invoke( + reward, inject, player, new HashMap<>(), state, "root:failure", null, null, null); + assertFalse(resumed.toCompletableFuture().isDone(), "saved award intent must survive configuration removal"); + replayReceipt.complete(null); resumed.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + data.set("PerkActivations.vote", 2); + var perkReceipt = new java.util.concurrent.CompletableFuture(); when(target.addActivationAsync("vote", 2)).thenReturn(perkReceipt); + @SuppressWarnings("unchecked") var perkResult = (java.util.concurrent.CompletionStage) dispatch.invoke( + reward, perkInject, player, new HashMap<>(), state, "root:perk", null, null, null); + assertFalse(perkResult.toCompletableFuture().isDone()); verify(target, never()).addActivation("vote", 2); + perkReceipt.complete(null); perkResult.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + verify(target).addActivationAsync("vote", 2); + } + } finally { com.bencodez.mcperks.MCPerksMain.plugin = previous; } + } + @Test void votingPluginRewardInjectorUsesTheSameReplayLedger() throws Exception { + UUID uuid = UUID.randomUUID(); var backend = backend(); + var plugin = mock(com.bencodez.mcperks.MCPerksMain.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var mcUsers = mock(com.bencodez.mcperks.userapi.UserManager.class); + var perks = mock(com.bencodez.mcperks.perk.PerkHandler.class); + var player = mock(com.bencodez.votingplugin.advancedcore.api.user.AdvancedCoreUser.class); + var target = mock(com.bencodez.mcperks.userapi.MCPerksUser.class, CALLS_REAL_METHODS); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(plugin.getMcperksUserManager()).thenReturn(mcUsers); when(plugin.getPerkHandler()).thenReturn(perks); + when(plugin.getOptions()).thenReturn(mock(com.bencodez.advancedcore.AdvancedCoreConfigOptions.class, RETURNS_DEEP_STUBS)); + var loaded = new HashMap(); loaded.put("vote", mock(com.bencodez.mcperks.perk.Perk.class)); + when(perks.getLoadedPerks()).thenReturn(loaded); when(plugin.getJavascriptEngineRequests()).thenReturn(new java.util.ArrayList<>()); + when(player.getPlayerName()).thenReturn("example"); when(mcUsers.getMCPerksUser("example")).thenReturn(target); + doReturn(plugin).when(target).getPlugin(); doReturn(uuid.toString()).when(target).getUUID(); + doReturn(new com.bencodez.advancedcore.api.user.UserData(target)).when(target).getData(); + when(users.getUser(uuid, false)).thenReturn(target); + doReturn(new com.bencodez.advancedcore.api.user.UserData(target)).when(target).getUserData(); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(plugin); when(users.getDataManager()).thenReturn(manager); + var owner = new com.bencodez.advancedcore.bukkit.user.runtime.BukkitUserCacheOwner(manager); + var runtime = new com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime(backend, owner); manager.bindSharedRuntime(runtime); + var previous = com.bencodez.mcperks.MCPerksMain.plugin; com.bencodez.mcperks.MCPerksMain.plugin = plugin; + var occurrenceField = com.bencodez.votingplugin.advancedcore.api.rewards.Reward.class.getDeclaredField("ACTIVE_REPLAY_OCCURRENCE_ID"); occurrenceField.setAccessible(true); + var keyField = com.bencodez.votingplugin.advancedcore.api.rewards.Reward.class.getDeclaredField("ACTIVE_REPLAY_KEY"); keyField.setAccessible(true); + @SuppressWarnings("unchecked") var occurrence = (ThreadLocal) occurrenceField.get(null); + @SuppressWarnings("unchecked") var key = (ThreadLocal) keyField.get(null); + var hooks = mock(com.bencodez.votingplugin.VotingPluginHooks.class); + try (var staticHooks = mockStatic(com.bencodez.votingplugin.VotingPluginHooks.class)) { + staticHooks.when(com.bencodez.votingplugin.VotingPluginHooks::getInstance).thenReturn(hooks); + com.bencodez.mcperks.VotingPluginHook.getInstance().loadRewards(); + var captured = org.mockito.ArgumentCaptor.forClass(com.bencodez.votingplugin.advancedcore.api.rewards.injected.RewardInject.class); + verify(hooks, times(2)).addCustomReward(captured.capture()); + var inject = (com.bencodez.votingplugin.advancedcore.api.rewards.injected.RewardInjectInt) captured.getAllValues().stream() + .filter(item -> item.getPath().equals("Activations")).findFirst().orElseThrow(); + var perkInject = (com.bencodez.votingplugin.advancedcore.api.rewards.injected.RewardInjectInt) captured.getAllValues().stream() + .filter(item -> item.getPath().equals("PerkActivations.vote")).findFirst().orElseThrow(); + assertTrue(inject.supportsAsyncRequest(), "VotingPlugin global reward must dispatch its real async receipt"); + assertTrue(perkInject.supportsAsyncRequest(), "VotingPlugin perk reward must dispatch its real async receipt"); + assertTrue(inject.requiresConfiguredDataForAsync()); assertTrue(perkInject.requiresConfiguredDataForAsync()); + var unrelated = mock(com.bencodez.votingplugin.advancedcore.api.user.AdvancedCoreUser.class); + when(unrelated.getPlayerName()).thenReturn("unrelated"); + for (var candidate : java.util.List.of(inject, perkInject)) { + var contextual = candidate.getClass().getMethod("onReplayCheckpointPersisted", + com.bencodez.votingplugin.advancedcore.api.rewards.Reward.class, + com.bencodez.votingplugin.advancedcore.api.user.AdvancedCoreUser.class, String.class, String.class, boolean.class); + contextual.setAccessible(true); + @SuppressWarnings("unchecked") var skipped = (java.util.concurrent.CompletionStage) contextual.invoke( + candidate, mock(com.bencodez.votingplugin.advancedcore.api.rewards.Reward.class), unrelated, "other", "root:other", false); + skipped.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + } + verify(mcUsers, never()).getMCPerksUser("unrelated"); + occurrence.set("event-one"); key.set("root:activation"); + var reward = mock(com.bencodez.votingplugin.advancedcore.api.rewards.Reward.class); + inject.onRewardRequestAsync(reward, player, 2, new HashMap<>()).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + inject.onRewardRequestAsync(reward, player, 2, new HashMap<>()).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + var row = backend.user(uuid).readRow(UserStorage.SQLITE); + assertEquals(2, row.stream().filter(c -> c.getName().equals("Activations")).findFirst().orElseThrow().getValue().getInt()); + inject.onReplayCheckpointPersisted(reward, player, "event-one", "root:activation").toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + occurrence.remove(); key.remove(); + inject.onRewardRequestAsync(reward, player, 1, new HashMap<>()).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + assertEquals(3, manager.getPublishedCache(uuid).getIfPresent("Activations").getInt()); + var fallback = mock(com.bencodez.mcperks.userapi.MCPerksUser.class); + when(mcUsers.getMCPerksUser("example")).thenReturn(fallback); + var fallbackReceipt = new java.util.concurrent.CompletableFuture(); + when(fallback.addActivationAsync(3)).thenReturn(fallbackReceipt); + try (var oldReward = mockStatic(com.bencodez.votingplugin.advancedcore.api.rewards.Reward.class, CALLS_REAL_METHODS)) { + oldReward.when(com.bencodez.votingplugin.advancedcore.api.rewards.Reward::currentReplaySideEffectOccurrenceId).thenThrow(new NoSuchMethodError()); + var pendingFallback = inject.onRewardRequestAsync(reward, player, 3, new HashMap<>()).toCompletableFuture(); + assertFalse(pendingFallback.isDone()); fallbackReceipt.complete(null); pendingFallback.get(5, java.util.concurrent.TimeUnit.SECONDS); + verify(fallback).addActivationAsync(3); verify(fallback, never()).addActivation(3); + oldReward.when(() -> com.bencodez.votingplugin.advancedcore.api.rewards.Reward.replaySideEffectOccurrenceId("event-one", "root:activation")) + .thenThrow(new NoSuchMethodError()); + inject.onReplayCheckpointPersisted(reward, player, "event-one", "root:activation").toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + } + // Exercise the shaded VotingPlugin Reward dispatcher for both registrations. + var dispatchReward = mock(com.bencodez.votingplugin.advancedcore.api.rewards.Reward.class); + var config = mock(com.bencodez.votingplugin.advancedcore.api.rewards.RewardFileData.class); + var configData = new org.bukkit.configuration.file.YamlConfiguration(); configData.set("Activations", 3); + when(dispatchReward.getConfig()).thenReturn(config); when(config.getConfigData()).thenReturn(configData); + when(player.endAsyncActionCollection(isNull())).thenReturn(java.util.concurrent.CompletableFuture.completedFuture(null)); + var state = com.bencodez.votingplugin.advancedcore.api.rewards.Reward.replayStateFor( + new com.bencodez.votingplugin.advancedcore.api.rewards.RewardOptions()); + var dispatch = com.bencodez.votingplugin.advancedcore.api.rewards.Reward.class.getDeclaredMethod("requestInjectionAsync", + com.bencodez.votingplugin.advancedcore.api.rewards.injected.RewardInject.class, + com.bencodez.votingplugin.advancedcore.api.user.AdvancedCoreUser.class, HashMap.class, + com.bencodez.votingplugin.advancedcore.api.rewards.Reward.ReplayState.class, String.class, String.class); + dispatch.setAccessible(true); + var vpCore = mock(com.bencodez.votingplugin.advancedcore.AdvancedCorePlugin.class); + try (var core = mockStatic(com.bencodez.votingplugin.advancedcore.AdvancedCorePlugin.class)) { + core.when(com.bencodez.votingplugin.advancedcore.AdvancedCorePlugin::getInstance).thenReturn(vpCore); + var globalReceipt = new java.util.concurrent.CompletableFuture(); when(fallback.addActivationAsync(3)).thenReturn(globalReceipt); + @SuppressWarnings("unchecked") var global = (java.util.concurrent.CompletionStage) dispatch.invoke( + dispatchReward, inject, player, new HashMap<>(), state, "root:global", null); + assertFalse(global.toCompletableFuture().isDone()); globalReceipt.complete(null); + global.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + var rejected = new java.util.concurrent.CompletableFuture(); when(fallback.addActivationAsync(3)).thenReturn(rejected); + @SuppressWarnings("unchecked") var failed = (java.util.concurrent.CompletionStage) dispatch.invoke( + dispatchReward, inject, player, new HashMap<>(), state, "root:rejected", null); + rejected.completeExceptionally(new UnsupportedOperationException("rejected before admission")); + assertThrows(java.util.concurrent.ExecutionException.class, () -> failed.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS)); + configData.set("Activations", null); + var resumedReceipt = new java.util.concurrent.CompletableFuture(); when(fallback.addActivationAsync(3)).thenReturn(resumedReceipt); + @SuppressWarnings("unchecked") var resumed = (java.util.concurrent.CompletionStage) dispatch.invoke( + dispatchReward, inject, player, new HashMap<>(), state, "root:rejected", null); + assertFalse(resumed.toCompletableFuture().isDone()); resumedReceipt.complete(null); + resumed.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + configData.set("PerkActivations.vote", 3); + var perkReceipt = new java.util.concurrent.CompletableFuture(); when(fallback.addActivationAsync("vote", 3)).thenReturn(perkReceipt); + @SuppressWarnings("unchecked") var perkResult = (java.util.concurrent.CompletionStage) dispatch.invoke( + dispatchReward, perkInject, player, new HashMap<>(), state, "root:perk", null); + assertFalse(perkResult.toCompletableFuture().isDone()); perkReceipt.complete(null); + perkResult.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + verify(fallback).addActivationAsync("vote", 3); verify(fallback, never()).addActivation("vote", 3); + } + manager.closeSharedRuntimeAsyncCompletion(() -> {}).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + } finally { + occurrence.remove(); key.remove(); com.bencodez.mcperks.MCPerksMain.plugin = previous; + manager.getTimer().shutdownNow(); if (backend.isOpen()) backend.close(); + } + } +} diff --git a/MCPerks/src/test/java/com/bencodez/mcperks/tests/userapi/UserStorageConfirmationTest.java b/MCPerks/src/test/java/com/bencodez/mcperks/tests/userapi/UserStorageConfirmationTest.java new file mode 100644 index 0000000..1d597ad --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/userapi/UserStorageConfirmationTest.java @@ -0,0 +1,204 @@ +package com.bencodez.mcperks.tests.userapi; +import static com.bencodez.mcperks.tests.TestAccess.*; +import com.bencodez.mcperks.userapi.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.nio.file.Path; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import com.bencodez.advancedcore.api.user.usercache.*; +import com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeString; +import com.bencodez.mcperks.MCPerksMain; +import com.bencodez.mcperks.data.OrderedStorage; + +class UserStorageConfirmationTest { + @TempDir Path directory; + @Test void confirmationCannotOvertakeAQueuedSharedAward() throws Exception { + var plugin = mock(MCPerksMain.class); var user = mock(MCPerksUser.class); UUID owner = UUID.randomUUID(); + when(user.getUUID()).thenReturn(owner.toString()); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); when(plugin.getUserManager()).thenReturn(users); + var manager = new UserDataManager(plugin); when(users.getDataManager()).thenReturn(manager); + var backend = mock(com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend.class); when(backend.isOpen()).thenReturn(true); + manager.bindSharedRuntime(new com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime(backend, + mock(com.bencodez.advancedcore.core.user.runtime.UserCacheOwner.class))); + var written = new ConcurrentHashMap(); var cache = new UserDataCache(manager, owner); + cache.configureSharedStorage(values -> values.forEach((key, value) -> written.put(key, value.getInt())), Runnable::run, Runnable::run); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(1)))); + manager.getUserDataCache().put(owner, cache); + var begin = UserDataManager.class.getDeclaredMethod("beginSharedSynchronousIntegerMutation", UUID.class); begin.setAccessible(true); + var end = UserDataManager.class.getDeclaredMethod("endSharedSynchronousIntegerMutation", UUID.class); end.setAccessible(true); + boolean admitted = false; + try (var storage = new OrderedStorage(directory.resolve("shared-pending.dat"))) { + storage.ready().get(5, TimeUnit.SECONDS); when(plugin.getStorage()).thenReturn(storage); + assertEquals(true, begin.invoke(manager, owner)); admitted = true; + var award = manager.submitSharedIntegerMutation(owner, Map.of("Points", 1), UserDataCache.IntegerMutation.ADD, null); + var confirmation = UserStorageConfirmation.confirm(plugin, user); + storage.runtimeStorage(() -> null).get(5, TimeUnit.SECONDS); manager.getTimer().submit(() -> {}).get(5, TimeUnit.SECONDS); + assertFalse(confirmation.isDone(), "confirmation must not pass an admitted but unscheduled shared award"); + end.invoke(manager, owner); admitted = false; + award.toCompletableFuture().get(5, TimeUnit.SECONDS); confirmation.get(5, TimeUnit.SECONDS); + assertEquals(2, written.get("Points")); + } finally { if (admitted) end.invoke(manager, owner); manager.getTimer().shutdownNow(); } + } + @Test void confirmationWaitsForRetainedLogicalMutationBeforeFlushing() throws Exception { + var plugin = mock(MCPerksMain.class); var user = mock(MCPerksUser.class); UUID owner = UUID.randomUUID(); + when(user.getUUID()).thenReturn(owner.toString()); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); var manager = mock(UserDataManager.class); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + var pending = new CompletableFuture(); when(manager.pendingLegacyIntegerMutations(owner)).thenReturn(pending); + when(manager.pendingSharedIntegerMutations(owner)).thenReturn(CompletableFuture.completedFuture(null)); + var worker = Executors.newSingleThreadScheduledExecutor(); when(manager.getTimer()).thenReturn(worker); + var cache = mock(UserDataCache.class); when(manager.getCache(owner)).thenReturn(cache); + doAnswer(call -> { call.getArgument(0).run(); return null; }).when(cache).flushChangesAndRun(any(Runnable.class)); + try (var storage = new OrderedStorage(directory.resolve("pending.dat"))) { + storage.ready().get(5, TimeUnit.SECONDS); when(plugin.getStorage()).thenReturn(storage); + var confirmation = UserStorageConfirmation.confirm(plugin, user); + storage.runtimeStorage(() -> null).get(5, TimeUnit.SECONDS); + assertFalse(confirmation.isDone()); verify(manager, never()).getCache(owner); + pending.complete(null); confirmation.get(5, TimeUnit.SECONDS); verify(cache).flushChangesAndRun(any(Runnable.class)); + } finally { worker.shutdownNow(); } + } + @Test void failedPendingMutationCannotBeConfirmedAsStored() throws Exception { + var plugin = mock(MCPerksMain.class); var user = mock(MCPerksUser.class); UUID owner = UUID.randomUUID(); + when(user.getUUID()).thenReturn(owner.toString()); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); var manager = mock(UserDataManager.class); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + var pending = new CompletableFuture(); when(manager.pendingLegacyIntegerMutations(owner)).thenReturn(pending); + when(manager.pendingSharedIntegerMutations(owner)).thenReturn(CompletableFuture.completedFuture(null)); + try (var storage = new OrderedStorage(directory.resolve("failed-pending.dat"))) { + storage.ready().get(5, TimeUnit.SECONDS); when(plugin.getStorage()).thenReturn(storage); + var confirmation = UserStorageConfirmation.confirm(plugin, user); + storage.runtimeStorage(() -> null).get(5, TimeUnit.SECONDS); + assertFalse(confirmation.isDone()); pending.completeExceptionally(new IllegalStateException("mutation failed")); + assertThrows(ExecutionException.class, () -> confirmation.get(5, TimeUnit.SECONDS)); + verify(manager, never()).getCache(owner); + } + } + @Test void failedPendingSharedMutationCannotBeConfirmedAsStored() throws Exception { + var plugin = mock(MCPerksMain.class); var user = mock(MCPerksUser.class); UUID owner = UUID.randomUUID(); + when(user.getUUID()).thenReturn(owner.toString()); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); var manager = mock(UserDataManager.class); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(manager.pendingLegacyIntegerMutations(owner)).thenReturn(CompletableFuture.completedFuture(null)); + var pending = new CompletableFuture(); when(manager.pendingSharedIntegerMutations(owner)).thenReturn(pending); + try (var storage = new OrderedStorage(directory.resolve("failed-shared.dat"))) { + storage.ready().get(5, TimeUnit.SECONDS); when(plugin.getStorage()).thenReturn(storage); + var confirmation = UserStorageConfirmation.confirm(plugin, user); + storage.runtimeStorage(() -> null).get(5, TimeUnit.SECONDS); + assertFalse(confirmation.isDone()); pending.completeExceptionally(new IllegalStateException("shared mutation failed")); + assertThrows(ExecutionException.class, () -> confirmation.get(5, TimeUnit.SECONDS)); verify(manager, never()).getCache(owner); + } + } + @Test void actualAdvancedCoreCacheCompletionWaitsForWriterAndPropagatesFailure() throws Exception { + var plugin=mock(MCPerksMain.class); var user=mock(MCPerksUser.class); UUID owner=UUID.randomUUID(); + when(user.getUUID()).thenReturn(owner.toString()); + var users=mock(com.bencodez.advancedcore.api.user.UserManager.class); when(plugin.getUserManager()).thenReturn(users); + var manager=mock(UserDataManager.class); when(users.getDataManager()).thenReturn(manager); when(manager.getPlugin()).thenReturn(plugin); + when(manager.pendingLegacyIntegerMutations(owner)).thenReturn(CompletableFuture.completedFuture(null)); + when(manager.pendingSharedIntegerMutations(owner)).thenReturn(CompletableFuture.completedFuture(null)); + when(users.getUser(owner,false)).thenReturn(user); + // Suppress only the cache's three-second background timer; exercise its real exclusive flush. + var storageWorker = Executors.newSingleThreadScheduledExecutor(task -> { + var thread = new Thread(task, "test-canonical-user-storage"); thread.setDaemon(true); return thread; + }); + when(manager.getTimer()).thenReturn(storageWorker); + var cache=new UserDataCache(manager,owner); cache.updateCache(new HashMap<>()); when(manager.getCache(owner)).thenReturn(cache); + var entered=new CountDownLatch(1); var release=new CountDownLatch(1); var fail=new AtomicBoolean(); + var written=new ConcurrentHashMap(); + cache.configureSharedStorage(values -> { + assertEquals("test-canonical-user-storage",Thread.currentThread().getName()); entered.countDown(); + try { if (!release.await(5,TimeUnit.SECONDS)) throw new IllegalStateException("timeout"); } + catch (InterruptedException error) { throw new RuntimeException(error); } + if (fail.get()) throw new IllegalStateException("SQL write failed"); + values.forEach((key,value) -> written.put(key,value.getString())); + },Runnable::run,Runnable::run); + try (var state=new OrderedStorage(directory.resolve("features.dat"))) { + state.ready().get(5,TimeUnit.SECONDS); when(plugin.getStorage()).thenReturn(state); + cache.addChange(new UserDataChangeString("Experation_passive","123456"),true); + cache.addChange(new UserDataChangeString("passive_CoolDown","234567"),true); + var confirmed=UserStorageConfirmation.confirm(plugin,user); + try { assertTrue(entered.await(5,TimeUnit.SECONDS)); assertFalse(confirmed.isDone()); assertTrue(written.isEmpty()); } + finally { release.countDown(); } + confirmed.get(5,TimeUnit.SECONDS); assertEquals(Map.of("Experation_passive","123456","passive_CoolDown","234567"),written); + fail.set(true); cache.addChange(new UserDataChangeString("Experation_passive","999999"),true); + assertThrows(ExecutionException.class, () -> UserStorageConfirmation.confirm(plugin,user).get(5,TimeUnit.SECONDS)); + assertEquals("123456",written.get("Experation_passive")); assertTrue(cache.hasChangesToProcess()); + } finally { storageWorker.shutdownNow(); } + } + @Test void confirmationWaitsBehindAnAdmittedDeferredMutation() throws Exception { + var plugin = mock(MCPerksMain.class); var user = mock(MCPerksUser.class); + UUID owner = UUID.randomUUID(); when(user.getUUID()).thenReturn(owner.toString()); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var manager = mock(UserDataManager.class); + when(manager.pendingLegacyIntegerMutations(owner)).thenReturn(CompletableFuture.completedFuture(null)); + when(manager.pendingSharedIntegerMutations(owner)).thenReturn(CompletableFuture.completedFuture(null)); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(manager.getPlugin()).thenReturn(plugin); when(users.getUser(owner, false)).thenReturn(user); + var worker = Executors.newSingleThreadScheduledExecutor(task -> { + var thread = new Thread(task, "test-canonical-user-storage"); thread.setDaemon(true); return thread; + }); + when(manager.getTimer()).thenReturn(worker); + var cache = new UserDataCache(manager, owner); cache.updateCache(new HashMap<>()); + when(manager.getCache(owner)).thenReturn(cache); + var written = new ConcurrentHashMap(); + cache.configureSharedStorage(values -> values.forEach((key, value) -> written.put(key, value.getString())), + Runnable::run, Runnable::run); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + try (var state = new OrderedStorage(directory.resolve("deferred.dat"))) { + state.ready().get(5, TimeUnit.SECONDS); when(plugin.getStorage()).thenReturn(state); + worker.execute(() -> { + entered.countDown(); + try { if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("Deferred mutation timed out"); } + catch (InterruptedException failure) { throw new RuntimeException(failure); } + cache.addChange(new UserDataChangeString("Experation_passive", "123456"), true); + cache.addChange(new UserDataChangeString("passive_CoolDown", "234567"), true); + }); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + var confirmation = UserStorageConfirmation.confirm(plugin, user); + state.runtimeStorage(() -> null).get(5, TimeUnit.SECONDS); + assertFalse(confirmation.isDone()); assertTrue(written.isEmpty()); + release.countDown(); confirmation.get(5, TimeUnit.SECONDS); + assertEquals(Map.of("Experation_passive", "123456", "passive_CoolDown", "234567"), written); + worker.shutdownNow(); + assertThrows(ExecutionException.class, + () -> UserStorageConfirmation.confirm(plugin, user).get(5, TimeUnit.SECONDS)); + } finally { release.countDown(); worker.shutdownNow(); } + } + @Test void acceptedWriteDoesNotExpireWhileCanonicalWorkerIsStillQueued() throws Exception { + var plugin = mock(MCPerksMain.class); var user = mock(MCPerksUser.class); + UUID owner = UUID.randomUUID(); when(user.getUUID()).thenReturn(owner.toString()); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var manager = mock(UserDataManager.class); + when(manager.pendingLegacyIntegerMutations(owner)).thenReturn(CompletableFuture.completedFuture(null)); + when(manager.pendingSharedIntegerMutations(owner)).thenReturn(CompletableFuture.completedFuture(null)); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(manager.getPlugin()).thenReturn(plugin); when(users.getUser(owner, false)).thenReturn(user); + var worker = Executors.newSingleThreadScheduledExecutor(); when(manager.getTimer()).thenReturn(worker); + var cache = new UserDataCache(manager, owner); cache.updateCache(new HashMap<>()); + when(manager.getCache(owner)).thenReturn(cache); + var written = new ConcurrentHashMap(); + cache.configureSharedStorage(values -> values.forEach((key, value) -> written.put(key, value.getString())), + Runnable::run, Runnable::run); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + try (var state = new OrderedStorage(directory.resolve("slow-user.dat"))) { + state.ready().get(5, TimeUnit.SECONDS); when(plugin.getStorage()).thenReturn(state); + worker.execute(() -> { + entered.countDown(); + try { if (!release.await(45, TimeUnit.SECONDS)) throw new IllegalStateException("test release timed out"); } + catch (InterruptedException failure) { throw new RuntimeException(failure); } + cache.addChange(new UserDataChangeString("Experation_passive", "123456"), true); + }); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + var confirmation = UserStorageConfirmation.confirm(plugin, user); + // Cross the old timeout while the accepted mutation is still waiting on its actual owner. + assertThrows(TimeoutException.class, () -> confirmation.get(31, TimeUnit.SECONDS)); + assertFalse(confirmation.isDone()); assertTrue(written.isEmpty()); + release.countDown(); confirmation.get(5, TimeUnit.SECONDS); + assertEquals("123456", written.get("Experation_passive")); + } finally { release.countDown(); worker.shutdownNow(); } + } +} diff --git a/README.md b/README.md index 0a818ab..eb38bd2 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,11 @@ Use `package` for verification; the existing `install` lifecycle copies the JAR The scheduler regression harness controls the native Bukkit boundary and exercises the bundled Spigot/Paper implementations. The conditional entity-owner scheduling path is retained; these headless tests do not declare Folia support or replace native server validation. -The `advancedcore.version` build property preserves the existing `LATEST` default. Override it explicitly when checking a published dependency version; record the resolved artifact hashes separately from the selector. +The `advancedcore.version` build property defaults to `3.8.2-SNAPSHOT`, which supplies the native storage and shutdown hooks used below. Override it explicitly when checking a published dependency version; record the resolved artifact hashes separately from the selector. ## Owner-task lifecycle integration -This PR introduces an unused scheduling foundation. Before adopting `OwnerTasks.run` or `OwnerTasks.later`, a consumer must initialize the helper before its first admission and retire it before beginning unload work: +`MCPerksMain` initializes the owner-task helper before native restoration can admit callbacks and retires it before unload work. Additional consumers must preserve the same ordering: ```java // At the beginning of the consumer's onPostLoad lifecycle callback: @@ -32,6 +32,16 @@ OwnerTasks.initialize(this); OwnerTasks.shutdown(this); ``` -The consumer must install both hooks when integrating new callers; this foundation does not change `MCPerksMain` or migrate existing callers. Admission fails closed before initialization and after retirement. Reinitialization during a later startup creates a new epoch, and callbacks retained by an old epoch cannot execute against its replacement. +Both hooks are installed in `MCPerksMain`. Keep them when adding owner-task callers. Admission fails closed before initialization and after retirement. Reinitialization during a later startup creates a new epoch, and callbacks retained by an old epoch cannot execute against its replacement. The helper also observes `PluginDisableEvent`, which Bukkit fires while the plugin still reports enabled, and retires pending admissions before native task cancellation can discard their callbacks. Action and retirement callbacks run outside the admission lock. Retirement callbacks are lifecycle cleanup and must be safe at the shutdown boundary; they must not require another plugin-owned scheduled callback to finish. These guarantees concern admission completion, not persistence or native Folia feature acceptance. + +## Confirmed native persistence + +Native perk, calendar and final shutdown saves share one bounded ordered storage worker. A successor waits for its predecessor to drain and release file ownership before reconciling `ServerData.yml`. Atomic publication completes before activation effects and hooks run. Failed publication does not report a successful activation or expose a staged calendar transition as saved. + +Existing native YAML is reconciled automatically. Purchased expiration times and timed permissions remain authoritative; restoration applies persistent effects without replaying activation rewards, commands or announcements. Missing or disabled perk definitions retain their shutdown records for a later valid restoration. Shutdown captures and removes native effects before retiring user storage, then keeps the file worker alive through final native saves. + +The ordered snapshot format bounds encoded files to four MB. Advisory history yields capacity to runtime state; an indispensable payload that cannot fit leaves the original file intact and reports the failure. No administrator deletion or data-reset step is performed. Optional queue/bank payloads are independent of this native storage owner. + +`/mcperks Reload` reports completion after the storage barrier and owner callback finish. Concurrent requests join the same pending operation; failed publication or dispatch reports failure instead of an early success message.