Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba6573943b | ||
|
|
66ba2aa3b1 | ||
|
|
b0da1508b6 |
@@ -1,5 +1,14 @@
|
|||||||
# Spigot Stealth Design Log
|
# Spigot Stealth Design Log
|
||||||
|
|
||||||
|
## 2026-09-05
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
- **Completion**: Extended US-003 and US-004 with contextual, prefix-filtered command completion that suppresses generic player suggestions, plus an administrative list of all known online and offline unlocked players; verified the complete Gradle build and OKF bundle.
|
||||||
|
|
||||||
## 2026-08-14
|
## 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.
|
- **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.
|
||||||
|
|||||||
@@ -15,7 +15,14 @@ As an **unlocked player**, I want to disconnect while invisibility from a potion
|
|||||||
- [x] A player who has not unlocked stealth cannot prepare a concealed login.
|
- [x] A player who has not unlocked stealth cannot prepare a concealed login.
|
||||||
- [x] An ordinary disconnect without an active qualifying effect clears any preparation for the next login.
|
- [x] An ordinary disconnect without an active qualifying effect clears any preparation for the next login.
|
||||||
- [x] On a prepared login, no public join announcement is shown.
|
- [x] On a prepared login, no public join announcement is shown.
|
||||||
|
- [x] When a concealed player disconnects, no public quit or disconnect announcement is shown.
|
||||||
|
- [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] 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] Throughout the concealed session, no overhead name tag identifies the player to any other player, including administrators.
|
- [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'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.
|
- [x] The concealed player receives a private message explaining that stealth is active for the session.
|
||||||
@@ -26,7 +33,7 @@ As an **unlocked player**, I want to disconnect while invisibility from a potion
|
|||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
|
|
||||||
Automated tests verify unlocked and locked disconnect transitions, ordinary-disconnect clearing, one-login consumption, announcement suppression, 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, public server-list count and sample filtering, nonnegative counts, unchanged maximum capacity, 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
|
## Related
|
||||||
|
|
||||||
|
|||||||
@@ -18,10 +18,12 @@ As a **player**, I want to check my stealth progress so that I know how close I
|
|||||||
- [x] Durations are presented in a concise, human-readable form.
|
- [x] Durations are presented in a concise, human-readable form.
|
||||||
- [x] Repeated command use does not change progression or concealment state.
|
- [x] Repeated command use does not change progression or concealment state.
|
||||||
- [x] The command has clear usage metadata and an appropriate player permission.
|
- [x] The command has clear usage metadata and an appropriate player permission.
|
||||||
|
- [x] `/stealth` tab-completes `progress`, filtered by the entered prefix.
|
||||||
|
- [x] Unsupported argument positions return no suggestions instead of Bukkit's generic player list.
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
|
|
||||||
Automated tests verify concise duration formatting, live in-flight progress, configured target and remaining output, command non-mutation, and unlocked usage guidance. Command metadata and the complete `./gradlew clean check jar` lifecycle pass.
|
Automated tests verify concise duration formatting, live in-flight progress, configured target and remaining output, command non-mutation, unlocked usage guidance, prefix-filtered completion, and suppression of irrelevant suggestions. Command metadata and the complete `./gradlew clean check jar` lifecycle pass.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
|
|||||||
@@ -24,10 +24,16 @@ As a **server administrator**, I want to inspect and correct player stealth stat
|
|||||||
- [x] Administrative inspection and modification require an operator-default administrative permission.
|
- [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] 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.
|
- [x] Grant and reset actions record the administrator, target UUID, and action in the server log without blocking the server tick thread.
|
||||||
|
- [x] `/stealthadmin` tab-completes `status`, `grant`, `reset`, and `list`, filtered by the entered prefix.
|
||||||
|
- [x] Target positions for `status`, `grant`, and `reset` suggest known player names, and reset's final argument suggests `confirm`.
|
||||||
|
- [x] Administrative completions require the administrative permission, and irrelevant positions return no suggestions instead of Bukkit's generic player list.
|
||||||
|
- [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.
|
||||||
|
|
||||||
## Validation
|
## 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.
|
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.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,17 @@ public final class KnownPlayerResolver {
|
|||||||
this.onlinePlayers = Objects.requireNonNull(onlinePlayers, "onlinePlayers");
|
this.onlinePlayers = Objects.requireNonNull(onlinePlayers, "onlinePlayers");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public java.util.List<String> completeNames(String prefix) {
|
||||||
|
String normalized = prefix == null ? "" : prefix.toLowerCase(Locale.ROOT);
|
||||||
|
return knownPlayers().values().stream()
|
||||||
|
.map(KnownPlayer::name)
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.distinct()
|
||||||
|
.filter(name -> name.toLowerCase(Locale.ROOT).startsWith(normalized))
|
||||||
|
.sorted(String.CASE_INSENSITIVE_ORDER)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
public Resolution resolve(String query) {
|
public Resolution resolve(String query) {
|
||||||
if (query == null || query.isBlank()) {
|
if (query == null || query.isBlank()) {
|
||||||
return new Resolution(Status.UNKNOWN, null);
|
return new Resolution(Status.UNKNOWN, null);
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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.WrappedGameProfile;
|
||||||
|
import com.comphenix.protocol.wrappers.WrappedServerPing;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
import org.bukkit.plugin.Plugin;
|
||||||
|
|
||||||
|
/** Rewrites outgoing server-list responses without changing actual online-player state. */
|
||||||
|
public final class ProtocolLibServerListPingListener extends PacketAdapter {
|
||||||
|
private final Supplier<Set<UUID>> concealedPlayerIds;
|
||||||
|
|
||||||
|
public ProtocolLibServerListPingListener(Plugin plugin, Supplier<Set<UUID>> concealedPlayerIds) {
|
||||||
|
super(plugin, PacketType.Status.Server.SERVER_INFO);
|
||||||
|
this.concealedPlayerIds = Objects.requireNonNull(concealedPlayerIds, "concealedPlayerIds");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onPacketSending(PacketEvent event) {
|
||||||
|
WrappedServerPing visiblePing = event.getPacket().getServerPings().read(0).deepClone();
|
||||||
|
boolean sampleVisible = visiblePing.isPlayersVisible();
|
||||||
|
List<WrappedGameProfile> sample = sampleVisible ? visiblePing.getPlayers() : List.of();
|
||||||
|
Set<UUID> concealed = concealedPlayerIds.get();
|
||||||
|
ServerListPingVisibility.Snapshot visible = ServerListPingVisibility.adjust(
|
||||||
|
visiblePing.getPlayersOnline(),
|
||||||
|
visiblePing.getPlayersMaximum(),
|
||||||
|
sample.stream().map(WrappedGameProfile::getUUID).toList(),
|
||||||
|
concealed);
|
||||||
|
Set<UUID> visibleSampleIds = Set.copyOf(visible.samplePlayerIds());
|
||||||
|
visiblePing.setPlayersOnline(visible.playersOnline());
|
||||||
|
if (sampleVisible) {
|
||||||
|
visiblePing.setPlayers(sample.stream()
|
||||||
|
.filter(profile -> visibleSampleIds.contains(profile.getUUID()))
|
||||||
|
.toList());
|
||||||
|
}
|
||||||
|
event.getPacket().getServerPings().write(0, visiblePing);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
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(
|
||||||
|
Math.max(0, playersOnline - concealedPlayerIds.size()),
|
||||||
|
playersMaximum,
|
||||||
|
visibleSample);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record Snapshot(int playersOnline, int playersMaximum, List<UUID> samplePlayerIds) {
|
||||||
|
public Snapshot {
|
||||||
|
samplePlayerIds = List.copyOf(samplePlayerIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package games.dmg.spigotstealth;
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
import com.comphenix.protocol.ProtocolLibrary;
|
import com.comphenix.protocol.ProtocolLibrary;
|
||||||
|
import com.comphenix.protocol.ProtocolManager;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
@@ -15,6 +16,7 @@ public final class SpigotStealthPlugin extends JavaPlugin {
|
|||||||
private QualifyingInvisibilityService progression;
|
private QualifyingInvisibilityService progression;
|
||||||
private StealthSessionService sessions;
|
private StealthSessionService sessions;
|
||||||
private IdentityPresentation identityPresentation;
|
private IdentityPresentation identityPresentation;
|
||||||
|
private ProtocolManager protocolManager;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onEnable() {
|
public void onEnable() {
|
||||||
@@ -41,6 +43,9 @@ public final class SpigotStealthPlugin extends JavaPlugin {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onDisable() {
|
public void onDisable() {
|
||||||
|
if (protocolManager != null) {
|
||||||
|
protocolManager.removePacketListeners(this);
|
||||||
|
}
|
||||||
if (identityPresentation != null && sessions != null) {
|
if (identityPresentation != null && sessions != null) {
|
||||||
for (org.bukkit.entity.Player player : getServer().getOnlinePlayers()) {
|
for (org.bukkit.entity.Player player : getServer().getOnlinePlayers()) {
|
||||||
if (sessions.isConcealed(player.getUniqueId())) {
|
if (sessions.isConcealed(player.getUniqueId())) {
|
||||||
@@ -71,26 +76,35 @@ public final class SpigotStealthPlugin extends JavaPlugin {
|
|||||||
progression = new QualifyingInvisibilityService(
|
progression = new QualifyingInvisibilityService(
|
||||||
manager, settings.unlockThreshold(), System::nanoTime, notifier);
|
manager, settings.unlockThreshold(), System::nanoTime, notifier);
|
||||||
sessions = new StealthSessionService(manager, progression);
|
sessions = new StealthSessionService(manager, progression);
|
||||||
|
protocolManager = ProtocolLibrary.getProtocolManager();
|
||||||
identityPresentation = new BukkitIdentityPresentation(
|
identityPresentation = new BukkitIdentityPresentation(
|
||||||
getServer()::getOnlinePlayers,
|
getServer()::getOnlinePlayers,
|
||||||
Objects.requireNonNull(getServer().getScoreboardManager(), "scoreboard manager").getMainScoreboard(),
|
Objects.requireNonNull(getServer().getScoreboardManager(), "scoreboard manager").getMainScoreboard(),
|
||||||
new ProtocolLibTabListController(ProtocolLibrary.getProtocolManager()));
|
new ProtocolLibTabListController(protocolManager));
|
||||||
|
protocolManager.addPacketListener(
|
||||||
|
new ProtocolLibServerListPingListener(this, sessions::concealedPlayerIds));
|
||||||
getServer().getPluginManager().registerEvents(
|
getServer().getPluginManager().registerEvents(
|
||||||
new InvisibilityEffectListener(progression, Clock.systemUTC()), this);
|
new InvisibilityEffectListener(progression, Clock.systemUTC()), this);
|
||||||
getServer().getPluginManager().registerEvents(
|
getServer().getPluginManager().registerEvents(
|
||||||
new StealthSessionListener(sessions, identityPresentation, settings.concealedMessage()), this);
|
new StealthSessionListener(sessions, identityPresentation, settings.concealedMessage()), this);
|
||||||
Objects.requireNonNull(getCommand("stealth"), "stealth command")
|
org.bukkit.command.PluginCommand stealthPluginCommand =
|
||||||
.setExecutor(new StealthCommand(manager, progression, settings));
|
Objects.requireNonNull(getCommand("stealth"), "stealth command");
|
||||||
|
StealthCommand stealthCommand = new StealthCommand(manager, progression, settings);
|
||||||
|
stealthPluginCommand.setExecutor(stealthCommand);
|
||||||
|
stealthPluginCommand.setTabCompleter(stealthCommand);
|
||||||
StealthAdministrationService administration = new StealthAdministrationService(
|
StealthAdministrationService administration = new StealthAdministrationService(
|
||||||
manager, progression, identityPresentation, notifier, getServer()::getOnlinePlayers);
|
manager, progression, identityPresentation, notifier, getServer()::getOnlinePlayers);
|
||||||
KnownPlayerResolver resolver = new KnownPlayerResolver(manager::snapshot, getServer()::getOnlinePlayers);
|
KnownPlayerResolver resolver = new KnownPlayerResolver(manager::snapshot, getServer()::getOnlinePlayers);
|
||||||
Objects.requireNonNull(getCommand("stealthadmin"), "stealthadmin command")
|
org.bukkit.command.PluginCommand stealthAdminPluginCommand =
|
||||||
.setExecutor(new StealthAdminCommand(
|
Objects.requireNonNull(getCommand("stealthadmin"), "stealthadmin command");
|
||||||
administration,
|
StealthAdminCommand stealthAdminCommand = new StealthAdminCommand(
|
||||||
resolver,
|
administration,
|
||||||
mainThread,
|
resolver,
|
||||||
getLogger()::info,
|
mainThread,
|
||||||
settings.unlockThreshold()));
|
getLogger()::info,
|
||||||
|
settings.unlockThreshold());
|
||||||
|
stealthAdminPluginCommand.setExecutor(stealthAdminCommand);
|
||||||
|
stealthAdminPluginCommand.setTabCompleter(stealthAdminCommand);
|
||||||
getServer().getScheduler().runTaskTimer(this, ignored -> {
|
getServer().getScheduler().runTaskTimer(this, ignored -> {
|
||||||
for (java.util.UUID playerId : progression.activePlayerIds()) {
|
for (java.util.UUID playerId : progression.activePlayerIds()) {
|
||||||
logSaveFailure(progression.checkpoint(playerId));
|
logSaveFailure(progression.checkpoint(playerId));
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ import java.util.function.Consumer;
|
|||||||
import org.bukkit.command.Command;
|
import org.bukkit.command.Command;
|
||||||
import org.bukkit.command.CommandExecutor;
|
import org.bukkit.command.CommandExecutor;
|
||||||
import org.bukkit.command.CommandSender;
|
import org.bukkit.command.CommandSender;
|
||||||
|
import org.bukkit.command.TabCompleter;
|
||||||
|
|
||||||
/** Permission-gated administrative command for online and known offline players. */
|
/** Permission-gated administrative command for online and known offline players. */
|
||||||
public final class StealthAdminCommand implements CommandExecutor {
|
public final class StealthAdminCommand implements CommandExecutor, TabCompleter {
|
||||||
private static final String PERMISSION = "spigotstealth.admin";
|
private static final String PERMISSION = "spigotstealth.admin";
|
||||||
|
private static final List<String> OPERATIONS = List.of("status", "grant", "reset", "list");
|
||||||
private final StealthAdministrationService administration;
|
private final StealthAdministrationService administration;
|
||||||
private final KnownPlayerResolver resolver;
|
private final KnownPlayerResolver resolver;
|
||||||
private final Consumer<Runnable> mainThread;
|
private final Consumer<Runnable> mainThread;
|
||||||
@@ -32,17 +34,57 @@ public final class StealthAdminCommand implements CommandExecutor {
|
|||||||
this.unlockThreshold = Objects.requireNonNull(unlockThreshold, "unlockThreshold");
|
this.unlockThreshold = Objects.requireNonNull(unlockThreshold, "unlockThreshold");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> onTabComplete(
|
||||||
|
CommandSender sender, Command command, String label, String[] arguments) {
|
||||||
|
if (!sender.hasPermission(PERMISSION)) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
if (arguments.length == 1) {
|
||||||
|
return matching(OPERATIONS, arguments[0]);
|
||||||
|
}
|
||||||
|
String operation = arguments[0].toLowerCase(Locale.ROOT);
|
||||||
|
if (arguments.length == 2) {
|
||||||
|
if (List.of("status", "grant", "reset").contains(operation)) {
|
||||||
|
return resolver.completeNames(arguments[1]);
|
||||||
|
}
|
||||||
|
if ("list".equals(operation)) {
|
||||||
|
return matching(List.of("unlocked"), arguments[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (arguments.length == 3 && "reset".equals(operation)) {
|
||||||
|
return matching(List.of("confirm"), arguments[2]);
|
||||||
|
}
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> matching(List<String> candidates, String prefix) {
|
||||||
|
String normalized = prefix.toLowerCase(Locale.ROOT);
|
||||||
|
return candidates.stream().filter(candidate -> candidate.startsWith(normalized)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
|
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
|
||||||
if (!sender.hasPermission(PERMISSION)) {
|
if (!sender.hasPermission(PERMISSION)) {
|
||||||
sender.sendMessage("You do not have permission to administer Spigot Stealth.");
|
sender.sendMessage("You do not have permission to administer Spigot Stealth.");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (arguments.length == 1 && "list".equalsIgnoreCase(arguments[0])) {
|
if (arguments.length > 0 && "list".equalsIgnoreCase(arguments[0])) {
|
||||||
List<String> names = administration.concealedOnlineNames();
|
if (arguments.length == 1) {
|
||||||
sender.sendMessage(names.isEmpty()
|
List<String> names = administration.concealedOnlineNames();
|
||||||
? "No players are currently concealed."
|
sender.sendMessage(names.isEmpty()
|
||||||
: "Currently concealed: " + String.join(", ", names));
|
? "No players are currently concealed."
|
||||||
|
: "Currently concealed: " + String.join(", ", names));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (arguments.length == 2 && "unlocked".equalsIgnoreCase(arguments[1])) {
|
||||||
|
List<String> names = administration.unlockedPlayerNames();
|
||||||
|
sender.sendMessage(names.isEmpty()
|
||||||
|
? "No players have unlocked stealth."
|
||||||
|
: "Stealth unlocked: " + String.join(", ", names));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
sendUsage(sender, label);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (arguments.length < 2) {
|
if (arguments.length < 2) {
|
||||||
@@ -121,7 +163,7 @@ public final class StealthAdminCommand implements CommandExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static void sendUsage(CommandSender sender, String label) {
|
private static void sendUsage(CommandSender sender, String label) {
|
||||||
sender.sendMessage("Usage: /" + label + " <status <player|uuid>|grant <player|uuid>|reset <player|uuid> confirm|list>");
|
sender.sendMessage("Usage: /" + label + " <status <player|uuid>|grant <player|uuid>|reset <player|uuid> confirm|list [unlocked]>");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String displayName(KnownPlayerResolver.KnownPlayer player) {
|
private static String displayName(KnownPlayerResolver.KnownPlayer player) {
|
||||||
|
|||||||
@@ -73,6 +73,17 @@ public final class StealthAdministrationService {
|
|||||||
return CompletableFuture.allOf(stopped, reset);
|
return CompletableFuture.allOf(stopped, reset);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<String> unlockedPlayerNames() {
|
||||||
|
return stateManager.snapshot().players().entrySet().stream()
|
||||||
|
.filter(entry -> entry.getValue().unlocked())
|
||||||
|
.map(entry -> {
|
||||||
|
String name = entry.getValue().lastKnownName();
|
||||||
|
return name == null || name.isBlank() ? entry.getKey().toString() : name;
|
||||||
|
})
|
||||||
|
.sorted(String.CASE_INSENSITIVE_ORDER)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
public List<String> concealedOnlineNames() {
|
public List<String> concealedOnlineNames() {
|
||||||
return onlinePlayers.get().stream()
|
return onlinePlayers.get().stream()
|
||||||
.filter(player -> stateManager.snapshot().player(player.getUniqueId()).concealed())
|
.filter(player -> stateManager.snapshot().player(player.getUniqueId()).concealed())
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
package games.dmg.spigotstealth;
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import org.bukkit.command.Command;
|
import org.bukkit.command.Command;
|
||||||
import org.bukkit.command.CommandExecutor;
|
import org.bukkit.command.CommandExecutor;
|
||||||
import org.bukkit.command.CommandSender;
|
import org.bukkit.command.CommandSender;
|
||||||
|
import org.bukkit.command.TabCompleter;
|
||||||
import org.bukkit.entity.Player;
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
/** Player-facing progress command. */
|
/** Player-facing progress command. */
|
||||||
public final class StealthCommand implements CommandExecutor {
|
public final class StealthCommand implements CommandExecutor, TabCompleter {
|
||||||
private final StealthStateManager stateManager;
|
private final StealthStateManager stateManager;
|
||||||
private final QualifyingInvisibilityService progression;
|
private final QualifyingInvisibilityService progression;
|
||||||
private final StealthSettings settings;
|
private final StealthSettings settings;
|
||||||
@@ -22,6 +24,15 @@ public final class StealthCommand implements CommandExecutor {
|
|||||||
this.settings = Objects.requireNonNull(settings, "settings");
|
this.settings = Objects.requireNonNull(settings, "settings");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> onTabComplete(
|
||||||
|
CommandSender sender, Command command, String label, String[] arguments) {
|
||||||
|
if (arguments.length == 1 && "progress".startsWith(arguments[0].toLowerCase(Locale.ROOT))) {
|
||||||
|
return List.of("progress");
|
||||||
|
}
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
|
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
|
||||||
if (!(sender instanceof Player player)) {
|
if (!(sender instanceof Player player)) {
|
||||||
|
|||||||
@@ -38,10 +38,14 @@ public final class StealthSessionListener implements Listener {
|
|||||||
presentation.refreshForObserver(player);
|
presentation.refreshForObserver(player);
|
||||||
}
|
}
|
||||||
|
|
||||||
@EventHandler(priority = EventPriority.MONITOR)
|
@EventHandler(priority = EventPriority.HIGHEST)
|
||||||
public void onQuit(PlayerQuitEvent event) {
|
public void onQuit(PlayerQuitEvent event) {
|
||||||
presentation.reveal(event.getPlayer());
|
Player player = event.getPlayer();
|
||||||
sessions.disconnect(event.getPlayer().getUniqueId());
|
if (sessions.isConcealed(player.getUniqueId())) {
|
||||||
|
event.setQuitMessage(null);
|
||||||
|
}
|
||||||
|
presentation.reveal(player);
|
||||||
|
sessions.disconnect(player.getUniqueId());
|
||||||
}
|
}
|
||||||
|
|
||||||
@EventHandler(priority = EventPriority.MONITOR)
|
@EventHandler(priority = EventPriority.MONITOR)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package games.dmg.spigotstealth;
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
@@ -9,6 +11,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
|||||||
public final class StealthSessionService {
|
public final class StealthSessionService {
|
||||||
private final StealthStateManager stateManager;
|
private final StealthStateManager stateManager;
|
||||||
private final QualifyingInvisibilityService progression;
|
private final QualifyingInvisibilityService progression;
|
||||||
|
private final Set<UUID> concealedOnlinePlayerIds = ConcurrentHashMap.newKeySet();
|
||||||
|
|
||||||
public StealthSessionService(
|
public StealthSessionService(
|
||||||
StealthStateManager stateManager,
|
StealthStateManager stateManager,
|
||||||
@@ -18,6 +21,7 @@ public final class StealthSessionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public CompletableFuture<Void> disconnect(UUID playerId) {
|
public CompletableFuture<Void> disconnect(UUID playerId) {
|
||||||
|
concealedOnlinePlayerIds.remove(playerId);
|
||||||
boolean qualifyingAtDisconnect = progression.isQualifying(playerId);
|
boolean qualifyingAtDisconnect = progression.isQualifying(playerId);
|
||||||
return progression.stop(playerId).thenCompose(ignored -> stateManager.update(state -> {
|
return progression.stop(playerId).thenCompose(ignored -> stateManager.update(state -> {
|
||||||
PlayerStealthState player = state.player(playerId);
|
PlayerStealthState player = state.player(playerId);
|
||||||
@@ -34,10 +38,16 @@ public final class StealthSessionService {
|
|||||||
concealed.set(conceal);
|
concealed.set(conceal);
|
||||||
return state.withPlayer(player.withSession(false, conceal).withQualifyingSince(null));
|
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);
|
return new LoginTransition(concealed.get(), saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
public CompletableFuture<Void> endConcealment(UUID playerId) {
|
public CompletableFuture<Void> endConcealment(UUID playerId) {
|
||||||
|
concealedOnlinePlayerIds.remove(playerId);
|
||||||
return stateManager.update(state -> {
|
return stateManager.update(state -> {
|
||||||
PlayerStealthState player = state.player(playerId);
|
PlayerStealthState player = state.player(playerId);
|
||||||
return state.withPlayer(player.withSession(player.preparedLogin(), false));
|
return state.withPlayer(player.withSession(player.preparedLogin(), false));
|
||||||
@@ -48,5 +58,9 @@ public final class StealthSessionService {
|
|||||||
return stateManager.snapshot().player(playerId).concealed();
|
return stateManager.snapshot().player(playerId).concealed();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Set<UUID> concealedPlayerIds() {
|
||||||
|
return Set.copyOf(concealedOnlinePlayerIds);
|
||||||
|
}
|
||||||
|
|
||||||
public record LoginTransition(boolean concealed, CompletableFuture<Void> saved) { }
|
public record LoginTransition(boolean concealed, CompletableFuture<Void> saved) { }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ commands:
|
|||||||
permission: spigotstealth.use
|
permission: spigotstealth.use
|
||||||
stealthadmin:
|
stealthadmin:
|
||||||
description: Inspect and administer Spigot Stealth.
|
description: Inspect and administer Spigot Stealth.
|
||||||
usage: /stealthadmin <status|grant|reset|list>
|
usage: /stealthadmin <status <player|uuid>|grant <player|uuid>|reset <player|uuid> confirm|list [unlocked]>
|
||||||
permission: spigotstealth.admin
|
permission: spigotstealth.admin
|
||||||
permissions:
|
permissions:
|
||||||
spigotstealth.use:
|
spigotstealth.use:
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package games.dmg.spigotstealth;
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.mockito.ArgumentMatchers.contains;
|
import static org.mockito.ArgumentMatchers.contains;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
@@ -16,6 +17,51 @@ import org.bukkit.command.CommandSender;
|
|||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
class StealthAdminCommandTest {
|
class StealthAdminCommandTest {
|
||||||
|
@Test
|
||||||
|
void completesAdministrativeOperationsByPrefix() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
try (Fixture fixture = fixture(playerId)) {
|
||||||
|
assertEquals(
|
||||||
|
List.of("grant"),
|
||||||
|
fixture.command.onTabComplete(
|
||||||
|
sender(true), mock(Command.class), "stealthadmin", new String[] {"gr"}));
|
||||||
|
assertEquals(
|
||||||
|
List.of("status", "grant", "reset", "list"),
|
||||||
|
fixture.command.onTabComplete(
|
||||||
|
sender(true), mock(Command.class), "stealthadmin", new String[] {""}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void completesContextualArgumentsAndSuppressesIrrelevantSuggestions() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
try (Fixture fixture = fixture(playerId)) {
|
||||||
|
Command command = mock(Command.class);
|
||||||
|
CommandSender administrator = sender(true);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
List.of("Alex"),
|
||||||
|
fixture.command.onTabComplete(
|
||||||
|
administrator, command, "stealthadmin", new String[] {"status", "al"}));
|
||||||
|
assertEquals(
|
||||||
|
List.of("confirm"),
|
||||||
|
fixture.command.onTabComplete(
|
||||||
|
administrator, command, "stealthadmin", new String[] {"reset", "Alex", "co"}));
|
||||||
|
assertEquals(
|
||||||
|
List.of("unlocked"),
|
||||||
|
fixture.command.onTabComplete(
|
||||||
|
administrator, command, "stealthadmin", new String[] {"list", "un"}));
|
||||||
|
assertEquals(
|
||||||
|
List.of(),
|
||||||
|
fixture.command.onTabComplete(
|
||||||
|
administrator, command, "stealthadmin", new String[] {"status", "Alex", ""}));
|
||||||
|
assertEquals(
|
||||||
|
List.of(),
|
||||||
|
fixture.command.onTabComplete(
|
||||||
|
sender(false), command, "stealthadmin", new String[] {""}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void statusInspectsKnownOfflinePlayerAndReportsAllState() {
|
void statusInspectsKnownOfflinePlayerAndReportsAllState() {
|
||||||
UUID playerId = UUID.randomUUID();
|
UUID playerId = UUID.randomUUID();
|
||||||
@@ -28,6 +74,25 @@ class StealthAdminCommandTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listsUnlockedPlayersAndClearlyReportsWhenThereAreNone() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
try (Fixture fixture = fixture(playerId)) {
|
||||||
|
Command command = mock(Command.class);
|
||||||
|
CommandSender emptySender = sender(true);
|
||||||
|
fixture.command.onCommand(
|
||||||
|
emptySender, command, "stealthadmin", new String[] {"list", "unlocked"});
|
||||||
|
verify(emptySender).sendMessage("No players have unlocked stealth.");
|
||||||
|
|
||||||
|
fixture.manager.update(state -> state.withPlayer(
|
||||||
|
state.player(playerId).withProgress(5000L, true))).join();
|
||||||
|
CommandSender populatedSender = sender(true);
|
||||||
|
fixture.command.onCommand(
|
||||||
|
populatedSender, command, "stealthadmin", new String[] {"list", "unlocked"});
|
||||||
|
verify(populatedSender).sendMessage("Stealth unlocked: Alex");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void grantPersistsThenReportsAndAuditsAction() {
|
void grantPersistsThenReportsAndAuditsAction() {
|
||||||
UUID playerId = UUID.randomUUID();
|
UUID playerId = UUID.randomUUID();
|
||||||
|
|||||||
@@ -58,6 +58,29 @@ class StealthAdministrationServiceTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listsAllUnlockedPlayersByNameOrUuidInCaseInsensitiveOrder() {
|
||||||
|
UUID namelessId = UUID.randomUUID();
|
||||||
|
UUID lockedId = UUID.randomUUID();
|
||||||
|
UUID zoeId = UUID.randomUUID();
|
||||||
|
UUID alexId = UUID.randomUUID();
|
||||||
|
try (StealthStateManager manager = manager()) {
|
||||||
|
manager.update(state -> state
|
||||||
|
.withPlayer(PlayerStealthState.empty(namelessId).withProgress(0L, true))
|
||||||
|
.withPlayer(PlayerStealthState.empty(lockedId).withLastKnownName("Locked"))
|
||||||
|
.withPlayer(PlayerStealthState.empty(zoeId).withLastKnownName("zoe").withProgress(1L, true))
|
||||||
|
.withPlayer(PlayerStealthState.empty(alexId).withLastKnownName("Alex").withProgress(2L, true)))
|
||||||
|
.join();
|
||||||
|
StealthAdministrationService administration = service(manager, List::of, ignored -> { });
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
List.of("Alex", namelessId.toString(), "zoe").stream()
|
||||||
|
.sorted(String.CASE_INSENSITIVE_ORDER)
|
||||||
|
.toList(),
|
||||||
|
administration.unlockedPlayerNames());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void listsOnlyCurrentlyOnlineConcealedPlayers() {
|
void listsOnlyCurrentlyOnlineConcealedPlayers() {
|
||||||
UUID concealedId = UUID.randomUUID();
|
UUID concealedId = UUID.randomUUID();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import static org.mockito.Mockito.when;
|
|||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
@@ -16,6 +17,24 @@ import org.bukkit.entity.Player;
|
|||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
class StealthCommandTest {
|
class StealthCommandTest {
|
||||||
|
@Test
|
||||||
|
void completesProgressByPrefixWithoutSuggestingPlayersElsewhere() {
|
||||||
|
try (StealthStateManager manager = manager()) {
|
||||||
|
QualifyingInvisibilityService progression = new QualifyingInvisibilityService(
|
||||||
|
manager, Duration.ofHours(8), System::nanoTime, ignored -> { });
|
||||||
|
StealthCommand command = new StealthCommand(manager, progression, StealthSettings.from(Map.of()));
|
||||||
|
Command bukkitCommand = mock(Command.class);
|
||||||
|
Player player = player(UUID.randomUUID());
|
||||||
|
|
||||||
|
org.junit.jupiter.api.Assertions.assertEquals(
|
||||||
|
List.of("progress"),
|
||||||
|
command.onTabComplete(player, bukkitCommand, "stealth", new String[] {"pr"}));
|
||||||
|
org.junit.jupiter.api.Assertions.assertEquals(
|
||||||
|
List.of(),
|
||||||
|
command.onTabComplete(player, bukkitCommand, "stealth", new String[] {"progress", ""}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void reportsLiveProgressTargetAndRemainingTimeWithoutMutatingState() {
|
void reportsLiveProgressTargetAndRemainingTimeWithoutMutatingState() {
|
||||||
UUID playerId = UUID.randomUUID();
|
UUID playerId = UUID.randomUUID();
|
||||||
|
|||||||
@@ -10,9 +10,50 @@ import java.util.Map;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import org.bukkit.entity.Player;
|
import org.bukkit.entity.Player;
|
||||||
import org.bukkit.event.player.PlayerJoinEvent;
|
import org.bukkit.event.player.PlayerJoinEvent;
|
||||||
|
import org.bukkit.event.player.PlayerQuitEvent;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
class StealthSessionListenerTest {
|
class StealthSessionListenerTest {
|
||||||
|
@Test
|
||||||
|
void concealedDisconnectSuppressesPublicAnnouncementBeforeCleanup() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
try (StealthStateManager manager = manager()) {
|
||||||
|
manager.update(state -> state.withPlayer(unlocked(playerId).withSession(false, true))).join();
|
||||||
|
QualifyingInvisibilityService progression = new QualifyingInvisibilityService(
|
||||||
|
manager, Duration.ofHours(8), System::nanoTime, ignored -> { });
|
||||||
|
StealthSessionService sessions = new StealthSessionService(manager, progression);
|
||||||
|
StealthSessionListener listener = new StealthSessionListener(
|
||||||
|
sessions, mock(IdentityPresentation.class), "Stealth active");
|
||||||
|
PlayerQuitEvent event = mock(PlayerQuitEvent.class);
|
||||||
|
Player player = player(playerId);
|
||||||
|
when(event.getPlayer()).thenReturn(player);
|
||||||
|
|
||||||
|
listener.onQuit(event);
|
||||||
|
|
||||||
|
verify(event).setQuitMessage(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ordinaryDisconnectRetainsPublicAnnouncement() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
try (StealthStateManager manager = manager()) {
|
||||||
|
QualifyingInvisibilityService progression = new QualifyingInvisibilityService(
|
||||||
|
manager, Duration.ofHours(8), System::nanoTime, ignored -> { });
|
||||||
|
StealthSessionListener listener = new StealthSessionListener(
|
||||||
|
new StealthSessionService(manager, progression),
|
||||||
|
mock(IdentityPresentation.class),
|
||||||
|
"Stealth active");
|
||||||
|
PlayerQuitEvent event = mock(PlayerQuitEvent.class);
|
||||||
|
Player player = player(playerId);
|
||||||
|
when(event.getPlayer()).thenReturn(player);
|
||||||
|
|
||||||
|
listener.onQuit(event);
|
||||||
|
|
||||||
|
verify(event, never()).setQuitMessage(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void preparedLoginSuppressesAnnouncementAndConcealsIdentityForSession() {
|
void preparedLoginSuppressesAnnouncementAndConcealsIdentityForSession() {
|
||||||
UUID playerId = UUID.randomUUID();
|
UUID playerId = UUID.randomUUID();
|
||||||
|
|||||||
@@ -1,15 +1,35 @@
|
|||||||
package games.dmg.spigotstealth;
|
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.assertFalse;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
class StealthSessionServiceTest {
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void unlockedQualifyingDisconnectPreparesAndConsumesOneConcealedLogin() {
|
void unlockedQualifyingDisconnectPreparesAndConsumesOneConcealedLogin() {
|
||||||
UUID playerId = UUID.randomUUID();
|
UUID playerId = UUID.randomUUID();
|
||||||
|
|||||||
Reference in New Issue
Block a user