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