feat(events): record combat reward resolutions
Release / release (push) Successful in 13m58s

This commit is contained in:
dmg committed 2026-09-24 23:31:16 -04:00
1 parent 6428ab999b
commit d18a41af6c
6 files changed
+96 -21

No files matched your search

@@ -173,6 +173,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.events.RecoveryEventBridge recoveryEvents;
private games.dmg.fantasystick.events.ReviveEventBridge reviveEvents;
private games.dmg.fantasystick.events.PartyEventBridge partyAuditEvents;
private games.dmg.fantasystick.events.CombatRewardEventBridge combatRewardEvents;
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
@Override public void onEnable() {
@@ -717,13 +718,14 @@ public final class FantasyStickPlugin extends JavaPlugin {
recoveryEvents = new games.dmg.fantasystick.events.RecoveryEventBridge(eventLog::emit, Clock.systemUTC());outOfCombatRecovery.attachEvents(recoveryEvents,record->record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationI.ID)?quickerRegeneration.bonus():java.math.BigInteger.ZERO);
reviveEvents = new games.dmg.fantasystick.events.ReviveEventBridge(eventLog::emit, Clock.systemUTC());revive.attachEvents(reviveEvents);
partyAuditEvents = new games.dmg.fantasystick.events.PartyEventBridge(eventLog::emit, Clock.systemUTC());partyManagement.attachEvents(partyAuditEvents);admin.attachPartyEvents(partyAuditEvents);
combatRewardEvents = new games.dmg.fantasystick.events.CombatRewardEventBridge(eventLog::emit,Clock.systemUTC());combatExperience.attachEvents(combatRewardEvents);
sessions.attachDownedEvents(downedEvents);
downedController.attachEvents(downedEvents);
revive.attachDownedEvents(downedEvents);
admin.attachDownedEvents(downedEvents);
getServer().getPluginManager().registerEvents(eventCapture, this);
} catch (java.io.IOException failure) {
eventLog = null; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; abilityEvents = null; passiveEvents = null; recoveryEvents = null; reviveEvents = null; partyAuditEvents = null;
eventLog = null; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; abilityEvents = null; passiveEvents = null; recoveryEvents = null; reviveEvents = null; partyAuditEvents = null; combatRewardEvents = null;
getLogger().log(Level.WARNING,"Fantasy Stick local events are unavailable; gameplay remains enabled",failure);
}
getServer().getPluginManager().registerEvents(crafting, this);
@@ -826,7 +828,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
combatFeedback = null;
var closingEvents = sessionEvents; sessionEvents = null;
var closingEventLog = eventLog; eventLog = null;
var closingEventCapture = eventCapture; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; abilityEvents = null; passiveEvents = null; recoveryEvents = null; reviveEvents = null; partyAuditEvents = null;
var closingEventCapture = eventCapture; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; abilityEvents = null; passiveEvents = null; recoveryEvents = null; reviveEvents = null; partyAuditEvents = null; combatRewardEvents = null;
var closingBooks = books; books = null;
var closingPartyEvents = partyEvents; partyEvents = null;
var closingPartyCombat = partyCombat; partyCombat = null;
@@ -0,0 +1,38 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.CombatRewardRules;
import games.dmg.fantasystick.progression.ManagedEncounterOrigin;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.entity.Player;
/** Failure-isolated, single-owner recipient resolution for combat XP and coin shares. */
public final class CombatRewardEventBridge {
public record Capture(UUID targetId,String targetType,String experienceClass,String coinClass,
CombatRewardRules.KillPosition killPosition,ManagedEncounterOrigin managedOrigin,
String stage,Instant defeatedAt){
public Capture{Objects.requireNonNull(targetId);Objects.requireNonNull(targetType);Objects.requireNonNull(experienceClass);Objects.requireNonNull(coinClass);Objects.requireNonNull(stage);Objects.requireNonNull(defeatedAt);}
}
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);}
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());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("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");}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){}
}
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));}
}
@@ -79,6 +79,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
private java.util.function.Predicate<org.bukkit.entity.Entity> excluded = ignored -> false;
private java.util.function.Function<LivingEntity,java.util.Optional<games.dmg.fantasystick.progression.ManagedEncounterOrigin>> managedOrigins=ignored->java.util.Optional.empty();
private Consumer<games.dmg.fantasystick.progression.ManagedEncounterOrigin> managedSettlement=ignored->{};
private games.dmg.fantasystick.events.CombatRewardEventBridge events;
private boolean closed;
private final class PendingAward {
@@ -88,12 +89,14 @@ public final class CombatExperience implements Listener, AutoCloseable {
final BigInteger coins;
final CombatRewardRules.KillPosition restricted;
final CombatRewardRules rewardRules;
BigInteger expectedRevision;
final games.dmg.fantasystick.events.CombatRewardEventBridge.Capture capture;
BigInteger expectedRevision;CharacterRecord before;
boolean running;final CompletableFuture<Void> settled=new CompletableFuture<>();
PendingAward(UUID id, UUID character, BigInteger experience, BigInteger coins,
CombatRewardRules.KillPosition restricted, CombatRewardRules rewardRules) {
CombatRewardRules.KillPosition restricted, CombatRewardRules rewardRules,
games.dmg.fantasystick.events.CombatRewardEventBridge.Capture capture) {
this.id = id; this.character = character; this.experience = experience;
this.coins = coins; this.restricted = restricted; this.rewardRules = rewardRules;
this.coins = coins; this.restricted = restricted; this.rewardRules = rewardRules;this.capture=Objects.requireNonNull(capture);
}
}
@@ -140,6 +143,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
if(closed)throw new IllegalStateException("Combat experience is closed");managedOrigins=Objects.requireNonNull(classifier);
}
public void attachManagedSettlement(Consumer<games.dmg.fantasystick.progression.ManagedEncounterOrigin> settlement){if(closed)throw new IllegalStateException("Combat experience is closed");managedSettlement=Objects.requireNonNull(settlement);}
public void attachEvents(games.dmg.fantasystick.events.CombatRewardEventBridge value){if(closed||events!=null)throw new IllegalStateException("Combat reward events already attached or combat is closed");events=Objects.requireNonNull(value);}
public void install(org.bukkit.plugin.Plugin plugin) {
if (closed || retryTask != null) throw new IllegalStateException("Combat experience is already installed or closed");
@@ -212,7 +216,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
/** Apply only the shares frozen in the winning durable final-death claim. */
public CompletionStage<Void> settleDownedDefeat(games.dmg.fantasystick.session.DownedEpisode episode){
if(episode.finalRewards().isEmpty())return CompletableFuture.completedStage(null);var capturedRewards=rewards;var settlements=new ArrayList<CompletableFuture<Void>>();for(var reward:episode.finalRewards()){UUID receipt=UUID.nameUUIDFromBytes(("downed:"+episode.id()+":"+reward.character()).getBytes(java.nio.charset.StandardCharsets.UTF_8));var proposed=new PendingAward(receipt,reward.character(),reward.experience(),reward.coins(),null,capturedRewards);var existing=pending.putIfAbsent(receipt,proposed);var operation=existing==null?proposed:existing;settlements.add(operation.settled);attempt(operation);}return CompletableFuture.allOf(settlements.toArray(CompletableFuture[]::new));
if(episode.finalRewards().isEmpty())return CompletableFuture.completedStage(null);var capturedRewards=rewards;var settlements=new ArrayList<CompletableFuture<Void>>();for(var reward:episode.finalRewards()){UUID receipt=UUID.nameUUIDFromBytes(("downed:"+episode.id()+":"+reward.character()).getBytes(java.nio.charset.StandardCharsets.UTF_8));var capture=new games.dmg.fantasystick.events.CombatRewardEventBridge.Capture(episode.player(),"player","fantasy_character","pvp_balance",null,null,"final_death",episode.resolvedAt());var proposed=new PendingAward(receipt,reward.character(),reward.experience(),reward.coins(),null,capturedRewards,capture);var existing=pending.putIfAbsent(receipt,proposed);var operation=existing==null?proposed:existing;settlements.add(operation.settled);attempt(operation);}return CompletableFuture.allOf(settlements.toArray(CompletableFuture[]::new));
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
@@ -250,7 +254,8 @@ public final class CombatExperience implements Listener, AutoCloseable {
for (UUID character : xp.keySet()) {
String receiptAuthority=managed.isPresent()?authority:targetReward+":"+now+":"+authority;
UUID receipt = UUID.nameUUIDFromBytes((receiptAuthority+":"+character).getBytes(java.nio.charset.StandardCharsets.UTF_8));
var proposed = new PendingAward(receipt, character, xp.get(character), coins.get(character), restricted,capturedRewards);
var capture=new games.dmg.fantasystick.events.CombatRewardEventBridge.Capture(target.getUniqueId(),target.getType().name().toLowerCase(java.util.Locale.ROOT),kind.name().toLowerCase(java.util.Locale.ROOT),coinTarget(target).name().toLowerCase(java.util.Locale.ROOT),defeatedCharacter==null?position(target.getLocation()):null,managed.orElse(null),defeatedCharacter==null?"mob_defeat":"fantasy_character_defeat",now);
var proposed = new PendingAward(receipt, character, xp.get(character), coins.get(character), restricted,capturedRewards,capture);
var existing=pending.putIfAbsent(receipt,proposed);var operation=existing==null?proposed:existing;settlements.add(operation.settled);attempt(operation);
}
managed.ifPresent(origin->CompletableFuture.allOf(settlements.toArray(CompletableFuture[]::new)).thenRun(()->settled(origin)));
@@ -304,7 +309,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
synchronized (operation) { operation.running = false; }
if (failure != null) { fail(operation, failure); return; }
if (record.isEmpty() || record.orElseThrow().dead()) { complete(operation); return; }
operation.expectedRevision = record.orElseThrow().revision();
operation.before=record.orElseThrow();operation.expectedRevision = operation.before.revision();
attempt(operation);
});
return;
@@ -314,7 +319,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
synchronized (operation) { operation.running = false; }
if (failure != null) { fail(operation, failure); return; }
if (award.status() == CharacterStorage.CombatAwardStatus.STALE && award.record().isPresent()) {
operation.expectedRevision = award.record().orElseThrow().revision(); attempt(operation); return;
operation.before=award.record().orElseThrow();operation.expectedRevision = operation.before.revision(); attempt(operation); return;
}
complete(operation);
if (award.record().isEmpty() || award.status() == CharacterStorage.CombatAwardStatus.UNAVAILABLE) return;
@@ -344,12 +349,9 @@ public final class CombatExperience implements Listener, AutoCloseable {
if (closed) return;
CharacterRecord saved = award.record().orElseThrow();
var active = sessions.activeCharacter(operation.character).orElse(null);
if (active != null && !sessions.acknowledgeProgress(active, saved.revision().subtract(BigInteger.ONE), saved)
&& !sessions.currentRecord(active).filter(saved::equals).isPresent()) {
report(operation.character, new IllegalStateException("Saved combat progress could not synchronize with active session"));
return;
}
Player player = active == null ? null : players.apply(active.player());
boolean synchronizedSession=active==null||sessions.acknowledgeProgress(active,saved.revision().subtract(BigInteger.ONE),saved)||sessions.currentRecord(active).filter(saved::equals).isPresent();
Player player=active==null?null:players.apply(active.player());String feedback=feedback(operation,award,player,synchronizedSession);var bridge=events;if(bridge!=null)bridge.resolved(operation.id,operation.character,operation.experience,operation.coins,operation.restricted,operation.rewardRules,operation.capture,operation.before,award,active,player,synchronizedSession?active==null?"not_applicable_offline":"acknowledged":"failed",feedback);
if(!synchronizedSession){report(operation.character,new IllegalStateException("Saved combat progress could not synchronize with active session"));return;}
if (player == null || !player.isOnline()) return;
if (award.status() == CharacterStorage.CombatAwardStatus.APPLIED) {
if (operation.experience.signum() > 0) player.sendMessage("Earned " + operation.experience + " fantasy XP.");
@@ -373,6 +375,8 @@ public final class CombatExperience implements Listener, AutoCloseable {
}
}
private static String feedback(PendingAward operation,CharacterStorage.CombatAward award,Player player,boolean synchronizedSession){if(!synchronizedSession)return "suppressed_native_sync_failed";if(player==null||!player.isOnline())return "not_delivered_offline";return switch(award.status()){case APPLIED->operation.experience.signum()>0&&operation.coins.signum()>0?"experience_and_coins":operation.experience.signum()>0?"experience":operation.coins.signum()>0?"coins":"none";case SPACING_BLOCKED->"spacing_refusal";case AT_CAP->operation.coins.signum()>0?"maximum_level_and_coins":"maximum_level";case UNCHANGED->"zero_reward";case DUPLICATE->"none_duplicate";case STALE,UNAVAILABLE->"none_unavailable";};}
public void configure(CombatRewardRules updated) {
if (closed) throw new IllegalStateException("Combat rewards are closed");
rewards = Objects.requireNonNull(updated);
@@ -42,11 +42,12 @@ class CombatExperienceNativeTest {
@Test void tooCloseHostileDeathDiscardsItsAllocatedCoinsAndPublishesNoCoinFeedback() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"hostile-coin-drop-min: 10\nhostile-coin-drop-max: 10\n");
UUID recipient,worldId;
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(actor.core.id, actor.player); f.activate(actor);
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
f.players.put(actor.core.id, actor.player); f.activate(actor);recipient=actor.core.record.id();
var world = mock(org.bukkit.World.class); worldId=UUID.randomUUID();when(world.getUID()).thenReturn(worldId);
var first = mob(EntityType.ZOMBIE, new Location(world, 0, 64, 0));
hit(f, actor.player, first, false); die(f, first); settle(f);
var eligible = record(f, actor);
@@ -64,6 +65,7 @@ class CombatExperienceNativeTest {
assertEquals(new java.math.BigDecimal("1.0"), blocked.restrictedKills().values().iterator().next().x(),
"Blocked attempts still advance the spacing reference");
}
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("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());
}
@Test void everyManagedEncounterOriginWaivesDistanceWithoutChangingRestrictedHistory() throws Exception {
@@ -74,13 +76,16 @@ class CombatExperienceNativeTest {
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");
}
}
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"));}}
}
@Test void repeatedManagedKillAuthorityCannotPayTwice() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),"hostile-coin-drop-min: 10\nhostile-coin-drop-max: 10\n");
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);var authority=UUID.randomUUID();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());
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());
}
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()));
}
@Test void everyRestrictedMobCoinClassUsesTheExistingExactAndCrossWorldSpacingGeometry() throws Exception {
@@ -132,11 +137,12 @@ class CombatExperienceNativeTest {
@Test void partyRecipientsApplyIndependentSpacingWithoutRedistributingBlockedShares() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"hostile-coin-drop-min: 20\nhostile-coin-drop-max: 20\n");
UUID characterA,characterB,sharedTarget;
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var b = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player); f.activate(a); f.activate(b);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player); f.activate(a); f.activate(b);characterA=a.core.record.id();characterB=b.core.record.id();
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
var history = mob(EntityType.ZOMBIE, new Location(world, 0, 64, 0));
hit(f, a.player, history, false); die(f, history); settle(f);
@@ -144,7 +150,7 @@ class CombatExperienceNativeTest {
var parties = PartyLifecycleNativeTest.parties(f); var party = parties.create(a.core.id).orElseThrow();
assertTrue(parties.accept(b.core.id, parties.invite(a.core.id, party.id(), b.core.id).orElseThrow().id()));
var shared = mob(EntityType.ZOMBIE, new Location(world, 1, 64, 0));
var shared = mob(EntityType.ZOMBIE, new Location(world, 1, 64, 0));sharedTarget=shared.getUniqueId();
hit(f, a.player, shared, false); die(f, shared); settle(f); settle(f);
var afterA = record(f, a); var afterB = record(f, b);
assertEquals(beforeA.experience(), afterA.experience());
@@ -156,6 +162,7 @@ class CombatExperienceNativeTest {
assertTrue(a.messages.stream().anyMatch(message -> message.contains("too close")));
assertTrue(b.messages.stream().anyMatch(message -> message.contains("10 character coins")));
}
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());
}
@Test void packagedWitchPoolFlowsThroughExactSoloAndPartyAllocationWithOrdinaryCoins() throws Exception {
@@ -524,6 +531,8 @@ class CombatExperienceNativeTest {
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;}
private static LivingEntity type(EntityType type) {
var entity = mock(LivingEntity.class); when(entity.getType()).thenReturn(type); return entity;
}
@@ -1405,7 +1405,8 @@ class FantasyStickLifecycleNativeTest {
}
void activate(Actor actor) throws Exception {
changed(actor); tick(); tick(); tick();
assertTrue(actor.core.marker().endsWith("|PACKED"));
for(int attempt=0;attempt<80&&(actor.core.marker()==null||!actor.core.marker().endsWith("|PACKED"));attempt++)tick();
assertNotNull(actor.core.marker());assertTrue(actor.core.marker().endsWith("|PACKED"));
}
private void captureWorkers(JavaPlugin instance) {
for (String name : List.of("characters", "receipts", "adventureSettings", "revivals", "trainingDummies",
@@ -0,0 +1,21 @@
package games.dmg.fantasystick.events;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.CombatRewardRules;
import games.dmg.fantasystick.progression.ProgressionRules;
import java.math.BigInteger;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Optional;
import java.util.UUID;
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"));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"));}
}