feat(admin): manage player stealth state
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
|
||||
## 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.
|
||||
- **Implementation**: Began US-004 with test-first exact offline targeting, grant/reset transitions, concealed-player listing, permissions, confirmation, and audit logging.
|
||||
- **Completion**: Completed US-002 with single-use durable prepared logins, join-announcement suppression, ProtocolLib tab-only removal, scoreboard overhead-name suppression, visible physical entities, periodic observer refresh, respawn restoration, and disconnect or disable cleanup; verified the full Gradle build.
|
||||
- **Implementation**: Began US-002 with test-first prepared-login consumption, session-scoped concealment, join suppression, tab removal, and overhead-name presentation.
|
||||
- **Completion**: Completed US-003 with `/stealth progress`, live unsaved interval inclusion, configured target and remaining output, concise duration formatting, and unlocked usage guidance; verified the full Gradle build.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: User Story
|
||||
title: "US-004: Inspect and manage player stealth"
|
||||
description: Give administrators durable controls for online and offline progression, unlocks, and active concealment.
|
||||
status: backlog
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-004: Inspect and manage player stealth
|
||||
@@ -11,19 +11,23 @@ As a **server administrator**, I want to inspect and correct player stealth stat
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `/stealthadmin status <player|uuid>` reports accumulated time, unlock status, prepared-login state, and current concealment state.
|
||||
- [ ] Status inspection works for online players and known offline players selected by exact current or previously recorded name or UUID.
|
||||
- [ ] `/stealthadmin grant <player|uuid>` grants the unlock to an online or known offline player without altering accumulated time unnecessarily.
|
||||
- [ ] Granting an already-owned unlock is safe and clearly reports that no change was needed.
|
||||
- [ ] Granting the unlock sends the normal full-screen title and chat notification when the target is online.
|
||||
- [ ] `/stealthadmin reset <player|uuid> confirm` clears accumulated time, unlock ownership, prepared-login state, and current concealment for an online or known offline player.
|
||||
- [ ] Reset requires explicit confirmation and safely restores an online concealed player to ordinary identity presentation.
|
||||
- [ ] `/stealthadmin list` lists every currently online concealed player and clearly reports when there are none.
|
||||
- [ ] Concealed players remain absent from administrators' ordinary tab lists and retain hidden overhead name tags; the admin command is the supported discovery mechanism.
|
||||
- [ ] Commands clearly reject unknown, ambiguous, malformed, or otherwise invalid targets without creating unintended player records.
|
||||
- [ ] Administrative inspection and modification require an operator-default administrative permission.
|
||||
- [ ] State-changing operations persist before success is reported and are safe under retries.
|
||||
- [ ] Grant and reset actions record the administrator, target UUID, and action in the server log without blocking the server tick thread.
|
||||
- [x] `/stealthadmin status <player|uuid>` reports accumulated time, unlock status, prepared-login state, and current concealment state.
|
||||
- [x] Status inspection works for online players and known offline players selected by exact current or previously recorded name or UUID.
|
||||
- [x] `/stealthadmin grant <player|uuid>` grants the unlock to an online or known offline player without altering accumulated time unnecessarily.
|
||||
- [x] Granting an already-owned unlock is safe and clearly reports that no change was needed.
|
||||
- [x] Granting the unlock sends the normal full-screen title and chat notification when the target is online.
|
||||
- [x] `/stealthadmin reset <player|uuid> confirm` clears accumulated time, unlock ownership, prepared-login state, and current concealment for an online or known offline player.
|
||||
- [x] Reset requires explicit confirmation and safely restores an online concealed player to ordinary identity presentation.
|
||||
- [x] `/stealthadmin list` lists every currently online concealed player and clearly reports when there are none.
|
||||
- [x] Concealed players remain absent from administrators' ordinary tab lists and retain hidden overhead name tags; the admin command is the supported discovery mechanism.
|
||||
- [x] Commands clearly reject unknown, ambiguous, malformed, or otherwise invalid targets without creating unintended player records.
|
||||
- [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] Grant and reset actions record the administrator, target UUID, and action in the server log without blocking the server tick thread.
|
||||
|
||||
## 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.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -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) { }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class KnownPlayerResolverTest {
|
||||
@Test
|
||||
void resolvesKnownOfflinePlayerByExactNameOrUuid() {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
PlayerStealthState player = PlayerStealthState.empty(playerId).withLastKnownName("Alex");
|
||||
PersistentStealthState state = new PersistentStealthState(Map.of(playerId, player), Map.of());
|
||||
KnownPlayerResolver resolver = new KnownPlayerResolver(() -> state, List::of);
|
||||
assertEquals(playerId, resolver.resolve("alex").player().playerId());
|
||||
assertEquals(playerId, resolver.resolve(playerId.toString()).player().playerId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportsAmbiguousAndUnknownNamesWithoutCreatingRecords() {
|
||||
UUID first = UUID.randomUUID();
|
||||
UUID second = UUID.randomUUID();
|
||||
PersistentStealthState state = new PersistentStealthState(Map.of(
|
||||
first, PlayerStealthState.empty(first).withLastKnownName("Alex"),
|
||||
second, PlayerStealthState.empty(second).withLastKnownName("ALEX")), Map.of());
|
||||
KnownPlayerResolver resolver = new KnownPlayerResolver(() -> state, List::of);
|
||||
assertEquals(KnownPlayerResolver.Status.AMBIGUOUS, resolver.resolve("alex").status());
|
||||
assertEquals(KnownPlayerResolver.Status.UNKNOWN, resolver.resolve("nobody").status());
|
||||
assertEquals(2, state.players().size());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.contains;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class StealthAdminCommandTest {
|
||||
@Test
|
||||
void statusInspectsKnownOfflinePlayerAndReportsAllState() {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
try (Fixture fixture = fixture(playerId)) {
|
||||
CommandSender sender = sender(true);
|
||||
fixture.command.onCommand(sender, mock(Command.class), "stealthadmin", new String[] {"status", "Alex"});
|
||||
verify(sender).sendMessage(contains(playerId.toString()));
|
||||
verify(sender).sendMessage(contains("prepared=yes"));
|
||||
verify(sender).sendMessage(contains("concealed=no"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void grantPersistsThenReportsAndAuditsAction() {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
try (Fixture fixture = fixture(playerId)) {
|
||||
CommandSender sender = sender(true);
|
||||
fixture.command.onCommand(sender, mock(Command.class), "stealthadmin", new String[] {"grant", playerId.toString()});
|
||||
verify(sender, org.mockito.Mockito.timeout(1000)).sendMessage(contains("granted"));
|
||||
org.junit.jupiter.api.Assertions.assertTrue(fixture.manager.snapshot().player(playerId).unlocked());
|
||||
org.junit.jupiter.api.Assertions.assertTrue(fixture.audit.stream().anyMatch(message -> message.contains("grant") && message.contains(playerId.toString())));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void resetRequiresConfirmationAndPermission() {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
try (Fixture fixture = fixture(playerId)) {
|
||||
CommandSender denied = sender(false);
|
||||
fixture.command.onCommand(denied, mock(Command.class), "stealthadmin", new String[] {"reset", "Alex", "confirm"});
|
||||
verify(denied).sendMessage(contains("permission"));
|
||||
CommandSender allowed = sender(true);
|
||||
fixture.command.onCommand(allowed, mock(Command.class), "stealthadmin", new String[] {"reset", "Alex"});
|
||||
verify(allowed).sendMessage(contains("confirm"));
|
||||
verify(allowed, never()).sendMessage(contains("reset complete"));
|
||||
}
|
||||
}
|
||||
|
||||
private static CommandSender sender(boolean permission) {
|
||||
CommandSender sender = mock(CommandSender.class);
|
||||
when(sender.hasPermission("spigotstealth.admin")).thenReturn(permission);
|
||||
when(sender.getName()).thenReturn("Operator");
|
||||
return sender;
|
||||
}
|
||||
|
||||
private static Fixture fixture(UUID playerId) {
|
||||
StealthStateRepository repository = new StealthStateRepository() {
|
||||
@Override public PersistentStealthState load() { return new PersistentStealthState(Map.of(), Map.of()); }
|
||||
@Override public void save(PersistentStealthState state) { }
|
||||
};
|
||||
StealthStateManager manager = new StealthStateManager(repository, new PersistentStealthState(Map.of(), Map.of()));
|
||||
PlayerStealthState player = new PlayerStealthState(playerId, "Alex", 5000L, false, true, false, null, Map.of());
|
||||
manager.update(state -> state.withPlayer(player)).join();
|
||||
QualifyingInvisibilityService progression = new QualifyingInvisibilityService(
|
||||
manager, Duration.ofHours(8), System::nanoTime, ignored -> { });
|
||||
StealthAdministrationService administration = new StealthAdministrationService(
|
||||
manager, progression, mock(IdentityPresentation.class), 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);
|
||||
}
|
||||
|
||||
private record Fixture(
|
||||
StealthStateManager manager,
|
||||
StealthAdminCommand command,
|
||||
ArrayList<String> audit) implements AutoCloseable {
|
||||
@Override public void close() { manager.close(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
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 static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class StealthAdministrationServiceTest {
|
||||
@Test
|
||||
void grantsOfflineUnlockAndNotifiesExactlyOnce() {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
ArrayList<UUID> notifications = new ArrayList<>();
|
||||
try (StealthStateManager manager = manager()) {
|
||||
StealthAdministrationService administration = service(manager, List::of, notifications::add);
|
||||
assertTrue(administration.grant(playerId, "Alex").join().changed());
|
||||
assertFalse(administration.grant(playerId, "Alex").join().changed());
|
||||
assertTrue(manager.snapshot().player(playerId).unlocked());
|
||||
assertEquals(List.of(playerId), notifications);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void resetClearsProgressUnlockPreparationConcealmentAndActiveTracking() {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
Player player = mock(Player.class);
|
||||
org.mockito.Mockito.when(player.getUniqueId()).thenReturn(playerId);
|
||||
try (StealthStateManager manager = manager()) {
|
||||
PlayerStealthState state = new PlayerStealthState(
|
||||
playerId, "Alex", 1234L, true, true, true, Instant.EPOCH, Map.of("future", "keep"));
|
||||
manager.update(all -> all.withPlayer(state)).join();
|
||||
QualifyingInvisibilityService progression = new QualifyingInvisibilityService(
|
||||
manager, Duration.ofHours(8), System::nanoTime, ignored -> { });
|
||||
progression.begin(playerId, "Alex", Instant.EPOCH).join();
|
||||
IdentityPresentation presentation = mock(IdentityPresentation.class);
|
||||
StealthAdministrationService administration = new StealthAdministrationService(
|
||||
manager, progression, presentation, ignored -> { }, () -> List.of(player));
|
||||
|
||||
administration.reset(playerId, "Alex", player).join();
|
||||
|
||||
PlayerStealthState reset = manager.snapshot().player(playerId);
|
||||
assertEquals(0L, reset.accumulatedMillis());
|
||||
assertFalse(reset.unlocked());
|
||||
assertFalse(reset.preparedLogin());
|
||||
assertFalse(reset.concealed());
|
||||
assertFalse(progression.isQualifying(playerId));
|
||||
assertEquals("keep", reset.unknownFields().get("future"));
|
||||
verify(presentation).reveal(player);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void listsOnlyCurrentlyOnlineConcealedPlayers() {
|
||||
UUID concealedId = UUID.randomUUID();
|
||||
UUID ordinaryId = UUID.randomUUID();
|
||||
Player concealed = player(concealedId, "Hidden");
|
||||
Player ordinary = player(ordinaryId, "Visible");
|
||||
try (StealthStateManager manager = manager()) {
|
||||
manager.update(state -> state
|
||||
.withPlayer(PlayerStealthState.empty(concealedId).withLastKnownName("Hidden").withSession(false, true))
|
||||
.withPlayer(PlayerStealthState.empty(ordinaryId).withLastKnownName("Visible"))).join();
|
||||
StealthAdministrationService administration = service(
|
||||
manager, () -> List.of(concealed, ordinary), ignored -> { });
|
||||
assertEquals(List.of("Hidden"), administration.concealedOnlineNames());
|
||||
}
|
||||
}
|
||||
|
||||
private static StealthAdministrationService service(
|
||||
StealthStateManager manager,
|
||||
java.util.function.Supplier<? extends java.util.Collection<? extends Player>> online,
|
||||
java.util.function.Consumer<UUID> notifier) {
|
||||
QualifyingInvisibilityService progression = new QualifyingInvisibilityService(
|
||||
manager, Duration.ofHours(8), System::nanoTime, ignored -> { });
|
||||
return new StealthAdministrationService(
|
||||
manager, progression, mock(IdentityPresentation.class), notifier, online);
|
||||
}
|
||||
|
||||
private static Player player(UUID playerId, String name) {
|
||||
Player player = mock(Player.class);
|
||||
org.mockito.Mockito.when(player.getUniqueId()).thenReturn(playerId);
|
||||
org.mockito.Mockito.when(player.getName()).thenReturn(name);
|
||||
return player;
|
||||
}
|
||||
|
||||
private static StealthStateManager manager() {
|
||||
StealthStateRepository repository = new StealthStateRepository() {
|
||||
@Override public PersistentStealthState load() { return new PersistentStealthState(Map.of(), Map.of()); }
|
||||
@Override public void save(PersistentStealthState state) { }
|
||||
};
|
||||
return new StealthStateManager(repository, new PersistentStealthState(Map.of(), Map.of()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user