Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1addc93062 | ||
|
|
831f6a2ce4 |
+3
-1
@@ -4,7 +4,9 @@ okf_version: "0.1"
|
||||
|
||||
# Spigot Stealth Design
|
||||
|
||||
Spigot Stealth rewards players who spend time under invisibility effects from potions they drink, then lets unlocked players conceal their identity for a later session.
|
||||
Spigot Stealth rewards players who spend time under invisibility effects from potions they drink, then lets unlocked players conceal their identity for a later session. [Session concealment](user-stories/us-002-rejoin-without-visible-identity.md) includes command-suggestion filtering and scrambled message and overhead names.
|
||||
|
||||
[Administration](user-stories/us-004-administer-player-stealth.md) provides private concealed-player counts at login and command-based inspection.
|
||||
|
||||
## Explore
|
||||
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
# Spigot Stealth Design Log
|
||||
|
||||
## 2026-09-06
|
||||
|
||||
- **Completion**: Extended US-004 with permission-gated private concealed-player counts for joining admins and arrival notices for online admins, including command hints, zero-count silence, and no duplicate notice for concealed admins. Verified all 70 tests, the JAR build, and OKF validation; not deployed or live-server tested.
|
||||
|
||||
- **Completion**: Extended US-002 with concealed-username suggestion filtering, scrambled default chat and vanilla message/advancement names, scrambled overhead tags, and display-name restoration. Verified 65 automated tests, the Gradle JAR build, and OKF validation; live-server compatibility remains untested and custom chat/scoreboard integrations are outside this slice.
|
||||
|
||||
## 2026-09-05
|
||||
|
||||
- **Fix**: Reworked US-002 server-list filtering for Purpur's native `NameAndId` samples by filtering names through Bukkit, limiting ProtocolLib to guarded count adjustment, and rate-limiting compatibility warnings; verified the complete Gradle build and OKF bundle.
|
||||
- **Completion**: Extended US-002, US-004, and US-005 with a persistent, permission-gated sleep-count policy that excludes concealed players by default, applies immediately, restores prior player state, and supports contextual administration; verified the complete Gradle build and OKF bundle.
|
||||
- **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.
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Spigot Stealth User Stories
|
||||
|
||||
1. [US-001: Accumulate invisibility time and unlock stealth](us-001-accumulate-invisibility-and-unlock.md) - Earn stealth by spending eight hours under invisibility effects from directly consumed potions.
|
||||
2. [US-002: Rejoin without a visible identity](us-002-rejoin-without-visible-identity.md) - Turn a qualifying invisible disconnect into one concealed online session.
|
||||
2. [US-002: Rejoin without a visible identity](us-002-rejoin-without-visible-identity.md) - Turn a qualifying invisible disconnect into one concealed online session with filtered name suggestions and scrambled message and overhead names.
|
||||
3. [US-003: Check personal stealth progress](us-003-check-personal-progress.md) - View accumulated time, remaining time, and unlock status.
|
||||
4. [US-004: Inspect and manage player stealth](us-004-administer-player-stealth.md) - Inspect and modify online or offline progression and find concealed players.
|
||||
4. [US-004: Inspect and manage player stealth](us-004-administer-player-stealth.md) - Inspect and modify online or offline progression, find concealed players, and receive private login count notices.
|
||||
5. [US-005: Configure and persist stealth progression](us-005-configure-and-persist-progression.md) - Keep progression durable and operator-configurable.
|
||||
6. [US-006: Build, test, and release the plugin](us-006-build-test-and-release.md) - Provide repeatable Gradle builds and Gitea delivery.
|
||||
|
||||
@@ -23,11 +23,17 @@ As an **unlocked player**, I want to disconnect while invisibility from a potion
|
||||
- [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] Server-list filtering remains error-free when Purpur represents player samples with native `NameAndId` values rather than Mojang `GameProfile` values.
|
||||
- [x] A server-ping compatibility failure leaves the original response usable and does not produce repeated unhandled listener exceptions.
|
||||
- [x] By default, concealed players are excluded from sleep-percentage calculations.
|
||||
- [x] When the sleep-count policy is `include`, concealed players count normally; ordinary players are never modified by either policy.
|
||||
- [x] A player's previous sleeping-ignore state is restored when concealment ends, the player disconnects or is reset, the policy changes to `include`, or the plugin disables.
|
||||
- [x] Policy changes apply immediately to currently concealed players.
|
||||
- [x] Throughout the concealed session, no overhead name tag identifies the player to any other player, including administrators.
|
||||
- [x] Throughout the concealed session, the overhead name is rendered as scrambled magic text, including for administrators, while the physical character stays visible.
|
||||
- [x] Concealed usernames are excluded from outgoing command suggestions, including `/msg`, `/tell`, and `/w`; manually addressing a known username remains possible.
|
||||
- [x] Default chat display names and vanilla private-message and advancement name components are scrambled without changing message content or signed chat bodies.
|
||||
- [x] Masked message name components do not expose the original identity through hover or click actions.
|
||||
- [x] Previous display names and ordinary suggestions return when concealment ends; ordinary players remain unaffected.
|
||||
- [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] Concealment lasts until the player disconnects and is handled predictably across death and plugin reload or disable events.
|
||||
@@ -37,7 +43,17 @@ As an **unlocked player**, I want to disconnect while invisibility from a potion
|
||||
|
||||
## Validation
|
||||
|
||||
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, default sleep-count exclusion, immediate policy changes, restoration of prior sleeping-ignore state, 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, ProtocolLib-independent native player-sample filtering, guarded public server-list count adjustment, nonnegative counts, unchanged maximum capacity, default sleep-count exclusion, immediate policy changes, restoration of prior sleeping-ignore state, 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.
|
||||
|
||||
Identity masking verification (2026-09-06): `./gradlew --offline clean check jar` passed all 65 tests and OKF validation. Tests cover semantic name slots, preserved message content, stripped identifying actions, reflected suggestion entries and chat-bound records, safe packet failure handling, immutable name snapshots, overhead formatting, and display-name restoration after repeated concealment. Presentation tests first failed to compile against the missing snapshot API, then passed after implementation. These are automated unit checks, not a live-client smoke test.
|
||||
|
||||
## Identity masking scope
|
||||
|
||||
Masking applies to active concealed sessions, not every potion effect. Outgoing username suggestions are filtered across commands; entering a known username manually still works. Default Bukkit chat uses a temporary scrambled `Anonymous` display name. Vanilla translated announcements and chat-type bound names are masked at the packet boundary without editing signed message bodies. The overhead tag uses scoreboard magic formatting on the profile name, not a profile rename; this is visual concealment, not anonymity against modified clients.
|
||||
|
||||
Custom chat formats that ignore Bukkit display names, nicknames, custom scoreboards, and third-party messaging plugins may need dedicated integration. Unsupported packet layouts retain the original packet and emit one warning per packet type rather than interrupting chat delivery. Live-server/client compatibility still requires a smoke test, particularly after Minecraft or ProtocolLib upgrades.
|
||||
|
||||
Implementation: [identity presentation](../../src/main/java/games/dmg/spigotstealth/BukkitIdentityPresentation.java), [packet listener](../../src/main/java/games/dmg/spigotstealth/ProtocolLibIdentityMaskingListener.java), and [semantic name masking](../../src/main/java/games/dmg/spigotstealth/StealthChatMasker.java).
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -19,7 +19,12 @@ As a **server administrator**, I want to inspect and correct player stealth stat
|
||||
- [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] Concealed players remain absent from administrators' ordinary tab lists and retain scrambled overhead names; the admin command is the supported discovery mechanism.
|
||||
- [x] An administrator joining while concealed players are online receives a private count and a hint to use `/stealthadmin list`.
|
||||
- [x] When a concealed player joins, online administrators receive a private arrival notice, the updated concealed-player count, and the command hint.
|
||||
- [x] Notifications require `spigotstealth.admin`; ordinary players receive no administrative notice.
|
||||
- [x] Counts include the newly joined concealed player, and a joining concealed administrator receives only one administrative notification.
|
||||
- [x] An administrator joining when no concealed players are online receives no notification.
|
||||
- [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.
|
||||
@@ -38,6 +43,10 @@ As a **server administrator**, I want to inspect and correct player stealth stat
|
||||
|
||||
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, durable sleep-count status and policy changes, immediate policy refresh, confirmation and permission gates, persisted-before-success replies, and audit records. The complete `./gradlew clean check jar` lifecycle passes.
|
||||
|
||||
Login notice verification (2026-09-06): `./gradlew --offline clean check jar` passed all 70 tests and OKF validation. New tests first failed against the absent listener, then passed after implementation. Tests cover permission gates, zero-count silence, singular/plural counts, concealed-admin deduplication, ordinary-join silence for existing admins, and a prepared-login transition followed by an accurate administrative notice. Live-server verification remains pending.
|
||||
|
||||
Implementation: [admin join listener](../../src/main/java/games/dmg/spigotstealth/StealthAdminJoinListener.java), registered at `MONITOR` after the session listener's `HIGHEST` login transition. Notices expose only counts; names remain available through `/stealthadmin list`.
|
||||
|
||||
## Related
|
||||
|
||||
- [Rejoin without a visible identity](us-002-rejoin-without-visible-identity.md)
|
||||
|
||||
@@ -18,6 +18,18 @@ public final class BukkitIdentityPresentation implements IdentityPresentation {
|
||||
private final Supplier<SleepCountPolicy> sleepCountPolicy;
|
||||
private final Map<UUID, Player> concealedPlayers = new LinkedHashMap<>();
|
||||
private final Map<UUID, Boolean> previousSleepingIgnored = new LinkedHashMap<>();
|
||||
private final Map<UUID, String> previousDisplayNames = new LinkedHashMap<>();
|
||||
private volatile java.util.Set<String> concealedNames = java.util.Set.of();
|
||||
|
||||
/** Immutable snapshot safe to read from outgoing packet threads. */
|
||||
public java.util.Set<String> concealedNames() {
|
||||
return concealedNames;
|
||||
}
|
||||
|
||||
private void publishNames() {
|
||||
concealedNames = concealedPlayers.values().stream().map(Player::getName)
|
||||
.collect(java.util.stream.Collectors.toUnmodifiableSet());
|
||||
}
|
||||
|
||||
public BukkitIdentityPresentation(
|
||||
Supplier<? extends Collection<? extends Player>> onlinePlayers,
|
||||
@@ -40,12 +52,17 @@ public final class BukkitIdentityPresentation implements IdentityPresentation {
|
||||
@Override
|
||||
public void conceal(Player player) {
|
||||
concealedPlayers.put(player.getUniqueId(), player);
|
||||
publishNames();
|
||||
previousDisplayNames.putIfAbsent(player.getUniqueId(), player.getDisplayName());
|
||||
player.setDisplayName("§kAnonymous§r");
|
||||
applySleepCountPolicy(player);
|
||||
Team team = scoreboard.getTeam(teamName(player.getUniqueId()));
|
||||
if (team == null) {
|
||||
team = scoreboard.registerNewTeam(teamName(player.getUniqueId()));
|
||||
}
|
||||
team.setOption(Team.Option.NAME_TAG_VISIBILITY, Team.OptionStatus.NEVER);
|
||||
team.setOption(Team.Option.NAME_TAG_VISIBILITY, Team.OptionStatus.ALWAYS);
|
||||
team.setPrefix("§k");
|
||||
team.setSuffix("§r");
|
||||
team.addEntry(player.getName());
|
||||
for (Player observer : onlinePlayers.get()) {
|
||||
if (!observer.getUniqueId().equals(player.getUniqueId())) {
|
||||
@@ -57,6 +74,10 @@ public final class BukkitIdentityPresentation implements IdentityPresentation {
|
||||
@Override
|
||||
public void reveal(Player player) {
|
||||
boolean wasConcealed = concealedPlayers.remove(player.getUniqueId()) != null;
|
||||
publishNames();
|
||||
if (previousDisplayNames.containsKey(player.getUniqueId())) {
|
||||
player.setDisplayName(previousDisplayNames.remove(player.getUniqueId()));
|
||||
}
|
||||
restoreSleepingIgnored(player);
|
||||
Team team = scoreboard.getTeam(teamName(player.getUniqueId()));
|
||||
if (team != null) {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.RecordComponent;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.function.UnaryOperator;
|
||||
|
||||
/** Small reflective bridge: avoids a compile/runtime dependency on a specific NMS or Brigadier version. */
|
||||
final class IdentityPacketFields {
|
||||
private IdentityPacketFields() { }
|
||||
|
||||
static List<?> filterEntries(List<?> entries, StealthChatMasker masker) throws ReflectiveOperationException {
|
||||
List<Object> filtered = new ArrayList<>();
|
||||
for (Object entry : entries) {
|
||||
Method text;
|
||||
try {
|
||||
text = entry.getClass().getMethod("text");
|
||||
} catch (NoSuchMethodException exception) {
|
||||
text = entry.getClass().getMethod("getText");
|
||||
}
|
||||
if (!masker.isConcealedName((String) text.invoke(entry))) {
|
||||
filtered.add(entry);
|
||||
}
|
||||
}
|
||||
return List.copyOf(filtered);
|
||||
}
|
||||
|
||||
static Object mapRecord(Object record, UnaryOperator<Object> mapper) throws ReflectiveOperationException {
|
||||
RecordComponent[] components = record.getClass().getRecordComponents();
|
||||
if (components == null) {
|
||||
throw new IllegalArgumentException("Unsupported non-record chat binding");
|
||||
}
|
||||
Class<?>[] types = new Class<?>[components.length];
|
||||
Object[] values = new Object[components.length];
|
||||
for (int i = 0; i < components.length; i++) {
|
||||
types[i] = components[i].getType();
|
||||
Object value = components[i].getAccessor().invoke(record);
|
||||
values[i] = value instanceof Optional<?> optional ? optional.map(mapper) : mapper.apply(value);
|
||||
}
|
||||
Constructor<?> constructor = record.getClass().getDeclaredConstructor(types);
|
||||
return constructor.newInstance(values);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import com.comphenix.protocol.PacketType;
|
||||
import com.comphenix.protocol.events.ListenerPriority;
|
||||
import com.comphenix.protocol.events.PacketAdapter;
|
||||
import com.comphenix.protocol.events.PacketContainer;
|
||||
import com.comphenix.protocol.events.PacketEvent;
|
||||
import com.comphenix.protocol.reflect.StructureModifier;
|
||||
import com.comphenix.protocol.utility.MinecraftReflection;
|
||||
import com.comphenix.protocol.wrappers.WrappedChatComponent;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
/**
|
||||
* Outgoing identity-only masking. Registration is deliberately left to the plugin.
|
||||
* The supplier must return an immutable, safely published snapshot (for example AtomicReference::get).
|
||||
* It must not inspect Bukkit players or mutable session collections on the packet thread.
|
||||
*/
|
||||
public final class ProtocolLibIdentityMaskingListener extends PacketAdapter {
|
||||
private final Supplier<Set<String>> concealedNames;
|
||||
private final Consumer<String> warningLog;
|
||||
private final Set<PacketType> warned = ConcurrentHashMap.newKeySet();
|
||||
|
||||
public ProtocolLibIdentityMaskingListener(
|
||||
Plugin plugin, Supplier<Set<String>> concealedNames, Consumer<String> warningLog) {
|
||||
// ProtocolLib calls the vanilla PLAYER_CHAT packet CHAT.
|
||||
super(plugin, ListenerPriority.HIGHEST, PacketType.Play.Server.TAB_COMPLETE,
|
||||
PacketType.Play.Server.SYSTEM_CHAT, PacketType.Play.Server.CHAT,
|
||||
PacketType.Play.Server.DISGUISED_CHAT);
|
||||
this.concealedNames = Objects.requireNonNull(concealedNames, "concealedNames");
|
||||
this.warningLog = Objects.requireNonNull(warningLog, "warningLog");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPacketSending(PacketEvent event) {
|
||||
if (event.isCancelled()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Set<String> snapshot = concealedNames.get();
|
||||
if (snapshot.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
StealthChatMasker masker = new StealthChatMasker(snapshot);
|
||||
PacketContainer packet = event.getPacket().shallowClone();
|
||||
if (event.getPacketType().equals(PacketType.Play.Server.TAB_COMPLETE)) {
|
||||
filterSuggestions(packet, masker);
|
||||
} else if (event.getPacketType().equals(PacketType.Play.Server.SYSTEM_CHAT)) {
|
||||
maskSystemChat(packet, masker);
|
||||
} else {
|
||||
maskBoundNames(packet, masker);
|
||||
}
|
||||
event.setPacket(packet);
|
||||
} catch (ReflectiveOperationException | RuntimeException exception) {
|
||||
// Keep delivery usable; do not cancel signed chat and break the acknowledgement chain.
|
||||
if (warned.add(event.getPacketType())) {
|
||||
warningLog.accept("Stealth identity masking unavailable for " + event.getPacketType()
|
||||
+ "; original packet retained. Failure: " + exception.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void filterSuggestions(PacketContainer packet, StealthChatMasker masker)
|
||||
throws ReflectiveOperationException {
|
||||
StructureModifier<Object> fields = packet.getModifier();
|
||||
for (int i = 0; i < fields.size(); i++) {
|
||||
Object value = fields.read(i);
|
||||
if (value instanceof List<?> entries) {
|
||||
// Modern ClientboundCommandSuggestionsPacket stores its Entry list directly.
|
||||
fields.write(i, IdentityPacketFields.filterEntries(entries, masker));
|
||||
return;
|
||||
}
|
||||
if (value != null && value.getClass().getName().equals("com.mojang.brigadier.suggestion.Suggestions")) {
|
||||
Object range = value.getClass().getMethod("getRange").invoke(value);
|
||||
List<?> entries = (List<?>) value.getClass().getMethod("getList").invoke(value);
|
||||
List<?> filtered = IdentityPacketFields.filterEntries(entries, masker);
|
||||
fields.write(i, value.getClass().getConstructor(range.getClass(), List.class).newInstance(range, filtered));
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Pre-Brigadier protocol compatibility.
|
||||
StructureModifier<String[]> arrays = packet.getStringArrays();
|
||||
if (arrays.size() > 0) {
|
||||
arrays.write(0, java.util.Arrays.stream(arrays.read(0))
|
||||
.filter(name -> !masker.isConcealedName(name)).toArray(String[]::new));
|
||||
return;
|
||||
}
|
||||
throw new IllegalStateException("Unsupported command suggestions layout");
|
||||
}
|
||||
|
||||
private static void maskSystemChat(PacketContainer packet, StealthChatMasker masker) {
|
||||
StructureModifier<WrappedChatComponent> components = packet.getChatComponents();
|
||||
if (components.size() > 0) {
|
||||
WrappedChatComponent original = components.read(0);
|
||||
String json = original.getJson();
|
||||
String masked = masker.maskAnnouncement(json);
|
||||
if (!json.equals(masked)) {
|
||||
components.write(0, WrappedChatComponent.fromJson(masked));
|
||||
}
|
||||
} else if (packet.getStrings().size() > 0) {
|
||||
// Older SYSTEM_CHAT represents its component as JSON text.
|
||||
String json = packet.getStrings().read(0);
|
||||
packet.getStrings().write(0, masker.maskAnnouncement(json));
|
||||
} else {
|
||||
throw new IllegalStateException("Unsupported system chat layout");
|
||||
}
|
||||
}
|
||||
|
||||
private static void maskBoundNames(PacketContainer packet, StealthChatMasker masker)
|
||||
throws ReflectiveOperationException {
|
||||
StructureModifier<Object> fields = packet.getModifier();
|
||||
for (int i = 0; i < fields.size(); i++) {
|
||||
Object value = fields.read(i);
|
||||
if (value != null && (value.getClass().getName().endsWith("ChatType$BoundNetwork")
|
||||
|| value.getClass().getName().endsWith("ChatType$Bound"))) {
|
||||
Object masked = IdentityPacketFields.mapRecord(value, field -> {
|
||||
if (field != null && MinecraftReflection.getIChatBaseComponentClass().isInstance(field)) {
|
||||
String json = WrappedChatComponent.fromHandle(field).getJson();
|
||||
String replacement = masker.maskDisplayName(json);
|
||||
return json.equals(replacement) ? field : WrappedChatComponent.fromJson(replacement).getHandle();
|
||||
}
|
||||
return field;
|
||||
});
|
||||
fields.write(i, masked);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Intentionally never touch signed body, unsigned body, UUID, signature, or filter mask.
|
||||
throw new IllegalStateException("Unsupported player chat display-name layout");
|
||||
}
|
||||
}
|
||||
@@ -3,42 +3,35 @@ 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.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
/** Rewrites outgoing server-list responses without changing actual online-player state. */
|
||||
/** Adjusts only the count in outgoing server-list responses through ProtocolLib. */
|
||||
public final class ProtocolLibServerListPingListener extends PacketAdapter {
|
||||
private final Supplier<Set<UUID>> concealedPlayerIds;
|
||||
private final ServerPingCompatibilityGuard compatibilityGuard;
|
||||
|
||||
public ProtocolLibServerListPingListener(Plugin plugin, Supplier<Set<UUID>> concealedPlayerIds) {
|
||||
public ProtocolLibServerListPingListener(
|
||||
Plugin plugin,
|
||||
Supplier<Set<UUID>> concealedPlayerIds,
|
||||
Consumer<String> warningLog) {
|
||||
super(plugin, PacketType.Status.Server.SERVER_INFO);
|
||||
this.concealedPlayerIds = Objects.requireNonNull(concealedPlayerIds, "concealedPlayerIds");
|
||||
this.compatibilityGuard = new ServerPingCompatibilityGuard(warningLog);
|
||||
}
|
||||
|
||||
@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);
|
||||
compatibilityGuard.run(() -> {
|
||||
WrappedServerPing ping = event.getPacket().getServerPings().read(0);
|
||||
int visiblePlayers = ServerListPingVisibility.visibleOnlineCount(
|
||||
ping.getPlayersOnline(), concealedPlayerIds.get().size());
|
||||
ping.setPlayersOnline(visiblePlayers);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import java.util.Iterator;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.server.ServerListPingEvent;
|
||||
|
||||
/** Filters concealed players through Bukkit's native server-list sample representation. */
|
||||
public final class ServerListPingListener implements Listener {
|
||||
private final Supplier<Set<UUID>> concealedPlayerIds;
|
||||
|
||||
public ServerListPingListener(Supplier<Set<UUID>> concealedPlayerIds) {
|
||||
this.concealedPlayerIds = Objects.requireNonNull(concealedPlayerIds, "concealedPlayerIds");
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
public void onServerListPing(ServerListPingEvent event) {
|
||||
Set<UUID> concealed = concealedPlayerIds.get();
|
||||
Iterator<Player> sample = event.iterator();
|
||||
while (sample.hasNext()) {
|
||||
if (concealed.contains(sample.next().getUniqueId())) {
|
||||
sample.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,11 +20,15 @@ public final class ServerListPingVisibility {
|
||||
.filter(playerId -> !concealedPlayerIds.contains(playerId))
|
||||
.toList();
|
||||
return new Snapshot(
|
||||
Math.max(0, playersOnline - concealedPlayerIds.size()),
|
||||
visibleOnlineCount(playersOnline, concealedPlayerIds.size()),
|
||||
playersMaximum,
|
||||
visibleSample);
|
||||
}
|
||||
|
||||
public static int visibleOnlineCount(int playersOnline, int concealedPlayers) {
|
||||
return Math.max(0, playersOnline - concealedPlayers);
|
||||
}
|
||||
|
||||
public record Snapshot(int playersOnline, int playersMaximum, List<UUID> samplePlayerIds) {
|
||||
public Snapshot {
|
||||
samplePlayerIds = List.copyOf(samplePlayerIds);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/** Keeps an incompatible server-ping adapter from breaking responses or flooding logs. */
|
||||
public final class ServerPingCompatibilityGuard {
|
||||
private final Consumer<String> warningLog;
|
||||
private final AtomicBoolean warningLogged = new AtomicBoolean();
|
||||
|
||||
public ServerPingCompatibilityGuard(Consumer<String> warningLog) {
|
||||
this.warningLog = Objects.requireNonNull(warningLog, "warningLog");
|
||||
}
|
||||
|
||||
public void run(Runnable edit) {
|
||||
try {
|
||||
edit.run();
|
||||
} catch (RuntimeException exception) {
|
||||
if (warningLogged.compareAndSet(false, true)) {
|
||||
warningLog.accept("Unable to adjust the public server-list player count; "
|
||||
+ "the original response will be used: " + exception.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,17 +77,24 @@ public final class SpigotStealthPlugin extends JavaPlugin {
|
||||
manager, settings.unlockThreshold(), System::nanoTime, notifier);
|
||||
sessions = new StealthSessionService(manager, progression);
|
||||
protocolManager = ProtocolLibrary.getProtocolManager();
|
||||
identityPresentation = new BukkitIdentityPresentation(
|
||||
BukkitIdentityPresentation bukkitPresentation = new BukkitIdentityPresentation(
|
||||
getServer()::getOnlinePlayers,
|
||||
Objects.requireNonNull(getServer().getScoreboardManager(), "scoreboard manager").getMainScoreboard(),
|
||||
new ProtocolLibTabListController(protocolManager),
|
||||
() -> manager.snapshot().sleepCountPolicy());
|
||||
protocolManager.addPacketListener(
|
||||
new ProtocolLibServerListPingListener(this, sessions::concealedPlayerIds));
|
||||
identityPresentation = bukkitPresentation;
|
||||
protocolManager.addPacketListener(new ProtocolLibIdentityMaskingListener(
|
||||
this, bukkitPresentation::concealedNames, getLogger()::warning));
|
||||
protocolManager.addPacketListener(new ProtocolLibServerListPingListener(
|
||||
this, sessions::concealedPlayerIds, getLogger()::warning));
|
||||
getServer().getPluginManager().registerEvents(
|
||||
new ServerListPingListener(sessions::concealedPlayerIds), this);
|
||||
getServer().getPluginManager().registerEvents(
|
||||
new InvisibilityEffectListener(progression, Clock.systemUTC()), this);
|
||||
getServer().getPluginManager().registerEvents(
|
||||
new StealthSessionListener(sessions, identityPresentation, settings.concealedMessage()), this);
|
||||
getServer().getPluginManager().registerEvents(
|
||||
new StealthAdminJoinListener(sessions, getServer()::getOnlinePlayers), this);
|
||||
org.bukkit.command.PluginCommand stealthPluginCommand =
|
||||
Objects.requireNonNull(getCommand("stealth"), "stealth command");
|
||||
StealthCommand stealthCommand = new StealthCommand(manager, progression, settings);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
|
||||
/** Private administrative notices after the HIGHEST-priority session transition. */
|
||||
public final class StealthAdminJoinListener implements Listener {
|
||||
private static final String PERMISSION = "spigotstealth.admin";
|
||||
private static final String HINT = " Use /stealthadmin list to see who.";
|
||||
private final StealthSessionService sessions;
|
||||
private final Supplier<? extends Collection<? extends Player>> onlinePlayers;
|
||||
|
||||
public StealthAdminJoinListener(
|
||||
StealthSessionService sessions,
|
||||
Supplier<? extends Collection<? extends Player>> onlinePlayers) {
|
||||
this.sessions = Objects.requireNonNull(sessions, "sessions");
|
||||
this.onlinePlayers = Objects.requireNonNull(onlinePlayers, "onlinePlayers");
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onJoin(PlayerJoinEvent event) {
|
||||
Player joining = event.getPlayer();
|
||||
Set<UUID> concealed = sessions.concealedPlayerIds();
|
||||
int count = concealed.size();
|
||||
if (count == 0) {
|
||||
return;
|
||||
}
|
||||
String quantity = count + (count == 1 ? " player is" : " players are");
|
||||
if (joining.hasPermission(PERMISSION)) {
|
||||
joining.sendMessage("[Stealth] " + quantity + " invisible." + HINT);
|
||||
}
|
||||
if (concealed.contains(joining.getUniqueId())) {
|
||||
String notice = "[Stealth] An invisible player joined. " + quantity + " now invisible." + HINT;
|
||||
for (Player observer : onlinePlayers.get()) {
|
||||
if (!observer.getUniqueId().equals(joining.getUniqueId()) && observer.hasPermission(PERMISSION)) {
|
||||
observer.sendMessage(notice);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** Masks semantic name slots, never searching/replacing user-authored message text. */
|
||||
public final class StealthChatMasker {
|
||||
private static final Set<String> NAME_FIRST_TRANSLATIONS = Set.of(
|
||||
"chat.type.text", "chat.type.announcement", "chat.type.emote",
|
||||
"commands.message.display.incoming", "commands.message.display.outgoing",
|
||||
"chat.type.advancement.task", "chat.type.advancement.goal", "chat.type.advancement.challenge");
|
||||
private final Set<String> names;
|
||||
|
||||
public StealthChatMasker(Set<String> concealedNames) {
|
||||
names = concealedNames.stream().map(name -> name.toLowerCase(Locale.ROOT)).collect(Collectors.toUnmodifiableSet());
|
||||
}
|
||||
|
||||
public boolean isConcealedName(String text) {
|
||||
return names.contains(text.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
public String maskAnnouncement(String json) {
|
||||
try {
|
||||
JsonElement component = JsonParser.parseString(json);
|
||||
return maskAnnouncement(component) ? component.toString() : json;
|
||||
} catch (JsonParseException | IllegalStateException exception) {
|
||||
return json;
|
||||
}
|
||||
}
|
||||
|
||||
public String maskDisplayName(String json) {
|
||||
try {
|
||||
return isConcealedName(plainName(JsonParser.parseString(json))) ? anonymous().toString() : json;
|
||||
} catch (JsonParseException | IllegalStateException exception) {
|
||||
return json;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean maskAnnouncement(JsonElement component) {
|
||||
boolean changed = false;
|
||||
if (component.isJsonArray()) {
|
||||
for (JsonElement child : component.getAsJsonArray()) {
|
||||
changed |= maskAnnouncement(child);
|
||||
}
|
||||
if (changed && !component.getAsJsonArray().isEmpty()) {
|
||||
clearInheritedActions(component.getAsJsonArray().get(0));
|
||||
}
|
||||
} else if (component.isJsonObject()) {
|
||||
JsonObject object = component.getAsJsonObject();
|
||||
// Translation arguments other than the explicitly known name slot are message content.
|
||||
if (object.has("translate") && object.get("translate").isJsonPrimitive()
|
||||
&& NAME_FIRST_TRANSLATIONS.contains(object.get("translate").getAsString())
|
||||
&& object.has("with") && object.get("with").isJsonArray()) {
|
||||
JsonArray arguments = object.getAsJsonArray("with");
|
||||
if (!arguments.isEmpty() && isConcealedName(plainName(arguments.get(0)))) {
|
||||
arguments.set(0, anonymous());
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (object.has("extra")) {
|
||||
changed |= maskAnnouncement(object.get("extra"));
|
||||
}
|
||||
// Prevent masked descendants from inheriting an identifying action from their parent.
|
||||
if (changed) {
|
||||
clearInheritedActions(object);
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
private static void clearInheritedActions(JsonElement component) {
|
||||
if (component.isJsonObject()) {
|
||||
for (String key : Set.of("hoverEvent", "clickEvent", "hover_event", "click_event", "insertion")) {
|
||||
component.getAsJsonObject().remove(key);
|
||||
}
|
||||
} else if (component.isJsonArray() && !component.getAsJsonArray().isEmpty()) {
|
||||
clearInheritedActions(component.getAsJsonArray().get(0));
|
||||
}
|
||||
}
|
||||
|
||||
private static String plainName(JsonElement component) {
|
||||
if (component.isJsonPrimitive()) {
|
||||
return component.getAsString();
|
||||
}
|
||||
StringBuilder text = new StringBuilder();
|
||||
if (component.isJsonArray()) {
|
||||
for (JsonElement child : component.getAsJsonArray()) {
|
||||
text.append(plainName(child));
|
||||
}
|
||||
} else if (component.isJsonObject()) {
|
||||
JsonObject object = component.getAsJsonObject();
|
||||
if (object.has("text") && object.get("text").isJsonPrimitive()) {
|
||||
text.append(object.get("text").getAsString());
|
||||
}
|
||||
if (object.has("extra")) {
|
||||
text.append(plainName(object.get("extra")));
|
||||
}
|
||||
}
|
||||
return text.toString();
|
||||
}
|
||||
|
||||
private static JsonObject anonymous() {
|
||||
JsonObject alias = new JsonObject();
|
||||
alias.addProperty("text", "Anonymous");
|
||||
alias.addProperty("obfuscated", true);
|
||||
alias.add("hoverEvent", com.google.gson.JsonNull.INSTANCE);
|
||||
alias.add("clickEvent", com.google.gson.JsonNull.INSTANCE);
|
||||
alias.addProperty("insertion", "");
|
||||
return alias;
|
||||
}
|
||||
}
|
||||
@@ -25,10 +25,14 @@ class BukkitIdentityPresentationTest {
|
||||
BukkitIdentityPresentation presentation = new BukkitIdentityPresentation(
|
||||
() -> List.of(target), scoreboard, mock(TabListController.class));
|
||||
|
||||
when(target.getDisplayName()).thenReturn("Original Alex");
|
||||
presentation.conceal(target);
|
||||
presentation.conceal(target);
|
||||
presentation.reveal(target);
|
||||
|
||||
verify(target).setSleepingIgnored(true);
|
||||
verify(target).setDisplayName("Original Alex");
|
||||
org.junit.jupiter.api.Assertions.assertTrue(presentation.concealedNames().isEmpty());
|
||||
verify(target, org.mockito.Mockito.times(2)).setSleepingIgnored(true);
|
||||
verify(target).setSleepingIgnored(false);
|
||||
}
|
||||
|
||||
@@ -73,7 +77,7 @@ class BukkitIdentityPresentationTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void concealRemovesTabEntryAndHidesNameTagWithoutHidingEntity() {
|
||||
void concealRemovesTabEntryAndScramblesNameTagWithoutHidingEntity() {
|
||||
UUID targetId = UUID.randomUUID();
|
||||
Player target = player(targetId, "Alex");
|
||||
Player observer = player(UUID.randomUUID(), "Morgan");
|
||||
@@ -87,7 +91,11 @@ class BukkitIdentityPresentationTest {
|
||||
|
||||
presentation.conceal(target);
|
||||
|
||||
verify(team).setOption(Team.Option.NAME_TAG_VISIBILITY, Team.OptionStatus.NEVER);
|
||||
verify(team).setOption(Team.Option.NAME_TAG_VISIBILITY, Team.OptionStatus.ALWAYS);
|
||||
verify(team).setPrefix("§k");
|
||||
verify(team).setSuffix("§r");
|
||||
verify(target).setDisplayName("§kAnonymous§r");
|
||||
org.junit.jupiter.api.Assertions.assertEquals(java.util.Set.of("Alex"), presentation.concealedNames());
|
||||
verify(team).addEntry("Alex");
|
||||
verify(tabLists).remove(observer, targetId);
|
||||
verify(observer, never()).hidePlayer(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class IdentityPacketFieldsTest {
|
||||
private final StealthChatMasker masker = new StealthChatMasker(Set.of("Secret"));
|
||||
|
||||
record Entry(String text, Optional<String> tooltip) { }
|
||||
record Bound(int chatType, String name, Optional<String> targetName) { }
|
||||
|
||||
@Test
|
||||
void filtersModernSuggestionEntriesWithoutChangingOrdinaryEntries() throws ReflectiveOperationException {
|
||||
Entry ordinary = new Entry("Someone", Optional.of("help"));
|
||||
List<Entry> entries = List.of(new Entry("sEcReT", Optional.empty()), ordinary);
|
||||
assertEquals(List.of(ordinary), IdentityPacketFields.filterEntries(entries, masker));
|
||||
assertEquals(2, entries.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rebuildsDisplayNameRecordWithoutMutatingOriginal() throws ReflectiveOperationException {
|
||||
Bound original = new Bound(42, "Secret", Optional.of("Secret"));
|
||||
Object copy = IdentityPacketFields.mapRecord(original,
|
||||
value -> value.equals("Secret") ? "Anonymous" : value);
|
||||
assertEquals(new Bound(42, "Anonymous", Optional.of("Anonymous")), copy);
|
||||
assertEquals("Secret", original.name());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import com.comphenix.protocol.PacketType;
|
||||
import com.comphenix.protocol.events.PacketEvent;
|
||||
import com.comphenix.protocol.utility.MinecraftReflection;
|
||||
import com.comphenix.protocol.utility.MinecraftVersion;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
class ProtocolLibIdentityMaskingListenerTest {
|
||||
private static MockedStatic<MinecraftReflection> reflection;
|
||||
|
||||
@BeforeAll
|
||||
static void initializeProtocolTypesWithoutServer() {
|
||||
MinecraftVersion.setCurrentVersion(new MinecraftVersion(1, 21, 8));
|
||||
reflection = mockStatic(MinecraftReflection.class);
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void resetProtocolEnvironment() {
|
||||
reflection.close();
|
||||
MinecraftVersion.setCurrentVersion(null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsPacketWorkWhenNobodyIsConcealed() {
|
||||
AtomicReference<Set<String>> names = new AtomicReference<>(Set.of());
|
||||
var listener = new ProtocolLibIdentityMaskingListener(mock(Plugin.class), names::get, ignored -> { });
|
||||
PacketEvent event = mock(PacketEvent.class);
|
||||
listener.onPacketSending(event);
|
||||
verify(event, never()).getPacket();
|
||||
assertTrue(listener.getSendingWhitelist().getTypes().contains(PacketType.Play.Server.CHAT));
|
||||
assertTrue(listener.getSendingWhitelist().getTypes().contains(PacketType.Play.Server.SYSTEM_CHAT));
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsFreshSnapshotAndWarnsOnceWithoutCancellingOrReplacingFailedPacket() {
|
||||
List<String> warnings = new ArrayList<>();
|
||||
AtomicReference<Set<String>> names = new AtomicReference<>(Set.of());
|
||||
var listener = new ProtocolLibIdentityMaskingListener(mock(Plugin.class), names::get, warnings::add);
|
||||
PacketEvent event = mock(PacketEvent.class);
|
||||
when(event.getPacketType()).thenReturn(PacketType.Play.Server.CHAT);
|
||||
listener.onPacketSending(event);
|
||||
assertTrue(warnings.isEmpty());
|
||||
names.set(Set.of("Secret"));
|
||||
// No real NMS packet: the clone attempt fails and must leave event delivery untouched.
|
||||
listener.onPacketSending(event);
|
||||
listener.onPacketSending(event);
|
||||
assertEquals(1, warnings.size());
|
||||
verify(event, never()).setPacket(any());
|
||||
verify(event, never()).setCancelled(anyBoolean());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.server.ServerListPingEvent;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ServerListPingListenerTest {
|
||||
@Test
|
||||
void removesOnlyConcealedPlayersFromNativeServerPingSample() {
|
||||
UUID concealedId = UUID.randomUUID();
|
||||
Player visible = player(UUID.randomUUID());
|
||||
Player concealed = player(concealedId);
|
||||
ArrayList<Player> sample = new ArrayList<>(List.of(visible, concealed));
|
||||
ServerListPingEvent event = mock(ServerListPingEvent.class);
|
||||
when(event.iterator()).thenReturn(sample.iterator());
|
||||
ServerListPingListener listener = new ServerListPingListener(() -> Set.of(concealedId));
|
||||
|
||||
listener.onServerListPing(event);
|
||||
|
||||
assertEquals(List.of(visible), sample);
|
||||
}
|
||||
|
||||
private static Player player(UUID playerId) {
|
||||
Player player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(playerId);
|
||||
return player;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ServerPingCompatibilityGuardTest {
|
||||
@Test
|
||||
void compatibilityFailureLeavesPingHandlingUsableAndWarnsOnlyOnce() {
|
||||
ArrayList<String> warnings = new ArrayList<>();
|
||||
ServerPingCompatibilityGuard guard = new ServerPingCompatibilityGuard(warnings::add);
|
||||
|
||||
assertDoesNotThrow(() -> guard.run(() -> {
|
||||
throw new IllegalArgumentException("unsupported profile representation");
|
||||
}));
|
||||
assertDoesNotThrow(() -> guard.run(() -> {
|
||||
throw new IllegalArgumentException("unsupported profile representation");
|
||||
}));
|
||||
|
||||
assertEquals(1, warnings.size());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class StealthAdminJoinListenerTest {
|
||||
@Test
|
||||
void joiningAdminReceivesCountButExistingAdminsDoNotReceiveOrdinaryJoinNotice() {
|
||||
Player joining = player(true);
|
||||
Player existing = player(true);
|
||||
StealthSessionService sessions = sessions(Set.of(UUID.randomUUID(), UUID.randomUUID()));
|
||||
new StealthAdminJoinListener(sessions, () -> List.of(joining, existing)).onJoin(event(joining));
|
||||
verify(joining).sendMessage("[Stealth] 2 players are invisible. Use /stealthadmin list to see who.");
|
||||
verify(existing, never()).sendMessage(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void concealedJoinNotifiesOnlyAdminsWithUpdatedCount() {
|
||||
Player joining = player(false);
|
||||
Player admin = player(true);
|
||||
Player ordinary = player(false);
|
||||
StealthSessionService sessions = sessions(Set.of(joining.getUniqueId()));
|
||||
new StealthAdminJoinListener(sessions, () -> List.of(joining, admin, ordinary)).onJoin(event(joining));
|
||||
verify(admin).sendMessage("[Stealth] An invisible player joined. 1 player is now invisible. Use /stealthadmin list to see who.");
|
||||
verify(joining, never()).sendMessage(anyString());
|
||||
verify(ordinary, never()).sendMessage(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void concealedAdminGetsExactlyOneNoticeEvenWhenIncludedInOnlinePlayers() {
|
||||
Player joining = player(true);
|
||||
Player admin = player(true);
|
||||
StealthSessionService sessions = sessions(Set.of(joining.getUniqueId(), UUID.randomUUID()));
|
||||
new StealthAdminJoinListener(sessions, () -> List.of(joining, admin)).onJoin(event(joining));
|
||||
verify(joining, times(1)).sendMessage("[Stealth] 2 players are invisible. Use /stealthadmin list to see who.");
|
||||
verify(admin).sendMessage("[Stealth] An invisible player joined. 2 players are now invisible. Use /stealthadmin list to see who.");
|
||||
}
|
||||
|
||||
@Test
|
||||
void zeroCountIsSilent() {
|
||||
Player joining = player(true);
|
||||
new StealthAdminJoinListener(sessions(Set.of()), () -> List.of(joining)).onJoin(event(joining));
|
||||
verify(joining, never()).sendMessage(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ordinaryJoinIsSilentAndSinglePlayerCountUsesSingular() {
|
||||
Player ordinary = player(false);
|
||||
Player admin = player(true);
|
||||
StealthAdminJoinListener listener = new StealthAdminJoinListener(
|
||||
sessions(Set.of(UUID.randomUUID())), () -> List.of(ordinary, admin));
|
||||
listener.onJoin(event(ordinary));
|
||||
verify(ordinary, never()).sendMessage(anyString());
|
||||
verify(admin, never()).sendMessage(anyString());
|
||||
listener.onJoin(event(admin));
|
||||
verify(admin).sendMessage("[Stealth] 1 player is invisible. Use /stealthadmin list to see who.");
|
||||
}
|
||||
|
||||
private static StealthSessionService sessions(Set<UUID> ids) {
|
||||
StealthSessionService sessions = mock(StealthSessionService.class);
|
||||
when(sessions.concealedPlayerIds()).thenReturn(ids);
|
||||
return sessions;
|
||||
}
|
||||
|
||||
private static Player player(boolean admin) {
|
||||
Player player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(player.hasPermission("spigotstealth.admin")).thenReturn(admin);
|
||||
return player;
|
||||
}
|
||||
|
||||
private static PlayerJoinEvent event(Player player) {
|
||||
PlayerJoinEvent event = mock(PlayerJoinEvent.class);
|
||||
when(event.getPlayer()).thenReturn(player);
|
||||
return event;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import com.google.gson.JsonParser;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class StealthChatMaskerTest {
|
||||
private final StealthChatMasker masker = new StealthChatMasker(Set.of("Secret"));
|
||||
|
||||
@Test
|
||||
void masksSenderAndRemovesIdentityMetadataWithoutChangingMessage() {
|
||||
String input = """
|
||||
{"translate":"chat.type.text","with":[
|
||||
{"text":"Secret","hoverEvent":{"action":"show_entity","contents":{"name":"Secret"}},
|
||||
"clickEvent":{"action":"suggest_command","value":"/msg Secret "}},
|
||||
{"text":"Secret says hello","clickEvent":{"action":"copy_to_clipboard","value":"Secret"}}]}
|
||||
""";
|
||||
var result = JsonParser.parseString(masker.maskAnnouncement(input)).getAsJsonObject();
|
||||
var args = result.getAsJsonArray("with");
|
||||
assertEquals(JsonParser.parseString("{\"text\":\"Anonymous\",\"obfuscated\":true,\"hoverEvent\":null,\"clickEvent\":null,\"insertion\":\"\"}"), args.get(0));
|
||||
assertEquals(JsonParser.parseString(input).getAsJsonObject().getAsJsonArray("with").get(1), args.get(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void handlesPrivateMessagesAndAdvancementNamesOnly() {
|
||||
for (String key : Set.of("commands.message.display.incoming", "commands.message.display.outgoing",
|
||||
"chat.type.advancement.task", "chat.type.advancement.goal", "chat.type.advancement.challenge")) {
|
||||
String input = "{\"translate\":\"" + key + "\",\"with\":[\"secret\",{\"text\":\"Secret\"}]}";
|
||||
var args = JsonParser.parseString(masker.maskAnnouncement(input)).getAsJsonObject().getAsJsonArray("with");
|
||||
assertEquals("Anonymous", args.get(0).getAsJsonObject().get("text").getAsString());
|
||||
assertEquals("Secret", args.get(1).getAsJsonObject().get("text").getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotTraverseMessageBodyEvenWhenItContainsChatTranslations() {
|
||||
String input = """
|
||||
{"translate":"chat.type.text","with":["Ordinary",
|
||||
{"translate":"chat.type.text","with":["Secret","body"]}]}
|
||||
""";
|
||||
assertEquals(input, masker.maskAnnouncement(input));
|
||||
}
|
||||
|
||||
@Test
|
||||
void handlesNestedAnnouncementsButNotPlainTextOrUnknownTranslations() {
|
||||
assertTrue(masker.maskAnnouncement("{\"text\":\"\",\"extra\":[{\"translate\":\"chat.type.text\",\"with\":[\"Secret\",\"hello\"]}]}").contains("Anonymous"));
|
||||
for (String input : Set.of("{\"text\":\"Secret: hello\"}", "{\"translate\":\"custom\",\"with\":[\"Secret\"]}", "not json")) {
|
||||
assertEquals(input, masker.maskAnnouncement(input));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void removesInheritedActionsFromArrayParent() {
|
||||
String json = """
|
||||
[{"text":"","hover_event":{"action":"show_text","value":"Secret"}},
|
||||
{"translate":"chat.type.text","with":["Secret","hello"]}]
|
||||
""";
|
||||
assertFalse(masker.maskAnnouncement(json).contains("Secret"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void recognizesSplitNamesAndMasksWholeNameComponent() {
|
||||
String name = "{\"text\":\"Sec\",\"extra\":[{\"text\":\"ret\"}],\"insertion\":\"Secret\"}";
|
||||
assertTrue(masker.maskDisplayName(name).contains("Anonymous"));
|
||||
assertFalse(masker.maskDisplayName(name).contains("Secret"));
|
||||
assertEquals("\"NotSecret\"", masker.maskDisplayName("\"NotSecret\""));
|
||||
}
|
||||
}
|
||||
@@ -62,8 +62,9 @@ class StealthSessionListenerTest {
|
||||
QualifyingInvisibilityService progression = new QualifyingInvisibilityService(
|
||||
manager, Duration.ofHours(8), System::nanoTime, ignored -> { });
|
||||
IdentityPresentation presentation = mock(IdentityPresentation.class);
|
||||
StealthSessionService sessions = new StealthSessionService(manager, progression);
|
||||
StealthSessionListener listener = new StealthSessionListener(
|
||||
new StealthSessionService(manager, progression), presentation, "Stealth active");
|
||||
sessions, presentation, "Stealth active");
|
||||
Player player = player(playerId);
|
||||
PlayerJoinEvent event = mock(PlayerJoinEvent.class);
|
||||
when(event.getPlayer()).thenReturn(player);
|
||||
@@ -73,6 +74,10 @@ class StealthSessionListenerTest {
|
||||
verify(event).setJoinMessage(null);
|
||||
verify(presentation).conceal(player);
|
||||
verify(player).sendMessage("Stealth active");
|
||||
Player admin = player(UUID.randomUUID());
|
||||
when(admin.hasPermission("spigotstealth.admin")).thenReturn(true);
|
||||
new StealthAdminJoinListener(sessions, () -> java.util.List.of(player, admin)).onJoin(event);
|
||||
verify(admin).sendMessage("[Stealth] An invisible player joined. 1 player is now invisible. Use /stealthadmin list to see who.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user