feat(admin): manage player stealth state
Release / release (push) Successful in 2m20s
CI / build (push) Successful in 1m6s

This commit is contained in:
dmg
2026-08-15 00:20:29 -04:00
parent 6071bfbdce
commit 2bcba5071f
9 changed files with 546 additions and 15 deletions
@@ -0,0 +1,64 @@
package games.dmg.spigotstealth;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Supplier;
import org.bukkit.entity.Player;
/** Resolves only already-known UUIDs and exact names without creating offline-player records. */
public final class KnownPlayerResolver {
private final Supplier<PersistentStealthState> state;
private final Supplier<? extends Collection<? extends Player>> onlinePlayers;
public KnownPlayerResolver(
Supplier<PersistentStealthState> state,
Supplier<? extends Collection<? extends Player>> onlinePlayers) {
this.state = Objects.requireNonNull(state, "state");
this.onlinePlayers = Objects.requireNonNull(onlinePlayers, "onlinePlayers");
}
public Resolution resolve(String query) {
if (query == null || query.isBlank()) {
return new Resolution(Status.UNKNOWN, null);
}
Map<UUID, KnownPlayer> known = knownPlayers();
try {
UUID playerId = UUID.fromString(query);
KnownPlayer player = known.get(playerId);
return player == null ? new Resolution(Status.UNKNOWN, null) : new Resolution(Status.FOUND, player);
} catch (IllegalArgumentException ignored) {
// Continue with exact case-insensitive name resolution.
}
String normalized = query.toLowerCase(Locale.ROOT);
java.util.List<KnownPlayer> matches = known.values().stream()
.filter(player -> player.name() != null && player.name().toLowerCase(Locale.ROOT).equals(normalized))
.toList();
if (matches.isEmpty()) {
return new Resolution(Status.UNKNOWN, null);
}
if (matches.size() > 1) {
return new Resolution(Status.AMBIGUOUS, null);
}
return new Resolution(Status.FOUND, matches.get(0));
}
private Map<UUID, KnownPlayer> knownPlayers() {
Map<UUID, KnownPlayer> known = new LinkedHashMap<>();
state.get().players().forEach((playerId, playerState) ->
known.put(playerId, new KnownPlayer(playerId, playerState.lastKnownName(), null)));
for (Player player : onlinePlayers.get()) {
known.put(player.getUniqueId(), new KnownPlayer(player.getUniqueId(), player.getName(), player));
}
return known;
}
public enum Status { FOUND, UNKNOWN, AMBIGUOUS }
public record KnownPlayer(UUID playerId, String name, Player onlinePlayer) { }
public record Resolution(Status status, KnownPlayer player) { }
}
@@ -62,9 +62,11 @@ public final class SpigotStealthPlugin extends JavaPlugin {
}
private void finishInitialization(StealthStateManager manager) {
java.util.function.Consumer<Runnable> mainThread =
runnable -> getServer().getScheduler().runTask(this, runnable);
BukkitUnlockNotifier notifier = new BukkitUnlockNotifier(
getServer()::getPlayer,
runnable -> getServer().getScheduler().runTask(this, runnable),
mainThread,
settings.unlockedMessage());
progression = new QualifyingInvisibilityService(
manager, settings.unlockThreshold(), System::nanoTime, notifier);
@@ -79,6 +81,16 @@ public final class SpigotStealthPlugin extends JavaPlugin {
new StealthSessionListener(sessions, identityPresentation, settings.concealedMessage()), this);
Objects.requireNonNull(getCommand("stealth"), "stealth command")
.setExecutor(new StealthCommand(manager, progression, settings));
StealthAdministrationService administration = new StealthAdministrationService(
manager, progression, identityPresentation, notifier, getServer()::getOnlinePlayers);
KnownPlayerResolver resolver = new KnownPlayerResolver(manager::snapshot, getServer()::getOnlinePlayers);
Objects.requireNonNull(getCommand("stealthadmin"), "stealthadmin command")
.setExecutor(new StealthAdminCommand(
administration,
resolver,
mainThread,
getLogger()::info,
settings.unlockThreshold()));
getServer().getScheduler().runTaskTimer(this, ignored -> {
for (java.util.UUID playerId : progression.activePlayerIds()) {
logSaveFailure(progression.checkpoint(playerId));
@@ -0,0 +1,134 @@
package games.dmg.spigotstealth;
import java.time.Duration;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
/** Permission-gated administrative command for online and known offline players. */
public final class StealthAdminCommand implements CommandExecutor {
private static final String PERMISSION = "spigotstealth.admin";
private final StealthAdministrationService administration;
private final KnownPlayerResolver resolver;
private final Consumer<Runnable> mainThread;
private final Consumer<String> auditLog;
private final Duration unlockThreshold;
public StealthAdminCommand(
StealthAdministrationService administration,
KnownPlayerResolver resolver,
Consumer<Runnable> mainThread,
Consumer<String> auditLog,
Duration unlockThreshold) {
this.administration = Objects.requireNonNull(administration, "administration");
this.resolver = Objects.requireNonNull(resolver, "resolver");
this.mainThread = Objects.requireNonNull(mainThread, "mainThread");
this.auditLog = Objects.requireNonNull(auditLog, "auditLog");
this.unlockThreshold = Objects.requireNonNull(unlockThreshold, "unlockThreshold");
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (!sender.hasPermission(PERMISSION)) {
sender.sendMessage("You do not have permission to administer Spigot Stealth.");
return true;
}
if (arguments.length == 1 && "list".equalsIgnoreCase(arguments[0])) {
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) {
sendUsage(sender, label);
return true;
}
String operation = arguments[0].toLowerCase(Locale.ROOT);
KnownPlayerResolver.Resolution resolution = resolver.resolve(arguments[1]);
if (resolution.status() != KnownPlayerResolver.Status.FOUND) {
sender.sendMessage(resolution.status() == KnownPlayerResolver.Status.AMBIGUOUS
? "That player name is ambiguous; use a UUID."
: "Unknown player. Use an exact known name or UUID.");
return true;
}
KnownPlayerResolver.KnownPlayer target = resolution.player();
return switch (operation) {
case "status" -> status(sender, target);
case "grant" -> grant(sender, target);
case "reset" -> reset(sender, label, arguments, target);
default -> {
sendUsage(sender, label);
yield 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() + "):");
sender.sendMessage("progress=" + StealthProgressFormatter.duration(status.accumulatedMillis())
+ "/" + StealthProgressFormatter.duration(unlockThreshold.toMillis())
+ " unlocked=" + yesNo(status.unlocked())
+ " prepared=" + yesNo(status.preparedLogin())
+ " concealed=" + yesNo(status.concealed())
+ " qualifying=" + yesNo(status.qualifyingNow()));
return true;
}
private boolean grant(CommandSender sender, KnownPlayerResolver.KnownPlayer target) {
CompletableFuture<StealthAdministrationService.ChangeResult> grant =
administration.grant(target.playerId(), target.name());
grant.whenComplete((result, failure) -> mainThread.accept(() -> {
if (failure != null) {
sender.sendMessage("Unable to persist the stealth grant; check the server log.");
return;
}
sender.sendMessage(result.changed()
? "Stealth unlock granted to " + displayName(target) + "."
: displayName(target) + " already has the stealth unlock.");
auditLog.accept("stealthadmin grant administrator=" + sender.getName()
+ " target=" + target.playerId() + " changed=" + result.changed());
}));
return true;
}
private boolean reset(
CommandSender sender,
String label,
String[] arguments,
KnownPlayerResolver.KnownPlayer target) {
if (arguments.length != 3 || !"confirm".equalsIgnoreCase(arguments[2])) {
sender.sendMessage("Confirm the complete reset with /" + label + " reset " + arguments[1] + " confirm");
return true;
}
administration.reset(target.playerId(), target.name(), target.onlinePlayer())
.whenComplete((ignored, failure) -> mainThread.accept(() -> {
if (failure != null) {
sender.sendMessage("Unable to persist the stealth reset; check the server log.");
return;
}
sender.sendMessage("Stealth reset complete for " + displayName(target) + ".");
auditLog.accept("stealthadmin reset administrator=" + sender.getName()
+ " target=" + target.playerId());
}));
return true;
}
private static void sendUsage(CommandSender sender, String label) {
sender.sendMessage("Usage: /" + label + " <status <player|uuid>|grant <player|uuid>|reset <player|uuid> confirm|list>");
}
private static String displayName(KnownPlayerResolver.KnownPlayer player) {
return player.name() == null ? player.playerId().toString() : player.name();
}
private static String yesNo(boolean value) {
return value ? "yes" : "no";
}
}
@@ -0,0 +1,92 @@
package games.dmg.spigotstealth;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
import java.util.function.Supplier;
import org.bukkit.entity.Player;
/** Durable administrative operations over player stealth state. */
public final class StealthAdministrationService {
private final StealthStateManager stateManager;
private final QualifyingInvisibilityService progression;
private final IdentityPresentation presentation;
private final Consumer<UUID> unlockNotifier;
private final Supplier<? extends Collection<? extends Player>> onlinePlayers;
public StealthAdministrationService(
StealthStateManager stateManager,
QualifyingInvisibilityService progression,
IdentityPresentation presentation,
Consumer<UUID> unlockNotifier,
Supplier<? extends Collection<? extends Player>> onlinePlayers) {
this.stateManager = Objects.requireNonNull(stateManager, "stateManager");
this.progression = Objects.requireNonNull(progression, "progression");
this.presentation = Objects.requireNonNull(presentation, "presentation");
this.unlockNotifier = Objects.requireNonNull(unlockNotifier, "unlockNotifier");
this.onlinePlayers = Objects.requireNonNull(onlinePlayers, "onlinePlayers");
}
public PlayerStatus status(UUID playerId) {
PlayerStealthState player = stateManager.snapshot().player(playerId);
return new PlayerStatus(
progression.currentAccumulatedMillis(playerId),
player.unlocked(),
player.preparedLogin(),
player.concealed(),
progression.isQualifying(playerId));
}
public CompletableFuture<ChangeResult> grant(UUID playerId, String playerName) {
AtomicBoolean changed = new AtomicBoolean();
CompletableFuture<Void> saved = stateManager.update(state -> {
PlayerStealthState player = state.player(playerId).withLastKnownName(playerName);
if (player.unlocked()) {
return state.withPlayer(player);
}
changed.set(true);
return state.withPlayer(player.withProgress(player.accumulatedMillis(), true));
});
return saved.thenApply(ignored -> {
if (changed.get()) {
unlockNotifier.accept(playerId);
}
return new ChangeResult(changed.get());
});
}
public CompletableFuture<Void> reset(UUID playerId, String playerName, Player onlinePlayer) {
CompletableFuture<Void> stopped = progression.stop(playerId);
CompletableFuture<Void> reset = stateManager.update(state -> {
PlayerStealthState player = state.player(playerId);
PlayerStealthState cleared = new PlayerStealthState(
playerId, playerName, 0L, false, false, false, null, player.unknownFields());
return state.withPlayer(cleared);
});
if (onlinePlayer != null) {
presentation.reveal(onlinePlayer);
}
return CompletableFuture.allOf(stopped, reset);
}
public List<String> concealedOnlineNames() {
return onlinePlayers.get().stream()
.filter(player -> stateManager.snapshot().player(player.getUniqueId()).concealed())
.map(Player::getName)
.sorted(String.CASE_INSENSITIVE_ORDER)
.toList();
}
public record PlayerStatus(
long accumulatedMillis,
boolean unlocked,
boolean preparedLogin,
boolean concealed,
boolean qualifyingNow) { }
public record ChangeResult(boolean changed) { }
}