5 Commits
Author SHA1 Message Date
dmg 831f6a2ce4 fix(ping): support Purpur NameAndId samples
Release / release (push) Successful in 2m24s
CI / build (push) Successful in 1m42s
2026-09-05 08:58:46 -04:00
dmg a084f86c1d feat(admin): add persistent sleep-count policy
Release / release (push) Successful in 2m17s
CI / build (push) Successful in 1m3s
2026-09-05 08:34:50 -04:00
dmg ba6573943b fix(stealth): hide concealed players from server ping
Release / release (push) Successful in 2m13s
CI / build (push) Successful in 58s
2026-09-05 08:11:58 -04:00
dmg 66ba2aa3b1 fix(stealth): suppress concealed disconnect messages
CI / build (push) Successful in 1m1s
Release / release (push) Successful in 2m8s
2026-09-04 23:54:23 -04:00
dmg b0da1508b6 feat(commands): add contextual completion and unlock listing
Release / release (push) Successful in 3m46s
CI / build (push) Successful in 57s
2026-09-04 23:37:20 -04:00
32 changed files with 846 additions and 32 deletions
+11
View File
@@ -1,5 +1,16 @@
# Spigot Stealth Design Log # Spigot Stealth Design Log
## 2026-09-05
- **Fix**: Reworked US-002 server-list filtering for Purpur's native `NameAndId` samples by filtering names through Bukkit, limiting ProtocolLib to guarded count adjustment, and rate-limiting compatibility warnings; verified the complete Gradle build and OKF bundle.
- **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
- **Completion**: Extended US-002 so concealed players disconnect without a public quit announcement while ordinary quit messages remain unchanged; verified listener tests, the complete Gradle build, and the OKF bundle.
- **Completion**: Extended US-003 and US-004 with contextual, prefix-filtered command completion that suppresses generic player suggestions, plus an administrative list of all known online and offline unlocked players; verified the complete Gradle build and OKF bundle.
## 2026-08-14 ## 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. - **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.
@@ -15,7 +15,20 @@ As an **unlocked player**, I want to disconnect while invisibility from a potion
- [x] A player who has not unlocked stealth cannot prepare a concealed login. - [x] A player who has not unlocked stealth cannot prepare a concealed login.
- [x] An ordinary disconnect without an active qualifying effect clears any preparation for the next login. - [x] An ordinary disconnect without an active qualifying effect clears any preparation for the next login.
- [x] On a prepared login, no public join announcement is shown. - [x] On a prepared login, no public join announcement is shown.
- [x] When a concealed player disconnects, no public quit or disconnect announcement is shown.
- [x] Ordinary players' quit messages remain unchanged.
- [x] Concealment is checked before disconnect cleanup so announcement suppression is reliable.
- [x] Throughout the concealed session, the player is absent from every other player's tab list, including administrators' tab lists. - [x] Throughout the concealed session, the player is absent from every other player's tab list, including administrators' tab lists.
- [x] The multiplayer server list's online-player count excludes currently concealed players.
- [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] Server-list filtering remains error-free when Purpur represents player samples with native `NameAndId` values rather than Mojang `GameProfile` values.
- [x] A server-ping compatibility failure leaves the original response usable and does not produce repeated unhandled listener exceptions.
- [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] 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'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. - [x] The concealed player receives a private message explaining that stealth is active for the session.
@@ -26,7 +39,7 @@ As an **unlocked player**, I want to disconnect while invisibility from a potion
## Validation ## Validation
Automated tests verify unlocked and locked disconnect transitions, ordinary-disconnect clearing, one-login consumption, announcement suppression, private activation messaging, ordinary-login presentation, tab removal for existing and new observers, overhead-name suppression, 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, ProtocolLib-independent native player-sample filtering, guarded public server-list count adjustment, 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 ## Related
@@ -18,10 +18,12 @@ As a **player**, I want to check my stealth progress so that I know how close I
- [x] Durations are presented in a concise, human-readable form. - [x] Durations are presented in a concise, human-readable form.
- [x] Repeated command use does not change progression or concealment state. - [x] Repeated command use does not change progression or concealment state.
- [x] The command has clear usage metadata and an appropriate player permission. - [x] The command has clear usage metadata and an appropriate player permission.
- [x] `/stealth` tab-completes `progress`, filtered by the entered prefix.
- [x] Unsupported argument positions return no suggestions instead of Bukkit's generic player list.
## Validation ## Validation
Automated tests verify concise duration formatting, live in-flight progress, configured target and remaining output, command non-mutation, and unlocked usage guidance. Command metadata and the complete `./gradlew clean check jar` lifecycle pass. Automated tests verify concise duration formatting, live in-flight progress, configured target and remaining output, command non-mutation, unlocked usage guidance, prefix-filtered completion, and suppression of irrelevant suggestions. Command metadata and the complete `./gradlew clean check jar` lifecycle pass.
## Related ## Related
@@ -24,10 +24,19 @@ As a **server administrator**, I want to inspect and correct player stealth stat
- [x] Administrative inspection and modification require an operator-default administrative permission. - [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] 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. - [x] Grant and reset actions record the administrator, target UUID, and action in the server log without blocking the server tick thread.
- [x] `/stealthadmin` tab-completes `status`, `grant`, `reset`, and `list`, filtered by the entered prefix.
- [x] Target positions for `status`, `grant`, and `reset` suggest known player names, and reset's final argument suggests `confirm`.
- [x] Administrative completions require the administrative permission, and irrelevant positions return no suggestions instead of Bukkit's generic player list.
- [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 <include|exclude>` 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 ## 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. 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 ## Related
@@ -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] 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] Unknown forward-compatible fields are preserved where practical.
- [x] Persistence work does not perform blocking file operations on the server tick thread. - [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 ## 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 ## Related
@@ -15,20 +15,32 @@ public final class BukkitIdentityPresentation implements IdentityPresentation {
private final Supplier<? extends Collection<? extends Player>> onlinePlayers; private final Supplier<? extends Collection<? extends Player>> onlinePlayers;
private final Scoreboard scoreboard; private final Scoreboard scoreboard;
private final TabListController tabLists; private final TabListController tabLists;
private final Supplier<SleepCountPolicy> sleepCountPolicy;
private final Map<UUID, Player> concealedPlayers = new LinkedHashMap<>(); private final Map<UUID, Player> concealedPlayers = new LinkedHashMap<>();
private final Map<UUID, Boolean> previousSleepingIgnored = new LinkedHashMap<>();
public BukkitIdentityPresentation( public BukkitIdentityPresentation(
Supplier<? extends Collection<? extends Player>> onlinePlayers, Supplier<? extends Collection<? extends Player>> onlinePlayers,
Scoreboard scoreboard, Scoreboard scoreboard,
TabListController tabLists) { TabListController tabLists) {
this(onlinePlayers, scoreboard, tabLists, () -> SleepCountPolicy.EXCLUDE);
}
public BukkitIdentityPresentation(
Supplier<? extends Collection<? extends Player>> onlinePlayers,
Scoreboard scoreboard,
TabListController tabLists,
Supplier<SleepCountPolicy> sleepCountPolicy) {
this.onlinePlayers = Objects.requireNonNull(onlinePlayers, "onlinePlayers"); this.onlinePlayers = Objects.requireNonNull(onlinePlayers, "onlinePlayers");
this.scoreboard = Objects.requireNonNull(scoreboard, "scoreboard"); this.scoreboard = Objects.requireNonNull(scoreboard, "scoreboard");
this.tabLists = Objects.requireNonNull(tabLists, "tabLists"); this.tabLists = Objects.requireNonNull(tabLists, "tabLists");
this.sleepCountPolicy = Objects.requireNonNull(sleepCountPolicy, "sleepCountPolicy");
} }
@Override @Override
public void conceal(Player player) { public void conceal(Player player) {
concealedPlayers.put(player.getUniqueId(), player); concealedPlayers.put(player.getUniqueId(), player);
applySleepCountPolicy(player);
Team team = scoreboard.getTeam(teamName(player.getUniqueId())); Team team = scoreboard.getTeam(teamName(player.getUniqueId()));
if (team == null) { if (team == null) {
team = scoreboard.registerNewTeam(teamName(player.getUniqueId())); team = scoreboard.registerNewTeam(teamName(player.getUniqueId()));
@@ -45,6 +57,7 @@ public final class BukkitIdentityPresentation implements IdentityPresentation {
@Override @Override
public void reveal(Player player) { public void reveal(Player player) {
boolean wasConcealed = concealedPlayers.remove(player.getUniqueId()) != null; boolean wasConcealed = concealedPlayers.remove(player.getUniqueId()) != null;
restoreSleepingIgnored(player);
Team team = scoreboard.getTeam(teamName(player.getUniqueId())); Team team = scoreboard.getTeam(teamName(player.getUniqueId()));
if (team != null) { if (team != null) {
wasConcealed = true; 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) { public static String teamName(UUID playerId) {
return "stlth" + playerId.toString().replace("-", "").substring(0, 11); return "stlth" + playerId.toString().replace("-", "").substring(0, 11);
} }
@@ -9,4 +9,6 @@ public interface IdentityPresentation {
void reveal(Player player); void reveal(Player player);
void refreshForObserver(Player observer); void refreshForObserver(Player observer);
void refreshSleepCountPolicy();
} }
@@ -21,6 +21,17 @@ public final class KnownPlayerResolver {
this.onlinePlayers = Objects.requireNonNull(onlinePlayers, "onlinePlayers"); this.onlinePlayers = Objects.requireNonNull(onlinePlayers, "onlinePlayers");
} }
public java.util.List<String> completeNames(String prefix) {
String normalized = prefix == null ? "" : prefix.toLowerCase(Locale.ROOT);
return knownPlayers().values().stream()
.map(KnownPlayer::name)
.filter(Objects::nonNull)
.distinct()
.filter(name -> name.toLowerCase(Locale.ROOT).startsWith(normalized))
.sorted(String.CASE_INSENSITIVE_ORDER)
.toList();
}
public Resolution resolve(String query) { public Resolution resolve(String query) {
if (query == null || query.isBlank()) { if (query == null || query.isBlank()) {
return new Resolution(Status.UNKNOWN, null); return new Resolution(Status.UNKNOWN, null);
@@ -8,12 +8,20 @@ import java.util.UUID;
/** Immutable snapshot of all durable plugin state. */ /** Immutable snapshot of all durable plugin state. */
public record PersistentStealthState( public record PersistentStealthState(
Map<UUID, PlayerStealthState> players, Map<UUID, PlayerStealthState> players,
SleepCountPolicy sleepCountPolicy,
Map<String, Object> unknownFields) { Map<String, Object> unknownFields) {
public PersistentStealthState { public PersistentStealthState {
players = Map.copyOf(new LinkedHashMap<>(Objects.requireNonNull(players, "players"))); players = Map.copyOf(new LinkedHashMap<>(Objects.requireNonNull(players, "players")));
Objects.requireNonNull(sleepCountPolicy, "sleepCountPolicy");
unknownFields = Map.copyOf(new LinkedHashMap<>(Objects.requireNonNull(unknownFields, "unknownFields"))); unknownFields = Map.copyOf(new LinkedHashMap<>(Objects.requireNonNull(unknownFields, "unknownFields")));
} }
public PersistentStealthState(
Map<UUID, PlayerStealthState> players,
Map<String, Object> unknownFields) {
this(players, SleepCountPolicy.EXCLUDE, unknownFields);
}
public PlayerStealthState player(UUID playerId) { public PlayerStealthState player(UUID playerId) {
return players.getOrDefault(playerId, PlayerStealthState.empty(playerId)); return players.getOrDefault(playerId, PlayerStealthState.empty(playerId));
} }
@@ -21,6 +29,10 @@ public record PersistentStealthState(
public PersistentStealthState withPlayer(PlayerStealthState player) { public PersistentStealthState withPlayer(PlayerStealthState player) {
Map<UUID, PlayerStealthState> updated = new LinkedHashMap<>(players); Map<UUID, PlayerStealthState> updated = new LinkedHashMap<>(players);
updated.put(player.playerId(), player); 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);
} }
} }
@@ -0,0 +1,37 @@
package games.dmg.spigotstealth;
import com.comphenix.protocol.PacketType;
import com.comphenix.protocol.events.PacketAdapter;
import com.comphenix.protocol.events.PacketEvent;
import com.comphenix.protocol.wrappers.WrappedServerPing;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.function.Supplier;
import org.bukkit.plugin.Plugin;
/** Adjusts only the count in outgoing server-list responses through ProtocolLib. */
public final class ProtocolLibServerListPingListener extends PacketAdapter {
private final Supplier<Set<UUID>> concealedPlayerIds;
private final ServerPingCompatibilityGuard compatibilityGuard;
public ProtocolLibServerListPingListener(
Plugin plugin,
Supplier<Set<UUID>> concealedPlayerIds,
Consumer<String> warningLog) {
super(plugin, PacketType.Status.Server.SERVER_INFO);
this.concealedPlayerIds = Objects.requireNonNull(concealedPlayerIds, "concealedPlayerIds");
this.compatibilityGuard = new ServerPingCompatibilityGuard(warningLog);
}
@Override
public void onPacketSending(PacketEvent event) {
compatibilityGuard.run(() -> {
WrappedServerPing ping = event.getPacket().getServerPings().read(0);
int visiblePlayers = ServerListPingVisibility.visibleOnlineCount(
ping.getPlayersOnline(), concealedPlayerIds.get().size());
ping.setPlayersOnline(visiblePlayers);
});
}
}
@@ -0,0 +1,32 @@
package games.dmg.spigotstealth;
import java.util.Iterator;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.function.Supplier;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.server.ServerListPingEvent;
/** Filters concealed players through Bukkit's native server-list sample representation. */
public final class ServerListPingListener implements Listener {
private final Supplier<Set<UUID>> concealedPlayerIds;
public ServerListPingListener(Supplier<Set<UUID>> concealedPlayerIds) {
this.concealedPlayerIds = Objects.requireNonNull(concealedPlayerIds, "concealedPlayerIds");
}
@EventHandler(priority = EventPriority.HIGHEST)
public void onServerListPing(ServerListPingEvent event) {
Set<UUID> concealed = concealedPlayerIds.get();
Iterator<Player> sample = event.iterator();
while (sample.hasNext()) {
if (concealed.contains(sample.next().getUniqueId())) {
sample.remove();
}
}
}
}
@@ -0,0 +1,37 @@
package games.dmg.spigotstealth;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
/** Computes the public multiplayer server-list view for concealed sessions. */
public final class ServerListPingVisibility {
private ServerListPingVisibility() { }
public static Snapshot adjust(
int playersOnline,
int playersMaximum,
List<UUID> samplePlayerIds,
Set<UUID> concealedPlayerIds) {
Objects.requireNonNull(samplePlayerIds, "samplePlayerIds");
Objects.requireNonNull(concealedPlayerIds, "concealedPlayerIds");
List<UUID> visibleSample = samplePlayerIds.stream()
.filter(playerId -> !concealedPlayerIds.contains(playerId))
.toList();
return new Snapshot(
visibleOnlineCount(playersOnline, concealedPlayerIds.size()),
playersMaximum,
visibleSample);
}
public static int visibleOnlineCount(int playersOnline, int concealedPlayers) {
return Math.max(0, playersOnline - concealedPlayers);
}
public record Snapshot(int playersOnline, int playersMaximum, List<UUID> samplePlayerIds) {
public Snapshot {
samplePlayerIds = List.copyOf(samplePlayerIds);
}
}
}
@@ -0,0 +1,26 @@
package games.dmg.spigotstealth;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
/** Keeps an incompatible server-ping adapter from breaking responses or flooding logs. */
public final class ServerPingCompatibilityGuard {
private final Consumer<String> warningLog;
private final AtomicBoolean warningLogged = new AtomicBoolean();
public ServerPingCompatibilityGuard(Consumer<String> warningLog) {
this.warningLog = Objects.requireNonNull(warningLog, "warningLog");
}
public void run(Runnable edit) {
try {
edit.run();
} catch (RuntimeException exception) {
if (warningLogged.compareAndSet(false, true)) {
warningLog.accept("Unable to adjust the public server-list player count; "
+ "the original response will be used: " + exception.getMessage());
}
}
}
}
@@ -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);
}
}
@@ -1,6 +1,7 @@
package games.dmg.spigotstealth; package games.dmg.spigotstealth;
import com.comphenix.protocol.ProtocolLibrary; import com.comphenix.protocol.ProtocolLibrary;
import com.comphenix.protocol.ProtocolManager;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.Clock; import java.time.Clock;
import java.util.Objects; import java.util.Objects;
@@ -15,6 +16,7 @@ public final class SpigotStealthPlugin extends JavaPlugin {
private QualifyingInvisibilityService progression; private QualifyingInvisibilityService progression;
private StealthSessionService sessions; private StealthSessionService sessions;
private IdentityPresentation identityPresentation; private IdentityPresentation identityPresentation;
private ProtocolManager protocolManager;
@Override @Override
public void onEnable() { public void onEnable() {
@@ -41,6 +43,9 @@ public final class SpigotStealthPlugin extends JavaPlugin {
@Override @Override
public void onDisable() { public void onDisable() {
if (protocolManager != null) {
protocolManager.removePacketListeners(this);
}
if (identityPresentation != null && sessions != null) { if (identityPresentation != null && sessions != null) {
for (org.bukkit.entity.Player player : getServer().getOnlinePlayers()) { for (org.bukkit.entity.Player player : getServer().getOnlinePlayers()) {
if (sessions.isConcealed(player.getUniqueId())) { if (sessions.isConcealed(player.getUniqueId())) {
@@ -71,26 +76,38 @@ public final class SpigotStealthPlugin extends JavaPlugin {
progression = new QualifyingInvisibilityService( progression = new QualifyingInvisibilityService(
manager, settings.unlockThreshold(), System::nanoTime, notifier); manager, settings.unlockThreshold(), System::nanoTime, notifier);
sessions = new StealthSessionService(manager, progression); sessions = new StealthSessionService(manager, progression);
protocolManager = ProtocolLibrary.getProtocolManager();
identityPresentation = new BukkitIdentityPresentation( identityPresentation = new BukkitIdentityPresentation(
getServer()::getOnlinePlayers, getServer()::getOnlinePlayers,
Objects.requireNonNull(getServer().getScoreboardManager(), "scoreboard manager").getMainScoreboard(), Objects.requireNonNull(getServer().getScoreboardManager(), "scoreboard manager").getMainScoreboard(),
new ProtocolLibTabListController(ProtocolLibrary.getProtocolManager())); new ProtocolLibTabListController(protocolManager),
() -> manager.snapshot().sleepCountPolicy());
protocolManager.addPacketListener(new ProtocolLibServerListPingListener(
this, sessions::concealedPlayerIds, getLogger()::warning));
getServer().getPluginManager().registerEvents(
new ServerListPingListener(sessions::concealedPlayerIds), this);
getServer().getPluginManager().registerEvents( getServer().getPluginManager().registerEvents(
new InvisibilityEffectListener(progression, Clock.systemUTC()), this); new InvisibilityEffectListener(progression, Clock.systemUTC()), this);
getServer().getPluginManager().registerEvents( getServer().getPluginManager().registerEvents(
new StealthSessionListener(sessions, identityPresentation, settings.concealedMessage()), this); new StealthSessionListener(sessions, identityPresentation, settings.concealedMessage()), this);
Objects.requireNonNull(getCommand("stealth"), "stealth command") org.bukkit.command.PluginCommand stealthPluginCommand =
.setExecutor(new StealthCommand(manager, progression, settings)); Objects.requireNonNull(getCommand("stealth"), "stealth command");
StealthCommand stealthCommand = new StealthCommand(manager, progression, settings);
stealthPluginCommand.setExecutor(stealthCommand);
stealthPluginCommand.setTabCompleter(stealthCommand);
StealthAdministrationService administration = new StealthAdministrationService( StealthAdministrationService administration = new StealthAdministrationService(
manager, progression, identityPresentation, notifier, getServer()::getOnlinePlayers); manager, progression, identityPresentation, notifier, getServer()::getOnlinePlayers);
KnownPlayerResolver resolver = new KnownPlayerResolver(manager::snapshot, getServer()::getOnlinePlayers); KnownPlayerResolver resolver = new KnownPlayerResolver(manager::snapshot, getServer()::getOnlinePlayers);
Objects.requireNonNull(getCommand("stealthadmin"), "stealthadmin command") org.bukkit.command.PluginCommand stealthAdminPluginCommand =
.setExecutor(new StealthAdminCommand( Objects.requireNonNull(getCommand("stealthadmin"), "stealthadmin command");
administration, StealthAdminCommand stealthAdminCommand = new StealthAdminCommand(
resolver, administration,
mainThread, resolver,
getLogger()::info, mainThread,
settings.unlockThreshold())); getLogger()::info,
settings.unlockThreshold());
stealthAdminPluginCommand.setExecutor(stealthAdminCommand);
stealthAdminPluginCommand.setTabCompleter(stealthAdminCommand);
getServer().getScheduler().runTaskTimer(this, ignored -> { getServer().getScheduler().runTaskTimer(this, ignored -> {
for (java.util.UUID playerId : progression.activePlayerIds()) { for (java.util.UUID playerId : progression.activePlayerIds()) {
logSaveFailure(progression.checkpoint(playerId)); logSaveFailure(progression.checkpoint(playerId));
@@ -9,10 +9,12 @@ import java.util.function.Consumer;
import org.bukkit.command.Command; import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender; import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
/** Permission-gated administrative command for online and known offline players. */ /** Permission-gated administrative command for online and known offline players. */
public final class StealthAdminCommand implements CommandExecutor { public final class StealthAdminCommand implements CommandExecutor, TabCompleter {
private static final String PERMISSION = "spigotstealth.admin"; private static final String PERMISSION = "spigotstealth.admin";
private static final List<String> OPERATIONS = List.of("status", "grant", "reset", "list", "sleepcount");
private final StealthAdministrationService administration; private final StealthAdministrationService administration;
private final KnownPlayerResolver resolver; private final KnownPlayerResolver resolver;
private final Consumer<Runnable> mainThread; private final Consumer<Runnable> mainThread;
@@ -32,17 +34,63 @@ public final class StealthAdminCommand implements CommandExecutor {
this.unlockThreshold = Objects.requireNonNull(unlockThreshold, "unlockThreshold"); this.unlockThreshold = Objects.requireNonNull(unlockThreshold, "unlockThreshold");
} }
@Override
public List<String> onTabComplete(
CommandSender sender, Command command, String label, String[] arguments) {
if (!sender.hasPermission(PERMISSION)) {
return List.of();
}
if (arguments.length == 1) {
return matching(OPERATIONS, arguments[0]);
}
String operation = arguments[0].toLowerCase(Locale.ROOT);
if (arguments.length == 2) {
if (List.of("status", "grant", "reset").contains(operation)) {
return resolver.completeNames(arguments[1]);
}
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]);
}
return List.of();
}
private static List<String> matching(List<String> candidates, String prefix) {
String normalized = prefix.toLowerCase(Locale.ROOT);
return candidates.stream().filter(candidate -> candidate.startsWith(normalized)).toList();
}
@Override @Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) { public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (!sender.hasPermission(PERMISSION)) { if (!sender.hasPermission(PERMISSION)) {
sender.sendMessage("You do not have permission to administer Spigot Stealth."); sender.sendMessage("You do not have permission to administer Spigot Stealth.");
return true; return true;
} }
if (arguments.length == 1 && "list".equalsIgnoreCase(arguments[0])) { if (arguments.length > 0 && "sleepcount".equalsIgnoreCase(arguments[0])) {
List<String> names = administration.concealedOnlineNames(); return sleepCount(sender, label, arguments);
sender.sendMessage(names.isEmpty() }
? "No players are currently concealed." if (arguments.length > 0 && "list".equalsIgnoreCase(arguments[0])) {
: "Currently concealed: " + String.join(", ", names)); if (arguments.length == 1) {
List<String> names = administration.concealedOnlineNames();
sender.sendMessage(names.isEmpty()
? "No players are currently concealed."
: "Currently concealed: " + String.join(", ", names));
return true;
}
if (arguments.length == 2 && "unlocked".equalsIgnoreCase(arguments[1])) {
List<String> names = administration.unlockedPlayerNames();
sender.sendMessage(names.isEmpty()
? "No players have unlocked stealth."
: "Stealth unlocked: " + String.join(", ", names));
return true;
}
sendUsage(sender, label);
return true; return true;
} }
if (arguments.length < 2) { if (arguments.length < 2) {
@@ -69,6 +117,44 @@ public final class StealthAdminCommand implements CommandExecutor {
}; };
} }
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) { private boolean status(CommandSender sender, KnownPlayerResolver.KnownPlayer target) {
StealthAdministrationService.PlayerStatus status = administration.status(target.playerId()); StealthAdministrationService.PlayerStatus status = administration.status(target.playerId());
sender.sendMessage("Stealth status for " + displayName(target) + " (" + target.playerId() + "):"); sender.sendMessage("Stealth status for " + displayName(target) + " (" + target.playerId() + "):");
@@ -121,13 +207,17 @@ public final class StealthAdminCommand implements CommandExecutor {
} }
private static void sendUsage(CommandSender sender, String label) { private static void sendUsage(CommandSender sender, String label) {
sender.sendMessage("Usage: /" + label + " <status <player|uuid>|grant <player|uuid>|reset <player|uuid> confirm|list>"); sender.sendMessage("Usage: /" + label + " <status <player|uuid>|grant <player|uuid>|reset <player|uuid> confirm|list [unlocked]|sleepcount <status|include|exclude>>");
} }
private static String displayName(KnownPlayerResolver.KnownPlayer player) { private static String displayName(KnownPlayerResolver.KnownPlayer player) {
return player.name() == null ? player.playerId().toString() : player.name(); 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) { private static String yesNo(boolean value) {
return value ? "yes" : "no"; return value ? "yes" : "no";
} }
@@ -31,6 +31,26 @@ public final class StealthAdministrationService {
this.onlinePlayers = Objects.requireNonNull(onlinePlayers, "onlinePlayers"); this.onlinePlayers = Objects.requireNonNull(onlinePlayers, "onlinePlayers");
} }
public SleepCountPolicy sleepCountPolicy() {
return stateManager.snapshot().sleepCountPolicy();
}
public CompletableFuture<ChangeResult> 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) { public PlayerStatus status(UUID playerId) {
PlayerStealthState player = stateManager.snapshot().player(playerId); PlayerStealthState player = stateManager.snapshot().player(playerId);
return new PlayerStatus( return new PlayerStatus(
@@ -73,6 +93,17 @@ public final class StealthAdministrationService {
return CompletableFuture.allOf(stopped, reset); return CompletableFuture.allOf(stopped, reset);
} }
public List<String> unlockedPlayerNames() {
return stateManager.snapshot().players().entrySet().stream()
.filter(entry -> entry.getValue().unlocked())
.map(entry -> {
String name = entry.getValue().lastKnownName();
return name == null || name.isBlank() ? entry.getKey().toString() : name;
})
.sorted(String.CASE_INSENSITIVE_ORDER)
.toList();
}
public List<String> concealedOnlineNames() { public List<String> concealedOnlineNames() {
return onlinePlayers.get().stream() return onlinePlayers.get().stream()
.filter(player -> stateManager.snapshot().player(player.getUniqueId()).concealed()) .filter(player -> stateManager.snapshot().player(player.getUniqueId()).concealed())
@@ -1,14 +1,16 @@
package games.dmg.spigotstealth; package games.dmg.spigotstealth;
import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Objects; import java.util.Objects;
import org.bukkit.command.Command; import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender; import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
/** Player-facing progress command. */ /** Player-facing progress command. */
public final class StealthCommand implements CommandExecutor { public final class StealthCommand implements CommandExecutor, TabCompleter {
private final StealthStateManager stateManager; private final StealthStateManager stateManager;
private final QualifyingInvisibilityService progression; private final QualifyingInvisibilityService progression;
private final StealthSettings settings; private final StealthSettings settings;
@@ -22,6 +24,15 @@ public final class StealthCommand implements CommandExecutor {
this.settings = Objects.requireNonNull(settings, "settings"); this.settings = Objects.requireNonNull(settings, "settings");
} }
@Override
public List<String> onTabComplete(
CommandSender sender, Command command, String label, String[] arguments) {
if (arguments.length == 1 && "progress".startsWith(arguments[0].toLowerCase(Locale.ROOT))) {
return List.of("progress");
}
return List.of();
}
@Override @Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) { public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (!(sender instanceof Player player)) { if (!(sender instanceof Player player)) {
@@ -38,10 +38,14 @@ public final class StealthSessionListener implements Listener {
presentation.refreshForObserver(player); presentation.refreshForObserver(player);
} }
@EventHandler(priority = EventPriority.MONITOR) @EventHandler(priority = EventPriority.HIGHEST)
public void onQuit(PlayerQuitEvent event) { public void onQuit(PlayerQuitEvent event) {
presentation.reveal(event.getPlayer()); Player player = event.getPlayer();
sessions.disconnect(event.getPlayer().getUniqueId()); if (sessions.isConcealed(player.getUniqueId())) {
event.setQuitMessage(null);
}
presentation.reveal(player);
sessions.disconnect(player.getUniqueId());
} }
@EventHandler(priority = EventPriority.MONITOR) @EventHandler(priority = EventPriority.MONITOR)
@@ -1,7 +1,9 @@
package games.dmg.spigotstealth; package games.dmg.spigotstealth;
import java.util.Objects; import java.util.Objects;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
@@ -9,6 +11,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
public final class StealthSessionService { public final class StealthSessionService {
private final StealthStateManager stateManager; private final StealthStateManager stateManager;
private final QualifyingInvisibilityService progression; private final QualifyingInvisibilityService progression;
private final Set<UUID> concealedOnlinePlayerIds = ConcurrentHashMap.newKeySet();
public StealthSessionService( public StealthSessionService(
StealthStateManager stateManager, StealthStateManager stateManager,
@@ -18,6 +21,7 @@ public final class StealthSessionService {
} }
public CompletableFuture<Void> disconnect(UUID playerId) { public CompletableFuture<Void> disconnect(UUID playerId) {
concealedOnlinePlayerIds.remove(playerId);
boolean qualifyingAtDisconnect = progression.isQualifying(playerId); boolean qualifyingAtDisconnect = progression.isQualifying(playerId);
return progression.stop(playerId).thenCompose(ignored -> stateManager.update(state -> { return progression.stop(playerId).thenCompose(ignored -> stateManager.update(state -> {
PlayerStealthState player = state.player(playerId); PlayerStealthState player = state.player(playerId);
@@ -34,10 +38,16 @@ public final class StealthSessionService {
concealed.set(conceal); concealed.set(conceal);
return state.withPlayer(player.withSession(false, conceal).withQualifyingSince(null)); return state.withPlayer(player.withSession(false, conceal).withQualifyingSince(null));
}); });
if (concealed.get()) {
concealedOnlinePlayerIds.add(playerId);
} else {
concealedOnlinePlayerIds.remove(playerId);
}
return new LoginTransition(concealed.get(), saved); return new LoginTransition(concealed.get(), saved);
} }
public CompletableFuture<Void> endConcealment(UUID playerId) { public CompletableFuture<Void> endConcealment(UUID playerId) {
concealedOnlinePlayerIds.remove(playerId);
return stateManager.update(state -> { return stateManager.update(state -> {
PlayerStealthState player = state.player(playerId); PlayerStealthState player = state.player(playerId);
return state.withPlayer(player.withSession(player.preparedLogin(), false)); return state.withPlayer(player.withSession(player.preparedLogin(), false));
@@ -48,5 +58,12 @@ public final class StealthSessionService {
return stateManager.snapshot().player(playerId).concealed(); return stateManager.snapshot().player(playerId).concealed();
} }
public Set<UUID> concealedPlayerIds() {
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<Void> saved) { } public record LoginTransition(boolean concealed, CompletableFuture<Void> saved) { }
} }
@@ -17,7 +17,7 @@ import org.bukkit.configuration.file.YamlConfiguration;
/** Defensive YAML repository using atomic file replacement where available. */ /** Defensive YAML repository using atomic file replacement where available. */
public final class YamlStealthStateRepository implements StealthStateRepository { public final class YamlStealthStateRepository implements StealthStateRepository {
private static final Set<String> ROOT_FIELDS = Set.of("schema-version", "players"); private static final Set<String> ROOT_FIELDS = Set.of("schema-version", "sleep-count-policy", "players");
private static final Set<String> PLAYER_FIELDS = Set.of( private static final Set<String> PLAYER_FIELDS = Set.of(
"last-known-name", "accumulated-millis", "unlocked", "prepared-login", "concealed", "qualifying-since"); "last-known-name", "accumulated-millis", "unlocked", "prepared-login", "concealed", "qualifying-since");
private final Path stateFile; 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 @Override
@@ -61,6 +64,7 @@ public final class YamlStealthStateRepository implements StealthStateRepository
YamlConfiguration yaml = new YamlConfiguration(); YamlConfiguration yaml = new YamlConfiguration();
state.unknownFields().forEach(yaml::set); state.unknownFields().forEach(yaml::set);
yaml.set("schema-version", 1); yaml.set("schema-version", 1);
yaml.set("sleep-count-policy", state.sleepCountPolicy().persistedValue());
for (PlayerStealthState player : state.players().values()) { for (PlayerStealthState player : state.players().values()) {
String base = "players." + player.playerId() + "."; String base = "players." + player.playerId() + ".";
player.unknownFields().forEach((key, value) -> yaml.set(base + key, value)); player.unknownFields().forEach((key, value) -> yaml.set(base + key, value));
+1 -1
View File
@@ -12,7 +12,7 @@ commands:
permission: spigotstealth.use permission: spigotstealth.use
stealthadmin: stealthadmin:
description: Inspect and administer Spigot Stealth. description: Inspect and administer Spigot Stealth.
usage: /stealthadmin <status|grant|reset|list> usage: /stealthadmin <status <player|uuid>|grant <player|uuid>|reset <player|uuid> confirm|list [unlocked]|sleepcount <status|include|exclude>>
permission: spigotstealth.admin permission: spigotstealth.admin
permissions: permissions:
spigotstealth.use: spigotstealth.use:
@@ -7,12 +7,71 @@ import static org.mockito.Mockito.when;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.scoreboard.Scoreboard; import org.bukkit.scoreboard.Scoreboard;
import org.bukkit.scoreboard.Team; import org.bukkit.scoreboard.Team;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
class BukkitIdentityPresentationTest { 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<SleepCountPolicy> 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 @Test
void concealRemovesTabEntryAndHidesNameTagWithoutHidingEntity() { void concealRemovesTabEntryAndHidesNameTagWithoutHidingEntity() {
UUID targetId = UUID.randomUUID(); UUID targetId = UUID.randomUUID();
@@ -0,0 +1,36 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import org.bukkit.entity.Player;
import org.bukkit.event.server.ServerListPingEvent;
import org.junit.jupiter.api.Test;
class ServerListPingListenerTest {
@Test
void removesOnlyConcealedPlayersFromNativeServerPingSample() {
UUID concealedId = UUID.randomUUID();
Player visible = player(UUID.randomUUID());
Player concealed = player(concealedId);
ArrayList<Player> sample = new ArrayList<>(List.of(visible, concealed));
ServerListPingEvent event = mock(ServerListPingEvent.class);
when(event.iterator()).thenReturn(sample.iterator());
ServerListPingListener listener = new ServerListPingListener(() -> Set.of(concealedId));
listener.onServerListPing(event);
assertEquals(List.of(visible), sample);
}
private static Player player(UUID playerId) {
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
return player;
}
}
@@ -0,0 +1,43 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class ServerListPingVisibilityTest {
@Test
void publicPlayerCountNeverBecomesNegative() {
ServerListPingVisibility.Snapshot adjusted = ServerListPingVisibility.adjust(
0, 100, List.of(), Set.of(UUID.randomUUID()));
assertEquals(0, adjusted.playersOnline());
}
@Test
void ordinaryPlayersRemainCountedAndSampled() {
UUID firstId = UUID.randomUUID();
UUID secondId = UUID.randomUUID();
ServerListPingVisibility.Snapshot adjusted = ServerListPingVisibility.adjust(
2, 100, List.of(firstId, secondId), Set.of());
assertEquals(2, adjusted.playersOnline());
assertEquals(List.of(firstId, secondId), adjusted.samplePlayerIds());
}
@Test
void excludesConcealedPlayersFromPublicCountAndSampleWithoutChangingMaximum() {
UUID visibleId = UUID.randomUUID();
UUID concealedId = UUID.randomUUID();
ServerListPingVisibility.Snapshot adjusted = ServerListPingVisibility.adjust(
2, 100, List.of(visibleId, concealedId), Set.of(concealedId));
assertEquals(1, adjusted.playersOnline());
assertEquals(100, adjusted.playersMaximum());
assertEquals(List.of(visibleId), adjusted.samplePlayerIds());
}
}
@@ -0,0 +1,24 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import java.util.ArrayList;
import org.junit.jupiter.api.Test;
class ServerPingCompatibilityGuardTest {
@Test
void compatibilityFailureLeavesPingHandlingUsableAndWarnsOnlyOnce() {
ArrayList<String> warnings = new ArrayList<>();
ServerPingCompatibilityGuard guard = new ServerPingCompatibilityGuard(warnings::add);
assertDoesNotThrow(() -> guard.run(() -> {
throw new IllegalArgumentException("unsupported profile representation");
}));
assertDoesNotThrow(() -> guard.run(() -> {
throw new IllegalArgumentException("unsupported profile representation");
}));
assertEquals(1, warnings.size());
}
}
@@ -1,5 +1,6 @@
package games.dmg.spigotstealth; package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.contains; import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
@@ -16,6 +17,77 @@ import org.bukkit.command.CommandSender;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
class StealthAdminCommandTest { class StealthAdminCommandTest {
@Test
void completesAdministrativeOperationsByPrefix() {
UUID playerId = UUID.randomUUID();
try (Fixture fixture = fixture(playerId)) {
assertEquals(
List.of("grant"),
fixture.command.onTabComplete(
sender(true), mock(Command.class), "stealthadmin", new String[] {"gr"}));
assertEquals(
List.of("status", "grant", "reset", "list", "sleepcount"),
fixture.command.onTabComplete(
sender(true), mock(Command.class), "stealthadmin", new String[] {""}));
}
}
@Test
void completesContextualArgumentsAndSuppressesIrrelevantSuggestions() {
UUID playerId = UUID.randomUUID();
try (Fixture fixture = fixture(playerId)) {
Command command = mock(Command.class);
CommandSender administrator = sender(true);
assertEquals(
List.of("Alex"),
fixture.command.onTabComplete(
administrator, command, "stealthadmin", new String[] {"status", "al"}));
assertEquals(
List.of("confirm"),
fixture.command.onTabComplete(
administrator, command, "stealthadmin", new String[] {"reset", "Alex", "co"}));
assertEquals(
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(
administrator, command, "stealthadmin", new String[] {"status", "Alex", ""}));
assertEquals(
List.of(),
fixture.command.onTabComplete(
sender(false), command, "stealthadmin", new String[] {""}));
}
}
@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 @Test
void statusInspectsKnownOfflinePlayerAndReportsAllState() { void statusInspectsKnownOfflinePlayerAndReportsAllState() {
UUID playerId = UUID.randomUUID(); UUID playerId = UUID.randomUUID();
@@ -28,6 +100,25 @@ class StealthAdminCommandTest {
} }
} }
@Test
void listsUnlockedPlayersAndClearlyReportsWhenThereAreNone() {
UUID playerId = UUID.randomUUID();
try (Fixture fixture = fixture(playerId)) {
Command command = mock(Command.class);
CommandSender emptySender = sender(true);
fixture.command.onCommand(
emptySender, command, "stealthadmin", new String[] {"list", "unlocked"});
verify(emptySender).sendMessage("No players have unlocked stealth.");
fixture.manager.update(state -> state.withPlayer(
state.player(playerId).withProgress(5000L, true))).join();
CommandSender populatedSender = sender(true);
fixture.command.onCommand(
populatedSender, command, "stealthadmin", new String[] {"list", "unlocked"});
verify(populatedSender).sendMessage("Stealth unlocked: Alex");
}
}
@Test @Test
void grantPersistsThenReportsAndAuditsAction() { void grantPersistsThenReportsAndAuditsAction() {
UUID playerId = UUID.randomUUID(); UUID playerId = UUID.randomUUID();
@@ -71,18 +162,20 @@ class StealthAdminCommandTest {
manager.update(state -> state.withPlayer(player)).join(); manager.update(state -> state.withPlayer(player)).join();
QualifyingInvisibilityService progression = new QualifyingInvisibilityService( QualifyingInvisibilityService progression = new QualifyingInvisibilityService(
manager, Duration.ofHours(8), System::nanoTime, ignored -> { }); manager, Duration.ofHours(8), System::nanoTime, ignored -> { });
IdentityPresentation presentation = mock(IdentityPresentation.class);
StealthAdministrationService administration = new StealthAdministrationService( 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); KnownPlayerResolver resolver = new KnownPlayerResolver(manager::snapshot, List::of);
ArrayList<String> audit = new ArrayList<>(); ArrayList<String> audit = new ArrayList<>();
StealthAdminCommand command = new StealthAdminCommand( StealthAdminCommand command = new StealthAdminCommand(
administration, resolver, Runnable::run, audit::add, Duration.ofHours(8)); 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( private record Fixture(
StealthStateManager manager, StealthStateManager manager,
StealthAdminCommand command, StealthAdminCommand command,
IdentityPresentation presentation,
ArrayList<String> audit) implements AutoCloseable { ArrayList<String> audit) implements AutoCloseable {
@Override public void close() { manager.close(); } @Override public void close() { manager.close(); }
} }
@@ -16,6 +16,18 @@ import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
class StealthAdministrationServiceTest { 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 @Test
void grantsOfflineUnlockAndNotifiesExactlyOnce() { void grantsOfflineUnlockAndNotifiesExactlyOnce() {
UUID playerId = UUID.randomUUID(); UUID playerId = UUID.randomUUID();
@@ -58,6 +70,29 @@ class StealthAdministrationServiceTest {
} }
} }
@Test
void listsAllUnlockedPlayersByNameOrUuidInCaseInsensitiveOrder() {
UUID namelessId = UUID.randomUUID();
UUID lockedId = UUID.randomUUID();
UUID zoeId = UUID.randomUUID();
UUID alexId = UUID.randomUUID();
try (StealthStateManager manager = manager()) {
manager.update(state -> state
.withPlayer(PlayerStealthState.empty(namelessId).withProgress(0L, true))
.withPlayer(PlayerStealthState.empty(lockedId).withLastKnownName("Locked"))
.withPlayer(PlayerStealthState.empty(zoeId).withLastKnownName("zoe").withProgress(1L, true))
.withPlayer(PlayerStealthState.empty(alexId).withLastKnownName("Alex").withProgress(2L, true)))
.join();
StealthAdministrationService administration = service(manager, List::of, ignored -> { });
assertEquals(
List.of("Alex", namelessId.toString(), "zoe").stream()
.sorted(String.CASE_INSENSITIVE_ORDER)
.toList(),
administration.unlockedPlayerNames());
}
}
@Test @Test
void listsOnlyCurrentlyOnlineConcealedPlayers() { void listsOnlyCurrentlyOnlineConcealedPlayers() {
UUID concealedId = UUID.randomUUID(); UUID concealedId = UUID.randomUUID();
@@ -8,6 +8,7 @@ import static org.mockito.Mockito.when;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
@@ -16,6 +17,24 @@ import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
class StealthCommandTest { class StealthCommandTest {
@Test
void completesProgressByPrefixWithoutSuggestingPlayersElsewhere() {
try (StealthStateManager manager = manager()) {
QualifyingInvisibilityService progression = new QualifyingInvisibilityService(
manager, Duration.ofHours(8), System::nanoTime, ignored -> { });
StealthCommand command = new StealthCommand(manager, progression, StealthSettings.from(Map.of()));
Command bukkitCommand = mock(Command.class);
Player player = player(UUID.randomUUID());
org.junit.jupiter.api.Assertions.assertEquals(
List.of("progress"),
command.onTabComplete(player, bukkitCommand, "stealth", new String[] {"pr"}));
org.junit.jupiter.api.Assertions.assertEquals(
List.of(),
command.onTabComplete(player, bukkitCommand, "stealth", new String[] {"progress", ""}));
}
}
@Test @Test
void reportsLiveProgressTargetAndRemainingTimeWithoutMutatingState() { void reportsLiveProgressTargetAndRemainingTimeWithoutMutatingState() {
UUID playerId = UUID.randomUUID(); UUID playerId = UUID.randomUUID();
@@ -10,9 +10,50 @@ import java.util.Map;
import java.util.UUID; import java.util.UUID;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
class StealthSessionListenerTest { class StealthSessionListenerTest {
@Test
void concealedDisconnectSuppressesPublicAnnouncementBeforeCleanup() {
UUID playerId = UUID.randomUUID();
try (StealthStateManager manager = manager()) {
manager.update(state -> state.withPlayer(unlocked(playerId).withSession(false, true))).join();
QualifyingInvisibilityService progression = new QualifyingInvisibilityService(
manager, Duration.ofHours(8), System::nanoTime, ignored -> { });
StealthSessionService sessions = new StealthSessionService(manager, progression);
StealthSessionListener listener = new StealthSessionListener(
sessions, mock(IdentityPresentation.class), "Stealth active");
PlayerQuitEvent event = mock(PlayerQuitEvent.class);
Player player = player(playerId);
when(event.getPlayer()).thenReturn(player);
listener.onQuit(event);
verify(event).setQuitMessage(null);
}
}
@Test
void ordinaryDisconnectRetainsPublicAnnouncement() {
UUID playerId = UUID.randomUUID();
try (StealthStateManager manager = manager()) {
QualifyingInvisibilityService progression = new QualifyingInvisibilityService(
manager, Duration.ofHours(8), System::nanoTime, ignored -> { });
StealthSessionListener listener = new StealthSessionListener(
new StealthSessionService(manager, progression),
mock(IdentityPresentation.class),
"Stealth active");
PlayerQuitEvent event = mock(PlayerQuitEvent.class);
Player player = player(playerId);
when(event.getPlayer()).thenReturn(player);
listener.onQuit(event);
verify(event, never()).setQuitMessage(null);
}
}
@Test @Test
void preparedLoginSuppressesAnnouncementAndConcealsIdentityForSession() { void preparedLoginSuppressesAnnouncementAndConcealsIdentityForSession() {
UUID playerId = UUID.randomUUID(); UUID playerId = UUID.randomUUID();
@@ -1,15 +1,38 @@
package games.dmg.spigotstealth; 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.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
class StealthSessionServiceTest { class StealthSessionServiceTest {
@Test
void reportsOnlyCurrentlyConcealedPlayerIdsForPublicPresentation() {
UUID concealedId = UUID.randomUUID();
UUID staleId = UUID.randomUUID();
try (StealthStateManager manager = manager()) {
manager.update(state -> state
.withPlayer(unlocked(concealedId).withSession(true, false))
.withPlayer(unlocked(staleId).withSession(false, true)))
.join();
QualifyingInvisibilityService progression = new QualifyingInvisibilityService(
manager, Duration.ofHours(8), System::nanoTime, ignored -> { });
StealthSessionService sessions = new StealthSessionService(manager, progression);
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());
}
}
@Test @Test
void unlockedQualifyingDisconnectPreparesAndConsumesOneConcealedLogin() { void unlockedQualifyingDisconnectPreparesAndConsumesOneConcealedLogin() {
UUID playerId = UUID.randomUUID(); UUID playerId = UUID.randomUUID();
@@ -15,15 +15,28 @@ import org.junit.jupiter.api.io.TempDir;
class YamlStealthStateRepositoryTest { class YamlStealthStateRepositoryTest {
@TempDir Path temporaryDirectory; @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 @Test
void roundTripsUuidStateAndPreservesUnknownFields() throws Exception { void roundTripsUuidStateAndPreservesUnknownFields() throws Exception {
UUID playerId = UUID.randomUUID(); UUID playerId = UUID.randomUUID();
Path stateFile = temporaryDirectory.resolve("state.yml"); 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); YamlStealthStateRepository repository = new YamlStealthStateRepository(stateFile);
PersistentStealthState loaded = repository.load(); PersistentStealthState loaded = repository.load();
PlayerStealthState player = loaded.player(playerId); PlayerStealthState player = loaded.player(playerId);
assertEquals(SleepCountPolicy.INCLUDE, loaded.sleepCountPolicy());
assertEquals(25L, player.accumulatedMillis()); assertEquals(25L, player.accumulatedMillis());
assertTrue(player.preparedLogin()); assertTrue(player.preparedLogin());
assertEquals(Instant.parse("2026-08-14T10:00:00Z"), player.qualifyingSince()); assertEquals(Instant.parse("2026-08-14T10:00:00Z"), player.qualifyingSince());
@@ -31,6 +44,7 @@ class YamlStealthStateRepositoryTest {
repository.save(loaded.withPlayer(player.withAccumulatedMillis(50L))); repository.save(loaded.withPlayer(player.withAccumulatedMillis(50L)));
String saved = Files.readString(stateFile); String saved = Files.readString(stateFile);
assertTrue(saved.contains("future-root: keep")); assertTrue(saved.contains("future-root: keep"));
assertTrue(saved.contains("sleep-count-policy: include"));
assertTrue(saved.contains("future-player: keep-too")); assertTrue(saved.contains("future-player: keep-too"));
assertEquals(50L, repository.load().player(playerId).accumulatedMillis()); assertEquals(50L, repository.load().player(playerId).accumulatedMillis());
} }