From 46bc42a0710d7bc1d31eb18698504385e0201fac Mon Sep 17 00:00:00 2001 From: Dubledice <138730151+Dubledice@users.noreply.github.com> Date: Wed, 3 Sep 2025 17:41:25 -0400 Subject: [PATCH 1/5] Threshold Decay: implement decay in manager/runtime and tick processing --- .../capability/entity/ResourceTracker.java | 28 ++++++- .../event_hooks/ontick/OnServerTick.java | 38 +++++++++ .../thresholds/SpendThresholdManager.java | 34 ++++++-- .../thresholds/SpendThresholdRuntime.java | 79 +++++++++++++++++-- 4 files changed, 168 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/robertx22/mine_and_slash/capability/entity/ResourceTracker.java b/src/main/java/com/robertx22/mine_and_slash/capability/entity/ResourceTracker.java index 5dc7d2a1b..b80800811 100644 --- a/src/main/java/com/robertx22/mine_and_slash/capability/entity/ResourceTracker.java +++ b/src/main/java/com/robertx22/mine_and_slash/capability/entity/ResourceTracker.java @@ -85,7 +85,7 @@ private float total(java.util.Set types) { private final java.util.EnumMap> keyProgress = new java.util.EnumMap<>(ResourceType.class); - public void clearKey(ResourceType rt, String key) { + public void clearKey(ResourceType rt, String key) { if (key == null || key.isEmpty()) return; var byKey = keyProgress.get(rt); if (byKey == null) return; @@ -119,6 +119,32 @@ public float getKeyProgress(String key, ResourceType rt) { return byKey == null ? 0f : byKey.getOrDefault(key, 0f); } + /** + * Set the exact cursor value for a specific key/resource. + * If value <= EPS the key entry is removed. + */ + public void setKeyProgress(String key, ResourceType rt, float value) { + if (key == null || key.isEmpty()) return; + var byKey = keyProgress.computeIfAbsent(rt, __ -> new java.util.HashMap<>()); + float val = Math.max(0f, value); + if (val <= EPS) byKey.remove(key); else byKey.put(key, val); + if (byKey.isEmpty()) keyProgress.remove(rt); + } + + /** + * Decrease the cursor by a fixed amount, clamped at zero. Returns the new value. + */ + public float decayKeyProgress(String key, ResourceType rt, float amount) { + if (key == null || key.isEmpty() || amount <= 0f) return getKeyProgress(key, rt); + var byKey = keyProgress.get(rt); + if (byKey == null) return 0f; + float cur = byKey.getOrDefault(key, 0f); + float next = Math.max(0f, cur - amount); + if (next <= EPS) byKey.remove(key); else byKey.put(key, next); + if (byKey.isEmpty()) keyProgress.remove(rt); + return next; + } + /** Optional utility if you want to wipe a resource’s accumulator. */ public void clear(ResourceType rt) { lost.remove(rt); diff --git a/src/main/java/com/robertx22/mine_and_slash/event_hooks/ontick/OnServerTick.java b/src/main/java/com/robertx22/mine_and_slash/event_hooks/ontick/OnServerTick.java index e65b65747..a0de8f541 100644 --- a/src/main/java/com/robertx22/mine_and_slash/event_hooks/ontick/OnServerTick.java +++ b/src/main/java/com/robertx22/mine_and_slash/event_hooks/ontick/OnServerTick.java @@ -18,6 +18,7 @@ import com.robertx22.mine_and_slash.uncommon.utilityclasses.LevelUtils; import com.robertx22.mine_and_slash.uncommon.utilityclasses.WorldUtils; import com.robertx22.mine_and_slash.vanilla_mc.packets.MapCompletePacket; +import com.robertx22.mine_and_slash.vanilla_mc.packets.ThresholdUiPacket; import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; @@ -128,6 +129,43 @@ public static void onEndTick(ServerPlayer player) { playerData.spellCastingData.charges.onTicks(player, 5); } + // Every second, apply passive decay to inactive threshold progress + if (age % 20 == 0) { + long now = player.level().getGameTime(); + var unit = Load.Unit(player); + if (unit != null) { + // Iterate only active keys per resource + for (var rt : com.robertx22.mine_and_slash.saveclasses.unit.ResourceType.values()) { + for (var key : unit.getSpendRuntime().getActiveKeys(rt)) { + var spec = unit.getSpendRuntime().getSpec(key); + if (!spec.showUi()) continue; + long lastAct = unit.getSpendRuntime().getLastActivity(key); + if (lastAct <= 0) continue; + long since = now - lastAct; + if (since < 300) continue; // < 15s + + long lastDecay = unit.getSpendRuntime().getLastDecay(key); + if (lastDecay == now) continue; // already decayed this second + unit.getSpendRuntime().markDecay(key, now); + + // Decay rate: 15% of this threshold's breakpoint per second + float thr = spec.thresholdFor(unit); + if (thr <= 0f) continue; + float decayPerSecond = thr * 0.15f; + float newVal = unit.getResourceTracker().decayKeyProgress(key, rt, decayPerSecond); + int cint = (int) newVal; + if (unit.getSpendRuntime().progressIntChanged(key, cint)) { + com.robertx22.library_of_exile.main.Packets.sendToClient(player, + new ThresholdUiPacket(key, rt.id, newVal > 0f, newVal)); + } + if (newVal <= 0f) { + unit.getSpendRuntime().removeActive(rt, key); + } + } + } + } + } + if (player.containerMenu instanceof CraftingStationMenu men) { if (player.tickCount % 5 == 0) { men.be.onTickWhenPlayerWatching(player); diff --git a/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/SpendThresholdManager.java b/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/SpendThresholdManager.java index 39cc5beb2..2888c3032 100644 --- a/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/SpendThresholdManager.java +++ b/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/SpendThresholdManager.java @@ -2,6 +2,7 @@ import com.robertx22.mine_and_slash.capability.entity.EntityData; import com.robertx22.mine_and_slash.event_hooks.my_events.OnResourceLost; +import com.robertx22.mine_and_slash.vanilla_mc.packets.ThresholdUiPacket; import com.robertx22.mine_and_slash.saveclasses.unit.ResourceType; import net.minecraft.server.level.ServerPlayer; @@ -44,7 +45,10 @@ public static void processSpend(ServerPlayer sp, EntityData unit, ResourceType t tracker.clearKey(type, key); } if (debug) { - sp.sendSystemMessage(net.minecraft.network.chat.Component.literal("[SPEND:" + spec.key() + "] locked by cooldown")); + long rem = unit.getSpendRuntime().cooldownRemainingTicks(key, now); + sp.sendSystemMessage(net.minecraft.network.chat.Component.literal( + "[SPEND:" + spec.key() + "] locked by cooldown (" + rem + "t ~ " + fmtSec((float) rem) + "s)" + )); } continue; } @@ -57,7 +61,10 @@ public static void processSpend(ServerPlayer sp, EntityData unit, ResourceType t if (debug) { sp.sendSystemMessage(net.minecraft.network.chat.Component.literal("[SPEND:" + spec.key() + "] locked")); } - // UI updates omitted (packet not included in this commit) + // hide UI while locked + if (spec.showUi()) { + com.robertx22.library_of_exile.main.Packets.sendToClient(sp, new ThresholdUiPacket(key, type.id, false, 0)); + } continue; } @@ -66,7 +73,10 @@ public static void processSpend(ServerPlayer sp, EntityData unit, ResourceType t if (threshold <= 0f) continue; int procs = tracker.addAndConsumeForKey(key, type, loss, threshold); - // activity tracking omitted for compatibility + if (loss > 0f && procs == 0) { + unit.getSpendRuntime().markActivity(key, now); + unit.getSpendRuntime().markActive(type, key, spec); + } if (procs > 0) { spec.onProc(sp, procs); spec.startCooldown(unit, now); @@ -74,13 +84,26 @@ public static void processSpend(ServerPlayer sp, EntityData unit, ResourceType t tracker.clearKey(type, key); } if (debug) dbg(sp, "[SPEND:" + spec.key() + "] " + type.id + " ×" + procs + " (thr=" + fmt(threshold) + ")"); - // UI/active tracking omitted + // hide UI on proc + if (spec.showUi()) { + com.robertx22.library_of_exile.main.Packets.sendToClient(sp, new ThresholdUiPacket(key, type.id, false, 0)); + } + unit.getSpendRuntime().removeActive(type, key); } else { float cur = tracker.getKeyProgress(key, type); if (debug) { dbg(sp, "[SPEND:" + spec.key() + "] +" + fmt(loss) + " " + type.id + " (cur=" + fmt(cur) + " / " + fmt(threshold) + ")"); } - // UI/active tracking omitted + if (spec.showUi()) { + int cint = (int) cur; + if (unit.getSpendRuntime().progressIntChanged(key, cint)) { + boolean show = cur > 0f; + com.robertx22.library_of_exile.main.Packets.sendToClient(sp, new ThresholdUiPacket(key, type.id, show, cur)); + } + } + if (cur <= 0f) { + unit.getSpendRuntime().removeActive(type, key); + } } } } @@ -91,4 +114,5 @@ private static void dbg(ServerPlayer sp, String msg) { sp.sendSystemMessage(net.minecraft.network.chat.Component.literal(msg)); } private static String fmt(float v) { return String.format(java.util.Locale.US, "%.1f", v); } + private static String fmtSec(float s) { return String.format(java.util.Locale.US, "%.1f", s); } } diff --git a/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/SpendThresholdRuntime.java b/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/SpendThresholdRuntime.java index 4273a4a1e..d8d6916a9 100644 --- a/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/SpendThresholdRuntime.java +++ b/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/SpendThresholdRuntime.java @@ -1,19 +1,35 @@ package com.robertx22.mine_and_slash.mechanics.thresholds; +import com.robertx22.mine_and_slash.saveclasses.unit.ResourceType; + import java.util.HashMap; +import java.util.HashSet; import java.util.Map; +import java.util.Set; public class SpendThresholdRuntime { // gameTime (ticks) when each key’s cooldown ends private final Map cooldownUntil = new HashMap<>(); - /** Start/refresh cooldown for a key. */ + // last activity tick for each threshold key (progress added) + private final Map lastActivityTick = new HashMap<>(); + + // last decay tick applied for each key (so we decay at most once per second) + private final Map lastDecayTick = new HashMap<>(); + + // last integer progress sent to client (to throttle network updates) + private final Map lastProgressIntSent = new HashMap<>(); + + // Index of active threshold keys by resource (only keys with progress > 0 or recently updated) + private final java.util.EnumMap> activeByResource = new java.util.EnumMap<>(ResourceType.class); + // Quick lookup of spec by key (used for decay threshold value) + private final Map specByKey = new HashMap<>(); + public void startCooldown(String key, long now, int cooldownTicks) { if (cooldownTicks <= 0) return; cooldownUntil.put(key, now + cooldownTicks); } - /** True if now is still before the stored end time. */ public boolean isCoolingDown(String key, long now) { Long until = cooldownUntil.get(key); return until != null && now < until; @@ -27,8 +43,61 @@ public int cooldownRemainingTicks(String key, long now) { return (int) Math.max(0, rem); } - /** Clear a key’s cooldown (optional utility). */ - public void clearCooldown(String key) { - cooldownUntil.remove(key); + // === Activity/Decay tracking === + public void markActivity(String key, long now) { + if (key == null || key.isEmpty()) return; + lastActivityTick.put(key, now); + lastDecayTick.remove(key); + } + + public long getLastActivity(String key) { + return lastActivityTick.getOrDefault(key, 0L); + } + + public long getLastDecay(String key) { + return lastDecayTick.getOrDefault(key, 0L); + } + + public void markDecay(String key, long now) { + if (key == null || key.isEmpty()) return; + lastDecayTick.put(key, now); + } + + public boolean progressIntChanged(String key, int intProgress) { + Integer prev = lastProgressIntSent.get(key); + if (prev == null || prev.intValue() != intProgress) { + lastProgressIntSent.put(key, intProgress); + return true; + } + return false; + } + + // === Active key index === + public void markActive(ResourceType rt, String key, SpendThresholdSpec spec) { + if (rt == null || key == null || key.isEmpty() || spec == null) return; + activeByResource.computeIfAbsent(rt, __ -> new HashSet<>()).add(key); + specByKey.put(key, spec); + } + + public void removeActive(ResourceType rt, String key) { + if (rt == null || key == null || key.isEmpty()) return; + var set = activeByResource.get(rt); + if (set != null) { + set.remove(key); + if (set.isEmpty()) activeByResource.remove(rt); + } + specByKey.remove(key); + lastActivityTick.remove(key); + lastDecayTick.remove(key); + lastProgressIntSent.remove(key); + } + + public Set getActiveKeys(ResourceType rt) { + var s = activeByResource.get(rt); + return s == null ? java.util.Set.of() : java.util.Set.copyOf(s); + } + + public SpendThresholdSpec getSpec(String key) { + return specByKey.get(key); } } From 88db75fc64cbe1685d464965245eae6808451169 Mon Sep 17 00:00:00 2001 From: Dubledice <138730151+Dubledice@users.noreply.github.com> Date: Thu, 4 Sep 2025 13:25:08 -0400 Subject: [PATCH 2/5] Threshold-Decay Fixed + Cleanup --- .../event_hooks/my_events/OnResourceLost.java | 9 --------- .../event_hooks/ontick/OnServerTick.java | 7 ------- .../DataDrivenSpendThresholdSpec.java | 7 +------ .../mechanics/thresholds/SpendKeys.java | 13 ------------ .../thresholds/SpendThresholdContributor.java | 9 --------- .../thresholds/SpendThresholdManager.java | 16 --------------- .../thresholds/SpendThresholdSpec.java | 20 +++---------------- .../datapack/SpendThresholdDef.java | 14 ------------- .../spend_thresholds/energy_xlvl_wotj.json | 10 +++------- 9 files changed, 7 insertions(+), 98 deletions(-) delete mode 100644 src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/SpendKeys.java delete mode 100644 src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/SpendThresholdContributor.java diff --git a/src/main/java/com/robertx22/mine_and_slash/event_hooks/my_events/OnResourceLost.java b/src/main/java/com/robertx22/mine_and_slash/event_hooks/my_events/OnResourceLost.java index 3e29a8206..27464f413 100644 --- a/src/main/java/com/robertx22/mine_and_slash/event_hooks/my_events/OnResourceLost.java +++ b/src/main/java/com/robertx22/mine_and_slash/event_hooks/my_events/OnResourceLost.java @@ -6,22 +6,13 @@ import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.entity.LivingEntity; -/** - * Unified entrypoint for resource LOSS (spend, drains, damage). - * Health damage integration calls this via the LivingDamageEvent handler below. - * - * Debug printing is handled inside SpendThresholdManager and is toggled by - * OnResourceLost.DEBUG_ENABLED. - */ public final class OnResourceLost { private OnResourceLost() {} public enum LossSource { SpendOrDrain, Damage, Other } - /** Toggle SpendThresholdManager debug logs per player. */ public static boolean DEBUG_ENABLED = false; - /** Call this whenever a resource actually goes down. */ public static void trigger(LivingEntity entity, ResourceType type, float loss, LossSource source) { if (loss <= 0f) return; if (!(entity instanceof ServerPlayer sp)) return; diff --git a/src/main/java/com/robertx22/mine_and_slash/event_hooks/ontick/OnServerTick.java b/src/main/java/com/robertx22/mine_and_slash/event_hooks/ontick/OnServerTick.java index a0de8f541..80dbdd747 100644 --- a/src/main/java/com/robertx22/mine_and_slash/event_hooks/ontick/OnServerTick.java +++ b/src/main/java/com/robertx22/mine_and_slash/event_hooks/ontick/OnServerTick.java @@ -18,7 +18,6 @@ import com.robertx22.mine_and_slash.uncommon.utilityclasses.LevelUtils; import com.robertx22.mine_and_slash.uncommon.utilityclasses.WorldUtils; import com.robertx22.mine_and_slash.vanilla_mc.packets.MapCompletePacket; -import com.robertx22.mine_and_slash.vanilla_mc.packets.ThresholdUiPacket; import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; @@ -138,7 +137,6 @@ public static void onEndTick(ServerPlayer player) { for (var rt : com.robertx22.mine_and_slash.saveclasses.unit.ResourceType.values()) { for (var key : unit.getSpendRuntime().getActiveKeys(rt)) { var spec = unit.getSpendRuntime().getSpec(key); - if (!spec.showUi()) continue; long lastAct = unit.getSpendRuntime().getLastActivity(key); if (lastAct <= 0) continue; long since = now - lastAct; @@ -153,11 +151,6 @@ public static void onEndTick(ServerPlayer player) { if (thr <= 0f) continue; float decayPerSecond = thr * 0.15f; float newVal = unit.getResourceTracker().decayKeyProgress(key, rt, decayPerSecond); - int cint = (int) newVal; - if (unit.getSpendRuntime().progressIntChanged(key, cint)) { - com.robertx22.library_of_exile.main.Packets.sendToClient(player, - new ThresholdUiPacket(key, rt.id, newVal > 0f, newVal)); - } if (newVal <= 0f) { unit.getSpendRuntime().removeActive(rt, key); } diff --git a/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/DataDrivenSpendThresholdSpec.java b/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/DataDrivenSpendThresholdSpec.java index ca498f77e..11f92fe31 100644 --- a/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/DataDrivenSpendThresholdSpec.java +++ b/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/DataDrivenSpendThresholdSpec.java @@ -15,7 +15,6 @@ public enum ThresholdMode { FLAT, PERCENT_OF_MAX } private final float value; private final boolean multiplyByLevel; @Nullable private final ResourceType percentMaxOf; - private final boolean showUi; public DataDrivenSpendThresholdSpec( String key, @@ -32,12 +31,11 @@ public DataDrivenSpendThresholdSpec( boolean showUi ) { super(resource, 0f, key, - lockWhileEffectIds, cooldownTicks, lockWhileCooldown, dropProgressWhileLocked, resetProgressOnProc, showUi); + lockWhileEffectIds, cooldownTicks, lockWhileCooldown, dropProgressWhileLocked, resetProgressOnProc); this.mode = mode; this.value = value; this.multiplyByLevel = multiplyByLevel; this.percentMaxOf = percentMaxOf; - this.showUi = showUi; } // Backward-compatible ctor (defaults showUi=false) @@ -79,7 +77,4 @@ public void onProc(ServerPlayer sp, int procs) { // No default action here; datapack loader wires actions. } - public boolean showUi() { return showUi; } - - // Perk lock is handled by callers (anonymous subclass) when needed. } diff --git a/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/SpendKeys.java b/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/SpendKeys.java deleted file mode 100644 index ba7bbe834..000000000 --- a/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/SpendKeys.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.robertx22.mine_and_slash.mechanics.thresholds; - -import com.robertx22.mine_and_slash.saveclasses.unit.ResourceType; -import com.robertx22.mine_and_slash.uncommon.datasaving.Load; -import net.minecraft.server.level.ServerPlayer; - -public final class SpendKeys { - private SpendKeys() {} - public static String key(String nodeId, ResourceType rt) { return "spend." + rt.id + "." + nodeId; } - public static float threshold(ServerPlayer sp, float perLevelFactor) { - return perLevelFactor * Load.Unit(sp).getLevel(); - } -} diff --git a/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/SpendThresholdContributor.java b/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/SpendThresholdContributor.java deleted file mode 100644 index 9180a6299..000000000 --- a/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/SpendThresholdContributor.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.robertx22.mine_and_slash.mechanics.thresholds; - -import com.robertx22.mine_and_slash.capability.entity.EntityData; -import java.util.List; - -public interface SpendThresholdContributor { - /** Return zero or more specs active for this unit (e.g., from allocated talents). */ - List getSpendThresholds(EntityData unit); -} diff --git a/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/SpendThresholdManager.java b/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/SpendThresholdManager.java index 2888c3032..541cd5a63 100644 --- a/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/SpendThresholdManager.java +++ b/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/SpendThresholdManager.java @@ -2,7 +2,6 @@ import com.robertx22.mine_and_slash.capability.entity.EntityData; import com.robertx22.mine_and_slash.event_hooks.my_events.OnResourceLost; -import com.robertx22.mine_and_slash.vanilla_mc.packets.ThresholdUiPacket; import com.robertx22.mine_and_slash.saveclasses.unit.ResourceType; import net.minecraft.server.level.ServerPlayer; @@ -61,10 +60,6 @@ public static void processSpend(ServerPlayer sp, EntityData unit, ResourceType t if (debug) { sp.sendSystemMessage(net.minecraft.network.chat.Component.literal("[SPEND:" + spec.key() + "] locked")); } - // hide UI while locked - if (spec.showUi()) { - com.robertx22.library_of_exile.main.Packets.sendToClient(sp, new ThresholdUiPacket(key, type.id, false, 0)); - } continue; } @@ -84,23 +79,12 @@ public static void processSpend(ServerPlayer sp, EntityData unit, ResourceType t tracker.clearKey(type, key); } if (debug) dbg(sp, "[SPEND:" + spec.key() + "] " + type.id + " ×" + procs + " (thr=" + fmt(threshold) + ")"); - // hide UI on proc - if (spec.showUi()) { - com.robertx22.library_of_exile.main.Packets.sendToClient(sp, new ThresholdUiPacket(key, type.id, false, 0)); - } unit.getSpendRuntime().removeActive(type, key); } else { float cur = tracker.getKeyProgress(key, type); if (debug) { dbg(sp, "[SPEND:" + spec.key() + "] +" + fmt(loss) + " " + type.id + " (cur=" + fmt(cur) + " / " + fmt(threshold) + ")"); } - if (spec.showUi()) { - int cint = (int) cur; - if (unit.getSpendRuntime().progressIntChanged(key, cint)) { - boolean show = cur > 0f; - com.robertx22.library_of_exile.main.Packets.sendToClient(sp, new ThresholdUiPacket(key, type.id, show, cur)); - } - } if (cur <= 0f) { unit.getSpendRuntime().removeActive(type, key); } diff --git a/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/SpendThresholdSpec.java b/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/SpendThresholdSpec.java index 6653eadcc..9a3a26e47 100644 --- a/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/SpendThresholdSpec.java +++ b/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/SpendThresholdSpec.java @@ -21,7 +21,6 @@ public abstract class SpendThresholdSpec { private final boolean lockWhileCooldown; // treat cooldown as a lock private final boolean dropProgressWhileLocked; private final boolean resetProgressOnProc; - private final boolean showUi; // whether to render progress HUD for this spec // registry ordering (lower runs first) private int priority = 0; @@ -34,8 +33,8 @@ public SpendThresholdSpec(ResourceType resource, int cooldownTicks, boolean lockWhileCooldown, boolean dropProgressWhileLocked, - boolean resetProgressOnProc, - boolean showUi) { + boolean resetProgressOnProc + ) { this.resource = resource; this.perLevelFactor = perLevelFactor; this.key = key; @@ -44,20 +43,8 @@ public SpendThresholdSpec(ResourceType resource, this.lockWhileCooldown = lockWhileCooldown; this.dropProgressWhileLocked = dropProgressWhileLocked; this.resetProgressOnProc = resetProgressOnProc; - this.showUi = showUi; } - // Backward-compatible ctor (defaults showUi=false) - public SpendThresholdSpec(ResourceType resource, - float perLevelFactor, - String key, - Set lockWhileEffectIds, - int cooldownTicks, - boolean lockWhileCooldown, - boolean dropProgressWhileLocked, - boolean resetProgressOnProc) { - this(resource, perLevelFactor, key, lockWhileEffectIds, cooldownTicks, lockWhileCooldown, dropProgressWhileLocked, resetProgressOnProc, false); - } // ===== accessors ===== public ResourceType resource() { return resource; } @@ -68,7 +55,6 @@ public SpendThresholdSpec(ResourceType resource, public boolean resetOnProc() { return resetProgressOnProc; } public int cooldownTicks() { return cooldownTicks; } public int priority() { return priority; } - public boolean showUi() { return showUi; } public SpendThresholdSpec withPriority(int p) { @@ -78,7 +64,7 @@ public SpendThresholdSpec withPriority(int p) { public SpendThresholdSpec withShowUi(boolean on) { - return new SpendThresholdSpec(this.resource, this.perLevelFactor, this.key, this.lockWhileEffectIds, this.cooldownTicks, this.lockWhileCooldown, this.dropProgressWhileLocked, this.resetProgressOnProc, on) { + return new SpendThresholdSpec(this.resource, this.perLevelFactor, this.key, this.lockWhileEffectIds, this.cooldownTicks, this.lockWhileCooldown, this.dropProgressWhileLocked, this.resetProgressOnProc) { @Override public float thresholdFor(EntityData unit) { return SpendThresholdSpec.this.thresholdFor(unit); } @Override public void onProc(ServerPlayer sp, int procs) { SpendThresholdSpec.this.onProc(sp, procs); } @Override public boolean isLockedFor(EntityData unit) { return SpendThresholdSpec.this.isLockedFor(unit); } diff --git a/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/datapack/SpendThresholdDef.java b/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/datapack/SpendThresholdDef.java index 7af2dc981..cc91c5ccc 100644 --- a/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/datapack/SpendThresholdDef.java +++ b/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/datapack/SpendThresholdDef.java @@ -100,20 +100,6 @@ public void onProc(ServerPlayer sp, int procs) { int durTicks = Math.max(1, a.durationTicks); int stacks = Math.max(1, a.stacks); var inst = EffectUtils.applyEffect(sp, effect, durTicks, stacks); - - // Attach on-expire duration overrides (ticks directly) - /*if (a.onExpire != null && !a.onExpire.isEmpty()) { - if (inst.onExpireEffectDurationTicks == null) { - inst.onExpireEffectDurationTicks = new java.util.HashMap<>(); - } - for (var e : a.onExpire.entrySet()) { - int ticks = Math.max(0, e.getValue()); - if (ticks > 0) { - inst.onExpireEffectDurationTicks.put(e.getKey(), ticks); - } - } - }*/ // TODO: Add back in when onExpire is implemented - } } diff --git a/src/main/resources/data/mmorpg/spend_thresholds/energy_xlvl_wotj.json b/src/main/resources/data/mmorpg/spend_thresholds/energy_xlvl_wotj.json index b58cea578..621a996c0 100644 --- a/src/main/resources/data/mmorpg/spend_thresholds/energy_xlvl_wotj.json +++ b/src/main/resources/data/mmorpg/spend_thresholds/energy_xlvl_wotj.json @@ -2,14 +2,13 @@ "key": "ENERGY_XLVL_WOTJ", "resource": "energy", "enabled": true, - "show_ui": true, "priority": 0, "threshold": { "mode": "FLAT", "value": 10, "multiply_by_level": true }, - "cooldown_seconds": 20, + "cooldown_ticks": 400, "require_stat": "unlock_wotj", "locks": { "effects": ["wrath_of_the_juggernaut", "burnout"], @@ -21,11 +20,8 @@ { "action": "exile_effect", "exile_potion_id": "wrath_of_the_juggernaut", - "duration_seconds": 10, - "stacks": 1, - "on_expire": { - "burnout": 10 - } + "duration_ticks": 200, + "stacks": 1 } ] } \ No newline at end of file From 4618f8da1e4402425682abbdba6e9924bdbc5a58 Mon Sep 17 00:00:00 2001 From: Dubledice <138730151+Dubledice@users.noreply.github.com> Date: Thu, 4 Sep 2025 14:11:50 -0400 Subject: [PATCH 3/5] Quick getter Fix for 25percent_health.json --- .../data/mmorpg/spend_thresholds/25percent_health.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/data/mmorpg/spend_thresholds/25percent_health.json b/src/main/resources/data/mmorpg/spend_thresholds/25percent_health.json index 84ff2d0fc..1c1bcc45c 100644 --- a/src/main/resources/data/mmorpg/spend_thresholds/25percent_health.json +++ b/src/main/resources/data/mmorpg/spend_thresholds/25percent_health.json @@ -9,7 +9,7 @@ "multiply_by_level": false, "percent_of": "health" }, - "cooldown_seconds": 0, + "cooldown_ticks": 0, "locks": { "effects": [], "drop_progress_while_locked": true, From 966057779d799dc09ffdb418e297f6b8a09b1f10 Mon Sep 17 00:00:00 2001 From: Dubledice <138730151+Dubledice@users.noreply.github.com> Date: Thu, 4 Sep 2025 19:17:48 -0400 Subject: [PATCH 4/5] Suggested Fixes --- .../capability/entity/ResourceTracker.java | 26 +++++++------------ .../event_hooks/ontick/OnServerTick.java | 2 +- .../DataDrivenSpendThresholdSpec.java | 8 +++--- .../thresholds/SpendThresholdManager.java | 2 +- .../thresholds/SpendThresholdSpec.java | 17 +++--------- .../datapack/SpendThresholdDef.java | 4 +-- .../spend_thresholds/25percent_health.json | 2 +- .../spend_thresholds/energy_xlvl_wotj.json | 2 +- 8 files changed, 24 insertions(+), 39 deletions(-) diff --git a/src/main/java/com/robertx22/mine_and_slash/capability/entity/ResourceTracker.java b/src/main/java/com/robertx22/mine_and_slash/capability/entity/ResourceTracker.java index b80800811..e208d0b41 100644 --- a/src/main/java/com/robertx22/mine_and_slash/capability/entity/ResourceTracker.java +++ b/src/main/java/com/robertx22/mine_and_slash/capability/entity/ResourceTracker.java @@ -9,6 +9,7 @@ */ public class ResourceTracker { private static final float EPS = 1e-4f; + private static final float DEFAULT_KEY_PROGRESS = 0f; // Global per-resource accumulators (used for simple thresholds or debug) private final java.util.EnumMap lost = new java.util.EnumMap<>(ResourceType.class); @@ -85,6 +86,10 @@ private float total(java.util.Set types) { private final java.util.EnumMap> keyProgress = new java.util.EnumMap<>(ResourceType.class); + private java.util.Map getKeyProgressOrCreate(ResourceType rt) { + return keyProgress.computeIfAbsent(rt, __ -> new java.util.HashMap<>()); + } + public void clearKey(ResourceType rt, String key) { if (key == null || key.isEmpty()) return; var byKey = keyProgress.get(rt); @@ -99,8 +104,8 @@ public void clearKey(ResourceType rt, String key) { public int addAndConsumeForKey(String key, ResourceType rt, float add, float threshold) { if (key == null || key.isEmpty() || add <= 0f || threshold <= 0f) return 0; - var byKey = keyProgress.computeIfAbsent(rt, __ -> new java.util.HashMap<>()); - float cur = byKey.getOrDefault(key, 0f) + add; + var byKey = getKeyProgressOrCreate(rt); + float cur = byKey.getOrDefault(key, DEFAULT_KEY_PROGRESS) + add; int procs = 0; while (cur + EPS >= threshold) { @@ -116,20 +121,9 @@ public int addAndConsumeForKey(String key, ResourceType rt, float add, float thr /** Read current cursor for debug/UI. */ public float getKeyProgress(String key, ResourceType rt) { var byKey = keyProgress.get(rt); - return byKey == null ? 0f : byKey.getOrDefault(key, 0f); + return byKey == null ? DEFAULT_KEY_PROGRESS : byKey.getOrDefault(key, DEFAULT_KEY_PROGRESS); } - /** - * Set the exact cursor value for a specific key/resource. - * If value <= EPS the key entry is removed. - */ - public void setKeyProgress(String key, ResourceType rt, float value) { - if (key == null || key.isEmpty()) return; - var byKey = keyProgress.computeIfAbsent(rt, __ -> new java.util.HashMap<>()); - float val = Math.max(0f, value); - if (val <= EPS) byKey.remove(key); else byKey.put(key, val); - if (byKey.isEmpty()) keyProgress.remove(rt); - } /** * Decrease the cursor by a fixed amount, clamped at zero. Returns the new value. @@ -137,8 +131,8 @@ public void setKeyProgress(String key, ResourceType rt, float value) { public float decayKeyProgress(String key, ResourceType rt, float amount) { if (key == null || key.isEmpty() || amount <= 0f) return getKeyProgress(key, rt); var byKey = keyProgress.get(rt); - if (byKey == null) return 0f; - float cur = byKey.getOrDefault(key, 0f); + if (byKey == null) return DEFAULT_KEY_PROGRESS; + float cur = byKey.getOrDefault(key, DEFAULT_KEY_PROGRESS); float next = Math.max(0f, cur - amount); if (next <= EPS) byKey.remove(key); else byKey.put(key, next); if (byKey.isEmpty()) keyProgress.remove(rt); diff --git a/src/main/java/com/robertx22/mine_and_slash/event_hooks/ontick/OnServerTick.java b/src/main/java/com/robertx22/mine_and_slash/event_hooks/ontick/OnServerTick.java index 80dbdd747..e49628ff1 100644 --- a/src/main/java/com/robertx22/mine_and_slash/event_hooks/ontick/OnServerTick.java +++ b/src/main/java/com/robertx22/mine_and_slash/event_hooks/ontick/OnServerTick.java @@ -134,7 +134,7 @@ public static void onEndTick(ServerPlayer player) { var unit = Load.Unit(player); if (unit != null) { // Iterate only active keys per resource - for (var rt : com.robertx22.mine_and_slash.saveclasses.unit.ResourceType.values()) { + for (var rt : ResourceType.values()) { for (var key : unit.getSpendRuntime().getActiveKeys(rt)) { var spec = unit.getSpendRuntime().getSpec(key); long lastAct = unit.getSpendRuntime().getLastActivity(key); diff --git a/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/DataDrivenSpendThresholdSpec.java b/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/DataDrivenSpendThresholdSpec.java index 11f92fe31..0269aeca7 100644 --- a/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/DataDrivenSpendThresholdSpec.java +++ b/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/DataDrivenSpendThresholdSpec.java @@ -27,11 +27,11 @@ public DataDrivenSpendThresholdSpec( int cooldownTicks, boolean lockWhileCooldown, boolean dropProgressWhileLocked, - boolean resetProgressOnProc, + boolean dropProgressOnProc, boolean showUi ) { super(resource, 0f, key, - lockWhileEffectIds, cooldownTicks, lockWhileCooldown, dropProgressWhileLocked, resetProgressOnProc); + lockWhileEffectIds, cooldownTicks, lockWhileCooldown, dropProgressWhileLocked, dropProgressOnProc); this.mode = mode; this.value = value; this.multiplyByLevel = multiplyByLevel; @@ -50,9 +50,9 @@ public DataDrivenSpendThresholdSpec( int cooldownTicks, boolean lockWhileCooldown, boolean dropProgressWhileLocked, - boolean resetProgressOnProc + boolean dropProgressOnProc ) { - this(key, resource, mode, value, multiplyByLevel, percentMaxOf, lockWhileEffectIds, cooldownTicks, lockWhileCooldown, dropProgressWhileLocked, resetProgressOnProc, false); + this(key, resource, mode, value, multiplyByLevel, percentMaxOf, lockWhileEffectIds, cooldownTicks, lockWhileCooldown, dropProgressWhileLocked, dropProgressOnProc, false); } @Override diff --git a/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/SpendThresholdManager.java b/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/SpendThresholdManager.java index 541cd5a63..5dc8a298f 100644 --- a/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/SpendThresholdManager.java +++ b/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/SpendThresholdManager.java @@ -75,7 +75,7 @@ public static void processSpend(ServerPlayer sp, EntityData unit, ResourceType t if (procs > 0) { spec.onProc(sp, procs); spec.startCooldown(unit, now); - if (spec.resetOnProc()) { + if (spec.dropProgressOnProc()) { tracker.clearKey(type, key); } if (debug) dbg(sp, "[SPEND:" + spec.key() + "] " + type.id + " ×" + procs + " (thr=" + fmt(threshold) + ")"); diff --git a/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/SpendThresholdSpec.java b/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/SpendThresholdSpec.java index 9a3a26e47..3d2e2afc6 100644 --- a/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/SpendThresholdSpec.java +++ b/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/SpendThresholdSpec.java @@ -20,7 +20,7 @@ public abstract class SpendThresholdSpec { private final int cooldownTicks; private final boolean lockWhileCooldown; // treat cooldown as a lock private final boolean dropProgressWhileLocked; - private final boolean resetProgressOnProc; + private final boolean dropProgressOnProc; // registry ordering (lower runs first) private int priority = 0; @@ -33,7 +33,7 @@ public SpendThresholdSpec(ResourceType resource, int cooldownTicks, boolean lockWhileCooldown, boolean dropProgressWhileLocked, - boolean resetProgressOnProc + boolean dropProgressOnProc ) { this.resource = resource; this.perLevelFactor = perLevelFactor; @@ -42,7 +42,7 @@ public SpendThresholdSpec(ResourceType resource, this.cooldownTicks = Math.max(0, cooldownTicks); this.lockWhileCooldown = lockWhileCooldown; this.dropProgressWhileLocked = dropProgressWhileLocked; - this.resetProgressOnProc = resetProgressOnProc; + this.dropProgressOnProc = dropProgressOnProc; } @@ -52,7 +52,7 @@ public SpendThresholdSpec(ResourceType resource, public String keyFor(EntityData unit) { return key; } public boolean lockWhileCooldown() { return lockWhileCooldown; } public boolean dropProgressWhileLocked() { return dropProgressWhileLocked; } - public boolean resetOnProc() { return resetProgressOnProc; } + public boolean dropProgressOnProc() { return dropProgressOnProc; } public int cooldownTicks() { return cooldownTicks; } public int priority() { return priority; } @@ -62,15 +62,6 @@ public SpendThresholdSpec withPriority(int p) { return this; } - - public SpendThresholdSpec withShowUi(boolean on) { - return new SpendThresholdSpec(this.resource, this.perLevelFactor, this.key, this.lockWhileEffectIds, this.cooldownTicks, this.lockWhileCooldown, this.dropProgressWhileLocked, this.resetProgressOnProc) { - @Override public float thresholdFor(EntityData unit) { return SpendThresholdSpec.this.thresholdFor(unit); } - @Override public void onProc(ServerPlayer sp, int procs) { SpendThresholdSpec.this.onProc(sp, procs); } - @Override public boolean isLockedFor(EntityData unit) { return SpendThresholdSpec.this.isLockedFor(unit); } - }.withPriority(this.priority); - } - /** Default threshold = perLevelFactor × LVL. Subclasses may override. */ public float thresholdFor(EntityData unit) { return Math.max(0f, perLevelFactor * Math.max(1, unit.getLevel())); diff --git a/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/datapack/SpendThresholdDef.java b/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/datapack/SpendThresholdDef.java index cc91c5ccc..7e1fdb5b2 100644 --- a/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/datapack/SpendThresholdDef.java +++ b/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/datapack/SpendThresholdDef.java @@ -31,7 +31,7 @@ public static class Locks { public List effects = new ArrayList<>(); @SerializedName("lock_while_cooldown") public boolean lockWhileCooldown = false; @SerializedName("drop_progress_while_locked") public boolean dropProgressWhileLocked = true; - @SerializedName("reset_progress_on_proc") public boolean resetProgressOnProc = true; + @SerializedName("drop_progress_on_proc") public boolean dropProgressOnProc = true; } public Locks locks = new Locks(); @@ -82,7 +82,7 @@ public SpendThresholdSpec toSpec() { cooldownTicks, locks != null && locks.lockWhileCooldown, locks != null && locks.dropProgressWhileLocked, - locks != null && locks.resetProgressOnProc, + locks != null && locks.dropProgressOnProc, showUi ) { @Override diff --git a/src/main/resources/data/mmorpg/spend_thresholds/25percent_health.json b/src/main/resources/data/mmorpg/spend_thresholds/25percent_health.json index 1c1bcc45c..f484183b4 100644 --- a/src/main/resources/data/mmorpg/spend_thresholds/25percent_health.json +++ b/src/main/resources/data/mmorpg/spend_thresholds/25percent_health.json @@ -13,7 +13,7 @@ "locks": { "effects": [], "drop_progress_while_locked": true, - "reset_progress_on_proc": true + "drop_progress_on_proc": true }, "on_proc": [ { diff --git a/src/main/resources/data/mmorpg/spend_thresholds/energy_xlvl_wotj.json b/src/main/resources/data/mmorpg/spend_thresholds/energy_xlvl_wotj.json index 621a996c0..5c8d0c3e2 100644 --- a/src/main/resources/data/mmorpg/spend_thresholds/energy_xlvl_wotj.json +++ b/src/main/resources/data/mmorpg/spend_thresholds/energy_xlvl_wotj.json @@ -14,7 +14,7 @@ "effects": ["wrath_of_the_juggernaut", "burnout"], "lock_while_cooldown": true, "drop_progress_while_locked": true, - "reset_progress_on_proc": true + "drop_progress_on_proc": true }, "on_proc": [ { From a36d2c99d9897bdab8b795ece31f1559fb2f6c2d Mon Sep 17 00:00:00 2001 From: Dubledice <138730151+Dubledice@users.noreply.github.com> Date: Fri, 5 Sep 2025 08:28:28 -0400 Subject: [PATCH 5/5] Suggested Fixes: Performance Optimization --- .../thresholds/SpendThresholdRuntime.java | 102 ++++++++++++------ 1 file changed, 67 insertions(+), 35 deletions(-) diff --git a/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/SpendThresholdRuntime.java b/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/SpendThresholdRuntime.java index d8d6916a9..2ae6b3bc0 100644 --- a/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/SpendThresholdRuntime.java +++ b/src/main/java/com/robertx22/mine_and_slash/mechanics/thresholds/SpendThresholdRuntime.java @@ -8,37 +8,37 @@ import java.util.Set; public class SpendThresholdRuntime { - // gameTime (ticks) when each key’s cooldown ends - private final Map cooldownUntil = new HashMap<>(); - // last activity tick for each threshold key (progress added) - private final Map lastActivityTick = new HashMap<>(); - - // last decay tick applied for each key (so we decay at most once per second) - private final Map lastDecayTick = new HashMap<>(); + private static final class KeyState { + long cooldownUntil; + long lastActivityTick; + long lastDecayTick; + int lastProgressIntSent = Integer.MIN_VALUE; + SpendThresholdSpec spec; + } - // last integer progress sent to client (to throttle network updates) - private final Map lastProgressIntSent = new HashMap<>(); + private final Map states = new HashMap<>(); - // Index of active threshold keys by resource (only keys with progress > 0 or recently updated) private final java.util.EnumMap> activeByResource = new java.util.EnumMap<>(ResourceType.class); - // Quick lookup of spec by key (used for decay threshold value) - private final Map specByKey = new HashMap<>(); + private final java.util.EnumMap> activeByResourceReadOnly = new java.util.EnumMap<>(ResourceType.class); public void startCooldown(String key, long now, int cooldownTicks) { if (cooldownTicks <= 0) return; - cooldownUntil.put(key, now + cooldownTicks); + if (key == null || key.isEmpty()) return; + KeyState ks = states.computeIfAbsent(key, __ -> new KeyState()); + ks.cooldownUntil = now + cooldownTicks; } public boolean isCoolingDown(String key, long now) { - Long until = cooldownUntil.get(key); - return until != null && now < until; + if (key == null || key.isEmpty()) return false; + KeyState ks = states.get(key); + return ks != null && now < ks.cooldownUntil; } - /** Remaining ticks until ready (0 if no cooldown / already ready). */ public int cooldownRemainingTicks(String key, long now) { - Long until = cooldownUntil.get(key); - if (until == null) return 0; + if (key == null || key.isEmpty()) return 0; + KeyState ks = states.get(key); + long until = (ks == null) ? 0L : ks.cooldownUntil; long rem = until - now; return (int) Math.max(0, rem); } @@ -46,27 +46,36 @@ public int cooldownRemainingTicks(String key, long now) { // === Activity/Decay tracking === public void markActivity(String key, long now) { if (key == null || key.isEmpty()) return; - lastActivityTick.put(key, now); - lastDecayTick.remove(key); + KeyState ks = states.computeIfAbsent(key, __ -> new KeyState()); + ks.lastActivityTick = now; + ks.lastDecayTick = 0L; } public long getLastActivity(String key) { - return lastActivityTick.getOrDefault(key, 0L); + if (key == null || key.isEmpty()) return 0L; + KeyState ks = states.get(key); + return ks == null ? 0L : ks.lastActivityTick; } public long getLastDecay(String key) { - return lastDecayTick.getOrDefault(key, 0L); + if (key == null || key.isEmpty()) return 0L; + KeyState ks = states.get(key); + return ks == null ? 0L : ks.lastDecayTick; } public void markDecay(String key, long now) { if (key == null || key.isEmpty()) return; - lastDecayTick.put(key, now); + KeyState ks = states.computeIfAbsent(key, __ -> new KeyState()); + ks.lastDecayTick = now; } public boolean progressIntChanged(String key, int intProgress) { - Integer prev = lastProgressIntSent.get(key); - if (prev == null || prev.intValue() != intProgress) { - lastProgressIntSent.put(key, intProgress); + if (key == null || key.isEmpty()) return false; + KeyState ks = states.get(key); + int prev = (ks == null) ? Integer.MIN_VALUE : ks.lastProgressIntSent; + if (prev != intProgress) { + if (ks == null) ks = states.computeIfAbsent(key, __ -> new KeyState()); + ks.lastProgressIntSent = intProgress; return true; } return false; @@ -75,8 +84,16 @@ public boolean progressIntChanged(String key, int intProgress) { // === Active key index === public void markActive(ResourceType rt, String key, SpendThresholdSpec spec) { if (rt == null || key == null || key.isEmpty() || spec == null) return; - activeByResource.computeIfAbsent(rt, __ -> new HashSet<>()).add(key); - specByKey.put(key, spec); + Set set = activeByResource.get(rt); + if (set == null) { + set = new HashSet<>(); + activeByResource.put(rt, set); + // create and cache a read-only view for this resource set to avoid future allocations + activeByResourceReadOnly.put(rt, java.util.Collections.unmodifiableSet(set)); + } + set.add(key); + KeyState ks = states.computeIfAbsent(key, __ -> new KeyState()); + ks.spec = spec; } public void removeActive(ResourceType rt, String key) { @@ -84,20 +101,35 @@ public void removeActive(ResourceType rt, String key) { var set = activeByResource.get(rt); if (set != null) { set.remove(key); - if (set.isEmpty()) activeByResource.remove(rt); + if (set.isEmpty()) { + activeByResource.remove(rt); + activeByResourceReadOnly.remove(rt); + } + } + KeyState ks = states.get(key); + if (ks != null) { + // clear volatile state but keep cooldown to preserve gating behavior + ks.spec = null; + ks.lastActivityTick = 0L; + ks.lastDecayTick = 0L; + ks.lastProgressIntSent = Integer.MIN_VALUE; } - specByKey.remove(key); - lastActivityTick.remove(key); - lastDecayTick.remove(key); - lastProgressIntSent.remove(key); } public Set getActiveKeys(ResourceType rt) { var s = activeByResource.get(rt); - return s == null ? java.util.Set.of() : java.util.Set.copyOf(s); + if (s == null || s.isEmpty()) return java.util.Set.of(); + var view = activeByResourceReadOnly.get(rt); + if (view == null) { + view = java.util.Collections.unmodifiableSet(s); + activeByResourceReadOnly.put(rt, view); + } + return view; } public SpendThresholdSpec getSpec(String key) { - return specByKey.get(key); + if (key == null || key.isEmpty()) return null; + KeyState ks = states.get(key); + return ks == null ? null : ks.spec; } }