feat(stealth): break concealed sessions on player damage
Resolve direct native damage sources and explicitly observed poison/wither provenance, including hidden layers and effective replacements. Preserve progression and potion effects while reusing identity/session cleanup. Verify native effect application, ticks, tab codecs and plugin registration. No deployment is included.
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
|
||||
/** Ends identity concealment on attributed, successful player damage; never removes potion effects. */
|
||||
public final class CombatRevealListener implements Listener {
|
||||
private final StealthSessionService sessions;
|
||||
private final IdentityPresentation presentation;
|
||||
private final Consumer<UUID> withdrawEyeTarget;
|
||||
private final Consumer<Throwable> failures;
|
||||
private final java.util.function.Function<EntityDamageEvent, org.bukkit.entity.Player> potionAttacker;
|
||||
|
||||
public CombatRevealListener(StealthSessionService sessions, IdentityPresentation presentation,
|
||||
Consumer<UUID> withdrawEyeTarget, Consumer<Throwable> failures) {
|
||||
this(sessions, presentation, withdrawEyeTarget, failures, event -> null);
|
||||
}
|
||||
|
||||
public CombatRevealListener(StealthSessionService sessions, IdentityPresentation presentation,
|
||||
Consumer<UUID> withdrawEyeTarget, Consumer<Throwable> failures,
|
||||
java.util.function.Function<EntityDamageEvent, org.bukkit.entity.Player> potionAttacker) {
|
||||
this.sessions = java.util.Objects.requireNonNull(sessions);
|
||||
this.presentation = java.util.Objects.requireNonNull(presentation);
|
||||
this.withdrawEyeTarget = java.util.Objects.requireNonNull(withdrawEyeTarget);
|
||||
this.failures = java.util.Objects.requireNonNull(failures);
|
||||
this.potionAttacker = java.util.Objects.requireNonNull(potionAttacker);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onDamage(EntityDamageEvent event) {
|
||||
if (event.isCancelled() || !Double.isFinite(event.getFinalDamage()) || event.getFinalDamage() <= 0
|
||||
|| !(event.getEntity() instanceof org.bukkit.entity.Player victim)) { return; }
|
||||
var attacker = event.getDamageSource().getCausingEntity() instanceof org.bukkit.entity.Player direct ? direct : potionAttacker.apply(event);
|
||||
if (attacker == null || attacker.getUniqueId().equals(victim.getUniqueId()) || !sessions.isConcealed(attacker.getUniqueId())) { return; }
|
||||
sessions.endConcealment(attacker.getUniqueId()).exceptionally(failure -> { failures.accept(failure); return null; });
|
||||
withdrawEyeTarget.accept(attacker.getUniqueId());
|
||||
presentation.reveal(attacker);
|
||||
attacker.sendMessage("Your stealth was broken because you hurt another player.");
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,8 @@ final class EyeRevealRuntime implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
public void withdrawTarget(UUID player) { controller.withdrawTarget(player); }
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) { return; }
|
||||
closed = true;
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
@@ -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<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 Eye rendering: " + rootMessage(exception));
|
||||
getLogger().severe("Unable to initialize Stealth gameplay: " + rootMessage(exception));
|
||||
getServer().getPluginManager().disablePlugin(this);
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user