diff --git a/README.md b/README.md index 07ab06f..348eb6c 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,12 @@ Progression-gated identity concealment for Purpur 26.2 build 2618, built and tes Artifacts are written to `build/libs/purpur-stealth-.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. The separately tracked signed-chat validation/system-message work remains pending. + ## 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. @@ -30,6 +36,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. -The v2.0.0 migration itself left the Eye, combat reveal and concealed-chat stories pending. Subsequent Eye acquisition and local revelation work is described above; combat-session breaking and system-chat routing remain separate unfinished stories. Runtime plugin metadata is unchanged. +The v2.0.0 migration itself left the Eye, combat reveal and concealed-chat stories pending. Subsequent Eye acquisition, local revelation and combat-session breaking are described above; system-chat routing remains a separate unfinished story. 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). diff --git a/src/main/java/games/dmg/spigotstealth/CombatRevealListener.java b/src/main/java/games/dmg/spigotstealth/CombatRevealListener.java new file mode 100644 index 0000000..c506f19 --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/CombatRevealListener.java @@ -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 withdrawEyeTarget; + private final Consumer failures; + private final java.util.function.Function potionAttacker; + + public CombatRevealListener(StealthSessionService sessions, IdentityPresentation presentation, + Consumer withdrawEyeTarget, Consumer failures) { + this(sessions, presentation, withdrawEyeTarget, failures, event -> null); + } + + public CombatRevealListener(StealthSessionService sessions, IdentityPresentation presentation, + Consumer withdrawEyeTarget, Consumer failures, + java.util.function.Function 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."); + } +} diff --git a/src/main/java/games/dmg/spigotstealth/EyeRevealRuntime.java b/src/main/java/games/dmg/spigotstealth/EyeRevealRuntime.java index 3ae715c..19c7f9c 100644 --- a/src/main/java/games/dmg/spigotstealth/EyeRevealRuntime.java +++ b/src/main/java/games/dmg/spigotstealth/EyeRevealRuntime.java @@ -44,6 +44,8 @@ final class EyeRevealRuntime implements AutoCloseable { } } + public void withdrawTarget(UUID player) { controller.withdrawTarget(player); } + @Override public void close() { if (closed) { return; } closed = true; diff --git a/src/main/java/games/dmg/spigotstealth/NativePotionEffects.java b/src/main/java/games/dmg/spigotstealth/NativePotionEffects.java new file mode 100644 index 0000000..7acdd20 --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/NativePotionEffects.java @@ -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)); + } +} diff --git a/src/main/java/games/dmg/spigotstealth/PotionDamageAttribution.java b/src/main/java/games/dmg/spigotstealth/PotionDamageAttribution.java new file mode 100644 index 0000000..5accae8 --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/PotionDamageAttribution.java @@ -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 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 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())); + } +} diff --git a/src/main/java/games/dmg/spigotstealth/PotionDamageListener.java b/src/main/java/games/dmg/spigotstealth/PotionDamageListener.java new file mode 100644 index 0000000..6f3dc9f --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/PotionDamageListener.java @@ -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 { + private final Function onlinePlayer; + private final Consumer failures; + private final PotionDamageAttribution attribution = new PotionDamageAttribution(); + private final NativePotionEffects effects = new NativePotionEffects(); + private boolean reported; + + PotionDamageListener(Function onlinePlayer, Consumer 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); } + } +} diff --git a/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java b/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java index 704d4d2..71dd77d 100644 --- a/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java +++ b/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java @@ -23,6 +23,7 @@ public final class SpigotStealthPlugin extends JavaPlugin { private EyePotionListener eyePotions; private boolean eyeRecipeRegistered; private EyeRevealRuntime eyeReveal; + private PotionDamageListener combatPotions; @Override public void onEnable() { @@ -69,6 +70,7 @@ public final class SpigotStealthPlugin extends JavaPlugin { logSaveFailure(eyeProgression.stopAll()); } if (progression != null) { logSaveFailure(progression.stopAll()); } + if (combatPotions != null) { combatPotions.close(); } if (eyeReveal != null) { eyeReveal.close(); } if (protocolManager != null) { protocolManager.removePacketListeners(this); } if (identityPresentation != null && sessions != null) { @@ -173,8 +175,13 @@ public final class SpigotStealthPlugin extends JavaPlugin { try { eyeReveal = new EyeRevealRuntime(this, eyeEquipment, sessions::isConcealed, protocolManager); eyeReveal.start(); + java.util.function.Consumer 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 Eye rendering: " + rootMessage(exception)); + getLogger().severe("Unable to initialize Stealth gameplay: " + rootMessage(exception)); getServer().getPluginManager().disablePlugin(this); return; } diff --git a/src/nativeTest/java/games/dmg/spigotstealth/CombatRevealTest.java b/src/nativeTest/java/games/dmg/spigotstealth/CombatRevealTest.java new file mode 100644 index 0000000..5415f47 --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotstealth/CombatRevealTest.java @@ -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(); + 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(); + var failures = new ArrayList(); + 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()); + } +} diff --git a/src/nativeTest/java/games/dmg/spigotstealth/EyePluginLifecycleTest.java b/src/nativeTest/java/games/dmg/spigotstealth/EyePluginLifecycleTest.java index cfab09c..8c50543 100644 --- a/src/nativeTest/java/games/dmg/spigotstealth/EyePluginLifecycleTest.java +++ b/src/nativeTest/java/games/dmg/spigotstealth/EyePluginLifecycleTest.java @@ -136,6 +136,8 @@ class EyePluginLifecycleTest { assertTrue(new EyeItems().isEye(recipe.get().getResult())); assertTrue(listeners.stream().anyMatch(EyePotionListener.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(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); diff --git a/src/nativeTest/java/games/dmg/spigotstealth/NativeCombatTabRestoreTest.java b/src/nativeTest/java/games/dmg/spigotstealth/NativeCombatTabRestoreTest.java new file mode 100644 index 0000000..6194306 --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotstealth/NativeCombatTabRestoreTest.java @@ -0,0 +1,68 @@ +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(); + doAnswer(call -> { sent.add(call.getArgument(1)); return null; }).when(protocol).sendServerPacket(eq(observer), any(PacketContainer.class)); + new ProtocolLibTabListController(protocol).add(observer, target); + var packet = assertInstanceOf(ClientboundPlayerInfoUpdatePacket.class, sent.getFirst().getHandle()); + assertTrue(packet.actions().contains(ClientboundPlayerInfoUpdatePacket.Action.ADD_PLAYER)); + 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()); + assertEquals(packet.entries(), decoded.entries()); + } finally { buffer.release(); } + } + } +} diff --git a/src/nativeTest/java/games/dmg/spigotstealth/NativePotionDamageTest.java b/src/nativeTest/java/games/dmg/spigotstealth/NativePotionDamageTest.java new file mode 100644 index 0000000..0f49a0c --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotstealth/NativePotionDamageTest.java @@ -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, 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(); + var attribution = new PotionDamageListener(id -> id.equals(attackerId) ? attacker : null, failures::add); + var applications = new ArrayList(); + 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(); + 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()); + } + } +} diff --git a/src/nativeTest/java/games/dmg/spigotstealth/PotionDamageAttributionTest.java b/src/nativeTest/java/games/dmg/spigotstealth/PotionDamageAttributionTest.java new file mode 100644 index 0000000..8b169a7 --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotstealth/PotionDamageAttributionTest.java @@ -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)); + } +}