2 Commits
Author SHA1 Message Date
dmg 8b412141da feat(stealth): route concealed public chat as system messages
Release / release (push) Successful in 4m59s
CI / build (push) Successful in 2m46s
Preserve final moderation, selected recipients, viewer-aware formatting, native content filters and client chat settings. Retain profile/chat sessions through tab-list status updates. Verify real native pipeline, packet codecs, RSA signed message chains and acknowledgement bookkeeping without changing authentication settings. No deployment is included.
2026-09-12 22:05:08 -04:00
dmg c1bd4406d2 feat(stealth): break concealed sessions on player damage
Release / release (push) Successful in 4m55s
CI / build (push) Successful in 2m47s
Resolve direct native damage sources and explicitly observed poison/wither provenance, including hidden layers and effective replacements. Preserve progression and potion effects while reusing identity/session cleanup. Verify native effect application, ticks, tab codecs and plugin registration. No deployment is included.
2026-09-12 21:09:10 -04:00
19 changed files with 1252 additions and 6 deletions
+13 -1
View File
@@ -10,6 +10,18 @@ Progression-gated identity concealment for Purpur 26.2 build 2618, built and tes
Artifacts are written to `build/libs/purpur-stealth-<version>.jar`. Gitea CI verifies pushes/pull requests; approved main-branch conventional commits drive versioned releases. The distribution tests inspect the built JAR, embedded version/runtime identity, Java 25 bytecode, and workflow naming. Artifacts are written to `build/libs/purpur-stealth-<version>.jar`. Gitea CI verifies pushes/pull requests; approved main-branch conventional commits drive versioned releases. The distribution tests inspect the built JAR, embedded version/runtime identity, Java 25 bytecode, and workflow naming.
## Combat breaks stealth
Dealing uncancelled positive damage to another player ends the attacker's concealed session and tells them: **Your stealth was broken because you hurt another player.** Melee, player-attributed projectiles and potions qualify; misses, cancelled/zero hits, self-damage, attacking mobs and merely taking damage do not. Identity and prior sleep-count participation are restored, including withdrawing Eye projections. Progress, unlocks and unrelated invisibility remain intact; re-entry uses the existing qualifying logout/login process.
Delayed poison/wither use explicitly observed application sources and read-only native effect-layer snapshots, including hidden-layer restoration and rejected/cosmetic replacements. No nearby-player or wall-clock guess is made. Provenance is runtime-only and discarded on victim disconnect; unknown effects loaded after reconnect/restart are not assigned an invented attacker. Public chat uses the anonymous system-message route below.
## Anonymous public chat
Concealed players' public chat is delivered as unsigned system messages, normally **[Stealth] Anonymous: hello**. The final Paper chat pipeline retains selected recipients, cancellation and moderated content, preserves original per-viewer formatting, and enforces native content filtering and client chat visibility. Plain output removes identifying interactive metadata and anonymizes the sender's name/UUID. Private commands/messages are not rerouted. Ordinary senders keep native signed chat.
Tab hiding uses `UPDATE_LISTED=false`, not profile deletion; server initialization updates cannot relist concealed players for other observers. Existing profile and chat-session data remain available when normal signed chat resumes. No secure-profile/authentication settings are changed. Native signature, packet and acknowledgement regressions cover the route; live-client validation remains a separate follow-up. System messages do not provide signed-player authenticity or equivalent reporting support.
## Eye of True Seeing ## Eye of True Seeing
Earn a separate, personal unlock through eight cumulative online hours of Night Vision from directly drunk potions. Splash/lingering potions, commands, plugins and other sources do not count. Rejected and cosmetic-only replacements do not change attribution. Unattributed restored effects cannot inherit the drink timer; a new effective drink can start a new interval. Offline time is excluded. Earn a separate, personal unlock through eight cumulative online hours of Night Vision from directly drunk potions. Splash/lingering potions, commands, plugins and other sources do not count. Rejected and cosmetic-only replacements do not change attribution. Unattributed restored effects cannot inherit the drink timer; a new effective drink can start a new interval. Offline time is excluded.
@@ -30,6 +42,6 @@ The repository and checkout are now `purpur-stealth` (previously remote `spigot-
Replace the old plugin JAR when installing the new distribution; never load both JARs together. Old tags and `spigot-stealth-*` release assets are preserved. Repository rename redirects have been checked against the previously deployed v1.5.0 download. Publishing a release does not authorize deployment. Replace the old plugin JAR when installing the new distribution; never load both JARs together. Old tags and `spigot-stealth-*` release assets are preserved. Repository rename redirects have been checked against the previously deployed v1.5.0 download. Publishing a release does not authorize deployment.
The v2.0.0 migration itself left the Eye, combat reveal and concealed-chat stories pending. Subsequent Eye acquisition and local revelation work is described above; combat-session breaking and system-chat routing remain separate unfinished stories. Runtime plugin metadata is unchanged. The v2.0.0 migration itself left the Eye, combat reveal and concealed-chat stories pending. Subsequent Eye acquisition, local revelation, combat-session breaking and anonymous system-chat routing are described above. Runtime plugin metadata is unchanged.
See the [canonical project](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-stealth/index.md), [stories](https://git.garvis.dev/dmg/somc-okf/src/branch/main/user-stories/purpur-stealth/index.md), and [development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md). See the [canonical project](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-stealth/index.md), [stories](https://git.garvis.dev/dmg/somc-okf/src/branch/main/user-stories/purpur-stealth/index.md), and [development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md).
@@ -0,0 +1,44 @@
package games.dmg.spigotstealth;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageEvent;
/** Ends identity concealment on attributed, successful player damage; never removes potion effects. */
public final class CombatRevealListener implements Listener {
private final StealthSessionService sessions;
private final IdentityPresentation presentation;
private final Consumer<UUID> withdrawEyeTarget;
private final Consumer<Throwable> failures;
private final java.util.function.Function<EntityDamageEvent, org.bukkit.entity.Player> potionAttacker;
public CombatRevealListener(StealthSessionService sessions, IdentityPresentation presentation,
Consumer<UUID> withdrawEyeTarget, Consumer<Throwable> failures) {
this(sessions, presentation, withdrawEyeTarget, failures, event -> null);
}
public CombatRevealListener(StealthSessionService sessions, IdentityPresentation presentation,
Consumer<UUID> withdrawEyeTarget, Consumer<Throwable> failures,
java.util.function.Function<EntityDamageEvent, org.bukkit.entity.Player> potionAttacker) {
this.sessions = java.util.Objects.requireNonNull(sessions);
this.presentation = java.util.Objects.requireNonNull(presentation);
this.withdrawEyeTarget = java.util.Objects.requireNonNull(withdrawEyeTarget);
this.failures = java.util.Objects.requireNonNull(failures);
this.potionAttacker = java.util.Objects.requireNonNull(potionAttacker);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onDamage(EntityDamageEvent event) {
if (event.isCancelled() || !Double.isFinite(event.getFinalDamage()) || event.getFinalDamage() <= 0
|| !(event.getEntity() instanceof org.bukkit.entity.Player victim)) { return; }
var attacker = event.getDamageSource().getCausingEntity() instanceof org.bukkit.entity.Player direct ? direct : potionAttacker.apply(event);
if (attacker == null || attacker.getUniqueId().equals(victim.getUniqueId()) || !sessions.isConcealed(attacker.getUniqueId())) { return; }
sessions.endConcealment(attacker.getUniqueId()).exceptionally(failure -> { failures.accept(failure); return null; });
withdrawEyeTarget.accept(attacker.getUniqueId());
presentation.reveal(attacker);
attacker.sendMessage("Your stealth was broken because you hurt another player.");
}
}
@@ -44,6 +44,8 @@ final class EyeRevealRuntime implements AutoCloseable {
} }
} }
public void withdrawTarget(UUID player) { controller.withdrawTarget(player); }
@Override public void close() { @Override public void close() {
if (closed) { return; } if (closed) { return; }
closed = true; closed = true;
@@ -0,0 +1,44 @@
package games.dmg.spigotstealth;
import java.lang.reflect.Method;
import net.kyori.adventure.audience.Audience;
import net.kyori.adventure.chat.SignedMessage;
import org.bukkit.entity.Player;
/** Preserves the native per-recipient text-filter decision when selecting system transport. */
final class NativeChatFiltering {
record Result(boolean applies, String text) { }
private final Method handle, shouldFilter, nativeMessage, mask, empty, apply, visibility;
private final Object fullVisibility;
NativeChatFiltering() {
try {
var player = Class.forName("net.minecraft.server.level.ServerPlayer");
var message = Class.forName("net.minecraft.network.chat.PlayerChatMessage");
var filter = Class.forName("net.minecraft.network.chat.FilterMask");
handle = Class.forName("org.bukkit.craftbukkit.entity.CraftPlayer").getMethod("getHandle");
shouldFilter = player.getMethod("shouldFilterMessageTo", player);
visibility = player.getMethod("getChatVisibility");
fullVisibility = Class.forName("net.minecraft.world.entity.player.ChatVisiblity").getField("FULL").get(null);
nativeMessage = Class.forName("net.minecraft.network.chat.PlayerChatMessage$AdventureView").getMethod("playerChatMessage");
mask = message.getMethod("filterMask");
empty = filter.getMethod("isEmpty");
apply = filter.getMethod("apply", String.class);
} catch (ReflectiveOperationException failure) { throw new IllegalStateException("Unsupported native chat filtering layout", failure); }
}
Result filter(Player sender, Audience audience, SignedMessage message) throws ReflectiveOperationException {
int depth = 0;
while (audience instanceof net.kyori.adventure.audience.ForwardingAudience.Single single) {
if (++depth > 64) { throw new IllegalStateException("Invalid forwarding audience chain"); }
audience = single.audience();
}
if (!(audience instanceof Player recipient)) { return new Result(false, null); }
Object target = handle.invoke(recipient);
// Public chat must not slip through a client's commands-only/hidden setting merely because transport is SYSTEM.
if (visibility.invoke(target) != fullVisibility) { return new Result(true, null); }
if (!(boolean) shouldFilter.invoke(handle.invoke(sender), target)) { return new Result(false, null); }
Object filter = mask.invoke(nativeMessage.invoke(message));
return (boolean) empty.invoke(filter) ? new Result(false, null) : new Result(true, (String) apply.invoke(filter, message.message()));
}
}
@@ -0,0 +1,40 @@
package games.dmg.spigotstealth;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import org.bukkit.entity.Player;
/** Read-only, server-thread snapshots of the exact Purpur effect objects and their hidden layers. */
final class NativePotionEffects {
private final Method handle, effect, duration, amplifier;
private final Field hidden;
private final Object poison, wither;
NativePotionEffects() {
try {
var living = Class.forName("net.minecraft.world.entity.LivingEntity");
var instance = Class.forName("net.minecraft.world.effect.MobEffectInstance");
var effects = Class.forName("net.minecraft.world.effect.MobEffects");
handle = Class.forName("org.bukkit.craftbukkit.entity.CraftLivingEntity").getMethod("getHandle");
effect = living.getMethod("getEffect", Class.forName("net.minecraft.core.Holder"));
duration = instance.getMethod("getDuration");
amplifier = instance.getMethod("getAmplifier");
hidden = instance.getField("hiddenEffect");
poison = effects.getField("POISON").get(null);
wither = effects.getField("WITHER").get(null);
} catch (ReflectiveOperationException exception) {
throw new IllegalStateException("Unsupported native potion provenance layout", exception);
}
}
PotionDamageAttribution.Snapshot snapshot(Player player, PotionDamageAttribution.Kind kind) throws ReflectiveOperationException {
Object current = effect.invoke(handle.invoke(player), kind == PotionDamageAttribution.Kind.POISON ? poison : wither);
return current == null ? null : new PotionDamageAttribution.Snapshot(current, read(current, 0));
}
private PotionDamageAttribution.Effect read(Object current, int depth) throws ReflectiveOperationException {
if (current == null) { return null; }
if (depth > 256) { throw new IllegalStateException("Invalid native effect chain"); }
return new PotionDamageAttribution.Effect((int) amplifier.invoke(current), (int) duration.invoke(current), read(hidden.get(current), depth + 1));
}
}
@@ -0,0 +1,96 @@
package games.dmg.spigotstealth;
import java.util.Optional;
import java.util.UUID;
/** Runtime provenance of poison/wither layers, reconciled against actual native effect state. */
final class PotionDamageAttribution {
enum Kind { POISON, WITHER }
record Effect(int amplifier, int duration, Effect hidden) { }
record Snapshot(Object identity, Effect effect) { }
private record Key(UUID victim, Kind kind) { }
private record Owned(int amplifier, int duration, UUID source, Owned hidden) { }
private record Entry(Object identity, Owned effect) { }
private final java.util.Map<Key, Entry> entries = new java.util.HashMap<>();
public void change(UUID victim, Kind kind, Snapshot before, Effect incoming, UUID source, boolean override) {
var key = new Key(victim, kind);
if (incoming == null) { entries.remove(key); return; }
var old = reconcile(entries.get(key), before);
if (old == null && before != null) { old = unowned(before.effect()); }
var next = merge(old, incoming, source, override);
entries.put(key, new Entry(before == null ? null : before.identity(), next));
}
public void forget(UUID victim) { entries.keySet().removeIf(key -> key.victim().equals(victim)); }
public void clear() { entries.clear(); }
private static Owned merge(Owned old, Effect incoming, UUID source, boolean override) {
if (old == null) { return new Owned(incoming.amplifier(), incoming.duration(), source, null); }
if (override && incoming.amplifier() > old.amplifier()) {
return new Owned(incoming.amplifier(), incoming.duration(), source,
shorter(incoming.duration(), old.duration()) ? old : old.hidden());
}
if (shorter(old.duration(), incoming.duration())) {
if (override && incoming.amplifier() == old.amplifier()) {
return new Owned(old.amplifier(), incoming.duration(), source, old.hidden());
}
if (incoming.amplifier() < old.amplifier()) {
return new Owned(old.amplifier(), old.duration(), old.source(), merge(old.hidden(), incoming, source, true));
}
}
return old; // Rejected or cosmetic-only changes do not replace the damaging layer.
}
public Optional<UUID> attacker(UUID victim, Kind kind, Snapshot current) {
var key = new Key(victim, kind);
var owned = reconcile(entries.get(key), current);
if (owned == null || owned.duration() != -1 && owned.duration() <= 0) { entries.remove(key); return Optional.empty(); }
entries.put(key, new Entry(current.identity(), owned));
return Optional.ofNullable(owned.source());
}
private static Owned reconcile(Entry entry, Snapshot current) {
if (entry == null || current == null || entry.identity() != null && entry.identity() != current.identity()) { return null; }
int expired = 0;
for (var candidate = entry.effect(); candidate != null; candidate = candidate.hidden()) {
if (matches(candidate, current.effect(), expired)) { return rebase(candidate, current.effect()); }
if (candidate.duration() == -1) { break; } // An infinite active layer cannot expire into its hidden layer.
expired = Math.max(expired, candidate.duration());
}
return null;
}
private static boolean shorter(int duration, int other) {
return duration != -1 && (other == -1 || duration < other);
}
/** All layers count down together. Solve their elapsed-duration constraints, including frozen/infinite effects. */
private static boolean matches(Owned expected, Effect actual, int expired) {
long minimum = expired, maximum = Long.MAX_VALUE;
while (expected != null && actual != null) {
if (expected.amplifier() != actual.amplifier() || actual.duration() < -1) { return false; }
if (expected.duration() == -1) {
if (actual.duration() != -1) { return false; }
} else {
if (actual.duration() == -1 || actual.duration() > expected.duration()) { return false; }
if (actual.duration() == 0) { minimum = Math.max(minimum, expected.duration()); }
else {
long elapsed = (long) expected.duration() - actual.duration();
minimum = Math.max(minimum, elapsed);
maximum = Math.min(maximum, elapsed);
}
}
expected = expected.hidden();
actual = actual.hidden();
}
return expected == null && actual == null && minimum <= maximum;
}
private static Owned rebase(Owned owner, Effect actual) {
return owner == null ? null : new Owned(actual.amplifier(), actual.duration(), owner.source(), rebase(owner.hidden(), actual.hidden()));
}
private static Owned unowned(Effect effect) {
return effect == null ? null : new Owned(effect.amplifier(), effect.duration(), null, unowned(effect.hidden()));
}
}
@@ -0,0 +1,71 @@
package games.dmg.spigotstealth;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.EntityPotionEffectEvent;
/** Observes explicit effect sources; does not change effects or infer a nearby attacker. */
final class PotionDamageListener implements Listener, Function<EntityDamageEvent, Player> {
private final Function<UUID, Player> onlinePlayer;
private final Consumer<Throwable> failures;
private final PotionDamageAttribution attribution = new PotionDamageAttribution();
private final NativePotionEffects effects = new NativePotionEffects();
private boolean reported;
PotionDamageListener(Function<UUID, Player> onlinePlayer, Consumer<Throwable> failures) {
this.onlinePlayer = java.util.Objects.requireNonNull(onlinePlayer);
this.failures = java.util.Objects.requireNonNull(failures);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onEffect(EntityPotionEffectEvent event) {
if (event.isCancelled() || !(event.getEntity() instanceof Player player)) { return; }
var kind = kind(event.getModifiedType());
if (kind == null) { return; }
try {
var incoming = event.getNewEffect();
var source = source(event.getSource());
attribution.change(player.getUniqueId(), kind, effects.snapshot(player, kind),
incoming == null ? null : new PotionDamageAttribution.Effect(Math.clamp(incoming.getAmplifier(), 0, 255), incoming.getDuration(), null),
source == null ? null : source.getUniqueId(), event.isOverride());
} catch (ReflectiveOperationException | RuntimeException exception) { attribution.forget(player.getUniqueId()); report(exception); }
}
@Override public Player apply(EntityDamageEvent event) {
if (!(event.getEntity() instanceof Player player)) { return null; }
var kind = switch (event.getCause()) {
case POISON -> PotionDamageAttribution.Kind.POISON;
case WITHER -> PotionDamageAttribution.Kind.WITHER;
default -> null;
};
if (kind == null) { return null; }
try {
return attribution.attacker(player.getUniqueId(), kind, effects.snapshot(player, kind)).map(onlinePlayer).orElse(null);
} catch (ReflectiveOperationException | RuntimeException exception) { attribution.forget(player.getUniqueId()); report(exception); return null; }
}
@EventHandler(priority = EventPriority.MONITOR)
public void onQuit(org.bukkit.event.player.PlayerQuitEvent event) { attribution.forget(event.getPlayer().getUniqueId()); }
public void close() { attribution.clear(); }
private static PotionDamageAttribution.Kind kind(org.bukkit.potion.PotionEffectType type) {
if (org.bukkit.potion.PotionEffectType.POISON.equals(type)) { return PotionDamageAttribution.Kind.POISON; }
if (org.bukkit.potion.PotionEffectType.WITHER.equals(type)) { return PotionDamageAttribution.Kind.WITHER; }
return null;
}
private static Player source(org.bukkit.entity.Entity entity) {
if (entity instanceof Player player) { return player; }
if (entity instanceof org.bukkit.entity.Projectile projectile && projectile.getShooter() instanceof Player player) { return player; }
if (entity instanceof org.bukkit.entity.AreaEffectCloud cloud && cloud.getSource() instanceof Player player) { return player; }
return null;
}
private void report(Throwable failure) {
if (!reported) { reported = true; failures.accept(failure); }
}
}
@@ -14,7 +14,7 @@ import java.util.Objects;
import java.util.UUID; import java.util.UUID;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
/** ProtocolLib adapter for tab-list-only removal and restoration packets. */ /** Changes tab listing without deleting the client profile or signed-chat session. */
public final class ProtocolLibTabListController implements TabListController { public final class ProtocolLibTabListController implements TabListController {
private final ProtocolManager protocolManager; private final ProtocolManager protocolManager;
@@ -24,8 +24,11 @@ public final class ProtocolLibTabListController implements TabListController {
@Override @Override
public void remove(Player observer, UUID targetPlayerId) { public void remove(Player observer, UUID targetPlayerId) {
PacketContainer packet = protocolManager.createPacket(PacketType.Play.Server.PLAYER_INFO_REMOVE); PacketContainer packet = protocolManager.createPacket(PacketType.Play.Server.PLAYER_INFO);
packet.getUUIDLists().write(0, List.of(targetPlayerId)); packet.getPlayerInfoActions().write(0, EnumSet.of(EnumWrappers.PlayerInfoAction.UPDATE_LISTED));
PlayerInfoData data = new PlayerInfoData(targetPlayerId, 0, false, EnumWrappers.NativeGameMode.SURVIVAL,
null, null, (WrappedRemoteChatSessionData) null);
packet.getPlayerInfoDataLists().write(1, List.of(data));
protocolManager.sendServerPacket(observer, packet); protocolManager.sendServerPacket(observer, packet);
} }
@@ -33,7 +36,6 @@ public final class ProtocolLibTabListController implements TabListController {
public void add(Player observer, Player target) { public void add(Player observer, Player target) {
PacketContainer packet = protocolManager.createPacket(PacketType.Play.Server.PLAYER_INFO); PacketContainer packet = protocolManager.createPacket(PacketType.Play.Server.PLAYER_INFO);
packet.getPlayerInfoActions().write(0, EnumSet.of( packet.getPlayerInfoActions().write(0, EnumSet.of(
EnumWrappers.PlayerInfoAction.ADD_PLAYER,
EnumWrappers.PlayerInfoAction.UPDATE_GAME_MODE, EnumWrappers.PlayerInfoAction.UPDATE_GAME_MODE,
EnumWrappers.PlayerInfoAction.UPDATE_LISTED, EnumWrappers.PlayerInfoAction.UPDATE_LISTED,
EnumWrappers.PlayerInfoAction.UPDATE_LATENCY, EnumWrappers.PlayerInfoAction.UPDATE_LATENCY,
@@ -23,6 +23,7 @@ public final class SpigotStealthPlugin extends JavaPlugin {
private EyePotionListener eyePotions; private EyePotionListener eyePotions;
private boolean eyeRecipeRegistered; private boolean eyeRecipeRegistered;
private EyeRevealRuntime eyeReveal; private EyeRevealRuntime eyeReveal;
private PotionDamageListener combatPotions;
@Override @Override
public void onEnable() { public void onEnable() {
@@ -69,6 +70,7 @@ public final class SpigotStealthPlugin extends JavaPlugin {
logSaveFailure(eyeProgression.stopAll()); logSaveFailure(eyeProgression.stopAll());
} }
if (progression != null) { logSaveFailure(progression.stopAll()); } if (progression != null) { logSaveFailure(progression.stopAll()); }
if (combatPotions != null) { combatPotions.close(); }
if (eyeReveal != null) { eyeReveal.close(); } if (eyeReveal != null) { eyeReveal.close(); }
if (protocolManager != null) { protocolManager.removePacketListeners(this); } if (protocolManager != null) { protocolManager.removePacketListeners(this); }
if (identityPresentation != null && sessions != null) { if (identityPresentation != null && sessions != null) {
@@ -121,6 +123,7 @@ public final class SpigotStealthPlugin extends JavaPlugin {
identityPresentation = bukkitPresentation; identityPresentation = bukkitPresentation;
protocolManager.addPacketListener(new ProtocolLibIdentityMaskingListener( protocolManager.addPacketListener(new ProtocolLibIdentityMaskingListener(
this, bukkitPresentation::concealedNames, getLogger()::warning)); this, bukkitPresentation::concealedNames, getLogger()::warning));
protocolManager.addPacketListener(new TabListMaskingListener(this, sessions::concealedPlayerIds, getLogger()::warning));
protocolManager.addPacketListener(new ProtocolLibServerListPingListener( protocolManager.addPacketListener(new ProtocolLibServerListPingListener(
this, sessions::concealedPlayerIds, getLogger()::warning)); this, sessions::concealedPlayerIds, getLogger()::warning));
getServer().getPluginManager().registerEvents( getServer().getPluginManager().registerEvents(
@@ -173,8 +176,15 @@ public final class SpigotStealthPlugin extends JavaPlugin {
try { try {
eyeReveal = new EyeRevealRuntime(this, eyeEquipment, sessions::isConcealed, protocolManager); eyeReveal = new EyeRevealRuntime(this, eyeEquipment, sessions::isConcealed, protocolManager);
eyeReveal.start(); eyeReveal.start();
getServer().getPluginManager().registerEvents(new StealthSystemChat(sessions::isConcealed,
failure -> getLogger().warning("Anonymous public-chat delivery failed closed: " + rootMessage(failure))), this);
java.util.function.Consumer<Throwable> combatFailure = failure -> getLogger().warning("Unable to complete combat reveal: " + rootMessage(failure));
combatPotions = new PotionDamageListener(getServer()::getPlayer, combatFailure);
getServer().getPluginManager().registerEvents(combatPotions, this);
getServer().getPluginManager().registerEvents(new CombatRevealListener(sessions, identityPresentation,
eyeReveal::withdrawTarget, combatFailure, combatPotions), this);
} catch (RuntimeException exception) { } catch (RuntimeException exception) {
getLogger().severe("Unable to initialize Eye rendering: " + rootMessage(exception)); getLogger().severe("Unable to initialize Stealth gameplay: " + rootMessage(exception));
getServer().getPluginManager().disablePlugin(this); getServer().getPluginManager().disablePlugin(this);
return; return;
} }
@@ -0,0 +1,64 @@
package games.dmg.spigotstealth;
import io.papermc.paper.event.player.ChatEvent;
import java.util.UUID;
import java.util.function.Predicate;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
/** Public-chat transport selection after moderation; private command messages are not handled here. */
public final class StealthSystemChat implements Listener {
private final Predicate<UUID> concealed;
private final NativeChatFiltering filtering = new NativeChatFiltering();
private final java.util.function.Consumer<Throwable> failures;
public StealthSystemChat(Predicate<UUID> concealed) {
this(concealed, failure -> System.getLogger(StealthSystemChat.class.getName()).log(System.Logger.Level.WARNING, "System chat delivery failed closed", failure));
}
public StealthSystemChat(Predicate<UUID> concealed, java.util.function.Consumer<Throwable> failures) {
this.concealed = java.util.Objects.requireNonNull(concealed);
this.failures = java.util.Objects.requireNonNull(failures);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onChat(ChatEvent event) {
if (event.isCancelled() || !concealed.test(event.getPlayer().getUniqueId())) { return; }
// Transport adaptation only, after the ordinary moderation priorities. Do not cancel or deliver here:
// Paper must still make its final cancellation decision before invoking any recipient.
var renderer = event.renderer();
var hiddenIdentity = java.util.regex.Pattern.compile(java.util.regex.Pattern.quote(event.getPlayer().getUniqueId().toString())
+ "|(?<![A-Za-z0-9_])" + java.util.regex.Pattern.quote(event.getPlayer().getName()) + "(?![A-Za-z0-9_])", java.util.regex.Pattern.CASE_INSENSITIVE);
var recipients = event.viewers().stream().map(viewer -> new SystemRecipient(viewer, hiddenIdentity, event.getPlayer())).toList();
event.viewers().clear();
event.viewers().addAll(recipients);
event.renderer((source, displayName, message, viewer) -> {
var original = viewer instanceof SystemRecipient recipient ? recipient.original : viewer;
if (renderer instanceof io.papermc.paper.chat.ChatRenderer.Default) {
return net.kyori.adventure.text.Component.text("[Stealth] Anonymous: ").append(message);
}
return renderer.render(source, net.kyori.adventure.text.Component.text("Anonymous"), message, original);
});
}
/** Deliberately not ForwardingAudience.Single: Paper unwraps it to the native signed-player path. */
private final class SystemRecipient implements net.kyori.adventure.audience.Audience {
private final net.kyori.adventure.audience.Audience original;
private final java.util.regex.Pattern hiddenIdentity;
private final org.bukkit.entity.Player sender;
private SystemRecipient(net.kyori.adventure.audience.Audience original, java.util.regex.Pattern hiddenIdentity, org.bukkit.entity.Player sender) {
this.original = original;
this.hiddenIdentity = hiddenIdentity;
this.sender = sender;
}
@Override public void sendMessage(net.kyori.adventure.chat.SignedMessage message, net.kyori.adventure.chat.ChatType.Bound type) {
try {
var filtered = filtering.filter(sender, original, message);
if (filtered.applies() && filtered.text() == null) { return; }
var body = message.unsignedContent() == null ? net.kyori.adventure.text.Component.text(message.message()) : message.unsignedContent();
var plain = filtered.applies() ? "[Stealth] Anonymous: " + filtered.text()
: net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(body);
original.sendMessage(net.kyori.adventure.text.Component.text(hiddenIdentity.matcher(plain).replaceAll("Anonymous")));
} catch (ReflectiveOperationException | RuntimeException failure) { failures.accept(failure); }
}
}
}
@@ -0,0 +1,60 @@
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.PacketEvent;
import java.util.Set;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.function.Supplier;
import org.bukkit.plugin.Plugin;
/** Unlists concealed entries without removing native profiles or chat-session data. */
public final class TabListMaskingListener extends PacketAdapter {
private final Supplier<Set<UUID>> concealed;
private final Consumer<String> warning;
private final java.util.concurrent.atomic.AtomicBoolean warned = new java.util.concurrent.atomic.AtomicBoolean();
public TabListMaskingListener(Plugin plugin, Supplier<Set<UUID>> concealed, Consumer<String> warning) {
super(plugin, ListenerPriority.HIGHEST, PacketType.Play.Server.PLAYER_INFO);
this.concealed = java.util.Objects.requireNonNull(concealed);
this.warning = java.util.Objects.requireNonNull(warning);
}
@Override public void onPacketSending(PacketEvent event) {
if (event.isCancelled() || event.isPlayerTemporary() || event.getPlayer() == null) { return; }
try {
var snapshot = concealed.get();
if (snapshot.isEmpty()) { return; }
Object original = event.getPacket().getHandle();
Object projected = project(original, snapshot, event.getPlayer().getUniqueId());
if (projected != original) { event.setPacket(new com.comphenix.protocol.events.PacketContainer(PacketType.Play.Server.PLAYER_INFO, projected)); }
} catch (ReflectiveOperationException | RuntimeException failure) {
if (warned.compareAndSet(false, true)) { warning.accept("Tab listing projection unavailable; native profile/session retained: " + failure.getClass().getSimpleName()); }
}
}
private static Object project(Object original, Set<UUID> hidden, UUID viewer) throws ReflectiveOperationException {
var packetType = Class.forName("net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket");
var actions = (java.util.EnumSet<?>) packetType.getMethod("actions").invoke(original);
if (actions.stream().noneMatch(action -> action.name().equals("UPDATE_LISTED"))) { return original; }
var entries = (java.util.List<?>) packetType.getMethod("entries").invoke(original);
var projected = new java.util.ArrayList<Object>(entries.size());
boolean changed = false;
for (var entry : entries) {
var type = entry.getClass();
UUID id = (UUID) type.getMethod("profileId").invoke(entry);
if (!id.equals(viewer) && hidden.contains(id) && (boolean) type.getMethod("listed").invoke(entry)) {
var components = type.getRecordComponents();
var parameterTypes = new Class<?>[components.length];
var values = new Object[components.length];
for (int i = 0; i < components.length; i++) {
parameterTypes[i] = components[i].getType();
values[i] = components[i].getName().equals("listed") ? false : components[i].getAccessor().invoke(entry);
}
projected.add(type.getConstructor(parameterTypes).newInstance(values));
changed = true;
} else { projected.add(entry); }
}
return changed ? packetType.getConstructor(java.util.EnumSet.class, java.util.List.class).newInstance(actions.clone(), java.util.List.copyOf(projected)) : original;
}
}
@@ -0,0 +1,135 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import net.minecraft.world.scores.Scoreboard;
import org.bukkit.craftbukkit.scoreboard.CraftScoreboard;
import org.bukkit.entity.Player;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent.DamageCause;
import org.junit.jupiter.api.BeforeAll;
class CombatRevealTest {
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"melee", "arrow", "instant-potion", "cancelled", "zero", "self", "mob-target", "taking-only", "miss", "poison", "wither"})
void attributedDamageEndsOnlyAttackerSessionAndRestoresIdentityExactlyOnce(String kind) throws Exception {
Player attacker = player("Attacker"), victim = player("Victim");
var initial = new PersistentStealthState(Map.of(), Map.of())
.withPlayer(unlocked(attacker).withSession(true, false))
.withPlayer(unlocked(victim).withSession(true, false));
var repository = new StealthStateRepository() {
@Override public PersistentStealthState load() { return initial; }
@Override public void save(PersistentStealthState state) { }
};
try (var states = new StealthStateManager(repository, initial)) {
var progression = new QualifyingInvisibilityService(states, Duration.ofHours(8), () -> 0L, id -> { });
var sessions = new StealthSessionService(states, progression);
sessions.login(attacker.getUniqueId(), attacker.getName()).saved().join();
sessions.login(victim.getUniqueId(), victim.getName()).saved().join();
var board = new Scoreboard();
var constructor = CraftScoreboard.class.getDeclaredConstructor(Scoreboard.class);
constructor.setAccessible(true);
var restoredTabs = new ArrayList<UUID>();
var tabs = new TabListController() {
@Override public void remove(Player observer, UUID target) { }
@Override public void add(Player observer, Player target) { restoredTabs.add(target.getUniqueId()); }
};
var presentation = new BukkitIdentityPresentation(() -> List.of(attacker, victim), constructor.newInstance(board), tabs);
presentation.conceal(attacker);
presentation.conceal(victim);
var withdrawn = new ArrayList<UUID>();
var failures = new ArrayList<Throwable>();
var potions = new PotionDamageListener(id -> id.equals(attacker.getUniqueId()) ? attacker : null, failures::add);
var listener = new CombatRevealListener(sessions, presentation, withdrawn::add, failures::add, potions);
boolean delayed = kind.equals("poison") || kind.equals("wither");
if (delayed) {
var type = kind.equals("poison") ? org.bukkit.potion.PotionEffectType.POISON : org.bukkit.potion.PotionEffectType.WITHER;
var nativeType = kind.equals("poison") ? net.minecraft.world.effect.MobEffects.POISON : net.minecraft.world.effect.MobEffects.WITHER;
potions.onEffect(new org.bukkit.event.entity.EntityPotionEffectEvent(victim, null,
new org.bukkit.potion.PotionEffect(type, 100, 0), attacker,
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.POTION_SPLASH,
org.bukkit.event.entity.EntityPotionEffectEvent.Action.ADDED, true));
when(((org.bukkit.craftbukkit.entity.CraftPlayer) victim).getHandle().getEffect(nativeType))
.thenReturn(new net.minecraft.world.effect.MobEffectInstance(nativeType, 100, 0));
}
var sources = new net.minecraft.world.damagesource.DamageSources(new net.minecraft.core.RegistryAccess.ImmutableRegistryAccess(
List.of(org.bukkit.craftbukkit.CraftRegistry.getMinecraftRegistry(net.minecraft.core.registries.Registries.DAMAGE_TYPE))));
var handle = ((org.bukkit.craftbukkit.entity.CraftPlayer) attacker).getHandle();
var nativeSource = switch (kind) {
case "arrow" -> sources.arrow(mock(net.minecraft.world.entity.projectile.arrow.AbstractArrow.class), handle);
case "instant-potion" -> sources.indirectMagic(mock(net.minecraft.world.entity.Entity.class), handle);
case "taking-only", "poison" -> sources.magic();
case "wither" -> sources.wither();
default -> sources.playerAttack(handle);
};
var source = new org.bukkit.craftbukkit.damage.CraftDamageSource(nativeSource);
org.bukkit.entity.Entity target = switch (kind) {
case "mob-target" -> mock(org.bukkit.entity.Zombie.class);
case "self" -> attacker;
default -> victim;
};
org.bukkit.event.entity.EntityDamageEvent damage = delayed
? new org.bukkit.event.entity.EntityDamageEvent(victim, kind.equals("poison") ? DamageCause.POISON : DamageCause.WITHER, source, 1.0)
: new EntityDamageByEntityEvent(attacker, target, DamageCause.ENTITY_ATTACK, source, kind.equals("zero") ? 0 : 3.0);
damage.setCancelled(kind.equals("cancelled"));
var before = states.snapshot();
if (!kind.equals("miss")) { listener.onDamage(damage); }
if (java.util.Set.of("cancelled", "zero", "self", "mob-target", "taking-only", "miss").contains(kind)) {
assertEquals(before, states.snapshot());
assertEquals(java.util.Set.of("Attacker", "Victim"), presentation.concealedNames());
assertTrue(restoredTabs.isEmpty());
assertTrue(withdrawn.isEmpty());
verify(attacker, never()).sendMessage(anyString());
return;
}
assertFalse(sessions.isConcealed(attacker.getUniqueId()), "positive damage must end the attacker's active session");
assertTrue(sessions.isConcealed(victim.getUniqueId()), "taking damage alone must not end the victim's session");
assertEquals(java.util.Set.of(victim.getUniqueId()), sessions.concealedPlayerIds());
assertEquals(java.util.Set.of("Victim"), presentation.concealedNames());
assertNull(board.getPlayerTeam(BukkitIdentityPresentation.teamName(attacker.getUniqueId())));
assertNotNull(board.getPlayerTeam(BukkitIdentityPresentation.teamName(victim.getUniqueId())));
verify(attacker).setDisplayName("Original Attacker");
verify(attacker).setSleepingIgnored(false);
assertEquals(List.of(attacker.getUniqueId()), restoredTabs);
assertEquals(List.of(attacker.getUniqueId()), withdrawn);
verify(attacker).sendMessage("Your stealth was broken because you hurt another player.");
verify(attacker, never()).removePotionEffect(any());
verify(attacker, never()).setInvisible(anyBoolean());
assertTrue(states.snapshot().player(attacker.getUniqueId()).unlocked());
assertEquals(28_800_000, states.snapshot().player(attacker.getUniqueId()).accumulatedMillis());
assertFalse(states.snapshot().player(attacker.getUniqueId()).preparedLogin());
listener.onDamage(damage);
assertEquals(1, restoredTabs.size());
verify(attacker, times(1)).sendMessage("Your stealth was broken because you hurt another player.");
assertFalse(sessions.login(attacker.getUniqueId(), attacker.getName()).concealed(), "damage must not prepare a direct re-entry");
progression.begin(attacker.getUniqueId(), attacker.getName(), java.time.Instant.EPOCH).join();
sessions.disconnect(attacker.getUniqueId()).join();
assertTrue(sessions.login(attacker.getUniqueId(), attacker.getName()).concealed(), "a later qualifying logout/login still works");
states.save().join();
assertTrue(failures.isEmpty());
}
}
private static Player player(String name) {
var player = mock(org.bukkit.craftbukkit.entity.CraftPlayer.class);
var handle = mock(net.minecraft.server.level.ServerPlayer.class);
when(handle.getBukkitEntity()).thenReturn(player);
when(player.getHandle()).thenReturn(handle);
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
when(player.getName()).thenReturn(name);
when(player.getDisplayName()).thenReturn("Original " + name);
return player;
}
private static PlayerStealthState unlocked(Player player) {
return new PlayerStealthState(player.getUniqueId(), player.getName(), 28_800_000, true, false, false, null, Map.of());
}
}
@@ -136,6 +136,10 @@ class EyePluginLifecycleTest {
assertTrue(new EyeItems().isEye(recipe.get().getResult())); assertTrue(new EyeItems().isEye(recipe.get().getResult()));
assertTrue(listeners.stream().anyMatch(EyePotionListener.class::isInstance)); assertTrue(listeners.stream().anyMatch(EyePotionListener.class::isInstance));
assertTrue(listeners.stream().anyMatch(EyeEquipment.class::isInstance)); assertTrue(listeners.stream().anyMatch(EyeEquipment.class::isInstance));
assertTrue(listeners.stream().anyMatch(CombatRevealListener.class::isInstance), "plugin startup must install combat breaking");
assertTrue(listeners.stream().anyMatch(StealthSystemChat.class::isInstance), "plugin startup must register anonymous public-chat routing");
assertTrue(registeredPackets.stream().anyMatch(TabListMaskingListener.class::isInstance), "plugin startup must preserve profile/session data while hiding tab entries");
assertTrue(listeners.stream().anyMatch(PotionDamageListener.class::isInstance), "plugin startup must observe potion provenance");
assertTrue(listeners.stream().anyMatch(EyeRevealEvents.class::isInstance), "plugin startup must register reveal invalidation hooks"); assertTrue(listeners.stream().anyMatch(EyeRevealEvents.class::isInstance), "plugin startup must register reveal invalidation hooks");
assertTrue(registeredPackets.stream().anyMatch(EyePacketListener.class::isInstance), "plugin startup must register the per-observer packet gate"); assertTrue(registeredPackets.stream().anyMatch(EyePacketListener.class::isInstance), "plugin startup must register the per-observer packet gate");
var inventory = mock(CraftingInventory.class); var inventory = mock(CraftingInventory.class);
@@ -0,0 +1,74 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.util.ArrayList;
import java.util.UUID;
import net.minecraft.network.chat.*;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.ClientboundPlayerChatPacket;
import net.minecraft.network.protocol.game.ClientboundSystemChatPacket;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.network.ServerGamePacketListenerImpl;
import org.bukkit.craftbukkit.CraftRegistry;
import org.bukkit.craftbukkit.entity.CraftPlayer;
/** Real native dispatch/codec/signature/ack state, with only the socket and already-authenticated player boundaries doubled. */
final class NativeChatWire {
final ArrayList<Packet<?>> packets = new ArrayList<>();
final LastSeenMessagesValidator serverSeen = new LastSeenMessagesValidator(20);
private final LastSeenMessagesTracker clientSeen = new LastSeenMessagesTracker(20);
private final MessageSignatureCache clientCache = MessageSignatureCache.createDefault();
private int nextChatIndex;
NativeChatWire(CraftPlayer recipient) throws Exception {
var player = recipient.getHandle();
set(ServerPlayer.class, player, "chatVisibility", net.minecraft.world.entity.player.ChatVisiblity.FULL);
var connection = mock(ServerGamePacketListenerImpl.class);
player.connection = connection;
when(connection.getCraftPlayer()).thenReturn(recipient);
when(recipient.canSeePlayer(any(UUID.class))).thenReturn(true);
set(ServerGamePacketListenerImpl.class, connection, "lastSeenMessages", serverSeen);
set(ServerGamePacketListenerImpl.class, connection, "messageSignatureCache", MessageSignatureCache.createDefault());
doCallRealMethod().when(connection).sendPlayerChatMessage(any(PlayerChatMessage.class), any(ChatType.Bound.class));
doCallRealMethod().when(player).sendChatMessage(any(OutgoingChatMessage.class), anyBoolean(), any(ChatType.Bound.class), nullable(Component.class));
doCallRealMethod().when(recipient).sendMessage(any(net.kyori.adventure.text.Component.class));
doAnswer(call -> { packets.add(call.getArgument(0)); return null; }).when(connection).send(org.mockito.ArgumentMatchers.<Packet<?>>any());
}
void receive(boolean system, UUID session, SignedMessageValidator validator, String expected) throws Exception {
assertEquals(1, packets.size(), "exactly one packet for the selected recipient");
var buffer = new net.minecraft.network.RegistryFriendlyByteBuf(io.netty.buffer.Unpooled.buffer(), CraftRegistry.getMinecraftRegistry());
try {
if (system) {
var packet = assertInstanceOf(ClientboundSystemChatPacket.class, packets.getFirst());
ClientboundSystemChatPacket.STREAM_CODEC.encode(buffer, packet);
var decoded = ClientboundSystemChatPacket.STREAM_CODEC.decode(buffer);
assertFalse(decoded.overlay());
assertEquals("[Stealth] Anonymous: " + expected, decoded.content().getString());
} else {
var packet = assertInstanceOf(ClientboundPlayerChatPacket.class, packets.getFirst());
ClientboundPlayerChatPacket.STREAM_CODEC.encode(buffer, packet);
var decoded = ClientboundPlayerChatPacket.STREAM_CODEC.decode(buffer);
assertEquals(nextChatIndex++, decoded.globalIndex(), "system messages must not create signed packet-index gaps");
var body = decoded.body().unpack(clientCache).orElseThrow();
var message = new PlayerChatMessage(new SignedMessageLink(decoded.index(), decoded.sender(), session), decoded.signature(), body,
decoded.unsignedContent(), decoded.filterMask());
assertEquals(expected, body.content());
assertNotNull(validator.updateAndValidate(message), "the retained native client signature chain must accept normal chat again");
assertTrue(clientSeen.addPending(decoded.signature(), true));
clientCache.push(body, decoded.signature());
}
} finally { buffer.release(); }
var acknowledgement = clientSeen.generateAndApplyUpdate();
assertEquals(acknowledgement.lastSeen(), serverSeen.applyUpdate(acknowledgement.update()));
assertEquals(20, serverSeen.trackedMessagesCount(), "no invisible signed packets may accumulate in server pending state");
packets.clear();
}
private static void set(Class<?> type, Object receiver, String name, Object value) throws Exception {
var field = type.getDeclaredField(name);
field.setAccessible(true);
field.set(receiver, value);
}
}
@@ -0,0 +1,83 @@
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.ProtocolManager;
import com.comphenix.protocol.events.PacketContainer;
import java.util.ArrayList;
import java.util.UUID;
import net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket;
import org.bukkit.Bukkit;
import org.bukkit.craftbukkit.CraftServer;
import org.bukkit.craftbukkit.entity.CraftPlayer;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
class NativeCombatTabRestoreTest {
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test
@SuppressWarnings("try")
void sharedRestorationProducesARealListedPlayerEntryWithCurrentIdentity() {
var server = mock(CraftServer.class);
try (var platform = mockStatic(Bukkit.class, call -> switch (call.getMethod().getName()) {
case "getServer" -> server;
case "isPrimaryThread" -> true;
case "getVersion" -> "Purpur 2618 (MC: 26.2)";
case "getMinecraftVersion" -> "26.2";
case "getBukkitVersion" -> "26.2-R0.1-SNAPSHOT";
default -> call.callRealMethod();
})) {
var target = mock(CraftPlayer.class);
var observer = mock(CraftPlayer.class);
UUID id = UUID.randomUUID();
when(target.getUniqueId()).thenReturn(id);
when(target.getName()).thenReturn("Attacker");
when(target.getPlayerListName()).thenReturn("Original Attacker");
when(target.getGameMode()).thenReturn(org.bukkit.GameMode.SURVIVAL);
when(target.getPing()).thenReturn(37);
var handle = mock(net.minecraft.server.level.ServerPlayer.class);
handle.gameProfile = new com.mojang.authlib.GameProfile(id, "Attacker");
when(target.getHandle()).thenReturn(handle);
when(target.getProfile()).thenReturn(handle.gameProfile);
var protocol = mock(ProtocolManager.class);
when(protocol.createPacket(any(PacketType.class))).thenAnswer(call -> new PacketContainer(call.getArgument(0)));
var sent = new ArrayList<PacketContainer>();
doAnswer(call -> { sent.add(call.getArgument(1)); return null; }).when(protocol).sendServerPacket(eq(observer), any(PacketContainer.class));
var controller = new ProtocolLibTabListController(protocol);
controller.remove(observer, id);
var hidden = assertInstanceOf(ClientboundPlayerInfoUpdatePacket.class, sent.getFirst().getHandle(),
"tab hiding must not remove the profile/chat session needed for later signed messages");
assertEquals(java.util.EnumSet.of(ClientboundPlayerInfoUpdatePacket.Action.UPDATE_LISTED), hidden.actions());
assertEquals(id, hidden.entries().getFirst().profileId());
assertFalse(hidden.entries().getFirst().listed());
sent.clear();
controller.add(observer, target);
var packet = assertInstanceOf(ClientboundPlayerInfoUpdatePacket.class, sent.getFirst().getHandle());
assertFalse(packet.actions().contains(ClientboundPlayerInfoUpdatePacket.Action.ADD_PLAYER), "restoration must retain the existing profile/chat session");
assertTrue(packet.actions().contains(ClientboundPlayerInfoUpdatePacket.Action.UPDATE_LISTED));
var entry = packet.entries().getFirst();
assertEquals(id, entry.profileId());
assertEquals("Attacker", entry.profile().name());
assertTrue(entry.listed());
assertEquals(37, entry.latency());
assertEquals(net.minecraft.world.level.GameType.SURVIVAL, entry.gameMode());
assertEquals("Original Attacker", entry.displayName().getString());
var buffer = new net.minecraft.network.RegistryFriendlyByteBuf(io.netty.buffer.Unpooled.buffer(), org.bukkit.craftbukkit.CraftRegistry.getMinecraftRegistry());
try {
ClientboundPlayerInfoUpdatePacket.STREAM_CODEC.encode(buffer, packet);
var decoded = ClientboundPlayerInfoUpdatePacket.STREAM_CODEC.decode(buffer);
assertEquals(packet.actions(), decoded.actions());
var restored = decoded.entries().getFirst();
assertEquals(entry.profileId(), restored.profileId());
assertEquals(entry.listed(), restored.listed());
assertEquals(entry.latency(), restored.latency());
assertEquals(entry.gameMode(), restored.gameMode());
assertEquals(entry.displayName(), restored.displayName());
assertNull(restored.profile(), "no profile replacement is encoded");
assertNull(restored.chatSession(), "no chat-session replacement is encoded");
} finally { buffer.release(); }
}
}
}
@@ -0,0 +1,104 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.UUID;
import net.minecraft.core.RegistryAccess;
import net.minecraft.core.registries.Registries;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.damagesource.DamageSource;
import net.minecraft.world.damagesource.DamageSources;
import net.minecraft.world.effect.MobEffectInstance;
import net.minecraft.world.effect.MobEffects;
import net.minecraft.world.entity.LivingEntity;
import org.bukkit.Bukkit;
import org.bukkit.craftbukkit.CraftRegistry;
import org.bukkit.craftbukkit.damage.CraftDamageSource;
import org.bukkit.craftbukkit.entity.CraftPlayer;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.EntityPotionEffectEvent;
import org.bukkit.plugin.PluginManager;
import org.junit.jupiter.api.BeforeAll;
class NativePotionDamageTest {
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
@SuppressWarnings("try")
void actualNativeApplicationAndDamageTickKeepTheObservedPlayerSource(boolean wither) throws Exception {
var manager = mock(PluginManager.class);
try (var platform = mockStatic(Bukkit.class, call -> switch (call.getMethod().getName()) {
case "getPluginManager" -> manager;
case "isPrimaryThread" -> true;
default -> call.callRealMethod();
})) {
var victim = mock(CraftPlayer.class);
var attacker = mock(CraftPlayer.class);
UUID victimId = UUID.randomUUID(), attackerId = UUID.randomUUID();
when(victim.getUniqueId()).thenReturn(victimId);
when(attacker.getUniqueId()).thenReturn(attackerId);
var nativeVictim = mock(ServerPlayer.class);
var nativeAttacker = mock(ServerPlayer.class);
when(victim.getHandle()).thenReturn(nativeVictim);
when(nativeVictim.getBukkitEntity()).thenReturn(victim);
when(nativeVictim.getBukkitLivingEntity()).thenReturn(victim);
when(nativeAttacker.getBukkitEntity()).thenReturn(attacker);
var active = new HashMap<net.minecraft.core.Holder<net.minecraft.world.effect.MobEffect>, MobEffectInstance>();
var field = LivingEntity.class.getDeclaredField("activeEffects");
field.setAccessible(true);
field.set(nativeVictim, active);
when(nativeVictim.canBeAffected(any(MobEffectInstance.class))).thenReturn(true);
when(nativeVictim.getEffect(any())).thenAnswer(call -> active.get(call.getArgument(0)));
when(nativeVictim.addEffect(any(MobEffectInstance.class), any(net.minecraft.world.entity.Entity.class), any(EntityPotionEffectEvent.Cause.class), anyBoolean())).thenCallRealMethod();
var failures = new ArrayList<Throwable>();
var attribution = new PotionDamageListener(id -> id.equals(attackerId) ? attacker : null, failures::add);
var applications = new ArrayList<EntityPotionEffectEvent>();
doAnswer(call -> {
if (call.getArgument(0) instanceof EntityPotionEffectEvent event) {
applications.add(event);
attribution.onEffect(event);
}
return null;
}).when(manager).callEvent(any());
var type = wither ? MobEffects.WITHER : MobEffects.POISON;
var potion = new MobEffectInstance(type, wither ? 80 : 100, 0);
assertTrue(nativeVictim.addEffect(potion, nativeAttacker, EntityPotionEffectEvent.Cause.POTION_SPLASH, true));
assertSame(attacker, applications.getFirst().getSource());
assertSame(potion, active.get(type));
var world = mock(ServerLevel.class);
var config = mock(org.purpurmc.purpur.PurpurWorldConfig.class);
config.entityMinimalHealthPoison = 1;
config.entityPoisonDegenerationAmount = 1;
config.entityWitherDegenerationAmount = 1;
var worldConfig = net.minecraft.world.level.Level.class.getField("purpurConfig");
worldConfig.setAccessible(true);
worldConfig.set(world, config);
when(nativeVictim.level()).thenReturn(world);
when(nativeVictim.getHealth()).thenReturn(10f);
var sources = new DamageSources(new RegistryAccess.ImmutableRegistryAccess(List.of(CraftRegistry.getMinecraftRegistry(Registries.DAMAGE_TYPE))));
when(nativeVictim.damageSources()).thenReturn(sources);
var resolved = new ArrayList<UUID>();
doAnswer(call -> {
DamageSource raw = call.getArgument(1);
assertNull(raw.getEntity(), "native delayed damage does not carry the applying player's identity");
var event = new EntityDamageEvent(victim, wither ? EntityDamageEvent.DamageCause.WITHER : EntityDamageEvent.DamageCause.POISON,
new CraftDamageSource(raw), ((Float) call.getArgument(2)).doubleValue());
var responsible = attribution.apply(event);
assertNotNull(responsible, "observed native potion provenance must resolve the responsible player");
resolved.add(responsible.getUniqueId());
return true;
}).when(nativeVictim).hurtServer(eq(world), any(DamageSource.class), anyFloat());
assertTrue(potion.tickServer(world, nativeVictim, () -> { }));
assertEquals(List.of(attackerId), resolved);
attribution.onQuit(new org.bukkit.event.player.PlayerQuitEvent(victim, net.kyori.adventure.text.Component.empty()));
assertNull(attribution.apply(new EntityDamageEvent(victim, wither ? EntityDamageEvent.DamageCause.WITHER : EntityDamageEvent.DamageCause.POISON,
new CraftDamageSource(wither ? sources.wither() : sources.magic()), 1.0)), "disconnect must discard runtime-only victim provenance");
assertTrue(failures.isEmpty(), failures.toString());
}
}
}
@@ -0,0 +1,203 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import io.papermc.paper.adventure.ChatProcessor;
import io.papermc.paper.event.player.AsyncChatEvent;
import io.papermc.paper.event.player.ChatEvent;
import java.util.List;
import java.util.UUID;
import net.kyori.adventure.text.Component;
import net.minecraft.network.chat.PlayerChatMessage;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import org.bukkit.Bukkit;
import org.bukkit.craftbukkit.CraftRegistry;
import org.bukkit.craftbukkit.CraftServer;
import org.bukkit.craftbukkit.CraftWorld;
import org.bukkit.craftbukkit.entity.CraftPlayer;
import org.bukkit.event.EventHandler;
import org.bukkit.event.HandlerList;
import org.bukkit.plugin.EventExecutor;
import org.bukkit.plugin.PluginManager;
import org.bukkit.plugin.RegisteredListener;
import org.bukkit.plugin.java.JavaPlugin;
import org.junit.jupiter.api.BeforeAll;
class NativeSystemChatTest {
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"local", "cancelled", "late-cancelled", "ordinary", "metadata", "custom-renderer", "transition", "filtered", "partially-filtered", "commands-only", "chat-hidden", "signed-chain", "async-main", "legacy", "legacy-cancelled"})
@SuppressWarnings("try")
void actualPaperPipelinePreservesModerationAndAnonymousSystemDelivery(String scenario) throws Exception {
var server = mock(CraftServer.class);
var console = mock(org.bukkit.command.ConsoleCommandSender.class);
try (var platform = mockStatic(Bukkit.class, call -> switch (call.getMethod().getName()) {
case "getServer" -> server;
case "getConsoleSender" -> console;
case "isPrimaryThread" -> true;
default -> call.callRealMethod();
})) {
var sender = mock(CraftPlayer.class);
var recipient = mock(CraftPlayer.class);
var outsider = mock(CraftPlayer.class);
UUID senderId = UUID.randomUUID();
when(sender.getUniqueId()).thenReturn(senderId);
when(sender.getName()).thenReturn("Secret");
when(sender.displayName()).thenReturn(Component.text("Secret"));
when(sender.getDisplayName()).thenReturn("Secret");
var nativeSender = mock(ServerPlayer.class);
var nativeRecipient = mock(ServerPlayer.class);
var nativeOutsider = mock(ServerPlayer.class);
when(sender.getHandle()).thenReturn(nativeSender);
when(recipient.getHandle()).thenReturn(nativeRecipient);
when(outsider.getHandle()).thenReturn(nativeOutsider);
when(nativeSender.getBukkitEntity()).thenReturn(sender);
when(nativeRecipient.getBukkitEntity()).thenReturn(recipient);
when(nativeRecipient.getChatVisibility()).thenReturn(scenario.equals("commands-only") ? net.minecraft.world.entity.player.ChatVisiblity.SYSTEM
: scenario.equals("chat-hidden") ? net.minecraft.world.entity.player.ChatVisiblity.HIDDEN : net.minecraft.world.entity.player.ChatVisiblity.FULL);
when(nativeOutsider.getBukkitEntity()).thenReturn(outsider);
var world = mock(CraftWorld.class);
var nativeWorld = mock(ServerLevel.class);
when(sender.getWorld()).thenReturn(world);
when(world.getHandle()).thenReturn(nativeWorld);
when(nativeSender.level()).thenReturn(nativeWorld);
when(nativeWorld.registryAccess()).thenReturn(CraftRegistry.getMinecraftRegistry());
var config = mock(io.papermc.paper.configuration.WorldConfiguration.class);
config.scoreboards = mock(io.papermc.paper.configuration.WorldConfiguration.Scoreboards.class);
when(nativeWorld.paperConfig()).thenReturn(config);
var nativeServer = mock(MinecraftServer.class);
nativeServer.server = server;
nativeServer.console = console;
var playerList = mock(net.minecraft.server.players.PlayerList.class);
when(nativeServer.getPlayerList()).thenReturn(playerList);
when(playerList.getPlayers()).thenReturn(List.of(nativeSender, nativeRecipient, nativeOutsider));
var plugin = mock(JavaPlugin.class);
when(plugin.isEnabled()).thenReturn(true);
var concealed = new java.util.concurrent.atomic.AtomicBoolean(!scenario.equals("ordinary"));
var listener = new StealthSystemChat(id -> senderId.equals(id) && concealed.get());
var method = StealthSystemChat.class.getMethod("onChat", ChatEvent.class);
var annotation = method.getAnnotation(EventHandler.class);
ChatEvent.getHandlerList().register(new RegisteredListener(listener, EventExecutor.create(method, ChatEvent.class), annotation.priority(), plugin, annotation.ignoreCancelled()));
org.bukkit.event.Listener legacy = new org.bukkit.event.Listener() { };
if (scenario.startsWith("legacy")) {
org.bukkit.event.player.AsyncPlayerChatEvent.getHandlerList().register(new RegisteredListener(legacy, (registered, event) -> {
var chat = (org.bukkit.event.player.AsyncPlayerChatEvent) event;
chat.getRecipients().retainAll(java.util.Set.of(recipient));
chat.setMessage("moderated");
chat.setFormat("[Local] %1$s: %2$s");
if (scenario.equals("legacy-cancelled")) { chat.setCancelled(true); }
}, org.bukkit.event.EventPriority.HIGHEST, plugin, false));
}
var manager = mock(PluginManager.class);
when(server.getPluginManager()).thenReturn(manager);
doAnswer(call -> {
var event = call.getArgument(0, org.bukkit.event.Event.class);
if (event instanceof AsyncChatEvent chat && !scenario.equals("async-main")) {
chat.viewers().retainAll(java.util.Set.of(recipient)); // Ordinary local-chat/moderation stage.
if (scenario.equals("cancelled")) { chat.setCancelled(true); }
if (scenario.equals("metadata")) {
chat.message(Component.text("hello Secret").hoverEvent(net.kyori.adventure.text.event.HoverEvent.showEntity(
net.kyori.adventure.key.Key.key("minecraft:player"), senderId, Component.text("Secret")))
.clickEvent(net.kyori.adventure.text.event.ClickEvent.suggestCommand("/msg Secret hi")).insertion(senderId.toString()));
}
if (scenario.equals("custom-renderer")) {
chat.renderer((source, display, message, viewer) -> {
assertSame(recipient, viewer, "viewer-aware formatting must see the original recipient");
return Component.text("[Local] ").append(display).append(Component.text(": ")).append(message);
});
}
}
if (event instanceof ChatEvent chat && scenario.equals("async-main")) {
assertFalse(chat.isAsynchronous());
chat.viewers().retainAll(java.util.Set.of(recipient));
}
for (var registered : event.getHandlers().getRegisteredListeners()) { registered.callEvent(event); }
if (event instanceof ChatEvent chat && scenario.equals("late-cancelled")) { chat.setCancelled(true); }
return null;
}).when(manager).callEvent(any());
try {
if (scenario.equals("signed-chain")) {
verifySignedChain(nativeServer, nativeSender, recipient, senderId, concealed);
return;
}
var message = PlayerChatMessage.unsigned(senderId, "hello");
if (scenario.endsWith("filtered")) {
when(nativeSender.shouldFilterMessageTo(nativeRecipient)).thenReturn(true);
var mask = new net.minecraft.network.chat.FilterMask(5);
mask.setFiltered(0);
message = message.filter(scenario.equals("filtered") ? net.minecraft.network.chat.FilterMask.FULLY_FILTERED : mask);
}
if (scenario.equals("async-main")) {
var queue = new java.util.concurrent.LinkedBlockingQueue<Runnable>();
var queueField = MinecraftServer.class.getDeclaredField("processQueue");
queueField.setAccessible(true);
queueField.set(nativeServer, queue);
Thread main = Thread.currentThread();
doAnswer(call -> { assertSame(main, Thread.currentThread(), "system delivery must stay on the main event thread"); return null; }).when(recipient).sendMessage(any(Component.class));
var processor = new ChatProcessor(nativeServer, nativeSender, message, true);
var completed = java.util.concurrent.CompletableFuture.runAsync(processor::process);
var work = queue.poll(5, java.util.concurrent.TimeUnit.SECONDS);
assertNotNull(work, "native asynchronous chat must queue its synchronous event");
work.run();
completed.get(5, java.util.concurrent.TimeUnit.SECONDS);
} else { new ChatProcessor(nativeServer, nativeSender, message, false).process(); }
if (scenario.equals("ordinary")) {
verify(recipient, never()).sendMessage(any(Component.class));
verify(nativeRecipient).sendChatMessage(any(net.minecraft.network.chat.OutgoingChatMessage.class), anyBoolean(), any(net.minecraft.network.chat.ChatType.Bound.class), nullable(net.minecraft.network.chat.Component.class));
return;
}
if (scenario.contains("cancelled") || java.util.Set.of("filtered", "commands-only", "chat-hidden").contains(scenario)) { verify(recipient, never()).sendMessage(any(Component.class)); }
else {
String expected = scenario.equals("legacy") ? "[Local] Anonymous: moderated" : scenario.equals("partially-filtered") ? "[Stealth] Anonymous: #ello" : scenario.equals("custom-renderer") ? "[Local] Anonymous: hello" : "[Stealth] Anonymous: hello" + (scenario.equals("metadata") ? " Anonymous" : "");
verify(recipient).sendMessage(Component.text(expected));
}
verify(nativeRecipient, never()).sendChatMessage(any(net.minecraft.network.chat.OutgoingChatMessage.class), anyBoolean(), any(net.minecraft.network.chat.ChatType.Bound.class), any());
verify(outsider, never()).sendMessage(any(Component.class));
verify(console, never()).sendMessage(any(Component.class));
verify(sender, never()).sendMessage(any(Component.class));
verify(nativeOutsider, never()).sendChatMessage(any(net.minecraft.network.chat.OutgoingChatMessage.class), anyBoolean(), any(net.minecraft.network.chat.ChatType.Bound.class), any());
if (scenario.equals("transition")) {
concealed.set(false);
new ChatProcessor(nativeServer, nativeSender, PlayerChatMessage.unsigned(senderId, "normal"), false).process();
verify(nativeRecipient).sendChatMessage(any(net.minecraft.network.chat.OutgoingChatMessage.class), anyBoolean(), any(net.minecraft.network.chat.ChatType.Bound.class), nullable(net.minecraft.network.chat.Component.class));
verify(recipient, times(1)).sendMessage(any(Component.class));
}
} finally { HandlerList.unregisterAll(listener); HandlerList.unregisterAll(legacy); }
}
}
private static void verifySignedChain(MinecraftServer server, ServerPlayer sender, CraftPlayer recipient, UUID senderId,
java.util.concurrent.atomic.AtomicBoolean concealed) throws Exception {
var generator = java.security.KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
var keys = generator.generateKeyPair();
var publicKey = new net.minecraft.world.entity.player.ProfilePublicKey(new net.minecraft.world.entity.player.ProfilePublicKey.Data(
java.time.Instant.now().plusSeconds(3600), keys.getPublic(), new byte[0]));
UUID session = UUID.randomUUID();
var encoder = new net.minecraft.network.chat.SignedMessageChain(senderId, session)
.encoder(net.minecraft.util.Signer.from(keys.getPrivate(), "SHA256withRSA"));
var decoder = new net.minecraft.network.chat.SignedMessageChain(senderId, session).decoder(publicKey);
var receiver = new net.minecraft.network.chat.SignedMessageValidator.KeyBased(publicKey.createSignatureValidator(), publicKey.data()::hasExpired);
var wire = new NativeChatWire(recipient);
for (int index = 0; index < 66; index++) {
boolean anonymous = index > 0 && index < 65;
concealed.set(anonymous);
String text = "hello " + index;
var body = new net.minecraft.network.chat.SignedMessageBody(text, java.time.Instant.now(), index + 1L, net.minecraft.network.chat.LastSeenMessages.EMPTY);
var signature = encoder.pack(body);
var message = decoder.unpack(signature, body);
assertTrue(message.verify(publicKey.createSignatureValidator()));
assertEquals(index, message.link().index());
new ChatProcessor(server, sender, message, false).process();
wire.receive(anonymous, session, receiver, text);
}
var invalid = new net.minecraft.network.chat.SignedMessageChain(senderId, session).decoder(publicKey);
assertThrows(net.minecraft.network.chat.SignedMessageChain.DecodeException.class,
() -> invalid.unpack(new net.minecraft.network.chat.MessageSignature(new byte[256]), net.minecraft.network.chat.SignedMessageBody.unsigned("tampered")));
assertThrows(net.minecraft.network.chat.LastSeenMessagesValidator.ValidationException.class,
() -> new net.minecraft.network.chat.LastSeenMessagesValidator(20).applyOffset(Integer.MAX_VALUE));
}
}
@@ -0,0 +1,81 @@
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.PacketContainer;
import com.comphenix.protocol.events.PacketEvent;
import java.util.EnumSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
class NativeTabMaskingTest {
private static net.minecraft.network.chat.RemoteChatSession.Data session;
@BeforeAll static void bootstrap() throws Exception {
NativeRuntime.bootstrap();
var generator = java.security.KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
session = new net.minecraft.network.chat.RemoteChatSession.Data(UUID.randomUUID(), new net.minecraft.world.entity.player.ProfilePublicKey.Data(
java.time.Instant.now().plusSeconds(3600).truncatedTo(java.time.temporal.ChronoUnit.MILLIS), generator.generateKeyPair().getPublic(), new byte[0]));
}
@Test
@SuppressWarnings("try")
void serverInitializationKeepsProfileAndChatStateButCannotRelistAConcealedPlayer() {
var server = mock(org.bukkit.craftbukkit.CraftServer.class);
try (var platform = mockStatic(org.bukkit.Bukkit.class, call -> switch (call.getMethod().getName()) {
case "getServer" -> server;
case "isPrimaryThread" -> true;
case "getVersion" -> "Purpur 2618 (MC: 26.2)";
case "getMinecraftVersion" -> "26.2";
case "getBukkitVersion" -> "26.2-R0.1-SNAPSHOT";
default -> call.callRealMethod();
})) {
UUID hiddenId = UUID.randomUUID(), otherId = UUID.randomUUID(), viewerId = UUID.randomUUID();
var viewer = mock(org.bukkit.entity.Player.class);
when(viewer.getUniqueId()).thenReturn(viewerId);
var snapshot = new java.util.concurrent.atomic.AtomicReference<>(Set.of(hiddenId));
var warnings = new java.util.ArrayList<String>();
var listener = new TabListMaskingListener(mock(org.bukkit.plugin.Plugin.class), snapshot::get, warnings::add);
var hidden = entry(hiddenId, "Hidden");
var other = entry(otherId, "Other");
var original = new ClientboundPlayerInfoUpdatePacket(EnumSet.allOf(ClientboundPlayerInfoUpdatePacket.Action.class), List.of(hidden, other));
var packet = new PacketContainer(PacketType.Play.Server.PLAYER_INFO, original);
var event = PacketEvent.fromServer(this, packet, viewer);
listener.onPacketSending(event);
var projected = assertInstanceOf(ClientboundPlayerInfoUpdatePacket.class, event.getPacket().getHandle());
assertFalse(projected.entries().getFirst().listed(), "native initialization must not undo concealment");
assertEquals(original.actions(), projected.actions());
var expected = new ClientboundPlayerInfoUpdatePacket.Entry(hiddenId, hidden.profile(), false, hidden.latency(), hidden.gameMode(), hidden.displayName(), hidden.showHat(), hidden.listOrder(), hidden.chatSession());
assertEquals(expected, projected.entries().getFirst());
assertSame(other, projected.entries().get(1));
assertTrue(original.entries().getFirst().listed());
var buffer = new net.minecraft.network.RegistryFriendlyByteBuf(io.netty.buffer.Unpooled.buffer(), org.bukkit.craftbukkit.CraftRegistry.getMinecraftRegistry());
try {
ClientboundPlayerInfoUpdatePacket.STREAM_CODEC.encode(buffer, projected);
var decoded = ClientboundPlayerInfoUpdatePacket.STREAM_CODEC.decode(buffer);
assertEquals(projected.actions(), decoded.actions());
assertEquals(projected.entries(), decoded.entries(), "profile/session and all other native fields survive the wire codec");
} finally { buffer.release(); }
when(viewer.getUniqueId()).thenReturn(hiddenId);
var self = PacketEvent.fromServer(this, packet, viewer);
listener.onPacketSending(self);
assertSame(packet, self.getPacket(), "retain existing self-tab behavior");
snapshot.set(Set.of());
var ended = PacketEvent.fromServer(this, packet, viewer);
listener.onPacketSending(ended);
assertSame(packet, ended.getPacket());
assertTrue(warnings.isEmpty(), warnings.toString());
}
}
private static ClientboundPlayerInfoUpdatePacket.Entry entry(UUID id, String name) {
return new ClientboundPlayerInfoUpdatePacket.Entry(id, new com.mojang.authlib.GameProfile(id, name), true, 37,
net.minecraft.world.level.GameType.SURVIVAL, net.minecraft.network.chat.Component.literal(name), true, 8,
session);
}
}
@@ -0,0 +1,117 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import java.util.UUID;
import net.minecraft.world.effect.MobEffectInstance;
import net.minecraft.world.effect.MobEffects;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
class PotionDamageAttributionTest {
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test
void actualPoisonCountdownRetainsTheExplicitSourceWithoutAClockGuess() {
UUID victim = UUID.randomUUID(), owner = UUID.randomUUID();
var attribution = new PotionDamageAttribution();
var poison = new MobEffectInstance(MobEffects.POISON, 100, 0);
attribution.change(victim, PotionDamageAttribution.Kind.POISON, null, effect(poison), owner, true);
assertEquals(java.util.Optional.of(owner), attribution.attacker(victim, PotionDamageAttribution.Kind.POISON, snapshot(poison)));
for (int tick = 0; tick < 20; tick++) { poison.tickClient(); }
assertEquals(java.util.Optional.of(owner), attribution.attacker(victim, PotionDamageAttribution.Kind.POISON, snapshot(poison)));
assertTrue(attribution.attacker(UUID.randomUUID(), PotionDamageAttribution.Kind.POISON, snapshot(poison)).isEmpty());
}
@Test
void strongerShortPotionReturnsOwnershipToTheRestoredHiddenLayer() {
UUID victim = UUID.randomUUID(), first = UUID.randomUUID(), second = UUID.randomUUID();
var attribution = new PotionDamageAttribution();
var poison = new MobEffectInstance(MobEffects.POISON, 100, 0);
attribution.change(victim, PotionDamageAttribution.Kind.POISON, null, effect(poison), first, true);
assertEquals(java.util.Optional.of(first), attribution.attacker(victim, PotionDamageAttribution.Kind.POISON, snapshot(poison)));
var stronger = new MobEffectInstance(MobEffects.POISON, 20, 1);
attribution.change(victim, PotionDamageAttribution.Kind.POISON, snapshot(poison), effect(stronger), second, true);
assertTrue(poison.update(stronger));
assertEquals(java.util.Optional.of(second), attribution.attacker(victim, PotionDamageAttribution.Kind.POISON, snapshot(poison)));
for (int tick = 0; tick < 20; tick++) { poison.tickClient(); }
assertEquals(0, poison.getAmplifier());
assertEquals(80, poison.getDuration());
assertEquals(java.util.Optional.of(first), attribution.attacker(victim, PotionDamageAttribution.Kind.POISON, snapshot(poison)));
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"weaker-hidden", "equal-shorter", "cosmetic", "equal-longer", "stronger-longer", "rejected-stronger", "unknown-stronger"})
void nativeReplacementRulesDoNotGiveRejectedOrCosmeticSourcesOwnership(String scenario) {
UUID victim = UUID.randomUUID(), first = UUID.randomUUID(), second = UUID.randomUUID();
var attribution = new PotionDamageAttribution();
boolean weaker = scenario.equals("weaker-hidden");
var poison = new MobEffectInstance(MobEffects.POISON, weaker ? 20 : 100, weaker ? 2 : 0);
attribution.change(victim, PotionDamageAttribution.Kind.POISON, null, effect(poison), first, true);
int amp = scenario.contains("stronger") || weaker ? 1 : 0;
int duration = scenario.contains("longer") ? 200 : weaker ? 100 : 20;
var incoming = new MobEffectInstance(MobEffects.POISON, duration, amp, false, !scenario.equals("cosmetic"));
boolean override = !scenario.equals("rejected-stronger") && new MobEffectInstance(poison).update(incoming);
attribution.change(victim, PotionDamageAttribution.Kind.POISON, snapshot(poison), effect(incoming),
scenario.equals("unknown-stronger") ? null : second, override);
if (!scenario.equals("rejected-stronger")) { poison.update(incoming); }
UUID expected = scenario.equals("unknown-stronger") ? null : scenario.contains("longer") ? second : first;
assertEquals(java.util.Optional.ofNullable(expected), attribution.attacker(victim, PotionDamageAttribution.Kind.POISON, snapshot(poison)));
if (weaker || scenario.equals("unknown-stronger")) {
for (int tick = 0; tick < 20; tick++) { poison.tickClient(); }
assertEquals(java.util.Optional.of(weaker ? second : first), attribution.attacker(victim, PotionDamageAttribution.Kind.POISON, snapshot(poison)));
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"infinite-active", "infinite-hidden", "expired", "removed", "replaced-native"})
void witherInfiniteDurationsAndEffectLifecycleDoNotLeakOldOwnership(String scenario) {
UUID victim = UUID.randomUUID(), owner = UUID.randomUUID(), other = UUID.randomUUID();
var attribution = new PotionDamageAttribution();
var kind = PotionDamageAttribution.Kind.WITHER;
var effect = new MobEffectInstance(MobEffects.WITHER, scenario.startsWith("infinite") ? -1 : 100, 0);
attribution.change(victim, kind, null, effect(effect), owner, true);
assertEquals(java.util.Optional.of(owner), attribution.attacker(victim, kind, snapshot(effect)));
assertTrue(attribution.attacker(victim, PotionDamageAttribution.Kind.POISON, snapshot(effect)).isEmpty());
if (scenario.equals("infinite-hidden")) {
var incoming = new MobEffectInstance(MobEffects.WITHER, 20, 1);
attribution.change(victim, kind, snapshot(effect), effect(incoming), other, true);
effect.update(incoming);
assertEquals(java.util.Optional.of(other), attribution.attacker(victim, kind, snapshot(effect)));
}
for (int tick = 0; tick < (scenario.equals("expired") ? 100 : 20); tick++) { effect.tickClient(); }
if (scenario.equals("removed")) { attribution.change(victim, kind, snapshot(effect), null, null, false); }
if (scenario.equals("replaced-native")) { effect = new MobEffectInstance(effect); }
assertEquals(scenario.startsWith("infinite") ? java.util.Optional.of(owner) : java.util.Optional.empty(), attribution.attacker(victim, kind, snapshot(effect)));
}
@Test
void repeatedNativeMergesAndCountdownsKeepKnownOwnershipAcrossDeepChains() {
var random = new java.util.Random(2618);
UUID victim = UUID.randomUUID(), owner = UUID.randomUUID();
var attribution = new PotionDamageAttribution();
MobEffectInstance current = null;
for (int step = 0; step < 2_000; step++) {
if (current == null || random.nextBoolean()) {
var incoming = new MobEffectInstance(MobEffects.POISON, random.nextInt(10) == 0 ? -1 : 1 + random.nextInt(200), random.nextInt(8));
boolean override = current == null || new MobEffectInstance(current).update(incoming);
attribution.change(victim, PotionDamageAttribution.Kind.POISON, snapshot(current), effect(incoming), owner, override);
if (current == null) { current = incoming; } else { current.update(incoming); }
} else {
for (int tick = 0; tick < 1 + random.nextInt(20) && current.getDuration() != 0; tick++) { current.tickClient(); }
}
if (current.getDuration() == 0) {
attribution.change(victim, PotionDamageAttribution.Kind.POISON, snapshot(current), null, null, false);
current = null;
}
assertEquals(current == null ? java.util.Optional.empty() : java.util.Optional.of(owner),
attribution.attacker(victim, PotionDamageAttribution.Kind.POISON, snapshot(current)), "native operation " + step);
}
}
static PotionDamageAttribution.Snapshot snapshot(MobEffectInstance effect) {
return effect == null ? null : new PotionDamageAttribution.Snapshot(effect, effect(effect));
}
static PotionDamageAttribution.Effect effect(MobEffectInstance effect) {
return effect == null ? null : new PotionDamageAttribution.Effect(effect.getAmplifier(), effect.getDuration(), effect(effect.hiddenEffect));
}
}