From 2cf7d56c0a7e81deed274d05194e4ce92dbda1d9 Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Sun, 20 Sep 2026 11:47:10 -0600
Subject: [PATCH 1/8] Add durable prepared reward definition snapshots
---
.../api/rewards/PreparedRewardCatalog.java | 247 ++++++++++++++++++
.../api/rewards/PreparedRewardDefinition.java | 210 +++++++++++++++
.../PreparedRewardDefinitionException.java | 14 +
.../api/rewards/RewardHandler.java | 31 +++
.../rewards/PreparedRewardCatalogTest.java | 125 +++++++++
.../rewards/PreparedRewardDefinitionTest.java | 129 +++++++++
6 files changed, 756 insertions(+)
create mode 100644 AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/PreparedRewardCatalog.java
create mode 100644 AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/PreparedRewardDefinition.java
create mode 100644 AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/PreparedRewardDefinitionException.java
create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PreparedRewardCatalogTest.java
create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PreparedRewardDefinitionTest.java
diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/PreparedRewardCatalog.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/PreparedRewardCatalog.java
new file mode 100644
index 0000000000..842e817ebb
--- /dev/null
+++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/PreparedRewardCatalog.java
@@ -0,0 +1,247 @@
+package com.bencodez.advancedcore.api.rewards;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Locale;
+import java.util.Map;
+import java.util.TreeMap;
+
+/**
+ * Immutable, persistable lookup data for the named rewards available while a
+ * caller prepares one root reward plan.
+ *
+ *
The catalog preserves {@link RewardRegistry} lookup precedence: directly
+ * defined entries, then sub-direct entries, then ordinary reward files. It
+ * freezes only the definitions supplied at capture time; callers must capture
+ * a new catalog when they intentionally want a later registry state. The
+ * legacy reward dispatcher does not consume this catalog automatically: a
+ * caller must resolve nested names through {@link #instantiate(String)} while
+ * preparing its own execution plan.
+ */
+public final class PreparedRewardCatalog {
+
+ private static final String ENCODED_PREFIX = "AdvancedCorePreparedRewardCatalog/";
+ private static final int FORMAT_VERSION = 1;
+ private static final int MAX_DEFINITIONS = 1024;
+ private static final int MAX_CATALOG_BYTES = 4 * 1024 * 1024;
+ private static final int MAX_ENCODED_BYTES = 8 * 1024 * 1024;
+
+ private final PreparedRewardDefinition root;
+ private final Map directDefinitions;
+ private final Map fileDefinitions;
+ private final String records;
+ private final String versionHash;
+
+ private PreparedRewardCatalog(PreparedRewardDefinition root,
+ Map directDefinitions,
+ Map fileDefinitions,
+ String records, String versionHash) {
+ this.root = root;
+ this.directDefinitions = Map.copyOf(directDefinitions);
+ this.fileDefinitions = Map.copyOf(fileDefinitions);
+ this.records = records;
+ this.versionHash = versionHash;
+ }
+
+ /** Captures the root and every currently registered named definition. */
+ public static PreparedRewardCatalog capture(Reward root, Collection directlyDefined,
+ Collection subDirectlyDefined, Collection rewardFiles) {
+ PreparedRewardDefinition preparedRoot = PreparedRewardDefinition.capture(root);
+ TreeMap direct = new TreeMap<>();
+ TreeMap files = new TreeMap<>();
+ int count = 1;
+
+ for (DirectlyDefinedReward entry : directlyDefined) {
+ count = checkCount(count + 1);
+ String key = directKey(entry == null ? null : entry.getPath());
+ PreparedRewardDefinition definition = definitionFor(entry == null ? null : entry.getReward(), key);
+ direct.putIfAbsent(key, definition);
+ }
+ for (SubDirectlyDefinedReward entry : subDirectlyDefined) {
+ count = checkCount(count + 1);
+ String key = directKey(entry == null ? null : entry.getFullPath());
+ PreparedRewardDefinition definition = definitionFor(entry == null ? null : entry.getReward(), key);
+ direct.putIfAbsent(key, definition);
+ }
+ for (Reward entry : rewardFiles) {
+ count = checkCount(count + 1);
+ String key = fileKey(entry == null ? null : entry.getRewardName());
+ files.putIfAbsent(key, definitionFor(entry, key));
+ }
+
+ String records = encodeRecords(direct, files);
+ validateRecords(records);
+ String rootEncoded = preparedRoot.encode();
+ return new PreparedRewardCatalog(preparedRoot, direct, files, records, hash(rootEncoded, records));
+ }
+
+ /** Restores a catalog written by {@link #encode()}, rejecting changed data. */
+ public static PreparedRewardCatalog decode(String encoded) {
+ if (encoded == null || encoded.length() > MAX_ENCODED_BYTES || !encoded.startsWith(ENCODED_PREFIX)) {
+ throw new PreparedRewardDefinitionException("Malformed prepared reward catalog");
+ }
+ String[] fields = encoded.split("/", -1);
+ if (fields.length != 5 || !"AdvancedCorePreparedRewardCatalog".equals(fields[0])) {
+ throw new PreparedRewardDefinitionException("Malformed prepared reward catalog");
+ }
+ if (!String.valueOf(FORMAT_VERSION).equals(fields[1])) {
+ throw new PreparedRewardDefinitionException("Unsupported prepared reward catalog version: " + fields[1]);
+ }
+ String rootEncoded = decodeField(fields[2], "root");
+ String records = decodeField(fields[3], "definitions");
+ validateRecords(records);
+ String actualHash = hash(rootEncoded, records);
+ if (!MessageDigest.isEqual(fields[4].getBytes(StandardCharsets.US_ASCII),
+ actualHash.getBytes(StandardCharsets.US_ASCII))) {
+ throw new PreparedRewardDefinitionException("Prepared reward catalog hash does not match");
+ }
+
+ PreparedRewardDefinition root = PreparedRewardDefinition.decode(rootEncoded);
+ TreeMap direct = new TreeMap<>();
+ TreeMap files = new TreeMap<>();
+ if (!records.isEmpty()) {
+ for (String record : records.split("\n", -1)) {
+ String[] parts = record.split("\\|", -1);
+ if (parts.length != 3) throw new PreparedRewardDefinitionException("Malformed prepared reward catalog record");
+ String key = decodeField(parts[1], "lookup key");
+ PreparedRewardDefinition definition = PreparedRewardDefinition.decode(decodeField(parts[2], "definition"));
+ Map target;
+ if ("D".equals(parts[0])) {
+ if (!key.equals(directKey(key))) {
+ throw new PreparedRewardDefinitionException("Malformed direct prepared reward lookup key");
+ }
+ target = direct;
+ } else if ("F".equals(parts[0])) {
+ if (!key.equals(fileKey(key))) {
+ throw new PreparedRewardDefinitionException("Malformed file prepared reward lookup key");
+ }
+ target = files;
+ } else {
+ throw new PreparedRewardDefinitionException("Malformed prepared reward catalog record type");
+ }
+ if (target.putIfAbsent(key, definition) != null) {
+ throw new PreparedRewardDefinitionException("Duplicate prepared reward catalog lookup key");
+ }
+ checkCount(1 + direct.size() + files.size());
+ }
+ }
+ return new PreparedRewardCatalog(root, direct, files, records, actualHash);
+ }
+
+ /** Encodes this bounded catalog for durable storage. */
+ public String encode() {
+ String rootEncoded = root.encode();
+ return ENCODED_PREFIX + FORMAT_VERSION + "/" + encodeField(rootEncoded) + "/" + encodeField(records) + "/"
+ + versionHash;
+ }
+
+ /** Creates a fresh detached root reward for plan preparation. */
+ public Reward instantiateRoot() {
+ return root.instantiate();
+ }
+
+ /**
+ * Resolves a named entry using the captured registry precedence and returns
+ * a fresh detached reward. Unknown names fail closed.
+ */
+ public Reward instantiate(String rewardName) {
+ PreparedRewardDefinition definition = directDefinitions.get(directKeyForLookup(rewardName));
+ if (definition == null) definition = fileDefinitions.get(fileKeyForLookup(rewardName));
+ if (definition == null) {
+ throw new PreparedRewardDefinitionException("Prepared reward catalog has no definition for: " + rewardName);
+ }
+ return definition.instantiate();
+ }
+
+ public String getVersionHash() {
+ return versionHash;
+ }
+
+ public int getDefinitionCount() {
+ return 1 + directDefinitions.size() + fileDefinitions.size();
+ }
+
+ private static PreparedRewardDefinition definitionFor(Reward reward, String lookupKey) {
+ if (reward == null) {
+ throw new PreparedRewardDefinitionException("Could not resolve prepared reward for " + lookupKey);
+ }
+ return PreparedRewardDefinition.capture(reward);
+ }
+
+ private static int checkCount(int count) {
+ if (count > MAX_DEFINITIONS) {
+ throw new PreparedRewardDefinitionException("Prepared reward catalog exceeds " + MAX_DEFINITIONS + " definitions");
+ }
+ return count;
+ }
+
+ private static String encodeRecords(Map direct,
+ Map files) {
+ ArrayList records = new ArrayList<>(direct.size() + files.size());
+ for (Map.Entry entry : direct.entrySet()) {
+ records.add("D|" + encodeField(entry.getKey()) + "|" + encodeField(entry.getValue().encode()));
+ }
+ for (Map.Entry entry : files.entrySet()) {
+ records.add("F|" + encodeField(entry.getKey()) + "|" + encodeField(entry.getValue().encode()));
+ }
+ return String.join("\n", records);
+ }
+
+ private static String directKeyForLookup(String name) {
+ return directKey(RewardRegistry.normalizeLookupName(name));
+ }
+
+ private static String fileKeyForLookup(String name) {
+ return fileKey(RewardRegistry.normalizeLookupName(name));
+ }
+
+ private static String directKey(String name) {
+ validateKey(name);
+ return RewardRegistry.normalizeDirectPath(name);
+ }
+
+ private static String fileKey(String name) {
+ validateKey(name);
+ return RewardRegistry.normalizeLookupName(name).toLowerCase(Locale.ROOT);
+ }
+
+ private static void validateKey(String key) {
+ if (key == null || key.isEmpty() || key.getBytes(StandardCharsets.UTF_8).length > 256) {
+ throw new PreparedRewardDefinitionException("Invalid prepared reward catalog lookup key");
+ }
+ }
+
+ private static void validateRecords(String records) {
+ if (records == null || records.getBytes(StandardCharsets.UTF_8).length > MAX_CATALOG_BYTES) {
+ throw new PreparedRewardDefinitionException("Prepared reward catalog exceeds " + MAX_CATALOG_BYTES + " bytes");
+ }
+ }
+
+ private static String encodeField(String value) {
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(value.getBytes(StandardCharsets.UTF_8));
+ }
+
+ private static String decodeField(String value, String field) {
+ try {
+ return new String(Base64.getUrlDecoder().decode(value), StandardCharsets.UTF_8);
+ } catch (IllegalArgumentException failure) {
+ throw new PreparedRewardDefinitionException("Malformed prepared reward catalog " + field, failure);
+ }
+ }
+
+ private static String hash(String rootEncoded, String records) {
+ try {
+ byte[] digest = MessageDigest.getInstance("SHA-256")
+ .digest((FORMAT_VERSION + "\n" + rootEncoded + "\n" + records).getBytes(StandardCharsets.UTF_8));
+ StringBuilder result = new StringBuilder(digest.length * 2);
+ for (byte value : digest) result.append(String.format("%02x", value));
+ return result.toString();
+ } catch (NoSuchAlgorithmException failure) {
+ throw new IllegalStateException("SHA-256 is unavailable", failure);
+ }
+ }
+}
diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/PreparedRewardDefinition.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/PreparedRewardDefinition.java
new file mode 100644
index 0000000000..5935452c58
--- /dev/null
+++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/PreparedRewardDefinition.java
@@ -0,0 +1,210 @@
+package com.bencodez.advancedcore.api.rewards;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.bukkit.configuration.InvalidConfigurationException;
+import org.bukkit.configuration.ConfigurationSection;
+import org.bukkit.configuration.file.YamlConfiguration;
+import org.bukkit.configuration.serialization.ConfigurationSerializable;
+
+/**
+ * An immutable, persistable snapshot of one resolved reward definition.
+ *
+ * It deliberately snapshots one reward at a time. Nested references must be
+ * resolved explicitly while building a prepared plan. Passing an instantiated
+ * reward to the legacy dispatcher can still resolve nested names from the live
+ * registry and does not provide a frozen graph execution contract.
+ */
+public final class PreparedRewardDefinition {
+
+ private static final String ENCODED_PREFIX = "AdvancedCorePreparedReward/";
+ private static final int FORMAT_VERSION = 1;
+ private static final int MAX_REWARD_NAME_BYTES = 256;
+ private static final int MAX_YAML_BYTES = 1024 * 1024;
+ private static final int MAX_ENCODED_BYTES = 1400000;
+
+ private final String rewardName;
+ private final String yamlPayload;
+ private final String versionHash;
+
+ private PreparedRewardDefinition(String rewardName, String yamlPayload, String versionHash) {
+ this.rewardName = rewardName;
+ this.yamlPayload = yamlPayload;
+ this.versionHash = versionHash;
+ }
+
+ /** Captures the current resolved reward configuration into a detached YAML payload. */
+ public static PreparedRewardDefinition capture(Reward reward) {
+ if (reward == null) throw new IllegalArgumentException("Reward to prepare must not be null");
+ return capture(reward.getRewardName(), reward.getConfig().getConfigData());
+ }
+
+ /** Captures a named, inline reward definition into a detached YAML payload. */
+ public static PreparedRewardDefinition capture(String rewardName, ConfigurationSection section) {
+ validateRewardName(rewardName);
+ if (section == null) throw new IllegalArgumentException("Prepared reward configuration must not be null");
+
+ try {
+ YamlConfiguration detached = new YamlConfiguration();
+ copySection(section, detached);
+ String payload = detached.saveToString();
+ validatePayloadSize(payload);
+ // Validate now, while capture still has not admitted a definition for execution.
+ parsePayload(payload);
+ return new PreparedRewardDefinition(rewardName, payload, hash(rewardName, payload));
+ } catch (PreparedRewardDefinitionException failure) {
+ throw failure;
+ } catch (RuntimeException failure) {
+ throw new PreparedRewardDefinitionException("Could not snapshot reward " + rewardName, failure);
+ }
+ }
+
+ /** Decodes a value produced by {@link #encode()}, rejecting malformed or changed payloads. */
+ public static PreparedRewardDefinition decode(String encoded) {
+ if (encoded == null || encoded.length() > MAX_ENCODED_BYTES || !encoded.startsWith(ENCODED_PREFIX)) {
+ throw new PreparedRewardDefinitionException("Malformed prepared reward payload");
+ }
+ String[] fields = encoded.split("/", -1);
+ if (fields.length != 5 || !"AdvancedCorePreparedReward".equals(fields[0])) {
+ throw new PreparedRewardDefinitionException("Malformed prepared reward payload");
+ }
+ int version;
+ try {
+ version = Integer.parseInt(fields[1]);
+ } catch (NumberFormatException failure) {
+ throw new PreparedRewardDefinitionException("Malformed prepared reward version", failure);
+ }
+ if (version != FORMAT_VERSION) {
+ throw new PreparedRewardDefinitionException("Unsupported prepared reward version: " + version);
+ }
+ String rewardName = decodeField(fields[2], "name");
+ String payload = decodeField(fields[3], "configuration");
+ validateRewardName(rewardName);
+ validatePayloadSize(payload);
+ String expectedHash = fields[4];
+ String actualHash = hash(rewardName, payload);
+ if (!MessageDigest.isEqual(expectedHash.getBytes(StandardCharsets.US_ASCII),
+ actualHash.getBytes(StandardCharsets.US_ASCII))) {
+ throw new PreparedRewardDefinitionException("Prepared reward payload hash does not match");
+ }
+ parsePayload(payload);
+ return new PreparedRewardDefinition(rewardName, payload, actualHash);
+ }
+
+ /** Encodes this immutable snapshot for durable storage. */
+ public String encode() {
+ return ENCODED_PREFIX + FORMAT_VERSION + "/" + encodeField(rewardName) + "/" + encodeField(yamlPayload)
+ + "/" + versionHash;
+ }
+
+ /**
+ * Creates a detached reward for plan preparation. It cannot create a legacy
+ * generated reward file: the caller must persist this definition with its
+ * own durable plan instead of queueing a name that may change on reload.
+ */
+ public Reward instantiate() {
+ return new Reward(rewardName, parsePayload(yamlPayload)).needsRewardFile(false);
+ }
+
+ public String getRewardName() {
+ return rewardName;
+ }
+
+ public String getVersionHash() {
+ return versionHash;
+ }
+
+ private static void copySection(ConfigurationSection source, ConfigurationSection target) {
+ for (String key : source.getKeys(false)) {
+ Object value = source.get(key);
+ if (value instanceof ConfigurationSection child) {
+ ConfigurationSection targetChild = target.createSection(key);
+ copySection(child, targetChild);
+ } else {
+ target.set(key, copyValue(value, key));
+ }
+ }
+ }
+
+ private static Object copyValue(Object value, String path) {
+ if (value == null || value instanceof String || value instanceof Number || value instanceof Boolean
+ || value instanceof Character || value instanceof ConfigurationSerializable) {
+ return value;
+ }
+ if (value instanceof List> list) {
+ List copy = new ArrayList<>(list.size());
+ for (Object entry : list) copy.add(copyValue(entry, path));
+ return copy;
+ }
+ if (value instanceof Map, ?> map) {
+ Map copy = new LinkedHashMap<>();
+ for (Map.Entry, ?> entry : map.entrySet()) {
+ if (!(entry.getKey() instanceof String key)) {
+ throw new PreparedRewardDefinitionException(
+ "Unsupported non-string map key in prepared reward at " + path);
+ }
+ copy.put(key, copyValue(entry.getValue(), path + "." + key));
+ }
+ return copy;
+ }
+ throw new PreparedRewardDefinitionException(
+ "Unsupported configuration value in prepared reward at " + path + ": " + value.getClass().getName());
+ }
+
+ private static YamlConfiguration parsePayload(String payload) {
+ try {
+ validatePayloadSize(payload);
+ YamlConfiguration parsed = new YamlConfiguration();
+ parsed.loadFromString(payload);
+ return parsed;
+ } catch (InvalidConfigurationException | RuntimeException failure) {
+ throw new PreparedRewardDefinitionException("Malformed prepared reward configuration", failure);
+ }
+ }
+
+ private static String encodeField(String value) {
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(value.getBytes(StandardCharsets.UTF_8));
+ }
+
+ private static String decodeField(String value, String field) {
+ try {
+ return new String(Base64.getUrlDecoder().decode(value), StandardCharsets.UTF_8);
+ } catch (IllegalArgumentException failure) {
+ throw new PreparedRewardDefinitionException("Malformed prepared reward " + field, failure);
+ }
+ }
+
+ private static String hash(String rewardName, String payload) {
+ try {
+ byte[] digest = MessageDigest.getInstance("SHA-256")
+ .digest((FORMAT_VERSION + "\n" + rewardName + "\n" + payload).getBytes(StandardCharsets.UTF_8));
+ StringBuilder result = new StringBuilder(digest.length * 2);
+ for (byte value : digest) result.append(String.format("%02x", value));
+ return result.toString();
+ } catch (NoSuchAlgorithmException failure) {
+ throw new IllegalStateException("SHA-256 is unavailable", failure);
+ }
+ }
+
+ private static void validateRewardName(String rewardName) {
+ if (rewardName == null || rewardName.isEmpty()) {
+ throw new PreparedRewardDefinitionException("Prepared reward name must not be empty");
+ }
+ if (rewardName.getBytes(StandardCharsets.UTF_8).length > MAX_REWARD_NAME_BYTES) {
+ throw new PreparedRewardDefinitionException("Prepared reward name exceeds " + MAX_REWARD_NAME_BYTES + " bytes");
+ }
+ }
+
+ private static void validatePayloadSize(String payload) {
+ if (payload == null || payload.getBytes(StandardCharsets.UTF_8).length > MAX_YAML_BYTES) {
+ throw new PreparedRewardDefinitionException("Prepared reward YAML exceeds " + MAX_YAML_BYTES + " bytes");
+ }
+ }
+}
diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/PreparedRewardDefinitionException.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/PreparedRewardDefinitionException.java
new file mode 100644
index 0000000000..5ca231dabe
--- /dev/null
+++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/PreparedRewardDefinitionException.java
@@ -0,0 +1,14 @@
+package com.bencodez.advancedcore.api.rewards;
+
+/** Raised when a prepared reward definition cannot be safely captured or restored. */
+public final class PreparedRewardDefinitionException extends IllegalArgumentException {
+ private static final long serialVersionUID = 1L;
+
+ public PreparedRewardDefinitionException(String message) {
+ super(message);
+ }
+
+ public PreparedRewardDefinitionException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardHandler.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardHandler.java
index 4312db52ea..0fdb428e28 100644
--- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardHandler.java
+++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardHandler.java
@@ -137,6 +137,37 @@ public Reward getReward(ConfigurationSection data, String path, RewardOptions re
return rewardExecutor.getReward(data, path, rewardOptions);
}
+ /**
+ * Resolves and snapshots one named reward. The returned definition freezes
+ * only this reward; nested definitions are resolved individually by their
+ * owning injectors.
+ */
+ public PreparedRewardDefinition prepareReward(String reward) {
+ if (!rewardRegistry.rewardExist(reward) && !rewardRegistry.hasDirectRewardHandle(reward)) {
+ throw new IllegalArgumentException("Resolved reward does not exist: " + reward);
+ }
+ return prepareReward(getReward(reward));
+ }
+
+ /** Resolves and snapshots one inline configuration-section reward. */
+ public PreparedRewardDefinition prepareReward(ConfigurationSection data, String path, RewardOptions rewardOptions) {
+ return prepareReward(getReward(data, path, rewardOptions));
+ }
+
+ /** Snapshots an already resolved reward definition. */
+ public PreparedRewardDefinition prepareReward(Reward reward) {
+ return PreparedRewardDefinition.capture(reward);
+ }
+
+ /**
+ * Captures a bounded catalog for a resolved root and all current named
+ * registry entries. Dispatch remains unchanged; callers opt in explicitly.
+ */
+ public PreparedRewardCatalog prepareCatalog(Reward root) {
+ return PreparedRewardCatalog.capture(root, rewardRegistry.getDirectlyDefinedRewards(),
+ rewardRegistry.getSubDirectlyDefinedRewards(), rewardRegistry.getRewards());
+ }
+
public Reward getReward(String reward) {
return rewardRegistry.getReward(reward);
}
diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PreparedRewardCatalogTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PreparedRewardCatalogTest.java
new file mode 100644
index 0000000000..feed4a5513
--- /dev/null
+++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PreparedRewardCatalogTest.java
@@ -0,0 +1,125 @@
+package com.bencodez.advancedcore.tests.api.rewards;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.List;
+import java.util.logging.Logger;
+
+import org.bukkit.configuration.file.YamlConfiguration;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import com.bencodez.advancedcore.AdvancedCorePlugin;
+import com.bencodez.advancedcore.api.rewards.DirectlyDefinedReward;
+import com.bencodez.advancedcore.api.rewards.PreparedRewardCatalog;
+import com.bencodez.advancedcore.api.rewards.PreparedRewardDefinitionException;
+import com.bencodez.advancedcore.api.rewards.Reward;
+import com.bencodez.advancedcore.api.rewards.RewardHandler;
+import com.bencodez.advancedcore.api.rewards.SubDirectlyDefinedReward;
+
+class PreparedRewardCatalogTest {
+
+ private AdvancedCorePlugin plugin;
+ private RewardHandler handler;
+
+ @BeforeEach
+ void setUp() {
+ plugin = mock(AdvancedCorePlugin.class);
+ when(plugin.getLogger()).thenReturn(mock(Logger.class));
+ AdvancedCorePlugin.setInstance(plugin);
+ handler = new RewardHandler(plugin);
+ when(plugin.getRewardHandler()).thenReturn(handler);
+ }
+
+ @AfterEach
+ void tearDown() {
+ AdvancedCorePlugin.setInstance(null);
+ }
+
+ @Test
+ void catalogCapturesNamedDirectSubAndFileDefinitionsBeforeTheLiveRegistryChanges() {
+ YamlConfiguration rootData = rewardData("root before");
+ YamlConfiguration directData = rewardData("direct before");
+ YamlConfiguration subData = rewardData("sub before");
+ YamlConfiguration fileData = rewardData("file before");
+ YamlConfiguration collisionFileData = rewardData("file collision");
+
+ DirectlyDefinedReward direct = mock(DirectlyDefinedReward.class);
+ when(direct.getPath()).thenReturn("Direct.Child");
+ when(direct.getReward()).thenReturn(new Reward("Direct_Child", directData));
+ handler.addDirectlyDefined(direct);
+
+ SubDirectlyDefinedReward sub = mock(SubDirectlyDefinedReward.class);
+ when(sub.getFullPath()).thenReturn("Sub.Child");
+ when(sub.getReward()).thenReturn(new Reward("Sub_Child", subData));
+ handler.addSubDirectlyDefined(sub);
+
+ handler.getRewards().add(new Reward("File_Child", fileData));
+ handler.getRewards().add(new Reward("Direct_Child", collisionFileData));
+
+ PreparedRewardCatalog catalog = handler.prepareCatalog(new Reward("Root", rootData));
+ rootData.set("Messages", List.of("root after"));
+ directData.set("Messages", List.of("direct after"));
+ subData.set("Messages", List.of("sub after"));
+ fileData.set("Messages", List.of("file after"));
+
+ assertEquals(5, catalog.getDefinitionCount());
+ assertMessage("root before", catalog.instantiateRoot());
+ assertMessage("direct before", catalog.instantiate("Direct Child"));
+ assertMessage("sub before", catalog.instantiate("Sub Child"));
+ assertMessage("file before", catalog.instantiate("File Child"));
+ assertMessage("direct before", catalog.instantiate("Direct_Child"));
+ Reward firstFileLookup = catalog.instantiate("File Child");
+ firstFileLookup.getConfig().getConfigData().set("Messages", List.of("execution mutation"));
+ assertMessage("file before", catalog.instantiate("File Child"));
+ }
+
+ @Test
+ void catalogRoundTripRetainsItsHashAndRejectsUnknownChangedAndOversizeInputs() {
+ YamlConfiguration child = rewardData("before");
+ handler.getRewards().add(new Reward("Child", child));
+ PreparedRewardCatalog captured = handler.prepareCatalog(new Reward("Root", rewardData("root")));
+ PreparedRewardCatalog restored = PreparedRewardCatalog.decode(captured.encode());
+
+ assertEquals(captured.encode(), restored.encode());
+ assertEquals(captured.getVersionHash(), restored.getVersionHash());
+ assertMessage("before", restored.instantiate("Child"));
+ assertThrows(PreparedRewardDefinitionException.class, () -> restored.instantiate("missing"));
+
+ String encoded = captured.encode();
+ String lastCharacter = encoded.substring(encoded.length() - 1);
+ String changed = encoded.substring(0, encoded.length() - 1) + ("0".equals(lastCharacter) ? "1" : "0");
+ assertThrows(PreparedRewardDefinitionException.class, () -> PreparedRewardCatalog.decode(changed));
+ assertThrows(PreparedRewardDefinitionException.class,
+ () -> PreparedRewardCatalog.decode("AdvancedCorePreparedRewardCatalog/" + "x".repeat(8 * 1024 * 1024)));
+ }
+
+ @Test
+ void nestedNameCanBeResolvedFromFrozenCatalogDuringPlanPreparation() {
+ YamlConfiguration root = rewardData("root");
+ root.set("Rewards", List.of("Child"));
+ YamlConfiguration oldChild = rewardData("before reload");
+ handler.getRewards().add(new Reward("Child", oldChild));
+ PreparedRewardCatalog prepared = PreparedRewardCatalog.decode(
+ handler.prepareCatalog(new Reward("Root", root)).encode());
+
+ oldChild.set("Messages", List.of("after reload"));
+ List nestedNames = prepared.instantiateRoot().getConfig().getConfigData().getStringList("Rewards");
+ assertEquals(List.of("Child"), nestedNames);
+ assertMessage("before reload", prepared.instantiate(nestedNames.get(0)));
+ }
+
+ private static YamlConfiguration rewardData(String message) {
+ YamlConfiguration data = new YamlConfiguration();
+ data.set("Messages", List.of(message));
+ return data;
+ }
+
+ private static void assertMessage(String expected, Reward reward) {
+ assertEquals(List.of(expected), reward.getConfig().getConfigData().getStringList("Messages"));
+ }
+}
diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PreparedRewardDefinitionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PreparedRewardDefinitionTest.java
new file mode 100644
index 0000000000..005d7a196f
--- /dev/null
+++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PreparedRewardDefinitionTest.java
@@ -0,0 +1,129 @@
+package com.bencodez.advancedcore.tests.api.rewards;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.util.List;
+import java.util.logging.Logger;
+
+import org.bukkit.configuration.ConfigurationSection;
+import org.bukkit.configuration.file.YamlConfiguration;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import com.bencodez.advancedcore.AdvancedCorePlugin;
+import com.bencodez.advancedcore.api.rewards.PreparedRewardDefinition;
+import com.bencodez.advancedcore.api.rewards.PreparedRewardDefinitionException;
+import com.bencodez.advancedcore.api.rewards.Reward;
+import com.bencodez.advancedcore.api.rewards.RewardHandler;
+import com.bencodez.advancedcore.api.rewards.RewardOptions;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class PreparedRewardDefinitionTest {
+
+ private AdvancedCorePlugin plugin;
+ private RewardHandler handler;
+
+ @BeforeEach
+ void setUp() {
+ plugin = mock(AdvancedCorePlugin.class);
+ when(plugin.getLogger()).thenReturn(mock(Logger.class));
+ AdvancedCorePlugin.setInstance(plugin);
+ handler = new RewardHandler(plugin);
+ when(plugin.getRewardHandler()).thenReturn(handler);
+ }
+
+ @AfterEach
+ void tearDown() {
+ AdvancedCorePlugin.setInstance(null);
+ }
+
+ @Test
+ void preparedInlineRewardSurvivesSourceMutationAndCreatesFreshConfigurations() {
+ YamlConfiguration source = new YamlConfiguration();
+ ConfigurationSection inline = source.createSection("Inline");
+ inline.set("Commands", List.of("say before"));
+ inline.createSection("Nested").set("Message", "before");
+
+ PreparedRewardDefinition prepared = handler.prepareReward(source, "Inline",
+ new RewardOptions().setPrefix("Parent"));
+ source.set("Inline.Commands", List.of("say after"));
+ source.set("Inline.Nested.Message", "after");
+
+ Reward first = prepared.instantiate();
+ first.getConfig().getConfigData().set("Commands", List.of("execution mutation"));
+ Reward second = prepared.instantiate();
+
+ assertEquals("Parent_Inline", prepared.getRewardName());
+ assertEquals(List.of("say before"), second.getConfig().getConfigData().getStringList("Commands"));
+ assertEquals("before", second.getConfig().getConfigData().getString("Nested.Message"));
+ }
+
+ @Test
+ void encodedDefinitionRoundTripsWithAStableHash() {
+ YamlConfiguration source = new YamlConfiguration();
+ source.set("Commands.Console", List.of("say one", "say two"));
+ source.set("Chance", 12.5D);
+
+ PreparedRewardDefinition captured = PreparedRewardDefinition.capture("Named", source);
+ PreparedRewardDefinition restored = PreparedRewardDefinition.decode(captured.encode());
+
+ assertEquals(captured.encode(), restored.encode());
+ assertEquals(captured.getVersionHash(), restored.getVersionHash());
+ assertEquals(List.of("say one", "say two"),
+ restored.instantiate().getConfig().getConfigData().getStringList("Commands.Console"));
+ }
+
+ @Test
+ void changedOrUnsupportedDefinitionsFailClosed() {
+ YamlConfiguration source = new YamlConfiguration();
+ source.set("Commands", List.of("say one"));
+ String encoded = PreparedRewardDefinition.capture("Named", source).encode();
+ String lastCharacter = encoded.substring(encoded.length() - 1);
+ String changed = encoded.substring(0, encoded.length() - 1) + ("0".equals(lastCharacter) ? "1" : "0");
+
+ assertThrows(PreparedRewardDefinitionException.class, () -> PreparedRewardDefinition.decode(changed));
+
+ source.set("Unsupported", new Object());
+ assertThrows(PreparedRewardDefinitionException.class,
+ () -> PreparedRewardDefinition.capture("Unsupported", source));
+ }
+
+ @Test
+ void namedPreparationCapturesTheResolvedRewardRatherThanTheLiveRegistryEntry() {
+ YamlConfiguration source = new YamlConfiguration();
+ source.set("Messages", List.of("before reload"));
+ Reward registered = new Reward("Named_Reward", source);
+ handler.getRewards().add(registered);
+
+ PreparedRewardDefinition prepared = handler.prepareReward("Named Reward");
+ source.set("Messages", List.of("after reload"));
+
+ assertEquals("Named_Reward", prepared.getRewardName());
+ assertEquals(List.of("before reload"),
+ prepared.instantiate().getConfig().getConfigData().getStringList("Messages"));
+ assertNotEquals(PreparedRewardDefinition.capture("Named_Reward", source).getVersionHash(),
+ prepared.getVersionHash());
+ }
+
+ @Test
+ void namedPreparationRejectsARewardThatWasNotResolvedByTheRegistry() {
+ assertThrows(IllegalArgumentException.class, () -> handler.prepareReward("missing"));
+ }
+
+ @Test
+ void detachedDirectDefinitionDoesNotCreateALegacyGeneratedRewardFile() {
+ YamlConfiguration source = new YamlConfiguration();
+ source.set("Delay.Seconds", 5);
+ Reward direct = new Reward("Direct", source).needsRewardFile(false);
+ Reward prepared = PreparedRewardDefinition.decode(PreparedRewardDefinition.capture(direct).encode())
+ .instantiate();
+
+ assertDoesNotThrow(prepared::checkRewardFile);
+ }
+}
From d6835b5d39d9dff8713300c131ebd69f62ec85e0 Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Sun, 20 Sep 2026 11:59:11 -0600
Subject: [PATCH 2/8] Construct reward fixtures before Mockito stubbing
---
.../tests/api/rewards/PreparedRewardCatalogTest.java | 9 ++++++---
.../tests/api/rewards/PreparedRewardDefinitionTest.java | 3 ++-
2 files changed, 8 insertions(+), 4 deletions(-)
diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PreparedRewardCatalogTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PreparedRewardCatalogTest.java
index feed4a5513..6e3e5f7aed 100644
--- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PreparedRewardCatalogTest.java
+++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PreparedRewardCatalogTest.java
@@ -29,7 +29,8 @@ class PreparedRewardCatalogTest {
@BeforeEach
void setUp() {
plugin = mock(AdvancedCorePlugin.class);
- when(plugin.getLogger()).thenReturn(mock(Logger.class));
+ Logger logger = mock(Logger.class);
+ when(plugin.getLogger()).thenReturn(logger);
AdvancedCorePlugin.setInstance(plugin);
handler = new RewardHandler(plugin);
when(plugin.getRewardHandler()).thenReturn(handler);
@@ -50,12 +51,14 @@ void catalogCapturesNamedDirectSubAndFileDefinitionsBeforeTheLiveRegistryChanges
DirectlyDefinedReward direct = mock(DirectlyDefinedReward.class);
when(direct.getPath()).thenReturn("Direct.Child");
- when(direct.getReward()).thenReturn(new Reward("Direct_Child", directData));
+ Reward directReward = new Reward("Direct_Child", directData);
+ when(direct.getReward()).thenReturn(directReward);
handler.addDirectlyDefined(direct);
SubDirectlyDefinedReward sub = mock(SubDirectlyDefinedReward.class);
when(sub.getFullPath()).thenReturn("Sub.Child");
- when(sub.getReward()).thenReturn(new Reward("Sub_Child", subData));
+ Reward subReward = new Reward("Sub_Child", subData);
+ when(sub.getReward()).thenReturn(subReward);
handler.addSubDirectlyDefined(sub);
handler.getRewards().add(new Reward("File_Child", fileData));
diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PreparedRewardDefinitionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PreparedRewardDefinitionTest.java
index 005d7a196f..75de5f498e 100644
--- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PreparedRewardDefinitionTest.java
+++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PreparedRewardDefinitionTest.java
@@ -32,7 +32,8 @@ class PreparedRewardDefinitionTest {
@BeforeEach
void setUp() {
plugin = mock(AdvancedCorePlugin.class);
- when(plugin.getLogger()).thenReturn(mock(Logger.class));
+ Logger logger = mock(Logger.class);
+ when(plugin.getLogger()).thenReturn(logger);
AdvancedCorePlugin.setInstance(plugin);
handler = new RewardHandler(plugin);
when(plugin.getRewardHandler()).thenReturn(handler);
From 28cede394986a4a9a4d6b8ffd0a9ab831056affb Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Sun, 20 Sep 2026 12:03:50 -0600
Subject: [PATCH 3/8] Preserve optional and Unicode reward lookup semantics
---
.../api/rewards/PreparedRewardCatalog.java | 63 +++++++++-----
.../api/rewards/PreparedRewardDefinition.java | 5 +-
.../api/rewards/RewardHandler.java | 9 +-
.../rewards/PreparedRewardCatalogTest.java | 85 +++++++++++++++++++
.../rewards/PreparedRewardDefinitionTest.java | 20 +++++
5 files changed, 160 insertions(+), 22 deletions(-)
diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/PreparedRewardCatalog.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/PreparedRewardCatalog.java
index 842e817ebb..1827bb3db3 100644
--- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/PreparedRewardCatalog.java
+++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/PreparedRewardCatalog.java
@@ -6,7 +6,8 @@
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collection;
-import java.util.Locale;
+import java.util.Collections;
+import java.util.LinkedHashMap;
import java.util.Map;
import java.util.TreeMap;
@@ -25,7 +26,7 @@
public final class PreparedRewardCatalog {
private static final String ENCODED_PREFIX = "AdvancedCorePreparedRewardCatalog/";
- private static final int FORMAT_VERSION = 1;
+ private static final int FORMAT_VERSION = 2;
private static final int MAX_DEFINITIONS = 1024;
private static final int MAX_CATALOG_BYTES = 4 * 1024 * 1024;
private static final int MAX_ENCODED_BYTES = 8 * 1024 * 1024;
@@ -41,8 +42,8 @@ private PreparedRewardCatalog(PreparedRewardDefinition root,
Map fileDefinitions,
String records, String versionHash) {
this.root = root;
- this.directDefinitions = Map.copyOf(directDefinitions);
- this.fileDefinitions = Map.copyOf(fileDefinitions);
+ this.directDefinitions = Collections.unmodifiableMap(new TreeMap<>(directDefinitions));
+ this.fileDefinitions = Collections.unmodifiableMap(new LinkedHashMap<>(fileDefinitions));
this.records = records;
this.versionHash = versionHash;
}
@@ -52,20 +53,26 @@ public static PreparedRewardCatalog capture(Reward root, Collection subDirectlyDefined, Collection rewardFiles) {
PreparedRewardDefinition preparedRoot = PreparedRewardDefinition.capture(root);
TreeMap direct = new TreeMap<>();
- TreeMap files = new TreeMap<>();
+ LinkedHashMap files = new LinkedHashMap<>();
int count = 1;
for (DirectlyDefinedReward entry : directlyDefined) {
+ if (entry == null) continue;
count = checkCount(count + 1);
- String key = directKey(entry == null ? null : entry.getPath());
- PreparedRewardDefinition definition = definitionFor(entry == null ? null : entry.getReward(), key);
- direct.putIfAbsent(key, definition);
+ String key = directKey(entry.getPath());
+ if (!direct.containsKey(key)) {
+ Reward reward = entry.getReward();
+ direct.put(key, reward == null ? null : definitionFor(reward, key));
+ }
}
for (SubDirectlyDefinedReward entry : subDirectlyDefined) {
+ if (entry == null) continue;
count = checkCount(count + 1);
- String key = directKey(entry == null ? null : entry.getFullPath());
- PreparedRewardDefinition definition = definitionFor(entry == null ? null : entry.getReward(), key);
- direct.putIfAbsent(key, definition);
+ String key = directKey(entry.getFullPath());
+ if (!direct.containsKey(key)) {
+ Reward reward = entry.getReward();
+ direct.put(key, reward == null ? null : definitionFor(reward, key));
+ }
}
for (Reward entry : rewardFiles) {
count = checkCount(count + 1);
@@ -102,30 +109,34 @@ public static PreparedRewardCatalog decode(String encoded) {
PreparedRewardDefinition root = PreparedRewardDefinition.decode(rootEncoded);
TreeMap direct = new TreeMap<>();
- TreeMap files = new TreeMap<>();
+ LinkedHashMap files = new LinkedHashMap<>();
if (!records.isEmpty()) {
for (String record : records.split("\n", -1)) {
String[] parts = record.split("\\|", -1);
if (parts.length != 3) throw new PreparedRewardDefinitionException("Malformed prepared reward catalog record");
String key = decodeField(parts[1], "lookup key");
- PreparedRewardDefinition definition = PreparedRewardDefinition.decode(decodeField(parts[2], "definition"));
Map target;
+ PreparedRewardDefinition definition;
if ("D".equals(parts[0])) {
if (!key.equals(directKey(key))) {
throw new PreparedRewardDefinitionException("Malformed direct prepared reward lookup key");
}
target = direct;
+ definition = parts[2].isEmpty() ? null
+ : PreparedRewardDefinition.decode(decodeField(parts[2], "definition"));
} else if ("F".equals(parts[0])) {
if (!key.equals(fileKey(key))) {
throw new PreparedRewardDefinitionException("Malformed file prepared reward lookup key");
}
target = files;
+ definition = PreparedRewardDefinition.decode(decodeField(parts[2], "definition"));
} else {
throw new PreparedRewardDefinitionException("Malformed prepared reward catalog record type");
}
- if (target.putIfAbsent(key, definition) != null) {
+ if (target.containsKey(key)) {
throw new PreparedRewardDefinitionException("Duplicate prepared reward catalog lookup key");
}
+ target.put(key, definition);
checkCount(1 + direct.size() + files.size());
}
}
@@ -149,8 +160,20 @@ public Reward instantiateRoot() {
* a fresh detached reward. Unknown names fail closed.
*/
public Reward instantiate(String rewardName) {
- PreparedRewardDefinition definition = directDefinitions.get(directKeyForLookup(rewardName));
- if (definition == null) definition = fileDefinitions.get(fileKeyForLookup(rewardName));
+ String directKey = directKeyForLookup(rewardName);
+ if (directDefinitions.containsKey(directKey) && directDefinitions.get(directKey) == null) {
+ throw new PreparedRewardDefinitionException("Prepared reward catalog has no definition for: " + rewardName);
+ }
+ PreparedRewardDefinition definition = directDefinitions.get(directKey);
+ if (definition == null) {
+ String lookup = fileKeyForLookup(rewardName);
+ for (Map.Entry entry : fileDefinitions.entrySet()) {
+ if (entry.getKey().equalsIgnoreCase(lookup)) {
+ definition = entry.getValue();
+ break;
+ }
+ }
+ }
if (definition == null) {
throw new PreparedRewardDefinitionException("Prepared reward catalog has no definition for: " + rewardName);
}
@@ -162,7 +185,8 @@ public String getVersionHash() {
}
public int getDefinitionCount() {
- return 1 + directDefinitions.size() + fileDefinitions.size();
+ return 1 + (int) directDefinitions.values().stream().filter(java.util.Objects::nonNull).count()
+ + fileDefinitions.size();
}
private static PreparedRewardDefinition definitionFor(Reward reward, String lookupKey) {
@@ -183,7 +207,8 @@ private static String encodeRecords(Map direct
Map files) {
ArrayList records = new ArrayList<>(direct.size() + files.size());
for (Map.Entry entry : direct.entrySet()) {
- records.add("D|" + encodeField(entry.getKey()) + "|" + encodeField(entry.getValue().encode()));
+ records.add("D|" + encodeField(entry.getKey()) + "|"
+ + (entry.getValue() == null ? "" : encodeField(entry.getValue().encode())));
}
for (Map.Entry entry : files.entrySet()) {
records.add("F|" + encodeField(entry.getKey()) + "|" + encodeField(entry.getValue().encode()));
@@ -206,7 +231,7 @@ private static String directKey(String name) {
private static String fileKey(String name) {
validateKey(name);
- return RewardRegistry.normalizeLookupName(name).toLowerCase(Locale.ROOT);
+ return RewardRegistry.normalizeLookupName(name);
}
private static void validateKey(String key) {
diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/PreparedRewardDefinition.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/PreparedRewardDefinition.java
index 5935452c58..4737e279d8 100644
--- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/PreparedRewardDefinition.java
+++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/PreparedRewardDefinition.java
@@ -25,7 +25,7 @@
public final class PreparedRewardDefinition {
private static final String ENCODED_PREFIX = "AdvancedCorePreparedReward/";
- private static final int FORMAT_VERSION = 1;
+ private static final int FORMAT_VERSION = 2;
private static final int MAX_REWARD_NAME_BYTES = 256;
private static final int MAX_YAML_BYTES = 1024 * 1024;
private static final int MAX_ENCODED_BYTES = 1400000;
@@ -184,7 +184,8 @@ private static String decodeField(String value, String field) {
private static String hash(String rewardName, String payload) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256")
- .digest((FORMAT_VERSION + "\n" + rewardName + "\n" + payload).getBytes(StandardCharsets.UTF_8));
+ .digest((FORMAT_VERSION + "\n" + encodeField(rewardName) + "\n" + encodeField(payload))
+ .getBytes(StandardCharsets.US_ASCII));
StringBuilder result = new StringBuilder(digest.length * 2);
for (byte value : digest) result.append(String.format("%02x", value));
return result.toString();
diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardHandler.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardHandler.java
index 0fdb428e28..e9bb36f9d8 100644
--- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardHandler.java
+++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardHandler.java
@@ -164,8 +164,15 @@ public PreparedRewardDefinition prepareReward(Reward reward) {
* registry entries. Dispatch remains unchanged; callers opt in explicitly.
*/
public PreparedRewardCatalog prepareCatalog(Reward root) {
+ List rewardFiles = rewardRegistry.getRewards();
+ List filesSnapshot;
+ // RewardRegistry uses a synchronized list during load/reload. Its
+ // iterator is only safe while holding the list's monitor.
+ synchronized (rewardFiles) {
+ filesSnapshot = new ArrayList<>(rewardFiles);
+ }
return PreparedRewardCatalog.capture(root, rewardRegistry.getDirectlyDefinedRewards(),
- rewardRegistry.getSubDirectlyDefinedRewards(), rewardRegistry.getRewards());
+ rewardRegistry.getSubDirectlyDefinedRewards(), filesSnapshot);
}
public Reward getReward(String reward) {
diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PreparedRewardCatalogTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PreparedRewardCatalogTest.java
index 6e3e5f7aed..0c47fe9452 100644
--- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PreparedRewardCatalogTest.java
+++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PreparedRewardCatalogTest.java
@@ -6,6 +6,10 @@
import static org.mockito.Mockito.when;
import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
import java.util.logging.Logger;
import org.bukkit.configuration.file.YamlConfiguration;
@@ -116,6 +120,87 @@ void nestedNameCanBeResolvedFromFrozenCatalogDuringPlanPreparation() {
assertMessage("before reload", prepared.instantiate(nestedNames.get(0)));
}
+ @Test
+ void optionalMissingDirectHandleDoesNotBlockValidRewards() {
+ DirectlyDefinedReward optional = mock(DirectlyDefinedReward.class);
+ when(optional.getPath()).thenReturn("Optional.Absent");
+ handler.addDirectlyDefined(optional);
+ handler.getRewards().add(new Reward("Available", rewardData("available")));
+
+ PreparedRewardCatalog catalog = handler.prepareCatalog(new Reward("Root", rewardData("root")));
+
+ assertEquals(2, catalog.getDefinitionCount());
+ assertMessage("available", catalog.instantiate("Available"));
+ assertThrows(PreparedRewardDefinitionException.class, () -> catalog.instantiate("Optional.Absent"));
+ }
+
+ @Test
+ void missingDirectHandleShadowsSameNamedFileAfterRoundTrip() {
+ DirectlyDefinedReward optional = mock(DirectlyDefinedReward.class);
+ when(optional.getPath()).thenReturn("Optional.Absent");
+ handler.addDirectlyDefined(optional);
+ handler.getRewards().add(new Reward("Optional_Absent", rewardData("file must stay shadowed")));
+ handler.getRewards().add(new Reward("Available", rewardData("available")));
+
+ PreparedRewardCatalog catalog = PreparedRewardCatalog.decode(
+ handler.prepareCatalog(new Reward("Root", rewardData("root"))).encode());
+
+ assertThrows(PreparedRewardDefinitionException.class, () -> catalog.instantiate("Optional.Absent"));
+ assertMessage("available", catalog.instantiate("Available"));
+ }
+
+ @Test
+ void fileLookupMatchesRegistryUnicodeCaseSemanticsAndFirstEntryWins() {
+ handler.getRewards().add(new Reward("İ", rewardData("first")));
+ handler.getRewards().add(new Reward("i", rewardData("second")));
+ PreparedRewardCatalog catalog = PreparedRewardCatalog.decode(
+ handler.prepareCatalog(new Reward("Root", rewardData("root"))).encode());
+
+ assertMessage("first", catalog.instantiate("i"));
+ assertMessage("first", catalog.instantiate("İ"));
+ }
+
+ @Test
+ void oldCatalogFormatIsRejectedBeforeRestoringItsDefinitions() {
+ assertThrows(PreparedRewardDefinitionException.class,
+ () -> PreparedRewardCatalog.decode("AdvancedCorePreparedRewardCatalog/1/eA/eQ/oldhash"));
+ }
+
+ @Test
+ void rewardFileCaptureWaitsForConcurrentRegistryReload() throws Exception {
+ List files = handler.getRewards();
+ files.add(new Reward("Available", rewardData("available")));
+ Reward root = new Reward("Root", rewardData("root"));
+ CountDownLatch lockHeld = new CountDownLatch(1);
+ CountDownLatch release = new CountDownLatch(1);
+ Thread reload = new Thread(() -> {
+ synchronized (files) {
+ lockHeld.countDown();
+ try {
+ release.await();
+ } catch (InterruptedException failure) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ });
+ reload.start();
+ try {
+ org.junit.jupiter.api.Assertions.assertTrue(lockHeld.await(5, TimeUnit.SECONDS));
+ CountDownLatch captureStarted = new CountDownLatch(1);
+ CompletableFuture capture = CompletableFuture.supplyAsync(() -> {
+ captureStarted.countDown();
+ return handler.prepareCatalog(root);
+ });
+ org.junit.jupiter.api.Assertions.assertTrue(captureStarted.await(5, TimeUnit.SECONDS));
+ assertThrows(TimeoutException.class, () -> capture.get(100, TimeUnit.MILLISECONDS));
+ release.countDown();
+ assertMessage("available", capture.get(5, TimeUnit.SECONDS).instantiate("Available"));
+ } finally {
+ release.countDown();
+ reload.join(5000);
+ }
+ }
+
private static YamlConfiguration rewardData(String message) {
YamlConfiguration data = new YamlConfiguration();
data.set("Messages", List.of(message));
diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PreparedRewardDefinitionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PreparedRewardDefinitionTest.java
index 75de5f498e..4bd69a005d 100644
--- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PreparedRewardDefinitionTest.java
+++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PreparedRewardDefinitionTest.java
@@ -127,4 +127,24 @@ void detachedDirectDefinitionDoesNotCreateALegacyGeneratedRewardFile() {
assertDoesNotThrow(prepared::checkRewardFile);
}
+
+ @Test
+ void hashFramesNameAndYamlWithoutDelimiterAmbiguity() {
+ YamlConfiguration shortPayload = new YamlConfiguration();
+ shortPayload.set("other", "thing");
+ YamlConfiguration longPayload = new YamlConfiguration();
+ longPayload.set("key", "value");
+ longPayload.set("other", "thing");
+
+ PreparedRewardDefinition first = PreparedRewardDefinition.capture("foo\nkey: value", shortPayload);
+ PreparedRewardDefinition second = PreparedRewardDefinition.capture("foo", longPayload);
+
+ assertNotEquals(first.getVersionHash(), second.getVersionHash());
+ }
+
+ @Test
+ void oldHashFramingIsRejectedByItsVersionBeforeRestore() {
+ assertThrows(PreparedRewardDefinitionException.class,
+ () -> PreparedRewardDefinition.decode("AdvancedCorePreparedReward/1/Zm9v/eDogeQ/oldhash"));
+ }
}
From e3b9e64d81e8ce04aebdf949517a57a201fc3aa8 Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Sun, 20 Sep 2026 12:29:41 -0600
Subject: [PATCH 4/8] Bound catalog capture and preserve reload lookup
semantics
---
.../api/rewards/PreparedRewardCatalog.java | 21 +++++++----
.../api/rewards/RewardHandler.java | 12 ++++---
.../api/rewards/RewardLoader.java | 28 ++++++++-------
.../rewards/PreparedRewardCatalogTest.java | 36 +++++++++++++++++--
4 files changed, 70 insertions(+), 27 deletions(-)
diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/PreparedRewardCatalog.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/PreparedRewardCatalog.java
index 1827bb3db3..b41de4387c 100644
--- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/PreparedRewardCatalog.java
+++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/PreparedRewardCatalog.java
@@ -3,7 +3,6 @@
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
-import java.util.ArrayList;
import java.util.Base64;
import java.util.Collection;
import java.util.Collections;
@@ -205,15 +204,25 @@ private static int checkCount(int count) {
private static String encodeRecords(Map direct,
Map files) {
- ArrayList records = new ArrayList<>(direct.size() + files.size());
+ StringBuilder records = new StringBuilder();
for (Map.Entry entry : direct.entrySet()) {
- records.add("D|" + encodeField(entry.getKey()) + "|"
+ appendRecord(records, "D|" + encodeField(entry.getKey()) + "|"
+ (entry.getValue() == null ? "" : encodeField(entry.getValue().encode())));
}
for (Map.Entry entry : files.entrySet()) {
- records.add("F|" + encodeField(entry.getKey()) + "|" + encodeField(entry.getValue().encode()));
+ appendRecord(records, "F|" + encodeField(entry.getKey()) + "|" + encodeField(entry.getValue().encode()));
}
- return String.join("\n", records);
+ return records.toString();
+ }
+
+ private static void appendRecord(StringBuilder records, String record) {
+ // All record characters are ASCII (the fields use Base64 URL encoding).
+ int nextLength = records.length() + (records.length() == 0 ? 0 : 1) + record.length();
+ if (nextLength > MAX_CATALOG_BYTES) {
+ throw new PreparedRewardDefinitionException("Prepared reward catalog exceeds " + MAX_CATALOG_BYTES + " bytes");
+ }
+ if (records.length() != 0) records.append('\n');
+ records.append(record);
}
private static String directKeyForLookup(String name) {
@@ -231,7 +240,7 @@ private static String directKey(String name) {
private static String fileKey(String name) {
validateKey(name);
- return RewardRegistry.normalizeLookupName(name);
+ return name;
}
private static void validateKey(String key) {
diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardHandler.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardHandler.java
index e9bb36f9d8..8ffe75deb1 100644
--- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardHandler.java
+++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardHandler.java
@@ -164,12 +164,14 @@ public PreparedRewardDefinition prepareReward(Reward reward) {
* registry entries. Dispatch remains unchanged; callers opt in explicitly.
*/
public PreparedRewardCatalog prepareCatalog(Reward root) {
- List rewardFiles = rewardRegistry.getRewards();
List filesSnapshot;
- // RewardRegistry uses a synchronized list during load/reload. Its
- // iterator is only safe while holding the list's monitor.
- synchronized (rewardFiles) {
- filesSnapshot = new ArrayList<>(rewardFiles);
+ // loadRewards holds the registry monitor from reset through publication.
+ // Also hold the synchronized list's monitor while copying its iterator.
+ synchronized (rewardRegistry) {
+ List rewardFiles = rewardRegistry.getRewards();
+ synchronized (rewardFiles) {
+ filesSnapshot = new ArrayList<>(rewardFiles);
+ }
}
return PreparedRewardCatalog.capture(root, rewardRegistry.getDirectlyDefinedRewards(),
rewardRegistry.getSubDirectlyDefinedRewards(), filesSnapshot);
diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardLoader.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardLoader.java
index 0912da1b3c..709a4bafcc 100644
--- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardLoader.java
+++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardLoader.java
@@ -198,20 +198,22 @@ public ArrayList getRewardNames(File file) {
}
public void loadRewards() {
- suppressGeneratedDirectlyDefinedFiles();
- handler.getRewardRegistry().resetRewards();
- setupExample();
- handler.addValidPath("DirectlyDefinedReward");
- handler.addValidPath("Delayed");
- handler.addValidPath("Timed");
- handler.addValidPath("DisplayItem");
- handler.addValidPath("ForceOffline");
- for (File file : rewardFolders) {
- loadRewards(file);
+ synchronized (handler.getRewardRegistry()) {
+ suppressGeneratedDirectlyDefinedFiles();
+ handler.getRewardRegistry().resetRewards();
+ setupExample();
+ handler.addValidPath("DirectlyDefinedReward");
+ handler.addValidPath("Delayed");
+ handler.addValidPath("Timed");
+ handler.addValidPath("DisplayItem");
+ handler.addValidPath("ForceOffline");
+ for (File file : rewardFolders) {
+ loadRewards(file);
+ }
+ handler.sortInjectedRewards();
+ handler.sortInjectedRequirements();
+ plugin.debug("Loaded rewards");
}
- handler.sortInjectedRewards();
- handler.sortInjectedRequirements();
- plugin.debug("Loaded rewards");
}
private void loadRewards(File file) {
diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PreparedRewardCatalogTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PreparedRewardCatalogTest.java
index 0c47fe9452..453e2ec6d3 100644
--- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PreparedRewardCatalogTest.java
+++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PreparedRewardCatalogTest.java
@@ -23,6 +23,7 @@
import com.bencodez.advancedcore.api.rewards.PreparedRewardDefinitionException;
import com.bencodez.advancedcore.api.rewards.Reward;
import com.bencodez.advancedcore.api.rewards.RewardHandler;
+import com.bencodez.advancedcore.api.rewards.RewardRegistry;
import com.bencodez.advancedcore.api.rewards.SubDirectlyDefinedReward;
class PreparedRewardCatalogTest {
@@ -160,6 +161,30 @@ void fileLookupMatchesRegistryUnicodeCaseSemanticsAndFirstEntryWins() {
assertMessage("first", catalog.instantiate("İ"));
}
+ @Test
+ void fileLookupKeepsRawRegisteredNamesDistinctFromNormalizedRequests() {
+ handler.getRewards().add(new Reward("Foo Bar", rewardData("spaced file")));
+ handler.getRewards().add(new Reward("Foo_Bar", rewardData("underscored file")));
+ PreparedRewardCatalog catalog = PreparedRewardCatalog.decode(
+ handler.prepareCatalog(new Reward("Root", rewardData("root"))).encode());
+
+ assertMessage("underscored file", handler.getReward("Foo Bar"));
+ assertMessage("underscored file", catalog.instantiate("Foo Bar"));
+ }
+
+ @Test
+ void combinedDefinitionBytesAreBoundedBeforeJoiningRecords() {
+ String largeValue = "x".repeat(800_000);
+ for (int index = 0; index < 5; index++) {
+ YamlConfiguration data = new YamlConfiguration();
+ data.set("Payload", largeValue);
+ handler.getRewards().add(new Reward("Large" + index, data));
+ }
+
+ assertThrows(PreparedRewardDefinitionException.class,
+ () -> handler.prepareCatalog(new Reward("Root", rewardData("root"))));
+ }
+
@Test
void oldCatalogFormatIsRejectedBeforeRestoringItsDefinitions() {
assertThrows(PreparedRewardDefinitionException.class,
@@ -169,18 +194,21 @@ void oldCatalogFormatIsRejectedBeforeRestoringItsDefinitions() {
@Test
void rewardFileCaptureWaitsForConcurrentRegistryReload() throws Exception {
List files = handler.getRewards();
- files.add(new Reward("Available", rewardData("available")));
+ files.add(new Reward("Before", rewardData("before")));
Reward root = new Reward("Root", rewardData("root"));
+ RewardRegistry registry = handler.getRewardRegistry();
CountDownLatch lockHeld = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
Thread reload = new Thread(() -> {
- synchronized (files) {
+ synchronized (registry) {
+ registry.resetRewards();
lockHeld.countDown();
try {
release.await();
} catch (InterruptedException failure) {
Thread.currentThread().interrupt();
}
+ registry.getRewards().add(new Reward("After", rewardData("after")));
}
});
reload.start();
@@ -194,7 +222,9 @@ void rewardFileCaptureWaitsForConcurrentRegistryReload() throws Exception {
org.junit.jupiter.api.Assertions.assertTrue(captureStarted.await(5, TimeUnit.SECONDS));
assertThrows(TimeoutException.class, () -> capture.get(100, TimeUnit.MILLISECONDS));
release.countDown();
- assertMessage("available", capture.get(5, TimeUnit.SECONDS).instantiate("Available"));
+ PreparedRewardCatalog catalog = capture.get(5, TimeUnit.SECONDS);
+ assertMessage("after", catalog.instantiate("After"));
+ assertThrows(PreparedRewardDefinitionException.class, () -> catalog.instantiate("Before"));
} finally {
release.countDown();
reload.join(5000);
From 4c6e51885975f28636a40f929ef5ca3121c48284 Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Sun, 20 Sep 2026 12:35:22 -0600
Subject: [PATCH 5/8] Bound decoded reward catalog record count
---
.../api/rewards/PreparedRewardCatalog.java | 5 ++++
.../rewards/PreparedRewardCatalogTest.java | 23 +++++++++++++++++++
2 files changed, 28 insertions(+)
diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/PreparedRewardCatalog.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/PreparedRewardCatalog.java
index b41de4387c..c46dcc8963 100644
--- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/PreparedRewardCatalog.java
+++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/PreparedRewardCatalog.java
@@ -110,6 +110,11 @@ public static PreparedRewardCatalog decode(String encoded) {
TreeMap direct = new TreeMap<>();
LinkedHashMap files = new LinkedHashMap<>();
if (!records.isEmpty()) {
+ long recordCount = 1L + records.chars().filter(value -> value == '\n').count();
+ if (recordCount > MAX_DEFINITIONS - 1L) {
+ throw new PreparedRewardDefinitionException(
+ "Prepared reward catalog exceeds " + MAX_DEFINITIONS + " definitions");
+ }
for (String record : records.split("\n", -1)) {
String[] parts = record.split("\\|", -1);
if (parts.length != 3) throw new PreparedRewardDefinitionException("Malformed prepared reward catalog record");
diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PreparedRewardCatalogTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PreparedRewardCatalogTest.java
index 453e2ec6d3..0a891b8b96 100644
--- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PreparedRewardCatalogTest.java
+++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PreparedRewardCatalogTest.java
@@ -2,6 +2,11 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.Base64;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@@ -185,6 +190,24 @@ void combinedDefinitionBytesAreBoundedBeforeJoiningRecords() {
() -> handler.prepareCatalog(new Reward("Root", rewardData("root"))));
}
+ @Test
+ void decodeRejectsExcessRecordsBeforeSplittingThem() throws Exception {
+ String[] parts = handler.prepareCatalog(new Reward("Root", rewardData("root"))).encode().split("/", -1);
+ String rootEncoded = new String(Base64.getUrlDecoder().decode(parts[2]), StandardCharsets.UTF_8);
+ String records = "\n".repeat(1024);
+ String hashInput = "2\n" + rootEncoded + "\n" + records;
+ byte[] digest = MessageDigest.getInstance("SHA-256").digest(hashInput.getBytes(StandardCharsets.UTF_8));
+ StringBuilder hash = new StringBuilder();
+ for (byte value : digest) hash.append(String.format("%02x", value));
+ String encoded = "AdvancedCorePreparedRewardCatalog/2/" + parts[2] + "/"
+ + Base64.getUrlEncoder().withoutPadding().encodeToString(records.getBytes(StandardCharsets.UTF_8))
+ + "/" + hash;
+
+ PreparedRewardDefinitionException failure = assertThrows(PreparedRewardDefinitionException.class,
+ () -> PreparedRewardCatalog.decode(encoded));
+ assertTrue(failure.getMessage().contains("definitions"));
+ }
+
@Test
void oldCatalogFormatIsRejectedBeforeRestoringItsDefinitions() {
assertThrows(PreparedRewardDefinitionException.class,
From 6e058d53f3f1489e88b8e7a2ccd80ae83fe7f49d Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Sun, 20 Sep 2026 12:41:06 -0600
Subject: [PATCH 6/8] Coordinate prepared reward capture with registry rebuild
---
.../api/rewards/RewardHandler.java | 25 +++++++++----
.../rewards/PreparedRewardCatalogTest.java | 37 +++++++++++++++++++
2 files changed, 54 insertions(+), 8 deletions(-)
diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardHandler.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardHandler.java
index 8ffe75deb1..376496c754 100644
--- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardHandler.java
+++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardHandler.java
@@ -102,11 +102,15 @@ public void checkDirectlyDefined() {
}
public void checkSubRewards() {
- subRewardResolver.checkSubRewards();
+ synchronized (rewardRegistry) {
+ subRewardResolver.checkSubRewards();
+ }
}
public void checkSubRewards(DefinedReward direct) {
- subRewardResolver.checkSubRewards(direct);
+ synchronized (rewardRegistry) {
+ subRewardResolver.checkSubRewards(direct);
+ }
}
public File getDefaultFolder() {
@@ -143,10 +147,12 @@ public Reward getReward(ConfigurationSection data, String path, RewardOptions re
* owning injectors.
*/
public PreparedRewardDefinition prepareReward(String reward) {
- if (!rewardRegistry.rewardExist(reward) && !rewardRegistry.hasDirectRewardHandle(reward)) {
- throw new IllegalArgumentException("Resolved reward does not exist: " + reward);
+ synchronized (rewardRegistry) {
+ if (!rewardRegistry.rewardExist(reward) && !rewardRegistry.hasDirectRewardHandle(reward)) {
+ throw new IllegalArgumentException("Resolved reward does not exist: " + reward);
+ }
+ return prepareReward(getReward(reward));
}
- return prepareReward(getReward(reward));
}
/** Resolves and snapshots one inline configuration-section reward. */
@@ -172,9 +178,9 @@ public PreparedRewardCatalog prepareCatalog(Reward root) {
synchronized (rewardFiles) {
filesSnapshot = new ArrayList<>(rewardFiles);
}
+ return PreparedRewardCatalog.capture(root, rewardRegistry.getDirectlyDefinedRewards(),
+ rewardRegistry.getSubDirectlyDefinedRewards(), filesSnapshot);
}
- return PreparedRewardCatalog.capture(root, rewardRegistry.getDirectlyDefinedRewards(),
- rewardRegistry.getSubDirectlyDefinedRewards(), filesSnapshot);
}
public Reward getReward(String reward) {
@@ -279,7 +285,10 @@ public void loadInjectedRewards() {
}
public void loadRewards() {
- rewardLoader.loadRewards();
+ synchronized (rewardRegistry) {
+ rewardLoader.loadRewards();
+ subRewardResolver.checkSubRewards();
+ }
}
public void openSubReward(Player player, String path, RewardEditData reward) {
diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PreparedRewardCatalogTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PreparedRewardCatalogTest.java
index 0a891b8b96..ebc1b0ffda 100644
--- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PreparedRewardCatalogTest.java
+++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PreparedRewardCatalogTest.java
@@ -25,6 +25,7 @@
import com.bencodez.advancedcore.AdvancedCorePlugin;
import com.bencodez.advancedcore.api.rewards.DirectlyDefinedReward;
import com.bencodez.advancedcore.api.rewards.PreparedRewardCatalog;
+import com.bencodez.advancedcore.api.rewards.PreparedRewardDefinition;
import com.bencodez.advancedcore.api.rewards.PreparedRewardDefinitionException;
import com.bencodez.advancedcore.api.rewards.Reward;
import com.bencodez.advancedcore.api.rewards.RewardHandler;
@@ -232,6 +233,10 @@ void rewardFileCaptureWaitsForConcurrentRegistryReload() throws Exception {
Thread.currentThread().interrupt();
}
registry.getRewards().add(new Reward("After", rewardData("after")));
+ SubDirectlyDefinedReward sub = mock(SubDirectlyDefinedReward.class);
+ when(sub.getFullPath()).thenReturn("After.Sub");
+ when(sub.getReward()).thenReturn(new Reward("After_Sub", rewardData("sub after")));
+ registry.addSubDirectlyDefined(sub);
}
});
reload.start();
@@ -247,6 +252,7 @@ void rewardFileCaptureWaitsForConcurrentRegistryReload() throws Exception {
release.countDown();
PreparedRewardCatalog catalog = capture.get(5, TimeUnit.SECONDS);
assertMessage("after", catalog.instantiate("After"));
+ assertMessage("sub after", catalog.instantiate("After.Sub"));
assertThrows(PreparedRewardDefinitionException.class, () -> catalog.instantiate("Before"));
} finally {
release.countDown();
@@ -254,6 +260,37 @@ void rewardFileCaptureWaitsForConcurrentRegistryReload() throws Exception {
}
}
+ @Test
+ void namedPreparationWaitsForRegistryReloadBeforeResolving() throws Exception {
+ RewardRegistry registry = handler.getRewardRegistry();
+ CountDownLatch lockHeld = new CountDownLatch(1);
+ CountDownLatch release = new CountDownLatch(1);
+ Thread reload = new Thread(() -> {
+ synchronized (registry) {
+ registry.resetRewards();
+ lockHeld.countDown();
+ try {
+ release.await();
+ } catch (InterruptedException failure) {
+ Thread.currentThread().interrupt();
+ }
+ registry.getRewards().add(new Reward("After", rewardData("after")));
+ }
+ });
+ reload.start();
+ try {
+ assertTrue(lockHeld.await(5, TimeUnit.SECONDS));
+ CompletableFuture capture = CompletableFuture.supplyAsync(
+ () -> handler.prepareReward("After"));
+ assertThrows(TimeoutException.class, () -> capture.get(100, TimeUnit.MILLISECONDS));
+ release.countDown();
+ assertMessage("after", capture.get(5, TimeUnit.SECONDS).instantiate());
+ } finally {
+ release.countDown();
+ reload.join(5000);
+ }
+ }
+
private static YamlConfiguration rewardData(String message) {
YamlConfiguration data = new YamlConfiguration();
data.set("Messages", List.of(message));
From 54708cd1701f01dc9b4372a413734cb4d072a719 Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Sun, 20 Sep 2026 12:44:39 -0600
Subject: [PATCH 7/8] Preserve empty named reward fallback in prepared catalogs
---
.../api/rewards/PreparedRewardCatalog.java | 6 ++++--
.../tests/api/rewards/PreparedRewardCatalogTest.java | 10 ++++++++++
2 files changed, 14 insertions(+), 2 deletions(-)
diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/PreparedRewardCatalog.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/PreparedRewardCatalog.java
index c46dcc8963..cd83bce8a0 100644
--- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/PreparedRewardCatalog.java
+++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/PreparedRewardCatalog.java
@@ -164,13 +164,15 @@ public Reward instantiateRoot() {
* a fresh detached reward. Unknown names fail closed.
*/
public Reward instantiate(String rewardName) {
- String directKey = directKeyForLookup(rewardName);
+ String lookupName = RewardRegistry.normalizeLookupName(rewardName);
+ if (lookupName.isEmpty()) lookupName = "EmptyName";
+ String directKey = directKeyForLookup(lookupName);
if (directDefinitions.containsKey(directKey) && directDefinitions.get(directKey) == null) {
throw new PreparedRewardDefinitionException("Prepared reward catalog has no definition for: " + rewardName);
}
PreparedRewardDefinition definition = directDefinitions.get(directKey);
if (definition == null) {
- String lookup = fileKeyForLookup(rewardName);
+ String lookup = fileKeyForLookup(lookupName);
for (Map.Entry entry : fileDefinitions.entrySet()) {
if (entry.getKey().equalsIgnoreCase(lookup)) {
definition = entry.getValue();
diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PreparedRewardCatalogTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PreparedRewardCatalogTest.java
index ebc1b0ffda..a4105f8b5a 100644
--- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PreparedRewardCatalogTest.java
+++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PreparedRewardCatalogTest.java
@@ -178,6 +178,16 @@ void fileLookupKeepsRawRegisteredNamesDistinctFromNormalizedRequests() {
assertMessage("underscored file", catalog.instantiate("Foo Bar"));
}
+ @Test
+ void emptyNameUsesTheRegistryFallbackWhenPrepared() {
+ handler.getRewards().add(new Reward("EmptyName", rewardData("fallback")));
+ PreparedRewardCatalog catalog = handler.prepareCatalog(new Reward("Root", rewardData("root")));
+
+ assertMessage("fallback", handler.getReward(""));
+ assertMessage("fallback", catalog.instantiate(""));
+ assertMessage("fallback", catalog.instantiate(null));
+ }
+
@Test
void combinedDefinitionBytesAreBoundedBeforeJoiningRecords() {
String largeValue = "x".repeat(800_000);
From 309f5e2d4437edd61850680246ab9524dc84877f Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Sun, 20 Sep 2026 12:51:29 -0600
Subject: [PATCH 8/8] Bound prepared catalog capture and preserve empty reward
lookup
---
.../api/rewards/PreparedRewardCatalog.java | 26 ++++++++++++++++---
.../api/rewards/RewardHandler.java | 6 +++--
.../rewards/PreparedRewardCatalogTest.java | 8 ++++++
3 files changed, 35 insertions(+), 5 deletions(-)
diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/PreparedRewardCatalog.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/PreparedRewardCatalog.java
index cd83bce8a0..32431e8cbb 100644
--- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/PreparedRewardCatalog.java
+++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/PreparedRewardCatalog.java
@@ -54,6 +54,7 @@ public static PreparedRewardCatalog capture(Reward root, Collection direct = new TreeMap<>();
LinkedHashMap files = new LinkedHashMap<>();
int count = 1;
+ int recordBytes = 0;
for (DirectlyDefinedReward entry : directlyDefined) {
if (entry == null) continue;
@@ -61,7 +62,9 @@ public static PreparedRewardCatalog capture(Reward root, Collection MAX_CATALOG_BYTES) {
+ throw new PreparedRewardDefinitionException("Prepared reward catalog exceeds " + MAX_CATALOG_BYTES + " bytes");
+ }
+ return (int) next;
+ }
+
private static String encodeRecords(Map direct,
Map files) {
StringBuilder records = new StringBuilder();
diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardHandler.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardHandler.java
index 376496c754..a6c5f59fc3 100644
--- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardHandler.java
+++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardHandler.java
@@ -148,10 +148,12 @@ public Reward getReward(ConfigurationSection data, String path, RewardOptions re
*/
public PreparedRewardDefinition prepareReward(String reward) {
synchronized (rewardRegistry) {
- if (!rewardRegistry.rewardExist(reward) && !rewardRegistry.hasDirectRewardHandle(reward)) {
+ String lookup = RewardRegistry.normalizeLookupName(reward);
+ if (lookup.isEmpty()) lookup = "EmptyName";
+ if (!rewardRegistry.rewardExist(lookup) && !rewardRegistry.hasDirectRewardHandle(lookup)) {
throw new IllegalArgumentException("Resolved reward does not exist: " + reward);
}
- return prepareReward(getReward(reward));
+ return prepareReward(getReward(lookup));
}
}
diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PreparedRewardCatalogTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PreparedRewardCatalogTest.java
index a4105f8b5a..54474066f2 100644
--- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PreparedRewardCatalogTest.java
+++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PreparedRewardCatalogTest.java
@@ -8,6 +8,9 @@
import java.security.MessageDigest;
import java.util.Base64;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
@@ -184,6 +187,8 @@ void emptyNameUsesTheRegistryFallbackWhenPrepared() {
PreparedRewardCatalog catalog = handler.prepareCatalog(new Reward("Root", rewardData("root")));
assertMessage("fallback", handler.getReward(""));
+ assertMessage("fallback", handler.prepareReward("").instantiate());
+ assertMessage("fallback", handler.prepareReward((String) null).instantiate());
assertMessage("fallback", catalog.instantiate(""));
assertMessage("fallback", catalog.instantiate(null));
}
@@ -196,9 +201,12 @@ void combinedDefinitionBytesAreBoundedBeforeJoiningRecords() {
data.set("Payload", largeValue);
handler.getRewards().add(new Reward("Large" + index, data));
}
+ Reward late = spy(new Reward("Late", rewardData("must not capture")));
+ handler.getRewards().add(late);
assertThrows(PreparedRewardDefinitionException.class,
() -> handler.prepareCatalog(new Reward("Root", rewardData("root"))));
+ verify(late, never()).getConfig();
}
@Test