feat(stealth): break concealed sessions on player damage
Release / release (push) Successful in 4m55s
CI / build (push) Successful in 2m47s

Resolve direct native damage sources and explicitly observed poison/wither provenance, including hidden layers and effective replacements. Preserve progression and potion effects while reusing identity/session cleanup. Verify native effect application, ticks, tab codecs and plugin registration. No deployment is included.
This commit is contained in:
dmg
2026-09-12 21:09:10 -04:00
parent 0499a31f71
commit c1bd4406d2
12 changed files with 694 additions and 2 deletions
@@ -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));
}
}