diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index fb874a493..e0091da27 100644 --- a/buildSrc/src/main/kotlin/Versions.kt +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -49,5 +49,7 @@ object Versions { // tests const val JUNIT_BOM = "5.13.4" const val MOCKITO_CORE = "5.20.0" + const val ASSERTJ_CORE = "3.26.3" + const val AWAITILITY = "4.2.1" } diff --git a/eternalcore-api/src/main/java/com/eternalcode/core/delay/Delay.java b/eternalcore-api/src/main/java/com/eternalcode/core/delay/Delay.java deleted file mode 100644 index 8d30ad07c..000000000 --- a/eternalcore-api/src/main/java/com/eternalcode/core/delay/Delay.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.eternalcode.core.delay; - -import com.google.common.cache.Cache; -import com.google.common.cache.CacheBuilder; - -import java.time.Duration; -import java.time.Instant; -import java.util.function.Supplier; - -public class Delay { - - private final Cache delays; - - private final Supplier delaySettings; - - public Delay(Supplier delayProvider) { - this.delaySettings = delayProvider; - - this.delays = CacheBuilder.newBuilder() - .expireAfterWrite(delayProvider.get()) - .build(); - } - - public void markDelay(T key, Duration delay) { - this.delays.put(key, Instant.now().plus(delay)); - } - - public void markDelay(T key) { - this.markDelay(key, this.delaySettings.get()); - } - - public void unmarkDelay(T key) { - this.delays.invalidate(key); - } - - public boolean hasDelay(T key) { - Instant delayExpireMoment = this.getDelayExpireMoment(key); - - return Instant.now().isBefore(delayExpireMoment); - } - - public Duration getDurationToExpire(T key) { - return Duration.between(Instant.now(), this.getDelayExpireMoment(key)); - } - - private Instant getDelayExpireMoment(T key) { - return this.delays.asMap().getOrDefault(key, Instant.MIN); - } - -} diff --git a/eternalcore-core/build.gradle.kts b/eternalcore-core/build.gradle.kts index 3fb9ae09a..c64bd8f02 100644 --- a/eternalcore-core/build.gradle.kts +++ b/eternalcore-core/build.gradle.kts @@ -25,6 +25,8 @@ dependencies { annotationProcessor("org.projectlombok:lombok:${Versions.LOMBOK}") testImplementation("com.eternalcode:eternalcode-commons-bukkit:${Versions.ETERNALCODE_COMMONS}") + testImplementation("org.assertj:assertj-core:${Versions.ASSERTJ_CORE}") + testImplementation("org.awaitility:awaitility:${Versions.AWAITILITY}") } eternalShadow { diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/delay/Delay.java b/eternalcore-core/src/main/java/com/eternalcode/core/delay/Delay.java new file mode 100644 index 000000000..1f6b529a2 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/delay/Delay.java @@ -0,0 +1,136 @@ +package com.eternalcode.core.delay; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; + +import java.time.Duration; +import java.time.Instant; +import java.util.function.Supplier; + +/** + * Provides per-entry delay management using Caffeine with wall-clock based expiration. + * + * @param the key type + */ +public class Delay { + + private static final long DEFAULT_MAXIMUM_SIZE = 50_000L; + + private final Cache cache; + private final Supplier defaultDelay; + + /** + * Creates a new delay with a default delay supplier and cache size limit. + * + * @param defaultDelay supplier providing default delay durations + * @param maximumSize maximum number of cached entries + */ + private Delay(Supplier defaultDelay, long maximumSize) { + if (defaultDelay == null) { + throw new IllegalArgumentException("defaultDelay cannot be null"); + } + + if (maximumSize <= 0) { + throw new IllegalArgumentException("maximumSize must be > 0"); + } + + this.defaultDelay = defaultDelay; + this.cache = Caffeine.newBuilder() + .maximumSize(maximumSize) + .expireAfter(new InstantExpiry()) + .build(); + } + + /** + * Creates a new delay manager with the default maximum cache size. + * + * @param defaultDelay supplier providing default delay durations + */ + private Delay(Supplier defaultDelay) { + this(defaultDelay, DEFAULT_MAXIMUM_SIZE); + } + + /** + * Marks a delay for the given key using a specific duration. + * + * @param key the key to delay + * @param delay the duration of the delay + */ + public void markDelay(T key, Duration delay) { + if (delay.isZero() || delay.isNegative()) { + this.cache.invalidate(key); + } + + this.cache.put(key, Instant.now().plus(delay)); + } + + /** + * Marks a delay for the given key using the default duration. + * + * @param key the key to delay + */ + public void markDelay(T key) { + this.markDelay(key, this.defaultDelay.get()); + } + + /** + * Removes any existing delay for the given key. + * + * @param key the key to clear + */ + public void unmarkDelay(T key) { + this.cache.invalidate(key); + } + + /** + * Checks whether the given key currently has an active delay. + * + * @param key the key to check + * @return true if the delay is active, false otherwise + */ + public boolean hasDelay(T key) { + Instant delayExpireMoment = this.getExpireAt(key); + return Instant.now().isBefore(delayExpireMoment); + } + + /** + * Returns the remaining delay duration for the given key. + * + * @param key the key to check + * @return the remaining duration, or {@code Duration.ZERO} if expired + */ + public Duration getRemaining(T key) { + return Duration.between(Instant.now(), this.getExpireAt(key)); + } + + /** + * Returns the expiration instant for the given key. + * + * @param key the key to check + * @return the expiration instant, or {@code Instant.MIN} if none + */ + private Instant getExpireAt(T key) { + return this.cache.asMap().getOrDefault(key, Instant.MIN); + } + + /** + * Creates a new {@link Delay} instance with a default delay supplier. + * + * @param defaultDelay supplier providing default delay durations + * @return a new Delay instance + */ + public static Delay withDefault(Supplier defaultDelay) { + return new Delay<>(defaultDelay); + } + + /** + * Creates a new {@link Delay} instance with a default delay supplier and cache size. + * + * @param defaultDelay supplier providing default delay durations + * @param maximumSize maximum number of cached entries + * @return a new Delay instance + */ + public static Delay withDefault(Supplier defaultDelay, long maximumSize) { + return new Delay<>(defaultDelay, maximumSize); + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/delay/InstantExpiry.java b/eternalcore-core/src/main/java/com/eternalcode/core/delay/InstantExpiry.java new file mode 100644 index 000000000..168c92b51 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/delay/InstantExpiry.java @@ -0,0 +1,39 @@ +package com.eternalcode.core.delay; + +import com.github.benmanes.caffeine.cache.Expiry; +import java.time.Duration; +import java.time.Instant; +import org.jetbrains.annotations.NotNull; + +class InstantExpiry implements Expiry<@NotNull T, @NotNull Instant> { + + @Override + public long expireAfterCreate(@NotNull T key, @NotNull Instant expireTime, long currentTime) { + return timeToExpire(expireTime); + } + + @Override + public long expireAfterUpdate(@NotNull T key, @NotNull Instant newExpireTime, long currentTime, long currentDuration) { + return timeToExpire(newExpireTime); + } + + @Override + public long expireAfterRead(@NotNull T key, @NotNull Instant value, long currentTime, long currentDuration) { + return currentDuration; + } + + private static long timeToExpire(Instant expireTime) { + Duration toExpire = Duration.between(Instant.now(), expireTime); + if (toExpire.isNegative()) { + return 0; + } + + long nanos = toExpire.toNanos(); + if (nanos == 0) { + return 1; + } + + return nanos; + } + +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/afk/AfkCommand.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/afk/AfkCommand.java index d37aa2270..c852138ac 100644 --- a/eternalcore-core/src/main/java/com/eternalcode/core/feature/afk/AfkCommand.java +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/afk/AfkCommand.java @@ -37,7 +37,7 @@ class AfkCommand { this.noticeService = noticeService; this.afkSettings = afkSettings; this.afkService = afkService; - this.delay = new Delay<>(() -> this.afkSettings.afkCommandDelay()); + this.delay = Delay.withDefault(() -> this.afkSettings.afkCommandDelay()); } @Execute @@ -51,7 +51,7 @@ void execute(@Sender Player player) { } if (this.delay.hasDelay(uuid)) { - Duration time = this.delay.getDurationToExpire(uuid); + Duration time = this.delay.getRemaining(uuid); this.noticeService .create() @@ -64,6 +64,6 @@ void execute(@Sender Player player) { } this.afkService.switchAfk(uuid, AfkReason.COMMAND); - this.delay.markDelay(uuid, this.afkSettings.afkCommandDelay()); + this.delay.markDelay(uuid); } } diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/helpop/HelpOpCommand.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/helpop/HelpOpCommand.java index ad77e0d31..924c9746c 100644 --- a/eternalcore-core/src/main/java/com/eternalcode/core/feature/helpop/HelpOpCommand.java +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/helpop/HelpOpCommand.java @@ -42,7 +42,7 @@ class HelpOpCommand { this.helpOpSettings = helpOpSettings; this.eventCaller = eventCaller; this.server = server; - this.delay = new Delay<>(() -> this.helpOpSettings.helpOpDelay()); + this.delay = Delay.withDefault(() -> this.helpOpSettings.helpOpDelay()); } @Execute @@ -58,7 +58,7 @@ void execute(@Sender Player player, @Join String message) { } if (this.delay.hasDelay(uuid)) { - Duration time = this.delay.getDurationToExpire(uuid); + Duration time = this.delay.getRemaining(uuid); this.noticeService.create() .notice(translation -> translation.helpOp().helpOpDelay()) @@ -91,7 +91,7 @@ void execute(@Sender Player player, @Join String message) { .notice(translation -> translation.helpOp().send()) .send(); - this.delay.markDelay(uuid, this.helpOpSettings.helpOpDelay()); + this.delay.markDelay(uuid); } } diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/randomteleport/RandomTeleportCommand.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/randomteleport/RandomTeleportCommand.java index 201a96bc1..a2b1f7af8 100644 --- a/eternalcore-core/src/main/java/com/eternalcode/core/feature/randomteleport/RandomTeleportCommand.java +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/randomteleport/RandomTeleportCommand.java @@ -40,7 +40,7 @@ class RandomTeleportCommand { this.randomTeleportService = randomTeleportService; this.randomTeleportTaskService = randomTeleportTaskService; this.randomTeleportSettings = randomTeleportSettings; - this.cooldown = new Delay<>(() -> this.randomTeleportSettings.cooldown()); + this.cooldown = Delay.withDefault(() -> this.randomTeleportSettings.cooldown()); } @Execute @@ -68,7 +68,7 @@ void executeSelf(@Sender Player player) { this.handleTeleportSuccess(player); }); - this.cooldown.markDelay(uuid, this.randomTeleportSettings.cooldown()); + this.cooldown.markDelay(uuid); } @Execute @@ -96,7 +96,7 @@ void executeOther(@Sender Viewer sender, @Arg Player player) { this.handleAdminTeleport(sender, player); }); - this.cooldown.markDelay(uuid, this.randomTeleportSettings.cooldown()); + this.cooldown.markDelay(uuid); } private void handleTeleportSuccess(Player player) { @@ -129,7 +129,7 @@ private boolean hasRandomTeleportDelay(Player player) { } if (this.cooldown.hasDelay(uniqueId)) { - Duration time = this.cooldown.getDurationToExpire(uniqueId); + Duration time = this.cooldown.getRemaining(uniqueId); this.noticeService.create() .notice(translation -> translation.randomTeleport().randomTeleportDelay()) diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/repair/RepairCommand.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/repair/RepairCommand.java index 3fa32d1c1..df1c243d5 100644 --- a/eternalcore-core/src/main/java/com/eternalcode/core/feature/repair/RepairCommand.java +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/repair/RepairCommand.java @@ -29,7 +29,7 @@ class RepairCommand { RepairCommand(NoticeService noticeService, RepairSettings repairSettings) { this.noticeService = noticeService; this.repairSettings = repairSettings; - this.delay = new Delay<>(() -> this.repairSettings.repairDelay()); + this.delay = Delay.withDefault(() -> this.repairSettings.repairDelay()); } @Execute @@ -73,7 +73,7 @@ void repair(@Sender Player player) { .player(player.getUniqueId()) .send(); - this.delay.markDelay(uuid, this.repairSettings.repairDelay()); + this.delay.markDelay(uuid); } @Execute(name = "all") @@ -117,7 +117,7 @@ void repairAll(@Sender Player player) { .player(player.getUniqueId()) .send(); - this.delay.markDelay(uuid, this.repairSettings.repairDelay()); + this.delay.markDelay(uuid); } @Execute(name = "armor") @@ -161,12 +161,12 @@ void repairArmor(@Sender Player player) { .player(player.getUniqueId()) .send(); - this.delay.markDelay(uuid, this.repairSettings.repairDelay()); + this.delay.markDelay(uuid); } private boolean hasRepairDelay(UUID uuid) { if (this.delay.hasDelay(uuid)) { - Duration time = this.delay.getDurationToExpire(uuid); + Duration time = this.delay.getRemaining(uuid); this.noticeService .create() diff --git a/eternalcore-core/src/test/java/com/eternalcode/core/delay/DelayTest.java b/eternalcore-core/src/test/java/com/eternalcode/core/delay/DelayTest.java new file mode 100644 index 000000000..c371af443 --- /dev/null +++ b/eternalcore-core/src/test/java/com/eternalcode/core/delay/DelayTest.java @@ -0,0 +1,125 @@ +package com.eternalcode.core.delay; + +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.UUID; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class DelayTest { + + @Test + void shouldExpireAfterDefaultDelay() { + Delay delay = Delay.withDefault(() -> Duration.ofMillis(500)); + UUID key = UUID.randomUUID(); + + delay.markDelay(key); + assertThat(delay.hasDelay(key)).isTrue(); + + await() + .pollDelay(250, MILLISECONDS) + .atMost(500, MILLISECONDS) + .until(() -> delay.hasDelay(key)); + + await() + .atMost(Duration.ofMillis(350)) // After previously await (600 ms - 900 ms) + .until(() -> !delay.hasDelay(key)); + } + + @Test + void shouldDoNotExpireBeforeCustomDelay() { + Delay delay = Delay.withDefault(() -> Duration.ofMillis(500)); + UUID key = UUID.randomUUID(); + + delay.markDelay(key, Duration.ofMillis(1000)); + assertThat(delay.hasDelay(key)).isTrue(); + + await() + .pollDelay(500, MILLISECONDS) + .atMost(1000, MILLISECONDS) + .until(() -> delay.hasDelay(key)); + + await() + .atMost(600, MILLISECONDS) // After previously await (1100 ms - 1600 ms) + .until(() -> !delay.hasDelay(key)); + } + + @Test + void shouldUnmarkDelay() { + Delay delay = Delay.withDefault(() -> Duration.ofMillis(500)); + UUID key = UUID.randomUUID(); + + delay.markDelay(key); + assertThat(delay.hasDelay(key)).isTrue(); + + delay.unmarkDelay(key); + assertThat(delay.hasDelay(key)).isFalse(); + } + + @Test + void shouldNotHaveDelayOnNonExistentKey() { + Delay delay = Delay.withDefault(() -> Duration.ofMillis(500)); + UUID key = UUID.randomUUID(); + + assertThat(delay.hasDelay(key)).isFalse(); + } + + @Test + void shouldReturnCorrectRemainingTime() { + Delay delay = Delay.withDefault(() -> Duration.ofMillis(500)); + UUID key = UUID.randomUUID(); + + delay.markDelay(key, Duration.ofMillis(1000)); + + // Immediately after marking, remaining time should be close to the full delay + assertThat(delay.getRemaining(key)).isCloseTo(Duration.ofMillis(1000), Duration.ofMillis(150)); + + // Wait for some time + await() + .pollDelay(400, MILLISECONDS) + .atMost(550, MILLISECONDS) + .untilAsserted(() -> { + // After 400ms, remaining time should be less than the original + assertThat(delay.getRemaining(key)).isLessThan(Duration.ofMillis(1000).minus(Duration.ofMillis(300))); + }); + + await() + .atMost(Duration.ofMillis(1000).plus(Duration.ofMillis(150))) + .until(() -> !delay.hasDelay(key)); + + // After expiration, remaining time should be negative + assertThat(delay.getRemaining(key)).isNegative(); + } + + @Test + void shouldHandleMultipleKeysIndependently() { + Delay delay = Delay.withDefault(() -> Duration.ofMillis(500)); + UUID shortTimeKey = UUID.randomUUID(); // 500ms + UUID longTimeKey = UUID.randomUUID(); // 1000ms + + delay.markDelay(shortTimeKey); + delay.markDelay(longTimeKey, Duration.ofMillis(1000)); + + assertThat(delay.hasDelay(shortTimeKey)).isTrue(); + assertThat(delay.hasDelay(longTimeKey)).isTrue(); + + // Wait for the first key to expire + await() + .atMost(Duration.ofMillis(500).plus(Duration.ofMillis(150))) + .until(() -> !delay.hasDelay(shortTimeKey)); + + // After first key expires, second should still be active + assertThat(delay.hasDelay(shortTimeKey)).isFalse(); + assertThat(delay.hasDelay(longTimeKey)).isTrue(); + + // Wait for the second key to expire + await() + .atMost(Duration.ofMillis(1000)) + .until(() -> !delay.hasDelay(longTimeKey)); + + assertThat(delay.hasDelay(longTimeKey)).isFalse(); + } +}