feat(admin): add persistent sleep-count policy
This commit is contained in:
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user