fix(protection): restore cleared Leaf effects
Release / release (push) Successful in 2m7s
CI / build (push) Successful in 1m1s

This commit is contained in:
dmg
2026-08-10 23:09:54 -04:00
parent 5609a0a656
commit 320c8ce193
9 changed files with 110 additions and 4 deletions
@@ -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.");
}
@@ -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<PotionEffectType, PotionEffect> owned
) {
if (owned.isEmpty()) {
appliedEffects.remove(playerId);
}
return applied || desired.equals(active);
}
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);
}
@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();