feat(combat): enrich spacing reward audit evidence
CI / build (push) Failing after 10m45s
CI / build (pull_request) Failing after 10m47s

This commit is contained in:
dmg committed 2026-10-07 08:57:43 -04:00
1 parent 3031a30d2c
commit 39de8516c8
6 files changed
+482 -24

No files matched your search

@@ -22,7 +22,15 @@ public final class CombatRewardEventBridge implements AutoCloseable {
CombatRewardRules.KillPosition killPosition,ManagedEncounterOrigin managedOrigin,
String stage,Instant defeatedAt,java.util.List<UUID> directContributors,
String rewardUnitId,int rewardUnitCount,BigInteger experiencePool,BigInteger coinPool,
Map<String,Object> managedEvidence){
Map<String,Object> managedEvidence,java.util.List<UUID> beneficiaries){
public Capture(UUID targetId,String targetType,String experienceClass,String coinClass,
CombatRewardRules.KillPosition killPosition,ManagedEncounterOrigin managedOrigin,
String stage,Instant defeatedAt,java.util.List<UUID> directContributors,
String rewardUnitId,int rewardUnitCount,BigInteger experiencePool,BigInteger coinPool,
Map<String,Object> 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<UUID> 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<EventInput> 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<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("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<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("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_capturLine truncated
}
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<String,Object> 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<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));}
@@ -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<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 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<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(),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);
}
@@ -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<JsonObject> rewards() { return CombatRewardEventsNativeTest.rewardRows(data); }
}
@@ -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()));
}
}
}
@@ -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;
}
}
@@ -570,30 +570,44 @@ final class FishingQuestNativeTest {
assertEquals(1,exportedCatches().size(),"writer replay cannot duplicate a native catch");
}
private List<String> 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<String> exportedTypes() throws java.io.IOException {
return exportedRecords().stream().map(value->value.get("type").getAsString()).toList();
}
private List<com.google.gson.JsonObject> 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<com.google.gson.JsonObject> exportedClaims() throws java.io.IOException {
return exportedRecords().stream()
.filter(value->value.get("type").getAsString().endsWith("adventure.reward_claimed")).toList();
}
private List<com.google.gson.JsonObject> 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<com.google.gson.JsonObject> 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<com.google.gson.JsonObject> exportedRecords() throws java.io.IOException {
var records=new ArrayList<com.google.gson.JsonObject>();
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 {