From 200d41a9490b2f4b8b4f55e8339fc1234701d4be Mon Sep 17 00:00:00 2001 From: ez-plugins Date: Fri, 22 May 2026 14:15:51 +0200 Subject: [PATCH 01/14] fix: duration countdown resets to full duration on reload resumeRunningCountdowns() now calls handler.ensureTarget() instead of handler.onStart(), which is a no-op when a target is already present. The same guard was added to the legacy fallback path for handler-less countdown types. End-commands that fired once before a reload will no longer fire again unexpectedly. Bumped version to 1.4.3. --- CHANGELOG.md | 8 +- pom.xml | 3 +- .../bootstrap/PluginBootstrap.java | 10 + .../ezcountdown/bootstrap/Registry.java | 3 + .../ezcountdown/config/ConfigService.java | 4 + .../ezcountdown/manager/CountdownManager.java | 32 ++- .../ezcountdown/manager/DisplayManager.java | 7 +- src/main/resources/config.yml | 6 + .../manager/CountdownManagerResumeTest.java | 201 ++++++++++++++++++ .../org.mockito.plugins.MockMaker | 1 + 10 files changed, 267 insertions(+), 8 deletions(-) create mode 100644 src/test/java/com/skyblockexp/ezcountdown/manager/CountdownManagerResumeTest.java create mode 100644 src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker diff --git a/CHANGELOG.md b/CHANGELOG.md index b2b3a6a..b6ee4a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [1.4.2] - 2026-05-16 +## [1.4.3] - 2026-05-16 + +### Fixed + +- **DURATION countdown resets to full duration on `/countdown reload`** — `resumeRunningCountdowns()` previously called `handler.onStart()`, which always sets `targetInstant` to `now + fullDuration`, discarding the `target_epoch` saved in storage. It now calls `handler.ensureTarget()` instead, which is a no-op when a target is already present. The same guard was added to the legacy fallback path for handler-less countdown types. End-commands that fired once before a reload will no longer fire again unexpectedly due to the countdown silently restarting. + + ### Added diff --git a/pom.xml b/pom.xml index 2831ec4..8129663 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ com.github.ez-plugins ezcountdown - 1.4.2 + 1.4.3 EzCountdown Plugin Configurable countdowns for server launches, events, and maintenance windows. jar @@ -176,6 +176,7 @@ 3.0.0-M7 false + -Dnet.bytebuddy.experimental=true **/*FeatureTest.java diff --git a/src/main/java/com/skyblockexp/ezcountdown/bootstrap/PluginBootstrap.java b/src/main/java/com/skyblockexp/ezcountdown/bootstrap/PluginBootstrap.java index 9dd21e1..5fe56da 100644 --- a/src/main/java/com/skyblockexp/ezcountdown/bootstrap/PluginBootstrap.java +++ b/src/main/java/com/skyblockexp/ezcountdown/bootstrap/PluginBootstrap.java @@ -82,6 +82,9 @@ public static Registry start(EzCountdownPlugin plugin) { yamlStorage.setHandlerRegistry(registry.handlersMap()); } + registry.setDebug(configService.loadDebug()); + displayManager.setDebug(registry.debug()); + CountdownManager countdownManager = new CountdownManager(registry, discordWebhookConfig, storage, displayManager, messageManager, locationManager); // register into registry registry.setCountdownManager(countdownManager); @@ -142,6 +145,13 @@ public static Registry start(EzCountdownPlugin plugin) { plugin.getLogger().log(java.util.logging.Level.WARNING, "Failed to reload defaults or permissions", ex); } + try { + registry.setDebug(configService.loadDebug()); + displayManager.setDebug(registry.debug()); + } catch (Exception ex) { + plugin.getLogger().log(java.util.logging.Level.WARNING, "Failed to reload debug flag", ex); + } + try { // refresh display manager configuration (clears handlers) displayManager.reload(configService); diff --git a/src/main/java/com/skyblockexp/ezcountdown/bootstrap/Registry.java b/src/main/java/com/skyblockexp/ezcountdown/bootstrap/Registry.java index de23e1d..4117f5a 100644 --- a/src/main/java/com/skyblockexp/ezcountdown/bootstrap/Registry.java +++ b/src/main/java/com/skyblockexp/ezcountdown/bootstrap/Registry.java @@ -32,6 +32,7 @@ public final class Registry { private EzCountdownPlaceholderExpansion placeholderExpansion; private EzCountdownApi api; private final Map handlers = new EnumMap<>(CountdownType.class); + private volatile boolean debug = false; public Registry(EzCountdownPlugin plugin, MessageManager messageManager, CountdownDefaults defaults, CountdownPermissions permissions, DisplayManager displayManager, CountdownStorage storage, LocationManager locationManager, LocationPermissions locationPermissions, CountdownManager countdownManager, GuiManager guiManager) { this.plugin = plugin; @@ -91,6 +92,8 @@ public java.util.Map handlersMap() { public LocationManager locations() { return locationManager; } public LocationPermissions locationPermissions() { return locationPermissions; } public CountdownManager countdowns() { return countdownManager; } + public boolean debug() { return debug; } + public void setDebug(boolean debug) { this.debug = debug; } public GuiManager gui() { return guiManager; } diff --git a/src/main/java/com/skyblockexp/ezcountdown/config/ConfigService.java b/src/main/java/com/skyblockexp/ezcountdown/config/ConfigService.java index c5989ba..3196fad 100644 --- a/src/main/java/com/skyblockexp/ezcountdown/config/ConfigService.java +++ b/src/main/java/com/skyblockexp/ezcountdown/config/ConfigService.java @@ -113,6 +113,10 @@ public TimeFormat.FormatConfig loadTimeFormatConfig() { return new TimeFormat.FormatConfig(pattern, hideLeadingZeros); } + public boolean loadDebug() { + return plugin.getConfig().getBoolean("debug", false); + } + private void ensureResource(String name) { File file = new File(plugin.getDataFolder(), name); if (!file.exists()) plugin.saveResource(name, false); diff --git a/src/main/java/com/skyblockexp/ezcountdown/manager/CountdownManager.java b/src/main/java/com/skyblockexp/ezcountdown/manager/CountdownManager.java index 7fbc5b8..3102e43 100644 --- a/src/main/java/com/skyblockexp/ezcountdown/manager/CountdownManager.java +++ b/src/main/java/com/skyblockexp/ezcountdown/manager/CountdownManager.java @@ -171,9 +171,11 @@ public CountdownTypeHandler getHandler(CountdownType type) { public boolean createCountdown(Countdown countdown) { String key = normalizeName(countdown.getName()); if (countdowns.containsKey(key)) { + if (registry.debug()) registry.plugin().getLogger().info("[debug] createCountdown: '" + countdown.getName() + "' already exists — skipped"); return false; } countdowns.put(key, countdown); + if (registry.debug()) registry.plugin().getLogger().info("[debug] createCountdown: '" + countdown.getName() + "' type=" + countdown.getType() + " running=" + countdown.isRunning() + " target=" + countdown.getTargetInstant()); if (countdown.isRunning()) { CountdownTypeHandler handler = registry.getHandler(countdown.getType()); if (handler != null) { @@ -191,9 +193,11 @@ public boolean createCountdown(Countdown countdown) { public boolean deleteCountdown(String name) { Countdown removed = countdowns.remove(normalizeName(name)); if (removed != null) { + if (registry.debug()) registry.plugin().getLogger().info("[debug] deleteCountdown: '" + name + "'"); displayManager.clearCountdown(removed); return true; } + if (registry.debug()) registry.plugin().getLogger().info("[debug] deleteCountdown: '" + name + "' not found"); return false; } @@ -211,11 +215,14 @@ public boolean updateCountdown(String name, Countdown updated) { public boolean startCountdown(String name) { Countdown countdown = countdowns.get(normalizeName(name)); if (countdown == null) { + if (registry.debug()) registry.plugin().getLogger().info("[debug] startCountdown: '" + name + "' not found"); return false; } if (countdown.isRunning()) { + if (registry.debug()) registry.plugin().getLogger().info("[debug] startCountdown: '" + name + "' already running"); return true; } + if (registry.debug()) registry.plugin().getLogger().info("[debug] startCountdown: '" + name + "'"); // persist change only if state actually changes countdown.setRunning(true); CountdownTypeHandler handler = registry.getHandler(countdown.getType()); @@ -236,11 +243,14 @@ public boolean startCountdown(String name) { public boolean stopCountdown(String name) { Countdown countdown = countdowns.get(normalizeName(name)); if (countdown == null) { + if (registry.debug()) registry.plugin().getLogger().info("[debug] stopCountdown: '" + name + "' not found"); return false; } if (!countdown.isRunning()) { + if (registry.debug()) registry.plugin().getLogger().info("[debug] stopCountdown: '" + name + "' already stopped"); return true; } + if (registry.debug()) registry.plugin().getLogger().info("[debug] stopCountdown: '" + name + "'"); countdown.setRunning(false); CountdownTypeHandler handler = registry.getHandler(countdown.getType()); if (handler != null) { @@ -269,18 +279,26 @@ public void resumeRunningCountdowns() { Instant now = Instant.now(); for (Countdown countdown : countdowns.values()) { if (!countdown.isRunning()) continue; + if (registry.debug()) registry.plugin().getLogger().info("[debug] resumeRunningCountdowns: '" + countdown.getName() + "' target=" + countdown.getTargetInstant()); CountdownTypeHandler handler = registry.getHandler(countdown.getType()); if (handler != null) { try { - handler.onStart(countdown, now); + // Use ensureTarget instead of onStart so that a target already restored + // from storage (target_epoch) is preserved rather than being reset to the + // full duration. ensureTarget is a no-op when targetInstant is non-null. + handler.ensureTarget(countdown, now); } catch (Exception ex) { registry.plugin().getLogger().log(java.util.logging.Level.WARNING, "Error while resuming countdown handler", ex); } } else { - if (countdown.getType() == CountdownType.DURATION || countdown.getType() == CountdownType.MANUAL) { - countdown.setTargetInstant(now.plusSeconds(countdown.getDurationSeconds())); - } else if (countdown.getType() == CountdownType.RECURRING) { - countdown.setTargetInstant(countdown.resolveNextRecurringTarget(now)); + // Legacy fallback for types without a registered handler: only set the target + // if it is absent so that persisted remaining time is not discarded. + if (countdown.getTargetInstant() == null) { + if (countdown.getType() == CountdownType.DURATION || countdown.getType() == CountdownType.MANUAL) { + countdown.setTargetInstant(now.plusSeconds(countdown.getDurationSeconds())); + } else if (countdown.getType() == CountdownType.RECURRING) { + countdown.setTargetInstant(countdown.resolveNextRecurringTarget(now)); + } } } // force an immediate update on next tick @@ -361,8 +379,10 @@ private void tick() { Instant lastEnd = lastEndAt.getOrDefault(nameKey, Instant.EPOCH); if (lastEnd.isAfter(now.minusSeconds(1))) { // recent end already executed + if (registry.debug()) registry.plugin().getLogger().info("[debug] tick: debounce suppressed end for '" + countdown.getName() + "' (last end: " + lastEnd + ")"); continue; } + if (registry.debug()) registry.plugin().getLogger().info("[debug] tick: expiry detected for '" + countdown.getName() + "', acquiring end lock"); // mark last end now to prevent very close duplicates lastEndAt.put(nameKey, now); @@ -433,6 +453,7 @@ private String buildMessage(Countdown countdown, long remaining) { } private void fireStart(Countdown countdown) { + if (registry.debug()) registry.plugin().getLogger().info("[debug] fireStart: '" + countdown.getName() + "'"); String message = countdown.getStartMessage(); if (message != null && !message.isBlank()) { displayManager.broadcastMessage(messageManager.formatWithPrefix(message, @@ -465,6 +486,7 @@ private void fireTick(Countdown countdown, long remaining) { } private void fireEnd(Countdown countdown) { + if (registry.debug()) registry.plugin().getLogger().info("[debug] fireEnd: '" + countdown.getName() + "'"); String message = countdown.getEndMessage(); if (message != null && !message.isBlank()) { displayManager.broadcastMessage(messageManager.formatWithPrefix(message, diff --git a/src/main/java/com/skyblockexp/ezcountdown/manager/DisplayManager.java b/src/main/java/com/skyblockexp/ezcountdown/manager/DisplayManager.java index 0a21e6a..acd60b7 100644 --- a/src/main/java/com/skyblockexp/ezcountdown/manager/DisplayManager.java +++ b/src/main/java/com/skyblockexp/ezcountdown/manager/DisplayManager.java @@ -30,6 +30,7 @@ public final class DisplayManager { private int bossbarRefreshTicks = 1; private int scoreboardRefreshTicks = 1; private long tickCount = 0; + private volatile boolean debug = false; public DisplayManager(com.skyblockexp.ezcountdown.config.ConfigService configService) { this.bossbarRefreshTicks = configService.loadBossbarRefreshTicks(); @@ -114,6 +115,8 @@ public void reload(com.skyblockexp.ezcountdown.config.ConfigService configServic configureHandlers(configService); } + public void setDebug(boolean debug) { this.debug = debug; } + public void display(Countdown countdown, String message, long remainingSeconds) { // Do not show displays for countdowns that reached zero if (remainingSeconds <= 0L) return; @@ -208,7 +211,9 @@ public void displayAll(java.util.Collection countdowns, java.util.Map } public void broadcastMessage(String message) { - for (Player player : Bukkit.getOnlinePlayers()) { + java.util.Collection online = Bukkit.getOnlinePlayers(); + if (debug) Bukkit.getLogger().info("[EzCountdown debug] broadcastMessage to " + online.size() + " player(s): " + message); + for (Player player : online) { player.sendMessage(message); } } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 3a75574..cb7b04a 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -53,3 +53,9 @@ display-overrides: scoreboard: false boss_bar: false dialog: false + +# Enable verbose debug logging for diagnosing countdown lifecycle events. +# When true, the server console will log each countdown create, start, stop, delete, +# tick expiry, debounce suppression, start/end broadcast, and message dispatch. +# Leave false in production; reload is not required — the flag is applied on next /countdown reload. +debug: false diff --git a/src/test/java/com/skyblockexp/ezcountdown/manager/CountdownManagerResumeTest.java b/src/test/java/com/skyblockexp/ezcountdown/manager/CountdownManagerResumeTest.java new file mode 100644 index 0000000..3b38a6c --- /dev/null +++ b/src/test/java/com/skyblockexp/ezcountdown/manager/CountdownManagerResumeTest.java @@ -0,0 +1,201 @@ +package com.skyblockexp.ezcountdown.manager; + +import com.skyblockexp.ezcountdown.bootstrap.Registry; +import com.skyblockexp.ezcountdown.storage.CountdownStorage; +import com.skyblockexp.ezcountdown.api.model.Countdown; +import com.skyblockexp.ezcountdown.api.model.CountdownType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.util.EnumSet; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Regression tests for: + * 1. resumeRunningCountdowns() reset bug — DURATION countdown's remaining time is + * discarded on reload because the handler's onStart() is called unconditionally, + * overwriting the target_epoch that was correctly restored from storage. + * 2. End commands running more than once per countdown expiry cycle. + */ +public class CountdownManagerResumeTest { + + private CountdownManager manager; + private Registry registry; + private CountdownStorage storage; + private DisplayManager displayManager; + private MessageManager messageManager; + private LocationManager locationManager; + private AtomicInteger dispatchCount; + + @BeforeEach + public void setup() { + registry = mock(Registry.class); + storage = mock(CountdownStorage.class); + displayManager = mock(DisplayManager.class); + messageManager = mock(MessageManager.class); + locationManager = mock(LocationManager.class); + + com.skyblockexp.ezcountdown.EzCountdownPlugin plugin = mock(com.skyblockexp.ezcountdown.EzCountdownPlugin.class); + when(registry.plugin()).thenReturn(plugin); + when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getLogger("test-resume")); + when(plugin.getDataFolder()).thenReturn(new java.io.File("target")); + + org.bukkit.Server bukkitServer = mock(org.bukkit.Server.class); + org.bukkit.plugin.PluginManager pm = mock(org.bukkit.plugin.PluginManager.class); + when(bukkitServer.getPluginManager()).thenReturn(pm); + + dispatchCount = new AtomicInteger(0); + try { + when(bukkitServer.dispatchCommand(any(org.bukkit.command.CommandSender.class), anyString())) + .thenAnswer(inv -> { dispatchCount.incrementAndGet(); return true; }); + } catch (Exception ignored) {} + + org.bukkit.command.ConsoleCommandSender console = mock(org.bukkit.command.ConsoleCommandSender.class); + when(bukkitServer.getConsoleSender()).thenReturn(console); + + try { + java.lang.reflect.Field serverField = org.bukkit.Bukkit.class.getDeclaredField("server"); + serverField.setAccessible(true); + serverField.set(null, bukkitServer); + } catch (Exception e) { + e.printStackTrace(); + } + + manager = new CountdownManager(registry, null, storage, displayManager, messageManager, locationManager); + } + + /** + * Regression: resumeRunningCountdowns() was calling handler.onStart() (or the legacy + * fallback) unconditionally, which resets targetInstant to now + fullDuration every + * reload — discarding the remaining time saved as target_epoch. + * + *

