From 2bcba5071fa734fc86b90168bed6ff498ad481cc Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Sat, 15 Aug 2026 00:20:29 -0400 Subject: [PATCH] feat(admin): manage player stealth state --- design/log.md | 2 + .../us-004-administer-player-stealth.md | 32 +++-- .../spigotstealth/KnownPlayerResolver.java | 64 +++++++++ .../spigotstealth/SpigotStealthPlugin.java | 14 +- .../spigotstealth/StealthAdminCommand.java | 134 ++++++++++++++++++ .../StealthAdministrationService.java | 92 ++++++++++++ .../KnownPlayerResolverTest.java | 33 +++++ .../StealthAdminCommandTest.java | 89 ++++++++++++ .../StealthAdministrationServiceTest.java | 101 +++++++++++++ 9 files changed, 546 insertions(+), 15 deletions(-) create mode 100644 src/main/java/games/dmg/spigotstealth/KnownPlayerResolver.java create mode 100644 src/main/java/games/dmg/spigotstealth/StealthAdminCommand.java create mode 100644 src/main/java/games/dmg/spigotstealth/StealthAdministrationService.java create mode 100644 src/test/java/games/dmg/spigotstealth/KnownPlayerResolverTest.java create mode 100644 src/test/java/games/dmg/spigotstealth/StealthAdminCommandTest.java create mode 100644 src/test/java/games/dmg/spigotstealth/StealthAdministrationServiceTest.java diff --git a/design/log.md b/design/log.md index 991261c..54e6cfa 100644 --- a/design/log.md +++ b/design/log.md @@ -2,6 +2,8 @@ ## 2026-08-14 +- **Completion**: Completed US-004 with exact known online and offline targeting, complete status, idempotent grants, confirmed resets, active concealment discovery, operator-default permissions, asynchronous persisted replies, and console audits; verified the full Gradle build. +- **Implementation**: Began US-004 with test-first exact offline targeting, grant/reset transitions, concealed-player listing, permissions, confirmation, and audit logging. - **Completion**: Completed US-002 with single-use durable prepared logins, join-announcement suppression, ProtocolLib tab-only removal, scoreboard overhead-name suppression, visible physical entities, periodic observer refresh, respawn restoration, and disconnect or disable cleanup; verified the full Gradle build. - **Implementation**: Began US-002 with test-first prepared-login consumption, session-scoped concealment, join suppression, tab removal, and overhead-name presentation. - **Completion**: Completed US-003 with `/stealth progress`, live unsaved interval inclusion, configured target and remaining output, concise duration formatting, and unlocked usage guidance; verified the full Gradle build. diff --git a/design/user-stories/us-004-administer-player-stealth.md b/design/user-stories/us-004-administer-player-stealth.md index 0033216..564618b 100644 --- a/design/user-stories/us-004-administer-player-stealth.md +++ b/design/user-stories/us-004-administer-player-stealth.md @@ -2,7 +2,7 @@ type: User Story title: "US-004: Inspect and manage player stealth" description: Give administrators durable controls for online and offline progression, unlocks, and active concealment. -status: backlog +status: done --- # US-004: Inspect and manage player stealth @@ -11,19 +11,23 @@ As a **server administrator**, I want to inspect and correct player stealth stat ## Acceptance criteria -- [ ] `/stealthadmin status ` reports accumulated time, unlock status, prepared-login state, and current concealment state. -- [ ] Status inspection works for online players and known offline players selected by exact current or previously recorded name or UUID. -- [ ] `/stealthadmin grant ` grants the unlock to an online or known offline player without altering accumulated time unnecessarily. -- [ ] Granting an already-owned unlock is safe and clearly reports that no change was needed. -- [ ] Granting the unlock sends the normal full-screen title and chat notification when the target is online. -- [ ] `/stealthadmin reset confirm` clears accumulated time, unlock ownership, prepared-login state, and current concealment for an online or known offline player. -- [ ] Reset requires explicit confirmation and safely restores an online concealed player to ordinary identity presentation. -- [ ] `/stealthadmin list` lists every currently online concealed player and clearly reports when there are none. -- [ ] Concealed players remain absent from administrators' ordinary tab lists and retain hidden overhead name tags; the admin command is the supported discovery mechanism. -- [ ] Commands clearly reject unknown, ambiguous, malformed, or otherwise invalid targets without creating unintended player records. -- [ ] Administrative inspection and modification require an operator-default administrative permission. -- [ ] State-changing operations persist before success is reported and are safe under retries. -- [ ] Grant and reset actions record the administrator, target UUID, and action in the server log without blocking the server tick thread. +- [x] `/stealthadmin status ` reports accumulated time, unlock status, prepared-login state, and current concealment state. +- [x] Status inspection works for online players and known offline players selected by exact current or previously recorded name or UUID. +- [x] `/stealthadmin grant ` grants the unlock to an online or known offline player without altering accumulated time unnecessarily. +- [x] Granting an already-owned unlock is safe and clearly reports that no change was needed. +- [x] Granting the unlock sends the normal full-screen title and chat notification when the target is online. +- [x] `/stealthadmin reset confirm` clears accumulated time, unlock ownership, prepared-login state, and current concealment for an online or known offline player. +- [x] Reset requires explicit confirmation and safely restores an online concealed player to ordinary identity presentation. +- [x] `/stealthadmin list` lists every currently online concealed player and clearly reports when there are none. +- [x] Concealed players remain absent from administrators' ordinary tab lists and retain hidden overhead name tags; the admin command is the supported discovery mechanism. +- [x] Commands clearly reject unknown, ambiguous, malformed, or otherwise invalid targets without creating unintended player records. +- [x] Administrative inspection and modification require an operator-default administrative permission. +- [x] State-changing operations persist before success is reported and are safe under retries. +- [x] Grant and reset actions record the administrator, target UUID, and action in the server log without blocking the server tick thread. + +## Validation + +Automated tests verify exact offline name and UUID resolution, ambiguous and unknown rejection without record creation, complete status output, idempotent grants and notification, complete resets with presentation cleanup, online concealed-player filtering, confirmation and permission gates, persisted-before-success replies, and audit records. The complete `./gradlew clean check jar` lifecycle passes. ## Related diff --git a/src/main/java/games/dmg/spigotstealth/KnownPlayerResolver.java b/src/main/java/games/dmg/spigotstealth/KnownPlayerResolver.java new file mode 100644 index 0000000..c84e3fd --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/KnownPlayerResolver.java @@ -0,0 +1,64 @@ +package games.dmg.spigotstealth; + +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.function.Supplier; +import org.bukkit.entity.Player; + +/** Resolves only already-known UUIDs and exact names without creating offline-player records. */ +public final class KnownPlayerResolver { + private final Supplier state; + private final Supplier> onlinePlayers; + + public KnownPlayerResolver( + Supplier state, + Supplier> onlinePlayers) { + this.state = Objects.requireNonNull(state, "state"); + this.onlinePlayers = Objects.requireNonNull(onlinePlayers, "onlinePlayers"); + } + + public Resolution resolve(String query) { + if (query == null || query.isBlank()) { + return new Resolution(Status.UNKNOWN, null); + } + Map known = knownPlayers(); + try { + UUID playerId = UUID.fromString(query); + KnownPlayer player = known.get(playerId); + return player == null ? new Resolution(Status.UNKNOWN, null) : new Resolution(Status.FOUND, player); + } catch (IllegalArgumentException ignored) { + // Continue with exact case-insensitive name resolution. + } + String normalized = query.toLowerCase(Locale.ROOT); + java.util.List matches = known.values().stream() + .filter(player -> player.name() != null && player.name().toLowerCase(Locale.ROOT).equals(normalized)) + .toList(); + if (matches.isEmpty()) { + return new Resolution(Status.UNKNOWN, null); + } + if (matches.size() > 1) { + return new Resolution(Status.AMBIGUOUS, null); + } + return new Resolution(Status.FOUND, matches.get(0)); + } + + private Map knownPlayers() { + Map known = new LinkedHashMap<>(); + state.get().players().forEach((playerId, playerState) -> + known.put(playerId, new KnownPlayer(playerId, playerState.lastKnownName(), null))); + for (Player player : onlinePlayers.get()) { + known.put(player.getUniqueId(), new KnownPlayer(player.getUniqueId(), player.getName(), player)); + } + return known; + } + + public enum Status { FOUND, UNKNOWN, AMBIGUOUS } + + public record KnownPlayer(UUID playerId, String name, Player onlinePlayer) { } + + public record Resolution(Status status, KnownPlayer player) { } +} diff --git a/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java b/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java index 069af62..84d1888 100644 --- a/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java +++ b/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java @@ -62,9 +62,11 @@ public final class SpigotStealthPlugin extends JavaPlugin { } private void finishInitialization(StealthStateManager manager) { + java.util.function.Consumer mainThread = + runnable -> getServer().getScheduler().runTask(this, runnable); BukkitUnlockNotifier notifier = new BukkitUnlockNotifier( getServer()::getPlayer, - runnable -> getServer().getScheduler().runTask(this, runnable), + mainThread, settings.unlockedMessage()); progression = new QualifyingInvisibilityService( manager, settings.unlockThreshold(), System::nanoTime, notifier); @@ -79,6 +81,16 @@ public final class SpigotStealthPlugin extends JavaPlugin { new StealthSessionListener(sessions, identityPresentation, settings.concealedMessage()), this); Objects.requireNonNull(getCommand("stealth"), "stealth command") .setExecutor(new StealthCommand(manager, progression, settings)); + StealthAdministrationService administration = new StealthAdministrationService( + manager, progression, identityPresentation, notifier, getServer()::getOnlinePlayers); + KnownPlayerResolver resolver = new KnownPlayerResolver(manager::snapshot, getServer()::getOnlinePlayers); + Objects.requireNonNull(getCommand("stealthadmin"), "stealthadmin command") + .setExecutor(new StealthAdminCommand( + administration, + resolver, + mainThread, + getLogger()::info, + settings.unlockThreshold())); getServer().getScheduler().runTaskTimer(this, ignored -> { for (java.util.UUID playerId : progression.activePlayerIds()) { logSaveFailure(progression.checkpoint(playerId)); diff --git a/src/main/java/games/dmg/spigotstealth/StealthAdminCommand.java b/src/main/java/games/dmg/spigotstealth/StealthAdminCommand.java new file mode 100644 index 0000000..f264add --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/StealthAdminCommand.java @@ -0,0 +1,134 @@ +package games.dmg.spigotstealth; + +import java.time.Duration; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; + +/** Permission-gated administrative command for online and known offline players. */ +public final class StealthAdminCommand implements CommandExecutor { + private static final String PERMISSION = "spigotstealth.admin"; + private final StealthAdministrationService administration; + private final KnownPlayerResolver resolver; + private final Consumer mainThread; + private final Consumer auditLog; + private final Duration unlockThreshold; + + public StealthAdminCommand( + StealthAdministrationService administration, + KnownPlayerResolver resolver, + Consumer mainThread, + Consumer auditLog, + Duration unlockThreshold) { + this.administration = Objects.requireNonNull(administration, "administration"); + this.resolver = Objects.requireNonNull(resolver, "resolver"); + this.mainThread = Objects.requireNonNull(mainThread, "mainThread"); + this.auditLog = Objects.requireNonNull(auditLog, "auditLog"); + this.unlockThreshold = Objects.requireNonNull(unlockThreshold, "unlockThreshold"); + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) { + if (!sender.hasPermission(PERMISSION)) { + sender.sendMessage("You do not have permission to administer Spigot Stealth."); + return true; + } + if (arguments.length == 1 && "list".equalsIgnoreCase(arguments[0])) { + List names = administration.concealedOnlineNames(); + sender.sendMessage(names.isEmpty() + ? "No players are currently concealed." + : "Currently concealed: " + String.join(", ", names)); + return true; + } + if (arguments.length < 2) { + sendUsage(sender, label); + return true; + } + String operation = arguments[0].toLowerCase(Locale.ROOT); + KnownPlayerResolver.Resolution resolution = resolver.resolve(arguments[1]); + if (resolution.status() != KnownPlayerResolver.Status.FOUND) { + sender.sendMessage(resolution.status() == KnownPlayerResolver.Status.AMBIGUOUS + ? "That player name is ambiguous; use a UUID." + : "Unknown player. Use an exact known name or UUID."); + return true; + } + KnownPlayerResolver.KnownPlayer target = resolution.player(); + return switch (operation) { + case "status" -> status(sender, target); + case "grant" -> grant(sender, target); + case "reset" -> reset(sender, label, arguments, target); + default -> { + sendUsage(sender, label); + yield true; + } + }; + } + + private boolean status(CommandSender sender, KnownPlayerResolver.KnownPlayer target) { + StealthAdministrationService.PlayerStatus status = administration.status(target.playerId()); + sender.sendMessage("Stealth status for " + displayName(target) + " (" + target.playerId() + "):"); + sender.sendMessage("progress=" + StealthProgressFormatter.duration(status.accumulatedMillis()) + + "/" + StealthProgressFormatter.duration(unlockThreshold.toMillis()) + + " unlocked=" + yesNo(status.unlocked()) + + " prepared=" + yesNo(status.preparedLogin()) + + " concealed=" + yesNo(status.concealed()) + + " qualifying=" + yesNo(status.qualifyingNow())); + return true; + } + + private boolean grant(CommandSender sender, KnownPlayerResolver.KnownPlayer target) { + CompletableFuture grant = + administration.grant(target.playerId(), target.name()); + grant.whenComplete((result, failure) -> mainThread.accept(() -> { + if (failure != null) { + sender.sendMessage("Unable to persist the stealth grant; check the server log."); + return; + } + sender.sendMessage(result.changed() + ? "Stealth unlock granted to " + displayName(target) + "." + : displayName(target) + " already has the stealth unlock."); + auditLog.accept("stealthadmin grant administrator=" + sender.getName() + + " target=" + target.playerId() + " changed=" + result.changed()); + })); + return true; + } + + private boolean reset( + CommandSender sender, + String label, + String[] arguments, + KnownPlayerResolver.KnownPlayer target) { + if (arguments.length != 3 || !"confirm".equalsIgnoreCase(arguments[2])) { + sender.sendMessage("Confirm the complete reset with /" + label + " reset " + arguments[1] + " confirm"); + return true; + } + administration.reset(target.playerId(), target.name(), target.onlinePlayer()) + .whenComplete((ignored, failure) -> mainThread.accept(() -> { + if (failure != null) { + sender.sendMessage("Unable to persist the stealth reset; check the server log."); + return; + } + sender.sendMessage("Stealth reset complete for " + displayName(target) + "."); + auditLog.accept("stealthadmin reset administrator=" + sender.getName() + + " target=" + target.playerId()); + })); + return true; + } + + private static void sendUsage(CommandSender sender, String label) { + sender.sendMessage("Usage: /" + label + " |grant |reset confirm|list>"); + } + + private static String displayName(KnownPlayerResolver.KnownPlayer player) { + return player.name() == null ? player.playerId().toString() : player.name(); + } + + private static String yesNo(boolean value) { + return value ? "yes" : "no"; + } +} diff --git a/src/main/java/games/dmg/spigotstealth/StealthAdministrationService.java b/src/main/java/games/dmg/spigotstealth/StealthAdministrationService.java new file mode 100644 index 0000000..2cbe2e9 --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/StealthAdministrationService.java @@ -0,0 +1,92 @@ +package games.dmg.spigotstealth; + +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; +import java.util.function.Supplier; +import org.bukkit.entity.Player; + +/** Durable administrative operations over player stealth state. */ +public final class StealthAdministrationService { + private final StealthStateManager stateManager; + private final QualifyingInvisibilityService progression; + private final IdentityPresentation presentation; + private final Consumer unlockNotifier; + private final Supplier> onlinePlayers; + + public StealthAdministrationService( + StealthStateManager stateManager, + QualifyingInvisibilityService progression, + IdentityPresentation presentation, + Consumer unlockNotifier, + Supplier> onlinePlayers) { + this.stateManager = Objects.requireNonNull(stateManager, "stateManager"); + this.progression = Objects.requireNonNull(progression, "progression"); + this.presentation = Objects.requireNonNull(presentation, "presentation"); + this.unlockNotifier = Objects.requireNonNull(unlockNotifier, "unlockNotifier"); + this.onlinePlayers = Objects.requireNonNull(onlinePlayers, "onlinePlayers"); + } + + public PlayerStatus status(UUID playerId) { + PlayerStealthState player = stateManager.snapshot().player(playerId); + return new PlayerStatus( + progression.currentAccumulatedMillis(playerId), + player.unlocked(), + player.preparedLogin(), + player.concealed(), + progression.isQualifying(playerId)); + } + + public CompletableFuture grant(UUID playerId, String playerName) { + AtomicBoolean changed = new AtomicBoolean(); + CompletableFuture saved = stateManager.update(state -> { + PlayerStealthState player = state.player(playerId).withLastKnownName(playerName); + if (player.unlocked()) { + return state.withPlayer(player); + } + changed.set(true); + return state.withPlayer(player.withProgress(player.accumulatedMillis(), true)); + }); + return saved.thenApply(ignored -> { + if (changed.get()) { + unlockNotifier.accept(playerId); + } + return new ChangeResult(changed.get()); + }); + } + + public CompletableFuture reset(UUID playerId, String playerName, Player onlinePlayer) { + CompletableFuture stopped = progression.stop(playerId); + CompletableFuture reset = stateManager.update(state -> { + PlayerStealthState player = state.player(playerId); + PlayerStealthState cleared = new PlayerStealthState( + playerId, playerName, 0L, false, false, false, null, player.unknownFields()); + return state.withPlayer(cleared); + }); + if (onlinePlayer != null) { + presentation.reveal(onlinePlayer); + } + return CompletableFuture.allOf(stopped, reset); + } + + public List concealedOnlineNames() { + return onlinePlayers.get().stream() + .filter(player -> stateManager.snapshot().player(player.getUniqueId()).concealed()) + .map(Player::getName) + .sorted(String.CASE_INSENSITIVE_ORDER) + .toList(); + } + + public record PlayerStatus( + long accumulatedMillis, + boolean unlocked, + boolean preparedLogin, + boolean concealed, + boolean qualifyingNow) { } + + public record ChangeResult(boolean changed) { } +} diff --git a/src/test/java/games/dmg/spigotstealth/KnownPlayerResolverTest.java b/src/test/java/games/dmg/spigotstealth/KnownPlayerResolverTest.java new file mode 100644 index 0000000..4edc1d8 --- /dev/null +++ b/src/test/java/games/dmg/spigotstealth/KnownPlayerResolverTest.java @@ -0,0 +1,33 @@ +package games.dmg.spigotstealth; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class KnownPlayerResolverTest { + @Test + void resolvesKnownOfflinePlayerByExactNameOrUuid() { + UUID playerId = UUID.randomUUID(); + PlayerStealthState player = PlayerStealthState.empty(playerId).withLastKnownName("Alex"); + PersistentStealthState state = new PersistentStealthState(Map.of(playerId, player), Map.of()); + KnownPlayerResolver resolver = new KnownPlayerResolver(() -> state, List::of); + assertEquals(playerId, resolver.resolve("alex").player().playerId()); + assertEquals(playerId, resolver.resolve(playerId.toString()).player().playerId()); + } + + @Test + void reportsAmbiguousAndUnknownNamesWithoutCreatingRecords() { + UUID first = UUID.randomUUID(); + UUID second = UUID.randomUUID(); + PersistentStealthState state = new PersistentStealthState(Map.of( + first, PlayerStealthState.empty(first).withLastKnownName("Alex"), + second, PlayerStealthState.empty(second).withLastKnownName("ALEX")), Map.of()); + KnownPlayerResolver resolver = new KnownPlayerResolver(() -> state, List::of); + assertEquals(KnownPlayerResolver.Status.AMBIGUOUS, resolver.resolve("alex").status()); + assertEquals(KnownPlayerResolver.Status.UNKNOWN, resolver.resolve("nobody").status()); + assertEquals(2, state.players().size()); + } +} diff --git a/src/test/java/games/dmg/spigotstealth/StealthAdminCommandTest.java b/src/test/java/games/dmg/spigotstealth/StealthAdminCommandTest.java new file mode 100644 index 0000000..fde2989 --- /dev/null +++ b/src/test/java/games/dmg/spigotstealth/StealthAdminCommandTest.java @@ -0,0 +1,89 @@ +package games.dmg.spigotstealth; + +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.junit.jupiter.api.Test; + +class StealthAdminCommandTest { + @Test + void statusInspectsKnownOfflinePlayerAndReportsAllState() { + UUID playerId = UUID.randomUUID(); + try (Fixture fixture = fixture(playerId)) { + CommandSender sender = sender(true); + fixture.command.onCommand(sender, mock(Command.class), "stealthadmin", new String[] {"status", "Alex"}); + verify(sender).sendMessage(contains(playerId.toString())); + verify(sender).sendMessage(contains("prepared=yes")); + verify(sender).sendMessage(contains("concealed=no")); + } + } + + @Test + void grantPersistsThenReportsAndAuditsAction() { + UUID playerId = UUID.randomUUID(); + try (Fixture fixture = fixture(playerId)) { + CommandSender sender = sender(true); + fixture.command.onCommand(sender, mock(Command.class), "stealthadmin", new String[] {"grant", playerId.toString()}); + verify(sender, org.mockito.Mockito.timeout(1000)).sendMessage(contains("granted")); + org.junit.jupiter.api.Assertions.assertTrue(fixture.manager.snapshot().player(playerId).unlocked()); + org.junit.jupiter.api.Assertions.assertTrue(fixture.audit.stream().anyMatch(message -> message.contains("grant") && message.contains(playerId.toString()))); + } + } + + @Test + void resetRequiresConfirmationAndPermission() { + UUID playerId = UUID.randomUUID(); + try (Fixture fixture = fixture(playerId)) { + CommandSender denied = sender(false); + fixture.command.onCommand(denied, mock(Command.class), "stealthadmin", new String[] {"reset", "Alex", "confirm"}); + verify(denied).sendMessage(contains("permission")); + CommandSender allowed = sender(true); + fixture.command.onCommand(allowed, mock(Command.class), "stealthadmin", new String[] {"reset", "Alex"}); + verify(allowed).sendMessage(contains("confirm")); + verify(allowed, never()).sendMessage(contains("reset complete")); + } + } + + private static CommandSender sender(boolean permission) { + CommandSender sender = mock(CommandSender.class); + when(sender.hasPermission("spigotstealth.admin")).thenReturn(permission); + when(sender.getName()).thenReturn("Operator"); + return sender; + } + + private static Fixture fixture(UUID playerId) { + StealthStateRepository repository = new StealthStateRepository() { + @Override public PersistentStealthState load() { return new PersistentStealthState(Map.of(), Map.of()); } + @Override public void save(PersistentStealthState state) { } + }; + StealthStateManager manager = new StealthStateManager(repository, new PersistentStealthState(Map.of(), Map.of())); + PlayerStealthState player = new PlayerStealthState(playerId, "Alex", 5000L, false, true, false, null, Map.of()); + manager.update(state -> state.withPlayer(player)).join(); + QualifyingInvisibilityService progression = new QualifyingInvisibilityService( + manager, Duration.ofHours(8), System::nanoTime, ignored -> { }); + StealthAdministrationService administration = new StealthAdministrationService( + manager, progression, mock(IdentityPresentation.class), ignored -> { }, List::of); + KnownPlayerResolver resolver = new KnownPlayerResolver(manager::snapshot, List::of); + ArrayList audit = new ArrayList<>(); + StealthAdminCommand command = new StealthAdminCommand( + administration, resolver, Runnable::run, audit::add, Duration.ofHours(8)); + return new Fixture(manager, command, audit); + } + + private record Fixture( + StealthStateManager manager, + StealthAdminCommand command, + ArrayList audit) implements AutoCloseable { + @Override public void close() { manager.close(); } + } +} diff --git a/src/test/java/games/dmg/spigotstealth/StealthAdministrationServiceTest.java b/src/test/java/games/dmg/spigotstealth/StealthAdministrationServiceTest.java new file mode 100644 index 0000000..f78b97c --- /dev/null +++ b/src/test/java/games/dmg/spigotstealth/StealthAdministrationServiceTest.java @@ -0,0 +1,101 @@ +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 static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +class StealthAdministrationServiceTest { + @Test + void grantsOfflineUnlockAndNotifiesExactlyOnce() { + UUID playerId = UUID.randomUUID(); + ArrayList notifications = new ArrayList<>(); + try (StealthStateManager manager = manager()) { + StealthAdministrationService administration = service(manager, List::of, notifications::add); + assertTrue(administration.grant(playerId, "Alex").join().changed()); + assertFalse(administration.grant(playerId, "Alex").join().changed()); + assertTrue(manager.snapshot().player(playerId).unlocked()); + assertEquals(List.of(playerId), notifications); + } + } + + @Test + void resetClearsProgressUnlockPreparationConcealmentAndActiveTracking() { + UUID playerId = UUID.randomUUID(); + Player player = mock(Player.class); + org.mockito.Mockito.when(player.getUniqueId()).thenReturn(playerId); + try (StealthStateManager manager = manager()) { + PlayerStealthState state = new PlayerStealthState( + playerId, "Alex", 1234L, true, true, true, Instant.EPOCH, Map.of("future", "keep")); + manager.update(all -> all.withPlayer(state)).join(); + QualifyingInvisibilityService progression = new QualifyingInvisibilityService( + manager, Duration.ofHours(8), System::nanoTime, ignored -> { }); + progression.begin(playerId, "Alex", Instant.EPOCH).join(); + IdentityPresentation presentation = mock(IdentityPresentation.class); + StealthAdministrationService administration = new StealthAdministrationService( + manager, progression, presentation, ignored -> { }, () -> List.of(player)); + + administration.reset(playerId, "Alex", player).join(); + + PlayerStealthState reset = manager.snapshot().player(playerId); + assertEquals(0L, reset.accumulatedMillis()); + assertFalse(reset.unlocked()); + assertFalse(reset.preparedLogin()); + assertFalse(reset.concealed()); + assertFalse(progression.isQualifying(playerId)); + assertEquals("keep", reset.unknownFields().get("future")); + verify(presentation).reveal(player); + } + } + + @Test + void listsOnlyCurrentlyOnlineConcealedPlayers() { + UUID concealedId = UUID.randomUUID(); + UUID ordinaryId = UUID.randomUUID(); + Player concealed = player(concealedId, "Hidden"); + Player ordinary = player(ordinaryId, "Visible"); + try (StealthStateManager manager = manager()) { + manager.update(state -> state + .withPlayer(PlayerStealthState.empty(concealedId).withLastKnownName("Hidden").withSession(false, true)) + .withPlayer(PlayerStealthState.empty(ordinaryId).withLastKnownName("Visible"))).join(); + StealthAdministrationService administration = service( + manager, () -> List.of(concealed, ordinary), ignored -> { }); + assertEquals(List.of("Hidden"), administration.concealedOnlineNames()); + } + } + + private static StealthAdministrationService service( + StealthStateManager manager, + java.util.function.Supplier> online, + java.util.function.Consumer notifier) { + QualifyingInvisibilityService progression = new QualifyingInvisibilityService( + manager, Duration.ofHours(8), System::nanoTime, ignored -> { }); + return new StealthAdministrationService( + manager, progression, mock(IdentityPresentation.class), notifier, online); + } + + private static Player player(UUID playerId, String name) { + Player player = mock(Player.class); + org.mockito.Mockito.when(player.getUniqueId()).thenReturn(playerId); + org.mockito.Mockito.when(player.getName()).thenReturn(name); + 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())); + } +}