feat(progression): unlock stealth from potion invisibility
CI / build (push) Successful in 2m31s
Release / release (push) Successful in 3m30s

This commit is contained in:
dmg
2026-08-15 00:01:26 -04:00
parent 248c5e72c1
commit 91c9e7b9ad
9 changed files with 430 additions and 13 deletions
@@ -0,0 +1,27 @@
package games.dmg.spigotstealth;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
class BukkitUnlockNotifierTest {
@Test
void sendsFullScreenTitleAndChatMessageToOnlinePlayer() {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
when(player.isOnline()).thenReturn(true);
Consumer<Runnable> immediateMainThread = Runnable::run;
BukkitUnlockNotifier notifier = new BukkitUnlockNotifier(
ignored -> player, immediateMainThread, "Stealth unlocked!");
notifier.accept(playerId);
verify(player).sendTitle("Stealth Unlocked", "Your identity can now be concealed", 10, 100, 20);
verify(player).sendMessage("Stealth unlocked!");
}
}
@@ -0,0 +1,68 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong;
import org.bukkit.entity.Player;
import org.bukkit.event.entity.EntityPotionEffectEvent;
import org.junit.jupiter.api.Test;
class InvisibilityEffectListenerTest {
@Test
void onlyPotionDrinkCauseStartsQualifyingInvisibility() {
UUID playerId = UUID.randomUUID();
AtomicLong nanos = new AtomicLong();
try (StealthStateManager manager = manager()) {
QualifyingInvisibilityService service = new QualifyingInvisibilityService(
manager, Duration.ofHours(8), nanos::get, ignored -> { });
InvisibilityEffectListener listener = new InvisibilityEffectListener(
service, Clock.fixed(Instant.EPOCH, ZoneOffset.UTC));
Player player = player(playerId);
listener.handlePotionEffect(player, true, EntityPotionEffectEvent.Cause.POTION_SPLASH, EntityPotionEffectEvent.Action.ADDED);
assertFalse(service.isQualifying(playerId));
listener.handlePotionEffect(player, true, EntityPotionEffectEvent.Cause.COMMAND, EntityPotionEffectEvent.Action.CHANGED);
assertFalse(service.isQualifying(playerId));
listener.handlePotionEffect(player, true, EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.ADDED);
assertTrue(service.isQualifying(playerId));
}
}
@Test
void anyLaterNonDrinkChangeStopsTheQualifyingInterval() {
UUID playerId = UUID.randomUUID();
try (StealthStateManager manager = manager()) {
QualifyingInvisibilityService service = new QualifyingInvisibilityService(
manager, Duration.ofHours(8), System::nanoTime, ignored -> { });
InvisibilityEffectListener listener = new InvisibilityEffectListener(service, Clock.systemUTC());
Player player = player(playerId);
listener.handlePotionEffect(player, true, EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.ADDED);
listener.handlePotionEffect(player, true, EntityPotionEffectEvent.Cause.PLUGIN, EntityPotionEffectEvent.Action.CHANGED);
assertFalse(service.isQualifying(playerId));
}
}
private static Player player(UUID playerId) {
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Alex");
return player;
}
private static StealthStateManager manager() {
StealthStateRepository repository = new StealthStateRepository() {
@Override public PersistentStealthState load() { return new PersistentStealthState(Map.of(), Map.of()); }
@Override public void save(PersistentStealthState state) { }
};
return new StealthStateManager(repository, new PersistentStealthState(Map.of(), Map.of()));
}
}
@@ -0,0 +1,80 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong;
import org.junit.jupiter.api.Test;
class QualifyingInvisibilityServiceTest {
@Test
void accumulatesMonotonicElapsedTimeAcrossQualifyingIntervals() {
AtomicLong nanos = new AtomicLong();
UUID playerId = UUID.randomUUID();
try (StealthStateManager manager = manager()) {
QualifyingInvisibilityService service = new QualifyingInvisibilityService(
manager, Duration.ofHours(8), nanos::get, ignored -> { });
service.begin(playerId, "Alex", Instant.parse("2026-08-14T10:00:00Z"));
nanos.addAndGet(Duration.ofMinutes(3).toNanos());
service.stop(playerId).join();
service.begin(playerId, "Alex", Instant.parse("2026-08-14T11:00:00Z"));
nanos.addAndGet(Duration.ofMinutes(2).toNanos());
service.stop(playerId).join();
assertEquals(Duration.ofMinutes(5).toMillis(), manager.snapshot().player(playerId).accumulatedMillis());
assertFalse(service.isQualifying(playerId));
}
}
@Test
void refreshSettlesExistingIntervalWithoutDoubleCounting() {
AtomicLong nanos = new AtomicLong();
UUID playerId = UUID.randomUUID();
try (StealthStateManager manager = manager()) {
QualifyingInvisibilityService service = new QualifyingInvisibilityService(
manager, Duration.ofHours(8), nanos::get, ignored -> { });
service.begin(playerId, "Alex", Instant.EPOCH);
nanos.addAndGet(Duration.ofSeconds(5).toNanos());
service.begin(playerId, "Alex", Instant.EPOCH.plusSeconds(5)).join();
nanos.addAndGet(Duration.ofSeconds(5).toNanos());
service.stop(playerId).join();
assertEquals(Duration.ofSeconds(10).toMillis(), manager.snapshot().player(playerId).accumulatedMillis());
}
}
@Test
void unlocksAndNotifiesExactlyOnceWhilePreservingExcessTime() {
AtomicLong nanos = new AtomicLong();
UUID playerId = UUID.randomUUID();
ArrayList<UUID> notifications = new ArrayList<>();
try (StealthStateManager manager = manager()) {
QualifyingInvisibilityService service = new QualifyingInvisibilityService(
manager, Duration.ofSeconds(10), nanos::get, notifications::add);
service.begin(playerId, "Alex", Instant.EPOCH);
nanos.addAndGet(Duration.ofSeconds(11).toNanos());
service.checkpoint(playerId).join();
nanos.addAndGet(Duration.ofSeconds(2).toNanos());
service.stop(playerId).join();
PlayerStealthState player = manager.snapshot().player(playerId);
assertTrue(player.unlocked());
assertEquals(Duration.ofSeconds(13).toMillis(), player.accumulatedMillis());
assertEquals(java.util.List.of(playerId), notifications);
}
}
private static StealthStateManager manager() {
StealthStateRepository repository = new StealthStateRepository() {
@Override public PersistentStealthState load() { return new PersistentStealthState(Map.of(), Map.of()); }
@Override public void save(PersistentStealthState state) { }
};
return new StealthStateManager(repository, new PersistentStealthState(Map.of(), Map.of()));
}
}