diff --git a/AGENTS.md b/AGENTS.md index ac6c3e5..aa30619 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Maintainer and AI-agent guide -MCPerks is a Bukkit/Paper/Folia plugin built on AdvancedCore. It grants time-limited perks and effects at player, server, permission, Towny, or Factions scope and optionally integrates with VotingPlugin, Vault, RoseStacker, mcMMO, PlaceholderAPI, and other plugins. +MCPerks is a Bukkit/Paper plugin built on AdvancedCore. It grants time-limited perks and effects at player, server, permission, Towny, or Factions scope and optionally integrates with VotingPlugin, Vault, RoseStacker, mcMMO, PlaceholderAPI, and other plugins. The current descriptor does not declare Folia support, and Folia refuses to load it. Conditional Folia scheduler paths are not a platform-support guarantee; do not add the descriptor flag without auditing and validating the complete plugin lifecycle and integrations. ## Build and verification @@ -35,6 +35,29 @@ The supported scope values are `ALL`, `PLAYER`, `TOWNY`, `FACTIONS`, and `PERMIS 7. Reward or command integration must preserve idempotency where the caller can retry and must not grant before required state is durably recorded. 8. Keep configuration keys, perk files, placeholders, permissions, aliases, and serialized user/server data backward compatible unless a migration is explicit and tested. +9. Optional interaction effects use immutable options captured at load. Charge budgets belong to a player UUID and one + fixed effect, require durable asynchronous admission before gameplay, and remain spent after an uncertain effect or + crash. Never create per-reservation SQL keys or fetch perk files from gameplay listeners. Shared charge ownership is + rejected; legacy unlimited shared perks remain supported. +10. Queue entries reserve existing entitlement without decrementing while waiting. Persist stable IDs and start fences + before consumption; limited queued activations require shared-runtime atomic user ownership and otherwise remain + waiting. Use confirmed asynchronous storage completion, consume one selected activation, and never replay + an uncertain start. Self cancellation checks ownership; pause and reordering are durable. Display unknown ETA when + eligibility or permanent/banked state prevents a reliable estimate. +11. Banked personal time is an explicit optional mode, separate from legacy absolute expirations. Lifecycle checkpoints + consume online eligible elapsed time, including final disconnect/world/pause/reload/shutdown intervals; queries remain pure. Unsupported one-shot, periodic and persistent-effect + activation paths are rejected. Resuming a bank never grants entitlement or replays reward/command hooks. Retired + player or reload timer callbacks cannot remove effects from a newer session. + Nonpositive legacy reactivation timers remain disabled and supported. A bank replacement starts its elapsed/debit + ownership boundary only after durable publication; prior debit reservations cannot consume the replacement balance. + Grant publication must retain concurrent pause/eligibility transitions. Every acknowledged queue/bank state file + must remain within the same 3,000,000 serialized-byte recovery bound; capacity rejection preserves usable prior state and must precede irreversible activation consumption. + A clock first attached during an additive publication can debit only the pre-grant balance for that wait. +12. Retire gameplay admissions and detach owner-thread effects before AdvancedCore storage retirement. Persist legacy + restart records before clearing native expirations. Keep the ordered feature worker open through both that + completion stage and AdvancedCore final permission-data cleanup; deferred shutdown may return before storage + preparation settles. Cleanup failures remain observable and must not prevent other perks from being persisted. + ## Drop-in upgrade and compatibility contract Treat compatibility as a release invariant for every new feature, refactor, fix, persistence change, configuration change, integration change, and dependency change. Unless the task explicitly says otherwise, an MCPerks upgrade must remain a **drop-in JAR replacement**: administrators replace the JAR and existing perks, timers, user/server state, configuration, commands, permissions, placeholders, and integrations continue working without manual migration steps. @@ -53,6 +76,10 @@ For compatibility-sensitive changes, add regression coverage for established con ## Dependencies and packaging +Startup defers native LastUpdated/version saves until strict ServerData reconciliation after predecessor drain. TimedPermissions become native-owned only after the actual AdvancedCore PermissionHandler restores the reconciled nodes through owner-scheduled native APIs; a generic empty YAML cache cannot clear recovered disk permissions. Compare the early native-handler baseline with current per-node state so successor revocations, renewals and new grants remain authoritative. Read only the bundled synchronized timedPermissionSnapshot/offlinePermissionSnapshot methods with exact schema checks; unsupported contracts fail initialization closed. Preserve pending offline nodes through the native final save without resurrecting revoked nodes. Successful bank replacement advances a FeatureState-owned debit epoch even for disconnected/reloaded/retired clocks; retained ownership is one scalar per bounded bank, never a retained list of session clocks. + +Ordered ServerData snapshots include a fixed leading format comment and stay within 4,000,000 encoded bytes including that comment. Unmarked native legacy YAML may migrate once after predecessor drain, with strict parsing and no symlink/unreadable-file bypass; consumed permissions and advisory history can yield the bounded format. An indispensable larger payload cannot migrate losslessly into this bounded file. Still-active oversized permission rows are conservatively checked before publishing a cleared snapshot, the original file stays intact, and initialization/final-save failures remain observable. Offline queued permission nodes have no native public snapshot API; the guarded bundled synchronized snapshot adapter preserves their pending native ownership. Future rows remain indispensable while that owner exists. Resolve excess active permissions on the prior version or reduce other indispensable payload before upgrading; do not add a supplemental persistence engine or silently discard those fields. + AdvancedCore is shaded and relocated. Before changing its version, shading, or API usage, inspect the actual dependency contract and downstream artifact. Avoid unpinned `LATEST` dependencies in reproducible release paths. Check that provided optional dependencies are not accidentally bundled and relocated libraries do not leak original packages or service metadata. ## Change and PR workflow @@ -60,3 +87,7 @@ AdvancedCore is shaded and relocated. Before changing its version, shading, or A Keep changes focused. Before any commit, push, PR update, review reply, or other remote change, run focused checks, the full Maven package build, fresh-artifact inspection, and `git diff --check`; then inspect the complete base-to-HEAD diff. Before committing local work, also inspect the staged changes and every relevant intended unstaged or untracked change as one effective final patch. For substantive work, obtain a fresh source-read-only review. The implementation agent verifies and fixes accepted findings, reruns validation, and obtains a new review of the updated snapshot. Do not reuse an old clean verdict after changes, and do not merge without explicit authorization. + +Queue-enabled direct activation retains the queue start fence through asynchronous bank publication, the initial player-owner attachment attempt, and cooldown confirmation. Queued bank starts retain their existing fence through the same attachment handoff. Resumed or reconnected banks wait behind unrelated active perks without consuming waiting time; queue-disabled personal banks retain parallel behavior. The first attachment attempt may finish ineligible or retired while preserving the published bank for a later eligible resume; it does not mint time or replay activation hooks. + +Limited direct starts persist an existing-format `STARTING` entry before any debit and never transition to queue `WAITING`. Validate and confirm current ServerData and caller storage before the atomic debit; publish new expiration/cooldown fields only after successful consumption so a rejected start cannot restore as a free permanent grant. Definite pre-debit failure or an authoritative false debit removes that entry with unchanged credit; debit ambiguity and later failure retain uncertainty without replay/refund. Hold owner/perk/lifecycle admission through all accepted callbacks and completion publication. Queue-disabled personal banks remain parallel across owners unless the same perk has a server-wide cooldown; bank attachment must never add the purchased balance again. diff --git a/MCPerks/pom.xml b/MCPerks/pom.xml index a737bce..6594641 100644 --- a/MCPerks/pom.xml +++ b/MCPerks/pom.xml @@ -260,9 +260,18 @@ + org.mockitomockito-core5.14.2test + + + org.junit.jupiter + junit-jupiter + 5.10.3 + test + + com.bencodez advancedcore - LATEST + 3.8.2-SNAPSHOT compile @@ -320,4 +329,15 @@ provided + + + tests + true + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + \ No newline at end of file diff --git a/MCPerks/src/main/java/com/bencodez/mcperks/MCPerksMain.java b/MCPerks/src/main/java/com/bencodez/mcperks/MCPerksMain.java index 8fa39b5..2766813 100644 --- a/MCPerks/src/main/java/com/bencodez/mcperks/MCPerksMain.java +++ b/MCPerks/src/main/java/com/bencodez/mcperks/MCPerksMain.java @@ -60,6 +60,20 @@ * The Class Main. */ public class MCPerksMain extends AdvancedCorePlugin { + private volatile int featureLifecycle; + public int getFeatureLifecycle() { return featureLifecycle; } + private final com.bencodez.mcperks.data.LocalStatistics localStatistics = new com.bencodez.mcperks.data.LocalStatistics(); + public com.bencodez.mcperks.data.LocalStatistics getLocalStatistics() { return localStatistics; } + private com.bencodez.mcperks.perk.BankedPerks bankedPerks; + public com.bencodez.mcperks.perk.BankedPerks getBankedPerks() { return bankedPerks; } + private com.bencodez.mcperks.data.FeatureState featureState; + private com.bencodez.mcperks.perk.QueuePlanner queuePlanner; + public com.bencodez.mcperks.data.FeatureState getFeatureState() { return featureState; } + public com.bencodez.mcperks.perk.QueuePlanner getQueuePlanner() { return queuePlanner; } + private com.bencodez.mcperks.userapi.UsageCharges usageCharges; + private com.bencodez.mcperks.listeners.UsageEffectListener usageEffects; + public com.bencodez.mcperks.userapi.UsageCharges getUsageCharges() { return usageCharges; } + public com.bencodez.mcperks.listeners.UsageEffectListener getUsageEffects() { return usageEffects; } /** The plugin. */ public static MCPerksMain plugin; @@ -137,21 +151,128 @@ 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<>(); + try { + featureLifecycle++; + var legacy = new ArrayList(); + if (getPerkHandler() != null) for (Perk perk : getPerkHandler().getActivePerks()) { + if (bankedPerks == null || !bankedPerks.attached(perk)) legacy.add(perk); + } + if (queuePlanner != null) queuePlanner.retire(); + var bankRetirement = bankedPerks == null ? java.util.concurrent.CompletableFuture.completedFuture(null) + : bankedPerks.retireAsync(); + if (usageEffects != null) usageEffects.retireAndClose(); + else if (usageCharges != null) usageCharges.close(); + if (getPerkHandler() != null) getPerkHandler().retireTimers(); + legacy.removeIf(perk -> !perk.detachForShutdown()); + if (legacy.isEmpty()) { + bankRetirement.whenComplete((ignored, failure) -> { + if (failure == null) shutdownPreparation.complete(null); + else shutdownPreparation.completeExceptionally(failure); + }); + return; + } + if (featureState == null) throw new IllegalStateException("Shutdown persistence owner unavailable"); + featureState.runtimeStorage(() -> legacy.stream().map(Perk::persistForShutdown).toList()) + .thenCompose(stages -> java.util.concurrent.CompletableFuture.allOf( + java.util.stream.Stream.concat(stages.stream(), java.util.stream.Stream.of(bankRetirement)) + .toArray(java.util.concurrent.CompletableFuture[]::new))) + .whenComplete((ignored, failure) -> { + if (failure == null) shutdownPreparation.complete(null); + else shutdownPreparation.completeExceptionally(failure); + }); + } catch (Throwable failure) { 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; + } + + @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 = featureState; + 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()); - } + // 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() { - loadListeners(); + shutdownPreparation = null; + shutdownFinalFiles = null; + shutdownCompletionHookDispatched = false; + int featureEpoch = ++featureLifecycle; + localStatistics.enabled(getConfigFile().getData().getBoolean("LocalStatistics.Enabled", false)); + usageCharges = new com.bencodez.mcperks.userapi.UsageCharges(getDataFolder().toPath().resolve("usage-charges.dat")); + usageCharges.ready().exceptionally(error -> { getLogger().severe("Usage charge store unavailable; charged effects fail closed: " + error.getMessage()); return null; }); + featureState = new com.bencodez.mcperks.data.FeatureState(getDataFolder().toPath().resolve("perk-features.dat")); + featureState.ready().exceptionally(error -> { getLogger().severe("Queue/bank store unavailable: " + error.getMessage()); return null; }); + java.util.concurrent.CompletableFuture runtimeReady = mcperksServerData.initialize(featureState); + runtimeReady.exceptionally(error -> { getLogger().severe("ServerData startup reconciliation failed; queued activations remain unavailable"); return null; }); + queuePlanner = new com.bencodez.mcperks.perk.QueuePlanner(this, featureState); + loadListeners(); perkHandler = new PerkHandler(this); effectHandler = new EffectHandler(this); + usageEffects = new com.bencodez.mcperks.listeners.UsageEffectListener(this); + getServer().getPluginManager().registerEvents(usageEffects, this); + usageEffects.synchronizeOnlinePlayers(); + bankedPerks = new com.bencodez.mcperks.perk.BankedPerks(this, featureState); + getServer().getPluginManager().registerEvents(bankedPerks, this); + runtimeReady.thenRun(() -> { + if (featureEpoch != featureLifecycle || !isEnabled()) return; + getBukkitScheduler().runTask(this, () -> { + if (featureEpoch != featureLifecycle || !isEnabled()) return; + perkHandler.loadActivePerksConfirmed(this::restoreOfflinePerks).whenComplete((ignored, error) -> { + if (error != null) { getLogger().severe("Legacy perk restoration failed; queued starts and bank attachments remain unavailable: " + error.getMessage()); return; } + if (featureEpoch != featureLifecycle || !isEnabled()) return; + getBukkitScheduler().runTask(this, () -> { + if (featureEpoch != featureLifecycle || !isEnabled()) return; + for (Player player : Bukkit.getOnlinePlayers()) getBukkitScheduler().runTask(this, () -> bankedPerks.start(player), player); + queuePlanner.refreshLegacyView(); queuePlanner.check(); queuePlanner.startChecks(); + }); + }); + }); }); commandLoader = new CommandLoader(this); commandLoader.loadCommands(); @@ -172,48 +293,6 @@ 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(); - } - }); getJavascriptEngineRequests().add(new JavascriptPlaceholderRequest("MCPerks") { @@ -476,13 +555,121 @@ 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 (featureState == 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; + @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 existing; + if (!reloadPending.compareAndSet(false, true)) return reloadOperation; + var operation = new java.util.concurrent.CompletableFuture(); + reloadOperation = operation; + if (usageEffects != null) usageEffects.invalidate(); + if (queuePlanner != null) queuePlanner.suspend(); + // ServerData snapshots accepted before reload must publish before AdvancedCore reads its file. + var barrier = featureState == null ? java.util.concurrent.CompletableFuture.completedFuture(null) : featureState.runtimeStorage(() -> null); + barrier.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()) return; + if (!isEnabled()) { finishReload(operation, new IllegalStateException("Plugin is disabled")); return; } + try { reloadAfterDrain(); finishReload(operation, null); } + catch (Throwable failure) { finishReload(operation, failure); } + }); + dispatch.whenComplete((dispatchResult, dispatchFailure) -> { + if (dispatchFailure != null) finishReload(operation, dispatchFailure); + }); + } catch (Throwable failure) { + finishReload(operation, failure); + } + }); + return operation; + } + private synchronized void finishReload(java.util.concurrent.CompletableFuture operation, Throwable failure) { + if (operation.isDone()) return; + reloadPending.set(false); + if (queuePlanner != null) queuePlanner.resume(); + 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++; + if (usageEffects != null) usageEffects.invalidate(); + if (queuePlanner != null) queuePlanner.invalidate(); configFile.reloadData(); + localStatistics.enabled(getConfigFile().getData().getBoolean("LocalStatistics.Enabled", false)); perkSystemType = configFile.getPerkSystemType(); Lang.getInstance().reloadData(); mcperksServerData.reloadData(); getPerkHandler().reload(); + if (usageEffects != null) usageEffects.synchronizeOnlinePlayers(); + if (bankedPerks != null) bankedPerks.reload(); commandLoader.loadTabComplete(); updateHook(); reloadAdvancedCore(true); @@ -492,6 +679,7 @@ public void reload() { * 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/commands/CommandLoader.java b/MCPerks/src/main/java/com/bencodez/mcperks/commands/CommandLoader.java index 9d239e9..89c210d 100644 --- a/MCPerks/src/main/java/com/bencodez/mcperks/commands/CommandLoader.java +++ b/MCPerks/src/main/java/com/bencodez/mcperks/commands/CommandLoader.java @@ -32,6 +32,7 @@ import com.bencodez.mcperks.perk.Effect; import com.bencodez.mcperks.perk.Perk; import com.bencodez.mcperks.userapi.MCPerksUser; +import com.bencodez.mcperks.userapi.UsageCharges; import com.bencodez.simpleapi.array.ArrayUtils; import com.bencodez.simpleapi.command.TabCompleteHandle; import com.bencodez.simpleapi.command.TabCompleteHandler; @@ -51,7 +52,34 @@ public class CommandLoader { private HashMap commands; private Object activationsLock = new Object(); - + + 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); + }); + } + }); + } + + private void registerFeatureArgumentMatchers() { + TabCompleteHandler replacements = TabCompleteHandler.getInstance(); + for (String name : new String[] { "(effect)", "(seconds)", "(id)", "(position)" }) { + if (replacements.getTabCompleteReplaces().contains(name)) continue; + replacements.addTabCompleteOption(new TabCompleteHandle(name) { + @Override public void reload() { } + @Override public void updateReplacements() { setReplace(new ArrayList()); } + }); + } + } + /** * Instantiates a new command loader. * @@ -141,7 +169,153 @@ public void loadCommand(CommandPerkAliases commandPerkAliases, String perk) { } - public void loadCommands() { + private void plannerReply(CommandSender sender, java.util.concurrent.CompletableFuture operation, String success) { + operation.whenComplete((result, error) -> { + Runnable reply = () -> { + sender.sendMessage(MessageAPI.colorize(error == null && !Boolean.FALSE.equals(result) ? success : "&cPlanner update failed or entry unavailable.")); + plugin.getQueuePlanner().refreshLegacyView(); plugin.getQueuePlanner().check(); + }; + if (sender instanceof org.bukkit.entity.Player player) plugin.getBukkitScheduler().runTask(plugin, reply, player); + else plugin.getBukkitScheduler().runTask(plugin, reply); + }); + } + private void loadClearQueueCommand() { + plugin.getCommands().add( + new CommandHandler(plugin, new String[] { "ClearQueue" }, "MCPerks.ClearQueue", "Clear Perk Queue") { + + @Override + public void execute(CommandSender sender, String[] args) { + plannerReply(sender, plugin.getPerkHandler().clearQueueConfirmed(), "&cPerk Queue Cleared"); + } + }); + } + private void loadPlannerCommands() { + for (boolean other : new boolean[] { false, true }) { + plugin.getCommands().add(new CommandHandler(plugin, other ? new String[] { "Diagnose", "(player)" } : new String[] { "Diagnose" }, + other ? "MCPerks.Diagnose.Other" : "MCPerks.Diagnose", "Explain actual MCPerks decisions") { + @Override public void execute(CommandSender sender, String[] args) { + org.bukkit.entity.Player target = other ? org.bukkit.Bukkit.getPlayerExact(args[1]) : sender instanceof org.bukkit.entity.Player p ? p : null; + if (target == null) { sendMessage(sender, "&cAn online player is required. Offline banked time is paused."); return; } + plugin.getBukkitScheduler().runTask(plugin, () -> new PerkDiagnostics(plugin).explain(sender, target), target); + } + }); + } + plugin.getCommands().add(new CommandHandler(plugin, new String[] { "LocalStatistics" }, "MCPerks.LocalStatistics", "Read local aggregate counters") { + @Override public void execute(CommandSender sender, String[] args) { sendMessage(sender, "&b" + plugin.getLocalStatistics().summary()); } + }); + plugin.getCommands().add(new CommandHandler(plugin, new String[] { "GetBankedTime", "(perk)" }, "MCPerks.BankedTime", "Read personal banked time") { + @Override public void execute(CommandSender sender, String[] args) { + if (!(sender instanceof org.bukkit.entity.Player player)) { sendMessage(sender, "&cPlayer only."); return; } + if (!plugin.getFeatureState().available()) { sendMessage(sender, "&cBanked state is unavailable."); return; } + var bank = plugin.getFeatureState().banks().get(new com.bencodez.mcperks.data.FeatureState.BankKey(player.getUniqueId(), args[1])); + sendMessage(sender, bank == null ? "&bNo banked grant. Legacy timers retain their absolute expiration." : "&bBanked: " + bank.milliseconds()/1000 + "s; paused=" + bank.paused()); + } + }); + plugin.getCommands().add(new CommandHandler(plugin, new String[] { "SetBankedTime", "(player)", "(perk)", "(seconds)" }, "MCPerks.SetBankedTime", "Set supported personal banked time") { + @Override public void execute(CommandSender sender, String[] args) { + Perk perk = plugin.getPerkHandler().getPerk(args[2]); + if (perk == null || !perk.getInteractionOptions().banked() || !com.bencodez.mcperks.perk.BankedPerks.supported(perk)) { sendMessage(sender, "&cEnable BankedTime for a supported PLAYER perk first."); return; } + try { + long milliseconds = Math.multiplyExact(Long.parseLong(args[3]), 1000L); + java.util.UUID owner = java.util.UUID.fromString(plugin.getMcperksUserManager().getMCPerksUser(args[1]).getUUID()); + plannerReply(sender, plugin.getFeatureState().setBank(new com.bencodez.mcperks.data.FeatureState.BankKey(owner, perk.getPerk()), milliseconds) + .thenApply(bank -> { plugin.getBankedPerks().refresh(owner); return bank; }), "&aBanked time saved."); + } catch (IllegalArgumentException | ArithmeticException error) { sendMessage(sender, "&cUse a nonnegative time of at most one year."); } + } + }); + for (boolean paused : new boolean[] { true, false }) { + plugin.getCommands().add(new CommandHandler(plugin, new String[] { paused ? "PauseBankedTime" : "ResumeBankedTime", "(perk)" }, "MCPerks.BankedTime", "Pause/resume a personal banked grant") { + @Override public void execute(CommandSender sender, String[] args) { + if (!(sender instanceof org.bukkit.entity.Player player)) { sendMessage(sender, "&cPlayer only."); return; } + var key = new com.bencodez.mcperks.data.FeatureState.BankKey(player.getUniqueId(), args[1]); + plannerReply(sender, plugin.getBankedPerks().pause(key, paused).thenApply(bank -> { + if (bank == null) throw new IllegalArgumentException("No banked grant"); plugin.getBankedPerks().refresh(player.getUniqueId()); return bank; + }), "&aBanked pause state saved."); + } + }); + } + plugin.getCommands().add(new CommandHandler(plugin, new String[] { "Queue" }, "MCPerks.Queue", "Show the active and queued perks") { + @Override public void execute(CommandSender sender, String[] args) { + if (!plugin.getFeatureState().available()) sendMessage(sender, "&cQueue storage is unavailable. Entries below are the last confirmed start fences; admin review required."); + long now = System.currentTimeMillis(); + for (Perk active : plugin.getPerkHandler().getActivePerks()) { + var bank = plugin.getBankedPerks().attached(active) ? plugin.getFeatureState().banks().get(new com.bencodez.mcperks.data.FeatureState.BankKey(java.util.UUID.fromString(active.getActivater().getUUID()), active.getPerk())) : null; + String remaining = bank != null ? bank.milliseconds()/1000 + " banked seconds" : active.getKnownExpiration() > 0 ? Math.max(0, active.getKnownExpiration()-now)/1000 + " seconds" : "unknown"; + sendMessage(sender, "&bActive: " + active.getPerk() + " (remaining: " + remaining + ")"); + } + int position = 1; + for (var entry : plugin.getFeatureState().queue()) { + if (position > 32) { sendMessage(sender, "&bFurther entries omitted; total " + plugin.getFeatureState().queue().size()); break; } + sendMessage(sender, "&b" + position++ + ". " + entry.perk() + " requester=" + entry.owner() + " id=" + entry.id() + " state=" + entry.stage() + " wait=" + Math.max(0, now-entry.admitted())/1000 + "s start=" + (plugin.getQueuePlanner().startUnavailable(entry) == null ? "eligible checks pending" : plugin.getQueuePlanner().startUnavailable(entry)) + " ETA=unknown"); + } + sendMessage(sender, "&bQueue paused: " + plugin.getFeatureState().paused()); + plugin.getQueuePlanner().estimatedWaits().thenAccept(estimates -> { + Runnable report = () -> estimates.forEach((id, seconds) -> sendMessage(sender, "&bEstimated start for " + id + ": " + seconds + "s")); + if (sender instanceof org.bukkit.entity.Player player) com.bencodez.mcperks.scheduling.OwnerTasks.run(plugin, player, report, () -> {}); + else if (plugin.isEnabled()) plugin.getBukkitScheduler().runTask(plugin, report); + }); + } + }); + for (boolean staff : new boolean[] { false, true }) { + String command = staff ? "QueueRemove" : "CancelQueue"; + plugin.getCommands().add(new CommandHandler(plugin, new String[] { command, "(id)" }, staff ? "MCPerks.Queue.Manage" : "MCPerks.CancelQueue", "Cancel a waiting queue entry") { + @Override public void execute(CommandSender sender, String[] args) { + if (!staff && !(sender instanceof org.bukkit.entity.Player)) { sendMessage(sender, "&cPlayer only."); return; } + try { java.util.UUID id = java.util.UUID.fromString(args[1]); java.util.UUID owner = sender instanceof org.bukkit.entity.Player player ? player.getUniqueId() : new java.util.UUID(0,0); + plannerReply(sender, plugin.getFeatureState().cancel(id, owner, staff), "&aQueue entry removed; consumed activations are not refunded."); + } catch (IllegalArgumentException error) { sendMessage(sender, "&cInvalid queue ID."); } + } + }); + } + plugin.getCommands().add(new CommandHandler(plugin, new String[] { "QueuePause", "(boolean)" }, "MCPerks.Queue.Manage", "Pause/resume queued starts") { + @Override public void execute(CommandSender sender, String[] args) { + if (!args[1].equalsIgnoreCase("true") && !args[1].equalsIgnoreCase("false")) { sendMessage(sender, "&cUse true or false."); return; } + plannerReply(sender, plugin.getFeatureState().pauseQueue(Boolean.parseBoolean(args[1])), "&aQueue pause state saved."); + } + }); + plugin.getCommands().add(new CommandHandler(plugin, new String[] { "QueueMove", "(id)", "(position)" }, "MCPerks.Queue.Manage", "Reorder a waiting queue entry") { + @Override public void execute(CommandSender sender, String[] args) { + try { int position = Integer.parseInt(args[2]); if (position < 1) throw new IllegalArgumentException(); + plannerReply(sender, plugin.getFeatureState().move(java.util.UUID.fromString(args[1]), position), "&aQueue order saved."); + } catch (IllegalArgumentException error) { sendMessage(sender, "&cUse a queue ID and positive position."); } + } + }); + } + + private void loadUsageCommands() { + plugin.getCommands().add(new CommandHandler(plugin, new String[] { "GetUsageCharges", "(effect)" }, + "MCPerks.GetUsageCharges", "Show a personal effect balance") { + @Override public void execute(CommandSender sender, String[] args) { + if (!(sender instanceof org.bukkit.entity.Player player)) { sendMessage(sender, "&cPlayer only."); return; } + if (!plugin.getUsageCharges().available()) { sendMessage(sender, "&cCharge state is unavailable."); return; } + try { UsageCharges.Kind kind = UsageCharges.Kind.valueOf(args[1]); + sendMessage(sender, "&b" + kind + " charges: " + plugin.getUsageCharges().get(player.getUniqueId(), kind)); + } catch (IllegalArgumentException e) { sendMessage(sender, "&cUnknown charge effect."); } + } + }); + for (boolean add : new boolean[] { false, true }) { + String command = add ? "AddUsageCharges" : "SetUsageCharges"; + plugin.getCommands().add(new CommandHandler(plugin, new String[] { command, "(player)", "(effect)", "(number)" }, + "MCPerks." + command, "Manage personal effect charges") { + @Override public void execute(CommandSender sender, String[] args) { + try { + UsageCharges.Kind kind = UsageCharges.Kind.valueOf(args[2]); int amount = Integer.parseInt(args[3]); + java.util.UUID owner = java.util.UUID.fromString(plugin.getMcperksUserManager().getMCPerksUser(args[1]).getUUID()); + java.util.concurrent.CompletableFuture result = add ? plugin.getUsageCharges().add(owner, kind, amount) + : plugin.getUsageCharges().set(owner, kind, amount); + result.whenComplete((balance, error) -> { + Runnable reply = () -> sendMessage(sender, error == null ? "&b" + kind + " charges: " + balance : "&cCharge update failed."); + if (sender instanceof org.bukkit.entity.Player player) plugin.getBukkitScheduler().runTask(plugin, reply, player); + else plugin.getBukkitScheduler().runTask(plugin, reply); + }); + } catch (IllegalArgumentException e) { sendMessage(sender, "&cUse a supported effect and nonnegative integer amount."); } + } + }); + } + } + + public void loadCommands() { + registerFeatureArgumentMatchers(); plugin.setCommands(new ArrayList()); commands = new HashMap(); @@ -157,16 +331,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") { @@ -396,15 +561,7 @@ public void execute(CommandSender sender, String[] args) { } }); - plugin.getCommands().add( - new CommandHandler(plugin, new String[] { "ClearQueue" }, "MCPerks.ClearQueue", "Clear Perk Queue") { - - @Override - public void execute(CommandSender sender, String[] args) { - plugin.getPerkHandler().clearQueue(); - sender.sendMessage(MessageAPI.colorize("&cPerk Queue Cleared")); - } - }); + loadClearQueueCommand(); plugin.getCommands().add(new CommandHandler(plugin, new String[] { "ActivatePerk", "(player)", "(perk)" }, "MCPerks.ActivatePerk", "Forcefly Activate Perk") { @@ -726,7 +883,10 @@ public void execute(CommandSender sender, String[] args) { sendMessage(sender, "&cSet activations to " + user.getActivations()); } } - }); + }); + + loadUsageCommands(); + loadPlannerCommands(); // load perk commands for (Perk perk : plugin.getPerkHandler().getLoadedPerks().values()) { diff --git a/MCPerks/src/main/java/com/bencodez/mcperks/commands/PerkDiagnostics.java b/MCPerks/src/main/java/com/bencodez/mcperks/commands/PerkDiagnostics.java new file mode 100644 index 0000000..5eb4d3e --- /dev/null +++ b/MCPerks/src/main/java/com/bencodez/mcperks/commands/PerkDiagnostics.java @@ -0,0 +1,100 @@ +package com.bencodez.mcperks.commands; +import java.util.*; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.command.CommandSender; +import com.bencodez.mcperks.MCPerksMain; +import com.bencodez.mcperks.perk.*; +import com.bencodez.mcperks.effects.AutoPlant; +/** Uses the same decisions and static policy helpers as the production paths. */ +public final class PerkDiagnostics { + private final MCPerksMain plugin; + public PerkDiagnostics(MCPerksMain plugin) { this.plugin = plugin; } + private record Stored(Perk perk, long cooldown, int used, int personalCredits, boolean retained, Perk activation, boolean alreadyActive) {} + public void explain(CommandSender sender, Player player) { + UUID owner = player.getUniqueId(); + // Reload preserves active clones and their immutable gameplay options. Diagnose those + // grants rather than a replacement definition that no longer governs the action. + var retained = plugin.getPerkHandler().getActivePerks().stream() + .filter(perk -> perk.getEffectedPlayers().contains(owner.toString())).limit(32).toList(); + Set retainedNames = new HashSet<>(); + retained.forEach(perk -> retainedNames.add(perk.getPerk())); + var perks = java.util.stream.Stream.concat(retained.stream(), + plugin.getPerkHandler().getLoadedPerks().values().stream() + .filter(perk -> !retainedNames.contains(perk.getPerk()))) + .distinct().limit(32).toList(); + Map definitions = new HashMap<>(); + for (Perk perk : perks) { + Perk configured = plugin.getPerkHandler().getLoadedPerks().get(perk.getPerk()); + if (configured != null) definitions.put(perk.getPerk(), configured); + } + plugin.getFeatureState().storage(() -> { + var user = plugin.getMcperksUserManager().getMCPerksUser(owner); + List stored = new ArrayList<>(); + for (Perk perk : perks) { + Perk activation = definitions.getOrDefault(perk.getPerk(), perk); + stored.add(new Stored(perk, Math.max(user.getPerkCoolDown(perk), plugin.getMcperksServerData().getData().getLong(perk.getPerk()+".CoolDown", 0)), user.getPerkTimesUsed(activation), user.getActivations(perk.getPerk()), retained.contains(perk), activation, activation.isPerkActive(user))); + } + return new Object[] { stored, user.getActivations(), user.isIgnorePerkEffects() }; + }).whenComplete((result, error) -> { + if (!plugin.isEnabled()) return; + plugin.getBukkitScheduler().runTask(plugin, () -> { + if (!player.isOnline()) { deliver(sender, List.of("Player is offline; banked time is paused.")); return; } + if (error != null) { deliver(sender, List.of("Stored perk state unavailable; no diagnosis inferred.")); return; } + @SuppressWarnings("unchecked") List stored = (List) result[0]; + List lines = new ArrayList<>(); lines.add("Perk decisions for " + player.getName() + "; world=" + player.getWorld().getName() + "; ignored=" + result[2]); + int permissionLimit = -1; + for (int i = plugin.getPerkHandler().getLoadedPerks().size(); i > 0; i--) if (player.hasPermission("MCPerks.Limit." + i)) { permissionLimit = i; break; } + boolean bypass = player.hasPermission("MCPerks.Perks.BypassCoolDown"); + for (Stored data : stored) { + Perk perk = data.perk(); boolean permission = player.hasPermission("MCPerks." + perk.getPerk()) || player.hasPermission("MCPerks.AllPerks"); + int personal = data.personalCredits() - plugin.getFeatureState().reserved(owner, "PerkActivations_" + perk.getPerk()); + int global = (Integer) result[1] - plugin.getFeatureState().reserved(owner, "Activations"); + // Activation can target a new definition while gameplay still uses its + // retained clone. Match runPerk's actual duplicate predicates for that request. + var bank = plugin.getFeatureState().banks().get(new com.bencodez.mcperks.data.FeatureState.BankKey(owner, perk.getPerk())); + boolean duplicate = !plugin.getConfigFile().getAllowDuplicatePerksActive() + && (data.alreadyActive() || bank != null && bank.milliseconds() > 0 + && data.activation().getInteractionOptions().banked()); + String reason = !permission ? "missing activation permission" : !data.activation().isNotInDisabledWorld(player.getWorld().getName()) ? "disabled world" + : duplicate ? "already active; duplicate activations disabled" + : !bypass && data.cooldown() > System.currentTimeMillis() ? "cooldown " + (data.cooldown()-System.currentTimeMillis())/1000 + "s" + : data.activation().isActivationLimitReached(data.used()) ? "activation limit" + : plugin.getConfigFile().getLimitPermission() && permissionLimit >= 0 + && plugin.getPerkHandler().getActivePerks().stream().filter(p -> p.getEffectedPlayers().contains(owner.toString())).count() >= permissionLimit ? "permission perk limit" + : plugin.getConfigFile().getLimitActivations() && personal <= 0 && global <= 0 ? "no unreserved activation" : "activation checks passed"; + lines.add(perk.getPerk() + (data.retained() ? " (retained active grant)" : " (configured definition)") + ": " + reason); + for (Effect effect : perk.getEffects()) { + if (lines.size() >= 64) break; + var decision = plugin.getPerkHandler().effectDecision(effect, owner.toString(), player.getWorld().getName()); + String detail = Boolean.TRUE.equals(result[2]) ? "; effects ignored by player" : ""; + if (effect == Effect.VeinMining) { + var options = perk.getInteractionOptions(); String tool = player.getInventory().getItemInMainHand().getType().name(); + if (options.veinTools().isEmpty() ? !tool.endsWith("_PICKAXE") : options.veinTools().stream().noneMatch(t -> t.equalsIgnoreCase(tool))) detail = "; tool not allowed"; + } else if (effect == Effect.RightClickHarvest && !AutoPlant.allows(perk, player.getInventory().getItemInMainHand(), org.bukkit.Material.WHEAT)) detail = "; tool/enchantment/crop policy restricts harvest"; + if (effect == Effect.AutoBlockRefill || effect == Effect.VeinMining || effect == Effect.RightClickHarvest) { + var kind = com.bencodez.mcperks.userapi.UsageCharges.Kind.valueOf(effect.name()); + int charges = plugin.getUsageCharges().get(owner, kind); + detail += perk.getInteractionOptions().charged() ? perk.getPerkType() != PerkSystemType.PLAYER ? "; charge mode rejected for shared audience" : "; charges=" + (plugin.getUsageCharges().available() ? charges : "unavailable") : "; unlimited"; + detail += "; successful units=" + plugin.getUsageCharges().successful(owner, kind); + } + lines.add(" " + effect + ": " + decision + detail); + } + if (bank != null) lines.add(" banked=" + bank.milliseconds()/1000 + "s; manual pause=" + bank.paused()); + if (lines.size() >= 64) { lines.add("Further perk details omitted."); break; } + } + var recent = plugin.getUsageEffects().recentSkip(owner); + if (recent != null) lines.add("Latest recorded MCPerks skip: " + recent.effect() + " " + recent.reason() + "; " + Math.max(0, System.currentTimeMillis()-recent.time())/1000 + "s ago"); + int index = 1; for (var entry : plugin.getFeatureState().queue()) { + if (entry.owner().equals(owner)) lines.add("Queue position=" + index + "; state=" + entry.stage() + "; start=" + plugin.getQueuePlanner().startUnavailable(entry) + "; ETA=unknown"); index++; + } + deliver(sender, lines); + }, player); + }); + } + private void deliver(CommandSender sender, List lines) { + Runnable send = () -> { for (String line : lines) sender.sendMessage(line); }; + if (sender instanceof Player player) plugin.getBukkitScheduler().runTask(plugin, send, player); + else plugin.getBukkitScheduler().runTask(plugin, send); + } +} 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..51181b0 --- /dev/null +++ b/MCPerks/src/main/java/com/bencodez/mcperks/data/DurableFeatureFiles.java @@ -0,0 +1,16 @@ +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; } + 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); + 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. */ } + } +} 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/FeatureState.java b/MCPerks/src/main/java/com/bencodez/mcperks/data/FeatureState.java new file mode 100644 index 0000000..074f3a5 --- /dev/null +++ b/MCPerks/src/main/java/com/bencodez/mcperks/data/FeatureState.java @@ -0,0 +1,251 @@ +package com.bencodez.mcperks.data; +import java.io.*; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.function.Supplier; +/** Bounded, ordered persistence for queue identities and opt-in personal banked time. */ +public final class FeatureState implements AutoCloseable { + private static final long MAX_FILE_BYTES = 3_000_000; + public enum Stage { WAITING, STARTING, UNCERTAIN } + public record QueueEntry(UUID id, UUID owner, String perk, long admitted, Stage stage, String credit) { + public QueueEntry withStage(Stage next) { return new QueueEntry(id, owner, perk, admitted, next, credit); } + } + public record BankKey(UUID owner, String perk) {} + public record Bank(long milliseconds, boolean paused) {} + private final Path file; + private final DurableFeatureFiles.Publisher publisher; + private final com.bencodez.mcperks.data.FeatureFileOwnership ownership; + private final ThreadPoolExecutor worker; + private volatile Thread ownerThread; + private volatile List queue = List.of(); + private volatile Map banks = Map.of(); + private final Object bankBoundaryLock = new Object(); + // Banks are never removed and are capped at 10,000. Retain one epoch per replaced + // bank, rather than retaining every disconnected clock during a blocked publish. + private final ConcurrentHashMap bankReplacementVersions = new ConcurrentHashMap<>(); + private volatile boolean paused, failed, ownershipFailed; + public record BankGrant(long elapsed, Runnable commit, Runnable rollback) {} + private volatile java.util.function.BiFunction bankGrantBoundary = (key, oldBalance) -> new BankGrant(0, () -> {}, () -> {}); + private volatile java.util.function.Function bankReplacementBoundary = key -> new BankGrant(0, () -> {}, () -> {}); + private final CompletableFuture ready; + public FeatureState(Path file) { this(file, DurableFeatureFiles::publish); } + FeatureState(Path file, DurableFeatureFiles.Publisher publisher) { + this.file = file; this.publisher = publisher; + ownership = new com.bencodez.mcperks.data.FeatureFileOwnership(file); + worker = new ThreadPoolExecutor(1, 1, 0, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(256), r -> { + Thread t = new Thread(r, "MCPerks-feature-store"); t.setDaemon(true); ownerThread = t; return t; + }, new ThreadPoolExecutor.AbortPolicy()) { + @Override protected void terminated() { ownership.drained(); } + }; + ready = submit(() -> { + try { ownership.awaitPredecessor(); } + catch (RuntimeException | Error failure) { ownershipFailed = true; failed = true; throw failure; } + read(); return null; + }); + } + public CompletableFuture ready() { return ready; } + public boolean available() { return ready.isDone() && !ready.isCompletedExceptionally() && !failed; } + public List queue() { return queue; } + public Map banks() { return banks; } + public long bankReplacementVersion(BankKey key) { return bankReplacementVersions.getOrDefault(key, 0L); } + /** Memory-only clock creation/reservation; never submit storage or perform I/O in this turn. */ + public T bankCheckpoint(Supplier checkpoint) { + synchronized (bankBoundaryLock) { return checkpoint.get(); } + } + /** Register the clock boundary used when a grant is published on this ordered store. */ + public void onBankGrant(java.util.function.BiFunction boundary) { + bankGrantBoundary = java.util.Objects.requireNonNull(boundary); + } + /** Replacements establish a new clock/debit ownership boundary after publication. */ + public void onBankReplacement(java.util.function.Function boundary) { + bankReplacementBoundary = java.util.Objects.requireNonNull(boundary); + } + public boolean paused() { return paused; } + public int reserved(UUID owner, String credit) { return (int) queue.stream().filter(e -> e.owner.equals(owner) && e.credit.equals(credit)).count(); } + public CompletableFuture enqueue(UUID owner, String perk, String credit, int available) { + return admit(owner, perk, credit, available, Stage.WAITING); + } + /** Direct starts never enter WAITING, so normal queue checks cannot schedule them. */ + public CompletableFuture startDirect(UUID owner, String perk, String credit, int available) { + return admit(owner, perk, credit, available, Stage.STARTING); + } + private CompletableFuture admit(UUID owner, String perk, String credit, int available, Stage stage) { + return submit(() -> { + if (queue.size() >= 256) throw new IllegalStateException("Queue capacity reached"); + if (queue.stream().anyMatch(e -> e.owner.equals(owner))) throw new IllegalStateException("Player already queued"); + if (!credit.isEmpty() && available <= reserved(owner, credit)) throw new IllegalStateException("Activation already reserved"); + QueueEntry entry = new QueueEntry(UUID.randomUUID(), owner, boundedName(perk), System.currentTimeMillis(), stage, boundedCredit(credit)); + var next = new ArrayList<>(queue); next.add(entry); publish(next, banks, paused); return entry; + }); + } + /** Persist the irreversible start fence before consuming credit or applying effects. */ + public CompletableFuture claim(UUID id) { + return submit(() -> { + if (paused || queue.isEmpty() || !queue.getFirst().id.equals(id) || queue.getFirst().stage != Stage.WAITING) return null; + var next = new ArrayList<>(queue); QueueEntry entry = next.getFirst().withStage(Stage.STARTING); next.set(0, entry); + publish(next, banks, paused); return entry; + }); + } + public CompletableFuture cancel(UUID id, UUID owner, boolean staff) { + return submit(() -> { + var next = new ArrayList<>(queue); + boolean removed = next.removeIf(e -> e.id.equals(id) && (staff || e.owner.equals(owner)) && (e.stage == Stage.WAITING || staff && e.stage == Stage.UNCERTAIN)); + if (removed) publish(next, banks, paused); return removed; + }); + } + public CompletableFuture complete(UUID id) { + return submit(() -> { var next = new ArrayList<>(queue); next.removeIf(e -> e.id.equals(id) && e.stage == Stage.STARTING); publish(next, banks, paused); return null; }); + } + public CompletableFuture uncertain(UUID id) { + return submit(() -> { var next = new ArrayList<>(queue); next.replaceAll(e -> e.id.equals(id) ? e.withStage(Stage.UNCERTAIN) : e); publish(next, banks, paused); return null; }); + } + public CompletableFuture releaseClaim(UUID id) { + return submit(() -> { var next = new ArrayList<>(queue); next.replaceAll(e -> e.id.equals(id) && e.stage == Stage.STARTING ? e.withStage(Stage.WAITING) : e); publish(next, banks, paused); return null; }); + } + public CompletableFuture move(UUID id, int position) { + return submit(() -> { + var next = new ArrayList<>(queue); QueueEntry selected = next.stream().filter(e -> e.id.equals(id) && e.stage == Stage.WAITING).findFirst().orElse(null); + if (selected == null || queue.stream().anyMatch(e -> e.stage == Stage.STARTING)) return false; + next.remove(selected); next.add(Math.max(0, Math.min(position - 1, next.size())), selected); publish(next, banks, paused); return true; + }); + } + public CompletableFuture pauseQueue(boolean value) { return submit(() -> { publish(queue, banks, value); return null; }); } + public CompletableFuture clearWaiting() { return submit(() -> { publish(queue.stream().filter(e -> e.stage != Stage.WAITING).toList(), banks, paused); return null; }); } + public CompletableFuture setBank(BankKey key, long milliseconds) { + if (milliseconds < 0 || milliseconds > 31_536_000_000L) return CompletableFuture.failedFuture(new IllegalArgumentException("Banked time must be between zero and one year")); + return submit(() -> { + boundedName(key.perk); if (!banks.containsKey(key) && (banks.size() >= 10000 || banks.keySet().stream().filter(k -> k.owner.equals(key.owner)).count() >= 64)) throw new IllegalStateException("Bank capacity reached"); + var next = new HashMap<>(banks); Bank bank = new Bank(milliseconds, false); next.put(key, bank); + BankGrant boundary = bankReplacementBoundary.apply(key); + try { + publish(queue, next, paused, () -> { + bankReplacementVersions.merge(key, 1L, Long::sum); + boundary.commit().run(); + }); + return bank; + } + catch (RuntimeException | Error failure) { boundary.rollback().run(); throw failure; } + }); + } + public CompletableFuture addBank(BankKey key, long milliseconds) { + return storage(() -> { Bank bank = addBankOnWorker(key, milliseconds, () -> true); + if (bank == null) throw new IllegalStateException("Bank capacity reached"); return bank; }); + } + /** Called only inside storage(): capacity and debit share this store's ordered ownership. */ + public Bank addBankOnWorker(BankKey key, long milliseconds, java.util.function.BooleanSupplier debit) { + if (Thread.currentThread() != ownerThread) throw new IllegalStateException("Bank grant requires state worker ownership"); + if (milliseconds < 1 || milliseconds > 31_536_000_000L) throw new IllegalArgumentException("Invalid bank grant"); + boundedName(key.perk); Bank old = banks.get(key); + if (old == null && (banks.size() >= 10000 || banks.keySet().stream().filter(k -> k.owner.equals(key.owner)).count() >= 64)) return null; + long remaining = old == null ? 0 : old.milliseconds; + if (milliseconds > 31_536_000_000L - remaining) return null; + // Deterministic byte capacity must reject before an irreversible activation debit. + var capacityCandidate = new HashMap<>(banks); + capacityCandidate.put(key, new Bank(remaining + milliseconds, old != null && old.paused)); + if (!fitsSerializedCapacity(queue, capacityCandidate, paused)) return null; + if (!debit.getAsBoolean()) return null; + BankGrant boundary = bankGrantBoundary.apply(key, remaining); + long elapsedBeforeGrant = Math.min(remaining, boundary.elapsed()); + Bank bank = new Bank(remaining + milliseconds - elapsedBeforeGrant, old != null && old.paused); + var next = new HashMap<>(banks); next.put(key, bank); + try { publish(queue, next, paused, boundary.commit()); return bank; } + catch (RuntimeException | Error failure) { boundary.rollback().run(); throw failure; } + } + public CompletableFuture pauseBank(BankKey key, boolean value) { + return submit(() -> { Bank old = banks.get(key); if (old == null) return null; var next = new HashMap<>(banks); + Bank bank = new Bank(old.milliseconds, value); next.put(key, bank); publish(queue, next, paused); return bank; }); + } + public CompletableFuture elapsed(BankKey key, long milliseconds) { + return elapsed(key, milliseconds, () -> true); + } + /** A delayed controller debit must still own the clock's published bank replacement. */ + public CompletableFuture elapsed(BankKey key, long milliseconds, java.util.function.BooleanSupplier ownsReplacement) { + if (milliseconds < 0 || milliseconds > 31_536_000_000L) return CompletableFuture.failedFuture(new IllegalArgumentException("Invalid bank checkpoint")); + return submit(() -> { Bank old = banks.get(key); if (!ownsReplacement.getAsBoolean() || old == null || old.paused) return old; var next = new HashMap<>(banks); + Bank bank = new Bank(Math.max(0, old.milliseconds - milliseconds), false); next.put(key, bank); publish(queue, next, paused); return bank; }); + } + /** Database confirmation runs on this ordered worker, never on a player/server owner. */ + public CompletableFuture storage(Supplier work) { return submit(work); } + /** Independent legacy YAML uses the same ordered capacity, even if optional feature data is unreadable. */ + public CompletableFuture runtimeStorage(Supplier work) { return submit(work, false); } + private CompletableFuture submit(Supplier operation) { return submit(operation, true); } + private CompletableFuture submit(Supplier operation, boolean requireFeatureData) { + var future = new CompletableFuture(); + try { worker.execute(() -> { + if (ownershipFailed || requireFeatureData && failed) { future.completeExceptionally(new IllegalStateException("Feature store unavailable until restart")); return; } + try { future.complete(operation.get()); } catch (Throwable error) { future.completeExceptionally(error); } + }); } catch (RejectedExecutionException error) { future.completeExceptionally(error); } + return future; + } + private void publish(List entries, Map bankValues, boolean queuePaused) { + publish(entries, bankValues, queuePaused, () -> {}); + } + private void publish(List entries, Map bankValues, boolean queuePaused, Runnable bankCommit) { + Path staged = null; + try { + Files.createDirectories(file.toAbsolutePath().getParent()); if (Files.isSymbolicLink(file)) throw new IOException("Feature file is a symlink"); + staged = Files.createTempFile(file.toAbsolutePath().getParent(), ".feature-", ".tmp"); + try (DataOutputStream out = new DataOutputStream(new BufferedOutputStream(Files.newOutputStream(staged)))) { + writeState(out, entries, bankValues, queuePaused); + } + // writeUTF uses modified UTF-8, so count the actual complete serialized bytes. + // Capacity rejection leaves the previous publication usable, unlike uncertain I/O failure. + if (Files.size(staged) > MAX_FILE_BYTES) throw new IllegalStateException("Feature state byte capacity reached"); + publisher.publish(staged, file); + var confirmedQueue = List.copyOf(entries); var confirmedBanks = Map.copyOf(bankValues); + // No filesystem work occurs under this monitor. A late clock cannot be + // created/reserved between its committed boundary and balance exposure. + synchronized (bankBoundaryLock) { + bankCommit.run(); + queue = confirmedQueue; banks = confirmedBanks; paused = queuePaused; + } + } catch (IOException error) { failed = true; throw new CompletionException(error); } + finally { if (staged != null) try { Files.deleteIfExists(staged); } catch (IOException ignored) {} } + } + private static void writeState(DataOutputStream out, List entries, + Map bankValues, boolean queuePaused) throws IOException { + out.writeInt(0x4d435032); out.writeBoolean(queuePaused); out.writeInt(entries.size()); + for (QueueEntry entry : entries) { uuid(out, entry.id); uuid(out, entry.owner); out.writeUTF(entry.perk); out.writeLong(entry.admitted); out.writeInt(entry.stage.ordinal()); out.writeUTF(entry.credit); } + out.writeInt(bankValues.size()); + for (var entry : bankValues.entrySet()) { uuid(out, entry.getKey().owner); out.writeUTF(entry.getKey().perk); out.writeLong(entry.getValue().milliseconds); out.writeBoolean(entry.getValue().paused); } + } + private static boolean fitsSerializedCapacity(List entries, Map bankValues, + boolean queuePaused) { + // The real serializer counts modified UTF-8 exactly without allocating a full snapshot. + var out = new DataOutputStream(java.io.OutputStream.nullOutputStream()); + try { writeState(out, entries, bankValues, queuePaused); return out.size() <= MAX_FILE_BYTES; } + catch (IOException invalid) { throw new CompletionException(invalid); } + } + private void read() { + try { + if (Files.isSymbolicLink(file)) throw new IOException("Feature file is a symlink"); + if (!Files.exists(file)) return; if (Files.size(file) > MAX_FILE_BYTES) throw new IOException("Oversized feature state"); + var entries = new ArrayList(); var bankValues = new HashMap(); Set ids = new HashSet<>(), owners = new HashSet<>(); + try (DataInputStream in = new DataInputStream(new BufferedInputStream(Files.newInputStream(file)))) { + if (in.readInt() != 0x4d435032) throw new IOException("Invalid feature state"); boolean queuePaused = in.readBoolean(); + int count = count(in.readInt(), 256); + for (int i = 0; i < count; i++) { + UUID id = uuid(in), owner = uuid(in); String perk = boundedName(in.readUTF()); long admitted = in.readLong(); int stage = count(in.readInt(), Stage.values().length-1); String credit = boundedCredit(in.readUTF()); + if (!ids.add(id) || !owners.add(owner) || admitted < 0) throw new IOException("Duplicate/invalid queue identity"); + entries.add(new QueueEntry(id, owner, perk, admitted, stage == Stage.STARTING.ordinal() ? Stage.UNCERTAIN : Stage.values()[stage], credit)); + } + int bankCount = count(in.readInt(), 10000); Map perOwner = new HashMap<>(); + for (int i = 0; i < bankCount; i++) { + BankKey key = new BankKey(uuid(in), boundedName(in.readUTF())); + if (perOwner.merge(key.owner(), 1, Integer::sum) > 64) throw new IOException("Too many banks for one player"); + long value = in.readLong(); boolean bankPaused = in.readBoolean(); + if (value < 0 || value > 31_536_000_000L || bankValues.put(key, new Bank(value, bankPaused)) != null) throw new IOException("Invalid bank value"); + } + if (in.read() != -1) throw new IOException("Trailing feature state"); queue = List.copyOf(entries); banks = Map.copyOf(bankValues); paused = queuePaused; + } + } catch (IOException | IllegalArgumentException error) { failed = true; throw new CompletionException(error); } + } + private static String boundedName(String name) { if (name == null || name.isEmpty() || name.length() > 128 || name.chars().anyMatch(c -> c < 32 || c == 127)) throw new IllegalArgumentException("Invalid perk name"); return name; } + private static String boundedCredit(String credit) { if (credit == null || credit.length() > 144 || !(credit.isEmpty() || credit.equals("Activations") || credit.startsWith("PerkActivations_") && validCreditName(credit.substring(16)))) throw new IllegalArgumentException("Invalid credit key"); return credit; } + private static boolean validCreditName(String name) { try { boundedName(name); return true; } catch (IllegalArgumentException invalid) { return false; } } + private static int count(int value, int maximum) throws IOException { if (value < 0 || value > maximum) throw new IOException("Invalid state count"); return value; } + private static void uuid(DataOutputStream out, UUID id) throws IOException { out.writeLong(id.getMostSignificantBits()); out.writeLong(id.getLeastSignificantBits()); } + private static UUID uuid(DataInputStream in) throws IOException { return new UUID(in.readLong(), in.readLong()); } + @Override public void close() { worker.shutdown(); } +} diff --git a/MCPerks/src/main/java/com/bencodez/mcperks/data/LocalStatistics.java b/MCPerks/src/main/java/com/bencodez/mcperks/data/LocalStatistics.java new file mode 100644 index 0000000..c69125b --- /dev/null +++ b/MCPerks/src/main/java/com/bencodez/mcperks/data/LocalStatistics.java @@ -0,0 +1,21 @@ +package com.bencodez.mcperks.data; +import java.util.*; +import java.util.concurrent.atomic.LongAdder; +/** Local aggregate counts only: no external reporting, players, inventories, commands or locations. */ +public final class LocalStatistics { + private volatile boolean enabled; + public void enabled(boolean value) { enabled = value; } + private final LongAdder activations = new LongAdder(), queueStarts = new LongAdder(), queueWaitMillis = new LongAdder(); + private final EnumMap skips = new EnumMap<>(Skip.class); + public enum Skip { INACTIVE, AUDIENCE, WORLD, PRECISION, BANKED, SHARED_CHARGES, DEPLETED, TOOL, MATERIAL, IMMATURE, SEED, INVENTORY_CHANGED, NATIVE_BREAK_DENIED, INTERACTION_DENIED, BUSY, STORAGE, UNCERTAIN } + public LocalStatistics() { for (Skip skip : Skip.values()) skips.put(skip, new LongAdder()); } + public void activation() { if (enabled) activations.increment(); } + public void queueStart(long waitMillis) { if (!enabled) return; queueStarts.increment(); queueWaitMillis.add(Math.max(0, Math.min(waitMillis, 31_536_000_000L))); } + public void skip(Skip reason) { if (enabled) skips.get(reason).increment(); } + public String summary() { + if (!enabled) return "LocalStatistics.Enabled is false; aggregate collection disabled."; + StringBuilder text = new StringBuilder("activations=").append(activations.sum()).append(", queued starts=").append(queueStarts.sum()).append(", queue wait seconds=").append(queueWaitMillis.sum()/1000); + for (Skip reason : Skip.values()) { long count = skips.get(reason).sum(); if (count > 0) text.append(", ").append(reason).append('=').append(count); } + return text.toString(); + } +} 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..ff11e35 --- /dev/null +++ b/MCPerks/src/main/java/com/bencodez/mcperks/data/OrderedServerDataFile.java @@ -0,0 +1,79 @@ +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 FeatureState. + // Keep that mutation in memory until the original disk source is strictly reconciled. + if (plugin.getFeatureState() == 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) { + 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.reloadFrom(delegate); } + @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/ServerData.java b/MCPerks/src/main/java/com/bencodez/mcperks/data/ServerData.java index 7eed851..c5037bf 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,866 @@ -/* - * - */ 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 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(); - } - - /** - * Gets the data. - * - * @return the data - */ - public FileConfiguration getData() { - return plugin.getServerDataFile().getData(); - } - - public long getPerkExperation(Perk perk) { - return getData().getLong("Experation." + perk.getPerk()); - } + public CompletableFuture ready() { return initialization; } + 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 volatile boolean published; + private CalendarUpdate(Map changes) { this.changes = Collections.unmodifiableMap(changes); } + 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 update = new CalendarUpdate(changes); + var copy = parse(getData().saveToString()); update.apply(copy, null); + byte[] snapshot = snapshotBytes(copy); + pendingCalendar = update; + return publishSnapshot(snapshot, List.of(), null, null, 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(FeatureState 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; + } + 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(FeatureState state, com.bencodez.advancedcore.api.permissions.PermissionHandler handler) { + if (!plugin.isEnabled() || plugin.getFeatureState() != state || plugin.getPermissionHandler() != handler + || handler.getTimer().isShutdown()) throw new IllegalStateException("Native permission lifecycle retired during reconciliation"); + } + private CompletableFuture reconcileNativePermissions(FeatureState 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, FeatureState 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); } + /** The caller drains the feature worker before reloading the legacy file. */ + public synchronized void reloadData() { plugin.getServerDataFile().reloadData(); } + synchronized void reloadFrom(com.bencodez.advancedcore.data.ServerData file) { + // A new calendar/perk save may arrive after the reload drain but before its owner callback. + // Keep its authoritative live YAML rather than replacing it with the earlier disk version. + if (pendingSnapshots.get() == 0) file.reloadData(); + } - /** - * Reload data. - */ - public void reloadData() { - plugin.getServerDataFile().reloadData(); - } + 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.getFeatureState() == null) { plugin.getServerDataFile().saveData(); return; } + confirmedFinalMutation(data -> {}).exceptionally(error -> { + plugin.getLogger().warning("ServerData persistence failed: " + error.getMessage()); return null; + }); + } - /** - * Save data. - */ - public void saveData() { - plugin.getServerDataFile().saveData(); - } + /** Stages private mutations; only successful ordered publication changes the live YAML. */ + public synchronized CompletableFuture confirmedFinalMutation( + java.util.function.Consumer mutation) { + if (!canonicalLoaded || plugin.getFeatureState() == 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); } + } - public void setPerkExperation(Perk perk, long time) { - getData().set("Experation." + perk.getPerk(), time); - saveData(); - } + /** 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; + } - 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())); - } - } + 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); + } + 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(); } + } + + // 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); + } + } + 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))); + } + /** 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)); + } + private synchronized CompletableFuture prepareActivation(Map fields, + java.util.function.Supplier> callerStorage, org.bukkit.entity.Entity owner) { + if (!isReady()) return CompletableFuture.failedFuture(new IllegalStateException("ServerData initialization 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 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 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.getFeatureState().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)); + } + 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.getFeatureState().runtimeStorage(() -> { + Path staged = null; + try { + 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 && overlay.published) { + var copy = parse(new String(snapshot, StandardCharsets.UTF_8)); overlay.apply(copy, edits); + 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); publisher.publish(staged, file); + 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) { pendingUpdates.remove(update); } + if (commit != null) synchronized (ServerData.this) { if (pendingCalendar == commit) pendingCalendar = null; } + }); + } + 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/effects/AutoPlant.java b/MCPerks/src/main/java/com/bencodez/mcperks/effects/AutoPlant.java index a9a35e7..57b7119 100644 --- a/MCPerks/src/main/java/com/bencodez/mcperks/effects/AutoPlant.java +++ b/MCPerks/src/main/java/com/bencodez/mcperks/effects/AutoPlant.java @@ -29,28 +29,8 @@ public AutoPlant(MCPerksMain plugin, Player player, Block block, Perk perk, Bloc if (!isFarmable(block)) { return; } - ItemStack itemInHand = player.getInventory().getItemInMainHand(); - if (!perk.getWhitelistedTools().isEmpty()) { - if (!perk.getWhitelistedTools().contains(itemInHand.getType())) { - return; - } - } - if (perk.getRequiredEnchants().size() > 0) { - for (Entry enchants : perk.getRequiredEnchants().entrySet()) { - if (itemInHand.getEnchantmentLevel( - Enchantment.getByKey(NamespacedKey.minecraft(enchants.getKey()))) != enchants.getValue()) { - return; - } - } - } - if (!perk.getWhitelistedBlocks().isEmpty()) { - if (!perk.getWhitelistedBlocks().contains(block.getType())) { - return; - } - } - if (perk.getBlacklistedBlocks().contains(block.getType())) { - return; - } + ItemStack itemInHand = player.getInventory().getItemInMainHand(); + if (!allows(perk, itemInHand, block.getType())) return; Material seedBlockType = getSeedBlockType(block.getType()); Material seedItemType = getSeedItemType(block.getType()); @@ -74,6 +54,27 @@ public AutoPlant(MCPerksMain plugin, Player player, Block block, Perk perk, Bloc PlayerManager.getInstance().damageItemInHand(player, 1); } } + + public static Material seedFor(Material crop) { + return switch (crop) { + case WHEAT -> Material.WHEAT_SEEDS; + case POTATOES -> Material.POTATO; + case CARROTS -> Material.CARROT; + case BEETROOTS -> Material.BEETROOT_SEEDS; + case NETHER_WART -> Material.NETHER_WART; + default -> null; + }; + } + @SuppressWarnings("deprecation") + public static boolean allows(Perk perk, ItemStack tool, Material crop) { + if (!perk.getWhitelistedTools().isEmpty() && !perk.getWhitelistedTools().contains(tool.getType())) return false; + for (Entry required : perk.getRequiredEnchants().entrySet()) { + Enchantment enchantment = Enchantment.getByKey(NamespacedKey.minecraft(required.getKey())); + if (enchantment == null || tool.getEnchantmentLevel(enchantment) != required.getValue()) return false; + } + return (perk.getWhitelistedBlocks().isEmpty() || perk.getWhitelistedBlocks().contains(crop)) + && !perk.getBlacklistedBlocks().contains(crop); + } private void plant(MCPerksMain plugin, Player player, Block block, Material seedType) { Bukkit.getScheduler().runTaskLater(plugin, new Runnable() { diff --git a/MCPerks/src/main/java/com/bencodez/mcperks/listeners/EffectBatch.java b/MCPerks/src/main/java/com/bencodez/mcperks/listeners/EffectBatch.java new file mode 100644 index 0000000..b931ae0 --- /dev/null +++ b/MCPerks/src/main/java/com/bencodez/mcperks/listeners/EffectBatch.java @@ -0,0 +1,18 @@ +package com.bencodez.mcperks.listeners; +import java.util.List; +/** Production execution seam: each true result is one successful gameplay unit. */ +public final class EffectBatch { + @FunctionalInterface public interface Unit { boolean apply() throws Exception; } + public record Result(int successful, int unused) {} + private EffectBatch() {} + public static Result execute(List units, int budget) { + if (budget < 0 || budget > units.size()) throw new IllegalArgumentException("Invalid budget"); + int successful = 0; + for (Unit unit : units) { + if (successful == budget) break; + try { if (unit.apply()) successful++; } + catch (Exception uncertain) { return new Result(successful, budget - successful - 1); } + } + return new Result(successful, budget - successful); + } +} 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..ab84540 100644 --- a/MCPerks/src/main/java/com/bencodez/mcperks/listeners/EffectListeners.java +++ b/MCPerks/src/main/java/com/bencodez/mcperks/listeners/EffectListeners.java @@ -153,7 +153,8 @@ public void onBlockDropItem(BlockDropItemEvent event) { if (plugin.getPerkHandler().effectActive(Effect.InstantSmelt, player.getUniqueId().toString(), player.getWorld().getName())) { for (Perk active : plugin.getPerkHandler().getActivePerks()) { - if (active.getEffects().contains(Effect.InstantSmelt)) { + if (active.getEffects().contains(Effect.InstantSmelt) && plugin.getPerkHandler().effectAllowed(active, + Effect.InstantSmelt, player.getUniqueId().toString(), player.getWorld().getName())) { ArrayList toRemove = new ArrayList(); for (Item item : event.getItems()) { ItemStack smeltedItem = null; @@ -213,6 +214,7 @@ private int getExperienceAmount(FurnaceRecipe recipe, int stackAmount) { @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) public void onBlockBreak(BlockBreakEvent event) { Player player = event.getPlayer(); + boolean nativeBreak = plugin.getUsageEffects() != null && plugin.getUsageEffects().isNativeBreak(player.getUniqueId()); if (!event.isCancelled()) { for (BlockBreakEvent e : plugin.getEffectHandler().getBlockBreakEvents()) { if (e.equals(event)) { @@ -224,7 +226,7 @@ public void onBlockBreak(BlockBreakEvent event) { Block blockBroken; Collection var5; - if (Tag.LOGS.isTagged(event.getBlock().getType())) { + if (!nativeBreak && Tag.LOGS.isTagged(event.getBlock().getType())) { if (plugin.getPerkHandler().effectActive(Effect.TreeHarvest, player.getUniqueId().toString(), player.getWorld().getName())) { boolean found = false; @@ -236,7 +238,8 @@ public void onBlockBreak(BlockBreakEvent event) { } } - if (e != null && !found) { + if (e != null && !found && plugin.getPerkHandler().effectAllowed(active, + e, player.getUniqueId().toString(), player.getWorld().getName())) { new TreeHarvestEffect(plugin, player, event.getBlock(), active); found = true; } @@ -245,7 +248,7 @@ public void onBlockBreak(BlockBreakEvent event) { } } - if (plugin.getPerkHandler().effectActive(Effect.AutoPlant, player.getUniqueId().toString(), + if (!nativeBreak && plugin.getPerkHandler().effectActive(Effect.AutoPlant, player.getUniqueId().toString(), player.getWorld().getName())) { boolean found = false; for (Perk active : plugin.getPerkHandler().getActivePerks()) { @@ -256,7 +259,8 @@ public void onBlockBreak(BlockBreakEvent event) { } } - if (e != null && !found) { + if (e != null && !found && plugin.getPerkHandler().effectAllowed(active, + e, player.getUniqueId().toString(), player.getWorld().getName())) { new AutoPlant(plugin, player, event.getBlock(), active, event); found = true; @@ -268,8 +272,9 @@ public void onBlockBreak(BlockBreakEvent event) { if (plugin.getPerkHandler().effectActive(Effect.Fortune, player.getUniqueId().toString(), player.getWorld().getName())) { - for (Perk active : plugin.getPerkHandler().getActivePerks()) { - if (active.getEffects().contains(Effect.Fortune)) { + for (Perk active : plugin.getPerkHandler().getActivePerks()) { + if (active.getEffects().contains(Effect.Fortune) && plugin.getPerkHandler().effectAllowed(active, + Effect.Fortune, player.getUniqueId().toString(), player.getWorld().getName())) { FortuneEffect fe = new FortuneEffect(active); blockBroken = event.getBlock(); @@ -289,8 +294,9 @@ public void onBlockBreak(BlockBreakEvent event) { if (plugin.getPerkHandler().effectActive(Effect.Farmer, player.getUniqueId().toString(), player.getWorld().getName())) { - for (Perk active : plugin.getPerkHandler().getActivePerks()) { - if (active.getEffects().contains(Effect.Farmer)) { + for (Perk active : plugin.getPerkHandler().getActivePerks()) { + if (active.getEffects().contains(Effect.Farmer) && plugin.getPerkHandler().effectAllowed(active, + Effect.Farmer, player.getUniqueId().toString(), player.getWorld().getName())) { FarmerEffect fe1 = new FarmerEffect(active); blockBroken = event.getBlock(); if (fe1.isFarmable(blockBroken)) { @@ -305,7 +311,7 @@ public void onBlockBreak(BlockBreakEvent event) { } } - if (plugin.getPerkHandler().effectActive(Effect.IncreaseMiningArea, player.getUniqueId().toString(), + if (!nativeBreak && plugin.getPerkHandler().effectActive(Effect.IncreaseMiningArea, player.getUniqueId().toString(), player.getWorld().getName())) { boolean found = false; for (Perk active : plugin.getPerkHandler().getActivePerks()) { @@ -316,7 +322,8 @@ public void onBlockBreak(BlockBreakEvent event) { } } - if (e != null && !found) { + if (e != null && !found && plugin.getPerkHandler().effectAllowed(active, + e, player.getUniqueId().toString(), player.getWorld().getName())) { blockBroken = event.getBlock(); BlockFace f = PlayerUtils.yawToFace(player.getLocation().getYaw(), false); new IncreaseMiningArea(plugin, event, active, player, e.getModifier(), f); @@ -353,7 +360,7 @@ public void onDisconnect(PlayerQuitEvent event) { if (perk.getBossBar() != null) { perk.getBossBar().removePlayer(player); } - if (plugin.getConfigFile().getDeactivateOnLogout()) { + if (plugin.getConfigFile().getDeactivateOnLogout() && (plugin.getBankedPerks() == null || !plugin.getBankedPerks().attached(perk))) { if (perk.getActivater().getUUID().equals(player.getUniqueId().toString())) { perk.deactivatePerk(perk.getActivater()); } @@ -364,12 +371,19 @@ public void onDisconnect(PlayerQuitEvent event) { } + private String killerIdentity(EntityDeathEvent event) { + org.bukkit.entity.Entity killer = plugin.getVersionHandle().hasDamageSourceMethod() + ? plugin.getVersionHandle().getCausingEntity(event) : event.getEntity().getKiller(); + return killer instanceof Player ? killer.getUniqueId().toString() : null; + } + @SuppressWarnings("unchecked") @EventHandler(ignoreCancelled = true) public void onEntityDeath(EntityDeathEvent event) { if (!(event.getEntity() instanceof Player) && !event.getEntityType().equals(EntityType.ARMOR_STAND)) { for (Perk active : plugin.getPerkHandler().getActivePerks()) { - if (active.getEffects().contains(Effect.IncreaseMobDrops)) { + if (active.getEffects().contains(Effect.IncreaseMobDrops) && plugin.getPerkHandler().effectAllowed(active, Effect.IncreaseMobDrops, + killerIdentity(event), event.getEntity().getWorld().getName())) { boolean giveEffects = false; if (active.getPerkType().equals(PerkSystemType.ALL)) { giveEffects = true; @@ -402,7 +416,8 @@ public void onEntityDeath(EntityDeathEvent event) { } for (Perk active : plugin.getPerkHandler().getActivePerks()) { - if (active.getEffects().contains(Effect.DoubleExperience)) { + if (active.getEffects().contains(Effect.DoubleExperience) && plugin.getPerkHandler().effectAllowed(active, Effect.DoubleExperience, + killerIdentity(event), event.getEntity().getWorld().getName())) { boolean giveEffects = false; if (active.getPerkType().equals(PerkSystemType.ALL)) { giveEffects = true; @@ -428,7 +443,8 @@ public void onEntityDeath(EntityDeathEvent event) { } for (Perk active : plugin.getPerkHandler().getActivePerks()) { - if (active.getEffects().contains(Effect.HeadDropper)) { + if (active.getEffects().contains(Effect.HeadDropper) && plugin.getPerkHandler().effectAllowed(active, Effect.HeadDropper, + killerIdentity(event), event.getEntity().getWorld().getName())) { if (!active.getBlackedListedMobs().contains(event.getEntity().getType())) { boolean giveEffects = false; if (active.getPerkType().equals(PerkSystemType.ALL)) { @@ -687,7 +703,8 @@ public void onPlayerMove(PlayerMoveEvent event) { } } - if (e != null) { + if (e != null && plugin.getPerkHandler().effectAllowed(active, Effect.BoneMealAura, + player.getUniqueId().toString(), player.getWorld().getName())) { new BoneMealAura().checkPlants(player, event.getTo(), (int) e.getModifier()); } diff --git a/MCPerks/src/main/java/com/bencodez/mcperks/listeners/RefillPlan.java b/MCPerks/src/main/java/com/bencodez/mcperks/listeners/RefillPlan.java new file mode 100644 index 0000000..44d4930 --- /dev/null +++ b/MCPerks/src/main/java/com/bencodez/mcperks/listeners/RefillPlan.java @@ -0,0 +1,37 @@ +package com.bencodez.mcperks.listeners; +import org.bukkit.Material; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +/** Captures and revalidates the exact hand, selected slot and matching source stack. */ +public final class RefillPlan implements EffectBatch.Unit { + private final PlayerInventory inventory; + private final int destination, source, selected; + private final EquipmentSlot hand; + private final ItemStack sourceBefore; + private RefillPlan(PlayerInventory inventory, EquipmentSlot hand, int source) { + this.inventory = inventory; this.hand = hand; this.source = source; + selected = inventory.getHeldItemSlot(); destination = hand == EquipmentSlot.OFF_HAND ? 40 : selected; + sourceBefore = inventory.getItem(source).clone(); + } + public static RefillPlan capture(PlayerInventory inventory, EquipmentSlot hand, ItemStack placed) { + if (placed == null || placed.getAmount() != 1) return null; + int destination = hand == EquipmentSlot.OFF_HAND ? 40 : inventory.getHeldItemSlot(); + for (int slot = 0; slot < 36; slot++) { + ItemStack item = inventory.getItem(slot); + if (slot != destination && item != null && item.getAmount() > 0 && item.isSimilar(placed)) return new RefillPlan(inventory, hand, slot); + } + return null; + } + @Override public boolean apply() { + if (hand == EquipmentSlot.HAND && inventory.getHeldItemSlot() != selected) return false; + ItemStack held = inventory.getItem(destination), item = inventory.getItem(source); + if (held != null && held.getType() != Material.AIR && held.getAmount() > 0) return false; + if (!sourceBefore.equals(item)) return false; + ItemStack replacement = item.clone(); replacement.setAmount(1); + ItemStack remainder = item.clone(); remainder.setAmount(remainder.getAmount() - 1); + inventory.setItem(source, remainder.getAmount() == 0 ? null : remainder); + inventory.setItem(destination, replacement); + return true; + } +} diff --git a/MCPerks/src/main/java/com/bencodez/mcperks/listeners/UsageEffectListener.java b/MCPerks/src/main/java/com/bencodez/mcperks/listeners/UsageEffectListener.java new file mode 100644 index 0000000..bceaed7 --- /dev/null +++ b/MCPerks/src/main/java/com/bencodez/mcperks/listeners/UsageEffectListener.java @@ -0,0 +1,351 @@ +package com.bencodez.mcperks.listeners; + +import java.lang.reflect.Method; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; +import org.bukkit.*; +import org.bukkit.block.*; +import org.bukkit.block.data.Ageable; +import org.bukkit.block.data.BlockData; +import org.bukkit.entity.Player; +import org.bukkit.event.*; +import org.bukkit.event.block.*; +import org.bukkit.event.player.*; +import org.bukkit.inventory.*; +import com.bencodez.mcperks.MCPerksMain; +import com.bencodez.mcperks.scheduling.OwnerTasks; +import com.bencodez.mcperks.data.LocalStatistics.Skip; +import com.bencodez.mcperks.effects.AutoPlant; +import com.bencodez.mcperks.perk.*; +import com.bencodez.mcperks.perk.Effect; +import com.bencodez.mcperks.userapi.UsageCharges; +import com.bencodez.mcperks.userapi.UsageCharges.Kind; + +/** New automation effects. World/inventory access always runs on the player's owner. */ +public final class UsageEffectListener implements Listener { + private final MCPerksMain plugin; + private final UsageCharges ledger; + private final Set nativeBreaks = ConcurrentHashMap.newKeySet(); + private final Map pending = new ConcurrentHashMap<>(); + private static final class Batch { + final AtomicBoolean invoked = new AtomicBoolean(); + final CompletableFuture settled = new CompletableFuture<>(); + volatile Runnable cancel = () -> invoked.compareAndSet(false, true); + } + public record RecentSkip(Effect effect, Skip reason, long time) {} + private final Map recentSkips = new ConcurrentHashMap<>(); + public RecentSkip recentSkip(UUID owner) { return recentSkips.get(owner); } + private void skip(Player player, Effect effect, Skip reason) { + noteSkip(player.getUniqueId(), effect, reason); + } + private synchronized void noteSkip(UUID owner, Effect effect, Skip reason) { + if (recentSkips.size() < 256 || recentSkips.containsKey(owner)) recentSkips.put(owner, new RecentSkip(effect, reason, System.currentTimeMillis())); + plugin.getLocalStatistics().skip(reason); + } + private volatile int generation; + private volatile boolean retired; + private record SneakingSession(long token, Player player) {} + private final ConcurrentMap sneakingSessions = new ConcurrentHashMap<>(); + private final java.util.concurrent.atomic.AtomicLong nextSneakingSession = new java.util.concurrent.atomic.AtomicLong(); + private final Object sneakingLock = new Object(); + private final Method ownedRegion; + public UsageEffectListener(MCPerksMain plugin) { + this.plugin = plugin; + this.ledger = plugin.getUsageCharges(); + Method method = null; + try { method = Bukkit.class.getMethod("isOwnedByCurrentRegion", Location.class); } catch (ReflectiveOperationException ignored) {} + ownedRegion = method; + } + public void invalidate() { synchronized (sneakingLock) { generation++; } } + /** Re-sample a connected player on that player's Bukkit/Folia owner. */ + public void synchronizeSneaking(Player player) { + if (player == null || retired || !plugin.isEnabled()) return; + UUID owner = player.getUniqueId(); + long session; + int version; + synchronized (sneakingLock) { + if (retired) return; + session = nextSneakingSession.incrementAndGet(); + sneakingSessions.put(owner, new SneakingSession(session, player)); + version = generation; + } + OwnerTasks.run(plugin, player, () -> { + boolean sneaking = player.isSneaking(); + synchronized (sneakingLock) { + if (!retired && version == generation && isCurrent(owner, player, session) + && plugin.isEnabled() && player.isOnline()) { + plugin.getPerkHandler().setPrecisionSneaking(owner, sneaking); + } + } + }, () -> clearSneaking(owner, player, version, session)); + } + /** Clear only the session that requested cleanup; a rejoined player remains authoritative. */ + public void clearSneaking(Player player) { + if (player != null) { + synchronized (sneakingLock) { + SneakingSession current = sneakingSessions.get(player.getUniqueId()); + if (current != null && current.player() == player) clearSneaking(player.getUniqueId(), player, generation, current.token()); + } + } + } + private boolean isCurrent(UUID owner, Player player, long session) { + SneakingSession current = sneakingSessions.get(owner); + return current != null && current.token() == session && current.player() == player; + } + private void clearSneaking(UUID owner, Player player, int version, long session) { + synchronized (sneakingLock) { + SneakingSession current = sneakingSessions.get(owner); + if (version == generation && current != null && current.token() == session && current.player() == player + && sneakingSessions.remove(owner, current)) { + plugin.getPerkHandler().setPrecisionSneaking(owner, false); + } + } + } + /** Resample all players present at enable/reload without reading player state off-owner. */ + public void synchronizeOnlinePlayers() { + if (retired || !plugin.isEnabled()) return; + for (Player player : Bukkit.getOnlinePlayers()) synchronizeSneaking(player); + } + public synchronized CompletableFuture retire() { + retired = true; invalidate(); + synchronized (sneakingLock) { sneakingSessions.clear(); } + List batches = List.copyOf(pending.values()); + batches.forEach(batch -> batch.cancel.run()); + return CompletableFuture.allOf(batches.stream().map(batch -> batch.settled).toArray(CompletableFuture[]::new)); + } + /** Seal admissions, release callbacks proved unused, then close their original ledger without owner waits. */ + public CompletableFuture retireAndClose() { + return retire().whenComplete((ignored, error) -> ledger.close()); + } + public boolean isNativeBreak(UUID player) { return nativeBreaks.contains(player); } + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void sneak(PlayerToggleSneakEvent event) { + if (event.isCancelled()) return; + synchronizeSneaking(event.getPlayer()); + } + @EventHandler public void join(PlayerJoinEvent event) { synchronizeSneaking(event.getPlayer()); plugin.getQueuePlanner().check(); } + @EventHandler public void quit(PlayerQuitEvent event) { clearSneaking(event.getPlayer()); recentSkips.remove(event.getPlayer().getUniqueId()); } + + private Perk eligible(Player player, Effect effect) { + return eligible(player, effect, candidate -> true); + } + private Perk eligible(Player player, Effect effect, java.util.function.Predicate context) { + if (retired || !plugin.isEnabled()) return null; + plugin.getPerkHandler().setPrecisionSneaking(player.getUniqueId(), player.isSneaking()); + var decision = plugin.getPerkHandler().effectDecision(effect, player.getUniqueId().toString(), player.getWorld().getName()); + if (decision != PerkHandler.Decision.ACTIVE) { + skip(player, effect, switch (decision) { + case OUTSIDE_AUDIENCE -> Skip.AUDIENCE; case DISABLED_WORLD -> Skip.WORLD; case PRECISION_SUPPRESSED -> Skip.PRECISION; + case BANKED_PAUSED -> Skip.BANKED; default -> Skip.INACTIVE; + }); return null; + } + Perk chargedCandidate = null; + for (Perk perk : plugin.getPerkHandler().getActivePerks()) { + if (!perk.getEffects().contains(effect) || !perk.getEffectedPlayers().contains(player.getUniqueId().toString()) + || !perk.isNotInDisabledWorld(player.getWorld().getName()) + || (plugin.getBankedPerks() != null && !plugin.getBankedPerks().available(perk)) || !context.test(perk)) continue; + if (charged(perk) && perk.getPerkType() != PerkSystemType.PLAYER) { skip(player, effect, Skip.SHARED_CHARGES); continue; } // No stable shared charge owner. + // Prefer a matching unlimited grant. Its gameplay does not depend on optional + // charge storage, and a reservation on another grant cannot hide it later. + if (!charged(perk)) return perk; + if (chargedCandidate == null) chargedCandidate = perk; + } + return chargedCandidate; + } + private boolean charged(Perk perk) { + return perk.getInteractionOptions().charged(); + } + private boolean owns(Player player, Block block) { + if (!player.getWorld().equals(block.getWorld()) || !block.getWorld().isChunkLoaded(block.getX() >> 4, block.getZ() >> 4)) return false; + if (!plugin.getBukkitScheduler().getFoliaLib().isFolia()) return true; + if (ownedRegion == null) return false; + try { return Boolean.TRUE.equals(ownedRegion.invoke(null, block.getLocation())); } + catch (ReflectiveOperationException e) { return false; } + } + private boolean matchesTool(Player player, ItemStack expected) { return player.getInventory().getItemInMainHand().equals(expected); } + private void later(Player player, Runnable action) { + int version = generation; + if (!retired) OwnerTasks.run(plugin, player, () -> { + if (!retired && version == generation && plugin.isEnabled()) action.run(); + }, () -> {}); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void refill(BlockPlaceEvent event) { + Player player = event.getPlayer(); Perk perk = eligible(player, Effect.AutoBlockRefill); + if (perk == null || player.getGameMode() != GameMode.SURVIVAL || event.getItemInHand().getAmount() != 1) return; + RefillPlan plan = RefillPlan.capture(player.getInventory(), event.getHand(), event.getItemInHand()); + if (plan == null) return; + later(player, () -> run(player, perk, Kind.AutoBlockRefill, List.of(() -> { + if (event.isCancelled() || eligible(player, Effect.AutoBlockRefill, candidate -> candidate == perk) != perk || player.getGameMode() != GameMode.SURVIVAL) return false; + boolean applied = plan.apply(); if (!applied) skip(player, Effect.AutoBlockRefill, Skip.INVENTORY_CHANGED); return applied; + }))); + } + + private record Point(int x, int y, int z) {} + private static final int[][] NEIGHBORS = { {1,0,0}, {-1,0,0}, {0,1,0}, {0,-1,0}, {0,0,1}, {0,0,-1} }; + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void vein(BlockBreakEvent event) { + Player player = event.getPlayer(); + if (nativeBreaks.contains(player.getUniqueId()) || player.getGameMode() != GameMode.SURVIVAL) return; + Block origin = event.getBlock(); Material ore = origin.getType(); + Material selectedTool = player.getInventory().getItemInMainHand().getType(); + Perk perk = eligible(player, Effect.VeinMining, candidate -> { + var options = candidate.getInteractionOptions(); + return allowed(options.veinMaterials(), ore) && (options.veinTools().isEmpty() + ? selectedTool.name().endsWith("_PICKAXE") : allowed(options.veinTools(), selectedTool)); + }); + if (perk == null) return; + if (!(ore.name().endsWith("_ORE") || ore == Material.ANCIENT_DEBRIS) || origin.hasMetadata("Placed")) return; + var data = perk.getInteractionOptions(); + if (!allowed(data.veinMaterials(), ore)) { skip(player, Effect.VeinMining, Skip.MATERIAL); return; } + ItemStack tool = player.getInventory().getItemInMainHand().clone(); + int toolSlot = player.getInventory().getHeldItemSlot(); + List tools = data.veinTools(); + if (tools.isEmpty() ? !tool.getType().name().endsWith("_PICKAXE") : !allowed(tools, tool.getType())) { skip(player, Effect.VeinMining, Skip.TOOL); return; } + int maximum = data.veinBlocks(); + int distance = data.veinDistance(); + if (!owns(player, origin)) return; + List blocks = connected(player, origin, ore, maximum, distance); + if (blocks.isEmpty()) return; + List units = new ArrayList<>(); + for (Block block : blocks) units.add(() -> { + if (player.getGameMode() != GameMode.SURVIVAL || event.isCancelled() || !owns(player, origin) || origin.getType() == ore || eligible(player, Effect.VeinMining, candidate -> candidate == perk) != perk + || !matchesTool(player, tool, toolSlot) || !owns(player, block) || block.getType() != ore || block.hasMetadata("Placed") + || player.getLocation().distanceSquared(block.getLocation()) > 256) return false; + boolean broken = nativeBreak(player, block); if (!broken) skip(player, Effect.VeinMining, Skip.NATIVE_BREAK_DENIED); return broken; + }); + later(player, () -> run(player, perk, Kind.VeinMining, units)); + } + private boolean matchesTool(Player player, ItemStack expected, int selectedSlot) { + if (player.getInventory().getHeldItemSlot() != selectedSlot) return false; + ItemStack actual = player.getInventory().getItemInMainHand(); + if (actual == null || actual.getType() != expected.getType()) return false; + var actualMeta = actual.getItemMeta(); var expectedMeta = expected.getItemMeta(); + // Native breaks change only durability. All other metadata identifies the admitted tool. + if (actualMeta instanceof org.bukkit.inventory.meta.Damageable damage) damage.setDamage(0); + if (expectedMeta instanceof org.bukkit.inventory.meta.Damageable damage) damage.setDamage(0); + return Objects.equals(actualMeta, expectedMeta); + } + private List connected(Player player, Block origin, Material ore, int maximum, int distance) { + List found = new ArrayList<>(); Set visited = new HashSet<>(); ArrayDeque queue = new ArrayDeque<>(); + Point start = new Point(origin.getX(), origin.getY(), origin.getZ()); queue.add(start); + while (!queue.isEmpty() && found.size() < maximum - 1 && visited.size() < 512) { + Point p = queue.remove(); if (!visited.add(p)) continue; + if (Math.abs(p.x-start.x) > distance || Math.abs(p.y-start.y) > distance || Math.abs(p.z-start.z) > distance + || p.y < origin.getWorld().getMinHeight() || p.y >= origin.getWorld().getMaxHeight()) continue; + // Check coordinates before obtaining a Block; never load/access an adjacent unloaded chunk. + if (!origin.getWorld().isChunkLoaded(p.x >> 4, p.z >> 4)) continue; + if (plugin.getBukkitScheduler().getFoliaLib().isFolia() && ((p.x >> 4) != (start.x >> 4) || (p.z >> 4) != (start.z >> 4))) continue; + Block b = origin.getWorld().getBlockAt(p.x, p.y, p.z); + if (!owns(player, b) || b.getType() != ore || b.hasMetadata("Placed")) continue; + if (!p.equals(start)) found.add(b); + for (int[] offset : NEIGHBORS) queue.add(new Point(p.x+offset[0], p.y+offset[1], p.z+offset[2])); + } + return found; + } + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void harvest(PlayerInteractEvent event) { + if (event.getAction() != Action.RIGHT_CLICK_BLOCK || event.getHand() != EquipmentSlot.HAND || event.getClickedBlock() == null + || !interactionAllowed(event)) return; + Player player = event.getPlayer(); Block block = event.getClickedBlock(); + Perk perk = eligible(player, Effect.RightClickHarvest, candidate -> allowed(candidate.getInteractionOptions().crops(), block.getType()) + && AutoPlant.allows(candidate, player.getInventory().getItemInMainHand(), block.getType())); + if (perk == null || player.getGameMode() != GameMode.SURVIVAL || !owns(player, block)) return; + Material crop = block.getType(); Material seed = AutoPlant.seedFor(crop); + if (seed == null || !(block.getBlockData() instanceof Ageable age) || age.getAge() != age.getMaximumAge() + || !allowed(perk.getInteractionOptions().crops(), crop) + || !AutoPlant.allows(perk, player.getInventory().getItemInMainHand(), crop)) return; + int seedSlot = -1; + for (int i = 0; i < 36; i++) { ItemStack item = player.getInventory().getItem(i); if (item != null && item.getType() == seed && item.getAmount() > 0) { seedSlot = i; break; } } + if (seedSlot < 0) { skip(player, Effect.RightClickHarvest, Skip.SEED); return; } + int slot = seedSlot; ItemStack seedBefore = player.getInventory().getItem(slot).clone(); + ItemStack tool = player.getInventory().getItemInMainHand().clone(); + int toolSlot = player.getInventory().getHeldItemSlot(); BlockData before = block.getBlockData().clone(); + later(player, () -> { + // Final interaction protection is distinct from native block-break protection. + if (!interactionAllowed(event)) { skip(player, Effect.RightClickHarvest, Skip.INTERACTION_DENIED); return; } + run(player, perk, Kind.RightClickHarvest, List.of(() -> { + if (player.getGameMode() != GameMode.SURVIVAL || !interactionAllowed(event) || eligible(player, Effect.RightClickHarvest, candidate -> candidate == perk) != perk || !owns(player, block) || !before.equals(block.getBlockData()) + || player.getLocation().distanceSquared(block.getLocation()) > 36 + || player.getInventory().getHeldItemSlot() != toolSlot || !matchesTool(player, tool) || !seedBefore.equals(player.getInventory().getItem(slot))) return false; + // Reserve the replant seed before native break callbacks can change inventory. + ItemStack remainder = seedBefore.clone(); remainder.setAmount(remainder.getAmount() - 1); + player.getInventory().setItem(slot, remainder.getAmount() == 0 ? null : remainder); + boolean broken = nativeBreak(player, block); + if (!broken) { + skip(player, Effect.RightClickHarvest, Skip.NATIVE_BREAK_DENIED); + // Restore only into the unchanged slot; otherwise give the exact seed through normal inventory/drop handling. + ItemStack current = player.getInventory().getItem(slot); + if ((remainder.getAmount() == 0 && (current == null || current.getType() == Material.AIR)) || remainder.equals(current)) player.getInventory().setItem(slot, seedBefore); + else { ItemStack returned = seedBefore.clone(); returned.setAmount(1); + for (ItemStack overflow : player.getInventory().addItem(returned).values()) player.getWorld().dropItemNaturally(player.getLocation(), overflow); } + return false; + } + if (block.getType() != Material.AIR) throw new IllegalStateException("Harvest replacement changed by another listener"); + Ageable replanted = (Ageable) before.clone(); replanted.setAge(0); block.setBlockData(replanted); + return true; + })); + }); + } + @SuppressWarnings("deprecation") + private static boolean interactionAllowed(PlayerInteractEvent event) { + return !event.isCancelled() && event.useInteractedBlock() != Event.Result.DENY && event.useItemInHand() != Event.Result.DENY; + } + private boolean nativeBreak(Player player, Block block) { + nativeBreaks.add(player.getUniqueId()); + try { return player.breakBlock(block); } finally { nativeBreaks.remove(player.getUniqueId()); } + } + private static boolean allowed(List values, Material material) { return values.isEmpty() || values.stream().anyMatch(v -> v.equalsIgnoreCase(material.name())); } + private synchronized boolean admitPending(String key, Batch batch) { + return !retired && pending.size() < 256 && pending.putIfAbsent(key, batch) == null; + } + private void settle(String key, Batch batch) { + pending.remove(key, batch); batch.settled.complete(null); + } + private void run(Player player, Perk perk, Kind kind, List units) { + if (player.getGameMode() != GameMode.SURVIVAL) return; + String key = player.getUniqueId() + ":" + kind; + Batch batch = new Batch(); + if (!admitPending(key, batch)) { skip(player, Effect.valueOf(kind.name()), Skip.BUSY); return; } + int version = generation; + boolean charge = charged(perk); + CompletableFuture admission = charge + ? ledger.reserve(player.getUniqueId(), kind, units.size()) : CompletableFuture.completedFuture(null); + admission.whenComplete((reservation, error) -> { + if (error != null || (charge && reservation == null)) { + noteSkip(player.getUniqueId(), Effect.valueOf(kind.name()), error == null ? Skip.DEPLETED : Skip.STORAGE); + settle(key, batch); return; + } + Runnable release = () -> { + CompletableFuture completion = charge ? ledger.finish(reservation, 0, reservation.units()) : CompletableFuture.completedFuture(null); + completion.whenComplete((ignored, failure) -> settle(key, batch)); + }; + Runnable cancel = () -> { if (batch.invoked.compareAndSet(false, true)) release.run(); }; + batch.cancel = cancel; + // Retirement may have claimed cancellation before the reservation finished publishing. + if (batch.invoked.get()) { release.run(); return; } + Runnable execute = () -> { + if (!batch.invoked.compareAndSet(false, true)) return; + try { + int budget = charge ? reservation.units() : units.size(); + EffectBatch.Result result = retired || version != generation || !player.isOnline() || player.getGameMode() != GameMode.SURVIVAL + ? new EffectBatch.Result(0, budget) : EffectBatch.execute(units.stream().map(unit -> () -> retired || version != generation || player.getGameMode() != GameMode.SURVIVAL ? false : unit.apply()).toList(), budget); + CompletableFuture completion = charge ? ledger.finish(reservation, result.successful(), result.unused()) + : ledger.record(player.getUniqueId(), kind, result.successful()); + if (result.successful() + result.unused() < budget) skip(player, Effect.valueOf(kind.name()), Skip.UNCERTAIN); + completion.whenComplete((ignored, failure) -> { + if (failure != null) plugin.getLogger().warning(charge + ? "Usage completion could not be persisted; reserved charges remain spent" + : "Usage statistics could not be persisted; gameplay completed"); + if (charge) settle(key, batch); + }); + // Best-effort statistics must never retain an unlimited gameplay admission. + if (!charge) settle(key, batch); + } catch (Throwable failure) { settle(key, batch); plugin.getLogger().warning("Usage callback failed; reservation remains spent"); } + }; + if (retired || !plugin.isEnabled()) cancel.run(); + else OwnerTasks.run(plugin, player, execute, cancel); + }); + } +} diff --git a/MCPerks/src/main/java/com/bencodez/mcperks/listeners/compatability/McMMOEvents.java b/MCPerks/src/main/java/com/bencodez/mcperks/listeners/compatability/McMMOEvents.java index 0681b8d..9d7be53 100644 --- a/MCPerks/src/main/java/com/bencodez/mcperks/listeners/compatability/McMMOEvents.java +++ b/MCPerks/src/main/java/com/bencodez/mcperks/listeners/compatability/McMMOEvents.java @@ -27,8 +27,9 @@ public void onMcMMOPlayerXpGainEvent(McMMOPlayerXpGainEvent event) { if (plugin.getPerkHandler().effectActive(Effect.McmmoXP, player.getUniqueId().toString(), player.getWorld().getName())) { for (Perk active : plugin.getPerkHandler().getActivePerks()) { - if (active.getEffects().contains(Effect.McmmoXP) - && active.getEffectedPlayers().contains(player.getUniqueId().toString())) { + if (active.getEffects().contains(Effect.McmmoXP) + && plugin.getPerkHandler().effectAllowed(active, Effect.McmmoXP, + player.getUniqueId().toString(), player.getWorld().getName())) { if (active.isNotInDisabledWorld(player.getWorld().getName())) { ArrayList skills = active.getMcmmoSkills(); boolean boost = false; diff --git a/MCPerks/src/main/java/com/bencodez/mcperks/listeners/compatability/RoseStackerListener.java b/MCPerks/src/main/java/com/bencodez/mcperks/listeners/compatability/RoseStackerListener.java index 5f51b36..7f8e847 100644 --- a/MCPerks/src/main/java/com/bencodez/mcperks/listeners/compatability/RoseStackerListener.java +++ b/MCPerks/src/main/java/com/bencodez/mcperks/listeners/compatability/RoseStackerListener.java @@ -83,7 +83,8 @@ public void onDropStack(PreDropStackedItemsEvent event) { if (plugin.getPerkHandler().effectActive(Effect.InstantSmelt, player.getUniqueId().toString(), player.getWorld().getName())) { for (Perk active : plugin.getPerkHandler().getActivePerks()) { - if (active.getEffects().contains(Effect.InstantSmelt)) { + if (active.getEffects().contains(Effect.InstantSmelt) && plugin.getPerkHandler().effectAllowed(active, + Effect.InstantSmelt, player.getUniqueId().toString(), player.getWorld().getName())) { for (ItemStack item : items) { ItemStack smeltedItem = null; Iterator recipeIterator = plugin.getServer().recipeIterator(); diff --git a/MCPerks/src/main/java/com/bencodez/mcperks/perk/BankedClock.java b/MCPerks/src/main/java/com/bencodez/mcperks/perk/BankedClock.java new file mode 100644 index 0000000..69dd95f --- /dev/null +++ b/MCPerks/src/main/java/com/bencodez/mcperks/perk/BankedClock.java @@ -0,0 +1,81 @@ +package com.bencodez.mcperks.perk; +/** Explicit lifecycle clock; queries do not checkpoint or consume time. */ +public final class BankedClock { + private long previous; + private boolean eligible; + private boolean grantInProgress; + private long pendingMilliseconds; + private long replacement; + private final java.util.function.LongSupplier publishedReplacement; + public BankedClock() { this(() -> 0); } + BankedClock(java.util.function.LongSupplier publishedReplacement) { this.publishedReplacement = publishedReplacement; } + public record Debit(long milliseconds, long replacement, long publication) {} + /** Capture debit ownership together with its elapsed-time reservation. */ + public synchronized Debit reserveCheckpoint(long monotonicNanos, boolean nowEligible, + java.util.function.LongSupplier confirmedMilliseconds) { + return new Debit(checkpoint(monotonicNanos, nowEligible, confirmedMilliseconds), replacement, publishedReplacement.getAsLong()); + } + private long elapsed(long monotonicNanos) { + return eligible && previous != 0 ? Math.max(0, (monotonicNanos - previous) / 1_000_000) : 0; + } + public synchronized long checkpoint(long monotonicNanos, boolean nowEligible) { + return checkpoint(monotonicNanos, nowEligible, () -> Long.MAX_VALUE); + } + public synchronized long checkpoint(long monotonicNanos, boolean nowEligible, + java.util.function.LongSupplier confirmedMilliseconds) { + if (grantInProgress) { eligible = nowEligible; return 0; } + long elapsed = Math.min(elapsed(monotonicNanos), Math.max(0, confirmedMilliseconds.getAsLong() - pendingMilliseconds)); + pendingMilliseconds = Math.min(31_536_000_000L, pendingMilliseconds + elapsed); + previous = monotonicNanos; eligible = nowEligible; return elapsed; + } + /** + * Move the elapsed-time boundary to a grant publication. The caller + * persists the returned debit as part of the same bank publication. + */ + public synchronized com.bencodez.mcperks.data.FeatureState.BankGrant prepareGrant(long monotonicNanos, long oldBalance, + java.util.function.LongSupplier committedTime) { + // A prior checkpoint may already be awaiting its worker debit. That + // debit is still charged against the published balance, so only the + // unreserved portion of the old balance can cover this interval. + long elapsed = Math.min(elapsed(monotonicNanos), + Math.max(0, oldBalance - pendingMilliseconds)); + grantInProgress = true; + return new com.bencodez.mcperks.data.FeatureState.BankGrant(elapsed, + () -> commitGrant(committedTime.getAsLong()), this::rollbackGrant); + } + private synchronized void commitGrant(long monotonicNanos) { previous = monotonicNanos; grantInProgress = false; } + /** A clock first attached during publication can spend only the pre-grant balance. */ + public synchronized Debit rebaseLateGrant(long monotonicNanos, long oldBalance) { + long elapsed = Math.min(elapsed(monotonicNanos), Math.max(0, oldBalance - pendingMilliseconds)); + pendingMilliseconds += elapsed; + previous = monotonicNanos; + return new Debit(elapsed, replacement, publishedReplacement.getAsLong()); + } + private synchronized void rollbackGrant() { grantInProgress = false; } + /** Replacement discards earlier elapsed reservations only once its new balance is published. */ + public synchronized com.bencodez.mcperks.data.FeatureState.BankGrant prepareReplacement( + java.util.function.LongSupplier committedTime) { + grantInProgress = true; + return new com.bencodez.mcperks.data.FeatureState.BankGrant(0, + () -> commitReplacement(committedTime.getAsLong()), this::rollbackGrant); + } + private synchronized void commitReplacement(long monotonicNanos) { + replacement++; pendingMilliseconds = 0; commitGrant(monotonicNanos); + } + public synchronized boolean owns(Debit debit) { + return debit.replacement() == replacement && debit.publication() == publishedReplacement.getAsLong(); + } + public synchronized void acknowledge(Debit debit) { + if (owns(debit)) acknowledge(debit.milliseconds()); + } + /** Called only after the matching debit has been published by the storage worker. */ + public synchronized void acknowledge(long milliseconds) { + pendingMilliseconds = Math.max(0, pendingMilliseconds - milliseconds); + } + /** Read the confirmed balance under the same monitor as acknowledgement, without mutation. */ + public synchronized long remaining(java.util.function.LongSupplier confirmedMilliseconds, long now) { + if (grantInProgress) return 0; + return Math.max(0, confirmedMilliseconds.getAsLong() - pendingMilliseconds - elapsed(now)); + } + public synchronized void pause() { previous = 0; eligible = false; } +} diff --git a/MCPerks/src/main/java/com/bencodez/mcperks/perk/BankedPerks.java b/MCPerks/src/main/java/com/bencodez/mcperks/perk/BankedPerks.java new file mode 100644 index 0000000..5a777b0 --- /dev/null +++ b/MCPerks/src/main/java/com/bencodez/mcperks/perk/BankedPerks.java @@ -0,0 +1,416 @@ +package com.bencodez.mcperks.perk; +import java.util.*; +import java.util.concurrent.*; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.event.*; +import org.bukkit.event.player.*; +import com.bencodez.mcperks.MCPerksMain; +import com.bencodez.mcperks.scheduling.OwnerTasks; +import com.bencodez.mcperks.data.FeatureState; +import com.bencodez.mcperks.data.FeatureState.BankKey; +import com.bencodez.mcperks.userapi.MCPerksUser; +/** Online/eligible personal time. Suspension never repeats activation/reward hooks. */ +public final class BankedPerks implements Listener { + private final MCPerksMain plugin; + private final FeatureState state; + private final Map attached = new ConcurrentHashMap<>(); + private final Map clocks = new ConcurrentHashMap<>(); + // At most one unsettled debit per persisted bank. Retain it across a + // rejected worker/publication so reconnect retries the same interval. + private final Map pendingDebits = new ConcurrentHashMap<>(); + private final Map> debitSubmissions = new ConcurrentHashMap<>(); + private final Set pausing = ConcurrentHashMap.newKeySet(); + private final java.util.function.LongSupplier monotonic; + private final Map sessions = new ConcurrentHashMap<>(); + private final Map ticking = new ConcurrentHashMap<>(); + // Live-session setter observations supersede user reads already queued on storage. + private final Map ignoreChanges = new ConcurrentHashMap<>(); + // At most one mutation epoch per persisted bank owner (the state has a fixed bank bound). + private final Map ignoreMutations = new ConcurrentHashMap<>(); + private static final class IgnoreMutation { volatile long version; int active; boolean uncertain; } + private final Set changingIgnoreEffects = ConcurrentHashMap.newKeySet(); + private final java.util.concurrent.atomic.AtomicLong tickIds = new java.util.concurrent.atomic.AtomicLong(); + private volatile int generation; + private volatile boolean retired; + private CompletableFuture retirement; + // Per-perk admission also covers a server-wide cooldown shared by different players. + private final Set directAdmissions = ConcurrentHashMap.newKeySet(); + public synchronized boolean beginDirect(Perk perk) { + return !retired && directAdmissions.size() < 256 && directAdmissions.add(perk.getPerk()); + } + public void finishDirect(Perk perk) { directAdmissions.remove(perk.getPerk()); } + private String directKey(MCPerksUser user, Perk perk) { + return perk.getServerWideCoolDown() > 0 ? perk.getPerk() : "PLAYER:" + user.getUUID() + ":" + perk.getPerk(); + } + public synchronized boolean beginDirect(MCPerksUser user, Perk perk) { + return !retired && directAdmissions.size() < 256 && directAdmissions.add(directKey(user, perk)); + } + public void finishDirect(MCPerksUser user, Perk perk) { directAdmissions.remove(directKey(user, perk)); } + public boolean hasEntitlement(UUID owner, Perk perk) { + var bank = state.banks().get(new BankKey(owner, perk.getPerk())); + return bank != null && bank.milliseconds() > 0; + } + private static final Set UNSUPPORTED = EnumSet.of(Effect.Commands, Effect.GlobalCommands, Effect.Rewards, + Effect.CureHunger, Effect.CureSpells, Effect.HealAll, Effect.RandomToolRepair, Effect.ToolRepair, + Effect.ArmorRepair, Effect.RandomArmorRepair, Effect.AllRepair, Effect.RandomAllRepair, + Effect.Fly, Effect.DoubleJump, Effect.DoubleJumpUp, Effect.Potions, Effect.TempPermission, Effect.IncreaseMaxHealth, Effect.IncreaseStrength, Effect.IncreaseLuck, Effect.MoveSpeed); + public BankedPerks(MCPerksMain plugin, FeatureState state) { this(plugin, state, System::nanoTime); } + BankedPerks(MCPerksMain plugin, FeatureState state, java.util.function.LongSupplier monotonic) { + this.plugin = plugin; this.state = state; this.monotonic = monotonic; + state.onBankGrant((key, oldBalance) -> { + BankedClock clock = clocks.get(key); + FeatureState.BankGrant boundary = clock == null ? new FeatureState.BankGrant(0, () -> {}, () -> {}) + : clock.prepareGrant(monotonic.getAsLong(), oldBalance, monotonic); + return new FeatureState.BankGrant(boundary.elapsed(), () -> { + boundary.commit().run(); + BankedClock current = clocks.get(key); + if (current != null && current != clock) { + var debit = current.rebaseLateGrant(monotonic.getAsLong(), Math.max(0, oldBalance - boundary.elapsed())); + if (debit.milliseconds() > 0) state.elapsed(key, debit.milliseconds(), () -> current.owns(debit)) + .thenRun(() -> current.acknowledge(debit)); + } + }, boundary.rollback()); + }); + state.onBankReplacement(key -> { + BankedClock clock = clocks.get(key); + FeatureState.BankGrant boundary = clock == null ? new FeatureState.BankGrant(0, () -> {}, () -> {}) + : clock.prepareReplacement(monotonic); + return new FeatureState.BankGrant(0, () -> { + boundary.commit().run(); + // A player can attach/reconnect while this worker's publication is blocked. + BankedClock current = clocks.get(key); + if (current != null && current != clock) current.prepareReplacement(monotonic).commit().run(); + }, boundary.rollback()); + }); + } + public String grantUnavailable(UUID owner, Perk perk, int seconds) { + if (!state.available()) return "bank storage unavailable"; + if (!supported(perk) || seconds < 1) return "unsupported banked perk"; + if ((long) seconds * 1000 > 31_536_000_000L) return "bank grant exceeds one year"; + BankKey key = new BankKey(owner, perk.getPerk()); + if (!state.banks().containsKey(key) && (state.banks().size() >= 10000 || state.banks().keySet().stream().filter(k -> k.owner().equals(owner)).count() >= 64)) return "bank capacity reached"; + var old = state.banks().get(key); + if (old != null && (long) seconds * 1000 > 31_536_000_000L - old.milliseconds()) return "bank capacity reached"; + return null; + } + public static boolean supported(Perk perk) { return perk.getPerkType() == PerkSystemType.PLAYER && perk.getReActivateEffectsTimer() <= 0 && Collections.disjoint(perk.getEffects(), UNSUPPORTED); } + public CompletableFuture grant(MCPerksUser user, Perk perk, int seconds) { + return publishGrant(user, perk, seconds).thenRun(() -> granted(user, perk)); + } + public CompletableFuture grantConfirmed(MCPerksUser user, Perk perk, int seconds) { + if (!plugin.getConfigFile().getPerkQue()) return grant(user, perk, seconds); + return publishGrant(user, perk, seconds).thenCompose(ignored -> grantedConfirmed(user, perk)); + } + private CompletableFuture publishGrant(MCPerksUser user, Perk perk, int seconds) { + String unavailable = grantUnavailable(UUID.fromString(user.getUUID()), perk, seconds); + if (unavailable != null) return CompletableFuture.failedFuture(new IllegalArgumentException(unavailable)); + BankKey key = new BankKey(UUID.fromString(user.getUUID()), perk.getPerk()); + return state.addBank(key, Math.multiplyExact((long) seconds, 1000)); + } + /** Attach only a grant that the state worker already published; never adds time a second time. */ + public void granted(MCPerksUser user, Perk perk) { + plugin.getLocalStatistics().activation(); + Player player = Bukkit.getPlayer(UUID.fromString(user.getUUID())); + if (player != null) OwnerTasks.run(plugin, player, () -> start(player), () -> stopSession(player)); + } + public CompletableFuture grantedConfirmed(MCPerksUser user, Perk perk) { + var attachment = new CompletableFuture(); + plugin.getLocalStatistics().activation(); + Player player = Bukkit.getPlayer(UUID.fromString(user.getUUID())); + if (player == null) attachment.complete(null); + else OwnerTasks.run(plugin, player, () -> start(player, attachment), () -> { stopSession(player); attachment.complete(null); }); + return attachment; + } + public synchronized void start(Player player) { + start(player, null); + } + private static void settled(CompletableFuture attachment) { if (attachment != null) attachment.complete(null); } + private synchronized void start(Player player, CompletableFuture attachment) { + UUID owner = player.getUniqueId(); + if (retired || !player.isOnline() || Bukkit.getPlayer(owner) != player) { settled(attachment); return; } + if (state.banks().entrySet().stream().noneMatch(e -> e.getKey().owner().equals(owner) && e.getValue().milliseconds() > 0)) { settled(attachment); return; } + Long current = ticking.get(owner); + if (current != null) { + // A grant to an already ticking owner gets one attachment attempt, not a second timer loop. + if (attachment != null) pulse(player, generation, current, attachment, false); + return; + } + long token = tickIds.incrementAndGet(); + if (!plugin.getPerkHandler().isRestorationReady() || !state.available() || ticking.size() >= 256 || ticking.putIfAbsent(player.getUniqueId(), token) != null) { settled(attachment); return; } + sessions.put(owner, player); + pulse(player, generation, token, attachment, true); + } + @EventHandler public void join(PlayerJoinEvent event) { start(event.getPlayer()); } + @EventHandler public synchronized void world(PlayerChangedWorldEvent event) { + UUID owner = event.getPlayer().getUniqueId(); + if (sessions.get(owner) != event.getPlayer()) return; + for (BankKey key : List.copyOf(clocks.keySet())) if (key.owner().equals(owner)) suspend(key); + start(event.getPlayer()); + } + @EventHandler public void quit(PlayerQuitEvent event) { stopSession(event.getPlayer()); } + public void refresh(UUID owner) { + Player player = Bukkit.getPlayer(owner); if (player != null) OwnerTasks.run(plugin, player, () -> { + synchronized (BankedPerks.this) { + if (Bukkit.getPlayer(owner) != player || !player.isOnline()) return; + for (BankKey key : List.copyOf(attached.keySet())) if (key.owner().equals(owner)) { + var bank = state.banks().get(key); if (bank == null || bank.paused() || bank.milliseconds() == 0) suspend(key); + } + start(player); + } + }, () -> stopSession(player)); + } + /** Checkpoint the setter boundary without Bukkit access, including asynchronous API calls. */ + public void changeIgnoreEffects(UUID owner, boolean ignored, Runnable mutation) { + if (state.banks().keySet().stream().noneMatch(key -> key.owner().equals(owner))) { + mutation.run(); return; + } + IgnoreMutation observation = ignoreMutations.computeIfAbsent(owner, key -> new IgnoreMutation()); + // Checkpoint before a setter can expose its flag, but never hold either + // controller or per-player admission across user storage. Concurrent + // setters retain suspension until all finish and a fresh read settles order. + synchronized (this) { + observation.version++; + if (observation.active++ == 0) observation.uncertain = false; + else observation.uncertain = true; + changingIgnoreEffects.add(owner); ignoreEffectsChanged(owner, true); + } + Boolean stored = null; + try { mutation.run(); stored = ignored; } + finally { + synchronized (this) { + observation.version++; observation.uncertain |= stored == null; + if (--observation.active == 0) changingIgnoreEffects.remove(owner); + ignoreEffectsChanged(owner, observation.active != 0 || observation.uncertain || stored == null || stored); + } + } + } + private synchronized void ignoreEffectsChanged(UUID owner, boolean ignored) { + Player player = sessions.get(owner); + Long token = ticking.get(owner); + if (retired || player == null || token == null) return; + ignoreChanges.put(owner, ignored); + for (BankKey key : List.copyOf(clocks.keySet())) if (key.owner().equals(owner)) { + var bank = state.banks().get(key); + boolean eligible = !ignored && attached.containsKey(key) && !pausing.contains(key) + && state.available() && bank != null && !bank.paused() && bank.milliseconds() > 0; + ClockDebit checkpoint = reserve(key, eligible, false); + if (checkpoint != null && checkpoint.debit().milliseconds() > 0) + debitElapsed(key, checkpoint.clock(), checkpoint.debit()); + } + int version = generation; + // Re-evaluate attachment on the captured session's owner. Existing timer ownership stays intact. + OwnerTasks.run(plugin, player, () -> { + synchronized (BankedPerks.this) { + if (retired || version != generation || !Objects.equals(ticking.get(owner), token)) return; + pulse(player, version, token, null, false); + } + }, () -> stopIfCurrent(owner, token)); + } + private record Context(MCPerksUser user, boolean ignored) {} + private void pulse(Player player, int version, long token) { + pulse(player, version, token, null, true); + } + private void pulse(Player player, int version, long token, CompletableFuture attachment, boolean repeat) { + UUID owner = player.getUniqueId(); + if (retired || version != generation || !Objects.equals(ticking.get(owner), token)) { settled(attachment); return; } + if (!player.isOnline()) { stopIfCurrent(owner, token); settled(attachment); return; } + var ownedBanks = state.banks().entrySet().stream().filter(e -> e.getKey().owner().equals(owner)).limit(64).toList(); + if (ownedBanks.stream().noneMatch(e -> e.getValue().milliseconds() > 0)) { stopIfCurrent(owner, token); settled(attachment); return; } + // User ignore state can require SQL. Fetch on the bounded feature worker before returning to owner. + IgnoreMutation observation = ignoreMutations.get(owner); + long observedVersion = observation == null ? 0 : observation.version; + state.storage(() -> { MCPerksUser user = plugin.getMcperksUserManager().getMCPerksUser(owner); return new Context(user, user.isIgnorePerkEffects()); }).whenComplete((context, readError) -> { + if (retired || version != generation || !Objects.equals(ticking.get(owner), token) || !plugin.isEnabled()) { settled(attachment); return; } + OwnerTasks.run(plugin, player, () -> { + try { synchronized (BankedPerks.this) { + if (retired || version != generation || !Objects.equals(ticking.get(owner), token)) { settled(attachment); return; } + if (!player.isOnline()) { stopIfCurrent(owner, token); settled(attachment); return; } + IgnoreMutation currentObservation = ignoreMutations.get(owner); + boolean currentRead = observation == currentObservation && (observation == null || observedVersion == observation.version); + if (!currentRead && !changingIgnoreEffects.contains(owner) && !Boolean.TRUE.equals(ignoreChanges.get(owner))) { + settled(attachment); + if (repeat && !retired && plugin.isEnabled()) OwnerTasks.later(plugin, player, () -> pulse(player, version, token), () -> stopIfCurrent(owner, token), 20); + return; + } + if (currentRead && observation != null && observation.uncertain && !changingIgnoreEffects.contains(owner) + && readError == null && context != null) { + observation.uncertain = false; + ignoreChanges.remove(owner); + } + List> checkpoints = new ArrayList<>(); + for (var entry : ownedBanks) { + BankKey key = entry.getKey(); Perk perk = plugin.getPerkHandler().getPerk(key.perk()); var bank = state.banks().get(key); + boolean eligible = plugin.getPerkHandler().isRestorationReady() && state.available() && !pausing.contains(key) && readError == null && context != null && !changingIgnoreEffects.contains(owner) && !ignoreChanges.getOrDefault(owner, context.ignored()) && bank != null && !bank.paused() && bank.milliseconds() > 0 + && withinActiveLimit(context.user(), key) && perk != null && perk.getInteractionOptions().banked() && supported(perk) && perk.isNotInDisabledWorld(player.getWorld().getName()) + && plugin.getPerkHandler().getActivePerks().stream().noneMatch(active -> !attached.containsValue(active) && active.getPerk().equals(key.perk()) && active.getEffectedPlayers().contains(owner.toString())); + if (eligible) eligible = plugin.getQueuePlanner().attachBank(key, attached.get(key), () -> { + if (!attached.containsKey(key)) attach(key, perk, context.user()); + }); + ClockDebit checkpoint = reserve(key, eligible, true); + BankedClock clock = checkpoint.clock(); BankedClock.Debit debit = checkpoint.debit(); + long elapsed = debit.milliseconds(); + if (elapsed > 0) checkpoints.add(debitElapsed(key, clock, debit)); + if (!eligible) { suspend(key); continue; } + if (elapsed > 0) checkpoints.add(state.storage(() -> state.banks().get(key)).whenComplete((updated, error) -> { + if (!retired && version == generation && Objects.equals(ticking.get(owner), token) && plugin.isEnabled()) OwnerTasks.run(plugin, player, () -> { + synchronized (BankedPerks.this) { + if (retired || version != generation || !Objects.equals(ticking.get(owner), token)) return; + if (error != null || updated == null || updated.paused() || updated.milliseconds() == 0) suspend(key); + } + }, () -> stopIfCurrent(owner, token)); + })); + } + CompletableFuture.allOf(checkpoints.toArray(CompletableFuture[]::new)).whenComplete((ignored, failure) -> { + settled(attachment); + if (failure != null) { stopIfCurrent(owner, token); return; } + if (repeat && !retired && plugin.isEnabled()) OwnerTasks.later(plugin, player, () -> pulse(player, version, token), () -> stopIfCurrent(owner, token), 20); + }); + } } catch (RuntimeException failure) { + if (attachment != null) attachment.completeExceptionally(failure); + stopIfCurrent(owner, token); + } + }, () -> { stopIfCurrent(owner, token); settled(attachment); }); + }); + } + /** Recheck on the player owner at attachment, after asynchronous grants and manual resume. */ + private boolean withinActiveLimit(MCPerksUser user, BankKey key) { + if (!plugin.getConfigFile().getLimitPermission()) return true; + int limit = user.getPerkLimit(); + return limit == -1 || user.getActivePerks() + (attached.containsKey(key) ? 0 : 1) <= limit; + } + private void attach(BankKey key, Perk source, MCPerksUser user) { + Perk clone = source.clone(); clone.setActivater(user); + clone.getEffectedPlayers().clear(); clone.getEffectedPlayers().add(key.owner().toString()); clone.setActive(true); + attached.put(key, clone); plugin.getPerkHandler().getActivePerks().add(clone); + // Only reversible persistent effects are eligible; command/reward effects are rejected at load/grant. + // Supported effects are read by gameplay listeners; there is no activation hook to replay. + } + private CompletableFuture suspend(BankKey key) { + var completion = checkpoint(key); + Perk perk = attached.remove(key); if (perk == null) return completion; + perk.setActive(false); + plugin.getPerkHandler().getActivePerks().remove(perk); plugin.getQueuePlanner().check(); + return completion; + } + private CompletableFuture checkpoint(BankKey key) { + ClockDebit checkpoint = reserve(key, false, false); + if (checkpoint == null || checkpoint.debit().milliseconds() == 0) return CompletableFuture.completedFuture(null); + return debitElapsed(key, checkpoint.clock(), checkpoint.debit()); + } + private record ClockDebit(BankedClock clock, BankedClock.Debit debit) {} + private ClockDebit reserve(BankKey key, boolean eligible, boolean create) { + return state.bankCheckpoint(() -> { + BankedClock clock = create ? clocks.computeIfAbsent(key, k -> new BankedClock(() -> state.bankReplacementVersion(k))) : clocks.get(key); + if (clock == null) return null; + BankedClock.Debit pending = pendingDebits.get(key); + if (pending != null) { + if (clock.owns(pending)) { + long additional = clock.checkpoint(monotonic.getAsLong(), eligible, () -> { + var current = state.banks().get(key); return current == null ? 0 : current.milliseconds(); + }); + if (additional > 0) { + pending = new BankedClock.Debit(pending.milliseconds() + additional, + pending.replacement(), pending.publication()); + pendingDebits.put(key, pending); + } + return new ClockDebit(clock, pending); + } + pendingDebits.remove(key, pending); + } + var debit = clock.reserveCheckpoint(monotonic.getAsLong(), eligible, () -> { + var current = state.banks().get(key); return current == null ? 0 : current.milliseconds(); + }); + if (debit.milliseconds() > 0) pendingDebits.put(key, debit); + return new ClockDebit(clock, debit); + }); + } + private CompletableFuture debitElapsed(BankKey key, BankedClock clock, BankedClock.Debit debit) { + synchronized (debitSubmissions) { + CompletableFuture existing = debitSubmissions.get(key); + if (existing != null) return existing; + var submission = new CompletableFuture(); + debitSubmissions.put(key, submission); + submitDebit(key, clock, debit, submission); + return submission; + } + } + /** One completion stage covers the full bounded aggregate, including a final interval added in flight. */ + private void submitDebit(BankKey key, BankedClock clock, BankedClock.Debit debit, + CompletableFuture submission) { + state.elapsed(key, debit.milliseconds(), () -> clock.owns(debit)).whenComplete((updated, failure) -> { + BankedClock.Debit remainder = state.bankCheckpoint(() -> { + if (!clock.owns(debit)) { pendingDebits.remove(key, debit); return null; } + if (failure != null) return null; + pendingDebits.computeIfPresent(key, (ignored, current) -> { + if (current.replacement() != debit.replacement() || current.publication() != debit.publication()) return current; + long remaining = Math.max(0, current.milliseconds() - debit.milliseconds()); + return remaining == 0 ? null : new BankedClock.Debit(remaining, current.replacement(), current.publication()); + }); + clock.acknowledge(debit); + var pending = pendingDebits.get(key); + return pending != null && clock.owns(pending) ? pending : null; + }); + if (failure == null && remainder != null) { submitDebit(key, clock, remainder, submission); return; } + debitSubmissions.remove(key, submission); + if (failure == null) submission.complete(updated); else submission.completeExceptionally(failure); + }); + } + public synchronized CompletableFuture pause(BankKey key, boolean paused) { + pausing.add(key); var checkpoint = suspend(key); + return checkpoint.thenCompose(ignored -> state.pauseBank(key, paused)).whenComplete((bank, error) -> { pausing.remove(key); if (error == null) refresh(key.owner()); }); + } + public synchronized boolean deactivate(Perk perk) { + BankKey key = attached.entrySet().stream().filter(e -> e.getValue() == perk).map(Map.Entry::getKey).findFirst().orElse(null); + if (key == null) return false; + pause(key, true); return true; + } + private synchronized void stopSession(Player player) { if (sessions.get(player.getUniqueId()) == player) stop(player.getUniqueId()); } + private synchronized void stopIfCurrent(UUID owner, long token) { if (Objects.equals(ticking.get(owner), token)) stop(owner); } + private synchronized void stop(UUID owner) { + ticking.remove(owner); sessions.remove(owner); ignoreChanges.remove(owner); for (BankKey key : List.copyOf(clocks.keySet())) if (key.owner().equals(owner)) suspend(key); + // Keep each owner clock until its asynchronous checkpoint settles. A + // rejected/full worker can leave the debit reservation in the clock; + // dropping it here lets a reconnect create a fresh clock and spend the + // same elapsed interval again. The map is bounded by the persisted + // bank cardinality (FeatureState caps it), and replacement epochs + // fence reservations from an older published balance. + } + public synchronized void reload() { + Set owners = new HashSet<>(ticking.keySet()); + for (BankKey key : clocks.keySet()) owners.add(key.owner()); + generation++; ticking.clear(); sessions.clear(); ignoreChanges.clear(); + for (BankKey key : List.copyOf(clocks.keySet())) suspend(key); + for (UUID owner : owners) { + Player player = Bukkit.getPlayer(owner); + if (player != null) OwnerTasks.run(plugin, player, () -> start(player), () -> stopSession(player)); + } + } + public synchronized void retire() { retireAsync(); } + public synchronized CompletableFuture retireAsync() { + if (retirement != null) return retirement; + retired = true; generation++; ticking.clear(); sessions.clear(); ignoreChanges.clear(); + retirement = new CompletableFuture<>(); + var checkpoints = new ArrayList>(); + for (BankKey key : List.copyOf(clocks.keySet())) checkpoints.add(suspend(key)); + CompletableFuture.allOf(checkpoints.toArray(CompletableFuture[]::new)).whenComplete((ignored, failure) -> { + if (failure == null) { + clocks.clear(); pendingDebits.clear(); debitSubmissions.clear(); attached.clear(); + retirement.complete(null); + } else retirement.completeExceptionally(failure); + }); + return retirement; + } + public boolean available(Perk perk) { + if (!attached.containsValue(perk)) return true; + UUID owner = UUID.fromString(perk.getActivater().getUUID()); + if (!state.available() || !ticking.containsKey(owner) || changingIgnoreEffects.contains(owner) || Boolean.TRUE.equals(ignoreChanges.get(owner))) return false; + BankKey key = new BankKey(owner, perk.getPerk()); + BankedClock clock = clocks.get(key); + return clock != null && clock.remaining(() -> { + var bank = state.banks().get(key); + return bank == null || bank.paused() ? 0 : bank.milliseconds(); + }, monotonic.getAsLong()) > 0; + } + public boolean attached(Perk perk) { return attached.containsValue(perk); } +} 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..ac499e8 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; @@ -113,7 +114,19 @@ public enum Effect { AllRepair, - RandomAllRepair; + RandomAllRepair, + + /** Refills a depleted held block stack from matching inventory items. */ + AutoBlockRefill, + + /** Mines a bounded connected group of configured blocks. */ + VeinMining, + + /** Harvests mature crops on right click and replants when possible. */ + RightClickHarvest, + + /** Suppresses configured MCPerks effects while sneaking. */ + PrecisionMode; public static Effect fromString(String str) { for (Effect eff : values()) { @@ -126,6 +139,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)); @@ -137,8 +158,17 @@ public void removeEffect(MCPerksMain plugin, Perk perk, ArrayList player } switch (this) { - case Fly: - new FlyEffect().disableFly(perk.getFlyWorlds(), players); + case Fly: + 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 +236,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 +256,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 +265,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..71807cd 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/InteractionOptions.java b/MCPerks/src/main/java/com/bencodez/mcperks/perk/InteractionOptions.java new file mode 100644 index 0000000..fd3a340 --- /dev/null +++ b/MCPerks/src/main/java/com/bencodez/mcperks/perk/InteractionOptions.java @@ -0,0 +1,27 @@ +package com.bencodez.mcperks.perk; +import java.util.List; +import org.bukkit.configuration.file.FileConfiguration; +/** Immutable options captured at perk load, never fetched from disk by a gameplay listener. */ +public record InteractionOptions(boolean charged, boolean banked, int veinBlocks, int veinDistance, List veinMaterials, + List veinTools, List crops, List precisionTargets) { + public InteractionOptions { + veinMaterials = List.copyOf(veinMaterials); veinTools = List.copyOf(veinTools); + crops = List.copyOf(crops); precisionTargets = List.copyOf(precisionTargets); + for (String target : precisionTargets) { + Effect effect = java.util.Arrays.stream(Effect.values()).filter(candidate -> candidate.name().equalsIgnoreCase(target)) + .findFirst().orElseThrow(() -> new IllegalArgumentException("Unknown PrecisionMode target: " + target)); + if (java.util.Set.of(Effect.Fly, Effect.DoubleJump, Effect.DoubleJumpUp, Effect.Potions, Effect.TempPermission, + Effect.IncreaseMaxHealth, Effect.IncreaseStrength, Effect.IncreaseLuck, Effect.MoveSpeed, + Effect.Commands, Effect.GlobalCommands, Effect.Rewards, Effect.CureHunger, Effect.CureSpells, Effect.HealAll, + Effect.RandomToolRepair, Effect.ToolRepair, Effect.ArmorRepair, Effect.RandomArmorRepair, Effect.AllRepair, Effect.RandomAllRepair, Effect.PrecisionMode).contains(effect)) + throw new IllegalArgumentException("PrecisionMode target is not an action effect: " + target); + } + } + public static InteractionOptions read(FileConfiguration config) { + return new InteractionOptions(config.getBoolean("UsageCharges.Enabled", false), config.getBoolean("BankedTime.Enabled", false), + Math.max(1, Math.min(64, config.getInt("VeinMining.MaxBlocks", 16))), + Math.max(1, Math.min(16, config.getInt("VeinMining.MaxDistance", 8))), + config.getStringList("VeinMining.Materials"), config.getStringList("VeinMining.Tools"), + config.getStringList("RightClickHarvest.Crops"), config.getStringList("PrecisionMode.TargetEffects")); + } +} 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..61ace75 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; @@ -143,8 +151,11 @@ public class Perk { @Getter private boolean clearModifiers; + @Getter private InteractionOptions interactionOptions; + public Perk(Perk perk) { this.perk = perk.perk; + interactionOptions = perk.interactionOptions; name = perk.name; description = perk.description; time = perk.time; @@ -190,6 +201,7 @@ public Perk(Perk perk) { */ public Perk(String perk) { this.perk = perk; + interactionOptions = InteractionOptions.read(ConfigPerks.getInstance().getData(perk)); setPriority(ConfigPerks.getInstance().getPerkPriority(perk)); name = ConfigPerks.getInstance().getPerkName(perk); description = ConfigPerks.getInstance().getPerkDescription(perk).replace("%TimeLasts%", "" + getTime()); @@ -269,8 +281,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) { @@ -366,20 +382,63 @@ public boolean checkTimesUsed(MCPerksUser user) { if (getLimit() == 0) { return true; } - int used = user.getPerkTimesUsed(this); - if (used >= getLimit()) { - - return false; - } - return true; - } - + return !isActivationLimitReached(user.getPerkTimesUsed(this)); + } + + /** Read-only activation rule shared with diagnostics using captured storage state. */ + public boolean isActivationLimitReached(int used) { + return getLimit() != 0 && used >= getLimit(); + } + @Override 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.getFeatureState(); + 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(); + plugin.getUserManager().getDataManager().getCache(UUID.fromString(getActivater().getUUID())) + .flushChangesAndRun(() -> flushed.complete(null)); + return flushed; + }).thenCompose(stage -> stage)); + } + public synchronized void deactivatePerk(MCPerksUser user) { + if (shutdownPrepared) return; + if (plugin.getBankedPerks() != null && plugin.getBankedPerks().deactivate(this)) return; setExperation(user, 0); setActive(false); // Main.plugin.getEffectHandler().deactivate(this); @@ -429,16 +488,138 @@ 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) { + 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); + } + private java.util.concurrent.CompletableFuture executeAdmitted(MCPerksUser user, int length, long admission) { + final String admissionKey = activationKey(user); + try { + return executeAdmittedWork(user, length, admissionKey, admission).whenComplete((ignored, error) -> + releaseActivationAdmission(admissionKey, admission)); + } catch (RuntimeException failure) { + releaseActivationAdmission(admissionKey, admission); + return java.util.concurrent.CompletableFuture.failedFuture(failure); + } + } + private java.util.concurrent.CompletableFuture executeAdmittedWork(MCPerksUser user, int length, String admissionKey, long admission) { + return executeAdmittedWork(user, length, admissionKey, admission, null); + } + private Runnable activationVerifier(String admissionKey, long admission) { + 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) + throw new IllegalStateException("Perk activation lifecycle retired"); + } + }; + } + private java.util.concurrent.CompletableFuture executeAdmittedWork(MCPerksUser user, int length, String admissionKey, long admission, + java.util.function.Supplier> consume) { + if (!plugin.getMcperksServerData().isReady()) return java.util.concurrent.CompletableFuture.failedFuture(new IllegalStateException("ServerData initialization unavailable")); + org.bukkit.entity.Player owner = user == null ? null : user.getPlayer(); + if (consume != null) { + Runnable verifyAdmission = activationVerifier(admissionKey, admission); + // Before credit commits, confirm only existing state. Publishing a new expiration + // here could restore a rejected permanent purchase as a free perk after restart. + return plugin.getMcperksServerData().confirmedActivationBeforeWork(Map.of(), () -> { + verifyAdmission.run(); + return com.bencodez.mcperks.userapi.UserStorageConfirmation.confirm(plugin, user) + .thenCompose(ignored -> consume.get()); + }, () -> { + verifyAdmission.run(); + return executeMetadataAndGameplay(user, length, admissionKey, admission, true, owner); + }, owner); + } + return executeMetadataAndGameplay(user, length, admissionKey, admission, false, owner); + } + private java.util.concurrent.CompletableFuture executeMetadataAndGameplay(MCPerksUser user, int length, + String admissionKey, long admission, boolean alreadyConsumed, org.bukkit.entity.Player owner) { + final boolean banked = getInteractionOptions().banked(); + 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); + Runnable verifyAdmission = activationVerifier(admissionKey, admission); + java.util.function.Supplier> callerStorage = () -> { + verifyAdmission.run(); + user.setPerkCoolDown(this, personalCooldown); + if (isTimed() && !banked) { + if (getPerkType() != PerkSystemType.ALL) user.setPerkExperation(this, admissionExpiration); + this.experation = admissionExpiration; + } + return com.bencodez.mcperks.userapi.UserStorageConfirmation.confirm(plugin, user); + }; + // Publish the cooldown/admission boundary before adding bank time. The + // bank remains durable if the later attachment fails, so a retry must + // observe the cooldown and duplicate-entitlement policy. + if (banked && !alreadyConsumed) + return plugin.getMcperksServerData().confirmedActivationBeforeWork(sharedFields, callerStorage, () -> { + verifyAdmission.run(); + return plugin.getBankedPerks().grantConfirmed(user, this, length); + }, owner); + if (banked) return plugin.getMcperksServerData().confirmedActivationBeforeWork(sharedFields, callerStorage, () -> { + verifyAdmission.run(); + return attachPublishedBank(user, verifyAdmission); + }, owner); + var confirmed = plugin.getMcperksServerData().confirmedActivationBeforeGameplay(sharedFields, callerStorage, () -> { + verifyAdmission.run(); + activatePerk(user, length, isTimed() && !isLastForever() ? new Timestamp(admissionExpiration) : null); + }, owner); + return 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 java.util.concurrent.CompletableFuture executePublishedBank(MCPerksUser user) { + return plugin.getMcperksServerData().confirmedActivation(() -> recordCooldown(user)) + .thenCompose(ignored -> attachPublishedBank(user, () -> {})); + } + private java.util.concurrent.CompletableFuture attachPublishedBank(MCPerksUser user, Runnable verifyAdmission) { + verifyAdmission.run(); + return plugin.getBankedPerks().grantedConfirmed(user, this) + .thenCompose(ignored -> { + verifyAdmission.run(); + return com.bencodez.mcperks.userapi.UserStorageConfirmation.confirm(plugin, user); + }); + } + private java.util.concurrent.CompletableFuture confirmCooldown(MCPerksUser user) { + return plugin.getMcperksServerData().confirmedActivation(() -> recordCooldown(user)) + .thenCompose(ignored -> com.bencodez.mcperks.userapi.UserStorageConfirmation.confirm(plugin, user)); + } + private void recordCooldown(MCPerksUser user) { + user.setPerkCoolDown(this, MiscUtils.getInstance().addSeconds(new Date(), getCoolDown()).getTime()); + if (getServerWideCoolDown() > 0) setServerCoolDownTime(MiscUtils.getInstance().addSeconds(new Date(), getServerWideCoolDown()).getTime()); + } 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 +688,12 @@ public ArrayList getEffects() { return effects; } + /** Last expiration published with the active grant; reading does not touch user storage. */ + public long getKnownExpiration() { return experation; } + 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,20 +932,40 @@ public boolean isNotInDisabledWorld(String world) { } public void runPerk(MCPerksUser user) { - - if (user.isInWorld(disabledWorlds)) { + boolean banked = getInteractionOptions().banked(); + var controller = banked ? plugin.getBankedPerks() : null; + if (banked && !controller.beginDirect(user, this)) { + user.sendMessage("&cA bank grant for this perk is still being confirmed; no activation consumed."); return; + } + boolean handedOff = false; + try { handedOff = runPerkAdmitted(user, controller); } + finally { if (banked && !handedOff) controller.finishDirect(user, this); } + } + + private boolean runPerkAdmitted(MCPerksUser user, BankedPerks controller) { + if (!plugin.getMcperksServerData().isReady() || !plugin.getPerkHandler().isRestorationReady()) { + user.sendMessage("&cPerk runtime state is unavailable; no activation consumed."); return false; + } + if ((plugin.getConfigFile().getPerkQue() || plugin.getConfigFile().getLimitActivations() || getInteractionOptions().banked()) && !plugin.getFeatureState().available()) { + user.sendMessage("&cPerk entitlement state is unavailable; no activation consumed."); return false; + } + + if (getInteractionOptions().banked() && (!BankedPerks.supported(this) || getTime() <= 0)) { + user.sendMessage("&cThis perk is not supported in banked mode; no activation consumed."); return false; + } + if (user.isInWorld(disabledWorlds)) { user.sendMessage(Lang.getInstance().getDisabledWorlds()); - return; + return false; } - if (isPerkActive(user) && !plugin.getConfigFile().getAllowDuplicatePerksActive()) { + if ((isPerkActive(user) || getInteractionOptions().banked() && plugin.getBankedPerks().hasEntitlement(UUID.fromString(user.getUUID()), this)) && !plugin.getConfigFile().getAllowDuplicatePerksActive()) { user.sendMessage(ConfigPerks.getInstance().getPerkAlreayActive(perk)); - return; + return false; } if (!checkTimesUsed(user)) { user.sendMessage(ConfigPerks.getInstance().getPerkLimitReached(perk)); - return; + return false; } long coolDownTime = user.getPerkCoolDown(plugin.getPerkHandler().getPerk(perk)); @@ -777,47 +981,81 @@ public void runPerk(MCPerksUser user) { if (!checkCoolDown(user)) { user.sendMessage(ConfigPerks.getInstance().getPerkInCoolDown(perk), "cooldown", coolDown); - return; + return false; } if (!checkServerWideCoolDown(user)) { user.sendMessage(ConfigPerks.getInstance().getPerkInCoolDown(perk), "cooldown", coolDown); - return; + return false; } if (plugin.getConfigFile().getLimitPermission()) { if (user.getPerkLimit() != -1) { if (user.getActivePerks() + 1 > user.getPerkLimit()) { user.sendMessage(ConfigPerks.getInstance().getPerkLimitReached(perk)); - return; + return false; } } } - boolean globalActivation = true; - - if (plugin.getConfigFile().getLimitActivations()) { - boolean passed = false; - - 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) { - user.sendMessage(ConfigPerks.getInstance().getPerkLimitReached(perk)); - return; - } - } - - runPerkPerk(user, globalActivation); + if (plugin.getConfigFile().getPerkQue() && (!plugin.getPerkHandler().getActivePerks().isEmpty() || plugin.getFeatureState().paused() + || !plugin.getFeatureState().queue().isEmpty() || plugin.getQueuePlanner().isStarting() || plugin.getQueuePlanner().hasAdmissions() || plugin.getQueuePlanner().hasDirectStart())) { + plugin.getPerkHandler().addQue(user, this); return false; + } + java.util.UUID directOwner = java.util.UUID.fromString(user.getUUID()); + boolean queueOwned = plugin.getConfigFile().getPerkQue(); + if (queueOwned && !plugin.getQueuePlanner().beginDirect(directOwner, this)) { + plugin.getPerkHandler().addQue(user, this); return false; + } + final long admission = acquireActivationAdmission(user); + if (admission == 0) { + if (queueOwned) plugin.getQueuePlanner().finishDirect(directOwner, this); + user.sendMessage("&cA perk activation is still being confirmed; no activation consumed."); + return false; + } + boolean handedOff = false; + try { + if (plugin.getConfigFile().getLimitActivations()) { + final String key = activationKey(user); + Runnable verify = activationVerifier(key, admission); + plugin.getQueuePlanner().startDirect(user, this, verify, + consume -> executeAdmittedWork(user, getTime(), key, admission, consume)) + .whenComplete((ignored, error) -> { + releaseActivationAdmission(key, admission); + if (controller != null) controller.finishDirect(user, this); + if (queueOwned) plugin.getQueuePlanner().finishDirect(directOwner, this); + if (error != null) plugin.getLogger().warning("Direct perk start could not be confirmed: " + getPerk()); + }); + handedOff = true; + return getInteractionOptions().banked(); + } + + if (getInteractionOptions().banked()) { + try { + executeAdmitted(user, getTime(), admission).whenComplete((ignored, error) -> { + controller.finishDirect(user, this); + if (queueOwned) plugin.getQueuePlanner().finishDirect(directOwner, this); + if (error != null) plugin.getLogger().warning("Bank grant could not be confirmed: " + getPerk()); + }); + } catch (RuntimeException failure) { + if (queueOwned) plugin.getQueuePlanner().finishDirect(directOwner, this); + throw failure; + } + handedOff = true; + return true; + } + if (queueOwned) { + try { + executeAdmitted(user, getTime(), admission).whenComplete((ignored, error) -> plugin.getQueuePlanner().finishDirect(directOwner, this)); + } catch (RuntimeException failure) { plugin.getQueuePlanner().finishDirect(directOwner, this); throw failure; } + } else executeAdmitted(user, getTime(), admission).exceptionally(error -> { + plugin.getLogger().warning("Perk execution could not be confirmed: " + getPerk()); return null; + }); + handedOff = true; + return false; + } finally { + if (!handedOff) releaseActivationAdmission(activationKey(user), admission); + } } @@ -907,7 +1145,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..56003c8 100644 --- a/MCPerks/src/main/java/com/bencodez/mcperks/perk/PerkHandler.java +++ b/MCPerks/src/main/java/com/bencodez/mcperks/perk/PerkHandler.java @@ -63,7 +63,9 @@ public class PerkHandler { /** The main. */ private MCPerksMain plugin; - private Timer timer = new Timer(); + 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.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) plugin.getLocalStatistics().activation(); 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,7 +217,7 @@ public void run() { } } - plugin.getEffectHandler().activate(clone, user); + plugin.getEffectHandler().activate(clone, user, runActivationHooks); if (clone.isTimed()) { clone.setActive(true); if (!clone.isLastForever()) { @@ -354,16 +372,18 @@ 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 (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,29 +448,30 @@ 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)); } } public void addQue(MCPerksUser user, Perk perk) { - que.put(user, perk); - } + plugin.getQueuePlanner().enqueue(user, perk); + } /** * Adds the to list. @@ -462,20 +483,13 @@ private void addToList(String key, Perk p) { loadedPerks.put(key, p); } - public void checkQue() { - if (!que.isEmpty() && activePerks.size() == 0) { - for (Entry entry : que.entrySet()) { - entry.getValue().runPerk(entry.getKey()); - que.remove(entry.getKey()); - return; - } + public void checkQue() { plugin.getQueuePlanner().check(); } - } - } - - public void clearQueue() { - que.clear(); - } + public void clearQueue() { clearQueueConfirmed(); } + /** Completion means the waiting entries were durably removed; legacy void API remains additive. */ + public java.util.concurrent.CompletableFuture clearQueueConfirmed() { + return plugin.getFeatureState().clearWaiting().thenRun(() -> plugin.getQueuePlanner().refreshLegacyView()); + } public void copyPerks() throws IOException { File file = new File(plugin.getDataFolder(), "Perks"); @@ -512,27 +526,50 @@ public void copyPerks() throws IOException { } } - public boolean effectActive(Effect effect, String uuid, String world) { + private final java.util.concurrent.ConcurrentHashMap precisionSneaking = new java.util.concurrent.ConcurrentHashMap<>(); + public void setPrecisionSneaking(UUID owner, boolean sneaking) { + if (sneaking) precisionSneaking.put(owner, true); else precisionSneaking.remove(owner); + } + private boolean precisionSuppresses(Effect effect, String uuid, String world) { + if (uuid == null || !precisionSneaking.containsKey(UUID.fromString(uuid)) || effect == Effect.PrecisionMode) return false; + for (Perk perk : activePerks) { + if (!perk.getEffects().contains(Effect.PrecisionMode) || !perk.getEffectedPlayers().contains(uuid) || !perk.isNotInDisabledWorld(world) + || (plugin.getBankedPerks() != null && !plugin.getBankedPerks().available(perk))) continue; + List targets = perk.getInteractionOptions().precisionTargets(); + if (targets.isEmpty()) { + if (effect == Effect.AutoBlockRefill || effect == Effect.VeinMining || effect == Effect.RightClickHarvest) return true; + } else if (targets.stream().anyMatch(target -> effect.name().equalsIgnoreCase(target))) return true; + } + return false; + } + + public boolean effectAllowed(Perk perk, Effect effect, String uuid, String world) { + return (uuid == null || perk.getEffectedPlayers().contains(uuid)) + && perk.isNotInDisabledWorld(world) && !precisionSuppresses(effect, uuid, world) + && (plugin.getBankedPerks() == null || plugin.getBankedPerks().available(perk)); + } + + public enum Decision { ACTIVE, INACTIVE, OUTSIDE_AUDIENCE, DISABLED_WORLD, PRECISION_SUPPRESSED, BANKED_PAUSED } + public Decision effectDecision(Effect effect, String uuid, String world) { + if (precisionSuppresses(effect, uuid, world)) return Decision.PRECISION_SUPPRESSED; + Decision decision = Decision.INACTIVE; + for (Perk perk : activePerks) { + if (!perk.getEffects().contains(effect)) continue; + if (uuid != null && !perk.getEffectedPlayers().contains(uuid)) { decision = Decision.OUTSIDE_AUDIENCE; continue; } + if (!perk.isNotInDisabledWorld(world)) { decision = Decision.DISABLED_WORLD; continue; } + if (plugin.getBankedPerks() != null && !plugin.getBankedPerks().available(perk)) { decision = Decision.BANKED_PAUSED; continue; } + return Decision.ACTIVE; + } + return decision; + } + public boolean effectActive(Effect effect, String uuid, String world) { return effectDecision(effect, uuid, world) == Decision.ACTIVE; } + + public double effectActiveModifier(Effect effect, String uuid, int defaultValue, String world) { + if (precisionSuppresses(effect, uuid, world)) return defaultValue; ListIterator it = activePerks.listIterator(); while (it.hasNext()) { Perk perk = it.next(); - if (perk.getEffects().contains(effect)) { - if (uuid == null || perk.getEffectedPlayers().contains(uuid)) { - // plugin.debug("Effect active, " + effect.toString() + " for " + uuid); - if (perk.isNotInDisabledWorld(world)) { - return true; - } - } - } - } - return false; - } - - public double effectActiveModifier(Effect effect, String uuid, int defaultValue, String world) { - ListIterator it = activePerks.listIterator(); - while (it.hasNext()) { - Perk perk = it.next(); - if (perk.getEffects().contains(effect)) { + if (perk.getEffects().contains(effect) && (plugin.getBankedPerks() == null || plugin.getBankedPerks().available(perk))) { if (uuid == null || perk.getEffectedPlayers().contains(uuid)) { // plugin.debug("Effect active, " + effect.toString() + " for " + uuid); @@ -587,32 +624,43 @@ 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); + } + for (Perk perk : List.copyOf(getLoadedPerks().values())) { + for (MCPerksUser user : users) { + requireCurrentRestoration(completed); + user.userDataFetechMode(UserDataFetchMode.NO_CACHE); + if (perk.getExperation(user) < 0) perk.activatePerk(user, perk.getTime()); + else perk.scheduleDeactivation(user); + } + } + requireCurrentRestoration(completed); + shutdownRecords.run(); + completed.complete(null); + } catch (Throwable error) { completed.completeExceptionally(error); } + }); + } catch (RuntimeException error) { completed.completeExceptionally(error); } + return completed; + } + 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. * @@ -670,6 +718,11 @@ public void loadInvPerks() { public void loadPerk(String perkName) { try { Perk perk = new Perk(perkName); + if (perk.getInteractionOptions().banked() && !BankedPerks.supported(perk)) plugin.getLogger().warning("Perk " + perkName + ": unsupported banked scope/effects; banked grants will be rejected"); + if (perk.getInteractionOptions().charged() + && perk.getPerkType() != PerkSystemType.PLAYER) { + plugin.getLogger().warning("Perk " + perkName + ": usage charges require PLAYER scope; charged automation is disabled for this shared audience"); + } if (perk.isEnabled()) { plugin.debug("Loading perk: " + perk.getName() + " : " + perk.getPerkType().toString() + " effects: " + perk.listEffects()); @@ -704,6 +757,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/perk/QueuePlanner.java b/MCPerks/src/main/java/com/bencodez/mcperks/perk/QueuePlanner.java new file mode 100644 index 0000000..fe3a95f --- /dev/null +++ b/MCPerks/src/main/java/com/bencodez/mcperks/perk/QueuePlanner.java @@ -0,0 +1,333 @@ +package com.bencodez.mcperks.perk; +import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import com.bencodez.mcperks.MCPerksMain; +import com.bencodez.mcperks.data.FeatureState; +import com.bencodez.mcperks.data.FeatureState.QueueEntry; +import com.bencodez.mcperks.userapi.MCPerksUser; +import com.bencodez.mcperks.scheduling.OwnerTasks; +/** Durable start fencing around the existing perk and activation models. */ +public final class QueuePlanner { + private final MCPerksMain plugin; + private final FeatureState state; + private final AtomicBoolean starting = new AtomicBoolean(); + private FeatureState.BankKey directStart, startingBank; + private volatile boolean retired, suspended; + public void suspend() { suspended = true; invalidate(); } + public void resume() { suspended = false; check(); } + private volatile int generation; + public void invalidate() { generation++; } + public QueuePlanner(MCPerksMain plugin, FeatureState state) { this.plugin = plugin; this.state = state; } + public void startChecks() { + if (!retired && plugin.isEnabled()) plugin.getBukkitScheduler().runTaskLater(plugin, () -> { try { check(); } finally { startChecks(); } }, 1); + } + public void retire() { retired = true; } + public boolean isStarting() { return starting.get(); } + public synchronized boolean hasDirectStart() { return directStart != null; } + public synchronized boolean beginDirect(UUID owner, Perk perk) { + if (retired || suspended || starting.get() || directStart != null || hasAdmissions() + || state.paused() || !state.queue().isEmpty() || !plugin.getPerkHandler().getActivePerks().isEmpty()) return false; + directStart = new FeatureState.BankKey(owner, perk.getPerk()); + return true; + } + public void finishDirect(UUID owner, Perk perk) { + synchronized (this) { + if (Objects.equals(directStart, new FeatureState.BankKey(owner, perk.getPerk()))) directStart = null; + } + check(); + } + /** Hold the existing queue fence through the actual active-list insertion. */ + public synchronized boolean attachBank(FeatureState.BankKey key, Perk current, Runnable attach) { + if (plugin.getConfigFile().getPerkQue()) { + if (directStart != null && !directStart.equals(key) || starting.get() && !key.equals(startingBank) + || plugin.getPerkHandler().getActivePerks().stream().anyMatch(active -> active != current)) return false; + } + attach.run(); + return true; + } + private record Credit(String key, int available) {} + private final Set admissions = java.util.concurrent.ConcurrentHashMap.newKeySet(); + private synchronized boolean admit(UUID owner) { return !retired && admissions.size() < 256 && admissions.add(owner); } + public boolean hasAdmissions() { return !admissions.isEmpty(); } + /** A limited direct start uses the same durable identity and atomic debit as a queued start. */ + public java.util.concurrent.CompletableFuture startDirect(MCPerksUser user, Perk perk, + Runnable verifyAdmission, + java.util.function.Function>, + java.util.concurrent.CompletableFuture> execute) { + UUID owner = UUID.fromString(user.getUUID()); + Player player = Bukkit.getPlayer(owner); + boolean bypass = player != null && player.hasPermission("MCPerks.Perks.BypassCoolDown"); + int version = generation; + if (!admit(owner)) return java.util.concurrent.CompletableFuture.failedFuture(new IllegalStateException("Activation admission busy")); + var work = state.storage(() -> { + verifyAdmission.run(); + if (!plugin.getUserManager().getDataManager().hasSharedRuntime()) + throw new IllegalStateException("Transactional activation ownership unavailable before debit"); + if (retired || suspended || version != generation || !storedEligible(user, perk, bypass, System.currentTimeMillis())) + throw new IllegalStateException("Activation eligibility changed"); + int personal = user.getActivations(perk.getPerk()); + if (personal > state.reserved(owner, "PerkActivations_" + perk.getPerk())) return new Credit("PerkActivations_" + perk.getPerk(), personal); + return new Credit("Activations", user.getActivations()); + }).thenCompose(credit -> state.startDirect(owner, perk.getPerk(), credit.key(), credit.available())) + .thenCompose(entry -> { + var completed = new java.util.concurrent.CompletableFuture(); + AtomicBoolean debitAttempted = new AtomicBoolean(), executed = new AtomicBoolean(); + var consumption = new java.util.concurrent.atomic.AtomicReference>(); + AtomicBoolean executionSettled = new AtomicBoolean(); + java.util.function.BiConsumer finish = (ignored, error) -> { + final java.util.concurrent.CompletableFuture accepted; + synchronized (consumption) { + if (!executionSettled.compareAndSet(false, true)) return; + accepted = consumption.get(); + } + var settled = accepted == null ? java.util.concurrent.CompletableFuture.completedFuture(null) : accepted; + settled.whenComplete((value, debitError) -> { + Throwable outcome = error == null ? debitError : error; + var saved = outcome != null && debitAttempted.get() ? state.uncertain(entry.id()) : state.complete(entry.id()); + saved.whenComplete((nothing, saveError) -> { + if (saveError != null) { + plugin.getLogger().warning("Direct activation completion persistence failed; retained start fence prevents replay"); + completed.completeExceptionally(saveError); + } else if (outcome != null) completed.completeExceptionally(outcome); + else completed.complete(null); + }); + }); + }; + java.util.function.Supplier> consume = () -> { + final java.util.concurrent.CompletableFuture once; + synchronized (consumption) { + if (executionSettled.get()) return java.util.concurrent.CompletableFuture.failedFuture( + new IllegalStateException("Activation execution already settled")); + if (consumption.get() != null) return consumption.get(); + once = new java.util.concurrent.CompletableFuture(); consumption.set(once); + } + state.storage(() -> { + verifyAdmission.run(); + if (retired || suspended || version != generation) throw new IllegalStateException("Activation lifecycle retired"); + java.util.function.BooleanSupplier debitOnce = () -> { + debitAttempted.set(true); + boolean consumed = debit(entry); + if (!consumed) debitAttempted.set(false); + return consumed; + }; + boolean consumed; + if (perk.getInteractionOptions().banked()) { + if (plugin.getBankedPerks().grantUnavailable(owner, perk, perk.getTime()) != null) + throw new IllegalStateException("Bank grant unavailable before debit"); + consumed = state.addBankOnWorker(new FeatureState.BankKey(owner, perk.getPerk()), + Math.multiplyExact((long) perk.getTime(), 1000), debitOnce) != null; + } else consumed = debitOnce.getAsBoolean(); + if (!consumed) throw new IllegalStateException("Activation credit unavailable; no activation consumed"); + var manager = plugin.getUserManager().getDataManager(); + manager.cacheUser(owner); + if (manager.getPublishedCache(owner) == null) throw new IllegalStateException("Activation cache publication unavailable after debit"); + return null; + }).whenComplete((ignored, error) -> { + if (error == null) once.complete(null); else once.completeExceptionally(error); + }); + return once; + }; + try { + reply(owner, () -> { + if (!executed.compareAndSet(false, true)) return; + try { verifyAdmission.run(); execute.apply(consume).whenComplete(finish); } + catch (Throwable error) { finish.accept(null, error); } + }, () -> { if (executed.compareAndSet(false, true)) finish.accept(null, new IllegalStateException("Activation owner retired")); }); + } catch (Throwable error) { if (executed.compareAndSet(false, true)) finish.accept(null, error); } + return completed; + }); + return work.whenComplete((ignored, error) -> { admissions.remove(owner); refreshLegacyView(); check(); }); + } + public void enqueue(MCPerksUser user, Perk perk) { + UUID owner = UUID.fromString(user.getUUID()); + if (!state.available()) { user.sendMessage("&cQueue state is unavailable; no activation consumed."); return; } + if (perk.getInteractionOptions().banked() && plugin.getBankedPerks().grantUnavailable(owner, perk, perk.getTime()) != null) { user.sendMessage("&cBank grant is unavailable; no activation consumed."); return; } + if (!admit(owner)) return; + state.storage(() -> { + if (!plugin.getConfigFile().getLimitActivations()) return new Credit("", Integer.MAX_VALUE); + int personal = user.getActivations(perk.getPerk()); + if (personal - state.reserved(owner, "PerkActivations_" + perk.getPerk()) > 0) return new Credit("PerkActivations_" + perk.getPerk(), personal); + return new Credit("Activations", user.getActivations()); + }).thenCompose(credit -> state.enqueue(owner, perk.getPerk(), credit.key(), credit.available())).whenComplete((entry, error) -> { + admissions.remove(owner); + reply(owner, () -> user.sendMessage(error == null ? "&aPerk queued. Entry: " + entry.id() : "&cPerk could not be queued; no activation consumed.")); + if (error == null) { refreshLegacyView(); check(); } + }); + } + + public void refreshLegacyView() { + if (retired) return; + state.storage(() -> { + Map snapshot = new LinkedHashMap<>(); + for (QueueEntry entry : state.queue()) { + Perk perk = plugin.getPerkHandler().getPerk(entry.perk()); + if (perk != null) snapshot.put(plugin.getMcperksUserManager().getMCPerksUser(entry.owner()), perk); + } + if (!retired && plugin.isEnabled()) plugin.getBukkitScheduler().runTask(plugin, () -> { + synchronized (plugin.getPerkHandler().que) { plugin.getPerkHandler().que.clear(); plugin.getPerkHandler().que.putAll(snapshot); } + }); + return null; + }); + } + public void check() { + if (retired || suspended || !plugin.getPerkHandler().isRestorationReady() || !plugin.isEnabled() || !state.available() || state.paused() || !plugin.getPerkHandler().getActivePerks().isEmpty()) return; + List snapshot = state.queue(); + synchronized (this) { + if (snapshot.isEmpty() || directStart != null || !plugin.getPerkHandler().getActivePerks().isEmpty() || !starting.compareAndSet(false, true)) return; + startingBank = new FeatureState.BankKey(snapshot.getFirst().owner(), snapshot.getFirst().perk()); + } + try { + QueueEntry entry = snapshot.getFirst(); + if (entry.stage() != FeatureState.Stage.WAITING) { starting.set(false); return; } + reply(entry.owner(), () -> prepare(entry), () -> starting.set(false)); + // Offline players are deliberately left queued; next login/expiry checks again. + if (Bukkit.getPlayer(entry.owner()) == null) starting.set(false); + } catch (RuntimeException failure) { + starting.set(false); + throw failure; + } + } + private void prepare(QueueEntry entry) { + int version = generation; + Player player = Bukkit.getPlayer(entry.owner()); Perk perk = plugin.getPerkHandler().getPerk(entry.perk()); + if (retired || suspended || player == null || perk == null || startUnavailable(entry) != null || !canStart(player, perk)) { starting.set(false); return; } + boolean bypassCooldown = player.hasPermission("MCPerks.Perks.BypassCoolDown"); + java.util.concurrent.atomic.AtomicReference resolvedUser = new java.util.concurrent.atomic.AtomicReference<>(); + state.claim(entry.id()).whenComplete((claimed, failure) -> { + if (failure != null || claimed == null) { starting.set(false); return; } + if (retired) { starting.set(false); return; } + state.storage(() -> { + if (retired || suspended || version != generation) return Confirmation.INELIGIBLE; + MCPerksUser user = plugin.getMcperksUserManager().getMCPerksUser(claimed.owner()); resolvedUser.set(user); + if (!storedEligible(user, perk, bypassCooldown, System.currentTimeMillis())) return Confirmation.INELIGIBLE; + if (perk.getInteractionOptions().banked()) { + if (plugin.getBankedPerks().grantUnavailable(claimed.owner(), perk, perk.getTime()) != null) return Confirmation.INELIGIBLE; + if (state.addBankOnWorker(new FeatureState.BankKey(claimed.owner(), perk.getPerk()), + Math.multiplyExact((long) perk.getTime(), 1000), () -> debit(claimed)) == null) return Confirmation.INELIGIBLE; + } else if (!debit(claimed)) return Confirmation.INELIGIBLE; + if (!claimed.credit().isEmpty()) { + // Atomic SQL commit retires the old cache. Populate its canonical successor off the owner + // before legacy activation reads ignore/expiration state on the game thread. + var manager = plugin.getUserManager().getDataManager(); + manager.cacheUser(claimed.owner()); + if (manager.getPublishedCache(claimed.owner()) == null) throw new IllegalStateException("Activation cache publication unavailable after debit"); + } + return Confirmation.CONFIRMED; + }).whenComplete((confirmed, debitError) -> { + if (confirmed == Confirmation.INELIGIBLE) { + state.releaseClaim(claimed.id()).whenComplete((ignored, error) -> starting.set(false)); return; + } + if (debitError != null || confirmed != Confirmation.CONFIRMED) { + state.uncertain(claimed.id()); starting.set(false); + reply(claimed.owner(), () -> { if (resolvedUser.get() != null) resolvedUser.get().sendMessage("&cQueued start requires review: activation persistence could not be confirmed. Entry " + claimed.id()); }); + return; + } + AtomicBoolean executed = new AtomicBoolean(); + reply(claimed.owner(), () -> { + if (!executed.compareAndSet(false, true)) return; + try { + Player current = Bukkit.getPlayer(claimed.owner()); + if (retired || suspended || version != generation || current == null || !canStart(current, perk)) { state.uncertain(claimed.id()); starting.set(false); return; } + (perk.getInteractionOptions().banked() ? perk.executePublishedBank(resolvedUser.get()) : perk.executeConfirmed(resolvedUser.get(), perk.getTime())).whenComplete((nothing, executionError) -> { + starting.set(false); + if (executionError != null) state.uncertain(claimed.id()); + else { plugin.getLocalStatistics().queueStart(System.currentTimeMillis() - claimed.admitted()); state.complete(claimed.id()).whenComplete((ignored, error) -> { if (error != null) plugin.getLogger().warning("Queue completion persistence failed; start fence prevents replay"); else { refreshLegacyView(); check(); } }); } + }); + } catch (Throwable uncertain) { state.uncertain(claimed.id()); starting.set(false); } + }, () -> { state.uncertain(claimed.id()); starting.set(false); }); + if (Bukkit.getPlayer(claimed.owner()) == null) { state.uncertain(claimed.id()); starting.set(false); } + }); + }); + } + private enum Confirmation { CONFIRMED, INELIGIBLE, UNCERTAIN } + private boolean canStart(Player player, Perk perk) { + return plugin.getPerkHandler().isRestorationReady() && plugin.getPerkHandler().getActivePerks().isEmpty() && perk.isNotInDisabledWorld(player.getWorld().getName()); + } + private boolean storedEligible(MCPerksUser user, Perk perk, boolean bypass, long now) { + if (!plugin.getMcperksServerData().isReady()) return false; + long cooldown = Math.max(user.getPerkCoolDown(perk), plugin.getMcperksServerData().getServerCooldown(perk.getPerk())); + return (bypass || cooldown <= now) && (perk.getLimit() <= 0 || user.getPerkTimesUsed(perk) < perk.getLimit()); + } + /** Advisory read on the storage worker; actual consumption still uses the atomic debit. */ + private boolean creditAvailable(QueueEntry entry) { + if (entry.credit().isEmpty()) return true; + var manager = plugin.getUserManager().getDataManager(); + if (!manager.hasSharedRuntime()) return false; + boolean available = manager.withAtomicUserTransaction(entry.owner(), plugin.getStorageType(), Map.of(), transaction -> + transaction.readRow().stream().filter(column -> column.getName().equals(entry.credit())) + .findFirst().map(column -> column.getValue().getInt() > 0).orElse(false)); + // Even read-only transactions retire the canonical cache on successful completion. + manager.cacheUser(entry.owner()); + if (manager.getPublishedCache(entry.owner()) == null) throw new IllegalStateException("Activation cache publication unavailable after inspection"); + return available; + } + private boolean debit(QueueEntry entry) { + if (entry.credit().isEmpty()) return true; + var manager = plugin.getUserManager().getDataManager(); + // Shared runtimes expose actual transactional completion. Unsupported stores fail closed. + if (manager.hasSharedRuntime()) { + return manager.withAtomicUserTransaction(entry.owner(), plugin.getStorageType(), Map.of(), transaction -> { + int current = transaction.readRow().stream().filter(column -> column.getName().equals(entry.credit())).findFirst().map(column -> column.getValue().getInt()).orElse(0); + if (current < 1) return false; + transaction.writeValues(Map.of(entry.credit(), new com.bencodez.simpleapi.sql.data.DataValueInt(current - 1))); + return true; + }); + } + throw new IllegalStateException("Transactional activation ownership unavailable"); + } + public String startUnavailable(QueueEntry entry) { + if (!state.available()) return "feature storage unavailable"; + if (!plugin.getPerkHandler().isRestorationReady()) return "legacy perk restoration unavailable"; + if (!entry.credit().isEmpty() && !plugin.getUserManager().getDataManager().hasSharedRuntime()) return "transactional activation storage unavailable"; + Perk perk = plugin.getPerkHandler().getPerk(entry.perk()); + if (perk == null) return "perk definition unavailable"; + if (perk.getInteractionOptions().banked()) return plugin.getBankedPerks().grantUnavailable(entry.owner(), perk, perk.getTime()); + return null; + } + /** Estimated waits only when every predecessor has a finite absolute duration and known eligibility. */ + public java.util.concurrent.CompletableFuture> estimatedWaits() { + List entries = state.queue().stream().limit(32).toList(); + List active = List.copyOf(plugin.getPerkHandler().getActivePerks()); + long now = System.currentTimeMillis(), initial = 0; + if (!plugin.getPerkHandler().isRestorationReady() || !state.available() || state.paused() || starting.get()) return java.util.concurrent.CompletableFuture.completedFuture(Map.of()); + for (Perk perk : active) { + if (perk.getInteractionOptions().banked() || perk.isLastForever() || perk.getKnownExpiration() <= 0) return java.util.concurrent.CompletableFuture.completedFuture(Map.of()); + initial = Math.max(initial, Math.max(0, perk.getKnownExpiration() - now)); + } + List> checks = new ArrayList<>(); + for (QueueEntry entry : entries) { + var eligible = new java.util.concurrent.CompletableFuture(); checks.add(eligible); + Perk perk = plugin.getPerkHandler().getPerk(entry.perk()); + if (entry.stage() != FeatureState.Stage.WAITING || startUnavailable(entry) != null || perk == null || perk.getInteractionOptions().banked() || perk.getTime() <= 0) { eligible.complete(false); continue; } + Player player = Bukkit.getPlayer(entry.owner()); + OwnerTasks.run(plugin, player, () -> { + boolean world = perk.isNotInDisabledWorld(player.getWorld().getName()); + boolean bypass = player.hasPermission("MCPerks.Perks.BypassCoolDown"); + // Starts wait until every active perk ends; the existing positive per-player cap then permits one start. + if (!world) { eligible.complete(false); return; } + state.storage(() -> { + var user = plugin.getMcperksUserManager().getMCPerksUser(entry.owner()); + return storedEligible(user, perk, bypass, now) && creditAvailable(entry); + }).whenComplete((value, error) -> eligible.complete(error == null && Boolean.TRUE.equals(value))); + }, () -> eligible.complete(false)); + } + long base = initial; + return java.util.concurrent.CompletableFuture.allOf(checks.toArray(java.util.concurrent.CompletableFuture[]::new)).thenApply(ignored -> { + if (!state.available() || state.paused() || !state.queue().stream().limit(32).toList().equals(entries) || !List.copyOf(plugin.getPerkHandler().getActivePerks()).equals(active)) return Map.of(); + Map result = new HashMap<>(); long wait = base; + for (int i = 0; i < entries.size(); i++) { + if (!checks.get(i).join()) break; + result.put(entries.get(i).id(), wait / 1000); + wait += (long) plugin.getPerkHandler().getPerk(entries.get(i).perk()).getTime() * 1000; + } + return Map.copyOf(result); + }); + } + private void reply(UUID owner, Runnable task) { reply(owner, task, () -> {}); } + private void reply(UUID owner, Runnable task, Runnable dropped) { + if (retired || !plugin.isEnabled()) { dropped.run(); return; } + OwnerTasks.run(plugin, Bukkit.getPlayer(owner), task, dropped); + } +} diff --git a/MCPerks/src/main/java/com/bencodez/mcperks/scheduling/OwnerTasks.java b/MCPerks/src/main/java/com/bencodez/mcperks/scheduling/OwnerTasks.java new file mode 100644 index 0000000..19342bf --- /dev/null +++ b/MCPerks/src/main/java/com/bencodez/mcperks/scheduling/OwnerTasks.java @@ -0,0 +1,44 @@ +package com.bencodez.mcperks.scheduling; + +import java.util.concurrent.atomic.AtomicBoolean; +import org.bukkit.entity.Player; +import com.bencodez.mcperks.MCPerksMain; +import com.bencodez.simpleapi.folialib.enums.EntityTaskResult; + +/** Retired/rejected entity tasks complete their admission without touching game state. */ +public final class OwnerTasks { + private OwnerTasks() {} + public static void run(MCPerksMain plugin, Player player, Runnable action, Runnable retired) { + AtomicBoolean decided = new AtomicBoolean(); + Runnable unavailable = () -> { if (decided.compareAndSet(false, true)) retired.run(); }; + if (!plugin.isEnabled() || player == null) { unavailable.run(); return; } + try { + plugin.getBukkitScheduler().getFoliaLib().getImpl().runAtEntityWithFallback(player, + task -> { + if (!plugin.isEnabled() || !player.isValid()) { unavailable.run(); return; } + if (decided.compareAndSet(false, true)) action.run(); + }, unavailable) + .whenComplete((result, error) -> { if (error != null || result != EntityTaskResult.SUCCESS) unavailable.run(); }); + } catch (RuntimeException failure) { unavailable.run(); } + } + public static void later(MCPerksMain plugin, Player player, Runnable action, Runnable retired, long ticks) { + AtomicBoolean decided = new AtomicBoolean(); + Runnable unavailable = () -> { if (decided.compareAndSet(false, true)) retired.run(); }; + if (!plugin.isEnabled() || player == null) { unavailable.run(); return; } + try { + var folia = plugin.getBukkitScheduler().getFoliaLib(); + Runnable onOwner = () -> { + if (!plugin.isEnabled() || !player.isValid()) { unavailable.run(); return; } + if (decided.compareAndSet(false, true)) action.run(); + }; + // The bundled Spigot implementation's entity-delay overloads recurse (Paper inherits + // them). Its synchronous runLater uses Bukkit runTaskLater; check retirement there, + // on the server owner. Folia must retain the moving entity's scheduler and fallback. + var task = folia.isFolia() + ? folia.getImpl().runAtEntityLater(player, onOwner, unavailable, ticks) + : folia.getImpl().runLater(onOwner, ticks); + if (task == null) unavailable.run(); + } catch (RuntimeException failure) { unavailable.run(); } + } + +} 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..bb52b6b 100644 --- a/MCPerks/src/main/java/com/bencodez/mcperks/userapi/MCPerksUser.java +++ b/MCPerks/src/main/java/com/bencodez/mcperks/userapi/MCPerksUser.java @@ -153,8 +153,12 @@ public boolean isIgnorePerkEffects() { return getData().getBoolean("IgnoreEffects"); } - public void setIgnoreEffects(boolean value) { - getData().setBoolean("IgnoreEffects", value); - } + public void setIgnoreEffects(boolean value) { + var data = getData(); + var banks = plugin.getBankedPerks(); + if (banks == null) data.setBoolean("IgnoreEffects", value); + else banks.changeIgnoreEffects(UUID.fromString(getUUID()), value, + () -> data.setBoolean("IgnoreEffects", value)); + } } diff --git a/MCPerks/src/main/java/com/bencodez/mcperks/userapi/UsageCharges.java b/MCPerks/src/main/java/com/bencodez/mcperks/userapi/UsageCharges.java new file mode 100644 index 0000000..1057d63 --- /dev/null +++ b/MCPerks/src/main/java/com/bencodez/mcperks/userapi/UsageCharges.java @@ -0,0 +1,198 @@ +package com.bencodez.mcperks.userapi; + +import java.io.*; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.function.Supplier; + +/** Ordered durable personal charge ledger. No Bukkit or SQL access occurs on its worker. */ +public final class UsageCharges implements AutoCloseable { + public enum Kind { AutoBlockRefill, VeinMining, RightClickHarvest } + public record Reservation(UUID id, UUID owner, Kind kind, int units) {} + private record Account(int[] remaining, long[] successful) { + Account copy() { return new Account(remaining.clone(), successful.clone()); } + } + private static final int MAX_ACCOUNTS = 10000, MAX_RESERVATIONS = 256, MAX_ORDINARY_OPERATIONS = 257, MAGIC = 0x4d435031; + private final Path file; + private final com.bencodez.mcperks.data.DurableFeatureFiles.Publisher publisher; + private final com.bencodez.mcperks.data.FeatureFileOwnership ownership; + private final ThreadPoolExecutor worker; + private volatile Map accounts = Map.of(); + private volatile Map reservations = Map.of(); + // Keep the existing ordinary queue budget (one running + 256 waiting). Existing + // durable identities each own a separate settlement slot on the same worker. + private final Semaphore ordinarySlots = new Semaphore(MAX_ORDINARY_OPERATIONS); + private final Map> settlements = new HashMap<>(); + private volatile boolean failed; + private final CompletableFuture ready; + + public UsageCharges(Path file) { this(file, com.bencodez.mcperks.data.DurableFeatureFiles::publish); } + UsageCharges(Path file, com.bencodez.mcperks.data.DurableFeatureFiles.Publisher publisher) { + this.file = file; this.publisher = publisher; + ownership = new com.bencodez.mcperks.data.FeatureFileOwnership(file); + worker = new ThreadPoolExecutor(1, 1, 0, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(MAX_ORDINARY_OPERATIONS + MAX_RESERVATIONS - 1), r -> { + Thread t = new Thread(r, "MCPerks-charge-store"); t.setDaemon(true); return t; + }, new ThreadPoolExecutor.AbortPolicy()) { + @Override protected void terminated() { ownership.drained(); } + }; + ready = submit(() -> { + try { ownership.awaitPredecessor(); } + catch (RuntimeException | Error failure) { failed = true; throw failure; } + read(); return null; + }); + } + public CompletableFuture ready() { return ready; } + public boolean available() { return ready.isDone() && !ready.isCompletedExceptionally() && !failed; } + public int get(UUID owner, Kind kind) { + Account account = accounts.get(owner); return account == null ? 0 : account.remaining[kind.ordinal()]; + } + public long successful(UUID owner, Kind kind) { + Account account = accounts.get(owner); return account == null ? 0 : account.successful[kind.ordinal()]; + } + public CompletableFuture set(UUID owner, Kind kind, int amount) { + if (amount < 0) return CompletableFuture.failedFuture(new IllegalArgumentException("Negative charge balance")); + return mutate(owner, kind, amount, false); + } + public CompletableFuture add(UUID owner, Kind kind, int amount) { + if (amount < 0) return CompletableFuture.failedFuture(new IllegalArgumentException("Negative charge amount")); + return mutate(owner, kind, amount, true); + } + private CompletableFuture mutate(UUID owner, Kind kind, int amount, boolean add) { + return submit(() -> { + Map next = copyAccounts(); Account a = account(next, owner); + a.remaining[kind.ordinal()] = add ? (int) Math.min(Integer.MAX_VALUE, + (long) a.remaining[kind.ordinal()] + amount) : amount; + publish(next, reservations); return a.remaining[kind.ordinal()]; + }); + } + /** Reserves up to the available budget, durably, before any irreversible effect. */ + public CompletableFuture reserve(UUID owner, Kind kind, int requested) { + if (requested < 1 || requested > 64) return CompletableFuture.failedFuture(new IllegalArgumentException("Invalid budget")); + return submit(() -> { + if (reservations.size() >= MAX_RESERVATIONS) throw new IllegalStateException("Charge reservation capacity reached"); + int units = Math.min(get(owner, kind), requested); + if (units == 0) return null; + Map next = copyAccounts(); Account a = account(next, owner); + a.remaining[kind.ordinal()] -= units; + Reservation reservation = new Reservation(UUID.randomUUID(), owner, kind, units); + Map pending = new HashMap<>(reservations); pending.put(reservation.id(), reservation); + publish(next, pending); return reservation; + }); + } + /** Refund only units proved not to have had an effect. Uncertain units stay spent. */ + public CompletableFuture finish(Reservation reservation, int successful, int unused) { + if (reservation == null || successful < 0 || unused < 0 || (long) successful + unused > reservation.units()) + return CompletableFuture.failedFuture(new IllegalArgumentException("Invalid completion")); + synchronized (settlements) { + CompletableFuture existing = settlements.get(reservation.id()); + // Only an exact live durable token can admit settlement. Duplicate callbacks + // share the first completion rather than occupying another queue/waiter slot. + if (!reservation.equals(reservations.get(reservation.id()))) return CompletableFuture.completedFuture(null); + if (existing != null) return existing; + CompletableFuture future = new CompletableFuture<>(); + settlements.put(reservation.id(), future); + try { worker.execute(() -> { + try { + if (failed) throw new IllegalStateException("Charge store unavailable until restart"); + if (reservation.equals(reservations.get(reservation.id()))) { + Map next = copyAccounts(); Account a = account(next, reservation.owner()); + int index = reservation.kind().ordinal(); + a.remaining[index] = (int) Math.min(Integer.MAX_VALUE, (long) a.remaining[index] + unused); + a.successful[index] = saturatingAdd(a.successful[index], successful); + Map pending = new HashMap<>(reservations); pending.remove(reservation.id()); + publish(next, pending); + } + future.complete(null); + } catch (Throwable error) { future.completeExceptionally(error); } + finally { synchronized (settlements) { settlements.remove(reservation.id(), future); } } + }); } catch (RejectedExecutionException error) { + settlements.remove(reservation.id(), future); future.completeExceptionally(error); + } + return future; + } + } + public CompletableFuture record(UUID owner, Kind kind, int successful) { + if (successful < 0 || successful > 64) return CompletableFuture.failedFuture(new IllegalArgumentException("Invalid count")); + if (successful == 0) return CompletableFuture.completedFuture(null); + return submit(() -> { + Map next = copyAccounts(); Account a = account(next, owner); + a.successful[kind.ordinal()] = saturatingAdd(a.successful[kind.ordinal()], successful); + publish(next, reservations); return null; + }); + } + private static long saturatingAdd(long old, int amount) { return old > Long.MAX_VALUE - amount ? Long.MAX_VALUE : old + amount; } + private Account account(Map next, UUID owner) { + Account a = next.get(Objects.requireNonNull(owner)); + if (a == null) { + if (next.size() >= MAX_ACCOUNTS) throw new IllegalStateException("Charge account capacity reached"); + a = new Account(new int[Kind.values().length], new long[Kind.values().length]); next.put(owner, a); + } + return a; + } + private Map copyAccounts() { + Map result = new HashMap<>(); accounts.forEach((id, a) -> result.put(id, a.copy())); return result; + } + private CompletableFuture submit(Supplier operation) { + CompletableFuture future = new CompletableFuture<>(); + if (!ordinarySlots.tryAcquire()) return CompletableFuture.failedFuture(new RejectedExecutionException("Charge operation capacity reached")); + try { worker.execute(() -> { + try { + if (failed) throw new IllegalStateException("Charge store unavailable until restart"); + future.complete(operation.get()); + } catch (Throwable error) { future.completeExceptionally(error); } + finally { ordinarySlots.release(); } + }); } catch (RejectedExecutionException e) { ordinarySlots.release(); future.completeExceptionally(e); } + return future; + } + private void publish(Map next, Map pending) { + Path staged = null; + try { + Files.createDirectories(file.toAbsolutePath().getParent()); + if (Files.isSymbolicLink(file)) throw new IOException("Charge file is a symlink"); + staged = Files.createTempFile(file.toAbsolutePath().getParent(), ".charges-", ".tmp"); + try (DataOutputStream out = new DataOutputStream(new BufferedOutputStream(Files.newOutputStream(staged)))) { + out.writeInt(MAGIC); out.writeInt(next.size()); + for (Map.Entry entry : next.entrySet()) { + writeUuid(out, entry.getKey()); + for (int i = 0; i < Kind.values().length; i++) { out.writeInt(entry.getValue().remaining[i]); out.writeLong(entry.getValue().successful[i]); } + } + out.writeInt(pending.size()); + for (Reservation r : pending.values()) { writeUuid(out, r.id()); writeUuid(out, r.owner()); out.writeInt(r.kind().ordinal()); out.writeInt(r.units()); } + } + publisher.publish(staged, file); + accounts = Collections.unmodifiableMap(next); reservations = Collections.unmodifiableMap(pending); + } catch (IOException e) { + failed = true; throw new CompletionException(e); // publication may be uncertain; freeze all future effects + } finally { if (staged != null) try { Files.deleteIfExists(staged); } catch (IOException ignored) {} } + } + private void read() { + try { + if (Files.isSymbolicLink(file)) throw new IOException("Charge file is a symlink"); + if (!Files.exists(file)) return; + if (Files.size(file) > 2_000_000) throw new IOException("Oversized charge ledger"); + Map recovered = new HashMap<>(); + try (DataInputStream in = new DataInputStream(new BufferedInputStream(Files.newInputStream(file)))) { + if (in.readInt() != MAGIC) throw new IOException("Invalid charge ledger version"); + int count = bounded(in.readInt(), MAX_ACCOUNTS); + for (int i = 0; i < count; i++) { + UUID id = readUuid(in); Account a = new Account(new int[3], new long[3]); + for (int j = 0; j < 3; j++) { a.remaining[j] = in.readInt(); a.successful[j] = in.readLong(); if (a.remaining[j] < 0 || a.successful[j] < 0) throw new IOException("Invalid counters"); } + if (recovered.put(id, a) != null) throw new IOException("Duplicate account"); + } + int countReservations = bounded(in.readInt(), MAX_RESERVATIONS); Set ids = new HashSet<>(); + for (int i = 0; i < countReservations; i++) { + UUID id = readUuid(in), owner = readUuid(in); int kind = in.readInt(), units = in.readInt(); + if (!ids.add(id) || !recovered.containsKey(owner) || kind < 0 || kind >= 3 || units < 1 || units > 64) throw new IOException("Invalid reservation"); + } + if (in.read() != -1) throw new IOException("Trailing charge ledger data"); + } + // Recovery never refunds possibly executed gameplay. Old tokens cannot authorize callbacks. + accounts = Collections.unmodifiableMap(recovered); reservations = Map.of(); + } catch (IOException e) { failed = true; throw new CompletionException(e); } + } + private static int bounded(int value, int max) throws IOException { if (value < 0 || value > max) throw new IOException("Invalid ledger count"); return value; } + private static void writeUuid(DataOutputStream out, UUID id) throws IOException { out.writeLong(id.getMostSignificantBits()); out.writeLong(id.getLeastSignificantBits()); } + private static UUID readUuid(DataInputStream in) throws IOException { return new UUID(in.readLong(), in.readLong()); } + @Override public void close() { worker.shutdown(); } // Drain queued writes without blocking an owner thread. +} 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..e984e6b --- /dev/null +++ b/MCPerks/src/main/java/com/bencodez/mcperks/userapi/UserStorageConfirmation.java @@ -0,0 +1,33 @@ +package com.bencodez.mcperks.userapi; + +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import com.bencodez.mcperks.MCPerksMain; + +/** Confirms the canonical user cache on storage, including writes staged by owner-thread setters. */ +public final class UserStorageConfirmation { + private UserStorageConfirmation() {} + public static CompletableFuture confirm(MCPerksMain plugin, MCPerksUser user) { + var confirmed = new CompletableFuture(); + plugin.getFeatureState().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.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); + } + }); + 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.orTimeout(30, TimeUnit.SECONDS); + } +} diff --git a/MCPerks/src/main/resources/Config.yml b/MCPerks/src/main/resources/Config.yml index 6b190fe..dfd589f 100644 --- a/MCPerks/src/main/resources/Config.yml +++ b/MCPerks/src/main/resources/Config.yml @@ -224,4 +224,8 @@ JavascriptEngine: # Enables the JavaScript command. # This only controls command access/registration, not whether JavaScript support itself is enabled. # Legacy option: EnableJavascriptCommand - CommandEnabled: false \ No newline at end of file + CommandEnabled: false + +# Optional local, aggregate-only counters. No external reporting. Counters reset on restart. +LocalStatistics: + Enabled: false diff --git a/MCPerks/src/test/java/com/bencodez/mcperks/tests/MCPerksReloadLifecycleTest.java b/MCPerks/src/test/java/com/bencodez/mcperks/tests/MCPerksReloadLifecycleTest.java new file mode 100644 index 0000000..8cf0234 --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/MCPerksReloadLifecycleTest.java @@ -0,0 +1,122 @@ +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.FeatureState; +import com.bencodez.mcperks.perk.QueuePlanner; + +/** Production reload admission remains pending until the ordered snapshot settles. */ +@org.junit.jupiter.api.Timeout(10) +class MCPerksReloadLifecycleTest { + @Test + void reloadCommandBoundaryJoinsPendingOperationAndReportsBarrierFailure() throws Exception { + MCPerksMain plugin = mock(MCPerksMain.class, CALLS_REAL_METHODS); + FeatureState state = mock(FeatureState.class); + QueuePlanner planner = mock(QueuePlanner.class); + CompletableFuture barrier = new CompletableFuture<>(); + when(plugin.getFeatureState()).thenReturn(state); + when(plugin.getQueuePlanner()).thenReturn(planner); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getLogger()).thenReturn(Logger.getLogger("reload-test")); + set(plugin, "featureState", state); + set(plugin, "queuePlanner", planner); + 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"); + verify(planner).suspend(); + + 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()); + verify(planner).resume(); + + CompletableFuture successor = plugin.reloadAsync(); + assertNotSame(first, successor, "a completed failure must not poison a later reload request"); + } + + @Test + void reloadWaitsForOwnerSchedulerAfterStorageBarrier() throws Exception { + MCPerksMain plugin = mock(MCPerksMain.class, CALLS_REAL_METHODS); + FeatureState state = mock(FeatureState.class); + QueuePlanner planner = mock(QueuePlanner.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.getFeatureState()).thenReturn(state); + when(plugin.getQueuePlanner()).thenReturn(planner); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getLogger()).thenReturn(Logger.getLogger("reload-test")); + set(plugin, "featureState", state); + set(plugin, "queuePlanner", planner); + 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"); + verify(planner).suspend(); + verify(planner, never()).resume(); + } + + @Test + void rejectedOwnerDispatchFailsReloadAndReleasesQueueAdmission() throws Exception { + MCPerksMain plugin = mock(MCPerksMain.class, CALLS_REAL_METHODS); + FeatureState state = mock(FeatureState.class); + QueuePlanner planner = mock(QueuePlanner.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.getFeatureState()).thenReturn(state); + when(plugin.getQueuePlanner()).thenReturn(planner); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getLogger()).thenReturn(Logger.getLogger("reload-test")); + set(plugin, "featureState", state); + set(plugin, "queuePlanner", planner); + 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()); + verify(planner).resume(); + } + + 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..b6a977d --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/TestAccess.java @@ -0,0 +1,67 @@ +package com.bencodez.mcperks.tests; + +import java.lang.reflect.InvocationTargetException; +import java.nio.file.Path; +import java.sql.Timestamp; +import java.util.List; +import java.util.function.LongSupplier; +import com.bencodez.mcperks.MCPerksMain; +import com.bencodez.mcperks.data.DurableFeatureFiles; +import com.bencodez.mcperks.data.FeatureState; +import com.bencodez.mcperks.data.ServerData; +import com.bencodez.mcperks.perk.BankedPerks; +import com.bencodez.mcperks.perk.Perk; +import com.bencodez.mcperks.perk.PerkHandler; +import com.bencodez.mcperks.userapi.MCPerksUser; +import com.bencodez.mcperks.userapi.UsageCharges; + +/** Narrow access to existing package-private test seams without changing production APIs. */ +public final class TestAccess { + private TestAccess() {} + + public static FeatureState newFeatureState(Path file, DurableFeatureFiles.Publisher publisher) { + return construct(FeatureState.class, new Class[] {Path.class, DurableFeatureFiles.Publisher.class}, file, publisher); + } + public static UsageCharges newUsageCharges(Path file, DurableFeatureFiles.Publisher publisher) { + return construct(UsageCharges.class, new Class[] {Path.class, DurableFeatureFiles.Publisher.class}, file, publisher); + } + public static ServerData newServerData(MCPerksMain plugin, DurableFeatureFiles.Publisher publisher) { + return construct(ServerData.class, new Class[] {MCPerksMain.class, DurableFeatureFiles.Publisher.class}, plugin, publisher); + } + public static BankedPerks newBankedPerks(MCPerksMain plugin, FeatureState state, LongSupplier clock) { + return construct(BankedPerks.class, new Class[] {MCPerksMain.class, FeatureState.class, LongSupplier.class}, plugin, state, clock); + } + 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/BankedTimeCommandAcceptanceTest.java b/MCPerks/src/test/java/com/bencodez/mcperks/tests/commands/BankedTimeCommandAcceptanceTest.java new file mode 100644 index 0000000..afb3b29 --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/commands/BankedTimeCommandAcceptanceTest.java @@ -0,0 +1,334 @@ +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.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Logger; + +import org.bukkit.Bukkit; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedStatic; + +import com.bencodez.advancedcore.AdvancedCoreConfigOptions; +import com.bencodez.advancedcore.api.command.CommandHandler; +import com.bencodez.mcperks.MCPerksMain; +import com.bencodez.mcperks.commands.CommandLoader; +import com.bencodez.mcperks.data.DurableFeatureFiles; +import com.bencodez.mcperks.data.FeatureState; +import com.bencodez.mcperks.data.FeatureState.Bank; +import com.bencodez.mcperks.data.FeatureState.BankKey; +import com.bencodez.mcperks.perk.BankedPerks; +import com.bencodez.mcperks.perk.Effect; +import com.bencodez.mcperks.perk.InteractionOptions; +import com.bencodez.mcperks.perk.Perk; +import com.bencodez.mcperks.perk.PerkHandler; +import com.bencodez.mcperks.perk.PerkSystemType; +import com.bencodez.mcperks.perk.QueuePlanner; +import com.bencodez.mcperks.tests.data.FeatureStateFixture; +import com.bencodez.mcperks.userapi.MCPerksUser; +import com.bencodez.mcperks.userapi.UserManager; +import com.bencodez.simpleapi.command.TabCompleteHandler; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; + +/** Registered bank commands with real storage; native refresh/eligibility is a separate boundary. */ +class BankedTimeCommandAcceptanceTest { + @TempDir Path directory; + private final AtomicBoolean block = new AtomicBoolean(), failAfterPublish = new AtomicBoolean(); + private final CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + private final BlockingQueue dispatches = new LinkedBlockingQueue<>(); + private final BlockingQueue playerReplies = new LinkedBlockingQueue<>(), consoleReplies = new LinkedBlockingQueue<>(); + private final ArrayList commands = new ArrayList<>(); + private FeatureState state; + private BankKey selfKey, otherKey; + private MCPerksMain plugin; + private Player self, other; + private CommandSender console; + private Perk perk; + private PerkHandler perks; + private UserManager users; + private MCPerksUser user; + private BankedPerks banked; + private QueuePlanner planner; + private MockedStatic bukkit; + private byte[] saved; + private Map original; + private List queued; + + @BeforeEach void setup() throws Exception { + state = FeatureStateFixture.create(directory.resolve("features.dat"), (staged, target) -> { + if (block.compareAndSet(true, false)) { + entered.countDown(); + try { + if (!release.await(5, TimeUnit.SECONDS)) throw new IOException("Synthetic publication timeout"); + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new IOException(failure); + } + } + DurableFeatureFiles.publish(staged, target); + if (failAfterPublish.compareAndSet(true, false)) throw new IOException("Synthetic acknowledgement failure"); + }); + state.ready().get(5, TimeUnit.SECONDS); + self = player("SyntheticSelf"); other = player("SyntheticOther"); + selfKey = new BankKey(self.getUniqueId(), "probe"); otherKey = new BankKey(other.getUniqueId(), "probe"); + state.setBank(selfKey, 9000).get(5, TimeUnit.SECONDS); + state.setBank(otherKey, 12000).get(5, TimeUnit.SECONDS); + state.enqueue(otherKey.owner(), "queued", "PerkActivations_queued", 1).get(5, TimeUnit.SECONDS); + original = state.banks(); queued = state.queue(); saved = Files.readAllBytes(directory.resolve("features.dat")); + plugin = mock(MCPerksMain.class); + when(plugin.getCommands()).thenReturn(commands); + when(plugin.getFeatureState()).thenReturn(state); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger()); + var options = mock(AdvancedCoreConfigOptions.class); + when(options.getFormatNoPerms()).thenReturn("Permission denied"); + when(plugin.getOptions()).thenReturn(options); + perk = mock(Perk.class); + when(perk.getPerk()).thenReturn("probe"); + when(perk.getPerkType()).thenReturn(PerkSystemType.PLAYER); + when(perk.getEffects()).thenReturn(new ArrayList<>(List.of(Effect.NoFallDamage))); + when(perk.getInteractionOptions()).thenReturn(options(true)); + perks = mock(PerkHandler.class); + when(perks.getLoadedPerks()).thenReturn(Map.of("probe", perk)); + when(perks.getPerk("probe")).thenReturn(perk); + when(plugin.getPerkHandler()).thenReturn(perks); + users = mock(UserManager.class); user = mock(MCPerksUser.class); + when(users.getMCPerksUser("SyntheticOther")).thenReturn(user); + when(user.getUUID()).thenReturn(otherKey.owner().toString()); + when(plugin.getMcperksUserManager()).thenReturn(users); + // Keep the actual pause/checkpoint/storage chain, isolating only native online refresh. + banked = spy(new BankedPerks(plugin, state)); + doNothing().when(banked).refresh(any(UUID.class)); + when(plugin.getBankedPerks()).thenReturn(banked); + planner = mock(QueuePlanner.class); + when(plugin.getQueuePlanner()).thenReturn(planner); + var scheduler = mock(BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + doAnswer(call -> { dispatches.add(call.getArgument(1)); return null; }) + .when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class)); + doAnswer(call -> { playerReplies.add(call.getArgument(1)); return null; }) + .when(scheduler).runTask(eq(plugin), any(Runnable.class), eq(self)); + doAnswer(call -> { consoleReplies.add(call.getArgument(1)); return null; }) + .when(scheduler).runTask(eq(plugin), any(Runnable.class)); + TabCompleteHandler.getInstance().addTabCompleteOption("(Player)", "SyntheticOther"); + var loader = new CommandLoader(plugin); + loader.loadTabComplete(); + for (String name : List.of("registerFeatureArgumentMatchers", "loadPlannerCommands")) { + var method = CommandLoader.class.getDeclaredMethod(name); method.setAccessible(true); method.invoke(loader); + } + console = mock(CommandSender.class); + when(console.hasPermission(anyString())).thenReturn(true); + bukkit = mockStatic(Bukkit.class); + bukkit.when(() -> Bukkit.getPlayer("SyntheticOther")).thenReturn(other); + clearInvocations(user, users, banked, perks); + } + + @AfterEach void close() throws Exception { + release.countDown(); + try { if (state != null) retire(state); } + finally { if (bukkit != null) bukkit.close(); } + } + + @ParameterizedTest @ValueSource(strings = {"GetBankedTime", "SetBankedTime", "PauseBankedTime", "ResumeBankedTime"}) + void deniedRegisteredCommandsCannotDispatchOrMutate(String name) throws Exception { + assertEquals(name.equals("SetBankedTime") ? "MCPerks.SetBankedTime" : "MCPerks.BankedTime", command(name).getPerm()); + when(self.hasPermission(anyString())).thenReturn(false); + assertTrue(command(name).runCommand(self, arguments(name))); + verify(self).sendMessage(contains("Permission denied")); + assertTrue(dispatches.isEmpty()); + assertUnchanged(); + verifyNoInteractions(users, user, banked, planner); + } + + @ParameterizedTest @ValueSource(strings = {"GetBankedTime", "PauseBankedTime", "ResumeBankedTime"}) + void consoleCannotReadOrPauseSomeOtherPlayersBank(String name) throws Exception { + invoke(console, arguments(name)); + verify(console).sendMessage(contains("Player only")); + assertUnchanged(); verifyNoInteractions(users, user, banked, planner); + } + + @Test void displayUsesTheSenderAndCannotSpendTimeOrMintAMissingGrant() throws Exception { + invoke(self, "GetBankedTime", "probe"); + verify(self).sendMessage(contains("Banked: 9s; paused=false")); + verify(self, never()).sendMessage(contains("Banked: 12s")); + invoke(self, "GetBankedTime", "missing"); + verify(self).sendMessage(contains("No banked grant. Legacy timers retain their absolute expiration.")); + assertUnchanged(); verifyNoInteractions(users, user, banked, planner); + } + + @ParameterizedTest @ValueSource(strings = {"SetBankedTime", "PauseBankedTime", "ResumeBankedTime"}) + void successWaitsForPublicationAndCorrectSenderReplyWithoutGrantingAnActivation(String name) throws Exception { + if (name.equals("ResumeBankedTime")) state.pauseBank(selfKey, true).get(5, TimeUnit.SECONDS); + Map before = state.banks(); + block.set(true); + CommandSender sender = name.equals("SetBankedTime") ? console : self; + invoke(sender, arguments(name)); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); + assertEquals(before, state.banks()); assertEquals(queued, state.queue()); + assertTrue(playerReplies.isEmpty()); assertTrue(consoleReplies.isEmpty()); + verify(sender, never()).sendMessage(anyString()); + verify(banked, never()).refresh(any(UUID.class)); + } finally { release.countDown(); } + Runnable response = reply(sender); + verify(sender, never()).sendMessage(anyString()); + response.run(); + assertTrue(sender == self ? consoleReplies.isEmpty() : playerReplies.isEmpty()); + verify(sender).sendMessage(contains(name.equals("SetBankedTime") ? "Banked time saved" : "Banked pause state saved")); + Map expected = name.equals("SetBankedTime") + ? Map.of(selfKey, before.get(selfKey), otherKey, new Bank(21000, false)) + : Map.of(selfKey, new Bank(9000, name.equals("PauseBankedTime")), otherKey, before.get(otherKey)); + assertEquals(expected, state.banks()); assertEquals(queued, state.queue()); + UUID target = name.equals("SetBankedTime") ? otherKey.owner() : selfKey.owner(); + verify(banked, atLeastOnce()).refresh(target); + verify(banked, never()).refresh(eq(target.equals(selfKey.owner()) ? otherKey.owner() : selfKey.owner())); + verify(planner).refreshLegacyView(); verify(planner).check(); + assertNoActivation(); + assertRecovered(expected); + } + + @ParameterizedTest @ValueSource(strings = {"SetBankedTime", "PauseBankedTime", "ResumeBankedTime"}) + void acknowledgementFailureNeverClaimsSuccessOrRefreshesAnUnconfirmedBank(String name) throws Exception { + if (name.equals("ResumeBankedTime")) state.pauseBank(selfKey, true).get(5, TimeUnit.SECONDS); + Map before = state.banks(); + byte[] beforeBytes = Files.readAllBytes(directory.resolve("features.dat")); + failAfterPublish.set(true); + CommandSender sender = name.equals("SetBankedTime") ? console : self; + invoke(sender, arguments(name)); + Runnable response = reply(sender); + verify(sender, never()).sendMessage(anyString()); + response.run(); + assertFalse(state.available()); assertEquals(before, state.banks()); assertEquals(queued, state.queue()); + verify(sender).sendMessage(contains("failed or entry unavailable")); + verify(sender, never()).sendMessage(contains("saved")); + verify(sender, never()).sendMessage(contains("Synthetic acknowledgement failure")); + verify(banked, never()).refresh(any(UUID.class)); + assertNoActivation(); + // Publication happened: an error is uncertainty, not a promise of disk rollback. + assertFalse(java.util.Arrays.equals(beforeBytes, Files.readAllBytes(directory.resolve("features.dat")))); + invoke(self, "GetBankedTime", "probe"); + verify(self).sendMessage(contains("Banked state is unavailable")); + verify(self, never()).sendMessage(contains("Banked: ")); + } + + @ParameterizedTest @ValueSource(strings = {"PauseBankedTime", "ResumeBankedTime"}) + void absentGrantCannotBeRemintedByPauseOrResume(String name) throws Exception { + invoke(self, name, "missing"); reply(self).run(); + verify(self).sendMessage(contains("failed or entry unavailable")); + verify(self, never()).sendMessage(contains("saved")); + assertUnchanged(); assertNoActivation(); + } + + @ParameterizedTest @ValueSource(strings = {"-1", "31536001", "9223372036854775807", "invalid"}) + void invalidAmountsCannotReplaceOrAddTime(String amount) throws Exception { + invoke(console, "SetBankedTime", "SyntheticOther", "probe", amount); + if (amount.equals("-1") || amount.equals("31536001")) { + reply(console).run(); verify(console).sendMessage(contains("failed or entry unavailable")); + } else verify(console).sendMessage(contains("Use a nonnegative time of at most one year")); + assertUnchanged(); assertNoActivation(); + verify(banked, never()).refresh(any(UUID.class)); + } + + @ParameterizedTest @ValueSource(longs = {0, 31536000}) + void supportedEndpointsStoreSecondsAsMillisecondsForTheNamedOwner(long seconds) throws Exception { + invoke(console, "SetBankedTime", "SyntheticOther", "probe", Long.toString(seconds)); reply(console).run(); + assertEquals(new Bank(seconds * 1000, false), state.banks().get(otherKey)); + assertEquals(original.get(selfKey), state.banks().get(selfKey)); + verify(users).getMCPerksUser("SyntheticOther"); verify(console).sendMessage(contains("Banked time saved")); + assertNoActivation(); assertRecovered(state.banks()); + } + + @ParameterizedTest @ValueSource(strings = {"missing", "disabled", "shared", "periodic", "reward"}) + void unsupportedPerksStopBeforeUserLookupOrStorage(String mode) throws Exception { + switch (mode) { + case "missing" -> when(perks.getPerk("probe")).thenReturn(null); + case "disabled" -> when(perk.getInteractionOptions()).thenReturn(options(false)); + case "shared" -> when(perk.getPerkType()).thenReturn(PerkSystemType.ALL); + case "periodic" -> when(perk.getReActivateEffectsTimer()).thenReturn(1); + case "reward" -> when(perk.getEffects()).thenReturn(new ArrayList<>(List.of(Effect.Rewards))); + default -> fail("Unknown fixture mode"); + } + invoke(console, arguments("SetBankedTime")); + verify(console).sendMessage(contains("Enable BankedTime for a supported PLAYER perk first")); + assertUnchanged(); verifyNoInteractions(users, user, banked, planner); + } + + @Test void malformedStoredIdentityAndWrongArityDoNotMutate() throws Exception { + when(user.getUUID()).thenReturn("invalid-uuid"); + invoke(console, arguments("SetBankedTime")); + verify(console).sendMessage(contains("Use a nonnegative time of at most one year")); + assertFalse(command("SetBankedTime").runCommand(console, new String[] {"SetBankedTime", "SyntheticOther", "probe"})); + assertFalse(command("PauseBankedTime").runCommand(self, new String[] {"PauseBankedTime", "probe", "extra"})); + assertTrue(dispatches.isEmpty()); assertUnchanged(); assertNoActivation(); + } + + private static InteractionOptions options(boolean banked) { + return new InteractionOptions(false, banked, 16, 8, List.of(), List.of(), List.of(), List.of()); + } + private static Player player(String name) { + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); when(player.getName()).thenReturn(name); + when(player.hasPermission(anyString())).thenReturn(true); return player; + } + private CommandHandler command(String name) { + return commands.stream().filter(c -> c.getArgs()[0].equals(name)).findFirst().orElseThrow(); + } + private String[] arguments(String name) { + return name.equals("SetBankedTime") ? new String[] {name, "SyntheticOther", "probe", "21"} : new String[] {name, "probe"}; + } + private void invoke(CommandSender sender, String... args) throws Exception { + assertTrue(command(args[0]).runCommand(sender, args)); + Runnable task = dispatches.poll(5, TimeUnit.SECONDS); + assertNotNull(task, "accepted command must reach the registered dispatcher"); task.run(); + } + private Runnable reply(CommandSender sender) throws Exception { + Runnable task = (sender == self ? playerReplies : consoleReplies).poll(5, TimeUnit.SECONDS); + assertNotNull(task, "durable completion must schedule the sender reply"); return task; + } + private void assertUnchanged() throws Exception { + state.storage(() -> null).get(5, TimeUnit.SECONDS); + assertEquals(original, state.banks()); assertEquals(queued, state.queue()); + assertArrayEquals(saved, Files.readAllBytes(directory.resolve("features.dat"))); + assertTrue(playerReplies.isEmpty()); assertTrue(consoleReplies.isEmpty()); + } + private void assertNoActivation() { + // UUID resolution is the only user operation permitted by these commands. + assertTrue(mockingDetails(user).getInvocations().stream().allMatch(i -> i.getMethod().getName().equals("getUUID"))); + verify(banked, never()).grant(any(), any(), anyInt()); verify(banked, never()).grantConfirmed(any(), any(), anyInt()); + verify(banked, never()).granted(any(), any()); verify(banked, never()).grantedConfirmed(any(), any()); + assertTrue(mockingDetails(perk).getInvocations().stream().allMatch(i -> i.getMethod().getName().startsWith("get"))); + } + private static void retire(FeatureState target) throws Exception { + target.close(); var field = FeatureState.class.getDeclaredField("worker"); field.setAccessible(true); + assertTrue(((ExecutorService) field.get(target)).awaitTermination(5, TimeUnit.SECONDS)); + } + private void assertRecovered(Map expected) throws Exception { + retire(state); + FeatureState recovered = new FeatureState(directory.resolve("features.dat")); + try { + recovered.ready().get(5, TimeUnit.SECONDS); + assertEquals(expected, recovered.banks()); assertEquals(queued, recovered.queue()); + } finally { retire(recovered); } + } +} diff --git a/MCPerks/src/test/java/com/bencodez/mcperks/tests/commands/CommandArgumentMatchingTest.java b/MCPerks/src/test/java/com/bencodez/mcperks/tests/commands/CommandArgumentMatchingTest.java new file mode 100644 index 0000000..a32f6a3 --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/commands/CommandArgumentMatchingTest.java @@ -0,0 +1,113 @@ +package com.bencodez.mcperks.tests.commands; +import com.bencodez.mcperks.commands.*; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.when; + +import java.util.concurrent.atomic.AtomicReference; + +import org.bukkit.command.CommandSender; +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.api.command.CommandHandler; +import com.bencodez.mcperks.MCPerksMain; + +/** Regression tests for the production SimpleAPI command matcher path. */ +class CommandArgumentMatchingTest { + + @Test + void featureArgumentMatchersReachHandlersThroughRunCommand() throws Exception { + MCPerksMain plugin = plugin(); + CommandLoader loader = new CommandLoader(plugin); + var register = CommandLoader.class.getDeclaredMethod("registerFeatureArgumentMatchers"); + register.setAccessible(true); + register.invoke(loader); + + AtomicReference received = new AtomicReference<>(); + CommandHandler command = command(plugin, + new String[] { "SetUsageCharges", "NativeOwner", "(effect)", "2" }, received); + CommandSender sender = mock(CommandSender.class); + when(sender.hasPermission("MCPerks.SetUsageCharges")).thenReturn(true); + + assertTrue(command.runCommand(sender, + new String[] { "SetUsageCharges", "NativeOwner", "AutoBlockRefill", "2" })); + assertArrayEquals(new String[] { "SetUsageCharges", "NativeOwner", "AutoBlockRefill", "2" }, + received.get()); + } + + @Test + void queueAndBankIdentifiersAndNumbersUseRegisteredMatchers() throws Exception { + MCPerksMain plugin = plugin(); + CommandLoader loader = new CommandLoader(plugin); + var register = CommandLoader.class.getDeclaredMethod("registerFeatureArgumentMatchers"); + register.setAccessible(true); + register.invoke(loader); + + CommandSender sender = mock(CommandSender.class); + when(sender.hasPermission("MCPerks.QueueMove")).thenReturn(true); + AtomicReference received = new AtomicReference<>(); + assertTrue(command(plugin, new String[] { "QueueMove", "(id)", "(position)" }, received) + .runCommand(sender, new String[] { "QueueMove", "entry", "12" })); + assertArrayEquals(new String[] { "QueueMove", "entry", "12" }, received.get()); + received.set(null); + when(sender.hasPermission("MCPerks.SetBankedTime")).thenReturn(true); + assertTrue(command(plugin, new String[] { "SetBankedTime", "NativeOwner", "LiveEffects", "(seconds)" }, received) + .runCommand(sender, new String[] { "SetBankedTime", "NativeOwner", "LiveEffects", "600" })); + assertArrayEquals(new String[] { "SetBankedTime", "NativeOwner", "LiveEffects", "600" }, received.get()); + } + + @Test + void wrongArityAndPermissionStillRejectDispatch() throws Exception { + MCPerksMain plugin = plugin(); + CommandLoader loader = new CommandLoader(plugin); + var register = CommandLoader.class.getDeclaredMethod("registerFeatureArgumentMatchers"); + register.setAccessible(true); + register.invoke(loader); + + AtomicReference received = new AtomicReference<>(); + CommandHandler command = command(plugin, new String[] { "AddUsageCharges", "NativeOwner", "(effect)", "2" }, + received); + CommandSender sender = mock(CommandSender.class); + when(sender.hasPermission("MCPerks.AddUsageCharges")).thenReturn(false); + + // The command API reports a handled permission denial as true, but the + // handler must never receive the invocation. + assertTrue(command.runCommand(sender, + new String[] { "AddUsageCharges", "NativeOwner", "VeinMining", "2" })); + when(sender.hasPermission("MCPerks.AddUsageCharges")).thenReturn(true); + assertFalse(command.runCommand(sender, new String[] { "AddUsageCharges", "NativeOwner", "VeinMining" })); + assertFalse(command.runCommand(sender, + new String[] { "AddUsageCharges", "NativeOwner", "VeinMining", "2", "extra" })); + assertFalse(command.runCommand(sender, + new String[] { "OtherCommand", "NativeOwner", "VeinMining", "2" })); + assertEquals(null, received.get()); + } + + private static CommandHandler command(MCPerksMain plugin, String[] args, AtomicReference received) { + return new CommandHandler(plugin, args, "MCPerks." + args[0], "test") { + @Override + public void execute(CommandSender sender, String[] actual) { + received.set(actual.clone()); + } + }; + } + + private static MCPerksMain plugin() { + MCPerksMain plugin = mock(MCPerksMain.class); + var options = mock(com.bencodez.advancedcore.AdvancedCoreConfigOptions.class); + when(options.getFormatNoPerms()).thenReturn(""); + when(plugin.getOptions()).thenReturn(options); + when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger()); + var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + doAnswer(invocation -> { invocation.getArgument(1).run(); return null; }) + .when(scheduler).runTaskAsynchronously(org.mockito.ArgumentMatchers.eq(plugin), + org.mockito.ArgumentMatchers.any(Runnable.class)); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + return plugin; + } +} diff --git a/MCPerks/src/test/java/com/bencodez/mcperks/tests/commands/DiagnosticsCommandAcceptanceTest.java b/MCPerks/src/test/java/com/bencodez/mcperks/tests/commands/DiagnosticsCommandAcceptanceTest.java new file mode 100644 index 0000000..89da65d --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/commands/DiagnosticsCommandAcceptanceTest.java @@ -0,0 +1,314 @@ +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.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.logging.Logger; + +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.command.CommandSender; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedStatic; + +import com.bencodez.advancedcore.AdvancedCoreConfigOptions; +import com.bencodez.advancedcore.api.command.CommandHandler; +import com.bencodez.mcperks.MCPerksMain; +import com.bencodez.mcperks.commands.CommandLoader; +import com.bencodez.mcperks.configs.Config; +import com.bencodez.mcperks.data.FeatureState; +import com.bencodez.mcperks.data.ServerData; +import com.bencodez.mcperks.listeners.UsageEffectListener; +import com.bencodez.mcperks.perk.Effect; +import com.bencodez.mcperks.perk.InteractionOptions; +import com.bencodez.mcperks.perk.Perk; +import com.bencodez.mcperks.perk.PerkHandler; +import com.bencodez.mcperks.perk.PerkSystemType; +import com.bencodez.mcperks.perk.QueuePlanner; +import com.bencodez.mcperks.userapi.MCPerksUser; +import com.bencodez.mcperks.userapi.UsageCharges; +import com.bencodez.mcperks.userapi.UserManager; +import com.bencodez.simpleapi.command.TabCompleteHandler; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; + +/** Registered command routing and reply ownership backed by real durable state. */ +class DiagnosticsCommandAcceptanceTest { + @TempDir Path directory; + private final ArrayList commands = new ArrayList<>(); + private final BlockingQueue dispatches = new LinkedBlockingQueue<>(); + private final BlockingQueue selfTasks = new LinkedBlockingQueue<>(); + private final BlockingQueue targetTasks = new LinkedBlockingQueue<>(); + private final BlockingQueue consoleTasks = new LinkedBlockingQueue<>(); + private FeatureState state; + private UsageCharges charges; + private MCPerksMain plugin; + private Player self, target; + private CommandSender console; + private UserManager users; + private MCPerksUser user; + private ServerData server; + private PerkHandler perks; + private QueuePlanner planner; + private MockedStatic bukkit; + private byte[] featureBytes, chargeBytes; + + @BeforeEach void setup() throws Exception { + state = new FeatureState(directory.resolve("features.dat")); + charges = new UsageCharges(directory.resolve("charges.dat")); + state.ready().get(5, TimeUnit.SECONDS); + charges.ready().get(5, TimeUnit.SECONDS); + self = player("SyntheticSelf"); target = player("SyntheticTarget"); + console = mock(CommandSender.class); + when(console.hasPermission(anyString())).thenReturn(true); + for (Player player : List.of(self, target)) { + state.setBank(new FeatureState.BankKey(player.getUniqueId(), "probe"), 9000).get(5, TimeUnit.SECONDS); + charges.set(player.getUniqueId(), UsageCharges.Kind.AutoBlockRefill, 7).get(5, TimeUnit.SECONDS); + } + state.enqueue(target.getUniqueId(), "queued", "PerkActivations_queued", 1).get(5, TimeUnit.SECONDS); + featureBytes = Files.readAllBytes(directory.resolve("features.dat")); + chargeBytes = Files.readAllBytes(directory.resolve("charges.dat")); + plugin = mock(MCPerksMain.class); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getCommands()).thenReturn(commands); + when(plugin.getFeatureState()).thenReturn(state); + when(plugin.getUsageCharges()).thenReturn(charges); + when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger()); + var options = mock(AdvancedCoreConfigOptions.class); + when(options.getFormatNoPerms()).thenReturn("Permission denied"); + when(plugin.getOptions()).thenReturn(options); + when(plugin.getConfigFile()).thenReturn(mock(Config.class)); + user = mock(MCPerksUser.class); + when(user.getActivations()).thenReturn(5); + when(user.getActivations("probe")).thenReturn(3); + users = mock(UserManager.class); + when(users.getMCPerksUser(any(UUID.class))).thenReturn(user); + when(plugin.getMcperksUserManager()).thenReturn(users); + server = mock(ServerData.class); + when(server.getData()).thenReturn(new YamlConfiguration()); + when(plugin.getMcperksServerData()).thenReturn(server); + Perk perk = mock(Perk.class); + when(perk.getPerk()).thenReturn("probe"); + when(perk.getPerkType()).thenReturn(PerkSystemType.PLAYER); + when(perk.getEffectedPlayers()).thenReturn(new ArrayList<>()); + when(perk.getEffects()).thenReturn(new ArrayList<>(List.of(Effect.AutoBlockRefill))); + when(perk.isNotInDisabledWorld(anyString())).thenReturn(true); + when(perk.getInteractionOptions()).thenReturn(new InteractionOptions(true, false, 16, 8, + List.of(), List.of(), List.of(), List.of())); + perks = mock(PerkHandler.class); + when(perks.getLoadedPerks()).thenReturn(Map.of("probe", perk)); + when(perks.getActivePerks()).thenReturn(new CopyOnWriteArrayList<>()); + when(perks.effectDecision(any(), anyString(), anyString())).thenReturn(PerkHandler.Decision.ACTIVE); + when(plugin.getPerkHandler()).thenReturn(perks); + when(plugin.getUsageEffects()).thenReturn(mock(UsageEffectListener.class)); + planner = mock(QueuePlanner.class); + when(planner.startUnavailable(any())).thenReturn("awaiting eligibility"); + when(plugin.getQueuePlanner()).thenReturn(planner); + var scheduler = mock(BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + doAnswer(call -> { dispatches.add(call.getArgument(1)); return null; }) + .when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class)); + doAnswer(call -> { selfTasks.add(call.getArgument(1)); return null; }) + .when(scheduler).runTask(eq(plugin), any(Runnable.class), eq(self)); + doAnswer(call -> { targetTasks.add(call.getArgument(1)); return null; }) + .when(scheduler).runTask(eq(plugin), any(Runnable.class), eq(target)); + doAnswer(call -> { consoleTasks.add(call.getArgument(1)); return null; }) + .when(scheduler).runTask(eq(plugin), any(Runnable.class)); + // AdvancedCore registers this player placeholder before MCPerks loads commands. + TabCompleteHandler.getInstance().addTabCompleteOption("(Player)", "SyntheticTarget", "Missing"); + var register = CommandLoader.class.getDeclaredMethod("loadPlannerCommands"); + register.setAccessible(true); + register.invoke(new CommandLoader(plugin)); + bukkit = mockStatic(Bukkit.class); + bukkit.when(() -> Bukkit.getPlayer("SyntheticTarget")).thenReturn(target); + bukkit.when(() -> Bukkit.getPlayerExact("SyntheticTarget")).thenReturn(target); + clearInvocations(self, target, user, users, server, perks, planner); + } + + @AfterEach void close() throws Exception { + if (bukkit != null) bukkit.close(); + for (Object store : new Object[] {state, charges}) { + if (store == null) continue; + ((AutoCloseable) store).close(); + var field = store.getClass().getDeclaredField("worker"); + field.setAccessible(true); + assertTrue(((ExecutorService) field.get(store)).awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @ParameterizedTest @ValueSource(booleans = {false, true}) + void deniedSelfAndOtherCommandsNeverReachDiagnosticLookupStorageOrSchedulers(boolean other) throws Exception { + when(self.hasPermission(anyString())).thenReturn(false); + assertEquals(other ? "MCPerks.Diagnose.Other" : "MCPerks.Diagnose", command(other).getPerm()); + assertTrue(command(other).runCommand(self, arguments(other, "SyntheticTarget"))); + verify(self).sendMessage(contains("Permission denied")); + // SimpleAPI may normalize the player argument before checking permission. + // The diagnostic handler's exact lookup and all later work must still be denied. + bukkit.verify(() -> Bukkit.getPlayerExact(anyString()), never()); + verifyNoInteractions(users, user); + assertTrue(dispatches.isEmpty()); assertTrue(selfTasks.isEmpty()); assertTrue(targetTasks.isEmpty()); + assertUnchanged(); + } + + @Test void selfPermissionCannotAuthorizeInspectionOfAnotherPlayer() throws Exception { + when(self.hasPermission("MCPerks.Diagnose.Other")).thenReturn(false); + assertTrue(command(true).runCommand(self, arguments(true, "SyntheticTarget"))); + verify(self).sendMessage(contains("Permission denied")); + assertTrue(dispatches.isEmpty()); + bukkit.verify(() -> Bukkit.getPlayerExact(anyString()), never()); + verifyNoInteractions(users); + assertUnchanged(); + } + + @ParameterizedTest @ValueSource(booleans = {false, true}) + void playerCommandsInspectOnlySelectedIdentityAndReplyOnRequester(boolean other) throws Exception { + Player selected = other ? target : self; + BlockingQueue selectedTasks = other ? targetTasks : selfTasks; + invoke(self, other, "SyntheticTarget"); + verifyNoInteractions(users); + verify(selected, never()).getWorld(); + take(selectedTasks).run(); + Runnable render = take(selectedTasks); + verify(users).getMCPerksUser(selected.getUniqueId()); + verify(users, never()).getMCPerksUser((other ? self : target).getUniqueId()); + verify(selected, never()).getWorld(); + verify(self, never()).sendMessage(anyString()); + render.run(); + Runnable reply = take(selfTasks); + verify(self, never()).sendMessage(anyString()); + reply.run(); + verify(self).sendMessage(contains("Perk decisions for " + selected.getName())); + verify(self).sendMessage(contains("charges=7")); + verify(self).sendMessage(contains("banked=9s")); + if (other) { + bukkit.verify(() -> Bukkit.getPlayerExact("SyntheticTarget")); + verify(target, never()).sendMessage(anyString()); + verify(self).sendMessage(contains("Queue position=1")); + } else bukkit.verifyNoInteractions(); + assertTrue(targetTasks.isEmpty()); assertTrue(selfTasks.isEmpty()); assertTrue(consoleTasks.isEmpty()); + assertUnchanged(); + } + + @Test void consoleOtherInspectionUsesTargetOwnerAndConsoleReplyScheduler() throws Exception { + invoke(console, true, "SyntheticTarget"); + take(targetTasks).run(); + take(targetTasks).run(); + Runnable reply = take(consoleTasks); + verify(console, never()).sendMessage(anyString()); + reply.run(); + verify(console).sendMessage(contains("Perk decisions for SyntheticTarget")); + verify(target, never()).sendMessage(anyString()); + assertTrue(selfTasks.isEmpty()); assertTrue(targetTasks.isEmpty()); + assertUnchanged(); + } + + @ParameterizedTest @ValueSource(booleans = {false, true}) + void missingOnlineTargetAndConsoleSelfRejectWithoutStorage(boolean other) throws Exception { + invoke(console, other, "Missing"); + verify(console).sendMessage(contains("An online player is required")); + if (other) bukkit.verify(() -> Bukkit.getPlayerExact("Missing")); + else bukkit.verifyNoInteractions(); + verifyNoInteractions(users, user); + assertTrue(targetTasks.isEmpty()); assertTrue(selfTasks.isEmpty()); assertTrue(consoleTasks.isEmpty()); + assertUnchanged(); + } + + @Test void disconnectBeforeRenderingCannotReportAnOnlineDiagnosis() throws Exception { + invoke(self, true, "SyntheticTarget"); + take(targetTasks).run(); + Runnable render = take(targetTasks); + when(target.isOnline()).thenReturn(false); + render.run(); + take(selfTasks).run(); + verify(self).sendMessage(contains("Player is offline; banked time is paused")); + verify(self, never()).sendMessage(contains("Perk decisions for")); + verify(target, never()).getWorld(); + assertUnchanged(); + } + + @Test void wrongArityNeverFallsThroughIntoAnotherRegisteredDiagnosis() throws Exception { + for (CommandHandler command : List.of(command(false), command(true))) { + assertFalse(command.runCommand(self, new String[] {"Diagnose", "SyntheticTarget", "extra"})); + assertFalse(command.runCommand(self, new String[] {"Other"})); + } + assertFalse(command(true).runCommand(self, new String[] {"Diagnose"})); + assertFalse(command(false).runCommand(self, arguments(true, "SyntheticTarget"))); + assertTrue(dispatches.isEmpty()); bukkit.verifyNoInteractions(); verifyNoInteractions(users); + assertUnchanged(); + } + + private Player player(String name) { + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + when(player.getName()).thenReturn(name); + when(player.isOnline()).thenReturn(true); + when(player.hasPermission(anyString())).thenReturn(true); + World world = mock(World.class); when(world.getName()).thenReturn("synthetic_world"); + when(player.getWorld()).thenReturn(world); + return player; + } + + private CommandHandler command(boolean other) { + return commands.stream().filter(c -> c.getArgs()[0].equals("Diagnose") + && c.getArgs().length == (other ? 2 : 1)).findFirst().orElseThrow(); + } + + private String[] arguments(boolean other, String name) { + return other ? new String[] {"Diagnose", name} : new String[] {"Diagnose"}; + } + + private void invoke(CommandSender sender, boolean other, String name) throws Exception { + assertTrue(command(other).runCommand(sender, arguments(other, name))); + take(dispatches).run(); + } + + private Runnable take(BlockingQueue tasks) throws Exception { + Runnable task = tasks.poll(5, TimeUnit.SECONDS); + assertNotNull(task, "accepted command stage must reach its scheduler"); + return task; + } + + private void assertUnchanged() throws Exception { + state.storage(() -> null).get(5, TimeUnit.SECONDS); + // A feature-worker barrier cannot observe a delayed mutation on the separate ledger worker. + var worker = UsageCharges.class.getDeclaredField("worker"); + worker.setAccessible(true); + ((ExecutorService) worker.get(charges)).submit(() -> {}).get(5, TimeUnit.SECONDS); + assertArrayEquals(featureBytes, Files.readAllBytes(directory.resolve("features.dat"))); + assertArrayEquals(chargeBytes, Files.readAllBytes(directory.resolve("charges.dat"))); + for (Player player : List.of(self, target)) { + assertEquals(7, charges.get(player.getUniqueId(), UsageCharges.Kind.AutoBlockRefill)); + assertEquals(0, charges.successful(player.getUniqueId(), UsageCharges.Kind.AutoBlockRefill)); + } + verify(user, never()).setActivations(anyInt()); + verify(user, never()).setActivations(anyString(), anyInt()); + verify(user, never()).setPerkCoolDown(any(Perk.class), anyLong()); + verify(user, never()).setPerkExperation(any(Perk.class), anyLong()); + verify(user, never()).setPerkTimesUsed(any(Perk.class), anyInt()); + assertFalse(mockingDetails(user).getInvocations().stream() + .anyMatch(call -> call.getMethod().getName().startsWith("addActivation"))); + verify(server, never()).saveData(); + verify(planner, never()).check(); + verify(planner, never()).refreshLegacyView(); + assertFalse(mockingDetails(perks).getInvocations().stream() + .anyMatch(call -> call.getMethod().getName().equals("runPerk"))); + } +} diff --git a/MCPerks/src/test/java/com/bencodez/mcperks/tests/commands/PerkDiagnosticsTest.java b/MCPerks/src/test/java/com/bencodez/mcperks/tests/commands/PerkDiagnosticsTest.java new file mode 100644 index 0000000..de6fca5 --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/commands/PerkDiagnosticsTest.java @@ -0,0 +1,218 @@ +package com.bencodez.mcperks.tests.commands; +import static com.bencodez.mcperks.tests.TestAccess.*; +import com.bencodez.mcperks.commands.*; + +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 org.bukkit.World; +import org.bukkit.command.CommandSender; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import com.bencodez.mcperks.MCPerksMain; +import com.bencodez.mcperks.data.FeatureState; +import com.bencodez.mcperks.data.ServerData; +import com.bencodez.mcperks.perk.*; +import com.bencodez.mcperks.perk.Effect; +import com.bencodez.mcperks.userapi.*; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; + +class PerkDiagnosticsTest { + @TempDir Path directory; + + private Perk perk(String name, UUID owner, boolean charged, Effect effect) { + Perk perk = mock(Perk.class); + when(perk.getPerk()).thenReturn(name); + doCallRealMethod().when(perk).isActivationLimitReached(anyInt()); + when(perk.getPerkType()).thenReturn(PerkSystemType.PLAYER); + when(perk.getEffectedPlayers()).thenReturn(new ArrayList<>(List.of(owner.toString()))); + when(perk.getEffects()).thenReturn(new ArrayList<>(List.of(effect))); + when(perk.isNotInDisabledWorld(anyString())).thenReturn(true); + when(perk.getInteractionOptions()).thenReturn(new InteractionOptions(charged, false, 16, 8, + List.of(), List.of(), List.of(), List.of())); + return perk; + } + + private enum ReloadedPolicy { WORLD_BLOCKED, LIMIT_REACHED, WORLD_ALLOWED } + private static java.util.stream.Stream reloadedPolicies() { + return Arrays.stream(ReloadedPolicy.values()).flatMap(policy -> java.util.stream.Stream.of(false, true) + .map(charged -> org.junit.jupiter.params.provider.Arguments.of(charged, policy))); + } + + @ParameterizedTest @org.junit.jupiter.params.provider.MethodSource("reloadedPolicies") + void explainUsesRetainedGameplayOptionsAfterReloadAndStaysReadOnly(boolean retainedCharged, ReloadedPolicy policy) throws Exception { + UUID owner = UUID.randomUUID(), other = UUID.randomUUID(); + try (FeatureState state = new FeatureState(directory.resolve("features.dat")); + UsageCharges charges = new UsageCharges(directory.resolve("charges.dat"))) { + state.ready().get(5, TimeUnit.SECONDS); charges.ready().get(5, TimeUnit.SECONDS); + charges.set(owner, UsageCharges.Kind.AutoBlockRefill, 7).get(5, TimeUnit.SECONDS); + MCPerksMain plugin = mock(MCPerksMain.class); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getFeatureState()).thenReturn(state); when(plugin.getUsageCharges()).thenReturn(charges); + when(plugin.getConfigFile()).thenReturn(mock(com.bencodez.mcperks.configs.Config.class)); + ServerData server = mock(ServerData.class); when(server.getData()).thenReturn(new YamlConfiguration()); + when(plugin.getMcperksServerData()).thenReturn(server); + UserManager users = mock(UserManager.class); MCPerksUser user = mock(MCPerksUser.class); + when(users.getMCPerksUser(owner)).thenReturn(user); when(plugin.getMcperksUserManager()).thenReturn(users); + Perk retained = perk("refill", owner, retainedCharged, Effect.AutoBlockRefill); + // Reload can change both options and the effect list; the running clone is authoritative. + Perk reloaded = perk("refill", owner, !retainedCharged, Effect.NoFallDamage); + when(retained.isNotInDisabledWorld("world")).thenReturn(policy != ReloadedPolicy.WORLD_ALLOWED); + when(reloaded.isNotInDisabledWorld("world")).thenReturn(policy != ReloadedPolicy.WORLD_BLOCKED); + when(reloaded.getLimit()).thenReturn(policy == ReloadedPolicy.LIMIT_REACHED ? 3 : 0); + when(user.getPerkTimesUsed(reloaded)).thenReturn(policy == ReloadedPolicy.LIMIT_REACHED ? 3 : 0); + // Run the same limit predicate used by actual new activation requests. + doCallRealMethod().when(reloaded).checkTimesUsed(user); + + Perk unrelated = perk("other", other, true, Effect.AutoBlockRefill); + Perk configuredOther = perk("other", owner, false, Effect.NoFallDamage); + PerkHandler handler = mock(PerkHandler.class); + when(handler.getActivePerks()).thenReturn(new CopyOnWriteArrayList<>(List.of(retained, unrelated))); + when(handler.getLoadedPerks()).thenReturn(Map.of("refill", reloaded, "other", configuredOther)); + when(handler.effectDecision(any(), anyString(), anyString())).thenReturn(PerkHandler.Decision.ACTIVE); + when(plugin.getPerkHandler()).thenReturn(handler); + var effects = mock(com.bencodez.mcperks.listeners.UsageEffectListener.class); + when(plugin.getUsageEffects()).thenReturn(effects); + Player player = mock(Player.class); when(player.getUniqueId()).thenReturn(owner); + when(player.isOnline()).thenReturn(true); when(player.getName()).thenReturn("Tester"); + World world = mock(World.class); when(world.getName()).thenReturn("world"); when(player.getWorld()).thenReturn(world); + when(player.hasPermission(anyString())).thenReturn(true); + CommandSender sender = mock(CommandSender.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); when(plugin.getBukkitScheduler()).thenReturn(scheduler); + BlockingQueue ownerTasks = new LinkedBlockingQueue<>(), replies = new LinkedBlockingQueue<>(); + doAnswer(i -> { ownerTasks.add(i.getArgument(1)); return null; }).when(scheduler) + .runTask(eq(plugin), any(Runnable.class), eq(player)); + doAnswer(i -> { replies.add(i.getArgument(1)); return null; }).when(scheduler) + .runTask(eq(plugin), any(Runnable.class)); + List messages = new ArrayList<>(); + doAnswer(i -> { messages.add(i.getArgument(0)); return null; }).when(sender).sendMessage(anyString()); + new PerkDiagnostics(plugin).explain(sender, player); + Runnable render = Objects.requireNonNull(ownerTasks.poll(5, TimeUnit.SECONDS)); + verify(player, never()).getWorld(); verify(sender, never()).sendMessage(anyString()); + render.run(); Objects.requireNonNull(replies.poll(5, TimeUnit.SECONDS)).run(); + assertTrue(messages.stream().anyMatch(s -> s.startsWith("refill (retained active grant)"))); + String activation = messages.stream().filter(s -> s.startsWith("refill (retained active grant)")).findFirst().orElseThrow(); + String expected = policy == ReloadedPolicy.WORLD_BLOCKED ? "disabled world" + : policy == ReloadedPolicy.LIMIT_REACHED ? "activation limit" : "activation checks passed"; + assertTrue(activation.contains(expected), activation); + assertEquals(policy != ReloadedPolicy.LIMIT_REACHED, reloaded.checkTimesUsed(user)); + + assertFalse(messages.stream().anyMatch(s -> s.startsWith("refill (configured definition)"))); + assertTrue(messages.stream().anyMatch(s -> s.startsWith("other (configured definition)"))); + String refill = messages.stream().filter(s -> s.startsWith(" AutoBlockRefill:")).findFirst().orElseThrow(); + assertTrue(refill.contains(retainedCharged ? "charges=7" : "unlimited"), refill); + assertFalse(refill.contains(retainedCharged ? "unlimited" : "charges="), refill); + verify(reloaded, never()).getInteractionOptions(); + assertEquals(7, charges.get(owner, UsageCharges.Kind.AutoBlockRefill)); + assertEquals(0, charges.successful(owner, UsageCharges.Kind.AutoBlockRefill)); + assertTrue(state.queue().isEmpty()); assertTrue(state.banks().isEmpty()); + verify(user, never()).setActivations(anyInt()); verify(server, never()).saveData(); + } + } + private enum DuplicateCase { OWN_PERSONAL, OTHER_PERSONAL, GLOBAL_RETAINED, PAUSED_BANK, ZERO_BANK, LEGACY_WITH_OLD_BANK } + private static java.util.stream.Stream duplicateCases() { + return Arrays.stream(DuplicateCase.values()).flatMap(kind -> java.util.stream.Stream.of(false, true) + .map(allowed -> org.junit.jupiter.params.provider.Arguments.of(kind, allowed))); + } + + @ParameterizedTest @org.junit.jupiter.params.provider.MethodSource("duplicateCases") + void explainMatchesProductionDuplicateChecksForRetainedGrantsAndBanks(DuplicateCase kind, boolean allowed) throws Exception { + UUID owner = UUID.randomUUID(), other = UUID.randomUUID(); + Path file = directory.resolve("duplicates.dat"); MCPerksMain previous = MCPerksMain.plugin; + try (FeatureState state = new FeatureState(file); UsageCharges charges = new UsageCharges(directory.resolve("duplicate-charges.dat"))) { + state.ready().get(5, TimeUnit.SECONDS); charges.ready().get(5, TimeUnit.SECONDS); + MCPerksMain plugin = mock(MCPerksMain.class); MCPerksMain.plugin = plugin; + when(plugin.isEnabled()).thenReturn(true); when(plugin.getFeatureState()).thenReturn(state); + when(plugin.getUsageCharges()).thenReturn(charges); + var config = mock(com.bencodez.mcperks.configs.Config.class, CALLS_REAL_METHODS); + var configData = new YamlConfiguration(); configData.set("LimitActivations", true); + // Exercise the actual optional configuration getter, including its disabled default. + if (allowed || kind == DuplicateCase.OWN_PERSONAL) configData.set("DuplicatePerksActive", allowed); + doReturn(configData).when(config).getData(); when(plugin.getConfigFile()).thenReturn(config); + assertEquals(allowed, config.getAllowDuplicatePerksActive()); + ServerData server = mock(ServerData.class); when(server.getData()).thenReturn(new YamlConfiguration()); + when(server.isReady()).thenReturn(true); when(plugin.getMcperksServerData()).thenReturn(server); + MCPerksUser user = mock(MCPerksUser.class), foreign = mock(MCPerksUser.class); + when(user.getUUID()).thenReturn(owner.toString()); when(user.getPlayerName()).thenReturn("Tester"); + when(user.getActivations()).thenReturn(5); when(user.getActivations("passive")).thenReturn(7); + when(foreign.getUUID()).thenReturn(other.toString()); when(foreign.getPlayerName()).thenReturn("Other"); + UserManager users = mock(UserManager.class); when(users.getMCPerksUser(owner)).thenReturn(user); + when(plugin.getMcperksUserManager()).thenReturn(users); + PerkHandler handler = mock(PerkHandler.class); when(plugin.getPerkHandler()).thenReturn(handler); + when(handler.isRestorationReady()).thenReturn(true); + var active = new CopyOnWriteArrayList(); when(handler.getActivePerks()).thenReturn(active); + when(handler.effectDecision(any(), anyString(), anyString())).thenReturn(PerkHandler.Decision.ACTIVE); + var banked = new BankedPerks(plugin, state); when(plugin.getBankedPerks()).thenReturn(banked); + boolean bankMode = kind == DuplicateCase.PAUSED_BANK || kind == DuplicateCase.ZERO_BANK; + var yaml = new YamlConfiguration(); + yaml.loadFromString("Enabled: true\nPerkType: PLAYER\nTimeLasts: 10\nEffects: [NoFallDamage]\nBankedTime:\n Enabled: " + bankMode + "\n"); + var perkConfig = mock(com.bencodez.mcperks.configs.ConfigPerks.class, CALLS_REAL_METHODS); + doReturn(yaml).when(perkConfig).getData("passive"); + doReturn(new ArrayList()).when(perkConfig).getPerkDisabledWorlds("passive"); + doReturn("duplicate blocked").when(perkConfig).getPerkAlreayActive("passive"); + try (var configs = mockStatic(com.bencodez.mcperks.configs.ConfigPerks.class)) { + configs.when(com.bencodez.mcperks.configs.ConfigPerks::getInstance).thenReturn(perkConfig); + Perk definition = new Perk("passive"); + when(handler.getLoadedPerks()).thenReturn(Map.of("passive", definition)); + when(handler.getPerk("passive")).thenReturn(definition); + boolean retained = kind == DuplicateCase.OWN_PERSONAL || kind == DuplicateCase.GLOBAL_RETAINED; + if (retained || kind == DuplicateCase.OTHER_PERSONAL) { + Perk clone = new Perk(definition); clone.setActivater(kind == DuplicateCase.OWN_PERSONAL ? user : foreign); + clone.getEffectedPlayers().add((retained ? owner : other).toString()); clone.setActive(true); + if (kind == DuplicateCase.GLOBAL_RETAINED) { + var type = Perk.class.getDeclaredField("perkType"); type.setAccessible(true); type.set(clone, PerkSystemType.ALL); + } + active.add(clone); + } + var key = new FeatureState.BankKey(owner, "passive"); + if (kind == DuplicateCase.PAUSED_BANK || kind == DuplicateCase.ZERO_BANK || kind == DuplicateCase.LEGACY_WITH_OLD_BANK) { + state.setBank(key, kind == DuplicateCase.ZERO_BANK ? 0 : 10000).get(5, TimeUnit.SECONDS); + state.pauseBank(key, true).get(5, TimeUnit.SECONDS); + } + boolean productionDuplicate = (definition.isPerkActive(user) || definition.getInteractionOptions().banked() + && banked.hasEntitlement(owner, definition)) && !allowed; + assertEquals(!allowed && (kind == DuplicateCase.OWN_PERSONAL || kind == DuplicateCase.GLOBAL_RETAINED + || kind == DuplicateCase.PAUSED_BANK), productionDuplicate); + byte[] before = java.nio.file.Files.exists(file) ? java.nio.file.Files.readAllBytes(file) : null; + Player player = mock(Player.class); when(player.getUniqueId()).thenReturn(owner); + when(player.isOnline()).thenReturn(true); when(player.getName()).thenReturn("Tester"); + World world = mock(World.class); when(world.getName()).thenReturn("world"); when(player.getWorld()).thenReturn(world); + when(player.hasPermission(anyString())).thenReturn(true); + CommandSender sender = mock(CommandSender.class); + var effects = mock(com.bencodez.mcperks.listeners.UsageEffectListener.class); when(plugin.getUsageEffects()).thenReturn(effects); + BukkitScheduler scheduler = mock(BukkitScheduler.class); when(plugin.getBukkitScheduler()).thenReturn(scheduler); + BlockingQueue ownerTasks = new LinkedBlockingQueue<>(), replies = new LinkedBlockingQueue<>(); + doAnswer(i -> { ownerTasks.add(i.getArgument(1)); return null; }).when(scheduler) + .runTask(eq(plugin), any(Runnable.class), eq(player)); + doAnswer(i -> { replies.add(i.getArgument(1)); return null; }).when(scheduler).runTask(eq(plugin), any(Runnable.class)); + List messages = new ArrayList<>(); doAnswer(i -> { messages.add(i.getArgument(0)); return null; }) + .when(sender).sendMessage(anyString()); + new PerkDiagnostics(plugin).explain(sender, player); + Runnable render = Objects.requireNonNull(ownerTasks.poll(5, TimeUnit.SECONDS)); + verify(player, never()).getWorld(); verify(sender, never()).sendMessage(anyString()); + render.run(); Objects.requireNonNull(replies.poll(5, TimeUnit.SECONDS)).run(); + String activation = messages.stream().filter(line -> line.startsWith("passive (")).findFirst().orElseThrow(); + assertTrue(activation.contains(retained ? "retained active grant" : "configured definition"), activation); + assertTrue(activation.contains(productionDuplicate ? "already active; duplicate activations disabled" : "activation checks passed"), activation); + assertEquals(before == null, !java.nio.file.Files.exists(file)); + if (before != null) assertArrayEquals(before, java.nio.file.Files.readAllBytes(file)); + assertEquals(0, charges.get(owner, UsageCharges.Kind.AutoBlockRefill)); + assertEquals(0, charges.successful(owner, UsageCharges.Kind.AutoBlockRefill)); + verify(user, never()).setActivations(anyInt()); verify(user, never()).addActivation(anyInt()); + verify(user, never()).addActivation(anyString(), anyInt()); verify(server, never()).saveData(); + // The rejected production request reaches the identical predicate without + // consuming credits or scheduling an effect. Allowed requests remain read-only here. + if (productionDuplicate) { + definition.runPerk(user); verify(user).sendMessage("duplicate blocked"); + verify(user, never()).addActivation(anyInt()); verify(user, never()).addActivation(anyString(), anyInt()); + if (before != null) assertArrayEquals(before, java.nio.file.Files.readAllBytes(file)); + } + } + } finally { MCPerksMain.plugin = previous; } + } + +} diff --git a/MCPerks/src/test/java/com/bencodez/mcperks/tests/commands/QueueCommandAcceptanceTest.java b/MCPerks/src/test/java/com/bencodez/mcperks/tests/commands/QueueCommandAcceptanceTest.java new file mode 100644 index 0000000..3cd7bbb --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/commands/QueueCommandAcceptanceTest.java @@ -0,0 +1,320 @@ +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.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Logger; + +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import com.bencodez.advancedcore.AdvancedCoreConfigOptions; +import com.bencodez.advancedcore.api.command.CommandHandler; +import com.bencodez.mcperks.MCPerksMain; +import com.bencodez.mcperks.commands.CommandLoader; +import com.bencodez.mcperks.data.DurableFeatureFiles; +import com.bencodez.mcperks.data.FeatureState; +import com.bencodez.mcperks.data.FeatureState.QueueEntry; +import com.bencodez.mcperks.perk.PerkHandler; +import com.bencodez.mcperks.perk.QueuePlanner; +import com.bencodez.mcperks.tests.data.FeatureStateFixture; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; +import com.bencodez.simpleapi.command.TabCompleteHandler; + +/** Registered dispatcher and reply boundaries backed by the real durable queue. */ +class QueueCommandAcceptanceTest { + @TempDir Path directory; + private final AtomicBoolean block = new AtomicBoolean(), failAfterPublish = new AtomicBoolean(); + private final CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + private final BlockingQueue dispatches = new LinkedBlockingQueue<>(), replies = new LinkedBlockingQueue<>(); + private final ArrayList commands = new ArrayList<>(); + private FeatureState state; + private QueueEntry first, second; + private MCPerksMain plugin; + private QueuePlanner planner; + private CommandSender console; + private Player owner; + private CompletableFuture> estimates; + + @BeforeEach void setup() throws Exception { + state = FeatureStateFixture.create(directory.resolve("features.dat"), (staged, target) -> { + if (block.compareAndSet(true, false)) { + entered.countDown(); + try { + if (!release.await(5, TimeUnit.SECONDS)) throw new IOException("Synthetic publication timeout"); + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new IOException(failure); + } + } + DurableFeatureFiles.publish(staged, target); + if (failAfterPublish.compareAndSet(true, false)) throw new IOException("Synthetic acknowledgement failure"); + }); + state.ready().get(5, TimeUnit.SECONDS); + first = state.enqueue(UUID.randomUUID(), "first", "PerkActivations_first", 1).get(5, TimeUnit.SECONDS); + second = state.enqueue(UUID.randomUUID(), "second", "PerkActivations_second", 1).get(5, TimeUnit.SECONDS); + plugin = mock(MCPerksMain.class); + when(plugin.getCommands()).thenReturn(commands); + when(plugin.getFeatureState()).thenReturn(state); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger()); + var options = mock(AdvancedCoreConfigOptions.class); + when(options.getFormatNoPerms()).thenReturn("&cPermission denied"); + when(plugin.getOptions()).thenReturn(options); + planner = mock(QueuePlanner.class); + when(plugin.getQueuePlanner()).thenReturn(planner); + estimates = new CompletableFuture<>(); + when(planner.estimatedWaits()).thenReturn(estimates); + var perks = mock(PerkHandler.class); + when(perks.getActivePerks()).thenReturn(new java.util.concurrent.CopyOnWriteArrayList<>()); + when(plugin.getPerkHandler()).thenReturn(perks); + var scheduler = mock(BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + doAnswer(call -> { dispatches.add(call.getArgument(1)); return null; }) + .when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class)); + doAnswer(call -> { replies.add(call.getArgument(1)); return null; }) + .when(scheduler).runTask(eq(plugin), any(Runnable.class)); + doAnswer(call -> { replies.add(call.getArgument(1)); return null; }) + .when(scheduler).runTask(eq(plugin), any(Runnable.class), any(Player.class)); + var loader = new CommandLoader(plugin); + // AdvancedCore.loadTabComplete() installs this shared wildcard before MCPerks commands. + TabCompleteHandler.getInstance().addTabCompleteOption("(Boolean)", "True", "False"); + for (String method : List.of("registerFeatureArgumentMatchers", "loadPlannerCommands")) { + var register = CommandLoader.class.getDeclaredMethod(method); + register.setAccessible(true); + register.invoke(loader); + } + console = mock(CommandSender.class); + when(console.hasPermission(anyString())).thenReturn(true); + owner = mock(Player.class); + when(owner.getUniqueId()).thenReturn(first.owner()); + when(owner.hasPermission(anyString())).thenReturn(true); + } + + @AfterEach void close() throws Exception { + release.countDown(); + if (state != null) { + state.close(); + var field = FeatureState.class.getDeclaredField("worker"); + field.setAccessible(true); + assertTrue(((ExecutorService) field.get(state)).awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @ParameterizedTest @ValueSource(strings = {"Queue", "CancelQueue", "QueueRemove", "QueuePause", "QueueMove"}) + void permissionDenialStopsActualDispatcherBeforeAnyStateOrWorkerAdmission(String name) throws Exception { + byte[] saved = Files.readAllBytes(directory.resolve("features.dat")); + assertEquals(name.equals("Queue") ? "MCPerks.Queue" : name.equals("CancelQueue") + ? "MCPerks.CancelQueue" : "MCPerks.Queue.Manage", command(name).getPerm()); + when(owner.hasPermission(anyString())).thenReturn(false); + assertTrue(command(name).runCommand(owner, arguments(name))); + verify(owner).sendMessage(contains("Permission denied")); + assertTrue(dispatches.isEmpty()); + state.storage(() -> null).get(5, TimeUnit.SECONDS); + assertTrue(replies.isEmpty()); + assertArrayEquals(saved, Files.readAllBytes(directory.resolve("features.dat"))); + verifyNoInteractions(planner); + } + + @Test void selfCancellationCannotRemoveAnotherOwnerButRemovesOnlyItsOwnReservedEntry() throws Exception { + invoke(owner, "CancelQueue", second.id().toString()); + reply().run(); + verify(owner).sendMessage(contains("failed or entry unavailable")); + assertEquals(List.of(first, second), state.queue()); + invoke(owner, "CancelQueue", first.id().toString()); + reply().run(); + verify(owner).sendMessage(contains("entry removed; consumed activations are not refunded")); + assertEquals(List.of(second), state.queue()); + assertEquals(0, state.reserved(first.owner(), first.credit())); + assertEquals(1, state.reserved(second.owner(), second.credit())); + assertRecovered(List.of(second), false); + } + + @Test void consoleSelfCancellationIsRejectedButStaffRemovalUsesItsManagePermission() throws Exception { + invoke(console, "CancelQueue", first.id().toString()); + verify(console).sendMessage(contains("Player only")); + assertTrue(replies.isEmpty()); + assertEquals(List.of(first, second), state.queue()); + assertEquals("MCPerks.CancelQueue", command("CancelQueue").getPerm()); + assertEquals("MCPerks.Queue.Manage", command("QueueRemove").getPerm()); + invoke(console, "QueueRemove", second.id().toString()); + reply().run(); + assertEquals(List.of(first), state.queue()); + verify(console).sendMessage(contains("entry removed")); + } + + @Test void startingFenceSurvivesSelfAndStaffCancellationAndStaffReordering() throws Exception { + assertNotNull(state.claim(first.id()).get(5, TimeUnit.SECONDS)); + var saved = state.queue(); + invoke(owner, "CancelQueue", first.id().toString()); reply().run(); + invoke(console, "QueueRemove", first.id().toString()); reply().run(); + invoke(console, "QueueMove", second.id().toString(), "1"); reply().run(); + assertEquals(saved, state.queue()); + verify(owner).sendMessage(contains("failed or entry unavailable")); + verify(console, times(2)).sendMessage(contains("failed or entry unavailable")); + verify(console, never()).sendMessage(contains("entry removed")); + verify(console, never()).sendMessage(contains("order saved")); + } + + @Test void uncertainFenceRequiresStaffAndIsNeverReportedAsRefunded() throws Exception { + state.uncertain(first.id()).get(5, TimeUnit.SECONDS); + invoke(owner, "CancelQueue", first.id().toString()); reply().run(); + assertEquals(FeatureState.Stage.UNCERTAIN, state.queue().getFirst().stage()); + invoke(console, "QueueRemove", first.id().toString()); reply().run(); + assertEquals(List.of(second), state.queue()); + verify(console).sendMessage(contains("consumed activations are not refunded")); + } + + @Test void pauseAndResumePreserveWaitingReservationsAndPersistTheConfirmedFinalState() throws Exception { + invoke(console, "QueuePause", "TRUE"); reply().run(); + assertTrue(state.paused()); + assertEquals(List.of(first, second), state.queue()); + invoke(console, "QueuePause", "FALSE"); reply().run(); + assertFalse(state.paused()); + assertEquals(1, state.reserved(first.owner(), first.credit())); + assertEquals(1, state.reserved(second.owner(), second.credit())); + verify(console, times(2)).sendMessage(contains("pause state saved")); + assertRecovered(List.of(first, second), false); + } + + @ParameterizedTest @ValueSource(strings = {"QueuePause", "QueueMove", "QueueRemove"}) + void staffSuccessWaitsForRealDurablePublicationAndItsScheduledReply(String name) throws Exception { + block.set(true); + invoke(console, arguments(name)); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); + assertEquals(List.of(first, second), state.queue()); + assertFalse(state.paused()); + assertTrue(replies.isEmpty()); + verify(console, never()).sendMessage(anyString()); + } finally { release.countDown(); } + Runnable response = reply(); + verify(console, never()).sendMessage(anyString()); + response.run(); + List expected = name.equals("QueueMove") ? List.of(second, first) + : name.equals("QueueRemove") ? List.of(first) : List.of(first, second); + assertEquals(expected, state.queue()); + assertEquals(name.equals("QueuePause"), state.paused()); + verify(console).sendMessage(contains(name.equals("QueuePause") ? "pause state saved" + : name.equals("QueueMove") ? "order saved" : "entry removed")); + verify(planner).refreshLegacyView(); + verify(planner).check(); + assertRecovered(expected, name.equals("QueuePause")); + } + + @ParameterizedTest @ValueSource(strings = {"QueuePause", "QueueMove", "QueueRemove"}) + void unknownPublicationReportsFailureAndNeverClaimsThePersistedActionSucceeded(String name) throws Exception { + failAfterPublish.set(true); + invoke(console, arguments(name)); + Runnable response = reply(); + assertFalse(state.available()); + verify(console, never()).sendMessage(anyString()); + response.run(); + verify(console).sendMessage(contains("failed or entry unavailable")); + verify(console, never()).sendMessage(contains("saved")); + verify(console, never()).sendMessage(contains("entry removed")); + verify(console, never()).sendMessage(contains("Synthetic acknowledgement failure")); + assertEquals(List.of(first, second), state.queue()); + assertFalse(state.paused()); + } + + @Test void malformedArgumentsAndWrongArityCannotMutateOrSchedulePlannerWork() throws Exception { + byte[] saved = Files.readAllBytes(directory.resolve("features.dat")); + invoke(console, "QueuePause", "maybe"); + invoke(console, "QueueMove", second.id().toString(), "0"); + invoke(console, "QueueMove", second.id().toString(), "2147483648"); + invoke(console, "QueueMove", "bad-id", "1"); + invoke(owner, "CancelQueue", "bad-id"); + assertFalse(command("QueueMove").runCommand(console, new String[] {"QueueMove", second.id().toString()})); + assertFalse(command("QueuePause").runCommand(console, new String[] {"QueuePause", "true", "extra"})); + assertTrue(dispatches.isEmpty()); + state.storage(() -> null).get(5, TimeUnit.SECONDS); + assertTrue(replies.isEmpty()); + assertArrayEquals(saved, Files.readAllBytes(directory.resolve("features.dat"))); + verifyNoInteractions(planner); + } + + @Test void queueDisplayIsReadOnlyAndKnownEstimatesAreRepliedOnlyThroughTheScheduler() throws Exception { + byte[] saved = Files.readAllBytes(directory.resolve("features.dat")); + when(planner.startUnavailable(first)).thenReturn("requires eligibility"); + invoke(console, "Queue"); + verify(console).sendMessage(contains("1. first requester=" + first.owner())); + verify(console).sendMessage(contains("2. second requester=" + second.owner())); + verify(console).sendMessage(contains("start=requires eligibility ETA=unknown")); + verify(console).sendMessage(contains("Queue paused: false")); + assertTrue(replies.isEmpty()); + estimates.complete(Map.of(second.id(), 7L)); + Runnable response = reply(); + verify(console, never()).sendMessage(contains("Estimated start")); + response.run(); + verify(console).sendMessage(contains("Estimated start for " + second.id() + ": 7s")); + verify(console, never()).sendMessage(contains("Estimated start for " + first.id())); + state.storage(() -> null).get(5, TimeUnit.SECONDS); + assertArrayEquals(saved, Files.readAllBytes(directory.resolve("features.dat"))); + verify(planner, never()).check(); + verify(planner, never()).refreshLegacyView(); + } + + private String[] arguments(String name) { + return switch (name) { + case "QueuePause" -> new String[] {name, "true"}; + case "QueueMove" -> new String[] {name, second.id().toString(), "1"}; + case "QueueRemove" -> new String[] {name, second.id().toString()}; + case "CancelQueue" -> new String[] {name, first.id().toString()}; + default -> new String[] {name}; + }; + } + + private void invoke(CommandSender sender, String... args) throws Exception { + assertTrue(command(args[0]).runCommand(sender, args)); + Runnable task = dispatches.poll(5, TimeUnit.SECONDS); + assertNotNull(task, "accepted command must reach the registered asynchronous dispatcher"); + task.run(); + } + + private Runnable reply() throws Exception { + Runnable reply = replies.poll(5, TimeUnit.SECONDS); + assertNotNull(reply, "durable completion must schedule a reply"); + return reply; + } + + private CommandHandler command(String name) { + return commands.stream().filter(c -> c.getArgs()[0].equals(name)).findFirst().orElseThrow(); + } + + private void assertRecovered(List expected, boolean paused) throws Exception { + state.close(); + var field = FeatureState.class.getDeclaredField("worker"); field.setAccessible(true); + assertTrue(((ExecutorService) field.get(state)).awaitTermination(5, TimeUnit.SECONDS)); + var recovered = new FeatureState(directory.resolve("features.dat")); + try { + recovered.ready().get(5, TimeUnit.SECONDS); + assertEquals(expected, recovered.queue()); + assertEquals(paused, recovered.paused()); + } finally { + recovered.close(); + assertTrue(((ExecutorService) field.get(recovered)).awaitTermination(5, TimeUnit.SECONDS)); + } + } +} 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/commands/UsageChargesCommandTest.java b/MCPerks/src/test/java/com/bencodez/mcperks/tests/commands/UsageChargesCommandTest.java new file mode 100644 index 0000000..f5a5400 --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/commands/UsageChargesCommandTest.java @@ -0,0 +1,172 @@ +package com.bencodez.mcperks.tests.commands; + +import static com.bencodez.mcperks.tests.TestAccess.newUsageCharges; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import com.bencodez.advancedcore.AdvancedCoreConfigOptions; +import com.bencodez.advancedcore.api.command.CommandHandler; +import com.bencodez.mcperks.MCPerksMain; +import com.bencodez.mcperks.commands.CommandLoader; +import com.bencodez.mcperks.data.DurableFeatureFiles; +import com.bencodez.mcperks.userapi.MCPerksUser; +import com.bencodez.mcperks.userapi.UsageCharges; +import com.bencodez.mcperks.userapi.UsageCharges.Kind; +import com.bencodez.mcperks.userapi.UserManager; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; + +/** Exercises registered handlers against the real asynchronous charge ledger. */ +class UsageChargesCommandTest { + @TempDir Path directory; + private final UUID owner = UUID.randomUUID(); + private final AtomicBoolean block = new AtomicBoolean(), failAfterPublish = new AtomicBoolean(); + private final CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + private final BlockingQueue replies = new LinkedBlockingQueue<>(); + private final ArrayList commands = new ArrayList<>(); + private UsageCharges ledger; + private CommandSender console; + + @BeforeEach void setup() throws Exception { + ledger = newUsageCharges(directory.resolve("charges.dat"), (staged, file) -> { + if (block.compareAndSet(true, false)) { + entered.countDown(); + try { + if (!release.await(5, TimeUnit.SECONDS)) throw new IOException("Publication test timeout"); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + throw new IOException(error); + } + } + DurableFeatureFiles.publish(staged, file); + if (failAfterPublish.compareAndSet(true, false)) throw new IOException("Synthetic acknowledgement failure"); + }); + ledger.set(owner, Kind.AutoBlockRefill, 5).get(5, TimeUnit.SECONDS); + var plugin = mock(MCPerksMain.class); + when(plugin.getCommands()).thenReturn(commands); + when(plugin.getOptions()).thenReturn(mock(AdvancedCoreConfigOptions.class)); + when(plugin.getUsageCharges()).thenReturn(ledger); + var user = mock(MCPerksUser.class); + when(user.getUUID()).thenReturn(owner.toString()); + var users = mock(UserManager.class); + when(users.getMCPerksUser("SyntheticPlayer")).thenReturn(user); + when(plugin.getMcperksUserManager()).thenReturn(users); + var scheduler = mock(BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + doAnswer(call -> { replies.add(call.getArgument(1)); return null; }) + .when(scheduler).runTask(eq(plugin), any(Runnable.class)); + var loader = new CommandLoader(plugin); + var register = CommandLoader.class.getDeclaredMethod("loadUsageCommands"); + register.setAccessible(true); + register.invoke(loader); + console = mock(CommandSender.class); + } + + @AfterEach void close() { + release.countDown(); + if (ledger != null) ledger.close(); + } + + @Test void registeredCommandsKeepSeparatePermissionsAndArgumentContracts() { + assertEquals(3, commands.size()); + for (String name : List.of("GetUsageCharges", "SetUsageCharges", "AddUsageCharges")) { + var command = command(name); + assertEquals("MCPerks." + name, command.getPerm()); + assertArrayEquals(name.equals("GetUsageCharges") ? new String[] {name, "(effect)"} + : new String[] {name, "(player)", "(effect)", "(number)"}, command.getArgs()); + } + } + + @ParameterizedTest @ValueSource(strings = {"SetUsageCharges", "AddUsageCharges"}) + void managementSuccessWaitsForDurablePublicationAndScheduledReply(String name) throws Exception { + block.set(true); + command(name).execute(console, new String[] {name, "SyntheticPlayer", "AutoBlockRefill", "7"}); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); + assertEquals(5, ledger.get(owner, Kind.AutoBlockRefill)); + assertTrue(replies.isEmpty()); + verify(console, never()).sendMessage(anyString()); + } finally { release.countDown(); } + Runnable reply = replies.poll(5, TimeUnit.SECONDS); + assertNotNull(reply); + int expected = name.equals("SetUsageCharges") ? 7 : 12; + assertEquals(expected, ledger.get(owner, Kind.AutoBlockRefill)); + verify(console, never()).sendMessage(anyString()); + reply.run(); + verify(console).sendMessage(contains("AutoBlockRefill charges: " + expected)); + assertTrue(replies.isEmpty()); + ledger.close(); + try (var recovered = new UsageCharges(directory.resolve("charges.dat"))) { + recovered.ready().get(5, TimeUnit.SECONDS); + assertEquals(expected, recovered.get(owner, Kind.AutoBlockRefill)); + } + } + + @ParameterizedTest @ValueSource(strings = {"SetUsageCharges", "AddUsageCharges"}) + void uncertainPublicationReportsFailureWithoutClaimingSuccess(String name) throws Exception { + failAfterPublish.set(true); + command(name).execute(console, new String[] {name, "SyntheticPlayer", "AutoBlockRefill", "7"}); + Runnable reply = replies.poll(5, TimeUnit.SECONDS); + assertNotNull(reply); + assertFalse(ledger.available()); + verify(console, never()).sendMessage(anyString()); + reply.run(); + verify(console).sendMessage(contains("Charge update failed")); + verify(console, never()).sendMessage(contains("charges:")); + verify(console, never()).sendMessage(contains("Synthetic acknowledgement failure")); + } + + @Test void invalidManagementInputNeverChangesThePersonalBalance() throws Exception { + var command = command("SetUsageCharges"); + command.execute(console, new String[] {"SetUsageCharges", "SyntheticPlayer", "MissingEffect", "7"}); + command.execute(console, new String[] {"SetUsageCharges", "SyntheticPlayer", "AutoBlockRefill", "not-a-number"}); + command.execute(console, new String[] {"SetUsageCharges", "SyntheticPlayer", "AutoBlockRefill", "2147483648"}); + command.execute(console, new String[] {"SetUsageCharges", "SyntheticPlayer", "AutoBlockRefill", "-1"}); + Runnable negativeReply = replies.poll(5, TimeUnit.SECONDS); + assertNotNull(negativeReply); + negativeReply.run(); + assertEquals(5, ledger.get(owner, Kind.AutoBlockRefill)); + verify(console, times(3)).sendMessage(contains("supported effect and nonnegative integer")); + verify(console).sendMessage(contains("Charge update failed")); + assertTrue(ledger.available()); + } + + @Test void personalReadUsesTheSenderUuidAndRejectsConsole() { + var read = command("GetUsageCharges"); + read.execute(console, new String[] {"GetUsageCharges", "AutoBlockRefill"}); + verify(console).sendMessage(contains("Player only")); + verify(console, never()).sendMessage(contains("charges:")); + var player = mock(Player.class); + when(player.getUniqueId()).thenReturn(owner); + read.execute(player, new String[] {"GetUsageCharges", "AutoBlockRefill"}); + verify(player).sendMessage(contains("AutoBlockRefill charges: 5")); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + read.execute(player, new String[] {"GetUsageCharges", "AutoBlockRefill"}); + verify(player).sendMessage(contains("AutoBlockRefill charges: 0")); + assertEquals(5, ledger.get(owner, Kind.AutoBlockRefill)); + } + + private CommandHandler command(String name) { + return commands.stream().filter(command -> command.getArgs()[0].equals(name)).findFirst().orElseThrow(); + } +} 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..49a2df0 --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/data/FeatureFileOwnershipTest.java @@ -0,0 +1,77 @@ +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(); } + } + @Test void ownershipFailureFreezesFeatureAndLegacyPublicationWithoutOverwritingExistingState() throws Exception { + Path file = root.resolve("features.dat"); var key = new FeatureState.BankKey(java.util.UUID.randomUUID(), "vein"); + try (var seed = new FeatureState(file)) { seed.setBank(key, 9000).get(5, TimeUnit.SECONDS); } + byte[] original = Files.readAllBytes(file); + Files.deleteIfExists(root.resolve("features.dat.lock")); + Path unrelated = root.resolve("unrelated"); Files.writeString(unrelated, "do not touch"); + Files.createSymbolicLink(root.resolve("features.dat.lock"), unrelated); + try (var state = new FeatureState(file)) { + assertThrows(ExecutionException.class, () -> state.ready().get(5, TimeUnit.SECONDS)); assertFalse(state.available()); + assertThrows(ExecutionException.class, () -> state.setBank(key, 1).get(5, TimeUnit.SECONDS)); + assertThrows(ExecutionException.class, () -> state.enqueue(key.owner(), "vein", "", 0).get(5, TimeUnit.SECONDS)); + var invoked = new java.util.concurrent.atomic.AtomicBoolean(); + assertThrows(ExecutionException.class, () -> state.runtimeStorage(() -> { invoked.set(true); return null; }).get(5, TimeUnit.SECONDS)); + assertFalse(invoked.get()); assertArrayEquals(original, Files.readAllBytes(file)); + assertEquals("do not touch", Files.readString(unrelated)); + } + } + @Test void ownershipFailureFreezesChargeGrantsReservationsAndUnlimitedStatistics() throws Exception { + Path file = root.resolve("charges.dat"); var owner = java.util.UUID.randomUUID(); + var kind = com.bencodez.mcperks.userapi.UsageCharges.Kind.VeinMining; + try (var seed = new com.bencodez.mcperks.userapi.UsageCharges(file)) { seed.set(owner, kind, 12).get(5, TimeUnit.SECONDS); } + byte[] original = Files.readAllBytes(file); Files.deleteIfExists(root.resolve("charges.dat.lock")); + Path unrelated = root.resolve("unrelated-charge"); Files.writeString(unrelated, "do not touch"); + Files.createSymbolicLink(root.resolve("charges.dat.lock"), unrelated); + try (var state = new com.bencodez.mcperks.userapi.UsageCharges(file)) { + assertThrows(ExecutionException.class, () -> state.ready().get(5, TimeUnit.SECONDS)); assertFalse(state.available()); + assertThrows(ExecutionException.class, () -> state.set(owner, kind, 1).get(5, TimeUnit.SECONDS)); + assertThrows(ExecutionException.class, () -> state.add(owner, kind, 1).get(5, TimeUnit.SECONDS)); + assertThrows(ExecutionException.class, () -> state.reserve(owner, kind, 1).get(5, TimeUnit.SECONDS)); + assertThrows(ExecutionException.class, () -> state.record(owner, kind, 1).get(5, TimeUnit.SECONDS)); + assertArrayEquals(original, Files.readAllBytes(file)); assertEquals("do not touch", Files.readString(unrelated)); + } + } + +} diff --git a/MCPerks/src/test/java/com/bencodez/mcperks/tests/data/FeatureStateFixture.java b/MCPerks/src/test/java/com/bencodez/mcperks/tests/data/FeatureStateFixture.java new file mode 100644 index 0000000..d1db6ac --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/data/FeatureStateFixture.java @@ -0,0 +1,25 @@ +package com.bencodez.mcperks.tests.data; +import static com.bencodez.mcperks.tests.TestAccess.*; +import com.bencodez.mcperks.data.*; +import java.nio.file.Path; +public final class FeatureStateFixture { + private FeatureStateFixture() {} + public static FeatureState create(Path file, DurableFeatureFiles.Publisher publisher) { return newFeatureState(file, publisher); } + /** Delay the real controller commit, retaining its original elapsed/rollback behavior. */ + @SuppressWarnings("unchecked") + public static void beforeBankCommit(FeatureState state, Runnable before) throws ReflectiveOperationException { + var grantField = FeatureState.class.getDeclaredField("bankGrantBoundary"); grantField.setAccessible(true); + var replacementField = FeatureState.class.getDeclaredField("bankReplacementBoundary"); replacementField.setAccessible(true); + var grants = (java.util.function.BiFunction) grantField.get(state); + var replacements = (java.util.function.Function) replacementField.get(state); + state.onBankGrant((key, balance) -> wrap(grants.apply(key, balance), before)); + state.onBankReplacement(key -> wrap(replacements.apply(key), before)); + } + private static FeatureState.BankGrant wrap(FeatureState.BankGrant actual, Runnable before) { + return new FeatureState.BankGrant(actual.elapsed(), () -> { before.run(); actual.commit().run(); }, actual.rollback()); + } + public static boolean ownsBankBoundary(FeatureState state) throws ReflectiveOperationException { + var lock = FeatureState.class.getDeclaredField("bankBoundaryLock"); lock.setAccessible(true); + return Thread.holdsLock(lock.get(state)); + } +} diff --git a/MCPerks/src/test/java/com/bencodez/mcperks/tests/data/FeatureStateTest.java b/MCPerks/src/test/java/com/bencodez/mcperks/tests/data/FeatureStateTest.java new file mode 100644 index 0000000..f777ed5 --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/data/FeatureStateTest.java @@ -0,0 +1,250 @@ +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.nio.file.*; +import java.util.*; +import java.util.concurrent.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +class FeatureStateTest { + @TempDir Path directory; + private static T await(CompletableFuture future) throws Exception { return future.get(5, TimeUnit.SECONDS); } + @Test void directStartUsesExistingBoundedWireFormatAndNeverBecomesWaitingOnRecovery() throws Exception { + var file = directory.resolve("direct.dat"); UUID owner = UUID.randomUUID(); FeatureState.QueueEntry entry; + try (var state = new FeatureState(file)) { + await(state.ready()); entry = await(state.startDirect(owner, "personal", "Activations", 1)); + assertEquals(FeatureState.Stage.STARTING, entry.stage()); assertNull(await(state.claim(entry.id()))); + assertThrows(ExecutionException.class, () -> await(state.startDirect(owner, "other", "Activations", 2))); + assertEquals(1, state.reserved(owner, "Activations")); + } + try (var recovered = new FeatureState(file)) { + await(recovered.ready()); assertEquals(List.of(entry.withStage(FeatureState.Stage.UNCERTAIN)), recovered.queue()); + assertNull(await(recovered.claim(entry.id()))); assertEquals(1, recovered.reserved(owner, "Activations")); + } + } + /** Real wire fixture avoids thousands of unrelated O(n) atomic publications in a boundary test. */ + private static String unicodeBankFixture(Path file) throws Exception { + String name = "\uD83D\uDE00".repeat(62); // 124 UTF-16 code units, 372 modified UTF-8 bytes. + try (var out = new java.io.DataOutputStream(new java.io.BufferedOutputStream(Files.newOutputStream(file)))) { + out.writeInt(0x4d435032); out.writeBoolean(false); out.writeInt(0); out.writeInt(7518); + for (int i = 1; i <= 7518; i++) { + out.writeLong(0); out.writeLong(i); out.writeUTF(name); out.writeLong(5000); out.writeBoolean(false); + } + } + assertEquals(2_999_695, Files.size(file)); return name; + } + @Test void serializedBankCapacityRejectsBeforeTheActivationDebit() throws Exception { + Path file = directory.resolve("bank-admission.dat"); + String name = "\uD83D\uDE00".repeat(62); + try (var out = new java.io.DataOutputStream(new java.io.BufferedOutputStream(Files.newOutputStream(file)))) { + out.writeInt(0x4d435032); out.writeBoolean(false); out.writeInt(0); out.writeInt(7516); + for (int i = 1; i <= 7516; i++) { + out.writeLong(0); out.writeLong(i); out.writeUTF(name); out.writeLong(5000); out.writeBoolean(false); + } + } + assertEquals(2_998_897, Files.size(file)); + UUID owner = new UUID(0, 9000); var debits = new java.util.concurrent.atomic.AtomicInteger(); + try (var state = new FeatureState(file)) { + await(state.ready()); + var entry = await(state.enqueue(owner, name, "PerkActivations_" + name, 1)); + assertEquals(2_999_705, Files.size(file)); + await(state.claim(entry.id())); byte[] before = Files.readAllBytes(file); + var result = await(state.storage(() -> state.addBankOnWorker(new FeatureState.BankKey(owner, name), + 10000, () -> { debits.incrementAndGet(); return true; }))); + assertNull(result); assertEquals(0, debits.get()); assertTrue(state.available()); + assertFalse(state.banks().containsKey(new FeatureState.BankKey(owner, name))); + assertArrayEquals(before, Files.readAllBytes(file)); + // The real planner releases this deterministic rejection, preserving unused entitlement. + await(state.releaseClaim(entry.id())); + assertEquals(FeatureState.Stage.WAITING, state.queue().getFirst().stage()); + assertEquals(1, state.reserved(owner, "PerkActivations_" + name)); + } + try (var recovered = new FeatureState(file)) { + await(recovered.ready()); assertTrue(recovered.available()); + assertEquals(FeatureState.Stage.WAITING, recovered.queue().getFirst().stage()); + assertEquals(7516, recovered.banks().size()); + } + } + @ParameterizedTest @ValueSource(ints = {1, 2, 3}) + void actualSerializedPublicationAcceptsBelowAndAtByteLimitAndRejectsAboveIt(int padding) throws Exception { + Path file = directory.resolve("unicode.dat"); String legacyName = unicodeBankFixture(file); + String name = "\uD83D\uDE00".repeat(46) + "x".repeat(padding); + var key = new FeatureState.BankKey(new UUID(0, 8000), name); + byte[] previous = Files.readAllBytes(file); + try (var state = new FeatureState(file)) { + await(state.ready()); assertEquals(7518, state.banks().size()); + if (padding <= 2) { + await(state.setBank(key, 6000)); assertEquals(2_999_998L + padding, Files.size(file)); + await(state.pauseQueue(true)); // Another publication at this exact size remains valid. + assertTrue(state.available()); + byte[] accepted = Files.readAllBytes(file); + assertThrows(ExecutionException.class, () -> await(state.enqueue(UUID.randomUUID(), "overflow", "", 0))); + assertArrayEquals(accepted, Files.readAllBytes(file)); assertTrue(state.queue().isEmpty()); + } else { + assertThrows(ExecutionException.class, () -> await(state.setBank(key, 6000))); + assertFalse(state.banks().containsKey(key)); assertArrayEquals(previous, Files.readAllBytes(file)); + // Capacity rejection does not disable safety/lifecycle updates of existing entries. + await(state.pauseBank(new FeatureState.BankKey(new UUID(0, 1), legacyName), true)); + } + assertTrue(state.available()); + try (var files = Files.list(directory)) { assertFalse(files.anyMatch(path -> path.getFileName().toString().startsWith(".feature-"))); } + } + try (var recovered = new FeatureState(file)) { + await(recovered.ready()); assertTrue(recovered.available()); + assertEquals(padding <= 2 ? 7519 : 7518, recovered.banks().size()); + if (padding <= 2) assertEquals(new FeatureState.Bank(6000, false), recovered.banks().get(key)); + else assertTrue(recovered.banks().get(new FeatureState.BankKey(new UUID(0, 1), legacyName)).paused()); + } + } + @Test void oversizedRecoveryStillFailsClosedAtTheUnchangedByteBound() throws Exception { + Path file = directory.resolve("oversized.dat"); unicodeBankFixture(file); + try (var state = new FeatureState(file)) { + await(state.setBank(new FeatureState.BankKey(new UUID(0, 8000), "\uD83D\uDE00".repeat(46) + "xx"), 6000)); + assertEquals(3_000_000, Files.size(file)); + } + Files.write(file, new byte[] {0}, StandardOpenOption.APPEND); + try (var state = new FeatureState(file)) { + assertThrows(ExecutionException.class, () -> await(state.ready())); + assertFalse(state.available()); assertTrue(state.banks().isEmpty()); + assertThrows(ExecutionException.class, () -> await(state.pauseQueue(true))); + assertEquals(3_000_001, Files.size(file)); + } + } + @Test void queueOwnershipPauseReorderAndRestartAreDurable() throws Exception { + Path file = directory.resolve("feature.dat"); UUID a = UUID.randomUUID(), b = UUID.randomUUID(); FeatureState.QueueEntry first, second; + try (FeatureState state = new FeatureState(file)) { + first = await(state.enqueue(a, "Old perk.v1", "Activations", 2)); second = await(state.enqueue(b, "vein", "", 0)); + assertFalse(await(state.cancel(first.id(), b, false))); await(state.pauseQueue(true)); assertNull(await(state.claim(first.id()))); + assertTrue(await(state.move(second.id(), 1))); assertEquals(second.id(), state.queue().getFirst().id()); + assertThrows(ExecutionException.class, () -> await(state.enqueue(a, "vein", "Activations", 2))); + } + try (FeatureState state = new FeatureState(file)) { + await(state.ready()); assertTrue(state.paused()); assertEquals(List.of(second.id(), first.id()), state.queue().stream().map(FeatureState.QueueEntry::id).toList()); + assertTrue(await(state.cancel(first.id(), a, false))); assertFalse(await(state.cancel(first.id(), a, false))); assertEquals(0, state.reserved(a, "Activations")); + } + } + @Test void startFenceIsSingleUseAndUncertainRestartNeverReplaysOrRefundsCredits() throws Exception { + Path file = directory.resolve("feature.dat"); UUID owner = UUID.randomUUID(); FeatureState.QueueEntry entry; + try (FeatureState state = new FeatureState(file)) { + entry = await(state.enqueue(owner, "vein", "Activations", 1)); assertNotNull(await(state.claim(entry.id()))); + assertNull(await(state.claim(entry.id()))); assertFalse(await(state.cancel(entry.id(), owner, false))); + } + try (FeatureState state = new FeatureState(file)) { + await(state.ready()); assertEquals(FeatureState.Stage.UNCERTAIN, state.queue().getFirst().stage()); assertNull(await(state.claim(entry.id()))); + assertEquals(1, state.reserved(owner, "Activations")); assertFalse(await(state.cancel(entry.id(), owner, false))); + assertTrue(await(state.cancel(entry.id(), UUID.randomUUID(), true))); // explicit staff removal, no DB credit refund + } + } + @Test void queueCapacityReclaimedByCancellationAndFailedPublicationNeverClaims() throws Exception { + Path file = directory.resolve("feature.dat"); + try (FeatureState state = new FeatureState(file)) { + FeatureState.QueueEntry first = null; + for (int i = 0; i < 256; i++) { var entry = await(state.enqueue(UUID.randomUUID(), "vein", "", 0)); if (first == null) first = entry; } + assertThrows(ExecutionException.class, () -> await(state.enqueue(UUID.randomUUID(), "vein", "", 0))); + assertTrue(await(state.cancel(first.id(), first.owner(), false))); await(state.enqueue(UUID.randomUUID(), "vein", "", 0)); + var head = state.queue().getFirst(); Files.delete(file); Files.createDirectory(file); + assertThrows(ExecutionException.class, () -> await(state.claim(head.id()))); + assertEquals(FeatureState.Stage.WAITING, state.queue().getFirst().stage()); + } + } + @Test void bankedLifecyclePersistsPauseAndRemainingWithoutChangingLegacyExpiration() throws Exception { + Path file = directory.resolve("feature.dat"); var key = new FeatureState.BankKey(UUID.randomUUID(), "vein"); + try (FeatureState state = new FeatureState(file)) { + await(state.addBank(key, 10000)); await(state.elapsed(key, 3000)); await(state.pauseBank(key, true)); await(state.elapsed(key, 3000)); + await(state.addBank(key, 2000)); assertEquals(new FeatureState.Bank(9000, true), state.banks().get(key)); + } + try (FeatureState state = new FeatureState(file)) { + await(state.ready()); assertEquals(new FeatureState.Bank(9000, true), state.banks().get(key)); + await(state.pauseBank(key, false)); await(state.elapsed(key, 15000)); assertEquals(0, state.banks().get(key).milliseconds()); + assertTrue(state.queue().isEmpty()); + } + } + @Test void malformedStateIsUnknownAndDoesNotErasePurchasedTime() throws Exception { + Path file = directory.resolve("feature.dat"); Files.writeString(file, "not a ledger"); + try (FeatureState state = new FeatureState(file)) { + assertThrows(ExecutionException.class, () -> await(state.ready())); assertThrows(ExecutionException.class, () -> await(state.setBank(new FeatureState.BankKey(UUID.randomUUID(), "vein"), 5000))); + assertEquals("not a ledger", Files.readString(file)); + } + } + @Test void successorWaitsOffOwnerUntilPriorLifecycleDrainsAndReadsItsFinalPublishedState() throws Exception { + Path file=directory.resolve("features.dat"); var key=new FeatureState.BankKey(UUID.randomUUID(),"passive"); + CountDownLatch entered=new CountDownLatch(1), release=new CountDownLatch(1); + FeatureState old=new FeatureState(file); await(old.ready()); + old.storage(() -> { entered.countDown(); try { if (!release.await(5,TimeUnit.SECONDS)) throw new IllegalStateException("timeout"); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IllegalStateException(e); } return null; }); + assertTrue(entered.await(5,TimeUnit.SECONDS)); var write=old.setBank(key,5000); old.close(); + try (FeatureState successor=new FeatureState(file)) { + assertFalse(successor.ready().isDone()); release.countDown(); await(write); await(successor.ready()); + assertEquals(5000,successor.banks().get(key).milliseconds()); + } finally { release.countDown(); old.close(); } + } + + @Test void bankCapacityRejectsOverflowWithoutSilentTruncationAndSerializesConcurrentSetWithDebit() throws Exception { + var key=new FeatureState.BankKey(UUID.randomUUID(),"passive"); long limit=31_536_000_000L; + try(FeatureState state=new FeatureState(directory.resolve("features.dat"))) { + await(state.setBank(key,limit)); await(state.pauseBank(key,true)); + assertThrows(ExecutionException.class,()->await(state.addBank(key,1))); + var debits=new java.util.concurrent.atomic.AtomicInteger(); + assertNull(await(state.storage(()->state.addBankOnWorker(key,1,()->{ debits.incrementAndGet(); return true; })))); + assertEquals(new FeatureState.Bank(limit,true),state.banks().get(key)); assertEquals(0,debits.get()); + await(state.setBank(key,limit-1000)); + var replacement=new java.util.concurrent.atomic.AtomicReference>(); + assertEquals(limit,await(state.storage(()->state.addBankOnWorker(key,1000,()->{ + debits.incrementAndGet(); replacement.set(state.setBank(key,1234)); return true; + }))).milliseconds()); + await(replacement.get()); assertEquals(1234,state.banks().get(key).milliseconds()); assertEquals(1,debits.get()); + assertThrows(IllegalStateException.class,()->state.addBankOnWorker(key,1,()->true)); + } + try(FeatureState state=new FeatureState(directory.resolve("features.dat"))) { + await(state.ready()); assertEquals(1234,state.banks().get(key).milliseconds()); + } + } + + @Test void failedGrantPublicationRollsBackClockWithoutMintingTime() throws Exception { + var fail = new java.util.concurrent.atomic.AtomicBoolean(); + var clock = new com.bencodez.mcperks.perk.BankedClock(); + var time = new java.util.concurrent.atomic.AtomicLong(1_000_000_000L); + var key = new FeatureState.BankKey(java.util.UUID.randomUUID(), "passive"); + try (var store = newFeatureState(directory.resolve("grant.dat"), (staged,file) -> { + if (fail.get()) throw new java.io.IOException("fixture grant publication failed"); + DurableFeatureFiles.publish(staged,file); + })) { + await(store.setBank(key,10000)); clock.checkpoint(time.get(),true); + store.onBankGrant((k,balance) -> clock.prepareGrant(time.get(),balance,time::get)); + time.addAndGet(2_000_000_000L); fail.set(true); + assertThrows(java.util.concurrent.ExecutionException.class, () -> await(store.addBank(key,10000))); + assertEquals(10000,store.banks().get(key).milliseconds()); + assertEquals(8000,clock.remaining(() -> store.banks().get(key).milliseconds(),time.get())); + assertFalse(store.available()); + } + } + @Test void delayedGrantPublishesBeforeRebasingAndPreservesEarlierBoundedDebit() throws Exception { + var clock = new com.bencodez.mcperks.perk.BankedClock(); + var time = new java.util.concurrent.atomic.AtomicLong(1_000_000_000L); + var block = new java.util.concurrent.atomic.AtomicBoolean(); + var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1); + var key = new FeatureState.BankKey(java.util.UUID.randomUUID(), "passive"); + try (var store = newFeatureState(directory.resolve("delayed-grant.dat"), (staged,file) -> { + if (block.get()) { entered.countDown(); try { if (!release.await(5,java.util.concurrent.TimeUnit.SECONDS)) throw new java.io.IOException("fixture timeout"); } + catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new java.io.IOException(e); } } + DurableFeatureFiles.publish(staged,file); + })) { + await(store.setBank(key,1000)); clock.checkpoint(time.get(),true); + time.addAndGet(2_000_000_000L); + long debit = clock.checkpoint(time.get(),true,() -> store.banks().get(key).milliseconds()); + assertEquals(1000,debit); + store.onBankGrant((k,balance) -> clock.prepareGrant(time.get(),balance,time::get)); + block.set(true); var grant = store.addBank(key,10000); assertTrue(entered.await(5,java.util.concurrent.TimeUnit.SECONDS)); + assertEquals(0,clock.remaining(() -> store.banks().get(key).milliseconds(),time.get())); + time.addAndGet(5_000_000_000L); assertEquals(0,clock.checkpoint(time.get(),true,() -> store.banks().get(key).milliseconds())); + var pendingDebit = store.elapsed(key,debit).thenRun(() -> clock.acknowledge(debit)); + release.countDown(); await(grant); pendingDebit.get(5,java.util.concurrent.TimeUnit.SECONDS); + assertEquals(10000,store.banks().get(key).milliseconds()); + time.addAndGet(1_000_000_000L); + assertEquals(9000,clock.remaining(() -> store.banks().get(key).milliseconds(),time.get())); + } finally { release.countDown(); } + } + +} 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..4e4324d --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/data/OrderedServerDataFileTest.java @@ -0,0 +1,892 @@ +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 FeatureState 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 FeatureState(directory.resolve("features.dat")); await(state.ready()); when(plugin.getFeatureState()).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 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")); + } + + @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); + }))); + } + 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(); verify(original).reloadData(); + verify(original, never()).saveData(); + verify((YamlConfiguration) original.getData(), never()).save(any(java.io.File.class)); + } + @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 (FeatureState successor = new FeatureState(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.getFeatureState()).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 corruptOptionalFeatureStateStillPermitsInitializedCalendarAndLegacySnapshots() throws Exception { + state.close(); Path broken = directory.resolve("broken-features.dat"); Files.writeString(broken, "invalid features"); + try (FeatureState unavailable = new FeatureState(broken)) { + assertThrows(ExecutionException.class, () -> await(unavailable.ready())); when(plugin.getFeatureState()).thenReturn(unavailable); + await(runtime.initialize(unavailable)); assertTrue(runtime.isReady()); assertFalse(unavailable.available()); + 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("featureState"); 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("featureState"); 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 (FeatureState successor = new FeatureState(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("featureState"); 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 (FeatureState successor = new FeatureState(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")); + } +} 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..e6661b0 --- /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 FeatureState 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 FeatureState(directory.resolve("features.dat")); await(state.ready()); when(plugin.getFeatureState()).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 beforeFeatureState) { + 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 (beforeFeatureState) when(plugin.getFeatureState()).thenReturn(null); + ordered = new OrderedServerDataFile(plugin, nativeFile, runtime); + if (beforeFeatureState) when(plugin.getFeatureState()).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 FeatureState(directory.resolve("features.dat")); + await(state.ready()); when(plugin.getFeatureState()).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("featureState"); 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 (FeatureState successor = new FeatureState(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))); + } + FeatureState 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 FeatureState(directory.resolve("features.dat")); when(plugin.getFeatureState()).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/listeners/AutoBlockRefillAcceptanceTest.java b/MCPerks/src/test/java/com/bencodez/mcperks/tests/listeners/AutoBlockRefillAcceptanceTest.java new file mode 100644 index 0000000..9154b48 --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/listeners/AutoBlockRefillAcceptanceTest.java @@ -0,0 +1,181 @@ +package com.bencodez.mcperks.tests.listeners; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.logging.Logger; +import org.bukkit.GameMode; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import com.bencodez.mcperks.MCPerksMain; +import com.bencodez.mcperks.data.LocalStatistics; +import com.bencodez.mcperks.listeners.UsageEffectListener; +import com.bencodez.mcperks.perk.*; +import com.bencodez.mcperks.tests.scheduling.OwnerTaskHarness; +import com.bencodez.mcperks.userapi.UsageCharges; +import com.bencodez.mcperks.userapi.UsageCharges.Kind; + +/** Runs actual placement callbacks and the durable ledger across inventory/eligibility transitions. */ +class AutoBlockRefillAcceptanceTest { + @TempDir Path directory; + private UUID owner; + private MCPerksMain plugin; + private Player player; + private PlayerInventory inventory; + private ItemStack[] items; + private int selected; + private BlockingQueue tasks; + private UsageCharges charges; + private UsageEffectListener listener; + + @BeforeEach void setup() throws Exception { + RefillPlanTest.server(); + owner = UUID.randomUUID(); + plugin = mock(MCPerksMain.class); + player = mock(Player.class); + World world = mock(World.class); + when(world.getName()).thenReturn("refill-test"); + when(player.getWorld()).thenReturn(world); + when(player.getUniqueId()).thenReturn(owner); + when(player.isOnline()).thenReturn(true); + when(player.getGameMode()).thenReturn(GameMode.SURVIVAL); + inventory = mock(PlayerInventory.class); + items = new ItemStack[41]; + when(player.getInventory()).thenReturn(inventory); + when(inventory.getHeldItemSlot()).thenAnswer(i -> selected); + when(inventory.getItem(anyInt())).thenAnswer(i -> items[i.getArgument(0, Integer.class)]); + doAnswer(i -> { items[i.getArgument(0, Integer.class)] = i.getArgument(1); return null; }) + .when(inventory).setItem(anyInt(), nullable(ItemStack.class)); + tasks = new OwnerTaskHarness(plugin, player).tasks; + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getLogger()).thenReturn(Logger.getLogger("refill-test")); + when(plugin.getLocalStatistics()).thenReturn(new LocalStatistics()); + charges = new UsageCharges(directory.resolve("charges.dat")); + charges.ready().get(5, TimeUnit.SECONDS); + when(plugin.getUsageCharges()).thenReturn(charges); + Perk perk = mock(Perk.class); + when(perk.getPerkType()).thenReturn(PerkSystemType.PLAYER); + when(perk.getInteractionOptions()).thenReturn(new InteractionOptions(true, false, 16, 8, + List.of(), List.of(), List.of(), List.of())); + when(perk.getEffects()).thenReturn(new ArrayList<>(List.of(Effect.AutoBlockRefill))); + when(perk.getEffectedPlayers()).thenReturn(new ArrayList<>(List.of(owner.toString()))); + when(perk.isNotInDisabledWorld(anyString())).thenReturn(true); + PerkHandler handler = mock(PerkHandler.class); + when(plugin.getPerkHandler()).thenReturn(handler); + when(handler.effectDecision(eq(Effect.AutoBlockRefill), eq(owner.toString()), anyString())) + .thenReturn(PerkHandler.Decision.ACTIVE); + when(handler.getActivePerks()).thenReturn(new java.util.concurrent.CopyOnWriteArrayList<>(List.of(perk))); + listener = new UsageEffectListener(plugin); + charges.set(owner, Kind.AutoBlockRefill, 2).get(5, TimeUnit.SECONDS); + charges.set(owner, Kind.VeinMining, 7).get(5, TimeUnit.SECONDS); + items[2] = new ItemStack(Material.STONE, 8); + } + + @AfterEach void close() throws Exception { + if (listener != null) listener.retire().get(5, TimeUnit.SECONDS); + if (charges != null) charges.close(); + } + + private BlockPlaceEvent place(EquipmentSlot hand) { + BlockPlaceEvent event = mock(BlockPlaceEvent.class); + when(event.getPlayer()).thenReturn(player); + when(event.getHand()).thenReturn(hand); + when(event.getItemInHand()).thenReturn(new ItemStack(Material.STONE, 1)); + return event; + } + private Runnable next() throws Exception { + Runnable task = tasks.poll(5, TimeUnit.SECONDS); + assertNotNull(task, "expected player-owner callback"); + return task; + } + private Runnable reserve(BlockPlaceEvent event) throws Exception { + listener.refill(event); + next().run(); + Runnable effect = next(); + assertEquals(1, charges.get(owner, Kind.AutoBlockRefill), "durable reservation before inventory mutation"); + assertEquals(8, items[2].getAmount()); + return effect; + } + private void assertUnused(Runnable effect) throws Exception { + effect.run(); + effect.run(); // duplicate scheduling cannot debit or mutate twice + charges.add(owner, Kind.AutoBlockRefill, 0).get(5, TimeUnit.SECONDS); + assertEquals(2, charges.get(owner, Kind.AutoBlockRefill)); + assertEquals(0, charges.successful(owner, Kind.AutoBlockRefill)); + assertEquals(7, charges.get(owner, Kind.VeinMining), "unrelated labeled balance"); + assertEquals(8, items[2].getAmount()); + verify(inventory, never()).setItem(anyInt(), any()); + assertTrue(tasks.isEmpty()); + } + + @ParameterizedTest @EnumSource(value = EquipmentSlot.class, names = {"HAND", "OFF_HAND"}) + void cancelledAfterReservationPreservesBothInventoriesAndBudget(EquipmentSlot hand) throws Exception { + BlockPlaceEvent event = place(hand); + Runnable effect = reserve(event); + when(event.isCancelled()).thenReturn(true); + assertUnused(effect); + } + + @ParameterizedTest @EnumSource(value = EquipmentSlot.class, names = {"HAND", "OFF_HAND"}) + void switchingToCreativeAfterReservationRefundsUnusedBudget(EquipmentSlot hand) throws Exception { + Runnable effect = reserve(place(hand)); + when(player.getGameMode()).thenReturn(GameMode.CREATIVE); + assertUnused(effect); + } + + @Test void creativePlacementNeverSchedulesOrReserves() { + when(player.getGameMode()).thenReturn(GameMode.CREATIVE); + listener.refill(place(EquipmentSlot.HAND)); + assertTrue(tasks.isEmpty()); + assertEquals(2, charges.get(owner, Kind.AutoBlockRefill)); + assertEquals(8, items[2].getAmount()); + verify(inventory, never()).setItem(anyInt(), any()); + } + + @Test void changedSelectedSlotAfterReservationCannotRefillEitherSlot() throws Exception { + Runnable effect = reserve(place(EquipmentSlot.HAND)); + selected = 1; + assertUnused(effect); + assertNull(items[0]); + assertNull(items[1]); + } + + @ParameterizedTest @EnumSource(value = EquipmentSlot.class, names = {"HAND", "OFF_HAND"}) + void replacementInDestinationAfterReservationIsNeverOverwritten(EquipmentSlot hand) throws Exception { + Runnable effect = reserve(place(hand)); + int destination = hand == EquipmentSlot.HAND ? 0 : 40; + ItemStack replacement = new ItemStack(Material.DIRT, 3); + items[destination] = replacement; + assertUnused(effect); + assertSame(replacement, items[destination]); + } + + @ParameterizedTest @EnumSource(value = EquipmentSlot.class, names = {"HAND", "OFF_HAND"}) + void successfulRefillMovesOneItemAndDebitsOnlyItsLabeledBudget(EquipmentSlot hand) throws Exception { + Runnable effect = reserve(place(hand)); + effect.run(); + effect.run(); + charges.add(owner, Kind.AutoBlockRefill, 0).get(5, TimeUnit.SECONDS); + assertEquals(7, items[2].getAmount()); + assertEquals(1, items[hand == EquipmentSlot.HAND ? 0 : 40].getAmount()); + assertEquals(1, charges.get(owner, Kind.AutoBlockRefill)); + assertEquals(1, charges.successful(owner, Kind.AutoBlockRefill)); + assertEquals(7, charges.get(owner, Kind.VeinMining)); + assertTrue(tasks.isEmpty()); + } +} diff --git a/MCPerks/src/test/java/com/bencodez/mcperks/tests/listeners/RefillPlanTest.java b/MCPerks/src/test/java/com/bencodez/mcperks/tests/listeners/RefillPlanTest.java new file mode 100644 index 0000000..646dbd3 --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/listeners/RefillPlanTest.java @@ -0,0 +1,80 @@ +package com.bencodez.mcperks.tests.listeners; +import com.bencodez.mcperks.listeners.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.util.Objects; +import java.util.logging.Logger; +import org.bukkit.*; +import org.bukkit.inventory.*; +import org.bukkit.inventory.meta.ItemMeta; +import org.junit.jupiter.api.*; +class RefillPlanTest { + private PlayerInventory inventory; + private ItemStack[] slots; + private int selected; + @BeforeAll static void server() { + Server server = Bukkit.getServer(); + if (server == null) { + server = mock(Server.class); when(server.getLogger()).thenReturn(Logger.getLogger("MCPerks-test")); Bukkit.setServer(server); + } + if (server.getItemFactory() == null) { + ItemFactory factory = mock(ItemFactory.class); when(server.getItemFactory()).thenReturn(factory); + when(factory.equals(nullable(ItemMeta.class), nullable(ItemMeta.class))).thenAnswer(i -> Objects.equals(i.getArgument(0), i.getArgument(1))); + when(factory.isApplicable(any(ItemMeta.class), any(Material.class))).thenReturn(true); + when(factory.asMetaFor(any(ItemMeta.class), any(Material.class))).thenAnswer(i -> i.getArgument(0)); + } + } + @BeforeEach void setup() { + slots = new ItemStack[41]; selected = 0; inventory = mock(PlayerInventory.class); + when(inventory.getHeldItemSlot()).thenAnswer(i -> selected); + when(inventory.getItem(anyInt())).thenAnswer(i -> slots[i.getArgument(0, Integer.class)]); + doAnswer(i -> { slots[i.getArgument(0, Integer.class)] = i.getArgument(1); return null; }).when(inventory).setItem(anyInt(), nullable(ItemStack.class)); + } + private ItemStack stack(int amount) { return new ItemStack(Material.STONE, amount); } + @Test void offhandCanRefillFromSelectedMainHandAndMoveExactlyOne() { + slots[0] = stack(8); RefillPlan plan = RefillPlan.capture(inventory, EquipmentSlot.OFF_HAND, stack(1)); assertNotNull(plan); + assertTrue(plan.apply()); assertEquals(7, slots[0].getAmount()); assertEquals(1, slots[40].getAmount()); + assertFalse(plan.apply()); // repeated callback cannot move another stack + } + @Test void offhandRefillPreservesCustomMetadataAndNeverOverwritesAReplacement() { + ItemMeta metadata = mock(org.bukkit.inventory.meta.Damageable.class); + when(metadata.clone()).thenReturn(metadata); + when(metadata.hasCustomModelData()).thenReturn(true); + when(metadata.getCustomModelData()).thenReturn(1234); + ItemStack custom = stack(4); custom.setItemMeta(metadata); + slots[2] = custom; + ItemStack placed = custom.clone(); placed.setAmount(1); + RefillPlan plan = RefillPlan.capture(inventory, EquipmentSlot.OFF_HAND, placed); + assertNotNull(plan); + slots[40] = new ItemStack(Material.DIRT, 1); + assertFalse(plan.apply()); assertEquals(4, slots[2].getAmount()); + slots[40] = null; + assertTrue(plan.apply()); + assertTrue(placed.isSimilar(slots[40])); + assertEquals(1234, slots[40].getItemMeta().getCustomModelData()); + assertEquals(1, slots[40].getAmount()); assertEquals(3, slots[2].getAmount()); + assertFalse(plan.apply()); assertEquals(3, slots[2].getAmount()); + } + @Test void lastSourceItemIsRemovedAndMainHandRefillsAfterVanillaConsumption() { + slots[0] = stack(1); slots[2] = stack(1); + RefillPlan plan = RefillPlan.capture(inventory, EquipmentSlot.HAND, slots[0]); + assertFalse(plan.apply()); slots[0] = null; + assertTrue(plan.apply()); assertNull(slots[2]); assertEquals(1, slots[0].getAmount()); + } + @Test void sourceCountAndMetadataChangesAreRejectedBeforeAnyMutation() { + slots[2] = stack(8); RefillPlan plan = RefillPlan.capture(inventory, EquipmentSlot.HAND, stack(1)); assertNotNull(plan); + slots[2].setAmount(7); assertFalse(plan.apply()); verify(inventory, never()).setItem(anyInt(), any()); + slots[2] = stack(8); plan = RefillPlan.capture(inventory, EquipmentSlot.HAND, stack(1)); + org.bukkit.inventory.meta.Damageable metadata = mock(org.bukkit.inventory.meta.Damageable.class); when(metadata.clone()).thenReturn(metadata); slots[2].setItemMeta(metadata); + assertFalse(plan.apply()); verify(inventory, never()).setItem(anyInt(), any()); + } + @Test void changedHotbarSlotAndOccupiedDestinationCannotRefill() { + slots[2] = stack(8); RefillPlan plan = RefillPlan.capture(inventory, EquipmentSlot.HAND, stack(1)); + selected = 1; assertFalse(plan.apply()); selected = 0; slots[0] = stack(1); assertFalse(plan.apply()); + verify(inventory, never()).setItem(anyInt(), any()); + } + @Test void differentMaterialsAndArmorAreNeverSources() { + slots[2] = new ItemStack(Material.DIRT, 4); slots[36] = stack(4); + assertNull(RefillPlan.capture(inventory, EquipmentSlot.HAND, stack(1))); + } +} diff --git a/MCPerks/src/test/java/com/bencodez/mcperks/tests/listeners/RightClickHarvestAdmissionTest.java b/MCPerks/src/test/java/com/bencodez/mcperks/tests/listeners/RightClickHarvestAdmissionTest.java new file mode 100644 index 0000000..05bca19 --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/listeners/RightClickHarvestAdmissionTest.java @@ -0,0 +1,205 @@ +package com.bencodez.mcperks.tests.listeners; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.logging.Logger; +import org.bukkit.GameMode; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.data.Ageable; +import org.bukkit.entity.Player; +import org.bukkit.event.Event; +import org.bukkit.event.block.Action; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import com.bencodez.mcperks.MCPerksMain; +import com.bencodez.mcperks.data.LocalStatistics; +import com.bencodez.mcperks.listeners.UsageEffectListener; +import com.bencodez.mcperks.perk.Effect; +import com.bencodez.mcperks.perk.InteractionOptions; +import com.bencodez.mcperks.perk.Perk; +import com.bencodez.mcperks.perk.PerkHandler; +import com.bencodez.mcperks.perk.PerkSystemType; +import com.bencodez.mcperks.tests.scheduling.OwnerTaskHarness; +import com.bencodez.mcperks.userapi.UsageCharges; +import com.bencodez.mcperks.userapi.UsageCharges.Kind; + +/** Admission and successful-use controls run the listener with its durable labeled ledger. */ +class RightClickHarvestAdmissionTest { + @TempDir Path directory; + private UUID owner; + private Player player; + private PlayerInventory inventory; + private Block block; + private Ageable age; + private ItemStack[] items; + private BlockingQueue tasks; + private UsageCharges charges; + private UsageEffectListener listener; + private Path ledger; + + @BeforeEach void setup() throws Exception { + RefillPlanTest.server(); + owner = UUID.randomUUID(); + MCPerksMain plugin = mock(MCPerksMain.class); + player = mock(Player.class); + World world = mock(World.class); + when(world.getName()).thenReturn("harvest-test"); + when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true); + when(player.getWorld()).thenReturn(world); + when(player.getUniqueId()).thenReturn(owner); + when(player.isOnline()).thenReturn(true); + when(player.getGameMode()).thenReturn(GameMode.SURVIVAL); + when(player.getLocation()).thenReturn(new Location(world, 4, 64, 4)); + inventory = mock(PlayerInventory.class); + items = new ItemStack[41]; + items[0] = new ItemStack(Material.STONE); + when(player.getInventory()).thenReturn(inventory); + when(inventory.getItem(anyInt())).thenAnswer(i -> items[i.getArgument(0, Integer.class)]); + when(inventory.getItemInMainHand()).thenAnswer(i -> items[0]); + doAnswer(i -> { items[i.getArgument(0, Integer.class)] = i.getArgument(1); return null; }) + .when(inventory).setItem(anyInt(), nullable(ItemStack.class)); + tasks = new OwnerTaskHarness(plugin, player).tasks; + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getLogger()).thenReturn(Logger.getLogger("MCPerks-harvest-test")); + when(plugin.getLocalStatistics()).thenReturn(new LocalStatistics()); + ledger = directory.resolve("charges.dat"); + charges = new UsageCharges(ledger); + charges.ready().get(5, TimeUnit.SECONDS); + when(plugin.getUsageCharges()).thenReturn(charges); + charges.set(owner, Kind.RightClickHarvest, 2).get(5, TimeUnit.SECONDS); + charges.set(owner, Kind.VeinMining, 7).get(5, TimeUnit.SECONDS); + Perk perk = mock(Perk.class); + when(perk.getPerkType()).thenReturn(PerkSystemType.PLAYER); + when(perk.getInteractionOptions()).thenReturn(new InteractionOptions(true, false, 16, 8, + List.of(), List.of(), List.of(), List.of())); + when(perk.getEffects()).thenReturn(new ArrayList<>(List.of(Effect.RightClickHarvest))); + when(perk.getEffectedPlayers()).thenReturn(new ArrayList<>(List.of(owner.toString()))); + when(perk.isNotInDisabledWorld(anyString())).thenReturn(true); + when(perk.getWhitelistedTools()).thenReturn(new ArrayList<>()); + when(perk.getWhitelistedBlocks()).thenReturn(new ArrayList<>()); + when(perk.getBlacklistedBlocks()).thenReturn(new ArrayList<>()); + when(perk.getRequiredEnchants()).thenReturn(new HashMap<>()); + PerkHandler handler = mock(PerkHandler.class); + when(plugin.getPerkHandler()).thenReturn(handler); + when(handler.getActivePerks()).thenReturn(new CopyOnWriteArrayList<>(List.of(perk))); + when(handler.effectDecision(eq(Effect.RightClickHarvest), eq(owner.toString()), anyString())) + .thenReturn(PerkHandler.Decision.ACTIVE); + block = mock(Block.class); + when(block.getWorld()).thenReturn(world); + when(block.getX()).thenReturn(4); + when(block.getY()).thenReturn(64); + when(block.getZ()).thenReturn(4); + when(block.getLocation()).thenReturn(new Location(world, 4, 64, 4)); + age = mock(Ageable.class); + when(age.clone()).thenReturn(age); + when(block.getBlockData()).thenReturn(age); + listener = new UsageEffectListener(plugin); + } + + @AfterEach void close() throws Exception { + if (listener != null) listener.retire().get(5, TimeUnit.SECONDS); + if (charges != null) charges.close(); + } + + private PlayerInteractEvent interact() { + PlayerInteractEvent event = mock(PlayerInteractEvent.class); + when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK); + when(event.getHand()).thenReturn(EquipmentSlot.HAND); + when(event.getClickedBlock()).thenReturn(block); + when(event.getPlayer()).thenReturn(player); + when(event.useInteractedBlock()).thenReturn(Event.Result.DEFAULT); + return event; + } + + private void assertNotAdmitted(byte[] originalLedger) throws Exception { + assertTrue(tasks.isEmpty(), "ineligible crop must not schedule a harvest or reserve a use"); + assertEquals(2, charges.get(owner, Kind.RightClickHarvest)); + assertEquals(7, charges.get(owner, Kind.VeinMining)); + assertEquals(0, charges.successful(owner, Kind.RightClickHarvest)); + assertArrayEquals(originalLedger, Files.readAllBytes(ledger)); + verify(inventory, never()).setItem(anyInt(), any()); + verify(player, never()).breakBlock(any()); + verify(block, never()).setBlockData(any()); + } + + @ParameterizedTest + @CsvSource({"WHEAT,7", "POTATOES,7", "CARROTS,7", "BEETROOTS,3", "NETHER_WART,3"}) + void matureCropWithoutReplantSeedNeverReservesOrChangesWorld(Material crop, int maximumAge) throws Exception { + when(block.getType()).thenReturn(crop); + when(age.getMaximumAge()).thenReturn(maximumAge); + when(age.getAge()).thenReturn(maximumAge); + items[1] = new ItemStack(Material.DIRT, 3); + byte[] before = Files.readAllBytes(ledger); + listener.harvest(interact()); + listener.harvest(interact()); + assertNotAdmitted(before); + assertEquals(3, items[1].getAmount()); + assertEquals(LocalStatistics.Skip.SEED, listener.recentSkip(owner).reason()); + } + + @ParameterizedTest + @CsvSource({"WHEAT,WHEAT_SEEDS,7", "POTATOES,POTATO,7", "CARROTS,CARROT,7", "BEETROOTS,BEETROOT_SEEDS,3", "NETHER_WART,NETHER_WART,3"}) + void immatureCropKeepsItsSeedAndDoesNotReserveAUse(Material crop, Material seed, int maximumAge) throws Exception { + when(block.getType()).thenReturn(crop); + when(age.getMaximumAge()).thenReturn(maximumAge); + when(age.getAge()).thenReturn(maximumAge - 1); + items[1] = new ItemStack(seed, 3); + ItemStack beforeSeed = items[1].clone(); + byte[] before = Files.readAllBytes(ledger); + listener.harvest(interact()); + listener.harvest(interact()); + assertNotAdmitted(before); + assertEquals(beforeSeed, items[1]); + } + + private Runnable next() throws Exception { + Runnable task = tasks.poll(5, TimeUnit.SECONDS); + assertNotNull(task, "eligible harvest must reach its player owner"); + return task; + } + + @ParameterizedTest + @CsvSource({"WHEAT,WHEAT_SEEDS,7", "POTATOES,POTATO,7", "CARROTS,CARROT,7", "BEETROOTS,BEETROOT_SEEDS,3", "NETHER_WART,NETHER_WART,3"}) + void matureCropWithSeedConsumesExactlyOneUse(Material crop, Material seed, int maximumAge) throws Exception { + when(block.getType()).thenReturn(crop); + when(age.getMaximumAge()).thenReturn(maximumAge); + when(age.getAge()).thenReturn(maximumAge); + items[1] = new ItemStack(seed, 3); + when(player.breakBlock(block)).thenAnswer(i -> { when(block.getType()).thenReturn(Material.AIR); return true; }); + listener.harvest(interact()); + next().run(); + Runnable execution = next(); + assertEquals(1, charges.get(owner, Kind.RightClickHarvest), "reserve before native break"); + execution.run(); + execution.run(); + charges.add(owner, Kind.RightClickHarvest, 0).get(5, TimeUnit.SECONDS); + assertEquals(1, charges.get(owner, Kind.RightClickHarvest)); + assertEquals(1, charges.successful(owner, Kind.RightClickHarvest)); + assertEquals(7, charges.get(owner, Kind.VeinMining)); + assertEquals(2, items[1].getAmount()); + verify(player, times(1)).breakBlock(block); + verify(age).setAge(0); + verify(block).setBlockData(age); + assertTrue(tasks.isEmpty()); + } +} diff --git a/MCPerks/src/test/java/com/bencodez/mcperks/tests/listeners/UsageEffectListenerTest.java b/MCPerks/src/test/java/com/bencodez/mcperks/tests/listeners/UsageEffectListenerTest.java new file mode 100644 index 0000000..b98d454 --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/listeners/UsageEffectListenerTest.java @@ -0,0 +1,484 @@ +package com.bencodez.mcperks.tests.listeners; +import static com.bencodez.mcperks.tests.TestAccess.*; +import com.bencodez.mcperks.listeners.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicReference; +import java.util.logging.Logger; +import org.bukkit.*; +import org.bukkit.block.Block; +import org.bukkit.block.data.Ageable; +import org.bukkit.entity.Player; +import org.bukkit.event.block.*; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import com.bencodez.mcperks.MCPerksMain; +import com.bencodez.mcperks.perk.*; +import com.bencodez.mcperks.perk.Effect; +import com.bencodez.mcperks.perk.Effect; +import com.bencodez.mcperks.userapi.UsageCharges; +import com.bencodez.mcperks.userapi.UsageCharges.Kind; +import com.bencodez.mcperks.data.LocalStatistics; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; +import com.bencodez.simpleapi.folialib.FoliaLib; +class UsageEffectListenerTest { + @TempDir Path directory; + private com.bencodez.mcperks.tests.scheduling.OwnerTaskHarness ownerScheduler; + private MCPerksMain plugin; private Player player; private Perk perk; private PerkHandler handler; + private UsageCharges charges; private UsageEffectListener listener; private ItemStack[] items; + private PlayerInventory inventory; private BlockingQueue tasks; private UUID owner; private World world; + @BeforeEach void setup() throws Exception { + RefillPlanTest.server(); owner = UUID.randomUUID(); plugin = mock(MCPerksMain.class); player = mock(Player.class); + world = mock(World.class); when(world.getName()).thenReturn("world"); when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true); + when(player.getWorld()).thenReturn(world); when(player.getUniqueId()).thenReturn(owner); when(player.isOnline()).thenReturn(true); + when(player.getGameMode()).thenReturn(GameMode.SURVIVAL); when(player.getLocation()).thenReturn(new Location(world, 4, 64, 4)); + items = new ItemStack[41]; inventory = mock(PlayerInventory.class); when(player.getInventory()).thenReturn(inventory); + when(inventory.getItem(anyInt())).thenAnswer(i -> items[i.getArgument(0, Integer.class)]); + when(inventory.getItemInMainHand()).thenAnswer(i -> items[0] == null ? new ItemStack(Material.AIR) : items[0]); + doAnswer(i -> { items[i.getArgument(0, Integer.class)] = i.getArgument(1); return null; }).when(inventory).setItem(anyInt(), nullable(ItemStack.class)); + ownerScheduler = new com.bencodez.mcperks.tests.scheduling.OwnerTaskHarness(plugin, player); tasks = ownerScheduler.tasks; + when(plugin.isEnabled()).thenReturn(true); when(plugin.getLogger()).thenReturn(Logger.getLogger("MCPerks-tests")); + when(plugin.getLocalStatistics()).thenReturn(new LocalStatistics()); + charges = new UsageCharges(directory.resolve("charges.dat")); charges.ready().get(5, TimeUnit.SECONDS); when(plugin.getUsageCharges()).thenReturn(charges); + perk = mock(Perk.class); when(perk.getPerkType()).thenReturn(PerkSystemType.PLAYER); + when(perk.getInteractionOptions()).thenReturn(new InteractionOptions(true, false, 16, 8, List.of(), List.of(), List.of(), List.of())); + when(perk.getEffects()).thenReturn(new ArrayList<>(List.of(Effect.AutoBlockRefill, Effect.RightClickHarvest, Effect.VeinMining))); + when(perk.getEffectedPlayers()).thenReturn(new ArrayList<>(List.of(owner.toString()))); when(perk.isNotInDisabledWorld(anyString())).thenReturn(true); + when(perk.getWhitelistedTools()).thenReturn(new ArrayList<>()); when(perk.getWhitelistedBlocks()).thenReturn(new ArrayList<>()); + when(perk.getBlacklistedBlocks()).thenReturn(new ArrayList<>()); when(perk.getRequiredEnchants()).thenReturn(new HashMap<>()); + handler = mock(PerkHandler.class); when(plugin.getPerkHandler()).thenReturn(handler); + when(handler.effectDecision(any(Effect.class), eq(owner.toString()), anyString())).thenReturn(PerkHandler.Decision.ACTIVE); + when(handler.getActivePerks()).thenReturn(new java.util.concurrent.CopyOnWriteArrayList<>(List.of(perk))); + listener = new UsageEffectListener(plugin); + } + @AfterEach void close() { charges.close(); } + private Runnable next() throws Exception { Runnable task = tasks.poll(5, TimeUnit.SECONDS); assertNotNull(task, "owner callback missing"); return task; } + private void barrier(Kind kind) throws Exception { charges.add(owner, kind, 0).get(5, TimeUnit.SECONDS); } + private BlockPlaceEvent place() { + BlockPlaceEvent event = mock(BlockPlaceEvent.class); when(event.getPlayer()).thenReturn(player); when(event.getHand()).thenReturn(EquipmentSlot.OFF_HAND); + when(event.getItemInHand()).thenReturn(new ItemStack(Material.STONE, 1)); return event; + } + @Test void actualRefillEntryReservesDurablyDefersUntilConsumptionAndDeduplicatesCallback() throws Exception { + charges.set(owner, Kind.AutoBlockRefill, 2).get(); items[0] = new ItemStack(Material.STONE, 8); items[40] = new ItemStack(Material.STONE, 1); + listener.refill(place()); assertEquals(8, items[0].getAmount()); items[40] = null; + next().run(); Runnable effect = next(); assertEquals(1, charges.get(owner, Kind.AutoBlockRefill)); + effect.run(); effect.run(); barrier(Kind.AutoBlockRefill); + assertEquals(7, items[0].getAmount()); assertEquals(1, items[40].getAmount()); assertEquals(1, charges.successful(owner, Kind.AutoBlockRefill)); + } + @Test void unlimitedRefillRemainsUsableWithDepletedOrUnavailableChargedGrantAheadOfIt() throws Exception { + var unlimited = mock(Perk.class); + when(unlimited.getEffects()).thenReturn(new ArrayList<>(List.of(Effect.AutoBlockRefill))); + when(unlimited.getEffectedPlayers()).thenReturn(new ArrayList<>(List.of(owner.toString()))); + when(unlimited.isNotInDisabledWorld(anyString())).thenReturn(true); + when(unlimited.getInteractionOptions()).thenReturn(new InteractionOptions(false, false, 16, 8, List.of(), List.of(), List.of(), List.of())); + when(handler.getActivePerks()).thenReturn(new CopyOnWriteArrayList<>(List.of(perk, unlimited))); + items[0] = new ItemStack(Material.STONE, 8); listener.refill(place()); next().run(); next().run(); barrier(Kind.AutoBlockRefill); + assertEquals(7, items[0].getAmount()); assertEquals(1, items[40].getAmount()); assertEquals(0, charges.get(owner, Kind.AutoBlockRefill)); + assertEquals(1, charges.successful(owner, Kind.AutoBlockRefill)); + charges.close(); items[40] = null; + listener.refill(place()); next().run(); next().run(); + assertEquals(6, items[0].getAmount()); assertEquals(1, items[40].getAmount()); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {true, false}) + void alternativeUnlimitedHarvestMustMatchItsOwnCropRulesAndNeverConsumeChargedGrant(boolean matchingCrop) throws Exception { + var unlimited = mock(Perk.class); + when(unlimited.getEffects()).thenReturn(new ArrayList<>(List.of(Effect.RightClickHarvest))); + when(unlimited.getEffectedPlayers()).thenReturn(new ArrayList<>(List.of(owner.toString()))); + when(unlimited.isNotInDisabledWorld(anyString())).thenReturn(true); + when(unlimited.getWhitelistedTools()).thenReturn(new ArrayList<>()); + when(unlimited.getWhitelistedBlocks()).thenReturn(new ArrayList<>()); + when(unlimited.getBlacklistedBlocks()).thenReturn(new ArrayList<>()); + when(unlimited.getRequiredEnchants()).thenReturn(new HashMap<>()); + when(unlimited.getInteractionOptions()).thenReturn(new InteractionOptions(false, false, 16, 8, List.of(), List.of(), + List.of(matchingCrop ? "WHEAT" : "CARROTS"), List.of())); + when(handler.getActivePerks()).thenReturn(new CopyOnWriteArrayList<>(List.of(perk, unlimited))); + charges.set(owner, Kind.RightClickHarvest, 2).get(5, TimeUnit.SECONDS); + items[0] = new ItemStack(Material.STONE); items[1] = new ItemStack(Material.WHEAT_SEEDS, 3); + Block block = crop(); when(player.breakBlock(block)).thenAnswer(i -> { when(block.getType()).thenReturn(Material.AIR); return true; }); + listener.harvest(harvest(block)); next().run(); next().run(); barrier(Kind.RightClickHarvest); + assertEquals(2, items[1].getAmount()); assertEquals(matchingCrop ? 2 : 1, charges.get(owner, Kind.RightClickHarvest)); + assertEquals(1, charges.successful(owner, Kind.RightClickHarvest)); verify(player, times(1)).breakBlock(block); + verify(block).setBlockData(any(Ageable.class)); + } + + @Test void depletedAndPrecisionSuppressedRefillsNeverMoveItemsOrConsumeCharges() throws Exception { + items[0] = new ItemStack(Material.STONE, 8); listener.refill(place()); next().run(); barrier(Kind.AutoBlockRefill); + assertEquals(8, items[0].getAmount()); assertNull(tasks.poll(50, TimeUnit.MILLISECONDS)); + charges.set(owner, Kind.AutoBlockRefill, 2).get(); when(handler.effectDecision(eq(Effect.AutoBlockRefill), anyString(), anyString())).thenReturn(PerkHandler.Decision.PRECISION_SUPPRESSED); + listener.refill(place()); assertNull(tasks.poll(50, TimeUnit.MILLISECONDS)); assertEquals(2, charges.get(owner, Kind.AutoBlockRefill)); + } + @Test void reloadBeforeInitialDeferredCallbackCannotAdoptSuccessorGeneration() throws Exception { + charges.set(owner,Kind.AutoBlockRefill,2).get(); items[0]=new ItemStack(Material.STONE,8); + listener.refill(place()); Runnable old=next(); listener.invalidate(); + old.run(); barrier(Kind.AutoBlockRefill); + assertEquals(2,charges.get(owner,Kind.AutoBlockRefill)); assertEquals(8,items[0].getAmount()); + assertNull(items[40]); assertNull(tasks.poll(50,TimeUnit.MILLISECONDS)); + listener.refill(place()); next().run(); next().run(); barrier(Kind.AutoBlockRefill); + assertEquals(1,charges.get(owner,Kind.AutoBlockRefill)); assertEquals(7,items[0].getAmount()); + assertEquals(1,items[40].getAmount()); + } + @Test void reloadAndInventoryChangesBeforeOwnerCallbackReleaseProvedUnusedReservation() throws Exception { + charges.set(owner, Kind.AutoBlockRefill, 2).get(); items[0] = new ItemStack(Material.STONE, 8); + listener.refill(place()); next().run(); Runnable effect = next(); listener.invalidate(); effect.run(); barrier(Kind.AutoBlockRefill); + assertEquals(2, charges.get(owner, Kind.AutoBlockRefill)); assertEquals(8, items[0].getAmount()); + listener.refill(place()); next().run(); effect = next(); items[0].setAmount(7); effect.run(); barrier(Kind.AutoBlockRefill); + assertEquals(2, charges.get(owner, Kind.AutoBlockRefill)); assertEquals(0, charges.successful(owner, Kind.AutoBlockRefill)); + } + private PlayerInteractEvent harvest(Block block) { + PlayerInteractEvent event = mock(PlayerInteractEvent.class); when(event.getPlayer()).thenReturn(player); when(event.getHand()).thenReturn(EquipmentSlot.HAND); + when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK); when(event.getClickedBlock()).thenReturn(block); when(event.useInteractedBlock()).thenReturn(org.bukkit.event.Event.Result.DEFAULT); return event; + } + private Block crop() { + Block block = mock(Block.class); when(block.getWorld()).thenReturn(world); when(block.getX()).thenReturn(4); when(block.getY()).thenReturn(64); when(block.getZ()).thenReturn(4); + when(block.getLocation()).thenReturn(new Location(world, 4, 64, 4)); when(block.getType()).thenReturn(Material.WHEAT); + Ageable age = mock(Ageable.class); when(age.getAge()).thenReturn(7); when(age.getMaximumAge()).thenReturn(7); when(age.clone()).thenReturn(age); when(block.getBlockData()).thenReturn(age); return block; + } + @Test void protectedHarvestRestoresExactSeedAndRefundsCharge() throws Exception { + charges.set(owner, Kind.RightClickHarvest, 2).get(); items[0] = new ItemStack(Material.STONE); items[1] = new ItemStack(Material.WHEAT_SEEDS, 3); + Block block = crop(); when(player.breakBlock(block)).thenReturn(false); listener.harvest(harvest(block)); next().run(); next().run(); barrier(Kind.RightClickHarvest); + assertEquals(3, items[1].getAmount()); assertEquals(2, charges.get(owner, Kind.RightClickHarvest)); assertEquals(0, charges.successful(owner, Kind.RightClickHarvest)); + verify(block, never()).setBlockData(any()); + } + @Test void successfulNativeHarvestConsumesOneSeedReplantsExactCropAndIgnoresOffhand() throws Exception { + charges.set(owner, Kind.RightClickHarvest, 2).get(); items[0] = new ItemStack(Material.STONE); items[1] = new ItemStack(Material.WHEAT_SEEDS, 3); + Block block = crop(); when(player.breakBlock(block)).thenAnswer(i -> { when(block.getType()).thenReturn(Material.AIR); listener.vein(new BlockBreakEvent(block, player)); return true; }); + var offhand = harvest(block); when(offhand.getHand()).thenReturn(EquipmentSlot.OFF_HAND); listener.harvest(offhand); assertTrue(tasks.isEmpty()); + listener.harvest(harvest(block)); next().run(); next().run(); barrier(Kind.RightClickHarvest); + assertEquals(2, items[1].getAmount()); assertEquals(1, charges.get(owner, Kind.RightClickHarvest)); assertEquals(1, charges.successful(owner, Kind.RightClickHarvest)); + verify(block).setBlockData(any(Ageable.class)); verify((Ageable) block.getBlockData()).setAge(0); verify(player, times(1)).breakBlock(block); + } + @Test void nativeHarvestReplacementIsPreservedWithoutRefundingAnUncertainSideEffect() throws Exception { + charges.set(owner, Kind.RightClickHarvest, 2).get(); + items[0] = new ItemStack(Material.STONE); items[1] = new ItemStack(Material.WHEAT_SEEDS, 3); + Block block = crop(); + when(player.breakBlock(block)).thenAnswer(i -> { + // A different native-break listener owns this replacement after the break succeeds. + when(block.getType()).thenReturn(Material.STONE); + return true; + }); + listener.harvest(harvest(block)); next().run(); next().run(); barrier(Kind.RightClickHarvest); + assertEquals(Material.STONE, block.getType()); + verify(block, never()).setBlockData(any()); + verify(player, times(1)).breakBlock(block); + assertEquals(2, items[1].getAmount()); + assertEquals(1, charges.get(owner, Kind.RightClickHarvest)); + assertEquals(0, charges.successful(owner, Kind.RightClickHarvest)); + assertEquals(LocalStatistics.Skip.UNCERTAIN, listener.recentSkip(owner).reason()); + assertTrue(tasks.isEmpty()); + } + @Test void ambiguousSharedChargeOwnerIsRejectedWhileLegacyUnlimitedRemainsAvailable() throws Exception { + when(perk.getPerkType()).thenReturn(PerkSystemType.ALL); items[0] = new ItemStack(Material.STONE, 8); + listener.refill(place()); assertTrue(tasks.isEmpty()); + when(perk.getInteractionOptions()).thenReturn(new InteractionOptions(false, false, 16, 8, List.of(), List.of(), List.of(), List.of())); + listener.refill(place()); next().run(); next().run(); barrier(Kind.AutoBlockRefill); assertEquals(7, items[0].getAmount()); + assertEquals(0, charges.get(owner, Kind.AutoBlockRefill)); + } + private Block oreBlock(int x, int y, int z, Material material) { + Block block = mock(Block.class); when(block.getWorld()).thenReturn(world); when(block.getX()).thenReturn(x); when(block.getY()).thenReturn(y); when(block.getZ()).thenReturn(z); + when(block.getLocation()).thenReturn(new Location(world, x, y, z)); when(block.getType()).thenReturn(material); return block; + } + @Test void actualVeinUsesPartialBudgetNativeProtectionAndNoRecursiveAdditionalBatch() throws Exception { + charges.set(owner, Kind.VeinMining, 1).get(); items[0] = new ItemStack(Material.DIAMOND_PICKAXE); + when(world.getMinHeight()).thenReturn(0); when(world.getMaxHeight()).thenReturn(256); + Map blocks = new HashMap<>(); Block origin = oreBlock(4,64,4,Material.DIAMOND_ORE), denied = oreBlock(5,64,4,Material.DIAMOND_ORE), allowed = oreBlock(6,64,4,Material.DIAMOND_ORE); + blocks.put("4,64,4", origin); blocks.put("5,64,4", denied); blocks.put("6,64,4", allowed); + when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(i -> { + int x=i.getArgument(0), y=i.getArgument(1), z=i.getArgument(2); + return blocks.computeIfAbsent(x+","+y+","+z, k -> oreBlock(x,y,z,Material.AIR)); + }); + when(player.breakBlock(denied)).thenReturn(false); + when(player.breakBlock(allowed)).thenAnswer(i -> { listener.vein(new BlockBreakEvent(allowed,player)); when(allowed.getType()).thenReturn(Material.AIR); return true; }); + listener.vein(new BlockBreakEvent(origin, player)); when(origin.getType()).thenReturn(Material.AIR); next().run(); next().run(); barrier(Kind.VeinMining); + verify(player).breakBlock(denied); verify(player).breakBlock(allowed); assertTrue(tasks.isEmpty()); + assertEquals(0, charges.get(owner, Kind.VeinMining)); assertEquals(1, charges.successful(owner, Kind.VeinMining)); + } + @Test void unloadedAdjacentChunkIsNeverObtainedAndToolBreakStopsAdditionalNativeBreaks() throws Exception { + charges.set(owner, Kind.VeinMining, 2).get(); items[0] = new ItemStack(Material.DIAMOND_PICKAXE); + when(world.getMinHeight()).thenReturn(0); when(world.getMaxHeight()).thenReturn(256); + when(world.isChunkLoaded(anyInt(), anyInt())).thenAnswer(i -> i.getArgument(0,Integer.class) == 0 && i.getArgument(1,Integer.class) == 0); + Map blocks = new HashMap<>(); Block origin = oreBlock(4,64,4,Material.DIAMOND_ORE), first = oreBlock(5,64,4,Material.DIAMOND_ORE), second = oreBlock(6,64,4,Material.DIAMOND_ORE); + blocks.put("4,64,4",origin); blocks.put("5,64,4",first); blocks.put("6,64,4",second); + when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(i -> { int x=i.getArgument(0),y=i.getArgument(1),z=i.getArgument(2); return blocks.computeIfAbsent(x+","+y+","+z,k -> oreBlock(x,y,z,Material.AIR)); }); + when(player.breakBlock(first)).thenAnswer(i -> { items[0] = new ItemStack(Material.AIR); return true; }); + listener.vein(new BlockBreakEvent(origin,player)); when(origin.getType()).thenReturn(Material.AIR); next().run(); next().run(); barrier(Kind.VeinMining); + verify(player,never()).breakBlock(second); assertEquals(1,charges.get(owner,Kind.VeinMining)); + // An ore at the loaded-chunk edge cannot cause access to the unloaded neighbour. + Block edge=oreBlock(15,64,4,Material.DIAMOND_ORE); blocks.put("15,64,4",edge); items[0]=new ItemStack(Material.DIAMOND_PICKAXE); + listener.vein(new BlockBreakEvent(edge,player)); verify(world,never()).getBlockAt(eq(16),anyInt(),anyInt()); + } + + @Test void retiredExecutionReleasesProvedUnusedChargesAndAdmissionForReconnect() throws Exception { + charges.set(owner,Kind.AutoBlockRefill,2).get(); items[0]=new ItemStack(Material.STONE,8); + listener.refill(place()); next().run(); Runnable execution=next(); ownerScheduler.retirement.get(execution).run(); execution.run(); barrier(Kind.AutoBlockRefill); + assertEquals(2,charges.get(owner,Kind.AutoBlockRefill)); assertEquals(8,items[0].getAmount()); + listener.refill(place()); next().run(); next().run(); barrier(Kind.AutoBlockRefill); assertEquals(1,charges.get(owner,Kind.AutoBlockRefill)); assertEquals(7,items[0].getAmount()); + } + @Test void deniedHarvestReturnsExactSeedMetadataWhenItsOriginalSlotChanges() throws Exception { + charges.set(owner,Kind.RightClickHarvest,1).get(); items[0]=new ItemStack(Material.STONE); items[1]=new ItemStack(Material.WHEAT_SEEDS,3); + var metadata=mock(org.bukkit.inventory.meta.Damageable.class); when(metadata.clone()).thenReturn(metadata); items[1].setItemMeta(metadata); ItemStack expected=items[1].clone(); assertEquals(items[1], expected); expected.setAmount(1); + when(inventory.addItem(any(ItemStack.class))).thenAnswer(i -> new HashMap<>(Map.of(0,i.getArgument(0,ItemStack.class)))); + Block block=crop(); when(player.breakBlock(block)).thenAnswer(i -> { items[1]=new ItemStack(Material.DIRT); return false; }); + listener.harvest(harvest(block)); next().run(); next().run(); barrier(Kind.RightClickHarvest); + verify(player).breakBlock(block); verify(inventory).addItem(any(ItemStack.class)); var restored=org.mockito.ArgumentCaptor.forClass(ItemStack.class); verify(world).dropItemNaturally(any(Location.class),restored.capture()); assertEquals(expected,restored.getValue()); assertEquals(1,charges.get(owner,Kind.RightClickHarvest)); + } + @Test void originChunkIsCheckedBeforeDeferredBlockTypeReadAndNoChargeIsSpent() throws Exception { + charges.set(owner,Kind.VeinMining,1).get(); items[0]=new ItemStack(Material.DIAMOND_PICKAXE); when(world.getMaxHeight()).thenReturn(256); + Block origin=oreBlock(4,64,4,Material.DIAMOND_ORE), extra=oreBlock(5,64,4,Material.DIAMOND_ORE); + when(world.getBlockAt(anyInt(),anyInt(),anyInt())).thenAnswer(i -> { int x=i.getArgument(0),y=i.getArgument(1),z=i.getArgument(2); return x==4&&y==64&&z==4 ? origin : x==5&&y==64&&z==4 ? extra : oreBlock(x,y,z,Material.AIR); }); + listener.vein(new BlockBreakEvent(origin,player)); next().run(); Runnable effect=next(); clearInvocations(origin); when(world.isChunkLoaded(anyInt(),anyInt())).thenReturn(false); effect.run(); barrier(Kind.VeinMining); + verify(origin,never()).getType(); verify(player,never()).breakBlock(any()); assertEquals(1,charges.get(owner,Kind.VeinMining)); + } + @Test void precisionTracksCompletedSneakingStateAndIgnoresCancelledTransitions() throws Exception { + var event=new org.bukkit.event.player.PlayerToggleSneakEvent(player,true); event.setCancelled(true); listener.sneak(event); assertTrue(tasks.isEmpty()); + event.setCancelled(false); listener.sneak(event); when(player.isSneaking()).thenReturn(false); next().run(); verify(handler).setPrecisionSneaking(owner,false); + listener.sneak(event); when(player.isSneaking()).thenReturn(true); next().run(); verify(handler).setPrecisionSneaking(owner,true); + } + + @Test void alreadySneakingPlayerIsResampledThroughTheOwnerOnListenerStartup() throws Exception { + when(player.isSneaking()).thenReturn(true); + listener.synchronizeSneaking(player); + verify(handler, never()).setPrecisionSneaking(any(), anyBoolean()); + next().run(); + verify(handler).setPrecisionSneaking(owner, true); + } + + @Test void actualOnlinePlayerResampleSuppressesARealLegacyEffectDecision() throws Exception { + when(player.isSneaking()).thenReturn(true); + Perk precision = mock(Perk.class), legacy = mock(Perk.class); + when(precision.getEffects()).thenReturn(new ArrayList<>(List.of(Effect.PrecisionMode))); + when(precision.getEffectedPlayers()).thenReturn(new ArrayList<>(List.of(owner.toString()))); + when(precision.getInteractionOptions()).thenReturn(new InteractionOptions(false, false, 16, 8, List.of(), List.of(), List.of(), List.of("NoFallDamage"))); + when(precision.isNotInDisabledWorld(anyString())).thenReturn(true); + when(legacy.getEffects()).thenReturn(new ArrayList<>(List.of(Effect.NoFallDamage))); + when(legacy.getEffectedPlayers()).thenReturn(new ArrayList<>(List.of(owner.toString()))); + when(legacy.isNotInDisabledWorld(anyString())).thenReturn(true); + var constructor = PerkHandler.class.getDeclaredConstructor(MCPerksMain.class, List.class); + constructor.setAccessible(true); + PerkHandler realHandler = constructor.newInstance(plugin, List.of(precision, legacy)); + when(plugin.getPerkHandler()).thenReturn(realHandler); + try (var mockedBukkit = mockStatic(Bukkit.class)) { + mockedBukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of(player)); + listener.synchronizeOnlinePlayers(); + } + next().run(); + assertEquals(PerkHandler.Decision.PRECISION_SUPPRESSED, + realHandler.effectDecision(Effect.NoFallDamage, owner.toString(), "world")); + } + + @Test void staleSneakingResampleCannotPublishAfterReloadOrReplaceAConnection() throws Exception { + when(player.isSneaking()).thenReturn(true); + listener.synchronizeSneaking(player); Runnable oldGeneration = next(); + listener.invalidate(); oldGeneration.run(); + verify(handler, never()).setPrecisionSneaking(any(), anyBoolean()); + + listener.synchronizeSneaking(player); Runnable oldConnection = next(); + listener.synchronizeSneaking(player); Runnable currentConnection = next(); + oldConnection.run(); verify(handler, never()).setPrecisionSneaking(any(), anyBoolean()); + currentConnection.run(); verify(handler).setPrecisionSneaking(owner, true); + } + + @Test void staleRetirementCleanupCannotClearARejoinedPlayer() throws Exception { + when(player.isSneaking()).thenReturn(true); + listener.synchronizeSneaking(player); Runnable old = next(); + listener.synchronizeSneaking(player); next(); + ownerScheduler.retirement.get(old).run(); + verify(handler, never()).setPrecisionSneaking(owner, false); + } + + @Test void oldQuitCannotClearReplacementPlayerWithTheSameUuid() throws Exception { + when(player.isSneaking()).thenReturn(true); + listener.synchronizeSneaking(player); Runnable old = next(); + Player replacement = mock(Player.class); when(replacement.getUniqueId()).thenReturn(owner); + when(replacement.isOnline()).thenReturn(true); when(replacement.isSneaking()).thenReturn(true); + var replacementScheduler = new com.bencodez.mcperks.tests.scheduling.OwnerTaskHarness(plugin, replacement); + when(plugin.getBukkitScheduler()).thenReturn(replacementScheduler.scheduler); + listener.synchronizeSneaking(replacement); Runnable current = replacementScheduler.tasks.poll(5, TimeUnit.SECONDS); + assertNotNull(current); + listener.clearSneaking(player); + ownerScheduler.retirement.get(old).run(); + verify(handler, never()).setPrecisionSneaking(owner, false); + current.run(); verify(handler).setPrecisionSneaking(owner, true); + } + + @Test void quitThenRejoinDoesNotReuseAnOldSessionToken() throws Exception { + when(player.isSneaking()).thenReturn(true); + listener.synchronizeSneaking(player); Runnable old = next(); + listener.clearSneaking(player); + clearInvocations(handler); + listener.synchronizeSneaking(player); Runnable current = next(); + old.run(); ownerScheduler.retirement.get(old).run(); + verify(handler, never()).setPrecisionSneaking(any(), anyBoolean()); + current.run(); verify(handler).setPrecisionSneaking(owner, true); + } + + @Test void offlineAndRetiredResamplesCannotPublishSneakingState() throws Exception { + listener.synchronizeSneaking(player); Runnable old = next(); + when(player.isOnline()).thenReturn(false); old.run(); + verify(handler, never()).setPrecisionSneaking(any(), anyBoolean()); + listener.retire().get(5, TimeUnit.SECONDS); + when(player.isOnline()).thenReturn(true); + listener.synchronizeOnlinePlayers(); listener.synchronizeSneaking(player); + assertTrue(tasks.isEmpty()); + verify(handler, never()).setPrecisionSneaking(any(), anyBoolean()); + } + + @Test void finalInteractionProtectionDenialPreventsReservationSeedsAndNativeBreakEvenWhenBreakingIsAllowed() throws Exception { + charges.set(owner,Kind.RightClickHarvest,1).get(); items[0]=new ItemStack(Material.STONE); items[1]=new ItemStack(Material.WHEAT_SEEDS,3); Block block=crop(); when(player.breakBlock(block)).thenReturn(true); + var event=harvest(block); listener.harvest(event); verify(event,never()).setUseInteractedBlock(any()); verify(event,never()).setUseItemInHand(any()); + // A later protection listener denies right-click interaction without denying ordinary breaking. + when(event.useInteractedBlock()).thenReturn(org.bukkit.event.Event.Result.DENY); next().run(); barrier(Kind.RightClickHarvest); + assertEquals(3,items[1].getAmount()); assertEquals(1,charges.get(owner,Kind.RightClickHarvest)); assertEquals(0,charges.successful(owner,Kind.RightClickHarvest)); assertTrue(tasks.isEmpty()); assertEquals(LocalStatistics.Skip.INTERACTION_DENIED,listener.recentSkip(owner).reason()); + var cancelled=harvest(block); listener.harvest(cancelled); when(cancelled.isCancelled()).thenReturn(true); next().run(); barrier(Kind.RightClickHarvest); + var itemDenied=harvest(block); listener.harvest(itemDenied); when(itemDenied.useItemInHand()).thenReturn(org.bukkit.event.Event.Result.DENY); next().run(); barrier(Kind.RightClickHarvest); + assertEquals(3,items[1].getAmount()); assertEquals(1,charges.get(owner,Kind.RightClickHarvest)); verify(player,never()).breakBlock(any()); verify(block,never()).setBlockData(any()); + } + + @Test void deferredHarvestAndVeinModeChangesNeverBreakSeedOrSpendCharges() throws Exception { + charges.set(owner,Kind.RightClickHarvest,1).get(); items[0]=new ItemStack(Material.STONE); items[1]=new ItemStack(Material.WHEAT_SEEDS,3); Block crop=crop(); + listener.harvest(harvest(crop)); next().run(); Runnable harvest=next(); when(player.getGameMode()).thenReturn(GameMode.CREATIVE); harvest.run(); barrier(Kind.RightClickHarvest); + assertEquals(3,items[1].getAmount()); assertEquals(1,charges.get(owner,Kind.RightClickHarvest)); verify(crop,never()).setBlockData(any()); + when(player.getGameMode()).thenReturn(GameMode.SURVIVAL); charges.set(owner,Kind.VeinMining,1).get(); items[0]=new ItemStack(Material.DIAMOND_PICKAXE); when(world.getMaxHeight()).thenReturn(256); + Block origin=oreBlock(4,64,4,Material.DIAMOND_ORE), extra=oreBlock(5,64,4,Material.DIAMOND_ORE); + when(world.getBlockAt(anyInt(),anyInt(),anyInt())).thenAnswer(i -> { int x=i.getArgument(0),y=i.getArgument(1),z=i.getArgument(2); return x==4&&y==64&&z==4?origin:x==5&&y==64&&z==4?extra:oreBlock(x,y,z,Material.AIR); }); + listener.vein(new BlockBreakEvent(origin,player)); when(origin.getType()).thenReturn(Material.AIR); next().run(); Runnable vein=next(); when(player.getGameMode()).thenReturn(GameMode.CREATIVE); vein.run(); barrier(Kind.VeinMining); + assertEquals(1,charges.get(owner,Kind.VeinMining)); verify(player,never()).breakBlock(any()); assertEquals(0,charges.successful(owner,Kind.VeinMining)); + } + @Test void unavailableBankCannotSupplyChargePolicyAheadOfAvailableUnlimitedPerk() throws Exception { + var banks=mock(BankedPerks.class); when(plugin.getBankedPerks()).thenReturn(banks); when(banks.available(perk)).thenReturn(false); + Perk unlimited=mock(Perk.class); when(unlimited.getEffects()).thenReturn(new ArrayList<>(List.of(Effect.AutoBlockRefill))); when(unlimited.getEffectedPlayers()).thenReturn(new ArrayList<>(List.of(owner.toString()))); when(unlimited.isNotInDisabledWorld("world")).thenReturn(true); + when(unlimited.getInteractionOptions()).thenReturn(new InteractionOptions(false,false,16,8,List.of(),List.of(),List.of(),List.of())); when(banks.available(unlimited)).thenReturn(true); handler.getActivePerks().add(unlimited); + items[0]=new ItemStack(Material.STONE,8); listener.refill(place()); next().run(); next().run(); barrier(Kind.AutoBlockRefill); + assertEquals(7,items[0].getAmount()); assertEquals(0,charges.get(owner,Kind.AutoBlockRefill)); assertEquals(1,charges.successful(owner,Kind.AutoBlockRefill)); + } + @Test void gracefulRetirementDrainsQueuedUnusedReservationBeforeClosingAndRestartingLedger() throws Exception { + charges.close(); Path path=directory.resolve("shutdown.dat"); CountDownLatch publishing=new CountDownLatch(1), release=new CountDownLatch(1); var block=new java.util.concurrent.atomic.AtomicBoolean(); + charges=com.bencodez.mcperks.tests.userapi.UsageChargesFixture.create(path,(staged,destination)->{ + if(block.get()) { publishing.countDown(); try { if(!release.await(5,TimeUnit.SECONDS)) throw new java.io.IOException("test timeout"); } catch(InterruptedException e) { throw new java.io.IOException(e); } } + com.bencodez.mcperks.data.DurableFeatureFiles.publish(staged,destination); + }); when(plugin.getUsageCharges()).thenReturn(charges); listener=new UsageEffectListener(plugin); charges.set(owner,Kind.AutoBlockRefill,2).get(); items[0]=new ItemStack(Material.STONE,8); + block.set(true); listener.refill(place()); next().run(); assertTrue(publishing.await(5,TimeUnit.SECONDS)); CompletableFuture drained=listener.retireAndClose(); assertFalse(drained.isDone()); + block.set(false); release.countDown(); drained.get(5,TimeUnit.SECONDS); assertEquals(2,charges.get(owner,Kind.AutoBlockRefill)); assertEquals(8,items[0].getAmount()); assertTrue(tasks.isEmpty()); + try(UsageCharges restarted=new UsageCharges(path)) { restarted.ready().get(5,TimeUnit.SECONDS); assertEquals(2,restarted.get(owner,Kind.AutoBlockRefill)); assertEquals(0,restarted.successful(owner,Kind.AutoBlockRefill)); } + } + @Test void retirementCancelsAlreadyScheduledUnusedEffectWithoutWaitingForRetiredOwner() throws Exception { + charges.set(owner,Kind.AutoBlockRefill,1).get(); items[0]=new ItemStack(Material.STONE,8); listener.refill(place()); next().run(); Runnable queued=next(); + listener.retireAndClose().get(5,TimeUnit.SECONDS); queued.run(); ownerScheduler.retirement.get(queued).run(); assertEquals(1,charges.get(owner,Kind.AutoBlockRefill)); assertEquals(8,items[0].getAmount()); + } + + @Test void predecessorListenerNeverAdmitsOrCompletesAgainstSuccessorLedgerDuringReload() throws Exception { + charges.set(owner,Kind.AutoBlockRefill,1).get(); items[0]=new ItemStack(Material.STONE,8); listener.refill(place()); + try(UsageCharges successor=new UsageCharges(directory.resolve("charges.dat"))) { + when(plugin.getUsageCharges()).thenReturn(successor); assertFalse(successor.ready().isDone()); + next().run(); next().run(); listener.retireAndClose().get(5,TimeUnit.SECONDS); successor.ready().get(5,TimeUnit.SECONDS); + assertEquals(7,items[0].getAmount()); assertEquals(0,successor.get(owner,Kind.AutoBlockRefill)); assertEquals(1,successor.successful(owner,Kind.AutoBlockRefill)); + } + } + + @Test void unlimitedRefillsContinueWhileStatisticsDiskPublicationIsBlocked() throws Exception { + charges.close(); var entered=new CountDownLatch(1); var release=new CountDownLatch(1); + charges=com.bencodez.mcperks.tests.userapi.UsageChargesFixture.create(directory.resolve("stats.dat"),(staged,destination)-> { + entered.countDown(); try { if(!release.await(5,TimeUnit.SECONDS)) throw new java.io.IOException("timeout"); } + catch(InterruptedException error) { throw new java.io.IOException(error); } + com.bencodez.mcperks.data.DurableFeatureFiles.publish(staged,destination); + }); + charges.ready().get(5,TimeUnit.SECONDS); when(plugin.getUsageCharges()).thenReturn(charges); + when(perk.getInteractionOptions()).thenReturn(new InteractionOptions(false,false,16,8,List.of(),List.of(),List.of(),List.of())); + listener=new UsageEffectListener(plugin); items[0]=new ItemStack(Material.STONE,8); + try { + listener.refill(place()); next().run(); next().run(); assertTrue(entered.await(5,TimeUnit.SECONDS)); + assertEquals(7,items[0].getAmount()); + listener.refill(place()); items[40]=null; next().run(); next().run(); + assertEquals(6,items[0].getAmount()); assertEquals(1,items[40].getAmount()); + } finally { release.countDown(); } + barrier(Kind.AutoBlockRefill); assertEquals(2,charges.successful(owner,Kind.AutoBlockRefill)); + } + @Test void veinRejectsChangedHeldSlotBeforeAnyAdditionalBlockOrCharge() throws Exception { + Runnable execute=queuedVein(); when(inventory.getHeldItemSlot()).thenReturn(1); + execute.run(); barrier(Kind.VeinMining); verify(player,never()).breakBlock(any()); + assertEquals(2,charges.get(owner,Kind.VeinMining)); assertEquals(0,charges.successful(owner,Kind.VeinMining)); + } + private Runnable queuedVein() throws Exception { + charges.set(owner,Kind.VeinMining,2).get(); items[0]=new ItemStack(Material.DIAMOND_PICKAXE); + when(world.getMinHeight()).thenReturn(0); when(world.getMaxHeight()).thenReturn(256); + Block origin=oreBlock(4,64,4,Material.DIAMOND_ORE), extra=oreBlock(5,64,4,Material.DIAMOND_ORE); + when(world.getBlockAt(anyInt(),anyInt(),anyInt())).thenAnswer(i-> { + int x=i.getArgument(0), y=i.getArgument(1), z=i.getArgument(2); + return x==4&&y==64&&z==4?origin:x==5&&y==64&&z==4?extra:oreBlock(x,y,z,Material.AIR); + }); + when(player.breakBlock(extra)).thenReturn(true); + listener.vein(new BlockBreakEvent(origin,player)); when(origin.getType()).thenReturn(Material.AIR); + next().run(); return next(); + } + private EffectListeners legacyBonus(Effect effect,Material material) { + when(plugin.getUsageEffects()).thenReturn(listener); + var effects=mock(com.bencodez.mcperks.perk.EffectHandler.class); when(plugin.getEffectHandler()).thenReturn(effects); + when(effects.getBlockBreakEvents()).thenReturn(new ArrayList<>()); + var bonus=mock(Perk.class); when(bonus.getEffects()).thenReturn(new ArrayList<>(List.of(effect))); + when(bonus.getBlocks()).thenReturn(new ArrayList<>(List.of(material))); when(bonus.getIncreasePercent(effect)).thenReturn(200.0); + when(handler.getActivePerks()).thenReturn(new CopyOnWriteArrayList<>(List.of(perk,bonus))); + when(handler.effectActive(eq(effect),eq(owner.toString()),eq("world"))).thenReturn(true); + when(bonus.getEffectedPlayers()).thenReturn(new ArrayList<>(List.of(owner.toString()))); + when(bonus.isNotInDisabledWorld("world")).thenReturn(true); + when(handler.effectAllowed(bonus,effect,owner.toString(),"world")).thenReturn(true); + when(player.getItemInHand()).thenAnswer(i -> inventory.getItemInMainHand()); + return new EffectListeners(plugin); + } + @Test void actualNativeVeinBreakRetainsFortuneDropsWithoutRecursingIntoAutomation() throws Exception { + Runnable execute=queuedVein(); Block extra=world.getBlockAt(5,64,4); + var legacy=legacyBonus(Effect.Fortune,Material.DIAMOND_ORE); + when(extra.getDrops(any(ItemStack.class))).thenAnswer(i -> List.of(new ItemStack(Material.DIAMOND,2))); + when(player.breakBlock(extra)).thenAnswer(i -> { + assertTrue(listener.isNativeBreak(owner)); var event=new BlockBreakEvent(extra,player); + legacy.onBlockBreak(event); listener.vein(event); return true; + }); + execute.run(); barrier(Kind.VeinMining); + verify(world).dropItemNaturally(eq(extra.getLocation()),argThat(item -> item.getType()==Material.DIAMOND && item.getAmount()==4)); + verify(player,times(1)).breakBlock(extra); assertFalse(listener.isNativeBreak(owner)); + verify(handler,never()).effectActive(eq(Effect.AutoPlant),anyString(),anyString()); + verify(handler,never()).effectActive(eq(Effect.IncreaseMiningArea),anyString(),anyString()); + assertEquals(1,charges.successful(owner,Kind.VeinMining)); assertTrue(tasks.isEmpty()); + } + @Test void actualNativeRightClickHarvestRetainsFarmerDropsWithoutAutoPlantOrRecursiveVein() throws Exception { + charges.set(owner,Kind.RightClickHarvest,2).get(); items[0]=new ItemStack(Material.STONE); items[1]=new ItemStack(Material.WHEAT_SEEDS,3); + Block block=crop(); var legacy=legacyBonus(Effect.Farmer,Material.WHEAT); + when(block.getDrops(any(ItemStack.class))).thenAnswer(i -> List.of(new ItemStack(Material.WHEAT,2))); + when(player.breakBlock(block)).thenAnswer(i -> { + assertTrue(listener.isNativeBreak(owner)); var event=new BlockBreakEvent(block,player); + legacy.onBlockBreak(event); listener.vein(event); when(block.getType()).thenReturn(Material.AIR); return true; + }); + listener.harvest(harvest(block)); next().run(); next().run(); barrier(Kind.RightClickHarvest); + verify(world).dropItemNaturally(eq(block.getLocation()),argThat(item -> item.getType()==Material.WHEAT && item.getAmount()==4)); + verify(player,times(1)).breakBlock(block); verify(block).setBlockData(any()); assertFalse(listener.isNativeBreak(owner)); + verify(handler,never()).effectActive(eq(Effect.AutoPlant),anyString(),anyString()); + verify(handler,never()).effectActive(eq(Effect.IncreaseMiningArea),anyString(),anyString()); + assertEquals(1,charges.successful(owner,Kind.RightClickHarvest)); assertEquals(2,items[1].getAmount()); assertTrue(tasks.isEmpty()); + } + @Test void veinRejectsReplacementToolWithSameMaterialAndEnchantmentsButDifferentMetadata() throws Exception { + Runnable execute=queuedVein(); var custom=mock(org.bukkit.inventory.meta.Damageable.class); + when(custom.clone()).thenReturn(custom); when(custom.getEnchants()).thenReturn(Map.of()); + when(custom.getDisplayName()).thenReturn("Different custom tool"); + items[0]=new ItemStack(Material.DIAMOND_PICKAXE); items[0].setItemMeta(custom); + execute.run(); barrier(Kind.VeinMining); verify(player,never()).breakBlock(any()); + assertEquals(2,charges.get(owner,Kind.VeinMining)); assertEquals(0,charges.successful(owner,Kind.VeinMining)); + } + + @Test void harvestRejectsEqualToolInAnotherSelectedSlotBeforeSeedNativeBreakOrCharge() throws Exception { + charges.set(owner,Kind.RightClickHarvest,1).get(); items[0]=new ItemStack(Material.STONE); + items[1]=items[0].clone(); items[2]=new ItemStack(Material.WHEAT_SEEDS,3); Block block=crop(); + when(player.breakBlock(block)).thenReturn(true); + listener.harvest(harvest(block)); next().run(); Runnable execution=next(); + when(inventory.getHeldItemSlot()).thenReturn(1); when(inventory.getItemInMainHand()).thenAnswer(i->items[1]); + execution.run(); barrier(Kind.RightClickHarvest); + assertEquals(3,items[2].getAmount()); assertEquals(new ItemStack(Material.STONE),items[0]); + assertEquals(1,charges.get(owner,Kind.RightClickHarvest)); assertEquals(0,charges.successful(owner,Kind.RightClickHarvest)); + verify(player,never()).breakBlock(any()); verify(block,never()).setBlockData(any()); + } + +} diff --git a/MCPerks/src/test/java/com/bencodez/mcperks/tests/listeners/compatability/McMMOEventsTest.java b/MCPerks/src/test/java/com/bencodez/mcperks/tests/listeners/compatability/McMMOEventsTest.java new file mode 100644 index 0000000..81d037e --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/listeners/compatability/McMMOEventsTest.java @@ -0,0 +1,69 @@ +package com.bencodez.mcperks.tests.listeners.compatability; +import com.bencodez.mcperks.listeners.compatability.*; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; + +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +import com.bencodez.mcperks.MCPerksMain; +import com.bencodez.mcperks.effects.McmmoXPEffect; +import com.bencodez.mcperks.perk.Effect; +import com.bencodez.mcperks.perk.Perk; +import com.bencodez.mcperks.perk.PerkHandler; +import com.gmail.nossr50.events.experience.McMMOPlayerXpGainEvent; + +class McMMOEventsTest { + @Test + void expiredBankGrantIsSkippedWhileValidGrantStillBoostsXp() { + MCPerksMain plugin = mock(MCPerksMain.class); + PerkHandler handler = mock(PerkHandler.class); + Player player = mock(Player.class); + World world = mock(World.class); + UUID owner = UUID.randomUUID(); + when(plugin.getPerkHandler()).thenReturn(handler); + when(player.getUniqueId()).thenReturn(owner); + when(player.getWorld()).thenReturn(world); + when(world.getName()).thenReturn("world"); + when(handler.effectActive(Effect.McmmoXP, owner.toString(), "world")).thenReturn(true); + + Perk expired = perk(owner, 100.0); + Perk valid = perk(owner, 50.0); + when(handler.getActivePerks()).thenReturn(new CopyOnWriteArrayList<>(List.of(expired, valid))); + when(handler.effectAllowed(expired, Effect.McmmoXP, owner.toString(), "world")).thenReturn(false); + when(handler.effectAllowed(valid, Effect.McmmoXP, owner.toString(), "world")).thenReturn(true); + + McMMOPlayerXpGainEvent event = mock(McMMOPlayerXpGainEvent.class); + when(event.getPlayer()).thenReturn(player); + when(event.getSkill()).thenReturn(null); + when(event.getXpGained()).thenReturn(10); + doAnswer(invocation -> { + assertEquals(15, invocation.getArgument(0, Integer.class)); + return null; + }).when(event).setXpGained(anyInt()); + + new McMMOEvents(plugin).onMcMMOPlayerXpGainEvent(event); + + verify(event).setXpGained(15); + verify(handler).effectAllowed(expired, Effect.McmmoXP, owner.toString(), "world"); + verify(handler).effectAllowed(valid, Effect.McmmoXP, owner.toString(), "world"); + } + + private static Perk perk(UUID owner, double increase) { + Perk perk = mock(Perk.class); + when(perk.getEffects()).thenReturn(new ArrayList<>(List.of(Effect.McmmoXP))); + when(perk.getEffectedPlayers()).thenReturn(new ArrayList<>(List.of(owner.toString()))); + when(perk.isNotInDisabledWorld("world")).thenReturn(true); + when(perk.getMcmmoSkills()).thenReturn(new ArrayList<>()); + when(perk.getIncreasePercent(Effect.McmmoXP)).thenReturn(increase); + return perk; + } +} diff --git a/MCPerks/src/test/java/com/bencodez/mcperks/tests/perk/BankedClockTest.java b/MCPerks/src/test/java/com/bencodez/mcperks/tests/perk/BankedClockTest.java new file mode 100644 index 0000000..c0c2c81 --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/perk/BankedClockTest.java @@ -0,0 +1,63 @@ +package com.bencodez.mcperks.tests.perk; +import com.bencodez.mcperks.perk.*; +import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.Test; +class BankedClockTest { + @Test void onlineEligibleElapsedUsesMonotonicTimeAndOfflineResumeStartsANewCheckpoint() { + BankedClock clock = new BankedClock(); assertEquals(0, clock.checkpoint(1_000_000_000L, true)); + assertEquals(2500, clock.checkpoint(3_500_000_000L, true)); clock.pause(); + assertEquals(0, clock.checkpoint(100_000_000_000L, true)); assertEquals(1000, clock.checkpoint(101_000_000_000L, true)); + } + @Test void manualWorldAndIgnoreTransitionsDoNotChargeIneligibleIntervals() { + BankedClock clock = new BankedClock(); clock.checkpoint(1_000_000_000L, true); + assertEquals(1000, clock.checkpoint(2_000_000_000L, false)); assertEquals(0, clock.checkpoint(200_000_000_000L, false)); + assertEquals(0, clock.checkpoint(201_000_000_000L, true)); assertEquals(1000, clock.checkpoint(202_000_000_000L, true)); + } + @Test void pendingDebitAndReadOnlyElapsedQueriesCannotMintTimeDuringStorageDelays() { + BankedClock clock = new BankedClock(); + var confirmed = new java.util.concurrent.atomic.AtomicLong(1000); + clock.checkpoint(1_000_000_000L, true); + assertEquals(500, clock.remaining(confirmed::get, 1_500_000_000L)); + assertEquals(500, clock.remaining(confirmed::get, 1_500_000_000L)); + assertEquals(500, clock.checkpoint(1_500_000_000L, true)); + assertEquals(0, clock.remaining(confirmed::get, 2_000_000_000L)); + confirmed.set(500); // Published balance, before callback acknowledges the matching debit. + assertEquals(0, clock.remaining(confirmed::get, 2_000_000_000L)); + clock.acknowledge(500); + assertEquals(0, clock.remaining(confirmed::get, 2_000_000_000L)); + assertEquals(500, clock.checkpoint(2_000_000_000L, false)); + confirmed.set(0); clock.acknowledge(500); + assertEquals(0, clock.remaining(confirmed::get, 50_000_000_000L)); + } + @Test void grantPublicationMovesBoundaryAfterOldBalanceIsConsumed() { + BankedClock clock = new BankedClock(); + clock.checkpoint(1_000_000_000L, true); + var grant = clock.prepareGrant(3_000_000_000L, 1000, () -> 3_000_000_000L); + assertEquals(1000, grant.elapsed()); + assertEquals(0, clock.remaining(() -> 11_000, 3_500_000_000L)); + grant.commit().run(); + assertEquals(1000, clock.checkpoint(4_000_000_000L, false)); + } + @Test void grantPublicationPreservesAnEarlierPendingDebit() { + BankedClock clock = new BankedClock(); + clock.checkpoint(1_000_000_000L, true); + assertEquals(1000, clock.checkpoint(2_000_000_000L, true)); + var grant = clock.prepareGrant(3_000_000_000L, 1000, () -> 3_000_000_000L); + assertEquals(0, grant.elapsed()); grant.commit().run(); + clock.acknowledge(1000); + assertEquals(1000, clock.checkpoint(4_000_000_000L, false)); + } + @Test void staleReplacementAcknowledgementCannotReleaseANewerDebitReservation() { + var clock = new BankedClock(); var confirmed = new java.util.concurrent.atomic.AtomicLong(10000); + clock.checkpoint(1_000_000_000L, true); + var oldDebit = clock.reserveCheckpoint(2_000_000_000L, true, confirmed::get); + clock.prepareReplacement(() -> 3_000_000_000L).commit().run(); + var currentDebit = clock.reserveCheckpoint(4_000_000_000L, true, confirmed::get); + assertFalse(clock.owns(oldDebit)); assertTrue(clock.owns(currentDebit)); + assertEquals(9000, clock.remaining(confirmed::get, 4_000_000_000L)); + clock.acknowledge(oldDebit); + assertEquals(9000, clock.remaining(confirmed::get, 4_000_000_000L)); + confirmed.set(9000); clock.acknowledge(currentDebit); + assertEquals(9000, clock.remaining(confirmed::get, 4_000_000_000L)); + } +} diff --git a/MCPerks/src/test/java/com/bencodez/mcperks/tests/perk/BankedPerksTest.java b/MCPerks/src/test/java/com/bencodez/mcperks/tests/perk/BankedPerksTest.java new file mode 100644 index 0000000..b461daa --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/perk/BankedPerksTest.java @@ -0,0 +1,1089 @@ +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.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicLong; +import java.util.logging.Logger; +import org.bukkit.*; +import org.bukkit.entity.Player; +import org.bukkit.event.player.*; +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 org.junit.jupiter.params.provider.CsvSource; +import com.bencodez.mcperks.MCPerksMain; +import com.bencodez.mcperks.data.*; +import com.bencodez.mcperks.tests.data.FeatureStateFixture; +import com.bencodez.mcperks.userapi.*; +import com.bencodez.mcperks.tests.scheduling.OwnerTaskHarness; +import com.bencodez.mcperks.tests.scheduling.BundledOwnerTaskHarness; +class BankedPerksTest { + @TempDir Path directory; + private UUID owner; private Player player; private World world; private MCPerksMain plugin; private MCPerksUser user; + private Perk source; private FeatureState state; private FeatureState.BankKey key; private BankedPerks banked; + private OwnerTaskHarness scheduler; private CopyOnWriteArrayList active; private AtomicLong time; + @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(player.getUniqueId()).thenReturn(owner); when(player.isOnline()).thenReturn(true); when(Bukkit.getServer().getPlayer(owner)).thenReturn(player); + world=mock(World.class); when(world.getName()).thenReturn("world"); when(player.getWorld()).thenReturn(world); + plugin=mock(MCPerksMain.class); when(plugin.getConfigFile()).thenReturn(mock(com.bencodez.mcperks.configs.Config.class)); when(plugin.isEnabled()).thenReturn(true); when(plugin.getLocalStatistics()).thenReturn(new LocalStatistics()); + user=mock(MCPerksUser.class); when(user.getUUID()).thenReturn(owner.toString()); + com.bencodez.mcperks.userapi.UserManager users=mock(com.bencodez.mcperks.userapi.UserManager.class); when(users.getMCPerksUser(owner)).thenReturn(user); when(users.getMCPerksUser(player)).thenReturn(user); when(plugin.getMcperksUserManager()).thenReturn(users); + source=mock(Perk.class); when(source.getPerk()).thenReturn("passive"); when(source.getPerkType()).thenReturn(PerkSystemType.PLAYER); when(source.getEffects()).thenReturn(new ArrayList<>(List.of(Effect.NoFallDamage))); + when(source.getInteractionOptions()).thenReturn(new InteractionOptions(false,true,16,8,List.of(),List.of(),List.of(),List.of())); when(source.isNotInDisabledWorld("world")).thenReturn(true); + when(source.clone()).thenAnswer(i -> { Perk clone=mock(Perk.class); when(clone.getPerk()).thenReturn("passive"); when(clone.getActivater()).thenReturn(user); when(clone.getEffectedPlayers()).thenReturn(new ArrayList<>()); field(clone,"plugin",plugin); doCallRealMethod().when(clone).deactivatePerk(user); return clone; }); + PerkHandler handler=mock(PerkHandler.class); when(handler.isRestorationReady()).thenReturn(true); when(plugin.getPerkHandler()).thenReturn(handler); when(handler.getPerk("passive")).thenReturn(source); active=new CopyOnWriteArrayList<>(); when(handler.getActivePerks()).thenReturn(active); when(plugin.getQueuePlanner()).thenReturn(mock(QueuePlanner.class)); + scheduler=new OwnerTaskHarness(plugin,player,true); time=new AtomicLong(1_000_000_000L); state=new FeatureState(directory.resolve("features.dat")); key=new FeatureState.BankKey(owner,"passive"); state.setBank(key,10000).get(5,TimeUnit.SECONDS); + banked=newBankedPerks(plugin,state,time::get); when(plugin.getBankedPerks()).thenReturn(banked); when(plugin.getFeatureState()).thenReturn(state); when(plugin.getQueuePlanner()).thenReturn(new QueuePlanner(plugin, state)); + } + private static void field(Perk perk,String name,Object value) throws Exception { + var field=Perk.class.getDeclaredField(name); field.setAccessible(true); field.set(perk,value); + if (name.equals("plugin")) { + var admissions = Perk.class.getDeclaredField("activationAdmissions"); admissions.setAccessible(true); + if (admissions.get(perk) == null) admissions.set(perk, new LinkedHashMap()); + } + } + private void enableRealIgnoreSetter() throws Exception { + var data = mock(com.bencodez.advancedcore.api.user.UserData.class); + when(user.getData()).thenReturn(data); + var flag = new java.util.concurrent.atomic.AtomicBoolean(); + doAnswer(call -> { flag.set(call.getArgument(1)); return null; }).when(data).setBoolean(eq("IgnoreEffects"), anyBoolean()); + when(user.isIgnorePerkEffects()).thenAnswer(call -> flag.get()); + var field = MCPerksUser.class.getDeclaredField("plugin"); field.setAccessible(true); field.set(user, plugin); + doCallRealMethod().when(user).setIgnoreEffects(anyBoolean()); + } + @Test void actualIgnoreSetterCheckpointsBeforeDisconnectWithoutWaitingForPulse() throws Exception { + enableRealIgnoreSetter(); banked.start(player); nextOwner(); + time.addAndGet(100_000_000L); user.setIgnoreEffects(true); + time.addAndGet(400_000_000L); banked.quit(new PlayerQuitEvent(player, "quit")); barrier(); + assertEquals(9900, state.banks().get(key).milliseconds()); assertTrue(active.isEmpty()); + } + @Test void repeatedIgnoreSettersAndImmediateResumeChargeOnlyEligibleIntervals() throws Exception { + enableRealIgnoreSetter(); banked.start(player); nextOwner(); + time.addAndGet(100_000_000L); user.setIgnoreEffects(true); + time.addAndGet(200_000_000L); user.setIgnoreEffects(true); + time.addAndGet(200_000_000L); user.setIgnoreEffects(false); + time.addAndGet(100_000_000L); banked.quit(new PlayerQuitEvent(player, "quit")); barrier(); + // An earlier in-flight debit may enqueue the resumed interval after a worker barrier. + banked.retireAsync().get(5, TimeUnit.SECONDS); + assertEquals(9800, state.banks().get(key).milliseconds()); + } + @Test void asyncIgnoreSetterDoesNotReadBukkitOrPlayerState() throws Exception { + enableRealIgnoreSetter(); banked.start(player); nextOwner(); + time.addAndGet(100_000_000L); clearInvocations(player, Bukkit.getServer()); + var executor = Executors.newSingleThreadExecutor(); + try { executor.submit(() -> user.setIgnoreEffects(true)).get(5, TimeUnit.SECONDS); } + finally { executor.shutdownNow(); } + verifyNoInteractions(player, Bukkit.getServer()); + time.addAndGet(400_000_000L); banked.quit(new PlayerQuitEvent(player, "quit")); barrier(); + assertEquals(9900, state.banks().get(key).milliseconds()); + } + @Test void userStorageWaitDoesNotHoldTheBankControllerMonitor() throws Exception { + enableRealIgnoreSetter(); banked.start(player); nextOwner(); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + var data = user.getData(); + doAnswer(call -> { + entered.countDown(); assertTrue(release.await(5, TimeUnit.SECONDS)); return null; + }).when(data).setBoolean(eq("IgnoreEffects"), eq(true)); + var executor = Executors.newFixedThreadPool(2); + try { + var setter = executor.submit(() -> user.setIgnoreEffects(true)); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + var pause = executor.submit(() -> banked.pause(key, true).get(5, TimeUnit.SECONDS)); + assertTrue(pause.get(2, TimeUnit.SECONDS).paused(), "owner lifecycle cannot wait for another storage setter"); + release.countDown(); setter.get(5, TimeUnit.SECONDS); + } finally { release.countDown(); executor.shutdownNow(); } + } + @Test void delayedPulseReadCannotReenableBankAfterIgnoreSetter() throws Exception { + enableRealIgnoreSetter(); banked.start(player); nextOwner(); + Objects.requireNonNull(scheduler.timers.poll(5, TimeUnit.SECONDS)).run(); + Runnable stale = Objects.requireNonNull(scheduler.tasks.poll(5, TimeUnit.SECONDS)); + time.addAndGet(100_000_000L); user.setIgnoreEffects(true); + time.addAndGet(400_000_000L); stale.run(); barrier(); + assertTrue(active.isEmpty()); assertEquals(9900, state.banks().get(key).milliseconds()); + banked.quit(new PlayerQuitEvent(player, "quit")); barrier(); assertEquals(9900, state.banks().get(key).milliseconds()); + } + @Test void ignoredSessionObservationCannotLeakAcrossReconnect() throws Exception { + enableRealIgnoreSetter(); banked.start(player); nextOwner(); + time.addAndGet(100_000_000L); user.setIgnoreEffects(true); + banked.quit(new PlayerQuitEvent(player, "quit")); barrier(); + user.setIgnoreEffects(false); scheduler.tasks.clear(); scheduler.timers.clear(); + banked.start(player); nextOwner(); assertEquals(1, active.size()); + time.addAndGet(200_000_000L); banked.quit(new PlayerQuitEvent(player, "quit")); barrier(); + assertEquals(9700, state.banks().get(key).milliseconds()); + } + @ParameterizedTest @ValueSource(strings = {"quit", "reload", "reconnect"}) + void visibleIgnoreSetterWaitPreservesBalanceAcrossLifecycle(String lifecycle) throws Exception { + enableRealIgnoreSetter(); banked.start(player); nextOwner(); + var visible = new java.util.concurrent.atomic.AtomicBoolean(); + when(user.isIgnorePerkEffects()).thenAnswer(call -> visible.get()); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + var data = user.getData(); + doAnswer(call -> { + visible.set(true); entered.countDown(); + assertTrue(release.await(5, TimeUnit.SECONDS)); return null; + }).when(data).setBoolean(eq("IgnoreEffects"), eq(true)); + var executor = Executors.newSingleThreadExecutor(); + try { + time.addAndGet(100_000_000L); + var setter = executor.submit(() -> user.setIgnoreEffects(true)); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + time.addAndGet(400_000_000L); + if (lifecycle.equals("reload")) { + banked.reload(); nextOwner(); nextOwner(); nextOwner(); + } else { + banked.quit(new PlayerQuitEvent(player, "quit")); barrier(); + if (lifecycle.equals("reconnect")) { banked.start(player); nextOwner(); nextOwner(); } + } + assertTrue(active.isEmpty()); + banked.quit(new PlayerQuitEvent(player, "quit")); barrier(); + assertEquals(9900, state.banks().get(key).milliseconds()); + release.countDown(); setter.get(5, TimeUnit.SECONDS); + barrier(); assertEquals(9900, state.banks().get(key).milliseconds()); + } finally { release.countDown(); executor.shutdownNow(); } + } + @Test void failedIgnoreSetterRereadsVisibleStorageBeforeReattaching() throws Exception { + enableRealIgnoreSetter(); banked.start(player); nextOwner(); + var visible = new java.util.concurrent.atomic.AtomicBoolean(); + when(user.isIgnorePerkEffects()).thenAnswer(call -> visible.get()); + var data = user.getData(); + doAnswer(call -> { visible.set(true); throw new IllegalStateException("uncertain user save"); }) + .when(data).setBoolean(eq("IgnoreEffects"), eq(true)); + time.addAndGet(100_000_000L); + assertThrows(IllegalStateException.class, () -> user.setIgnoreEffects(true)); + nextOwner(); nextOwner(); nextOwner(); nextOwner(); barrier(); + assertTrue(active.isEmpty()); + time.addAndGet(400_000_000L); banked.quit(new PlayerQuitEvent(player, "quit")); barrier(); + assertEquals(9900, state.banks().get(key).milliseconds()); + } + @Test void failedSetterCannotRestoreEligibilityFromAnEarlierQueuedRead() throws Exception { + enableRealIgnoreSetter(); banked.start(player); nextOwner(); + var attached = active.getFirst(); + Objects.requireNonNull(scheduler.timers.poll(5, TimeUnit.SECONDS)).run(); + Runnable stale = Objects.requireNonNull(scheduler.tasks.poll(5, TimeUnit.SECONDS)); + var visible = new java.util.concurrent.atomic.AtomicBoolean(); + when(user.isIgnorePerkEffects()).thenAnswer(call -> visible.get()); + var data = user.getData(); + doAnswer(call -> { visible.set(true); throw new IllegalStateException("uncertain user save"); }) + .when(data).setBoolean(eq("IgnoreEffects"), eq(true)); + time.addAndGet(100_000_000L); + assertThrows(IllegalStateException.class, () -> user.setIgnoreEffects(true)); + assertFalse(banked.available(attached), "failed setter needs a fresh storage observation"); + stale.run(); + assertTrue(active.isEmpty(), "a pre-setter read can suspend but cannot restore eligibility"); + nextOwner(); nextOwner(); nextOwner(); nextOwner(); barrier(); + assertTrue(active.isEmpty()); + time.addAndGet(400_000_000L); banked.quit(new PlayerQuitEvent(player, "quit")); barrier(); + assertEquals(9900, state.banks().get(key).milliseconds()); + } + @ParameterizedTest @ValueSource(booleans = {true, false}) + void concurrentSettersReachStorageWithoutWaitingAndResolveTheActualFinalFlag(boolean firstValue) throws Exception { + enableRealIgnoreSetter(); banked.start(player); nextOwner(); + var attached = active.getFirst(); + var visible = new java.util.concurrent.atomic.AtomicBoolean(); + when(user.isIgnorePerkEffects()).thenAnswer(call -> visible.get()); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + var data = user.getData(); + doAnswer(call -> { + boolean value = call.getArgument(1); visible.set(value); + if (value == firstValue) { entered.countDown(); assertTrue(release.await(5, TimeUnit.SECONDS)); } + return null; + }).when(data).setBoolean(eq("IgnoreEffects"), anyBoolean()); + var executor = Executors.newFixedThreadPool(2); + try { + time.addAndGet(100_000_000L); + var first = executor.submit(() -> user.setIgnoreEffects(firstValue)); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + time.addAndGet(400_000_000L); + // The second caller must reach its storage deferral before the first returns. + executor.submit(() -> user.setIgnoreEffects(!firstValue)).get(1, TimeUnit.SECONDS); + assertFalse(banked.available(attached)); + assertEquals(!firstValue, user.isIgnorePerkEffects()); + release.countDown(); first.get(5, TimeUnit.SECONDS); + assertFalse(banked.available(attached), "finish order alone cannot establish the final stored flag"); + for (int i = 0; i < 8; i++) nextOwner(); + if (firstValue) assertEquals(1, active.size()); else assertTrue(active.isEmpty()); + time.addAndGet(100_000_000L); banked.quit(new PlayerQuitEvent(player, "quit")); + banked.retireAsync().get(5, TimeUnit.SECONDS); + assertEquals(firstValue ? 9800 : 9900, state.banks().get(key).milliseconds()); + } finally { release.countDown(); executor.shutdownNow(); } + } + private void nextOwner() throws Exception { Objects.requireNonNull(scheduler.tasks.poll(5,TimeUnit.SECONDS)).run(); } + private void barrier() throws Exception { state.storage(() -> null).get(5,TimeUnit.SECONDS); } + @ParameterizedTest @ValueSource(strings = {"omitted", "-1", "-12", "0", "1", "60"}) + void configuredLegacyDisabledTimersGrantAndAttachWithoutAnOverride(String timer) throws Exception { + var yaml = new org.bukkit.configuration.file.YamlConfiguration(); + yaml.loadFromString("Enabled: true\nPerkType: PLAYER\nTimeLasts: 10\nEffects: [NoFallDamage]\nBankedTime:\n Enabled: true\n"); + if (!timer.equals("omitted")) yaml.set("ReActivateEffectsTimer", Integer.parseInt(timer)); + var config = mock(com.bencodez.mcperks.configs.ConfigPerks.class, CALLS_REAL_METHODS); + doReturn(yaml).when(config).getData("passive"); + doReturn(new ArrayList()).when(config).getPerkDisabledWorlds("passive"); + var previous = MCPerksMain.plugin; MCPerksMain.plugin = plugin; + try (var configs = mockStatic(com.bencodez.mcperks.configs.ConfigPerks.class)) { + configs.when(com.bencodez.mcperks.configs.ConfigPerks::getInstance).thenReturn(config); + var configured = new Perk("passive"); + when(plugin.getPerkHandler().getPerk("passive")).thenReturn(configured); + int value = timer.equals("omitted") ? -1 : Integer.parseInt(timer); + assertEquals(value, configured.getReActivateEffectsTimer()); + assertEquals(value <= 0, BankedPerks.supported(configured)); + if (value <= 0) { + banked.grant(user, configured, 10).get(5, TimeUnit.SECONDS); nextOwner(); nextOwner(); + assertEquals(20000, state.banks().get(key).milliseconds()); assertEquals(1, active.size()); + assertTrue(banked.available(active.getFirst())); + } else { + assertThrows(ExecutionException.class, () -> banked.grant(user, configured, 10).get(5, TimeUnit.SECONDS)); + banked.start(player); nextOwner(); assertTrue(active.isEmpty()); + assertEquals(10000, state.banks().get(key).milliseconds()); + } + } finally { MCPerksMain.plugin = previous; } + } + @Test void actualControllerReplacementStartsWithTheEntireNewBalance() throws Exception { + banked.start(player); nextOwner(); time.addAndGet(2_000_000_000L); + state.setBank(key, 10000).get(5, TimeUnit.SECONDS); banked.refresh(owner); nextOwner(); + assertTrue(banked.available(active.getFirst())); + time.addAndGet(1_000_000_000L); banked.pause(key, true).get(5, TimeUnit.SECONDS); barrier(); + assertEquals(new FeatureState.Bank(9000, true), state.banks().get(key)); + } + @Test void actualControllerReplacementFencesAnEarlierDebitAdmittedAfterPublication() throws Exception { + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + state = spy(state); when(plugin.getFeatureState()).thenReturn(state); when(plugin.getQueuePlanner()).thenReturn(new QueuePlanner(plugin, state)); + doAnswer(invocation -> { + if (Thread.currentThread().getName().equals("old-bank-debit")) { + entered.countDown(); + try { if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("checkpoint timeout"); } + catch (InterruptedException failure) { Thread.currentThread().interrupt(); throw new IllegalStateException(failure); } + } + return invocation.callRealMethod(); + }).when(state).elapsed(eq(key), anyLong(), any(java.util.function.BooleanSupplier.class)); + banked = newBankedPerks(plugin, state, time::get); when(plugin.getBankedPerks()).thenReturn(banked); + banked.start(player); nextOwner(); time.addAndGet(2_000_000_000L); + Objects.requireNonNull(scheduler.timers.poll(5, TimeUnit.SECONDS)).run(); + Runnable pulse = Objects.requireNonNull(scheduler.tasks.poll(5, TimeUnit.SECONDS)); + var executor = Executors.newSingleThreadExecutor(); + try { + var running = executor.submit(() -> { Thread.currentThread().setName("old-bank-debit"); pulse.run(); }); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + state.setBank(key, 10000).get(5, TimeUnit.SECONDS); + release.countDown(); running.get(5, TimeUnit.SECONDS); barrier(); + assertEquals(10000, state.banks().get(key).milliseconds()); + time.addAndGet(1_000_000_000L); banked.pause(key, true).get(5, TimeUnit.SECONDS); barrier(); + assertEquals(new FeatureState.Bank(9000, true), state.banks().get(key)); + } finally { release.countDown(); executor.shutdownNow(); } + } + private void publishingState(DurableFeatureFiles.Publisher publisher) throws Exception { + banked.retire(); state.close(); + state = FeatureStateFixture.create(directory.resolve("features.dat"), publisher); state.ready().get(5, TimeUnit.SECONDS); + banked = newBankedPerks(plugin, state, time::get); + when(plugin.getBankedPerks()).thenReturn(banked); when(plugin.getFeatureState()).thenReturn(state); when(plugin.getQueuePlanner()).thenReturn(new QueuePlanner(plugin, state)); + scheduler.tasks.clear(); scheduler.timers.clear(); + } + @Test void actualControllerAttachingDuringReplacementPublicationUsesItsCommitBoundary() throws Exception { + var block = new java.util.concurrent.atomic.AtomicBoolean(); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + publishingState((staged, file) -> { + if (block.get()) { + entered.countDown(); + try { if (!release.await(5, TimeUnit.SECONDS)) throw new java.io.IOException("publication timeout"); } + catch (InterruptedException failure) { Thread.currentThread().interrupt(); throw new java.io.IOException(failure); } + } + DurableFeatureFiles.publish(staged, file); + }); + banked.start(player); + Runnable initialPulse = Objects.requireNonNull(scheduler.tasks.poll(5, TimeUnit.SECONDS)); + block.set(true); var replacement = state.setBank(key, 10000); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); initialPulse.run(); assertEquals(1, active.size()); + time.addAndGet(2_000_000_000L); release.countDown(); replacement.get(5, TimeUnit.SECONDS); + time.addAndGet(1_000_000_000L); banked.pause(key, true).get(5, TimeUnit.SECONDS); barrier(); + assertEquals(new FeatureState.Bank(9000, true), state.banks().get(key)); + } finally { release.countDown(); } + } + @ParameterizedTest @ValueSource(strings = {"quit", "reload", "retire"}) + void actualReplacementFencesLateClocksRemovedBeforeCommit(String removal) throws Exception { + var block = new java.util.concurrent.atomic.AtomicBoolean(); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + publishingState((staged, file) -> { + if (block.get()) { + entered.countDown(); + try { if (!release.await(5, TimeUnit.SECONDS)) throw new java.io.IOException("publication timeout"); } + catch (InterruptedException failure) { Thread.currentThread().interrupt(); throw new java.io.IOException(failure); } + } + DurableFeatureFiles.publish(staged, file); + }); + banked.start(player); + Runnable initialPulse = Objects.requireNonNull(scheduler.tasks.poll(5, TimeUnit.SECONDS)); + block.set(true); var replacement = state.setBank(key, 10000); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); initialPulse.run(); assertEquals(1, active.size()); + time.addAndGet(2_000_000_000L); + switch (removal) { + case "quit" -> banked.quit(new PlayerQuitEvent(player, "quit")); + case "reload" -> banked.reload(); + case "retire" -> banked.retire(); + default -> fail("unknown removal"); + } + assertTrue(active.isEmpty()); + block.set(false); release.countDown(); replacement.get(5, TimeUnit.SECONDS); barrier(); + assertEquals(10000, state.banks().get(key).milliseconds(), "removed old clock cannot debit replacement"); + // A new controller proves the fence belongs to the existing state owner, + // while a new clock still acknowledges and spends its own reservations. + banked.retire(); scheduler.tasks.clear(); scheduler.timers.clear(); + banked = newBankedPerks(plugin, state, time::get); when(plugin.getBankedPerks()).thenReturn(banked); + banked.start(player); nextOwner(); assertEquals(1, active.size()); + time.addAndGet(1_000_000_000L); banked.pause(key, true).get(5, TimeUnit.SECONDS); barrier(); + assertEquals(new FeatureState.Bank(9000, true), state.banks().get(key)); + time.addAndGet(5_000_000_000L); state.addBank(key, 1000).get(5, TimeUnit.SECONDS); + assertEquals(new FeatureState.Bank(10000, true), state.banks().get(key)); + } finally { release.countDown(); } + } + @Test void actualControllerAttachingDuringTopUpCannotSpendNewTimeBeforePublication() throws Exception { + state.setBank(key, 1000).get(5, TimeUnit.SECONDS); + var block = new java.util.concurrent.atomic.AtomicBoolean(); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + publishingState((staged, file) -> { + if (block.get()) { + entered.countDown(); + try { if (!release.await(5, TimeUnit.SECONDS)) throw new java.io.IOException("publication timeout"); } + catch (InterruptedException failure) { Thread.currentThread().interrupt(); throw new java.io.IOException(failure); } + } + DurableFeatureFiles.publish(staged, file); + }); + banked.start(player); + Runnable initialPulse = Objects.requireNonNull(scheduler.tasks.poll(5, TimeUnit.SECONDS)); + block.set(true); var topUp = state.addBank(key, 10000); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); initialPulse.run(); assertEquals(1, active.size()); + time.addAndGet(5_000_000_000L); + assertFalse(banked.available(active.getFirst())); // Only the old one second was spendable. + release.countDown(); topUp.get(5, TimeUnit.SECONDS); barrier(); + assertEquals(10000, state.banks().get(key).milliseconds()); + time.addAndGet(1_000_000_000L); banked.pause(key, true).get(5, TimeUnit.SECONDS); barrier(); + assertEquals(new FeatureState.Bank(9000, true), state.banks().get(key)); + } finally { release.countDown(); } + } + @ParameterizedTest @CsvSource({"add,quit", "add,reload", "add,retire", "replace,quit", "replace,reload", "replace,retire"}) + void actualConfirmedBankBoundaryFencesLateClockRetirement(String publication, String removal) throws Exception { + state.setBank(key, 1000).get(5, TimeUnit.SECONDS); + var block = new java.util.concurrent.atomic.AtomicBoolean(); + var publicationEntered = new CountDownLatch(1); var releasePublication = new CountDownLatch(1); + var commitEntered = new CountDownLatch(1); var releaseCommit = new CountDownLatch(1); + var checkpointEntered = new CountDownLatch(1); + publishingState((staged, file) -> { + try { assertFalse(FeatureStateFixture.ownsBankBoundary(state), "filesystem publication cannot hold the bank boundary"); } + catch (ReflectiveOperationException failure) { throw new java.io.IOException(failure); } + if (block.get()) { + publicationEntered.countDown(); + try { if (!releasePublication.await(5, TimeUnit.SECONDS)) throw new java.io.IOException("publication timeout"); } + catch (InterruptedException failure) { Thread.currentThread().interrupt(); throw new java.io.IOException(failure); } + } + DurableFeatureFiles.publish(staged, file); + }); + state = spy(state); when(plugin.getFeatureState()).thenReturn(state); when(plugin.getQueuePlanner()).thenReturn(new QueuePlanner(plugin, state)); + banked = newBankedPerks(plugin, state, time::get); when(plugin.getBankedPerks()).thenReturn(banked); + doAnswer(call -> { + if (Thread.currentThread().getName().equals("late-bank-retirement")) checkpointEntered.countDown(); + return call.callRealMethod(); + }).when(state).bankCheckpoint(any()); + FeatureStateFixture.beforeBankCommit(state, () -> { + commitEntered.countDown(); + try { if (!releaseCommit.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("commit timeout"); } + catch (InterruptedException failure) { Thread.currentThread().interrupt(); throw new IllegalStateException(failure); } + }); + banked.start(player); Runnable initialPulse = Objects.requireNonNull(scheduler.tasks.poll(5, TimeUnit.SECONDS)); + var ownerExecutor = Executors.newSingleThreadExecutor(); block.set(true); + var publishing = publication.equals("add") ? state.addBank(key, 10000) : state.setBank(key, 10000); + try { + assertTrue(publicationEntered.await(5, TimeUnit.SECONDS)); initialPulse.run(); assertEquals(1, active.size()); + time.addAndGet(5_000_000_000L); block.set(false); releasePublication.countDown(); + assertTrue(commitEntered.await(5, TimeUnit.SECONDS)); + assertEquals(1000, state.banks().get(key).milliseconds(), "new time cannot appear before its clock boundary"); + var retiring = ownerExecutor.submit(() -> { + Thread.currentThread().setName("late-bank-retirement"); + switch (removal) { + case "quit" -> banked.quit(new PlayerQuitEvent(player, "quit")); + case "reload" -> banked.reload(); + case "retire" -> banked.retire(); + default -> fail("unknown removal"); + } + }); + assertTrue(checkpointEntered.await(5, TimeUnit.SECONDS)); assertFalse(retiring.isDone()); + releaseCommit.countDown(); publishing.get(5, TimeUnit.SECONDS); retiring.get(5, TimeUnit.SECONDS); barrier(); + assertEquals(10000, state.banks().get(key).milliseconds()); assertTrue(active.isEmpty()); + banked.retire(); scheduler.tasks.clear(); scheduler.timers.clear(); + banked = newBankedPerks(plugin, state, time::get); when(plugin.getBankedPerks()).thenReturn(banked); + banked.start(player); nextOwner(); assertEquals(1, active.size()); + time.addAndGet(1_000_000_000L); banked.pause(key, true).get(5, TimeUnit.SECONDS); barrier(); + assertEquals(new FeatureState.Bank(9000, true), state.banks().get(key)); + } finally { releasePublication.countDown(); releaseCommit.countDown(); ownerExecutor.shutdownNow(); } + } + @ParameterizedTest @ValueSource(strings = {"add", "replace"}) + void actualLateClockAfterCompletePublicationStartsAtAttachment(String publication) throws Exception { + state.setBank(key, 1000).get(5, TimeUnit.SECONDS); banked.start(player); + Runnable initialPulse = Objects.requireNonNull(scheduler.tasks.poll(5, TimeUnit.SECONDS)); + time.addAndGet(5_000_000_000L); + if (publication.equals("add")) state.addBank(key, 10000).get(5, TimeUnit.SECONDS); + else state.setBank(key, 10000).get(5, TimeUnit.SECONDS); + initialPulse.run(); assertEquals(1, active.size()); + time.addAndGet(1_000_000_000L); banked.pause(key, true).get(5, TimeUnit.SECONDS); barrier(); + assertEquals(new FeatureState.Bank(publication.equals("add") ? 10000 : 9000, true), state.banks().get(key)); + } + @Test void failedLateClockTopUpNeverExposesUnconfirmedTimeOrRunsCommit() throws Exception { + state.setBank(key, 1000).get(5, TimeUnit.SECONDS); + var fail = new java.util.concurrent.atomic.AtomicBoolean(); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + publishingState((staged, file) -> { + if (fail.get()) { + entered.countDown(); + try { if (!release.await(5, TimeUnit.SECONDS)) throw new java.io.IOException("publication timeout"); } + catch (InterruptedException failure) { Thread.currentThread().interrupt(); throw new java.io.IOException(failure); } + throw new java.io.IOException("top-up publication rejected"); + } + DurableFeatureFiles.publish(staged, file); + }); + var commits = new java.util.concurrent.atomic.AtomicInteger(); FeatureStateFixture.beforeBankCommit(state, commits::incrementAndGet); + banked.start(player); Runnable initialPulse = Objects.requireNonNull(scheduler.tasks.poll(5, TimeUnit.SECONDS)); + fail.set(true); var topUp = state.addBank(key, 10000); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); initialPulse.run(); time.addAndGet(5_000_000_000L); + assertFalse(banked.available(active.getFirst())); release.countDown(); + assertThrows(ExecutionException.class, () -> topUp.get(5, TimeUnit.SECONDS)); + assertEquals(0, commits.get()); assertEquals(1000, state.banks().get(key).milliseconds()); state.close(); + try (var recovered = new FeatureState(directory.resolve("features.dat"))) { + recovered.ready().get(5, TimeUnit.SECONDS); assertEquals(1000, recovered.banks().get(key).milliseconds()); + } + } finally { release.countDown(); } + } + @Test void actualControllerManualPauseDuringGrantPublicationNeverChargesThePausedInterval() throws Exception { + var block = new java.util.concurrent.atomic.AtomicBoolean(); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + publishingState((staged, file) -> { + if (block.get()) { + entered.countDown(); + try { if (!release.await(5, TimeUnit.SECONDS)) throw new java.io.IOException("publication timeout"); } + catch (InterruptedException failure) { Thread.currentThread().interrupt(); throw new java.io.IOException(failure); } + } + DurableFeatureFiles.publish(staged, file); + }); + banked.start(player); nextOwner(); time.addAndGet(2_000_000_000L); + block.set(true); var grant = state.addBank(key, 10000); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); + var pause = banked.pause(key, true); assertTrue(active.isEmpty()); assertFalse(pause.isDone()); + time.addAndGet(5_000_000_000L); release.countDown(); grant.get(5, TimeUnit.SECONDS); pause.get(5, TimeUnit.SECONDS); + assertEquals(new FeatureState.Bank(18000, true), state.banks().get(key)); + time.addAndGet(3_000_000_000L); state.addBank(key, 10000).get(5, TimeUnit.SECONDS); + assertEquals(new FeatureState.Bank(28000, true), state.banks().get(key)); assertTrue(active.isEmpty()); + time.addAndGet(10_000_000_000L); banked.pause(key, false).get(5, TimeUnit.SECONDS); + nextOwner(); nextOwner(); // pause/resume refreshes cannot bypass the existing pulse. + Objects.requireNonNull(scheduler.timers.poll(5, TimeUnit.SECONDS)).run(); nextOwner(); assertEquals(1, active.size()); + time.addAndGet(1_000_000_000L); banked.pause(key, true).get(5, TimeUnit.SECONDS); barrier(); + assertEquals(new FeatureState.Bank(27000, true), state.banks().get(key)); + } finally { release.countDown(); } + } + @Test void actualControllerPauseAndResumeDuringReplacementSaveExcludePausedTime() throws Exception { + var block = new java.util.concurrent.atomic.AtomicBoolean(); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + publishingState((staged, file) -> { + if (block.get()) { + entered.countDown(); + try { if (!release.await(5, TimeUnit.SECONDS)) throw new java.io.IOException("publication timeout"); } + catch (InterruptedException failure) { Thread.currentThread().interrupt(); throw new java.io.IOException(failure); } + } + DurableFeatureFiles.publish(staged, file); + }); + banked.start(player); nextOwner(); time.addAndGet(2_000_000_000L); + block.set(true); var replacement = state.setBank(key, 10000); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); var pause = banked.pause(key, true); assertTrue(active.isEmpty()); + time.addAndGet(5_000_000_000L); var resume = banked.pause(key, false); + release.countDown(); replacement.get(5, TimeUnit.SECONDS); pause.get(5, TimeUnit.SECONDS); resume.get(5, TimeUnit.SECONDS); + assertEquals(new FeatureState.Bank(10000, false), state.banks().get(key)); + time.addAndGet(3_000_000_000L); nextOwner(); nextOwner(); + Objects.requireNonNull(scheduler.timers.poll(5, TimeUnit.SECONDS)).run(); nextOwner(); assertEquals(1, active.size()); + time.addAndGet(1_000_000_000L); banked.pause(key, true).get(5, TimeUnit.SECONDS); barrier(); + assertEquals(new FeatureState.Bank(9000, true), state.banks().get(key)); + } finally { release.countDown(); } + } + @Test void actualControllerFailedReplacementPreservesItsOldClockAndPendingOwnership() throws Exception { + var fail = new java.util.concurrent.atomic.AtomicBoolean(); + publishingState((staged, file) -> { + if (fail.get()) throw new java.io.IOException("replacement publication failed"); + DurableFeatureFiles.publish(staged, file); + }); + banked.start(player); nextOwner(); time.addAndGet(2_000_000_000L); fail.set(true); + assertThrows(ExecutionException.class, () -> state.setBank(key, 20000).get(5, TimeUnit.SECONDS)); + assertEquals(10000, state.banks().get(key).milliseconds()); assertFalse(banked.available(active.getFirst())); + state.close(); + try (var recovered = new FeatureState(directory.resolve("features.dat"))) { + recovered.ready().get(5, TimeUnit.SECONDS); assertEquals(10000, recovered.banks().get(key).milliseconds()); + } + } + @Test void actualControllerRebasesClockWhenDepletedBankIsToppedUpBeforeCheckpoint() throws Exception { + state.setBank(key, 1000).get(5, TimeUnit.SECONDS); + banked.start(player); nextOwner(); + time.addAndGet(2_000_000_000L); + state.addBank(key, 10_000).get(5, TimeUnit.SECONDS); + time.addAndGet(1_000_000_000L); + banked.pause(key, true).get(5, TimeUnit.SECONDS); + barrier(); + assertEquals(9000, state.banks().get(key).milliseconds()); + } + @Test void actualControllerNondepletedTopUpChargesTimeAcrossTheSameBoundary() throws Exception { + banked.start(player); nextOwner(); + time.addAndGet(2_000_000_000L); + state.addBank(key, 10_000).get(5, TimeUnit.SECONDS); + time.addAndGet(1_000_000_000L); + banked.pause(key, true).get(5, TimeUnit.SECONDS); + barrier(); + assertEquals(17_000, state.banks().get(key).milliseconds()); + } + private com.bencodez.advancedcore.api.user.usercache.UserDataCache directRuntime() throws Exception { + field(source,"plugin",plugin); field(source,"perk","passive"); when(source.getTime()).thenReturn(10); + // Direct activation's admission callback uses the global scheduler in + // this fixture; the bank attachment itself still uses the owner task. + var directScheduler = plugin.getBukkitScheduler(); + doAnswer(invocation -> { invocation.getArgument(1).run(); return null; }) + .when(directScheduler).runTask(eq(plugin), any(Runnable.class)); + when(source.checkTimesUsed(any())).thenReturn(true); when(source.checkCoolDown(any())).thenReturn(true); when(source.checkServerWideCoolDown(any())).thenReturn(true); + var config=mock(com.bencodez.mcperks.configs.Config.class); when(plugin.getConfigFile()).thenReturn(config); + when(plugin.getLogger()).thenReturn(Logger.getLogger("test")); + var file=mock(com.bencodez.advancedcore.data.ServerData.class); when(file.getData()).thenReturn(new org.bukkit.configuration.file.YamlConfiguration()); + when(file.getdFile()).thenReturn(directory.resolve("ServerData.yml").toFile()); when(plugin.getServerDataFile()).thenReturn(file); + when(plugin.getMcperksServerData()).thenReturn(new ServerData(plugin)); + var users=mock(com.bencodez.advancedcore.api.user.UserManager.class); when(plugin.getUserManager()).thenReturn(users); + var data=mock(com.bencodez.advancedcore.api.user.usercache.UserDataManager.class); when(users.getDataManager()).thenReturn(data); + 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(data.getTimer()).thenReturn(storageWorker); + var cache=mock(com.bencodez.advancedcore.api.user.usercache.UserDataCache.class); when(data.getCache(any())).thenReturn(cache); + doAnswer(i -> { i.getArgument(0,Runnable.class).run(); return null; }).when(cache).flushChangesAndRun(any()); + doCallRealMethod().when(source).runPerk(any()); doCallRealMethod().when(source).executeConfirmed(any(),anyInt()); + return cache; + } + @AfterEach void close() throws Exception { + // Some cases intentionally fail checkpoint publication. Wait for either outcome + // and for the real bounded worker to stop before JUnit removes its directory. + try { banked.retireAsync().handle((ignored, failure) -> null).get(5, TimeUnit.SECONDS); } + finally { + state.close(); + var field = FeatureState.class.getDeclaredField("worker"); field.setAccessible(true); + assertTrue(((ExecutorService) field.get(state)).awaitTermination(5, TimeUnit.SECONDS), + "feature storage worker must stop before temporary-directory cleanup"); + } + } + @Test void concurrentPauseWaitsForPulseCheckpointDebitAdmission() throws Exception { + var checkpointEntered = new CountDownLatch(1); var releaseCheckpoint = new CountDownLatch(1); + state = spy(state); when(plugin.getFeatureState()).thenReturn(state); when(plugin.getQueuePlanner()).thenReturn(new QueuePlanner(plugin, state)); + doAnswer(invocation -> { + if (Thread.currentThread().getName().equals("pulse-checkpoint")) { + checkpointEntered.countDown(); + try { if (!releaseCheckpoint.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("checkpoint timeout"); } + catch (InterruptedException failure) { Thread.currentThread().interrupt(); throw new IllegalStateException(failure); } + } + return invocation.callRealMethod(); + }).when(state).elapsed(eq(key), anyLong(), any(java.util.function.BooleanSupplier.class)); + banked = newBankedPerks(plugin, state, time::get); + when(plugin.getBankedPerks()).thenReturn(banked); + banked.start(player); nextOwner(); time.addAndGet(1_000_000_000L); + Objects.requireNonNull(scheduler.timers.poll(5, TimeUnit.SECONDS)).run(); + Runnable pulse = Objects.requireNonNull(scheduler.tasks.poll(5, TimeUnit.SECONDS)); + var executor = Executors.newFixedThreadPool(2); + try { + var running = executor.submit(() -> { Thread.currentThread().setName("pulse-checkpoint"); pulse.run(); }); + assertTrue(checkpointEntered.await(5, TimeUnit.SECONDS)); + var pauseEntered = new CountDownLatch(1); + var paused = executor.submit(() -> { pauseEntered.countDown(); return banked.pause(key, true).get(5, TimeUnit.SECONDS); }); + assertTrue(pauseEntered.await(5, TimeUnit.SECONDS)); + assertFalse(paused.isDone(), "pause cannot overtake an in-progress pulse checkpoint"); + releaseCheckpoint.countDown(); running.get(5, TimeUnit.SECONDS); paused.get(5, TimeUnit.SECONDS); barrier(); + assertEquals(9000, state.banks().get(key).milliseconds()); assertTrue(state.banks().get(key).paused()); + assertTrue(active.isEmpty()); + } finally { releaseCheckpoint.countDown(); executor.shutdownNow(); } + } + @Test void actualControllerPausesOfflineManualTimeAndRejectsStaleReloadCallbackWithoutRegrant() throws Exception { + banked.start(player); nextOwner(); assertEquals(1,active.size()); Runnable oldTimer=Objects.requireNonNull(scheduler.timers.poll(5,TimeUnit.SECONDS)); + banked.pause(key,true).get(); nextOwner(); assertTrue(active.isEmpty()); banked.quit(new PlayerQuitEvent(player,"quit")); long remaining=state.banks().get(key).milliseconds(); + state.pauseBank(key,false).get(); banked.start(player); nextOwner(); assertEquals(1,active.size()); assertEquals(remaining,state.banks().get(key).milliseconds()); oldTimer.run(); assertEquals(1,active.size()); + banked.reload(); nextOwner(); nextOwner(); assertEquals(1,active.size()); oldTimer.run(); assertEquals(1,active.size()); banked.retire(); assertTrue(active.isEmpty()); barrier(); + assertEquals(remaining,state.banks().get(key).milliseconds()); verify(plugin,never()).getEffectHandler(); verify(user,never()).addActivation(anyInt()); verify(user,never()).setPerkExperation(any(Perk.class),anyLong()); + verify(scheduler.implementation,atLeastOnce()).runAtEntityLater(eq(player),any(Runnable.class),any(Runnable.class),eq(20L)); + } + @Test void attachedShortBankExpiresWhileContextReadWaitsWithoutMutatingInspection() throws Exception { + state.setBank(key,1000).get(); banked.start(player); nextOwner(); + Perk effect=active.getFirst(); assertTrue(banked.available(effect)); + CountDownLatch entered=new CountDownLatch(1), release=new CountDownLatch(1); + var blocker=state.storage(()-> { entered.countDown(); try { assertTrue(release.await(5,TimeUnit.SECONDS)); } + catch(InterruptedException failure) { throw new RuntimeException(failure); } return null; }); + assertTrue(entered.await(5,TimeUnit.SECONDS)); + try { + Objects.requireNonNull(scheduler.timers.poll(5,TimeUnit.SECONDS)).run(); + time.addAndGet(1_000_000_000L); + assertFalse(banked.available(effect)); assertFalse(banked.available(effect)); + assertEquals(1000,state.banks().get(key).milliseconds()); + assertSame(effect,active.getFirst()); + } finally { release.countDown(); } + blocker.get(5,TimeUnit.SECONDS); nextOwner(); barrier(); nextOwner(); + assertTrue(active.isEmpty()); assertEquals(0,state.banks().get(key).milliseconds()); + } + @Test void shortSessionsWorldReloadAndRetirementPersistEveryElapsedEligibleInterval() throws Exception { + banked.start(player); nextOwner(); time.addAndGet(600_000_000); banked.quit(new PlayerQuitEvent(player,"quit")); barrier(); assertEquals(9400,state.banks().get(key).milliseconds()); + time.set(10_000_000_000L); banked.start(player); nextOwner(); assertEquals(9400,state.banks().get(key).milliseconds()); time.addAndGet(300_000_000); banked.world(new PlayerChangedWorldEvent(player,world)); barrier(); assertEquals(9100,state.banks().get(key).milliseconds()); + banked.reload(); nextOwner(); nextOwner(); time.addAndGet(200_000_000); banked.reload(); barrier(); assertEquals(8900,state.banks().get(key).milliseconds()); nextOwner(); nextOwner(); time.addAndGet(100_000_000); banked.retire(); barrier(); assertEquals(8800,state.banks().get(key).milliseconds()); + state.close(); try(FeatureState restarted=new FeatureState(directory.resolve("features.dat"))) { restarted.ready().get(); assertEquals(8800,restarted.banks().get(key).milliseconds()); } + } + @Test void reloadRestartsSuspendedOnlineBanksAndLegacyDeactivationPausesOwnedEntitlement() throws Exception { + when(source.isNotInDisabledWorld("world")).thenReturn(false); banked.start(player); nextOwner(); assertTrue(active.isEmpty()); banked.reload(); nextOwner(); nextOwner(); assertTrue(active.isEmpty()); + when(source.isNotInDisabledWorld("world")).thenReturn(true); Objects.requireNonNull(scheduler.timers.poll(5,TimeUnit.SECONDS)).run(); // stale timer is fenced + Objects.requireNonNull(scheduler.timers.poll(5,TimeUnit.SECONDS)).run(); nextOwner(); assertEquals(1,active.size()); + time.addAndGet(500_000_000); active.getFirst().deactivatePerk(user); + assertTrue(active.isEmpty()); + // Pause publication follows the elapsed checkpoint's completion callback; + // one file-worker barrier may precede that callback's next admitted write. + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (!state.banks().get(key).paused() && System.nanoTime() < deadline) barrier(); + assertTrue(state.banks().get(key).paused()); assertEquals(9500,state.banks().get(key).milliseconds()); + verify(user,never()).setPerkExperation(any(),anyLong()); + } + @Test void stalePositiveBankCannotAuthorizeEffectsAfterStorageFailure() throws Exception { + banked.start(player); nextOwner(); Perk attached=active.getFirst(); Files.delete(directory.resolve("features.dat")); Files.createDirectory(directory.resolve("features.dat")); assertThrows(ExecutionException.class,()->state.setBank(key,10000).get()); + assertEquals(10000,state.banks().get(key).milliseconds()); assertFalse(banked.available(attached)); + } + @Test void newBankGrantRecordsCooldownOnceAndResumeDoesNotRecordItAgain() throws Exception { + field(source,"plugin",plugin); when(source.getCoolDown()).thenReturn(30); when(source.getServerWideCoolDown()).thenReturn(60); doCallRealMethod().when(source).executeConfirmed(user,10); + directRuntime(); + long before=System.currentTimeMillis(); source.executeConfirmed(user,10).get(5,TimeUnit.SECONDS); verify(user,times(1)).setPerkCoolDown(eq(source),longThat(value->value>=before+29000)); long serverCooldown = plugin.getMcperksServerData().getServerCooldown("passive"); assertTrue(serverCooldown >= before + 59000); + var written = org.bukkit.configuration.file.YamlConfiguration.loadConfiguration(directory.resolve("ServerData.yml").toFile()); assertEquals(serverCooldown, written.getLong("passive.CoolDown")); + banked.pause(key,true).get(); banked.pause(key,false).get(); verify(user,times(1)).setPerkCoolDown(any(),anyLong()); + assertEquals(serverCooldown, plugin.getMcperksServerData().getServerCooldown("passive")); + } + @Test void directBankSerializesBeforeChecksAndKeepsFenceThroughUserConfirmation() throws Exception { + var cache=directRuntime(); state.setBank(key,0).get(5,TimeUnit.SECONDS); + var callback=new CompletableFuture(); doAnswer(i -> { callback.complete(i.getArgument(0)); return null; }).when(cache).flushChangesAndRun(any()); + var entered=new CountDownLatch(1); var release=new CountDownLatch(1); + state.storage(() -> { 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)); + try { + source.runPerk(user); source.runPerk(user); assertEquals(0,state.banks().get(key).milliseconds()); + verify(source,times(1)).checkTimesUsed(user); verify(user).sendMessage(contains("still being confirmed")); + verify(user, never()).setPerkCoolDown(eq(source), anyLong()); + release.countDown(); var completed=callback.get(5,TimeUnit.SECONDS); + assertEquals(0,state.banks().get(key).milliseconds()); assertTrue(active.isEmpty()); + source.runPerk(user); verify(source,times(1)).checkTimesUsed(user); + completed.run(); barrier(); barrier(); + assertEquals(10000,state.banks().get(key).milliseconds()); + assertTrue(banked.beginDirect(source)); banked.finishDirect(source); + try (var configs=mockStatic(com.bencodez.mcperks.configs.ConfigPerks.class)) { + var config=mock(com.bencodez.mcperks.configs.ConfigPerks.class); configs.when(com.bencodez.mcperks.configs.ConfigPerks::getInstance).thenReturn(config); + when(config.getPerkAlreayActive("passive")).thenReturn("already active"); + source.runPerk(user); verify(user).sendMessage("already active"); + verify(user,times(1)).setPerkCoolDown(eq(source),anyLong()); assertEquals(10000,state.banks().get(key).milliseconds()); + } + } finally { release.countDown(); } + } + @Test void idleLimitedBankedActivationUsesDirectStartFenceInsteadOfWaitingQueue() throws Exception { + directRuntime(); state.setBank(key, 0).get(5, TimeUnit.SECONDS); + when(plugin.getConfigFile().getLimitActivations()).thenReturn(true); + when(plugin.getConfigFile().getPerkQue()).thenReturn(true); + when(user.getActivations()).thenReturn(1); + var planner = mock(QueuePlanner.class); + when(plugin.getQueuePlanner()).thenReturn(planner); + when(planner.beginDirect(owner, source)).thenReturn(true); + when(planner.startDirect(eq(user), eq(source), any(), any())).thenReturn(CompletableFuture.completedFuture(null)); + source.runPerk(user); + verify(planner).startDirect(eq(user), eq(source), any(), any()); + verify(plugin.getPerkHandler(), never()).addQue(user, source); + } + @Test void directBankDoesNotPublishEntitlementUntilCooldownConfirmationSucceeds() throws Exception { + var cache = directRuntime(); state.setBank(key, 0).get(5, TimeUnit.SECONDS); + var confirmation = new CompletableFuture(); + doAnswer(invocation -> { confirmation.complete(invocation.getArgument(0)); return null; }) + .when(cache).flushChangesAndRun(any()); + var granting = source.executeConfirmed(user, 10); + var callback = confirmation.get(5, TimeUnit.SECONDS); + assertEquals(0, state.banks().get(key).milliseconds(), + "bank publication must wait for the caller cooldown confirmation"); + assertFalse(granting.isDone()); + callback.run(); + granting.get(5, TimeUnit.SECONDS); + assertEquals(10000, state.banks().get(key).milliseconds()); + } + @Test void directBankPublishesServerCooldownBeforeReleasingAdmissionAndRejectsOtherPlayerAfterReload() throws Exception { + directRuntime(); state.setBank(key,0).get(5,TimeUnit.SECONDS); when(source.getServerWideCoolDown()).thenReturn(60); + doCallRealMethod().when(source).setServerCoolDownTime(anyLong()); + var previous=MCPerksMain.plugin; MCPerksMain.plugin=plugin; + try { + source.runPerk(user); barrier(); barrier(); barrier(); + assertTrue(banked.beginDirect(source)); banked.finishDirect(source); + long cooldown=plugin.getMcperksServerData().getServerCooldown("passive"); assertTrue(cooldown>System.currentTimeMillis()+59000); + var written=org.bukkit.configuration.file.YamlConfiguration.loadConfiguration(directory.resolve("ServerData.yml").toFile()); + assertEquals(cooldown,written.getLong("passive.CoolDown")); + plugin.getMcperksServerData().getData().loadFromString(written.saveToString()); + UUID other=UUID.randomUUID(); var otherUser=mock(MCPerksUser.class); when(otherUser.getUUID()).thenReturn(other.toString()); + doCallRealMethod().when(source).checkServerWideCoolDown(any()); + try (var configs=mockStatic(com.bencodez.mcperks.configs.ConfigPerks.class)) { + var config=mock(com.bencodez.mcperks.configs.ConfigPerks.class); configs.when(com.bencodez.mcperks.configs.ConfigPerks::getInstance).thenReturn(config); + when(config.getPerkInCoolDown("passive")).thenReturn("cooldown"); source.runPerk(otherUser); + verify(otherUser).sendMessage(eq("cooldown"),eq("cooldown"),anyString()); + } + assertFalse(state.banks().containsKey(new FeatureState.BankKey(other,"passive"))); verify(user,times(1)).setPerkCoolDown(eq(source),anyLong()); + } finally { MCPerksMain.plugin=previous; } + } + @Test void failedDirectBankPublicationReleasesAdmissionAndRetainsConfirmedCooldown() throws Exception { + directRuntime(); state.setBank(key,0).get(5,TimeUnit.SECONDS); + when(plugin.getConfigFile().getPerkQue()).thenReturn(true); + Files.delete(directory.resolve("features.dat")); Files.createDirectory(directory.resolve("features.dat")); + source.runPerk(user); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (plugin.getQueuePlanner().hasDirectStart() && System.nanoTime() < deadline) + state.runtimeStorage(() -> null).get(5,TimeUnit.SECONDS); + assertFalse(plugin.getQueuePlanner().hasDirectStart()); + assertTrue(banked.beginDirect(source)); banked.finishDirect(source); + assertEquals(0, state.banks().get(key).milliseconds()); + verify(user,times(1)).setPerkCoolDown(eq(source),anyLong()); + assertTrue(Files.exists(directory.resolve("ServerData.yml"))); + Files.delete(directory.resolve("features.dat")); + } + @Test void unavailableDirectBankPurchaseRejectsBeforeAnyActivationDebit() throws Exception { + field(source,"plugin",plugin); field(source,"perk","passive"); when(source.getTime()).thenReturn(10); when(source.checkTimesUsed(user)).thenReturn(true); when(source.checkCoolDown(user)).thenReturn(true); when(source.checkServerWideCoolDown(user)).thenReturn(true); + var config=mock(com.bencodez.mcperks.configs.Config.class); when(config.getLimitActivations()).thenReturn(true); when(plugin.getConfigFile()).thenReturn(config); var serverData=mock(ServerData.class); when(serverData.isReady()).thenReturn(true); when(serverData.getData()).thenReturn(new org.bukkit.configuration.file.YamlConfiguration()); when(plugin.getMcperksServerData()).thenReturn(serverData); + Files.delete(directory.resolve("features.dat")); Files.createDirectory(directory.resolve("features.dat")); assertThrows(ExecutionException.class,()->state.setBank(key,10000).get()); doCallRealMethod().when(source).runPerk(user); source.runPerk(user); + verify(user,never()).addActivation(anyInt()); verify(user,never()).addActivation(anyString(),anyInt()); verify(user).sendMessage(contains("unavailable")); verify(source,never()).executeConfirmed(any(),anyInt()); + } + @Test void oldCheckpointCompletionCannotSuspendAReloadedSuccessorGrant() throws Exception { + state.setBank(key,1000).get(); banked.start(player); nextOwner(); time.addAndGet(1_000_000_000); + scheduler.timers.remove().run(); nextOwner(); Runnable oldCompletion=Objects.requireNonNull(scheduler.tasks.poll(5,TimeUnit.SECONDS)); barrier(); assertEquals(0,state.banks().get(key).milliseconds()); + state.setBank(key,10000).get(); banked.reload(); nextOwner(); nextOwner(); assertEquals(1,active.size()); oldCompletion.run(); assertEquals(1,active.size()); assertTrue(banked.available(active.getFirst())); + } + @Test void legacyLogoutPolicyDoesNotTurnAutomaticOfflinePauseIntoManualBankPause() throws Exception { + var config=mock(com.bencodez.mcperks.configs.Config.class); when(config.getDeactivateOnLogout()).thenReturn(true); when(plugin.getConfigFile()).thenReturn(config); + banked.start(player); nextOwner(); time.addAndGet(250_000_000); var event=new PlayerQuitEvent(player,"quit"); new com.bencodez.mcperks.listeners.EffectListeners(plugin).onDisconnect(event); banked.quit(event); barrier(); + assertEquals(9750,state.banks().get(key).milliseconds()); assertFalse(state.banks().get(key).paused()); assertTrue(active.isEmpty()); + } + + @Test void defaultPlayersWithoutBankedEntitlementDoNotStartPollingUserStorage() throws Exception { + state.setBank(key,0).get(); banked.start(player); assertTrue(scheduler.tasks.isEmpty()); assertTrue(scheduler.timers.isEmpty()); verify(user,never()).isIgnorePerkEffects(); + } + + @Test void failedOrRejectedCheckpointDetachesBankEvenWithAvailablePositiveSnapshot() throws Exception { + banked.start(player); nextOwner(); time.addAndGet(1_000_000_000); scheduler.timers.remove().run(); + // Ignore read is admitted, but saturate the actual bounded store before the owner checkpoint. + Runnable checkpoint=Objects.requireNonNull(scheduler.tasks.poll(5,TimeUnit.SECONDS)); + CountDownLatch entered=new CountDownLatch(1), release=new CountDownLatch(1); + state.storage(()->{ entered.countDown(); try { assertTrue(release.await(5,TimeUnit.SECONDS)); } catch(InterruptedException e) { throw new RuntimeException(e); } return null; }); + assertTrue(entered.await(5,TimeUnit.SECONDS)); List> queued=new ArrayList<>(); for(int i=0;i<256;i++) queued.add(state.storage(()->null)); + checkpoint.run(); assertTrue(state.available()); assertEquals(10000,state.banks().get(key).milliseconds()); assertTrue(active.isEmpty()); assertTrue(scheduler.timers.isEmpty()); + release.countDown(); CompletableFuture.allOf(queued.toArray(CompletableFuture[]::new)).get(5,TimeUnit.SECONDS); barrier(); + } + @Test void retirementDrainsAdditionalElapsedReservedWhileDebitIsInFlight() throws Exception { + banked.start(player); nextOwner(); time.addAndGet(1_000_000_000L); + scheduler.timers.remove().run(); + Runnable checkpoint = Objects.requireNonNull(scheduler.tasks.poll(5, TimeUnit.SECONDS)); + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + state.storage(() -> { entered.countDown(); try { assertTrue(release.await(5, TimeUnit.SECONDS)); } + catch (InterruptedException failure) { Thread.currentThread().interrupt(); throw new RuntimeException(failure); } + return null; }); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + try { + checkpoint.run(); time.addAndGet(1_000_000_000L); + var retirement = banked.retireAsync(); + assertSame(retirement, banked.retireAsync()); assertFalse(retirement.isDone()); + release.countDown(); retirement.get(5, TimeUnit.SECONDS); + assertEquals(8000, state.banks().get(key).milliseconds()); + assertTrue(active.isEmpty()); + } finally { release.countDown(); } + } + @Test void retirementSurfacesFinalDebitFailureInsteadOfAcknowledgingIt() throws Exception { + banked.start(player); nextOwner(); time.addAndGet(1_000_000_000L); + banked.retireAsync().get(5, TimeUnit.SECONDS); + state = spy(state); + when(plugin.getFeatureState()).thenReturn(state); + // The controller must capture the failing persistence owner at construction. + doReturn(CompletableFuture.failedFuture(new IllegalStateException("checkpoint rejected"))) + .when(state).elapsed(eq(key), anyLong(), any(java.util.function.BooleanSupplier.class)); + banked = newBankedPerks(plugin, state, time::get); when(plugin.getBankedPerks()).thenReturn(banked); + banked.start(player); nextOwner(); time.addAndGet(1_000_000_000L); + var retirement = banked.retireAsync(); + assertThrows(ExecutionException.class, () -> retirement.get(5, TimeUnit.SECONDS)); + assertSame(retirement, banked.retireAsync()); + assertEquals(9000, state.banks().get(key).milliseconds()); + } + @Test void reconnectRetainsAnInFlightDisconnectDebitReservation() throws Exception { + banked.start(player); nextOwner(); time.addAndGet(1_000_000_000L); + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + state.storage(() -> { entered.countDown(); try { assertTrue(release.await(5, TimeUnit.SECONDS)); } + catch (InterruptedException failure) { Thread.currentThread().interrupt(); throw new RuntimeException(failure); } + return null; }); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + banked.quit(new PlayerQuitEvent(player, "quit")); + banked.start(player); + release.countDown(); + Runnable reconnectPulse = Objects.requireNonNull(scheduler.tasks.poll(5, TimeUnit.SECONDS)); + reconnectPulse.run(); barrier(); + assertEquals(9000, state.banks().get(key).milliseconds(), "the reserved interval must debit once across reconnect"); + } + @Test void reconnectRetriesARejectedDisconnectDebitWithoutRemintingElapsedTime() throws Exception { + var reject = new java.util.concurrent.atomic.AtomicBoolean(true); + state = spy(state); when(plugin.getFeatureState()).thenReturn(state); when(plugin.getQueuePlanner()).thenReturn(new QueuePlanner(plugin, state)); + doAnswer(call -> reject.getAndSet(false) + ? CompletableFuture.failedFuture(new IllegalStateException("checkpoint rejected")) + : call.callRealMethod()).when(state).elapsed(eq(key), anyLong(), any(java.util.function.BooleanSupplier.class)); + banked = newBankedPerks(plugin, state, time::get); when(plugin.getBankedPerks()).thenReturn(banked); + banked.start(player); nextOwner(); time.addAndGet(1_000_000_000L); banked.quit(new PlayerQuitEvent(player, "quit")); + banked.start(player); nextOwner(); barrier(); + assertEquals(9000, state.banks().get(key).milliseconds(), "a failed debit must be retried once, not replaced by a fresh interval"); + assertTrue(banked.available(active.getFirst())); + } + @Test void storageDelayBeforeInitialAttachAndResumeDoesNotSpendUnavailableTime() throws Exception { + CountDownLatch entered=new CountDownLatch(1), release=new CountDownLatch(1); + state.storage(()->{ entered.countDown(); try { assertTrue(release.await(5,TimeUnit.SECONDS)); } catch(InterruptedException e) { throw new RuntimeException(e); } return null; }); + assertTrue(entered.await(5,TimeUnit.SECONDS)); banked.start(player); time.addAndGet(4_000_000_000L); assertTrue(active.isEmpty()); release.countDown(); nextOwner(); + time.addAndGet(250_000_000L); banked.pause(key,true).get(); barrier(); assertEquals(9750,state.banks().get(key).milliseconds()); nextOwner(); + state.pauseBank(key,false).get(); CountDownLatch resumed=new CountDownLatch(1), resumeRelease=new CountDownLatch(1); + state.storage(()->{ resumed.countDown(); try { assertTrue(resumeRelease.await(5,TimeUnit.SECONDS)); } catch(InterruptedException e) { throw new RuntimeException(e); } return null; }); + assertTrue(resumed.await(5,TimeUnit.SECONDS)); scheduler.timers.remove().run(); time.addAndGet(3_000_000_000L); resumeRelease.countDown(); nextOwner(); + assertEquals(1,active.size()); time.addAndGet(100_000_000L); banked.quit(new PlayerQuitEvent(player,"quit")); barrier(); assertEquals(9650,state.banks().get(key).milliseconds()); + } + @Test void retiredRefreshGrantAndReloadDispatchesCannotStopReconnectSuccessor() throws Exception { + banked.start(player); nextOwner(); banked.refresh(owner); Runnable refresh=Objects.requireNonNull(scheduler.tasks.poll(5,TimeUnit.SECONDS)); + banked.grant(user,source,1).get(); Runnable grant=Objects.requireNonNull(scheduler.tasks.poll(5,TimeUnit.SECONDS)); + banked.reload(); Runnable reload=Objects.requireNonNull(scheduler.tasks.poll(5,TimeUnit.SECONDS)); + when(player.isOnline()).thenReturn(false); Player successor=mock(Player.class); when(successor.getUniqueId()).thenReturn(owner); when(successor.isOnline()).thenReturn(true); when(successor.getWorld()).thenReturn(world); when(Bukkit.getServer().getPlayer(owner)).thenReturn(successor); + OwnerTaskHarness newScheduler=new OwnerTaskHarness(plugin,successor,true); banked.start(successor); Objects.requireNonNull(newScheduler.tasks.poll(5,TimeUnit.SECONDS)).run(); assertEquals(1,active.size()); Perk current=active.getFirst(); + scheduler.retirement.get(refresh).run(); scheduler.retirement.get(grant).run(); scheduler.retirement.get(reload).run(); refresh.run(); grant.run(); reload.run(); + assertSame(current,active.getFirst()); assertTrue(banked.available(current)); time.addAndGet(100_000_000L); banked.quit(new PlayerQuitEvent(player,"old quit")); assertSame(current,active.getFirst()); + banked.quit(new PlayerQuitEvent(successor,"quit")); barrier(); assertTrue(active.isEmpty()); assertEquals(10900,state.banks().get(key).milliseconds()); + } + + @Test void persistentDoubleJumpFlagsAreRejectedBeforeBankGrantOrAttachment() throws Exception { + for(Effect effect:List.of(Effect.DoubleJump,Effect.DoubleJumpUp)) { + when(source.getEffects()).thenReturn(new ArrayList<>(List.of(effect))); + assertNotNull(banked.grantUnavailable(owner,source,10)); + assertThrows(ExecutionException.class,()->banked.grant(user,source,10).get(5,TimeUnit.SECONDS)); + banked.start(player); while(!scheduler.tasks.isEmpty()) nextOwner(); + assertTrue(active.isEmpty()); assertEquals(10000,state.banks().get(key).milliseconds()); + } + verify(player,never()).setAllowFlight(anyBoolean()); verify(user,never()).addActivation(anyInt()); + } + + @ParameterizedTest @ValueSource(booleans={false,true}) + void bundledSchedulerPulsesExpireTheAttachedEffectAtPurchasedTime(boolean paper) throws Exception { + var bundled=new BundledOwnerTaskHarness(plugin,paper); + state.setBank(key,1000).get(5,TimeUnit.SECONDS); + banked.start(player); + Objects.requireNonNull(bundled.tasks.poll(5,TimeUnit.SECONDS)).run(); + assertEquals(1,active.size()); Perk effect=active.getFirst(); assertTrue(banked.available(effect)); + verify(bundled.nativeScheduler).runTaskLater(eq(plugin),any(Runnable.class),eq(20L)); + Runnable pulse=Objects.requireNonNull(bundled.timers.poll(5,TimeUnit.SECONDS)); + time.addAndGet(1_000_000_000L); pulse.run(); + Objects.requireNonNull(bundled.tasks.poll(5,TimeUnit.SECONDS)).run(); // owner checkpoint + Objects.requireNonNull(bundled.tasks.poll(5,TimeUnit.SECONDS)).run(); // confirmed zero balance + barrier(); assertEquals(0,state.banks().get(key).milliseconds()); + assertTrue(active.isEmpty()); assertFalse(banked.attached(effect)); verify(effect).setActive(false); + Objects.requireNonNull(bundled.timers.poll(5,TimeUnit.SECONDS)).run(); // zero bank stops polling + assertTrue(bundled.tasks.isEmpty()); assertTrue(bundled.timers.isEmpty()); + verify(plugin,never()).getEffectHandler(); verify(user,never()).addActivation(anyInt()); + } + + @ParameterizedTest @ValueSource(booleans={false,true}) + void bundledSchedulerRejectionDetachesTheBankInsteadOfLeavingAnUnpulsedEffect(boolean paper) throws Exception { + var bundled=new BundledOwnerTaskHarness(plugin,paper); + when(bundled.nativeScheduler.runTaskLater(eq(plugin),any(Runnable.class),eq(20L))).thenThrow(new IllegalStateException("scheduler retired")); + banked.start(player); Objects.requireNonNull(bundled.tasks.poll(5,TimeUnit.SECONDS)).run(); + barrier(); assertTrue(active.isEmpty()); assertEquals(10000,state.banks().get(key).milliseconds()); + assertTrue(bundled.tasks.isEmpty()); assertTrue(bundled.timers.isEmpty()); + } + + @ParameterizedTest @ValueSource(booleans={false,true}) + void bundledDelayedCallbacksCannotReactivateARetiredBankController(boolean paper) throws Exception { + var bundled=new BundledOwnerTaskHarness(plugin,paper); + banked.start(player); Objects.requireNonNull(bundled.tasks.poll(5,TimeUnit.SECONDS)).run(); + Runnable oldPulse=Objects.requireNonNull(bundled.timers.poll(5,TimeUnit.SECONDS)); + time.addAndGet(250_000_000L); banked.retire(); barrier(); + assertTrue(active.isEmpty()); assertEquals(9750,state.banks().get(key).milliseconds()); + time.addAndGet(2_000_000_000L); oldPulse.run(); oldPulse.run(); barrier(); + assertTrue(active.isEmpty()); assertEquals(9750,state.banks().get(key).milliseconds()); + assertTrue(bundled.tasks.isEmpty()); assertTrue(bundled.timers.isEmpty()); + } + + private Perk secondBank() throws Exception { + var second = mock(Perk.class); when(second.getPerk()).thenReturn("other"); + when(second.getPerkType()).thenReturn(PerkSystemType.PLAYER); + when(second.getEffects()).thenReturn(new ArrayList<>(List.of(Effect.NoFallDamage))); + var options = source.getInteractionOptions(); when(second.getInteractionOptions()).thenReturn(options); when(second.isNotInDisabledWorld("world")).thenReturn(true); + when(second.clone()).thenAnswer(i -> { + var clone = mock(Perk.class); when(clone.getPerk()).thenReturn("other"); when(clone.getActivater()).thenReturn(user); + when(clone.getEffectedPlayers()).thenReturn(new ArrayList<>()); return clone; + }); + when(plugin.getPerkHandler().getPerk("other")).thenReturn(second); + field(second, "plugin", plugin); field(second, "perk", "other"); when(second.getTime()).thenReturn(10); + when(second.checkTimesUsed(any())).thenReturn(true); when(second.checkCoolDown(any())).thenReturn(true); when(second.checkServerWideCoolDown(any())).thenReturn(true); + doCallRealMethod().when(second).runPerk(any()); doCallRealMethod().when(second).executeConfirmed(any(), anyInt()); + return second; + } + private void activeLimit(boolean enabled, int limit) { + when(plugin.getConfigFile().getLimitPermission()).thenReturn(enabled); when(user.getPerkLimit()).thenReturn(limit); + when(user.getPlayerName()).thenReturn(owner.toString()); doCallRealMethod().when(user).getActivePerks(); + } + private MCPerksUser otherPlayer() { + UUID other = UUID.randomUUID(); var otherPlayer = mock(Player.class); var otherUser = mock(MCPerksUser.class); + when(otherPlayer.getUniqueId()).thenReturn(other); when(otherPlayer.isOnline()).thenReturn(true); + when(otherPlayer.isValid()).thenReturn(true); when(otherPlayer.getWorld()).thenReturn(world); + when(Bukkit.getServer().getPlayer(other)).thenReturn(otherPlayer); when(otherUser.getUUID()).thenReturn(other.toString()); + when(plugin.getMcperksUserManager().getMCPerksUser(other)).thenReturn(otherUser); + // Both players use the same deterministic owner queues, preserving each callback's player identity. + when(scheduler.implementation.runAtEntityWithFallback(eq(otherPlayer), any(), any())).thenAnswer(invocation -> { + java.util.function.Consumer action = invocation.getArgument(1); + Runnable fallback = invocation.getArgument(2); + var result = new CompletableFuture(); + Runnable task = () -> { action.accept(null); result.complete(com.bencodez.simpleapi.folialib.enums.EntityTaskResult.SUCCESS); }; + scheduler.retirement.put(task, fallback); scheduler.tasks.add(task); return result; + }); + when(scheduler.implementation.runAtEntityLater(eq(otherPlayer), any(Runnable.class), any(Runnable.class), anyLong())).thenAnswer(invocation -> { + scheduler.timers.add(invocation.getArgument(1)); return mock(com.bencodez.simpleapi.folialib.wrapper.task.WrappedTask.class); + }); + return otherUser; + } + @ParameterizedTest @ValueSource(booleans = {false, true}) + void queueEnabledDirectBankOwnsPublicationAndEmptyActiveAttachmentGap(boolean secondBanked) throws Exception { + directRuntime(); state.setBank(key, 0).get(5, TimeUnit.SECONDS); + when(plugin.getConfigFile().getPerkQue()).thenReturn(true); + var planner = plugin.getQueuePlanner(); var second = secondBank(); var other = otherPlayer(); + if (!secondBanked) when(second.getInteractionOptions()).thenReturn(new InteractionOptions(false, false, 16, 8, List.of(), List.of(), List.of(), List.of())); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + banked.retire(); state.close(); + state = FeatureStateFixture.create(directory.resolve("features.dat"), (staged, file) -> { + if (staged.toFile().length() > 0) { + entered.countDown(); + try { if (!release.await(5, TimeUnit.SECONDS)) throw new java.io.IOException("test publication timeout"); } + catch (InterruptedException failure) { Thread.currentThread().interrupt(); throw new java.io.IOException(failure); } + } + DurableFeatureFiles.publish(staged, file); + }); + state.ready().get(5, TimeUnit.SECONDS); when(plugin.getFeatureState()).thenReturn(state); + planner = new QueuePlanner(plugin, state); when(plugin.getQueuePlanner()).thenReturn(planner); + banked = newBankedPerks(plugin, state, time::get); when(plugin.getBankedPerks()).thenReturn(banked); + var queued = new CopyOnWriteArrayList(); + var handler = plugin.getPerkHandler(); + doAnswer(invocation -> { queued.add(invocation.getArgument(1)); return null; }).when(handler).addQue(any(), any()); + try { + source.runPerk(user); assertTrue(entered.await(5, TimeUnit.SECONDS)); + assertTrue(planner.hasDirectStart()); assertTrue(active.isEmpty()); assertEquals(0, state.banks().get(key).milliseconds()); + second.runPerk(other); assertEquals(List.of(second), queued); verify(second, never()).executeConfirmed(any(), anyInt()); + release.countDown(); barrier(); barrier(); + assertEquals(10000, state.banks().get(key).milliseconds()); assertTrue(active.isEmpty()); assertTrue(planner.hasDirectStart()); + state.enqueue(UUID.fromString(other.getUUID()), "other", "", 1).get(5, TimeUnit.SECONDS); + planner.check(); assertFalse(planner.isStarting()); + nextOwner(); barrier(); assertTrue(active.isEmpty()); assertTrue(planner.hasDirectStart()); + planner.check(); assertFalse(planner.isStarting()); + nextOwner(); barrier(); barrier(); + assertEquals(1, active.size()); assertEquals("passive", active.getFirst().getPerk()); assertFalse(planner.hasDirectStart()); + assertEquals(FeatureState.Stage.WAITING, state.queue().getFirst().stage()); + assertFalse(state.banks().containsKey(new FeatureState.BankKey(UUID.fromString(other.getUUID()), "other"))); + verify(user, never()).addActivation(anyInt()); verify(other, never()).addActivation(anyInt()); + } finally { release.countDown(); } + } + @Test void queueEnabledManualResumeWaitsBehindUnrelatedActivePerkWithoutChargingTime() throws Exception { + when(plugin.getConfigFile().getPerkQue()).thenReturn(true); + banked.start(player); nextOwner(); time.addAndGet(250_000_000L); + banked.pause(key, true).get(5, TimeUnit.SECONDS); nextOwner(); barrier(); + var legacy = mock(Perk.class); when(legacy.getPerk()).thenReturn("unrelated"); active.add(legacy); + banked.pause(key, false).get(5, TimeUnit.SECONDS); nextOwner(); + time.addAndGet(4_000_000_000L); Objects.requireNonNull(scheduler.timers.poll(5, TimeUnit.SECONDS)).run(); nextOwner(); barrier(); + assertEquals(List.of(legacy), active); assertEquals(9750, state.banks().get(key).milliseconds()); + active.remove(legacy); Objects.requireNonNull(scheduler.timers.poll(5, TimeUnit.SECONDS)).run(); nextOwner(); + assertEquals(1, active.size()); assertTrue(banked.attached(active.getFirst())); + time.addAndGet(100_000_000L); banked.quit(new PlayerQuitEvent(player, "quit")); barrier(); + assertEquals(9650, state.banks().get(key).milliseconds()); + verify(source, never()).executeConfirmed(any(), anyInt()); verify(user, never()).setPerkCoolDown(any(), anyLong()); + } + @ParameterizedTest @ValueSource(booleans = {false, true}) + void retiredInitialGrantAttachmentReleasesQueueOwnershipWithoutEffectsOrLostBank(boolean retireController) throws Exception { + directRuntime(); state.setBank(key, 0).get(5, TimeUnit.SECONDS); when(plugin.getConfigFile().getPerkQue()).thenReturn(true); + var planner = plugin.getQueuePlanner(); source.runPerk(user); barrier(); barrier(); + assertTrue(planner.hasDirectStart()); assertTrue(active.isEmpty()); + Runnable callback = Objects.requireNonNull(scheduler.tasks.poll(5, TimeUnit.SECONDS)); + if (retireController) { banked.retire(); callback.run(); } + else { scheduler.retirement.get(callback).run(); callback.run(); } + barrier(); barrier(); + assertFalse(planner.hasDirectStart()); assertTrue(active.isEmpty()); assertEquals(10000, state.banks().get(key).milliseconds()); + assertEquals(!retireController, banked.beginDirect(source)); banked.finishDirect(source); verify(user, never()).addActivation(anyInt()); + } + @Test void manualResumeWaitsForLimitSlotWithoutSpendingTimeOrReplayingHooks() throws Exception { + activeLimit(true, 1); var previous = MCPerksMain.plugin; MCPerksMain.plugin = plugin; + try { + banked.start(player); nextOwner(); assertEquals(1, active.size()); + time.addAndGet(250_000_000L); banked.pause(key, true).get(5, TimeUnit.SECONDS); nextOwner(); barrier(); + var legacy = mock(Perk.class); when(legacy.getPerk()).thenReturn("legacy"); when(legacy.getActivater()).thenReturn(user); + when(legacy.getEffectedPlayers()).thenReturn(new ArrayList<>(List.of(owner.toString()))); active.add(legacy); + banked.pause(key, false).get(5, TimeUnit.SECONDS); nextOwner(); + time.addAndGet(4_000_000_000L); Objects.requireNonNull(scheduler.timers.poll(5, TimeUnit.SECONDS)).run(); nextOwner(); barrier(); + assertEquals(List.of(legacy), active); assertEquals(9750, state.banks().get(key).milliseconds()); + active.remove(legacy); Objects.requireNonNull(scheduler.timers.poll(5, TimeUnit.SECONDS)).run(); nextOwner(); + assertEquals(1, active.size()); assertTrue(banked.attached(active.getFirst())); + time.addAndGet(100_000_000L); banked.quit(new PlayerQuitEvent(player, "quit")); barrier(); + assertEquals(9650, state.banks().get(key).milliseconds()); assertFalse(state.banks().get(key).paused()); + verify(source, never()).executeConfirmed(any(), anyInt()); verify(plugin, never()).getEffectHandler(); + verify(user, never()).addActivation(anyInt()); verify(user, never()).setPerkCoolDown(any(), anyLong()); + } finally { MCPerksMain.plugin = previous; } + } + @Test void parallelDistinctDirectBankPurchasesAttachOnlyUpToLimitAndPreserveWaitingTime() throws Exception { + directRuntime(); state.setBank(key, 0).get(5, TimeUnit.SECONDS); var second = secondBank(); + activeLimit(true, 1); var previous = MCPerksMain.plugin; MCPerksMain.plugin = plugin; + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + state.storage(() -> { + entered.countDown(); try { if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("test timeout"); } + catch (InterruptedException error) { Thread.currentThread().interrupt(); throw new RuntimeException(error); } return null; + }); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); source.runPerk(user); second.runPerk(user); + assertTrue(active.isEmpty()); release.countDown(); barrier(); barrier(); barrier(); + nextOwner(); nextOwner(); nextOwner(); // two confirmed grants, then one controller pulse + assertEquals(1, active.size()); + var running = new FeatureState.BankKey(owner, active.getFirst().getPerk()); + var waiting = new FeatureState.BankKey(owner, running.perk().equals("passive") ? "other" : "passive"); + assertEquals(10000, state.banks().get(running).milliseconds()); assertEquals(10000, state.banks().get(waiting).milliseconds()); + time.addAndGet(5_000_000_000L); Objects.requireNonNull(scheduler.timers.poll(5, TimeUnit.SECONDS)).run(); nextOwner(); nextOwner(); barrier(); + assertEquals(1, active.size()); assertEquals(5000, state.banks().get(running).milliseconds()); assertEquals(10000, state.banks().get(waiting).milliseconds()); + banked.pause(running, true).get(5, TimeUnit.SECONDS); nextOwner(); + Objects.requireNonNull(scheduler.timers.poll(5, TimeUnit.SECONDS)).run(); nextOwner(); barrier(); + assertEquals(1, active.size()); assertEquals(waiting.perk(), active.getFirst().getPerk()); assertEquals(10000, state.banks().get(waiting).milliseconds()); + verify(user, times(1)).setPerkCoolDown(eq(source), anyLong()); verify(user, times(1)).setPerkCoolDown(eq(second), anyLong()); + verify(user, never()).addActivation(anyInt()); verify(plugin, never()).getEffectHandler(); + } finally { release.countDown(); MCPerksMain.plugin = previous; } + } + @ParameterizedTest @ValueSource(booleans = {false, true}) + void disabledPermissionLimitOrAbsentPermissionStillAllowsMultipleBanks(boolean enabled) throws Exception { + secondBank(); state.setBank(new FeatureState.BankKey(owner, "other"), 10000).get(5, TimeUnit.SECONDS); + activeLimit(enabled, enabled ? -1 : 1); var previous = MCPerksMain.plugin; MCPerksMain.plugin = plugin; + try { banked.start(player); nextOwner(); assertEquals(2, active.size()); } + finally { MCPerksMain.plugin = previous; } + } + @Test void attachedBankAtExactLimitKeepsRunningAndCountsOtherAffectedPerks() throws Exception { + activeLimit(true, 1); var previous = MCPerksMain.plugin; MCPerksMain.plugin = plugin; + try { + banked.start(player); nextOwner(); var attached = active.getFirst(); + time.addAndGet(250_000_000L); Objects.requireNonNull(scheduler.timers.poll(5, TimeUnit.SECONDS)).run(); nextOwner(); nextOwner(); barrier(); + assertEquals(List.of(attached), active); assertEquals(9750, state.banks().get(key).milliseconds()); + var otherUser = mock(MCPerksUser.class); when(otherUser.getPlayerName()).thenReturn("other-player"); + var shared = mock(Perk.class); when(shared.getPerk()).thenReturn("shared"); when(shared.getActivater()).thenReturn(otherUser); + when(shared.getEffectedPlayers()).thenReturn(new ArrayList<>(List.of(owner.toString()))); active.add(shared); + Objects.requireNonNull(scheduler.timers.poll(5, TimeUnit.SECONDS)).run(); nextOwner(); barrier(); + assertEquals(List.of(shared), active); assertEquals(9750, state.banks().get(key).milliseconds()); + } finally { MCPerksMain.plugin = previous; } + } + @Test void bankControllerAdmissionWaitsForLegacyRestoration() throws Exception { + when(plugin.getPerkHandler().isRestorationReady()).thenReturn(false); banked.start(player); + assertTrue(active.isEmpty()); assertTrue(scheduler.tasks.isEmpty()); assertTrue(scheduler.timers.isEmpty()); + when(plugin.getPerkHandler().isRestorationReady()).thenReturn(true); banked.start(player); nextOwner(); + assertEquals(1, active.size()); assertEquals(10000, state.banks().get(key).milliseconds()); + } + +} diff --git a/MCPerks/src/test/java/com/bencodez/mcperks/tests/perk/ClearQueueCommandTest.java b/MCPerks/src/test/java/com/bencodez/mcperks/tests/perk/ClearQueueCommandTest.java new file mode 100644 index 0000000..99fc97b --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/perk/ClearQueueCommandTest.java @@ -0,0 +1,64 @@ +package com.bencodez.mcperks.tests.perk; +import static com.bencodez.mcperks.tests.TestAccess.*; +import com.bencodez.mcperks.perk.*; + +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 org.bukkit.command.CommandSender; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import com.bencodez.mcperks.MCPerksMain; +import com.bencodez.mcperks.commands.CommandLoader; +import com.bencodez.mcperks.data.*; +import com.bencodez.mcperks.tests.data.FeatureStateFixture; +import com.bencodez.advancedcore.api.command.CommandHandler; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; + +class ClearQueueCommandTest { + @TempDir Path directory; + @ParameterizedTest @ValueSource(booleans={true,false}) + void commandWaitsForDurablePublicationAndDoesNotReportFailedClearAsSuccess(boolean publicationFails) throws Exception { + var reject = new java.util.concurrent.atomic.AtomicBoolean(); + try (FeatureState state = FeatureStateFixture.create(directory.resolve("features.dat"), (staged, target) -> { + if (reject.get()) throw new java.io.IOException("fixture clear rejected"); + DurableFeatureFiles.publish(staged, target); + })) { + state.ready().get(5, TimeUnit.SECONDS); + var entry = state.enqueue(UUID.randomUUID(), "perk", "", 1).get(5, TimeUnit.SECONDS); + MCPerksMain plugin = mock(MCPerksMain.class); when(plugin.getFeatureState()).thenReturn(state); + when(plugin.getName()).thenReturn("MCPerks"); + when(plugin.getOptions()).thenReturn(mock(com.bencodez.advancedcore.AdvancedCoreConfigOptions.class)); + QueuePlanner planner = mock(QueuePlanner.class); when(plugin.getQueuePlanner()).thenReturn(planner); + PerkHandler handler = newPerkHandler(plugin, List.of()); when(plugin.getPerkHandler()).thenReturn(handler); + var commands = new ArrayList(); when(plugin.getCommands()).thenReturn(commands); + var scheduler = mock(BukkitScheduler.class); when(plugin.getBukkitScheduler()).thenReturn(scheduler); + BlockingQueue replies = new LinkedBlockingQueue<>(); + doAnswer(i -> { replies.add(i.getArgument(1)); return null; }).when(scheduler).runTask(eq(plugin), any(Runnable.class)); + var loader = new CommandLoader(plugin); + var registration = CommandLoader.class.getDeclaredMethod("loadClearQueueCommand"); registration.setAccessible(true); registration.invoke(loader); + assertEquals(1, commands.size()); assertEquals("MCPerks.ClearQueue", commands.getFirst().getPerm()); + CommandSender sender = mock(CommandSender.class); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + var blocked = state.storage(() -> { entered.countDown(); try { assertTrue(release.await(5, TimeUnit.SECONDS)); } + catch (InterruptedException failure) { throw new RuntimeException(failure); } return null; }); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + try { + commands.getFirst().execute(sender, new String[]{"ClearQueue"}); + verify(sender, never()).sendMessage(anyString()); assertTrue(replies.isEmpty()); + assertEquals(entry.id(), state.queue().getFirst().id()); reject.set(publicationFails); + } finally { release.countDown(); } + blocked.get(5, TimeUnit.SECONDS); + Objects.requireNonNull(replies.poll(5, TimeUnit.SECONDS)).run(); + if (publicationFails) { + verify(sender).sendMessage(contains("failed")); verify(sender, never()).sendMessage(contains("Cleared")); + assertEquals(entry.id(), state.queue().getFirst().id()); + } else { + verify(sender).sendMessage(contains("Cleared")); assertTrue(state.queue().isEmpty()); + } + } + } +} 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/PerkActivationAdmissionTest.java b/MCPerks/src/test/java/com/bencodez/mcperks/tests/perk/PerkActivationAdmissionTest.java new file mode 100644 index 0000000..c562861 --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/perk/PerkActivationAdmissionTest.java @@ -0,0 +1,208 @@ +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 { + 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(plugin.isEnabled()).thenReturn(true); + when(data.isReady()).thenReturn(true); + doReturn(new InteractionOptions(false, false, 16, 8, java.util.List.of(), java.util.List.of(), java.util.List.of(), java.util.List.of())).when(perk).getInteractionOptions(); + 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); + } + + @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 directLimitedActivationKeepsPerkAdmissionThroughDurablePlannerCompletion() throws Exception { + Fixture f = fixture(false); + var config = mock(com.bencodez.mcperks.configs.Config.class); + var features = mock(com.bencodez.mcperks.data.FeatureState.class); + when(f.plugin().getConfigFile()).thenReturn(config); + when(f.plugin().getFeatureState()).thenReturn(features); + when(config.getLimitActivations()).thenReturn(true); + when(features.available()).thenReturn(true); + when(f.handler().isRestorationReady()).thenReturn(true); + when(f.data().getData()).thenReturn(new org.bukkit.configuration.file.YamlConfiguration()); + 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()); + var planner = mock(QueuePlanner.class); when(f.plugin().getQueuePlanner()).thenReturn(planner); + var completed = new CompletableFuture(); + when(planner.startDirect(eq(f.user()), eq(f.perk()), any(), any())).thenReturn(completed); + f.perk().runPerk(f.user()); f.perk().runPerk(f.user()); + verify(planner, times(1)).startDirect(eq(f.user()), eq(f.perk()), any(), any()); + verify(f.user(), never()).addActivation(anyInt()); verify(f.user(), never()).addActivation(anyString(), anyInt()); + verify(f.user()).sendMessage(contains("still being confirmed")); + completed.complete(null); f.perk().runPerk(f.user()); + verify(planner, times(2)).startDirect(eq(f.user()), eq(f.perk()), any(), any()); + } + + @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()); + } + } +} 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..5199f92 --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/perk/PerkDecisionTest.java @@ -0,0 +1,145 @@ +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.data.LocalStatistics; +import com.bencodez.mcperks.configs.Config; +import com.bencodez.advancedcore.api.rewards.RewardBuilder; +class PerkDecisionTest { + private Perk perk(Effect effect, String owner, List targets) { + Perk perk = mock(Perk.class); when(perk.getEffects()).thenReturn(new ArrayList<>(List.of(effect))); + when(perk.getEffectedPlayers()).thenReturn(new ArrayList<>(List.of(owner))); + when(perk.isNotInDisabledWorld(anyString())).thenReturn(true); + when(perk.getInteractionOptions()).thenReturn(new InteractionOptions(false, false, 16, 8, List.of(), List.of(), List.of(), targets)); return perk; + } + @Test void persistedRestoreUsesSavedExpirationAndSuppressesActivationHooks() { + UUID owner = UUID.randomUUID(); MCPerksMain plugin = mock(MCPerksMain.class); + MCPerksUser user = mock(MCPerksUser.class); when(user.getUUID()).thenReturn(owner.toString()); + when(plugin.getLocalStatistics()).thenReturn(new LocalStatistics()); + 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); + Perk definition = mock(Perk.class), clone = mock(Perk.class); + 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(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"); + when(clone.getPerkType()).thenReturn(PerkSystemType.PLAYER); when(clone.getTime()).thenReturn(30); + when(clone.isTimed()).thenReturn(true); when(clone.isLastForever()).thenReturn(false); + when(clone.getEffects()).thenReturn(new ArrayList<>()); + PerkHandler handler = newPerkHandler(plugin, List.of()); + 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); + })) { + 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); + handler.restoreActivePerk(definition, user, 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 == expiration)); + assertTrue(rewards.constructed().isEmpty()); verify(serverData, never()).addPerkHistory(anyString()); + verify(clone, never()).announcePerk(any(), anyInt()); + reset(effects); handler.activePerk(definition, user, 30, 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()); + } + } + + @Test void precisionAppliesToAffectedAudienceAndConfiguredTargetsButNotOtherPlayersOrWorlds() { + UUID target = UUID.randomUUID(), other = UUID.randomUUID(); MCPerksMain plugin = mock(MCPerksMain.class); + Perk precision = perk(Effect.PrecisionMode, target.toString(), List.of("VeinMining")); Perk vein = perk(Effect.VeinMining, target.toString(), List.of()); + PerkHandler handler = newPerkHandler(plugin, List.of(precision, vein)); handler.setPrecisionSneaking(target, true); handler.setPrecisionSneaking(other, true); + assertEquals(PerkHandler.Decision.PRECISION_SUPPRESSED, handler.effectDecision(Effect.VeinMining, target.toString(), "world")); + assertFalse(handler.effectActive(Effect.VeinMining, other.toString(), "world")); + assertTrue(handler.effectActive(Effect.PrecisionMode, target.toString(), "world")); + when(precision.isNotInDisabledWorld("disabled")).thenReturn(false); + assertTrue(handler.effectActive(Effect.VeinMining, target.toString(), "disabled")); + handler.setPrecisionSneaking(target, false); assertTrue(handler.effectActive(Effect.VeinMining, target.toString(), "world")); + } + @Test void depletedAttachedPrecisionDoesNotSuppressValidAutomationAndAudienceIsPerGrant() { + UUID owner = UUID.randomUUID(), other = UUID.randomUUID(); MCPerksMain plugin = mock(MCPerksMain.class); + Perk precision = perk(Effect.PrecisionMode, owner.toString(), List.of("VeinMining")); + Perk vein = perk(Effect.VeinMining, owner.toString(), List.of()); + BankedPerks banks = mock(BankedPerks.class); when(plugin.getBankedPerks()).thenReturn(banks); + when(banks.available(precision)).thenReturn(false); when(banks.available(vein)).thenReturn(true); + PerkHandler handler = newPerkHandler(plugin, List.of(precision, vein)); handler.setPrecisionSneaking(owner, true); + assertTrue(handler.effectActive(Effect.VeinMining, owner.toString(), "world")); + assertTrue(handler.effectAllowed(vein, Effect.VeinMining, owner.toString(), "world")); + assertFalse(handler.effectAllowed(vein, Effect.VeinMining, other.toString(), "world")); + when(banks.available(precision)).thenReturn(true); + assertFalse(handler.effectAllowed(vein, Effect.VeinMining, owner.toString(), "world")); + verify(plugin, never()).getFeatureState(); + } + @Test void absentPrecisionTargetsSuppressOnlyNewAutomationAndDecisionsArePure() { + UUID target = UUID.randomUUID(); MCPerksMain plugin = mock(MCPerksMain.class); + Perk precision = perk(Effect.PrecisionMode, target.toString(), List.of()); Perk passive = perk(Effect.NoFallDamage, target.toString(), List.of()); + PerkHandler handler = newPerkHandler(plugin, List.of(precision, passive)); handler.setPrecisionSneaking(target, true); + for (int i = 0; i < 10; i++) assertTrue(handler.effectActive(Effect.NoFallDamage, target.toString(), "world")); + verify(plugin, never()).getFeatureState(); verify(plugin, never()).getMcperksUserManager(); + } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.EnumSource(value = Effect.class, names = {"Farmer", "Fortune"}) + void actualBlockBonusListenerSkipsUnavailableGrantWhileApplyingValidGrant(Effect effect) { + UUID owner = UUID.randomUUID(); MCPerksMain plugin = mock(MCPerksMain.class); + Perk expired = perk(effect, owner.toString(), List.of()), valid = perk(effect, owner.toString(), List.of()); + when(expired.getIncreasePercent(effect)).thenReturn(900.0); when(valid.getIncreasePercent(effect)).thenReturn(200.0); + when(expired.getBlocks()).thenReturn(new ArrayList<>(List.of(org.bukkit.Material.WHEAT))); + when(valid.getBlocks()).thenReturn(new ArrayList<>(List.of(org.bukkit.Material.WHEAT))); + BankedPerks banks = mock(BankedPerks.class); when(plugin.getBankedPerks()).thenReturn(banks); + when(banks.available(expired)).thenReturn(false); when(banks.available(valid)).thenReturn(true); + PerkHandler handler = newPerkHandler(plugin, List.of(expired, valid)); when(plugin.getPerkHandler()).thenReturn(handler); + var effects = mock(EffectHandler.class); when(plugin.getEffectHandler()).thenReturn(effects); + var nativeEffects = mock(com.bencodez.mcperks.listeners.UsageEffectListener.class); + when(plugin.getUsageEffects()).thenReturn(nativeEffects); when(nativeEffects.isNativeBreak(owner)).thenReturn(true); + when(effects.getBlockBreakEvents()).thenReturn(new ArrayList<>()); + var player = mock(org.bukkit.entity.Player.class); when(player.getUniqueId()).thenReturn(owner); + var world = mock(org.bukkit.World.class); when(world.getName()).thenReturn("world"); when(player.getWorld()).thenReturn(world); + var block = mock(org.bukkit.block.Block.class); when(block.getType()).thenReturn(org.bukkit.Material.WHEAT); + var location = new org.bukkit.Location(world, 0, 64, 0); when(block.getLocation()).thenReturn(location); + when(block.getDrops(any())).thenAnswer(i -> List.of(new org.bukkit.inventory.ItemStack(org.bukkit.Material.WHEAT, 1))); + var event = new org.bukkit.event.block.BlockBreakEvent(block, player); + new com.bencodez.mcperks.listeners.EffectListeners(plugin).onBlockBreak(event); + verify(world, times(1)).dropItemNaturally(eq(location), argThat(item -> item.getAmount() == 2)); + verify(expired, never()).getIncreasePercent(effect); + } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {true, false}) + void actualMobListenerRespectsPrecisionTargetsAndRestoresLegacyExperienceWhenUnsneaking(boolean modernDamageSource) { + UUID owner=UUID.randomUUID(); MCPerksMain plugin=mock(MCPerksMain.class); + Perk precision=perk(Effect.PrecisionMode,owner.toString(),List.of("DoubleExperience","IncreaseMobDrops","HeadDropper")); + Perk mob=perk(Effect.DoubleExperience,owner.toString(),List.of()); when(mob.getEffects()).thenReturn(new ArrayList<>(List.of(Effect.DoubleExperience,Effect.IncreaseMobDrops,Effect.HeadDropper))); + when(mob.getPerkType()).thenReturn(PerkSystemType.PLAYER); when(mob.getBlackedListedMobs()).thenReturn(new ArrayList<>()); when(mob.getIncreasePercent(Effect.DoubleExperience)).thenReturn(200.0); + PerkHandler handler=newPerkHandler(plugin,List.of(precision,mob)); when(plugin.getPerkHandler()).thenReturn(handler); handler.setPrecisionSneaking(owner,true); + var version=mock(com.bencodez.mcperks.version.VersionHandle.class); when(plugin.getVersionHandle()).thenReturn(version); when(version.hasDamageSourceMethod()).thenReturn(modernDamageSource); + var player=mock(org.bukkit.entity.Player.class); when(player.getUniqueId()).thenReturn(owner); + var event=mock(org.bukkit.event.entity.EntityDeathEvent.class); var entity=mock(org.bukkit.entity.LivingEntity.class); var world=mock(org.bukkit.World.class); when(world.getName()).thenReturn("world"); when(entity.getWorld()).thenReturn(world); when(entity.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE); + when(entity.getKiller()).thenReturn(player); when(event.getEntity()).thenReturn(entity); when(event.getEntityType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE); when(event.getDroppedExp()).thenReturn(10); when(version.getCausingEntity(event)).thenReturn(player); + var listener=new com.bencodez.mcperks.listeners.EffectListeners(plugin); listener.onEntityDeath(event); verify(event,never()).setDroppedExp(anyInt()); verify(event,never()).getDrops(); + when(mob.getEffects()).thenReturn(new ArrayList<>(List.of(Effect.DoubleExperience))); handler.setPrecisionSneaking(owner,false); listener.onEntityDeath(event); verify(event).setDroppedExp(20); + } + +} diff --git a/MCPerks/src/test/java/com/bencodez/mcperks/tests/perk/PrecisionOptionsTest.java b/MCPerks/src/test/java/com/bencodez/mcperks/tests/perk/PrecisionOptionsTest.java new file mode 100644 index 0000000..7395013 --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/perk/PrecisionOptionsTest.java @@ -0,0 +1,16 @@ +package com.bencodez.mcperks.tests.perk; +import com.bencodez.mcperks.perk.*; +import static org.junit.jupiter.api.Assertions.*; +import java.util.List; +import org.junit.jupiter.api.Test; +class PrecisionOptionsTest { + private InteractionOptions options(String target) { return new InteractionOptions(false,false,16,8,List.of(),List.of(),List.of(),List.of(target)); } + @Test void persistentAndUnknownTargetsAreRejectedInsteadOfReportingFalseSuppression() { + for(String target:List.of("MoveSpeed","Potions","TempPermission","Fly","IncreaseMaxHealth","IncreaseStrength","IncreaseLuck","DoubleJump","DoubleJumpUp","Commands","GlobalCommands","Rewards","CureHunger","HealAll","ToolRepair","PrecisionMode","Unknown")) + assertThrows(IllegalArgumentException.class,()->options(target),target); + } + @Test void independentlyConfiguredActionTargetsAndEmptyDefaultsRemainSupported() { + for(String target:List.of("AutoBlockRefill","VeinMining","RightClickHarvest","AutoPlant","Fortune")) assertEquals(List.of(target),options(target).precisionTargets()); + assertTrue(new InteractionOptions(false,false,16,8,List.of(),List.of(),List.of(),List.of()).precisionTargets().isEmpty()); + } +} diff --git a/MCPerks/src/test/java/com/bencodez/mcperks/tests/perk/QueuePlannerTest.java b/MCPerks/src/test/java/com/bencodez/mcperks/tests/perk/QueuePlannerTest.java new file mode 100644 index 0000000..b0945fb --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/perk/QueuePlannerTest.java @@ -0,0 +1,1107 @@ +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.tests.data.FeatureStateFixture; +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 QueuePlannerTest { + @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 FeatureState state; private QueuePlanner planner; 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.getLocalStatistics()).thenReturn(new LocalStatistics()); 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.getInteractionOptions()).thenReturn(new InteractionOptions(false,false,16,8,List.of(),List.of(),List.of(),List.of())); 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); 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 FeatureState(directory.resolve("features.dat")); state.ready().get(5,TimeUnit.SECONDS); when(plugin.getFeatureState()).thenReturn(state); + planner = new QueuePlanner(plugin,state); when(plugin.getQueuePlanner()).thenReturn(planner); when(plugin.getBankedPerks()).thenReturn(new BankedPerks(plugin,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 { + planner.retire(); state.close(); + // Closing admits worker retirement; await actual termination before JUnit removes its owned files. + var worker = FeatureState.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); + } + @Test void queueHeadUsesOneSnapshotWhenConcurrentCancellationPublishesEmptyQueue() throws Exception { + var entry = state.enqueue(owner, "vein", "", 1).get(5, TimeUnit.SECONDS); + FeatureState racing = spy(state); + doReturn(List.of(entry), List.of()).when(racing).queue(); + planner = new QueuePlanner(plugin, racing); when(plugin.getQueuePlanner()).thenReturn(planner); + assertDoesNotThrow(planner::check); + verify(racing, times(1)).queue(); + assertTrue(planner.isStarting()); + Runnable preparation = next(); scheduler.retirement.get(preparation).run(); + assertFalse(planner.isStarting()); + assertDoesNotThrow(planner::check); assertFalse(planner.isStarting()); + } + @Test void synchronousOwnerSchedulingFailureReleasesStartLatch() throws Exception { + state.enqueue(owner, "vein", "", 1).get(5, TimeUnit.SECONDS); + doThrow(new IllegalStateException("scheduler rejected")).when(scheduler.implementation).runAtEntityWithFallback(eq(player), any(), any()); + // OwnerTasks may either propagate rejection or settle it through the retirement callback. + try { planner.check(); } catch (IllegalStateException expected) { assertEquals("scheduler rejected", expected.getMessage()); } + assertFalse(planner.isStarting()); + } + @Test void actualStartDebitsOneReservedCreditAndDuplicateOwnerCallbackDoesNotActivateTwice() throws Exception { + planner.enqueue(user,perk); drainUntil(() -> state.queue().isEmpty() && activated.get() == 1); + assertEquals(0,personal.get()); assertEquals(5,global.get()); verify(dataManager,times(1)).withAtomicUserTransaction(eq(owner),any(),anyMap(),any()); verify(user.getData(),never()).setInt(anyString(),anyInt(),anyBoolean(),anyBoolean()); + } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void queuedBankKeepsStartingFenceThroughPublishedButUnattachedOwnerCallbacks(boolean retireInitialOwner) throws Exception { + when(config.getLimitActivations()).thenReturn(false); when(config.getPerkQue()).thenReturn(true); + when(perk.getInteractionOptions()).thenReturn(new InteractionOptions(false, true, 16, 8, List.of(), List.of(), List.of(), List.of())); + when(perk.getPerkType()).thenReturn(PerkSystemType.PLAYER); when(perk.getEffects()).thenReturn(new ArrayList<>(List.of(Effect.NoFallDamage))); + when(perk.clone()).thenAnswer(invocation -> { + var clone = mock(Perk.class); when(clone.getPerk()).thenReturn("vein"); when(clone.getActivater()).thenReturn(user); + when(clone.getEffectedPlayers()).thenReturn(new ArrayList()); return clone; + }); + var pluginField = Perk.class.getDeclaredField("plugin"); pluginField.setAccessible(true); pluginField.set(perk, plugin); + when(plugin.getMcperksServerData().confirmedActivation(any())).thenAnswer(invocation -> { + invocation.getArgument(0, Runnable.class).run(); return CompletableFuture.completedFuture(null); + }); + doCallRealMethod().when(perk).executePublishedBank(user); + var entry = state.enqueue(owner, "vein", "", 1).get(5, TimeUnit.SECONDS); + planner.check(); next().run(); // durable claim and bank publication + Runnable execution = next(); execution.run(); execution.run(); + assertTrue(planner.isStarting()); assertTrue(handler.getActivePerks().isEmpty()); + assertEquals(10000, state.banks().get(new FeatureState.BankKey(owner, "vein")).milliseconds()); + assertEquals(FeatureState.Stage.STARTING, state.queue().getFirst().stage()); + planner.check(); Runnable initialStart = next(); + if (retireInitialOwner) { + scheduler.retirement.get(initialStart).run(); initialStart.run(); + drainUntil(() -> !planner.isStarting() && state.queue().isEmpty()); + assertFalse(planner.isStarting()); assertTrue(handler.getActivePerks().isEmpty()); assertTrue(state.queue().isEmpty()); + assertEquals(10000, state.banks().get(new FeatureState.BankKey(owner, "vein")).milliseconds()); + verify(perk, times(1)).executePublishedBank(user); verify(dataManager, never()).withAtomicUserTransaction(any(), any(), anyMap(), any()); + return; + } + initialStart.run(); // only schedules the storage->owner attachment + state.storage(() -> null).get(5, TimeUnit.SECONDS); + assertTrue(planner.isStarting()); assertTrue(handler.getActivePerks().isEmpty()); planner.check(); + next().run(); drainUntil(() -> !planner.isStarting() && state.queue().isEmpty()); + assertEquals(1, handler.getActivePerks().size()); assertFalse(planner.isStarting()); + assertTrue(state.queue().isEmpty()); assertEquals(10000, state.banks().get(new FeatureState.BankKey(owner, "vein")).milliseconds()); + verify(perk, times(1)).executePublishedBank(user); verify(dataManager, never()).withAtomicUserTransaction(any(), any(), anyMap(), any()); + assertEquals(1, personal.get()); assertNotNull(entry); + } + @Test void unsupportedTransactionLeavesWaitingAndUnlimitedLegacyStillStarts() throws Exception { + when(dataManager.hasSharedRuntime()).thenReturn(false); planner.enqueue(user,perk); next().run(); next().run(); state.storage(() -> null).get(5,TimeUnit.SECONDS); + assertEquals(FeatureState.Stage.WAITING,state.queue().getFirst().stage()); assertEquals(1,personal.get()); assertEquals(0,activated.get()); assertEquals("transactional activation storage unavailable",planner.startUnavailable(state.queue().getFirst())); + state.clearWaiting().get(); when(config.getLimitActivations()).thenReturn(false); planner.enqueue(user,perk); drainUntil(() -> activated.get()==1 && state.queue().isEmpty()); assertEquals(1,personal.get()); + verify(dataManager,never()).withAtomicUserTransaction(any(),any(),anyMap(),any()); + } + @Test void droppedPreparationReleasesStartingAndRetryCanStart() throws Exception { + planner.enqueue(user,perk); next().run(); Runnable prepare = next(); scheduler.retirement.get(prepare).run(); assertFalse(planner.isStarting()); assertEquals(FeatureState.Stage.WAITING,state.queue().getFirst().stage()); + prepare.run(); assertEquals(1,personal.get()); planner.check(); drainUntil(() -> activated.get()==1 && state.queue().isEmpty()); + } + @Test void droppedExecutionKeepsVisibleUncertainFenceAndNeverReplaysCredit() throws Exception { + planner.enqueue(user,perk); next().run(); next().run(); Runnable effect = next(); scheduler.retirement.get(effect).run(); state.storage(() -> null).get(); effect.run(); planner.check(); + assertFalse(planner.isStarting()); assertEquals(FeatureState.Stage.UNCERTAIN,state.queue().getFirst().stage()); assertEquals(0,personal.get()); assertEquals(0,activated.get()); verify(dataManager,times(1)).withAtomicUserTransaction(any(),any(),anyMap(),any()); + } + @Test void changedUnsupportedBankDefinitionRejectsBeforeClaimOrDebit() throws Exception { + state.pauseQueue(true).get(); planner.enqueue(user,perk); next().run(); state.storage(() -> null).get(); + when(perk.getInteractionOptions()).thenReturn(new InteractionOptions(false,true,16,8,List.of(),List.of(),List.of(),List.of())); when(perk.getPerkType()).thenReturn(PerkSystemType.PLAYER); when(perk.getEffects()).thenReturn(new ArrayList<>(List.of(Effect.Commands))); + state.pauseQueue(false).get(); planner.check(); next().run(); assertEquals(FeatureState.Stage.WAITING,state.queue().getFirst().stage()); assertEquals(1,personal.get()); verify(dataManager,never()).withAtomicUserTransaction(any(),any(),anyMap(),any()); + } + @Test void postDebitBankPublicationFailureIsUncertainAndDoesNotRefund() throws Exception { + when(perk.getInteractionOptions()).thenReturn(new InteractionOptions(false,true,16,8,List.of(),List.of(),List.of(),List.of())); when(perk.getPerkType()).thenReturn(PerkSystemType.PLAYER); when(perk.getEffects()).thenReturn(new ArrayList<>(List.of(Effect.NoFallDamage))); + doAnswer(i -> plugin.getBankedPerks().grant(user,perk,10)).when(perk).executeConfirmed(user,10); + planner.retire(); state.close(); var fail = new java.util.concurrent.atomic.AtomicBoolean(); + state = FeatureStateFixture.create(directory.resolve("features.dat"), (staged,file) -> { if (fail.get()) throw new java.io.IOException("bank publication rejected"); DurableFeatureFiles.publish(staged,file); }); + state.ready().get(); when(plugin.getFeatureState()).thenReturn(state); planner = new QueuePlanner(plugin,state); when(plugin.getQueuePlanner()).thenReturn(planner); when(plugin.getBankedPerks()).thenReturn(new BankedPerks(plugin,state)); afterDebit = () -> fail.set(true); + planner.enqueue(user,perk); next().run(); next().run(); + long deadline = System.nanoTime()+TimeUnit.SECONDS.toNanos(5); while(state.available() && System.nanoTime()> estimates = planner.estimatedWaits(); next().run(); long wait = estimates.get(5,TimeUnit.SECONDS).get(entry.id()); assertTrue(wait>=28 && wait<=30); verify(plugin.getMcperksUserManager(),never()).getMCPerksUser(player); + state.pauseQueue(true).get(); assertTrue(planner.estimatedWaits().get().isEmpty()); state.pauseQueue(false).get(); when(active.getKnownExpiration()).thenReturn(0L); assertTrue(planner.estimatedWaits().get().isEmpty()); + } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {true, false}) + void advisoryCreditReadRepopulatesRetiredCacheBeforeCompleting(boolean hasCredit) throws Exception { + var entry = state.enqueue(owner,"vein","PerkActivations_vein",1).get(); + var ready = new java.util.concurrent.atomic.AtomicBoolean(true); + var published = mock(com.bencodez.advancedcore.api.user.usercache.UserDataCache.class); + when(dataManager.getPublishedCache(owner)).thenAnswer(i -> ready.get() ? published : null); + when(dataManager.withAtomicUserTransaction(eq(owner),any(),anyMap(),any())).thenAnswer(i -> { + var transaction = mock(SqlUserStorage.TransactionScope.class); + var column = mock(com.bencodez.simpleapi.sql.Column.class); + when(column.getName()).thenReturn("PerkActivations_vein"); + when(column.getValue()).thenReturn(new com.bencodez.simpleapi.sql.data.DataValueInt(hasCredit ? 1 : 0)); + when(transaction.readRow()).thenReturn(List.of(column)); + SqlUserStorage.TransactionWork work = i.getArgument(3); + Object result = work.run(transaction); + verify(transaction,never()).writeValues(anyMap()); + ready.set(false); return result; + }); + CountDownLatch populating = new CountDownLatch(1), release = new CountDownLatch(1); + doAnswer(i -> { + assertEquals("MCPerks-feature-store",Thread.currentThread().getName()); + populating.countDown(); assertTrue(release.await(5,TimeUnit.SECONDS)); ready.set(true); return null; + }).when(dataManager).cacheUser(owner); + try { + var estimates = planner.estimatedWaits(); next().run(); + assertTrue(populating.await(5,TimeUnit.SECONDS)); assertFalse(estimates.isDone()); + release.countDown(); + assertEquals(hasCredit,estimates.get(5,TimeUnit.SECONDS).containsKey(entry.id())); + assertSame(published,dataManager.getPublishedCache(owner)); + assertEquals(1,personal.get()); assertEquals(0,activated.get()); + } finally { release.countDown(); } + } + @Test void periodicQueueChecksUseTheSchedulersSecondsContract() { + planner.startChecks(); verify(scheduler.scheduler).runTaskLater(eq(plugin),any(Runnable.class),eq(1L)); + } + + @Test void committedDebitWaitsForCanonicalCachePublicationBeforeGrantingOnce() throws Exception { + CountDownLatch publishing=new CountDownLatch(1), release=new CountDownLatch(1); var ready=new java.util.concurrent.atomic.AtomicBoolean(true); + var published=mock(com.bencodez.advancedcore.api.user.usercache.UserDataCache.class); when(dataManager.getPublishedCache(owner)).thenAnswer(i->ready.get()?published:null); + afterDebit=()->ready.set(false); + doAnswer(i->{ assertEquals("MCPerks-feature-store",Thread.currentThread().getName()); publishing.countDown(); if(!release.await(5,TimeUnit.SECONDS)) throw new IllegalStateException("cache release timeout"); ready.set(true); return null; }).when(dataManager).cacheUser(owner); + when(user.isIgnorePerkEffects()).thenAnswer(i->{ if(!ready.get()) throw new IllegalStateException("Shared user data is still loading"); return false; }); + doAnswer(i->{ user.isIgnorePerkEffects(); activated.incrementAndGet(); return CompletableFuture.completedFuture(null); }).when(perk).executeConfirmed(user,10); + try { + planner.enqueue(user,perk); next().run(); next().run(); assertTrue(publishing.await(5,TimeUnit.SECONDS)); + assertEquals(0,personal.get()); assertEquals(0,activated.get()); assertEquals(FeatureState.Stage.STARTING,state.queue().getFirst().stage()); assertNull(scheduler.tasks.poll(50,TimeUnit.MILLISECONDS)); + release.countDown(); drainUntil(()->activated.get()==1 && state.queue().isEmpty()); assertEquals(0,personal.get()); + verify(dataManager,times(1)).withAtomicUserTransaction(any(),any(),anyMap(),any()); verify(dataManager,times(1)).cacheUser(owner); verify(user,times(1)).isIgnorePerkEffects(); + } finally { release.countDown(); } + } + + @Test void removedReservedCreditMakesThisEntryAndSuccessorsUnknownWithoutSpendingIt() throws Exception { + var first=state.enqueue(owner,"vein","PerkActivations_vein",1).get(); + UUID nextOwner=UUID.randomUUID(); Player nextPlayer=mock(Player.class); World world=player.getWorld(); + when(nextPlayer.isOnline()).thenReturn(true); when(nextPlayer.getUniqueId()).thenReturn(nextOwner); + when(nextPlayer.isValid()).thenReturn(true); + when(nextPlayer.getWorld()).thenReturn(world); when(nextPlayer.hasPermission(anyString())).thenReturn(true); + when(Bukkit.getServer().getPlayer(nextOwner)).thenReturn(nextPlayer); + MCPerksUser nextUser=mock(MCPerksUser.class); when(plugin.getMcperksUserManager().getMCPerksUser(nextOwner)).thenReturn(nextUser); + when(scheduler.implementation.runAtEntityWithFallback(eq(nextPlayer),any(),any())).thenAnswer(invocation -> { + java.util.function.Consumer action=invocation.getArgument(1); + scheduler.tasks.add(()->action.accept(null)); + return CompletableFuture.completedFuture(com.bencodez.simpleapi.folialib.enums.EntityTaskResult.SUCCESS); + }); + var second=state.enqueue(nextOwner,"vein","",2).get(); personal.set(0); + var blocked=planner.estimatedWaits(); next().run(); next().run(); assertTrue(blocked.get(5,TimeUnit.SECONDS).isEmpty()); + assertEquals(0,personal.get()); assertEquals(2,state.queue().size()); + personal.set(1); var restored=planner.estimatedWaits(); next().run(); next().run(); + var estimates=restored.get(5,TimeUnit.SECONDS); assertEquals(0L,estimates.get(first.id())); + assertEquals(10L,estimates.get(second.id())); assertEquals(1,personal.get()); assertEquals(0,activated.get()); + } + + private void bankDefinition() { + when(perk.getInteractionOptions()).thenReturn(new InteractionOptions(false,true,16,8,List.of(),List.of(),List.of(),List.of())); + when(perk.getPerkType()).thenReturn(PerkSystemType.PLAYER); when(perk.getEffects()).thenReturn(new ArrayList<>(List.of(Effect.NoFallDamage))); + when(perk.executePublishedBank(user)).thenAnswer(i->{ activated.incrementAndGet(); return CompletableFuture.completedFuture(null); }); + } + @Test void bankFilledAfterOwnerAdmissionIsRejectedOnWorkerBeforeAnyDebit() throws Exception { + state.pauseQueue(true).get(); planner.enqueue(user,perk); next().run(); state.storage(()->null).get(); + bankDefinition(); state.pauseQueue(false).get(); + CountDownLatch entered=new CountDownLatch(1), release=new CountDownLatch(1); + state.storage(()->{ 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)); planner.check(); next().run(); + var key=new FeatureState.BankKey(owner,"vein"); var fill=state.setBank(key,31_536_000_000L); + release.countDown(); fill.get(5,TimeUnit.SECONDS); drainUntil(()->!planner.isStarting()); + assertEquals(FeatureState.Stage.WAITING,state.queue().getFirst().stage()); assertEquals(1,personal.get()); + assertEquals("bank capacity reached",planner.startUnavailable(state.queue().getFirst())); + assertEquals(0,activated.get()); verify(dataManager,never()).withAtomicUserTransaction(any(),any(),anyMap(),any()); + } + @Test void exactRemainingBankCapacityDebitsOnceAndOwnerCompletionNeverAddsTimeAgain() throws Exception { + bankDefinition(); var key=new FeatureState.BankKey(owner,"vein"); + state.setBank(key,31_536_000_000L-10000).get(); state.pauseBank(key,true).get(); + planner.enqueue(user,perk); drainUntil(()->activated.get()==1 && state.queue().isEmpty()); + assertEquals(new FeatureState.Bank(31_536_000_000L,true),state.banks().get(key)); assertEquals(0,personal.get()); + verify(perk,times(1)).executePublishedBank(user); verify(perk,never()).executeConfirmed(any(),anyInt()); + verify(dataManager,times(1)).withAtomicUserTransaction(any(),any(),anyMap(),any()); + } + 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 queuedPersonalStartRetainsFenceUntilActualUserFlushCallback() throws Exception { + runtimeFile(); when(perk.getPerkType()).thenReturn(PerkSystemType.PLAYER); + var cache = dataManager.getCache(owner); var callback = new CompletableFuture(); + doAnswer(i -> { assertEquals("MCPerks-feature-store", Thread.currentThread().getName()); callback.complete(i.getArgument(0)); return null; }) + .when(cache).flushChangesAndRun(any(Runnable.class)); + activatePerk(doAnswer(i -> { activated.incrementAndGet(); user.setPerkExperation(perk, 123456); return null; }).when(perk), eq(user),eq(10),nullable(Timestamp.class)); + planner.enqueue(user,perk); drainUntil(callback::isDone); + Runnable completed = callback.get(5,TimeUnit.SECONDS); + assertEquals(0, personal.get()); assertEquals(0, activated.get()); + assertEquals(FeatureState.Stage.STARTING, state.queue().getFirst().stage()); + completed.run(); drainUntil(() -> state.queue().isEmpty()); assertEquals(1, activated.get()); + verify(user).setPerkExperation(perk,123456); verify(user).setPerkCoolDown(eq(perk),anyLong()); + completed.run(); planner.check(); assertEquals(1, activated.get()); + } + @Test void queuedPersonalFlushFailureRetainsUncertainFenceAfterYamlSucceeds() throws Exception { + runtimeFile(); when(perk.getPerkType()).thenReturn(PerkSystemType.PLAYER); + var failedCache = dataManager.getCache(owner); + doThrow(new IllegalStateException("SQL flush failed")).when(failedCache).flushChangesAndRun(any(Runnable.class)); + activatePerk(doAnswer(i -> { activated.incrementAndGet(); user.setPerkExperation(perk,123456); return null; }).when(perk), eq(user),eq(10),nullable(Timestamp.class)); + planner.enqueue(user,perk); drainUntil(() -> !state.queue().isEmpty() && state.queue().getFirst().stage()==FeatureState.Stage.UNCERTAIN); + assertTrue(Files.isRegularFile(directory.resolve("ServerData.yml"))); assertEquals(0,personal.get()); + planner.check(); assertEquals(0,activated.get()); + state.close(); try (var recovered = new FeatureState(directory.resolve("features.dat"))) { + recovered.ready().get(5,TimeUnit.SECONDS); assertEquals(FeatureState.Stage.UNCERTAIN,recovered.queue().getFirst().stage()); + } + } + @Test void directLimitedActivationRejectsPendingAndFailedRuntimeBeforeEitherCreditDebit() throws Exception { + var nativeFile=runtimeFile(); field(perk,"perk","vein"); doCallRealMethod().when(perk).runPerk(user); + var entered=new CountDownLatch(1); var 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)); + Files.createDirectory(nativeFile.getdFile().toPath()); + var runtime=plugin.getMcperksServerData(); var initialized=runtime.initialize(state); + try { perk.runPerk(user); perk.runPerk(user); assertFalse(runtime.isReady()); } + finally { release.countDown(); } + assertThrows(ExecutionException.class, () -> initialized.get(5,TimeUnit.SECONDS)); perk.runPerk(user); + verify(user,never()).addActivation(anyInt()); verify(user,never()).addActivation(anyString(),anyInt()); + verify(perk,never()).executeConfirmed(any(),anyInt()); verify(user,times(3)).sendMessage(contains("runtime state is unavailable")); + } + @Test void readyLegacyRuntimeStillConsumesOnlySelectedPersonalCreditOnDirectActivation() throws Exception { + runtimeFile(); field(perk,"perk","vein"); doCallRealMethod().when(perk).runPerk(user); + when(perk.checkTimesUsed(user)).thenReturn(true); when(perk.checkCoolDown(user)).thenReturn(true); when(perk.checkServerWideCoolDown(user)).thenReturn(true); + activatePerk(doAnswer(call -> { activated.incrementAndGet(); return null; }).when(perk), eq(user), eq(10), nullable(Timestamp.class)); + perk.runPerk(user); + drainUntil(() -> activated.get() == 1 && state.queue().isEmpty()); + assertEquals(0, personal.get()); assertEquals(5, global.get()); + verify(user, never()).addActivation(anyString(), anyInt()); verify(user,never()).addActivation(anyInt()); + verify(dataManager, times(1)).withAtomicUserTransaction(eq(owner), any(), anyMap(), any()); + activatePerk(verify(perk, times(1)), eq(user), eq(10), nullable(Timestamp.class)); verify(user,never()).sendMessage(contains("unavailable")); + } + private void directRuntime() throws Exception { + runtimeFile(); field(perk, "perk", "vein"); doCallRealMethod().when(perk).runPerk(user); + when(perk.checkTimesUsed(user)).thenReturn(true); when(perk.checkCoolDown(user)).thenReturn(true); + when(perk.checkServerWideCoolDown(user)).thenReturn(true); + activatePerk(doAnswer(call -> { activated.incrementAndGet(); return null; }).when(perk), eq(user), eq(10), nullable(Timestamp.class)); + } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void directPredebitServerPublicationOrCapacityFailureConsumesNothing(boolean oversized) throws Exception { + directRuntime(); + if (oversized) plugin.getMcperksServerData().getData().set("Indispensable", "x".repeat(4_100_000)); + else Files.createDirectory(directory.resolve("ServerData.yml")); + perk.runPerk(user); + // Serializing the intentionally oversized four-MB YAML fixture can exceed five + // seconds in the full suite; retain every accounting assertion and a hard bound. + drainUntil(() -> !planner.hasAdmissions(), oversized ? 20 : 5); + assertEquals(1, personal.get()); assertEquals(0, activated.get()); assertTrue(state.queue().isEmpty()); + verify(dataManager, never()).withAtomicUserTransaction(any(), any(), anyMap(), any()); + } + @Test void directCallerStorageFailureReleasesDurableStartWithoutDebitOrGameplay() throws Exception { + directRuntime(); + var cache = dataManager.getCache(owner); + doThrow(new IllegalStateException("caller flush unavailable")).when(cache).flushChangesAndRun(any()); + perk.runPerk(user); drainUntil(() -> !planner.hasAdmissions()); + assertTrue(Files.isRegularFile(directory.resolve("ServerData.yml"))); + assertTrue(state.queue().isEmpty()); assertEquals(1, personal.get()); assertEquals(0, activated.get()); + verify(dataManager, never()).withAtomicUserTransaction(any(), any(), anyMap(), any()); + } + @Test void directAtomicFalseReleasesFenceWithoutGameplay() throws Exception { + directRuntime(); personal.set(0); // advisory cache still offers one, authoritative transaction offers none + perk.runPerk(user); drainUntil(() -> !planner.hasAdmissions()); + assertTrue(state.queue().isEmpty()); assertEquals(0, personal.get()); assertEquals(0, activated.get()); + verify(dataManager, times(1)).withAtomicUserTransaction(eq(owner), any(), anyMap(), any()); + verify(dataManager, never()).cacheUser(any()); + } + @Test void directDebitThenCachePublicationFailureRetainsUncertainIdentityAcrossRestart() throws Exception { + directRuntime(); when(dataManager.getPublishedCache(owner)).thenReturn(null); + perk.runPerk(user); drainUntil(() -> !planner.hasAdmissions()); + assertEquals(0, personal.get()); assertEquals(0, activated.get()); + var entry = state.queue().getFirst(); assertEquals(FeatureState.Stage.UNCERTAIN, entry.stage()); + planner.check(); perk.runPerk(user); drainUntil(() -> !planner.hasAdmissions()); + assertEquals(List.of(entry), state.queue()); + verify(dataManager, times(1)).withAtomicUserTransaction(eq(owner), any(), anyMap(), any()); + state.close(); + try (var recovered = new FeatureState(directory.resolve("features.dat"))) { + recovered.ready().get(5, TimeUnit.SECONDS); assertEquals(List.of(entry), recovered.queue()); + } + } + @Test void directStartIsPersistedBeforeCallerFlushAndRetainsOwnerUntilFinalCompletion() throws Exception { + directRuntime(); personal.set(2); when(user.getActivations("vein")).thenReturn(2); + var flush = new CompletableFuture(); + var cache = dataManager.getCache(owner); + var firstFlush = new java.util.concurrent.atomic.AtomicBoolean(true); + doAnswer(call -> { Runnable callback = call.getArgument(0); if (firstFlush.getAndSet(false)) flush.complete(callback); else callback.run(); return null; }).when(cache).flushChangesAndRun(any()); + perk.runPerk(user); drainUntil(flush::isDone); + var entry = state.queue().getFirst(); assertEquals(FeatureState.Stage.STARTING, entry.stage()); + assertEquals(2, personal.get()); assertEquals(0, activated.get()); + perk.runPerk(user); assertEquals(1, state.queue().size()); assertTrue(planner.hasAdmissions()); + verify(user).sendMessage(contains("still being confirmed")); + flush.get(5, TimeUnit.SECONDS).run(); drainUntil(() -> !planner.hasAdmissions()); + assertTrue(state.queue().isEmpty()); assertEquals(1, personal.get()); assertEquals(1, activated.get()); + verify(dataManager, times(1)).withAtomicUserTransaction(eq(owner), any(), anyMap(), any()); + } + @Test void directReloadRetiresPendingCallerFlushBeforeDebit() throws Exception { + directRuntime(); var epoch = new AtomicInteger(); when(plugin.getFeatureLifecycle()).thenAnswer(call -> epoch.get()); + var flush = new CompletableFuture(); + var cache = dataManager.getCache(owner); + doAnswer(call -> { flush.complete(call.getArgument(0)); return null; }).when(cache).flushChangesAndRun(any()); + perk.runPerk(user); drainUntil(flush::isDone); epoch.incrementAndGet(); planner.invalidate(); + flush.get(5, TimeUnit.SECONDS).run(); drainUntil(() -> !planner.hasAdmissions()); + assertEquals(1, personal.get()); assertEquals(0, activated.get()); assertTrue(state.queue().isEmpty()); + verify(dataManager, never()).withAtomicUserTransaction(any(), any(), anyMap(), any()); + } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.CsvSource({"PLAYER,false", "PLAYER,true", "ALL,false", "ALL,true"}) + void rejectedPermanentDirectPurchasePreservesExistingMetadataAndCannotRestoreAsFreeGrant(String audience, boolean retireBeforeDebit) throws Exception { + var nativeFile = runtimeFile(); field(perk, "perk", "vein"); doCallRealMethod().when(perk).runPerk(user); + when(perk.checkTimesUsed(user)).thenReturn(true); when(perk.checkCoolDown(user)).thenReturn(true); when(perk.checkServerWideCoolDown(user)).thenReturn(true); + PerkSystemType scope = PerkSystemType.valueOf(audience); when(perk.getPerkType()).thenReturn(scope); + when(perk.isTimed()).thenReturn(true); when(perk.isLastForever()).thenReturn(true); + when(perk.getCoolDown()).thenReturn(30); when(perk.getServerWideCoolDown()).thenReturn(60); + long priorExpiration = System.currentTimeMillis() + 120_000, priorPersonalCooldown = System.currentTimeMillis() - 100_000; + long priorServerCooldown = System.currentTimeMillis() - 50_000; + var personalExpiration = new java.util.concurrent.atomic.AtomicLong(priorExpiration); + var personalCooldown = new java.util.concurrent.atomic.AtomicLong(priorPersonalCooldown); + when(user.getPerkExperation(perk)).thenAnswer(call -> personalExpiration.get()); + when(user.getPerkCoolDown(perk)).thenAnswer(call -> personalCooldown.get()); + doAnswer(call -> { personalExpiration.set(call.getArgument(1)); return null; }).when(user).setPerkExperation(eq(perk), anyLong()); + doAnswer(call -> { personalCooldown.set(call.getArgument(1)); return null; }).when(user).setPerkCoolDown(eq(perk), anyLong()); + plugin.getMcperksServerData().getData().set("Experation.vein", priorExpiration); + plugin.getMcperksServerData().getData().set("vein.CoolDown", priorServerCooldown); + var epoch = new AtomicInteger(); when(plugin.getFeatureLifecycle()).thenAnswer(call -> epoch.get()); + var flush = new CompletableFuture(); var cache = dataManager.getCache(owner); + if (retireBeforeDebit) doAnswer(call -> { flush.complete(call.getArgument(0)); return null; }).when(cache).flushChangesAndRun(any()); + else personal.set(0); // stale admission cache still offers one; the authoritative debit returns false + perk.runPerk(user); + if (retireBeforeDebit) { + drainUntil(flush::isDone); assertEquals(FeatureState.Stage.STARTING, state.queue().getFirst().stage()); + assertEquals(priorExpiration, personalExpiration.get()); assertEquals(priorPersonalCooldown, personalCooldown.get()); + epoch.incrementAndGet(); planner.invalidate(); flush.get(5, TimeUnit.SECONDS).run(); + } + drainUntil(() -> !planner.hasAdmissions()); assertTrue(state.queue().isEmpty()); + assertEquals(retireBeforeDebit ? 1 : 0, personal.get()); assertEquals(priorExpiration, personalExpiration.get()); + assertEquals(priorPersonalCooldown, personalCooldown.get()); + assertEquals(priorExpiration, plugin.getMcperksServerData().getPerkExperation(perk)); + assertEquals(priorServerCooldown, plugin.getMcperksServerData().getServerCooldown("vein")); + verify(user, never()).setPerkExperation(any(), anyLong()); verify(user, never()).setPerkCoolDown(any(), anyLong()); + activatePerk(verify(perk, never()), any(), anyInt(), any()); + var recovered = org.bukkit.configuration.file.YamlConfiguration.loadConfiguration(directory.resolve("ServerData.yml").toFile()); + assertEquals(priorExpiration, recovered.getLong("Experation.vein")); assertEquals(priorServerCooldown, recovered.getLong("vein.CoolDown")); + when(nativeFile.getData()).thenReturn(recovered); + var runtime = new ServerData(plugin); when(plugin.getMcperksServerData()).thenReturn(runtime); + runtime.initialize(state).get(5, TimeUnit.SECONDS); + when(perk.getExperation(user)).thenAnswer(call -> scope == PerkSystemType.ALL ? runtime.getPerkExperation(perk) : user.getPerkExperation(perk)); + var restorationTasks = new LinkedBlockingQueue(); var actual = restorer(restorationTasks, perk); + var restored = actual.loadActivePerksConfirmed(() -> {}); + Objects.requireNonNull(restorationTasks.poll(5, TimeUnit.SECONDS)).run(); restored.get(5, TimeUnit.SECONDS); + verify(perk, never()).activatePerk(user, 10); verify(perk, atLeastOnce()).scheduleDeactivation(user); + } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"PLAYER", "ALL"}) + void successfulPermanentDirectPurchasePublishesMetadataOnlyAfterOneDebitAndRunsHooksOnce(String audience) throws Exception { + directRuntime(); when(perk.getPerkType()).thenReturn(PerkSystemType.valueOf(audience)); + when(perk.isTimed()).thenReturn(true); when(perk.isLastForever()).thenReturn(true); + when(perk.getCoolDown()).thenReturn(30); when(perk.getServerWideCoolDown()).thenReturn(60); + doAnswer(call -> { assertEquals(0, personal.get(), "new personal metadata requires a committed debit"); return null; }).when(user).setPerkExperation(eq(perk), anyLong()); + doAnswer(call -> { assertEquals(0, personal.get(), "new cooldown requires a committed debit"); return null; }).when(user).setPerkCoolDown(eq(perk), anyLong()); + perk.runPerk(user); drainUntil(() -> !planner.hasAdmissions()); + assertEquals(0, personal.get()); assertEquals(1, activated.get()); assertTrue(state.queue().isEmpty()); + if (audience.equals("PLAYER")) verify(user).setPerkExperation(perk, -1); + else assertEquals(-1, plugin.getMcperksServerData().getPerkExperation(perk)); + verify(dataManager, times(1)).withAtomicUserTransaction(eq(owner), any(), anyMap(), any()); + activatePerk(verify(perk, times(1)), eq(user), eq(10), isNull()); + } + @Test void directTransactionExceptionRetainsUncertaintyEvenWhenBackendMayHaveCommitted() throws Exception { + directRuntime(); afterDebit = () -> { throw new IllegalStateException("transaction acknowledgement lost"); }; + perk.runPerk(user); drainUntil(() -> !planner.hasAdmissions()); + assertEquals(0, personal.get()); assertEquals(0, activated.get()); + assertEquals(FeatureState.Stage.UNCERTAIN, state.queue().getFirst().stage()); + planner.check(); verify(dataManager, times(1)).withAtomicUserTransaction(eq(owner), any(), anyMap(), any()); + } + @Test void repeatedDirectConsumeCallbackUsesTheSameAtomicCompletion() throws Exception { + var completed = planner.startDirect(user, perk, () -> {}, consume -> { + var first = consume.get(); assertSame(first, consume.get()); return first; + }); + drainUntil(completed::isDone); completed.get(5, TimeUnit.SECONDS); + assertEquals(0, personal.get()); assertTrue(state.queue().isEmpty()); + verify(dataManager, times(1)).withAtomicUserTransaction(eq(owner), any(), anyMap(), any()); + } + @Test void acceptedQueuedConsumeThenSynchronousThrowKeepsStartFenceUntilDebitSettles() throws Exception { + var completed = planner.startDirect(user, perk, () -> {}, consume -> { + consume.get(); throw new IllegalStateException("caller failed after accepting consumption"); + }); + Runnable callback = next(); // admission is durable, but the owner has not requested consumption yet + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + var blocker = state.storage(() -> { + entered.countDown(); try { if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("timeout"); } + catch (InterruptedException error) { throw new RuntimeException(error); } return null; + }); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); callback.run(); callback.run(); + assertFalse(completed.isDone()); assertTrue(planner.hasAdmissions()); + assertEquals(FeatureState.Stage.STARTING, state.queue().getFirst().stage()); + assertEquals(1, personal.get()); assertEquals(0, activated.get()); + } finally { release.countDown(); } + blocker.get(5, TimeUnit.SECONDS); drainUntil(completed::isDone); + assertThrows(ExecutionException.class, () -> completed.get(5, TimeUnit.SECONDS)); + assertFalse(planner.hasAdmissions()); assertEquals(0, personal.get()); assertEquals(0, activated.get()); + assertEquals(FeatureState.Stage.UNCERTAIN, state.queue().getFirst().stage()); + verify(dataManager, times(1)).withAtomicUserTransaction(eq(owner), any(), anyMap(), any()); + } + @Test void consumeAfterExecutionSettlesIsRejectedBeforeAnyDebit() throws Exception { + var captured = new java.util.concurrent.atomic.AtomicReference>>(); + var completed = planner.startDirect(user, perk, () -> {}, consume -> { + captured.set(consume); return CompletableFuture.completedFuture(null); + }); + drainUntil(completed::isDone); completed.get(5, TimeUnit.SECONDS); + assertTrue(state.queue().isEmpty()); assertEquals(1, personal.get()); assertFalse(planner.hasAdmissions()); + assertThrows(ExecutionException.class, () -> captured.get().get().get(5, TimeUnit.SECONDS)); + assertEquals(1, personal.get()); assertTrue(state.queue().isEmpty()); + verify(dataManager, never()).withAtomicUserTransaction(any(), any(), anyMap(), any()); + } + @Test void directCompletionFailureRetainsStartFenceAndRecoversUncertainWithoutReplay() throws Exception { + directRuntime(); planner.retire(); state.close(); var publications = new AtomicInteger(); + state = FeatureStateFixture.create(directory.resolve("features.dat"), (staged, target) -> { + if (publications.incrementAndGet() == 2) throw new java.io.IOException("completion publication failed"); + DurableFeatureFiles.publish(staged, target); + }); + state.ready().get(5, TimeUnit.SECONDS); when(plugin.getFeatureState()).thenReturn(state); + planner = new QueuePlanner(plugin, state); when(plugin.getQueuePlanner()).thenReturn(planner); + perk.runPerk(user); drainUntil(() -> !planner.hasAdmissions()); + assertEquals(1, activated.get()); assertEquals(0, personal.get()); assertFalse(state.available()); + var id = state.queue().getFirst().id(); assertEquals(FeatureState.Stage.STARTING, state.queue().getFirst().stage()); + planner.check(); assertEquals(1, activated.get()); state.close(); + try (var recovered = new FeatureState(directory.resolve("features.dat"))) { + recovered.ready().get(5, TimeUnit.SECONDS); assertEquals(id, recovered.queue().getFirst().id()); + assertEquals(FeatureState.Stage.UNCERTAIN, recovered.queue().getFirst().stage()); + } + } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void queueDisabledLimitedBanksRemainParallelExceptSharedPerkCooldown(boolean sharedCooldown) throws Exception { + directRuntime(); when(config.getPerkQue()).thenReturn(false); + when(perk.getInteractionOptions()).thenReturn(new InteractionOptions(false, true, 16, 8, List.of(), List.of(), List.of(), List.of())); + when(perk.getEffects()).thenReturn(new ArrayList<>(List.of(Effect.NoFallDamage))); + when(perk.getServerWideCoolDown()).thenReturn(sharedCooldown ? 60 : 0); + when(perk.clone()).thenAnswer(call -> { + var clone = mock(Perk.class); when(clone.getPerk()).thenReturn("vein"); + when(clone.getEffectedPlayers()).thenReturn(new ArrayList()); return clone; + }); + UUID secondOwner = UUID.randomUUID(); var secondPlayer = mock(Player.class); var secondUser = mock(MCPerksUser.class); + when(secondPlayer.getUniqueId()).thenReturn(secondOwner); when(secondPlayer.isOnline()).thenReturn(true); + var secondWorld = player.getWorld(); + when(secondPlayer.isValid()).thenReturn(true); when(secondPlayer.getWorld()).thenReturn(secondWorld); + when(Bukkit.getServer().getPlayer(secondOwner)).thenReturn(secondPlayer); + when(secondUser.getUUID()).thenReturn(secondOwner.toString()); when(secondUser.getActivations("vein")).thenReturn(1); + when(plugin.getMcperksUserManager().getMCPerksUser(secondOwner)).thenReturn(secondUser); + when(perk.checkTimesUsed(secondUser)).thenReturn(true); when(perk.checkCoolDown(secondUser)).thenReturn(true); + when(perk.checkServerWideCoolDown(secondUser)).thenReturn(true); + doCallRealMethod().when(perk).runPerk(secondUser); + when(scheduler.implementation.runAtEntityWithFallback(eq(secondPlayer), any(), any())).thenAnswer(call -> { + java.util.function.Consumer action = call.getArgument(1); + scheduler.tasks.add(() -> action.accept(null)); + return CompletableFuture.completedFuture(com.bencodez.simpleapi.folialib.enums.EntityTaskResult.SUCCESS); + }); + var secondCredits = new AtomicInteger(1); var secondCache = mock(com.bencodez.advancedcore.api.user.usercache.UserDataCache.class); + when(dataManager.getCache(secondOwner)).thenReturn(secondCache); when(dataManager.getPublishedCache(secondOwner)).thenReturn(secondCache); + when(dataManager.withAtomicUserTransaction(eq(secondOwner), any(), anyMap(), any())).thenAnswer(call -> { + var transaction = mock(SqlUserStorage.TransactionScope.class); var 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(secondCredits.get())); + when(transaction.readRow()).thenReturn(List.of(column)); + doAnswer(i -> { Map values = i.getArgument(0); secondCredits.set(values.get("PerkActivations_vein").getInt()); return null; }).when(transaction).writeValues(anyMap()); + SqlUserStorage.TransactionWork work = call.getArgument(3); return work.run(transaction); + }); + var flushes = new ConcurrentHashMap(); + for (var entry : Map.of(owner, dataManager.getCache(owner), secondOwner, secondCache).entrySet()) { + var first = new java.util.concurrent.atomic.AtomicBoolean(true); + doAnswer(call -> { Runnable callback = call.getArgument(0); if (first.getAndSet(false)) flushes.put(entry.getKey(), callback); else callback.run(); return null; }).when(entry.getValue()).flushChangesAndRun(any()); + } + var unrelated = mock(Perk.class); when(unrelated.getPerk()).thenReturn("permanent-global"); when(unrelated.isLastForever()).thenReturn(true); + handler.getActivePerks().add(unrelated); + perk.runPerk(user); drainUntil(() -> flushes.containsKey(owner)); + perk.runPerk(secondUser); + if (sharedCooldown) verify(secondUser).sendMessage(contains("still being confirmed")); + else { drainUntil(() -> flushes.containsKey(secondOwner)); verify(perk).checkTimesUsed(secondUser); } + assertEquals(sharedCooldown ? 1 : 2, state.queue().size()); assertEquals(1, personal.get()); assertEquals(1, secondCredits.get()); + flushes.values().forEach(Runnable::run); drainUntil(() -> !planner.hasAdmissions()); + assertTrue(state.queue().isEmpty()); assertEquals(0, personal.get()); assertEquals(sharedCooldown ? 1 : 0, secondCredits.get()); + assertEquals(10_000, state.banks().get(new FeatureState.BankKey(owner, "vein")).milliseconds()); + assertEquals(sharedCooldown ? 2 : 3, handler.getActivePerks().size()); + if (!sharedCooldown) assertEquals(10_000, state.banks().get(new FeatureState.BankKey(secondOwner, "vein")).milliseconds()); + verify(handler, never()).addQue(any(), any()); activatePerk(verify(perk, never()), any(), anyInt(), any()); + } + private static void field(Perk perk,String name,Object value) throws Exception { + var field=Perk.class.getDeclaredField(name); field.setAccessible(true); field.set(perk,value); + } + @Test void earlierProductionEligibilityBlockedByCalendarSnapshotDoesNotDeadlockWorkerOrOwnerActivation() 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); + state.runtimeStorage(()->null).get(5,TimeUnit.SECONDS); + var eligibilityEntered=new CountDownLatch(1); var releaseEligibility=new CountDownLatch(1); + var eligibilityThread=new java.util.concurrent.atomic.AtomicReference(); + when(user.getPerkCoolDown(perk)).thenAnswer(call->{ + eligibilityThread.set(Thread.currentThread()); eligibilityEntered.countDown(); + assertTrue(releaseEligibility.await(5,TimeUnit.SECONDS)); return 0L; + }); + activatePerk(doAnswer(call->{ activated.incrementAndGet(); runtime.setPerkExperation(perk,123456); return null; }).when(perk), eq(user),eq(10),nullable(Timestamp.class)); + planner.enqueue(user,perk); + long eligibilityDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (eligibilityEntered.getCount() != 0 && System.nanoTime() < eligibilityDeadline) { + Runnable ownerTask = scheduler.tasks.poll(20, TimeUnit.MILLISECONDS); + if (ownerTask != null) ownerTask.run(); + } + assertEquals(0, eligibilityEntered.getCount(), "production eligibility did not start"); + var blockedByCalendar=new java.util.concurrent.atomic.AtomicBoolean(); + var live=(org.bukkit.configuration.file.YamlConfiguration)nativeFile.getData(); + doAnswer(call->{ + if(blockedByCalendar.compareAndSet(false,true)) { + assertTrue(Thread.holdsLock(runtime)); releaseEligibility.countDown(); + var threads=java.lang.management.ManagementFactory.getThreadMXBean(); + long deadline=System.nanoTime()+TimeUnit.SECONDS.toNanos(5); boolean observed=false; + while(System.nanoTime()ordered.beginTimeChangeTransition(com.bencodez.advancedcore.api.time.TimeType.DAY,"2026-10-09","9")); + try { + var transition=calendar.get(5,TimeUnit.SECONDS); assertTrue(blockedByCalendar.get()); + drainUntil(()->activated.get()==1 && state.queue().isEmpty()); + 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")); + assertFalse(written.getBoolean("TimeTransitions.DAY.Pending")); assertEquals(0,personal.get()); + } finally { releaseEligibility.countDown(); } + } + @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 actualQueuedLegacyActivationKeepsStartingUntilImmutableYamlAndCapturedLogPublish() throws Exception { + var nativeFile=runtimeFile(); when(perk.getPerkType()).thenReturn(PerkSystemType.ALL); + CountDownLatch entered=new CountDownLatch(1), release=new CountDownLatch(1); + AtomicInteger publications=new AtomicInteger(); + var constructor=ServerData.class.getDeclaredConstructor(MCPerksMain.class,DurableFeatureFiles.Publisher.class); + constructor.setAccessible(true); + DurableFeatureFiles.Publisher publisher=(staged,file)->{ + if(publications.getAndIncrement()==0) { + entered.countDown(); + try { if(!release.await(5,TimeUnit.SECONDS)) throw new IllegalStateException("timeout"); } + catch(InterruptedException error) { Thread.currentThread().interrupt(); throw new IllegalStateException(error); } + } + DurableFeatureFiles.publish(staged,file); + }; + when(plugin.getMcperksServerData()).thenReturn(constructor.newInstance(plugin,publisher)); + var nativeEffects=new AtomicInteger(); var logThread=new java.util.concurrent.atomic.AtomicReference(); + activatePerk(doAnswer(i->{ + assertNotEquals("MCPerks-feature-store",Thread.currentThread().getName()); + nativeEffects.incrementAndGet(); plugin.getMcperksServerData().setPerkExperation(perk,123456); + plugin.getMcperksServerData().addPerkHistory("actual queued start"); + plugin.getMcperksServerData().activationLog(()->logThread.set(Thread.currentThread().getName())); + return null; + }).when(perk), eq(user),eq(10),nullable(Timestamp.class)); + planner.enqueue(user,perk); + try { + long deadline=System.nanoTime()+TimeUnit.SECONDS.toNanos(5); + while(entered.getCount()!=0 && System.nanoTime()state.queue().isEmpty()); state.storage(()->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(List.of("actual queued start"),written.getStringList("PerkHistory.OCTOBER")); assertEquals(9,written.getInt("PrevDay")); + assertEquals("MCPerks-feature-store",logThread.get()); + ordered.reloadData(); verify(nativeFile,times(1)).reloadData(); + } finally { release.countDown(); } + } + @Test void failedQueuedLegacyYamlPublicationKeepsUncertainFenceWithoutReplayingEffectOrDebit() throws Exception { + runtimeFile(); Files.createDirectory(directory.resolve("ServerData.yml")); + activatePerk(doAnswer(i->{ activated.incrementAndGet(); plugin.getMcperksServerData().setPerkExperation(perk,123456); plugin.getMcperksServerData().addPerkHistory("failed publish"); return null; }).when(perk), eq(user),eq(10),nullable(Timestamp.class)); + planner.enqueue(user,perk); drainUntil(()->!state.queue().isEmpty() && state.queue().getFirst().stage()==FeatureState.Stage.UNCERTAIN); + planner.check(); state.storage(()->null).get(); assertEquals(0,activated.get()); assertEquals(0,personal.get()); + verify(dataManager,times(1)).withAtomicUserTransaction(any(),any(),anyMap(),any()); + } + + @Test void realPerkHandlerAllExpirationHistoryAndOptionalActivationLogUseConfirmedWorkerPublication() throws Exception { + var nativeFile=runtimeFile(); MCPerksMain previous=MCPerksMain.plugin; MCPerksMain.plugin=plugin; + var actual=spy(newPerkHandler(plugin,List.of())); doReturn(perk).when(actual).getPerk("vein"); when(plugin.getPerkHandler()).thenReturn(actual); + var clone=mock(Perk.class); when(perk.clone()).thenReturn(clone); when(perk.getPerkType()).thenReturn(PerkSystemType.ALL); + field(clone, "plugin", plugin); // A real Perk constructor captures its owning plugin. + when(perk.getEffects()).thenReturn(new ArrayList<>()); when(perk.getName()).thenReturn("Vein"); + when(clone.getPerkType()).thenReturn(PerkSystemType.ALL); when(clone.getPerk()).thenReturn("vein"); + when(clone.getEffectedPlayers()).thenReturn(new ArrayList<>()); when(clone.getEffects()).thenReturn(new ArrayList<>()); + when(clone.getActivater()).thenReturn(user); when(clone.isTimed()).thenReturn(true); when(user.getPlayerName()).thenReturn("requester"); + doCallRealMethod().when(clone).setExperation(eq(user),anyLong()); activatePerk(doCallRealMethod().when(perk), eq(user),eq(10),nullable(Timestamp.class)); + var effects=mock(EffectHandler.class); when(plugin.getEffectHandler()).thenReturn(effects); + doAnswer(i->{ assertNotEquals("MCPerks-feature-store",Thread.currentThread().getName()); activated.incrementAndGet(); return null; }).when(effects).activate(clone,user,true); + when(plugin.getRewardHandler()).thenReturn(mock(com.bencodez.advancedcore.api.rewards.RewardHandler.class)); + var writer=mock(com.bencodez.advancedcore.logger.Logger.class); var logThread=new java.util.concurrent.atomic.AtomicReference(); + doAnswer(i->{ logThread.set(Thread.currentThread().getName()); return null; }).when(writer).logToFile(anyString()); + var logField=PerkHandler.class.getDeclaredField("activationLog"); logField.setAccessible(true); logField.set(actual,writer); when(config.getLogActivation()).thenReturn(true); + var perkConfig=mock(com.bencodez.mcperks.configs.ConfigPerks.class); when(perkConfig.getCountDownTimes("vein")).thenReturn(new ArrayList<>()); + var language=mock(com.bencodez.mcperks.configs.Lang.class); when(language.getCountDownTimer()).thenReturn(""); + try(var configStatic=mockStatic(com.bencodez.mcperks.configs.ConfigPerks.class); var langStatic=mockStatic(com.bencodez.mcperks.configs.Lang.class)) { + configStatic.when(com.bencodez.mcperks.configs.ConfigPerks::getInstance).thenReturn(perkConfig); + langStatic.when(com.bencodez.mcperks.configs.Lang::getInstance).thenReturn(language); + planner.enqueue(user,perk); drainUntil(()->activated.get()==1 && state.queue().isEmpty()); + var written=org.bukkit.configuration.file.YamlConfiguration.loadConfiguration(directory.resolve("ServerData.yml").toFile()); + assertTrue(written.getLong("Experation.vein")>System.currentTimeMillis()); assertEquals(1,written.getStringList("PerkHistory.OCTOBER").size()); + verify(nativeFile,never()).saveData(); verify(writer,times(1)).logToFile(anyString()); assertEquals("MCPerks-feature-store",logThread.get()); + verify(effects,times(1)).activate(clone,user,true); verify(dataManager,times(1)).withAtomicUserTransaction(any(),any(),anyMap(),any()); + } finally { + var timer=PerkHandler.class.getDeclaredField("timer"); timer.setAccessible(true); ((Timer)timer.get(actual)).cancel(); MCPerksMain.plugin=previous; + } + } + @Test void activeLegacyShutdownReadsExpirationAndDeactivatesBeforeNativeStorageRetirement() throws Exception { + runtimeFile(); + MCPerksMain previous = MCPerksMain.plugin; MCPerksMain.plugin = plugin; + var stateField = MCPerksMain.class.getDeclaredField("featureState"); + 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("featureState"); + 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("featureState"); 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.storage(()->{ 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(FeatureState successor=new FeatureState(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(FeatureState successor=new FeatureState(directory.resolve("features.dat"))) { + when(plugin.getFeatureState()).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(); } + } + @Test void unreadableOptionalFeatureFileDoesNotDisableIndependentLegacyRuntimePersistence() throws Exception { + runtimeFile(); state.close(); Path broken=directory.resolve("broken-features.dat"); Files.writeString(broken,"invalid feature state"); + try(FeatureState unavailable=new FeatureState(broken)) { + assertThrows(ExecutionException.class,()->unavailable.ready().get()); when(plugin.getFeatureState()).thenReturn(unavailable); + var runtime=new ServerData(plugin); when(plugin.getMcperksServerData()).thenReturn(runtime); runtime.initialize(unavailable).get(5,TimeUnit.SECONDS); + assertFalse(unavailable.available()); assertTrue(runtime.isReady()); + runtime.confirmedActivation(()->runtime.setPerkExperation(perk,1234)).get(5,TimeUnit.SECONDS); + var written=org.bukkit.configuration.file.YamlConfiguration.loadConfiguration(directory.resolve("ServerData.yml").toFile()); + assertEquals(1234,written.getLong("Experation.vein")); assertEquals("invalid feature state",Files.readString(broken)); + } + } + + 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); + 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)); + return actual; + } + private Perk permanent() { + var legacy = mock(Perk.class); when(legacy.getPerk()).thenReturn("permanent"); when(legacy.isEnabled()).thenReturn(true); + when(legacy.getExperation(user)).thenReturn(-1L); return legacy; + } + @Test void persistedQueueWaitsForDelayedLegacyRestorationBeforeAnyClaimOrDebit() throws Exception { + var entry = state.enqueue(owner, "vein", "PerkActivations_vein", 1).get(5, TimeUnit.SECONDS); + planner.retire(); state.close(); state = new FeatureState(directory.resolve("features.dat")); state.ready().get(5, TimeUnit.SECONDS); + when(plugin.getFeatureState()).thenReturn(state); planner = new QueuePlanner(plugin, state); when(plugin.getQueuePlanner()).thenReturn(planner); + var tasks = new LinkedBlockingQueue(); var legacy = permanent(); var actual = restorer(tasks, legacy); + doAnswer(i -> { actual.getActivePerks().add(legacy); return null; }).when(legacy).activatePerk(user, 0); + var restored = actual.loadActivePerksConfirmed(() -> {}); + planner.check(); planner.check(); + assertFalse(actual.isRestorationReady()); assertFalse(restored.isDone()); assertFalse(planner.isStarting()); + assertEquals(entry, state.queue().getFirst()); assertEquals(1, personal.get()); assertTrue(scheduler.tasks.isEmpty()); + assertTrue(planner.estimatedWaits().get(5, TimeUnit.SECONDS).isEmpty()); + Objects.requireNonNull(tasks.poll(5, TimeUnit.SECONDS)).run(); restored.get(5, TimeUnit.SECONDS); + assertTrue(actual.isRestorationReady()); assertEquals(List.of(legacy), actual.getActivePerks()); + planner.check(); assertEquals(FeatureState.Stage.WAITING, state.queue().getFirst().stage()); assertEquals(1, personal.get()); + actual.getActivePerks().clear(); planner.check(); drainUntil(() -> activated.get() == 1 && state.queue().isEmpty()); + assertEquals(0, personal.get()); verify(dataManager, times(1)).withAtomicUserTransaction(any(), any(), anyMap(), any()); + } + @Test void shutdownRecordRestorationAlsoKeepsQueueWaitingUntilItsActualCallbackReturns() throws Exception { + state.enqueue(owner, "vein", "PerkActivations_vein", 1).get(5, TimeUnit.SECONDS); + var tasks = new LinkedBlockingQueue(); var legacy = permanent(); var actual = restorer(tasks, legacy); + plugin.getMcperksServerData().getData().set("OfflinePerk." + owner + ".permanent.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 finished = new java.util.concurrent.atomic.AtomicBoolean(); + var restored = actual.loadActivePerksConfirmed(() -> { + assertFalse(actual.isRestorationReady()); planner.check(); + assertFalse(planner.isStarting()); assertEquals(1, personal.get()); + try { shutdown.invoke(plugin); } catch (ReflectiveOperationException error) { throw new RuntimeException(error); } + finished.set(true); + }); + planner.check(); Objects.requireNonNull(tasks.poll(5, TimeUnit.SECONDS)).run(); restored.get(5, TimeUnit.SECONDS); + assertTrue(finished.get()); assertTrue(actual.isRestorationReady()); assertEquals(List.of(legacy), actual.getActivePerks()); + verify(actual).restoreActivePerk(legacy, user, null); + verify(plugin.getMcperksServerData()).removeOfflinePerkIfMatches(owner.toString(), "permanent", -1L); + verify(actual, never()).activePerk(any(), any(), anyInt(), any()); + planner.check(); assertEquals(FeatureState.Stage.WAITING, state.queue().getFirst().stage()); assertEquals(1, personal.get()); + verify(dataManager, never()).withAtomicUserTransaction(any(), any(), anyMap(), any()); + } + @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); } + }); + Objects.requireNonNull(tasks.poll(5, TimeUnit.SECONDS)).run(); restored.get(5, TimeUnit.SECONDS); + 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.storage(() -> 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 failedLegacyRestorationLeavesPersistedQueueAndCreditUntouched() throws Exception { + var entry = state.enqueue(owner, "vein", "PerkActivations_vein", 1).get(5, TimeUnit.SECONDS); + var tasks = new LinkedBlockingQueue(); var legacy = permanent(); var actual = restorer(tasks, legacy); + when(legacy.getExperation(user)).thenThrow(new IllegalStateException("user storage unavailable")); + var restored = actual.loadActivePerksConfirmed(() -> fail("shutdown records must not run after scan failure")); + Objects.requireNonNull(tasks.poll(5, TimeUnit.SECONDS)).run(); + assertThrows(ExecutionException.class, () -> restored.get(5, TimeUnit.SECONDS)); planner.check(); + assertFalse(actual.isRestorationReady()); assertFalse(planner.isStarting()); assertEquals(entry, state.queue().getFirst()); + assertEquals("legacy perk restoration unavailable", planner.startUnavailable(entry)); assertEquals(1, personal.get()); + verify(dataManager, never()).withAtomicUserTransaction(any(), any(), anyMap(), any()); verify(perk, never()).executeConfirmed(any(), anyInt()); + } + @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()); + } + @Test void rejectedRestorationSchedulingFailsClosedBeforeAnyQueuedDebit() throws Exception { + var entry = state.enqueue(owner, "vein", "PerkActivations_vein", 1).get(5, TimeUnit.SECONDS); + var actual = restorer(new LinkedBlockingQueue<>(), permanent()); + doThrow(new IllegalStateException("scheduler rejected")).when(scheduler.scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class)); + var restored = actual.loadActivePerksConfirmed(() -> fail("unscheduled shutdown callback")); + assertThrows(ExecutionException.class, () -> restored.get(5, TimeUnit.SECONDS)); planner.check(); + assertFalse(actual.isRestorationReady()); assertEquals(entry, state.queue().getFirst()); assertEquals(1, personal.get()); + verify(dataManager, never()).withAtomicUserTransaction(any(), any(), anyMap(), any()); + } + @Test void directPurchaseDuringLegacyRestorationRejectsBeforeCreditOrGameplay() throws Exception { + runtimeFile(); field(perk, "perk", "vein"); doCallRealMethod().when(perk).runPerk(user); + when(handler.isRestorationReady()).thenReturn(false); perk.runPerk(user); + verify(user).sendMessage(contains("runtime state is unavailable")); + verify(user, never()).addActivation(anyInt()); verify(user, never()).addActivation(anyString(), anyInt()); + verify(perk, never()).executeConfirmed(any(), anyInt()); assertEquals(1, personal.get()); assertTrue(state.queue().isEmpty()); + } +} 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/scheduling/BundledOwnerTaskHarness.java b/MCPerks/src/test/java/com/bencodez/mcperks/tests/scheduling/BundledOwnerTaskHarness.java new file mode 100644 index 0000000..d2a03ac --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/scheduling/BundledOwnerTaskHarness.java @@ -0,0 +1,47 @@ +package com.bencodez.mcperks.tests.scheduling; +import com.bencodez.mcperks.scheduling.*; + +import static org.mockito.Mockito.*; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.function.Consumer; +import org.bukkit.Server; +import org.bukkit.scheduler.BukkitTask; +import com.bencodez.mcperks.MCPerksMain; +import com.bencodez.simpleapi.folialib.FoliaLib; +import com.bencodez.simpleapi.folialib.impl.PaperImplementation; +import com.bencodez.simpleapi.folialib.impl.PlatformScheduler; +import com.bencodez.simpleapi.folialib.impl.SpigotImplementation; + +/** Real bundled implementation; only the native Bukkit scheduling boundary is controlled. */ +public final class BundledOwnerTaskHarness { + public final BlockingQueue tasks = new LinkedBlockingQueue<>(); + public final BlockingQueue timers = new LinkedBlockingQueue<>(); + public final org.bukkit.scheduler.BukkitScheduler nativeScheduler = mock(org.bukkit.scheduler.BukkitScheduler.class); + public final PlatformScheduler implementation; + + @SuppressWarnings("unchecked") + public BundledOwnerTaskHarness(MCPerksMain plugin, boolean paper) { + Server server = mock(Server.class); + when(plugin.getServer()).thenReturn(server); + when(server.getScheduler()).thenReturn(nativeScheduler); + FoliaLib folia = mock(FoliaLib.class); + when(folia.getPlugin()).thenReturn(plugin); + when(folia.getOptions()).thenReturn(new com.bencodez.simpleapi.folialib.util.FoliaLibOptions()); + implementation = paper ? new PaperImplementation(folia) : new SpigotImplementation(folia); + when(folia.getImpl()).thenReturn(implementation); + when(folia.getScheduler()).thenReturn(implementation); + var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + when(scheduler.getFoliaLib()).thenReturn(folia); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + doAnswer(invocation -> { + Consumer callback = invocation.getArgument(1); + tasks.add(() -> callback.accept(mock(BukkitTask.class))); + return null; + }).when(nativeScheduler).runTask(eq(plugin), any(Consumer.class)); + when(nativeScheduler.runTaskLater(eq(plugin), any(Runnable.class), anyLong())).thenAnswer(invocation -> { + timers.add(invocation.getArgument(1)); + return mock(BukkitTask.class); + }); + } +} diff --git a/MCPerks/src/test/java/com/bencodez/mcperks/tests/scheduling/OwnerTaskHarness.java b/MCPerks/src/test/java/com/bencodez/mcperks/tests/scheduling/OwnerTaskHarness.java new file mode 100644 index 0000000..83c9d0e --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/scheduling/OwnerTaskHarness.java @@ -0,0 +1,43 @@ +package com.bencodez.mcperks.tests.scheduling; +import com.bencodez.mcperks.scheduling.*; +import static org.mockito.Mockito.*; +import java.util.concurrent.*; +import java.util.function.Consumer; +import org.bukkit.entity.Player; +import com.bencodez.mcperks.MCPerksMain; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; +import com.bencodez.simpleapi.folialib.FoliaLib; +import com.bencodez.simpleapi.folialib.impl.PlatformScheduler; +import com.bencodez.simpleapi.folialib.enums.EntityTaskResult; +import com.bencodez.simpleapi.folialib.wrapper.task.WrappedTask; +/** Exercises production owner dispatch, including scheduler rejection/retirement completions. */ +public final class OwnerTaskHarness { + public final BlockingQueue tasks = new LinkedBlockingQueue<>(), timers = new LinkedBlockingQueue<>(), retired = new LinkedBlockingQueue<>(); + public final java.util.Map retirement = new ConcurrentHashMap<>(); + public final BukkitScheduler scheduler = mock(BukkitScheduler.class); + public final PlatformScheduler implementation = mock(PlatformScheduler.class); + public OwnerTaskHarness(MCPerksMain plugin, Player player) { + this(plugin, player, false); + } + public OwnerTaskHarness(MCPerksMain plugin, Player player, boolean foliaPlatform) { + // Ordinary gameplay fixtures use Spigot. Entity-owner lifecycle tests explicitly + // select Folia; real Spigot/Paper code is exercised by BundledOwnerTaskHarness. + when(player.isValid()).thenReturn(true); + FoliaLib folia = mock(FoliaLib.class); when(folia.isFolia()).thenReturn(foliaPlatform); when(scheduler.getFoliaLib()).thenReturn(folia); when(folia.getImpl()).thenReturn(implementation); when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(folia.getScheduler()).thenReturn(implementation); + when(implementation.runAtEntityWithFallback(eq(player), any(), any())).thenAnswer(invocation -> { + Consumer action = invocation.getArgument(1); Runnable fallback = invocation.getArgument(2); + CompletableFuture result = new CompletableFuture<>(); + Runnable task = () -> { action.accept(null); result.complete(EntityTaskResult.SUCCESS); }; + Runnable drop = () -> { fallback.run(); result.complete(EntityTaskResult.ENTITY_RETIRED); }; + retirement.put(task, drop); tasks.add(task); retired.add(drop); + return result; + }); + when(implementation.runAtEntityLater(eq(player), any(Runnable.class), any(Runnable.class), anyLong())).thenAnswer(invocation -> { + timers.add(invocation.getArgument(1)); retired.add(invocation.getArgument(2)); return mock(WrappedTask.class); + }); + when(implementation.runLater(any(Runnable.class), anyLong())).thenAnswer(invocation -> { + timers.add(invocation.getArgument(0)); return mock(WrappedTask.class); + }); + } +} diff --git a/MCPerks/src/test/java/com/bencodez/mcperks/tests/scheduling/OwnerTasksTest.java b/MCPerks/src/test/java/com/bencodez/mcperks/tests/scheduling/OwnerTasksTest.java new file mode 100644 index 0000000..67d90e5 --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/scheduling/OwnerTasksTest.java @@ -0,0 +1,102 @@ +package com.bencodez.mcperks.tests.scheduling; +import com.bencodez.mcperks.scheduling.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import com.bencodez.mcperks.MCPerksMain; +import com.bencodez.simpleapi.folialib.enums.EntityTaskResult; +class OwnerTasksTest { + @Test void rejectedEntitySchedulerCompletesAdmissionEvenWhenFallbackIsNotCalled() { + MCPerksMain plugin=mock(MCPerksMain.class); when(plugin.isEnabled()).thenReturn(true); Player player=mock(Player.class); var harness=new OwnerTaskHarness(plugin,player); + when(harness.implementation.runAtEntityWithFallback(eq(player),any(),any())).thenReturn(CompletableFuture.completedFuture(EntityTaskResult.SCHEDULER_RETIRED)); + AtomicInteger executed=new AtomicInteger(),retired=new AtomicInteger(); OwnerTasks.run(plugin,player,executed::incrementAndGet,retired::incrementAndGet); + assertEquals(0,executed.get()); assertEquals(1,retired.get()); + } + @Test void retirementAndDuplicateCallbackCannotBothCompleteTheSameAdmission() { + MCPerksMain plugin=mock(MCPerksMain.class); when(plugin.isEnabled()).thenReturn(true); Player player=mock(Player.class); var harness=new OwnerTaskHarness(plugin,player); + AtomicInteger executed=new AtomicInteger(),retired=new AtomicInteger(); OwnerTasks.run(plugin,player,executed::incrementAndGet,retired::incrementAndGet); + Runnable action=harness.tasks.remove(); harness.retirement.get(action).run(); action.run(); action.run(); assertEquals(0,executed.get()); assertEquals(1,retired.get()); + } + @Test void rejectedDelayedEntityTaskUsesExplicitTicksAndReleasesOwnership() { + MCPerksMain plugin=mock(MCPerksMain.class); when(plugin.isEnabled()).thenReturn(true); Player player=mock(Player.class); var harness=new OwnerTaskHarness(plugin,player,true); + when(harness.implementation.runAtEntityLater(eq(player),any(Runnable.class),any(Runnable.class),eq(20L))).thenReturn(null); + AtomicInteger retired=new AtomicInteger(); OwnerTasks.later(plugin,player,()->fail("not admitted"),retired::incrementAndGet,20); assertEquals(1,retired.get()); + verify(harness.implementation,never()).runLater(any(Runnable.class),anyLong()); + } + @Test void foliaDelayedEntityRetirementCannotAlsoExecuteTheAction() { + MCPerksMain plugin=mock(MCPerksMain.class); when(plugin.isEnabled()).thenReturn(true); Player player=mock(Player.class); var harness=new OwnerTaskHarness(plugin,player,true); + AtomicInteger executed=new AtomicInteger(),retired=new AtomicInteger(); + OwnerTasks.later(plugin,player,executed::incrementAndGet,retired::incrementAndGet,20); + harness.retired.remove().run(); harness.timers.remove().run(); + assertEquals(0,executed.get()); assertEquals(1,retired.get()); + verify(harness.implementation,never()).runLater(any(Runnable.class),anyLong()); + } + @ParameterizedTest @ValueSource(booleans={false,true}) + void bundledSpigotAndPaperDelayRunsOnceAfterTheRequestedTicks(boolean paper) { + MCPerksMain plugin=mock(MCPerksMain.class); when(plugin.isEnabled()).thenReturn(true); + Player player=mock(Player.class); when(player.isValid()).thenReturn(true); + var harness=new BundledOwnerTaskHarness(plugin,paper); + AtomicInteger executed=new AtomicInteger(),retired=new AtomicInteger(); + assertDoesNotThrow(()->OwnerTasks.later(plugin,player,executed::incrementAndGet,retired::incrementAndGet,20)); + verify(harness.nativeScheduler).runTaskLater(eq(plugin),any(Runnable.class),eq(20L)); + verify(player,never()).isValid(); // Entity validity is read only after returning to its owner. + assertEquals(0,executed.get()); assertEquals(0,retired.get()); + Runnable delayed=harness.timers.remove(); delayed.run(); delayed.run(); + assertEquals(1,executed.get()); assertEquals(0,retired.get()); + } + @ParameterizedTest @ValueSource(booleans={false,true}) + void bundledDelayRetiresAnEntityThatBecameInvalidBeforeExecution(boolean paper) { + MCPerksMain plugin=mock(MCPerksMain.class); when(plugin.isEnabled()).thenReturn(true); + Player player=mock(Player.class); when(player.isValid()).thenReturn(true); + var harness=new BundledOwnerTaskHarness(plugin,paper); + AtomicInteger executed=new AtomicInteger(),retired=new AtomicInteger(); + OwnerTasks.later(plugin,player,executed::incrementAndGet,retired::incrementAndGet,20); + when(player.isValid()).thenReturn(false); + Runnable delayed=harness.timers.remove(); delayed.run(); delayed.run(); + assertEquals(0,executed.get()); assertEquals(1,retired.get()); + } + @ParameterizedTest @ValueSource(booleans={false,true}) + void bundledDelayRejectsSchedulerFailureAndDisabledPluginBeforeExecution(boolean paper) { + MCPerksMain plugin=mock(MCPerksMain.class); when(plugin.isEnabled()).thenReturn(true); + Player player=mock(Player.class); when(player.isValid()).thenReturn(true); + var harness=new BundledOwnerTaskHarness(plugin,paper); + AtomicInteger executed=new AtomicInteger(),retired=new AtomicInteger(); + OwnerTasks.later(plugin,player,executed::incrementAndGet,retired::incrementAndGet,20); + when(plugin.isEnabled()).thenReturn(false); + harness.timers.remove().run(); assertEquals(1,retired.get()); + when(plugin.isEnabled()).thenReturn(true); + when(harness.nativeScheduler.runTaskLater(eq(plugin),any(Runnable.class),eq(20L))).thenThrow(new IllegalStateException("scheduler retired")); + OwnerTasks.later(plugin,player,executed::incrementAndGet,retired::incrementAndGet,20); + assertEquals(0,executed.get()); assertEquals(2,retired.get()); assertTrue(harness.timers.isEmpty()); + } + @ParameterizedTest @ValueSource(booleans={false,true}) + void bundledImmediateCallbackAfterDisableRetiresOnceWithoutRunningTheAction(boolean paper) { + MCPerksMain plugin=mock(MCPerksMain.class); when(plugin.isEnabled()).thenReturn(true); + Player player=mock(Player.class); when(player.isValid()).thenReturn(true); + var harness=new BundledOwnerTaskHarness(plugin,paper); + AtomicInteger executed=new AtomicInteger(),retired=new AtomicInteger(); + OwnerTasks.run(plugin,player,executed::incrementAndGet,retired::incrementAndGet); + when(plugin.isEnabled()).thenReturn(false); + Runnable callback=harness.tasks.remove(); callback.run(); callback.run(); + assertEquals(0,executed.get()); assertEquals(1,retired.get()); + assertTrue(harness.tasks.isEmpty()); + } + @ParameterizedTest @ValueSource(booleans={false,true}) + void bundledImmediateSiblingUsesTheNativeOwnerAndCompletesInvalidEntityRetirementOnce(boolean paper) { + MCPerksMain plugin=mock(MCPerksMain.class); when(plugin.isEnabled()).thenReturn(true); + Player player=mock(Player.class); when(player.isValid()).thenReturn(true); + var harness=new BundledOwnerTaskHarness(plugin,paper); + AtomicInteger executed=new AtomicInteger(),retired=new AtomicInteger(); + OwnerTasks.run(plugin,player,executed::incrementAndGet,retired::incrementAndGet); + assertEquals(0,executed.get()); harness.tasks.remove().run(); assertEquals(1,executed.get()); + OwnerTasks.run(plugin,player,executed::incrementAndGet,retired::incrementAndGet); + when(player.isValid()).thenReturn(false); + Runnable rejected=harness.tasks.remove(); rejected.run(); rejected.run(); + assertEquals(1,executed.get()); assertEquals(1,retired.get()); + } +} diff --git a/MCPerks/src/test/java/com/bencodez/mcperks/tests/userapi/UsageChargesFixture.java b/MCPerks/src/test/java/com/bencodez/mcperks/tests/userapi/UsageChargesFixture.java new file mode 100644 index 0000000..0387d5e --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/userapi/UsageChargesFixture.java @@ -0,0 +1,9 @@ +package com.bencodez.mcperks.tests.userapi; +import static com.bencodez.mcperks.tests.TestAccess.*; +import com.bencodez.mcperks.userapi.*; +import java.nio.file.Path; +import com.bencodez.mcperks.data.DurableFeatureFiles; +public final class UsageChargesFixture { + private UsageChargesFixture() {} + public static UsageCharges create(Path file, DurableFeatureFiles.Publisher publisher) { return newUsageCharges(file, publisher); } +} diff --git a/MCPerks/src/test/java/com/bencodez/mcperks/tests/userapi/UsageChargesTest.java b/MCPerks/src/test/java/com/bencodez/mcperks/tests/userapi/UsageChargesTest.java new file mode 100644 index 0000000..287e9ae --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/userapi/UsageChargesTest.java @@ -0,0 +1,199 @@ +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 java.nio.file.*; +import java.util.*; +import java.util.concurrent.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import com.bencodez.mcperks.listeners.EffectBatch; +import com.bencodez.mcperks.userapi.UsageCharges.Kind; +class UsageChargesTest { + @TempDir Path directory; + private static T await(CompletableFuture future) throws Exception { return future.get(5, TimeUnit.SECONDS); } + @Test void batchNoopsPartialBudgetAndUncertainSideEffectRemainCorrectAfterRestart() throws Exception { + UUID owner = UUID.randomUUID(); Path file = directory.resolve("charges.dat"); + try (UsageCharges store = new UsageCharges(file)) { + await(store.ready()); await(store.set(owner, Kind.VeinMining, 3)); + var reservation = await(store.reserve(owner, Kind.VeinMining, 8)); assertEquals(3, reservation.units()); + var result = EffectBatch.execute(List.of(() -> false, () -> true, () -> { throw new IllegalStateException("possible effect"); }, () -> true), reservation.units()); + assertEquals(1, result.successful()); assertEquals(1, result.unused()); + await(store.finish(reservation, result.successful(), result.unused())); + await(store.finish(reservation, result.successful(), result.unused())); + assertEquals(1, store.get(owner, Kind.VeinMining)); assertEquals(1, store.successful(owner, Kind.VeinMining)); + } + try (UsageCharges recovered = new UsageCharges(file)) { + await(recovered.ready()); assertEquals(1, recovered.get(owner, Kind.VeinMining)); assertEquals(1, recovered.successful(owner, Kind.VeinMining)); + } + } + @Test void concurrentAdmissionCannotOverdrawAndDuplicateCompletionsCannotRefundTwice() throws Exception { + UUID owner = UUID.randomUUID(); + try (UsageCharges store = new UsageCharges(directory.resolve("charges.dat"))) { + await(store.set(owner, Kind.AutoBlockRefill, 10)); + List> futures = new ArrayList<>(); + for (int i = 0; i < 30; i++) futures.add(store.reserve(owner, Kind.AutoBlockRefill, 1)); + List admitted = new ArrayList<>(); + for (var future : futures) { var r = await(future); if (r != null) admitted.add(r); } + assertEquals(10, admitted.size()); assertEquals(0, store.get(owner, Kind.AutoBlockRefill)); + for (var reservation : admitted) { await(store.finish(reservation, 0, 1)); await(store.finish(reservation, 0, 1)); } + assertEquals(10, store.get(owner, Kind.AutoBlockRefill)); + } + } + @Test void restartRetainsUncertainSpendAndRejectsOldReleaseWithoutRegranting() throws Exception { + UUID owner = UUID.randomUUID(); Path file = directory.resolve("charges.dat"); UsageCharges.Reservation old; + try (UsageCharges store = new UsageCharges(file)) { + await(store.add(owner, Kind.RightClickHarvest, 5)); old = await(store.reserve(owner, Kind.RightClickHarvest, 2)); + } + try (UsageCharges store = new UsageCharges(file)) { + await(store.ready()); await(store.finish(old, 0, 2)); assertEquals(3, store.get(owner, Kind.RightClickHarvest)); + await(store.record(owner, Kind.RightClickHarvest, 1)); + assertEquals(1, store.successful(owner, Kind.RightClickHarvest)); + assertEquals(0, store.get(owner, Kind.VeinMining)); + } + } + @Test void malformedLedgerFailsClosedRatherThanInitializingEmptyBalances() throws Exception { + Path file = directory.resolve("charges.dat"); Files.writeString(file, "broken"); + try (UsageCharges store = new UsageCharges(file)) { + assertThrows(ExecutionException.class, () -> await(store.ready())); + assertThrows(ExecutionException.class, () -> await(store.set(UUID.randomUUID(), Kind.VeinMining, 4))); + assertEquals("broken", Files.readString(file)); + } + } + @Test void failedPublicationNeverAdmitsGameplayAndFreezesLaterUpdates() throws Exception { + Path file = directory.resolve("charges.dat"); UUID owner = UUID.randomUUID(); + try (UsageCharges store = new UsageCharges(file)) { + await(store.set(owner, Kind.VeinMining, 2)); + Files.delete(file); Files.createDirectory(file); + assertThrows(ExecutionException.class, () -> await(store.reserve(owner, Kind.VeinMining, 1))); + assertThrows(ExecutionException.class, () -> await(store.add(owner, Kind.VeinMining, 2))); + } + } + @Test void cancelledAndNoopUnitsRefundBudgetButSuccessesUseBudgetNotAttemptCount() { + int[] successes = {0}; + var result = EffectBatch.execute(List.of(() -> false, () -> { successes[0]++; return true; }, () -> { successes[0]++; return true; }), 1); + assertEquals(1, successes[0]); assertEquals(new EffectBatch.Result(1, 0), result); + assertEquals(new EffectBatch.Result(0, 2), EffectBatch.execute(List.of(() -> false, () -> false), 2)); + } + @Test void postPublicationReservationFailureNeverAuthorizesGameplayAndRestartKeepsItSpent() throws Exception { + Path file=directory.resolve("charges.dat"); UUID owner=UUID.randomUUID(); java.util.concurrent.atomic.AtomicInteger publications=new java.util.concurrent.atomic.AtomicInteger(); + try (UsageCharges store=newUsageCharges(file,(staged,target) -> { + com.bencodez.mcperks.data.DurableFeatureFiles.publish(staged,target); + if (publications.incrementAndGet()==2) throw new java.io.IOException("published but acknowledgment uncertain"); + })) { + await(store.set(owner,Kind.AutoBlockRefill,2)); + assertThrows(ExecutionException.class,() -> await(store.reserve(owner,Kind.AutoBlockRefill,1))); + assertFalse(store.available()); assertThrows(ExecutionException.class,() -> await(store.add(owner,Kind.AutoBlockRefill,5))); + } + try (UsageCharges store=new UsageCharges(file)) { await(store.ready()); assertEquals(1,store.get(owner,Kind.AutoBlockRefill)); } + } + + @Test void saturatedOrdinaryWorkerSettlesEveryDurableIdentityAndReclaimsCapacity() throws Exception { + UUID owner = UUID.randomUUID(); Path file = directory.resolve("saturated.dat"); + CountDownLatch publishing = new CountDownLatch(1), resume = new CountDownLatch(1); + java.util.concurrent.atomic.AtomicBoolean block = new java.util.concurrent.atomic.AtomicBoolean(); + List admitted = new ArrayList<>(); + List> settlements = new ArrayList<>(), ordinary = new ArrayList<>(); + try (UsageCharges store = newUsageCharges(file, (staged, target) -> { + assertEquals("MCPerks-charge-store", Thread.currentThread().getName()); + if (block.compareAndSet(true, false)) { + publishing.countDown(); + try { if (!resume.await(10, TimeUnit.SECONDS)) throw new java.io.IOException("publication test timeout"); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new java.io.IOException(interrupted); } + } + com.bencodez.mcperks.data.DurableFeatureFiles.publish(staged, target); + })) { + await(store.set(owner, Kind.VeinMining, 768)); + for (int i = 0; i < 256; i++) admitted.add(await(store.reserve(owner, Kind.VeinMining, 3))); + assertThrows(ExecutionException.class, () -> await(store.reserve(owner, Kind.VeinMining, 1))); + block.set(true); ordinary.add(store.record(owner, Kind.VeinMining, 1)); + assertTrue(publishing.await(5, TimeUnit.SECONDS)); + try { + // One running plus all 256 ordinary waiting slots. Further ordinary work + // remains rejected while every existing durable identity can still settle. + for (int i = 0; i < 256; i++) ordinary.add(store.record(owner, Kind.VeinMining, 1)); + assertThrows(ExecutionException.class, () -> await(store.add(owner, Kind.VeinMining, 1))); + assertTimeoutPreemptively(java.time.Duration.ofSeconds(2), () -> { + for (var reservation : admitted) { + var completion = store.finish(reservation, 1, 1); settlements.add(completion); + for (int i = 0; i < 4; i++) assertSame(completion, store.finish(reservation, 1, 1)); + assertSame(completion, store.finish(reservation, 0, 3)); // first proven outcome wins + assertFalse(completion.isDone()); + } + }); + assertEquals(0, store.get(owner, Kind.VeinMining)); + } finally { resume.countDown(); } + for (var completion : settlements) await(completion); + for (var completion : ordinary) await(completion); + assertEquals(256, store.get(owner, Kind.VeinMining)); + assertEquals(513, store.successful(owner, Kind.VeinMining)); + // All 256 reservation slots are usable again without a restart. + List reused = new ArrayList<>(); + for (int i = 0; i < 256; i++) reused.add(await(store.reserve(owner, Kind.VeinMining, 1))); + for (var reservation : reused) await(store.finish(reservation, 0, 1)); + for (var reservation : admitted) await(store.finish(reservation, 1, 1)); + assertEquals(256, store.get(owner, Kind.VeinMining)); + assertEquals(513, store.successful(owner, Kind.VeinMining)); + } finally { resume.countDown(); } + try (UsageCharges recovered = new UsageCharges(file)) { + await(recovered.ready()); + for (var old : admitted) await(recovered.finish(old, 0, 3)); + assertEquals(256, recovered.get(owner, Kind.VeinMining)); + assertEquals(513, recovered.successful(owner, Kind.VeinMining)); + } + } + + @Test void closeDrainsSettlementAdmittedBehindSaturatedOrdinaryWrites() throws Exception { + Path file = directory.resolve("drain.dat"); UUID owner = UUID.randomUUID(); + CountDownLatch blocked = new CountDownLatch(1), release = new CountDownLatch(1); + java.util.concurrent.atomic.AtomicBoolean pause = new java.util.concurrent.atomic.AtomicBoolean(); + UsageCharges store = newUsageCharges(file, (staged, target) -> { + if (pause.compareAndSet(true, false)) { + blocked.countDown(); + try { if (!release.await(10, TimeUnit.SECONDS)) throw new java.io.IOException("drain test timeout"); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new java.io.IOException(interrupted); } + } + com.bencodez.mcperks.data.DurableFeatureFiles.publish(staged, target); + }); + try { + await(store.set(owner, Kind.AutoBlockRefill, 2)); + var reservation = await(store.reserve(owner, Kind.AutoBlockRefill, 2)); + pause.set(true); store.record(owner, Kind.AutoBlockRefill, 1); assertTrue(blocked.await(5, TimeUnit.SECONDS)); + for (int i = 0; i < 256; i++) store.record(owner, Kind.AutoBlockRefill, 1); + var completion = store.finish(reservation, 0, 2); + assertFalse(completion.isDone()); store.close(); release.countDown(); await(completion); + } finally { release.countDown(); store.close(); } + try (UsageCharges recovered = new UsageCharges(file)) { + await(recovered.ready()); assertEquals(2, recovered.get(owner, Kind.AutoBlockRefill)); + assertEquals(257, recovered.successful(owner, Kind.AutoBlockRefill)); + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void uncertainSettlementNeverRetriesRefundAndRestartUsesPublishedEvidence(boolean moved) throws Exception { + Path file = directory.resolve("uncertain-settlement.dat"); UUID owner = UUID.randomUUID(); + java.util.concurrent.atomic.AtomicInteger publications = new java.util.concurrent.atomic.AtomicInteger(); + UsageCharges.Reservation reservation; + try (UsageCharges store = newUsageCharges(file, (staged, target) -> { + if (publications.incrementAndGet() == 3) { + if (moved) com.bencodez.mcperks.data.DurableFeatureFiles.publish(staged, target); + throw new java.io.IOException("settlement outcome uncertain"); + } + com.bencodez.mcperks.data.DurableFeatureFiles.publish(staged, target); + })) { + await(store.set(owner, Kind.RightClickHarvest, 3)); reservation = await(store.reserve(owner, Kind.RightClickHarvest, 2)); + assertThrows(ExecutionException.class, () -> await(store.finish(reservation, 1, 1))); + assertFalse(store.available()); + assertThrows(ExecutionException.class, () -> await(store.finish(reservation, 1, 1))); + assertThrows(ExecutionException.class, () -> await(store.add(owner, Kind.RightClickHarvest, 10))); + assertEquals(3, publications.get()); assertEquals(1, store.get(owner, Kind.RightClickHarvest)); + } + try (UsageCharges recovered = new UsageCharges(file)) { + await(recovered.ready()); await(recovered.finish(reservation, 1, 1)); + assertEquals(moved ? 2 : 1, recovered.get(owner, Kind.RightClickHarvest)); + assertEquals(moved ? 1 : 0, recovered.successful(owner, Kind.RightClickHarvest)); + } + } + +} 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..334da82 --- /dev/null +++ b/MCPerks/src/test/java/com/bencodez/mcperks/tests/userapi/UserStorageConfirmationTest.java @@ -0,0 +1,91 @@ +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.FeatureState; + +class UserStorageConfirmationTest { + @TempDir Path directory; + @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(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 FeatureState(directory.resolve("features.dat"))) { + state.ready().get(5,TimeUnit.SECONDS); when(plugin.getFeatureState()).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(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 FeatureState(directory.resolve("deferred.dat"))) { + state.ready().get(5, TimeUnit.SECONDS); when(plugin.getFeatureState()).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(); } + } +} diff --git a/README.md b/README.md index c47477a..4b22fbb 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,309 @@ # MCPerks - ### Premium plugin on spigot, compile yourself and you will get no support. +### Premium plugin on spigot, compile yourself and you will get no support. ## License + ### Creative Commons Arttribution 3.0 Unported https://github.com/Ben12345rocks/AdvancedCore/blob/master/VotingPlugin/LICENSE.txt + +## Platform support + +Use Bukkit/Spigot or Paper. The current plugin descriptor does not declare Folia +support, so Folia refuses to load MCPerks. The conditional Folia scheduling code +described below does not establish whole-plugin support. An actual Folia 26.2 +build 7 startup check confirmed this loader rejection; no effect tests ran there. +Do not add `folia-supported` as a workaround without a complete ownership audit +and runtime validation. + +## Optional interaction effects + +Add any of `AutoBlockRefill`, `VeinMining`, `RightClickHarvest`, or `PrecisionMode` +to a perk's existing `Effects` list. Existing perks keep their established timed, +unlimited behavior when the new options are absent. + +- `AutoBlockRefill` moves one matching item from storage inventory into the depleted + main or off hand after vanilla placement. It preserves metadata and rechecks the + hand, selected slot, source stack, cancellation, and current perk before moving it. +- `VeinMining` finds six-neighbour connected ore blocks. Its total block limit + includes the original player break; only additional blocks count as successful + units. It uses the native player break path for protection events, normal tool + durability, enchantments, drops, and experience, with a recursion guard. It + accesses loaded chunks only. Its conditional Folia path stays in the starting + chunk and checks current region ownership, but MCPerks currently cannot load + on Folia (see Platform support). +- `RightClickHarvest` accepts the main hand once, mature wheat, carrots, potatoes, + beetroot and nether wart, and the existing AutoPlant tool, enchantment and block + policy. The final right-click decision and the native block-break decision must + both permit it; later interaction denial prevents reservation and gameplay. + It reserves one inventory seed, uses the native player break path, then + replants the same crop at age zero. A denied break restores that seed. All native + drops are retained; seeds are not removed wholesale from the drop collection. +- `PrecisionMode` applies to the perk's affected audience while sneaking. Its + default targets are the three new automation effects. Set + `PrecisionMode.TargetEffects` to an explicit list to change the targets. A + disabled world or another player's precision perk cannot suppress your effect. + +Optional keys belong in the perk definition: + +```yaml +Effects: [VeinMining, PrecisionMode] +VeinMining: + MaxBlocks: 16 # total blocks, clamped to 1..64 + MaxDistance: 8 # coordinate distance from original break, clamped to 1..16 + Materials: [COAL_ORE, IRON_ORE, DIAMOND_ORE] # empty: native ore materials + Tools: [IRON_PICKAXE, DIAMOND_PICKAXE] # empty: pickaxes +RightClickHarvest: + Crops: [WHEAT, CARROTS, POTATOES, BEETROOTS, NETHER_WART] +PrecisionMode: + TargetEffects: [VeinMining, RightClickHarvest, AutoBlockRefill] +UsageCharges: + Enabled: false +BankedTime: + Enabled: false +``` + +## Successful-use charges + +Enable `UsageCharges.Enabled` separately on each **PLAYER** perk that needs charges. +Shared/global audiences have no stable personal charge owner: charged automation +for those scopes is rejected with a load warning. Leave charge mode disabled to +retain unlimited shared perks. + +Balances are personal UUID plus one of the fixed effect names `AutoBlockRefill`, +`VeinMining`, or `RightClickHarvest`. They are independent of the timed perk grant: +activation, expiry, reconnect, reload, and restart never refill a balance. +Administrators explicitly grant or set them: + +```text +/mcperks SetUsageCharges +/mcperks AddUsageCharges +/mcperks GetUsageCharges +``` + +The matching permissions are `MCPerks.SetUsageCharges`, `MCPerks.AddUsageCharges` +and `MCPerks.GetUsageCharges`; the read command is player-only. Each successful +refill, harvest, or additional mined block spends one unit. Batches reserve up to +available charges before gameplay. Protected, suppressed and proven no-op units +are not charged. An exception after a possible effect remains spent. + +`usage-charges.dat` is a bounded, ordered, asynchronously written ledger. Its +atomic publication is confirmed before a charged effect can run. Outstanding +reservations remain spent after a crash and cannot be refunded by an old callback. +Unreadable or uncertain storage fails closed for charged effects; legacy unlimited +perks continue. These are local guarantees, not a transaction spanning Minecraft +world saves and the charge ledger. Limits are 10,000 personal accounts, 256 live +reservations and 256 queued storage requests. Staff command replies confirm saved +updates rather than merely submitting them. + +## Queue planner + +The existing `PerkQue` switch remains authoritative. The planner reserves an +existing activation while waiting and consumes only one selected personal or +shared activation at start. It never grants another activation during recovery. +Entries have stable IDs and are stored in `perk-features.dat`, with durable pause, +order, cancellation and `WAITING`/`STARTING` fences. + +```text +/mcperks Queue +/mcperks CancelQueue +/mcperks QueuePause +/mcperks QueueRemove +/mcperks QueueMove +``` + +`Queue` requires `MCPerks.Queue`; self cancellation requires `MCPerks.CancelQueue` +and checks entry ownership. Staff operations require `MCPerks.Queue.Manage`. +`ClearQueue` retains its existing permission and clears waiting entries. +The view includes active remaining time when known, requester UUID, ordered +position, ID, stage and waiting time, with at most 32 queued entries displayed. +Finite absolute timers and finite queued grants receive an estimated start time +when all preceding requesters are online and their eligibility is known. ETA stays +unknown for offline requesters, uncertain eligibility, manual pause, banked time, +and permanent perks. Estimates are observational and may change. + +Limited-activation queued starts require the shaded AdvancedCore shared runtime's +atomic user transaction. The bundled runtime binds this ownership to initialized +`DataStorage: SQLITE` (the default) and `MYSQL` backends. Older or custom runtimes +without a bound shared owner cannot start billed entries. Without that ownership, +entries remain waiting and show +`transactional activation storage unavailable`; no credit is consumed or start +fence claimed. Unlimited legacy queued starts remain supported. Database work +runs on the bounded storage worker, including canonical user-cache publication +after a committed debit before legacy gameplay reads. This does not make the database and queue +file one transaction. Failed or interrupted starts become `UNCERTAIN`, +never replay automatically and never refund a possibly consumed activation. +Queue admission retains the existing command/GUI authorization and granted-credit +model. Starts recheck world, cooldown and usage limits; they do not revoke admitted +entitlement when the requester later loses an activation-command permission. +Staff may explicitly remove an uncertain entry without a database refund after +investigating it. Waiting entries survive reconnect, reload and restart; one entry +per player and at most 256 total are admitted. Direct public mutation of the old +in-memory `que` field is not a durable queue admission API; use `addQue`. + +Graceful shutdown seals automation admissions, cancels effects that have not +started, and asynchronously drains their proved-unused reservation releases +before closing charge storage. No server-owner thread waits for that drain; +crash recovery still keeps uncertain reservations spent. + +## Personal banked time + +`BankedTime.Enabled` is opt-in per **PLAYER** perk. New finite grants add durable +milliseconds to the personal bank. Existing absolute expirations remain separate; +there is no implicit conversion, expiry reset, or time granted at login/reload. +The bank counts monotonic elapsed time at lifecycle checkpoints while the player +is online, in an allowed world, not ignoring effects, and not manually paused. +Read commands and gameplay eligibility queries do not consume time. Disconnect, +world transition, manual pause, reload and normal shutdown checkpoint the final +eligible interval before retiring its clock. Legacy deactivation buttons and +commands pause the bank without changing absolute expirations or replaying hooks. + +Banked mode supports passive effects read by gameplay listeners, including the +new interaction effects. It rejects command/reward/cure/repair effects, periodic +reapplication, and fly/double-jump/potion/attribute/temporary-permission effects, whose legacy +activation paths cannot safely promise pause/resume ownership. These restrictions +are reported at load and before consuming an activation. Banked resume attaches +only the existing entitlement and does not replay activation/deactivation reward +hooks, announcements or commands. New grants retain personal/server cooldown +bookkeeping. Unlimited direct grants confirm personal/server cooldown storage before +publishing new bank time. A later bank-publication failure may leave a cooldown +without added time; inspect and reconcile it rather than automatically retrying or +refunding. A successful bank write followed by attachment failure keeps the bank +and its cooldown. Configured zero cooldown and duplicate-active permission can +intentionally allow another grant; they are not delivery deduplication. Limited direct purchases persist a `STARTING` identity in the existing +bounded feature file before consuming credit. With `PerkQue` disabled, personal banks +can start alongside unrelated active or permanent perks, and different players can +start in parallel unless this perk has a shared server cooldown. Direct identities +never enter `WAITING` or run through the ordinary global queue. Known +unsupported/unavailable bank admission is rejected before credit consumption. +If a bank write fails after a confirmed debit, the retained start fence requires +admin review; it is never automatically replayed or refunded. The queue command +shows the last confirmed fences even when storage becomes unavailable. +Direct purchases validate and publish current ServerData and confirm current +caller-owned storage before the atomic credit debit. New expiration and cooldown +fields are published through the normal owner admission only after that debit +succeeds, so a rejected purchase cannot restore as a free permanent perk. A definite failure before debit, or an authoritative false +debit, consumes no credit and removes the direct fence. Once the debit may have +committed, publication or gameplay failure retains `UNCERTAIN` evidence without +automatic replay or refund. A crash or completion-save failure preserves the start +identity for review. Owner, perk and reload admission remain held until the actual +callbacks and completion publication settle. Bank publication uses the existing +capacity-before-debit transaction; attachment does not grant banked time again. +Checkpoints normally occur each second; a +crash can preserve the uncheckpointed final fraction of time. + +```text +/mcperks GetBankedTime +/mcperks PauseBankedTime +/mcperks ResumeBankedTime +/mcperks SetBankedTime +``` + +Self commands require `MCPerks.BankedTime`; setting requires +`MCPerks.SetBankedTime`. Grants are bounded to one year, 64 banks per player and +10,000 banks total, with at most 256 live bank controllers. Players without +positive banked entitlement do not start a controller or poll user storage. +A failed or rejected bank checkpoint detaches the banked effect and stops its +controller, even if the last confirmed balance remains positive. Eligible time +starts when the effect attaches after storage admission; storage delays before +initial attachment or resume consume no bank time. Retired player-session +callbacks cannot stop a reconnecting successor. +Bank pulses use the synchronous Bukkit delay on Spigot/Paper. The conditional +Folia path uses the moving player's entity scheduler; current Folia loading is +unsupported (see Platform support). Delayed scheduler rejection or entity +retirement stops the owning controller and detaches its effect; callbacks from +retired controllers cannot attach it again. + +The optional `ReActivateEffectsTimer` may be omitted or set to any nonpositive +value to keep legacy periodic reapplication disabled. `SetBankedTime` replaces +the balance at durable publication: earlier elapsed intervals and pending debit +reservations cannot consume that replacement balance. Pausing during a pending +grant publication remains paused after publication, including for later grants. +Queue and bank publications share the recovery limit of 3,000,000 serialized +bytes; a byte-capacity rejection preserves the previous file and usable state. + +## Diagnostics and local statistics + +`/mcperks Diagnose` (`MCPerks.Diagnose`) explains the caller's actual permission, +entitlement reservation, cooldown, activation limit, world, tool/enchantment, +ignore, precision, charge, bank and queue state. `/mcperks Diagnose ` +requires `MCPerks.Diagnose.Other` and an online target. The bounded latest skip +records only a known MCPerks decision; it does not identify or blame an external +protection plugin. Output is bounded to 32 perk definitions and 64 effect details. + +`LocalStatistics.Enabled: true` in `Config.yml` enables local aggregate activation, +queue-wait and fixed skip-reason counters. `/mcperks LocalStatistics` requires +`MCPerks.LocalStatistics`. These counters reset on restart and send nothing to an +external service. Successful-unit totals use the same durable fixed effect ledger +as charge finalization, including unlimited effects. Counter persistence failures +do not block unlimited gameplay. + +## Verification + +Use JDK 21 and Maven from this checkout: + +```text +mvn -B -f MCPerks/pom.xml test +mvn -B -f MCPerks/pom.xml package +``` + +Use `package`, not `install`; the install lifecycle copies the artifact to a +maintainer-specific test-server directory. Tests exercise real listener entry +points, inventory revalidation, native-break denial, partial budgets, recursion, +restart/duplicate reservations, queue start fences, ownership, capacity and +publication failures, and banked offline/manual/reload lifecycle behavior. + +Banked usable time also rejects `DoubleJump` and `DoubleJumpUp`, whose persistent flight flags cannot safely pause with a passive bank. Vein batches retain their original selected slot and complete item metadata (ignoring only normal durability changes). Unlimited effects release gameplay admission before best-effort statistics publication; charge acknowledgements remain authoritative. Queue estimates become unknown when a reserved activation credit has been removed. + +Queued legacy activation captures runtime YAML and optional activation-log messages under the runtime-data owner; only immutable snapshots and captured log writes reach the bounded feature worker. A queue start remains STARTING until publication completes, and a failed publication remains UNCERTAIN. AdvancedCore calendar writes share that ordering. Reload drains accepted snapshots before refreshing runtime files, and normal shutdown admits its final snapshot before sealing the worker. Startup reconciles the prior writer's confirmed data before restoring legacy perks, while preserving calendar fields changed during the drain. Corrupt optional queue/bank data does not disable independent legacy YAML publication. Runtime snapshots are bounded to four MB. History retains at most 256 complete activations per month within an aggregate one-MB encoded YAML budget; recent suffixes of the last retained months take priority when pruning is necessary. History is further reduced when authoritative timers, cooldowns or calendar state need the remaining snapshot capacity. A single oversized history entry is omitted rather than allowed to prevent later runtime saves. The optional activation file log keeps its existing behavior. + +Direct perk starts now stage shared cooldown/activation fields in a private snapshot, publish it, then apply personal fields on the player owner and confirm caller-owned storage before running effect, reward or command hooks. Failed or oversized shared publication changes neither personal nor shared live timing fields and prevents its gameplay callback. Ordered later snapshots omit failed admissions and preserve newer explicit writes. A failure after shared publication or during personal storage confirmation is conservative: admitted timing can remain without gameplay, so reconcile it rather than blindly retrying or refunding. A crash after state publication but before gameplay can leave admitted time/cooldown without completed hooks and needs owner reconciliation; publication and arbitrary command/reward effects are not one transaction. The existing legacy restoration semantics remain unchanged, and queue starts retain their separate uncertain-start fences. Concurrent starts are fenced per player for personal perks and per perk audience for shared perks. Reload and shutdown generations invalidate delayed callbacks before they can run under a replacement lifecycle. + +Bank grants reject aggregate overflow rather than truncate purchased time. The current bank capacity check, activation debit and bank publication run under one ordered worker turn; a concurrent staff Set/Add cannot occupy the checked capacity between check and debit. The owner callback attaches already published time without granting it again. Harvest requires its captured selected hotbar slot even when another slot holds an equal item. + +Queued personal activation now also waits for the canonical AdvancedCore user-cache flush callback before retiring its start fence. Submission alone is not confirmation; a thrown failure or missing completion within 30 seconds leaves the entry `UNCERTAIN` for owner reconciliation. Direct purchases reject unavailable runtime/entitlement storage before debit. Direct bank admission is serialized per perk until bank, personal cooldown and server cooldown publication completes; an existing positive bank counts toward the existing duplicate-activation policy. Calendar transition begin/completion use the same ordered immutable YAML publication and retain AdvancedCore rollback semantics on failure. Additional native vein breaks and harvests retain existing Fortune/Farmer bonus behavior while recursive automation remains suppressed. + +Calendar durability waits hold only a private transition view, so an earlier queue eligibility read and owner activation can acquire runtime data while calendar publication is pending. Perk snapshots captured during that wait carry the calendar result: confirmed fields survive later publication, failed begin/completion changes stay out of the live cache and later snapshots, and explicit later marker edits are preserved. Calendar and perk writes still use the same bounded worker and four-MB snapshot limit; reload defers file replacement while either is pending, and shutdown/predecessor draining includes accepted calendar writes. + +Startup queue checks, direct purchases and bank attachment wait for confirmed completion of both the asynchronous all-user legacy restoration scan and shutdown-record restoration, after runtime YAML reconciliation. A failed or rejected restoration leaves queued credit unspent and banks unattached; the startup log identifies the failure. The existing `loadActivePerks()` entry point remains available. + +Bank attachment and resume recheck `LimitPermission` on the player owner, including already active perks affecting that player. Each attachment immediately counts toward the next bank's check, so concurrent purchases of different banks cannot attach past the cap. Existing attached banks count once. Banks waiting for a free active-perk slot keep their remaining time, consume no waiting interval, and attach on a later pulse when eligible; resume does not grant time or replay activation hooks. Disabled limits and players without a matching limit permission retain unlimited behavior. + +Precision targets must be action effects whose execution consults MCPerks eligibility. Persistent movement/attribute/potion/temporary-permission/fly/jump effects and already-executed commands, rewards, cures and repairs are rejected rather than claiming to suspend them. Unknown targets are invalid perk configuration; other configured perks continue loading. + +Charge and feature stores hold an OS lock in an empty sibling `.lock` file until their accepted writes drain. This protects reloads using a fresh plugin classloader as well as normal reloads. Acquisition occurs only on the storage worker, waits up to 30 seconds for an external/classloader owner, and otherwise leaves that store unavailable without reading or publishing stale data. Do not delete lock files while the plugin is running. Deleted or disabled perk definitions in legacy shutdown records are skipped so healthy restoration and queue/bank admissions can finish. + +Storage ownership acquisition failure freezes every subsequent queue, bank, charge and statistic write, including the ordered legacy publication lane. An unreadable optional feature file remains distinct from a failed ownership guard. When multiple active grants supply the same automation, a context-matching unlimited grant takes precedence; its crop/material/tool configuration still applies, and an unrelated charged grant does not block it through depletion or unavailable storage. Precision suppression resolves the Bukkit killer on servers without the modern damage-source API. + +Banked action eligibility uses monotonic elapsed time and unconfirmed checkpoint debits as well as the persisted balance. Delayed storage cannot extend usable time; inspection never debits or checkpoints it. Automation callbacks capture their reload generation before scheduling, so actions queued before reload cannot mutate gameplay under a replacement generation. + +Banked effects recheck every contributing grant, including passive drop/XP bonuses and PrecisionMode suppression. Pause and elapsed-time debit admission share one lifecycle boundary. Queue clearing reports success only after waiting-entry removal is durably published; the existing public void API remains available alongside completion-aware callers. + +The ordered file worker remains available until both shutdown preparation and AdvancedCore's actual final-file publication stages settle. The inherited disable hook may return while either is pending; its completion hook receives the final-file stage and closes the captured FeatureState owner only after both settle, including exceptional settlement. Final YAML mutations and serialization use the runtime monitor, while publication stays on the existing ordered worker; no stage is joined under that monitor. The final permission snapshot reconciles timed and pending offline native nodes before publication, retaining renewed grants and omitting revoked nodes. Worker termination is outside these completion stages so closing the owner cannot wait on itself. Queue/bank byte-capacity rejection is checked before activation consumption. A clock attached during a pending top-up can spend only previously published time until the top-up commits; the wait never consumes newly granted time. + +Startup keeps AdvancedCore's actual restored/cleared temporary-permission state authoritative, so a delayed version save cannot resurrect a permission later revoked. Bank replacement also fences elapsed debits from clocks removed by disconnect, reload or retirement while publication was pending. New clocks consume the replacement from its commit boundary and keep manual pause semantics. + +Confirmed bank balances become visible in the same memory turn as their clock boundary. Clock attachment and checkpoint reservation share that boundary, so disconnect, reload or retirement cannot consume newly topped-up time for an earlier interval. Filesystem publication and staged-file cleanup stay outside the boundary; a clock attached after complete publication starts at attachment and does not charge offline waiting. Diagnose reports the options retained by active grants after reload, including their captured charge policy, rather than the replacement definitions for those grants. + +The four-MB ServerData limit includes its ordered-format comment. A readable, unmarked legacy file larger than this limit can migrate automatically when already expired/revoked permission rows and advisory history allow its indispensable state to fit. New marked files retain the strict recovery limit. If still-active permissions or other indispensable legacy fields alone exceed four MB, migration leaves the original file intact and reports the unsupported payload; it cannot preserve that data inside a bounded file. Before upgrading that server, revoke or expire excess timed permissions using the prior version, or reduce other indispensable legacy payload, then stop normally and retry. Offline queued permissions are conservatively retained while their native pending owner exists. A failed reconciliation also reports a final persistence skip rather than overwriting the original file with a cleared snapshot. + +Native startup defers early LastUpdated/version file saves until the original ServerData has been strictly read after predecessor shutdown work drains. Temporary permissions are reconciled against the native handler baseline and restored through server/player ownership before recovered rows are cleared. Successor revocations, renewals and new grants take precedence. The adapter validates the bundled synchronized permission snapshot methods and fails closed if their contract is unavailable; it does not require a dependency upgrade. Pending offline permissions remain represented in the ordered final save. + +Queue-enabled direct activation retains the queue start fence through asynchronous bank publication, the initial player-owner attachment attempt, and cooldown confirmation. Queued bank starts retain their existing fence through the same attachment handoff. Resumed or reconnected banks wait behind unrelated active perks without consuming waiting time; queue-disabled personal banks retain parallel behavior. The first attachment attempt may finish ineligible or retired while preserving the published bank for a later eligible resume; it does not mint time or replay activation hooks. + +PrecisionMode resamples already-connected players on their entity owner at enable and reload, and samples new connections on join. Sneaking state is tied to that connection and reload generation; old callbacks and quit cleanup cannot change a replacement connection. No extra sneak toggle or automation action is required before configured legacy targets are suppressed. + +Shutdown perk records for missing or disabled definitions are retained for later startup restoration. Legacy absolute expiry continues while unavailable; restoration preserves its original deadline and retires an already expired record without activating it. Successfully handled records are removed individually; an activation failure keeps restoration unavailable rather than admitting queued perks over uncertain active state. This does not promise atomic recovery across gameplay effects and file publication. + +Persisted timed perks restored after startup/reload keep their saved expiration and reapply persistent effects. Restoration does not replay activation rewards, player/global commands, activation announcements, countdown rewards, history entries or activation statistics. Newly granted perks retain their existing activation hooks; configured periodic reactivation still uses its existing cadence. + +Direct activation holds one audience/owner admission before consuming an activation allowance and through asynchronous confirmation. A repeated request while that activation is pending consumes no second credit. Reload retires pending callbacks from the previous configuration generation; an uncertain already-consumed activation is not automatically refunded or replayed. Personal owners keep independent admission fences, while shared audience ownership retains its established scope. + + +### AdvancedCore shutdown dependency + +This candidate pins the bundled AdvancedCore dependency to `3.8.2-SNAPSHOT` instead of resolving `LATEST`. It requires the pre-storage shutdown hook, completion-aware final-file shutdown hook, and additive `ServerData.mutateFinalData` owner contract from [AdvancedCore PR #360](https://github.com/BenCodez/AdvancedCore/pull/360). Merge/build that producer change before building this MCPerks candidate from remote dependencies; an older snapshot with the same version cannot compile these lifecycle overrides. Test builds use the exact locally built producer artifact and record its SHA-256; the version alone is not an immutable artifact pin. MCPerks shades AdvancedCore, so server owners do not install a separate AdvancedCore plugin. No manual player-data migration is required. The PR remains dependent on the producer change and its snapshot availability; a green test build using a local candidate does not prove remote artifact availability. + +During disable, MCPerks captures legacy perk state and detaches native effects on the shutdown owner before asynchronous storage retirement. Bank checkpoints and expiration/cooldown confirmation run on the canonical storage worker; the final file owner stays available through inherited permission cleanup. User mutation setters and their flush confirmation use the same ordered AdvancedCore worker, preventing confirmation of an empty cache before queued changes arrive. + +After reload, Diagnose uses the current configured definition for new activation world and usage-limit checks, while effect details keep the options captured by the running grant. Inspection shares the production activation-limit predicate and does not debit or update stored state.