feat(adventure): audit persistent camp trophies
Release / release (push) Failing after 19m8s

This commit is contained in:
dmg committed 2026-09-26 20:18:09 -04:00
1 parent 5923fcaf75
commit a79814118e
6 files changed
+423 -17

No files matched your search

@@ -162,8 +162,14 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
private List<UUID> recipients(AdventureQuestState.Quest quest,UUID site){var value=directory.findSite(site).orElse(null);return value==null?List.of():recipients(quest,site,arena(value));}
private List<UUID> recipients(AdventureQuestState.Quest quest,UUID site,AdventureQuestState.Arena arena){var value=directory.findSite(site).orElse(null);if(value==null)return List.of();var result=new ArrayList<UUID>();for(UUID character:quest.snapshot()){if(directory.characterClaimed(quest.catalog(),quest.site(),character))continue;var active=sessions.activeCharacter(character).filter(sessions::current).orElse(null);if(active==null)continue;var player=plugin.getServer().getPlayer(active.player());if(player==null||!inside(player.getLocation(),value,arena))continue;if(quest.unitType()==AdventureQuestState.UnitType.PARTY){var party=parties.partyOf(player.getUniqueId()).orElse(null);if(party==null||!party.id().equals(quest.unit())||!party.characters().contains(character))continue;}result.add(character);}return result.stream().distinct().sorted().toList();}
private void award(AdventureQuestState.Quest quest,List<UUID> recipients,Attempt attempt){var unit=new RewardAllocator.Unit<UUID>(quest.unit(),recipients);var xp=RewardAllocator.allocate(quest.rewardXp(),List.of(unit),random);var coins=RewardAllocator.allocate(quest.rewardCoins(),List.of(unit),random);if((attempt.type==AdventureQuestState.SiteType.CHAMPION||attempt.type==AdventureQuestState.SiteType.CAMP)&&fishing!=null){var rewards=new LinkedHashMap<UUID,FishingState.Reward>();for(UUID character:recipients)rewards.put(character,new FishingState.Reward(xp.get(character),coins.get(character)));UUID carrier=quest.unitType()==AdventureQuestState.UnitType.SOLO?quest.unit():recipients.stream().map(parties::partyOfCharacter).flatMap(Optional::stream).filter(value->value.id().equals(quest.unit())).map(PartyRoster.Party::leaderCharacter).findFirst().orElse(null);var site=directory.findSite(quest.site()).orElse(null);if(carrier==null||site==null){plugin.getLogger().warning("Champion trophy carrier unavailable for quest "+quest.id());return;}var trophyType=attempt.type==AdventureQuestState.SiteType.CHAMPION?FishingState.TrophyType.CHAMPION:FishingState.TrophyType.CAMP;UUID trophy=UUID.nameUUIDFromBytes((trophyType.name().toLowerCase(java.util.Locale.ROOT)+":"+quest.id()).getBytes(java.nio.charset.StandardCharsets.UTF_8));String trophyName=trophyType==FishingState.TrophyType.CHAMPION?"Daybreak Champion Head":"Spoils of "+site.displayName();var audit=new LinkedHashMap<String,String>();
audit.put("champion_id",attempt.defeatedEntity==null?"unavailable":attempt.defeatedEntity.toString());
audit.put(trophyType==FishingState.TrophyType.CHAMPION?"champion_id":"final_enemy_id",
attempt.defeatedEntity==null?"unavailable":attempt.defeatedEntity.toString());
audit.put("defeated_at",attempt.defeatedAt==null?"unavailable":attempt.defeatedAt.truncatedTo(java.time.temporal.ChronoUnit.SECONDS).toString());
if(trophyType==FishingState.TrophyType.CAMP){
audit.put("arena_shape",attempt.arena.shape().name().toLowerCase(Locale.ROOT));
audit.put("arena_x_blocks",Integer.toString(attempt.arena.xBlocks()));
audit.put("arena_z_blocks",Integer.toString(attempt.arena.zBlocks()));
}
for(var entry:attempt.defeatLocation.entrySet())
audit.put("defeat_"+entry.getKey(),String.valueOf(entry.getValue()));
audit.put("unit_kind",quest.unitType().name().toLowerCase(Locale.ROOT));
@@ -51,7 +51,7 @@ public final class FishingController implements Listener,AutoCloseable,Adventure
.thenApply(committed->{
var earned=committed==FishingDirectory.TrophyCommitStatus.NOT_FOUND?retained:retained.earned();
if(committed==FishingDirectory.TrophyCommitStatus.APPLIED&&fishingEvents!=null)
fishingEvents.championEarned(earned);
fishingEvents.trophyEarned(earned);
return new TrophyEarned(committed!=FishingDirectory.TrophyCommitStatus.NOT_FOUND,
committed==FishingDirectory.TrophyCommitStatus.APPLIED,earned);
});
@@ -64,7 +64,7 @@ public final class FishingController implements Listener,AutoCloseable,Adventure
.whenComplete((committed,failure)->{
if(failure!=null)report(trophy.id(),trophy.carrier(),"trophy-reconcile",failure);
else if(committed==FishingDirectory.TrophyCommitStatus.APPLIED&&fishingEvents!=null)
fishingEvents.championEarned(trophy.earned());
fishingEvents.trophyEarned(trophy.earned());
});
}
public void open(Player player){open(player,0,null);}
@@ -265,9 +265,16 @@ public final class FishingController implements Listener,AutoCloseable,Adventure
player.sendMessage("Trophy redemption failed; the earned trophy is retained.");return;}
var status=receipt==null?null:receipt.status();
if(status==FishingDirectory.TrophyRedeemStatus.APPLIED){
if(captured!=null&&captured.type()==FishingState.TrophyType.CHAMPION&&fishingEvents!=null){
if(captured!=null&&(captured.type()==FishingState.TrophyType.CHAMPION
||captured.type()==FishingState.TrophyType.CAMP)&&fishingEvents!=null){
var data=new LinkedHashMap<String,Object>();data.put("receipt_id",captured.id().toString());
data.put("trophy_type","champion_trophy");data.put("trophy_name",captured.name());
data.put("trophy_type",captured.type().name().toLowerCase(java.util.Locale.ROOT)+"_trophy");
data.put("trophy_name",captured.name());
if(captured.type()==FishingState.TrophyType.CAMP){
data.put("final_enemy_id",captured.audit().getOrDefault("final_enemy_id","unavailable_legacy_capture"));
data.put("clear_location",games.dmg.fantasystick.events.FishingEventBridge.savedDefeatLocation(captured.audit()));
data.put("arena",games.dmg.fantasystick.events.FishingEventBridge.savedArena(captured.audit()));
}
data.put("source_name",captured.origin());data.put("carrier_character_id",captured.carrier().toString());
data.put("catalog_id",captured.catalog().toString());data.put("site_id",captured.site().toString());
data.put("quest_id",captured.quest().toString());data.put("encounter_id",captured.source().toString());
@@ -294,7 +301,7 @@ public final class FishingController implements Listener,AutoCloseable,Adventure
}
player.sendMessage("Adventure trophy redeemed; frozen shares are ready for each beneficiary.");
}else player.sendMessage("Trophy redemption refused: the receipt is unavailable for this character.");
open(player);
open(player,0,menuGivers.get(player.getUniqueId()));
}));
}
private void claimLegacy(Player player,Context context){adventures.beginLegacyClaim(context.record().id()).whenComplete((claim,failure)->{if(failure!=null||claim.isEmpty()){if(failure!=null)report(UUID.randomUUID(),context.record().id(),"legacy-claim-prepare",failure);schedule(()->player.sendMessage("Retained adventure reward claim failed; earned shares remain."));return;}awardLegacy(player,claim.orElseThrow(),context.record());});}
@@ -127,14 +127,15 @@ public final class FishingEventBridge implements AutoCloseable {
} catch (RuntimeException unavailable) { return new Actor("system", null); }
}
/** Reconstruct a champion outcome solely from the saved receipt, including after a prepared retry. */
public void championEarned(FishingState.Trophy receipt) {
if (receipt.type() != FishingState.TrophyType.CHAMPION
/** Reconstruct a combat-trophy outcome solely from the saved receipt, including after a prepared retry. */
public void trophyEarned(FishingState.Trophy receipt) {
if ((receipt.type() != FishingState.TrophyType.CHAMPION
&& receipt.type() != FishingState.TrophyType.CAMP)
|| receipt.phase() != FishingState.TrophyPhase.EARNED) return;
var audit = receipt.audit();
var evidence = new LinkedHashMap<String, Object>();
evidence.put("receipt_id", receipt.id().toString());
evidence.put("trophy_type", "champion_trophy");
evidence.put("trophy_type", receipt.type().name().toLowerCase(java.util.Locale.ROOT) + "_trophy");
evidence.put("trophy_name", receipt.name());
evidence.put("source_name", receipt.origin());
evidence.put("carrier_character_id", receipt.carrier().toString());
@@ -143,9 +144,16 @@ public final class FishingEventBridge implements AutoCloseable {
evidence.put("catalog_id", receipt.catalog().toString());
evidence.put("site_id", receipt.site().toString());
evidence.put("encounter_id", receipt.source().toString());
evidence.put("champion_id", audit.getOrDefault("champion_id", "unavailable_legacy_capture"));
evidence.put("defeat_location", savedDefeatLocation(audit));
evidence.put("defeated_at", audit.getOrDefault("defeated_at", "unavailable_legacy_capture"));
if (receipt.type() == FishingState.TrophyType.CHAMPION) {
evidence.put("champion_id", audit.getOrDefault("champion_id", "unavailable_legacy_capture"));
evidence.put("defeat_location", savedDefeatLocation(audit));
evidence.put("defeated_at", audit.getOrDefault("defeated_at", "unavailable_legacy_capture"));
} else {
evidence.put("final_enemy_id", audit.getOrDefault("final_enemy_id", "unavailable_legacy_capture"));
evidence.put("clear_location", savedDefeatLocation(audit));
evidence.put("cleared_at", audit.getOrDefault("defeated_at", "unavailable_legacy_capture"));
evidence.put("arena", savedArena(audit));
}
evidence.put("unit_kind", audit.getOrDefault("unit_kind", "unavailable_legacy_capture"));
evidence.put("unit_id", audit.getOrDefault("unit_id", "unavailable_legacy_capture"));
var roster = audit.get("accepted_roster");
@@ -170,7 +178,22 @@ public final class FishingEventBridge implements AutoCloseable {
durable("adventure.trophy_earned", receipt.id(), new Actor("system", null), evidence);
}
private static Map<String, Object> savedDefeatLocation(Map<String, String> audit) {
public static Map<String, Object> savedArena(Map<String, String> audit) {
try {
var shape = audit.get("arena_shape");
int x = Integer.parseInt(audit.get("arena_x_blocks"));
int z = Integer.parseInt(audit.get("arena_z_blocks"));
var expected = "circle".equals(shape)
? games.dmg.fantasystick.adventure.AdventureQuestState.Arena.circle(x)
: "rectangle".equals(shape)
? games.dmg.fantasystick.adventure.AdventureQuestState.Arena.rectangle(x, z)
: null;
if (expected == null || expected.zBlocks() != z) return Map.of("status", "unavailable");
return Map.of("shape", shape, "x_blocks", x, "z_blocks", z);
} catch (RuntimeException unavailable) { return Map.of("status", "unavailable"); }
}
public static Map<String, Object> savedDefeatLocation(Map<String, String> audit) {
try {
if (!audit.containsKey("defeat_world_id")) return Map.of("status", "unavailable");
var world = UUID.fromString(audit.get("defeat_world_id"));
@@ -145,7 +145,21 @@ class AdventureQuestNativeTest {
for(Runnable pulse:List.copyOf(f.pulses))pulse.run();for(int retry=0;retry<10&&spawned.stream().filter(entity->!(entity instanceof Villager)).count()<4;retry++)drainOne(f);
var enemies=spawned.stream().filter(entity->!(entity instanceof Villager)).toList();assertEquals(4,enemies.size());assertTrue(controller.hasActiveEncounter());
var enemy=enemies.getFirst();assertEquals(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CAMP,controller.encounterOrigin(enemy).orElseThrow().kind());when(enemy.getLocation()).thenReturn(new Location(actor.world,siteRecord.x()+11,siteRecord.y(),siteRecord.z()));when(actor.world.getEntities()).thenReturn(new ArrayList<Entity>(spawned));for(Runnable pulse:List.copyOf(f.pulses))pulse.run();verify(enemy).teleport(argThat((Location location)->location.getX()==siteRecord.x()&&location.getY()==siteRecord.y()&&location.getZ()==siteRecord.z()));
var enemy=enemies.getFirst();assertEquals(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CAMP,controller.encounterOrigin(enemy).orElseThrow().kind());
var unrelated=mock(org.bukkit.entity.Cow.class);
when(unrelated.getUniqueId()).thenReturn(UUID.randomUUID());
var copiedCampTag=enemy.getPersistentDataContainer();
when(unrelated.getPersistentDataContainer()).thenReturn(copiedCampTag);
assertTrue(controller.encounterOrigin(unrelated).isEmpty(),
"a passive with a copied camp tag is not a registered spawn");
var nativeDrop=new org.bukkit.inventory.ItemStack(Material.BEEF);
var unrelatedDeath=new org.bukkit.event.entity.EntityDeathEvent(unrelated,
mock(org.bukkit.damage.DamageSource.class),new ArrayList<>(List.of(nativeDrop)),7);
f.dispatcher.accept(unrelatedDeath);
assertEquals(7,unrelatedDeath.getDroppedExp());
assertEquals(List.of(nativeDrop),unrelatedDeath.getDrops(),
"an unrelated in-arena passive cannot consume camp completion or native loot");
when(enemy.getLocation()).thenReturn(new Location(actor.world,siteRecord.x()+11,siteRecord.y(),siteRecord.z()));when(actor.world.getEntities()).thenReturn(new ArrayList<Entity>(spawned));for(Runnable pulse:List.copyOf(f.pulses))pulse.run();verify(enemy).teleport(argThat((Location location)->location.getX()==siteRecord.x()&&location.getY()==siteRecord.y()&&location.getZ()==siteRecord.z()));
var outsider=new FantasyStickLifecycleNativeTest.Actor(data);outsider.core.location.setWorld(actor.world);f.players.put(outsider.core.id,outsider.player);var outsiderSource=mock(org.bukkit.damage.DamageSource.class);when(outsiderSource.getCausingEntity()).thenReturn(outsider.player);when(outsiderSource.getDirectEntity()).thenReturn(outsider.player);var refusedHit=new org.bukkit.event.entity.EntityDamageByEntityEvent(outsider.player,enemy,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,outsiderSource,1);f.dispatcher.accept(refusedHit);assertTrue(refusedHit.isCancelled());var enemySource=mock(org.bukkit.damage.DamageSource.class);when(enemySource.getCausingEntity()).thenReturn(enemy);when(enemySource.getDirectEntity()).thenReturn(enemy);var refusedAttack=new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,outsider.player,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,enemySource,1);f.dispatcher.accept(refusedAttack);assertTrue(refusedAttack.isCancelled());var targetEvent=new org.bukkit.event.entity.EntityTargetLivingEntityEvent(enemy,outsider.player,org.bukkit.event.entity.EntityTargetEvent.TargetReason.CLOSEST_PLAYER);f.dispatcher.accept(targetEvent);assertTrue(targetEvent.isCancelled());
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);
var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(actor.player,enemy,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);f.dispatcher.accept(hit);assertFalse(hit.isCancelled());
@@ -155,8 +169,240 @@ class AdventureQuestNativeTest {
for(int i=0;i<50&&(directory.active().size()!=1||trophies.trophies(actor.core.record.id()).isEmpty()||actor.core.inventory.getItem(0).getType()!=Material.BOOK);i++){var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(10);}
var banked=storage.find(actor.core.record.id()).get().orElseThrow();assertEquals(java.math.BigInteger.ZERO,banked.experience());assertEquals(actorCoins,banked.coins());assertEquals(games.dmg.fantasystick.adventure.FishingState.TrophyType.CAMP,trophies.trophies(actor.core.record.id()).getFirst().type());assertEquals(Material.BOOK,actor.core.inventory.getItem(0).getType(),"completion restores the US-069 book control");assertEquals(1,directory.active().size(),"Unrepresented party retains its quest");assertEquals(java.math.BigInteger.ZERO,storage.find(partyA.core.record.id()).get().orElseThrow().experience());assertEquals(java.math.BigInteger.ZERO,storage.find(partyB.core.record.id()).get().orElseThrow().experience());
var soloTrophy=trophies.trophies(actor.core.record.id()).getFirst();assertEquals("Spoils of Dawn Camp",soloTrophy.name());assertEquals("Dawn Camp",soloTrophy.origin());assertEquals(new games.dmg.fantasystick.adventure.FishingState.Reward(java.math.BigInteger.valueOf(100),java.math.BigInteger.valueOf(20)),soloTrophy.total());assertEquals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.Reward.empty(),directory.unclaimedAll(actor.core.record.id()),"camp clear does not immediately unlock rewards");
int priorEnemies=enemies.size();for(Runnable pulse:List.copyOf(f.pulses))pulse.run();for(int retry=0;retry<10&&spawned.stream().filter(entity->!(entity instanceof Villager)).count()<priorEnemies+4;retry++)drainOne(f);var second=spawned.stream().filter(entity->!(entity instanceof Villager)).skip(priorEnemies).toList();assertEquals(4,second.size());var partySource=mock(org.bukkit.damage.DamageSource.class);when(partySource.getCausingEntity()).thenReturn(partyA.player);when(partySource.getDirectEntity()).thenReturn(partyA.player);var partyHit=new org.bukkit.event.entity.EntityDamageByEntityEvent(partyA.player,second.getFirst(),org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1);f.dispatcher.accept(partyHit);partyA.core.location.setX(100);doReturn(false).when(partyA.player).isOnline();for(var value:second)f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(value,partySource,new ArrayList<>(),0));await(f,()->directory.active().isEmpty()&&!trophies.trophies(partyA.core.record.id()).isEmpty());var partyTrophy=trophies.trophies(partyA.core.record.id()).getFirst();assertEquals(games.dmg.fantasystick.adventure.FishingState.TrophyType.CAMP,partyTrophy.type());assertEquals(Set.of(partyB.core.record.id()),partyTrophy.rewards().keySet(),"absent leader carries the spoils without gaining a share");assertEquals(new games.dmg.fantasystick.adventure.FishingState.Reward(java.math.BigInteger.valueOf(100),java.math.BigInteger.valueOf(20)),partyTrophy.total());assertEquals(java.math.BigInteger.ZERO,storage.find(partyA.core.record.id()).get().orElseThrow().experience());assertEquals(java.math.BigInteger.ZERO,storage.find(partyB.core.record.id()).get().orElseThrow().experience());var completedCatalog=directory.snapshot().catalog();assertFalse(directory.snapshot().rosters().isEmpty());f.command.execute(actor.player,"fantasystick",new String[]{"adventure","reroll","confirm"});for(int retry=0;retry<15&&(directory.snapshot().catalog().revision().equals(completedCatalog.revision())||directory.snapshot().reset()!=null);retry++)drainOne(f);var replacement=directory.snapshot().catalog();assertNotEquals(completedCatalog.revision(),replacement.revision());assertTrue(directory.snapshot().rosters().isEmpty());var turn=(games.dmg.fantasystick.adventure.FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");redeemAndClaim(f,turn,trophies,storage,actor,actorCoins,100,20);doReturn(true).when(partyA.player).isOnline();redeem(f,turn,partyA);claim(f,turn,trophies,storage,partyB,partyBCoins,100,20);assertEquals(partyACoins,storage.find(partyA.core.record.id()).get().orElseThrow().coins(),"carrier without a share receives no coins");assertEquals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.Acceptance.APPLIED,directory.accept(replacement.revision(),replacement.sites().getFirst(),games.dmg.fantasystick.adventure.AdventureQuestState.UnitType.SOLO,actor.core.record.id(),Set.of(actor.core.record.id())).get(5,java.util.concurrent.TimeUnit.SECONDS).status());
int priorEnemies=enemies.size();for(Runnable pulse:List.copyOf(f.pulses))pulse.run();for(int retry=0;retry<10&&spawned.stream().filter(entity->!(entity instanceof Villager)).count()<priorEnemies+4;retry++)drainOne(f);var second=spawned.stream().filter(entity->!(entity instanceof Villager)).skip(priorEnemies).toList();assertEquals(4,second.size());var partySource=mock(org.bukkit.damage.DamageSource.class);when(partySource.getCausingEntity()).thenReturn(partyA.player);when(partySource.getDirectEntity()).thenReturn(partyA.player);var partyHit=new org.bukkit.event.entity.EntityDamageByEntityEvent(partyA.player,second.getFirst(),org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1);f.dispatcher.accept(partyHit);partyA.core.location.setX(100);doReturn(false).when(partyA.player).isOnline();for(var value:second)f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(value,partySource,new ArrayList<>(),0));await(f,()->directory.active().isEmpty()&&!trophies.trophies(partyA.core.record.id()).isEmpty());var partyTrophy=trophies.trophies(partyA.core.record.id()).getFirst();assertEquals(games.dmg.fantasystick.adventure.FishingState.TrophyType.CAMP,partyTrophy.type());assertEquals(Set.of(partyB.core.record.id()),partyTrophy.rewards().keySet(),"absent leader carries the spoils without gaining a share");assertEquals(new games.dmg.fantasystick.adventure.FishingState.Reward(java.math.BigInteger.valueOf(100),java.math.BigInteger.valueOf(20)),partyTrophy.total());assertEquals(java.math.BigInteger.ZERO,storage.find(partyA.core.record.id()).get().orElseThrow().experience());assertEquals(java.math.BigInteger.ZERO,storage.find(partyB.core.record.id()).get().orElseThrow().experience());var completedCatalog=directory.snapshot().catalog();assertFalse(directory.snapshot().rosters().isEmpty());f.command.execute(actor.player,"fantasystick",new String[]{"adventure","reroll","confirm"});for(int retry=0;retry<15&&(directory.snapshot().catalog().revision().equals(completedCatalog.revision())||directory.snapshot().reset()!=null);retry++)drainOne(f);var replacement=directory.snapshot().catalog();assertNotEquals(completedCatalog.revision(),replacement.revision());assertTrue(directory.snapshot().rosters().isEmpty());var turn=(games.dmg.fantasystick.adventure.FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");
var giverLocation=directory.snapshot().givers().getFirst();
actor.core.location.setX(giverLocation.x());actor.core.location.setZ(giverLocation.z());
int beforeOffers=actor.inventories.size();
f.dispatcher.accept(new PlayerInteractEntityEvent(actor.player,giver,EquipmentSlot.HAND));
await(f,()->actor.inventories.size()>beforeOffers);
var giverOffers=actor.inventories.getLast();
var turnEntry=mock(InventoryClickEvent.class);when(turnEntry.getWhoClicked()).thenReturn(actor.player);
when(turnEntry.getInventory()).thenReturn(giverOffers);
when(turnEntry.getCurrentItem()).thenReturn(item(giverOffers,"Turn in quest items"));
controller.click(turnEntry);
var turnMenu=actor.inventories.getLast();int beforeClaimMenu=actor.inventories.size();
click(turn,actor,turnMenu,item(turnMenu,"Redeem Spoils of"));
await(f,()->trophies.trophies(actor.core.record.id()).isEmpty()
&&actor.inventories.size()>beforeClaimMenu);
var claimMenu=actor.inventories.getLast();
click(turn,actor,claimMenu,item(claimMenu,"Claim earned quest rewards"));
await(f,()->trophies.unclaimed(actor.core.record.id()).equals(
games.dmg.fantasystick.adventure.FishingState.Reward.empty()));
var paid=storage.find(actor.core.record.id()).get().orElseThrow();
assertEquals(java.math.BigInteger.valueOf(100),paid.experience());
assertEquals(actorCoins.add(java.math.BigInteger.valueOf(20)),paid.coins());doReturn(true).when(partyA.player).isOnline();redeem(f,turn,partyA);claim(f,turn,trophies,storage,partyB,partyBCoins,100,20);assertEquals(partyACoins,storage.find(partyA.core.record.id()).get().orElseThrow().coins(),"carrier without a share receives no coins");assertEquals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.Acceptance.APPLIED,directory.accept(replacement.revision(),replacement.sites().getFirst(),games.dmg.fantasystick.adventure.AdventureQuestState.UnitType.SOLO,actor.core.record.id(),Set.of(actor.core.record.id())).get(5,java.util.concurrent.TimeUnit.SECONDS).status());
}
var exported=exportedCampEvents(data);
var earned=exported.stream().filter(event->event.get("type").getAsString()
.endsWith("adventure.trophy_earned")).toList();
var redeemed=exported.stream().filter(event->event.get("type").getAsString()
.endsWith("adventure.trophy_redeemed")).toList();
var claims=exported.stream().filter(event->event.get("type").getAsString()
.endsWith("adventure.reward_claimed")&&event.getAsJsonObject("data")
.getAsJsonArray("sources").asList().stream().anyMatch(source->source
.getAsJsonObject().get("source_kind").getAsString().equals("camp_trophy"))).toList();
assertEquals(2,earned.size(),"two native represented camp clears export separate earned receipts");
assertEquals(2,redeemed.size(),"exact earned camp spoils unlock once per carrier");
assertTrue(redeemed.stream().anyMatch(event->event.getAsJsonObject("data")
.get("giver_status").getAsString().equals("entered_via_registered_giver")),
"at least one redemption must follow the registered Quest Giver menu");
assertEquals(2,claims.size(),"each beneficiary claims only their saved camp allocation");
assertTrue(claims.stream().anyMatch(event->event.getAsJsonObject("data")
.get("giver_status").getAsString().equals("entered_via_registered_giver")),
"the redeemed camp's beneficiary claims through the same registered Giver");
assertEquals(2,earned.stream().map(event->event.getAsJsonObject("data")
.get("receipt_id").getAsString()).distinct().count());
assertTrue(earned.stream().allMatch(event->event.getAsJsonObject("data")
.get("trophy_type").getAsString().equals("camp_trophy")));
assertTrue(earned.stream().allMatch(event->event.getAsJsonObject("data")
.getAsJsonObject("arena").get("shape").getAsString().equals("rectangle")));
assertTrue(earned.stream().allMatch(event->event.getAsJsonObject("data")
.getAsJsonObject("clear_location").has("world_id")));
assertTrue(earned.stream().allMatch(event->event.getAsJsonObject("data")
.get("receipt_revision").getAsString().equals("2")));
var partyEarned=earned.stream().map(event->event.getAsJsonObject("data"))
.filter(value->!value.get("carrier_is_beneficiary").getAsBoolean())
.findFirst().orElseThrow();
assertEquals("party",partyEarned.get("unit_kind").getAsString());
assertEquals(2,partyEarned.getAsJsonArray("accepted_roster").size());
assertEquals(1,partyEarned.getAsJsonArray("beneficiaries").size());
assertEquals("100",partyEarned.get("reward_xp_pool").getAsString());
assertEquals("20",partyEarned.get("reward_coin_pool").getAsString());
var partyRedemption=redeemed.stream().map(event->event.getAsJsonObject("data"))
.filter(value->value.get("receipt_id").getAsString()
.equals(partyEarned.get("receipt_id").getAsString())).findFirst().orElseThrow();
assertEquals(partyEarned.get("encounter_id").getAsString(),
partyRedemption.get("encounter_id").getAsString());
assertTrue(partyRedemption.getAsJsonArray("beneficiaries").get(0).getAsJsonObject()
.has("allocation_id"),"a positive frozen camp share keeps its exact unlock identity");
assertEquals("rectangle",partyRedemption.getAsJsonObject("arena")
.get("shape").getAsString());
assertEquals(8,exported.stream().filter(event->event.get("type").getAsString()
.endsWith("combat.reward_resolved")&&event.getAsJsonObject("data").has("managed_origin_kind")
&&event.getAsJsonObject("data").get("managed_origin_kind").getAsString().equals("camp")
&&event.getAsJsonObject("data").get("resolution").getAsString()
.equals("encounter_suppressed")).count());
}
@SuppressWarnings({"unchecked", "rawtypes"})
@Test void registeredCircularCampClearExportsItsFrozenArenaAndFinalEnemy() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"adventure-daily-site-count: 1\nadventure-site-reset-seconds: 0\nadventure-camp-enemies: 4\n");
UUID finalEnemy;
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);doReturn(true).when(actor.player)
.hasPermission("fantasystick.admin");
var world=actor.world;var worldId=UUID.randomUUID();when(world.getUID()).thenReturn(worldId);
when(world.getName()).thenReturn("Circle Realm");when(world.isChunkLoaded(anyInt(),anyInt()))
.thenReturn(true);
var chunk=mock(Chunk.class);when(chunk.getWorld()).thenReturn(world);
when(chunk.getX()).thenReturn(actor.core.location.getBlockX()>>4);
when(chunk.getZ()).thenReturn(actor.core.location.getBlockZ()>>4);
when(chunk.getEntities()).thenReturn(new Entity[0]);
when(world.getChunkAt(anyInt(),anyInt())).thenReturn(chunk);
when(world.getLoadedChunks()).thenReturn(new Chunk[]{chunk});
var spawned=new ArrayList<LivingEntity>();
doAnswer(call->{Class<?> type=call.getArgument(1);
var entity=mock(type.asSubclass(LivingEntity.class));
when(entity.getPersistentDataContainer()).thenReturn(
new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(
new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry()));
when(entity.getUniqueId()).thenReturn(UUID.randomUUID());
when(entity.getWorld()).thenReturn(world);
when(entity.getLocation()).thenReturn(((Location)call.getArgument(0)).clone());
((java.util.function.Consumer<LivingEntity>)call.getArgument(2)).accept(entity);
spawned.add(entity);return entity;
}).when(world).spawn(any(Location.class),any(Class.class),any(java.util.function.Consumer.class));
f.command.execute(actor.player,"fantasystick",
new String[]{"adventure-site","place","camp","10","Round Trial"});drainOne(f);
var directory=(games.dmg.fantasystick.adventure.AdventureQuestDirectory)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");
var old=directory.snapshot();var snapshot=directory.getClass().getDeclaredField("snapshot");
snapshot.setAccessible(true);snapshot.set(directory,
new games.dmg.fantasystick.adventure.AdventureQuestState(old.sites(),old.givers(),
null,old.quests(),old.unitClaims(),old.characterClaims(),old.rosters()));
var catalog=directory.ensureToday().get(5,java.util.concurrent.TimeUnit.SECONDS);
var site=catalog.sites().getFirst();f.activate(actor);
assertEquals(games.dmg.fantasystick.adventure.AdventureQuestState.Arena.circle(10),
directory.findSite(site).orElseThrow().arena());
assertEquals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.Acceptance.APPLIED,
directory.accept(catalog.revision(),site,
games.dmg.fantasystick.adventure.AdventureQuestState.UnitType.SOLO,
actor.core.record.id(),Set.of(actor.core.record.id()))
.get(5,java.util.concurrent.TimeUnit.SECONDS).status());
for(Runnable pulse:List.copyOf(f.pulses))pulse.run();
await(f,()->spawned.size()==4);
var source=mock(org.bukkit.damage.DamageSource.class);
when(source.getCausingEntity()).thenReturn(actor.player);
when(source.getDirectEntity()).thenReturn(actor.player);
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(actor.player,
spawned.getFirst(),org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,4));
finalEnemy=spawned.getLast().getUniqueId();
for(var enemy:spawned)f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(
enemy,source,new ArrayList<>(),0));
var trophies=(games.dmg.fantasystick.adventure.FishingDirectory)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");
await(f,()->!trophies.trophies(actor.core.record.id()).isEmpty());
assertEquals("Round Trial",trophies.trophies(actor.core.record.id()).getFirst().origin());
f.command.execute(actor.player,"fantasystick",
new String[]{"adventure-site","remove",site.toString()});
await(f,()->directory.findSite(site).isEmpty());
var storage=(games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");
var beforeCoins=storage.find(actor.core.record.id()).get().orElseThrow().coins();
var turn=(games.dmg.fantasystick.adventure.FishingController)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");
redeem(f,turn,actor);
claim(f,turn,trophies,storage,actor,beforeCoins,100,20);
}
var earned=exportedCampEvents(data).stream().filter(event->event.get("type").getAsString()
.endsWith("adventure.trophy_earned")).toList();
assertEquals(1,earned.size());
var evidence=earned.getFirst().getAsJsonObject("data");
assertEquals("circle",evidence.getAsJsonObject("arena").get("shape").getAsString());
assertEquals(10,evidence.getAsJsonObject("arena").get("x_blocks").getAsInt());
assertEquals(finalEnemy.toString(),evidence.get("final_enemy_id").getAsString());
assertTrue(evidence.getAsJsonObject("clear_location").has("world_id"));
var redemption=exportedCampEvents(data).stream().filter(event->event.get("type")
.getAsString().endsWith("adventure.trophy_redeemed")).toList();
assertEquals(1,redemption.size(),"deleted-circle site cannot expire an earned camp item");
assertEquals(evidence.get("site_id").getAsString(),redemption.getFirst()
.getAsJsonObject("data").get("site_id").getAsString());
}
@Test void failedCampCommitRestartsWithSavedArenaAndOneExportedEarnedEvent() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"adventure-daily-site-count: 1\nadventure-camp-enemies: 4\n");
UUID carrier,receiptId=UUID.randomUUID(),finalEnemy=UUID.randomUUID();
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);
doReturn(true).when(actor.player).hasPermission("fantasystick.admin");
when(actor.world.getUID()).thenReturn(UUID.randomUUID());
f.command.execute(actor.player,"fantasystick",
new String[]{"adventure-site","place","camp","10","Restart Circle"});drainOne(f);
var quests=(games.dmg.fantasystick.adventure.AdventureQuestDirectory)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");
var old=quests.snapshot();var snapshot=quests.getClass().getDeclaredField("snapshot");
snapshot.setAccessible(true);snapshot.set(quests,
new games.dmg.fantasystick.adventure.AdventureQuestState(old.sites(),old.givers(),
null,old.quests(),old.unitClaims(),old.characterClaims(),old.rosters()));
var catalog=quests.ensureToday().get(5,java.util.concurrent.TimeUnit.SECONDS);
carrier=actor.core.record.id();
var site=catalog.sites().getFirst();
assertEquals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.Acceptance.APPLIED,
quests.accept(catalog.revision(),site,
games.dmg.fantasystick.adventure.AdventureQuestState.UnitType.SOLO,carrier,
Set.of(carrier)).get(5,java.util.concurrent.TimeUnit.SECONDS).status());
var quest=quests.activeFor(carrier,null).orElseThrow();
var audit=new java.util.LinkedHashMap<String,String>();
audit.put("final_enemy_id",finalEnemy.toString());audit.put("unit_kind","solo");
audit.put("unit_id",carrier.toString());audit.put("accepted_roster",carrier.toString());
audit.put("arena_shape","circle");audit.put("arena_x_blocks","10");
audit.put("arena_z_blocks","10");audit.put("defeat_world_id",actor.world.getUID().toString());
audit.put("defeat_world_name","Circle Realm");audit.put("defeat_x","12.0");
audit.put("defeat_y","64.0");audit.put("defeat_z","-8.0");
audit.put("defeated_at",java.time.Instant.now().truncatedTo(
java.time.temporal.ChronoUnit.SECONDS).toString());
var plan=new games.dmg.fantasystick.adventure.FishingState.Trophy(receiptId,
games.dmg.fantasystick.adventure.FishingState.TrophyType.CAMP,carrier,
quest.catalog(),quest.site(),quest.id(),UUID.randomUUID(),
"Spoils of Restart Circle","Restart Circle",
Map.of(carrier,new games.dmg.fantasystick.adventure.FishingState.Reward(
java.math.BigInteger.valueOf(100),java.math.BigInteger.valueOf(20))),
games.dmg.fantasystick.adventure.FishingState.TrophyPhase.PREPARED,audit);
var fishing=(games.dmg.fantasystick.adventure.FishingDirectory)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");
assertEquals(games.dmg.fantasystick.adventure.FishingDirectory.TrophyPrepareStatus.APPLIED,
fishing.prepareTrophy(plan).get(5,java.util.concurrent.TimeUnit.SECONDS));
var file=data.resolve("fishing.yml");var saved=java.nio.file.Files.readAllBytes(file);
java.nio.file.Files.delete(file);java.nio.file.Files.createDirectory(file);
var controller=(games.dmg.fantasystick.adventure.FishingController)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");
assertThrows(java.util.concurrent.ExecutionException.class,()->controller
.earnTrophyWithEvidence(plan).toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS));
assertTrue(fishing.trophies(carrier).isEmpty(),"failed commit cannot turn prepared proof into stock");
java.nio.file.Files.delete(file);java.nio.file.Files.write(file,saved);
f.plugin.setEnabled(false);
}
try(var restarted=new FantasyStickLifecycleNativeTest.Fixture(data)){
restarted.plugin.setEnabled(true);
var fishing=(games.dmg.fantasystick.adventure.FishingDirectory)
FantasyStickLifecycleNativeTest.Fixture.field(restarted.plugin,"fishing");
await(restarted,()->!fishing.trophies(carrier).isEmpty());
assertEquals(finalEnemy.toString(),fishing.trophies(carrier).getFirst().audit()
.get("final_enemy_id"));
restarted.plugin.setEnabled(false);
}
var earned=exportedCampEvents(data).stream().filter(event->event.get("type").getAsString()
.endsWith("adventure.trophy_earned")).toList();
assertEquals(1,earned.size());
var details=earned.getFirst().getAsJsonObject("data");
assertEquals(receiptId.toString(),details.get("receipt_id").getAsString());
assertEquals(finalEnemy.toString(),details.get("final_enemy_id").getAsString());
assertEquals("circle",details.getAsJsonObject("arena").get("shape").getAsString());
assertEquals("12.0",details.getAsJsonObject("clear_location").get("x").getAsString());
}
@SuppressWarnings({"unchecked", "rawtypes"})
@@ -533,6 +779,15 @@ class AdventureQuestNativeTest {
}
}
private static List<com.google.gson.JsonObject> exportedCampEvents(Path root)throws Exception{
try(var files=java.nio.file.Files.walk(root.resolve("events"))){
return files.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()).toList();
}
}
private static void redeemAndClaim(FantasyStickLifecycleNativeTest.Fixture f,games.dmg.fantasystick.adventure.FishingController turn,games.dmg.fantasystick.adventure.FishingDirectory trophies,games.dmg.fantasystick.persistence.CharacterStorage storage,FantasyStickLifecycleNativeTest.Actor actor,java.math.BigInteger initialCoins,long xp,long coins)throws Exception{redeem(f,turn,actor);claim(f,turn,trophies,storage,actor,initialCoins,xp,coins);}
private static void redeem(FantasyStickLifecycleNativeTest.Fixture f,games.dmg.fantasystick.adventure.FishingController turn,FantasyStickLifecycleNativeTest.Actor actor)throws Exception{turn.open(actor.player);var menu=actor.inventories.getLast();click(turn,actor,menu,item(menu,"Redeem Spoils of"));await(f,()->actor.inventories.size()>0&&menu!=actor.inventories.getLast());}
private static void claim(FantasyStickLifecycleNativeTest.Fixture f,games.dmg.fantasystick.adventure.FishingController turn,games.dmg.fantasystick.adventure.FishingDirectory trophies,games.dmg.fantasystick.persistence.CharacterStorage storage,FantasyStickLifecycleNativeTest.Actor actor,java.math.BigInteger initialCoins,long xp,long coins)throws Exception{turn.open(actor.player);var menu=actor.inventories.getLast();click(turn,actor,menu,item(menu,"Claim earned quest rewards"));await(f,()->trophies.unclaimed(actor.core.record.id()).equals(games.dmg.fantasystick.adventure.FishingState.Reward.empty()));var saved=storage.find(actor.core.record.id()).get().orElseThrow();assertEquals(java.math.BigInteger.valueOf(xp),saved.experience());assertEquals(initialCoins.add(java.math.BigInteger.valueOf(coins)),saved.coins());}
@@ -37,7 +37,7 @@ class FishingEventBridgePartsNativeTest {
carrier+","+zero,"unit_kind","party","unit_id",UUID.randomUUID().toString()));
var refused=new FishingEventBridge(event->CompletableFuture.completedFuture(
LocalEventLog.Delivery.REJECTED),Clock.systemUTC(),data,mock(EquipmentSessions.class),ignored->{});
refused.championEarned(receipt);refused.close();refused.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);
refused.trophyEarned(receipt);refused.close();refused.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);
var log=LocalEventLog.open(data,Clock.systemUTC(),
new LocalEventLog.RuntimeInfo("test","test","test"),128,1_000_000,ignored->{});
assertEquals(LocalEventLog.Delivery.EXPORTED,log.ready().toCompletableFuture().get(5,TimeUnit.SECONDS));
@@ -63,6 +63,59 @@ class FishingEventBridgePartsNativeTest {
.equals("zero_share_completion")).count());
}
@Test void rejectedCampIntentReplaysSavedArenaAndZeroShareIntoExportedJsonl() throws Exception {
var zero=UUID.randomUUID();var positive=UUID.randomUUID();var receiptId=UUID.randomUUID();
var receipt=new games.dmg.fantasystick.adventure.FishingState.Trophy(receiptId,
games.dmg.fantasystick.adventure.FishingState.TrophyType.CAMP,zero,
UUID.randomUUID(),UUID.randomUUID(),UUID.randomUUID(),UUID.randomUUID(),
"Spoils of Twin Camp","Twin Camp",Map.of(zero,
games.dmg.fantasystick.adventure.FishingState.Reward.empty(),positive,
new games.dmg.fantasystick.adventure.FishingState.Reward(
java.math.BigInteger.ONE,java.math.BigInteger.ZERO)),
games.dmg.fantasystick.adventure.FishingState.TrophyPhase.EARNED,
Map.of("final_enemy_id",UUID.randomUUID().toString(),"accepted_roster",
zero+","+positive,"unit_kind","party","unit_id",UUID.randomUUID().toString(),
"arena_shape","circle","arena_x_blocks","10","arena_z_blocks","10"));
var refused=new FishingEventBridge(event->CompletableFuture.completedFuture(
LocalEventLog.Delivery.REJECTED),Clock.systemUTC(),data,mock(EquipmentSessions.class),ignored->{});
var other=new games.dmg.fantasystick.adventure.FishingState.Trophy(UUID.randomUUID(),
receipt.type(),receipt.carrier(),UUID.randomUUID(),UUID.randomUUID(),UUID.randomUUID(),
UUID.randomUUID(),receipt.name(),receipt.origin(),receipt.rewards(),receipt.phase(),receipt.audit());
refused.trophyEarned(receipt);refused.trophyEarned(other);
refused.close();refused.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);
var log=LocalEventLog.open(data,Clock.systemUTC(),
new LocalEventLog.RuntimeInfo("test","test","test"),128,1_000_000,ignored->{});
log.ready().toCompletableFuture().get(5,TimeUnit.SECONDS);
var recovered=new FishingEventBridge(log::emit,Clock.systemUTC(),data,
mock(EquipmentSessions.class),ignored->{});
recovered.replay();recovered.close();recovered.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);
log.close();
var rows=new ArrayList<com.google.gson.JsonObject>();
try(var files=java.nio.file.Files.walk(data.resolve("events"))){
for(var path:files.filter(value->value.toString().endsWith(".jsonl")).toList())
for(var line:java.nio.file.Files.readAllLines(path))
rows.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
var earned=rows.stream().filter(value->value.get("type").getAsString()
.endsWith("adventure.trophy_earned")).toList();
assertEquals(2,earned.size(),"same-name sites retain independent exported receipts");
assertEquals(2,earned.stream().map(event->event.getAsJsonObject("data")
.get("site_id").getAsString()).distinct().count());
assertEquals(2,earned.stream().map(event->event.getAsJsonObject("data")
.get("receipt_id").getAsString()).distinct().count());
assertEquals(1,earned.stream().map(event->event.getAsJsonObject("data")
.get("trophy_name").getAsString()).distinct().count());
var evidence=earned.stream().map(value->value.getAsJsonObject("data"))
.filter(value->value.get("receipt_id").getAsString().equals(receiptId.toString()))
.findFirst().orElseThrow();
assertEquals("camp_trophy",evidence.get("trophy_type").getAsString());
assertEquals("circle",evidence.getAsJsonObject("arena").get("shape").getAsString());
assertEquals(2,evidence.getAsJsonArray("beneficiaries").size());
assertEquals(1,evidence.getAsJsonArray("beneficiaries").asList().stream()
.filter(value->value.getAsJsonObject().get("allocation_status").getAsString()
.equals("zero_share_completion")).count());
}
@Test void largeChampionRosterAndFrozenBeneficiariesKeepSeparateLinkedProof() throws Exception {
var published=new ArrayList<EventInput>();var failures=new ArrayList<Throwable>();
var bridge=new FishingEventBridge(event->{published.add(event);
@@ -364,6 +364,68 @@ final class FishingQuestNativeTest {
}
}
@Test void transferredCampTrophyShareIsClaimedOnlyByItsCharacterAtNewHolder() throws Exception {
var identities=new UUID[2];var receiptId=UUID.randomUUID();
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var original=new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(original.core.id,original.player);f.activate(original);
var character=original.core.record.id();identities[0]=character;
var fishing=(FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");
var trophy=new FishingState.Trophy(receiptId,FishingState.TrophyType.CAMP,
character,UUID.randomUUID(),UUID.randomUUID(),UUID.randomUUID(),UUID.randomUUID(),
"Spoils of Transfer Camp","Transfer Camp",
Map.of(character,new FishingState.Reward(BigInteger.valueOf(7),BigInteger.valueOf(5))));
assertEquals(FishingDirectory.TrophyPrepareStatus.APPLIED,
fishing.prepareTrophy(trophy).get(5,TimeUnit.SECONDS));
assertEquals(FishingDirectory.TrophyCommitStatus.APPLIED,
fishing.commitTrophy(receiptId).get(5,TimeUnit.SECONDS));
var controller=(FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");
controller.open(original.player);var stale=original.inventories.getLast();
controller.open(original.player);var menu=original.inventories.getLast();
clickFishing(controller,original,stale,offer(stale,"Redeem Spoils of Transfer Camp"));
assertEquals(1,fishing.trophies(character).size(),"stale menu cannot consume camp receipt");
var file=data.resolve("fishing.yml");var savedFishing=java.nio.file.Files.readAllBytes(file);
java.nio.file.Files.delete(file);java.nio.file.Files.createDirectory(file);
clickFishing(controller,original,menu,offer(menu,"Redeem Spoils of Transfer Camp"));
await(f,()->original.messages.stream().anyMatch(message->message.contains("Trophy redemption failed")));
assertEquals(1,fishing.trophies(character).size(),"failed write preserves the exact camp receipt");
assertEquals(FishingState.Reward.empty(),fishing.unclaimed(character));
java.nio.file.Files.delete(file);java.nio.file.Files.write(file,savedFishing);
controller.open(original.player);var retry=original.inventories.getLast();
clickFishing(controller,original,retry,offer(retry,"Redeem Spoils of Transfer Camp"));
await(f,()->fishing.trophies(character).isEmpty()
&&fishing.unclaimed(character).xp().equals(BigInteger.valueOf(7)));
var sessions=(games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.RESTORED,
sessions.exit(original.player));f.awaitCharacterAndReceipt(original);
var newHolder=new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(newHolder.core.id,newHolder.player);identities[1]=newHolder.core.id;
newHolder.core.inventory.setItemInOffHand(original.core.inventory.getItemInOffHand().clone());
f.activate(newHolder);
assertEquals(character,sessions.active(newHolder.core.id).orElseThrow().character());
controller.open(newHolder.player);var claims=newHolder.inventories.getLast();
clickFishing(controller,newHolder,claims,offer(claims,"Claim earned quest rewards"));
await(f,()->fishing.unclaimed(character).equals(FishingState.Reward.empty()));
var saved=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");
await(f,()->saved.find(character).join().orElseThrow().experience()
.equals(BigInteger.valueOf(7)));
assertEquals(original.core.record.coins().add(BigInteger.valueOf(5)),
saved.find(character).get(5,TimeUnit.SECONDS).orElseThrow().coins());
await(f,()->{try{return exportedClaims().size()==1;}
catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}});
f.plugin.setEnabled(false);
}
var claims=exportedClaims();assertEquals(1,claims.size());
var claim=claims.getFirst().getAsJsonObject("data");
assertEquals(identities[0].toString(),claim.get("character_id").getAsString());
assertEquals(identities[1].toString(),claim.get("player_id").getAsString());
assertEquals("camp_trophy",claim.getAsJsonArray("sources").get(0)
.getAsJsonObject().get("source_kind").getAsString());
assertEquals(receiptId.toString(),claim.getAsJsonArray("sources").get(0)
.getAsJsonObject().get("source_receipt_id").getAsString());
}
@Test void exactHorizontalRadiusCreditsOnlyTheValidNativeBobberAndExportsItsGeometry() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 1\nfishing-catch-xp-min: 2\nfishing-catch-xp-max: 2\n");
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){