1 Commits
Author SHA1 Message Date
dmg 320c8ce193 fix(protection): restore cleared Leaf effects
Release / release (push) Successful in 2m7s
CI / build (push) Successful in 1m1s
2026-08-10 23:09:54 -04:00
9 changed files with 110 additions and 4 deletions
+2
View File
@@ -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. 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 ## 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. 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.
+8
View File
@@ -95,3 +95,11 @@
- Opt-out, combat, administrative disablement, and global disablement remove both Leaf-managed effects while conservatively preserving distinct external effects. - 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. - 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`. - 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`.
@@ -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] 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] 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] 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 ## Related
@@ -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] 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] 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] 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. - [x] Unknown forward-compatible configuration and player-state fields are preserved where practical.
## Related ## Related
@@ -37,6 +37,12 @@ public final class LeafPlugin extends JavaPlugin {
return; return;
} }
getServer().getScheduler().runTaskTimer(
this,
new LeafRecoveryTask(runtime),
LeafRecoveryTask.INTERVAL_TICKS,
LeafRecoveryTask.INTERVAL_TICKS
);
getServer().getScheduler().runTaskTimer(this, this::saveState, 600L, 600L); getServer().getScheduler().runTaskTimer(this, this::saveState, 600L, 600L);
getLogger().info("Leaf enabled."); getLogger().info("Leaf enabled.");
} }
@@ -88,23 +88,38 @@ public final class LeafProtection {
ignored -> new HashMap<>() ignored -> new HashMap<>()
); );
PotionEffect previous = owned.get(desired.getType()); 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); removeMatching(player, previous);
owned.remove(desired.getType()); owned.remove(desired.getType());
active = player.getPotionEffect(desired.getType());
} else if (previous != null) {
owned.remove(desired.getType());
} }
PotionEffect active = player.getPotionEffect(desired.getType()); if (active != null) {
if (desired.equals(active) && desired.equals(owned.get(desired.getType()))) { removeEmptyOwnership(playerId, owned);
return true; return true;
} }
boolean applied = player.addPotionEffect(desired); boolean applied = player.addPotionEffect(desired);
if (applied) { if (applied) {
owned.put(desired.getType(), desired); owned.put(desired.getType(), desired);
} }
removeEmptyOwnership(playerId, owned);
return applied;
}
private void removeEmptyOwnership(
UUID playerId,
Map<PotionEffectType, PotionEffect> owned
) {
if (owned.isEmpty()) { if (owned.isEmpty()) {
appliedEffects.remove(playerId); appliedEffects.remove(playerId);
} }
return applied || desired.equals(active);
} }
private static void removeMatching(Player player, PotionEffect expected) { private static void removeMatching(Player player, PotionEffect expected) {
@@ -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();
}
}
@@ -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);
}
}
@@ -68,6 +68,38 @@ final class LeafRuntimeTest {
verify(protection).isEffective(player, 1, 1); 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 @Test
void welcomesPlayersOnEachJoinOnlyDuringTheConfiguredWindow() throws Exception { void welcomesPlayersOnEachJoinOnlyDuringTheConfiguredWindow() throws Exception {
UUID playerId = UUID.randomUUID(); UUID playerId = UUID.randomUUID();