diff --git a/src/main/java/com/gmail/nossr50/datatypes/experience/DiminishedReturnsCache.java b/src/main/java/com/gmail/nossr50/datatypes/experience/DiminishedReturnsCache.java new file mode 100644 index 000000000..4ec702ecc --- /dev/null +++ b/src/main/java/com/gmail/nossr50/datatypes/experience/DiminishedReturnsCache.java @@ -0,0 +1,84 @@ +package com.gmail.nossr50.datatypes.experience; + +import com.gmail.nossr50.config.experience.ExperienceConfig; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Server-side cache that keeps {@link DiminishedReturnsState} alive across a player's + * disconnect/reconnect cycle, preventing players from resetting their DR window by logging out. + * + *
Entries are keyed by player UUID. When the DR time window elapses the entry becomes + * eligible for removal; call {@link #evictExpired()} periodically (e.g. from + * {@link com.gmail.nossr50.runnables.player.ClearRegisteredXPGainTask}) to release stale entries + * and prevent unbounded cache growth.
+ * + *All public methods are safe to call from multiple threads.
+ */ +public final class DiminishedReturnsCache { + + private static final ConcurrentHashMapPassing {@code null} (offline or legacy profiles without a UUID) always returns a fresh, + * uncached state — those profiles never participate in the disconnect/reconnect bypass.
+ * + * @param uuid the player's unique identifier, or {@code null} for uncached profiles + * @return a non-null {@link DiminishedReturnsState} for this player + */ + public static @NotNull DiminishedReturnsState getOrCreate(@Nullable final UUID uuid) { + if (uuid == null || !ExperienceConfig.getInstance().getDiminishedReturnsEnabled()) { + return new DiminishedReturnsState(); + } + return cache.compute(uuid, (k, existing) -> { + // Keep the existing instance if it is either still active OR fresh (never used). + // A fresh existing state means the player's PlayerProfile (which holds a strong + // reference to it) has not yet gained any XP — replacing it now would orphan + // that reference and break the disconnect/reconnect bypass protection on the + // next XP gain. + if (existing != null && !existing.isEvictable()) { + return existing; + } + return new DiminishedReturnsState(); + }); + } + + /** + * Removes all cache entries whose DR time window has fully elapsed. Fresh entries that + * have never registered XP are preserved — see {@link DiminishedReturnsState#isEvictable()}. + * Safe to call from any thread; intended for use in the periodic cleanup task. + */ + public static void evictExpired() { + cache.values().removeIf(DiminishedReturnsState::isEvictable); + } + + /** Removes the cache entry for {@code uuid}. For test teardown only. */ + static void remove(@Nullable final UUID uuid) { + if (uuid != null) { + cache.remove(uuid); + } + } + + /** Clears every entry from the cache. For test teardown only. */ + static void clearAll() { + cache.clear(); + } + + /** Returns the number of entries currently held in the cache. For test assertions only. */ + static int size() { + return cache.size(); + } + + /** Returns {@code true} if the cache contains an entry for {@code uuid}. For test assertions only. */ + static boolean contains(@NotNull final UUID uuid) { + return cache.containsKey(uuid); + } +} diff --git a/src/main/java/com/gmail/nossr50/datatypes/experience/DiminishedReturnsState.java b/src/main/java/com/gmail/nossr50/datatypes/experience/DiminishedReturnsState.java new file mode 100644 index 000000000..c65d8e968 --- /dev/null +++ b/src/main/java/com/gmail/nossr50/datatypes/experience/DiminishedReturnsState.java @@ -0,0 +1,103 @@ +package com.gmail.nossr50.datatypes.experience; + +import com.gmail.nossr50.datatypes.skills.PrimarySkillType; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.DelayQueue; +import org.jetbrains.annotations.NotNull; + +/** + * Thread-safe container for a player's Diminished Returns tracking data. + * + *Stored in {@link DiminishedReturnsCache} keyed by player UUID so that the DR window + * survives a disconnect/reconnect cycle. Each skill's rolling XP total and the expiry + * queue are held here rather than directly in {@code PlayerProfile}.
+ */ +public final class DiminishedReturnsState { + + private final DelayQueue{@code volatile} ensures the write from one thread (e.g. the main thread registering XP) + * is visible to the scheduler thread that runs {@link DiminishedReturnsCache#evictExpired()}. + */ + private volatile long latestExpiryTimeMillis = 0L; + + // Package-private: only DiminishedReturnsCache (same package) and PlayerProfile + // (via DiminishedReturnsCache.getOrCreate) should instantiate this class. + DiminishedReturnsState() {} + + /** + * Returns {@code true} if at least one DR entry is still within its time window. + * + *
This is intentionally distinct from {@link #isEvictable()}: a freshly constructed state + * that has never registered any XP has no active entries but is also not evictable, + * because its owning {@code PlayerProfile} still holds a reference and may yet register XP + * on it. Removing it from the cache prematurely would break the disconnect/reconnect + * bypass protection.
+ */ + public boolean hasActiveEntries() { + return latestExpiryTimeMillis != 0L && System.currentTimeMillis() < latestExpiryTimeMillis; + } + + /** + * Returns {@code true} if this state has held DR entries and they have all expired. + * Fresh states (no XP ever registered) are never evictable. + */ + public boolean isEvictable() { + return latestExpiryTimeMillis != 0L && System.currentTimeMillis() >= latestExpiryTimeMillis; + } + + /** + * Returns the rolling XP total recorded for the given skill within the current DR window. + * Returns {@code 0} if no XP has been registered or all entries have expired. + * + * @param skill the skill to query + * @return total registered XP for the skill, or {@code 0} + */ + public float getRegisteredXpGain(@NotNull final PrimarySkillType skill) { + return rollingSkillsXp.getOrDefault(skill, 0F); + } + + /** + * Records an XP gain for DR tracking and advances the latest-expiry ceiling accordingly. + * + * @param skill the skill that gained XP + * @param xp the amount of XP gained + */ + public void registerXpGain(@NotNull final PrimarySkillType skill, final float xp) { + final SkillXpGain gain = new SkillXpGain(skill, xp); + gainedSkillsXp.add(gain); + rollingSkillsXp.merge(skill, xp, Float::sum); + + // Advance the expiry ceiling so we know the exact moment all entries will have expired. + final long entryExpiry = gain.getExpiryTimeMillis(); + if (entryExpiry > latestExpiryTimeMillis) { + latestExpiryTimeMillis = entryExpiry; + } + } + + /** + * Polls the delay queue for expired entries and decrements the rolling skill totals. + * Called periodically by + * {@link com.gmail.nossr50.runnables.player.ClearRegisteredXPGainTask}. + */ + public void purgeExpiredXpGains() { + SkillXpGain gain; + while ((gain = gainedSkillsXp.poll()) != null) { + final PrimarySkillType skill = gain.getSkill(); + final float expiredXp = gain.getXp(); + rollingSkillsXp.compute(skill, (k, existing) -> { + if (existing == null) { + return null; + } + final float updated = existing - expiredXp; + return updated <= 0F ? null : updated; + }); + } + } +} diff --git a/src/main/java/com/gmail/nossr50/datatypes/experience/SkillXpGain.java b/src/main/java/com/gmail/nossr50/datatypes/experience/SkillXpGain.java index c166ae10a..23e896d63 100644 --- a/src/main/java/com/gmail/nossr50/datatypes/experience/SkillXpGain.java +++ b/src/main/java/com/gmail/nossr50/datatypes/experience/SkillXpGain.java @@ -25,6 +25,14 @@ public class SkillXpGain implements Delayed { return xp; } + /** + * Returns the absolute epoch-millisecond timestamp at which this entry expires. + * Used by {@link DiminishedReturnsState} to track the latest expiry across all entries. + */ + public long getExpiryTimeMillis() { + return expiryTime; + } + private static long getDuration() { return TimeUnit.MINUTES.toMillis( ExperienceConfig.getInstance().getDiminishedReturnsTimeInterval()); diff --git a/src/main/java/com/gmail/nossr50/datatypes/player/PlayerProfile.java b/src/main/java/com/gmail/nossr50/datatypes/player/PlayerProfile.java index 1c61af81d..9ab19f9d2 100644 --- a/src/main/java/com/gmail/nossr50/datatypes/player/PlayerProfile.java +++ b/src/main/java/com/gmail/nossr50/datatypes/player/PlayerProfile.java @@ -1,8 +1,9 @@ package com.gmail.nossr50.datatypes.player; import com.gmail.nossr50.config.experience.ExperienceConfig; +import com.gmail.nossr50.datatypes.experience.DiminishedReturnsCache; +import com.gmail.nossr50.datatypes.experience.DiminishedReturnsState; import com.gmail.nossr50.datatypes.experience.FormulaType; -import com.gmail.nossr50.datatypes.experience.SkillXpGain; import com.gmail.nossr50.datatypes.skills.PrimarySkillType; import com.gmail.nossr50.datatypes.skills.SuperAbilityType; import com.gmail.nossr50.mcMMO; @@ -15,7 +16,6 @@ import java.util.EnumMap; import java.util.Map; import java.util.Objects; import java.util.UUID; -import java.util.concurrent.DelayQueue; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -41,10 +41,8 @@ public class PlayerProfile { private final MapThese tests verify the cache's UUID-keyed persistence semantics and eviction logic.
+ * {@link ExperienceConfig} is mocked so tests do not require a running Minecraft server.
+ * By default DR is enabled; individual tests that need DR disabled override that mock.
+ */
+class DiminishedReturnsCacheTest {
+
+ private MockedStatic