diff --git a/src/main/java/games/dmg/fantasystick/events/CombatRewardEventBridge.java b/src/main/java/games/dmg/fantasystick/events/CombatRewardEventBridge.java index 651f36c..10d6e24 100644 --- a/src/main/java/games/dmg/fantasystick/events/CombatRewardEventBridge.java +++ b/src/main/java/games/dmg/fantasystick/events/CombatRewardEventBridge.java @@ -22,7 +22,15 @@ public final class CombatRewardEventBridge implements AutoCloseable { CombatRewardRules.KillPosition killPosition,ManagedEncounterOrigin managedOrigin, String stage,Instant defeatedAt,java.util.List directContributors, String rewardUnitId,int rewardUnitCount,BigInteger experiencePool,BigInteger coinPool, - Map managedEvidence){ + Map managedEvidence,java.util.List beneficiaries){ + public Capture(UUID targetId,String targetType,String experienceClass,String coinClass, + CombatRewardRules.KillPosition killPosition,ManagedEncounterOrigin managedOrigin, + String stage,Instant defeatedAt,java.util.List directContributors, + String rewardUnitId,int rewardUnitCount,BigInteger experiencePool,BigInteger coinPool, + Map managedEvidence){ + this(targetId,targetType,experienceClass,coinClass,killPosition,managedOrigin,stage,defeatedAt, + directContributors,rewardUnitId,rewardUnitCount,experiencePool,coinPool,managedEvidence,java.util.List.of()); + } public Capture(UUID targetId,String targetType,String experienceClass,String coinClass, CombatRewardRules.KillPosition killPosition,ManagedEncounterOrigin managedOrigin, String stage,Instant defeatedAt,java.util.List directContributors, @@ -30,7 +38,7 @@ public final class CombatRewardEventBridge implements AutoCloseable { this(targetId,targetType,experienceClass,coinClass,killPosition,managedOrigin,stage,defeatedAt, directContributors,rewardUnitId,rewardUnitCount,experiencePool,coinPool,Map.of()); } - 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");} + public Capture{beneficiaries=java.util.List.copyOf(beneficiaries);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 sink;private final Clock clock; private final FeatureEventOutbox outbox; @@ -47,7 +55,7 @@ public final class CombatRewardEventBridge implements AutoCloseable { 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||award.status()==CharacterStorage.CombatAwardStatus.DUPLICATE)return;var after=award.record().orElseThrow();var data=new LinkedHashMap();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("target_entity_type",capture.targetType().toUpperCase(java.util.Locale.ROOT));data.put("reward_class",capture.experienceClass());data.put("classifier_version","native_entity_type_v1");data.put("classifier_evidence",Map.of("native_entity_type",capture.targetType().toUpperCase(java.util.Locale.ROOT),"selected_experience_class",capture.experienceClass(),"capture_stage",capture.stage()));if("hard".equals(capture.experienceClass()))data.put("hard_mob_xp",capture.experiencePool().toString());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){} + if(award==null||award.record().isEmpty()||before==null||award.status()==CharacterStorage.CombatAwardStatus.DUPLICATE)return;var after=award.record().orElseThrow();var data=new LinkedHashMap();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("target_entity_type",capture.targetType().toUpperCase(java.util.Locale.ROOT));data.put("reward_class",capture.experienceClass());data.put("classifier_version","native_entity_type_v1");data.put("classifier_evidence",Map.of("native_entity_type",capture.targetType().toUpperCase(java.util.Locale.ROOT),"selected_experience_class",capture.experienceClass(),"capture_stage",capture.stage()));if("hard".equals(capture.experienceClass()))data.put("hard_mob_xp",capture.experiencePool().toString());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("beneficiary_capture_quality",capture.beneficiaries().isEmpty()?"unavailable":"exact");if(!capture.beneficiaries().isEmpty())data.put("reward_unit_beneficiary_character_ids",capture.beneficiaries().stream().map(UUID::toString).sorted().toList());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));spacingEvidence(data,prior,restricted,current,rules,blocked);}else{data.put("resolution","spacing_not_applicable");data.put("spacing_applicability","fantasy_character");}var experienceLoss=allocatedExperience.compareTo(appliedExperience)>0?(award.status()==CharacterStorage.CombatAwardStatus.SPACING_BLOCKED?"restricted_kill_spacing":"maximum_level"):"none";var coinLoss=allocatedCoins.compareTo(appliedCoins)>0?loss(award.status()):"none";data.put("experience_loss_reason",experienceLoss);data.put("coin_loss_reason",coinLoss);data.put("loss_reason",!experienceLoss.equals("none")?experienceLoss: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; @@ -60,6 +68,20 @@ public final class CombatRewardEventBridge implements AutoCloseable { 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 static void spacingEvidence(Map data,CombatRewardRules.KillPosition prior, + CombatRewardRules.KillPosition captured,CombatRewardRules.KillPosition saved, + CombatRewardRules rules,boolean blocked){ + var thresholdSquared=rules.restrictedSpacing().multiply(rules.restrictedSpacing()); + data.put("spacing_first_kill",prior==null); + data.put("spacing_eligible",!blocked); + data.put("spacing_threshold_squared",thresholdSquared.toPlainString()); + data.put("ordinary_history_updated",captured.equals(saved)); + if(prior==null){data.put("spacing_result","first_kill");return;} + var dx=captured.x().subtract(prior.x());var dy=captured.y().subtract(prior.y());var dz=captured.z().subtract(prior.z()); + var distanceSquared=dx.multiply(dx).add(dy.multiply(dy)).add(dz.multiply(dz)); + data.put("spacing_distance_squared",distanceSquared.toPlainString()); + data.put("spacing_result",blocked?"too_close":distanceSquared.compareTo(thresholdSquared)==0?"exact_threshold":"beyond_threshold"); + } private static Map 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));} diff --git a/src/main/java/games/dmg/fantasystick/party/CombatExperience.java b/src/main/java/games/dmg/fantasystick/party/CombatExperience.java index daae3df..6ef2b8d 100644 --- a/src/main/java/games/dmg/fantasystick/party/CombatExperience.java +++ b/src/main/java/games/dmg/fantasystick/party/CombatExperience.java @@ -218,7 +218,7 @@ public final class CombatExperience implements Listener, AutoCloseable { /** Apply only the shares frozen in the winning durable final-death claim. */ public CompletionStage settleDownedDefeat(games.dmg.fantasystick.session.DownedEpisode episode){ - if(episode.finalRewards().isEmpty())return CompletableFuture.completedStage(null);var capturedRewards=rewards;var settlements=new ArrayList>();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(),episode.attribution().contributors(),reward.unitId(),reward.unitCount(),capturedRewards.pool(CombatRewardRules.Target.FANTASY_CHARACTER,episode.attribution().victimLevel()),episode.attribution().victimCoins());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)); + if(episode.finalRewards().isEmpty())return CompletableFuture.completedStage(null);var capturedRewards=rewards;var settlements=new ArrayList>();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(),episode.attribution().contributors(),reward.unitId(),reward.unitCount(),capturedRewards.pool(CombatRewardRules.Target.FANTASY_CHARACTER,episode.attribution().victimLevel()),episode.attribution().victimCoins(),Map.of(),episode.finalRewards().stream().filter(value->value.unitId().equals(reward.unitId())).map(games.dmg.fantasystick.session.DownedEpisode.Reward::character).toList());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) @@ -260,7 +260,7 @@ 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 unit=units.stream().filter(value->value.recipients().contains(character)).findFirst().orElseThrow();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,direct,String.valueOf(unit.key()),units.size(),experiencePool,coinPool,managedEvidence); + var unit=units.stream().filter(value->value.recipients().contains(character)).findFirst().orElseThrow();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,direct,String.valueOf(unit.key()),units.size(),experiencePool,coinPool,managedEvidence,unit.recipients()); 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); } diff --git a/src/nativeTest/java/games/dmg/fantasystick/CombatSpacingAuditNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/CombatSpacingAuditNativeTest.java new file mode 100644 index 0000000..13aeb54 --- /dev/null +++ b/src/nativeTest/java/games/dmg/fantasystick/CombatSpacingAuditNativeTest.java @@ -0,0 +1,149 @@ +package games.dmg.fantasystick; + +import static games.dmg.fantasystick.CombatExperienceNativeTest.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.google.gson.JsonObject; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.UUID; +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; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +/** Native contribution/death -> durable history/rewards -> parsed single-owner audit. */ +class CombatSpacingAuditNativeTest { + @TempDir Path data; + @BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); } + + @ParameterizedTest + @EnumSource(value = EntityType.class, names = {"COW", "BEE", "ZOMBIE", "WITCH", "WITHER", "WARDEN", "ENDER_DRAGON"}) + void eachOrdinaryCategoryExportsFirstNearExactAndFarThreeDimensionalDecisions(EntityType type) throws Exception { + Files.writeString(data.resolve("config.yml"), "hostile-coin-drop-min: 12\nhostile-coin-drop-max: 12\n" + + "boss-coin-drop-min: 12\nboss-coin-drop-max: 12\n"); + UUID character; + 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); + character = record(f, actor).id(); + var world = mock(World.class); when(world.getUID()).thenReturn(UUID.randomUUID()); + var positions = List.of(new Location(world, 0, 64, 0), new Location(world, 1, 65, 1), + new Location(world, 2, 67, 3), new Location(world, 6, 67, 3)); + var results = List.of("first_kill", "too_close", "exact_threshold", "beyond_threshold"); + var distances = List.of("0", "3.00", "9.00", "16.00"); + for (int i = 0; i < positions.size(); i++) { + var before = record(f, actor); + var target = mob(type, positions.get(i)); + hit(f, actor.player, target, false); die(f, target); settle(f); + var saved = record(f, actor); + awaitRewardCount(f, data, i + 1); + var row = rewards().stream().filter(value -> value.getAsJsonObject("data") + .get("target_id").getAsString().equals(target.getUniqueId().toString())).findFirst().orElseThrow(); + var audit = row.getAsJsonObject("data"); + assertEquals(i != 1, saved.experience().compareTo(before.experience()) > 0); + BigInteger coins = type == EntityType.COW || type == EntityType.BEE || i == 1 + ? BigInteger.ZERO : BigInteger.valueOf(12); + assertEquals(before.coins().add(coins), saved.coins()); + assertEquals(character.toString(), audit.get("recipient_character_id").getAsString()); + assertEquals(saved.revision().toString(), audit.get("after_revision").getAsString()); + assertTrue(saved.rewardReceipts().contains(UUID.fromString(audit.get("reward_receipt").getAsString()))); + assertTrue(audit.has("spacing_result"), "Acknowledged ordinary spacing must expose its actual first/near/exact/far decision"); + assertEquals(results.get(i), audit.get("spacing_result").getAsString()); + assertEquals(i == 0, audit.get("spacing_first_kill").getAsBoolean()); + assertEquals(i != 1, audit.get("spacing_eligible").getAsBoolean()); + assertEquals(i != 0, audit.has("spacing_distance_squared")); + if (i != 0) assertEquals(0, new BigDecimal(distances.get(i)) + .compareTo(new BigDecimal(audit.get("spacing_distance_squared").getAsString()))); + assertEquals("9", audit.get("spacing_threshold_squared").getAsString()); + assertTrue(audit.get("ordinary_history_updated").getAsBoolean(), "Blocked shares still persist the current world history"); + assertEquals(i != 0, audit.has("ordinary_history_before")); + assertEquals(positions.get(i).getY(), audit.getAsJsonObject("ordinary_history_after").get("y").getAsDouble()); + assertEquals(saved.experience().subtract(before.experience()).toString(), audit.get("applied_experience").getAsString()); + assertEquals(coins.toString(), audit.get("applied_coins").getAsString()); + die(f, target); settle(f); assertEquals(saved, record(f, actor)); + } + assertEquals(4, rewards().size(), "Duplicate death callbacks never mint an extra resolution"); + } + assertEquals(4, rewards().stream().map(row -> row.get("id").getAsString()).distinct().count()); + var reloaded = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(character).orElseThrow(); + assertEquals(new BigDecimal("6.0"), reloaded.restrictedKills().values().iterator().next().x()); + } + + @Test void mixedPassiveHistoryPartySharesKeepTheCapturedBeneficiariesAndDiscardOnlyTheBlockedShare() throws Exception { + Files.writeString(data.resolve("config.yml"), "hostile-coin-drop-min: 20\nhostile-coin-drop-max: 20\n"); + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + f.plugin.setEnabled(true); + var a = new FantasyStickLifecycleNativeTest.Actor(data); var b = new FantasyStickLifecycleNativeTest.Actor(data); + for (var actor : List.of(a, b)) { f.players.put(actor.core.id, actor.player); f.activate(actor); } + var world = mock(World.class); when(world.getUID()).thenReturn(UUID.randomUUID()); + var cow = mob(EntityType.COW, new Location(world, 0, 64, 0)); + hit(f, a.player, cow, false); die(f, cow); settle(f); awaitRewardCount(f, data, 1); + 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 beforeA = record(f, a); var beforeB = record(f, b); + var zombie = mob(EntityType.ZOMBIE, new Location(world, 1, 64, 0)); + hit(f, a.player, zombie, false); die(f, zombie); + assertTrue(parties.leave(b.core.id, party.id()), "Later membership cannot retarget captured shares or audit roster"); + settle(f); settle(f); awaitRewardCount(f, data, 3); + assertEquals(beforeA.experience(), record(f, a).experience()); + assertEquals(beforeA.coins(), record(f, a).coins()); + assertEquals(beforeB.experience().add(BigInteger.valueOf(5)), record(f, b).experience()); + assertEquals(beforeB.coins().add(BigInteger.TEN), record(f, b).coins()); + var rows = rewards().stream().filter(row -> row.getAsJsonObject("data").get("target_id") + .getAsString().equals(zombie.getUniqueId().toString())).toList(); + assertEquals(2, rows.size()); + var beneficiaries = List.of(beforeA.id().toString(), beforeB.id().toString()).stream().sorted().toList(); + for (var row : rows) { + var values = row.getAsJsonObject("data"); + assertTrue(values.has("reward_unit_beneficiary_character_ids"), "Each actual recipient resolution must retain its captured unit roster"); + assertEquals(beneficiaries, values.getAsJsonArray("reward_unit_beneficiary_character_ids").asList() + .stream().map(value -> value.getAsString()).toList()); + assertEquals(party.id().toString(), values.get("reward_unit_id").getAsString()); + assertEquals("5", values.get("allocated_experience").getAsString()); + assertEquals("10", values.get("allocated_coins").getAsString()); + boolean blocked = row.get("characterid").getAsString().equals(beforeA.id().toString()); + assertEquals(blocked ? "too_close" : "first_kill", values.get("spacing_result").getAsString()); + assertEquals(blocked ? "restricted_kill_spacing" : "none", values.get("coin_loss_reason").getAsString()); + } + assertEquals(BigInteger.TEN, rows.stream().map(row -> new BigInteger(row.getAsJsonObject("data") + .get("applied_coins").getAsString())).reduce(BigInteger.ZERO, BigInteger::add)); + } + } + + @Test void partialMaximumLevelClampReportsActualExperienceLossWithoutSuppressingPermittedCoins() throws Exception { + Files.writeString(data.resolve("config.yml"), "maximum-level: 2\nxp-per-level: 10\nordinary-xp: 15\n" + + "hostile-coin-drop-min: 12\nhostile-coin-drop-max: 12\n"); + 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 before = record(f, actor); + var world = mock(World.class); when(world.getUID()).thenReturn(UUID.randomUUID()); + var zombie = mob(EntityType.ZOMBIE, new Location(world, 0, 64, 0)); + hit(f, actor.player, zombie, false); die(f, zombie); settle(f); awaitRewardCount(f, data, 1); + var saved = record(f, actor); assertEquals(2, saved.level()); assertEquals(BigInteger.TEN, saved.experience()); + assertEquals(before.coins().add(BigInteger.valueOf(12)), saved.coins()); + var values = rewards().getFirst().getAsJsonObject("data"); + assertEquals("15", values.get("allocated_experience").getAsString()); + assertEquals("10", values.get("applied_experience").getAsString()); + assertEquals("5", values.get("suppressed_experience").getAsString()); + assertEquals("12", values.get("applied_coins").getAsString()); + assertTrue(values.has("experience_loss_reason"), "A partial cap must expose its real XP loss despite APPLIED storage status"); + assertEquals("maximum_level", values.get("experience_loss_reason").getAsString()); + assertEquals("maximum_level", values.get("loss_reason").getAsString()); + assertEquals("none", values.get("coin_loss_reason").getAsString()); + actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, actor); + assertTrue(PartyBookNativeTest.text(actor).contains("Level: 2")); + } + } + + private List rewards() { return CombatRewardEventsNativeTest.rewardRows(data); } +} diff --git a/src/nativeTest/java/games/dmg/fantasystick/CombatSpacingBoundaryNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/CombatSpacingBoundaryNativeTest.java new file mode 100644 index 0000000..10fe63c --- /dev/null +++ b/src/nativeTest/java/games/dmg/fantasystick/CombatSpacingBoundaryNativeTest.java @@ -0,0 +1,161 @@ +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.party.CombatExperience; +import games.dmg.fantasystick.persistence.CharacterStorage; +import games.dmg.fantasystick.session.EquipmentSessions; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; +import org.bukkit.Location; +import org.bukkit.entity.EntityType; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class CombatSpacingBoundaryNativeTest { + @TempDir Path data; + @BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); } + + @Test void actualFailedReplacementRetainsTheOriginalCapturedPositionRulesAndOneReceiptForRetry() throws Exception { + Files.writeString(data.resolve("config.yml"), "hostile-coin-drop-min: 12\nhostile-coin-drop-max: 12\n"); + 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 before = record(f, actor); var file = data.resolve("characters").resolve(before.id() + ".yml"); + var bytes = Files.readAllBytes(file); + var combat = (CombatExperience) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "combatExperience"); + var zombie = mob(EntityType.ZOMBIE, new Location(actor.world, 12, 64, 0)); + int messages = actor.messages.size(); + try (var channel = FileChannel.open(data.resolve("characters/.records.lock"), StandardOpenOption.WRITE); + var lock = channel.lock()) { + assertTrue(lock.isValid(), "Test owns the real mutation lock before forcing replacement refusal"); + hit(f, actor.player, zombie, false); die(f, zombie); settle(f); + assertEquals(before, record(f, actor)); assertArrayEquals(bytes, Files.readAllBytes(file)); + assertTrue(CombatRewardEventsNativeTest.rewardRows(data).isEmpty()); + assertEquals(messages, actor.messages.size(), "Failed replacement cannot report reward success"); + die(f, zombie); combat.retryPending(); settle(f); + assertEquals(before, record(f, actor)); + assertTrue(CombatRewardEventsNativeTest.rewardRows(data).isEmpty()); + } + when(zombie.getLocation()).thenReturn(new Location(actor.world, 900, 4, 900)); + var rules = combat.rules(); + combat.configure(new games.dmg.fantasystick.progression.CombatRewardRules(rules.passivePool(), + BigInteger.valueOf(999), rules.hardPool(), rules.bossPool(), BigDecimal.valueOf(999))); + combat.retryPending(); settle(f); awaitRewardCount(f, data, 1); + var saved = record(f, actor); var row = CombatRewardEventsNativeTest.rewardRows(data).getFirst(); + var values = row.getAsJsonObject("data"); + assertEquals(before.experience().add(BigInteger.TEN), saved.experience()); + assertEquals(before.coins().add(BigInteger.valueOf(12)), saved.coins()); + assertEquals("10", values.get("experience_pool").getAsString()); + assertEquals("3", values.get("spacing_minimum").getAsString()); + assertEquals("12.0", values.getAsJsonObject("kill_location").get("x").getAsString()); + assertEquals(List.of(before.id().toString()), values.getAsJsonArray("reward_unit_beneficiary_character_ids") + .asList().stream().map(value -> value.getAsString()).toList()); + assertEquals(before.revision().toString(), values.get("before_revision").getAsString()); + assertTrue(saved.rewardReceipts().contains(UUID.fromString(values.get("operation_id").getAsString()))); + die(f, zombie); combat.retryPending(); settle(f); + assertEquals(saved, record(f, actor)); assertEquals(List.of(row), CombatRewardEventsNativeTest.rewardRows(data)); + } + } + + @Test void staleRevisionRetriesAgainstTheAcknowledgedCurrentHistoryWithoutDuplicatingTheCapturedShare() throws Exception { + Files.writeString(data.resolve("config.yml"), "hostile-coin-drop-min: 12\nhostile-coin-drop-max: 12\n"); + 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 before = record(f, actor); + var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters"); + var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions"); + var active = sessions.active(actor.core.id).orElseThrow(); + var worker = (ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(storage, "worker"); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + worker.execute(() -> { entered.countDown(); try { release.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + try { + var zombie = mob(EntityType.ZOMBIE, new Location(actor.world, 3, 64, 0)); + hit(f, actor.player, zombie, false); die(f, zombie); + var health = storage.saveHealth(before.id(), before.revision(), BigDecimal.valueOf(9), before.maximumHealth()); + health.thenAccept(saved -> f.tasks.offer(() -> assertTrue(sessions.acknowledgeProgress(active, + before.revision(), saved.orElseThrow())))); + release.countDown(); assertTrue(health.get(5, TimeUnit.SECONDS).isPresent()); + settle(f); awaitRewardCount(f, data, 1); + var saved = record(f, actor); var values = CombatRewardEventsNativeTest.rewardRows(data).getFirst().getAsJsonObject("data"); + assertEquals(before.revision().add(BigInteger.ONE).toString(), values.get("before_revision").getAsString()); + assertEquals(before.revision().add(BigInteger.TWO), saved.revision()); + assertEquals(BigDecimal.valueOf(9), saved.health()); + assertEquals(before.experience().add(BigInteger.TEN), saved.experience()); + assertEquals(before.coins().add(BigInteger.valueOf(12)), saved.coins()); + assertEquals("acknowledged", values.get("native_sync").getAsString()); + die(f, zombie); settle(f); assertEquals(saved, record(f, actor)); + assertEquals(1, CombatRewardEventsNativeTest.rewardRows(data).size()); + } finally { release.countDown(); } + } + } + + @ParameterizedTest @ValueSource(strings = {"ordinary-xp: 0\n", "maximum-level: 1\n"}) + void zeroExperienceAndMaxLevelRemainCoinEligibleButNearKillsStillDiscardCoins(String configuration) throws Exception { + Files.writeString(data.resolve("config.yml"), configuration + "hostile-coin-drop-min: 12\nhostile-coin-drop-max: 12\n"); + 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 before = record(f, actor); + assertTrue(f.playerCommand.execute(actor.player, "fstick", new String[]{"feedback", "off"})); + ReskillEventsNativeTest.await(f, () -> actor.messages.contains("Fantasy feedback is now off.")); + for (int x : List.of(0, 1, 4)) { + var zombie = mob(EntityType.ZOMBIE, new Location(actor.world, x, 64, 0)); + hit(f, actor.player, zombie, false); die(f, zombie); settle(f); + } + awaitRewardCount(f, data, 3); var saved = record(f, actor); + assertEquals(before.experience(), saved.experience()); + assertEquals(before.coins().add(BigInteger.valueOf(24)), saved.coins()); + assertEquals(new BigDecimal("4.0"), saved.restrictedKills().get(actor.world.getUID()).x()); + var rows = CombatRewardEventsNativeTest.rewardRows(data); + assertEquals(List.of("12", "0", "12"), rows.stream().map(row -> row.getAsJsonObject("data") + .get("applied_coins").getAsString()).toList()); + assertEquals(List.of("first_kill", "too_close", "exact_threshold"), rows.stream().map(row -> row.getAsJsonObject("data") + .get("spacing_result").getAsString()).toList()); + assertTrue(rows.stream().allMatch(row -> row.getAsJsonObject("data").get("applied_experience").getAsString().equals("0"))); + } + } + + @Test void oneXpPartyPoolKeepsZeroShareRecipientHistoryAndNativeCancellationCreatesNoResolution() throws Exception { + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data); + var b = new FantasyStickLifecycleNativeTest.Actor(data); + for (var actor : List.of(a, b)) { f.players.put(actor.core.id, actor.player); f.activate(actor); } + 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 beforeA = record(f, a); var beforeB = record(f, b); + var cancelled = mob(EntityType.COW, new Location(a.world, 0, 64, 0)); + hit(f, a.player, cancelled, true); die(f, cancelled); settle(f); + assertEquals(beforeA, record(f, a)); assertEquals(beforeB, record(f, b)); + assertTrue(CombatRewardEventsNativeTest.rewardRows(data).isEmpty()); + for (int x : List.of(0, 1)) { + var cow = mob(EntityType.COW, new Location(a.world, x, 64, 0)); + hit(f, a.player, cow, false); die(f, cow); settle(f); settle(f); + } + awaitRewardCount(f, data, 4); + var savedA = record(f, a); var savedB = record(f, b); + assertEquals(BigInteger.ONE, savedA.experience().subtract(beforeA.experience()) + .add(savedB.experience().subtract(beforeB.experience()))); + assertEquals(beforeA.coins(), savedA.coins()); assertEquals(beforeB.coins(), savedB.coins()); + assertEquals(new BigDecimal("1.0"), savedA.restrictedKills().get(a.world.getUID()).x()); + assertEquals(new BigDecimal("1.0"), savedB.restrictedKills().get(a.world.getUID()).x()); + var rows = CombatRewardEventsNativeTest.rewardRows(data); + assertEquals(2, rows.stream().filter(row -> row.getAsJsonObject("data").get("allocated_experience").getAsString().equals("0")).count()); + assertTrue(rows.stream().allMatch(row -> row.getAsJsonObject("data").get("ordinary_history_updated").getAsBoolean())); + } + } +} diff --git a/src/nativeTest/java/games/dmg/fantasystick/FishingPublishedReaderNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/FishingPublishedReaderNativeTest.java new file mode 100644 index 0000000..de7e6ad --- /dev/null +++ b/src/nativeTest/java/games/dmg/fantasystick/FishingPublishedReaderNativeTest.java @@ -0,0 +1,112 @@ +package games.dmg.fantasystick; + +import static org.junit.jupiter.api.Assertions.*; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.List; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** Pure file-observation contracts for the actual three local fishing journey readers. */ +class FishingPublishedReaderNativeTest { + @TempDir Path data; + + @ParameterizedTest @ValueSource(strings = {"types", "claims", "catches"}) + void onlyDailyPublishedSegmentsAreReadWithoutEnteringPendingOrOtherTrees(String reader) throws Exception { + var published = row(reader, "published"); + Files.writeString(segment(), published + "\n"); + for (String unpublished : List.of("pending", "not-a-published-day")) { + var directory = Files.createDirectories(data.resolve("events").resolve(unpublished)); + Files.writeString(directory.resolve("must-not-observe.jsonl"), row(reader, unpublished) + "\n"); + } + Files.writeString(data.resolve("events/root-segment.jsonl"), row(reader, "unpublished-root") + "\n"); + assertEquals(List.of(expected(reader, published)), read(reader), + "Unpublished trees must never be traversed, regardless of their contents or writer cleanup"); + } + + @ParameterizedTest @ValueSource(strings = {"types", "claims", "catches"}) + void incompleteTrailingJsonIsNotACommittedRowButCompletedMalformedJsonStillFails(String reader) throws Exception { + var published = row(reader, "published"); var segment = segment(); + Files.writeString(segment, published + "\n{\"type\":"); + assertEquals(List.of(expected(reader, published)), assertDoesNotThrow(() -> read(reader)), + "A still-being-appended tail is not a complete published row"); + Files.writeString(segment, published + "\n{\"type\":\n"); + assertThrows(JsonParseException.class, () -> read(reader), "Malformed complete records must not be hidden"); + } + + @ParameterizedTest @ValueSource(strings = {"types", "claims", "catches"}) + void evenValidJsonRequiresItsPublicationNewline(String reader) throws Exception { + var first = row(reader, "first"); var second = row(reader, "second"); var segment = segment(); + Files.writeString(segment, first + "\n" + second); + assertEquals(List.of(expected(reader, first)), read(reader), "Do not report an unterminated writer tail"); + Files.writeString(segment, first + "\n" + second + "\n"); + assertEquals(List.of(expected(reader, first), expected(reader, second)), read(reader)); + } + + @ParameterizedTest @ValueSource(strings = {"types", "claims", "catches"}) + void splitUtf8TailIsIgnoredUntilNewlineButMalformedCompleteEncodingStillFails(String reader) throws Exception { + var published = row(reader, "published"); var segment = segment(); + var bytes = new java.io.ByteArrayOutputStream(); + bytes.writeBytes((published + "\n").getBytes(java.nio.charset.StandardCharsets.UTF_8)); + bytes.write(0xc3); // First byte of a multi-byte character at an unfinished writer tail. + Files.write(segment, bytes.toByteArray()); + assertEquals(List.of(expected(reader, published)), assertDoesNotThrow(() -> read(reader))); + bytes.write('\n'); Files.write(segment, bytes.toByteArray()); + var failure = assertThrows(Exception.class, () -> read(reader)); + assertTrue(failure instanceof IOException || failure instanceof UncheckedIOException, + "Complete malformed UTF-8 must remain an observable decoding failure"); + } + + @ParameterizedTest @ValueSource(strings = {"types", "claims", "catches"}) + void malformedCompletePublishedRecordIsNeverCaughtAndIgnored(String reader) throws Exception { + Files.writeString(segment(), "{\"type\":\n"); + assertThrows(JsonParseException.class, () -> read(reader)); + } + + @ParameterizedTest @ValueSource(strings = {"types", "claims", "catches"}) + void genuinePublishedSegmentIoFailureIsNotSkipped(String reader) throws Exception { + Files.createDirectory(segment()); + var failure = assertThrows(Exception.class, () -> read(reader)); + assertTrue(failure instanceof IOException || failure instanceof UncheckedIOException, + "Real published-segment I/O failure must remain visible, not an empty successful read"); + } + + @ParameterizedTest @ValueSource(strings = {"types", "claims", "catches"}) + void ordinaryCompletePublishedRecordsRetainExactValues(String reader) throws Exception { + var first = row(reader, "first — 海"); var second = row(reader, "second — 𝒟"); + Files.writeString(segment(), first + "\n" + second + "\n"); + assertEquals(List.of(expected(reader, first), expected(reader, second)), read(reader)); + } + + private Path segment() throws IOException { + var day = Files.createDirectories(data.resolve("events").resolve(LocalDate.now(ZoneOffset.UTC).toString())); + return day.resolve("events-observation-fixture.jsonl"); + } + private List read(String reader) throws IOException { + var journey = new FishingQuestNativeTest(); journey.data = data; + return switch (reader) { + case "types" -> journey.exportedTypes(); + case "claims" -> journey.exportedClaims(); + case "catches" -> journey.exportedCatches(); + default -> throw new AssertionError("Unknown scoped reader"); + }; + } + private static JsonObject row(String reader, String identity) { + var row = new JsonObject(); + row.addProperty("type", "games.dmg.fantasystick." + (reader.equals("claims") + ? "adventure.reward_claimed" : "fishing.catch_resolved")); + row.addProperty("observation_fixture_identity", identity); + return row; + } + private static Object expected(String reader, JsonObject row) { + return reader.equals("types") ? row.get("type").getAsString() : row; + } +} diff --git a/src/nativeTest/java/games/dmg/fantasystick/FishingQuestNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/FishingQuestNativeTest.java index 13d7028..e877ed9 100644 --- a/src/nativeTest/java/games/dmg/fantasystick/FishingQuestNativeTest.java +++ b/src/nativeTest/java/games/dmg/fantasystick/FishingQuestNativeTest.java @@ -570,30 +570,44 @@ final class FishingQuestNativeTest { assertEquals(1,exportedCatches().size(),"writer replay cannot duplicate a native catch"); } - private List exportedTypes() throws java.io.IOException { - try(var paths=java.nio.file.Files.walk(data.resolve("events"))){ - return paths.filter(path->path.toString().endsWith(".jsonl")) - .flatMap(path->{try{return java.nio.file.Files.lines(path);}catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}}) - .map(line->com.google.gson.JsonParser.parseString(line).getAsJsonObject().get("type").getAsString()).toList(); - } + List exportedTypes() throws java.io.IOException { + return exportedRecords().stream().map(value->value.get("type").getAsString()).toList(); } - private List exportedClaims() throws java.io.IOException { - try(var paths=java.nio.file.Files.walk(data.resolve("events"))){ - return paths.filter(path->path.toString().endsWith(".jsonl")) - .flatMap(path->{try{return java.nio.file.Files.lines(path);}catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}}) - .map(line->com.google.gson.JsonParser.parseString(line).getAsJsonObject()) - .filter(value->value.get("type").getAsString().endsWith("adventure.reward_claimed")).toList(); - } + List exportedClaims() throws java.io.IOException { + return exportedRecords().stream() + .filter(value->value.get("type").getAsString().endsWith("adventure.reward_claimed")).toList(); } - private List exportedCatches() throws java.io.IOException { - try(var paths=java.nio.file.Files.walk(data.resolve("events"))){ - return paths.filter(path->path.toString().endsWith(".jsonl")) - .flatMap(path->{try{return java.nio.file.Files.lines(path);}catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}}) - .map(line->com.google.gson.JsonParser.parseString(line).getAsJsonObject()) - .filter(value->value.get("type").getAsString().endsWith("fishing.catch_resolved")).toList(); + List exportedCatches() throws java.io.IOException { + return exportedRecords().stream() + .filter(value->value.get("type").getAsString().endsWith("fishing.catch_resolved")).toList(); + } + + /** Observe the writer's published layout; pending artifacts and unfinished tails are not rows. */ + private List exportedRecords() throws java.io.IOException { + var records=new ArrayList(); + try(var days=java.nio.file.Files.list(data.resolve("events"))){ + for(var day:days.filter(path->path.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}")).toList()){ + try(var segments=java.nio.file.Files.list(day)){ + for(var segment:segments.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList()){ + try(var input=new java.io.BufferedInputStream(java.nio.file.Files.newInputStream(segment))){ + var line=new java.io.ByteArrayOutputStream(); + var decoder=java.nio.charset.StandardCharsets.UTF_8.newDecoder(); + for(int value;(value=input.read())!=-1;){ + if(value=='\n'){ + // Frame bytes before decoding: a writer tail can split a UTF-8 character. + var text=decoder.decode(java.nio.ByteBuffer.wrap(line.toByteArray())).toString(); + if(!text.isBlank())records.add(com.google.gson.JsonParser.parseString(text).getAsJsonObject()); + line.reset(); + }else line.write(value); + } + } + } + } + } } + return List.copyOf(records); } @Test void failedNativeCatchPreparationProducesNoItemXpOrResolvedSuccess() throws Exception {