After the fix, resumeRunningCountdowns() must NOT overwrite an already-set + * targetInstant; it should behave like ensureTarget() (no-op when target is present). + */ + @Test + public void resumeRunningCountdowns_preservesRemainingTimeForDuration() throws Exception { + long fullDuration = 60L; + long remainingSeconds = 30L; // halfway through + + Countdown c = new Countdown("reload-test", CountdownType.DURATION, + EnumSet.noneOf(com.skyblockexp.ezcountdown.display.DisplayType.class), + 1, null, "{formatted}", "start", "end", List.of(), ZoneId.systemDefault()); + c.setDurationSeconds(fullDuration); + c.setRunning(true); + manager.createCountdown(c); + // Set the target AFTER createCountdown so the legacy fallback inside createCountdown + // (which has no registered handler) does not overwrite the value we want to test. + Instant savedTarget = Instant.now().plusSeconds(remainingSeconds); + c.setTargetInstant(savedTarget); + + // Simulate the reload path: after load() correctly restores the target, + // resumeRunningCountdowns() is called and must NOT reset it. + manager.resumeRunningCountdowns(); + + Countdown after = manager.getCountdown("reload-test").orElseThrow(); + assertNotNull(after.getTargetInstant(), "targetInstant must remain non-null after resume"); + + long remainingAfter = Duration.between(Instant.now(), after.getTargetInstant()).toSeconds(); + + // After the fix: remaining should still be ~30s, not reset to ~60s. + assertTrue(remainingAfter <= remainingSeconds + 3, + "BUG: resumeRunningCountdowns reset DURATION countdown to full duration. " + + "Expected ~" + remainingSeconds + "s remaining but got " + remainingAfter + "s."); + assertTrue(remainingAfter >= remainingSeconds - 3, + "Remaining time dropped unexpectedly: " + remainingAfter + "s"); + } + + /** + * Regression: end commands must fire exactly once when a DURATION countdown expires, + * even if tick() is called multiple times after the expiry. + */ + @Test + public void endCommands_fireExactlyOncePerExpiry() throws Exception { + Countdown c = new Countdown("cmd-once", CountdownType.DURATION, + EnumSet.noneOf(com.skyblockexp.ezcountdown.display.DisplayType.class), + 1, null, "{formatted}", "start", "end", + List.of("/say done"), ZoneId.systemDefault()); + c.setDurationSeconds(10); + c.setRunning(true); + manager.createCountdown(c); + // Override the target set by createCountdown's legacy fallback so the countdown + // appears already expired when tick() is invoked. + c.setTargetInstant(Instant.now().minusSeconds(2)); + + java.lang.reflect.Method tick = CountdownManager.class.getDeclaredMethod("tick"); + tick.setAccessible(true); + + // First tick — end should be handled, commands dispatched once. + tick.invoke(manager); + assertEquals(1, dispatchCount.get(), "command should be dispatched exactly once on first tick"); + + // Second tick — countdown is no longer running; commands must NOT fire again. + tick.invoke(manager); + assertEquals(1, dispatchCount.get(), "command must NOT be dispatched a second time on subsequent tick"); + } + + /** + * Regression: the end-message broadcast must be sent exactly once per countdown + * expiry, even if tick() is called multiple times after the expiry. + */ + @Test + public void endMessage_broadcastedExactlyOncePerExpiry() throws Exception { + Countdown c = new Countdown("msg-once", CountdownType.DURATION, + EnumSet.noneOf(com.skyblockexp.ezcountdown.display.DisplayType.class), + 1, null, "{formatted}", "start-msg", "end-msg", + List.of(), ZoneId.systemDefault()); + c.setDurationSeconds(10); + c.setRunning(true); + manager.createCountdown(c); + // Override target so the countdown is already expired when tick() runs. + c.setTargetInstant(Instant.now().minusSeconds(2)); + + // Clear call history accumulated during createCountdown (e.g., fireStart broadcast). + reset(displayManager); + + java.lang.reflect.Method tick = CountdownManager.class.getDeclaredMethod("tick"); + tick.setAccessible(true); + + // First tick — end must be handled and message broadcast exactly once. + tick.invoke(manager); + verify(displayManager, times(1)).broadcastMessage(any()); + + // Second tick — countdown is stopped; message must NOT be broadcast again. + tick.invoke(manager); + verify(displayManager, times(1)).broadcastMessage(any()); // still 1 + } + + /** + * Regression: after a simulated reload (resumeRunningCountdowns() + tick()), + * commands for a DURATION countdown that had already ended must NOT fire again. + */ + @Test + public void endCommands_doNotFireAgainAfterReload() throws Exception { + Countdown c = new Countdown("cmd-reload", CountdownType.DURATION, + EnumSet.noneOf(com.skyblockexp.ezcountdown.display.DisplayType.class), + 1, null, "{formatted}", "start", "end", + List.of("/say finished"), ZoneId.systemDefault()); + c.setDurationSeconds(10); + c.setRunning(false); // countdown already ended, saved state + c.setTargetInstant(null); + manager.createCountdown(c); + + // Simulate reload: resumeRunningCountdowns skips non-running countdowns + manager.resumeRunningCountdowns(); + + java.lang.reflect.Method tick = CountdownManager.class.getDeclaredMethod("tick"); + tick.setAccessible(true); + tick.invoke(manager); + + assertEquals(0, dispatchCount.get(), + "commands must not fire for a countdown that is not running after reload"); + } +} diff --git a/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000..1f0955d --- /dev/null +++ b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline From 7f07bb1a616ce6c79d0c5b6a7fa3f49d3b75dda7 Mon Sep 17 00:00:00 2001 From: ez-plugins Date: Fri, 22 May 2026 14:30:26 +0200 Subject: [PATCH 02/14] ci: add smoke tests for Spigot / Paper / Folia on MC 1.21 and 26.1 with Java 21 and 25 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - smoke-papermc: matrix over paper+folia × MC 1.21/26.1 × Java 21/25 Downloads server JARs from api.papermc.io; skips gracefully when a platform/version combo has no builds yet. - smoke-spigot: matrix over MC 1.21/26.1 × Java 21/25 Compiles Spigot via BuildTools (cached by MC×Java); skips gracefully when BuildTools does not yet support the requested version. Both jobs need unit-tests and feature-tests to pass first. --- .github/workflows/ci.yml | 236 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 236 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fd7a5c..29537e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,3 +65,239 @@ jobs: - name: Run feature tests run: mvn -B -Dpaper.version=${{ matrix.paper-version }} -Dmockbukkit.artifactId=${{ matrix.mockbukkit-artifactId }} -Dmockbukkit.version=${{ matrix.mockbukkit-version }} -Pfeature-tests -Dtest=*FeatureTest test + + # ───────────────────────────────────────────────────────────────────────── + # Smoke tests — Paper & Folia (downloaded from api.papermc.io) + # Matrix: platform × mc-prefix × java + # ───────────────────────────────────────────────────────────────────────── + smoke-papermc: + name: "Smoke · ${{ matrix.platform }} / MC ${{ matrix.mc-prefix }} / Java ${{ matrix.java }}" + runs-on: ubuntu-latest + needs: [unit-tests, feature-tests] + strategy: + fail-fast: false + matrix: + platform: [paper, folia] + mc-prefix: ["1.21", "26.1"] + java: [21, 25] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK ${{ matrix.java }} + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: ${{ matrix.java }} + cache: maven + + - name: Build plugin JAR + run: mvn -B -ntp -DskipTests package + + - name: Resolve latest ${{ matrix.platform }} build for MC ${{ matrix.mc-prefix }}.x + id: server + run: | + PLATFORM="${{ matrix.platform }}" + PREFIX="${{ matrix.mc-prefix }}" + VERSIONS_JSON=$(curl -fsSL "https://api.papermc.io/v2/projects/${PLATFORM}") + MC_VERSION=$(echo "$VERSIONS_JSON" | python3 -c "import sys,json; prefix='${PREFIX}'; vs=json.load(sys.stdin)['versions']; matches=[v for v in vs if v==prefix or v.startswith(prefix+'.')]; print(matches[-1] if matches else '')") + if [[ -z "$MC_VERSION" ]]; then + echo "::notice::No ${PLATFORM} builds found matching MC ${PREFIX}.x — skipping." + echo "available=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + BUILD=$(curl -fsSL "https://api.papermc.io/v2/projects/${PLATFORM}/versions/${MC_VERSION}/builds" | \ + python3 -c "import sys,json; builds=json.load(sys.stdin)['builds']; print(builds[-1]['build'])") + JAR_NAME=$(curl -fsSL "https://api.papermc.io/v2/projects/${PLATFORM}/versions/${MC_VERSION}/builds/${BUILD}" | \ + python3 -c "import sys,json; d=json.load(sys.stdin); print(d['downloads']['application']['name'])") + echo "mc_version=${MC_VERSION}" >> "$GITHUB_OUTPUT" + echo "build=${BUILD}" >> "$GITHUB_OUTPUT" + echo "jar_name=${JAR_NAME}" >> "$GITHUB_OUTPUT" + echo "available=true" >> "$GITHUB_OUTPUT" + echo "Resolved: ${PLATFORM} ${MC_VERSION} build ${BUILD} (${JAR_NAME})" + + - name: Download ${{ matrix.platform }} server JAR + if: steps.server.outputs.available == 'true' + run: | + mkdir -p smoke-server/plugins + curl -fsSL \ + "https://api.papermc.io/v2/projects/${{ matrix.platform }}/versions/${{ steps.server.outputs.mc_version }}/builds/${{ steps.server.outputs.build }}/downloads/${{ steps.server.outputs.jar_name }}" \ + -o smoke-server/server.jar + + - name: Copy plugin JAR + if: steps.server.outputs.available == 'true' + run: cp target/EzCountdown-*.jar smoke-server/plugins/EzCountdown.jar + + - name: Configure server + if: steps.server.outputs.available == 'true' + run: | + echo "eula=true" > smoke-server/eula.txt + printf '%s\n' \ + "online-mode=false" \ + "level-type=flat" \ + "generate-structures=false" \ + "max-players=0" \ + > smoke-server/server.properties + + - name: Run server and wait for ready + if: steps.server.outputs.available == 'true' + run: | + cd smoke-server + java -Xms512m -Xmx1g -jar server.jar --nogui > server.log 2>&1 & + SERVER_PID=$! + ELAPSED=0 + while [ $ELAPSED -lt 120 ]; do + sleep 2; ELAPSED=$((ELAPSED + 2)) + if grep -q "Done (" server.log 2>/dev/null; then + echo "Server ready after ${ELAPSED}s"; break + fi + if ! kill -0 $SERVER_PID 2>/dev/null; then + echo "Server process exited after ${ELAPSED}s"; break + fi + done + kill $SERVER_PID 2>/dev/null || true + wait $SERVER_PID 2>/dev/null || true + + - name: Assert no EzCountdown errors + if: steps.server.outputs.available == 'true' + run: | + LOG=smoke-server/server.log + if grep -qiE "SEVERE.*EzCountdown|Could not load.*EzCountdown|Error initialising.*EzCountdown" "$LOG"; then + echo "::error::EzCountdown logged errors on ${{ matrix.platform }} MC ${{ matrix.mc-prefix }} (Java ${{ matrix.java }}):" + grep -iE "SEVERE.*EzCountdown|Could not load.*EzCountdown|Error initialising.*EzCountdown" "$LOG" + exit 1 + fi + if grep -qiE "Enabling EzCountdown|EzCountdown.*enabled" "$LOG"; then + echo "Smoke test passed — EzCountdown loaded on ${{ matrix.platform }} MC ${{ steps.server.outputs.mc_version }} (Java ${{ matrix.java }})." + else + echo "::warning::Plugin load confirmation not found — server may not have finished loading within the timeout." + fi + + - name: Upload server log + if: always() + uses: actions/upload-artifact@v4 + with: + name: smoke-log-${{ matrix.platform }}-mc${{ matrix.mc-prefix }}-java${{ matrix.java }} + path: smoke-server/server.log + if-no-files-found: ignore + retention-days: 7 + + # ───────────────────────────────────────────────────────────────────────── + # Smoke tests — Spigot (compiled via BuildTools, result cached by MC+Java) + # Matrix: mc-prefix × java + # ───────────────────────────────────────────────────────────────────────── + smoke-spigot: + name: "Smoke · spigot / MC ${{ matrix.mc-prefix }} / Java ${{ matrix.java }}" + runs-on: ubuntu-latest + needs: [unit-tests, feature-tests] + strategy: + fail-fast: false + matrix: + mc-prefix: ["1.21", "26.1"] + java: [21, 25] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK ${{ matrix.java }} + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: ${{ matrix.java }} + cache: maven + + - name: Configure Git for BuildTools + run: | + git config --global user.email "ci@github.com" + git config --global user.name "CI" + + - name: Build plugin JAR + run: mvn -B -ntp -DskipTests package + + - name: Restore cached Spigot JAR (MC ${{ matrix.mc-prefix }}, Java ${{ matrix.java }}) + id: spigot-cache + uses: actions/cache@v4 + with: + path: spigot-build/spigot-${{ matrix.mc-prefix }}.jar + key: spigot-jar-mc${{ matrix.mc-prefix }}-java${{ matrix.java }}-v1 + + - name: Build Spigot via BuildTools + if: steps.spigot-cache.outputs.cache-hit != 'true' + id: buildtools + run: | + mkdir -p spigot-build/work + cd spigot-build/work + curl -fsSL \ + "https://hub.spigotmc.org/jenkins/job/BuildTools/lastSuccessfulBuild/artifact/target/BuildTools.jar" \ + -o BuildTools.jar + java -jar BuildTools.jar --rev "${{ matrix.mc-prefix }}" 2>&1 | tee buildtools.log || { + echo "::warning::BuildTools failed for MC ${{ matrix.mc-prefix }} on Java ${{ matrix.java }} — version may not be supported yet." + echo "failed=true" >> "$GITHUB_OUTPUT" + exit 0 + } + SPIGOT_JAR=$(ls spigot-${{ matrix.mc-prefix }}*.jar 2>/dev/null | head -1) + if [[ -z "$SPIGOT_JAR" ]]; then + SPIGOT_JAR=$(ls spigot*.jar 2>/dev/null | grep -v craftbukkit | head -1) + fi + if [[ -n "$SPIGOT_JAR" ]]; then + cp "$SPIGOT_JAR" "../spigot-${{ matrix.mc-prefix }}.jar" + echo "::notice::Cached ${SPIGOT_JAR} as spigot-${{ matrix.mc-prefix }}.jar" + else + echo "::error::BuildTools completed but no Spigot JAR found." + echo "failed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Run Spigot smoke test + run: | + SPIGOT_JAR="spigot-build/spigot-${{ matrix.mc-prefix }}.jar" + if [[ ! -f "$SPIGOT_JAR" ]]; then + echo "::notice::Spigot JAR not available for MC ${{ matrix.mc-prefix }} — smoke test skipped." + exit 0 + fi + mkdir -p smoke-server/plugins + cp "$SPIGOT_JAR" smoke-server/server.jar + cp target/EzCountdown-*.jar smoke-server/plugins/EzCountdown.jar + echo "eula=true" > smoke-server/eula.txt + printf '%s\n' \ + "online-mode=false" \ + "level-type=flat" \ + "generate-structures=false" \ + "max-players=0" \ + > smoke-server/server.properties + cd smoke-server + java -Xms512m -Xmx1g -jar server.jar --nogui > server.log 2>&1 & + SERVER_PID=$! + ELAPSED=0 + while [ $ELAPSED -lt 120 ]; do + sleep 2; ELAPSED=$((ELAPSED + 2)) + if grep -q "Done (" server.log 2>/dev/null; then + echo "Server ready after ${ELAPSED}s"; break + fi + if ! kill -0 $SERVER_PID 2>/dev/null; then + echo "Server process exited after ${ELAPSED}s"; break + fi + done + kill $SERVER_PID 2>/dev/null || true + wait $SERVER_PID 2>/dev/null || true + LOG=server.log + if grep -qiE "SEVERE.*EzCountdown|Could not load.*EzCountdown|Error initialising.*EzCountdown" "$LOG"; then + echo "::error::EzCountdown logged errors on Spigot MC ${{ matrix.mc-prefix }} (Java ${{ matrix.java }}):" + grep -iE "SEVERE.*EzCountdown|Could not load.*EzCountdown|Error initialising.*EzCountdown" "$LOG" + exit 1 + fi + if grep -qiE "Enabling EzCountdown|EzCountdown.*enabled" "$LOG"; then + echo "Smoke test passed — EzCountdown loaded on Spigot MC ${{ matrix.mc-prefix }} (Java ${{ matrix.java }})." + else + echo "::warning::Plugin load confirmation not found — server may not have finished loading within the timeout." + fi + + - name: Upload Spigot server log + if: always() + uses: actions/upload-artifact@v4 + with: + name: smoke-log-spigot-mc${{ matrix.mc-prefix }}-java${{ matrix.java }} + path: smoke-server/server.log + if-no-files-found: ignore + retention-days: 7 From a67857447ec0141a14a40b787f7a21ad2d1e3b05 Mon Sep 17 00:00:00 2001 From: ez-plugins Date: Fri, 22 May 2026 14:39:50 +0200 Subject: [PATCH 03/14] feat: per-player targeting for sendNotification Add optional player targeting to ephemeral notifications so a notification can be shown to a specific subset of players rather than broadcasting to all online players. Changes: - Countdown: add targetPlayers Set field + setTargetPlayers() + getTargetPlayers() + isVisibleTo(Player) helper that combines both visibility-permission and player-set checks - CountdownBuilder: add targetPlayers(Collection) builder method - Notification: add targetPlayers Set field + getTargetPlayers() - NotificationBuilder: add players(Collection) builder method - EzCountdownApi: add sendNotification(Notification, Collection) default method (UnsupportedOperationException for 3rd-party impls) - EzCountdownApiImpl: implement per-player overload; original single-arg overload delegates to it; also respects players embedded in the Notification itself (via builder) - All display handlers (ActionBar, Title, Chat, BossBar, Scoreboard, Dialog, DisplayManager): replace inline permission check with countdown.isVisibleTo(player) - Tests: add per-player tests to EzCountdownApiImplTest and NotificationBuilderTest --- CHANGELOG.md | 4 +- .../ezcountdown/api/EzCountdownApi.java | 32 +++++++++++ .../ezcountdown/api/EzCountdownApiImpl.java | 27 ++++++++- .../ezcountdown/api/model/Countdown.java | 53 ++++++++++++++++++ .../api/model/CountdownBuilder.java | 22 ++++++++ .../ezcountdown/api/model/Notification.java | 34 +++++++++++- .../api/model/NotificationBuilder.java | 23 +++++++- .../ezcountdown/display/DisplayManager.java | 3 +- .../display/actionbar/ActionBarDisplay.java | 6 +- .../display/bossbar/BossBarDisplay.java | 3 +- .../ezcountdown/display/chat/ChatDisplay.java | 6 +- .../display/dialog/DialogDisplay.java | 3 +- .../display/scoreboard/ScoreboardDisplay.java | 9 +-- .../display/title/TitleDisplay.java | 9 +-- .../api/EzCountdownApiImplTest.java | 55 +++++++++++++++++++ .../api/model/NotificationBuilderTest.java | 28 ++++++++++ 16 files changed, 283 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6ee4a7..03588d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,13 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [1.4.3] - 2026-05-16 +## [1.4.3] - 2026-05-25 ### Fixed - **DURATION countdown resets to full duration on `/countdown reload`** — `resumeRunningCountdowns()` previously called `handler.onStart()`, which always sets `targetInstant` to `now + fullDuration`, discarding the `target_epoch` saved in storage. It now calls `handler.ensureTarget()` instead, which is a no-op when a target is already present. The same guard was added to the legacy fallback path for handler-less countdown types. End-commands that fired once before a reload will no longer fire again unexpectedly due to the countdown silently restarting. - +## [1.4.2] - 2026-05-16 ### Added diff --git a/src/main/java/com/skyblockexp/ezcountdown/api/EzCountdownApi.java b/src/main/java/com/skyblockexp/ezcountdown/api/EzCountdownApi.java index 08388a1..8e22630 100644 --- a/src/main/java/com/skyblockexp/ezcountdown/api/EzCountdownApi.java +++ b/src/main/java/com/skyblockexp/ezcountdown/api/EzCountdownApi.java @@ -65,4 +65,36 @@ public interface EzCountdownApi { * {@link Optional#empty()} on a (very unlikely) name collision */ Optional sendNotification(Notification notification); + + /** + * Send an ephemeral countdown notification to a specific subset of players. + *

+ * Behaves identically to {@link #sendNotification(Notification)} but + * restricts display output to the given players for the lifetime of the + * notification. When {@code players} is {@code null} or empty the + * notification falls back to targeting all online players. + * + *

Example

+ *
{@code
+     * // Show a 10-second action-bar only to a single player
+     * api.sendNotification(Notification.ofSeconds(10), List.of(player));
+     *
+     * // Builder approach — same result
+     * api.sendNotification(
+     *     Notification.builder()
+     *         .duration(10)
+     *         .players(List.of(player))
+     *         .build()
+     * );
+     * }
+ * + * @param notification the notification descriptor + * @param players players to receive the notification; + * {@code null} or empty sends to all online players + * @return the generated countdown name wrapped in Optional, or empty on collision + */ + default Optional sendNotification(Notification notification, Collection players) { + throw new UnsupportedOperationException( + "Per-player sendNotification is not supported by this EzCountdownApi implementation."); + } } diff --git a/src/main/java/com/skyblockexp/ezcountdown/api/EzCountdownApiImpl.java b/src/main/java/com/skyblockexp/ezcountdown/api/EzCountdownApiImpl.java index 5f60346..5c31bc7 100644 --- a/src/main/java/com/skyblockexp/ezcountdown/api/EzCountdownApiImpl.java +++ b/src/main/java/com/skyblockexp/ezcountdown/api/EzCountdownApiImpl.java @@ -94,11 +94,16 @@ public boolean deleteCountdown(String name) { @Override public Optional sendNotification(Notification notification) { + return sendNotification(notification, null); + } + + @Override + public Optional sendNotification(Notification notification, Collection players) { Objects.requireNonNull(notification, "notification"); String name = "notif-" + UUID.randomUUID().toString().substring(0, 8); com.skyblockexp.ezcountdown.manager.CountdownDefaults defs = registry.defaults(); - Countdown countdown = CountdownBuilder.builder(name) + CountdownBuilder builder = CountdownBuilder.builder(name) .type(CountdownType.DURATION) .displayTypes(notification.getDisplayTypes()) .updateIntervalSeconds(defs.updateIntervalSeconds()) @@ -106,9 +111,25 @@ public Optional sendNotification(Notification notification) { .startMessage(notification.getStartMessage()) .endMessage(notification.getEndMessage()) .durationSeconds(notification.getDurationSeconds()) - .ephemeral(true) - .build(); + .ephemeral(true); + + // Resolve player set: explicit arg takes priority; then fall back to + // players embedded in the notification descriptor itself. + Collection effectivePlayers = (players != null && !players.isEmpty()) + ? players + : null; + if (effectivePlayers == null && notification.getTargetPlayers() != null + && !notification.getTargetPlayers().isEmpty()) { + // Convert stored UUIDs back into online players (best-effort) + effectivePlayers = org.bukkit.Bukkit.getOnlinePlayers().stream() + .filter(p -> notification.getTargetPlayers().contains(p.getUniqueId())) + .collect(java.util.stream.Collectors.toList()); + } + if (effectivePlayers != null && !effectivePlayers.isEmpty()) { + builder.targetPlayers(effectivePlayers); + } + Countdown countdown = builder.build(); countdown.setRunning(true); countdown.setTargetInstant(java.time.Instant.now().plusSeconds(notification.getDurationSeconds())); diff --git a/src/main/java/com/skyblockexp/ezcountdown/api/model/Countdown.java b/src/main/java/com/skyblockexp/ezcountdown/api/model/Countdown.java index 9d11b30..2792050 100644 --- a/src/main/java/com/skyblockexp/ezcountdown/api/model/Countdown.java +++ b/src/main/java/com/skyblockexp/ezcountdown/api/model/Countdown.java @@ -7,8 +7,13 @@ import java.time.LocalTime; import java.time.ZoneId; import java.time.ZonedDateTime; +import java.util.Collection; import java.util.EnumSet; +import java.util.HashSet; import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import org.bukkit.entity.Player; import com.skyblockexp.ezcountdown.util.DurationParser; import org.bukkit.boss.BarColor; import org.bukkit.boss.BarStyle; @@ -82,6 +87,13 @@ public final class Countdown { */ private boolean ephemeral = false; + /** + * When non-null and non-empty, only the players whose UUIDs are in this set + * will see this countdown's display output. {@code null} (the default) means + * the countdown is visible to all players (subject to {@link #visibilityPermission}). + */ + private Set targetPlayers = null; + /** * Create a new Countdown instance. * @@ -276,6 +288,47 @@ public Countdown(String name, /** Package-private — set by {@link CountdownBuilder#ephemeral(boolean)}. */ void setEphemeral(boolean ephemeral) { this.ephemeral = ephemeral; } + /** + * Returns an unmodifiable snapshot of the player UUIDs this countdown is + * restricted to, or {@code null} if the countdown is visible to all players + * (subject to {@link #getVisibilityPermission()}). + */ + public Set getTargetPlayers() { + return targetPlayers == null ? null : java.util.Collections.unmodifiableSet(targetPlayers); + } + + /** + * Restrict this countdown's display output to the given players. + * Pass {@code null} or an empty collection to remove the restriction and + * make the countdown visible to all players again. + * + * @param players target player UUIDs; {@code null} clears the restriction + */ + public void setTargetPlayers(Collection players) { + this.targetPlayers = (players == null || players.isEmpty()) ? null : new HashSet<>(players); + } + + /** + * Returns {@code true} if {@code player} should receive display updates + * from this countdown. + * + *

A player is visible when both conditions are satisfied: + *

    + *
  1. The player is in the {@linkplain #getTargetPlayers() target set} + * (or no target set is configured).
  2. + *
  3. The player has the {@linkplain #getVisibilityPermission() visibility + * permission} (or no permission is configured).
  4. + *
+ */ + public boolean isVisibleTo(Player player) { + if (targetPlayers != null && !targetPlayers.isEmpty() + && !targetPlayers.contains(player.getUniqueId())) { + return false; + } + String perm = visibilityPermission; + return perm == null || perm.isBlank() || player.hasPermission(perm); + } + /** @return configured duration in seconds for duration/manual types */ public long getDurationSeconds() { return durationSeconds; } diff --git a/src/main/java/com/skyblockexp/ezcountdown/api/model/CountdownBuilder.java b/src/main/java/com/skyblockexp/ezcountdown/api/model/CountdownBuilder.java index b858f7a..4c4f601 100644 --- a/src/main/java/com/skyblockexp/ezcountdown/api/model/CountdownBuilder.java +++ b/src/main/java/com/skyblockexp/ezcountdown/api/model/CountdownBuilder.java @@ -6,9 +6,15 @@ import java.time.LocalTime; import java.time.ZoneId; import java.util.ArrayList; +import java.util.Collection; import java.util.EnumSet; +import java.util.HashSet; import java.util.List; import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import org.bukkit.entity.Player; /** * Builder for {@link Countdown} to simplify construction from consumer code. @@ -36,6 +42,7 @@ public final class CountdownBuilder { private org.bukkit.boss.BarStyle bossBarStyle = org.bukkit.boss.BarStyle.SOLID; private boolean ephemeral = false; + private Set targetPlayers = null; /* optional runtime values that the builder can configure */ private long durationSeconds = -1L; @@ -182,6 +189,7 @@ public Countdown build() { if (recurringDay > 0) countdown.setRecurringDay(recurringDay); if (recurringTime != null) countdown.setRecurringTime(recurringTime); if (ephemeral) countdown.setEphemeral(true); + if (targetPlayers != null) countdown.setTargetPlayers(targetPlayers); return countdown; } @@ -196,6 +204,20 @@ public CountdownBuilder bossBarStyle(org.bukkit.boss.BarStyle style) { return this; } + /** + * Restrict display of this countdown to the given players only. + * Pass {@code null} or an empty collection to target all online players. + * + * @param players players to show the countdown to + * @return this builder + */ + public CountdownBuilder targetPlayers(Collection players) { + this.targetPlayers = (players == null || players.isEmpty()) ? null + : players.stream().map(Player::getUniqueId) + .collect(Collectors.toCollection(HashSet::new)); + return this; + } + /** * Mark the countdown as ephemeral — it will never be persisted to storage * and will be automatically removed from memory once it ends. diff --git a/src/main/java/com/skyblockexp/ezcountdown/api/model/Notification.java b/src/main/java/com/skyblockexp/ezcountdown/api/model/Notification.java index 9ebd6ed..dd6b67b 100644 --- a/src/main/java/com/skyblockexp/ezcountdown/api/model/Notification.java +++ b/src/main/java/com/skyblockexp/ezcountdown/api/model/Notification.java @@ -3,8 +3,14 @@ import com.skyblockexp.ezcountdown.display.DisplayType; import java.time.Duration; +import java.util.Collection; +import java.util.Collections; import java.util.EnumSet; +import java.util.HashSet; import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import org.bukkit.entity.Player; /** * A lightweight, immutable descriptor for an ephemeral countdown notification. @@ -50,12 +56,15 @@ public final class Notification { private final String formatMessage; private final String startMessage; private final String endMessage; + /** Non-null → only these players see the notification. Null → all players. */ + private final Set targetPlayers; Notification(long durationSeconds, EnumSet displayTypes, String formatMessage, String startMessage, - String endMessage) { + String endMessage, + Set targetPlayers) { if (durationSeconds <= 0) throw new IllegalArgumentException("durationSeconds must be > 0, got " + durationSeconds); this.durationSeconds = durationSeconds; this.displayTypes = displayTypes == null || displayTypes.isEmpty() @@ -66,6 +75,17 @@ public final class Notification { : formatMessage; this.startMessage = startMessage; this.endMessage = endMessage; + this.targetPlayers = (targetPlayers == null || targetPlayers.isEmpty()) ? null + : Collections.unmodifiableSet(new HashSet<>(targetPlayers)); + } + + /** Package-private backwards-compat constructor (no target players → global). */ + Notification(long durationSeconds, + EnumSet displayTypes, + String formatMessage, + String startMessage, + String endMessage) { + this(durationSeconds, displayTypes, formatMessage, startMessage, endMessage, null); } // ----------------------------------------------------------------------- @@ -131,12 +151,22 @@ public String getEndMessage() { return endMessage; } + /** + * Returns an unmodifiable set of player UUIDs that should receive this + * notification, or {@code null} if the notification targets all online + * players (the default). + */ + public Set getTargetPlayers() { + return targetPlayers; + } + @Override public String toString() { return "Notification{durationSeconds=" + durationSeconds + ", displayTypes=" + displayTypes + ", formatMessage='" + formatMessage + "'" + ", startMessage=" + startMessage - + ", endMessage=" + endMessage + '}'; + + ", endMessage=" + endMessage + + ", targetPlayers=" + targetPlayers + '}'; } } diff --git a/src/main/java/com/skyblockexp/ezcountdown/api/model/NotificationBuilder.java b/src/main/java/com/skyblockexp/ezcountdown/api/model/NotificationBuilder.java index 9ade9b3..0e750c3 100644 --- a/src/main/java/com/skyblockexp/ezcountdown/api/model/NotificationBuilder.java +++ b/src/main/java/com/skyblockexp/ezcountdown/api/model/NotificationBuilder.java @@ -4,8 +4,14 @@ import java.time.Duration; import java.util.Arrays; +import java.util.Collection; import java.util.EnumSet; +import java.util.HashSet; import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import org.bukkit.entity.Player; /** * Fluent builder for {@link Notification}. @@ -26,6 +32,7 @@ public final class NotificationBuilder { private String formatMessage = Notification.DEFAULT_FORMAT_MESSAGE; private String startMessage = null; private String endMessage = null; + private Set targetPlayers = null; // package-private — created via Notification.builder() NotificationBuilder() {} @@ -129,6 +136,20 @@ public NotificationBuilder endMessage(String message) { return this; } + /** + * Restrict this notification to the given players only. + * Pass {@code null} or an empty collection to target all online players. + * + * @param players players to receive the notification + * @return this builder + */ + public NotificationBuilder players(Collection players) { + this.targetPlayers = (players == null || players.isEmpty()) ? null + : players.stream().map(Player::getUniqueId) + .collect(Collectors.toCollection(HashSet::new)); + return this; + } + // ----------------------------------------------------------------------- // Build // ----------------------------------------------------------------------- @@ -145,6 +166,6 @@ public Notification build() { "A positive duration must be set before calling build(). " + "Use duration(long) or duration(Duration)."); } - return new Notification(durationSeconds, displayTypes, formatMessage, startMessage, endMessage); + return new Notification(durationSeconds, displayTypes, formatMessage, startMessage, endMessage, targetPlayers); } } diff --git a/src/main/java/com/skyblockexp/ezcountdown/display/DisplayManager.java b/src/main/java/com/skyblockexp/ezcountdown/display/DisplayManager.java index be8949a..cb7a320 100644 --- a/src/main/java/com/skyblockexp/ezcountdown/display/DisplayManager.java +++ b/src/main/java/com/skyblockexp/ezcountdown/display/DisplayManager.java @@ -89,8 +89,7 @@ public void clearAll() { } private boolean canSee(Player player, Countdown countdown) { - String permission = countdown.getVisibilityPermission(); - return permission == null || permission.isBlank() || player.hasPermission(permission); + return countdown.isVisibleTo(player); } private void updateBossBar(Countdown countdown, String message, long remainingSeconds) { diff --git a/src/main/java/com/skyblockexp/ezcountdown/display/actionbar/ActionBarDisplay.java b/src/main/java/com/skyblockexp/ezcountdown/display/actionbar/ActionBarDisplay.java index 94363d6..04f60f6 100644 --- a/src/main/java/com/skyblockexp/ezcountdown/display/actionbar/ActionBarDisplay.java +++ b/src/main/java/com/skyblockexp/ezcountdown/display/actionbar/ActionBarDisplay.java @@ -12,8 +12,7 @@ public class ActionBarDisplay implements DisplayHandler { public void display(Countdown countdown, String message, long remainingSeconds) { if (remainingSeconds <= 0L) return; for (Player player : Bukkit.getOnlinePlayers()) { - String perm = countdown.getVisibilityPermission(); - if (perm == null || perm.isBlank() || player.hasPermission(perm)) { + if (countdown.isVisibleTo(player)) { try { player.sendActionBar(message); } catch (NoSuchMethodError err) { @@ -32,8 +31,7 @@ public void display(Countdown countdown, String message, long remainingSeconds) public void displayBatched(Countdown countdown, String message, long remainingSeconds, MessageBatch batch) { if (remainingSeconds <= 0L) return; for (Player player : Bukkit.getOnlinePlayers()) { - String perm = countdown.getVisibilityPermission(); - if (perm == null || perm.isBlank() || player.hasPermission(perm)) { + if (countdown.isVisibleTo(player)) { try { player.sendActionBar(message); } catch (NoSuchMethodError err) { diff --git a/src/main/java/com/skyblockexp/ezcountdown/display/bossbar/BossBarDisplay.java b/src/main/java/com/skyblockexp/ezcountdown/display/bossbar/BossBarDisplay.java index b1084bf..bbd066c 100644 --- a/src/main/java/com/skyblockexp/ezcountdown/display/bossbar/BossBarDisplay.java +++ b/src/main/java/com/skyblockexp/ezcountdown/display/bossbar/BossBarDisplay.java @@ -32,9 +32,8 @@ private void updateSingle(Countdown countdown, String message, long remainingSec bossBar.setTitle(message); bossBar.setProgress(calculateProgress(countdown, remainingSeconds)); for (Player player : Bukkit.getOnlinePlayers()) { - String perm = countdown.getVisibilityPermission(); try { - if (perm == null || perm.isBlank() || player.hasPermission(perm)) { + if (countdown.isVisibleTo(player)) { bossBar.addPlayer(player); } else { bossBar.removePlayer(player); diff --git a/src/main/java/com/skyblockexp/ezcountdown/display/chat/ChatDisplay.java b/src/main/java/com/skyblockexp/ezcountdown/display/chat/ChatDisplay.java index 7ad2e2f..9a88058 100644 --- a/src/main/java/com/skyblockexp/ezcountdown/display/chat/ChatDisplay.java +++ b/src/main/java/com/skyblockexp/ezcountdown/display/chat/ChatDisplay.java @@ -12,8 +12,7 @@ public class ChatDisplay implements DisplayHandler { public void display(Countdown countdown, String message, long remainingSeconds) { if (remainingSeconds <= 0L) return; for (Player player : Bukkit.getOnlinePlayers()) { - String perm = countdown.getVisibilityPermission(); - if (perm == null || perm.isBlank() || player.hasPermission(perm)) { + if (countdown.isVisibleTo(player)) { player.sendMessage(message); } } @@ -23,8 +22,7 @@ public void display(Countdown countdown, String message, long remainingSeconds) public void displayBatched(Countdown countdown, String message, long remainingSeconds, MessageBatch batch) { if (remainingSeconds <= 0L) return; for (Player player : Bukkit.getOnlinePlayers()) { - String perm = countdown.getVisibilityPermission(); - if (perm == null || perm.isBlank() || player.hasPermission(perm)) { + if (countdown.isVisibleTo(player)) { batch.add(player, countdown, message); } } diff --git a/src/main/java/com/skyblockexp/ezcountdown/display/dialog/DialogDisplay.java b/src/main/java/com/skyblockexp/ezcountdown/display/dialog/DialogDisplay.java index d57fd04..4eae8ac 100644 --- a/src/main/java/com/skyblockexp/ezcountdown/display/dialog/DialogDisplay.java +++ b/src/main/java/com/skyblockexp/ezcountdown/display/dialog/DialogDisplay.java @@ -61,10 +61,9 @@ public void display(Countdown countdown, String message, long remainingSeconds) } String cdName = countdown.getName(); - String perm = countdown.getVisibilityPermission(); for (Player player : Bukkit.getOnlinePlayers()) { - if (perm != null && !perm.isBlank() && !player.hasPermission(perm)) { + if (!countdown.isVisibleTo(player)) { // Player lost permission — close any open dialog from this countdown UUID uid = player.getUniqueId(); LastShown prev = lastShown.get(uid); diff --git a/src/main/java/com/skyblockexp/ezcountdown/display/scoreboard/ScoreboardDisplay.java b/src/main/java/com/skyblockexp/ezcountdown/display/scoreboard/ScoreboardDisplay.java index 1045be8..038d455 100644 --- a/src/main/java/com/skyblockexp/ezcountdown/display/scoreboard/ScoreboardDisplay.java +++ b/src/main/java/com/skyblockexp/ezcountdown/display/scoreboard/ScoreboardDisplay.java @@ -43,8 +43,7 @@ public void display(Countdown countdown, String message, long remainingSeconds) ScoreboardManager manager = Bukkit.getScoreboardManager(); if (manager == null) return; for (Player player : Bukkit.getOnlinePlayers()) { - String perm = countdown.getVisibilityPermission(); - if (perm == null || perm.isBlank() || player.hasPermission(perm)) { + if (countdown.isVisibleTo(player)) { try { Scoreboard scoreboard = player.getScoreboard(); if (scoreboard == manager.getMainScoreboard()) { @@ -116,8 +115,7 @@ public void displayMultiple(Collection result = api.sendNotification(Notification.ofSeconds(5)); assertFalse(result.isPresent()); } + + // ------------------------------------------------------------------ + // Per-player sendNotification tests + // ------------------------------------------------------------------ + + @Test + public void sendNotification_withPlayers_setsTargetPlayers() { + Registry registry = mockRegistryWithDefaults(); + EzCountdownApi api = new EzCountdownApiImpl(registry); + + java.util.UUID uid = java.util.UUID.randomUUID(); + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(uid); + + api.sendNotification(Notification.ofSeconds(10), List.of(player)); + + var captor = org.mockito.ArgumentCaptor.forClass(Countdown.class); + verify(registry.countdowns()).createCountdown(captor.capture()); + Countdown created = captor.getValue(); + + assertNotNull(created.getTargetPlayers(), "targetPlayers should be set"); + assertTrue(created.getTargetPlayers().contains(uid), "player UUID should be in targetPlayers"); + } + + @Test + public void sendNotification_withNullPlayers_noTargetPlayers() { + Registry registry = mockRegistryWithDefaults(); + EzCountdownApi api = new EzCountdownApiImpl(registry); + + api.sendNotification(Notification.ofSeconds(10), null); + + var captor = org.mockito.ArgumentCaptor.forClass(Countdown.class); + verify(registry.countdowns()).createCountdown(captor.capture()); + Countdown created = captor.getValue(); + + assertNull(created.getTargetPlayers(), "null players should leave targetPlayers unset"); + } + + @Test + public void sendNotification_notificationWithPlayers_setsTargetPlayers() { + Registry registry = mockRegistryWithDefaults(); + EzCountdownApi api = new EzCountdownApiImpl(registry); + + java.util.UUID uid = java.util.UUID.randomUUID(); + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(uid); + + // Players embedded in notification via builder + Notification notification = Notification.builder() + .duration(5) + .players(List.of(player)) + .build(); + assertNotNull(notification.getTargetPlayers()); + assertTrue(notification.getTargetPlayers().contains(uid)); + } } diff --git a/src/test/java/com/skyblockexp/ezcountdown/api/model/NotificationBuilderTest.java b/src/test/java/com/skyblockexp/ezcountdown/api/model/NotificationBuilderTest.java index 962ae61..d8d4fa9 100644 --- a/src/test/java/com/skyblockexp/ezcountdown/api/model/NotificationBuilderTest.java +++ b/src/test/java/com/skyblockexp/ezcountdown/api/model/NotificationBuilderTest.java @@ -147,4 +147,32 @@ public void getDisplayTypes_returnsDefensiveCopy() { // original should be unaffected assertFalse(n.getDisplayTypes().contains(DisplayType.CHAT)); } + + // ------------------------------------------------------------------ + // Per-player targeting + // ------------------------------------------------------------------ + + @Test + public void builder_players_storesUUIDs() { + org.bukkit.entity.Player p = org.mockito.Mockito.mock(org.bukkit.entity.Player.class); + java.util.UUID uid = java.util.UUID.randomUUID(); + org.mockito.Mockito.when(p.getUniqueId()).thenReturn(uid); + + Notification n = Notification.builder().duration(10).players(java.util.List.of(p)).build(); + + assertNotNull(n.getTargetPlayers()); + assertTrue(n.getTargetPlayers().contains(uid)); + } + + @Test + public void builder_nullPlayers_noTargetPlayers() { + Notification n = Notification.builder().duration(10).players(null).build(); + assertNull(n.getTargetPlayers()); + } + + @Test + public void ofSeconds_noTargetPlayers() { + Notification n = Notification.ofSeconds(10); + assertNull(n.getTargetPlayers()); + } } From 641f44839e1da01640088d68632793b01d380b92 Mon Sep 17 00:00:00 2001 From: ez-plugins Date: Fri, 22 May 2026 16:02:28 +0200 Subject: [PATCH 04/14] feat: Folia support, compat/ abstraction layer, and API modernisation Scheduler - Add compat.scheduler.SchedulerAdapter / TaskHandle interface - BukkitSchedulerAdapter (Paper/Spigot) and FoliaSchedulerAdapter (Folia) - SchedulerAdapterFactory: selects adapter at startup, falls back gracefully - Wire SchedulerAdapter through CountdownManager, FireworkShowManager, PatternScheduler, ChatInputListener, all listener/actions classes, SpigotIntegration, SpigotUpdateChecker Platform detection - compat.platform.PlatformDetector: detects Folia via RegionizedServer class - plugin.yml: folia-supported: true, api-version: 1.18 Version / material compat - compat.version.ServerVersionUtil: canonical runtime MC-version helper - compat.material.MaterialCompat: canonical cross-version material resolver - util.ServerVersionUtil / util.MaterialCompat: deprecated delegation stubs Scoreboard API modernisation (Paper 1.20.3+) - compat.scoreboard.ScoreboardCompat: uses registerNewObjective(Criteria, Component) when Criteria.DUMMY is available; falls back to legacy String overload; guards against null Criteria.DUMMY in test environments - ScoreboardCompat.resetObjective: replaces deprecated getEntries/resetScores with objective unregister+reregister - ScoreboardDisplay: delegate to ScoreboardCompat in display() and displayMultiple() Title API modernisation (Paper 1.18+) - compat.title.TitleCompat: prefers Adventure showTitle(Title) / clearTitle(); falls back to deprecated sendTitle / resetTitle on Spigot - TitleDisplay: delegate to TitleCompat; remove hand-rolled try/catch - TitleValidator: accept showTitle as valid title capability Tests - MissedRunPolicyTest / MissedRunPolicyRunAllEdgeCasesTest: stub registry.scheduler() with BukkitSchedulerAdapter - ScoreboardDisplayStackableTest: stub modern registerNewObjective overload All 145 tests pass. --- CHANGELOG.md | 58 +++++- docs/_sass/color_schemes/ezcountdown.scss | 4 +- docs/api/EzCountdownApi.md | 53 ++++- docs/api/README.md | 108 ++++++---- docs/api/model/Notification.md | 56 ++++- docs/commands.md | 141 +++++++++---- docs/configuration.md | 197 +++++++++++------- docs/feature/index.md | 17 +- docs/index.md | 43 ++-- docs/permissions.md | 48 +++-- docs/server-owners.md | 16 +- docs/tutorials/first-countdown.md | 129 ++++++++++++ docs/tutorials/index.md | 17 ++ docs/tutorials/notifications.md | 139 ++++++++++++ docs/tutorials/recurring-countdown.md | 107 ++++++++++ pom.xml | 16 +- .../ezcountdown/EzCountdownPlugin.java | 32 +++ .../exception/CountdownNotFoundException.java | 22 ++ .../DuplicateCountdownException.java | 22 ++ .../api/exception/EzCountdownException.java | 18 ++ .../InvalidConfigurationException.java | 20 ++ .../api/model/NotificationBuilder.java | 5 +- .../bootstrap/PluginBootstrap.java | 10 +- .../ezcountdown/bootstrap/Registry.java | 7 +- .../compat/material/MaterialCompat.java | 50 +++++ .../compat/platform/PlatformDetector.java | 47 +++++ .../scheduler/BukkitSchedulerAdapter.java | 55 +++++ .../scheduler/FoliaSchedulerAdapter.java | 70 +++++++ .../compat/scheduler/SchedulerAdapter.java | 56 +++++ .../scheduler/SchedulerAdapterFactory.java | 45 ++++ .../compat/scheduler/TaskHandle.java | 31 +++ .../compat/scoreboard/ScoreboardCompat.java | 87 ++++++++ .../ezcountdown/compat/title/TitleCompat.java | 129 ++++++++++++ .../compat/version/ServerVersionUtil.java | 58 ++++++ .../display/scoreboard/ScoreboardDisplay.java | 18 +- .../display/title/TitleDisplay.java | 29 +-- .../display/title/TitleValidator.java | 14 +- .../firework/FireworkShowManager.java | 37 ++-- .../firework/PatternScheduler.java | 48 ++--- .../integration/SpigotIntegration.java | 2 +- .../listener/ChatInputListener.java | 7 +- .../actions/CommandsEditorActions.java | 10 +- .../actions/EditDurationOrTargetAction.java | 2 +- .../actions/EditEndMessageAction.java | 2 +- .../actions/EditFormatMessageAction.java | 2 +- .../EditStartCountdownTargetAction.java | 2 +- .../actions/EditStartMessageAction.java | 2 +- .../ezcountdown/manager/CountdownManager.java | 16 +- .../update/SpigotUpdateChecker.java | 2 +- .../ezcountdown/util/MaterialCompat.java | 29 +-- .../ezcountdown/util/ServerVersionUtil.java | 24 +++ src/main/resources/plugin.yml | 3 +- .../api/model/NotificationBuilderTest.java | 3 +- .../ScoreboardDisplayStackableTest.java | 5 +- .../MissedRunPolicyRunAllEdgeCasesTest.java | 1 + .../manager/MissedRunPolicyTest.java | 1 + 56 files changed, 1803 insertions(+), 369 deletions(-) create mode 100644 docs/tutorials/first-countdown.md create mode 100644 docs/tutorials/index.md create mode 100644 docs/tutorials/notifications.md create mode 100644 docs/tutorials/recurring-countdown.md create mode 100644 src/main/java/com/skyblockexp/ezcountdown/api/exception/CountdownNotFoundException.java create mode 100644 src/main/java/com/skyblockexp/ezcountdown/api/exception/DuplicateCountdownException.java create mode 100644 src/main/java/com/skyblockexp/ezcountdown/api/exception/EzCountdownException.java create mode 100644 src/main/java/com/skyblockexp/ezcountdown/api/exception/InvalidConfigurationException.java create mode 100644 src/main/java/com/skyblockexp/ezcountdown/compat/material/MaterialCompat.java create mode 100644 src/main/java/com/skyblockexp/ezcountdown/compat/platform/PlatformDetector.java create mode 100644 src/main/java/com/skyblockexp/ezcountdown/compat/scheduler/BukkitSchedulerAdapter.java create mode 100644 src/main/java/com/skyblockexp/ezcountdown/compat/scheduler/FoliaSchedulerAdapter.java create mode 100644 src/main/java/com/skyblockexp/ezcountdown/compat/scheduler/SchedulerAdapter.java create mode 100644 src/main/java/com/skyblockexp/ezcountdown/compat/scheduler/SchedulerAdapterFactory.java create mode 100644 src/main/java/com/skyblockexp/ezcountdown/compat/scheduler/TaskHandle.java create mode 100644 src/main/java/com/skyblockexp/ezcountdown/compat/scoreboard/ScoreboardCompat.java create mode 100644 src/main/java/com/skyblockexp/ezcountdown/compat/title/TitleCompat.java create mode 100644 src/main/java/com/skyblockexp/ezcountdown/compat/version/ServerVersionUtil.java create mode 100644 src/main/java/com/skyblockexp/ezcountdown/util/ServerVersionUtil.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 03588d6..b60842f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,19 +7,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [1.4.3] - 2026-05-25 +## [2.0.0] - 2026-05-30 + +### Added + +- **Folia support** - the plugin now runs on Folia (marked `folia-supported: true` in `plugin.yml`). Folia's `GlobalRegionScheduler` is used automatically when detected at runtime; Paper/Spigot continue to use the Bukkit scheduler. +- **`compat/` package hierarchy** - clean OOP abstraction layer: + - `compat.platform.PlatformDetector` - detects Folia vs. Paper/Spigot at startup via `RegionizedServer` class presence. + - `compat.scheduler.SchedulerAdapter` / `TaskHandle` - platform-agnostic scheduler interface (`runTask`, `runTaskTimer`, `runTaskLater`, `runTaskAsync`). + - `compat.scheduler.BukkitSchedulerAdapter` - `SchedulerAdapter` backed by `BukkitScheduler` (Paper/Spigot). + - `compat.scheduler.FoliaSchedulerAdapter` - `SchedulerAdapter` backed by `GlobalRegionScheduler` (Folia); only class-loaded when Folia is detected. + - `compat.scheduler.SchedulerAdapterFactory` - chooses the correct adapter with graceful fallback if Folia classes are missing. + - `compat.version.ServerVersionUtil` - canonical location for runtime Minecraft version detection (replaces `util.ServerVersionUtil`). + - `compat.material.MaterialCompat` - canonical location for cross-version material resolution (replaces `util.MaterialCompat`). +- **Startup platform log** - `PluginBootstrap` now logs the active scheduler adapter class on enable (e.g. `Scheduler: BukkitSchedulerAdapter`). + +### Changed + +- `util.ServerVersionUtil` and `util.MaterialCompat` are now `@Deprecated` delegation stubs that forward to their canonical `compat.*` counterparts; they will be removed in a future release. +- All internal scheduler usages (`CountdownManager`, `FireworkShowManager`, `PatternScheduler`, `ChatInputListener`, all `listener.actions.*` GUI action classes, `SpigotIntegration`, `SpigotUpdateChecker`) now go through `SchedulerAdapter` instead of `Bukkit.getScheduler()` / `BukkitRunnable` directly. +- **Scoreboard API modernised** - `ScoreboardDisplay` now delegates to `compat.scoreboard.ScoreboardCompat`. On Paper 1.20.3+ (`Criteria.DUMMY` available) the modern `registerNewObjective(String, Criteria, Component)` overload is used; older builds fall back to the legacy string overload. The deprecated `Scoreboard.resetScores(String)` calls are replaced by objective unregister/re-register via `ScoreboardCompat.resetObjective`. +- **Title API modernised** - `TitleDisplay` now delegates to `compat.title.TitleCompat`. On Paper 1.18+ the Adventure `Player.showTitle(Title)` / `Player.clearTitle()` APIs are preferred over the deprecated `sendTitle(String...)` / `resetTitle()`. `TitleValidator` now also accepts `showTitle` as a valid title capability. A Spigot/legacy string fallback is retained. + +- **Per-player notification targeting** - `NotificationBuilder.players(Collection)` restricts a notification to specific players. `EzCountdownApi.sendNotification(Notification, Collection)` provides an inline alternative without building a `Notification` first. +- **`Countdown.isVisibleTo(Player)`** - centralises per-player visibility logic (permission gate + target-player set) in one method; all display handlers now use it instead of duplicated inline checks. +- **`CountdownBuilder.targetPlayers(Collection)`** - restrict a persistent countdown's display to specific players. +- **Developer-friendly exception hierarchy** - new `com.skyblockexp.ezcountdown.api.exception` package: + - `EzCountdownException` - base unchecked exception; catch this for all EzCountdown API errors. + - `CountdownNotFoundException` - thrown when referencing a countdown name that does not exist; carries `getCountdownName()`. + - `DuplicateCountdownException` - thrown when creating a countdown whose name already exists; carries `getCountdownName()`. + - `InvalidConfigurationException` - thrown by `NotificationBuilder.build()` and future builder validators when configuration is invalid; replaces the generic `IllegalStateException`. +- **`api-version` bumped to `1.18`** in `plugin.yml`; minimum supported Minecraft version is Paper/Spigot 1.18. Dialog display continues to require Paper 1.21.7+. +- **Broadened Java compatibility** - release JARs now target Java 17 bytecode (previously Java 21), so the plugin runs on Paper 1.18 - 1.20.4 (Java 17) as well as Paper 1.20.5+ (Java 21). The `jdk21` Maven profile also targets Java 17 bytecode. +- **`ServerVersionUtil`** - new utility class (`com.skyblockexp.ezcountdown.util.ServerVersionUtil`) for runtime Minecraft version detection; enables future conditional feature gating without hard API dependencies. +- **Startup guard** - `onEnable` now logs the detected MC and Java version and disables the plugin with a clear error message if the server is too old (MC < 1.18 or Java < 17). + +### Changed + +- **All display handlers** (`ActionBarDisplay`, `TitleDisplay`, `ChatDisplay`, `BossBarDisplay`, `ScoreboardDisplay`, `DialogDisplay`) use `countdown.isVisibleTo(player)` instead of duplicated inline permission checks. +- **`NotificationBuilder.build()`** now throws `InvalidConfigurationException` (a subclass of `EzCountdownException`) instead of a plain `IllegalStateException`. +- **`ScoreboardDisplay`** catch blocks now include `NoSuchMethodError` so the scoreboard falls back to chat if the String-criteria `registerNewObjective` overload is ever removed in a future Paper build. +- Project version bumped from `1.4.3` to `2.0.0`. ### Fixed -- **DURATION countdown resets to full duration on `/countdown reload`** — `resumeRunningCountdowns()` previously called `handler.onStart()`, which always sets `targetInstant` to `now + fullDuration`, discarding the `target_epoch` saved in storage. It now calls `handler.ensureTarget()` instead, which is a no-op when a target is already present. The same guard was added to the legacy fallback path for handler-less countdown types. End-commands that fired once before a reload will no longer fire again unexpectedly due to the countdown silently restarting. +- **DURATION countdown resets to full duration on `/countdown reload`** - `resumeRunningCountdowns()` previously called `handler.onStart()`, which always sets `targetInstant` to `now + fullDuration`, discarding the `target_epoch` saved in storage. It now calls `handler.ensureTarget()` instead, which is a no-op when a target is already present. The same guard was added to the legacy fallback path for handler-less countdown types. End-commands that fired once before a reload will no longer fire again unexpectedly due to the countdown silently restarting. ## [1.4.2] - 2026-05-16 ### Added -- **Configurable time format** — new `display.time-format` section in `config.yml`. - - `pattern` (default `"{days}d {hours}h {minutes}m {seconds}s"`) — customize the token layout used for the `{formatted}` placeholder and `%ezcountdown__formatted%` PAPI expansion. - - `hide-leading-zeros` (default `true`) — when enabled, leading space-delimited segments whose unit value is zero are suppressed. For example, `0d 0h 5m 3s` is displayed as `5m 3s`. +- **Configurable time format** - new `display.time-format` section in `config.yml`. + - `pattern` (default `"{days}d {hours}h {minutes}m {seconds}s"`) - customize the token layout used for the `{formatted}` placeholder and `%ezcountdown__formatted%` PAPI expansion. + - `hide-leading-zeros` (default `true`) - when enabled, leading space-delimited segments whose unit value is zero are suppressed. For example, `0d 0h 5m 3s` is displayed as `5m 3s`. - Applies to countdown display messages, discord webhook `{time_left}`, PlaceholderAPI, and the GUI preview action. - Hot-reloads with `/countdown reload`. @@ -33,10 +73,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **Notification API** — `EzCountdownApi.sendNotification(Notification)` fires a one-shot ephemeral timed display from any plugin without creating a persistent countdown or touching `countdowns.yml`. -- **`Notification` model** — lightweight immutable value object with factory methods `Notification.ofSeconds(long)`, `Notification.of(Duration)`, and a fluent `NotificationBuilder` (display types, format message, start/end messages). -- **`NotificationBuilder`** — fluent builder for `Notification`; all fields have sensible defaults (`ACTION_BAR` display, `{formatted}` message); `build()` validates duration > 0. -- **Ephemeral countdown support** — `Countdown.isEphemeral()` / `CountdownBuilder.ephemeral(boolean)`; ephemeral countdowns are removed from memory automatically when they end and are never written to storage. +- **Notification API** - `EzCountdownApi.sendNotification(Notification)` fires a one-shot ephemeral timed display from any plugin without creating a persistent countdown or touching `countdowns.yml`. +- **`Notification` model** - lightweight immutable value object with factory methods `Notification.ofSeconds(long)`, `Notification.of(Duration)`, and a fluent `NotificationBuilder` (display types, format message, start/end messages). +- **`NotificationBuilder`** - fluent builder for `Notification`; all fields have sensible defaults (`ACTION_BAR` display, `{formatted}` message); `build()` validates duration > 0. +- **Ephemeral countdown support** - `Countdown.isEphemeral()` / `CountdownBuilder.ephemeral(boolean)`; ephemeral countdowns are removed from memory automatically when they end and are never written to storage. ## [1.3.2] - 2026-04-27 diff --git a/docs/_sass/color_schemes/ezcountdown.scss b/docs/_sass/color_schemes/ezcountdown.scss index 782cf91..0b7e63f 100644 --- a/docs/_sass/color_schemes/ezcountdown.scss +++ b/docs/_sass/color_schemes/ezcountdown.scss @@ -1,4 +1,4 @@ -// EzCountdown — dark/green color scheme for just-the-docs +// EzCountdown - dark/green color scheme for just-the-docs // // Palette: // Background #111111 (body) @@ -6,7 +6,7 @@ // Elevated #1e1e1e (code blocks, search, table rows) // Border #2a2a2a // Text #c8d4cc (body) / #ffffff (headings) -// Accent #4ade80 (green — links, nav highlight, buttons) +// Accent #4ade80 (green - links, nav highlight, buttons) // Accent dim #22c55e (hover state) $color-scheme: dark; diff --git a/docs/api/EzCountdownApi.md b/docs/api/EzCountdownApi.md index 216222a..0742767 100644 --- a/docs/api/EzCountdownApi.md +++ b/docs/api/EzCountdownApi.md @@ -8,33 +8,57 @@ nav_order: 2 Public service interface exposed by the plugin: `com.skyblockexp.ezcountdown.api.EzCountdownApi` -Methods +## Methods + +### Countdown lifecycle - `boolean startCountdown(String name)` - - Start a configured countdown by name. Returns `true` if started successfully. + - Start a configured countdown by name. Returns `true` if started successfully, `false` if the countdown was already running or does not exist. - `boolean stopCountdown(String name)` - - Stop a running countdown by name. Returns `true` if stopped. + - Stop a running countdown by name. Returns `true` if stopped, `false` if it was already stopped or does not exist. - `Optional getCountdown(String name)` - - Retrieve a countdown configuration/runtime instance by name. + - Retrieve a countdown configuration and runtime state by name. - `Collection listCountdowns()` - List all countdowns currently known to the plugin. +### Countdown management + - `boolean createCountdown(Countdown countdown)` - - Create and persist a new countdown configuration. Returns `true` on success. + - Create and persist a new countdown configuration. Returns `true` on success, `false` if a countdown with the same name already exists. + +- `boolean createCountdown(CountdownType type, long durationSeconds, Collection targetPlayers)` + - Convenience overload: create an ephemeral countdown of the given type and duration, visible only to the specified players. Returns `true` on success. - `boolean deleteCountdown(String name)` - Delete a configured countdown by name. Returns `true` when deleted. +### Notifications + - `Optional sendNotification(Notification notification)` - - Fire a one-shot, ephemeral timed display notification without creating a persistent countdown. The notification runs for its configured duration then disappears automatically — it is never written to `countdowns.yml`. Returns an `Optional` containing the generated internal name on success, or an empty `Optional` on collision. + - Fire a one-shot ephemeral timed display notification. The notification runs for its configured duration then is removed automatically - it is never written to `countdowns.yml` and does not appear in `/countdown list`. Returns an `Optional` containing the generated internal name on success, or an empty `Optional` on name collision. + +- `Optional sendNotification(Notification notification, Collection players)` + - Same as above, but restrict the notification to the specified players. Equivalent to building a `Notification` with `NotificationBuilder.players(players)`. + +## Exceptions -Usage example (service lookup): +Methods return boolean flags or Optional on expected failures (e.g. countdown not found). The API also defines a typed exception hierarchy for use in builders and validation: + +| Exception | Package | Description | +|---|---|---| +| `EzCountdownException` | `api.exception` | Base class for all EzCountdown API exceptions | +| `CountdownNotFoundException` | `api.exception` | A countdown name does not exist; carries `getCountdownName()` | +| `DuplicateCountdownException` | `api.exception` | A countdown with this name already exists; carries `getCountdownName()` | +| `InvalidConfigurationException` | `api.exception` | Thrown by `NotificationBuilder.build()` when duration is missing or invalid | + +## Usage example ```java -RegisteredServiceProvider rsp = Bukkit.getServicesManager().getRegistration(EzCountdownApi.class); +RegisteredServiceProvider rsp = + Bukkit.getServicesManager().getRegistration(EzCountdownApi.class); if (rsp != null) { EzCountdownApi api = rsp.getProvider(); api.createCountdown(myCountdown); @@ -42,8 +66,15 @@ if (rsp != null) { } ``` -Notes +Per-player notification: + +```java +List targets = List.of(player1, player2); +api.sendNotification(Notification.ofSeconds(30), targets); +``` + +## Notes - Changes made via the API are persisted using the plugin's `countdowns.yml` storage. -- Methods return boolean flags to indicate success; check plugin logs or events for failure reasons. -- Countdowns created by `sendNotification` are ephemeral: they run in memory only and are deleted automatically when they end. They are never stored in `countdowns.yml` and do not appear in `/countdown list`. +- Ephemeral countdowns created by `sendNotification` are held in memory only; they are deleted automatically when they end and are never stored in `countdowns.yml`. +- `createCountdown(CountdownType, long, Collection)` creates an ephemeral (in-memory) countdown visible only to the provided players. diff --git a/docs/api/README.md b/docs/api/README.md index c52de25..23ef9a5 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -1,6 +1,6 @@ --- title: Developer API -nav_order: 4 +nav_order: 5 has_children: true --- @@ -8,8 +8,8 @@ has_children: true ## Prerequisites -- Java 21 (matches Paper API classfile version used by the plugin). -- Access to the GitHub Packages repository +- Java 17 or newer. +- Access to the GitHub Packages repository. ## Quick start @@ -17,7 +17,7 @@ Follow these steps to add and use the EzCountdown API from your plugin. ### Installation -1) Add the GitHub Packages repository to your `pom.xml` (replace owner/repo if different): +1) Add the GitHub Packages repository to your `pom.xml`: ```xml @@ -29,30 +29,34 @@ Follow these steps to add and use the EzCountdown API from your plugin. ``` -2) Add the dependency (use the published version tag): +2) Add the dependency: ```xml - com.skyblockexp + com.github.ez-plugins ezcountdown - 1.3.1 + 2.0.0 + provided ``` +> Use `provided` - the plugin jar is already on the server; you do not want to shade it into your own jar. + ## Service lookup ### Getting the API -Use Bukkit's `ServicesManager` to obtain a reference to the `EzCountdownApi` service. The example below shows the common pattern used in other plugins: +Use Bukkit's `ServicesManager` to obtain a reference to the `EzCountdownApi` service: ```java import com.skyblockexp.ezcountdown.api.EzCountdownApi; import org.bukkit.Bukkit; import org.bukkit.plugin.RegisteredServiceProvider; -RegisteredServiceProvider rsp = Bukkit.getServicesManager().getRegistration(EzCountdownApi.class); +RegisteredServiceProvider rsp = + Bukkit.getServicesManager().getRegistration(EzCountdownApi.class); if (rsp == null) { - // EzCountdown not available + // EzCountdown is not installed or not loaded yet return; } EzCountdownApi api = rsp.getProvider(); @@ -62,22 +66,13 @@ EzCountdownApi api = rsp.getProvider(); ### Start/Stop -Start an existing countdown by id: - ```java api.startCountdown("example-countdown"); -``` - -Stop a running countdown: - -```java api.stopCountdown("example-countdown"); ``` ### Create -Create a countdown using `CountdownBuilder`: - ```java import com.skyblockexp.ezcountdown.api.model.Countdown; import com.skyblockexp.ezcountdown.api.model.CountdownType; @@ -104,27 +99,23 @@ api.startCountdown("launch"); ### Inspect -Inspect or list countdowns: - ```java Optional maybe = api.getCountdown("launch"); Collection all = api.listCountdowns(); ``` -### Send a notification +### Send a notification (global) -`sendNotification` fires a one-shot ephemeral display that runs for the specified duration and then vanishes — no YAML entry is created and no `/countdown list` entry appears. - -The simplest usage: +`sendNotification` fires a one-shot ephemeral display that runs for the specified duration and then vanishes - no YAML entry is created and no `/countdown list` entry appears. ```java import com.skyblockexp.ezcountdown.api.model.Notification; -// Show an action bar countdown for 30 seconds (plugin defaults for display). +// Show an action bar countdown for 30 seconds using plugin defaults. api.sendNotification(Notification.ofSeconds(30)); ``` -Use the builder for full control: +Full builder example: ```java import com.skyblockexp.ezcountdown.api.model.Notification; @@ -135,28 +126,69 @@ import java.util.EnumSet; Notification notif = Notification.builder() .duration(Duration.ofMinutes(5)) .displays(EnumSet.of(DisplayType.ACTION_BAR, DisplayType.BOSS_BAR)) - .message("{formatted}") // optional: format message key - .startMessage("Event starts soon!") // optional: broadcast on start - .endMessage("Event started!") // optional: broadcast on end + .message("{formatted}") + .startMessage("Event starts soon!") + .endMessage("Event started!") .build(); Optional handle = api.sendNotification(notif); + +// Stop early if needed: +handle.ifPresent(name -> api.stopCountdown(name)); ``` -`sendNotification` returns the generated internal name wrapped in `Optional.of(...)` on success, or `Optional.empty()` on the rare name collision. You can use the name to stop the notification early: +### Send a notification to specific players + +Two ways to send a notification to a subset of online players: + +**Option A - via the builder:** ```java -handle.ifPresent(name -> api.stopCountdown(name)); +import com.skyblockexp.ezcountdown.api.model.Notification; +import java.util.List; + +List vipPlayers = /* your player collection */; + +Notification notif = Notification.builder() + .duration(60) + .message("VIP event in {formatted}") + .players(vipPlayers) // restrict to these players + .build(); + +api.sendNotification(notif); ``` -## Javadoc & API reference +**Option B - inline (without a pre-built Notification):** + +```java +api.sendNotification(Notification.ofSeconds(60), vipPlayers); +``` -See the generated API docs for full type and method details: `docs/api/EzCountdownApi.md` and the `model/` and `event/` pages in this folder. If you want, I can add a GitHub Pages workflow to publish hosted Javadoc automatically on release. +### Exceptions + +EzCountdown throws typed exceptions from the `com.skyblockexp.ezcountdown.api.exception` package: + +| Exception | When thrown | +|---|---| +| `EzCountdownException` | Base class - catch this to handle any EzCountdown error | +| `CountdownNotFoundException` | A countdown name does not exist | +| `DuplicateCountdownException` | Creating a countdown with an already-used name | +| `InvalidConfigurationException` | `NotificationBuilder.build()` called without a valid duration | + +```java +import com.skyblockexp.ezcountdown.api.exception.EzCountdownException; +import com.skyblockexp.ezcountdown.api.exception.CountdownNotFoundException; + +try { + Notification notif = Notification.builder().build(); // missing duration! +} catch (InvalidConfigurationException e) { + getLogger().warning("Bad notification config: " + e.getMessage()); +} +``` ## Further reading -- API reference: [docs/api/EzCountdownApi.md](docs/api/EzCountdownApi.md) -- Events: [docs/api/event](docs/api/event) -- Models: [docs/api/model](docs/api/model) -- Notification model: [docs/api/model/Notification.md](docs/api/model/Notification.md) +- [EzCountdownApi interface](EzCountdownApi) - full method list +- [Events](event/) - `CountdownStartEvent`, `CountdownTickEvent`, `CountdownEndEvent` +- [Models](model/) - `Countdown`, `Notification`, `CountdownType` diff --git a/docs/api/model/Notification.md b/docs/api/model/Notification.md index 8e74609..2c807fe 100644 --- a/docs/api/model/Notification.md +++ b/docs/api/model/Notification.md @@ -16,17 +16,18 @@ Notifications are used exclusively with [`EzCountdownApi.sendNotification`](../E ## Factory methods -- `static Notification ofSeconds(long seconds)` — Create a notification with the given duration (seconds) and all other settings at their defaults. -- `static Notification of(Duration duration)` — Create a notification from a `java.time.Duration` with all other settings at their defaults. -- `static NotificationBuilder builder()` — Start a fluent `NotificationBuilder`. +- `static Notification ofSeconds(long seconds)` - Create a notification with the given duration (seconds) and all other settings at their defaults. +- `static Notification of(Duration duration)` - Create a notification from a `java.time.Duration` with all other settings at their defaults. +- `static NotificationBuilder builder()` - Start a fluent `NotificationBuilder`. ## Accessors -- `long getDurationSeconds()` — Duration of the notification in seconds (always > 0). -- `EnumSet getDisplayTypes()` — Which displays are shown. Defaults to `ACTION_BAR`. -- `String getFormatMessage()` — Format message key. Defaults to `{formatted}`. -- `String getStartMessage()` — Optional message broadcast when the notification starts. May be `null`. -- `String getEndMessage()` — Optional message broadcast when the notification ends. May be `null`. +- `long getDurationSeconds()` - Duration of the notification in seconds (always > 0). +- `EnumSet getDisplayTypes()` - Which displays are shown. Defaults to `ACTION_BAR`. +- `String getFormatMessage()` - Format message key. Defaults to `{formatted}`. +- `String getStartMessage()` - Optional message broadcast when the notification starts. May be `null`. +- `String getEndMessage()` - Optional message broadcast when the notification ends. May be `null`. +- `Set getTargetPlayers()` - Set of player UUIDs that will receive this notification. `null` means all online players. ## Default values @@ -36,6 +37,7 @@ Notifications are used exclusively with [`EzCountdownApi.sendNotification`](../E | `formatMessage` | `{formatted}` | | `startMessage` | `null` (no broadcast) | | `endMessage` | `null` (no broadcast) | +| `targetPlayers` | `null` (all online players) | ## NotificationBuilder @@ -51,13 +53,49 @@ Obtained via `Notification.builder()`. All methods return `this` for chaining. | `message(String)` | Set the format message; `null` or blank resets to `{formatted}` | | `startMessage(String)` | Set the start broadcast; blank treated as `null` | | `endMessage(String)` | Set the end broadcast; blank treated as `null` | -| `build()` | Build and return the `Notification`. Throws `IllegalStateException` if duration was never set or is ≤ 0. | +| `players(Collection)` | Restrict the notification to these players; `null` or empty targets all online players | +| `build()` | Build and return the `Notification`. Throws `InvalidConfigurationException` if duration was never set or is ≤ 0. | ## Examples One-liner (action bar for 30 s, plugin defaults): ```java +Notification n = Notification.ofSeconds(30); +api.sendNotification(n); +``` + +Full builder - action bar + boss bar for 5 minutes: + +```java +Notification n = Notification.builder() + .duration(Duration.ofMinutes(5)) + .displays(EnumSet.of(DisplayType.ACTION_BAR, DisplayType.BOSS_BAR)) + .message("{formatted}") + .startMessage("Event starts soon!") + .endMessage("Event started!") + .build(); + +Optional handle = api.sendNotification(n); +``` + +Target specific players only: + +```java +List vipPlayers = getVipPlayers(); + +Notification n = Notification.builder() + .duration(60) + .message("VIP reward in {formatted}") + .players(vipPlayers) + .build(); + +api.sendNotification(n); + +// Or inline: +api.sendNotification(Notification.ofSeconds(60), vipPlayers); +``` + Notification notif = Notification.ofSeconds(30); api.sendNotification(notif); ``` diff --git a/docs/commands.md b/docs/commands.md index b11e136..90cd2d5 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -4,74 +4,133 @@ parent: Server Owners nav_order: 1 --- -# Commands (Quick Reference for Server Owners) +# Commands -This page lists the most important commands staff will use to manage countdowns. Keep these in a staff guide or paste them into your control panel. +All commands use the `/countdown` base (aliases: `/ezcountdown`, `/ezcd`). -Core commands +## Command reference + +### `/countdown create` + +Create a new countdown. The command syntax depends on the type: + +| Type | Syntax | Example | +|---|---|---| +| Fixed date | `/countdown create ` | `/countdown create new_year 2026-01-01 00:00` | +| Duration | `/countdown create duration