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..32431e8cbb
--- /dev/null
+++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/PreparedRewardCatalog.java
@@ -0,0 +1,308 @@
+package com.bencodez.advancedcore.api.rewards;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+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 = 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;
+
+ 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 = Collections.unmodifiableMap(new TreeMap<>(directDefinitions));
+ this.fileDefinitions = Collections.unmodifiableMap(new LinkedHashMap<>(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<>();
+ LinkedHashMap files = new LinkedHashMap<>();
+ int count = 1;
+ int recordBytes = 0;
+
+ for (DirectlyDefinedReward entry : directlyDefined) {
+ if (entry == null) continue;
+ count = checkCount(count + 1);
+ String key = directKey(entry.getPath());
+ if (!direct.containsKey(key)) {
+ Reward reward = entry.getReward();
+ PreparedRewardDefinition definition = reward == null ? null : definitionFor(reward, key);
+ recordBytes = addRecordBytes(recordBytes, "D", key, definition);
+ direct.put(key, definition);
+ }
+ }
+ for (SubDirectlyDefinedReward entry : subDirectlyDefined) {
+ if (entry == null) continue;
+ count = checkCount(count + 1);
+ String key = directKey(entry.getFullPath());
+ if (!direct.containsKey(key)) {
+ Reward reward = entry.getReward();
+ PreparedRewardDefinition definition = reward == null ? null : definitionFor(reward, key);
+ recordBytes = addRecordBytes(recordBytes, "D", key, definition);
+ direct.put(key, definition);
+ }
+ }
+ for (Reward entry : rewardFiles) {
+ count = checkCount(count + 1);
+ String key = fileKey(entry == null ? null : entry.getRewardName());
+ if (!files.containsKey(key)) {
+ PreparedRewardDefinition definition = definitionFor(entry, key);
+ recordBytes = addRecordBytes(recordBytes, "F", key, definition);
+ files.put(key, definition);
+ }
+ }
+
+ 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<>();
+ 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");
+ String key = decodeField(parts[1], "lookup key");
+ 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.containsKey(key)) {
+ throw new PreparedRewardDefinitionException("Duplicate prepared reward catalog lookup key");
+ }
+ target.put(key, definition);
+ 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) {
+ 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(lookupName);
+ 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);
+ }
+ return definition.instantiate();
+ }
+
+ public String getVersionHash() {
+ return versionHash;
+ }
+
+ public int getDefinitionCount() {
+ return 1 + (int) directDefinitions.values().stream().filter(java.util.Objects::nonNull).count()
+ + 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 int addRecordBytes(int used, String type, String key, PreparedRewardDefinition definition) {
+ // Records are ASCII: Base64 URL fields separated by two pipes and newlines.
+ String record = type + "|" + encodeField(key) + "|"
+ + (definition == null ? "" : encodeField(definition.encode()));
+ long next = (long) used + (used == 0 ? 0 : 1) + record.length();
+ if (next > 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();
+ for (Map.Entry entry : direct.entrySet()) {
+ appendRecord(records, "D|" + encodeField(entry.getKey()) + "|"
+ + (entry.getValue() == null ? "" : encodeField(entry.getValue().encode())));
+ }
+ for (Map.Entry entry : files.entrySet()) {
+ appendRecord(records, "F|" + encodeField(entry.getKey()) + "|" + encodeField(entry.getValue().encode()));
+ }
+ 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) {
+ 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 name;
+ }
+
+ 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..4737e279d8
--- /dev/null
+++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/PreparedRewardDefinition.java
@@ -0,0 +1,211 @@
+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 = 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;
+
+ 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