feat(parties): audit native protection decisions
Release / release (push) Successful in 15m37s

This commit is contained in:
dmg committed 2026-09-25 14:11:48 -04:00
1 parent 39881ad6fc
commit af7c75b6ed
4 files changed
+704 -11

No files matched your search

@@ -752,6 +752,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
recoveryEvents = new games.dmg.fantasystick.events.RecoveryEventBridge(eventLog::emit, Clock.systemUTC());outOfCombatRecovery.attachEvents(recoveryEvents,record->record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationI.ID)?quickerRegeneration.bonus():java.math.BigInteger.ZERO);
reviveEvents = new games.dmg.fantasystick.events.ReviveEventBridge(eventLog::emit, Clock.systemUTC());revive.attachEvents(reviveEvents);
partyAuditEvents = new games.dmg.fantasystick.events.PartyEventBridge(eventLog::emit, Clock.systemUTC());partyManagement.attachEvents(partyAuditEvents);admin.attachPartyEvents(partyAuditEvents);
partyCombat.attachProtectionEvents(new games.dmg.fantasystick.events.PartyProtectionEventBridge(
eventLog::emit, Clock.systemUTC()));
combatRewardEvents = new games.dmg.fantasystick.events.CombatRewardEventBridge(eventLog::emit,Clock.systemUTC());combatExperience.attachEvents(combatRewardEvents);
coinEvents = new games.dmg.fantasystick.events.CoinEventBridge(eventLog::emit,Clock.systemUTC());characters.attachCoinEvents(coinEvents);admin.attachCoinEvents(coinEvents);
sessionAuditEvents = new games.dmg.fantasystick.events.SessionEventBridge(eventLog::emit,Clock.systemUTC());sessions.attachSessionEvents(sessionAuditEvents);
@@ -0,0 +1,150 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.party.CombatSources;
import games.dmg.fantasystick.party.PartyRoster;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Location;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.bukkit.event.entity.EntityDamageEvent;
/** Captures native protection decisions without changing the native damage/event authority. */
public final class PartyProtectionEventBridge {
public record Action(UUID id, Instant observedAt, EventInput.Context actor, Map<String, Object> data) { }
private final Consumer<EventInput> sink;
private final Clock clock;
public PartyProtectionEventBridge(Consumer<EventInput> sink, Clock clock) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
}
public Action damage(EntityDamageEvent event, CombatSources.Origin origin,
CombatSources.Origin recipient, Player target,
PartyRoster.Party sourceParty, PartyRoster.Party targetParty, String reason) {
try {
var id = UUID.randomUUID();
var time = clock.instant();
Entity causing = event.getDamageSource().getCausingEntity();
Entity direct = event.getDamageSource().getDirectEntity();
var source = causing instanceof Player player && player.getUniqueId().equals(origin.player()) ? player : null;
var context = source == null ? null : new EventInput.Context(source.getUniqueId(), source.getName(),
null, null, null, location(source.getLocation()));
var data = new LinkedHashMap<String, Object>();
data.put("action_kind", "party_damage_protection");
if (context == null) data.put("actor_kind", "system");
data.put("damage_cause", event.getCause().name().toLowerCase(java.util.Locale.ROOT));
data.put("source_kind", causing == null ? "provenance" : causing instanceof Player ? "player"
: causing.getType() == null ? "unavailable" : causing.getType().name().toLowerCase(java.util.Locale.ROOT));
data.put("source_player_id", origin.player().toString());
if (causing != null && causing.getUniqueId() != null)
data.put("causing_entity_id", causing.getUniqueId().toString());
if (direct != null && direct.getUniqueId() != null)
data.put("direct_entity_id", direct.getUniqueId().toString());
if (origin.character() != null) data.put("source_character_id", origin.character().toString());
if (origin.session() != null) data.put("source_session_id", origin.session().toString());
data.put("target_player_id", target.getUniqueId().toString());
if (recipient.character() != null) data.put("target_character_id", recipient.character().toString());
if (recipient.session() != null) data.put("target_session_id", recipient.session().toString());
if (sourceParty != null) data.put("source_party_id", sourceParty.id().toString());
if (targetParty != null) data.put("target_party_id", targetParty.id().toString());
data.put("provenance_quality", origin.session() == null ? "ordinary_or_unproved"
: reason.equals("stale_source") ? "claimed_session_unverified" : "matching_session_id");
data.put("provenance_age_status", "unavailable");
data.put("requested_amount", event.getDamage());
data.put("prior_cancelled", event.isCancelled());
data.put("proposed_reason", reason);
var sourceLocation = causing == null ? null : location(causing.getLocation());
var targetLocation = location(target.getLocation());
if (sourceLocation != null) data.put("source_location", coordinates(sourceLocation));
else data.put("source_location_status", "unavailable");
if (targetLocation != null) data.put("target_location", coordinates(targetLocation));
else data.put("target_location_status", "unavailable");
var action = new Action(id, time, context, Map.copyOf(data));
var observed = new LinkedHashMap<String, Object>(data);
observed.put("outcome", "observed");
emit(action, "action.observed", "observed", observed);
return action;
} catch (RuntimeException ignored) { return null; }
}
public Action nativeAttempt(String kind, Entity causing, Player target, CombatSources.Origin origin,
CombatSources.Origin recipient, PartyRoster.Party sourceParty,
PartyRoster.Party targetParty, String reason, String nativeKind) {
try {
var id = UUID.randomUUID();
var now = clock.instant();
var source = causing instanceof Player player && player.getUniqueId().equals(origin.player()) ? player : null;
var context = source == null ? null : new EventInput.Context(source.getUniqueId(), source.getName(),
null, null, null, location(source.getLocation()));
var data = new LinkedHashMap<String, Object>();
data.put("action_kind", kind);
if (context == null) data.put("actor_kind", "system");
data.put("native_kind", nativeKind);
data.put("source_player_id", origin.player().toString());
if (causing != null && causing.getUniqueId() != null)
data.put("direct_entity_id", causing.getUniqueId().toString());
if (origin.character() != null) data.put("source_character_id", origin.character().toString());
if (origin.session() != null) data.put("source_session_id", origin.session().toString());
if (recipient != null) {
data.put("target_player_id", recipient.player().toString());
if (recipient.character() != null) data.put("target_character_id", recipient.character().toString());
if (recipient.session() != null) data.put("target_session_id", recipient.session().toString());
} else data.put("target_status", "not_single_proved_player");
if (sourceParty != null) data.put("source_party_id", sourceParty.id().toString());
if (targetParty != null) data.put("target_party_id", targetParty.id().toString());
data.put("provenance_quality", origin.session() == null ? "ordinary_or_unproved"
: reason.equals("stale_source") ? "claimed_session_unverified" : "matching_session_id");
data.put("provenance_age_status", "unavailable");
data.put("requested_amount_status", "not_applicable");
data.put("prior_cancelled", false);
var sourceLocation = causing == null ? null : location(causing.getLocation());
var targetLocation = target == null ? null : location(target.getLocation());
if (sourceLocation != null) data.put("source_location", coordinates(sourceLocation));
else data.put("source_location_status", "unavailable");
if (targetLocation != null) data.put("target_location", coordinates(targetLocation));
else data.put("target_location_status", "unavailable");
var action = new Action(id, now, context, Map.copyOf(data));
var observed = new LinkedHashMap<String, Object>(data);
observed.put("outcome", "observed");
emit(action, "action.observed", "observed", observed);
return action;
} catch (RuntimeException ignored) { return null; }
}
public void resolved(Action action, String outcome, String reason, boolean cancelled, Double finalAmount) {
if (action == null) return;
var values = new LinkedHashMap<String, Object>(action.data());
values.put("outcome", outcome);
values.put("reason", reason);
values.put("final_cancelled", cancelled);
if (finalAmount != null) values.put("final_amount", finalAmount);
else values.put("final_amount_status", "not_applicable");
emit(action, "action.resolved", "resolved", values);
}
private EventInput.Location location(Location value) {
return value == null || value.getWorld() == null ? null
: new EventInput.Location(value.getWorld().getUID(), value.getWorld().getName(), value.getX(),
value.getY(), value.getZ(), clock.instant(), "exact");
}
private Map<String, Object> coordinates(EventInput.Location value) {
return Map.of("world_id", value.worldId().toString(), "world_name", value.worldName(),
"x", value.x(), "y", value.y(), "z", value.z(), "captured_at", value.capturedAt().toString());
}
private void emit(Action action, String type, String stage, Map<String, Object> data) {
try { sink.accept(new EventInput(UUID.nameUUIDFromBytes(("fantasystick:party-protection:" + action.id()
+ ':' + stage).getBytes(StandardCharsets.UTF_8)), action.id(),
stage.equals("observed") ? action.observedAt() : clock.instant(), type, action.actor(), data)); }
catch (RuntimeException ignored) { }
}
}
@@ -5,6 +5,7 @@ import java.util.Objects;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import java.util.WeakHashMap;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
@@ -17,12 +18,24 @@ public final class PartyCombat implements Listener, AutoCloseable {
private boolean closed;
private final PartyRoster parties;
private final CombatSources sources;
private games.dmg.fantasystick.events.PartyProtectionEventBridge protectionEvents;
private final WeakHashMap<EntityDamageEvent, DamageAudit> damageAudits = new WeakHashMap<>();
private final WeakHashMap<org.bukkit.event.Event, NativeAudit> nativeAudits = new WeakHashMap<>();
private record NativeAudit(games.dmg.fantasystick.events.PartyProtectionEventBridge.Action action,
String reason) { }
private record DamageAudit(games.dmg.fantasystick.events.PartyProtectionEventBridge.Action action,
String decision) { }
public PartyCombat(PartyRoster parties, EquipmentSessions sessions) {
this.parties = Objects.requireNonNull(parties);
this.sources = new CombatSources(sessions);
}
public void attachProtectionEvents(games.dmg.fantasystick.events.PartyProtectionEventBridge events) {
if (protectionEvents != null || closed) throw new IllegalStateException("Party protection events already attached");
protectionEvents = Objects.requireNonNull(events);
}
public void install(org.bukkit.plugin.Plugin plugin) {
cleanup = plugin.getServer().getScheduler().runTaskTimer(plugin, () -> {
if (!closed) for (var player : plugin.getServer().getOnlinePlayers()) OngoingDamage.sweep(player);
@@ -31,6 +44,8 @@ public final class PartyCombat implements Listener, AutoCloseable {
@Override public void close() {
closed = true;
damageAudits.clear();
nativeAudits.clear();
if (cleanup != null) cleanup.cancel();
}
@@ -40,8 +55,21 @@ public final class PartyCombat implements Listener, AutoCloseable {
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void impact(org.bukkit.event.entity.ProjectileHitEvent event) {
var origin = sources.read(event.getEntity());
if (origin != null && (!sources.current(origin) || !(event.getEntity() instanceof org.bukkit.entity.ThrownPotion)
&& event.getHitEntity() instanceof Player target && blocked(origin, target))) event.setCancelled(true);
if (origin == null) return;
var target = event.getHitEntity() instanceof Player player ? player : null;
var reason = !sources.current(origin) ? "stale_source"
: target != null && !(event.getEntity() instanceof org.bukkit.entity.ThrownPotion)
? protectionReason(origin, target) : null;
if (reason != null) {
captureNative(event, "party_projectile_impact_protection", event.getEntity(), target, origin,
reason, "projectile_impact");
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void resolvedImpact(org.bukkit.event.entity.ProjectileHitEvent event) {
resolveNative(event, event);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
@@ -50,13 +78,33 @@ public final class PartyCombat implements Listener, AutoCloseable {
// Revoke stale continuation here. Valid healing and unrelated recipients are untouched;
// actual harmful applications and instantaneous damage have their own guards below.
var origin = sources.read(event.getEntity());
if (origin != null && !sources.current(origin)) event.setCancelled(true);
if (origin != null && !sources.current(origin)) {
var recipients = event.getAffectedEntities().stream().filter(Player.class::isInstance)
.map(Player.class::cast).toList();
captureNative(event, "party_splash_protection", event.getEntity(),
recipients.size() == 1 ? recipients.getFirst() : null, origin, "stale_source", "potion_splash");
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void resolvedSplash(org.bukkit.event.entity.PotionSplashEvent event) {
resolveNative(event, event);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void staleLingering(LingeringPotionSplashEvent event) {
var origin = sources.read(event.getEntity());
if (origin != null && !sources.current(origin)) event.setCancelled(true);
if (origin != null && !sources.current(origin)) {
captureNative(event, "party_lingering_protection", event.getEntity(), null,
origin, "stale_source", "lingering_splash");
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void resolvedLingering(LingeringPotionSplashEvent event) {
resolveNative(event, event);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
@@ -64,12 +112,49 @@ public final class PartyCombat implements Listener, AutoCloseable {
sources.store(event.getAreaEffectCloud(), sources.read(event.getEntity()));
}
@EventHandler(priority = EventPriority.LOWEST)
public void observeDamage(EntityDamageEvent event) {
if (protectionEvents == null || !(event.getEntity() instanceof Player target)) return;
// OngoingDamage.read reconciles native PDC. Only the original HIGHEST guard may do so;
// the LOWEST audit must not change its timing or mutate an earlier protection's state.
if (event.getCause() == EntityDamageEvent.DamageCause.POISON
|| event.getCause() == EntityDamageEvent.DamageCause.WITHER
|| event.getCause() == EntityDamageEvent.DamageCause.FIRE_TICK) return;
var origin = sources.source(event);
if (origin == null) return;
var ongoing = OngoingDamage.read(target, event.getCause());
String reason = ongoing != null && !sources.current(ongoing.target()) ? "stale_target"
: protectionReason(origin, target);
if (reason == null) return;
captureDamage(event, target, origin, reason);
}
@EventHandler(priority = EventPriority.MONITOR)
public void resolvedDamage(EntityDamageEvent event) {
if (protectionEvents == null) return;
var audit = damageAudits.remove(event);
if (audit == null) return;
boolean prior = Boolean.TRUE.equals(audit.action().data().get("prior_cancelled"));
String reason = prior && event.isCancelled() ? "prior_cancelled"
: !event.isCancelled() && audit.decision() != null ? "protection_overridden"
: event.isCancelled() && audit.decision() != null ? audit.decision()
: event.isCancelled() ? "external_cancelled" : "protection_not_applied";
String outcome = !event.isCancelled() ? "accepted" : prior || audit.decision() == null
? "cancelled" : "refused";
protectionEvents.resolved(audit.action(), outcome, reason, event.isCancelled(),
event.isCancelled() ? 0 : event.getFinalDamage());
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void damage(EntityDamageEvent event) {
if (games.dmg.fantasystick.ability.GuardianBond.redirectedDamage()) return;
if (event.getEntity() instanceof Player target) {
var ongoing = OngoingDamage.read(target, event.getCause());
if (ongoing != null && (!sources.current(ongoing.target()) || blocked(ongoing.source(), target))) {
var reason = !sources.current(ongoing.target()) ? "stale_target"
: protectionReason(ongoing.source(), target);
captureDamage(event, target, ongoing.source(), reason);
markDamageDecision(event, reason);
event.setCancelled(true); return;
}
}
@@ -77,7 +162,14 @@ public final class PartyCombat implements Listener, AutoCloseable {
if (origin == null && event instanceof EntityDamageByEntityEvent entity) origin = sources.read(entity.getDamager());
if (origin == null) origin = sources.read(event.getDamageSource().getCausingEntity());
if (origin != null && (!sources.current(origin)
|| event.getEntity() instanceof Player target && blocked(origin, target))) event.setCancelled(true);
|| event.getEntity() instanceof Player target && blocked(origin, target))) {
if (event.getEntity() instanceof Player target) {
var reason = protectionReason(origin, target);
captureDamage(event, target, origin, reason);
markDamageDecision(event, reason);
}
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
@@ -85,7 +177,16 @@ public final class PartyCombat implements Listener, AutoCloseable {
if (!(event.getEntity() instanceof Player target) || event.getNewEffect() == null
|| OngoingDamage.cause(event.getModifiedType()) == null) return;
var origin = sources.read(event.getSource());
if (origin != null && blocked(origin, target)) event.setCancelled(true);
if (origin != null && blocked(origin, target)) {
captureNative(event, "party_effect_application_protection", event.getSource(), target,
origin, protectionReason(origin, target), event.getModifiedType().getKey().getKey());
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void resolvedPotion(org.bukkit.event.entity.EntityPotionEffectEvent event) {
resolveNative(event, event);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
@@ -105,7 +206,16 @@ public final class PartyCombat implements Listener, AutoCloseable {
public void combust(org.bukkit.event.entity.EntityCombustByEntityEvent event) {
if (!(event.getEntity() instanceof Player target)) return;
var origin = sources.read(event.getCombuster());
if (origin != null && blocked(origin, target)) event.setCancelled(true);
if (origin != null && blocked(origin, target)) {
captureNative(event, "party_ignite_protection", event.getCombuster(), target,
origin, protectionReason(origin, target), "combust");
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void resolvedCombust(org.bukkit.event.entity.EntityCombustByEntityEvent event) {
resolveNative(event, event);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
@@ -118,11 +228,49 @@ public final class PartyCombat implements Listener, AutoCloseable {
: new OngoingDamage.Application(origin, sources.player(target.getUniqueId())));
}
private boolean blocked(CombatSources.Origin origin, Player target) {
private void captureNative(org.bukkit.event.Event event, String kind, org.bukkit.entity.Entity causing,
Player target, CombatSources.Origin origin, String reason, String nativeKind) {
if (protectionEvents == null || reason == null) return;
var recipient = target == null ? null : sources.player(target.getUniqueId());
var action = protectionEvents.nativeAttempt(kind, causing, target, origin, recipient,
parties.partyOf(origin.player()).orElse(null),
recipient == null ? null : parties.partyOf(recipient.player()).orElse(null),
reason, nativeKind);
if (action != null) nativeAudits.put(event, new NativeAudit(action, reason));
}
private void resolveNative(org.bukkit.event.Event event, org.bukkit.event.Cancellable cancellable) {
if (protectionEvents == null) return;
var audit = nativeAudits.remove(event);
if (audit != null) protectionEvents.resolved(audit.action(), cancellable.isCancelled() ? "refused" : "accepted",
cancellable.isCancelled() ? audit.reason() : "external_uncancelled", cancellable.isCancelled(), null);
}
private void captureDamage(EntityDamageEvent event, Player target, CombatSources.Origin origin, String reason) {
if (protectionEvents == null || damageAudits.containsKey(event) || reason == null) return;
var recipient = sources.player(target.getUniqueId());
var action = protectionEvents.damage(event, origin, recipient, target,
parties.partyOf(origin.player()).orElse(null), parties.partyOf(recipient.player()).orElse(null), reason);
if (action != null) damageAudits.put(event, new DamageAudit(action, null));
}
private void markDamageDecision(EntityDamageEvent event, String reason) {
var audit = damageAudits.get(event);
if (audit != null && reason != null) damageAudits.put(event, new DamageAudit(audit.action(), reason));
}
private String protectionReason(CombatSources.Origin origin, Player target) {
if (!sources.current(origin)) return "stale_source";
var recipient = sources.player(target.getUniqueId());
boolean fantasy = origin.session() != null;
return !sources.current(origin) || fantasy != (recipient.session() != null) || fantasy
&& !origin.player().equals(recipient.player())
&& parties.partyOf(origin.player()).filter(group -> group.members().contains(recipient.player())).isPresent();
if (fantasy != (recipient.session() != null)) return "mixed_mode";
if (fantasy && !origin.player().equals(recipient.player())
&& parties.partyOf(origin.player()).filter(group -> group.members().contains(recipient.player())).isPresent())
return "same_party";
return null;
}
private boolean blocked(CombatSources.Origin origin, Player target) {
return protectionReason(origin, target) != null;
}
}
@@ -0,0 +1,393 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Current persistent-party native damage decision -> generic local JSONL action. */
class PartyProtectionEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void currentCharacterPartyNativeDirectDamageRefusalHasOneCorrelatedFinalDecision() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var attacker = actor(fixture); var target = actor(fixture);
fixture.activate(attacker); fixture.activate(target);
var parties = PartyLifecycleNativeTest.parties(fixture);
var group = parties.create(attacker.core.id).orElseThrow();
var invitation = parties.invite(attacker.core.id, group.id(), target.core.id).orElseThrow();
assertTrue(parties.accept(target.core.id, invitation.id()));
var damage = PartyCombatNativeTest.hit(fixture, attacker.player, target.player);
assertTrue(damage.isCancelled());
for (int i = 0; i < 250 && records().stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("reason")
&& entry.getAsJsonObject("data").get("reason").getAsString().equals("same_party")); i++)
Thread.sleep(20);
assertTrue(records().stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("combat.damage")), "A protected hit cannot become an accepted damage record");
assertTrue(parties.leave(target.core.id, group.id()));
assertFalse(PartyCombatNativeTest.hit(fixture, attacker.player, target.player).isCancelled());
fixture.plugin.setEnabled(false);
var entries = records();
var observed = entries.stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.observed") && entry.getAsJsonObject("data").has("action_kind")
&& entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_damage_protection"))
.toList();
var resolved = entries.stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("reason")
&& entry.getAsJsonObject("data").get("reason").getAsString().equals("same_party"))
.toList();
assertEquals(1, observed.size()); assertEquals(1, resolved.size());
assertEquals(observed.getFirst().getAsJsonObject("data").get("action_id").getAsString(),
resolved.getFirst().getAsJsonObject("data").get("action_id").getAsString());
var value = resolved.getFirst().getAsJsonObject("data");
assertEquals(group.id().toString(), value.get("source_party_id").getAsString());
assertEquals(group.id().toString(), value.get("target_party_id").getAsString());
assertEquals("refused", value.get("outcome").getAsString());
assertTrue(value.has("source_location")); assertTrue(value.has("target_location"));
assertTrue(entries.stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("party.created")), "Protection cannot resurrect transient lifecycle records");
}
}
@Test void observationDoesNotReconcileNativeOngoingProvenanceBeforeProtectionPriority() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var attacker = actor(fixture); var target = actor(fixture);
fixture.activate(attacker); fixture.activate(target);
var effect = new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.POISON, 200, 0);
org.mockito.Mockito.doReturn(effect).when(target.player).getPotionEffect(effect.getType());
fixture.dispatcher.accept(new org.bukkit.event.entity.EntityPotionEffectEvent(target.player,
null, effect, attacker.player, org.bukkit.event.entity.EntityPotionEffectEvent.Cause.POTION_SPLASH,
org.bukkit.event.entity.EntityPotionEffectEvent.Action.ADDED, true));
var before = target.core.pdc.serializeToBytes();
org.mockito.Mockito.doReturn(null).when(target.player).getPotionEffect(effect.getType());
var atNormalPriority = new org.bukkit.event.Listener() {
byte[] observed;
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.NORMAL)
public void inspect(org.bukkit.event.entity.EntityDamageEvent ignored) {
observed = assertDoesNotThrow(() -> target.core.pdc.serializeToBytes());
}
};
fixture.dispatcher.register(atNormalPriority, fixture.plugin);
var tick = new org.bukkit.event.entity.EntityDamageEvent(target.player,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.POISON,
org.mockito.Mockito.mock(org.bukkit.damage.DamageSource.class), 1);
fixture.dispatcher.accept(tick);
assertNotNull(atNormalPriority.observed, "NORMAL-priority observer must execute");
assertArrayEquals(before, atNormalPriority.observed,
"Audit must not reconcile native effect PDC earlier than its protection guard");
assertFalse(tick.isCancelled());
assertFalse(java.util.Arrays.equals(before, target.core.pdc.serializeToBytes()),
"Original HIGHEST protection read still reconciles an ended effect");
}
}
@Test void poisonWitherAndFireTickRecheckBoundSourceWithoutInventedNativeAttacker() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var attacker = actor(fixture); var target = actor(fixture);
fixture.activate(attacker); fixture.activate(target);
for (var type : List.of(org.bukkit.potion.PotionEffectType.POISON,
org.bukkit.potion.PotionEffectType.WITHER)) {
var effect = new org.bukkit.potion.PotionEffect(type, 200, 0);
org.mockito.Mockito.doReturn(effect).when(target.player).getPotionEffect(type);
var apply = new org.bukkit.event.entity.EntityPotionEffectEvent(target.player, null, effect,
attacker.player, org.bukkit.event.entity.EntityPotionEffectEvent.Cause.POTION_SPLASH,
org.bukkit.event.entity.EntityPotionEffectEvent.Action.ADDED, true);
fixture.dispatcher.accept(apply);
assertFalse(apply.isCancelled());
}
org.mockito.Mockito.doReturn(200).when(target.player).getFireTicks();
var ignite = new org.bukkit.event.entity.EntityCombustByEntityEvent(attacker.player, target.player, 10.0f);
fixture.dispatcher.accept(ignite); assertFalse(ignite.isCancelled());
var parties = PartyLifecycleNativeTest.parties(fixture);
var group = parties.create(attacker.core.id).orElseThrow();
var invitation = parties.invite(attacker.core.id, group.id(), target.core.id).orElseThrow();
assertTrue(parties.accept(target.core.id, invitation.id()));
var beforeVeto = target.core.pdc.serializeToBytes();
org.mockito.Mockito.doReturn(null).when(target.player).getPotionEffect(org.bukkit.potion.PotionEffectType.POISON);
var vetoedTick = new org.bukkit.event.entity.EntityDamageEvent(target.player,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.POISON,
org.mockito.Mockito.mock(org.bukkit.damage.DamageSource.class), 1);
vetoedTick.setCancelled(true); fixture.dispatcher.accept(vetoedTick);
assertArrayEquals(beforeVeto, target.core.pdc.serializeToBytes(),
"A prior veto must not reconcile or clear owned native provenance");
org.mockito.Mockito.doReturn(new org.bukkit.potion.PotionEffect(
org.bukkit.potion.PotionEffectType.POISON, 200, 0)).when(target.player)
.getPotionEffect(org.bukkit.potion.PotionEffectType.POISON);
for (var cause : List.of(org.bukkit.event.entity.EntityDamageEvent.DamageCause.POISON,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.WITHER,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.FIRE_TICK)) {
var tick = new org.bukkit.event.entity.EntityDamageEvent(target.player, cause,
org.mockito.Mockito.mock(org.bukkit.damage.DamageSource.class), 1);
fixture.dispatcher.accept(tick);
assertTrue(tick.isCancelled(), cause.toString());
}
for (var type : List.of(org.bukkit.potion.PotionEffectType.POISON,
org.bukkit.potion.PotionEffectType.WITHER)) {
var remove = new org.bukkit.event.entity.EntityPotionEffectEvent(target.player,
new org.bukkit.potion.PotionEffect(type, 200, 0), null, null,
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.MILK,
org.bukkit.event.entity.EntityPotionEffectEvent.Action.REMOVED, false);
fixture.dispatcher.accept(remove);
org.mockito.Mockito.doReturn(null).when(target.player).getPotionEffect(type);
}
org.mockito.Mockito.doReturn(0).when(target.player).getFireTicks();
for (var cause : List.of(org.bukkit.event.entity.EntityDamageEvent.DamageCause.POISON,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.WITHER,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.FIRE_TICK)) {
var expired = new org.bukkit.event.entity.EntityDamageEvent(target.player, cause,
org.mockito.Mockito.mock(org.bukkit.damage.DamageSource.class), 1);
fixture.dispatcher.accept(expired);
assertFalse(expired.isCancelled(), "Expired provenance cannot create party protection: " + cause);
}
for (int i = 0; i < 250 && records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
&& entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_damage_protection"))
.count() < 3; i++) Thread.sleep(20);
fixture.plugin.setEnabled(false);
var decisions = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
&& entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_damage_protection"))
.map(entry -> entry.getAsJsonObject("data")).toList();
assertEquals(3, decisions.size());
assertEquals(java.util.Set.of("poison", "wither", "fire_tick"), decisions.stream()
.map(value -> value.get("damage_cause").getAsString()).collect(java.util.stream.Collectors.toSet()));
assertTrue(decisions.stream().allMatch(value -> value.get("reason").getAsString().equals("same_party")
&& value.get("source_location_status").getAsString().equals("unavailable")
&& value.has("target_location")));
}
}
@Test void mixedModeAndPriorCancelledHitsReportTruthfulDistinctOutcomes() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var attacker = actor(fixture); var target = actor(fixture);
fixture.activate(attacker);
var mixed = PartyCombatNativeTest.hit(fixture, attacker.player, target.player);
assertTrue(mixed.isCancelled());
fixture.activate(target);
var parties = PartyLifecycleNativeTest.parties(fixture);
var group = parties.create(attacker.core.id).orElseThrow();
var invitation = parties.invite(attacker.core.id, group.id(), target.core.id).orElseThrow();
assertTrue(parties.accept(target.core.id, invitation.id()));
var source = org.mockito.Mockito.mock(org.bukkit.damage.DamageSource.class);
org.mockito.Mockito.when(source.getCausingEntity()).thenReturn(attacker.player);
org.mockito.Mockito.when(source.getDirectEntity()).thenReturn(attacker.player);
var cancelled = new org.bukkit.event.entity.EntityDamageByEntityEvent(attacker.player, target.player,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 4);
cancelled.setCancelled(true); fixture.dispatcher.accept(cancelled);
assertTrue(cancelled.isCancelled());
for (int i = 0; i < 250 && records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
&& entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_damage_protection"))
.count() < 2; i++) Thread.sleep(20);
fixture.plugin.setEnabled(false);
var results = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
&& entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_damage_protection"))
.map(entry -> entry.getAsJsonObject("data")).toList();
assertEquals(2, results.size());
assertTrue(results.stream().anyMatch(value -> value.get("reason").getAsString().equals("mixed_mode")
&& value.get("outcome").getAsString().equals("refused")));
assertTrue(results.stream().anyMatch(value -> value.get("reason").getAsString().equals("prior_cancelled")
&& value.get("outcome").getAsString().equals("cancelled")
&& value.get("prior_cancelled").getAsBoolean()));
}
}
@Test void stalePotionAndLingeringSplashCancellationHaveBoundedNativeResolutions() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var attacker = actor(fixture); var target = actor(fixture);
fixture.activate(attacker); fixture.activate(target);
var potion = PartyCombatNativeTest.entity(org.bukkit.entity.ThrownPotion.class);
org.mockito.Mockito.when(potion.getShooter()).thenReturn(attacker.player);
fixture.dispatcher.accept(new org.bukkit.event.entity.ProjectileLaunchEvent(potion));
PartyCombatNativeTest.exit(fixture, attacker);
var splash = new org.bukkit.event.entity.PotionSplashEvent(potion,
new java.util.HashMap<>(java.util.Map.of(target.player, 1.0)));
fixture.dispatcher.accept(splash); assertTrue(splash.isCancelled());
var cloud = PartyCombatNativeTest.entity(org.bukkit.entity.AreaEffectCloud.class);
var lingering = new org.bukkit.event.entity.LingeringPotionSplashEvent(potion, cloud);
fixture.dispatcher.accept(lingering); assertTrue(lingering.isCancelled());
for (int i = 0; i < 250 && records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
&& (entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_splash_protection")
|| entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_lingering_protection")))
.count() < 2; i++) Thread.sleep(20);
fixture.plugin.setEnabled(false);
var decisions = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
&& (entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_splash_protection")
|| entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_lingering_protection")))
.map(entry -> entry.getAsJsonObject("data")).toList();
assertEquals(java.util.Set.of("party_splash_protection", "party_lingering_protection"), decisions.stream()
.map(value -> value.get("action_kind").getAsString()).collect(java.util.stream.Collectors.toSet()));
assertTrue(decisions.stream().allMatch(value -> value.get("reason").getAsString().equals("stale_source")
&& value.get("outcome").getAsString().equals("refused")));
assertEquals("not_single_proved_player", decisions.stream().filter(value -> value.get("action_kind")
.getAsString().equals("party_lingering_protection")).findFirst().orElseThrow()
.get("target_status").getAsString());
}
}
@Test void protectedImpactPotionAndIgnitionDecisionsWithoutDamageEventsAreAudited() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var attacker = actor(fixture); var target = actor(fixture);
fixture.activate(attacker); fixture.activate(target);
var parties = PartyLifecycleNativeTest.parties(fixture);
var group = parties.create(attacker.core.id).orElseThrow();
var invitation = parties.invite(attacker.core.id, group.id(), target.core.id).orElseThrow();
assertTrue(parties.accept(target.core.id, invitation.id()));
var arrow = PartyCombatNativeTest.projectile(fixture, attacker.player);
var impact = new org.bukkit.event.entity.ProjectileHitEvent(arrow, target.player);
fixture.dispatcher.accept(impact); assertTrue(impact.isCancelled());
var effect = new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.POISON, 200, 0);
var poison = new org.bukkit.event.entity.EntityPotionEffectEvent(target.player, null, effect,
attacker.player, org.bukkit.event.entity.EntityPotionEffectEvent.Cause.POTION_SPLASH,
org.bukkit.event.entity.EntityPotionEffectEvent.Action.ADDED, true);
fixture.dispatcher.accept(poison); assertTrue(poison.isCancelled());
var ignition = new org.bukkit.event.entity.EntityCombustByEntityEvent(arrow, target.player, 10.0f);
fixture.dispatcher.accept(ignition); assertTrue(ignition.isCancelled());
for (int i = 0; i < 250 && records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
&& entry.getAsJsonObject("data").get("action_kind").getAsString().startsWith("party_"))
.count() < 3; i++) Thread.sleep(20);
fixture.plugin.setEnabled(false);
var outcomes = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
&& entry.getAsJsonObject("data").get("action_kind").getAsString().startsWith("party_"))
.map(entry -> entry.getAsJsonObject("data")).toList();
assertEquals(java.util.Set.of("party_projectile_impact_protection", "party_effect_application_protection",
"party_ignite_protection"), outcomes.stream().map(value -> value.get("action_kind").getAsString())
.collect(java.util.stream.Collectors.toSet()));
assertTrue(outcomes.stream().allMatch(value -> value.get("reason").getAsString().equals("same_party")
&& value.get("final_cancelled").getAsBoolean()));
assertTrue(records().stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("combat.damage")));
}
}
@Test void serializedOldProjectileAfterRestartRetainsOldSessionAndNeverClaimsNewHolder() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var attacker = actor(fixture); var target = actor(fixture);
fixture.activate(attacker); fixture.activate(target);
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
var originalSession = sessions.active(attacker.core.id).orElseThrow().session();
var arrow = PartyCombatNativeTest.projectile(fixture, attacker.player);
var bytes = arrow.getPersistentDataContainer().serializeToBytes();
fixture.plugin.setEnabled(false);
fixture.awaitWorkers();
var serialized = PartyCombatNativeTest.entity(org.bukkit.entity.Arrow.class);
org.mockito.Mockito.when(serialized.getShooter()).thenReturn(attacker.player);
serialized.getPersistentDataContainer().readFromBytes(bytes, true);
fixture.plugin.setEnabled(true);
assertTrue(PartyCombatNativeTest.hit(fixture, serialized, target.player).isCancelled());
for (int i = 0; i < 250 && records().stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("reason")
&& entry.getAsJsonObject("data").get("reason").getAsString().equals("stale_source")); i++)
Thread.sleep(20);
fixture.plugin.setEnabled(false);
var result = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("reason")
&& entry.getAsJsonObject("data").get("reason").getAsString().equals("stale_source"))
.findFirst().orElseThrow().getAsJsonObject("data");
assertEquals(originalSession.toString(), result.get("source_session_id").getAsString());
assertEquals("unavailable", result.get("provenance_age_status").getAsString());
assertTrue(result.has("target_location"));
}
}
@Test void malformedAndForgedProjectileOriginsNeverBorrowCurrentCharacterOrParty() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var attacker = actor(fixture); var target = actor(fixture);
fixture.activate(attacker); fixture.activate(target);
var arrow = PartyCombatNativeTest.projectile(fixture, attacker.player);
var key = arrow.getPersistentDataContainer().getKeys().stream()
.filter(value -> value.getKey().equals("combat_origin_v1")).findFirst().orElseThrow();
arrow.getPersistentDataContainer().set(key, org.bukkit.persistence.PersistentDataType.STRING, "malformed");
assertTrue(PartyCombatNativeTest.hit(fixture, arrow, target.player).isCancelled());
arrow.getPersistentDataContainer().set(key, org.bukkit.persistence.PersistentDataType.STRING,
attacker.core.id + "/" + java.util.UUID.randomUUID() + "/" + attacker.core.record.id());
assertTrue(PartyCombatNativeTest.hit(fixture, arrow, target.player).isCancelled());
for (int i = 0; i < 250 && records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
&& entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_damage_protection"))
.count() < 2; i++) Thread.sleep(20);
fixture.plugin.setEnabled(false);
var decisions = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
&& entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_damage_protection"))
.map(entry -> entry.getAsJsonObject("data")).toList();
assertEquals(List.of("stale_source", "stale_source"), decisions.stream()
.map(value -> value.get("reason").getAsString()).toList());
assertEquals("ordinary_or_unproved", decisions.getFirst().get("provenance_quality").getAsString());
assertFalse(decisions.getFirst().has("source_session_id"));
assertEquals("claimed_session_unverified", decisions.get(1).get("provenance_quality").getAsString());
assertFalse(decisions.stream().anyMatch(value -> value.has("source_party_id")));
}
}
@Test void attributedProjectileRechecksCurrentPartyAndExpiresWithOriginalSession() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var attacker = actor(fixture); var target = actor(fixture);
fixture.activate(attacker); fixture.activate(target);
var projectile = PartyCombatNativeTest.projectile(fixture, attacker.player);
var parties = PartyLifecycleNativeTest.parties(fixture);
var group = parties.create(attacker.core.id).orElseThrow();
var invitation = parties.invite(attacker.core.id, group.id(), target.core.id).orElseThrow();
assertTrue(parties.accept(target.core.id, invitation.id()));
assertTrue(PartyCombatNativeTest.hit(fixture, projectile, target.player).isCancelled());
PartyCombatNativeTest.exit(fixture, attacker);
assertTrue(PartyCombatNativeTest.hit(fixture, projectile, target.player).isCancelled());
for (int i = 0; i < 250 && records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
&& entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_damage_protection"))
.count() < 2; i++) Thread.sleep(20);
fixture.plugin.setEnabled(false);
var reasons = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
&& entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_damage_protection"))
.map(entry -> entry.getAsJsonObject("data").get("reason").getAsString()).toList();
assertEquals(List.of("same_party", "stale_source"), reasons);
}
}
private FantasyStickLifecycleNativeTest.Actor actor(FantasyStickLifecycleNativeTest.Fixture fixture) throws Exception {
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
return actor;
}
private List<JsonObject> records() throws Exception {
var result = new ArrayList<JsonObject>();
var directory = data.resolve("events");
if (!Files.exists(directory)) return result;
try (var paths = Files.walk(directory)) {
for (var path : paths.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList()) {
var content = Files.readString(path);
int end = content.lastIndexOf('\n');
if (end < 0) continue;
for (var line : content.substring(0, end).lines().toList())
if (!line.isBlank()) result.add(JsonParser.parseString(line).getAsJsonObject());
}
}
return result;
}
}