feat(events): retain shared combat reward audit intents
Release / release (push) Failing after 10m57s

This commit is contained in:
dmg committed 2026-10-02 08:03:15 -04:00
1 parent f1b4479e83
commit 6fc8c5d9eb
6 files changed
+273 -12

No files matched your search

@@ -186,6 +186,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
private java.util.concurrent.CompletionStage<Void> adminRevivalAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.PartyEventBridge partyAuditEvents;
private games.dmg.fantasystick.events.CombatRewardEventBridge combatRewardEvents;
private java.util.concurrent.CompletionStage<Void> combatRewardAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.CoinEventBridge coinEvents;
private games.dmg.fantasystick.events.SessionEventBridge sessionAuditEvents;
private games.dmg.fantasystick.events.NutritionEventBridge nutritionAuditEvents;
@@ -896,7 +897,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
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);
combatRewardEvents = new games.dmg.fantasystick.events.CombatRewardEventBridge(eventLog::emit,Clock.systemUTC(),getDataFolder().toPath(),
failure -> getLogger().log(Level.WARNING,"Combat reward event intent failed",failure));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);
nutritionAuditEvents = new games.dmg.fantasystick.events.NutritionEventBridge(
@@ -1067,6 +1069,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (failure != null) getLogger().log(Level.WARNING, "Previous passive potion event drain failed", failure);
if (replayPassivePotionEvents != null) replayPassivePotionEvents.replay();
});
var replayCombatRewardEvents = combatRewardEvents;
combatRewardAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous combat reward event drain failed", failure);
if (replayCombatRewardEvents != null) replayCombatRewardEvents.replay();
});
var replayHealthStealEvents = healthStealEvents;
healthStealAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous Health Steal event drain failed", failure);
@@ -1160,6 +1167,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingPartyCapacityEvents = partyCapacityEvents; partyCapacityEvents = null;
var closingSheetPackEvents = sheetResourcePackEvents; sheetResourcePackEvents = null;
var closingGrantEvents = adminExperienceEvents; adminExperienceEvents = null;
var closingCombatRewardEvents = combatRewardEvents;
var closingHealthStealEvents = healthStealEvents; healthStealEvents = null;
var closingPassivePotionEvents = passivePotionEvents; passivePotionEvents = null;
var closingRecoveryEvents = recoveryEvents; recoveryEvents = null;
@@ -1528,6 +1536,14 @@ public final class FantasyStickPlugin extends JavaPlugin {
closingPassivePotionEvents.close();
passivePotionAuditDrain = closingPassivePotionEvents.drained();
});
if (closingCombatRewardEvents != null) cleanup("combat reward event intent drain", () -> {
var storageDrained = closingCharacters == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingCharacters.drained();
combatRewardAuditDrain = storageDrained.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Character storage drain failed", failure);
closingCombatRewardEvents.close(); return null;
}).thenCompose(ignored -> closingCombatRewardEvents.drained());
});
if (closingHealthStealEvents != null) cleanup("Health Steal event intent drain", () -> {
var storageDrained = closingCharacters == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingCharacters.drained();
@@ -17,7 +17,7 @@ import java.util.function.Consumer;
import org.bukkit.entity.Player;
/** Failure-isolated, single-owner recipient resolution for combat XP and coin shares. */
public final class CombatRewardEventBridge {
public final class CombatRewardEventBridge implements AutoCloseable {
public record Capture(UUID targetId,String targetType,String experienceClass,String coinClass,
CombatRewardRules.KillPosition killPosition,ManagedEncounterOrigin managedOrigin,
String stage,Instant defeatedAt,java.util.List<UUID> directContributors,
@@ -33,14 +33,33 @@ public final class CombatRewardEventBridge {
public Capture{managedEvidence=Map.copyOf(managedEvidence);Objects.requireNonNull(targetId);Objects.requireNonNull(targetType);Objects.requireNonNull(experienceClass);Objects.requireNonNull(coinClass);Objects.requireNonNull(stage);Objects.requireNonNull(defeatedAt);directContributors=java.util.List.copyOf(directContributors);Objects.requireNonNull(rewardUnitId);Objects.requireNonNull(experiencePool);Objects.requireNonNull(coinPool);if(rewardUnitCount<1||experiencePool.signum()<0||coinPool.signum()<0)throw new IllegalArgumentException("Invalid combat reward capture");}
}
private final Consumer<EventInput> sink;private final Clock clock;
public CombatRewardEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
private final FeatureEventOutbox outbox;
public CombatRewardEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);outbox=null;}
public CombatRewardEventBridge(java.util.function.Function<EventInput,java.util.concurrent.CompletionStage<LocalEventLog.Delivery>> sink,
Clock clock,java.nio.file.Path data,Consumer<Throwable> failures){
this.sink=event->sink.apply(event);this.clock=Objects.requireNonNull(clock);
outbox=new FeatureEventOutbox(data,"combat-reward-event-intents",java.util.Set.of("combat.reward_resolved"),sink,failures);
}
public void replay(){if(outbox!=null)outbox.replay();}
public java.util.concurrent.CompletionStage<Void> drained(){return outbox==null?java.util.concurrent.CompletableFuture.completedFuture(null):outbox.drained();}
@Override public void close(){if(outbox!=null)outbox.close();}
public void resolved(UUID operation,UUID character,BigInteger allocatedExperience,BigInteger allocatedCoins,
CombatRewardRules.KillPosition restricted,CombatRewardRules rules,Capture capture,
CharacterRecord before,CharacterStorage.CombatAward award,EquipmentSessions.Active active,
Player player,String nativeSync,String feedback){
if(award==null||award.record().isEmpty()||before==null)return;var after=award.record().orElseThrow();var data=new LinkedHashMap<String,Object>();data.put("operation_id",operation.toString());data.put("reward_receipt",operation.toString());data.put("stage",capture.stage());data.put("recipient_character_id",character.toString());data.put("target_id",capture.targetId().toString());data.put("target_type",capture.targetType());data.put("experience_class",capture.experienceClass());data.put("coin_class",capture.coinClass());data.put("defeated_at",capture.defeatedAt().toString());data.put("direct_contributor_character_ids",capture.directContributors().stream().map(UUID::toString).sorted().toList());data.put("reward_unit_id",capture.rewardUnitId());data.put("reward_unit_count",capture.rewardUnitCount());data.put("experience_pool",capture.experiencePool().toString());data.put("coin_pool",capture.coinPool().toString());if(capture.killPosition()!=null)data.put("kill_location",position(capture.killPosition()));data.put("allocated_experience",allocatedExperience.toString());data.put("allocated_coins",allocatedCoins.toString());var appliedExperience=after.experience().subtract(before.experience()).max(BigInteger.ZERO);var appliedCoins=after.coins().subtract(before.coins()).max(BigInteger.ZERO);data.put("applied_experience",appliedExperience.toString());data.put("applied_coins",appliedCoins.toString());data.put("suppressed_experience",allocatedExperience.subtract(appliedExperience.min(allocatedExperience)).toString());data.put("suppressed_coins",allocatedCoins.subtract(appliedCoins.min(allocatedCoins)).toString());data.put("before_revision",before.revision().toString());data.put("after_revision",after.revision().toString());data.put("before_level",before.level());data.put("after_level",after.level());data.put("before_experience",before.experience().toString());data.put("after_experience",after.experience().toString());data.put("before_points",before.unspentPoints().toString());data.put("after_points",after.unspentPoints().toString());data.put("balance_before",before.coins().toString());data.put("balance_after",after.coins().toString());data.put("storage_status",award.status().name().toLowerCase(java.util.Locale.ROOT));data.put("levels_gained",award.levels());data.put("native_sync",nativeSync);data.put("feedback",feedback);if(capture.managedOrigin()!=null){data.put("resolution","managed_origin_exempt");data.put("managed_origin_kind",capture.managedOrigin().kind().name().toLowerCase(java.util.Locale.ROOT));data.put("managed_origin_source",capture.managedOrigin().source().toString());data.put("spacing_applicability","authenticated_managed_origin_exempt");if(capture.managedOrigin().kind()==ManagedEncounterOrigin.Kind.DUNGEON&&capture.managedOrigin().source().toString().equals(capture.managedEvidence().get("managed_source_id")))data.putAll(capture.managedEvidence());}else if(restricted!=null){boolean blocked=award.status()==CharacterStorage.CombatAwardStatus.SPACING_BLOCKED;data.put("resolution",blocked?"ordinary_spacing_blocked":"ordinary_spacing_eligible");data.put("spacing_applicability","ordinary_restricted_kill");data.put("spacing_minimum",rules.restrictedSpacing().toPlainString());var prior=before.restrictedKills().get(restricted.world());if(prior!=null)data.put("ordinary_history_before",position(prior));var current=after.restrictedKills().get(restricted.world());if(current!=null)data.put("ordinary_history_after",position(current));}else{data.put("resolution","spacing_not_applicable");data.put("spacing_applicability","passive_or_fantasy_character");}data.put("loss_reason",loss(award.status()));var context=context(active,player,after.name());try{sink.accept(new EventInput(id(operation),operation,clock.instant(),"combat.reward_resolved",context,Map.copyOf(data)));}catch(RuntimeException ignored){}
if(award==null||award.record().isEmpty()||before==null||award.status()==CharacterStorage.CombatAwardStatus.DUPLICATE)return;var after=award.record().orElseThrow();var data=new LinkedHashMap<String,Object>();data.put("operation_id",operation.toString());data.put("reward_receipt",operation.toString());data.put("stage",capture.stage());data.put("recipient_character_id",character.toString());data.put("target_id",capture.targetId().toString());data.put("target_type",capture.targetType());data.put("experience_class",capture.experienceClass());data.put("coin_class",capture.coinClass());data.put("defeated_at",capture.defeatedAt().toString());data.put("direct_contributor_character_ids",capture.directContributors().stream().map(UUID::toString).sorted().toList());data.put("reward_unit_id",capture.rewardUnitId());data.put("reward_unit_count",capture.rewardUnitCount());data.put("experience_pool",capture.experiencePool().toString());data.put("coin_pool",capture.coinPool().toString());if(capture.killPosition()!=null)data.put("kill_location",position(capture.killPosition()));data.put("allocated_experience",allocatedExperience.toString());data.put("allocated_coins",allocatedCoins.toString());var appliedExperience=after.experience().subtract(before.experience()).max(BigInteger.ZERO);var appliedCoins=after.coins().subtract(before.coins()).max(BigInteger.ZERO);data.put("applied_experience",appliedExperience.toString());data.put("applied_coins",appliedCoins.toString());data.put("suppressed_experience",allocatedExperience.subtract(appliedExperience.min(allocatedExperience)).toString());data.put("suppressed_coins",allocatedCoins.subtract(appliedCoins.min(allocatedCoins)).toString());data.put("before_revision",before.revision().toString());data.put("after_revision",after.revision().toString());data.put("before_level",before.level());data.put("after_level",after.level());data.put("before_experience",before.experience().toString());data.put("after_experience",after.experience().toString());data.put("before_points",before.unspentPoints().toString());data.put("after_points",after.unspentPoints().toString());data.put("balance_before",before.coins().toString());data.put("balance_after",after.coins().toString());data.put("storage_status",award.status().name().toLowerCase(java.util.Locale.ROOT));data.put("levels_gained",award.levels());data.put("native_sync",nativeSync);data.put("feedback",feedback);if(capture.managedOrigin()!=null){data.put("resolution","managed_origin_exempt");data.put("managed_origin_kind",capture.managedOrigin().kind().name().toLowerCase(java.util.Locale.ROOT));data.put("managed_origin_source",capture.managedOrigin().source().toString());data.put("spacing_applicability","authenticated_managed_origin_exempt");if(capture.managedOrigin().kind()==ManagedEncounterOrigin.Kind.DUNGEON&&capture.managedOrigin().source().toString().equals(capture.managedEvidence().get("managed_source_id")))data.putAll(capture.managedEvidence());}else if(restricted!=null){boolean blocked=award.status()==CharacterStorage.CombatAwardStatus.SPACING_BLOCKED;data.put("resolution",blocked?"ordinary_spacing_blocked":"ordinary_spacing_eligible");data.put("spacing_applicability","ordinary_restricted_kill");data.put("spacing_minimum",rules.restrictedSpacing().toPlainString());var prior=before.restrictedKills().get(restricted.world());if(prior!=null)data.put("ordinary_history_before",position(prior));var current=after.restrictedKills().get(restricted.world());if(current!=null)data.put("ordinary_history_after",position(current));}else{data.put("resolution","spacing_not_applicable");data.put("spacing_applicability","passive_or_fantasy_character");}data.put("loss_reason",loss(award.status()));var context=context(active,player,after.name());data.put("location_capture_quality",context==null||context.location()==null?"unavailable":"exact");try{var event=new EventInput(id(operation),operation,clock.instant(),"combat.reward_resolved",context,Map.copyOf(data));if(outbox!=null)outbox.accepted(event);else sink.accept(event);}catch(RuntimeException ignored){}
}
private EventInput.Context context(EquipmentSessions.Active active,Player player,String characterName){
if(active==null||player==null)return null;
try{return new EventInput.Context(active.player(),player.getName(),active.character(),characterName,active.session(),location(player));}
catch(RuntimeException unavailable){return null;}
}
private EventInput.Location location(Player player){
try{
var value=player.getLocation();if(value==null||value.getWorld()==null)return null;
return new EventInput.Location(value.getWorld().getUID(),value.getWorld().getName(),value.getX(),value.getY(),value.getZ(),clock.instant(),"exact");
}catch(RuntimeException unavailable){return null;}
}
private EventInput.Context context(EquipmentSessions.Active active,Player player,String characterName){if(active==null||player==null)return null;var location=player.getLocation();if(location==null||location.getWorld()==null)return new EventInput.Context(active.player(),player.getName(),active.character(),characterName,active.session(),null);return new EventInput.Context(active.player(),player.getName(),active.character(),characterName,active.session(),new EventInput.Location(location.getWorld().getUID(),location.getWorld().getName(),location.getX(),location.getY(),location.getZ(),clock.instant(),"exact"));}
private static Map<String,Object> position(CombatRewardRules.KillPosition value){return Map.of("world_id",value.world().toString(),"x",value.x().toPlainString(),"y",value.y().toPlainString(),"z",value.z().toPlainString());}
private static String loss(CharacterStorage.CombatAwardStatus status){return switch(status){case SPACING_BLOCKED->"restricted_kill_spacing";case AT_CAP->"maximum_level";case UNCHANGED->"zero_experience_and_coins";case DUPLICATE->"duplicate_receipt";case UNAVAILABLE->"recipient_unavailable";case STALE->"stale_retry";case APPLIED->"none";};}
private static UUID id(UUID operation){return UUID.nameUUIDFromBytes(("fantasystick:combat-reward:"+operation).getBytes(StandardCharsets.UTF_8));}
@@ -64,6 +64,7 @@ class CombatExperienceNativeTest {
assertTrue(actor.messages.stream().anyMatch(message -> message.contains("too close")));
assertEquals(new java.math.BigDecimal("1.0"), blocked.restrictedKills().values().iterator().next().x(),
"Blocked attempts still advance the spacing reference");
awaitRewardCount(f,data,2);
}
var records=events(data).stream().filter(event->event.get("type").getAsString().endsWith("combat.reward_resolved")).toList();assertEquals(2,records.size());var allowed=records.get(0).getAsJsonObject("data");var blocked=records.get(1).getAsJsonObject("data");assertEquals(recipient.toString(),allowed.get("character_id").getAsString());assertEquals("ordinary_spacing_eligible",allowed.get("resolution").getAsString());assertEquals("10",allowed.get("allocated_coins").getAsString());assertEquals("10",allowed.get("applied_coins").getAsString());assertEquals("10",allowed.get("experience_pool").getAsString());assertEquals("1",allowed.get("reward_unit_count").getAsString());assertEquals(java.util.List.of(recipient.toString()),allowed.getAsJsonArray("direct_contributor_character_ids").asList().stream().map(com.google.gson.JsonElement::getAsString).toList());assertTrue(allowed.has("before_points"));assertTrue(allowed.has("after_points"));assertEquals("ordinary_spacing_blocked",blocked.get("resolution").getAsString());assertEquals("10",blocked.get("allocated_coins").getAsString());assertEquals("0",blocked.get("applied_coins").getAsString());assertEquals("spacing_refusal",blocked.get("feedback").getAsString());assertEquals(worldId.toString(),blocked.getAsJsonObject("ordinary_history_after").get("world_id").getAsString());assertEquals("1.0",blocked.getAsJsonObject("ordinary_history_after").get("x").getAsString());
}
@@ -75,6 +76,7 @@ class CombatExperienceNativeTest {
for(var kind:games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.values()){
var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);f.activate(actor);combat.attachManagedOrigins(entity->java.util.Optional.of(new games.dmg.fantasystick.progression.ManagedEncounterOrigin(kind,UUID.nameUUIDFromBytes(entity.getUniqueId().toString().getBytes(java.nio.charset.StandardCharsets.UTF_8)))));var world=mock(org.bukkit.World.class);when(world.getUID()).thenReturn(UUID.randomUUID());var before=record(f,actor);var first=mob(EntityType.ZOMBIE,new Location(world,0,64,0));hit(f,actor.player,first,false);die(f,first);settle(f);var second=mob(EntityType.ZOMBIE,new Location(world,1,64,0));hit(f,actor.player,second,false);die(f,second);settle(f);var saved=record(f,actor);assertTrue(saved.experience().compareTo(before.experience())>0,kind.toString());assertEquals(before.coins().add(java.math.BigInteger.valueOf(20)),saved.coins(),kind.toString());assertEquals(before.restrictedKills(),saved.restrictedKills(),kind+" never consults or advances ordinary spacing history");
}
awaitRewardCount(f,data,6);
}
var records=events(data).stream().filter(event->event.get("type").getAsString().endsWith("combat.reward_resolved")).map(event->event.getAsJsonObject("data")).toList();assertEquals(6,records.size());for(var kind:games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.values()){var matching=records.stream().filter(event->event.get("managed_origin_kind").getAsString().equals(kind.name().toLowerCase(java.util.Locale.ROOT))).toList();assertEquals(2,matching.size(),kind.toString());for(var event:matching){assertEquals("managed_origin_exempt",event.get("resolution").getAsString());assertEquals("10",event.get("allocated_coins").getAsString());assertEquals("10",event.get("applied_coins").getAsString());assertFalse(event.has("ordinary_history_before"));assertFalse(event.has("ordinary_history_after"));assertTrue(event.has("managed_origin_source"));}}
}
@@ -84,8 +86,9 @@ class CombatExperienceNativeTest {
UUID authority=UUID.randomUUID();
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var combat=(games.dmg.fantasystick.party.CombatExperience)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"combatExperience");var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);f.activate(actor);combat.attachManagedOrigins(entity->java.util.Optional.of(new games.dmg.fantasystick.progression.ManagedEncounterOrigin(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON,authority)));var world=mock(org.bukkit.World.class);when(world.getUID()).thenReturn(UUID.randomUUID());var before=record(f,actor);for(int attempt=0;attempt<2;attempt++){var target=mob(EntityType.ZOMBIE,new Location(world,0,64,0));hit(f,actor.player,target,false);die(f,target);settle(f);}var saved=record(f,actor);assertEquals(before.experience().add(java.math.BigInteger.TEN),saved.experience());assertEquals(before.coins().add(java.math.BigInteger.TEN),saved.coins());assertEquals(before.restrictedKills(),saved.restrictedKills());
awaitRewardCount(f,data,1);
}
var records=events(data).stream().filter(event->event.get("type").getAsString().endsWith("combat.reward_resolved")&&event.getAsJsonObject("data").get("managed_origin_source").getAsString().equals(authority.toString())).toList();assertEquals(2,records.size());assertEquals(1,records.stream().map(event->event.get("id").getAsString()).distinct().count());assertEquals(java.util.Set.of("applied","duplicate"),records.stream().map(event->event.getAsJsonObject("data").get("storage_status").getAsString()).collect(java.util.stream.Collectors.toSet()));
var records=events(data).stream().filter(event->event.get("type").getAsString().endsWith("combat.reward_resolved")&&event.getAsJsonObject("data").get("managed_origin_source").getAsString().equals(authority.toString())).toList();assertEquals(1,records.size(),"A duplicate managed reward receipt cannot emit a second conflicting resolution");assertEquals(1,records.stream().map(event->event.get("id").getAsString()).distinct().count());assertEquals("applied",records.getFirst().getAsJsonObject("data").get("storage_status").getAsString());
}
@Test void everyRestrictedMobCoinClassUsesTheExistingExactAndCrossWorldSpacingGeometry() throws Exception {
@@ -161,6 +164,7 @@ class CombatExperienceNativeTest {
assertEquals(new java.math.BigDecimal("1.0"), afterB.restrictedKills().get(world.getUID()).x());
assertTrue(a.messages.stream().anyMatch(message -> message.contains("too close")));
assertTrue(b.messages.stream().anyMatch(message -> message.contains("10 character coins")));
awaitRewardCount(f,data,3);
}
var records=events(data).stream().filter(event->event.get("type").getAsString().endsWith("combat.reward_resolved")&&event.getAsJsonObject("data").get("target_id").getAsString().equals(sharedTarget.toString())).toList();assertEquals(2,records.size());var byCharacter=records.stream().collect(java.util.stream.Collectors.toMap(event->event.getAsJsonObject("data").get("recipient_character_id").getAsString(),event->event.getAsJsonObject("data")));assertEquals("ordinary_spacing_blocked",byCharacter.get(characterA.toString()).get("resolution").getAsString());assertEquals("0",byCharacter.get(characterA.toString()).get("applied_coins").getAsString());assertEquals("ordinary_spacing_eligible",byCharacter.get(characterB.toString()).get("resolution").getAsString());assertEquals("10",byCharacter.get(characterB.toString()).get("applied_coins").getAsString());assertEquals(2,records.stream().map(event->event.get("id").getAsString()).distinct().count());
}
@@ -253,6 +257,21 @@ class CombatExperienceNativeTest {
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(target, source, new ArrayList<>(), 17));
PartyBookNativeTest.drain(f);
assertEquals(saved, storage.find(a.core.record.id()).get().orElseThrow(), "One defeated target awards only once");
awaitRewardCount(f,data,1);
var reward=CombatRewardEventsNativeTest.rewardRows(data).getFirst().getAsJsonObject("data");
assertEquals(target.getUniqueId().toString(),reward.get("target_id").getAsString());
assertEquals(a.core.record.id().toString(),reward.get("recipient_character_id").getAsString());
assertEquals(java.util.List.of(a.core.record.id().toString()),reward.getAsJsonArray("direct_contributor_character_ids")
.asList().stream().map(com.google.gson.JsonElement::getAsString).toList());
assertTrue(saved.rewardReceipts().contains(UUID.fromString(reward.get("operation_id").getAsString())));
assertEquals("boss",reward.get("experience_class").getAsString());
assertEquals("100",reward.get("experience_pool").getAsString());
assertEquals("100",reward.get("applied_experience").getAsString());
assertEquals(bossCoins.toString(),reward.get("applied_coins").getAsString());
assertEquals(1,reward.get("before_level").getAsInt());assertEquals(2,reward.get("after_level").getAsInt());
assertEquals("40",reward.get("before_points").getAsString());assertEquals("41",reward.get("after_points").getAsString());
assertEquals("acknowledged",reward.get("native_sync").getAsString());
assertEquals("experience_and_coins",reward.get("feedback").getAsString());
}
}
@@ -392,6 +411,7 @@ class CombatExperienceNativeTest {
assertEquals(before.experience(),after.experience());
assertEquals(before.coins(),after.coins());
assertEquals(new java.math.BigDecimal("1.0"),after.restrictedKills().get(worldId).x());
awaitRewardCount(f,data,2);
}
var reloaded=new games.dmg.fantasystick.persistence.YamlCharacterRepository(data)
.load(character).orElseThrow();
@@ -434,6 +454,7 @@ class CombatExperienceNativeTest {
assertEquals(initial.experience().add(java.math.BigInteger.valueOf(3)),record(f,actor).experience());
assertEquals(new java.math.BigDecimal("1.0"),record(f,actor).restrictedKills().get(worldId).x());
assertEquals(initial.coins(),record(f,actor).coins(),"passives award no coins");
awaitRewardCount(f,directory,4);
f.plugin.setEnabled(false);
}
var records=events(directory).stream().filter(event->event.get("type").getAsString()
@@ -470,6 +491,7 @@ class CombatExperienceNativeTest {
assertEquals(before.restrictedKills(),after.restrictedKills(),kind+" "+category);
if(category==EntityType.COW)assertEquals(before.coins(),after.coins());
}
awaitRewardCount(f,data,18);
}
var evidence=events(data).stream().filter(event->event.get("type").getAsString()
.endsWith("combat.reward_resolved")).map(event->event.getAsJsonObject("data")).toList();
@@ -666,11 +688,12 @@ class CombatExperienceNativeTest {
assertEquals(java.math.BigInteger.ZERO, record(f, b).coins(), "PvP defeat binds and zeroes the full saved balance");
f.dispatcher.accept(defeat); settle(f);
assertEquals(java.math.BigInteger.TEN, record(f, a).experience(), "Repeated intercepted callback cannot award twice");
awaitRewardCount(f,second,1);
}
var rewards=events(second).stream().filter(event->event.get("type").getAsString().endsWith("combat.reward_resolved")).toList();assertEquals(1,rewards.size(),"Initial downing and repeated callbacks emit no extra reward resolution");var reward=rewards.getFirst().getAsJsonObject("data");assertEquals("final_death",reward.get("stage").getAsString());assertEquals(finalVictimPlayer.toString(),reward.get("target_id").getAsString());assertEquals("10",reward.get("experience_pool").getAsString());assertEquals("10",reward.get("allocated_experience").getAsString());assertEquals("10",reward.get("applied_experience").getAsString());assertEquals(finalVictimCoins.toString(),reward.get("coin_pool").getAsString());assertEquals(finalVictimCoins.toString(),reward.get("allocated_coins").getAsString());assertEquals(finalVictimCoins.toString(),reward.get("applied_coins").getAsString());assertEquals(finalAttackerCoins.toString(),reward.get("balance_before").getAsString());assertEquals(finalAttackerCoins.add(finalVictimCoins).toString(),reward.get("balance_after").getAsString());assertEquals("spacing_not_applicable",reward.get("resolution").getAsString());assertEquals(java.util.List.of(finalAttacker.toString()),reward.getAsJsonArray("direct_contributor_character_ids").asList().stream().map(com.google.gson.JsonElement::getAsString).toList());assertTrue(reward.has("reward_unit_id"));var losses=events(second).stream().filter(event->event.get("type").getAsString().endsWith("character.coins_lost")).toList();assertEquals(1,losses.size());var loss=losses.getFirst().getAsJsonObject("data");assertEquals(deathEpisode.toString(),loss.get("death_id").getAsString());assertEquals(finalVictimCoins.toString(),loss.get("balance_before").getAsString());assertEquals("0",loss.get("balance_after").getAsString());assertEquals(finalVictimCoins.toString(),loss.get("death_bound_escrow").getAsString());assertEquals("player_attributed_final_death",loss.get("reason").getAsString());var returns=events(second).stream().filter(event->event.get("type").getAsString().endsWith("action.resolved")&&event.getAsJsonObject("data").has("action_kind")&&event.getAsJsonObject("data").get("action_kind").getAsString().equals("final_death_return")).toList();assertEquals(1,returns.size());assertEquals(deathEpisode.toString(),returns.getFirst().getAsJsonObject("data").get("death_id").getAsString());assertEquals("returned",returns.getFirst().getAsJsonObject("data").get("outcome").getAsString());
}
private static EntityDamageByEntityEvent hit(FantasyStickLifecycleNativeTest.Fixture f, org.bukkit.entity.Entity source,
static EntityDamageByEntityEvent hit(FantasyStickLifecycleNativeTest.Fixture f, org.bukkit.entity.Entity source,
org.bukkit.entity.Entity target, boolean cancelled) {
var damage = mock(org.bukkit.damage.DamageSource.class);
when(damage.getCausingEntity()).thenReturn(source);
@@ -679,29 +702,35 @@ class CombatExperienceNativeTest {
event.setCancelled(cancelled); f.dispatcher.accept(event); return event;
}
private static void die(FantasyStickLifecycleNativeTest.Fixture f, LivingEntity target) {
static void die(FantasyStickLifecycleNativeTest.Fixture f, LivingEntity target) {
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(target,
mock(org.bukkit.damage.DamageSource.class), new ArrayList<>(), 0));
}
private static void settle(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
static void settle(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
}
private static games.dmg.fantasystick.persistence.CharacterRecord record(FantasyStickLifecycleNativeTest.Fixture f,
static games.dmg.fantasystick.persistence.CharacterRecord record(FantasyStickLifecycleNativeTest.Fixture f,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
return storage.find(actor.core.record.id()).get().orElseThrow();
}
private static java.util.List<com.google.gson.JsonObject> events(Path data)throws Exception{var records=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())records.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}return records;}
static void awaitRewardCount(FantasyStickLifecycleNativeTest.Fixture f,Path data,int expected)throws Exception{
ReskillEventsNativeTest.await(f,()->CombatRewardEventsNativeTest.rewardRows(data).size()==expected);
}
private static java.util.List<com.google.gson.JsonObject> events(Path data)throws Exception{
return ReskillEventsNativeTest.records(data);
}
private static LivingEntity type(EntityType type) {
var entity = mock(LivingEntity.class); when(entity.getType()).thenReturn(type); return entity;
}
private static LivingEntity mob(EntityType type, Location location) {
static LivingEntity mob(EntityType type, Location location) {
var entity = mock(LivingEntity.class);
when(entity.getUniqueId()).thenReturn(UUID.randomUUID());
when(entity.getType()).thenReturn(type);
@@ -0,0 +1,167 @@
package games.dmg.fantasystick;
import static games.dmg.fantasystick.CombatExperienceNativeTest.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import games.dmg.fantasystick.events.LocalEventLog;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import java.math.BigInteger;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Properties;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.EntityType;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class CombatRewardEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void rejectedNativePassiveRewardRetainsCapturedPayloadAcrossRestartWithoutGameplayReplay() throws Exception {
CharacterRecord saved;
Properties intent = new Properties();
UUID targetId;
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
((LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog"))
.ready().toCompletableFuture().get(5, TimeUnit.SECONDS);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.activate(a);
var before = record(f, a);
var writer = (LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog");
writer.close();
var world = mock(World.class); var worldId = UUID.randomUUID(); when(world.getUID()).thenReturn(worldId);
var target = mob(EntityType.COW, new Location(world, 12, 70, -8)); targetId = target.getUniqueId();
hit(f, a.player, target, false); die(f, target); settle(f);
saved = record(f, a);
assertEquals(before.experience().add(BigInteger.ONE), saved.experience());
assertEquals(before.coins(), saved.coins());
assertEquals("12.0", saved.restrictedKills().get(worldId).x().toPlainString());
when(target.getLocation()).thenReturn(new Location(world, 900, 4, 900));
die(f, target); settle(f); assertEquals(saved, record(f, a));
f.plugin.setEnabled(false); f.awaitWorkers();
var folder = data.resolve("combat-reward-event-intents");
assertTrue(Files.isDirectory(folder), "Rejected acknowledged native reward must retain a durable audit intent");
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "combatRewardAuditDrain"))
.toCompletableFuture().get(5, TimeUnit.SECONDS);
try (var files = Files.list(folder)) {
var pending = files.filter(p -> p.getFileName().toString().endsWith(".pending")).toList();
assertEquals(1, pending.size());
try (var input = Files.newInputStream(pending.getFirst())) { intent.load(input); }
}
assertTrue(rewards().isEmpty());
// Offline audit replay is independent of legitimate online session re-entry.
f.players.clear(); clearInvocations(a.player, target);
f.plugin.setEnabled(true);
((LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog"))
.ready().toCompletableFuture().get(5, TimeUnit.SECONDS);
ReskillEventsNativeTest.await(f, () -> rewards().size() == 1);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertEquals(saved, storage.find(saved.id()).get().orElseThrow());
verify(a.player, never()).setHealth(anyDouble()); verify(target, never()).damage(anyDouble(), any(org.bukkit.entity.Entity.class));
f.plugin.setEnabled(false); f.awaitWorkers();
}
var rows = rewards(); assertEquals(1, rows.size()); var row = rows.getFirst(); var values = row.getAsJsonObject("data");
assertEquals(intent.getProperty("id"), row.get("id").getAsString());
assertEquals(intent.getProperty("time"), row.get("time").getAsString());
assertEquals(intent.getProperty("action"), values.get("action_id").getAsString());
assertEquals(targetId.toString(), values.get("target_id").getAsString());
assertEquals("12.0", values.getAsJsonObject("kill_location").get("x").getAsString());
assertEquals("1", values.get("experience_pool").getAsString());
assertEquals("1", values.get("applied_experience").getAsString());
assertEquals("0", values.get("applied_coins").getAsString());
assertEquals("acknowledged", values.get("native_sync").getAsString());
assertEquals(saved.revision().toString(), values.get("after_revision").getAsString());
assertEquals(intent.getProperty("player"), row.get("playerid").getAsString());
assertEquals(intent.getProperty("character"), row.get("characterid").getAsString());
assertEquals(intent.getProperty("session"), values.get("session_id").getAsString());
for (var key : intent.stringPropertyNames()) if (key.startsWith("data.")) {
var encoded = intent.getProperty(key);
var expected = switch (encoded.charAt(0)) {
case 's' -> new com.google.gson.JsonPrimitive(encoded.substring(1));
case 'b' -> new com.google.gson.JsonPrimitive(Boolean.parseBoolean(encoded.substring(1)));
case 'i' -> new com.google.gson.JsonPrimitive(Integer.parseInt(encoded.substring(1)));
case 'd' -> new com.google.gson.JsonPrimitive(Double.parseDouble(encoded.substring(1)));
case 'j' -> com.google.gson.JsonParser.parseString(encoded.substring(1));
default -> throw new AssertionError("Unexpected reward payload encoding: " + key);
};
var parts = key.substring(5).split("\\.");
var actual = parts.length == 1 ? values.get(parts[0]) : values.getAsJsonObject(parts[0]).get(parts[1]);
assertEquals(expected, actual, "Replay retains every captured reward field: " + key);
}
}
@Test void unavailableAuditLocationAndThrowingSinkCannotInterruptSavedRewardOrFeedback() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.activate(a);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
var failures = new java.util.ArrayList<games.dmg.fantasystick.party.CombatExperience.Failure>();
var combat = new games.dmg.fantasystick.party.CombatExperience(storage,
games.dmg.fantasystick.progression.ProgressionRules.defaults(),
games.dmg.fantasystick.progression.CombatRewardRules.defaults(), sessions, java.time.Clock.systemUTC(),
f.tasks::offer, f.players::get, failures::add);
var emitted = new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();
combat.attachEvents(new games.dmg.fantasystick.events.CombatRewardEventBridge(emitted::add, java.time.Clock.systemUTC()));
var before = record(f, a);
var originalWorldName = a.world.getName();
when(a.world.getName()).thenThrow(new IllegalStateException("Audit world metadata unavailable"));
try {
var world = mock(World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
var first = mob(EntityType.COW, new Location(world, 0, 64, 0));
var source = mock(org.bukkit.damage.DamageSource.class);
when(source.getCausingEntity()).thenReturn(a.player); when(source.getDirectEntity()).thenReturn(a.player);
combat.damage(new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, first,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 1));
combat.mobDeath(new org.bukkit.event.entity.EntityDeathEvent(first, source, new java.util.ArrayList<>(), 0));
assertDoesNotThrow(() -> settle(f), "Audit metadata cannot interrupt the acknowledged reward path");
var saved = record(f, a);
assertEquals(before.experience().add(BigInteger.ONE), saved.experience());
assertTrue(a.messages.stream().anyMatch(message -> message.contains("Earned 1 fantasy XP")));
assertEquals(1, emitted.size()); var event = emitted.getFirst();
assertEquals(a.core.id, event.context().playerId()); assertEquals(saved.id(), event.context().characterId());
assertNull(event.context().location()); assertEquals("unavailable", event.data().get("location_capture_quality"));
combat.close();
var throwing = new games.dmg.fantasystick.party.CombatExperience(storage,
games.dmg.fantasystick.progression.ProgressionRules.defaults(),
games.dmg.fantasystick.progression.CombatRewardRules.defaults(), sessions, java.time.Clock.systemUTC(),
f.tasks::offer, f.players::get, failures::add);
throwing.attachEvents(new games.dmg.fantasystick.events.CombatRewardEventBridge(
ignored -> { throw new IllegalStateException("Audit sink unavailable"); }, java.time.Clock.systemUTC()));
var second = mob(EntityType.COW, new Location(world, 3, 64, 0));
throwing.damage(new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, second,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 1));
var secondDeath = new org.bukkit.event.entity.EntityDeathEvent(second, source, new java.util.ArrayList<>(), 0);
throwing.mobDeath(secondDeath); assertDoesNotThrow(() -> settle(f));
var after = record(f, a);
assertEquals(saved.experience().add(BigInteger.ONE), after.experience());
assertEquals(saved.coins(), after.coins());
assertEquals(2, a.messages.stream().filter(message -> message.contains("Earned 1 fantasy XP")).count());
throwing.mobDeath(secondDeath); settle(f); assertEquals(after, record(f, a));
throwing.close(); assertTrue(failures.isEmpty());
assertEquals(1, emitted.size());
} finally {
doReturn(originalWorldName).when(a.world).getName(); combat.close();
}
}
}
private List<com.google.gson.JsonObject> rewards() { return rewardRows(data); }
static List<com.google.gson.JsonObject> rewardRows(Path data) {
try {
return ReskillEventsNativeTest.records(data).stream()
.filter(row -> row.get("type").getAsString().endsWith("combat.reward_resolved")).toList();
} catch (Exception failure) { throw new AssertionError("Cannot parse exported reward evidence", failure); }
}
}
@@ -127,6 +127,8 @@ class FireballNativeTest {
PartyBookNativeTest.drain(f);
assertEquals(1, launches.get(), "Exit/resume and control reconstruction preserve the same Fireball deadline");
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("fireball"));
// US-007: await the shared reward export only after the existing timing-sensitive checks.
CombatExperienceNativeTest.awaitRewardCount(f,data,1);
}
var events = events(data);
for (var suffix : java.util.List.of("ability.learned", "ability.assigned", "ability.cast",
@@ -153,6 +155,15 @@ class FireballNativeTest {
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("fireball")).findFirst().orElseThrow();
assertEquals("7.0", damage.getAsJsonObject("data").get("actual_amount").getAsString());
var rewards=CombatRewardEventsNativeTest.rewardRows(data);assertEquals(1,rewards.size());
var reward=rewards.getFirst().getAsJsonObject("data");
assertEquals(damage.getAsJsonObject("data").get("target_entity_id").getAsString(),reward.get("target_id").getAsString());
assertEquals(castEvent.get("characterid").getAsString(),reward.get("recipient_character_id").getAsString());
assertEquals(java.util.List.of(castEvent.get("characterid").getAsString()),reward.getAsJsonArray("direct_contributor_character_ids")
.asList().stream().map(com.google.gson.JsonElement::getAsString).toList());
assertEquals("10",reward.get("experience_pool").getAsString());assertEquals("10",reward.get("applied_experience").getAsString());
assertEquals("39",reward.get("before_points").getAsString());assertEquals("39",reward.get("after_points").getAsString());
assertEquals("acknowledged",reward.get("native_sync").getAsString());
assertEquals(castEvent.getAsJsonObject("data").get("action_id").getAsString(),
damage.getAsJsonObject("data").get("action_id").getAsString());
var steals = events.stream().filter(row -> row.get("type").getAsString().endsWith("combat.healed")
@@ -18,4 +18,23 @@ import org.junit.jupiter.api.Test;
class CombatRewardEventBridgeTest {
@Test void sinkFailureCannotChangeAnAcknowledgedRewardAndIdentityIsStable(){var before=new games.dmg.fantasystick.crafting.CharacterFactory().create(ProgressionRules.defaults());var receipt=UUID.randomUUID();var position=new CombatRewardRules.KillPosition(UUID.randomUUID(),java.math.BigDecimal.ZERO,java.math.BigDecimal.valueOf(64),java.math.BigDecimal.ZERO);var history=new HashMap<>(before.restrictedKills());history.put(position.world(),position);var receipts=new HashSet<>(before.rewardReceipts());receipts.add(receipt);var after=new CharacterRecord(before.id(),before.name(),before.revision().add(BigInteger.ONE),before.stats(),before.level(),before.experience().add(BigInteger.TEN),before.unspentPoints(),before.health(),before.learned(),before.loadout(),before.passives(),before.cooldowns(),history,false,before.deathId(),before.diedAt(),before.coins().add(BigInteger.TEN),true,receipts,before.pvpCoinEscrow(),before.gear(),before.sheetGeneration());var award=new CharacterStorage.CombatAward(CharacterStorage.CombatAwardStatus.APPLIED,Optional.of(after),0);var capture=new CombatRewardEventBridge.Capture(UUID.randomUUID(),"zombie","ordinary","hostile",position,null,"mob_defeat",Instant.parse("2026-09-25T00:00:00Z"),java.util.List.of(before.id()),before.id().toString(),1,BigInteger.TEN,BigInteger.TEN);var bridge=new CombatRewardEventBridge(event->{throw new IllegalStateException("disk");},Clock.fixed(Instant.parse("2026-09-25T00:00:01Z"),ZoneOffset.UTC));assertDoesNotThrow(()->bridge.resolved(receipt,before.id(),BigInteger.TEN,BigInteger.TEN,position,CombatRewardRules.defaults(),capture,before,award,null,null,"not_applicable_offline","not_delivered_offline"));}
@Test void duplicateReceiptCannotEmitAConflictingResolutionUnderTheOriginalEventId() {
var before = new games.dmg.fantasystick.crafting.CharacterFactory().create(ProgressionRules.defaults());
var operation = UUID.randomUUID(); var capturedAt = Instant.parse("2026-09-25T00:00:00Z");
var capture = new CombatRewardEventBridge.Capture(UUID.randomUUID(), "cow", "passive", "passive",
null, null, "mob_defeat", capturedAt, java.util.List.of(before.id()), before.id().toString(),
1, BigInteger.ONE, BigInteger.ZERO);
var emitted = new java.util.ArrayList<EventInput>();
var bridge = new CombatRewardEventBridge(emitted::add, Clock.fixed(capturedAt, ZoneOffset.UTC));
bridge.resolved(operation, before.id(), BigInteger.ONE, BigInteger.ZERO, null, CombatRewardRules.defaults(),
capture, before, new CharacterStorage.CombatAward(CharacterStorage.CombatAwardStatus.APPLIED,
Optional.of(before), 0), null, null, "not_applicable_offline", "not_delivered_offline");
var original = emitted.getFirst();
bridge.resolved(operation, before.id(), BigInteger.ONE, BigInteger.ZERO, null, CombatRewardRules.defaults(),
capture, before, new CharacterStorage.CombatAward(CharacterStorage.CombatAwardStatus.DUPLICATE,
Optional.of(before), 0), null, null, "not_applicable_offline", "none_duplicate");
assertEquals(java.util.List.of(original), emitted,
"A duplicate receipt is not another resolution and cannot replace the original payload");
assertEquals("applied", original.data().get("storage_status"));
}
}