feat(combat): enrich spacing reward audit evidence
This commit was merged in pull request #5.
This commit is contained in:
commit
b62b7952e6
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 {
|
||||
|
||||
Reference in new issue
Block a user