From 91c9e7b9ada4b04dcbe3843b34ca20bf60f390b1 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Sat, 15 Aug 2026 00:01:26 -0400 Subject: [PATCH] feat(progression): unlock stealth from potion invisibility --- design/log.md | 2 + ...-001-accumulate-invisibility-and-unlock.md | 24 ++-- .../spigotstealth/BukkitUnlockNotifier.java | 35 +++++ .../InvisibilityEffectListener.java | 57 ++++++++ .../QualifyingInvisibilityService.java | 122 ++++++++++++++++++ .../spigotstealth/SpigotStealthPlugin.java | 28 +++- .../BukkitUnlockNotifierTest.java | 27 ++++ .../InvisibilityEffectListenerTest.java | 68 ++++++++++ .../QualifyingInvisibilityServiceTest.java | 80 ++++++++++++ 9 files changed, 430 insertions(+), 13 deletions(-) create mode 100644 src/main/java/games/dmg/spigotstealth/BukkitUnlockNotifier.java create mode 100644 src/main/java/games/dmg/spigotstealth/InvisibilityEffectListener.java create mode 100644 src/main/java/games/dmg/spigotstealth/QualifyingInvisibilityService.java create mode 100644 src/test/java/games/dmg/spigotstealth/BukkitUnlockNotifierTest.java create mode 100644 src/test/java/games/dmg/spigotstealth/InvisibilityEffectListenerTest.java create mode 100644 src/test/java/games/dmg/spigotstealth/QualifyingInvisibilityServiceTest.java diff --git a/design/log.md b/design/log.md index eb5a1bb..aaa0193 100644 --- a/design/log.md +++ b/design/log.md @@ -2,6 +2,8 @@ ## 2026-08-14 +- **Completion**: Completed US-001 with direct potion-drink effect attribution, monotonic elapsed-time accumulation, safe refresh and stop transitions, durable UUID progress, exact-once unlocking, and title plus chat presentation; verified the full Gradle build. +- **Implementation**: Began US-001 with monotonic-clock progression tests and potion-cause event attribution. - **Completion**: Completed US-005 with validated eight-hour defaults and configurable messages, UUID-keyed immutable state, RFC 3339 timing metadata, safe invalid-record defaults, unknown-field preservation, atomic YAML replacement, periodic saves, and serialized off-thread persistence; verified the full Gradle build. - **Implementation**: Began US-005 with test-first validated settings, UUID-keyed state, and defensive asynchronous persistence. - **Completion**: Completed US-006 with a Java 17 Gradle build, strict compiler linting, Spigot API, JUnit 5, Mockito, plugin metadata, OKF validation, Gitea CI, conventional-commit checks, semantic releases, and versioned release assets; verified the full build and a `1.2.3` release JAR. diff --git a/design/user-stories/us-001-accumulate-invisibility-and-unlock.md b/design/user-stories/us-001-accumulate-invisibility-and-unlock.md index 72e879e..8a1eeb1 100644 --- a/design/user-stories/us-001-accumulate-invisibility-and-unlock.md +++ b/design/user-stories/us-001-accumulate-invisibility-and-unlock.md @@ -2,7 +2,7 @@ type: User Story title: "US-001: Accumulate invisibility time and unlock stealth" description: Reward sustained use of directly consumed invisibility potions with the stealth ability. -status: backlog +status: done --- # US-001: Accumulate invisibility time and unlock stealth @@ -11,15 +11,19 @@ As a **player**, I want my qualifying invisibility time to accumulate so that su ## Acceptance criteria -- [ ] Only time spent online under an invisibility effect produced by a potion the player directly drank contributes to progression. -- [ ] Invisibility received from splash potions, lingering potions, tipped arrows, commands, plugins, or other sources does not contribute. -- [ ] Qualifying time stops when the effect ends, is removed, is replaced by a non-qualifying source, the player disconnects, or the plugin disables. -- [ ] Refreshed or overlapping qualifying effects never count elapsed time more than once. -- [ ] Qualifying time accumulates across effects, sessions, and server restarts. -- [ ] The ability unlocks when accumulated qualifying time reaches eight hours by default. -- [ ] Reaching the threshold grants the unlock exactly once without discarding excess elapsed time. -- [ ] When an online player unlocks stealth, they receive both a full-screen title and a chat message explaining the ability. -- [ ] Progress and unlock ownership are associated with the player's UUID rather than their current name. +- [x] Only time spent online under an invisibility effect produced by a potion the player directly drank contributes to progression. +- [x] Invisibility received from splash potions, lingering potions, tipped arrows, commands, plugins, or other sources does not contribute. +- [x] Qualifying time stops when the effect ends, is removed, is replaced by a non-qualifying source, the player disconnects, or the plugin disables. +- [x] Refreshed or overlapping qualifying effects never count elapsed time more than once. +- [x] Qualifying time accumulates across effects, sessions, and server restarts. +- [x] The ability unlocks when accumulated qualifying time reaches eight hours by default. +- [x] Reaching the threshold grants the unlock exactly once without discarding excess elapsed time. +- [x] When an online player unlocks stealth, they receive both a full-screen title and a chat message explaining the ability. +- [x] Progress and unlock ownership are associated with the player's UUID rather than their current name. + +## Validation + +Automated tests verify monotonic accumulation across intervals, refresh without duplicate time, exact-once threshold crossing with excess-time preservation, direct potion-drink cause filtering, non-qualifying replacement, UUID state, and online title and chat presentation. The complete `./gradlew clean check jar` lifecycle passes. ## Related diff --git a/src/main/java/games/dmg/spigotstealth/BukkitUnlockNotifier.java b/src/main/java/games/dmg/spigotstealth/BukkitUnlockNotifier.java new file mode 100644 index 0000000..0a60022 --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/BukkitUnlockNotifier.java @@ -0,0 +1,35 @@ +package games.dmg.spigotstealth; + +import java.util.Objects; +import java.util.UUID; +import java.util.function.Consumer; +import java.util.function.Function; +import org.bukkit.entity.Player; + +/** Marshals unlock presentation onto the server thread. */ +public final class BukkitUnlockNotifier implements Consumer { + private final Function playerLookup; + private final Consumer mainThread; + private final String chatMessage; + + public BukkitUnlockNotifier( + Function playerLookup, + Consumer mainThread, + String chatMessage) { + this.playerLookup = Objects.requireNonNull(playerLookup, "playerLookup"); + this.mainThread = Objects.requireNonNull(mainThread, "mainThread"); + this.chatMessage = Objects.requireNonNull(chatMessage, "chatMessage"); + } + + @Override + public void accept(UUID playerId) { + mainThread.accept(() -> { + Player player = playerLookup.apply(playerId); + if (player == null || !player.isOnline()) { + return; + } + player.sendTitle("Stealth Unlocked", "Your identity can now be concealed", 10, 100, 20); + player.sendMessage(chatMessage); + }); + } +} diff --git a/src/main/java/games/dmg/spigotstealth/InvisibilityEffectListener.java b/src/main/java/games/dmg/spigotstealth/InvisibilityEffectListener.java new file mode 100644 index 0000000..8c730eb --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/InvisibilityEffectListener.java @@ -0,0 +1,57 @@ +package games.dmg.spigotstealth; + +import java.time.Clock; +import java.time.Instant; +import java.util.Objects; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityPotionEffectEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.potion.PotionEffectType; + +/** Attributes invisibility changes to directly consumed potions and closes intervals safely. */ +public final class InvisibilityEffectListener implements Listener { + private final QualifyingInvisibilityService progression; + private final Clock clock; + + public InvisibilityEffectListener(QualifyingInvisibilityService progression, Clock clock) { + this.progression = Objects.requireNonNull(progression, "progression"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onPotionEffect(EntityPotionEffectEvent event) { + if (!(event.getEntity() instanceof Player player)) { + return; + } + handlePotionEffect( + player, + PotionEffectType.INVISIBILITY.equals(event.getModifiedType()), + event.getCause(), + event.getAction()); + } + + void handlePotionEffect( + Player player, + boolean invisibility, + EntityPotionEffectEvent.Cause cause, + EntityPotionEffectEvent.Action action) { + if (!invisibility) { + return; + } + boolean addedOrChanged = action == EntityPotionEffectEvent.Action.ADDED + || action == EntityPotionEffectEvent.Action.CHANGED; + if (cause == EntityPotionEffectEvent.Cause.POTION_DRINK && addedOrChanged) { + progression.begin(player.getUniqueId(), player.getName(), Instant.now(clock)); + } else { + progression.stop(player.getUniqueId()); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onQuit(PlayerQuitEvent event) { + progression.stop(event.getPlayer().getUniqueId()); + } +} diff --git a/src/main/java/games/dmg/spigotstealth/QualifyingInvisibilityService.java b/src/main/java/games/dmg/spigotstealth/QualifyingInvisibilityService.java new file mode 100644 index 0000000..4b5587c --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/QualifyingInvisibilityService.java @@ -0,0 +1,122 @@ +package games.dmg.spigotstealth; + +import java.time.Duration; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; +import java.util.function.LongSupplier; + +/** Accumulates qualifying potion invisibility using monotonic elapsed time. */ +public final class QualifyingInvisibilityService { + private final StealthStateManager stateManager; + private final long unlockThresholdMillis; + private final LongSupplier nanoTime; + private final Consumer unlockNotifier; + private final Map activeIntervals = new HashMap<>(); + + public QualifyingInvisibilityService( + StealthStateManager stateManager, + Duration unlockThreshold, + LongSupplier nanoTime, + Consumer unlockNotifier) { + this.stateManager = Objects.requireNonNull(stateManager, "stateManager"); + this.unlockThresholdMillis = Objects.requireNonNull(unlockThreshold, "unlockThreshold").toMillis(); + this.nanoTime = Objects.requireNonNull(nanoTime, "nanoTime"); + this.unlockNotifier = Objects.requireNonNull(unlockNotifier, "unlockNotifier"); + } + + public CompletableFuture begin(UUID playerId, String playerName, Instant startedAt) { + Objects.requireNonNull(playerId, "playerId"); + Objects.requireNonNull(startedAt, "startedAt"); + long nowNanos = nanoTime.getAsLong(); + ActiveInterval previous = activeIntervals.put(playerId, new ActiveInterval(nowNanos, startedAt, playerName)); + return record(playerId, playerName, elapsedMillis(previous, nowNanos), startedAt, true); + } + + public CompletableFuture checkpoint(UUID playerId) { + long nowNanos = nanoTime.getAsLong(); + ActiveInterval previous = activeIntervals.get(playerId); + if (previous == null) { + return CompletableFuture.completedFuture(null); + } + long elapsedMillis = elapsedMillis(previous, nowNanos); + Instant continuedAt = previous.startedAt().plusMillis(elapsedMillis); + activeIntervals.put(playerId, new ActiveInterval(nowNanos, continuedAt, previous.playerName())); + return record(playerId, previous.playerName(), elapsedMillis, continuedAt, true); + } + + public CompletableFuture stop(UUID playerId) { + long nowNanos = nanoTime.getAsLong(); + ActiveInterval previous = activeIntervals.remove(playerId); + if (previous == null) { + return CompletableFuture.completedFuture(null); + } + return record(playerId, previous.playerName(), elapsedMillis(previous, nowNanos), null, false); + } + + public boolean isQualifying(UUID playerId) { + return activeIntervals.containsKey(playerId); + } + + public Set activePlayerIds() { + return Set.copyOf(activeIntervals.keySet()); + } + + public CompletableFuture stopAll() { + CompletableFuture[] stops = activePlayerIds().stream() + .map(this::stop) + .toArray(CompletableFuture[]::new); + return CompletableFuture.allOf(stops); + } + + public long currentAccumulatedMillis(UUID playerId) { + long persisted = stateManager.snapshot().player(playerId).accumulatedMillis(); + return Math.addExact(persisted, elapsedMillis(activeIntervals.get(playerId), nanoTime.getAsLong())); + } + + private CompletableFuture record( + UUID playerId, + String playerName, + long elapsedMillis, + Instant qualifyingSince, + boolean active) { + AtomicBoolean unlockedNow = new AtomicBoolean(); + CompletableFuture saved = stateManager.update(state -> { + PlayerStealthState player = state.player(playerId); + long accumulated = Math.addExact(player.accumulatedMillis(), elapsedMillis); + boolean unlocked = player.unlocked() || accumulated >= unlockThresholdMillis; + unlockedNow.set(unlocked && !player.unlocked()); + PlayerStealthState updated = new PlayerStealthState( + playerId, + playerName, + accumulated, + unlocked, + player.preparedLogin(), + player.concealed(), + active ? qualifyingSince : null, + player.unknownFields()); + return state.withPlayer(updated); + }); + return saved.thenRun(() -> { + if (unlockedNow.get()) { + unlockNotifier.accept(playerId); + } + }); + } + + private static long elapsedMillis(ActiveInterval interval, long nowNanos) { + if (interval == null) { + return 0L; + } + long elapsedNanos = Math.max(0L, nowNanos - interval.startedNanos()); + return Duration.ofNanos(elapsedNanos).toMillis(); + } + + private record ActiveInterval(long startedNanos, Instant startedAt, String playerName) { } +} diff --git a/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java b/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java index 80d5114..b3fc94b 100644 --- a/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java +++ b/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java @@ -1,6 +1,7 @@ package games.dmg.spigotstealth; import java.nio.file.Path; +import java.time.Clock; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import org.bukkit.plugin.java.JavaPlugin; @@ -9,6 +10,7 @@ import org.bukkit.plugin.java.JavaPlugin; public final class SpigotStealthPlugin extends JavaPlugin { private CompletableFuture stateManagerFuture; private StealthSettings settings; + private QualifyingInvisibilityService progression; @Override public void onEnable() { @@ -35,6 +37,9 @@ public final class SpigotStealthPlugin extends JavaPlugin { @Override public void onDisable() { + if (progression != null) { + progression.stopAll().join(); + } if (stateManagerFuture != null && stateManagerFuture.isDone() && !stateManagerFuture.isCompletedExceptionally()) { stateManagerFuture.join().close(); } @@ -45,11 +50,28 @@ public final class SpigotStealthPlugin extends JavaPlugin { } private void finishInitialization(StealthStateManager manager) { - getServer().getScheduler().runTaskTimer(this, ignored -> manager.save().exceptionally(failure -> { + BukkitUnlockNotifier notifier = new BukkitUnlockNotifier( + getServer()::getPlayer, + runnable -> getServer().getScheduler().runTask(this, runnable), + settings.unlockedMessage()); + progression = new QualifyingInvisibilityService( + manager, settings.unlockThreshold(), System::nanoTime, notifier); + getServer().getPluginManager().registerEvents( + new InvisibilityEffectListener(progression, Clock.systemUTC()), this); + getServer().getScheduler().runTaskTimer(this, ignored -> { + for (java.util.UUID playerId : progression.activePlayerIds()) { + logSaveFailure(progression.checkpoint(playerId)); + } + }, 20L, 20L); + getServer().getScheduler().runTaskTimer(this, ignored -> logSaveFailure(manager.save()), 6000L, 6000L); + getLogger().info("Spigot Stealth enabled"); + } + + private void logSaveFailure(CompletableFuture save) { + save.exceptionally(failure -> { getLogger().severe("Unable to save Spigot Stealth state: " + rootMessage(failure)); return null; - }), 6000L, 6000L); - getLogger().info("Spigot Stealth enabled"); + }); } private static String rootMessage(Throwable throwable) { diff --git a/src/test/java/games/dmg/spigotstealth/BukkitUnlockNotifierTest.java b/src/test/java/games/dmg/spigotstealth/BukkitUnlockNotifierTest.java new file mode 100644 index 0000000..ad34f8a --- /dev/null +++ b/src/test/java/games/dmg/spigotstealth/BukkitUnlockNotifierTest.java @@ -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 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!"); + } +} diff --git a/src/test/java/games/dmg/spigotstealth/InvisibilityEffectListenerTest.java b/src/test/java/games/dmg/spigotstealth/InvisibilityEffectListenerTest.java new file mode 100644 index 0000000..49c610c --- /dev/null +++ b/src/test/java/games/dmg/spigotstealth/InvisibilityEffectListenerTest.java @@ -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())); + } +} diff --git a/src/test/java/games/dmg/spigotstealth/QualifyingInvisibilityServiceTest.java b/src/test/java/games/dmg/spigotstealth/QualifyingInvisibilityServiceTest.java new file mode 100644 index 0000000..b7f215c --- /dev/null +++ b/src/test/java/games/dmg/spigotstealth/QualifyingInvisibilityServiceTest.java @@ -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 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())); + } +}