From c2d0d2b3c8e8f522e563646648b886c6faa9ab16 Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Fri, 9 Oct 2026 18:14:58 -0600
Subject: [PATCH 01/23] Add opt-in automation, usage charges and durable perk
lifecycle
---
AGENTS.md | 27 +
MCPerks/pom.xml | 20 +
.../com/bencodez/mcperks/MCPerksMain.java | 191 ++++-
.../mcperks/commands/CommandLoader.java | 163 +++-
.../mcperks/commands/PerkDiagnostics.java | 100 +++
.../mcperks/data/DurableFeatureFiles.java | 16 +
.../mcperks/data/FeatureFileOwnership.java | 47 ++
.../bencodez/mcperks/data/FeatureState.java | 244 ++++++
.../mcperks/data/LocalStatistics.java | 21 +
.../mcperks/data/OrderedServerDataFile.java | 73 ++
.../com/bencodez/mcperks/data/ServerData.java | 555 ++++++++++--
.../bencodez/mcperks/effects/AutoPlant.java | 45 +-
.../mcperks/listeners/EffectBatch.java | 18 +
.../mcperks/listeners/EffectListeners.java | 49 +-
.../mcperks/listeners/RefillPlan.java | 37 +
.../listeners/UsageEffectListener.java | 299 +++++++
.../listeners/compatability/McMMOEvents.java | 5 +-
.../compatability/RoseStackerListener.java | 3 +-
.../bencodez/mcperks/perk/BankedClock.java | 81 ++
.../bencodez/mcperks/perk/BankedPerks.java | 278 ++++++
.../com/bencodez/mcperks/perk/Effect.java | 14 +-
.../mcperks/perk/InteractionOptions.java | 27 +
.../java/com/bencodez/mcperks/perk/Perk.java | 153 +++-
.../bencodez/mcperks/perk/PerkHandler.java | 170 ++--
.../bencodez/mcperks/perk/QueuePlanner.java | 244 ++++++
.../mcperks/scheduling/OwnerTasks.java | 41 +
.../mcperks/userapi/UsageCharges.java | 198 +++++
.../userapi/UserStorageConfirmation.java | 23 +
MCPerks/src/main/resources/Config.yml | 6 +-
.../mcperks/commands/PerkDiagnosticsTest.java | 196 +++++
.../data/FeatureFileOwnershipTest.java | 75 ++
.../mcperks/data/FeatureStateFixture.java | 23 +
.../mcperks/data/FeatureStateTest.java | 235 ++++++
.../data/OrderedServerDataFileTest.java | 288 +++++++
.../data/ServerDataNativeMigrationTest.java | 378 +++++++++
.../mcperks/listeners/RefillPlanTest.java | 60 ++
.../listeners/UsageEffectListenerTest.java | 374 +++++++++
.../compatability/McMMOEventsTest.java | 68 ++
.../mcperks/perk/BankedClockTest.java | 62 ++
.../mcperks/perk/BankedPerksTest.java | 791 ++++++++++++++++++
.../mcperks/perk/ClearQueueCommandTest.java | 61 ++
.../mcperks/perk/PerkDecisionTest.java | 87 ++
.../mcperks/perk/PrecisionOptionsTest.java | 15 +
.../mcperks/perk/QueuePlannerTest.java | 635 ++++++++++++++
.../scheduling/BundledOwnerTaskHarness.java | 44 +
.../mcperks/scheduling/OwnerTaskHarness.java | 41 +
.../mcperks/scheduling/OwnerTasksTest.java | 89 ++
.../mcperks/userapi/UsageChargesFixture.java | 7 +
.../mcperks/userapi/UsageChargesTest.java | 197 +++++
.../userapi/UserStorageConfirmationTest.java | 49 ++
README.md | 256 +++++-
51 files changed, 6911 insertions(+), 268 deletions(-)
create mode 100644 MCPerks/src/main/java/com/bencodez/mcperks/commands/PerkDiagnostics.java
create mode 100644 MCPerks/src/main/java/com/bencodez/mcperks/data/DurableFeatureFiles.java
create mode 100644 MCPerks/src/main/java/com/bencodez/mcperks/data/FeatureFileOwnership.java
create mode 100644 MCPerks/src/main/java/com/bencodez/mcperks/data/FeatureState.java
create mode 100644 MCPerks/src/main/java/com/bencodez/mcperks/data/LocalStatistics.java
create mode 100644 MCPerks/src/main/java/com/bencodez/mcperks/data/OrderedServerDataFile.java
create mode 100644 MCPerks/src/main/java/com/bencodez/mcperks/listeners/EffectBatch.java
create mode 100644 MCPerks/src/main/java/com/bencodez/mcperks/listeners/RefillPlan.java
create mode 100644 MCPerks/src/main/java/com/bencodez/mcperks/listeners/UsageEffectListener.java
create mode 100644 MCPerks/src/main/java/com/bencodez/mcperks/perk/BankedClock.java
create mode 100644 MCPerks/src/main/java/com/bencodez/mcperks/perk/BankedPerks.java
create mode 100644 MCPerks/src/main/java/com/bencodez/mcperks/perk/InteractionOptions.java
create mode 100644 MCPerks/src/main/java/com/bencodez/mcperks/perk/QueuePlanner.java
create mode 100644 MCPerks/src/main/java/com/bencodez/mcperks/scheduling/OwnerTasks.java
create mode 100644 MCPerks/src/main/java/com/bencodez/mcperks/userapi/UsageCharges.java
create mode 100644 MCPerks/src/main/java/com/bencodez/mcperks/userapi/UserStorageConfirmation.java
create mode 100644 MCPerks/src/test/java/com/bencodez/mcperks/commands/PerkDiagnosticsTest.java
create mode 100644 MCPerks/src/test/java/com/bencodez/mcperks/data/FeatureFileOwnershipTest.java
create mode 100644 MCPerks/src/test/java/com/bencodez/mcperks/data/FeatureStateFixture.java
create mode 100644 MCPerks/src/test/java/com/bencodez/mcperks/data/FeatureStateTest.java
create mode 100644 MCPerks/src/test/java/com/bencodez/mcperks/data/OrderedServerDataFileTest.java
create mode 100644 MCPerks/src/test/java/com/bencodez/mcperks/data/ServerDataNativeMigrationTest.java
create mode 100644 MCPerks/src/test/java/com/bencodez/mcperks/listeners/RefillPlanTest.java
create mode 100644 MCPerks/src/test/java/com/bencodez/mcperks/listeners/UsageEffectListenerTest.java
create mode 100644 MCPerks/src/test/java/com/bencodez/mcperks/listeners/compatability/McMMOEventsTest.java
create mode 100644 MCPerks/src/test/java/com/bencodez/mcperks/perk/BankedClockTest.java
create mode 100644 MCPerks/src/test/java/com/bencodez/mcperks/perk/BankedPerksTest.java
create mode 100644 MCPerks/src/test/java/com/bencodez/mcperks/perk/ClearQueueCommandTest.java
create mode 100644 MCPerks/src/test/java/com/bencodez/mcperks/perk/PerkDecisionTest.java
create mode 100644 MCPerks/src/test/java/com/bencodez/mcperks/perk/PrecisionOptionsTest.java
create mode 100644 MCPerks/src/test/java/com/bencodez/mcperks/perk/QueuePlannerTest.java
create mode 100644 MCPerks/src/test/java/com/bencodez/mcperks/scheduling/BundledOwnerTaskHarness.java
create mode 100644 MCPerks/src/test/java/com/bencodez/mcperks/scheduling/OwnerTaskHarness.java
create mode 100644 MCPerks/src/test/java/com/bencodez/mcperks/scheduling/OwnerTasksTest.java
create mode 100644 MCPerks/src/test/java/com/bencodez/mcperks/userapi/UsageChargesFixture.java
create mode 100644 MCPerks/src/test/java/com/bencodez/mcperks/userapi/UsageChargesTest.java
create mode 100644 MCPerks/src/test/java/com/bencodez/mcperks/userapi/UserStorageConfirmationTest.java
diff --git a/AGENTS.md b/AGENTS.md
index ac6c3e5..d509d8f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -35,6 +35,27 @@ 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. Keep the ordered feature worker open through AdvancedCore final permission-data cleanup. Retire gameplay
+ admissions in onUnLoad, but seal/drain the file owner only after super.onDisable returns.
+
## 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 +74,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 +85,5 @@ 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.
diff --git a/MCPerks/pom.xml b/MCPerks/pom.xml
index a737bce..4cfe23f 100644
--- a/MCPerks/pom.xml
+++ b/MCPerks/pom.xml
@@ -260,6 +260,15 @@
+ org.mockitomockito-core5.14.2test
+
+
+ org.junit.jupiter
+ junit-jupiter
+ 5.10.3
+ test
+
+
com.bencodez
advancedcore
LATEST
@@ -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..fe8a7b1 100644
--- a/MCPerks/src/main/java/com/bencodez/mcperks/MCPerksMain.java
+++ b/MCPerks/src/main/java/com/bencodez/mcperks/MCPerksMain.java
@@ -60,6 +60,19 @@
* The Class Main.
*/
public class MCPerksMain extends AdvancedCorePlugin {
+ private volatile int 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 +150,64 @@ public void loadListeners() {
*
* @see org.bukkit.plugin.java.JavaPlugin#onDisable()
*/
+ @Override
+ public void onDisable() {
+ try { super.onDisable(); }
+ finally {
+ // AdvancedCore permission cleanup performs its final ServerData save after onUnLoad.
+ if (featureState != null) featureState.close();
+ }
+ }
+
@Override
public void onUnLoad() {
+ featureLifecycle++;
+ if (queuePlanner != null) queuePlanner.retire();
+ if (bankedPerks != null) bankedPerks.retire();
+ if (usageEffects != null) usageEffects.retireAndClose();
+ else if (usageCharges != null) usageCharges.close();
for (Perk perk : getPerkHandler().getActivePerks()) {
- getMcperksServerData().saveOfflinePerk(perk);
+ if (bankedPerks != null && bankedPerks.attached(perk)) continue;
+ getMcperksServerData().saveOfflinePerk(perk);
perk.deactivatePerk(perk.getActivater());
}
+ getMcperksServerData().saveData();
HandlerList.unregisterAll(plugin);
plugin = null;
}
@Override
public void onPostLoad() {
- loadListeners();
+ 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);
+ 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 +228,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 +490,97 @@ 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");
+ 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;
+ }
+ }
+ 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().activePerk(definition,
+ getMcperksUserManager().getMCPerksUser(UUID.fromString(uuid)),
+ (int) timeLast, timestamp);
+ }
+
+ }
+ }
+ }
+ }
+
+ getMcperksServerData().clearOfflinePerks();
+ getMcperksServerData().saveData();
+ }
+
+ private final java.util.concurrent.atomic.AtomicBoolean reloadPending = new java.util.concurrent.atomic.AtomicBoolean();
+ @Override
+ public void reload() {
+ if (!reloadPending.compareAndSet(false, true)) return;
+ 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()) {
+ reloadPending.set(false);
+ if (error != null) getLogger().warning("Reload postponed: pending ServerData publication could not drain");
+ if (queuePlanner != null) queuePlanner.resume();
+ return;
+ }
+ getBukkitScheduler().runTask(this, () -> {
+ try { reloadAfterDrain(); }
+ finally { reloadPending.set(false); if (queuePlanner != null) queuePlanner.resume(); }
+ });
+ });
+ }
+ private void reloadAfterDrain() {
+ 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 (bankedPerks != null) bankedPerks.reload();
commandLoader.loadTabComplete();
updateHook();
reloadAdvancedCore(true);
@@ -492,6 +590,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..73d55e1 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;
@@ -141,7 +142,152 @@ 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() {
plugin.setCommands(new ArrayList());
commands = new HashMap();
@@ -396,15 +542,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 +864,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..25d3128
--- /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(perk), 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" : !perk.isNotInDisabledWorld(player.getWorld().getName()) ? "disabled world"
+ : duplicate ? "already active; duplicate activations disabled"
+ : !bypass && data.cooldown() > System.currentTimeMillis() ? "cooldown " + (data.cooldown()-System.currentTimeMillis())/1000 + "s"
+ : perk.getLimit() > 0 && data.used() >= perk.getLimit() ? "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..19cb07c
--- /dev/null
+++ b/MCPerks/src/main/java/com/bencodez/mcperks/data/FeatureState.java
@@ -0,0 +1,244 @@
+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 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.WAITING, 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..ba65568
--- /dev/null
+++ b/MCPerks/src/main/java/com/bencodez/mcperks/data/OrderedServerDataFile.java
@@ -0,0 +1,73 @@
+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 void saveData() { runtime.saveData(); }
+ // 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..883abb9 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,505 @@
-/*
- *
- */
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());
- }
-
- /**
- * Reload data.
- */
- public void reloadData() {
- plugin.getServerDataFile().reloadData();
- }
-
- /**
- * Save data.
- */
- public void saveData() {
- plugin.getServerDataFile().saveData();
- }
-
- public void setPerkExperation(Perk perk, long time) {
- getData().set("Experation." + perk.getPerk(), time);
- saveData();
- }
-
- 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()));
- }
- }
+ 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) {
+ 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