feat(events): retain shared combat reward audit intents
Release / release (push) Failing after 10m57s
Release / release (push) Failing after 10m57s
This commit is contained in:
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"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user