From 320c8ce19380e6f6bdd37a02f70b4255e8d42356 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Mon, 10 Aug 2026 23:09:54 -0400 Subject: [PATCH] fix(protection): restore cleared Leaf effects --- README.md | 2 ++ design/log.md | 8 +++++ .../us-001-opt-into-leaf-protection.md | 2 ++ .../us-006-configure-and-persist-leaf.md | 2 ++ src/main/java/games/dmg/leaf/LeafPlugin.java | 6 ++++ .../java/games/dmg/leaf/LeafProtection.java | 23 ++++++++++--- .../java/games/dmg/leaf/LeafRecoveryTask.java | 19 +++++++++++ .../games/dmg/leaf/LeafRecoveryTaskTest.java | 20 ++++++++++++ .../java/games/dmg/leaf/LeafRuntimeTest.java | 32 +++++++++++++++++++ 9 files changed, 110 insertions(+), 4 deletions(-) create mode 100644 src/main/java/games/dmg/leaf/LeafRecoveryTask.java create mode 100644 src/test/java/games/dmg/leaf/LeafRecoveryTaskTest.java diff --git a/README.md b/README.md index 5c7e560..c90affc 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,8 @@ Leaf prefixes the standard Spigot display name (used by standard chat), player-l Spigot identifies potion effects by type but does not expose their owning plugin. Leaf tracks the exact infinite, quiet Resistance and Strength effects it successfully installed and removes them only while each visible effect still matches. A distinct level, duration, or presentation is preserved. Spigot cannot distinguish an externally supplied effect with an identical fingerprint; Leaf therefore does not claim or later remove an identical effect that was already active when reconciliation ran. +Leaf reconciles opted-in online players approximately once per second. Effects cleared by death, milk, commands, or another plugin are restored automatically. A temporary external Resistance or Strength effect is left untouched, the leaf prefix remains visible, and Leaf restores its configured effect after the external effect ends. + ## Releases Gitea Actions checks pushes and pull requests and stores a development JAR. Pull requests validate conventional commits. Main-branch conventional commits drive semantic releases after the repository defines a `RELEASE_TOKEN` with contents-write permission. diff --git a/design/log.md b/design/log.md index 118fe52..af7514e 100644 --- a/design/log.md +++ b/design/log.md @@ -95,3 +95,11 @@ - Opt-out, combat, administrative disablement, and global disablement remove both Leaf-managed effects while conservatively preserving distinct external effects. - Minecraft calculates the initiating PvP hit before Leaf can process the damage event, so that first hit can include the configured Strength bonus before automatic opt-out. - Verified settings, runtime reconciliation, status, combat cleanup, persistence, command compatibility, autocomplete, and the complete build with `./gradlew clean check jar`. + +### Automatic effect recovery added + +- Leaf now reconciles opted-in online players every 20 ticks so death, milk, commands, and plugins cannot permanently clear configured protection. +- Recovery remains disabled for opted-out players and while Leaf is globally disabled. +- A temporary external Resistance or Strength effect is preserved instead of overwritten; the leaf prefix remains visible and Leaf restores its configured effect after the external effect ends. +- Added regression coverage for the one-second recovery task and eligibility-aware online-player reconciliation. +- Verified the complete build with `./gradlew clean check jar`. diff --git a/design/user-stories/us-001-opt-into-leaf-protection.md b/design/user-stories/us-001-opt-into-leaf-protection.md index 9354270..0134bfc 100644 --- a/design/user-stories/us-001-opt-into-leaf-protection.md +++ b/design/user-stories/us-001-opt-into-leaf-protection.md @@ -22,6 +22,8 @@ As a **player**, I want to opt into Leaf protection so that I can receive a mode - [x] Player-command autocomplete suggests only valid next arguments available to the sender. - [x] Opted-in players receive quiet Strength I alongside Resistance I by default. - [x] Player status reports active protection only when both configured Leaf effects are effective. +- [x] Missing Leaf effects are restored for opted-in online players within approximately one second after death, milk, commands, or plugins clear them. +- [x] Recovery remains suppressed while Leaf is globally disabled or the player is opted out. ## Related diff --git a/design/user-stories/us-006-configure-and-persist-leaf.md b/design/user-stories/us-006-configure-and-persist-leaf.md index aa0c50c..9bba3d5 100644 --- a/design/user-stories/us-006-configure-and-persist-leaf.md +++ b/design/user-stories/us-006-configure-and-persist-leaf.md @@ -21,6 +21,8 @@ As a **server operator**, I want Leaf settings and player state to be validated - [x] Invalid required configuration prevents partial plugin initialization and produces a clear server log message. - [x] Corrupt or invalid player records are handled defensively and cannot silently grant protection or privileges. - [x] Removing Leaf-managed Resistance or Strength does not remove a distinct corresponding effect that Leaf does not own when the API provides enough information to distinguish it. +- [x] A temporary external Resistance or Strength effect is preserved instead of overwritten, and Leaf restores its configured effect after the external effect ends. +- [x] The leaf prefix remains visible while an external effect temporarily replaces a Leaf-managed effect. - [x] Unknown forward-compatible configuration and player-state fields are preserved where practical. ## Related diff --git a/src/main/java/games/dmg/leaf/LeafPlugin.java b/src/main/java/games/dmg/leaf/LeafPlugin.java index 223d692..68a50c2 100644 --- a/src/main/java/games/dmg/leaf/LeafPlugin.java +++ b/src/main/java/games/dmg/leaf/LeafPlugin.java @@ -37,6 +37,12 @@ public final class LeafPlugin extends JavaPlugin { return; } + getServer().getScheduler().runTaskTimer( + this, + new LeafRecoveryTask(runtime), + LeafRecoveryTask.INTERVAL_TICKS, + LeafRecoveryTask.INTERVAL_TICKS + ); getServer().getScheduler().runTaskTimer(this, this::saveState, 600L, 600L); getLogger().info("Leaf enabled."); } diff --git a/src/main/java/games/dmg/leaf/LeafProtection.java b/src/main/java/games/dmg/leaf/LeafProtection.java index 44e4e63..624582b 100644 --- a/src/main/java/games/dmg/leaf/LeafProtection.java +++ b/src/main/java/games/dmg/leaf/LeafProtection.java @@ -88,23 +88,38 @@ public final class LeafProtection { ignored -> new HashMap<>() ); PotionEffect previous = owned.get(desired.getType()); - if (previous != null && !previous.equals(desired)) { + PotionEffect active = player.getPotionEffect(desired.getType()); + if (previous != null && previous.equals(active)) { + if (previous.equals(desired)) { + return true; + } removeMatching(player, previous); owned.remove(desired.getType()); + active = player.getPotionEffect(desired.getType()); + } else if (previous != null) { + owned.remove(desired.getType()); } - PotionEffect active = player.getPotionEffect(desired.getType()); - if (desired.equals(active) && desired.equals(owned.get(desired.getType()))) { + if (active != null) { + removeEmptyOwnership(playerId, owned); return true; } + boolean applied = player.addPotionEffect(desired); if (applied) { owned.put(desired.getType(), desired); } + removeEmptyOwnership(playerId, owned); + return applied; + } + + private void removeEmptyOwnership( + UUID playerId, + Map owned + ) { if (owned.isEmpty()) { appliedEffects.remove(playerId); } - return applied || desired.equals(active); } private static void removeMatching(Player player, PotionEffect expected) { diff --git a/src/main/java/games/dmg/leaf/LeafRecoveryTask.java b/src/main/java/games/dmg/leaf/LeafRecoveryTask.java new file mode 100644 index 0000000..4d6a791 --- /dev/null +++ b/src/main/java/games/dmg/leaf/LeafRecoveryTask.java @@ -0,0 +1,19 @@ +package games.dmg.leaf; + +import java.util.Objects; + +/** Periodically restores eligible protection after effects are cleared or expire. */ +public final class LeafRecoveryTask implements Runnable { + static final long INTERVAL_TICKS = 20L; + + private final LeafRuntime runtime; + + public LeafRecoveryTask(LeafRuntime runtime) { + this.runtime = Objects.requireNonNull(runtime, "runtime"); + } + + @Override + public void run() { + runtime.reconcileAllOnline(); + } +} diff --git a/src/test/java/games/dmg/leaf/LeafRecoveryTaskTest.java b/src/test/java/games/dmg/leaf/LeafRecoveryTaskTest.java new file mode 100644 index 0000000..855604f --- /dev/null +++ b/src/test/java/games/dmg/leaf/LeafRecoveryTaskTest.java @@ -0,0 +1,20 @@ +package games.dmg.leaf; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import org.junit.jupiter.api.Test; + +final class LeafRecoveryTaskTest { + @Test + void reconcilesAllOnlinePlayersEverySecond() { + LeafRuntime runtime = mock(LeafRuntime.class); + LeafRecoveryTask recovery = new LeafRecoveryTask(runtime); + + recovery.run(); + + verify(runtime).reconcileAllOnline(); + assertEquals(20L, LeafRecoveryTask.INTERVAL_TICKS); + } +} diff --git a/src/test/java/games/dmg/leaf/LeafRuntimeTest.java b/src/test/java/games/dmg/leaf/LeafRuntimeTest.java index 0c6f261..921eb7f 100644 --- a/src/test/java/games/dmg/leaf/LeafRuntimeTest.java +++ b/src/test/java/games/dmg/leaf/LeafRuntimeTest.java @@ -68,6 +68,38 @@ final class LeafRuntimeTest { verify(protection).isEffective(player, 1, 1); } + @Test + void recoveryReconcilesEligiblePlayersAndLeavesOptedOutPlayersUnprotected() + throws Exception { + UUID protectedId = UUID.randomUUID(); + UUID optedOutId = UUID.randomUUID(); + Player protectedPlayer = player(protectedId, "Alex"); + Player optedOutPlayer = player(optedOutId, "Steve"); + Server server = mock(Server.class); + when(server.getPlayer(protectedId)).thenReturn(protectedPlayer); + when(server.getPlayer(optedOutId)).thenReturn(optedOutPlayer); + org.mockito.Mockito.doReturn(List.of(protectedPlayer, optedOutPlayer)) + .when(server).getOnlinePlayers(); + LeafProtection protection = mock(LeafProtection.class); + when(protection.apply(protectedPlayer, 1, 1)).thenReturn(true); + LeafRuntime runtime = runtime( + server, + protection, + temporaryDirectory.resolve("recovery.yml") + ); + Instant observedAt = Instant.parse("2026-08-10T00:00:00Z"); + runtime.observe(protectedPlayer, observedAt); + runtime.observe(optedOutPlayer, observedAt); + runtime.setOwnChoice(protectedPlayer, true); + org.mockito.Mockito.clearInvocations(protection); + + runtime.reconcileAllOnline(); + + verify(protection).apply(protectedPlayer, 1, 1); + verify(protection, never()).apply(optedOutPlayer, 1, 1); + verify(protection).remove(optedOutPlayer); + } + @Test void welcomesPlayersOnEachJoinOnlyDuringTheConfiguredWindow() throws Exception { UUID playerId = UUID.randomUUID();