Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b412141da | ||
|
|
c1bd4406d2 | ||
|
|
0499a31f71 |
@@ -10,13 +10,27 @@ 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.
|
||||||
|
|
||||||
After the unlock is saved, craft an **Eye of True Seeing** with eight Netherite Blocks around an Eye of Ender. Right-click an authenticated Eye in either hand to move it into an empty helmet slot. Existing helmets are not replaced. Traded Eyes still require the holder's own saved unlock; renamed ordinary Eyes do not qualify. Automated crafters cannot bypass progression. Vanilla handles crafting ingredient consumption.
|
After the unlock is saved, craft an **Eye of True Seeing** with eight Netherite Blocks around an Eye of Ender. Right-click an authenticated Eye in either hand to move it into an empty helmet slot. Existing helmets are not replaced. Traded Eyes still require the holder's own saved unlock; renamed ordinary Eyes do not qualify. Automated crafters cannot bypass progression. Vanilla handles crafting ingredient consumption.
|
||||||
|
|
||||||
**Local name/body revelation is separate US-008 work and is not implemented by this acquisition story.** Eligibility is checked even for an Eye manually placed in the helmet slot.
|
While worn by an eligible player, the Eye reveals active Stealth targets' real overhead names within **16 blocks in three dimensions and line of sight**. It also reveals their bodies if they have gameplay invisibility, only to that observer. Ordinary invisible players without a concealed Stealth session are not revealed. Platform hiding, spectator mode, vanished metadata and effective team name-visibility restrictions take precedence. Eligibility is checked even for an Eye manually placed in the helmet slot.
|
||||||
|
|
||||||
|
Revelation uses private client-only teams and recipient-specific metadata; it does not change server teams, remove potion effects or end Stealth sessions. Tab-list, chat, suggestions and server-list concealment remain unchanged, including for the wearer. Unequipping, leaving range/sight, lifecycle changes and disable withdraw the projection and restore current raw state. Failed deliveries revoke authorization and queue cleanup before recreation. Live-client appearance/timing and compatibility remain separate validation follow-ups.
|
||||||
|
|
||||||
Eye records are independently UUID-keyed in `plugins/SpigotStealth/state.yml` (schema 2). Legacy Stealth records and extension fields are preserved. Crafting/use wait for successful save acknowledgement. Shutdown queues a final snapshot without blocking the tick thread, and initialization callbacks cannot revive a disabled or superseded lifecycle. Unacknowledged progress at abrupt process/power loss is not guaranteed.
|
Eye records are independently UUID-keyed in `plugins/SpigotStealth/state.yml` (schema 2). Legacy Stealth records and extension fields are preserved. Crafting/use wait for successful save acknowledgement. Shutdown queues a final snapshot without blocking the tick thread, and initialization callbacks cannot revive a disabled or superseded lifecycle. Unacknowledged progress at abrupt process/power loss is not guaranteed.
|
||||||
|
|
||||||
@@ -28,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 work is described above; Eye revelation, combat reveal 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
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 java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
import org.bukkit.plugin.Plugin;
|
||||||
|
|
||||||
|
/** Outgoing packet projection reads immutable authorization and never queries world or inventory state. */
|
||||||
|
public final class EyePacketListener extends PacketAdapter {
|
||||||
|
private final Supplier<Map<UUID, Map<Integer, EyeRevealController.Projection>>> views;
|
||||||
|
private final Consumer<UUID> refresh;
|
||||||
|
private final Consumer<String> warning;
|
||||||
|
private final Set<PacketType> warned = ConcurrentHashMap.newKeySet();
|
||||||
|
|
||||||
|
public EyePacketListener(Plugin plugin, Supplier<Map<UUID, Map<Integer, EyeRevealController.Projection>>> views,
|
||||||
|
Consumer<UUID> refresh, Consumer<String> warning) {
|
||||||
|
super(plugin, ListenerPriority.HIGHEST, PacketType.Play.Server.ENTITY_METADATA, PacketType.Play.Server.SCOREBOARD_TEAM);
|
||||||
|
this.views = Objects.requireNonNull(views);
|
||||||
|
this.refresh = Objects.requireNonNull(refresh);
|
||||||
|
this.warning = Objects.requireNonNull(warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public void onPacketSending(PacketEvent event) {
|
||||||
|
if (event.isCancelled() || event.getPlayer() == null || event.isPlayerTemporary()) { return; }
|
||||||
|
try {
|
||||||
|
// UUID is immutable connection identity; no inventory, world, visibility or effect queries occur here.
|
||||||
|
UUID viewer = event.getPlayer().getUniqueId();
|
||||||
|
if (viewer == null) { return; }
|
||||||
|
var current = views.get().getOrDefault(viewer, Map.of());
|
||||||
|
if (current.isEmpty()) { return; }
|
||||||
|
Object original = event.getPacket().getHandle();
|
||||||
|
if (event.getPacketType().equals(PacketType.Play.Server.ENTITY_METADATA)) {
|
||||||
|
int id = (Integer) original.getClass().getMethod("id").invoke(original);
|
||||||
|
var projection = current.get(id);
|
||||||
|
if (projection != null && projection.revealBody()) {
|
||||||
|
event.setPacket(new PacketContainer(event.getPacketType(), EyePacketProjection.metadataPacket(original)));
|
||||||
|
}
|
||||||
|
} else if (event.getPacketType().equals(PacketType.Play.Server.SCOREBOARD_TEAM)) {
|
||||||
|
String name = (String) original.getClass().getMethod("getName").invoke(original);
|
||||||
|
var privateTeams = current.values().stream().map(EyeRevealController.Projection::privateTeam)
|
||||||
|
.collect(java.util.stream.Collectors.toUnmodifiableSet());
|
||||||
|
var names = current.values().stream().map(EyeRevealController.Projection::playerName)
|
||||||
|
.collect(java.util.stream.Collectors.toUnmodifiableSet());
|
||||||
|
if (!privateTeams.contains(name) && current.values().stream().anyMatch(p -> p.sourceTeam().filter(name::equals).isPresent())) {
|
||||||
|
refresh.accept(viewer); // Only marks work; the callback must not query Bukkit here.
|
||||||
|
}
|
||||||
|
Object projected = EyePacketProjection.teamPacket(original, privateTeams, names);
|
||||||
|
if (projected != original) { event.setPacket(new PacketContainer(event.getPacketType(), projected)); }
|
||||||
|
}
|
||||||
|
} catch (ReflectiveOperationException | RuntimeException exception) {
|
||||||
|
if (warned.add(event.getPacketType())) {
|
||||||
|
warning.accept("Eye projection unavailable for " + event.getPacketType()
|
||||||
|
+ "; original packet retained. Failure: " + exception.getClass().getSimpleName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
import java.lang.reflect.RecordComponent;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/** Pure copies of declared-runtime packet data; no Bukkit lookups or global entity/team mutations. */
|
||||||
|
public final class EyePacketProjection {
|
||||||
|
private static final String TEAM_PARAMETERS = "net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket$Parameters";
|
||||||
|
private EyePacketProjection() { }
|
||||||
|
|
||||||
|
public static Object teamPacket(Object original, java.util.Set<String> privateTeams, java.util.Set<String> projectedPlayers) {
|
||||||
|
Objects.requireNonNull(original, "original");
|
||||||
|
Class<?> type = original.getClass();
|
||||||
|
if (!type.getName().equals("net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket")) {
|
||||||
|
throw new IllegalArgumentException("Unsupported native team packet");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
String name = (String) type.getMethod("getName").invoke(original);
|
||||||
|
var players = (java.util.Collection<?>) type.getMethod("getPlayers").invoke(original);
|
||||||
|
var parameters = (java.util.Optional<?>) type.getMethod("getParameters").invoke(original);
|
||||||
|
boolean privateTeam = privateTeams.contains(name);
|
||||||
|
var projectedParameters = privateTeam ? parameters.map(EyePacketProjection::teamParameters) : parameters;
|
||||||
|
var projectedMembers = privateTeam ? players : players.stream().filter(player -> !projectedPlayers.contains(player)).toList();
|
||||||
|
if (projectedParameters == parameters && projectedMembers.size() == players.size()) { return original; }
|
||||||
|
var method = type.getDeclaredField("method");
|
||||||
|
method.setAccessible(true);
|
||||||
|
var constructor = type.getDeclaredConstructor(String.class, int.class, java.util.Optional.class, java.util.Collection.class);
|
||||||
|
constructor.setAccessible(true);
|
||||||
|
return constructor.newInstance(name, method.getInt(original), projectedParameters, projectedMembers);
|
||||||
|
} catch (ReflectiveOperationException exception) {
|
||||||
|
throw new IllegalStateException("Could not project native team membership", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Object metadataPacket(Object original) {
|
||||||
|
Objects.requireNonNull(original, "original");
|
||||||
|
Class<?> type = original.getClass();
|
||||||
|
if (!type.getName().equals("net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket")) {
|
||||||
|
throw new IllegalArgumentException("Unsupported native metadata packet");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var values = (List<?>) type.getMethod("packedItems").invoke(original);
|
||||||
|
var projected = new ArrayList<Object>(values.size());
|
||||||
|
for (Object value : values) {
|
||||||
|
Class<?> valueType = value.getClass();
|
||||||
|
if (!valueType.getName().equals("net.minecraft.network.syncher.SynchedEntityData$DataValue")) {
|
||||||
|
throw new IllegalArgumentException("Unsupported native metadata value");
|
||||||
|
}
|
||||||
|
int index = (Integer) valueType.getMethod("id").invoke(value);
|
||||||
|
if (index != 0) { projected.add(value); continue; }
|
||||||
|
Object raw = valueType.getMethod("value").invoke(value);
|
||||||
|
if (!(raw instanceof Byte flags)) { throw new IllegalArgumentException("Shared entity flags must be a byte"); }
|
||||||
|
Object serializer = valueType.getMethod("serializer").invoke(value);
|
||||||
|
projected.add(valueType.getConstructor(int.class,
|
||||||
|
Class.forName("net.minecraft.network.syncher.EntityDataSerializer"), Object.class)
|
||||||
|
.newInstance(index, serializer, (byte) (flags & ~0x20)));
|
||||||
|
}
|
||||||
|
return type.getConstructor(int.class, List.class)
|
||||||
|
.newInstance(type.getMethod("id").invoke(original), List.copyOf(projected));
|
||||||
|
} catch (ReflectiveOperationException exception) {
|
||||||
|
throw new IllegalStateException("Could not project native entity metadata", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Object teamParameters(Object original) {
|
||||||
|
try {
|
||||||
|
Object empty = Class.forName("net.minecraft.network.chat.Component").getMethod("empty").invoke(null);
|
||||||
|
return copyParameters(original, java.util.Map.of("playerPrefix", empty));
|
||||||
|
} catch (ReflectiveOperationException exception) {
|
||||||
|
throw new IllegalStateException("Could not project the native team prefix", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Object teamVisibility(Object original, boolean visible) {
|
||||||
|
try {
|
||||||
|
Object visibility = Class.forName("net.minecraft.world.scores.Team$Visibility").getField(visible ? "ALWAYS" : "NEVER").get(null);
|
||||||
|
return copyParameters(original, java.util.Map.of("nameTagVisibility", visibility));
|
||||||
|
} catch (ReflectiveOperationException exception) {
|
||||||
|
throw new IllegalStateException("Could not preserve effective team visibility", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Object copyParameters(Object original, java.util.Map<String, Object> replacements) {
|
||||||
|
Objects.requireNonNull(original, "original");
|
||||||
|
Class<?> type = original.getClass();
|
||||||
|
if (!TEAM_PARAMETERS.equals(type.getName()) || !type.isRecord()) {
|
||||||
|
throw new IllegalArgumentException("Unsupported native team parameter layout");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
RecordComponent[] components = type.getRecordComponents();
|
||||||
|
Class<?>[] types = new Class<?>[components.length];
|
||||||
|
Object[] values = new Object[components.length];
|
||||||
|
int replaced = 0;
|
||||||
|
for (int index = 0; index < components.length; index++) {
|
||||||
|
var component = components[index];
|
||||||
|
types[index] = component.getType();
|
||||||
|
if (replacements.containsKey(component.getName())) {
|
||||||
|
values[index] = replacements.get(component.getName());
|
||||||
|
replaced++;
|
||||||
|
} else {
|
||||||
|
// Accessors may consult global collision configuration. Copy the stored record data verbatim.
|
||||||
|
var field = type.getDeclaredField(component.getName());
|
||||||
|
field.setAccessible(true);
|
||||||
|
values[index] = field.get(original);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (replaced != replacements.size()) { throw new IllegalArgumentException("Required native team fields are unavailable"); }
|
||||||
|
return type.getDeclaredConstructor(types).newInstance(values);
|
||||||
|
} catch (ReflectiveOperationException exception) {
|
||||||
|
throw new IllegalStateException("Could not project the native team prefix", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.function.Predicate;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
import org.bukkit.GameMode;
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.World;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.metadata.MetadataValue;
|
||||||
|
import org.bukkit.potion.PotionEffectType;
|
||||||
|
|
||||||
|
/** Server-thread visibility capture and delivery; published projections contain no mutable Bukkit objects. */
|
||||||
|
public final class EyeRevealController implements Runnable, AutoCloseable {
|
||||||
|
public record Projection(UUID targetId, int entityId, String playerName, String privateTeam,
|
||||||
|
Optional<String> sourceTeam, boolean revealBody) { }
|
||||||
|
public interface Delivery {
|
||||||
|
void show(Player viewer, Player target, Projection projection, boolean create);
|
||||||
|
void restore(Player viewer, Optional<Player> target, Projection projection);
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Supplier<? extends Collection<? extends Player>> onlinePlayers;
|
||||||
|
private final EyeEquipment equipment;
|
||||||
|
private final Predicate<UUID> concealed;
|
||||||
|
private final Delivery delivery;
|
||||||
|
private final Supplier<String> teamNames;
|
||||||
|
private final java.util.function.Consumer<Throwable> failure;
|
||||||
|
private record RestoreKey(UUID viewer, String team) { }
|
||||||
|
private record TargetKey(UUID viewer, UUID target) { }
|
||||||
|
private final Map<RestoreKey, Projection> restoring = new java.util.LinkedHashMap<>();
|
||||||
|
private final Set<UUID> warned = new HashSet<>();
|
||||||
|
private volatile Map<UUID, Map<Integer, Projection>> views = Map.of();
|
||||||
|
private volatile boolean closed;
|
||||||
|
private final Set<UUID> dirty = java.util.concurrent.ConcurrentHashMap.newKeySet();
|
||||||
|
|
||||||
|
public EyeRevealController(Supplier<? extends Collection<? extends Player>> onlinePlayers, EyeEquipment equipment,
|
||||||
|
Predicate<UUID> concealed, Delivery delivery, Supplier<String> teamNames) {
|
||||||
|
this(onlinePlayers, equipment, concealed, delivery, teamNames,
|
||||||
|
error -> java.util.logging.Logger.getLogger(EyeRevealController.class.getName())
|
||||||
|
.warning("Eye delivery failed; cleanup queued: " + error.getClass().getSimpleName()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public EyeRevealController(Supplier<? extends Collection<? extends Player>> onlinePlayers, EyeEquipment equipment,
|
||||||
|
Predicate<UUID> concealed, Delivery delivery, Supplier<String> teamNames, java.util.function.Consumer<Throwable> failure) {
|
||||||
|
this.onlinePlayers = Objects.requireNonNull(onlinePlayers);
|
||||||
|
this.equipment = Objects.requireNonNull(equipment);
|
||||||
|
this.concealed = Objects.requireNonNull(concealed);
|
||||||
|
this.delivery = Objects.requireNonNull(delivery);
|
||||||
|
this.teamNames = Objects.requireNonNull(teamNames);
|
||||||
|
this.failure = Objects.requireNonNull(failure);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<UUID, Map<Integer, Projection>> snapshot() { return views; }
|
||||||
|
|
||||||
|
/** Packet-thread safe: defer all Bukkit access and actual delivery to the next server-thread frame. */
|
||||||
|
public void requestRefresh(UUID viewer) {
|
||||||
|
if (!closed) { dirty.add(Objects.requireNonNull(viewer)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public void run() {
|
||||||
|
if (closed) { return; }
|
||||||
|
var requested = Set.copyOf(dirty);
|
||||||
|
requested.forEach(dirty::remove);
|
||||||
|
List<? extends Player> online = List.copyOf(onlinePlayers.get());
|
||||||
|
var players = new HashMap<UUID, Player>();
|
||||||
|
online.forEach(player -> players.put(player.getUniqueId(), player));
|
||||||
|
var attemptedRestores = new HashSet<RestoreKey>();
|
||||||
|
retryRestores(players, attemptedRestores);
|
||||||
|
var blocked = restoring.entrySet().stream().map(entry -> new TargetKey(entry.getKey().viewer(), entry.getValue().targetId()))
|
||||||
|
.collect(java.util.stream.Collectors.toUnmodifiableSet());
|
||||||
|
Map<UUID, Map<Integer, Projection>> previous = views;
|
||||||
|
var usedNames = new HashSet<String>();
|
||||||
|
previous.values().forEach(view -> view.values().forEach(projection -> usedNames.add(projection.privateTeam())));
|
||||||
|
restoring.values().forEach(projection -> usedNames.add(projection.privateTeam()));
|
||||||
|
var next = new HashMap<UUID, Map<Integer, Projection>>();
|
||||||
|
for (Player viewer : online) {
|
||||||
|
try {
|
||||||
|
if (!viewer.isOnline() || viewer.isDead() || !equipment.isEligibleWearer(viewer)) { continue; }
|
||||||
|
Location origin = viewer.getLocation();
|
||||||
|
var observer = new EyeVisibility.Viewer(viewer.getUniqueId(), position(origin), true);
|
||||||
|
var targets = new HashMap<Integer, Projection>();
|
||||||
|
for (Player target : online) {
|
||||||
|
if (!target.isOnline() || target.isDead() || blocked.contains(new TargetKey(viewer.getUniqueId(), target.getUniqueId()))) { continue; }
|
||||||
|
Location destination = target.getLocation();
|
||||||
|
var subject = new EyeVisibility.Target(target.getUniqueId(), position(destination),
|
||||||
|
concealed.test(target.getUniqueId()), target.getGameMode() == GameMode.SPECTATOR,
|
||||||
|
target.getMetadata("vanished").stream().anyMatch(MetadataValue::asBoolean));
|
||||||
|
boolean visible = viewer.canSee(target) && target.getTrackedBy().contains(viewer);
|
||||||
|
if (!EyeVisibility.canReveal(observer, subject, visible, true)) { continue; }
|
||||||
|
var sourceTeam = viewer.getScoreboard().getEntryTeam(target.getName());
|
||||||
|
if (!EyeTeamVisibility.visible(viewer, sourceTeam)
|
||||||
|
|| !chunksLoaded(origin.getWorld(), origin, destination) || !viewer.hasLineOfSight(target)) { continue; }
|
||||||
|
Projection old = previous.getOrDefault(viewer.getUniqueId(), Map.of()).get(target.getEntityId());
|
||||||
|
String team = old != null && old.targetId().equals(target.getUniqueId()) && old.playerName().equals(target.getName())
|
||||||
|
? old.privateTeam() : allocateTeam(viewer, usedNames);
|
||||||
|
var baseline = Optional.ofNullable(sourceTeam).map(org.bukkit.scoreboard.Team::getName);
|
||||||
|
targets.put(target.getEntityId(), new Projection(target.getUniqueId(), target.getEntityId(), target.getName(),
|
||||||
|
team, baseline, target.hasPotionEffect(PotionEffectType.INVISIBILITY)));
|
||||||
|
}
|
||||||
|
if (!targets.isEmpty()) { next.put(viewer.getUniqueId(), Map.copyOf(targets)); }
|
||||||
|
} catch (RuntimeException error) {
|
||||||
|
// Never retain a stale authorization when an authoritative visibility query fails.
|
||||||
|
report(viewer.getUniqueId(), error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
views = Map.copyOf(next); // Publish authorization before any outgoing projection/restoration packet.
|
||||||
|
previous.forEach((viewerId, targets) -> {
|
||||||
|
Player viewer = players.get(viewerId);
|
||||||
|
if (viewer == null || !viewer.isOnline()) { return; }
|
||||||
|
targets.values().forEach(old -> {
|
||||||
|
Projection current = views.getOrDefault(viewerId, Map.of()).get(old.entityId());
|
||||||
|
if (current == null || !current.privateTeam().equals(old.privateTeam())) {
|
||||||
|
restoring.putIfAbsent(new RestoreKey(viewerId, old.privateTeam()), old);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
retryRestores(players, attemptedRestores);
|
||||||
|
views.forEach((viewerId, targets) -> targets.values().forEach(current -> {
|
||||||
|
Projection old = previous.getOrDefault(viewerId, Map.of()).get(current.entityId());
|
||||||
|
if (!current.equals(old) || requested.contains(viewerId)) {
|
||||||
|
try {
|
||||||
|
delivery.show(players.get(viewerId), players.get(current.targetId()), current,
|
||||||
|
old == null || !current.privateTeam().equals(old.privateTeam()));
|
||||||
|
warned.remove(viewerId);
|
||||||
|
} catch (RuntimeException error) {
|
||||||
|
deny(viewerId, current.targetId());
|
||||||
|
restoring.putIfAbsent(new RestoreKey(viewerId, current.privateTeam()), current);
|
||||||
|
report(viewerId, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void hide(UUID viewer, UUID target) {
|
||||||
|
withdraw((observer, projection) -> observer.equals(viewer) && projection.targetId().equals(target));
|
||||||
|
}
|
||||||
|
public void withdrawViewer(UUID viewer) { withdraw((observer, projection) -> observer.equals(viewer)); }
|
||||||
|
public void withdrawTarget(UUID target) { withdraw((observer, projection) -> projection.targetId().equals(target)); }
|
||||||
|
|
||||||
|
/** Lifecycle callbacks call this on the server thread before the next scheduled visibility capture. */
|
||||||
|
private void withdraw(java.util.function.BiPredicate<UUID, Projection> remove) {
|
||||||
|
var previous = views;
|
||||||
|
var next = new HashMap<UUID, Map<Integer, Projection>>();
|
||||||
|
previous.forEach((viewer, targets) -> {
|
||||||
|
var retained = new HashMap<Integer, Projection>();
|
||||||
|
targets.forEach((id, projection) -> { if (!remove.test(viewer, projection)) { retained.put(id, projection); } });
|
||||||
|
if (!retained.isEmpty()) { next.put(viewer, Map.copyOf(retained)); }
|
||||||
|
});
|
||||||
|
views = Map.copyOf(next);
|
||||||
|
var players = new HashMap<UUID, Player>();
|
||||||
|
onlinePlayers.get().forEach(player -> players.put(player.getUniqueId(), player));
|
||||||
|
previous.forEach((viewerId, targets) -> {
|
||||||
|
Player viewer = players.get(viewerId);
|
||||||
|
if (viewer != null && viewer.isOnline()) {
|
||||||
|
targets.values().stream().filter(projection -> remove.test(viewerId, projection))
|
||||||
|
.forEach(projection -> restoring.putIfAbsent(new RestoreKey(viewerId, projection.privateTeam()), projection));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
retryRestores(players, new HashSet<>());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public void close() {
|
||||||
|
if (closed && restoring.isEmpty()) { return; }
|
||||||
|
closed = true;
|
||||||
|
dirty.clear();
|
||||||
|
withdraw((viewer, projection) -> true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void retryRestores(Map<UUID, Player> players, Set<RestoreKey> attempted) {
|
||||||
|
for (var entry : List.copyOf(restoring.entrySet())) {
|
||||||
|
var key = entry.getKey();
|
||||||
|
if (!attempted.add(key)) { continue; }
|
||||||
|
var projection = entry.getValue();
|
||||||
|
Player viewer = players.get(key.viewer());
|
||||||
|
if (viewer == null || !viewer.isOnline()) { restoring.remove(key); continue; }
|
||||||
|
try {
|
||||||
|
delivery.restore(viewer, Optional.ofNullable(players.get(projection.targetId())), projection);
|
||||||
|
restoring.remove(key);
|
||||||
|
} catch (RuntimeException error) {
|
||||||
|
deny(key.viewer(), projection.targetId());
|
||||||
|
report(key.viewer(), error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void deny(UUID viewer, UUID target) {
|
||||||
|
var next = new HashMap<>(views);
|
||||||
|
var retained = new HashMap<>(next.getOrDefault(viewer, Map.of()));
|
||||||
|
retained.values().removeIf(projection -> projection.targetId().equals(target));
|
||||||
|
if (retained.isEmpty()) { next.remove(viewer); }
|
||||||
|
else { next.put(viewer, Map.copyOf(retained)); }
|
||||||
|
views = Map.copyOf(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void report(UUID viewer, RuntimeException error) {
|
||||||
|
if (warned.add(viewer)) { failure.accept(error); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private String allocateTeam(Player viewer, Set<String> used) {
|
||||||
|
for (int attempt = 0; attempt < 32; attempt++) {
|
||||||
|
String name = teamNames.get();
|
||||||
|
if (name != null && name.matches("[a-zA-Z0-9_-]{1,16}") && !used.contains(name)
|
||||||
|
&& viewer.getScoreboard().getTeam(name) == null) {
|
||||||
|
used.add(name);
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new IllegalStateException("Could not allocate an isolated Eye team name");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static EyeVisibility.Position position(Location location) {
|
||||||
|
return new EyeVisibility.Position(location.getWorld().getUID(), location.getX(), location.getY(), location.getZ());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean chunksLoaded(World world, Location from, Location to) {
|
||||||
|
int minX = (int) Math.floor(Math.min(from.getX(), to.getX())) >> 4;
|
||||||
|
int maxX = (int) Math.floor(Math.max(from.getX(), to.getX())) >> 4;
|
||||||
|
int minZ = (int) Math.floor(Math.min(from.getZ(), to.getZ())) >> 4;
|
||||||
|
int maxZ = (int) Math.floor(Math.max(from.getZ(), to.getZ())) >> 4;
|
||||||
|
for (int x = minX; x <= maxX; x++) {
|
||||||
|
for (int z = minZ; z <= maxZ; z++) { if (!world.isChunkLoaded(x, z)) { return false; } }
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
import com.destroystokyo.paper.event.player.PlayerArmorChangeEvent;
|
||||||
|
import io.papermc.paper.event.player.PlayerUntrackEntityEvent;
|
||||||
|
import org.bukkit.event.EventHandler;
|
||||||
|
import org.bukkit.event.EventPriority;
|
||||||
|
import org.bukkit.event.Listener;
|
||||||
|
import org.bukkit.event.entity.EntityPotionEffectEvent;
|
||||||
|
import org.bukkit.event.entity.PlayerDeathEvent;
|
||||||
|
import org.bukkit.event.player.PlayerChangedWorldEvent;
|
||||||
|
import org.bukkit.event.player.PlayerGameModeChangeEvent;
|
||||||
|
import org.bukkit.event.player.PlayerHideEntityEvent;
|
||||||
|
import org.bukkit.event.player.PlayerQuitEvent;
|
||||||
|
import org.bukkit.event.player.PlayerRespawnEvent;
|
||||||
|
import org.bukkit.event.player.PlayerTeleportEvent;
|
||||||
|
|
||||||
|
/** Immediate server-thread invalidation; ordinary motion and line-of-sight changes are polled each tick. */
|
||||||
|
public final class EyeRevealEvents implements Listener {
|
||||||
|
private final EyeRevealController controller;
|
||||||
|
public EyeRevealEvents(EyeRevealController controller) { this.controller = java.util.Objects.requireNonNull(controller); }
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.MONITOR) public void onHide(PlayerHideEntityEvent event) {
|
||||||
|
if (event.getEntity() instanceof org.bukkit.entity.Player player) { controller.hide(event.getPlayer().getUniqueId(), player.getUniqueId()); }
|
||||||
|
}
|
||||||
|
@EventHandler(priority = EventPriority.MONITOR) public void onUntrack(PlayerUntrackEntityEvent event) {
|
||||||
|
if (event.getEntity() instanceof org.bukkit.entity.Player player) { controller.hide(event.getPlayer().getUniqueId(), player.getUniqueId()); }
|
||||||
|
}
|
||||||
|
@EventHandler(priority = EventPriority.MONITOR) public void onArmor(PlayerArmorChangeEvent event) {
|
||||||
|
if (event.getSlot() == org.bukkit.inventory.EquipmentSlot.HEAD) { controller.withdrawViewer(event.getPlayer().getUniqueId()); }
|
||||||
|
}
|
||||||
|
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onGameMode(PlayerGameModeChangeEvent event) {
|
||||||
|
if (!event.isCancelled() && event.getNewGameMode() == org.bukkit.GameMode.SPECTATOR) { both(event.getPlayer().getUniqueId()); }
|
||||||
|
}
|
||||||
|
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onEffect(EntityPotionEffectEvent event) {
|
||||||
|
if (!event.isCancelled() && event.getEntity() instanceof org.bukkit.entity.Player player
|
||||||
|
&& org.bukkit.potion.PotionEffectType.INVISIBILITY.equals(event.getModifiedType())) {
|
||||||
|
controller.withdrawTarget(player.getUniqueId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@EventHandler(priority = EventPriority.MONITOR) public void onQuit(PlayerQuitEvent event) { both(event.getPlayer().getUniqueId()); }
|
||||||
|
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onTeleport(PlayerTeleportEvent event) {
|
||||||
|
if (!event.isCancelled()) { both(event.getPlayer().getUniqueId()); }
|
||||||
|
}
|
||||||
|
@EventHandler(priority = EventPriority.MONITOR) public void onRespawn(PlayerRespawnEvent event) { both(event.getPlayer().getUniqueId()); }
|
||||||
|
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onDeath(PlayerDeathEvent event) {
|
||||||
|
if (!cancelled(event)) { both(event.getEntity().getUniqueId()); }
|
||||||
|
}
|
||||||
|
@EventHandler(priority = EventPriority.MONITOR) public void onWorld(PlayerChangedWorldEvent event) { both(event.getPlayer().getUniqueId()); }
|
||||||
|
|
||||||
|
private void both(java.util.UUID player) {
|
||||||
|
controller.withdrawViewer(player);
|
||||||
|
controller.withdrawTarget(player);
|
||||||
|
}
|
||||||
|
private static boolean cancelled(org.bukkit.event.Event event) {
|
||||||
|
return event instanceof org.bukkit.event.Cancellable cancellable && cancellable.isCancelled();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
import com.comphenix.protocol.ProtocolManager;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.function.Predicate;
|
||||||
|
import org.bukkit.event.HandlerList;
|
||||||
|
import org.bukkit.plugin.java.JavaPlugin;
|
||||||
|
import org.bukkit.scheduler.BukkitTask;
|
||||||
|
|
||||||
|
/** Owns only the Eye's viewer-specific runtime and its lifecycle resources. */
|
||||||
|
final class EyeRevealRuntime implements AutoCloseable {
|
||||||
|
private final JavaPlugin plugin;
|
||||||
|
private final ProtocolManager protocol;
|
||||||
|
private final EyeRevealController controller;
|
||||||
|
private final EyePacketListener packets;
|
||||||
|
private final EyeRevealEvents events;
|
||||||
|
private BukkitTask task;
|
||||||
|
private boolean started;
|
||||||
|
private boolean closed;
|
||||||
|
|
||||||
|
EyeRevealRuntime(JavaPlugin plugin, EyeEquipment equipment, Predicate<UUID> concealed, ProtocolManager protocol) {
|
||||||
|
this.plugin = Objects.requireNonNull(plugin);
|
||||||
|
this.protocol = Objects.requireNonNull(protocol);
|
||||||
|
controller = new EyeRevealController(plugin.getServer()::getOnlinePlayers, equipment, concealed, new NativeEyeDelivery(),
|
||||||
|
() -> UUID.randomUUID().toString().replace("-", "").substring(0, 16),
|
||||||
|
failure -> plugin.getLogger().warning("Eye rendering failed; unsafe views withheld: " + failure.getClass().getSimpleName()));
|
||||||
|
packets = new EyePacketListener(plugin, controller::snapshot, controller::requestRefresh, plugin.getLogger()::warning);
|
||||||
|
events = new EyeRevealEvents(controller);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void start() {
|
||||||
|
if (closed) { throw new IllegalStateException("Eye runtime is closed"); }
|
||||||
|
if (started) { return; }
|
||||||
|
if (!plugin.isEnabled()) { throw new IllegalStateException("Cannot start Eye rendering for a disabled plugin"); }
|
||||||
|
started = true;
|
||||||
|
try {
|
||||||
|
protocol.addPacketListener(packets);
|
||||||
|
plugin.getServer().getPluginManager().registerEvents(events, plugin);
|
||||||
|
task = plugin.getServer().getScheduler().runTaskTimer(plugin, controller, 1L, 1L);
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
close();
|
||||||
|
throw exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void withdrawTarget(UUID player) { controller.withdrawTarget(player); }
|
||||||
|
|
||||||
|
@Override public void close() {
|
||||||
|
if (closed) { return; }
|
||||||
|
closed = true;
|
||||||
|
if (task != null) { task.cancel(); }
|
||||||
|
try {
|
||||||
|
controller.close(); // Withdraw authorization and restore raw state before unregistering the outgoing gate.
|
||||||
|
} finally {
|
||||||
|
protocol.removePacketListener(packets);
|
||||||
|
HandlerList.unregisterAll(events);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.scoreboard.Team;
|
||||||
|
|
||||||
|
/** Preserve effective name visibility when a viewer-only team changes client membership relationships. */
|
||||||
|
final class EyeTeamVisibility {
|
||||||
|
private EyeTeamVisibility() { }
|
||||||
|
static boolean visible(Player viewer, Team source) {
|
||||||
|
if (source == null) { return true; }
|
||||||
|
boolean sameTeam = source.equals(viewer.getScoreboard().getEntryTeam(viewer.getName()));
|
||||||
|
return switch (source.getOption(Team.Option.NAME_TAG_VISIBILITY)) {
|
||||||
|
case ALWAYS -> true;
|
||||||
|
case NEVER -> false;
|
||||||
|
// CraftTeam maps these to native HIDE_FOR_* values by ordinal, not to SHOW_FOR_*.
|
||||||
|
case FOR_OWN_TEAM -> !sameTeam;
|
||||||
|
case FOR_OTHER_TEAMS -> sameTeam;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** Immutable, platform-independent inputs to the local Eye reveal decision. */
|
||||||
|
public final class EyeVisibility {
|
||||||
|
private EyeVisibility() { }
|
||||||
|
public record Position(UUID world, double x, double y, double z) { }
|
||||||
|
public record Viewer(UUID id, Position position, boolean eligible) { }
|
||||||
|
public record Target(UUID id, Position position, boolean concealed, boolean spectator, boolean vanished) { }
|
||||||
|
|
||||||
|
public static boolean canReveal(Viewer viewer, Target target, boolean visibleToViewer, boolean lineOfSight) {
|
||||||
|
if (!viewer.eligible() || !target.concealed() || target.spectator() || target.vanished()
|
||||||
|
|| !visibleToViewer || !lineOfSight || viewer.id().equals(target.id())
|
||||||
|
|| !viewer.position().world().equals(target.position().world())) { return false; }
|
||||||
|
double dx = viewer.position().x() - target.position().x();
|
||||||
|
double dy = viewer.position().y() - target.position().y();
|
||||||
|
double dz = viewer.position().z() - target.position().z();
|
||||||
|
return dx * dx + dy * dy + dz * dz <= 16.0 * 16.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,108 @@
|
|||||||
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
import java.lang.reflect.Constructor;
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
|
/** Main-thread native packet preparation/delivery. Outgoing authorization performs the final projection. */
|
||||||
|
final class NativeEyeDelivery implements EyeRevealController.Delivery {
|
||||||
|
private final Constructor<?> teamPacket;
|
||||||
|
private final Constructor<?> parameters;
|
||||||
|
private final Constructor<?> scoreboard;
|
||||||
|
private final Constructor<?> playerTeam;
|
||||||
|
private final Constructor<?> metadata;
|
||||||
|
private final Constructor<?> dataValue;
|
||||||
|
private final Field nativeTeam;
|
||||||
|
private final Method playerHandle;
|
||||||
|
private final Method entityData;
|
||||||
|
private final Method dataGet;
|
||||||
|
private final Object sharedFlags;
|
||||||
|
private final Object byteSerializer;
|
||||||
|
private final Field connection;
|
||||||
|
private final Method sendPacket;
|
||||||
|
|
||||||
|
NativeEyeDelivery() {
|
||||||
|
try {
|
||||||
|
Class<?> team = Class.forName("net.minecraft.world.scores.PlayerTeam");
|
||||||
|
Class<?> board = Class.forName("net.minecraft.world.scores.Scoreboard");
|
||||||
|
teamPacket = Class.forName("net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket")
|
||||||
|
.getDeclaredConstructor(String.class, int.class, Optional.class, Collection.class);
|
||||||
|
teamPacket.setAccessible(true);
|
||||||
|
parameters = Class.forName("net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket$Parameters")
|
||||||
|
.getConstructor(team);
|
||||||
|
scoreboard = board.getConstructor();
|
||||||
|
playerTeam = team.getConstructor(board, String.class);
|
||||||
|
nativeTeam = Class.forName("org.bukkit.craftbukkit.scoreboard.CraftTeam").getDeclaredField("team");
|
||||||
|
nativeTeam.setAccessible(true);
|
||||||
|
playerHandle = Class.forName("org.bukkit.craftbukkit.entity.CraftPlayer").getMethod("getHandle");
|
||||||
|
Class<?> entity = Class.forName("net.minecraft.world.entity.Entity");
|
||||||
|
entityData = entity.getMethod("getEntityData");
|
||||||
|
var flags = entity.getDeclaredField("DATA_SHARED_FLAGS_ID");
|
||||||
|
flags.setAccessible(true);
|
||||||
|
sharedFlags = flags.get(null);
|
||||||
|
Class<?> accessor = Class.forName("net.minecraft.network.syncher.EntityDataAccessor");
|
||||||
|
dataGet = Class.forName("net.minecraft.network.syncher.SynchedEntityData").getMethod("get", accessor);
|
||||||
|
byteSerializer = Class.forName("net.minecraft.network.syncher.EntityDataSerializers").getField("BYTE").get(null);
|
||||||
|
dataValue = Class.forName("net.minecraft.network.syncher.SynchedEntityData$DataValue").getConstructor(
|
||||||
|
int.class, Class.forName("net.minecraft.network.syncher.EntityDataSerializer"), Object.class);
|
||||||
|
metadata = Class.forName("net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket").getConstructor(int.class, List.class);
|
||||||
|
connection = Class.forName("net.minecraft.server.level.ServerPlayer").getField("connection");
|
||||||
|
sendPacket = Class.forName("net.minecraft.server.network.ServerCommonPacketListenerImpl")
|
||||||
|
.getMethod("send", Class.forName("net.minecraft.network.protocol.Packet"));
|
||||||
|
} catch (ReflectiveOperationException exception) {
|
||||||
|
throw new IllegalStateException("Native Eye delivery is unsupported on this server version", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public void show(Player viewer, Player target, EyeRevealController.Projection projection, boolean create) {
|
||||||
|
try {
|
||||||
|
var source = viewer.getScoreboard().getEntryTeam(projection.playerName());
|
||||||
|
Object basis = source == null ? playerTeam.newInstance(scoreboard.newInstance(), projection.privateTeam()) : nativeTeam.get(source);
|
||||||
|
// Send unprojected data. The packet listener checks the latest per-viewer authorization at dispatch.
|
||||||
|
Object privateParameters = EyePacketProjection.teamVisibility(parameters.newInstance(basis), EyeTeamVisibility.visible(viewer, source));
|
||||||
|
Object team = teamPacket.newInstance(projection.privateTeam(), create ? 0 : 2,
|
||||||
|
Optional.of(privateParameters), create ? List.of(projection.playerName()) : List.of());
|
||||||
|
var packets = List.of(team, rawMetadata(target));
|
||||||
|
for (Object packet : packets) { send(viewer, packet); }
|
||||||
|
} catch (ReflectiveOperationException exception) {
|
||||||
|
throw new IllegalStateException("Could not deliver a private Eye view", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public void restore(Player viewer, Optional<Player> target, EyeRevealController.Projection projection) {
|
||||||
|
try {
|
||||||
|
var packets = new ArrayList<Object>();
|
||||||
|
var source = viewer.getScoreboard().getEntryTeam(projection.playerName());
|
||||||
|
Object basis = source == null ? playerTeam.newInstance(scoreboard.newInstance(), projection.privateTeam()) : nativeTeam.get(source);
|
||||||
|
Object baseline = parameters.newInstance(basis);
|
||||||
|
// Restore presentation before changing membership, avoiding a temporary unteamed/plain-name gap.
|
||||||
|
packets.add(teamPacket.newInstance(projection.privateTeam(), 2,
|
||||||
|
Optional.of(EyePacketProjection.teamVisibility(baseline, EyeTeamVisibility.visible(viewer, source))), List.of()));
|
||||||
|
if (target.isPresent() && target.orElseThrow().isOnline()) { packets.add(rawMetadata(target.orElseThrow())); }
|
||||||
|
if (source != null) {
|
||||||
|
packets.add(teamPacket.newInstance(source.getName(), 2, Optional.of(baseline), List.of()));
|
||||||
|
packets.add(teamPacket.newInstance(source.getName(), 3, Optional.empty(), List.of(projection.playerName())));
|
||||||
|
}
|
||||||
|
packets.add(teamPacket.newInstance(projection.privateTeam(), 1, Optional.empty(), List.of()));
|
||||||
|
// Prepare everything before sending, avoiding half-created views on reflection/layout failures.
|
||||||
|
for (Object packet : packets) { send(viewer, packet); }
|
||||||
|
} catch (ReflectiveOperationException exception) {
|
||||||
|
throw new IllegalStateException("Could not restore an Eye observer's baseline", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Object rawMetadata(Player player) throws ReflectiveOperationException {
|
||||||
|
Object value = dataGet.invoke(entityData.invoke(playerHandle.invoke(player)), sharedFlags);
|
||||||
|
if (!(value instanceof Byte)) { throw new IllegalStateException("Native player flags are unavailable"); }
|
||||||
|
return metadata.newInstance(player.getEntityId(), List.of(dataValue.newInstance(0, byteSerializer, value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void send(Player viewer, Object packet) throws ReflectiveOperationException {
|
||||||
|
sendPacket.invoke(connection.get(playerHandle.invoke(viewer)), packet);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ public final class SpigotStealthPlugin extends JavaPlugin {
|
|||||||
private EyeProgressionService eyeProgression;
|
private EyeProgressionService eyeProgression;
|
||||||
private EyePotionListener eyePotions;
|
private EyePotionListener eyePotions;
|
||||||
private boolean eyeRecipeRegistered;
|
private boolean eyeRecipeRegistered;
|
||||||
|
private EyeRevealRuntime eyeReveal;
|
||||||
|
private PotionDamageListener combatPotions;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onEnable() {
|
public void onEnable() {
|
||||||
@@ -68,6 +70,8 @@ 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 (protocolManager != null) { protocolManager.removePacketListeners(this); }
|
if (protocolManager != null) { protocolManager.removePacketListeners(this); }
|
||||||
if (identityPresentation != null && sessions != null) {
|
if (identityPresentation != null && sessions != null) {
|
||||||
for (org.bukkit.entity.Player player : getServer().getOnlinePlayers()) {
|
for (org.bukkit.entity.Player player : getServer().getOnlinePlayers()) {
|
||||||
@@ -119,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(
|
||||||
@@ -166,7 +171,23 @@ public final class SpigotStealthPlugin extends JavaPlugin {
|
|||||||
eyeRecipeRegistered = true;
|
eyeRecipeRegistered = true;
|
||||||
getServer().getPluginManager().registerEvents(eyePotions, this);
|
getServer().getPluginManager().registerEvents(eyePotions, this);
|
||||||
getServer().getPluginManager().registerEvents(eyeCrafting, this);
|
getServer().getPluginManager().registerEvents(eyeCrafting, this);
|
||||||
getServer().getPluginManager().registerEvents(new EyeEquipment(eyeProgression, eyeItems), this);
|
var eyeEquipment = new EyeEquipment(eyeProgression, eyeItems);
|
||||||
|
getServer().getPluginManager().registerEvents(eyeEquipment, this);
|
||||||
|
try {
|
||||||
|
eyeReveal = new EyeRevealRuntime(this, eyeEquipment, sessions::isConcealed, protocolManager);
|
||||||
|
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) {
|
||||||
|
getLogger().severe("Unable to initialize Stealth gameplay: " + rootMessage(exception));
|
||||||
|
getServer().getPluginManager().disablePlugin(this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
getServer().getScheduler().runTaskTimer(this, ignored -> {
|
getServer().getScheduler().runTaskTimer(this, ignored -> {
|
||||||
eyePotions.checkpoint(getServer()::getPlayer);
|
eyePotions.checkpoint(getServer()::getPlayer);
|
||||||
for (var player : getServer().getOnlinePlayers()) {
|
for (var player : getServer().getOnlinePlayers()) {
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
import io.netty.buffer.Unpooled;
|
||||||
|
import java.util.List;
|
||||||
|
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||||
|
import net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket;
|
||||||
|
import net.minecraft.network.syncher.EntityDataAccessor;
|
||||||
|
import net.minecraft.network.syncher.EntityDataSerializers;
|
||||||
|
import net.minecraft.network.syncher.SynchedEntityData;
|
||||||
|
import org.bukkit.craftbukkit.CraftRegistry;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class EyeMetadataProjectionTest {
|
||||||
|
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nativeMetadataProjectionClearsOnlyInvisibilityAndKeepsTheSharedPacketUnchanged() throws Exception {
|
||||||
|
var config = new io.papermc.paper.configuration.GlobalConfiguration();
|
||||||
|
config.anticheat = config.new Anticheat();
|
||||||
|
config.anticheat.obfuscation = config.anticheat.new Obfuscation();
|
||||||
|
config.anticheat.obfuscation.items = config.anticheat.obfuscation.new Items();
|
||||||
|
var server = org.mockito.Mockito.mock(net.minecraft.server.MinecraftServer.class);
|
||||||
|
org.mockito.Mockito.when(server.registryAccess()).thenReturn(CraftRegistry.getMinecraftRegistry().freeze());
|
||||||
|
try (var runtime = org.mockito.Mockito.mockStatic(net.minecraft.server.MinecraftServer.class);
|
||||||
|
var platform = org.mockito.Mockito.mockStatic(io.papermc.paper.configuration.GlobalConfiguration.class)) {
|
||||||
|
runtime.when(net.minecraft.server.MinecraftServer::getServer).thenReturn(server);
|
||||||
|
platform.when(io.papermc.paper.configuration.GlobalConfiguration::get).thenReturn(config);
|
||||||
|
config.anticheat.obfuscation.items.bindDataSanitizer();
|
||||||
|
assertProjection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertProjection() throws Exception {
|
||||||
|
var accessorField = net.minecraft.world.entity.Entity.class.getDeclaredField("DATA_SHARED_FLAGS_ID");
|
||||||
|
accessorField.setAccessible(true);
|
||||||
|
var accessor = assertInstanceOf(EntityDataAccessor.class, accessorField.get(null));
|
||||||
|
assertEquals(0, accessor.id());
|
||||||
|
assertSame(EntityDataSerializers.BYTE, accessor.serializer());
|
||||||
|
for (int flags = 0; flags <= 255; flags++) {
|
||||||
|
var sharedFlags = new SynchedEntityData.DataValue<>(0, EntityDataSerializers.BYTE, (byte) flags);
|
||||||
|
var air = new SynchedEntityData.DataValue<>(1, EntityDataSerializers.INT, 300);
|
||||||
|
var original = new ClientboundSetEntityDataPacket(42, List.of(sharedFlags, air));
|
||||||
|
var projected = assertInstanceOf(ClientboundSetEntityDataPacket.class, EyePacketProjection.metadataPacket(original));
|
||||||
|
assertEquals(42, projected.id());
|
||||||
|
assertEquals((byte) (flags & ~0x20), projected.packedItems().getFirst().value(), "flag byte " + flags);
|
||||||
|
assertSame(air, projected.packedItems().get(1));
|
||||||
|
assertEquals((byte) flags, original.packedItems().getFirst().value());
|
||||||
|
var buffer = new RegistryFriendlyByteBuf(Unpooled.buffer(), CraftRegistry.getMinecraftRegistry());
|
||||||
|
try {
|
||||||
|
ClientboundSetEntityDataPacket.STREAM_CODEC.encode(buffer, projected);
|
||||||
|
assertEquals(projected, ClientboundSetEntityDataPacket.STREAM_CODEC.decode(buffer));
|
||||||
|
} finally { buffer.release(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
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.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket;
|
||||||
|
import net.minecraft.network.syncher.EntityDataSerializers;
|
||||||
|
import net.minecraft.network.syncher.SynchedEntityData;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.plugin.Plugin;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class EyePacketListenerTest {
|
||||||
|
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@SuppressWarnings("try")
|
||||||
|
void metadataIsProjectedOnlyForTheCurrentlyAuthorizedRecipientWithoutMutatingSharedPackets() {
|
||||||
|
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 viewerId = UUID.randomUUID();
|
||||||
|
var viewer = mock(Player.class);
|
||||||
|
when(viewer.getUniqueId()).thenReturn(viewerId);
|
||||||
|
var other = mock(Player.class);
|
||||||
|
when(other.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||||
|
var projection = new EyeRevealController.Projection(UUID.randomUUID(), 42, "Concealed", "eye-private", Optional.of("source"), true);
|
||||||
|
var snapshot = new AtomicReference<>(Map.of(viewerId, Map.of(42, projection)));
|
||||||
|
var warnings = new ArrayList<String>();
|
||||||
|
var dirty = new ArrayList<UUID>();
|
||||||
|
var listener = new EyePacketListener(mock(Plugin.class), snapshot::get, dirty::add, warnings::add);
|
||||||
|
assertEquals(Set.of(PacketType.Play.Server.ENTITY_METADATA, PacketType.Play.Server.SCOREBOARD_TEAM),
|
||||||
|
listener.getSendingWhitelist().getTypes());
|
||||||
|
var raw = new ClientboundSetEntityDataPacket(42, List.of(new SynchedEntityData.DataValue<>(0, EntityDataSerializers.BYTE, (byte) 0x63)));
|
||||||
|
var shared = new PacketContainer(PacketType.Play.Server.ENTITY_METADATA, raw);
|
||||||
|
var first = PacketEvent.fromServer(this, shared, viewer);
|
||||||
|
listener.onPacketSending(first);
|
||||||
|
var shown = assertInstanceOf(ClientboundSetEntityDataPacket.class, first.getPacket().getHandle());
|
||||||
|
assertEquals((byte) 0x43, shown.packedItems().getFirst().value());
|
||||||
|
assertEquals((byte) 0x63, raw.packedItems().getFirst().value());
|
||||||
|
var second = PacketEvent.fromServer(this, shared, other);
|
||||||
|
listener.onPacketSending(second);
|
||||||
|
assertSame(shared, second.getPacket());
|
||||||
|
snapshot.set(Map.of());
|
||||||
|
var late = PacketEvent.fromServer(this, shared, viewer);
|
||||||
|
listener.onPacketSending(late);
|
||||||
|
assertSame(shared, late.getPacket(), "queued packets must use current, not captured authorization");
|
||||||
|
snapshot.set(Map.of(viewerId, Map.of(42, projection)));
|
||||||
|
var cancelled = PacketEvent.fromServer(this, shared, viewer);
|
||||||
|
cancelled.setCancelled(true);
|
||||||
|
listener.onPacketSending(cancelled);
|
||||||
|
assertTrue(cancelled.isCancelled());
|
||||||
|
assertSame(shared, cancelled.getPacket());
|
||||||
|
var noBody = new EyeRevealController.Projection(projection.targetId(), 42, projection.playerName(),
|
||||||
|
projection.privateTeam(), projection.sourceTeam(), false);
|
||||||
|
snapshot.set(Map.of(viewerId, Map.of(42, noBody)));
|
||||||
|
var notGameplayInvisible = PacketEvent.fromServer(this, shared, viewer);
|
||||||
|
listener.onPacketSending(notGameplayInvisible);
|
||||||
|
assertSame(shared, notGameplayInvisible.getPacket());
|
||||||
|
snapshot.set(Map.of(viewerId, Map.of(42, projection)));
|
||||||
|
var board = new net.minecraft.world.scores.Scoreboard();
|
||||||
|
var privateTeam = board.addPlayerTeam("eye-private");
|
||||||
|
privateTeam.setPlayerPrefix(net.minecraft.network.chat.Component.empty().withStyle(net.minecraft.ChatFormatting.OBFUSCATED));
|
||||||
|
board.addPlayerToTeam("Concealed", privateTeam);
|
||||||
|
var privateRaw = net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.createAddOrModifyPacket(privateTeam, true);
|
||||||
|
var privateWrapped = new PacketContainer(PacketType.Play.Server.SCOREBOARD_TEAM, privateRaw);
|
||||||
|
var privateEvent = PacketEvent.fromServer(this, privateWrapped, viewer);
|
||||||
|
listener.onPacketSending(privateEvent);
|
||||||
|
var shownTeam = assertInstanceOf(net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.class, privateEvent.getPacket().getHandle());
|
||||||
|
assertFalse(shownTeam.getParameters().orElseThrow().playerPrefix().getStyle().isObfuscated());
|
||||||
|
assertTrue(privateRaw.getParameters().orElseThrow().playerPrefix().getStyle().isObfuscated());
|
||||||
|
assertTrue(dirty.isEmpty(), "our own private updates must not create a refresh loop");
|
||||||
|
var source = board.addPlayerTeam("source");
|
||||||
|
source.setPlayerPrefix(net.minecraft.network.chat.Component.empty().withStyle(net.minecraft.ChatFormatting.OBFUSCATED));
|
||||||
|
board.addPlayerToTeam("Concealed", source);
|
||||||
|
board.addPlayerToTeam("Other", source);
|
||||||
|
var sourceRaw = net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.createAddOrModifyPacket(source, true);
|
||||||
|
var sourceWrapped = new PacketContainer(PacketType.Play.Server.SCOREBOARD_TEAM, sourceRaw);
|
||||||
|
var sourceEvent = PacketEvent.fromServer(this, sourceWrapped, viewer);
|
||||||
|
listener.onPacketSending(sourceEvent);
|
||||||
|
var filtered = assertInstanceOf(net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.class, sourceEvent.getPacket().getHandle());
|
||||||
|
assertEquals(Set.of("Other"), Set.copyOf(filtered.getPlayers()));
|
||||||
|
assertTrue(filtered.getParameters().orElseThrow().playerPrefix().getStyle().isObfuscated());
|
||||||
|
assertEquals(List.of(viewerId), dirty, "source style updates request a main-thread refresh");
|
||||||
|
var otherEvent = PacketEvent.fromServer(this, sourceWrapped, other);
|
||||||
|
listener.onPacketSending(otherEvent);
|
||||||
|
assertSame(sourceWrapped, otherEvent.getPacket());
|
||||||
|
var system = new PacketContainer(PacketType.Play.Server.SYSTEM_CHAT,
|
||||||
|
new net.minecraft.network.protocol.game.ClientboundSystemChatPacket(net.minecraft.network.chat.Component.literal("Unchanged"), false));
|
||||||
|
var systemEvent = PacketEvent.fromServer(this, system, viewer);
|
||||||
|
listener.onPacketSending(systemEvent);
|
||||||
|
assertSame(system, systemEvent.getPacket());
|
||||||
|
var tabRemoval = new PacketContainer(PacketType.Play.Server.PLAYER_INFO_REMOVE);
|
||||||
|
tabRemoval.getUUIDLists().write(0, List.of(projection.targetId()));
|
||||||
|
var tabEvent = PacketEvent.fromServer(this, tabRemoval, viewer);
|
||||||
|
listener.onPacketSending(tabEvent);
|
||||||
|
assertSame(tabRemoval, tabEvent.getPacket());
|
||||||
|
assertEquals(List.of(projection.targetId()), tabEvent.getPacket().getUUIDLists().read(0));
|
||||||
|
assertTrue(warnings.isEmpty());
|
||||||
|
verify(viewer, never()).getInventory();
|
||||||
|
verify(viewer, never()).getWorld();
|
||||||
|
verify(viewer, never()).hasLineOfSight(any(org.bukkit.entity.Entity.class));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -100,6 +100,9 @@ class EyePluginLifecycleTest {
|
|||||||
default -> call.callRealMethod();
|
default -> call.callRealMethod();
|
||||||
}); var protocol = mockStatic(ProtocolLibrary.class)) {
|
}); var protocol = mockStatic(ProtocolLibrary.class)) {
|
||||||
var protocolManager = mock(ProtocolManager.class);
|
var protocolManager = mock(ProtocolManager.class);
|
||||||
|
var registeredPackets = new ArrayList<com.comphenix.protocol.events.PacketListener>();
|
||||||
|
doAnswer(call -> { registeredPackets.add(call.getArgument(0)); return null; }).when(protocolManager).addPacketListener(any());
|
||||||
|
doAnswer(call -> { registeredPackets.remove(call.getArgument(0)); return null; }).when(protocolManager).removePacketListener(any());
|
||||||
protocol.when(ProtocolLibrary::getProtocolManager).thenReturn(protocolManager);
|
protocol.when(ProtocolLibrary::getProtocolManager).thenReturn(protocolManager);
|
||||||
try {
|
try {
|
||||||
plugin.onEnable();
|
plugin.onEnable();
|
||||||
@@ -133,6 +136,12 @@ 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(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);
|
||||||
when(inventory.getRecipe()).thenAnswer(ignored -> recipe.get());
|
when(inventory.getRecipe()).thenAnswer(ignored -> recipe.get());
|
||||||
var result = new AtomicReference<>(recipe.get().getResult());
|
var result = new AtomicReference<>(recipe.get().getResult());
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import org.bukkit.GameMode;
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.World;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
import org.bukkit.inventory.PlayerInventory;
|
||||||
|
import org.bukkit.potion.PotionEffectType;
|
||||||
|
import org.bukkit.scoreboard.Scoreboard;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
class EyeRevealControllerTest {
|
||||||
|
@TempDir Path directory;
|
||||||
|
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void onlyTheEligibleObserverGetsAnImmutableProjectionAndUnequippingRestoresTheBaseline() throws Exception {
|
||||||
|
var world = mock(World.class);
|
||||||
|
when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||||
|
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||||
|
var viewer = player(world, "Viewer", 1, 0);
|
||||||
|
var unearned = player(world, "Unearned", 2, 0);
|
||||||
|
var target = player(world, "Concealed", 42, 8);
|
||||||
|
when(target.getTrackedBy()).thenReturn(Set.of(viewer, unearned));
|
||||||
|
when(viewer.canSee(target)).thenReturn(true);
|
||||||
|
when(viewer.hasLineOfSight(target)).thenReturn(true);
|
||||||
|
when(target.hasPotionEffect(PotionEffectType.INVISIBILITY)).thenReturn(true);
|
||||||
|
var items = new EyeItems();
|
||||||
|
var helmet = new AtomicReference<>(items.create());
|
||||||
|
when(viewer.getInventory().getHelmet()).thenAnswer(ignored -> helmet.get());
|
||||||
|
when(unearned.getInventory().getHelmet()).thenReturn(items.create());
|
||||||
|
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
|
||||||
|
var initial = new PersistentStealthState(Map.of(), Map.of())
|
||||||
|
.withEyeProgress(viewer.getUniqueId(), new EyeProgress(28_800_000, true));
|
||||||
|
var actions = new ArrayList<String>();
|
||||||
|
var controllerRef = new AtomicReference<EyeRevealController>();
|
||||||
|
var delivery = new EyeRevealController.Delivery() {
|
||||||
|
@Override public void show(Player observer, Player subject, EyeRevealController.Projection projection, boolean create) {
|
||||||
|
actions.add((create ? "create:" : "refresh:") + observer.getName() + ":" + subject.getName());
|
||||||
|
assertTrue(projection.revealBody());
|
||||||
|
assertEquals(projection, controllerRef.get().snapshot().get(observer.getUniqueId()).get(projection.entityId()));
|
||||||
|
}
|
||||||
|
@Override public void restore(Player observer, Optional<Player> subject, EyeRevealController.Projection projection) {
|
||||||
|
assertFalse(controllerRef.get().snapshot().getOrDefault(observer.getUniqueId(), Map.of()).containsKey(projection.entityId()),
|
||||||
|
"authorization must be withdrawn before sending restoration packets");
|
||||||
|
actions.add("restore:" + observer.getName() + ":" + projection.playerName());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
try (var states = new StealthStateManager(repository, initial)) {
|
||||||
|
var equipment = new EyeEquipment(new EyeProgressionService(states, () -> 0L, ignored -> { }), items);
|
||||||
|
var ids = new AtomicInteger();
|
||||||
|
var controller = new EyeRevealController(() -> List.of(viewer, unearned, target), equipment,
|
||||||
|
id -> id.equals(target.getUniqueId()), delivery, () -> "eye" + ids.incrementAndGet());
|
||||||
|
controllerRef.set(controller);
|
||||||
|
controller.run();
|
||||||
|
var snapshot = controller.snapshot();
|
||||||
|
assertNotNull(snapshot.get(viewer.getUniqueId()), "an eligible observer needs a projection");
|
||||||
|
assertEquals(Set.of(42), snapshot.get(viewer.getUniqueId()).keySet());
|
||||||
|
assertFalse(snapshot.containsKey(unearned.getUniqueId()));
|
||||||
|
assertEquals(List.of("create:Viewer:Concealed"), actions);
|
||||||
|
assertThrows(UnsupportedOperationException.class, () -> snapshot.clear());
|
||||||
|
assertThrows(UnsupportedOperationException.class, () -> snapshot.get(viewer.getUniqueId()).clear());
|
||||||
|
controller.run();
|
||||||
|
assertEquals(1, actions.size(), "unchanged frames must not resend packets");
|
||||||
|
UUID observerId = viewer.getUniqueId();
|
||||||
|
java.util.concurrent.CompletableFuture.runAsync(() -> {
|
||||||
|
controller.requestRefresh(observerId);
|
||||||
|
controller.requestRefresh(observerId);
|
||||||
|
}).get(3, java.util.concurrent.TimeUnit.SECONDS);
|
||||||
|
assertEquals(1, actions.size(), "packet-thread invalidation must not perform delivery");
|
||||||
|
controller.run();
|
||||||
|
assertEquals(List.of("create:Viewer:Concealed", "refresh:Viewer:Concealed"), actions,
|
||||||
|
"source updates must coalesce into a main-thread refresh");
|
||||||
|
helmet.set(new ItemStack(Material.IRON_HELMET));
|
||||||
|
controller.run();
|
||||||
|
assertTrue(controller.snapshot().isEmpty());
|
||||||
|
assertEquals(List.of("create:Viewer:Concealed", "refresh:Viewer:Concealed", "restore:Viewer:Concealed"), actions);
|
||||||
|
assertEquals(1, snapshot.get(viewer.getUniqueId()).size(), "published snapshots cannot be mutated by later frames");
|
||||||
|
helmet.set(items.create());
|
||||||
|
controller.run();
|
||||||
|
controller.close();
|
||||||
|
assertTrue(controller.snapshot().isEmpty(), "disable must withdraw all projections");
|
||||||
|
assertEquals(5, actions.size());
|
||||||
|
assertEquals("restore:Viewer:Concealed", actions.getLast());
|
||||||
|
controller.close();
|
||||||
|
controller.run();
|
||||||
|
assertEquals(5, actions.size(), "closed controllers cannot resend or revive projections");
|
||||||
|
verify(target, never()).removePotionEffect(any());
|
||||||
|
verify(target, never()).setInvisible(anyBoolean());
|
||||||
|
verify(viewer, never()).hasLineOfSight(unearned);
|
||||||
|
verify(unearned, never()).hasLineOfSight(any(org.bukkit.entity.Entity.class));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@org.junit.jupiter.params.ParameterizedTest
|
||||||
|
@org.junit.jupiter.params.provider.ValueSource(strings = {"range", "sight", "hidden", "spectator", "vanished", "untracked", "unloaded", "world", "death", "session", "capture-failure", "team-hidden"})
|
||||||
|
void platformChangesWithdrawTheProjectionWithoutLoadingChunksOrForcingVisibility(String change) throws Exception {
|
||||||
|
var world = mock(World.class);
|
||||||
|
when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||||
|
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||||
|
var viewer = player(world, "Viewer", 1, 0);
|
||||||
|
var target = player(world, "Concealed", 42, 8);
|
||||||
|
when(target.getTrackedBy()).thenReturn(Set.of(viewer));
|
||||||
|
when(viewer.canSee(target)).thenReturn(true);
|
||||||
|
when(viewer.hasLineOfSight(target)).thenReturn(true);
|
||||||
|
var items = new EyeItems();
|
||||||
|
when(viewer.getInventory().getHelmet()).thenReturn(items.create());
|
||||||
|
var initial = new PersistentStealthState(Map.of(), Map.of())
|
||||||
|
.withEyeProgress(viewer.getUniqueId(), new EyeProgress(28_800_000, true));
|
||||||
|
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
|
||||||
|
var session = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||||
|
var restored = new AtomicInteger();
|
||||||
|
var delivery = new EyeRevealController.Delivery() {
|
||||||
|
@Override public void show(Player observer, Player subject, EyeRevealController.Projection projection, boolean create) {
|
||||||
|
assertFalse(projection.revealBody(), "identity concealment alone does not authorize changing body flags");
|
||||||
|
}
|
||||||
|
@Override public void restore(Player observer, Optional<Player> subject, EyeRevealController.Projection projection) {
|
||||||
|
assertSame(viewer, observer);
|
||||||
|
assertEquals(target.getUniqueId(), projection.targetId());
|
||||||
|
restored.incrementAndGet();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
try (var states = new StealthStateManager(repository, initial)) {
|
||||||
|
var controller = new EyeRevealController(() -> List.of(viewer, target),
|
||||||
|
new EyeEquipment(new EyeProgressionService(states, () -> 0L, ignored -> { }), items),
|
||||||
|
id -> id.equals(target.getUniqueId()) && session.get(), delivery, () -> "eye1");
|
||||||
|
controller.run();
|
||||||
|
assertEquals(1, controller.snapshot().get(viewer.getUniqueId()).size());
|
||||||
|
switch (change) {
|
||||||
|
case "range" -> when(target.getLocation()).thenReturn(new Location(world, 16.001, 64, 0));
|
||||||
|
case "sight" -> when(viewer.hasLineOfSight(target)).thenReturn(false);
|
||||||
|
case "hidden" -> when(viewer.canSee(target)).thenReturn(false);
|
||||||
|
case "spectator" -> when(target.getGameMode()).thenReturn(GameMode.SPECTATOR);
|
||||||
|
case "vanished" -> {
|
||||||
|
var flag = mock(org.bukkit.metadata.MetadataValue.class);
|
||||||
|
when(flag.asBoolean()).thenReturn(true);
|
||||||
|
when(target.getMetadata("vanished")).thenReturn(List.of(flag));
|
||||||
|
}
|
||||||
|
case "untracked" -> when(target.getTrackedBy()).thenReturn(Set.of());
|
||||||
|
case "unloaded" -> when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(false);
|
||||||
|
case "world" -> {
|
||||||
|
var otherWorld = mock(World.class);
|
||||||
|
when(otherWorld.getUID()).thenReturn(UUID.randomUUID());
|
||||||
|
when(target.getLocation()).thenReturn(new Location(otherWorld, 8, 64, 0));
|
||||||
|
}
|
||||||
|
case "death" -> when(target.isDead()).thenReturn(true);
|
||||||
|
case "session" -> session.set(false);
|
||||||
|
case "capture-failure" -> when(target.getMetadata("vanished")).thenThrow(new IllegalStateException("Visibility state unavailable"));
|
||||||
|
case "team-hidden" -> {
|
||||||
|
var hidden = mock(org.bukkit.scoreboard.Team.class);
|
||||||
|
when(hidden.getOption(org.bukkit.scoreboard.Team.Option.NAME_TAG_VISIBILITY)).thenReturn(org.bukkit.scoreboard.Team.OptionStatus.NEVER);
|
||||||
|
when(viewer.getScoreboard().getEntryTeam(target.getName())).thenReturn(hidden);
|
||||||
|
}
|
||||||
|
default -> throw new AssertionError(change);
|
||||||
|
}
|
||||||
|
org.mockito.Mockito.<Object>clearInvocations(world, viewer, target);
|
||||||
|
controller.run();
|
||||||
|
assertTrue(controller.snapshot().isEmpty(), change);
|
||||||
|
assertEquals(1, restored.get());
|
||||||
|
if (!change.equals("sight")) { verify(viewer, never()).hasLineOfSight(target); }
|
||||||
|
verify(world, never()).getChunkAt(anyInt(), anyInt());
|
||||||
|
verify(world, never()).loadChunk(anyInt(), anyInt());
|
||||||
|
verify(viewer, never()).showPlayer(any(), any());
|
||||||
|
verify(target, never()).removePotionEffect(any());
|
||||||
|
verify(target, never()).setInvisible(anyBoolean());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@org.junit.jupiter.params.ParameterizedTest
|
||||||
|
@org.junit.jupiter.params.provider.ValueSource(strings = {"pair", "viewer", "target", "hide-event", "untrack-event", "helmet-event", "spectator-event", "potion-event", "quit-event", "teleport-event", "respawn-event", "death-event", "world-event", "cancelled-teleport", "cancelled-mode", "cancelled-potion", "other-armor", "other-effect"})
|
||||||
|
void lifecycleInvalidationWithdrawsAuthorizationImmediatelyAndOnlyForAffectedViews(String change) throws Exception {
|
||||||
|
var world = mock(World.class);
|
||||||
|
when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||||
|
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||||
|
var first = player(world, "First", 1, 0);
|
||||||
|
var second = player(world, "Second", 2, 0);
|
||||||
|
var target = player(world, "Concealed", 42, 8);
|
||||||
|
when(target.getTrackedBy()).thenReturn(Set.of(first, second));
|
||||||
|
var items = new EyeItems();
|
||||||
|
for (var viewer : List.of(first, second)) {
|
||||||
|
when(viewer.canSee(target)).thenReturn(true);
|
||||||
|
when(viewer.hasLineOfSight(target)).thenReturn(true);
|
||||||
|
when(viewer.getInventory().getHelmet()).thenReturn(items.create());
|
||||||
|
}
|
||||||
|
var initial = new PersistentStealthState(Map.of(), Map.of())
|
||||||
|
.withEyeProgress(first.getUniqueId(), new EyeProgress(28_800_000, true))
|
||||||
|
.withEyeProgress(second.getUniqueId(), new EyeProgress(28_800_000, true));
|
||||||
|
var restored = new ArrayList<UUID>();
|
||||||
|
var delivery = new EyeRevealController.Delivery() {
|
||||||
|
@Override public void show(Player observer, Player subject, EyeRevealController.Projection projection, boolean create) { }
|
||||||
|
@Override public void restore(Player observer, Optional<Player> subject, EyeRevealController.Projection projection) {
|
||||||
|
restored.add(observer.getUniqueId());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
|
||||||
|
try (var states = new StealthStateManager(repository, initial)) {
|
||||||
|
var ids = new AtomicInteger();
|
||||||
|
var controller = new EyeRevealController(() -> List.of(first, second, target),
|
||||||
|
new EyeEquipment(new EyeProgressionService(states, () -> 0L, ignored -> { }), items),
|
||||||
|
id -> id.equals(target.getUniqueId()), delivery, () -> "eye" + ids.incrementAndGet());
|
||||||
|
controller.run();
|
||||||
|
assertEquals(2, controller.snapshot().size());
|
||||||
|
var events = new EyeRevealEvents(controller);
|
||||||
|
switch (change) {
|
||||||
|
case "cancelled-teleport" -> {
|
||||||
|
var event = new org.bukkit.event.player.PlayerTeleportEvent(first, first.getLocation(), target.getLocation());
|
||||||
|
event.setCancelled(true);
|
||||||
|
events.onTeleport(event);
|
||||||
|
}
|
||||||
|
case "cancelled-mode" -> {
|
||||||
|
var event = new org.bukkit.event.player.PlayerGameModeChangeEvent(target, GameMode.SPECTATOR);
|
||||||
|
event.setCancelled(true);
|
||||||
|
events.onGameMode(event);
|
||||||
|
}
|
||||||
|
case "cancelled-potion" -> {
|
||||||
|
var event = new org.bukkit.event.entity.EntityPotionEffectEvent(target,
|
||||||
|
new org.bukkit.potion.PotionEffect(PotionEffectType.INVISIBILITY, 100, 0), null, null,
|
||||||
|
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.MILK, org.bukkit.event.entity.EntityPotionEffectEvent.Action.REMOVED, false);
|
||||||
|
event.setCancelled(true);
|
||||||
|
events.onEffect(event);
|
||||||
|
}
|
||||||
|
case "other-armor" -> events.onArmor(new com.destroystokyo.paper.event.player.PlayerArmorChangeEvent(first,
|
||||||
|
com.destroystokyo.paper.event.player.PlayerArmorChangeEvent.SlotType.CHEST, new ItemStack(Material.AIR), new ItemStack(Material.IRON_CHESTPLATE)));
|
||||||
|
case "other-effect" -> events.onEffect(new org.bukkit.event.entity.EntityPotionEffectEvent(target, null,
|
||||||
|
new org.bukkit.potion.PotionEffect(PotionEffectType.NIGHT_VISION, 100, 0), null,
|
||||||
|
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.COMMAND, org.bukkit.event.entity.EntityPotionEffectEvent.Action.ADDED, false));
|
||||||
|
case "hide-event" -> events.onHide(new org.bukkit.event.player.PlayerHideEntityEvent(first, target));
|
||||||
|
case "untrack-event" -> events.onUntrack(new io.papermc.paper.event.player.PlayerUntrackEntityEvent(first, target));
|
||||||
|
case "helmet-event" -> events.onArmor(new com.destroystokyo.paper.event.player.PlayerArmorChangeEvent(first,
|
||||||
|
com.destroystokyo.paper.event.player.PlayerArmorChangeEvent.SlotType.HEAD, items.create(), new ItemStack(Material.AIR)));
|
||||||
|
case "spectator-event" -> events.onGameMode(new org.bukkit.event.player.PlayerGameModeChangeEvent(target, GameMode.SPECTATOR));
|
||||||
|
case "potion-event" -> events.onEffect(new org.bukkit.event.entity.EntityPotionEffectEvent(target,
|
||||||
|
new org.bukkit.potion.PotionEffect(PotionEffectType.INVISIBILITY, 100, 0), null, null,
|
||||||
|
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.MILK, org.bukkit.event.entity.EntityPotionEffectEvent.Action.REMOVED, false));
|
||||||
|
case "quit-event" -> events.onQuit(new org.bukkit.event.player.PlayerQuitEvent(first, net.kyori.adventure.text.Component.empty()));
|
||||||
|
case "teleport-event" -> events.onTeleport(new org.bukkit.event.player.PlayerTeleportEvent(first, first.getLocation(), target.getLocation()));
|
||||||
|
case "respawn-event" -> events.onRespawn(new org.bukkit.event.player.PlayerRespawnEvent(first, first.getLocation(), false));
|
||||||
|
case "death-event" -> events.onDeath(new org.bukkit.event.entity.PlayerDeathEvent(target, mock(org.bukkit.damage.DamageSource.class),
|
||||||
|
new ArrayList<>(), 0, net.kyori.adventure.text.Component.empty(), false));
|
||||||
|
case "world-event" -> events.onWorld(new org.bukkit.event.player.PlayerChangedWorldEvent(first, world));
|
||||||
|
case "pair" -> controller.hide(first.getUniqueId(), target.getUniqueId());
|
||||||
|
case "viewer" -> controller.withdrawViewer(first.getUniqueId());
|
||||||
|
case "target" -> controller.withdrawTarget(target.getUniqueId());
|
||||||
|
default -> throw new AssertionError(change);
|
||||||
|
}
|
||||||
|
if (change.startsWith("cancelled-") || change.startsWith("other-")) {
|
||||||
|
assertEquals(2, controller.snapshot().size());
|
||||||
|
assertTrue(restored.isEmpty(), "cancelled or unrelated events must not disturb valid projections");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assertFalse(controller.snapshot().containsKey(first.getUniqueId()), "invalidation cannot wait for the next frame");
|
||||||
|
boolean allObservers = Set.of("target", "spectator-event", "potion-event", "death-event").contains(change);
|
||||||
|
assertEquals(allObservers ? 0 : 1, controller.snapshot().size());
|
||||||
|
assertEquals(allObservers ? Set.of(first.getUniqueId(), second.getUniqueId()) : Set.of(first.getUniqueId()), Set.copyOf(restored));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void failedDeliveryRevokesAuthorizationAndCleanupMustSucceedBeforeRetryingTheView() throws Exception {
|
||||||
|
var world = mock(World.class);
|
||||||
|
when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||||
|
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||||
|
var viewer = player(world, "Viewer", 1, 0);
|
||||||
|
var target = player(world, "Concealed", 42, 8);
|
||||||
|
when(target.getTrackedBy()).thenReturn(Set.of(viewer));
|
||||||
|
when(viewer.canSee(target)).thenReturn(true);
|
||||||
|
when(viewer.hasLineOfSight(target)).thenReturn(true);
|
||||||
|
var items = new EyeItems();
|
||||||
|
when(viewer.getInventory().getHelmet()).thenReturn(items.create());
|
||||||
|
var initial = new PersistentStealthState(Map.of(), Map.of())
|
||||||
|
.withEyeProgress(viewer.getUniqueId(), new EyeProgress(28_800_000, true));
|
||||||
|
var shows = new AtomicInteger();
|
||||||
|
var restores = new AtomicInteger();
|
||||||
|
var failures = new ArrayList<Throwable>();
|
||||||
|
var delivery = new EyeRevealController.Delivery() {
|
||||||
|
@Override public void show(Player observer, Player subject, EyeRevealController.Projection projection, boolean create) {
|
||||||
|
assertTrue(create);
|
||||||
|
if (shows.incrementAndGet() == 1) { throw new IllegalStateException("Injected partial delivery failure"); }
|
||||||
|
}
|
||||||
|
@Override public void restore(Player observer, Optional<Player> subject, EyeRevealController.Projection projection) {
|
||||||
|
if (restores.incrementAndGet() == 1) { throw new IllegalStateException("Injected restoration failure"); }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
|
||||||
|
try (var states = new StealthStateManager(repository, initial)) {
|
||||||
|
var ids = new AtomicInteger();
|
||||||
|
var controller = new EyeRevealController(() -> List.of(viewer, target),
|
||||||
|
new EyeEquipment(new EyeProgressionService(states, () -> 0L, ignored -> { }), items),
|
||||||
|
id -> id.equals(target.getUniqueId()), delivery, () -> "eye" + ids.incrementAndGet(), failures::add);
|
||||||
|
assertDoesNotThrow(controller::run);
|
||||||
|
assertTrue(controller.snapshot().isEmpty(), "failed delivery cannot retain outgoing reveal authorization");
|
||||||
|
assertDoesNotThrow(controller::run);
|
||||||
|
assertEquals(1, shows.get(), "failed cleanup must block recreation of the view");
|
||||||
|
assertTrue(controller.snapshot().isEmpty());
|
||||||
|
controller.run();
|
||||||
|
assertEquals(2, shows.get());
|
||||||
|
assertEquals(2, restores.get());
|
||||||
|
assertEquals(1, controller.snapshot().get(viewer.getUniqueId()).size());
|
||||||
|
assertFalse(failures.isEmpty());
|
||||||
|
controller.withdrawViewer(viewer.getUniqueId());
|
||||||
|
assertEquals(3, restores.get());
|
||||||
|
assertTrue(controller.snapshot().isEmpty());
|
||||||
|
controller.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Player player(World world, String name, int entityId, double x) {
|
||||||
|
var player = mock(Player.class);
|
||||||
|
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||||
|
when(player.getName()).thenReturn(name);
|
||||||
|
when(player.getEntityId()).thenReturn(entityId);
|
||||||
|
when(player.getLocation()).thenReturn(new Location(world, x, 64, 0));
|
||||||
|
when(player.isOnline()).thenReturn(true);
|
||||||
|
when(player.getGameMode()).thenReturn(GameMode.SURVIVAL);
|
||||||
|
when(player.getInventory()).thenReturn(mock(PlayerInventory.class));
|
||||||
|
when(player.getScoreboard()).thenReturn(mock(Scoreboard.class));
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
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 com.comphenix.protocol.events.PacketEvent;
|
||||||
|
import com.comphenix.protocol.events.PacketListener;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import net.minecraft.ChatFormatting;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
import net.minecraft.network.protocol.Packet;
|
||||||
|
import net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket;
|
||||||
|
import net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket;
|
||||||
|
import net.minecraft.network.syncher.EntityDataAccessor;
|
||||||
|
import net.minecraft.network.syncher.SynchedEntityData;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import net.minecraft.server.network.ServerGamePacketListenerImpl;
|
||||||
|
import net.minecraft.world.scores.Scoreboard;
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.World;
|
||||||
|
import org.bukkit.craftbukkit.CraftServer;
|
||||||
|
import org.bukkit.craftbukkit.entity.CraftPlayer;
|
||||||
|
import org.bukkit.craftbukkit.inventory.CraftInventoryPlayer;
|
||||||
|
import org.bukkit.craftbukkit.scheduler.CraftScheduler;
|
||||||
|
import org.bukkit.craftbukkit.scoreboard.CraftScoreboard;
|
||||||
|
import org.bukkit.event.Listener;
|
||||||
|
import org.bukkit.plugin.PluginManager;
|
||||||
|
import org.bukkit.plugin.java.JavaPlugin;
|
||||||
|
import org.bukkit.potion.PotionEffectType;
|
||||||
|
import org.bukkit.scheduler.BukkitTask;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
class EyeRevealRuntimeTest {
|
||||||
|
@TempDir Path directory;
|
||||||
|
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@SuppressWarnings({"try", "unchecked"})
|
||||||
|
void registeredRuntimeProjectsAndRestoresRealPacketsWithoutChangingTheServerOrIdentityChannels() throws Exception {
|
||||||
|
var server = mock(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();
|
||||||
|
})) {
|
||||||
|
var world = mock(World.class);
|
||||||
|
when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||||
|
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||||
|
var viewer = mock(CraftPlayer.class);
|
||||||
|
var target = mock(CraftPlayer.class);
|
||||||
|
UUID viewerId = UUID.randomUUID(), targetId = UUID.randomUUID();
|
||||||
|
when(viewer.getUniqueId()).thenReturn(viewerId);
|
||||||
|
when(target.getUniqueId()).thenReturn(targetId);
|
||||||
|
when(viewer.getName()).thenReturn("Viewer");
|
||||||
|
when(target.getName()).thenReturn("Concealed");
|
||||||
|
when(viewer.isOnline()).thenReturn(true);
|
||||||
|
when(target.isOnline()).thenReturn(true);
|
||||||
|
when(viewer.getEntityId()).thenReturn(1);
|
||||||
|
when(target.getEntityId()).thenReturn(42);
|
||||||
|
when(viewer.getLocation()).thenReturn(new Location(world, 0, 64, 0));
|
||||||
|
when(target.getLocation()).thenReturn(new Location(world, 8, 64, 0));
|
||||||
|
when(viewer.canSee(target)).thenReturn(true);
|
||||||
|
when(viewer.hasLineOfSight(target)).thenReturn(true);
|
||||||
|
when(target.getTrackedBy()).thenReturn(Set.of(viewer));
|
||||||
|
when(target.hasPotionEffect(PotionEffectType.INVISIBILITY)).thenReturn(true);
|
||||||
|
var inventory = mock(CraftInventoryPlayer.class);
|
||||||
|
when(viewer.getInventory()).thenReturn(inventory);
|
||||||
|
var items = new EyeItems();
|
||||||
|
when(inventory.getHelmet()).thenReturn(items.create());
|
||||||
|
var board = new Scoreboard();
|
||||||
|
var baseline = board.addPlayerTeam("source");
|
||||||
|
baseline.setPlayerPrefix(Component.empty().withStyle(ChatFormatting.OBFUSCATED));
|
||||||
|
board.addPlayerToTeam("Concealed", baseline);
|
||||||
|
var constructor = CraftScoreboard.class.getDeclaredConstructor(Scoreboard.class);
|
||||||
|
constructor.setAccessible(true);
|
||||||
|
when(viewer.getScoreboard()).thenReturn(constructor.newInstance(board));
|
||||||
|
var viewerHandle = mock(ServerPlayer.class);
|
||||||
|
viewerHandle.connection = mock(ServerGamePacketListenerImpl.class);
|
||||||
|
when(viewer.getHandle()).thenReturn(viewerHandle);
|
||||||
|
var targetHandle = mock(ServerPlayer.class);
|
||||||
|
when(target.getHandle()).thenReturn(targetHandle);
|
||||||
|
var data = mock(SynchedEntityData.class);
|
||||||
|
when(targetHandle.getEntityData()).thenReturn(data);
|
||||||
|
var field = net.minecraft.world.entity.Entity.class.getDeclaredField("DATA_SHARED_FLAGS_ID");
|
||||||
|
field.setAccessible(true);
|
||||||
|
var flags = (EntityDataAccessor<Byte>) field.get(null);
|
||||||
|
when(data.get(flags)).thenReturn((byte) 0x60);
|
||||||
|
var protocol = mock(ProtocolManager.class);
|
||||||
|
var packetListeners = new ArrayList<PacketListener>();
|
||||||
|
doAnswer(call -> { packetListeners.add(call.getArgument(0)); return null; }).when(protocol).addPacketListener(any());
|
||||||
|
doAnswer(call -> { packetListeners.remove(call.getArgument(0)); return null; }).when(protocol).removePacketListener(any());
|
||||||
|
var sent = new ArrayList<Packet<?>>();
|
||||||
|
doAnswer(call -> {
|
||||||
|
Packet<?> raw = call.getArgument(0);
|
||||||
|
PacketType type = raw instanceof ClientboundSetPlayerTeamPacket ? PacketType.Play.Server.SCOREBOARD_TEAM : PacketType.Play.Server.ENTITY_METADATA;
|
||||||
|
var event = PacketEvent.fromServer(this, new PacketContainer(type, raw), viewer);
|
||||||
|
for (var listener : List.copyOf(packetListeners)) {
|
||||||
|
if (listener.getSendingWhitelist().getTypes().contains(type)) { listener.onPacketSending(event); }
|
||||||
|
}
|
||||||
|
if (!event.isCancelled()) { sent.add((Packet<?>) event.getPacket().getHandle()); }
|
||||||
|
return null;
|
||||||
|
}).when(viewerHandle.connection).send(org.mockito.ArgumentMatchers.<Packet<?>>any());
|
||||||
|
var plugin = mock(JavaPlugin.class);
|
||||||
|
when(plugin.getServer()).thenReturn(server);
|
||||||
|
when(plugin.isEnabled()).thenReturn(true);
|
||||||
|
when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger());
|
||||||
|
var manager = mock(PluginManager.class);
|
||||||
|
when(server.getPluginManager()).thenReturn(manager);
|
||||||
|
var listeners = new ArrayList<Listener>();
|
||||||
|
doAnswer(call -> { listeners.add(call.getArgument(0)); return null; }).when(manager).registerEvents(any(), eq(plugin));
|
||||||
|
doReturn(List.of(viewer, target)).when(server).getOnlinePlayers();
|
||||||
|
var scheduler = mock(CraftScheduler.class);
|
||||||
|
when(server.getScheduler()).thenReturn(scheduler);
|
||||||
|
var ticks = new ArrayList<Runnable>();
|
||||||
|
var task = mock(BukkitTask.class);
|
||||||
|
when(scheduler.runTaskTimer(eq(plugin), any(Runnable.class), eq(1L), eq(1L))).thenAnswer(call -> {
|
||||||
|
ticks.add(call.getArgument(1)); return task;
|
||||||
|
});
|
||||||
|
var initial = new PersistentStealthState(Map.of(), Map.of()).withEyeProgress(viewerId, new EyeProgress(28_800_000, true));
|
||||||
|
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
|
||||||
|
try (var states = new StealthStateManager(repository, initial)) {
|
||||||
|
var equipment = new EyeEquipment(new EyeProgressionService(states, () -> 0L, ignored -> { }), items);
|
||||||
|
var runtime = new EyeRevealRuntime(plugin, equipment, targetId::equals, protocol);
|
||||||
|
runtime.start();
|
||||||
|
assertEquals(1, ticks.size(), "the runtime must register a one-tick visibility loop");
|
||||||
|
ticks.getFirst().run();
|
||||||
|
var create = assertInstanceOf(ClientboundSetPlayerTeamPacket.class, sent.getFirst());
|
||||||
|
assertFalse(create.getParameters().orElseThrow().playerPrefix().getStyle().isObfuscated());
|
||||||
|
assertEquals((byte) 0x40, assertInstanceOf(ClientboundSetEntityDataPacket.class, sent.get(1)).packedItems().getFirst().value());
|
||||||
|
assertSame(baseline, board.getPlayersTeam("Concealed"));
|
||||||
|
assertNull(board.getPlayerTeam(create.getName()));
|
||||||
|
var events = assertInstanceOf(EyeRevealEvents.class, listeners.getFirst());
|
||||||
|
when(viewer.canSee(target)).thenReturn(false);
|
||||||
|
events.onHide(new org.bukkit.event.player.PlayerHideEntityEvent(viewer, target));
|
||||||
|
assertTrue(assertInstanceOf(ClientboundSetPlayerTeamPacket.class, sent.get(2)).getParameters().orElseThrow().playerPrefix().getStyle().isObfuscated());
|
||||||
|
assertEquals((byte) 0x60, assertInstanceOf(ClientboundSetEntityDataPacket.class, sent.get(3)).packedItems().getFirst().value());
|
||||||
|
ticks.getFirst().run();
|
||||||
|
assertEquals(7, sent.size(), "platform-hidden targets must remain hidden");
|
||||||
|
when(viewer.canSee(target)).thenReturn(true);
|
||||||
|
ticks.getFirst().run();
|
||||||
|
assertFalse(assertInstanceOf(ClientboundSetPlayerTeamPacket.class, sent.get(7)).getParameters().orElseThrow().playerPrefix().getStyle().isObfuscated());
|
||||||
|
runtime.close();
|
||||||
|
int count = sent.size();
|
||||||
|
ticks.getFirst().run();
|
||||||
|
assertEquals(count, sent.size());
|
||||||
|
assertTrue(packetListeners.isEmpty());
|
||||||
|
verify(task).cancel();
|
||||||
|
assertSame(baseline, board.getPlayersTeam("Concealed"));
|
||||||
|
assertEquals((byte) 0x60, data.get(flags));
|
||||||
|
verify(target, never()).removePotionEffect(any());
|
||||||
|
verify(target, never()).setInvisible(anyBoolean());
|
||||||
|
assertEquals(initial, states.snapshot());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
import io.netty.buffer.Unpooled;
|
||||||
|
import java.util.Optional;
|
||||||
|
import net.minecraft.ChatFormatting;
|
||||||
|
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
import net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.Parameters;
|
||||||
|
import net.minecraft.world.scores.Team;
|
||||||
|
import net.minecraft.world.scores.TeamColor;
|
||||||
|
import org.bukkit.craftbukkit.CraftRegistry;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class EyeTeamProjectionTest {
|
||||||
|
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nativeTeamProjectionRemovesOnlyTheScramblingPrefixAndRoundTripsEveryOtherField() {
|
||||||
|
var config = new io.papermc.paper.configuration.GlobalConfiguration();
|
||||||
|
config.collisions = config.new Collisions();
|
||||||
|
config.collisions.enablePlayerCollisions = true;
|
||||||
|
try (var platform = org.mockito.Mockito.mockStatic(io.papermc.paper.configuration.GlobalConfiguration.class)) {
|
||||||
|
platform.when(io.papermc.paper.configuration.GlobalConfiguration::get).thenReturn(config);
|
||||||
|
assertProjection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void privateTeamsRevealOnlyTheirTargetWhileSharedServerMembershipUpdatesStayMasked() {
|
||||||
|
var board = new net.minecraft.world.scores.Scoreboard();
|
||||||
|
var shared = board.addPlayerTeam("shared");
|
||||||
|
shared.setPlayerPrefix(Component.empty().withStyle(ChatFormatting.OBFUSCATED));
|
||||||
|
board.addPlayerToTeam("Alice", shared);
|
||||||
|
board.addPlayerToTeam("Bob", shared);
|
||||||
|
var packet = net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.createAddOrModifyPacket(shared, true);
|
||||||
|
var filtered = assertInstanceOf(net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.class,
|
||||||
|
EyePacketProjection.teamPacket(packet, java.util.Set.of("eye-private"), java.util.Set.of("Alice")));
|
||||||
|
assertEquals(java.util.Set.of("Bob"), java.util.Set.copyOf(filtered.getPlayers()),
|
||||||
|
"server membership updates must not pull an active Eye target out of its private team");
|
||||||
|
assertSame(packet.getParameters().orElseThrow(), filtered.getParameters().orElseThrow());
|
||||||
|
assertTrue(filtered.getParameters().orElseThrow().playerPrefix().getStyle().isObfuscated());
|
||||||
|
assertEquals(java.util.Set.of("Alice", "Bob"), java.util.Set.copyOf(packet.getPlayers()));
|
||||||
|
var isolated = board.addPlayerTeam("eye-private");
|
||||||
|
isolated.setPlayerPrefix(Component.empty().withStyle(ChatFormatting.OBFUSCATED));
|
||||||
|
board.addPlayerToTeam("Alice", isolated);
|
||||||
|
var privatePacket = net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.createAddOrModifyPacket(isolated, true);
|
||||||
|
var revealed = assertInstanceOf(net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.class,
|
||||||
|
EyePacketProjection.teamPacket(privatePacket, java.util.Set.of("eye-private"), java.util.Set.of("Alice")));
|
||||||
|
assertEquals(java.util.Set.of("Alice"), java.util.Set.copyOf(revealed.getPlayers()));
|
||||||
|
assertFalse(revealed.getParameters().orElseThrow().playerPrefix().getStyle().isObfuscated());
|
||||||
|
assertTrue(privatePacket.getParameters().orElseThrow().playerPrefix().getStyle().isObfuscated());
|
||||||
|
assertSame(privatePacket, EyePacketProjection.teamPacket(privatePacket, java.util.Set.of(), java.util.Set.of()),
|
||||||
|
"without current authorization even queued private packets must retain their original mask");
|
||||||
|
for (var action : net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.Action.values()) {
|
||||||
|
var membership = net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.createPlayerPacket(shared, "Alice", action);
|
||||||
|
var projected = assertInstanceOf(net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.class,
|
||||||
|
EyePacketProjection.teamPacket(membership, java.util.Set.of("eye-private"), java.util.Set.of("Alice")));
|
||||||
|
assertTrue(projected.getPlayers().isEmpty());
|
||||||
|
assertEquals(membership.getPlayerAction(), projected.getPlayerAction());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertProjection() {
|
||||||
|
var original = new Parameters(Component.literal("Keep team display"),
|
||||||
|
Component.empty().withStyle(ChatFormatting.OBFUSCATED), Component.literal("Keep suffix"),
|
||||||
|
Team.Visibility.ALWAYS, Team.CollisionRule.PUSH_OWN_TEAM, Optional.of(TeamColor.AQUA), (byte) 0x53);
|
||||||
|
var projected = assertInstanceOf(Parameters.class, EyePacketProjection.teamParameters(original));
|
||||||
|
var expected = new Parameters(original.displayName(), Component.empty(), original.playerSuffix(),
|
||||||
|
original.nameTagVisibility(), original.collisionRule(), original.color(), original.options());
|
||||||
|
assertEquals(expected, projected);
|
||||||
|
assertNotSame(original, projected);
|
||||||
|
assertTrue(original.playerPrefix().getStyle().isObfuscated(), "the shared server/team packet must remain unchanged");
|
||||||
|
var buffer = new RegistryFriendlyByteBuf(Unpooled.buffer(), CraftRegistry.getMinecraftRegistry());
|
||||||
|
try {
|
||||||
|
Parameters.STREAM_CODEC.encode(buffer, projected);
|
||||||
|
assertEquals(projected, Parameters.STREAM_CODEC.decode(buffer));
|
||||||
|
} finally { buffer.release(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,125 @@
|
|||||||
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import net.minecraft.ChatFormatting;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
import net.minecraft.network.protocol.Packet;
|
||||||
|
import net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket;
|
||||||
|
import net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket;
|
||||||
|
import net.minecraft.network.syncher.EntityDataAccessor;
|
||||||
|
import net.minecraft.network.syncher.SynchedEntityData;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import net.minecraft.server.network.ServerGamePacketListenerImpl;
|
||||||
|
import net.minecraft.world.scores.Scoreboard;
|
||||||
|
import net.minecraft.world.scores.TeamColor;
|
||||||
|
import org.bukkit.craftbukkit.entity.CraftPlayer;
|
||||||
|
import org.bukkit.craftbukkit.scoreboard.CraftScoreboard;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class NativeEyeDeliveryTest {
|
||||||
|
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||||
|
|
||||||
|
@org.junit.jupiter.params.ParameterizedTest
|
||||||
|
@org.junit.jupiter.params.provider.CsvSource({"ALWAYS,false,true", "NEVER,false,false", "HIDE_FOR_OWN_TEAM,true,false",
|
||||||
|
"HIDE_FOR_OWN_TEAM,false,true", "HIDE_FOR_OTHER_TEAMS,true,true", "HIDE_FOR_OTHER_TEAMS,false,false"})
|
||||||
|
@SuppressWarnings("unchecked") // The declared runtime accessor's BYTE serializer is verified in the metadata codec tests.
|
||||||
|
void deliveryUsesOnlyTheViewerConnectionAndRestoresCurrentStateWithoutChangingServerTeamsOrFlags(
|
||||||
|
net.minecraft.world.scores.Team.Visibility visibility, boolean teammate, boolean visible) throws Exception {
|
||||||
|
var board = new Scoreboard();
|
||||||
|
var baseline = board.addPlayerTeam("source");
|
||||||
|
baseline.setPlayerPrefix(Component.empty().withStyle(ChatFormatting.OBFUSCATED));
|
||||||
|
baseline.setColor(Optional.of(TeamColor.AQUA));
|
||||||
|
baseline.setNameTagVisibility(visibility);
|
||||||
|
if (teammate) { board.addPlayerToTeam("Viewer", baseline); }
|
||||||
|
board.addPlayerToTeam("Concealed", baseline);
|
||||||
|
board.addPlayerToTeam("OtherConcealed", baseline);
|
||||||
|
var boardConstructor = CraftScoreboard.class.getDeclaredConstructor(Scoreboard.class);
|
||||||
|
boardConstructor.setAccessible(true);
|
||||||
|
var bukkitBoard = boardConstructor.newInstance(board);
|
||||||
|
var viewer = mock(CraftPlayer.class);
|
||||||
|
var viewerHandle = mock(ServerPlayer.class);
|
||||||
|
var connection = mock(ServerGamePacketListenerImpl.class);
|
||||||
|
viewerHandle.connection = connection;
|
||||||
|
when(viewer.getHandle()).thenReturn(viewerHandle);
|
||||||
|
when(viewer.getName()).thenReturn("Viewer");
|
||||||
|
when(viewer.getScoreboard()).thenReturn(bukkitBoard);
|
||||||
|
var sent = new ArrayList<Packet<?>>();
|
||||||
|
doAnswer(call -> { sent.add(call.getArgument(0)); return null; })
|
||||||
|
.when(connection).send(org.mockito.ArgumentMatchers.<Packet<?>>any());
|
||||||
|
var target = mock(CraftPlayer.class);
|
||||||
|
var targetHandle = mock(ServerPlayer.class);
|
||||||
|
when(target.getHandle()).thenReturn(targetHandle);
|
||||||
|
when(target.getEntityId()).thenReturn(42);
|
||||||
|
when(target.isOnline()).thenReturn(true);
|
||||||
|
var data = mock(SynchedEntityData.class);
|
||||||
|
when(targetHandle.getEntityData()).thenReturn(data);
|
||||||
|
var accessorField = net.minecraft.world.entity.Entity.class.getDeclaredField("DATA_SHARED_FLAGS_ID");
|
||||||
|
accessorField.setAccessible(true);
|
||||||
|
var flagsAccessor = (EntityDataAccessor<Byte>) accessorField.get(null);
|
||||||
|
var flags = new AtomicReference<>((byte) 0xa0);
|
||||||
|
when(data.get(flagsAccessor)).thenAnswer(ignored -> flags.get());
|
||||||
|
var projection = new EyeRevealController.Projection(UUID.randomUUID(), 42, "Concealed", "eye-private",
|
||||||
|
Optional.of("source"), true);
|
||||||
|
var delivery = new NativeEyeDelivery();
|
||||||
|
delivery.show(viewer, target, projection, true);
|
||||||
|
assertEquals(2, sent.size(), "a private team and raw metadata refresh are required");
|
||||||
|
var team = assertInstanceOf(ClientboundSetPlayerTeamPacket.class, sent.get(0));
|
||||||
|
assertEquals("eye-private", team.getName());
|
||||||
|
assertEquals(ClientboundSetPlayerTeamPacket.Action.ADD, team.getTeamAction());
|
||||||
|
assertEquals(java.util.List.of("Concealed"), java.util.List.copyOf(team.getPlayers()));
|
||||||
|
assertEquals(visible ? net.minecraft.world.scores.Team.Visibility.ALWAYS : net.minecraft.world.scores.Team.Visibility.NEVER,
|
||||||
|
team.getParameters().orElseThrow().nameTagVisibility(), "private teams must preserve effective visibility, not change team relationships");
|
||||||
|
assertTrue(team.getParameters().orElseThrow().playerPrefix().getStyle().isObfuscated(),
|
||||||
|
"raw state must reach the outgoing authorization gate before any prefix is revealed");
|
||||||
|
var metadata = assertInstanceOf(ClientboundSetEntityDataPacket.class, sent.get(1));
|
||||||
|
assertEquals(42, metadata.id());
|
||||||
|
assertEquals((byte) 0xa0, metadata.packedItems().getFirst().value());
|
||||||
|
assertNull(board.getPlayerTeam("eye-private"));
|
||||||
|
assertEquals(teammate ? Set.of("Viewer", "Concealed", "OtherConcealed") : Set.of("Concealed", "OtherConcealed"), Set.copyOf(baseline.getPlayers()));
|
||||||
|
assertEquals((byte) 0xa0, flags.get());
|
||||||
|
// Another plugin can move the actor to a different real team while the private view is active.
|
||||||
|
var replacement = board.addPlayerTeam("replacement");
|
||||||
|
replacement.setPlayerPrefix(Component.empty().withStyle(ChatFormatting.OBFUSCATED));
|
||||||
|
replacement.setColor(Optional.of(TeamColor.GOLD));
|
||||||
|
replacement.setNameTagVisibility(visibility);
|
||||||
|
board.addPlayerToTeam("Concealed", replacement);
|
||||||
|
flags.set((byte) 9);
|
||||||
|
sent.clear();
|
||||||
|
delivery.restore(viewer, Optional.of(target), projection);
|
||||||
|
assertEquals(5, sent.size(), "restore the mask and flags before moving or removing client team membership");
|
||||||
|
var privateMask = assertInstanceOf(ClientboundSetPlayerTeamPacket.class, sent.get(0));
|
||||||
|
assertEquals("eye-private", privateMask.getName());
|
||||||
|
assertTrue(privateMask.getParameters().orElseThrow().playerPrefix().getStyle().isObfuscated());
|
||||||
|
boolean nowVisible = visibility == net.minecraft.world.scores.Team.Visibility.ALWAYS
|
||||||
|
|| visibility == net.minecraft.world.scores.Team.Visibility.HIDE_FOR_OWN_TEAM;
|
||||||
|
assertEquals(nowVisible ? net.minecraft.world.scores.Team.Visibility.ALWAYS : net.minecraft.world.scores.Team.Visibility.NEVER,
|
||||||
|
privateMask.getParameters().orElseThrow().nameTagVisibility());
|
||||||
|
var restoredFlags = assertInstanceOf(ClientboundSetEntityDataPacket.class, sent.get(1));
|
||||||
|
assertEquals((byte) 9, restoredFlags.packedItems().getFirst().value());
|
||||||
|
var restoredTeam = assertInstanceOf(ClientboundSetPlayerTeamPacket.class, sent.get(2));
|
||||||
|
assertEquals("replacement", restoredTeam.getName());
|
||||||
|
assertEquals(Optional.of(TeamColor.GOLD), restoredTeam.getParameters().orElseThrow().color());
|
||||||
|
assertEquals(visibility, restoredTeam.getParameters().orElseThrow().nameTagVisibility());
|
||||||
|
var membership = assertInstanceOf(ClientboundSetPlayerTeamPacket.class, sent.get(3));
|
||||||
|
assertEquals("replacement", membership.getName());
|
||||||
|
assertEquals(ClientboundSetPlayerTeamPacket.Action.ADD, membership.getPlayerAction());
|
||||||
|
assertEquals(java.util.List.of("Concealed"), java.util.List.copyOf(membership.getPlayers()));
|
||||||
|
var removal = assertInstanceOf(ClientboundSetPlayerTeamPacket.class, sent.get(4));
|
||||||
|
assertEquals("eye-private", removal.getName());
|
||||||
|
assertEquals(ClientboundSetPlayerTeamPacket.Action.REMOVE, removal.getTeamAction());
|
||||||
|
assertSame(replacement, board.getPlayersTeam("Concealed"));
|
||||||
|
assertEquals(teammate ? Set.of("Viewer", "OtherConcealed") : Set.of("OtherConcealed"), Set.copyOf(baseline.getPlayers()));
|
||||||
|
assertNull(board.getPlayerTeam("eye-private"));
|
||||||
|
verify(data, never()).set(any(), any());
|
||||||
|
verify(connection, times(7)).send(org.mockito.ArgumentMatchers.<Packet<?>>any());
|
||||||
|
verifyNoMoreInteractions(connection);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class EyeVisibilityTest {
|
||||||
|
private final UUID world = UUID.randomUUID();
|
||||||
|
private final UUID viewerId = UUID.randomUUID();
|
||||||
|
private final UUID targetId = UUID.randomUUID();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void personalEligibilityConcealmentVisibilityAndSightMustAllPermitTheReveal() {
|
||||||
|
var position = new EyeVisibility.Position(world, 0, 64, 0);
|
||||||
|
var viewer = new EyeVisibility.Viewer(viewerId, position, true);
|
||||||
|
var target = new EyeVisibility.Target(targetId, position, true, false, false);
|
||||||
|
assertFalse(EyeVisibility.canReveal(new EyeVisibility.Viewer(viewerId, position, false), target, true, true),
|
||||||
|
"an unearned or unequipped observer cannot reveal a target");
|
||||||
|
assertFalse(EyeVisibility.canReveal(viewer, new EyeVisibility.Target(targetId, position, false, false, false), true, true));
|
||||||
|
assertFalse(EyeVisibility.canReveal(viewer, new EyeVisibility.Target(targetId, position, true, true, false), true, true),
|
||||||
|
"the Eye must not bypass spectator hiding");
|
||||||
|
assertFalse(EyeVisibility.canReveal(viewer, new EyeVisibility.Target(targetId, position, true, false, true), true, true),
|
||||||
|
"the Eye must not bypass administrative vanish");
|
||||||
|
assertFalse(EyeVisibility.canReveal(viewer, target, false, true), "platform visibility denial takes precedence");
|
||||||
|
assertFalse(EyeVisibility.canReveal(viewer, target, true, false), "walls must block revelation");
|
||||||
|
assertFalse(EyeVisibility.canReveal(viewer, new EyeVisibility.Target(viewerId, position, true, false, false), true, true));
|
||||||
|
assertFalse(EyeVisibility.canReveal(viewer, new EyeVisibility.Target(targetId,
|
||||||
|
new EyeVisibility.Position(UUID.randomUUID(), 0, 64, 0), true, false, false), true, true));
|
||||||
|
assertFalse(EyeVisibility.canReveal(viewer, new EyeVisibility.Target(targetId,
|
||||||
|
new EyeVisibility.Position(world, Double.NaN, 64, 0), true, false, false), true, true));
|
||||||
|
assertTrue(EyeVisibility.canReveal(viewer, target, true, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void anEligibleWearerCanRevealAtTheInclusiveThreeDimensionalSixteenBlockBoundary() {
|
||||||
|
var viewer = new EyeVisibility.Viewer(viewerId, new EyeVisibility.Position(world, 0, 64, 0), true);
|
||||||
|
var horizontal = new EyeVisibility.Target(targetId, new EyeVisibility.Position(world, 16, 64, 0), true, false, false);
|
||||||
|
var vertical = new EyeVisibility.Target(targetId, new EyeVisibility.Position(world, 0, 80, 0), true, false, false);
|
||||||
|
assertTrue(EyeVisibility.canReveal(viewer, horizontal, true, true));
|
||||||
|
assertTrue(EyeVisibility.canReveal(viewer, vertical, true, true));
|
||||||
|
var diagonal = new EyeVisibility.Target(targetId, new EyeVisibility.Position(world, 12, 76, 0), true, false, false);
|
||||||
|
var beyond = new EyeVisibility.Target(targetId, new EyeVisibility.Position(world, 16.001, 64, 0), true, false, false);
|
||||||
|
assertFalse(EyeVisibility.canReveal(viewer, diagonal, true, true));
|
||||||
|
assertFalse(EyeVisibility.canReveal(viewer, beyond, true, true));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user