Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
831f6a2ce4 | ||
|
|
a084f86c1d | ||
|
|
ba6573943b |
@@ -1,5 +1,11 @@
|
||||
# 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.
|
||||
|
||||
@@ -19,6 +19,16 @@ As an **unlocked player**, I want to disconnect while invisibility from a potion
|
||||
- [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] 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] 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.
|
||||
@@ -29,7 +39,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, 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
|
||||
|
||||
|
||||
@@ -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 <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
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -15,20 +15,32 @@ public final class BukkitIdentityPresentation implements IdentityPresentation {
|
||||
private final Supplier<? extends Collection<? extends Player>> onlinePlayers;
|
||||
private final Scoreboard scoreboard;
|
||||
private final TabListController tabLists;
|
||||
private final Supplier<SleepCountPolicy> sleepCountPolicy;
|
||||
private final Map<UUID, Player> concealedPlayers = new LinkedHashMap<>();
|
||||
private final Map<UUID, Boolean> previousSleepingIgnored = new LinkedHashMap<>();
|
||||
|
||||
public BukkitIdentityPresentation(
|
||||
Supplier<? extends Collection<? extends Player>> onlinePlayers,
|
||||
Scoreboard scoreboard,
|
||||
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.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);
|
||||
}
|
||||
|
||||
@@ -9,4 +9,6 @@ public interface IdentityPresentation {
|
||||
void reveal(Player player);
|
||||
|
||||
void refreshForObserver(Player observer);
|
||||
|
||||
void refreshSleepCountPolicy();
|
||||
}
|
||||
|
||||
@@ -8,12 +8,20 @@ import java.util.UUID;
|
||||
/** Immutable snapshot of all durable plugin state. */
|
||||
public record PersistentStealthState(
|
||||
Map<UUID, PlayerStealthState> players,
|
||||
SleepCountPolicy sleepCountPolicy,
|
||||
Map<String, Object> 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<UUID, PlayerStealthState> players,
|
||||
Map<String, Object> 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<UUID, PlayerStealthState> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
import com.comphenix.protocol.ProtocolLibrary;
|
||||
import com.comphenix.protocol.ProtocolManager;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.util.Objects;
|
||||
@@ -15,6 +16,7 @@ public final class SpigotStealthPlugin extends JavaPlugin {
|
||||
private QualifyingInvisibilityService progression;
|
||||
private StealthSessionService sessions;
|
||||
private IdentityPresentation identityPresentation;
|
||||
private ProtocolManager protocolManager;
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
@@ -41,6 +43,9 @@ public final class SpigotStealthPlugin extends JavaPlugin {
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
if (protocolManager != null) {
|
||||
protocolManager.removePacketListeners(this);
|
||||
}
|
||||
if (identityPresentation != null && sessions != null) {
|
||||
for (org.bukkit.entity.Player player : getServer().getOnlinePlayers()) {
|
||||
if (sessions.isConcealed(player.getUniqueId())) {
|
||||
@@ -71,10 +76,16 @@ public final class SpigotStealthPlugin extends JavaPlugin {
|
||||
progression = new QualifyingInvisibilityService(
|
||||
manager, settings.unlockThreshold(), System::nanoTime, notifier);
|
||||
sessions = new StealthSessionService(manager, progression);
|
||||
protocolManager = ProtocolLibrary.getProtocolManager();
|
||||
identityPresentation = new BukkitIdentityPresentation(
|
||||
getServer()::getOnlinePlayers,
|
||||
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(
|
||||
new InvisibilityEffectListener(progression, Clock.systemUTC()), this);
|
||||
getServer().getPluginManager().registerEvents(
|
||||
|
||||
@@ -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<String> OPERATIONS = List.of("status", "grant", "reset", "list");
|
||||
private static final List<String> OPERATIONS = List.of("status", "grant", "reset", "list", "sleepcount");
|
||||
private final StealthAdministrationService administration;
|
||||
private final KnownPlayerResolver resolver;
|
||||
private final Consumer<Runnable> 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<String> 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 + " <status <player|uuid>|grant <player|uuid>|reset <player|uuid> confirm|list [unlocked]>");
|
||||
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) {
|
||||
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";
|
||||
}
|
||||
|
||||
@@ -31,6 +31,26 @@ public final class StealthAdministrationService {
|
||||
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) {
|
||||
PlayerStealthState player = stateManager.snapshot().player(playerId);
|
||||
return new PlayerStatus(
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
@@ -9,6 +11,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
||||
public final class StealthSessionService {
|
||||
private final StealthStateManager stateManager;
|
||||
private final QualifyingInvisibilityService progression;
|
||||
private final Set<UUID> concealedOnlinePlayerIds = ConcurrentHashMap.newKeySet();
|
||||
|
||||
public StealthSessionService(
|
||||
StealthStateManager stateManager,
|
||||
@@ -18,6 +21,7 @@ public final class StealthSessionService {
|
||||
}
|
||||
|
||||
public CompletableFuture<Void> disconnect(UUID playerId) {
|
||||
concealedOnlinePlayerIds.remove(playerId);
|
||||
boolean qualifyingAtDisconnect = progression.isQualifying(playerId);
|
||||
return progression.stop(playerId).thenCompose(ignored -> stateManager.update(state -> {
|
||||
PlayerStealthState player = state.player(playerId);
|
||||
@@ -34,10 +38,16 @@ public final class StealthSessionService {
|
||||
concealed.set(conceal);
|
||||
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);
|
||||
}
|
||||
|
||||
public CompletableFuture<Void> endConcealment(UUID playerId) {
|
||||
concealedOnlinePlayerIds.remove(playerId);
|
||||
return stateManager.update(state -> {
|
||||
PlayerStealthState player = state.player(playerId);
|
||||
return state.withPlayer(player.withSession(player.preparedLogin(), false));
|
||||
@@ -48,5 +58,12 @@ public final class StealthSessionService {
|
||||
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) { }
|
||||
}
|
||||
|
||||
@@ -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<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(
|
||||
"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));
|
||||
|
||||
@@ -12,7 +12,7 @@ commands:
|
||||
permission: spigotstealth.use
|
||||
stealthadmin:
|
||||
description: Inspect and administer Spigot Stealth.
|
||||
usage: /stealthadmin <status <player|uuid>|grant <player|uuid>|reset <player|uuid> confirm|list [unlocked]>
|
||||
usage: /stealthadmin <status <player|uuid>|grant <player|uuid>|reset <player|uuid> confirm|list [unlocked]|sleepcount <status|include|exclude>>
|
||||
permission: spigotstealth.admin
|
||||
permissions:
|
||||
spigotstealth.use:
|
||||
|
||||
@@ -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<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
|
||||
void concealRemovesTabEntryAndHidesNameTagWithoutHidingEntity() {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<String> audit) implements AutoCloseable {
|
||||
@Override public void close() { manager.close(); }
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,15 +1,38 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
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
|
||||
void unlockedQualifyingDisconnectPreparesAndConsumesOneConcealedLogin() {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user