From a084f86c1ddd7f57352af1d98fc4fec2b6b0ee69 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Sat, 5 Sep 2026 08:34:50 -0400 Subject: [PATCH] feat(admin): add persistent sleep-count policy --- design/log.md | 1 + .../us-002-rejoin-without-visible-identity.md | 6 +- .../us-004-administer-player-stealth.md | 5 +- ...s-005-configure-and-persist-progression.md | 3 +- .../BukkitIdentityPresentation.java | 36 +++++++++++ .../spigotstealth/IdentityPresentation.java | 2 + .../spigotstealth/PersistentStealthState.java | 14 ++++- .../dmg/spigotstealth/SleepCountPolicy.java | 24 ++++++++ .../spigotstealth/SpigotStealthPlugin.java | 3 +- .../spigotstealth/StealthAdminCommand.java | 52 +++++++++++++++- .../StealthAdministrationService.java | 20 +++++++ .../spigotstealth/StealthSessionService.java | 5 +- .../YamlStealthStateRepository.java | 8 ++- src/main/resources/plugin.yml | 2 +- .../BukkitIdentityPresentationTest.java | 59 +++++++++++++++++++ .../StealthAdminCommandTest.java | 34 ++++++++++- .../StealthAdministrationServiceTest.java | 12 ++++ .../StealthSessionServiceTest.java | 3 + .../YamlStealthStateRepositoryTest.java | 16 ++++- 19 files changed, 290 insertions(+), 15 deletions(-) create mode 100644 src/main/java/games/dmg/spigotstealth/SleepCountPolicy.java diff --git a/design/log.md b/design/log.md index 3bc40ae..4b60bdd 100644 --- a/design/log.md +++ b/design/log.md @@ -2,6 +2,7 @@ ## 2026-09-05 +- **Completion**: Extended US-002, US-004, and US-005 with a persistent, permission-gated sleep-count policy that excludes concealed players by default, applies immediately, restores prior player state, and supports contextual administration; verified the complete Gradle build and OKF bundle. - **Completion**: Extended US-002 with ProtocolLib filtering of concealed sessions from multiplayer server-list counts and player samples while preserving actual online state and advertised capacity; verified the complete Gradle build and OKF bundle. ## 2026-09-04 diff --git a/design/user-stories/us-002-rejoin-without-visible-identity.md b/design/user-stories/us-002-rejoin-without-visible-identity.md index c933d16..28f6ef3 100644 --- a/design/user-stories/us-002-rejoin-without-visible-identity.md +++ b/design/user-stories/us-002-rejoin-without-visible-identity.md @@ -23,6 +23,10 @@ As an **unlocked player**, I want to disconnect while invisibility from a potion - [x] Concealed players are excluded from any player-name sample shown for the server-list count, while ordinary players remain represented. - [x] The public count never becomes negative, and the configured maximum-player count remains unchanged. - [x] Server-list concealment changes only the public ping response and does not alter actual online-player state or gameplay. +- [x] By default, concealed players are excluded from sleep-percentage calculations. +- [x] When the sleep-count policy is `include`, concealed players count normally; ordinary players are never modified by either policy. +- [x] A player's previous sleeping-ignore state is restored when concealment ends, the player disconnects or is reset, the policy changes to `include`, or the plugin disables. +- [x] Policy changes apply immediately to currently concealed players. - [x] Throughout the concealed session, no overhead name tag identifies the player to any other player, including administrators. - [x] The concealed player's physical character remains visible in the world and retains ordinary movement, interaction, combat, and permission behavior. - [x] The concealed player receives a private message explaining that stealth is active for the session. @@ -33,7 +37,7 @@ As an **unlocked player**, I want to disconnect while invisibility from a potion ## Validation -Automated tests verify unlocked and locked disconnect transitions, ordinary-disconnect clearing, one-login consumption, concealed join and quit announcement suppression, preservation of ordinary announcements, private activation messaging, ordinary-login presentation, tab removal for existing and new observers, overhead-name suppression, active concealed-session tracking, public server-list count and sample filtering, nonnegative counts, unchanged maximum capacity, and the absence of entity-hiding calls. ProtocolLib is declared as a required dependency, prepared state round trips through YAML, and `./gradlew clean check jar` passes. +Automated tests verify unlocked and locked disconnect transitions, ordinary-disconnect clearing, one-login consumption, concealed join and quit announcement suppression, preservation of ordinary announcements, private activation messaging, ordinary-login presentation, tab removal for existing and new observers, overhead-name suppression, active concealed-session tracking, public server-list count and sample filtering, nonnegative counts, unchanged maximum capacity, default sleep-count exclusion, immediate policy changes, restoration of prior sleeping-ignore state, and the absence of entity-hiding calls. ProtocolLib is declared as a required dependency, prepared state round trips through YAML, and `./gradlew clean check jar` passes. ## Related diff --git a/design/user-stories/us-004-administer-player-stealth.md b/design/user-stories/us-004-administer-player-stealth.md index e1c47c1..25316bb 100644 --- a/design/user-stories/us-004-administer-player-stealth.md +++ b/design/user-stories/us-004-administer-player-stealth.md @@ -30,10 +30,13 @@ As a **server administrator**, I want to inspect and correct player stealth stat - [x] `/stealthadmin list unlocked` lists every known online or offline player with stealth unlocked. - [x] The unlocked list is sorted case-insensitively, identifies nameless records by UUID, and clearly reports when it is empty. - [x] Existing `/stealthadmin list` behavior continues to list currently concealed online players. +- [x] `/stealthadmin sleepcount status` reports whether concealed players are included in or excluded from sleep-percentage calculations. +- [x] `/stealthadmin sleepcount ` persists the policy before reporting success, applies it immediately, and records the administrator and policy in the server log. +- [x] The `sleepcount`, `status`, `include`, and `exclude` arguments are permission-gated and contextually tab-completed. ## 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, online and offline unlocked-player listing, contextual permission-gated completion, confirmation and permission gates, persisted-before-success replies, and audit records. The complete `./gradlew clean check jar` lifecycle passes. +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, online and offline unlocked-player listing, contextual permission-gated completion, durable sleep-count status and policy changes, immediate policy refresh, confirmation and permission gates, persisted-before-success replies, and audit records. The complete `./gradlew clean check jar` lifecycle passes. ## Related diff --git a/design/user-stories/us-005-configure-and-persist-progression.md b/design/user-stories/us-005-configure-and-persist-progression.md index c011125..b315d3e 100644 --- a/design/user-stories/us-005-configure-and-persist-progression.md +++ b/design/user-stories/us-005-configure-and-persist-progression.md @@ -23,10 +23,11 @@ As a **server operator**, I want stealth progression to be configurable and dura - [x] Corrupt, unknown, or invalid records cannot silently grant time, an unlock, a prepared login, or concealment. - [x] Unknown forward-compatible fields are preserved where practical. - [x] Persistence work does not perform blocking file operations on the server tick thread. +- [x] The sleep-count policy persists in `state.yml` and defaults safely to `exclude` when missing or invalid. ## Validation -Verified settings defaults and rejection, packaged configuration, safe UUID-state defaults, RFC 3339 round trips, unknown-field preservation, invalid-record rejection, atomic repository writes, and dedicated-thread loading and saving with automated tests and `./gradlew clean check jar`. +Verified settings defaults and rejection, packaged configuration, safe UUID-state defaults, sleep-count policy defaults and round trips, RFC 3339 round trips, unknown-field preservation, invalid-record rejection, atomic repository writes, and dedicated-thread loading and saving with automated tests and `./gradlew clean check jar`. ## Related diff --git a/src/main/java/games/dmg/spigotstealth/BukkitIdentityPresentation.java b/src/main/java/games/dmg/spigotstealth/BukkitIdentityPresentation.java index 5ceef54..f27168a 100644 --- a/src/main/java/games/dmg/spigotstealth/BukkitIdentityPresentation.java +++ b/src/main/java/games/dmg/spigotstealth/BukkitIdentityPresentation.java @@ -15,20 +15,32 @@ public final class BukkitIdentityPresentation implements IdentityPresentation { private final Supplier> onlinePlayers; private final Scoreboard scoreboard; private final TabListController tabLists; + private final Supplier sleepCountPolicy; private final Map concealedPlayers = new LinkedHashMap<>(); + private final Map previousSleepingIgnored = new LinkedHashMap<>(); public BukkitIdentityPresentation( Supplier> onlinePlayers, Scoreboard scoreboard, TabListController tabLists) { + this(onlinePlayers, scoreboard, tabLists, () -> SleepCountPolicy.EXCLUDE); + } + + public BukkitIdentityPresentation( + Supplier> onlinePlayers, + Scoreboard scoreboard, + TabListController tabLists, + Supplier sleepCountPolicy) { this.onlinePlayers = Objects.requireNonNull(onlinePlayers, "onlinePlayers"); this.scoreboard = Objects.requireNonNull(scoreboard, "scoreboard"); this.tabLists = Objects.requireNonNull(tabLists, "tabLists"); + this.sleepCountPolicy = Objects.requireNonNull(sleepCountPolicy, "sleepCountPolicy"); } @Override public void conceal(Player player) { concealedPlayers.put(player.getUniqueId(), player); + applySleepCountPolicy(player); Team team = scoreboard.getTeam(teamName(player.getUniqueId())); if (team == null) { team = scoreboard.registerNewTeam(teamName(player.getUniqueId())); @@ -45,6 +57,7 @@ public final class BukkitIdentityPresentation implements IdentityPresentation { @Override public void reveal(Player player) { boolean wasConcealed = concealedPlayers.remove(player.getUniqueId()) != null; + restoreSleepingIgnored(player); Team team = scoreboard.getTeam(teamName(player.getUniqueId())); if (team != null) { wasConcealed = true; @@ -69,6 +82,29 @@ public final class BukkitIdentityPresentation implements IdentityPresentation { } } + @Override + public void refreshSleepCountPolicy() { + for (Player player : concealedPlayers.values()) { + applySleepCountPolicy(player); + } + } + + private void applySleepCountPolicy(Player player) { + if (sleepCountPolicy.get() == SleepCountPolicy.EXCLUDE) { + previousSleepingIgnored.computeIfAbsent(player.getUniqueId(), ignored -> player.isSleepingIgnored()); + player.setSleepingIgnored(true); + } else { + restoreSleepingIgnored(player); + } + } + + private void restoreSleepingIgnored(Player player) { + Boolean previous = previousSleepingIgnored.remove(player.getUniqueId()); + if (previous != null) { + player.setSleepingIgnored(previous); + } + } + public static String teamName(UUID playerId) { return "stlth" + playerId.toString().replace("-", "").substring(0, 11); } diff --git a/src/main/java/games/dmg/spigotstealth/IdentityPresentation.java b/src/main/java/games/dmg/spigotstealth/IdentityPresentation.java index 6a17163..cfd19ab 100644 --- a/src/main/java/games/dmg/spigotstealth/IdentityPresentation.java +++ b/src/main/java/games/dmg/spigotstealth/IdentityPresentation.java @@ -9,4 +9,6 @@ public interface IdentityPresentation { void reveal(Player player); void refreshForObserver(Player observer); + + void refreshSleepCountPolicy(); } diff --git a/src/main/java/games/dmg/spigotstealth/PersistentStealthState.java b/src/main/java/games/dmg/spigotstealth/PersistentStealthState.java index d38141b..63ad14d 100644 --- a/src/main/java/games/dmg/spigotstealth/PersistentStealthState.java +++ b/src/main/java/games/dmg/spigotstealth/PersistentStealthState.java @@ -8,12 +8,20 @@ import java.util.UUID; /** Immutable snapshot of all durable plugin state. */ public record PersistentStealthState( Map players, + SleepCountPolicy sleepCountPolicy, Map unknownFields) { public PersistentStealthState { players = Map.copyOf(new LinkedHashMap<>(Objects.requireNonNull(players, "players"))); + Objects.requireNonNull(sleepCountPolicy, "sleepCountPolicy"); unknownFields = Map.copyOf(new LinkedHashMap<>(Objects.requireNonNull(unknownFields, "unknownFields"))); } + public PersistentStealthState( + Map players, + Map unknownFields) { + this(players, SleepCountPolicy.EXCLUDE, unknownFields); + } + public PlayerStealthState player(UUID playerId) { return players.getOrDefault(playerId, PlayerStealthState.empty(playerId)); } @@ -21,6 +29,10 @@ public record PersistentStealthState( public PersistentStealthState withPlayer(PlayerStealthState player) { Map updated = new LinkedHashMap<>(players); updated.put(player.playerId(), player); - return new PersistentStealthState(updated, unknownFields); + return new PersistentStealthState(updated, sleepCountPolicy, unknownFields); + } + + public PersistentStealthState withSleepCountPolicy(SleepCountPolicy policy) { + return new PersistentStealthState(players, policy, unknownFields); } } diff --git a/src/main/java/games/dmg/spigotstealth/SleepCountPolicy.java b/src/main/java/games/dmg/spigotstealth/SleepCountPolicy.java new file mode 100644 index 0000000..7feb00b --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/SleepCountPolicy.java @@ -0,0 +1,24 @@ +package games.dmg.spigotstealth; + +import java.util.Locale; + +/** Whether concealed players participate in the server's sleep percentage. */ +public enum SleepCountPolicy { + INCLUDE, + EXCLUDE; + + public static SleepCountPolicy fromPersisted(Object value) { + if (value instanceof String text) { + try { + return valueOf(text.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ignored) { + // Invalid values use the privacy-preserving default. + } + } + return EXCLUDE; + } + + public String persistedValue() { + return name().toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java b/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java index efdc27c..557d4a8 100644 --- a/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java +++ b/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java @@ -80,7 +80,8 @@ public final class SpigotStealthPlugin extends JavaPlugin { identityPresentation = new BukkitIdentityPresentation( getServer()::getOnlinePlayers, Objects.requireNonNull(getServer().getScoreboardManager(), "scoreboard manager").getMainScoreboard(), - new ProtocolLibTabListController(protocolManager)); + new ProtocolLibTabListController(protocolManager), + () -> manager.snapshot().sleepCountPolicy()); protocolManager.addPacketListener( new ProtocolLibServerListPingListener(this, sessions::concealedPlayerIds)); getServer().getPluginManager().registerEvents( diff --git a/src/main/java/games/dmg/spigotstealth/StealthAdminCommand.java b/src/main/java/games/dmg/spigotstealth/StealthAdminCommand.java index 506f662..8fc2d4f 100644 --- a/src/main/java/games/dmg/spigotstealth/StealthAdminCommand.java +++ b/src/main/java/games/dmg/spigotstealth/StealthAdminCommand.java @@ -14,7 +14,7 @@ import org.bukkit.command.TabCompleter; /** Permission-gated administrative command for online and known offline players. */ public final class StealthAdminCommand implements CommandExecutor, TabCompleter { private static final String PERMISSION = "spigotstealth.admin"; - private static final List OPERATIONS = List.of("status", "grant", "reset", "list"); + private static final List OPERATIONS = List.of("status", "grant", "reset", "list", "sleepcount"); private final StealthAdministrationService administration; private final KnownPlayerResolver resolver; private final Consumer mainThread; @@ -51,6 +51,9 @@ public final class StealthAdminCommand implements CommandExecutor, TabCompleter if ("list".equals(operation)) { return matching(List.of("unlocked"), arguments[1]); } + if ("sleepcount".equals(operation)) { + return matching(List.of("status", "include", "exclude"), arguments[1]); + } } if (arguments.length == 3 && "reset".equals(operation)) { return matching(List.of("confirm"), arguments[2]); @@ -69,6 +72,9 @@ public final class StealthAdminCommand implements CommandExecutor, TabCompleter sender.sendMessage("You do not have permission to administer Spigot Stealth."); return true; } + if (arguments.length > 0 && "sleepcount".equalsIgnoreCase(arguments[0])) { + return sleepCount(sender, label, arguments); + } if (arguments.length > 0 && "list".equalsIgnoreCase(arguments[0])) { if (arguments.length == 1) { List names = administration.concealedOnlineNames(); @@ -111,6 +117,44 @@ public final class StealthAdminCommand implements CommandExecutor, TabCompleter }; } + private boolean sleepCount(CommandSender sender, String label, String[] arguments) { + if (arguments.length != 2) { + sendUsage(sender, label); + return true; + } + String action = arguments[1].toLowerCase(Locale.ROOT); + if ("status".equals(action)) { + sender.sendMessage("Concealed players are " + + policyDescription(administration.sleepCountPolicy()) + + " sleep-percentage calculations."); + return true; + } + SleepCountPolicy policy = switch (action) { + case "include" -> SleepCountPolicy.INCLUDE; + case "exclude" -> SleepCountPolicy.EXCLUDE; + default -> null; + }; + if (policy == null) { + sendUsage(sender, label); + return true; + } + administration.setSleepCountPolicy(policy).whenComplete((result, failure) -> mainThread.accept(() -> { + if (failure != null) { + sender.sendMessage("Unable to persist the sleep-count policy; check the server log."); + return; + } + administration.refreshSleepCountPolicy(); + sender.sendMessage(result.changed() + ? "Concealed players are now " + policyDescription(policy) + + " sleep-percentage calculations." + : "Concealed players are already " + policyDescription(policy) + + " sleep-percentage calculations."); + auditLog.accept("stealthadmin sleepcount administrator=" + sender.getName() + + " policy=" + policy.persistedValue() + " changed=" + result.changed()); + })); + return 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() + "):"); @@ -163,13 +207,17 @@ public final class StealthAdminCommand implements CommandExecutor, TabCompleter } private static void sendUsage(CommandSender sender, String label) { - sender.sendMessage("Usage: /" + label + " |grant |reset confirm|list [unlocked]>"); + sender.sendMessage("Usage: /" + label + " |grant |reset confirm|list [unlocked]|sleepcount >"); } private static String displayName(KnownPlayerResolver.KnownPlayer player) { return player.name() == null ? player.playerId().toString() : player.name(); } + private static String policyDescription(SleepCountPolicy policy) { + return policy == SleepCountPolicy.EXCLUDE ? "excluded from" : "included in"; + } + 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 index 46d0a6f..bf55c88 100644 --- a/src/main/java/games/dmg/spigotstealth/StealthAdministrationService.java +++ b/src/main/java/games/dmg/spigotstealth/StealthAdministrationService.java @@ -31,6 +31,26 @@ public final class StealthAdministrationService { this.onlinePlayers = Objects.requireNonNull(onlinePlayers, "onlinePlayers"); } + public SleepCountPolicy sleepCountPolicy() { + return stateManager.snapshot().sleepCountPolicy(); + } + + public CompletableFuture setSleepCountPolicy(SleepCountPolicy policy) { + Objects.requireNonNull(policy, "policy"); + AtomicBoolean changed = new AtomicBoolean(); + return stateManager.update(state -> { + if (state.sleepCountPolicy() == policy) { + return state; + } + changed.set(true); + return state.withSleepCountPolicy(policy); + }).thenApply(ignored -> new ChangeResult(changed.get())); + } + + public void refreshSleepCountPolicy() { + presentation.refreshSleepCountPolicy(); + } + public PlayerStatus status(UUID playerId) { PlayerStealthState player = stateManager.snapshot().player(playerId); return new PlayerStatus( diff --git a/src/main/java/games/dmg/spigotstealth/StealthSessionService.java b/src/main/java/games/dmg/spigotstealth/StealthSessionService.java index 62a3012..de754d0 100644 --- a/src/main/java/games/dmg/spigotstealth/StealthSessionService.java +++ b/src/main/java/games/dmg/spigotstealth/StealthSessionService.java @@ -59,7 +59,10 @@ public final class StealthSessionService { } public Set concealedPlayerIds() { - return Set.copyOf(concealedOnlinePlayerIds); + PersistentStealthState state = stateManager.snapshot(); + return concealedOnlinePlayerIds.stream() + .filter(playerId -> state.player(playerId).concealed()) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); } public record LoginTransition(boolean concealed, CompletableFuture saved) { } diff --git a/src/main/java/games/dmg/spigotstealth/YamlStealthStateRepository.java b/src/main/java/games/dmg/spigotstealth/YamlStealthStateRepository.java index 149685b..50d2337 100644 --- a/src/main/java/games/dmg/spigotstealth/YamlStealthStateRepository.java +++ b/src/main/java/games/dmg/spigotstealth/YamlStealthStateRepository.java @@ -17,7 +17,7 @@ import org.bukkit.configuration.file.YamlConfiguration; /** Defensive YAML repository using atomic file replacement where available. */ public final class YamlStealthStateRepository implements StealthStateRepository { - private static final Set ROOT_FIELDS = Set.of("schema-version", "players"); + private static final Set ROOT_FIELDS = Set.of("schema-version", "sleep-count-policy", "players"); private static final Set PLAYER_FIELDS = Set.of( "last-known-name", "accumulated-millis", "unlocked", "prepared-login", "concealed", "qualifying-since"); private final Path stateFile; @@ -53,7 +53,10 @@ public final class YamlStealthStateRepository implements StealthStateRepository } } } - return new PersistentStealthState(players, unknownRoot); + return new PersistentStealthState( + players, + SleepCountPolicy.fromPersisted(yaml.get("sleep-count-policy")), + unknownRoot); } @Override @@ -61,6 +64,7 @@ public final class YamlStealthStateRepository implements StealthStateRepository YamlConfiguration yaml = new YamlConfiguration(); state.unknownFields().forEach(yaml::set); yaml.set("schema-version", 1); + yaml.set("sleep-count-policy", state.sleepCountPolicy().persistedValue()); for (PlayerStealthState player : state.players().values()) { String base = "players." + player.playerId() + "."; player.unknownFields().forEach((key, value) -> yaml.set(base + key, value)); diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index d6f7a7a..b9ddd09 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -12,7 +12,7 @@ commands: permission: spigotstealth.use stealthadmin: description: Inspect and administer Spigot Stealth. - usage: /stealthadmin |grant |reset confirm|list [unlocked]> + usage: /stealthadmin |grant |reset confirm|list [unlocked]|sleepcount > permission: spigotstealth.admin permissions: spigotstealth.use: diff --git a/src/test/java/games/dmg/spigotstealth/BukkitIdentityPresentationTest.java b/src/test/java/games/dmg/spigotstealth/BukkitIdentityPresentationTest.java index a294fb5..4663d6e 100644 --- a/src/test/java/games/dmg/spigotstealth/BukkitIdentityPresentationTest.java +++ b/src/test/java/games/dmg/spigotstealth/BukkitIdentityPresentationTest.java @@ -7,12 +7,71 @@ import static org.mockito.Mockito.when; import java.util.List; import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; import org.bukkit.entity.Player; import org.bukkit.scoreboard.Scoreboard; import org.bukkit.scoreboard.Team; import org.junit.jupiter.api.Test; class BukkitIdentityPresentationTest { + @Test + void excludedConcealmentIgnoresSleepCountAndRestoresPreviousState() { + UUID targetId = UUID.randomUUID(); + Player target = player(targetId, "Alex"); + when(target.isSleepingIgnored()).thenReturn(false); + Scoreboard scoreboard = mock(Scoreboard.class); + Team team = mock(Team.class); + when(scoreboard.getTeam(BukkitIdentityPresentation.teamName(targetId))).thenReturn(team); + BukkitIdentityPresentation presentation = new BukkitIdentityPresentation( + () -> List.of(target), scoreboard, mock(TabListController.class)); + + presentation.conceal(target); + presentation.reveal(target); + + verify(target).setSleepingIgnored(true); + verify(target).setSleepingIgnored(false); + } + + @Test + void includedConcealmentDoesNotChangeOrdinarySleepParticipation() { + UUID targetId = UUID.randomUUID(); + Player target = player(targetId, "Alex"); + Scoreboard scoreboard = mock(Scoreboard.class); + Team team = mock(Team.class); + when(scoreboard.getTeam(BukkitIdentityPresentation.teamName(targetId))).thenReturn(team); + BukkitIdentityPresentation presentation = new BukkitIdentityPresentation( + () -> List.of(target), + scoreboard, + mock(TabListController.class), + () -> SleepCountPolicy.INCLUDE); + + presentation.conceal(target); + + verify(target, never()).setSleepingIgnored(org.mockito.ArgumentMatchers.anyBoolean()); + } + + @Test + void policyChangesImmediatelyRestoreAndReapplyConcealedSleepParticipation() { + UUID targetId = UUID.randomUUID(); + Player target = player(targetId, "Alex"); + when(target.isSleepingIgnored()).thenReturn(false); + Scoreboard scoreboard = mock(Scoreboard.class); + Team team = mock(Team.class); + when(scoreboard.getTeam(BukkitIdentityPresentation.teamName(targetId))).thenReturn(team); + AtomicReference policy = new AtomicReference<>(SleepCountPolicy.EXCLUDE); + BukkitIdentityPresentation presentation = new BukkitIdentityPresentation( + () -> List.of(target), scoreboard, mock(TabListController.class), policy::get); + presentation.conceal(target); + + policy.set(SleepCountPolicy.INCLUDE); + presentation.refreshSleepCountPolicy(); + policy.set(SleepCountPolicy.EXCLUDE); + presentation.refreshSleepCountPolicy(); + + verify(target, org.mockito.Mockito.times(2)).setSleepingIgnored(true); + verify(target).setSleepingIgnored(false); + } + @Test void concealRemovesTabEntryAndHidesNameTagWithoutHidingEntity() { UUID targetId = UUID.randomUUID(); diff --git a/src/test/java/games/dmg/spigotstealth/StealthAdminCommandTest.java b/src/test/java/games/dmg/spigotstealth/StealthAdminCommandTest.java index d3cd2df..ee081da 100644 --- a/src/test/java/games/dmg/spigotstealth/StealthAdminCommandTest.java +++ b/src/test/java/games/dmg/spigotstealth/StealthAdminCommandTest.java @@ -26,7 +26,7 @@ class StealthAdminCommandTest { fixture.command.onTabComplete( sender(true), mock(Command.class), "stealthadmin", new String[] {"gr"})); assertEquals( - List.of("status", "grant", "reset", "list"), + List.of("status", "grant", "reset", "list", "sleepcount"), fixture.command.onTabComplete( sender(true), mock(Command.class), "stealthadmin", new String[] {""})); } @@ -51,6 +51,10 @@ class StealthAdminCommandTest { List.of("unlocked"), fixture.command.onTabComplete( administrator, command, "stealthadmin", new String[] {"list", "un"})); + assertEquals( + List.of("status", "include", "exclude"), + fixture.command.onTabComplete( + administrator, command, "stealthadmin", new String[] {"sleepcount", ""})); assertEquals( List.of(), fixture.command.onTabComplete( @@ -62,6 +66,28 @@ class StealthAdminCommandTest { } } + @Test + void reportsAndChangesPersistentSleepCountPolicyWithImmediateRefreshAndAudit() { + UUID playerId = UUID.randomUUID(); + try (Fixture fixture = fixture(playerId)) { + CommandSender sender = sender(true); + Command command = mock(Command.class); + + fixture.command.onCommand( + sender, command, "stealthadmin", new String[] {"sleepcount", "status"}); + verify(sender).sendMessage(contains("excluded")); + + fixture.command.onCommand( + sender, command, "stealthadmin", new String[] {"sleepcount", "include"}); + + verify(sender, org.mockito.Mockito.timeout(1000)).sendMessage(contains("now included")); + assertEquals(SleepCountPolicy.INCLUDE, fixture.manager.snapshot().sleepCountPolicy()); + verify(fixture.presentation).refreshSleepCountPolicy(); + org.junit.jupiter.api.Assertions.assertTrue(fixture.audit.stream() + .anyMatch(message -> message.contains("sleepcount") && message.contains("include"))); + } + } + @Test void statusInspectsKnownOfflinePlayerAndReportsAllState() { UUID playerId = UUID.randomUUID(); @@ -136,18 +162,20 @@ class StealthAdminCommandTest { manager.update(state -> state.withPlayer(player)).join(); QualifyingInvisibilityService progression = new QualifyingInvisibilityService( manager, Duration.ofHours(8), System::nanoTime, ignored -> { }); + IdentityPresentation presentation = mock(IdentityPresentation.class); StealthAdministrationService administration = new StealthAdministrationService( - manager, progression, mock(IdentityPresentation.class), ignored -> { }, List::of); + manager, progression, presentation, 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); + return new Fixture(manager, command, presentation, audit); } private record Fixture( StealthStateManager manager, StealthAdminCommand command, + IdentityPresentation presentation, 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 index 604f18c..da35579 100644 --- a/src/test/java/games/dmg/spigotstealth/StealthAdministrationServiceTest.java +++ b/src/test/java/games/dmg/spigotstealth/StealthAdministrationServiceTest.java @@ -16,6 +16,18 @@ import org.bukkit.entity.Player; import org.junit.jupiter.api.Test; class StealthAdministrationServiceTest { + @Test + void persistsSleepCountPolicyChangesIdempotently() { + try (StealthStateManager manager = manager()) { + StealthAdministrationService administration = service(manager, List::of, ignored -> { }); + + assertEquals(SleepCountPolicy.EXCLUDE, administration.sleepCountPolicy()); + assertTrue(administration.setSleepCountPolicy(SleepCountPolicy.INCLUDE).join().changed()); + assertEquals(SleepCountPolicy.INCLUDE, manager.snapshot().sleepCountPolicy()); + assertFalse(administration.setSleepCountPolicy(SleepCountPolicy.INCLUDE).join().changed()); + } + } + @Test void grantsOfflineUnlockAndNotifiesExactlyOnce() { UUID playerId = UUID.randomUUID(); diff --git a/src/test/java/games/dmg/spigotstealth/StealthSessionServiceTest.java b/src/test/java/games/dmg/spigotstealth/StealthSessionServiceTest.java index b086200..8aa83eb 100644 --- a/src/test/java/games/dmg/spigotstealth/StealthSessionServiceTest.java +++ b/src/test/java/games/dmg/spigotstealth/StealthSessionServiceTest.java @@ -27,6 +27,9 @@ class StealthSessionServiceTest { sessions.login(concealedId, "Hidden"); assertEquals(Set.of(concealedId), sessions.concealedPlayerIds()); + manager.update(state -> state.withPlayer( + state.player(concealedId).withSession(false, false))).join(); + assertEquals(Set.of(), sessions.concealedPlayerIds()); } } diff --git a/src/test/java/games/dmg/spigotstealth/YamlStealthStateRepositoryTest.java b/src/test/java/games/dmg/spigotstealth/YamlStealthStateRepositoryTest.java index 6f638d9..dd0ffc5 100644 --- a/src/test/java/games/dmg/spigotstealth/YamlStealthStateRepositoryTest.java +++ b/src/test/java/games/dmg/spigotstealth/YamlStealthStateRepositoryTest.java @@ -15,15 +15,28 @@ import org.junit.jupiter.api.io.TempDir; class YamlStealthStateRepositoryTest { @TempDir Path temporaryDirectory; + @Test + void missingOrInvalidSleepCountPolicyDefaultsToExclude() throws Exception { + Path missingFile = temporaryDirectory.resolve("missing.yml"); + YamlStealthStateRepository missing = new YamlStealthStateRepository(missingFile); + assertEquals(SleepCountPolicy.EXCLUDE, missing.load().sleepCountPolicy()); + + Path invalidFile = temporaryDirectory.resolve("invalid.yml"); + Files.writeString(invalidFile, "sleep-count-policy: surprise\n"); + YamlStealthStateRepository invalid = new YamlStealthStateRepository(invalidFile); + assertEquals(SleepCountPolicy.EXCLUDE, invalid.load().sleepCountPolicy()); + } + @Test void roundTripsUuidStateAndPreservesUnknownFields() throws Exception { UUID playerId = UUID.randomUUID(); Path stateFile = temporaryDirectory.resolve("state.yml"); - Files.writeString(stateFile, "schema-version: 1\nfuture-root: keep\nplayers:\n " + playerId + ":\n accumulated-millis: 25\n unlocked: false\n prepared-login: true\n concealed: false\n qualifying-since: '2026-08-14T10:00:00Z'\n last-known-name: Alex\n future-player: keep-too\n"); + Files.writeString(stateFile, "schema-version: 1\nsleep-count-policy: include\nfuture-root: keep\nplayers:\n " + playerId + ":\n accumulated-millis: 25\n unlocked: false\n prepared-login: true\n concealed: false\n qualifying-since: '2026-08-14T10:00:00Z'\n last-known-name: Alex\n future-player: keep-too\n"); YamlStealthStateRepository repository = new YamlStealthStateRepository(stateFile); PersistentStealthState loaded = repository.load(); PlayerStealthState player = loaded.player(playerId); + assertEquals(SleepCountPolicy.INCLUDE, loaded.sleepCountPolicy()); assertEquals(25L, player.accumulatedMillis()); assertTrue(player.preparedLogin()); assertEquals(Instant.parse("2026-08-14T10:00:00Z"), player.qualifyingSince()); @@ -31,6 +44,7 @@ class YamlStealthStateRepositoryTest { repository.save(loaded.withPlayer(player.withAccumulatedMillis(50L))); String saved = Files.readString(stateFile); assertTrue(saved.contains("future-root: keep")); + assertTrue(saved.contains("sleep-count-policy: include")); assertTrue(saved.contains("future-player: keep-too")); assertEquals(50L, repository.load().player(playerId).accumulatedMillis()); }