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:
@@ -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-<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. 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).
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import net.minecraft.world.scores.Scoreboard;
|
||||
import org.bukkit.craftbukkit.scoreboard.CraftScoreboard;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent.DamageCause;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
|
||||
class CombatRevealTest {
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(strings = {"melee", "arrow", "instant-potion", "cancelled", "zero", "self", "mob-target", "taking-only", "miss", "poison", "wither"})
|
||||
void attributedDamageEndsOnlyAttackerSessionAndRestoresIdentityExactlyOnce(String kind) throws Exception {
|
||||
Player attacker = player("Attacker"), victim = player("Victim");
|
||||
var initial = new PersistentStealthState(Map.of(), Map.of())
|
||||
.withPlayer(unlocked(attacker).withSession(true, false))
|
||||
.withPlayer(unlocked(victim).withSession(true, false));
|
||||
var repository = new StealthStateRepository() {
|
||||
@Override public PersistentStealthState load() { return initial; }
|
||||
@Override public void save(PersistentStealthState state) { }
|
||||
};
|
||||
try (var states = new StealthStateManager(repository, initial)) {
|
||||
var progression = new QualifyingInvisibilityService(states, Duration.ofHours(8), () -> 0L, id -> { });
|
||||
var sessions = new StealthSessionService(states, progression);
|
||||
sessions.login(attacker.getUniqueId(), attacker.getName()).saved().join();
|
||||
sessions.login(victim.getUniqueId(), victim.getName()).saved().join();
|
||||
var board = new Scoreboard();
|
||||
var constructor = CraftScoreboard.class.getDeclaredConstructor(Scoreboard.class);
|
||||
constructor.setAccessible(true);
|
||||
var restoredTabs = new ArrayList<UUID>();
|
||||
var tabs = new TabListController() {
|
||||
@Override public void remove(Player observer, UUID target) { }
|
||||
@Override public void add(Player observer, Player target) { restoredTabs.add(target.getUniqueId()); }
|
||||
};
|
||||
var presentation = new BukkitIdentityPresentation(() -> List.of(attacker, victim), constructor.newInstance(board), tabs);
|
||||
presentation.conceal(attacker);
|
||||
presentation.conceal(victim);
|
||||
var withdrawn = new ArrayList<UUID>();
|
||||
var failures = new ArrayList<Throwable>();
|
||||
var potions = new PotionDamageListener(id -> id.equals(attacker.getUniqueId()) ? attacker : null, failures::add);
|
||||
var listener = new CombatRevealListener(sessions, presentation, withdrawn::add, failures::add, potions);
|
||||
boolean delayed = kind.equals("poison") || kind.equals("wither");
|
||||
if (delayed) {
|
||||
var type = kind.equals("poison") ? org.bukkit.potion.PotionEffectType.POISON : org.bukkit.potion.PotionEffectType.WITHER;
|
||||
var nativeType = kind.equals("poison") ? net.minecraft.world.effect.MobEffects.POISON : net.minecraft.world.effect.MobEffects.WITHER;
|
||||
potions.onEffect(new org.bukkit.event.entity.EntityPotionEffectEvent(victim, null,
|
||||
new org.bukkit.potion.PotionEffect(type, 100, 0), attacker,
|
||||
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.POTION_SPLASH,
|
||||
org.bukkit.event.entity.EntityPotionEffectEvent.Action.ADDED, true));
|
||||
when(((org.bukkit.craftbukkit.entity.CraftPlayer) victim).getHandle().getEffect(nativeType))
|
||||
.thenReturn(new net.minecraft.world.effect.MobEffectInstance(nativeType, 100, 0));
|
||||
}
|
||||
var sources = new net.minecraft.world.damagesource.DamageSources(new net.minecraft.core.RegistryAccess.ImmutableRegistryAccess(
|
||||
List.of(org.bukkit.craftbukkit.CraftRegistry.getMinecraftRegistry(net.minecraft.core.registries.Registries.DAMAGE_TYPE))));
|
||||
var handle = ((org.bukkit.craftbukkit.entity.CraftPlayer) attacker).getHandle();
|
||||
var nativeSource = switch (kind) {
|
||||
case "arrow" -> sources.arrow(mock(net.minecraft.world.entity.projectile.arrow.AbstractArrow.class), handle);
|
||||
case "instant-potion" -> sources.indirectMagic(mock(net.minecraft.world.entity.Entity.class), handle);
|
||||
case "taking-only", "poison" -> sources.magic();
|
||||
case "wither" -> sources.wither();
|
||||
default -> sources.playerAttack(handle);
|
||||
};
|
||||
var source = new org.bukkit.craftbukkit.damage.CraftDamageSource(nativeSource);
|
||||
org.bukkit.entity.Entity target = switch (kind) {
|
||||
case "mob-target" -> mock(org.bukkit.entity.Zombie.class);
|
||||
case "self" -> attacker;
|
||||
default -> victim;
|
||||
};
|
||||
org.bukkit.event.entity.EntityDamageEvent damage = delayed
|
||||
? new org.bukkit.event.entity.EntityDamageEvent(victim, kind.equals("poison") ? DamageCause.POISON : DamageCause.WITHER, source, 1.0)
|
||||
: new EntityDamageByEntityEvent(attacker, target, DamageCause.ENTITY_ATTACK, source, kind.equals("zero") ? 0 : 3.0);
|
||||
damage.setCancelled(kind.equals("cancelled"));
|
||||
var before = states.snapshot();
|
||||
if (!kind.equals("miss")) { listener.onDamage(damage); }
|
||||
if (java.util.Set.of("cancelled", "zero", "self", "mob-target", "taking-only", "miss").contains(kind)) {
|
||||
assertEquals(before, states.snapshot());
|
||||
assertEquals(java.util.Set.of("Attacker", "Victim"), presentation.concealedNames());
|
||||
assertTrue(restoredTabs.isEmpty());
|
||||
assertTrue(withdrawn.isEmpty());
|
||||
verify(attacker, never()).sendMessage(anyString());
|
||||
return;
|
||||
}
|
||||
|
||||
assertFalse(sessions.isConcealed(attacker.getUniqueId()), "positive damage must end the attacker's active session");
|
||||
assertTrue(sessions.isConcealed(victim.getUniqueId()), "taking damage alone must not end the victim's session");
|
||||
assertEquals(java.util.Set.of(victim.getUniqueId()), sessions.concealedPlayerIds());
|
||||
assertEquals(java.util.Set.of("Victim"), presentation.concealedNames());
|
||||
assertNull(board.getPlayerTeam(BukkitIdentityPresentation.teamName(attacker.getUniqueId())));
|
||||
assertNotNull(board.getPlayerTeam(BukkitIdentityPresentation.teamName(victim.getUniqueId())));
|
||||
verify(attacker).setDisplayName("Original Attacker");
|
||||
verify(attacker).setSleepingIgnored(false);
|
||||
assertEquals(List.of(attacker.getUniqueId()), restoredTabs);
|
||||
assertEquals(List.of(attacker.getUniqueId()), withdrawn);
|
||||
verify(attacker).sendMessage("Your stealth was broken because you hurt another player.");
|
||||
verify(attacker, never()).removePotionEffect(any());
|
||||
verify(attacker, never()).setInvisible(anyBoolean());
|
||||
assertTrue(states.snapshot().player(attacker.getUniqueId()).unlocked());
|
||||
assertEquals(28_800_000, states.snapshot().player(attacker.getUniqueId()).accumulatedMillis());
|
||||
assertFalse(states.snapshot().player(attacker.getUniqueId()).preparedLogin());
|
||||
listener.onDamage(damage);
|
||||
assertEquals(1, restoredTabs.size());
|
||||
verify(attacker, times(1)).sendMessage("Your stealth was broken because you hurt another player.");
|
||||
assertFalse(sessions.login(attacker.getUniqueId(), attacker.getName()).concealed(), "damage must not prepare a direct re-entry");
|
||||
progression.begin(attacker.getUniqueId(), attacker.getName(), java.time.Instant.EPOCH).join();
|
||||
sessions.disconnect(attacker.getUniqueId()).join();
|
||||
assertTrue(sessions.login(attacker.getUniqueId(), attacker.getName()).concealed(), "a later qualifying logout/login still works");
|
||||
states.save().join();
|
||||
assertTrue(failures.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
private static Player player(String name) {
|
||||
var player = mock(org.bukkit.craftbukkit.entity.CraftPlayer.class);
|
||||
var handle = mock(net.minecraft.server.level.ServerPlayer.class);
|
||||
when(handle.getBukkitEntity()).thenReturn(player);
|
||||
when(player.getHandle()).thenReturn(handle);
|
||||
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(player.getName()).thenReturn(name);
|
||||
when(player.getDisplayName()).thenReturn("Original " + name);
|
||||
return player;
|
||||
}
|
||||
private static PlayerStealthState unlocked(Player player) {
|
||||
return new PlayerStealthState(player.getUniqueId(), player.getName(), 28_800_000, true, false, false, null, Map.of());
|
||||
}
|
||||
}
|
||||
@@ -136,6 +136,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);
|
||||
|
||||
@@ -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<PacketContainer>();
|
||||
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(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user