feat(admin): add persistent sleep-count policy
Release / release (push) Successful in 2m17s
CI / build (push) Successful in 1m3s

This commit is contained in:
dmg
2026-09-05 08:34:50 -04:00
parent ba6573943b
commit a084f86c1d
19 changed files with 290 additions and 15 deletions
@@ -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,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);
}
}
@@ -80,7 +80,8 @@ public final class SpigotStealthPlugin extends JavaPlugin {
identityPresentation = new BukkitIdentityPresentation(
getServer()::getOnlinePlayers,
Objects.requireNonNull(getServer().getScoreboardManager(), "scoreboard manager").getMainScoreboard(),
new ProtocolLibTabListController(protocolManager));
new ProtocolLibTabListController(protocolManager),
() -> manager.snapshot().sleepCountPolicy());
protocolManager.addPacketListener(
new ProtocolLibServerListPingListener(this, sessions::concealedPlayerIds));
getServer().getPluginManager().registerEvents(
@@ -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(
@@ -59,7 +59,10 @@ public final class StealthSessionService {
}
public Set<UUID> concealedPlayerIds() {
return Set.copyOf(concealedOnlinePlayerIds);
PersistentStealthState state = stateManager.snapshot();
return concealedOnlinePlayerIds.stream()
.filter(playerId -> state.player(playerId).concealed())
.collect(java.util.stream.Collectors.toUnmodifiableSet());
}
public record LoginTransition(boolean concealed, CompletableFuture<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));