Compare commits

..
9 Commits
Author SHA1 Message Date
dmg cd51ef127d feat(recovery): publish saved-character recovery events
CI / build (pull_request) Failing after 11m15s
CI / build (push) Failing after 11m16s
2026-10-07 09:18:18 -04:00
dmg b62b7952e6 feat(combat): enrich spacing reward audit evidence
Release / release (push) Failing after 13m51s
CI / build (push) Failing after 13m42s
2026-10-07 13:00:45 +00:00
dmg 39de8516c8 feat(combat): enrich spacing reward audit evidence
CI / build (push) Failing after 10m45s
CI / build (pull_request) Failing after 10m47s
2026-10-07 08:57:43 -04:00
dmg 3031a30d2c feat(characters): merge saved-read audit events
Release / release (push) Failing after 10m34s
Merge verified US-078 branch: 2233 local tests; exact tested tree. Ordinary CI setup failures remain tracked separately; no deployment approval.
2026-10-07 11:54:02 +00:00
dmg b107c08dc3 feat(characters): publish saved-read audit events
CI / build (pull_request) Failing after 14m2s
CI / build (push) Failing after 14m21s
2026-10-07 00:00:11 -04:00
dmg efd64f4f8b Merge pull request 'feat(quests): publish acknowledged paper turn-in events' (#3) from us-070-turn-in-paper-adventure-quests into main
Release / release (push) Failing after 14m58s
2026-10-07 03:18:29 +00:00
dmg 3b9cb407f0 feat(quests): publish acknowledged paper turn-in events
CI / build (pull_request) Failing after 12m49s
CI / build (push) Failing after 13m25s
2026-10-06 23:15:59 -04:00
dmg b4c8e09936 test(fantasy-stick): await switch and fishing acknowledgments
Release / release (push) Canceled after 0s
2026-10-07 02:28:56 +00:00
dmg a630490f5b test(fantasy-stick): await switch and fishing acknowledgments
CI / build (push) Failing after 11m6s
CI / build (pull_request) Failing after 11m5s
2026-10-06 22:28:05 -04:00
24 changed files with 2290 additions and 164 deletions

No files matched your search

@@ -1140,6 +1140,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
var inspectionEvents = new games.dmg.fantasystick.events.ActiveInspectionEventBridge(
input -> { inspectionLog.emit(input); }, Clock.systemUTC());
books.attachInspectionEvents(inspectionEvents);
var savedEvents = new games.dmg.fantasystick.events.SavedCharacterEventBridge(
input -> { inspectionLog.emit(input); }, Clock.systemUTC());
admin.attachSavedEvents(savedEvents);
books.attachSavedEvents(savedEvents);
admin.attachAdminAudit(adminActionEvents);
characterRecoveryEvents = new games.dmg.fantasystick.events.CharacterRecoveryEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
@@ -1157,7 +1161,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
});
eventCapture.excludeOwnedTeleport(adventureQuestCommands::ownsTeleport);
eventCapture.excludeOwnedTeleport(dungeonCommands::ownsTeleport);
adminActionObserver = new games.dmg.fantasystick.events.AdminCommandEntryObserver(adminCommand, adminActionEvents, inspectionEvents);
adminActionObserver = new games.dmg.fantasystick.events.AdminCommandEntryObserver(adminCommand, adminActionEvents, inspectionEvents, savedEvents);
getServer().getPluginManager().registerEvents(adminActionObserver, this);
var replayAdminActions = adminActionEvents;
adminActionDrain.whenComplete((ignored, failure) -> {
@@ -126,7 +126,7 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
private void configure(Villager villager,AdventureQuestState.Giver value){var data=villager.getPersistentDataContainer();data.set(typeKey,PersistentDataType.STRING,GIVER);data.set(idKey,PersistentDataType.STRING,value.id().toString());villager.setAI(false);villager.setGravity(false);villager.setSilent(true);villager.setInvulnerable(true);villager.setCollidable(false);villager.setCanPickupItems(false);villager.setLeashHolder(null);villager.setRemoveWhenFarAway(false);villager.setAdult();villager.setAgeLock(true);villager.customName(net.kyori.adventure.text.Component.text(value.displayName()));villager.setCustomNameVisible(true);}
public void open(Player player){open(player,0);}
private void open(Player player,int requestedPage){open(player,requestedPage,null);}
private void open(Player player,int requestedPage,AdventureQuestState.Giver giver){var context=context(player);if(context==null){player.sendMessage("Quest Giver refused: actively play a living fantasy character first.");return;}var pending=directory.pendingClaim(context.character);if(pending.isPresent()){var record=sessions.currentRecord(context.active).orElse(null);if(record==null){player.sendMessage("Reward claim recovery requires the exact active character.");return;}attemptClaim(player,context.active,pending.orElseThrow(),record);return;}directory.ensureToday().whenComplete((catalog,failure)->schedule(()->{var currentContext=context(player);if(failure!=null||currentContext==null||!currentContext.active.equals(context.active)){player.sendMessage("Daily quests are unavailable.");return;}if(directory.snapshot().reset()!=null){publishPendingReset();player.sendMessage("Daily adventure reset is completing; try again.");return;}int pages=Math.max(1,(catalog.sites().size()+44)/45),page=Math.max(0,Math.min(requestedPage,pages-1));int count=Math.min(45,Math.max(0,catalog.sites().size()-page*45));int size=pages>1?54:Math.min(54,Math.max(18,((count+8)/9)*9+9));var inventory=plugin.getServer().createInventory(new MenuHolder(catalog.revision(),page,giver),size,net.kyori.adventure.text.Component.text("Daily Adventure Quests"));int slot=0;for(UUID siteId:catalog.sites().stream().skip(page*45L).limit(45).toList()){var site=directory.findSite(siteId).orElse(null);var item=new ItemStack(Material.PAPER);var meta=item.getItemMeta();String name=site==null?"Unavailable site":site.displayName();meta.displayName(net.kyori.adventure.text.Component.text(name));var active=directory.activeFor(context.character,context.partyId).orElse(null);var unclaimed=directory.unclaimed(catalog.revision(),siteId,context.character);boolean rewardsReady=unclaimed.xp().signum()>0||unclaimed.coins().signum()>0;String state=rewardsReady?"Rewards ready — click to claim":active!=null&&active.site().equals(siteId)?"Active — right-click to abandon":active!=null?"Another quest is active":directory.characterClaimed(catalog.revision(),siteId,context.character)?directory.rewardClaimed(catalog.revision(),siteId,context.character)?"Already claimed":"Completed with nothing owed":site==null||!directory.offerOpen(siteId)||!directory.available(site)?"Unavailable / cleared":"Available — click to accept";String kind=site==null?"Unavailable":switch(site.type()){case CAMP->"Monster camp";case CHAMPION->"Champion";case DUNGEON->"Dungeon";case FISHING->"Fishing";};var reward=site==null?new AdventureQuestDirectory.Reward(BigInteger.ZERO,BigInteger.ZERO):directory.reward(site);var rewardXp=rewardsReady?unclaimed.xp():active!=null&&active.site().equals(siteId)?active.rewardXp():reward.xp();var rewardCoins=rewardsReady?unclaimed.coins():active!=null&&active.site().equals(siteId)?active.rewardCoins():reward.coins();String rewardLabel=rewardsReady?"Banked reward: ":site!=null&&site.type()==AdventureQuestState.SiteType.DUNGEON?"Completion trophy pool: ":"Reward: ";var lore=new ArrayList<net.kyori.adventure.text.Component>();lore.add(net.kyori.adventure.text.Component.text(kind));var fishingTerms=active!=null&&active.site().equals(siteId)&&active.fishing()!=null?active.fishing():site==null||site.fishing()==null?null:site.fishing().terms();if(fishingTerms!=null){lore.add(net.kyori.adventure.text.Component.text("Item: "+fishingTerms.itemName()+" × "+fishingTerms.required()));lore.add(net.kyori.adventure.text.Component.text("Catch chance: "+fishingTerms.chancePercent().toPlainString()+"%"));if(active!=null&&active.site().equals(siteId)&&fishing!=null)lore.add(net.kyori.adventure.text.Component.text("Held: "+fishing.quantity(context.character,fishingTerms.itemId())+" / "+fishingTerms.required()));}lore.add(net.kyori.adventure.text.Component.text(rewardLabel+rewardXp+" XP, "+rewardCoins+" coins"));if(site!=null&&site.type()==AdventureQuestState.SiteType.DUNGEON)lore.add(net.kyori.adventure.text.Component.text("Mob kills: additional variable XP, coins and native loot"));if(site!=null){lore.add(net.kyori.adventure.text.Component.text("Mode: "+site.participationMode().value()));if(active==null&&!rewardsReady&&!directory.characterClaimed(catalog.revision(),siteId,context.character)&&directory.offerOpen(siteId)&&directory.available(site))acceptanceLore(lore,site.participationMode(),context);}lore.add(net.kyori.adventure.text.Component.text("Expires: "+catalog.expiresAt()));lore.add(net.kyori.adventure.text.Component.text(state));meta.lore(lore);meta.getPersistentDataContainer().set(menuSiteKey,PersistentDataType.STRING,siteId.toString());meta.getPersistentDataContainer().set(menuCatalogKey,PersistentDataType.STRING,catalog.revision().toString());item.setItemMeta(meta);inventory.setItem(slot++,item);}if(pages>1){if(page>0)inventory.setItem(45,pageItem(page-1,"Previous page"));if(page+1<pages)inventory.setItem(53,pageItem(page+1,"Next page"));}if(fishing!=null){var turnIn=new ILine truncated
private void open(Player player,int requestedPage,AdventureQuestState.Giver giver){var context=context(player);if(context==null){player.sendMessage("Quest Giver refused: actively play a living fantasy character first.");return;}var pending=directory.pendingClaim(context.character);if(pending.isPresent()){var record=sessions.currentRecord(context.active).orElse(null);if(record==null){player.sendMessage("Reward claim recovery requires the exact active character.");return;}var request=fishingEvents==null?null:fishingEvents.turnIn(player,giver,"legacy_recovery");attemptClaim(player,context.active,pending.orElseThrow(),record,giver,request,fishingEvents==null?null:fishingEvents.actor(player));return;}directory.ensureToday().whenComplete((catalog,failure)->schedule(()->{var currentContext=context(player);if(failure!=null||currentContext==null||!currentContext.active.equals(context.active)){player.sendMessage("Daily quests are unavailable.");return;}if(directory.snapshot().reset()!=null){publishPendingReset();player.sendMessage("Daily adventure reset is completing; try again.");return;}int pages=Math.max(1,(catalog.sites().size()+44)/45),page=Math.max(0,Math.min(requestedPage,pages-1));int count=Math.min(45,Math.max(0,catalog.sites().size()-page*45));int size=pages>1?54:Math.min(54,Math.max(18,((count+8)/9)*9+9));var inventory=plugin.getServer().createInventory(new MenuHolder(catalog.revision(),page,giver),size,net.kyori.adventure.text.Component.text("Daily Adventure Quests"));int slot=0;for(UUID siteId:catalog.sites().stream().skip(page*45L).limit(45).toList()){var site=directory.findSite(siteId).orElse(null);var item=new ItemStack(Material.PAPER);var meta=item.getItemMeta();String name=site==null?"Unavailable site":site.displayName();meta.displayName(net.kyori.adventure.text.Component.text(name));var active=directory.activeFor(context.character,context.partyId).orElse(null);var unclaimed=directory.unclaimed(catalog.revision(),siteId,context.character);boolean rewardsReady=unclaimed.xp().signum()>0||unclaimed.coins().signum()>0;String state=rewardsReady?"Rewards ready — click to claim":active!=null&&active.site().equals(siteId)?"Active — right-click to abandon":active!=null?"Another quest is active":directory.characterClaimed(catalog.revision(),siteId,context.character)?directory.rewardClaimed(catalog.revision(),siteId,context.character)?"Already claimed":"Completed with nothing owed":site==null||!directory.offerOpen(siteId)||!directory.available(site)?"Unavailable / cleared":"Available — click to accept";String kind=site==null?"Unavailable":switch(site.type()){case CAMP->"Monster camp";case CHAMPION->"Champion";case DUNGEON->"Dungeon";case FISHING->"Fishing";};var reward=site==null?new AdventureQuestDirectory.Reward(BigInteger.ZERO,BigInteger.ZERO):directory.reward(site);var rewardXp=rewardsReady?unclaimed.xp():active!=null&&active.site().equals(siteId)?active.rewardXp():reward.xp();var rewardCoins=rewardsReady?unclaimed.coins():active!=null&&active.site().equals(siteId)?active.rewardCoins():reward.coins();String rewardLabel=rewardsReady?"Banked reward: ":site!=null&&site.type()==AdventureQuestState.SiteType.DUNGEON?"Completion trophy pool: ":"Reward: ";var lore=new ArrayList<net.kyori.adventure.text.Component>();lore.add(net.kyori.adventure.text.Component.text(kind));var fishingTerms=active!=null&&active.site().equals(siteId)&&active.fishing()!=null?active.fishing():site==null||site.fishing()==null?null:site.fishing().terms();if(fishingTerms!=null){lore.add(net.kyori.adventure.text.Component.text("Item: "+fishingTerms.itemName()+" × "+fishingTerms.required()));lore.add(net.kyori.adventure.text.Component.text("Catch chance: "+fishingTerms.chancePercent().toPlainString()+"%"));if(active!=null&&active.site().equals(siteId)&&fishing!=null)lore.add(net.kyori.adventure.text.Component.text("Held: "+fishing.quantity(context.character,fishingTerms.itemId())+" / "+fishingTerms.required()));}lore.add(net.kyori.adventure.text.Component.text(rewardLabel+rewardXp+" XP, "+rewardCoins+" coins"));if(site!=null&&site.type()==AdventureQuestState.SiteType.DUNGEON)lore.add(net.kyori.adventure.text.Component.text("Mob kills: additional variable XP, coins and native loot"));if(site!=null){lore.add(net.kyori.adventure.text.Component.text("Mode: "+site.participationMode().value()));if(active==null&&!rewardsReady&&!directory.characterClaimed(catalog.revision(),siteId,context.character)&&directory.offerOpen(siteId)&&directory.available(site))acceptanceLore(lore,site.participationMode(),context);}lore.add(net.kyori.adventure.text.Component.text("Expires: "+catalog.expiresAt()));lore.add(net.kyori.adventure.text.Component.text(state));meta.lore(lore);meta.getPersistentDataContainer().set(menuSiteKey,PersistentDataType.STRING,siteId.toString());meta.getPersistentDataContainer().set(menuCatalogKey,PersistentDataType.STRING,catalog.revision().toString());item.setItemMeta(meta);inventory.setItem(slot++,item);}if(pages>1){if(page>0)Line truncated
private ItemStack pageItem(int page,String name){var item=new ItemStack(Material.ARROW);var meta=item.getItemMeta();meta.displayName(net.kyori.adventure.text.Component.text(name));meta.getPersistentDataContainer().set(menuPageKey,PersistentDataType.INTEGER,page);item.setItemMeta(meta);return item;}
private static games.dmg.fantasystick.events.CampEventBridge.Actor championQuestActor(games.dmg.fantasystick.events.CampEventBridge.Actor actor,AdventureQuestState.Giver giver){
var facts=new LinkedHashMap<String,Object>(actor==null?Map.of("actor_kind","unavailable"):actor.captured());
@@ -138,9 +138,57 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
private record Context(EquipmentSessions.Active active,UUID character,UUID partyId,boolean leader,Set<UUID> members){}
private Context context(Player player){var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return null;var record=sessions.currentRecord(active).orElse(null);if(record==null||record.dead()||player.isDead())return null;var party=parties.partyOf(player.getUniqueId()).orElse(null);if(party==null)return new Context(active,record.id(),null,true,Set.of(record.id()));return new Context(active,record.id(),party.id(),party.leaderCharacter().equals(record.id()),Set.copyOf(party.characters()));}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void interact(PlayerInteractEntityEvent event){if(!giver(event.getRightClicked()))return;event.setCancelled(true);if(event.getHand()!=EquipmentSlot.HAND)return;UUID id=id(event.getRightClicked(),idKey);if(id!=null)directory.findGiver(id).ifPresent(giver->open(event.getPlayer(),0,giver));}
@EventHandler(priority=EventPriority.HIGHEST)public void click(InventoryClickEvent event){if(!(event.getInventory().getHolder() instanceof MenuHolder holder))return;event.setCancelled(true);if(!(event.getWhoClicked() instanceof Player player)||event.getCurrentItem()==null)return;var data=event.getCurrentItem().getPersistentDataContainer();Integer page=data.get(menuPageKey,PersistentDataType.INTEGER);if(page!=null){open(player,page,holder.giver);return;}if(data.has(menuFishingKey,PersistentDataType.INTEGER)&&fishing!=null){var now=directory.snapshot();if(now.reset()!=null||now.catalog()==null||!holder.catalog.equals(now.catalog().revision())){player.sendMessage("Quest item entry refused: the Quest Giver menu expired.");return;}fishing.open(player,holder.giver);return;}UUID site=parse(data.get(menuSiteKey,PersistentDataType.STRING)),catalog=parse(data.get(menuCatalogKey,PersistentDataType.STRING));if(site==null||catalog==null||!holder.catalog.equals(catalog))return;var context=context(player);if(context==null){player.sendMessage("Quest action refused: stale fantasy session.");return;}var active=directory.activeFor(context.character,context.partyId).orElse(null);var unclaimed=directory.unclaimed(catalog,site,context.character);if(unclaimed.xp().signum()>0||unclaimed.coins().signum()>0){claim(player,context,catalog,site);return;}if(active!=null){if(!active.site().equals(site)){player.sendMessage("Quest action refused: another quest is active.");return;}if(!event.isRightClick()){player.sendMessage("This quest is active; right-click its Paper to abandon it.");return;}var audit=campEvents;var championAudit=championEvents;var actor=audit==null?null:audit.actor(player,null);var championActor=championQuestActor(actor,holder.giver);UUID operation=UUID.randomUUID();var capturedSite=directory.findSite(active.site()).orElse(null);var dungeonAudit=dungeonEvents;var originalDungeon=captureDungeonDefinition(active.site());directory.abandon(active.id(),active.unit()).whenComplete((changed,failure)->{if(failure==null&&changed){if(audit!=null)audit.questTransition("adventure.quest.abandoned",operation,actor,active,capturedSite,null,"player_abandoned");if(championAudit!=null)championAudit.questTransition("adventure.quest.abandoned",operation,championActor,active,capturedSite,null,"player_abandoned");if(dungeonAudit!=null)try{dungeonAudit.questTransition("adventure.quest.abandoned",operation,championActor,active,capturedSite,originalDungeon,"player_abandoned");}catch(RuntimeException ignored){}}schedule(()->{refreshTracking(player);player.sendMessage(failure==null&&changed?"Adventure quest abandoned.":"Quest abandonment failed; retained state is unchanged.");open(player);});});return;}if(directory.characterClaimed(catalog,site,context.character)){player.sendMessage(directory.rewardClaimed(catalog,site,context.character)?"Adventure rewards were already claimed.":"Adventure complete with no reward owed.");return;}if(!directory.offerOpen(site)||directory.findSite(site).filter(directory::available).isEmpty()){player.sendMessage("Quest action refused: this offer is unavailable.");return;}var offered=directory.findSite(site).orElseThrow();var type=event.isRightClick()?AdventureQuestState.UnitType.PARTY:AdventureQuestState.UnitType.SOLO;if(!offered.participationMode().permits(type)){player.sendMessage("Quest acceptance refused: this site is "+offered.participationMode().value()+".");return;}if(type==AdventureQuestState.UnitType.PARTY&&(context.partyId==null||!context.leader)){player.sendMessage("Only the active party leader may accept a party quest.");return;}var unit=type==AdventureQuestState.UnitType.SOLO?context.character:context.partyId;var members=type==AdventureQuestState.UnitType.SOLO?Set.of(context.character):context.members;var campAudit=campEvents;var championAudit=championEvents;var campActor=campAudit==null?null:campAudit.actor(player,null);var championActor=championQuestActor(campActor,holder.giver);var dungeonAudit=dungeonEvents;var originalDungeon=captureDungeonDefinition(site);UUID operation=UUID.randomUUID();var trackingAudit=questTrackingEvents;var trackingAcceptance=captureTrackingAcceptance(operation,player,members);directory.accept(catalog,site,type,unit,members,offered.participationMode()).whenComplete((result,failure)->{if(failure==null&&result.status()==AdventureQuestDirectory.Acceptance.APPLIED&&trackingAudit!=null&&trackingAcceptance!=null)try{trackingAudit.accepted(trackingAcceptance,result.quest().orElseThrow());}catch(RuntimeException ignored){}schedule(()->{if(failure!=null){player.sendMessage("Quest acceptance failed; retained state is unchanged.");return;}if(result.status()!=AdventureQuestDirectory.Acceptance.APPLIED){player.sendMessage("Quest acceptance refused: "+result.status().name().toLowerCase(Locale.ROOT)+".");return;}var quest=result.quest().orElseThrow();var current=context(player);boolean same=current!=null&&current.active.equals(context.active)&&current.character.equals(context.character)Line truncated
private void claim(Player player,Context expected,UUID catalog,UUID site){directory.beginClaim(catalog,site,expected.character).whenComplete((operation,failure)->schedule(()->{if(failure!=null||operation.isEmpty()){player.sendMessage("Reward claim failed; retained rewards are unchanged.");return;}var current=context(player);if(current==null||!current.active.equals(expected.active)||!current.character.equals(operation.orElseThrow().character())){player.sendMessage("Reward claim paused: actively play the exact living character and try again.");return;}var record=sessions.currentRecord(current.active).orElse(null);if(record==null){player.sendMessage("Reward claim paused: the current character changed.");return;}attemptClaim(player,current.active,operation.orElseThrow(),record);}));}
private void attemptClaim(Player player,EquipmentSessions.Active active,AdventureQuestState.RewardClaim operation,games.dmg.fantasystick.persistence.CharacterRecord record){characters.awardCombatReward(record.id(),record.revision(),operation.id(),operation.xp(),operation.coins(),progression,games.dmg.fantasystick.progression.CombatRewardRules.defaults(),null).whenComplete((award,failure)->{if(failure!=null){schedule(()->player.sendMessage("Reward claim failed; retained rewards are unchanged."));return;}if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){attemptClaim(player,active,operation,award.record().orElseThrow());return;}if(award.record().isEmpty()){schedule(()->player.sendMessage("Reward claim failed; retained rewards are unchanged."));return;}var saved=award.record().orElseThrow();directory.finishClaim(operation.id()).whenComplete((finished,finishFailure)->schedule(()->{if(finishFailure!=null||!finished){player.sendMessage("Reward claim is pending recovery; retry at a Quest Giver.");return;}var holder=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);if(award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE&&holder!=null)sessions.acknowledgeProgress(holder,saved.revision().subtract(BigInteger.ONE),saved);var currentPlayer=holder==null?null:plugin.getServer().getPlayer(holder.player());var current=currentPlayer==null?null:context(currentPlayer);if(current!=null&&current.character.equals(operation.character())){currentPlayer.sendMessage("Claimed "+operation.xp()+" fantasy XP and "+operation.coins()+" character coins.");open(currentPlayer);}}));});}
@EventHandler(priority=EventPriority.HIGHEST)public void click(InventoryClickEvent event){if(!(event.getInventory().getHolder() instanceof MenuHolder holder))return;event.setCancelled(true);if(!(event.getWhoClicked() instanceof Player player)||event.getCurrentItem()==null)return;var data=event.getCurrentItem().getPersistentDataContainer();Integer page=data.get(menuPageKey,PersistentDataType.INTEGER);if(page!=null){open(player,page,holder.giver);return;}if(data.has(menuFishingKey,PersistentDataType.INTEGER)&&fishing!=null){var now=directory.snapshot();if(now.reset()!=null||now.catalog()==null||!holder.catalog.equals(now.catalog().revision())){player.sendMessage("Quest item entry refused: the Quest Giver menu expired.");return;}fishing.open(player,holder.giver);return;}UUID site=parse(data.get(menuSiteKey,PersistentDataType.STRING)),catalog=parse(data.get(menuCatalogKey,PersistentDataType.STRING));if(site==null||catalog==null||!holder.catalog.equals(catalog))return;var context=context(player);if(context==null){player.sendMessage("Quest action refused: stale fantasy session.");return;}var active=directory.activeFor(context.character,context.partyId).orElse(null);var unclaimed=directory.unclaimed(catalog,site,context.character);if(unclaimed.xp().signum()>0||unclaimed.coins().signum()>0){claim(player,context,catalog,site,holder.giver);return;}if(active!=null){if(!active.site().equals(site)){player.sendMessage("Quest action refused: another quest is active.");return;}if(!event.isRightClick()){player.sendMessage("This quest is active; right-click its Paper to abandon it.");return;}var audit=campEvents;var championAudit=championEvents;var actor=audit==null?null:audit.actor(player,null);var championActor=championQuestActor(actor,holder.giver);UUID operation=UUID.randomUUID();var capturedSite=directory.findSite(active.site()).orElse(null);var dungeonAudit=dungeonEvents;var originalDungeon=captureDungeonDefinition(active.site());directory.abandon(active.id(),active.unit()).whenComplete((changed,failure)->{if(failure==null&&changed){if(audit!=null)audit.questTransition("adventure.quest.abandoned",operation,actor,active,capturedSite,null,"player_abandoned");if(championAudit!=null)championAudit.questTransition("adventure.quest.abandoned",operation,championActor,active,capturedSite,null,"player_abandoned");if(dungeonAudit!=null)try{dungeonAudit.questTransition("adventure.quest.abandoned",operation,championActor,active,capturedSite,originalDungeon,"player_abandoned");}catch(RuntimeException ignored){}}schedule(()->{refreshTracking(player);player.sendMessage(failure==null&&changed?"Adventure quest abandoned.":"Quest abandonment failed; retained state is unchanged.");open(player);});});return;}if(directory.characterClaimed(catalog,site,context.character)){player.sendMessage(directory.rewardClaimed(catalog,site,context.character)?"Adventure rewards were already claimed.":"Adventure complete with no reward owed.");return;}if(!directory.offerOpen(site)||directory.findSite(site).filter(directory::available).isEmpty()){player.sendMessage("Quest action refused: this offer is unavailable.");return;}var offered=directory.findSite(site).orElseThrow();var type=event.isRightClick()?AdventureQuestState.UnitType.PARTY:AdventureQuestState.UnitType.SOLO;if(!offered.participationMode().permits(type)){player.sendMessage("Quest acceptance refused: this site is "+offered.participationMode().value()+".");return;}if(type==AdventureQuestState.UnitType.PARTY&&(context.partyId==null||!context.leader)){player.sendMessage("Only the active party leader may accept a party quest.");return;}var unit=type==AdventureQuestState.UnitType.SOLO?context.character:context.partyId;var members=type==AdventureQuestState.UnitType.SOLO?Set.of(context.character):context.members;var campAudit=campEvents;var championAudit=championEvents;var campActor=campAudit==null?null:campAudit.actor(player,null);var championActor=championQuestActor(campActor,holder.giver);var dungeonAudit=dungeonEvents;var originalDungeon=captureDungeonDefinition(site);UUID operation=UUID.randomUUID();var trackingAudit=questTrackingEvents;var trackingAcceptance=captureTrackingAcceptance(operation,player,members);directory.accept(catalog,site,type,unit,members,offered.participationMode()).whenComplete((result,failure)->{if(failure==null&&result.status()==AdventureQuestDirectory.Acceptance.APPLIED&&trackingAudit!=null&&trackingAcceptance!=null)try{trackingAudit.accepted(trackingAcceptance,result.quest().orElseThrow());}catch(RuntimeException ignored){}schedule(()->{if(failure!=null){player.sendMessage("Quest acceptance failed; retained state is unchanged.");return;}if(result.status()!=AdventureQuestDirectory.Acceptance.APPLIED){player.sendMessage("Quest acceptance refused: "+result.status().name().toLowerCase(Locale.ROOT)+".");return;}var quest=result.quest().orElseThrow();var current=context(player);boolean same=current!=null&&current.active.equals(context.active)&&current.character.equals(conteLine truncated
private void legacyResult(games.dmg.fantasystick.events.FishingEventBridge.TurnInAction request,String outcome,String reason){
if(fishingEvents!=null)fishingEvents.turnInResolved(request,outcome,reason);
}
private void claim(Player player,Context expected,UUID catalog,UUID site,AdventureQuestState.Giver giver){
var request=fishingEvents==null?null:fishingEvents.turnIn(player,giver,"legacy_claim");
var actor=fishingEvents==null?null:fishingEvents.actor(player);
directory.beginClaim(catalog,site,expected.character).whenComplete((operation,failure)->schedule(()->{
if(failure!=null||operation.isEmpty()){
legacyResult(request,failure==null?"refused":"failed",failure==null?"no_unlocked_rewards":"source_write_failed");
player.sendMessage("Reward claim failed; retained rewards are unchanged.");return;
}
var current=context(player);
if(current==null||!current.active.equals(expected.active)||!current.character.equals(operation.orElseThrow().character())){
legacyResult(request,"refused","stale_or_foreign_character");
player.sendMessage("Reward claim paused: actively play the exact living character and try again.");return;
}
var record=sessions.currentRecord(current.active).orElse(null);
if(record==null){legacyResult(request,"refused","character_unavailable");player.sendMessage("Reward claim paused: the current character changed.");return;}
attemptClaim(player,current.active,operation.orElseThrow(),record,giver,request,actor);
}));
}
private void attemptClaim(Player player,EquipmentSessions.Active active,AdventureQuestState.RewardClaim operation,
games.dmg.fantasystick.persistence.CharacterRecord record,AdventureQuestState.Giver giver,
games.dmg.fantasystick.events.FishingEventBridge.TurnInAction request,
games.dmg.fantasystick.events.FishingEventBridge.Actor actor){
characters.awardCombatReward(record.id(),record.revision(),operation.id(),operation.xp(),operation.coins(),progression,games.dmg.fantasystick.progression.CombatRewardRules.defaults(),null).whenComplete((award,failure)->{
if(failure!=null){legacyResult(request,"failed","source_write_failed");schedule(()->player.sendMessage("Reward claim failed; retained rewards are unchanged."));return;}
if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){
attemptClaim(player,active,operation,award.record().orElseThrow(),giver,request,actor);return;
}
if(award.record().isEmpty()||award.status()==CharacterStorage.CombatAwardStatus.UNAVAILABLE){
legacyResult(request,"refused","character_unavailable");schedule(()->player.sendMessage("Reward claim failed; retained rewards are unchanged."));return;
}
var saved=award.record().orElseThrow();
var sources=directory.snapshot().rewards().allocations().stream().filter(value->operation.allocations().contains(value.id())).toList();
directory.finishClaim(operation.id()).whenComplete((finished,finishFailure)->{
if(finishFailure!=null||!finished){
legacyResult(request,"uncertain",finishFailure==null?"claim_already_settled":"source_write_failed");
schedule(()->player.sendMessage("Reward claim is pending recovery; retry at a Quest Giver."));return;
}
if(fishingEvents!=null)fishingEvents.legacyClaimed(operation,actor,giver,record,award,sources,request);
schedule(()->{
var holder=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);
if(award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE&&holder!=null)sessions.acknowledgeProgress(holder,saved.revision().subtract(BigInteger.ONE),saved);
var currentPlayer=holder==null?null:plugin.getServer().getPlayer(holder.player());var current=currentPlayer==null?null:context(currentPlayer);
if(current!=null&&current.character.equals(operation.character())){currentPlayer.sendMessage("Claimed "+operation.xp()+" fantasy XP and "+operation.coins()+" character coins.");open(currentPlayer);}
});
});
});
}
public boolean trackingAvailable(Player player){var context=context(player);return context!=null&&directory.activeFor(context.character,context.partyId).filter(quest->quest.snapshot().contains(context.character)).isPresent();}
public void tracking(Player player,Boolean enabled){
var audit=questTrackingEvents;var initial=audit==null?null:audit.request(player,enabled);
@@ -70,11 +70,46 @@ public final class FishingController implements Listener,AutoCloseable,Adventure
public boolean earnedTrophy(UUID id){return fishing.trophy(id)
.map(value->value.phase()==FishingState.TrophyPhase.EARNED)
.orElseGet(()->fishing.snapshot().trophyReceipts().contains(id));}
public void open(Player player){open(player,0,null);}
public void open(Player player,AdventureQuestState.Giver giver){open(player,0,giver);}
public void open(Player player){open(player,null);}
public void open(Player player,AdventureQuestState.Giver giver){
var request=fishingEvents==null?null:fishingEvents.turnIn(player,giver,"view");
boolean eligible=context(player)!=null;
open(player,0,giver);
if(fishingEvents!=null)fishingEvents.turnInResolved(request,eligible?"viewed":"refused",
eligible?null:"inactive_or_dead_character");
}
private void resolved(games.dmg.fantasystick.events.FishingEventBridge.TurnInAction request,String outcome,String reason){
if(fishingEvents!=null)fishingEvents.turnInResolved(request,outcome,reason);
}
private void open(Player player,int requestedPage,AdventureQuestState.Giver giver){
if(giver==null)menuGivers.remove(player.getUniqueId());else menuGivers.put(player.getUniqueId(),giver);var context=context(player);if(context==null){player.sendMessage("Quest item turn-in requires an active living character.");return;}for(var pending:fishing.pendingCatches())if(pending.character().equals(context.record().id()))recoverCatch(pending);var entries=new ArrayList<ItemStack>();var quest=fishingQuest(context);if(quest!=null){var terms=quest.fishing();entries.add(item(Material.EMERALD,"Submit "+terms.itemName(),"submit",List.of("Held: "+fishing.quantity(context.record().id(),terms.itemId())+" / "+terms.required(),"Reward: "+terms.rewardXp()+" XP, "+terms.rewardCoins()+" coins",quest.unitType()==AdventureQuestState.UnitType.PARTY?"One accepted member supplies all items":"Individual submission","Beneficiaries: "+beneficiaries(context,quest).size()+" accepted character(s)")));}for(var trophy:fishing.trophies(context.record().id())){var total=trophy.total();entries.add(item(Material.PLAYER_HEAD,"Redeem "+trophy.name(),"trophy:"+trophy.id(),List.of("Origin: "+trophy.origin(),"Reward pool: "+total.xp()+" XP, "+total.coins()+" coins","Beneficiaries: "+trophy.rewards().size()+" frozen character(s)")));}var legacy=adventures.unclaimedAll(context.record().id());if(legacy.xp().signum()>0||legacy.coins().signum()>0)entries.add(item(Material.NETHER_STAR,"Claim retained adventure rewards","legacy-claim",List.of(legacy.xp()+" XP, "+legacy.coins()+" coins","Previously earned shares do not expire")));var reward=fishing.unclaimed(context.record().id());if(reward.xp().signum()>0||reward.coins().signum()>0)entries.add(item(Material.GOLD_INGOT,"Claim earned quest rewards","claim",List.of(reward.xp()+" XP, "+reward.coins()+" coins","Earned shares do not expire")));int pages=Math.max(1,(entries.size()+44)/45),page=Math.max(0,Math.min(requestedPage,pages-1));UUID nonce=UUID.randomUUID();menus.put(player.getUniqueId(),nonce);var inventory=plugin.getServer().createInventory(new Holder(player.getUniqueId(),context.active().session(),context.record().id(),nonce,page),54,net.kyori.adventure.text.Component.text("Turn in quest items "+(page+1)+"/"+pages));int start=page*45;for(int index=start;index<Math.min(entries.size(),start+45);index++)inventory.setItem(index-start,entries.get(index));if(entries.isEmpty())inventory.setItem(22,item(Material.BARRIER,"No eligible fishing items or rewards",null,List.of()));if(page>0)inventory.setItem(45,item(Material.ARROW,"Previous","page:"+(page-1),List.of()));if(page+1<pages)inventory.setItem(53,item(Material.ARROW,"Next","page:"+(page+1),List.of()));player.openInventory(inventory);}
@EventHandler(priority=EventPriority.HIGHEST)public void click(InventoryClickEvent event){if(!(event.getInventory().getHolder() instanceof Holder holder))return;event.setCancelled(true);if(!(event.getWhoClicked() instanceof Player player)||event.getCurrentItem()==null||closed)return;String action=event.getCurrentItem().getItemMeta().getPersistentDataContainer().get(actionKey,PersistentDataType.STRING);if(action==null)return;var context=context(player);if(context==null||!holder.player().equals(player.getUniqueId())||!holder.session().equals(context.active().session())||!holder.character().equals(context.record().id())||!menus.remove(player.getUniqueId(),holder.nonce())){player.sendMessage("Quest item action refused: the menu, character or session changed.");return;}if(action.startsWith("page:")){try{open(player,Integer.parseInt(action.substring(5)),menuGivers.get(player.getUniqueId()));}catch(NumberFormatException invalid){player.sendMessage("Quest item page refused.");}return;}if(action.startsWith("trophy:")){redeemTrophy(player,context,action.substring(7));return;}if(action.equals("legacy-claim")){claimLegacy(player,context);return;}if(action.equals("claim")){claim(player,context);return;}if(action.equals("submit"))submit(player,context);}
@EventHandler(priority=EventPriority.HIGHEST)public void click(InventoryClickEvent event){
if(!(event.getInventory().getHolder() instanceof Holder holder))return;
event.setCancelled(true);
if(!(event.getWhoClicked() instanceof Player player)||event.getCurrentItem()==null||closed)return;
String action=event.getCurrentItem().getItemMeta().getPersistentDataContainer().get(actionKey,PersistentDataType.STRING);
if(action==null)return;
String kind=action.startsWith("page:")?"navigation":action.startsWith("trophy:")?"redeem"
:action.equals("legacy-claim")?"legacy_claim":action.equals("claim")?"claim":action.equals("submit")?"submit":"unsupported";
var request=fishingEvents==null?null:fishingEvents.turnIn(player,menuGivers.get(player.getUniqueId()),kind);
var context=context(player);
if(context==null||!holder.player().equals(player.getUniqueId())||!holder.session().equals(context.active().session())
||!holder.character().equals(context.record().id())||!menus.remove(player.getUniqueId(),holder.nonce())){
resolved(request,"refused","stale_or_foreign_menu");
player.sendMessage("Quest item action refused: the menu, character or session changed.");return;
}
if(action.startsWith("page:")){
try{open(player,Integer.parseInt(action.substring(5)),menuGivers.get(player.getUniqueId()));resolved(request,"viewed",null);}
catch(NumberFormatException invalid){resolved(request,"refused","invalid_page");player.sendMessage("Quest item page refused.");}return;
}
// Dispatch is not acknowledgment: the owning durable domain record alone denotes business success.
resolved(request,"pending","awaiting_authoritative_domain_result");
if(action.startsWith("trophy:")){redeemTrophy(player,context,action.substring(7),request);return;}
if(action.equals("legacy-claim")){claimLegacy(player,context,request);return;}
if(action.equals("claim")){claim(player,context,request);return;}
if(action.equals("submit")){submit(player,context,request);return;}
resolved(request,"refused","unsupported_action");
}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=false)
public void fish(PlayerFishEvent event){
if(closed)return;
@@ -202,10 +237,11 @@ public final class FishingController implements Listener,AutoCloseable,Adventure
data.put("catalog_id",plan.catalog().toString());data.put("site_id",plan.site().toString());
data.put("item_id",plan.item().toString());data.put("submitter_id",plan.submitter().toString());
data.put("required",plan.quantity().toString());data.put("consumed",plan.quantity().toString());
data.put("stock_before",receipt.stockBefore().toString());
data.put("stock_after",receipt.stockAfter().toString());
if(receipt.stockBefore()!=null&&receipt.stockAfter()!=null){
data.put("stock_before",receipt.stockBefore().toString());data.put("stock_after",receipt.stockAfter().toString());
}else data.put("stock_status","unavailable_legacy_commit");
data.putAll(plan.audit());
data.remove("online_roster");
data.remove("actor_snapshot");data.remove("online_roster");
data.put("roster",plan.rewards().keySet().stream().sorted().map(UUID::toString).toList());
if(plan.audit().containsKey("submitter_location"))
data.put("submitter_location",locationMap(plan.audit().get("submitter_location")));
@@ -229,10 +265,14 @@ public final class FishingController implements Listener,AutoCloseable,Adventure
beneficiaries.add(part);
});
data.put("beneficiaries",beneficiaries);
fishingEvents.durable("adventure.items_submitted",plan.id(),actor==null
?new games.dmg.fantasystick.events.FishingEventBridge.Actor("system",null):actor,data);
var occurred=java.time.Instant.now();
if(plan.audit().containsKey("committed_at"))try{occurred=java.time.Instant.parse(plan.audit().get("committed_at"));}
catch(java.time.DateTimeException invalid){data.put("commit_time_status","unavailable_legacy_commit");}
else data.put("commit_time_status","unavailable_legacy_commit");
fishingEvents.durableAt("adventure.items_submitted",plan.id(),actor==null
?fishingEvents.actorFromSubmission(plan):actor,data,occurred);
}
private void submit(Player player,Context context){var quest=fishingQuest(context);if(quest==null){player.sendMessage("Fishing submission refused: no eligible accepted fishing quest.");return;}var beneficiaries=beneficiaries(context,quest);if(beneficiaries.isEmpty()||!beneficiaries.contains(context.record().id())){player.sendMessage("Fishing submission refused: party eligibility changed.");return;}var unit=new RewardAllocator.Unit<UUID>(quest.unit(),beneficiaries);var xp=RewardAllocator.allocate(quest.rewardXp(),List.of(unit),random);var coins=RewardAllocator.allocate(quest.rewardCoins(),List.of(unit),random);var rewards=new LinkedHashMap<UUID,FishingState.Reward>();for(UUID character:beneficiaries)rewards.put(character,new FishingState.Reward(xp.get(character),coins.get(character)));UUID operation=UUID.nameUUIDFromBytes((quest.catalog()+":"+quest.site()+":"+quest.id()+":"+context.record().id()).getBytes(StandardCharsets.UTF_8));var submissionAudit=new LinkedHashMap<String,String>();
private void submit(Player player,Context context,games.dmg.fantasystick.events.FishingEventBridge.TurnInAction request){var quest=fishingQuest(context);if(quest==null){resolved(request,"refused","no_eligible_quest");player.sendMessage("Fishing submission refused: no eligible accepted fishing quest.");return;}var beneficiaries=beneficiaries(context,quest);if(beneficiaries.isEmpty()||!beneficiaries.contains(context.record().id())){resolved(request,"refused","party_eligibility_changed");player.sendMessage("Fishing submission refused: party eligibility changed.");return;}var unit=new RewardAllocator.Unit<UUID>(quest.unit(),beneficiaries);var xp=RewardAllocator.allocate(quest.rewardXp(),List.of(unit),random);var coins=RewardAllocator.allocate(quest.rewardCoins(),List.of(unit),random);var rewards=new LinkedHashMap<UUID,FishingState.Reward>();for(UUID character:beneficiaries)rewards.put(character,new FishingState.Reward(xp.get(character),coins.get(character)));UUID operation=UUID.nameUUIDFromBytes((quest.catalog()+":"+quest.site()+":"+quest.id()+":"+context.record().id()).getBytes(StandardCharsets.UTF_8));var submissionAudit=new LinkedHashMap<String,String>();
submissionAudit.put("unit_kind",quest.unitType().name().toLowerCase(java.util.Locale.ROOT));
submissionAudit.put("unit_id",quest.unit().toString());
var onlineRoster=beneficiaries.stream().sorted().filter(character->{
@@ -242,34 +282,42 @@ public final class FishingController implements Listener,AutoCloseable,Adventure
}).map(UUID::toString).collect(java.util.stream.Collectors.joining(","));
if(onlineRoster.length()<=8192)submissionAudit.put("online_roster",onlineRoster);
else submissionAudit.put("online_roster_status","unavailable_large_roster");
submissionAudit.put("submitter_session_id",context.active().session().toString());
var auditActor=fishingEvents==null?null:fishingEvents.actor(player);
submissionAudit.put("actor_snapshot",games.dmg.fantasystick.events.FishingEventBridge.actorSnapshot(auditActor));
submissionAudit.put("player_id",player.getUniqueId().toString());
submissionAudit.put("player_name",player.getName());
submissionAudit.put("character_name",context.record().name());
submissionAudit.put("item_name",quest.fishing().itemName());
if(request!=null)submissionAudit.put("causation_id",request.id().toString());
submissionAudit.put("submitter_location",capture(player.getLocation()));
var giver=menuGivers.get(player.getUniqueId());
if(giver!=null){submissionAudit.put("giver_id",giver.id().toString());
submissionAudit.put("giver_location",giver.world()+","+giver.x()+","+giver.y()+","+giver.z());}
var plan=new FishingState.Submission(operation,quest.id(),quest.catalog(),quest.site(),context.record().id(),
quest.fishing().itemId(),quest.fishing().required(),rewards,FishingState.SubmissionPhase.PREPARED,submissionAudit);
var auditActor=fishingEvents==null?null:fishingEvents.actor(player);fishing.prepareSubmission(plan).whenComplete((status,failure)->{if(failure!=null){report(operation,context.record().id(),"submit-prepare",failure);return;}if(status==FishingDirectory.PrepareStatus.INSUFFICIENT||status==FishingDirectory.PrepareStatus.COMPLETED){schedule(()->player.sendMessage(status==FishingDirectory.PrepareStatus.INSUFFICIENT?"Fishing submission refused: the full required quantity is not held.":"Fishing submission refused: this character already completed the offered generation."));return;}commitSubmission(plan,player,auditActor);});}
private void recoverSubmission(FishingState.Submission plan){if(plan.phase()==FishingState.SubmissionPhase.PREPARED)fishing.commitSubmission(plan.id()).whenComplete((status,failure)->{if(failure!=null){report(plan.id(),plan.submitter(),"submission-recovery-commit",failure);return;}if(status==FishingDirectory.CommitStatus.APPLIED||status==FishingDirectory.CommitStatus.DUPLICATE)finishRecoveredSubmission(plan);});else finishRecoveredSubmission(plan);}
fishing.prepareSubmission(plan).whenComplete((status,failure)->{if(failure!=null){resolved(request,"failed","source_write_failed");report(operation,context.record().id(),"submit-prepare",failure);return;}if(status==FishingDirectory.PrepareStatus.INSUFFICIENT||status==FishingDirectory.PrepareStatus.COMPLETED){resolved(request,"refused",status==FishingDirectory.PrepareStatus.INSUFFICIENT?"insufficient_stock":"generation_already_completed");schedule(()->player.sendMessage(status==FishingDirectory.PrepareStatus.INSUFFICIENT?"Fishing submission refused: the full required quantity is not held.":"Fishing submission refused: this character already completed the offered generation."));return;}commitSubmission(plan,player,auditActor,request);});}
private void recoverSubmission(FishingState.Submission plan){
fishing.commitSubmissionWithEvidence(plan.id()).whenComplete((receipt,failure)->{
if(failure!=null){report(plan.id(),plan.submitter(),"submission-recovery-commit",failure);return;}
if(receipt.status()!=FishingDirectory.CommitStatus.APPLIED&&receipt.status()!=FishingDirectory.CommitStatus.DUPLICATE)return;
var committed=fishing.pendingSubmissions().stream().filter(value->value.id().equals(plan.id())).findFirst().orElse(plan);
emitSubmission(committed,null,receipt);finishRecoveredSubmission(committed);
});
}
private void finishRecoveredSubmission(FishingState.Submission plan){adventures.complete(plan.quest(),plan.rewards().keySet()).whenComplete((ignored,failure)->{if(failure!=null){report(plan.id(),plan.submitter(),"submission-recovery-quest",failure);return;}fishing.finishSubmission(plan.id()).whenComplete((finished,finishFailure)->{if(finishFailure!=null)report(plan.id(),plan.submitter(),"submission-recovery-finish",finishFailure);});});}
private void commitSubmission(FishingState.Submission plan,Player player,games.dmg.fantasystick.events.FishingEventBridge.Actor actor){fishing.commitSubmissionWithEvidence(plan.id()).whenComplete((receipt,failure)->{var status=receipt==null?null:receipt.status();if(failure!=null){report(plan.id(),plan.submitter(),"submit-commit",failure);return;}if(status==FishingDirectory.CommitStatus.INSUFFICIENT){schedule(()->player.sendMessage("Fishing submission refused: stock changed before commitment."));return;}if(status==FishingDirectory.CommitStatus.NOT_FOUND)return;if(status==FishingDirectory.CommitStatus.APPLIED)emitSubmission(plan,actor,receipt);adventures.complete(plan.quest(),plan.rewards().keySet()).whenComplete((completed,questFailure)->{if(questFailure!=null){report(plan.id(),plan.submitter(),"quest-complete",questFailure);return;}fishing.finishSubmission(plan.id()).whenComplete((finished,finishFailure)->{if(finishFailure!=null)report(plan.id(),plan.submitter(),"submit-finish",finishFailure);schedule(()->{player.sendMessage("Fishing quest submitted; earned shares are ready at any Quest Giver.");open(player,0,menuGivers.get(player.getUniqueId()));});});});});}
private void redeemTrophy(Player player,Context context,String encoded){
private void commitSubmission(FishingState.Submission plan,Player player,games.dmg.fantasystick.events.FishingEventBridge.Actor actor,games.dmg.fantasystick.events.FishingEventBridge.TurnInAction request){fishing.commitSubmissionWithEvidence(plan.id()).whenComplete((receipt,failure)->{var status=receipt==null?null:receipt.status();if(failure!=null){resolved(request,"uncertain","source_write_failed");report(plan.id(),plan.submitter(),"submit-commit",failure);return;}if(status==FishingDirectory.CommitStatus.INSUFFICIENT){resolved(request,"refused","stock_changed");schedule(()->player.sendMessage("Fishing submission refused: stock changed before commitment."));return;}if(status==FishingDirectory.CommitStatus.NOT_FOUND)return;if(status==FishingDirectory.CommitStatus.APPLIED){var committed=fishing.pendingSubmissions().stream().filter(value->value.id().equals(plan.id())).findFirst().orElse(plan);emitSubmission(committed,actor,receipt);}adventures.complete(plan.quest(),plan.rewards().keySet()).whenComplete((completed,questFailure)->{if(questFailure!=null){report(plan.id(),plan.submitter(),"quest-complete",questFailure);return;}fishing.finishSubmission(plan.id()).whenComplete((finished,finishFailure)->{if(finishFailure!=null)report(plan.id(),plan.submitter(),"submit-finish",finishFailure);schedule(()->{player.sendMessage("Fishing quest submitted; earned shares are ready at any Quest Giver.");open(player,0,menuGivers.get(player.getUniqueId()));});});});});}
private void redeemTrophy(Player player,Context context,String encoded,games.dmg.fantasystick.events.FishingEventBridge.TurnInAction request){
UUID trophy;try{trophy=UUID.fromString(encoded);}catch(IllegalArgumentException invalid){
player.sendMessage("Trophy redemption refused: invalid authority.");return;}
resolved(request,"refused","invalid_receipt");player.sendMessage("Trophy redemption refused: invalid authority.");return;}
var captured=fishing.trophy(trophy).orElse(null);
var giver=menuGivers.get(player.getUniqueId());
var actor=fishingEvents==null?null:fishingEvents.actor(player);
var interactionLocation=locationMap(capture(player.getLocation()));
fishing.redeemTrophyWithEvidence(trophy,context.record().id()).whenComplete((receipt,failure)->schedule(()->{
if(failure!=null){report(trophy,context.record().id(),"trophy-redeem",failure);
if(failure!=null){resolved(request,"failed","source_write_failed");report(trophy,context.record().id(),"trophy-redeem",failure);
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&&fishingEvents!=null){
var data=new LinkedHashMap<String,Object>();data.put("receipt_id",captured.id().toString());
var data=new LinkedHashMap<String,Object>();if(request!=null)data.put("causation_id",request.id().toString());data.put("receipt_id",captured.id().toString());
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){
@@ -306,30 +354,64 @@ public final class FishingController implements Listener,AutoCloseable,Adventure
?new games.dmg.fantasystick.events.FishingEventBridge.Actor("system",null):actor,data);
}
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.");
}else {resolved(request,"refused","unavailable_or_foreign_receipt");player.sendMessage("Trophy redemption refused: the receipt is unavailable for this character.");}
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());});}
private void awardLegacy(Player player,AdventureQuestState.RewardClaim claim,CharacterRecord record){characters.awardCombatReward(record.id(),record.revision(),claim.id(),claim.xp(),claim.coins(),progression,CombatRewardRules.defaults(),null).whenComplete((award,failure)->{if(failure!=null){report(claim.id(),claim.character(),"legacy-claim-award",failure);return;}if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){awardLegacy(player,claim,award.record().orElseThrow());return;}if(award.record().isEmpty())return;var saved=award.record().orElseThrow();adventures.finishClaim(claim.id()).whenComplete((finished,finishFailure)->{if(finishFailure!=null){report(claim.id(),claim.character(),"legacy-claim-finish",finishFailure);return;}schedule(()->{var current=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);if(current!=null&&award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE)sessions.acknowledgeProgress(current,saved.revision().subtract(BigInteger.ONE),saved);var target=current==null?null:plugin.getServer().getPlayer(current.player());if(target!=null){target.sendMessage(award.status()==CharacterStorage.CombatAwardStatus.DUPLICATE?"Finished a previously applied adventure reward claim.":"Claimed "+saved.experience().subtract(record.experience()).max(BigInteger.ZERO)+" retained adventure XP and "+saved.coins().subtract(record.coins()).max(BigInteger.ZERO)+" character coins.");open(target);}});});});}
private void claim(Player player,Context context){var actor=fishingEvents==null?null:fishingEvents.actor(player);var giver=menuGivers.get(player.getUniqueId());fishing.beginClaim(context.record().id()).whenComplete((claim,failure)->{if(failure!=null||claim.isEmpty()){if(failure!=null)report(UUID.randomUUID(),context.record().id(),"claim-prepare",failure);schedule(()->player.sendMessage("Fishing reward claim failed; earned shares are retained."));return;}awardClaim(player,context.active(),claim.orElseThrow(),context.record(),actor,giver,false);});}
private void recoverClaim(FishingState.Claim claim){characters.find(claim.character()).whenComplete((record,failure)->{if(failure!=null){report(claim.id(),claim.character(),"claim-load",failure);return;}record.ifPresent(value->awardClaim(null,null,claim,value,null,null,true));});}
private void awardClaim(Player player,EquipmentSessions.Active active,FishingState.Claim claim,CharacterRecord record,
games.dmg.fantasystick.events.FishingEventBridge.Actor actor,AdventureQuestState.Giver giver,boolean recovery){
characters.awardCombatReward(record.id(),record.revision(),claim.id(),claim.xp(),claim.coins(),progression,CombatRewardRules.defaults(),null).whenComplete((award,failure)->{
if(failure!=null){report(claim.id(),claim.character(),"claim-award",failure);return;}
if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){
awardClaim(player,active,claim,award.record().orElseThrow(),actor,giver,recovery);return;
private void claimLegacy(Player player,Context context,games.dmg.fantasystick.events.FishingEventBridge.TurnInAction request){
var actor=fishingEvents==null?null:fishingEvents.actor(player);var giver=menuGivers.get(player.getUniqueId());
adventures.beginLegacyClaim(context.record().id()).whenComplete((claim,failure)->{
if(failure!=null||claim.isEmpty()){
resolved(request,failure==null?"refused":"failed",failure==null?"no_unlocked_rewards":"source_write_failed");
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;
}
if(award.record().isEmpty())return;
awardLegacy(player,claim.orElseThrow(),context.record(),actor,giver,request);
});
}
private void awardLegacy(Player player,AdventureQuestState.RewardClaim claim,CharacterRecord record,
games.dmg.fantasystick.events.FishingEventBridge.Actor actor,AdventureQuestState.Giver giver,
games.dmg.fantasystick.events.FishingEventBridge.TurnInAction request){
characters.awardCombatReward(record.id(),record.revision(),claim.id(),claim.xp(),claim.coins(),progression,CombatRewardRules.defaults(),null).whenComplete((award,failure)->{
if(failure!=null){resolved(request,"failed","source_write_failed");report(claim.id(),claim.character(),"legacy-claim-award",failure);return;}
if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){
awardLegacy(player,claim,award.record().orElseThrow(),actor,giver,request);return;
}
if(award.record().isEmpty()||award.status()==CharacterStorage.CombatAwardStatus.UNAVAILABLE){resolved(request,"refused","character_unavailable");return;}
var saved=award.record().orElseThrow();
var sources=adventures.snapshot().rewards().allocations().stream().filter(value->claim.allocations().contains(value.id())).toList();
adventures.finishClaim(claim.id()).whenComplete((finished,finishFailure)->{
if(finishFailure!=null){resolved(request,"uncertain","source_write_failed");report(claim.id(),claim.character(),"legacy-claim-finish",finishFailure);return;}
if(!finished)return;
if(fishingEvents!=null)fishingEvents.legacyClaimed(claim,actor,giver,record,award,sources,request);
schedule(()->{
var current=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);
if(current!=null&&award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE)sessions.acknowledgeProgress(current,saved.revision().subtract(BigInteger.ONE),saved);
var target=current==null?null:plugin.getServer().getPlayer(current.player());
if(target!=null){target.sendMessage(award.status()==CharacterStorage.CombatAwardStatus.DUPLICATE?"Finished a previously applied adventure reward claim.":"Claimed "+saved.experience().subtract(record.experience()).max(BigInteger.ZERO)+" retained adventure XP and "+saved.coins().subtract(record.coins()).max(BigInteger.ZERO)+" character coins.");open(target,0,menuGivers.get(target.getUniqueId()));}
});
});
});
}
private void claim(Player player,Context context,games.dmg.fantasystick.events.FishingEventBridge.TurnInAction request){var actor=fishingEvents==null?null:fishingEvents.actor(player);var giver=menuGivers.get(player.getUniqueId());fishing.beginClaim(context.record().id()).whenComplete((claim,failure)->{if(failure!=null||claim.isEmpty()){resolved(request,failure==null?"refused":"failed",failure==null?"no_unlocked_rewards":"source_write_failed");if(failure!=null)report(UUID.randomUUID(),context.record().id(),"claim-prepare",failure);schedule(()->player.sendMessage("Fishing reward claim failed; earned shares are retained."));return;}awardClaim(player,context.active(),claim.orElseThrow(),context.record(),actor,giver,false,request);});}
private void recoverClaim(FishingState.Claim claim){characters.find(claim.character()).whenComplete((record,failure)->{if(failure!=null){report(claim.id(),claim.character(),"claim-load",failure);return;}record.ifPresent(value->awardClaim(null,null,claim,value,null,null,true,null));});}
private void awardClaim(Player player,EquipmentSessions.Active active,FishingState.Claim claim,CharacterRecord record,
games.dmg.fantasystick.events.FishingEventBridge.Actor actor,AdventureQuestState.Giver giver,boolean recovery,games.dmg.fantasystick.events.FishingEventBridge.TurnInAction request){
characters.awardCombatReward(record.id(),record.revision(),claim.id(),claim.xp(),claim.coins(),progression,CombatRewardRules.defaults(),null).whenComplete((award,failure)->{
if(failure!=null){resolved(request,"failed","source_write_failed");report(claim.id(),claim.character(),"claim-award",failure);return;}
if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){
awardClaim(player,active,claim,award.record().orElseThrow(),actor,giver,recovery,request);return;
}
if(award.record().isEmpty()||award.status()==CharacterStorage.CombatAwardStatus.UNAVAILABLE){resolved(request,"refused","character_unavailable");return;}
var saved=award.record().orElseThrow();
var before=fishing.snapshot();
var sources=before.allocations().stream().filter(value->claim.allocations().contains(value.id()))
.sorted(Comparator.comparing(FishingState.Allocation::id)).toList();
fishing.finishClaim(claim.id()).whenComplete((finished,finishFailure)->{
if(finishFailure!=null){report(claim.id(),claim.character(),"claim-finish",finishFailure);return;}
if(finishFailure!=null){resolved(request,"uncertain","source_write_failed");report(claim.id(),claim.character(),"claim-finish",finishFailure);return;}
if(finished&&fishingEvents!=null){
var data=new LinkedHashMap<String,Object>();
if(request!=null)data.put("causation_id",request.id().toString());
data.put("claim_id",claim.id().toString());data.put("beneficiary_id",claim.character().toString());
data.put("requested_xp",claim.xp().toString());data.put("requested_coins",claim.coins().toString());
data.put("recovery",recovery);data.put("award_status",award.status().name().toLowerCase(java.util.Locale.ROOT));
@@ -357,7 +439,7 @@ public final class FishingController implements Listener,AutoCloseable,Adventure
fishingEvents.durable("adventure.reward_claimed",claim.id(),actor==null
?new games.dmg.fantasystick.events.FishingEventBridge.Actor("system",null):actor,data);
}
schedule(()->{var current=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);if(current!=null&&award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE)sessions.acknowledgeProgress(current,saved.revision().subtract(BigInteger.ONE),saved);var target=current==null?null:plugin.getServer().getPlayer(current.player());if(target!=null){target.sendMessage(award.status()==CharacterStorage.CombatAwardStatus.DUPLICATE?"Finished a previously applied fishing reward claim.":"Claimed "+saved.experience().subtract(record.experience()).max(BigInteger.ZERO)+" quest XP and "+saved.coins().subtract(record.coins()).max(BigInteger.ZERO)+" character coins.");open(target);}});
schedule(()->{var current=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);if(current!=null&&award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE)sessions.acknowledgeProgress(current,saved.revision().subtract(BigInteger.ONE),saved);var target=current==null?null:plugin.getServer().getPlayer(current.player());if(target!=null){target.sendMessage(award.status()==CharacterStorage.CombatAwardStatus.DUPLICATE?"Finished a previously applied fishing reward claim.":"Claimed "+saved.experience().subtract(record.experience()).max(BigInteger.ZERO)+" quest XP and "+saved.coins().subtract(record.coins()).max(BigInteger.ZERO)+" character coins.");open(target,0,menuGivers.get(target.getUniqueId()));}});
});
});
}
@@ -90,7 +90,9 @@ public final class FishingDirectory implements AutoCloseable {
var submission=state.submissions().stream().filter(value->value.id().equals(operation)).findFirst().orElse(null);
if(submission==null)return result(state,new SubmissionReceipt(CommitStatus.NOT_FOUND,null,null));
if(submission.phase()==FishingState.SubmissionPhase.COMMITTED)
return result(state,new SubmissionReceipt(CommitStatus.DUPLICATE,null,null));
return result(state,new SubmissionReceipt(CommitStatus.DUPLICATE,
submission.audit().containsKey("stock_before")?new BigInteger(submission.audit().get("stock_before")):null,
submission.audit().containsKey("stock_after")?new BigInteger(submission.audit().get("stock_after")):null));
var before=quantity(state,submission.submitter(),submission.item());
if(before.compareTo(submission.quantity())<0)
return result(state,new SubmissionReceipt(CommitStatus.INSUFFICIENT,before,before));
@@ -106,7 +108,13 @@ public final class FishingDirectory implements AutoCloseable {
allocations.add(new FishingState.Allocation(allocationId(submission.id(),entry.getKey()),submission.id(),
submission.catalog(),submission.site(),entry.getKey(),reward.xp(),reward.coins(),"fishing"));
}
var submissions=state.submissions().stream().map(value->value.id().equals(operation)?value.committed():value).toList();
var audit=new LinkedHashMap<>(submission.audit());
// Older/custom receipts may already fill the unchanged audit envelope. Logging cannot block their deduction.
if(audit.size()<=13){audit.put("stock_before",before.toString());audit.put("stock_after",remaining.toString());
audit.put("committed_at",java.time.Instant.now().toString());}
var committed=new FishingState.Submission(submission.id(),submission.quest(),submission.catalog(),submission.site(),
submission.submitter(),submission.item(),submission.quantity(),submission.rewards(),FishingState.SubmissionPhase.COMMITTED,audit);
var submissions=state.submissions().stream().map(value->value.id().equals(operation)?committed:value).toList();
return result(copy(state,inventory,state.catches(),state.catchReceipts(),submissions,completions,allocations,state.claims()),
new SubmissionReceipt(CommitStatus.APPLIED,before,remaining));
});}
@@ -42,9 +42,13 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
private games.dmg.fantasystick.events.AbilityEventBridge abilityEvents;
private games.dmg.fantasystick.events.PassiveEventBridge passiveEvents;
private games.dmg.fantasystick.events.ActiveInspectionEventBridge inspectionEvents;
private games.dmg.fantasystick.events.SavedCharacterEventBridge savedEvents;
private record AdminLoad(games.dmg.fantasystick.events.AdminActionEventBridge.Action action,
games.dmg.fantasystick.events.ActiveInspectionEventBridge.Request inspection) {
AdminLoad(games.dmg.fantasystick.events.AdminActionEventBridge.Action action) { this(action, null); }
games.dmg.fantasystick.events.ActiveInspectionEventBridge.Request inspection,
games.dmg.fantasystick.events.SavedCharacterEventBridge.Inspection saved) {
AdminLoad(games.dmg.fantasystick.events.AdminActionEventBridge.Action action) { this(action, null, null); }
AdminLoad(games.dmg.fantasystick.events.AdminActionEventBridge.Action action,
games.dmg.fantasystick.events.ActiveInspectionEventBridge.Request inspection) { this(action, inspection, null); }
}
private final Map<UUID, AdminLoad> adminPending = new ConcurrentHashMap<>();
private games.dmg.fantasystick.events.AdminActionEventBridge adminAudit;
@@ -72,7 +76,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
private record AdminView(String nonce, UUID administrator, org.bukkit.entity.Player target,
games.dmg.fantasystick.session.EquipmentSessions.Active active,
int previous, int next) { }
private record SavedView(String nonce, UUID administrator, UUID character, int previous, int next) { }
private record SavedView(String nonce, UUID administrator, org.bukkit.entity.Player holder,
UUID character, int previous, int next) { }
private record StatView(String nonce, ControlInput input, java.math.BigInteger revision, CharacterRecord before) { }
private record AbilityView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids, CharacterRecord before) { }
private record PassiveView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids, CharacterRecord before) { }
@@ -173,6 +178,11 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
}
private void retireAdminLoad(AdminLoad load, String reason) {
if (load.saved() != null) {
savedInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.UNCERTAIN,
reason.equals("superseded") ? "superseded" : "cancelled", reason);
return;
}
activeInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.UNCERTAIN,
"operation_uncertain", reason);
}
@@ -291,23 +301,70 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
});
}
public void attachSavedEvents(games.dmg.fantasystick.events.SavedCharacterEventBridge events) {
if (closed || savedEvents != null) throw new IllegalStateException("Saved events already attached or books closed");
savedEvents = Objects.requireNonNull(events);
}
private void savedInspectionResult(AdminLoad load,
games.dmg.fantasystick.events.AdminActionEventBridge.Outcome central, String outcome, String reason,
CharacterRecord record, int pages, int volumes, int presentedOffset, java.util.List<String> sections) {
boolean active = record != null && sessions.activeCharacter(record.id()).filter(sessions::current).isPresent();
if (load.saved() != null) savedEvents.inspectionResolved(load.saved(), outcome, reason,
record, active, pages, volumes, presentedOffset, sections);
if (load.saved() != null && adminAudit != null) {
var facts = new java.util.LinkedHashMap<String, Object>();
facts.put("domain_event_id", load.saved().eventId());
if (central == games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.COMPLETED && record != null)
facts.put("subject_id", record.id());
String centralReason = switch (central) {
case COMPLETED -> "read_completed";
case FAILED -> "operation_failed";
case UNCERTAIN -> "operation_uncertain";
default -> reason.equals("permission_changed") || reason.equals("sender_unsupported")
|| reason.equals("invalid_arguments") || reason.equals("unauthorized") ? reason
: outcome.equals("not_found") ? "not_found" : "stale";
};
adminAudit.result(load.action(), central, centralReason, facts);
} else adminResult(load.action(), central,
central == games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.COMPLETED
? "read_completed" : "owner_refused", record == null ? null : record.id());
}
private void savedInspectionResult(AdminLoad load,
games.dmg.fantasystick.events.AdminActionEventBridge.Outcome central, String outcome, String reason) {
savedInspectionResult(load, central, outcome, reason, null, 0, 0, 0, java.util.List.of());
}
public void refuseSavedInspection(String reason) {
var action = commandAction();
var request = savedEvents == null ? null : savedEvents.inspection(action, null, 0, "command");
savedInspectionResult(new AdminLoad(action, null, request),
games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED, "refused", reason);
}
public void openSavedReadOnly(org.bukkit.entity.Player administrator, UUID character) {
Objects.requireNonNull(administrator); Objects.requireNonNull(character);
var action = commandAction();
if (closed) { adminResult(action, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.UNCERTAIN, "operation_uncertain", null); return; }
if (!administrator.isOnline() || !administrator.hasPermission("fantasystick.admin")) {
adminResult(action, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED, "permission_changed", null);
if (administrator.isOnline()) administrator.sendMessage("Saved character view refused: administrator permission is required.");
return;
}
loadSavedReadOnly(administrator, character, 0, action);
loadSavedReadOnly(administrator, character, 0, commandAction(), "command");
}
private void loadSavedReadOnly(org.bukkit.entity.Player administrator, UUID character, int offset,
games.dmg.fantasystick.events.AdminActionEventBridge.Action action) {
if (closed) { adminResult(action, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.UNCERTAIN, "operation_uncertain", null); return; }
games.dmg.fantasystick.events.AdminActionEventBridge.Action action, String entryPoint) {
var request = savedEvents == null ? null : savedEvents.inspection(action, character, offset, entryPoint);
var load = new AdminLoad(action, null, request);
if (closed) {
savedInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.UNCERTAIN,
"cancelled", "plugin_shutdown"); return;
}
if (!administrator.hasPermission("fantasystick.admin")) {
savedInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,
"refused", "permission_changed"); return;
}
UUID administratorId = administrator.getUniqueId();
var load = new AdminLoad(action);
if (!administrator.isOnline() || server.getPlayer(administratorId) != administrator) {
savedInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,
"stale", "sender_lifetime_changed"); return;
}
adminViews.remove(administratorId);
savedViews.remove(administratorId);
supersedeAdminLoad(administratorId, load);
@@ -316,28 +373,49 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
try {
nextTick.accept(() -> {
if (closed || !adminPending.remove(administratorId, load)) return;
if (!administrator.isOnline() || !administrator.hasPermission("fantasystick.admin")) {
adminResult(action, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED, "permission_changed", null);
if (!administrator.hasPermission("fantasystick.admin")) {
savedInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,
"refused", "permission_changed");
if (administrator.isOnline()) administrator.sendMessage("Saved character view refused: administrator permission changed.");
return;
}
if (!administrator.isOnline() || server.getPlayer(administratorId) != administrator) {
savedInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,
"stale", "sender_lifetime_changed"); return;
}
if (failure != null) {
adminResult(action, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED, "operation_failed", null);
savedInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED,
"unreadable", "read_failed");
report(new Failure(administratorId, character, failure));
administrator.sendMessage("Saved character view could not be loaded; no state was changed.");
return;
administrator.sendMessage("Saved character view could not be loaded; no state was changed."); return;
}
if (record.isEmpty()) {
adminResult(action, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED, "not_found", null);
administrator.sendMessage("Saved character view refused: character not found.");
return;
savedInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,
"not_found", "not_found");
administrator.sendMessage("Saved character view refused: character not found."); return;
}
try {
administrator.openBook(renderSavedReadOnly(record.orElseThrow(), administrator, offset));
adminResult(action, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.COMPLETED,
"read_completed", character);
var loaded = record.orElseThrow();
var book = renderSavedReadOnly(loaded, administrator, offset);
int count = overviewPages(loaded, administrator, null, true).size();
int presentedOffset = Math.min(offset, (count - 1) / VOLUME_SIZE * VOLUME_SIZE);
var sections = new java.util.ArrayList<String>();
if (presentedOffset == 0) sections.add("saved_identity");
sections.add("overview");
if (presentedOffset + VOLUME_SIZE >= count) {
sections.add("gear");
if (!abilities.definitions().isEmpty()) sections.add("active_abilities");
if (!passives.definitions().isEmpty()) sections.add("passive_abilities");
if (fishing != null || rareInventory != null) sections.add("inventory");
sections.add("party");
}
administrator.openBook(book);
savedInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.COMPLETED,
"opened", "native_open_acknowledged", loaded, ((BookMeta) book.getItemMeta()).getPageCount(),
(count + VOLUME_SIZE - 1) / VOLUME_SIZE, presentedOffset, sections);
} catch (RuntimeException error) {
adminResult(action, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED, "operation_failed", null);
savedInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED,
"failed", "open_failed");
savedViews.remove(administratorId);
report(new Failure(administratorId, character, error));
administrator.sendMessage("Saved character view could not be opened; no state was changed.");
@@ -345,7 +423,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
});
} catch (RuntimeException rejected) {
if (adminPending.remove(administratorId, load))
adminResult(action, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED, "operation_failed", null);
savedInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED,
"failed", "scheduler_failed");
report(new Failure(administratorId, character, rejected));
}
});
@@ -520,7 +599,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
if (view.next() >= 0) navigation = navigation.append(Component.newline())
.append(pageButton("Next pages", view, 1));
} else if (savedState) {
var view = new SavedView(UUID.randomUUID().toString(), administrator.getUniqueId(), record.id(),
var view = new SavedView(UUID.randomUUID().toString(), administrator.getUniqueId(), administrator, record.id(),
start > 0 ? start - VOLUME_SIZE : -1, end < allOverview.size() ? end : -1);
savedViews.put(administrator.getUniqueId(), view);
if (view.previous() >= 0) navigation = navigation.append(Component.newline())
@@ -847,15 +926,42 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
if (closed || !SAVED_PAGES.equals(event.getIdentifier())
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)) return;
var administrator = connection.getPlayer();
if (adminAudit == null) navigateSavedInspection(event, administrator, null);
else {
try (var invocation = adminAudit.openSavedInspectionNavigation(administrator)) {
navigateSavedInspection(event, administrator, invocation.action());
}
}
}
private void navigateSavedInspection(io.papermc.paper.event.player.PlayerCustomClickEvent event,
org.bukkit.entity.Player administrator, games.dmg.fantasystick.events.AdminActionEventBridge.Action action) {
var refused = new AdminLoad(action, null, savedEvents == null ? null
: savedEvents.inspection(action, null, 0, "book_navigation"));
if (!administrator.hasPermission("fantasystick.admin")) {
savedInspectionResult(refused, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,
"refused", "permission_changed"); return;
}
if (!administrator.isOnline() || server.getPlayer(administrator.getUniqueId()) != administrator) {
savedInspectionResult(refused, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,
"stale", "sender_lifetime_changed"); return;
}
var view = savedViews.get(administrator.getUniqueId());
if (view == null || event.getTag() == null || !view.administrator().equals(administrator.getUniqueId())) return;
String raw = event.getTag().string();
if (raw.length() > 64) return;
var match = PAGE_PAYLOAD.matcher(raw);
if (!match.matches() || !view.nonce().equals(match.group(1))) return;
int offset = match.group(2).equals("0") ? view.previous() : view.next();
if (offset < 0 || !savedViews.remove(administrator.getUniqueId(), view)) return;
loadSavedReadOnly(administrator, view.character(), offset, null);
int offset = -1;
if (view != null && view.holder() == administrator && event.getTag() != null
&& view.administrator().equals(administrator.getUniqueId())) {
String raw = event.getTag().string();
if (raw.length() <= 64) {
var match = PAGE_PAYLOAD.matcher(raw);
if (match.matches() && view.nonce().equals(match.group(1)))
offset = match.group(2).equals("0") ? view.previous() : view.next();
}
}
if (offset < 0 || !savedViews.remove(administrator.getUniqueId(), view)) {
savedInspectionResult(refused, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,
"refused", "invalid_navigation"); return;
}
loadSavedReadOnly(administrator, view.character(), offset, action, "book_navigation");
}
@org.bukkit.event.EventHandler
@@ -56,6 +56,7 @@ public final class AdminActionEventBridge implements AutoCloseable {
public String route() { return route; }
public EventInput.Context context() { return context; }
public String actorKind() { return actorKind; }
public Map<String, Object> commandLocation() { return Map.copyOf(location); }
public boolean authorized() { return authorization.equals("authorized"); }
}
@@ -134,7 +135,15 @@ public final class AdminActionEventBridge implements AutoCloseable {
/** An actual custom-click inspection attempt, not synthetic command dispatch or new permission authority. */
public Invocation openInspectionNavigation(Player administrator) {
var action = capture(administrator, new String[]{"character", "view"}, "book_navigation");
return openInspectionNavigation(administrator, "view");
}
public Invocation openSavedInspectionNavigation(Player administrator) {
return openInspectionNavigation(administrator, "show");
}
private Invocation openInspectionNavigation(Player administrator, String route) {
var action = capture(administrator, new String[]{"character", route}, "book_navigation");
emit(action, "request", null, null, Map.of());
emit(action, "authorization", null, null, Map.of());
return new Invocation(action);
@@ -12,12 +12,19 @@ public final class AdminCommandEntryObserver implements Listener {
private final PluginCommand root;
private final AdminActionEventBridge audit;
private final ActiveInspectionEventBridge inspections;
private final SavedCharacterEventBridge saved;
public AdminCommandEntryObserver(PluginCommand root, AdminActionEventBridge audit) {
this(root, audit, null);
}
public AdminCommandEntryObserver(PluginCommand root, AdminActionEventBridge audit, ActiveInspectionEventBridge inspections) {
this(root, audit, inspections, null);
}
public AdminCommandEntryObserver(PluginCommand root, AdminActionEventBridge audit,
ActiveInspectionEventBridge inspections, SavedCharacterEventBridge saved) {
this.saved = saved;
this.inspections = inspections;
this.root = Objects.requireNonNull(root);
this.audit = Objects.requireNonNull(audit);
@@ -38,6 +45,13 @@ public final class AdminCommandEntryObserver implements Listener {
if (inspections != null && args.length >= 2 && args[0].equals("character") && args[1].equals("view"))
inspections.resolved(inspections.request(action, null, 0, "command"), "refused",
allowed ? "native_entry_cancelled" : "unauthorized", null, null, 0, 0);
if (saved != null && args.length >= 2 && args[0].equals("character")) {
var reason = allowed ? "native_entry_cancelled" : "unauthorized";
if (args[1].equals("find")) saved.searchResolved(saved.search(action, ""), "refused", reason,
java.util.List.of(), 0, false);
if (args[1].equals("show")) saved.inspectionResolved(saved.inspection(action, null, 0, "command"),
"refused", reason, null, false, 0, 0, 0, java.util.List.of());
}
}
// Otherwise the executor owns the request; observing here too would duplicate the action.
} catch (RuntimeException unavailable) { /* Audit may not change native dispatch or authority. */ }
@@ -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));}
@@ -53,6 +53,78 @@ public final class FishingEventBridge implements AutoCloseable {
} catch (RuntimeException unavailable) { return null; }
}
public record TurnInAction(UUID id, java.time.Instant time, Actor actor, Map<String,Object> data) { }
/** Actual user input only; automatic menu refreshes must not call this boundary. */
public TurnInAction turnIn(Player player, AdventureQuestState.Giver giver, String kind) {
try {
var captured = actor(player);
if (captured == null) return null;
var data = new LinkedHashMap<String,Object>();
data.put("action_kind", "quest_turn_in_" + kind);
data.put("entry_point", kind.equals("view") ? "Quest Giver turn-in entry" : "InventoryClickEvent");
data.put("actor_kind", captured.kind());
data.put("giver_status", giver == null ? "menu_origin_unverified" : "entered_via_registered_giver");
if (giver != null) {
data.put("giver_id", giver.id().toString());
data.put("giver_location", Map.of("world_id", giver.world().toString(),
"x", giver.x(), "y", giver.y(), "z", giver.z()));
}
var action = new TurnInAction(UUID.randomUUID(), clock.instant(), captured, Map.copyOf(data));
var observation = new LinkedHashMap<String,Object>(data);
observation.put("outcome", "observed");
transientEvent("action.observed", action.id(), captured, observation);
return action;
} catch (RuntimeException unavailable) { return null; }
}
public void turnInResolved(TurnInAction action, String outcome, String reason) {
if (action == null) return;
var data = new LinkedHashMap<String,Object>(action.data());
data.put("outcome", outcome);
if (reason != null) data.put("reason", reason);
data.put("operation_id",action.id().toString());
// An input may be pending and later refused/failed: separate facts, one shared action identity.
try {sink.apply(new EventInput(UUID.randomUUID(),action.id(),clock.instant(),"action.resolved",action.actor().context(),data));}
catch(RuntimeException ignored){/* Audit cannot change the source operation. */}
}
public void legacyClaimed(AdventureQuestState.RewardClaim claim, Actor actor,
AdventureQuestState.Giver giver, games.dmg.fantasystick.persistence.CharacterRecord before,
games.dmg.fantasystick.persistence.CharacterStorage.CombatAward award,
List<AdventureQuestState.RewardAllocation> allocations, TurnInAction request) {
var saved = award.record().orElseThrow();
var data = new LinkedHashMap<String,Object>();
data.put("claim_kind", "legacy_adventure");
data.put("claim_id", claim.id().toString()); data.put("beneficiary_id", claim.character().toString());
data.put("requested_xp", claim.xp().toString()); data.put("requested_coins", claim.coins().toString());
data.put("award_status", award.status().name().toLowerCase(java.util.Locale.ROOT));
data.put("revision_before", before.revision().toString()); data.put("revision_after", saved.revision().toString());
data.put("experience_after", saved.experience().toString()); data.put("coins_after", saved.coins().toString());
data.put("level_after", Integer.toString(saved.level()));
if (award.status() == games.dmg.fantasystick.persistence.CharacterStorage.CombatAwardStatus.DUPLICATE)
data.put("applied_status", "previously_applied_receipt");
else {
var xp = saved.experience().subtract(before.experience()).max(java.math.BigInteger.ZERO);
var coins = saved.coins().subtract(before.coins()).max(java.math.BigInteger.ZERO);
data.put("applied_xp", xp.toString()); data.put("lost_xp", claim.xp().subtract(xp).max(java.math.BigInteger.ZERO).toString());
data.put("applied_coins", coins.toString()); data.put("lost_coins", claim.coins().subtract(coins).max(java.math.BigInteger.ZERO).toString());
data.put("loss_reason", xp.compareTo(claim.xp()) < 0 ? "maximum_level_clamp" : "none");
}
data.put("giver_status", giver == null ? "menu_origin_unverified" : "entered_via_registered_giver");
if (giver != null) {
data.put("giver_id", giver.id().toString());
data.put("giver_location", Map.of("world_id", giver.world().toString(), "x", giver.x(), "y", giver.y(), "z", giver.z()));
}
if (request != null) data.put("causation_id", request.id().toString());
data.put("sources", allocations.stream().sorted(java.util.Comparator.comparing(AdventureQuestState.RewardAllocation::id))
.map(value -> Map.of("allocation_id", value.id().toString(), "source_receipt_id", value.source().toString(),
"source_kind", "legacy_adventure", "quest_id", value.quest().toString(),
"catalog_id", value.catalog().toString(), "site_id", value.site().toString(),
"allocated_xp", value.xp().toString(), "allocated_coins", value.coins().toString())).toList());
durable("adventure.reward_claimed", claim.id(), actor == null ? new Actor("system", null) : actor, data);
}
public void site(String type, UUID operation, Actor actor, AdventureQuestState.Site site,
Map<String, Object> changed) {
if (actor == null || site == null || site.fishing() == null) return;
@@ -132,6 +204,32 @@ public final class FishingEventBridge implements AutoCloseable {
} catch (RuntimeException unavailable) { return new Actor("system", null); }
}
/** Small immutable actor proof inside the owning submission, never a live worker-thread lookup. */
public static String actorSnapshot(Actor actor) {
if(actor==null||actor.context()==null)return "{}";
var c=actor.context();var value=new com.google.gson.JsonObject();
value.addProperty("player_id",c.playerId().toString());value.addProperty("player_name",c.playerName());
if(c.characterId()!=null){value.addProperty("character_id",c.characterId().toString());value.addProperty("character_name",c.characterName());value.addProperty("session_id",c.sessionId().toString());}
if(c.location()!=null){var l=c.location();var location=new com.google.gson.JsonObject();
location.addProperty("world_id",l.worldId().toString());location.addProperty("world_name",l.worldName());
location.addProperty("x",l.x());location.addProperty("y",l.y());location.addProperty("z",l.z());
location.addProperty("captured_at",l.capturedAt().toString());location.addProperty("quality",l.quality());value.add("location",location);}
return value.toString();
}
public Actor actorFromSubmission(FishingState.Submission receipt) {
try {
var audit=receipt.audit();
if(audit.containsKey("actor_snapshot")){
var c=com.google.gson.JsonParser.parseString(audit.get("actor_snapshot")).getAsJsonObject();
EventInput.Location location=null;
if(c.has("location")){var l=c.getAsJsonObject("location");location=new EventInput.Location(UUID.fromString(l.get("world_id").getAsString()),l.get("world_name").getAsString(),l.get("x").getAsDouble(),l.get("y").getAsDouble(),l.get("z").getAsDouble(),java.time.Instant.parse(l.get("captured_at").getAsString()),l.get("quality").getAsString());}
return new Actor("player",new EventInput.Context(UUID.fromString(c.get("player_id").getAsString()),c.get("player_name").getAsString(),UUID.fromString(c.get("character_id").getAsString()),c.get("character_name").getAsString(),UUID.fromString(c.get("session_id").getAsString()),location));
}
return new Actor("player",new EventInput.Context(UUID.fromString(audit.get("player_id")),audit.get("player_name"),receipt.submitter(),audit.get("character_name"),UUID.fromString(audit.get("submitter_session_id")),null));
} catch(RuntimeException unavailable){return new Actor("system",null);}
}
/** 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
@@ -220,6 +318,10 @@ public final class FishingEventBridge implements AutoCloseable {
}
public void durable(String type, UUID operation, Actor actor, Map<String, Object> data) {
durableAt(type,operation,actor,data,clock.instant());
}
public void durableAt(String type, UUID operation, Actor actor, Map<String, Object> data, java.time.Instant occurredAt) {
if (actor == null) return;
try {
String primary = data.get("beneficiaries") instanceof List<?> ? "beneficiaries"
@@ -230,7 +332,7 @@ public final class FishingEventBridge implements AutoCloseable {
if (primary == null) { primary = secondary; secondary = null; }
if ((primary == null || ((List<?>)data.get(primary)).size() <= 20)
&& (secondary == null || ((List<?>)data.get(secondary)).size() <= 20)) {
outbox.accepted(input(type, operation, actor, data));
outbox.accepted(input(type, operation, actor, data, -1, occurredAt));
return;
}
var first = (List<?>)data.get(primary);
@@ -253,7 +355,7 @@ public final class FishingEventBridge implements AutoCloseable {
part.put("collection_part_index", collectionPart);
part.put("part_index", index);
part.put("part_count", total);
outbox.accepted(input(type, operation, actor, part, index));
outbox.accepted(input(type, operation, actor, part, index, occurredAt));
}
} catch (RuntimeException ignored) { /* Audit availability cannot change acknowledged fishing. */ }
}
@@ -265,15 +367,15 @@ public final class FishingEventBridge implements AutoCloseable {
}
private EventInput input(String type, UUID operation, Actor actor, Map<String, Object> data) {
return input(type, operation, actor, data, -1);
return input(type, operation, actor, data, -1, clock.instant());
}
private EventInput input(String type, UUID operation, Actor actor, Map<String, Object> data, int part) {
private EventInput input(String type, UUID operation, Actor actor, Map<String, Object> data, int part, java.time.Instant occurredAt) {
var values = new LinkedHashMap<String, Object>(data);
values.put("operation_id", operation.toString());
var identity = "fantasystick:fishing:" + operation + ':' + type + (part < 0 ? "" : ":part:" + part);
return new EventInput(UUID.nameUUIDFromBytes(identity.getBytes(StandardCharsets.UTF_8)), operation,
clock.instant(), type, actor.context(), values);
occurredAt, type, actor.context(), values);
}
private EventInput.Location safeLocation(Player player) {
@@ -0,0 +1,143 @@
package games.dmg.fantasystick.events;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
/** Bounded saved-read outcomes only; no character records, books, native objects or mutation authority. */
public final class SavedCharacterEventBridge {
public static final int SEARCH_PART_SIZE = 8;
public record Summary(UUID character, String name, int level, boolean alive, boolean active) { }
public static final class Search {
private final AdminActionEventBridge.Action action;
private final String query;
private final boolean truncated;
private final AtomicBoolean resolved = new AtomicBoolean();
private Search(AdminActionEventBridge.Action action, String query) {
this.action = action;
var points = query.codePoints().toArray();
truncated = points.length > 256;
var text = new StringBuilder();
for (int i = 0; i < Math.min(points.length, 256); i++)
text.appendCodePoint(Character.isISOControl(points[i]) ? ' ' : points[i]);
this.query = text.toString();
}
public UUID eventId() { return id(action.id(), "search", 0); }
}
public static final class Inspection {
private final AdminActionEventBridge.Action action;
private final UUID character;
private final int offset;
private final String entryPoint;
private final AtomicBoolean resolved = new AtomicBoolean();
private Inspection(AdminActionEventBridge.Action action, UUID character, int offset, String entryPoint) {
this.action = action; this.character = character; this.offset = offset; this.entryPoint = entryPoint;
}
public UUID eventId() { return id(action.id(), "inspection", 0); }
}
public Inspection inspection(AdminActionEventBridge.Action action, UUID character, int offset, String entryPoint) {
if (action == null) return null;
if (offset < 0 || !(entryPoint.equals("command") || entryPoint.equals("book_navigation")))
throw new IllegalArgumentException("Invalid saved inspection metadata");
return new Inspection(action, action.authorized() ? character : null, offset, entryPoint);
}
public void inspectionResolved(Inspection request, String outcome, String reason,
games.dmg.fantasystick.persistence.CharacterRecord record, boolean active,
int pages, int volumes, int presentedOffset, List<String> sections) {
if (request == null || !request.resolved.compareAndSet(false, true)) return;
boolean denied = !request.action.authorized() || reason.equals("permission_changed") || reason.equals("unauthorized");
var values = new LinkedHashMap<String, Object>();
values.put("route", "character.show");
values.put("actor_kind", request.action.actorKind());
values.put("authorization", denied ? "denied" : "authorized");
values.put("entry_point", request.entryPoint);
values.put("requested_offset", request.offset);
values.put("requested_volume", request.offset / 30 + 1);
values.put("outcome", outcome); values.put("reason", reason);
location(values, request.action);
if (!denied && !reason.equals("sender_unsupported") && request.character != null)
values.put("requested_character_id", request.character);
if (!denied && outcome.equals("opened") && record != null && record.id().equals(request.character)) {
values.put("subject_character_id", record.id()); values.put("subject_character_name", record.name());
values.put("saved_revision", record.revision().toString());
values.put("sheet_generation", record.sheetGeneration().toString());
values.put("alive", !record.dead()); values.put("active", active);
values.put("presented_pages", pages); values.put("presented_volumes", volumes);
values.put("presented_offset", presentedOffset); values.put("exposed_sections", List.copyOf(sections));
}
emit(new EventInput(request.eventId(), request.action.id(), clock.instant(),
"character.saved_inspection_resolved", request.action.context(), values));
}
private final Consumer<EventInput> sink;
private final Clock clock;
public SavedCharacterEventBridge(Consumer<EventInput> sink, Clock clock) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
}
public Search search(AdminActionEventBridge.Action action, String query) {
return action == null ? null : new Search(action, Objects.requireNonNull(query));
}
public void searchResolved(Search request, String outcome, String reason,
List<Summary> results, int unreadable, boolean complete) {
if (request == null || !request.resolved.compareAndSet(false, true)) return;
boolean denied = !request.action.authorized() || reason.equals("permission_changed") || reason.equals("unauthorized");
boolean exposed = !denied && (outcome.equals("completed") || outcome.equals("incomplete") || outcome.equals("no_match"));
var visible = exposed ? List.copyOf(results) : List.<Summary>of();
int pages = (visible.size() + SEARCH_PART_SIZE - 1) / SEARCH_PART_SIZE;
int parts = Math.max(1, pages);
var time = clock.instant();
for (int part = 0; part < parts; part++) {
var values = new LinkedHashMap<String, Object>();
values.put("route", "character.find");
location(values, request.action);
values.put("actor_kind", request.action.actorKind());
values.put("authorization", denied ? "denied" : "authorized");
values.put("outcome", outcome);
values.put("reason", reason);
values.put("resolution_id", request.eventId());
values.put("part_index", part + 1);
values.put("part_count", parts);
if (!denied && !reason.equals("sender_unsupported")) {
values.put("query", request.query);
values.put("query_truncated", request.truncated);
}
if (exposed) {
values.put("match_count", visible.size());
values.put("page_count", pages);
values.put("unreadable_count", unreadable);
values.put("catalog_complete", complete);
values.put("results", visible.subList(part * SEARCH_PART_SIZE,
Math.min(visible.size(), (part + 1) * SEARCH_PART_SIZE)).stream().map(summary -> {
var value = new LinkedHashMap<String, Object>();
value.put("character_id", summary.character()); value.put("name", summary.name());
value.put("level", Integer.toString(summary.level()));
value.put("alive", summary.alive()); value.put("active", summary.active());
return value;
}).toList());
}
emit(new EventInput(id(request.action.id(), "search", part), request.action.id(), time,
"character.saved_search_resolved", request.action.context(), values));
}
}
private static void location(LinkedHashMap<String, Object> values, AdminActionEventBridge.Action action) {
if (action.context() != null) {
if (action.context().location() == null) values.put("location_status", "unavailable");
} else if (!action.commandLocation().isEmpty()) {
values.put("location", action.commandLocation());
values.put("location_status", "captured");
} else values.put("location_status", action.actorKind().equals("command_block") ? "unavailable" : "not_applicable");
}
private void emit(EventInput input) {
try { sink.accept(input); }
catch (RuntimeException unavailable) { /* Logging cannot change or replay a read. */ }
}
private static UUID id(UUID action, String kind, int part) {
return UUID.nameUUIDFromBytes(("fantasystick:saved-" + kind + ":" + action + ":" + part)
.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);
}
@@ -21,7 +21,35 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private final Consumer<Runnable> nextTick;
private final Consumer<Failure> diagnostics;
private final java.util.LinkedHashMap<UUID, String> savedCharacterCatalog = new java.util.LinkedHashMap<>();
private final java.util.concurrent.ConcurrentHashMap<Object, Object> savedSearches = new java.util.concurrent.ConcurrentHashMap<>();
private record SavedSearch(games.dmg.fantasystick.events.AdminActionEventBridge.Action action,
games.dmg.fantasystick.events.SavedCharacterEventBridge.Search event) { }
private final java.util.concurrent.ConcurrentHashMap<Object, SavedSearch> savedSearches = new java.util.concurrent.ConcurrentHashMap<>();
private games.dmg.fantasystick.events.SavedCharacterEventBridge savedEvents;
public void attachSavedEvents(games.dmg.fantasystick.events.SavedCharacterEventBridge events) {
if (closed || savedEvents != null) throw new IllegalStateException("Saved events already attached or command closed");
savedEvents = Objects.requireNonNull(events);
}
private void searchResult(SavedSearch request, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome central,
String outcome, String reason,
java.util.List<games.dmg.fantasystick.events.SavedCharacterEventBridge.Summary> matches,
int unreadable, boolean complete) {
if (request.event() != null) savedEvents.searchResolved(request.event(), outcome, reason, matches, unreadable, complete);
if (adminAudit != null && request.action() != null) adminAudit.result(request.action(), central,
central == games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.COMPLETED ? "read_completed"
: central == games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED ? "operation_failed"
: central == games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.UNCERTAIN ? "operation_uncertain"
: reason.equals("permission_changed") ? reason : "stale",
request.event() == null ? java.util.Map.of() : java.util.Map.of("domain_event_id", request.event().eventId()));
}
private void searchResult(SavedSearch request, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome central,
String outcome, String reason) {
searchResult(request, central, outcome, reason, java.util.List.of(), 0, false);
}
private void refuseSavedSearch(String reason) {
var action = adminAudit == null ? null : adminAudit.current();
if (savedEvents != null) savedEvents.searchResolved(savedEvents.search(action, ""), "refused", reason,
java.util.List.of(), 0, false);
}
private static final int SAVED_CATALOG_COMPLETION_LIMIT = 256;
private static final int SAVED_SEARCH_PAGE_SIZE = 8;
private volatile boolean closed;
@@ -562,6 +590,10 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (!sender.hasPermission(PERMISSION)) {
if (characterBooks != null && args.length >= 2 && args[0].equals("character") && args[1].equals("view"))
characterBooks.refuseActiveInspection("unauthorized");
if (args.length >= 2 && args[0].equals("character")) {
if (args[1].equals("find")) refuseSavedSearch("unauthorized");
if (characterBooks != null && args[1].equals("show")) characterBooks.refuseSavedInspection("unauthorized");
}
if (sessionRecoveryEvents != null && args.length > 0
&& (args[0].equals("inspect") || args[0].equals("recover"))) {
var action = sessionRecoveryEvents.capture(sender, null);
@@ -790,64 +822,86 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void characterFind(CommandSender sender, String[] args) {
if (args.length < 3 || String.join(" ", java.util.Arrays.copyOfRange(args, 2, args.length)).isBlank()) {
refuseSavedSearch("invalid_arguments");
adminInvalid(); sender.sendMessage("Usage: /fstickadmin character find <name...>");
return;
}
var findAction = adminAudit == null ? null : adminAudit.current();
String query = String.join(" ", java.util.Arrays.copyOfRange(args, 2, args.length));
String folded = query.toLowerCase(java.util.Locale.ROOT);
Object ticket = new Object();
var ticket = new SavedSearch(findAction, savedEvents == null ? null : savedEvents.search(findAction, query));
Object requestKey = sender instanceof org.bukkit.entity.Player player
? player.getUniqueId() : EquipmentAdminCommand.class;
savedSearches.put(requestKey, ticket);
var previous = savedSearches.put(requestKey, ticket);
if (previous != null) searchResult(previous, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.UNCERTAIN,
"superseded", "superseded");
characters.catalog().whenComplete((catalog, failure) -> {
if (closed || savedSearches.get(requestKey) != ticket) return;
try { nextTick.accept(() -> {
if (closed || !savedSearches.remove(requestKey, ticket)) return;
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) {
adminResult(findAction,games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,
"permission_changed",null,null); return;
}
if (failure != null) {
adminSaved(findAction,failure,false,null,null);
sender.sendMessage("Saved character search failed; no records were changed.");
return;
}
adminResult(findAction,games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.COMPLETED,
"read_completed",null,null);
rememberSavedCharacters(catalog.records());
var matches = catalog.records().stream()
.filter(record -> record.name().toLowerCase(java.util.Locale.ROOT).contains(folded))
.sorted(java.util.Comparator.comparing(
(games.dmg.fantasystick.persistence.CharacterRecord record) ->
record.name().toLowerCase(java.util.Locale.ROOT))
.thenComparing(record -> record.id().toString()))
.toList();
if (matches.isEmpty()) {
sender.sendMessage(catalog.complete()
? "No saved characters match '" + query + "'."
: "No readable saved characters match '" + query
+ "'; results are incomplete because " + catalog.unreadable() + " record(s) could not be examined.");
return;
}
int pages = (matches.size() + SAVED_SEARCH_PAGE_SIZE - 1) / SAVED_SEARCH_PAGE_SIZE;
for (int page = 0; page < pages; page++) {
sender.sendMessage("Saved character results " + (page + 1) + "/" + pages
+ (catalog.complete() ? "" : " (incomplete: " + catalog.unreadable() + " unreadable)"));
int from = page * SAVED_SEARCH_PAGE_SIZE;
for (var record : matches.subList(from, Math.min(matches.size(), from + SAVED_SEARCH_PAGE_SIZE))) {
boolean active = sessions.activeCharacter(record.id()).filter(sessions::current).isPresent();
var result = net.kyori.adventure.text.Component.text(record.name() + " — " + record.id()
+ " — level " + record.level() + " — " + (record.dead() ? "dead" : "alive")
+ " — " + (active ? "active" : "inactive"))
.clickEvent(net.kyori.adventure.text.event.ClickEvent.suggestCommand(
"/fstickadmin character show " + record.id()));
sender.sendMessage(result);
try {
if (!sender.hasPermission(PERMISSION)) {
searchResult(ticket, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,
"refused", "permission_changed"); return;
}
if (sender instanceof org.bukkit.entity.Player player
&& (!player.isOnline() || server.getPlayer(player.getUniqueId()) != player)) {
searchResult(ticket, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,
"stale", "sender_lifetime_changed"); return;
}
if (failure != null) {
searchResult(ticket, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED,
"failed", "read_failed");
sender.sendMessage("Saved character search failed; no records were changed.");
return;
}
rememberSavedCharacters(catalog.records());
var matches = catalog.records().stream()
.filter(record -> record.name().toLowerCase(java.util.Locale.ROOT).contains(folded))
.sorted(java.util.Comparator.comparing(
(games.dmg.fantasystick.persistence.CharacterRecord record) ->
record.name().toLowerCase(java.util.Locale.ROOT))
.thenComparing(record -> record.id().toString()))
.toList();
if (matches.isEmpty()) {
sender.sendMessage(catalog.complete()
? "No saved characters match '" + query + "'."
: "No readable saved characters match '" + query
+ "'; results are incomplete because " + catalog.unreadable() + " record(s) could not be examined.");
searchResult(ticket, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.COMPLETED,
catalog.complete() ? "no_match" : "incomplete", "catalog_read_acknowledged",
java.util.List.of(), catalog.unreadable(), catalog.complete());
return;
}
var summaries = new java.util.ArrayList<games.dmg.fantasystick.events.SavedCharacterEventBridge.Summary>();
int pages = (matches.size() + SAVED_SEARCH_PAGE_SIZE - 1) / SAVED_SEARCH_PAGE_SIZE;
for (int page = 0; page < pages; page++) {
sender.sendMessage("Saved character results " + (page + 1) + "/" + pages
+ (catalog.complete() ? "" : " (incomplete: " + catalog.unreadable() + " unreadable)"));
int from = page * SAVED_SEARCH_PAGE_SIZE;
for (var record : matches.subList(from, Math.min(matches.size(), from + SAVED_SEARCH_PAGE_SIZE))) {
boolean active = sessions.activeCharacter(record.id()).filter(sessions::current).isPresent();
summaries.add(new games.dmg.fantasystick.events.SavedCharacterEventBridge.Summary(
record.id(), record.name(), record.level(), !record.dead(), active));
var result = net.kyori.adventure.text.Component.text(record.name() + " — " + record.id()
+ " — level " + record.level() + " — " + (record.dead() ? "dead" : "alive")
+ " — " + (active ? "active" : "inactive"))
.clickEvent(net.kyori.adventure.text.event.ClickEvent.suggestCommand(
"/fstickadmin character show " + record.id()));
sender.sendMessage(result);
}
}
searchResult(ticket, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.COMPLETED,
catalog.complete() ? "completed" : "incomplete", "catalog_read_acknowledged",
summaries, catalog.unreadable(), catalog.complete());
} catch (RuntimeException responseFailure) {
searchResult(ticket, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED,
"failed", "response_failed");
report(null, "character-find-response", responseFailure);
}
}); } catch (RuntimeException rejected) {
savedSearches.remove(requestKey, ticket);
if (savedSearches.remove(requestKey, ticket)) searchResult(ticket,
games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED, "failed", "scheduler_failed");
report(null, "character-find-response", rejected);
}
});
@@ -878,6 +932,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void characterShow(CommandSender sender, String[] args) {
if (characterBooks == null || !(sender instanceof org.bukkit.entity.Player administrator)) {
if (characterBooks != null) characterBooks.refuseSavedInspection("sender_unsupported");
adminRefused(); sender.sendMessage("Saved character books require an in-game administrator.");
return;
}
@@ -887,6 +942,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
character = UUID.fromString(args[2]);
if (!character.toString().equals(args[2])) throw new IllegalArgumentException("Noncanonical UUID");
} catch (IllegalArgumentException invalid) {
characterBooks.refuseSavedInspection("invalid_arguments");
adminInvalid(); sender.sendMessage("Usage: /fstickadmin character show <full-character-uuid>");
return;
}
@@ -2960,5 +3016,6 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
.distinct().sorted(java.util.Comparator.comparing((String value) -> value.toLowerCase(java.util.Locale.ROOT))
.thenComparing(java.util.Comparator.naturalOrder())).toList();
}
@Override public void close() { closed = true; savedSearches.clear(); if (characterIssuer != null) characterIssuer.close(); if(adminRevival!=null)adminRevival.close(); if(characterRecovery!=null)characterRecovery.close(); if(experienceGrant!=null)experienceGrant.close(); if(characterCoins!=null)characterCoins.close(); }
@Override public void close() { closed = true; savedSearches.values().forEach(request -> searchResult(request,
games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.UNCERTAIN, "cancelled", "plugin_shutdown")); savedSearches.clear(); if (characterIssuer != null) characterIssuer.close(); if(adminRevival!=null)adminRevival.close(); if(characterRecovery!=null)characterRecovery.close(); if(experienceGrant!=null)experienceGrant.close(); if(characterCoins!=null)characterCoins.close(); }
}
@@ -177,14 +177,7 @@ class AdminActionConfigurationCatalogNativeTest {
assertNotEquals(old, requested, "write must actually change effective value: " + key);
var before = actions().size(); var messages = entry.messages().size();
assertTrue(entry.map.dispatch(entry.sender, "fantasystick:fstickadmin config " + key + " " + requested));
long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(5);
while (System.nanoTime() < deadline) {
Runnable callback;
while ((callback = f.tasks.poll()) != null) callback.run();
if (requested.equals(readValue(data, key))
&& entry.map.tabComplete(entry.sender, "fstickadmin config " + key + " ").contains(requested)) break;
Thread.sleep(20);
}
awaitWrite(f, entry, key, requested, messages);
assertEquals(requested, readValue(data, key), "actual durable save: " + key);
assertTrue(entry.map.tabComplete(entry.sender, "fstickadmin config " + key + " ").contains(requested),
"native effective setting must change, not just the file: " + key);
@@ -216,6 +209,66 @@ class AdminActionConfigurationCatalogNativeTest {
}
}
private void awaitWrite(FantasyStickLifecycleNativeTest.Fixture f, AdminActionEntryNativeTest.Entry entry,
String key, String requested, int messages) throws Exception {
long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(5);
while (System.nanoTime() < deadline) {
Runnable callback;
while ((callback = f.tasks.poll()) != null) callback.run();
if (requested.equals(readValue(data, key))
&& entry.map.tabComplete(entry.sender, "fstickadmin config " + key + " ").contains(requested)
&& entry.messages().subList(messages, entry.messages().size()).stream()
.anyMatch(message -> message.contains(key + "=" + requested))) break;
Thread.sleep(20);
}
assertTrue(entry.messages().subList(messages, entry.messages().size()).stream()
.anyMatch(message -> message.contains(key + "=" + requested)), "matching native owner feedback: " + key);
}
@Test void savedAndEffectiveConfigurationMustWaitForItsHeldNativeOwnerFeedback() throws Exception {
seed();
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f;
var entry = new AdminActionEntryNativeTest.Entry(fixture, "console", true);
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
var port = games.dmg.fantasystick.session.EquipmentAdminCommand.class.getDeclaredField("nextTick");
var admin = assertInstanceOf(games.dmg.fantasystick.session.EquipmentAdminCommand.class,
f.adminCommand.getExecutor());
port.setAccessible(true); var original = port.get(admin);
var held = new java.util.concurrent.LinkedBlockingQueue<Runnable>();
var queued = new java.util.concurrent.CountDownLatch(1);
var returned = new java.util.concurrent.CountDownLatch(1);
var premature = new java.util.concurrent.atomic.AtomicBoolean();
java.util.concurrent.CompletableFuture<Void> observer = null;
int messages = entry.messages().size();
try {
port.set(admin, (java.util.function.Consumer<Runnable>) task -> {
held.add(task); queued.countDown();
});
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin config adventure-mode false"));
assertTrue(queued.await(5, java.util.concurrent.TimeUnit.SECONDS));
assertEquals("false", readValue(data, "adventure-mode"));
assertTrue(entry.map.tabComplete(entry.sender, "fstickadmin config adventure-mode ").contains("false"));
assertEquals(messages, entry.messages().size(), "real owner feedback is still held");
observer = java.util.concurrent.CompletableFuture.runAsync(() -> {
try { premature.set(returned.await(200, java.util.concurrent.TimeUnit.MILLISECONDS)); }
catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new AssertionError(interrupted); }
finally { Runnable task; while ((task = held.poll()) != null) f.tasks.add(task); }
});
awaitWrite(f, entry, "adventure-mode", "false", messages);
returned.countDown(); observer.get(5, java.util.concurrent.TimeUnit.SECONDS);
assertFalse(premature.get(), "saved/effective state is not the native feedback acknowledgment");
assertTrue(entry.messages().size() > messages);
} finally {
returned.countDown();
if (observer != null) observer.get(5, java.util.concurrent.TimeUnit.SECONDS);
Runnable task; while ((task = held.poll()) != null) f.tasks.add(task);
while ((task = f.tasks.poll()) != null) task.run();
port.set(admin, original);
}
}
}
@Test void unknownKeyAndMalformedTypedValuesRefuseWithoutSavingOrLeakingFreeformInput() throws Exception {
seed();
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
@@ -136,9 +136,56 @@ class CharacterBossMetadataSafetyNativeTest {
}
}
@Test void acknowledgedBossShutdownDoesNotWaitForUnrelatedQuestTrackingWorker() throws Exception {
try (var fixture = new CharacterBossAuditNativeFixture(data)) {
var b = fixture.base;
((LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin, "eventLog")).close();
fixture.authorAndAccept();
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(b.f);
var boss = FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin, "characterBossEvents");
var bossOutbox = FantasyStickLifecycleNativeTest.Fixture.field(boss, "outbox");
var bossClosed = (java.util.concurrent.atomic.AtomicBoolean) FantasyStickLifecycleNativeTest.Fixture.field(bossOutbox, "closed");
var tracking = FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin, "questTrackingEvents");
var trackingOutbox = FantasyStickLifecycleNativeTest.Fixture.field(tracking, "outbox");
var worker = (java.util.concurrent.ThreadPoolExecutor) FantasyStickLifecycleNativeTest.Fixture.field(trackingOutbox, "worker");
var entered = new java.util.concurrent.CountDownLatch(1);
var release = new java.util.concurrent.CountDownLatch(1);
var returned = new java.util.concurrent.CountDownLatch(1);
var unnecessarilyBlocked = new java.util.concurrent.atomic.AtomicBoolean();
java.util.concurrent.Future<?> held = null;
java.util.concurrent.CompletableFuture<Void> observer = null;
try {
held = worker.submit(() -> {
entered.countDown();
try { release.await(); }
catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new AssertionError(interrupted); }
});
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
observer = java.util.concurrent.CompletableFuture.runAsync(() -> {
try {
returned.await(200, java.util.concurrent.TimeUnit.MILLISECONDS);
unnecessarilyBlocked.set(!bossClosed.get());
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt(); throw new AssertionError(interrupted);
} finally { release.countDown(); }
});
shutdownAfterMetadata(b.f); returned.countDown(); observer.get(5, java.util.concurrent.TimeUnit.SECONDS);
assertFalse(unnecessarilyBlocked.get(), "settled original boss metadata must not wait for an unrelated fixture worker");
assertTrue(worker.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS), "the unrelated owner still drains at disposal");
assertEquals(fixture.custody, b.directory.custody(fixture.template.id()).orElseThrow());
} finally {
release.countDown();
if (observer != null) observer.get(5, java.util.concurrent.TimeUnit.SECONDS);
if (held != null) held.get(5, java.util.concurrent.TimeUnit.SECONDS);
}
}
}
private static void shutdownAfterMetadata(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
// Test-owned accepted metadata settles before starting the unchanged production shutdown attempt.
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
// The closed fixture writer rejects synchronously: this owned FIFO fence covers accepted
// original metadata and its sink callbacks, without requiring unrelated owners to be idle.
var owner = FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characterBossEvents");
fence(FantasyStickLifecycleNativeTest.Fixture.field(owner, "outbox"));
f.plugin.setEnabled(false); f.awaitWorkers(); WitchAuditTestSupport.drain(f, "characterBossDrain");
}
@@ -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()));
}
}
}
@@ -1090,6 +1090,7 @@ class FantasyStickLifecycleNativeTest {
if (!value.equals("default")) java.nio.file.Files.writeString(directory.resolve("config.yml"), "character-swap-cooldown-seconds: " + value + "\n");
try (var f = new Fixture(directory)) {
f.plugin.setEnabled(true); var a = new Actor(directory); f.players.put(a.core.id, a.player); f.activate(a);
org.mockito.Mockito.doReturn(null).when(a.player).getFishHook();
var characters = (CharacterStorage) Fixture.field(f.plugin, "characters");
var worker = (java.util.concurrent.ExecutorService) Fixture.field(characters, "worker");
var entered = new java.util.concurrent.CountDownLatch(1);
@@ -1126,6 +1127,7 @@ class FantasyStickLifecycleNativeTest {
f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(a.player, (net.kyori.adventure.text.Component) null));
f.tick(); f.tick(); f.tick(); assertTrue(a.core.marker().endsWith("|PACKED"));
}
JourneyAcknowledgmentNativeFixture.awaitSwitchExport(f, directory, exit.sessionId());
} finally { release.countDown(); }
}
var records = new java.util.ArrayList<com.google.gson.JsonObject>();
@@ -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;
}
}
@@ -24,6 +24,8 @@ import org.junit.jupiter.api.io.TempDir;
final class FishingQuestNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
java.util.function.Consumer<FantasyStickLifecycleNativeTest.Fixture> beforeAcceptance = ignored -> {};
Runnable acceptanceReadComplete = () -> {};
@SuppressWarnings("unchecked")
@Test void registeredFishingDefinitionCatchInventorySubmissionResetAndClaimUseConnectedPath() throws Exception {
@@ -40,6 +42,7 @@ final class FishingQuestNativeTest {
var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");var fishing=(FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");var site=adventures.snapshot().sites().getFirst();assertEquals(AdventureQuestState.SiteType.FISHING,site.type());assertEquals("Moonlit Pond",site.name());assertEquals("Silver Scale",site.fishing().itemName());assertEquals(AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY,site.participationMode());
f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","fishing",site.id().toString()});assertTrue(actor.messages.getLast().contains("Silver Scale"));assertEquals(List.of(site.id().toString()),f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure-site","fishing",""}));
var giver=mock(Villager.class);
when(giver.getType()).thenReturn(EntityType.VILLAGER);
var giverData=new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(
new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry());
when(giver.getPersistentDataContainer()).thenReturn(giverData);
@@ -61,7 +64,7 @@ final class FishingQuestNativeTest {
int before=adventures.snapshot().sites().size();f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","place","fishing","--name","Broken","--item","X","--required","0","--chance","101","--xp","1","--coins","1"});assertEquals(before,adventures.snapshot().sites().size());
var oldCatalog=adventures.snapshot().catalog();f.command.execute(actor.player,"fantasystick",new String[]{"adventure","reroll","confirm"});await(f,()->adventures.snapshot().reset()==null&&adventures.snapshot().catalog()!=null&&(oldCatalog==null||!adventures.snapshot().catalog().revision().equals(oldCatalog.revision())));var catalog=adventures.snapshot().catalog();assertTrue(catalog.sites().contains(site.id()));
var controller=(AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");
f.dispatcher.accept(new PlayerInteractEntityEvent(actor.player,giver,EquipmentSlot.HAND));drain(f);var offerMenu=actor.inventories.getLast();var offer=offer(offerMenu,"Moonlit Pond");var lore=plain(offer);assertTrue(lore.stream().anyMatch(line->line.contains("Mode: individual-only")));assertTrue(lore.stream().anyMatch(line->line.contains("Silver Scale × 1")));assertTrue(lore.stream().anyMatch(line->line.contains("Catch chance: 100%")));assertTrue(lore.stream().anyMatch(line->line.contains("10 XP, 4 coins")));click(controller,actor,offerMenu,offer);drain(f);var quest=adventures.active().getFirst();assertNotNull(quest.fishing());UUID stableItem=quest.fishing().itemId();f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","fishing",site.id().toString(),"--item","\"Golden Scale\"","--required","2","--chance","0","--xp","20","--coins","8","--radius","4"});await(f,()->actor.messages.stream().anyMatch(message->message.contains("Updated fishing site")&&message.contains(site.id().toString())));var updatedSite=adventures.findSite(site.id()).orElseThrow();assertEquals(stableItem,updatedSite.fishing().itemId());assertEquals("Golden Scale",updatedSite.fishing().itemName());assertEquals("Silver Scale",quest.fishing().itemName(),"accepted terms remain immutable");assertEquals(16,quest.fishing().radius());f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","mode",site.id().toString(),"party-required"});await(f,()->actor.messages.stream().anyMatch(message->message.contains("Updated adventure site "+site.id()+" mode=party-required")));actor.core.location.setX(10);
f.dispatcher.accept(new PlayerInteractEntityEvent(actor.player,giver,EquipmentSlot.HAND));drain(f);var offerMenu=actor.inventories.getLast();var offer=offer(offerMenu,"Moonlit Pond");var lore=plain(offer);assertTrue(lore.stream().anyMatch(line->line.contains("Mode: individual-only")));assertTrue(lore.stream().anyMatch(line->line.contains("Silver Scale × 1")));assertTrue(lore.stream().anyMatch(line->line.contains("Catch chance: 100%")));assertTrue(lore.stream().anyMatch(line->line.contains("10 XP, 4 coins")));beforeAcceptance.accept(f);click(controller,actor,offerMenu,offer);AdventureQuestState.Quest quest;try{quest=JourneyAcknowledgmentNativeFixture.awaitFishingAcceptance(f,adventures,actor,catalog.revision(),site.id());}finally{acceptanceReadComplete.run();}assertNotNull(quest.fishing());UUID stableItem=quest.fishing().itemId();f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","fishing",site.id().toString(),"--item","\"Golden Scale\"","--required","2","--chance","0","--xp","20","--coins","8","--radius","4"});await(f,()->actor.messages.stream().anyMatch(message->message.contains("Updated fishing site")&&message.contains(site.id().toString())));var updatedSite=adventures.findSite(site.id()).orElseThrow();assertEquals(stableItem,updatedSite.fishing().itemId());assertEquals("Golden Scale",updatedSite.fishing().itemName());assertEquals("Silver Scale",quest.fishing().itemName(),"accepted terms remain immutable");assertEquals(16,quest.fishing().radius());f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","mode",site.id().toString(),"party-required"});await(f,()->actor.messages.stream().anyMatch(message->message.contains("Updated adventure site "+site.id()+" mode=party-required")));actor.core.location.setX(10);
for(int retry=0;retry<20&&actor.core.inventory.getItem(0).getType()!=Material.FISHING_ROD;retry++){pulse(f);drain(f);}var rod=actor.core.inventory.getItem(0);assertEquals(Material.FISHING_ROD,rod.getType());assertEquals(quest.id(),AdventureQuestNavigation.fishingQuest(rod));assertTrue(rod.getEnchantments().isEmpty());
var use=new PlayerInteractEvent(actor.player,Action.RIGHT_CLICK_AIR,rod,null,org.bukkit.block.BlockFace.SELF,EquipmentSlot.HAND);f.dispatcher.accept(use);assertNotEquals(org.bukkit.event.Event.Result.DENY,use.useItemInHand(),"owned quest rod keeps native cast/reel mechanics");
var ordinaryHook=mock(FishHook.class);when(ordinaryHook.getUniqueId()).thenReturn(UUID.randomUUID());var ordinaryEvent=new PlayerFishEvent(actor.player,null,ordinaryHook,EquipmentSlot.HAND,PlayerFishEvent.State.REEL_IN);actor.core.inventory.setItem(0,new ItemStack(Material.FISHING_ROD));f.dispatcher.accept(ordinaryEvent);assertFalse(ordinaryEvent.isCancelled(),"ordinary fishing remains unchanged");
@@ -127,6 +130,15 @@ final class FishingQuestNativeTest {
var exported=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()).toList();
var turnInActions=exported.stream().filter(event->event.get("type").getAsString().endsWith("action.observed"))
.filter(event->event.getAsJsonObject("data").has("action_kind")
&&event.getAsJsonObject("data").get("action_kind").getAsString().startsWith("quest_turn_in_")).toList();
for(var kind:List.of("quest_turn_in_view","quest_turn_in_submit","quest_turn_in_claim"))
assertTrue(turnInActions.stream().anyMatch(event->event.getAsJsonObject("data").get("action_kind").getAsString().equals(kind)),
kind+" actual Giver input is missing from JSONL");
assertTrue(exported.stream().anyMatch(event->event.get("type").getAsString().endsWith("action.resolved")
&&event.getAsJsonObject("data").has("reason")
&&event.getAsJsonObject("data").get("reason").getAsString().equals("stale_or_foreign_menu")));
var fishingActions=exported.stream().filter(event->event.get("type").getAsString().endsWith("action.resolved"))
.filter(event->event.getAsJsonObject("data").has("action_kind")
&&event.getAsJsonObject("data").get("action_kind").getAsString().equals("fishing")).toList();
@@ -558,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 {
@@ -744,6 +770,6 @@ final class FishingQuestNativeTest {
private static void click(AdventureQuestController controller,FantasyStickLifecycleNativeTest.Actor actor,org.bukkit.inventory.Inventory menu,ItemStack item){var event=mock(InventoryClickEvent.class);when(event.getWhoClicked()).thenReturn(actor.player);when(event.getInventory()).thenReturn(menu);when(event.getCurrentItem()).thenReturn(item);controller.click(event);}
private static void clickFishing(FishingController controller,FantasyStickLifecycleNativeTest.Actor actor,org.bukkit.inventory.Inventory menu,ItemStack item){var event=mock(InventoryClickEvent.class);when(event.getWhoClicked()).thenReturn(actor.player);when(event.getInventory()).thenReturn(menu);when(event.getCurrentItem()).thenReturn(item);controller.click(event);}
private static void pulse(FantasyStickLifecycleNativeTest.Fixture f){for(var pulse:List.copyOf(f.pulses))pulse.run();}
private static void drain(FantasyStickLifecycleNativeTest.Fixture f)throws Exception{for(int attempt=0;attempt<30;attempt++){var task=f.tasks.poll(50,TimeUnit.MILLISECONDS);if(task==null)break;task.run();}}
private static void await(FantasyStickLifecycleNativeTest.Fixture f,java.util.function.BooleanSupplier condition)throws Exception{for(int attempt=0;attempt<80&&!condition.getAsBoolean();attempt++){drain(f);Thread.sleep(5);}assertTrue(condition.getAsBoolean());}
static void drain(FantasyStickLifecycleNativeTest.Fixture f)throws Exception{for(int attempt=0;attempt<30;attempt++){var task=f.tasks.poll(50,TimeUnit.MILLISECONDS);if(task==null)break;task.run();}}
static void await(FantasyStickLifecycleNativeTest.Fixture f,java.util.function.BooleanSupplier condition)throws Exception{for(int attempt=0;attempt<80&&!condition.getAsBoolean();attempt++){drain(f);Thread.sleep(5);}assertTrue(condition.getAsBoolean());}
}
@@ -0,0 +1,34 @@
package games.dmg.fantasystick;
import games.dmg.fantasystick.adventure.AdventureQuestDirectory;
import games.dmg.fantasystick.adventure.AdventureQuestState;
import java.nio.file.Path;
import java.util.UUID;
/** Scoped observation ports for the two approved switch/fishing journeys; never gameplay authority. */
final class JourneyAcknowledgmentNativeFixture {
private JourneyAcknowledgmentNativeFixture() {}
static void awaitSwitchExport(FantasyStickLifecycleNativeTest.Fixture f, Path directory, UUID session) throws Exception {
ReskillEventsNativeTest.await(f, () -> {
try {
return ReskillEventsNativeTest.records(directory).stream().anyMatch(row -> row.get("type").getAsString()
.endsWith("character.switch_delay_started") && row.getAsJsonObject("data")
.get("exit_operation_id").getAsString().equals(session.toString()));
} catch (Exception failure) { throw new AssertionError("Cannot read actual switch export", failure); }
});
}
static AdventureQuestState.Quest awaitFishingAcceptance(FantasyStickLifecycleNativeTest.Fixture f,
AdventureQuestDirectory directory, FantasyStickLifecycleNativeTest.Actor actor,
UUID catalog, UUID site) throws Exception {
var character = actor.core.record.id();
var acceptedPrefix = "Accepted " + directory.findSite(site).orElseThrow().displayName() + ";";
FishingQuestNativeTest.await(f, () -> directory.active().stream().anyMatch(quest -> quest.catalog().equals(catalog)
&& quest.site().equals(site) && quest.unit().equals(character) && quest.snapshot().contains(character))
&& actor.messages.stream().anyMatch(message -> message.startsWith(acceptedPrefix)));
return directory.active().stream().filter(quest -> quest.catalog().equals(catalog)
&& quest.site().equals(site) && quest.unit().equals(character) && quest.snapshot().contains(character))
.findFirst().orElseThrow();
}
}
@@ -0,0 +1,116 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import games.dmg.fantasystick.adventure.AdventureQuestDirectory;
import games.dmg.fantasystick.events.LocalEventLog;
import java.nio.file.Path;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Real owned workers are held; observer threads only touch latches, never native state. */
class JourneyAcknowledgmentNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void realPersistedLogoutMustWaitForItsOriginalSessionExportBeforeJourneyTeardown() throws Exception {
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(null).when(actor.player).getFishHook();
((LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog"))
.ready().toCompletableFuture().get(5, TimeUnit.SECONDS);
var bridge = FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "switchAuditEvents");
var worker = (ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(bridge, "worker");
try (var held = new HeldWorker(worker)) {
f.dispatcher.accept(new org.bukkit.event.player.PlayerQuitEvent(actor.player,
(net.kyori.adventure.text.Component) null));
f.awaitCharacterAndReceipt(actor);
var exit = f.receipts().find(actor.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS)
.orElseThrow().lastExit();
assertNotNull(exit, "Actual native logout durably acknowledged its original session exit");
assertFalse(ReskillEventsNativeTest.records(data).stream().anyMatch(row -> row.get("type").getAsString()
.endsWith("character.switch_delay_started")), "The real export owner is still held");
held.observe();
try { JourneyAcknowledgmentNativeFixture.awaitSwitchExport(f, data, exit.sessionId()); }
finally { held.returned.countDown(); }
held.assertDidWait();
var rows = ReskillEventsNativeTest.records(data).stream().filter(row -> row.get("type").getAsString()
.endsWith("character.switch_delay_started")).toList();
assertEquals(1, rows.size());
var values = rows.getFirst().getAsJsonObject("data");
assertEquals(exit.sessionId().toString(), values.get("exit_operation_id").getAsString());
assertEquals(exit.characterId().toString(), values.get("character_id").getAsString());
assertEquals(exit.exitedAt().toString(), values.get("exited_at").getAsString());
assertEquals(exit.deadline().toString(), values.get("deadline").getAsString());
}
}
}
@Test void registeredFishingMenuJourneyMustWaitForItsActualAcceptanceNotFixedQueueDrains() throws Exception {
var journey = new FishingQuestNativeTest(); journey.data = data;
var held = new java.util.concurrent.atomic.AtomicReference<HeldWorker>();
journey.beforeAcceptance = f -> {
var directory = (AdventureQuestDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "adventureQuests");
var worker = (ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(directory, "worker");
try {
var control = new HeldWorker(worker); held.set(control);
assertTrue(directory.active().isEmpty());
control.observe();
} catch (Exception failure) { throw new AssertionError(failure); }
};
journey.acceptanceReadComplete = () -> held.get().returned.countDown();
try {
assertDoesNotThrow(journey::registeredFishingDefinitionCatchInventorySubmissionResetAndClaimUseConnectedPath,
"The real fishing menu acceptance must be acknowledged before the journey reads its exact active quest");
assertNotNull(held.get()); held.get().assertDidWait();
} finally { if (held.get() != null) held.get().close(); }
}
private static final class HeldWorker implements AutoCloseable {
final CountDownLatch returned = new CountDownLatch(1);
private final CountDownLatch release = new CountDownLatch(1);
private final AtomicBoolean premature = new AtomicBoolean();
private final Future<?> block;
private CompletableFuture<Void> observer;
HeldWorker(ExecutorService worker) throws Exception {
var entered = new CountDownLatch(1);
block = worker.submit(() -> {
entered.countDown();
try { assertTrue(release.await(5, TimeUnit.SECONDS), "Controlled owner hold must be explicitly released"); }
catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new AssertionError(interrupted); }
});
assertTrue(entered.await(5, TimeUnit.SECONDS));
}
void observe() {
observer = CompletableFuture.runAsync(() -> {
try { premature.set(returned.await(200, TimeUnit.MILLISECONDS)); }
catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new AssertionError(interrupted); }
finally { release.countDown(); }
});
}
void assertDidWait() throws Exception {
observer.get(5, TimeUnit.SECONDS);
assertFalse(premature.get(), "Journey read/teardown returned before its actual owned acknowledgment");
}
@Override public void close() throws java.io.IOException {
release.countDown();
try {
if (observer != null) observer.get(5, TimeUnit.SECONDS);
block.get(5, TimeUnit.SECONDS);
} catch (Exception failure) {
if (failure instanceof InterruptedException) Thread.currentThread().interrupt();
throw new java.io.IOException("Controlled owner hold did not finish cleanly", failure);
}
}
}
}
@@ -0,0 +1,266 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import com.google.gson.JsonObject;
import games.dmg.fantasystick.adventure.*;
import games.dmg.fantasystick.events.LocalEventLog;
import games.dmg.fantasystick.persistence.CharacterStorage;
import java.math.BigInteger;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.TimeUnit;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.inventory.Inventory;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.io.TempDir;
/** Owner input → real durable ledgers/character → parseable operator JSONL. */
final class QuestTurnInEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void legacyClaimPublishesExactRetainedSourcesWithoutAnotherTrophyOrDuplicateSuccess() throws Exception {
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var actor=actor(f);var directory=adventures(f);var turn=turn(f);
var source=legacy(f,actor,BigInteger.valueOf(7),BigInteger.valueOf(3));
var giver=directory.placeGiver("Old Earnings Guide",actor.world.getUID(),0,64,0,0,0).get(5,TimeUnit.SECONDS);
var old=storage(f).find(actor.core.record.id()).get().orElseThrow();
turn.open(actor.player,giver);var menu=actor.inventories.getLast();
click(turn,actor,menu,"Claim retained adventure rewards");
click(turn,actor,menu,"Claim retained adventure rewards");
await(f,()->directory.unclaimedAll(old.id()).equals(AdventureQuestDirectory.Reward.empty()));
await(f,()->!rows("adventure.reward_claimed").isEmpty());
var saved=storage(f).find(old.id()).get().orElseThrow();
assertEquals(old.experience().add(BigInteger.valueOf(7)),saved.experience());
assertEquals(old.coins().add(BigInteger.valueOf(3)),saved.coins());
var claims=rows("adventure.reward_claimed");assertEquals(1,claims.size());
var value=claims.getFirst().getAsJsonObject("data");
assertEquals("legacy_adventure",value.get("claim_kind").getAsString());
assertEquals("7",value.get("applied_xp").getAsString());
assertEquals("3",value.get("applied_coins").getAsString());
assertEquals(giver.id().toString(),value.get("giver_id").getAsString());
assertEquals(source.toString(),value.getAsJsonArray("sources").get(0).getAsJsonObject().get("source_receipt_id").getAsString());
assertTrue(rows("adventure.trophy_redeemed").isEmpty());
assertTrue(rows("action.resolved").stream().anyMatch(row->reason(row,"stale_or_foreign_menu")));
}
}
@Test void failedLegacyCharacterSaveRetainsSharesAndReportsFailureBeforeFreshRetry() throws Exception {
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var actor=actor(f);legacy(f,actor,BigInteger.valueOf(7),BigInteger.ONE);
var turn=turn(f);var old=storage(f).find(actor.core.record.id()).get().orElseThrow();
var file=data.resolve("characters").resolve(old.id()+".yml");assertTrue(Files.isRegularFile(file));
byte[] saved=Files.readAllBytes(file);Files.delete(file);Files.createDirectory(file);
Files.writeString(file.resolve("block"),"owned test-only storage refusal");
turn.open(actor.player);click(turn,actor,actor.inventories.getLast(),"Claim retained adventure rewards");
await(f,()->rows("action.resolved").stream().anyMatch(row->reason(row,"source_write_failed")));
assertTrue(rows("adventure.reward_claimed").isEmpty());
var input=rows("action.observed").stream().filter(row->kind(row,"quest_turn_in_legacy_claim")).findFirst().orElseThrow();
var actionId=input.getAsJsonObject("data").get("action_id").getAsString();
var stages=rows("action.resolved").stream().filter(row->row.getAsJsonObject("data").get("action_id").getAsString().equals(actionId)).toList();
assertEquals(2,stages.size(),"pending and failed facts correlate to this one native click");
assertEquals(2,stages.stream().map(row->row.get("id").getAsString()).distinct().count(),
"distinct stages cannot reuse an event ID with a different payload");
assertEquals(BigInteger.valueOf(7),adventures(f).unclaimedAll(old.id()).xp());
Files.delete(file.resolve("block"));Files.delete(file);Files.write(file,saved);
turn.open(actor.player);click(turn,actor,actor.inventories.getLast(),"Claim retained adventure rewards");
await(f,()->!rows("adventure.reward_claimed").isEmpty());
assertEquals(1,rows("adventure.reward_claimed").size());
assertEquals(old.coins().add(BigInteger.ONE),storage(f).find(old.id()).get().orElseThrow().coins());
}
}
@Test void explicitViewsNavigationForeignAndStaleMenusGrantNothing() throws Exception {
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var owner=actor(f);var foreign=actor(f);var fishing=fishing(f);var turn=turn(f);
for(int index=0;index<46;index++)trophy(f,owner,"Saved Trophy "+index,BigInteger.ONE,BigInteger.ONE);
var old=storage(f).find(owner.core.record.id()).get().orElseThrow();
turn.open(owner.player);var first=owner.inventories.getLast();
click(turn,foreign,first,"Redeem Saved Trophy");
click(turn,owner,first,"Next");
var second=owner.inventories.getLast();assertNotSame(first,second);
click(turn,owner,first,"Next");
click(turn,owner,second,"Previous");
await(f,()->rows("action.resolved").stream().anyMatch(row->reason(row,"stale_or_foreign_menu")));
assertEquals(46,fishing.trophies(old.id()).size());
assertEquals(old,storage(f).find(old.id()).get().orElseThrow());
assertTrue(rows("adventure.trophy_redeemed").isEmpty());
assertTrue(rows("adventure.reward_claimed").isEmpty());
assertEquals(1,rows("action.observed").stream().filter(row->kind(row,"quest_turn_in_view")
&&row.getAsJsonObject("data").get("character_id").getAsString().equals(old.id().toString())).count(),
"automatic/page refreshes are not new user view inputs");
await(f,()->rows("action.observed").stream().filter(row->kind(row,"quest_turn_in_navigation")).count()==3);
assertEquals(3,rows("action.observed").stream().filter(row->kind(row,"quest_turn_in_navigation")).count());
}
}
@Test void rejectedWriterRestartPreservesClaimIdentityPayloadAndNeverReappliesRewards() throws Exception {
UUID character;BigInteger coins;Properties frozen;
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var actor=actor(f);character=actor.core.record.id();
var trophy=trophy(f,actor,"Retained Receipt",BigInteger.valueOf(7),BigInteger.valueOf(3));
var turn=turn(f);turn.open(actor.player);click(turn,actor,actor.inventories.getLast(),"Redeem Retained Receipt");
await(f,()->fishing(f).trophies(character).isEmpty());
await(f,()->!rows("adventure.trophy_redeemed").isEmpty());
((LocalEventLog)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"eventLog")).close();
coins=storage(f).find(character).get().orElseThrow().coins();
turn.open(actor.player);click(turn,actor,actor.inventories.getLast(),"Claim earned quest rewards");
await(f,()->fishing(f).unclaimed(character).equals(FishingState.Reward.empty()));
f.plugin.setEnabled(false);
// Disable is nonblocking for this owner: retain its accepted intent before restart/disposal.
((java.util.concurrent.CompletionStage<?>)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingAuditDrain"))
.toCompletableFuture().get(5,TimeUnit.SECONDS);
assertTrue(rows("adventure.reward_claimed").isEmpty(),"pending intent is not export proof");
frozen=pendingClaim();assertEquals("s7",frozen.getProperty("data.applied_xp"));
assertEquals(trophy.toString(),com.google.gson.JsonParser.parseString(frozen.getProperty("data.sources").substring(1))
.getAsJsonArray().get(0).getAsJsonObject().get("source_receipt_id").getAsString());
f.plugin.setEnabled(true);
await(f,()->!rows("adventure.reward_claimed").isEmpty());
var saved=storage(f).find(character).get().orElseThrow();
assertEquals(BigInteger.valueOf(7),saved.experience());assertEquals(coins.add(BigInteger.valueOf(3)),saved.coins());
f.plugin.setEnabled(false);
}
var exported=rows("adventure.reward_claimed");assertEquals(1,exported.size());
assertEquals(frozen.getProperty("id"),exported.getFirst().get("id").getAsString());
assertEquals(frozen.getProperty("time"),exported.getFirst().get("time").getAsString());
assertEquals(frozen.getProperty("player"),exported.getFirst().get("playerid").getAsString());
}
@Test void pendingLegacyClaimRecoveryAtGiverOffersPublishesSameOperationOnlyOnce() throws Exception {
UUID operation;UUID character;
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var actor=actor(f);character=actor.core.record.id();
legacy(f,actor,BigInteger.valueOf(7),BigInteger.valueOf(3));
var claim=adventures(f).beginLegacyClaim(character).get(5,TimeUnit.SECONDS).orElseThrow();operation=claim.id();
var controller=(AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");
controller.open(actor.player);
await(f,()->!rows("adventure.reward_claimed").isEmpty());
assertEquals(operation.toString(),rows("adventure.reward_claimed").getFirst().getAsJsonObject("data").get("claim_id").getAsString());
controller.open(actor.player);f.plugin.setEnabled(false);
}
assertEquals(1,rows("adventure.reward_claimed").size());
}
@Test void preparedSubmissionRestartExportsAcknowledgedDeductionWithOriginalActorAndItem() throws Exception {
UUID character,submitter,operation,item,session;String name,capturedAt,worldName;
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var actor=actor(f);character=actor.core.record.id();submitter=actor.player.getUniqueId();
session=((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions")).active(submitter).orElseThrow().session();
name=actor.core.record.name();operation=UUID.randomUUID();item=UUID.randomUUID();
actor.core.location.setX(4);actor.core.location.setZ(7);
var bridge=(games.dmg.fantasystick.events.FishingEventBridge)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingAuditEvents");
var original=bridge.actor(actor.player);assertNotNull(original.context().location());
capturedAt=original.context().location().capturedAt().toString();worldName=original.context().location().worldName();
var fishing=fishing(f);var quest=UUID.randomUUID();var site=UUID.randomUUID();var catalog=UUID.randomUUID();
var caught=new FishingState.Catch(UUID.randomUUID(),character,quest,site,item,"Saved Scale",BigInteger.ZERO,true);
fishing.prepareCatch(caught).get(5,TimeUnit.SECONDS);fishing.completeCatch(caught.id()).get(5,TimeUnit.SECONDS);
var metadata=Map.of("player_id",submitter.toString(),"player_name",actor.player.getName(),
"character_name",name,"submitter_session_id",session.toString(),"submitter_location",actor.world.getUID()+",4.0,64.0,7.0",
"item_name","Saved Scale","unit_kind","solo","unit_id",character.toString(),
"actor_snapshot",games.dmg.fantasystick.events.FishingEventBridge.actorSnapshot(original));
var pending=new FishingState.Submission(operation,quest,catalog,site,character,item,BigInteger.ONE,
Map.of(character,new FishingState.Reward(BigInteger.TEN,BigInteger.ONE)),FishingState.SubmissionPhase.PREPARED,metadata);
assertEquals(FishingDirectory.PrepareStatus.APPLIED,fishing.prepareSubmission(pending).get(5,TimeUnit.SECONDS));
f.plugin.setEnabled(false);
assertTrue(rows("adventure.items_submitted").isEmpty());
f.plugin.setEnabled(true);
await(f,()->!rows("adventure.items_submitted").isEmpty());
assertEquals(BigInteger.ZERO,fishing(f).quantity(character,item));
assertEquals(BigInteger.TEN,fishing(f).unclaimed(character).xp());
f.plugin.setEnabled(false);
}
var records=rows("adventure.items_submitted");assertEquals(1,records.size());var event=records.getFirst();
assertEquals(submitter.toString(),event.get("playerid").getAsString());
assertEquals(character.toString(),event.get("characterid").getAsString());
var value=event.getAsJsonObject("data");assertEquals(operation.toString(),value.get("submission_id").getAsString());
assertEquals("Saved Scale",value.get("item_name").getAsString());assertEquals("1",value.get("stock_before").getAsString());
assertEquals("0",value.get("stock_after").getAsString());assertEquals(4d,value.getAsJsonObject("submitter_location").get("x").getAsDouble());
assertEquals(capturedAt,value.getAsJsonObject("location").get("captured_at").getAsString());
assertEquals(worldName,value.getAsJsonObject("location").get("world_name").getAsString());
assertFalse(value.has("actor_snapshot"),"internal persisted proof is projected, not dumped into operator records");
}
@Test void cappedClaimReportsAppliedAndLostXpWithoutLosingCoins() throws Exception {
Files.writeString(data.resolve("config.yml"),"maximum-level: 1\n");
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var actor=actor(f);trophy(f,actor,"Capped Receipt",BigInteger.valueOf(7),BigInteger.valueOf(3));
var turn=turn(f);turn.open(actor.player);click(turn,actor,actor.inventories.getLast(),"Redeem Capped Receipt");
await(f,()->fishing(f).trophies(actor.core.record.id()).isEmpty());
turn.open(actor.player);click(turn,actor,actor.inventories.getLast(),"Claim earned quest rewards");
await(f,()->!rows("adventure.reward_claimed").isEmpty());
var value=rows("adventure.reward_claimed").getFirst().getAsJsonObject("data");
assertEquals("0",value.get("applied_xp").getAsString());assertEquals("7",value.get("lost_xp").getAsString());
assertEquals("3",value.get("applied_coins").getAsString());assertEquals("at_cap",value.get("award_status").getAsString());
}
}
@Test void foreignStockAndDeadCurrentCharacterCannotSubmitOrRedeem() throws Exception {
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var owner=actor(f);var foreign=actor(f);var directory=adventures(f);var turn=turn(f);
var site=directory.placeFishingSite("Private Stock","Owned Scale",owner.world.getUID(),0,64,0,16,
java.math.BigDecimal.valueOf(100),BigInteger.ONE,BigInteger.TEN,BigInteger.ONE,AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY).get(5,TimeUnit.SECONDS);
var before=directory.snapshot().catalog();var roll=directory.rerollToday(before==null?null:before.revision()).get(5,TimeUnit.SECONDS);
if(directory.snapshot().reset()!=null)directory.completeReset(directory.snapshot().reset().operation()).get(5,TimeUnit.SECONDS);
var quest=directory.accept(roll.catalog().orElseThrow().revision(),site.id(),AdventureQuestState.UnitType.SOLO,owner.core.record.id(),Set.of(owner.core.record.id())).get(5,TimeUnit.SECONDS).quest().orElseThrow();
var stock=new FishingState.Catch(UUID.randomUUID(),foreign.core.record.id(),quest.id(),site.id(),site.fishing().itemId(),"Owned Scale",BigInteger.ZERO,true);
fishing(f).prepareCatch(stock).get(5,TimeUnit.SECONDS);fishing(f).completeCatch(stock.id()).get(5,TimeUnit.SECONDS);
turn.open(owner.player);click(turn,owner,owner.inventories.getLast(),"Submit Owned Scale");
await(f,()->rows("action.resolved").stream().anyMatch(row->reason(row,"insufficient_stock")));
assertEquals(BigInteger.ONE,fishing(f).quantity(foreign.core.record.id(),site.fishing().itemId()));
assertTrue(rows("adventure.items_submitted").isEmpty());
trophy(f,owner,"Dead Holder Receipt",BigInteger.ONE,BigInteger.ONE);
turn.open(owner.player);var menu=owner.inventories.getLast();doReturn(true).when(owner.player).isDead();
click(turn,owner,menu,"Redeem Dead Holder Receipt");
await(f,()->rows("action.resolved").stream().anyMatch(row->reason(row,"stale_or_foreign_menu")));
assertEquals(1,fishing(f).trophies(owner.core.record.id()).size());
assertTrue(rows("adventure.trophy_redeemed").isEmpty());
doReturn(false).when(owner.player).isDead();
}
}
private Properties pendingClaim() throws Exception {
try(var paths=Files.list(data.resolve("fishing-event-intents"))){
for(var path:paths.filter(value->value.toString().endsWith(".pending")).toList()){
var value=new Properties();try(var input=Files.newInputStream(path)){value.load(input);}
if("adventure.reward_claimed".equals(value.getProperty("type")))return value;
}
}
throw new AssertionError("acknowledged claim lacks a retained outbox intent");
}
private FantasyStickLifecycleNativeTest.Actor actor(FantasyStickLifecycleNativeTest.Fixture f)throws Exception{
var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);f.activate(actor);return actor;
}
private static UUID legacy(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor actor,BigInteger xp,BigInteger coins)throws Exception{
var directory=adventures(f);var site=directory.placeSite(AdventureQuestState.SiteType.CAMP,"Legacy Site",actor.world.getUID(),0,64,0).get(5,TimeUnit.SECONDS);
var before=directory.snapshot().catalog();var roll=directory.rerollToday(before==null?null:before.revision()).get(5,TimeUnit.SECONDS);
if(directory.snapshot().reset()!=null)directory.completeReset(directory.snapshot().reset().operation()).get(5,TimeUnit.SECONDS);
var quest=directory.accept(roll.catalog().orElseThrow().revision(),site.id(),AdventureQuestState.UnitType.SOLO,actor.core.record.id(),Set.of(actor.core.record.id())).get(5,TimeUnit.SECONDS).quest().orElseThrow();
var source=UUID.randomUUID();assertTrue(directory.bankAndComplete(quest.id(),source,Map.of(actor.core.record.id(),new AdventureQuestDirectory.Reward(xp,coins))).get(5,TimeUnit.SECONDS));return source;
}
private static UUID trophy(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor actor,String name,BigInteger xp,BigInteger coins)throws Exception{
var id=UUID.randomUUID();var trophy=new FishingState.Trophy(id,FishingState.TrophyType.CAMP,actor.core.record.id(),UUID.randomUUID(),UUID.randomUUID(),UUID.randomUUID(),UUID.randomUUID(),name,"Prior Camp",Map.of(actor.core.record.id(),new FishingState.Reward(xp,coins)));
assertEquals(FishingDirectory.TrophyPrepareStatus.APPLIED,fishing(f).prepareTrophy(trophy).get(5,TimeUnit.SECONDS));
assertEquals(FishingDirectory.TrophyCommitStatus.APPLIED,fishing(f).commitTrophy(id).get(5,TimeUnit.SECONDS));return id;
}
private static void click(FishingController turn,FantasyStickLifecycleNativeTest.Actor actor,Inventory menu,String name){
var item=Arrays.stream(menu.getContents()).filter(Objects::nonNull).filter(value->value.hasItemMeta()
&&PlainTextComponentSerializer.plainText().serialize(value.getItemMeta().displayName()).startsWith(name)).findFirst().orElseThrow();
var event=mock(InventoryClickEvent.class);when(event.getInventory()).thenReturn(menu);when(event.getWhoClicked()).thenReturn(actor.player);when(event.getCurrentItem()).thenReturn(item);turn.click(event);verify(event).setCancelled(true);
}
private List<JsonObject> rows(String suffix)throws Exception{
return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith(suffix)).toList();
}
private static boolean kind(JsonObject row,String kind){var values=row.getAsJsonObject("data");return values.has("action_kind")&&values.get("action_kind").getAsString().equals(kind);}
private static boolean reason(JsonObject row,String reason){var values=row.getAsJsonObject("data");return values.has("reason")&&values.get("reason").getAsString().equals(reason);}
@FunctionalInterface private interface Check {boolean get()throws Exception;}
private static void await(FantasyStickLifecycleNativeTest.Fixture f,Check check)throws Exception{
long deadline=System.nanoTime()+TimeUnit.SECONDS.toNanos(5);while(!check.get()&&System.nanoTime()<deadline){var task=f.tasks.poll(10,TimeUnit.MILLISECONDS);if(task!=null)task.run();}assertTrue(check.get(),"supported turn-in result not acknowledged in bound");
}
private static AdventureQuestDirectory adventures(FantasyStickLifecycleNativeTest.Fixture f){return (AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");}
private static FishingDirectory fishing(FantasyStickLifecycleNativeTest.Fixture f){return (FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");}
private static FishingController turn(FantasyStickLifecycleNativeTest.Fixture f){return (FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");}
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture f){return (CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");}
}
@@ -0,0 +1,539 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import com.google.gson.JsonObject;
import games.dmg.fantasystick.persistence.CharacterStorage;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
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;
/** Registered saved-read commands -> authoritative reads -> bounded, correlated JSONL. */
class SavedCharacterEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@ParameterizedTest @ValueSource(strings = {"player", "console", "command_block"})
void registeredSearchExportsOnlyExposedUnicodeSummaryWithoutMutation(String kind) throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f;
var entry = new AdminActionEntryNativeTest.Entry(fixture, kind, true);
var record = storage(f).renameCharacter(fixture.actor.core.record.id(), "Éowyn 雪 — Hero")
.get(5, TimeUnit.SECONDS).orElseThrow();
var path = data.resolve("characters").resolve(record.id() + ".yml");
var saved = Files.readAllBytes(path);
var before = fixture.actor.core.snapshot();
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
assertTrue(entry.map.dispatch(entry.sender, "fantasystick:fstickadmin character find ÉOWYN 雪"));
reads(f);
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
var rows = rows("character.saved_search_resolved");
assertEquals(1, rows.size(), "an authoritative saved search must produce its feature-owned resolution");
var values = rows.getFirst().getAsJsonObject("data");
assertEquals("completed", values.get("outcome").getAsString());
assertEquals("ÉOWYN 雪", values.get("query").getAsString());
assertEquals(kind, values.get("actor_kind").getAsString());
if (kind.equals("command_block")) {
var place = values.getAsJsonObject("location");
assertNotNull(place, "physical command-block origin must be captured, not mislabeled as absent");
assertEquals(fixture.actor.world.getUID().toString(), place.get("world_id").getAsString());
assertEquals(12, place.get("x").getAsDouble());
assertEquals("captured", values.get("location_status").getAsString());
} else if (kind.equals("console")) assertEquals("not_applicable", values.get("location_status").getAsString());
else assertTrue(values.has("location"));
assertEquals(1, values.get("match_count").getAsInt());
assertEquals(1, values.get("page_count").getAsInt());
assertTrue(values.get("catalog_complete").getAsBoolean());
assertEquals(0, values.get("unreadable_count").getAsInt());
var match = values.getAsJsonArray("results").get(0).getAsJsonObject();
assertEquals(record.id().toString(), match.get("character_id").getAsString());
assertEquals(record.name(), match.get("name").getAsString());
assertEquals(Integer.toString(record.level()), match.get("level").getAsString());
assertTrue(match.get("alive").getAsBoolean());
assertFalse(match.get("active").getAsBoolean());
assertEquals(5, match.size());
assertFalse(values.has("stats") || values.has("record") || values.has("inventory"));
var central = AdminActionEntryNativeTest.rows(data);
var terminal = central.stream().filter(row -> row.getAsJsonObject("data").has("outcome")
&& !row.getAsJsonObject("data").get("outcome").getAsString().equals("pending")).toList();
assertEquals(1, terminal.size());
assertEquals(values.get("action_id"), terminal.getFirst().getAsJsonObject("data").get("action_id"));
assertEquals(rows.getFirst().get("id"), terminal.getFirst().getAsJsonObject("data").get("domain_event_id"));
assertArrayEquals(saved, Files.readAllBytes(path));
assertArrayEquals(before, fixture.actor.core.snapshot());
assertTrue(fixture.actor.books.isEmpty());
entry.map.tabComplete(entry.sender, "fstickadmin character find É");
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
assertEquals(1, rows("character.saved_search_resolved").size());
}
}
@ParameterizedTest @ValueSource(strings = {"inactive", "dead", "active"})
void exactUuidInspectionExportsOnlySavedIdentityAndActualOpenedView(String state) throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f;
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
var target = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(target.core.id, target.player);
if (state.equals("active")) { f.activate(target); target.core.health = 3; }
if (state.equals("dead")) storage(f).recordDeath(target.core.record.id()).get(5, TimeUnit.SECONDS);
var record = storage(f).inspect(target.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
var path = data.resolve("characters").resolve(record.id() + ".yml");
var saved = Files.readAllBytes(path);
var adminBefore = fixture.actor.core.snapshot();
var targetBefore = target.core.snapshot();
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin character show " + record.id()));
assertTrue(fixture.actor.books.isEmpty());
reads(f);
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
var rows = rows("character.saved_inspection_resolved");
assertEquals(1, rows.size(), "exact-UUID saved-state read must emit its own inspection outcome");
var values = rows.getFirst().getAsJsonObject("data");
assertEquals("opened", values.get("outcome").getAsString());
assertEquals("command", values.get("entry_point").getAsString());
assertEquals(record.id().toString(), values.get("subject_character_id").getAsString());
assertEquals(record.name(), values.get("subject_character_name").getAsString());
assertEquals(record.revision().toString(), values.get("saved_revision").getAsString());
assertEquals(record.sheetGeneration().toString(), values.get("sheet_generation").getAsString());
assertEquals(!record.dead(), values.get("alive").getAsBoolean());
assertEquals(state.equals("active"), values.get("active").getAsBoolean());
assertEquals(0, values.get("requested_offset").getAsInt());
assertEquals(1, values.get("requested_volume").getAsInt());
assertEquals(((org.bukkit.inventory.meta.BookMeta) fixture.actor.books.getLast().getItemMeta()).getPageCount(),
values.get("presented_pages").getAsInt());
assertTrue(values.getAsJsonArray("exposed_sections").toString().contains("saved_identity"));
assertFalse(values.has("stats") || values.has("record") || values.has("inventory") || values.has("pages"));
var terminal = AdminActionEntryNativeTest.rows(data).stream().filter(row -> row.getAsJsonObject("data").has("outcome")
&& row.getAsJsonObject("data").get("outcome").getAsString().equals("completed")).toList();
assertEquals(1, terminal.size());
assertEquals(rows.getFirst().get("id"), terminal.getFirst().getAsJsonObject("data").get("domain_event_id"));
assertEquals(values.get("action_id"), terminal.getFirst().getAsJsonObject("data").get("action_id"));
assertArrayEquals(saved, Files.readAllBytes(path));
assertArrayEquals(adminBefore, fixture.actor.core.snapshot());
assertArrayEquals(targetBefore, target.core.snapshot());
assertTrue(target.books.isEmpty());
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(fixture.actor.books.getLast())
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
String text = content.pages().stream().map(page -> page.raw().getString())
.collect(java.util.stream.Collectors.joining()).replace("\n", "");
assertTrue(text.contains("Saved state"));
assertTrue(text.contains("Saved health: " + record.health().stripTrailingZeros().toPlainString()));
}
}
@ParameterizedTest @ValueSource(strings = {"find", "show"})
void nativePermissionGateProducesSafeFeatureRefusalWithoutProtectedArguments(String route) throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", false);
var target = new FantasyStickLifecycleNativeTest.Actor(data);
storage(fixture.f).renameCharacter(target.core.record.id(), "Protected Name").get(5, TimeUnit.SECONDS);
assertTrue(entry.map.dispatch(entry.sender, "fantasystick:fstickadmin character " + route + " "
+ (route.equals("find") ? "Protected Name" : target.core.record.id())));
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(fixture.f);
var events = rows("character.saved_" + (route.equals("find") ? "search" : "inspection") + "_resolved");
assertEquals(1, events.size());
var values = events.getFirst().getAsJsonObject("data");
assertEquals("refused", values.get("outcome").getAsString());
assertEquals("denied", values.get("authorization").getAsString());
assertFalse(events.toString().contains(target.core.record.id().toString()));
assertFalse(events.toString().contains("Protected Name"));
assertFalse(values.has("query") || values.has("results") || values.has("subject_character_id"));
assertTrue(fixture.actor.books.isEmpty());
assertEquals(3, AdminActionEntryNativeTest.rows(data).size());
}
}
@Test void nonceNavigationRereadsSavedRevisionAndRefusesReplayWithoutRecordDump() throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f;
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
var record = storage(f).inspect(fixture.actor.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
var stats = new java.util.EnumMap<>(record.stats());
String privateDigits = "1234567890".repeat(4_000);
stats.put(games.dmg.fantasystick.progression.Stat.STR, new java.math.BigInteger(privateDigits));
var repo = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
repo.replace(withStats(record, stats), record.revision());
entry.map.dispatch(entry.sender, "fstickadmin character show " + record.id());
reads(f); FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
assertEquals(1, rows("character.saved_inspection_resolved").size());
var link = next(fixture.actor);
assertNotNull(link);
var renamed = storage(f).renameCharacter(record.id(), "Current Unicode 雪").get(5, TimeUnit.SECONDS).orElseThrow();
var path = data.resolve("characters").resolve(record.id() + ".yml");
var saved = Files.readAllBytes(path);
var before = fixture.actor.core.snapshot();
PartyBookNativeTest.click(f, fixture.actor, link);
reads(f); FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
var events = rows("character.saved_inspection_resolved");
assertEquals(2, events.size(), "each actual nonce-bound reread owns a fresh correlated outcome");
var values = events.getLast().getAsJsonObject("data");
assertEquals("opened", values.get("outcome").getAsString());
assertEquals("book_navigation", values.get("entry_point").getAsString());
assertEquals(30, values.get("requested_offset").getAsInt());
assertEquals(2, values.get("requested_volume").getAsInt());
assertEquals(30, values.get("presented_offset").getAsInt());
assertTrue(values.get("presented_volumes").getAsInt() > 1);
assertEquals(renamed.revision().toString(), values.get("saved_revision").getAsString());
assertEquals(renamed.name(), values.get("subject_character_name").getAsString());
assertEquals(List.of("overview"), values.getAsJsonArray("exposed_sections").asList().stream()
.map(com.google.gson.JsonElement::getAsString).toList());
PartyBookNativeTest.click(f, fixture.actor, link);
reads(f); FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
events = rows("character.saved_inspection_resolved");
assertEquals(3, events.size());
assertEquals(2, fixture.actor.books.size());
var refusal = events.getLast().getAsJsonObject("data");
assertEquals("refused", refusal.get("outcome").getAsString());
assertFalse(refusal.has("subject_character_id") || refusal.has("saved_revision"));
assertFalse(events.toString().contains(privateDigits.substring(0, 100)));
assertEquals(3, events.stream().map(row -> row.get("id")).distinct().count());
assertEquals(3, events.stream().map(row -> row.getAsJsonObject("data").get("action_id")).distinct().count());
assertArrayEquals(saved, Files.readAllBytes(path));
assertArrayEquals(before, fixture.actor.core.snapshot());
for (var row : events) assertCorrelated(row);
}
}
@Test void multipartSearchPreservesEveryDuplicateSummaryAndCatalogIncompleteness() throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f;
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
var expected = new java.util.ArrayList<UUID>();
for (int n = 0; n < 18; n++) {
var target = new FantasyStickLifecycleNativeTest.Actor(data);
var record = storage(f).renameCharacter(target.core.record.id(), "雪 Duplicate").get(5, TimeUnit.SECONDS).orElseThrow();
expected.add(record.id());
if (n == 0) {
target.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(record));
f.players.put(target.core.id, target.player); f.activate(target);
}
if (n == 1) storage(f).recordDeath(record.id()).get(5, TimeUnit.SECONDS);
}
UUID corrupt = UUID.randomUUID();
Files.writeString(data.resolve("characters").resolve(corrupt + ".yml"), "schema-version: malformed\n");
entry.map.dispatch(entry.sender, "fstickadmin character find 雪 Duplicate");
reads(f); FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
var events = rows("character.saved_search_resolved");
assertEquals(3, events.size());
var exposed = new java.util.ArrayList<String>();
for (int n = 0; n < events.size(); n++) {
var v = events.get(n).getAsJsonObject("data");
assertEquals("incomplete", v.get("outcome").getAsString());
assertEquals(18, v.get("match_count").getAsInt());
assertEquals(3, v.get("page_count").getAsInt());
assertEquals(n + 1, v.get("part_index").getAsInt());
assertEquals(3, v.get("part_count").getAsInt());
assertEquals(1, v.get("unreadable_count").getAsInt());
assertFalse(v.get("catalog_complete").getAsBoolean());
assertEquals(events.getFirst().get("id"), v.get("resolution_id"));
assertTrue(v.getAsJsonArray("results").size() <= 8);
for (var item : v.getAsJsonArray("results")) {
var summary = item.getAsJsonObject();
assertEquals(5, summary.size());
exposed.add(summary.get("character_id").getAsString());
}
assertCorrelated(events.get(n));
}
assertEquals(expected.stream().map(UUID::toString).sorted().toList(), exposed);
assertEquals(3, events.stream().map(row -> row.get("id")).distinct().count());
assertEquals(1, events.stream().map(row -> row.getAsJsonObject("data").get("action_id")).distinct().count());
assertFalse(events.toString().contains(corrupt.toString()));
assertEquals(1, events.stream().flatMap(row -> row.getAsJsonObject("data").getAsJsonArray("results").asList().stream())
.filter(item -> item.getAsJsonObject().get("active").getAsBoolean()).count());
assertEquals(1, events.stream().flatMap(row -> row.getAsJsonObject("data").getAsJsonArray("results").asList().stream())
.filter(item -> !item.getAsJsonObject().get("alive").getAsBoolean()).count());
}
}
@ParameterizedTest @ValueSource(strings = {"find_permission", "find_disconnect", "find_replacement", "find_superseded", "find_shutdown",
"show_permission", "show_disconnect", "show_replacement", "show_superseded", "show_shutdown"})
void pendingRequestsResolveOnceWithoutStaleSavedMetadata(String variant) throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f;
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
var target = new FantasyStickLifecycleNativeTest.Actor(data);
var record = storage(f).renameCharacter(target.core.record.id(), "Protected Pending 雪").get(5, TimeUnit.SECONDS).orElseThrow();
boolean search = variant.startsWith("find_");
String command = "fstickadmin character " + (search ? "find Protected Pending" : "show " + record.id());
entry.map.dispatch(entry.sender, command);
storage(f).inspect(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
String race = variant.substring(variant.indexOf('_') + 1);
switch (race) {
case "permission" -> fixture.actor.allowed = false;
case "disconnect" -> fixture.actor.core.online = false;
case "replacement" -> f.players.put(fixture.actor.core.id, new FantasyStickLifecycleNativeTest.Actor(data).player);
case "superseded" -> entry.map.dispatch(entry.sender, command);
case "shutdown" -> f.plugin.setEnabled(false);
default -> fail(variant);
}
reads(f);
if (race.equals("shutdown")) WitchAuditTestSupport.drain(f, "adminActionDrain");
else FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
var events = rows("character.saved_" + (search ? "search" : "inspection") + "_resolved");
assertEquals(race.equals("superseded") ? 2 : 1, events.size());
assertEquals(race.equals("superseded") && !search ? 1 : 0, fixture.actor.books.size());
for (var row : events) {
var v = row.getAsJsonObject("data");
if (List.of("opened", "completed").contains(v.get("outcome").getAsString())) continue;
assertFalse(v.has("results") || v.has("subject_character_name") || v.has("saved_revision"));
assertFalse(row.toString().contains(record.name()));
if (race.equals("permission")) {
assertEquals("denied", v.get("authorization").getAsString());
assertFalse(v.has("requested_character_id") || v.has("query"));
}
}
assertEquals(events.size(), events.stream().map(row -> row.get("id")).distinct().count());
for (var row : events) assertCorrelated(row);
}
}
@ParameterizedTest @ValueSource(strings = {"missing", "corrupt", "open_failed", "console", "command_block", "malformed"})
void savedInspectionFailuresRemainSafeAndValidRetryReadsCurrentState(String failure) throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f;
var entry = new AdminActionEntryNativeTest.Entry(fixture,
failure.equals("console") || failure.equals("command_block") ? failure : "player", true);
var target = new FantasyStickLifecycleNativeTest.Actor(data);
var path = data.resolve("characters").resolve(target.core.record.id() + ".yml");
var valid = Files.readAllBytes(path);
if (failure.equals("missing")) Files.delete(path);
if (failure.equals("corrupt")) Files.writeString(path, "schema-version: malformed\n");
if (failure.equals("open_failed")) org.mockito.Mockito.doThrow(new IllegalStateException("controlled native open failure"))
.when(fixture.actor.player).openBook(org.mockito.ArgumentMatchers.any(org.bukkit.inventory.ItemStack.class));
entry.map.dispatch(entry.sender, "fstickadmin character show " + target.core.record.id()
+ (failure.equals("malformed") ? " SECRET=opaque" : ""));
reads(f); FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
var events = rows("character.saved_inspection_resolved");
assertEquals(1, events.size());
var v = events.getFirst().getAsJsonObject("data");
assertEquals(switch (failure) {
case "missing" -> "not_found"; case "corrupt" -> "unreadable"; case "open_failed" -> "failed";
default -> "refused";
}, v.get("outcome").getAsString());
assertFalse(v.has("subject_character_id") || v.has("subject_character_name") || v.has("saved_revision") || v.has("exposed_sections"));
assertFalse(events.toString().contains("opaque"));
assertTrue(fixture.actor.books.isEmpty());
if (failure.equals("console") || failure.equals("command_block") || failure.equals("malformed"))
assertFalse(events.toString().contains(target.core.record.id().toString()));
assertCorrelated(events.getFirst());
if (failure.equals("missing") || failure.equals("corrupt")) {
Files.write(path, valid);
entry.map.dispatch(entry.sender, "fstickadmin character show " + target.core.record.id());
reads(f); FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
assertEquals("opened", rows("character.saved_inspection_resolved").getLast().getAsJsonObject("data").get("outcome").getAsString());
assertArrayEquals(valid, Files.readAllBytes(path));
assertEquals(2, rows("character.saved_inspection_resolved").stream().map(row -> row.get("id")).distinct().count());
}
}
}
@ParameterizedTest @ValueSource(strings = {"no_match", "incomplete", "read_failed"})
void searchNoMatchAndUnreadableStateAreTruthfulAndRetryDoesNotReplay(String failure) throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f;
var entry = new AdminActionEntryNativeTest.Entry(fixture, "console", true);
var directory = data.resolve("characters");
var parked = data.resolve("characters-held");
if (failure.equals("incomplete")) Files.writeString(directory.resolve(UUID.randomUUID() + ".yml"), "schema-version: malformed\n");
if (failure.equals("read_failed")) { Files.move(directory, parked); Files.writeString(directory, "not a directory"); }
try {
entry.map.dispatch(entry.sender, "fstickadmin character find Definitely no match");
if (failure.equals("read_failed")) {
assertTrue(storage(f).catalog().handle((v, error) -> error != null).get(5, TimeUnit.SECONDS));
Runnable task; while ((task = f.tasks.poll()) != null) task.run();
} else reads(f);
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
var events = rows("character.saved_search_resolved");
assertEquals(1, events.size());
var v = events.getFirst().getAsJsonObject("data");
assertEquals(failure.equals("read_failed") ? "failed" : failure, v.get("outcome").getAsString());
if (failure.equals("read_failed")) assertFalse(v.has("results") || v.has("catalog_complete"));
else {
assertEquals(0, v.get("match_count").getAsInt());
assertEquals(0, v.get("page_count").getAsInt());
assertEquals(failure.equals("incomplete") ? 1 : 0, v.get("unreadable_count").getAsInt());
assertEquals(failure.equals("no_match"), v.get("catalog_complete").getAsBoolean());
}
assertCorrelated(events.getFirst());
} finally {
if (failure.equals("read_failed")) { Files.delete(directory); Files.move(parked, directory); }
}
entry.map.dispatch(entry.sender, "fstickadmin character find Definitely no match");
reads(f); FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
assertEquals(2, rows("character.saved_search_resolved").size());
assertEquals(2, rows("character.saved_search_resolved").stream().map(row -> row.get("id")).distinct().count());
}
}
@ParameterizedTest @ValueSource(strings = {"forged", "permission", "reconnect", "missing", "open_failed"})
void navigationRejectsForeignOrUnavailableAuthorityWithoutExposingSavedState(String failure) throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f;
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
var record = storage(f).inspect(fixture.actor.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
var stats = new java.util.EnumMap<>(record.stats());
stats.put(games.dmg.fantasystick.progression.Stat.STR, new java.math.BigInteger("1234567890".repeat(4_000)));
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(withStats(record, stats), record.revision());
entry.map.dispatch(entry.sender, "fstickadmin character show " + record.id());
reads(f); FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
var link = next(fixture.actor); assertNotNull(link);
var holder = fixture.actor;
switch (failure) {
case "permission" -> fixture.actor.allowed = false;
case "reconnect" -> {
holder = new FantasyStickLifecycleNativeTest.Actor(data);
org.mockito.Mockito.doReturn(fixture.actor.core.id).when(holder.player).getUniqueId();
f.players.put(fixture.actor.core.id, holder.player);
}
case "missing" -> Files.delete(data.resolve("characters").resolve(record.id() + ".yml"));
case "open_failed" -> org.mockito.Mockito.doThrow(new IllegalStateException("controlled navigation open failure"))
.when(fixture.actor.player).openBook(org.mockito.ArgumentMatchers.any(org.bukkit.inventory.ItemStack.class));
default -> { }
}
if (failure.equals("forged")) {
var connection = f.own(org.mockito.Mockito.mock(io.papermc.paper.connection.PlayerGameConnection.class));
org.mockito.Mockito.when(connection.getPlayer()).thenReturn(holder.player);
f.dispatcher.accept(new io.papermc.paper.event.player.PaperPlayerCustomClickEvent(
net.kyori.adventure.key.Key.key("fantasystick", "saved_character_pages"), connection,
net.minecraft.nbt.StringTag.valueOf("\"" + UUID.randomUUID() + ":1\"")));
} else PartyBookNativeTest.click(f, holder, link);
reads(f); FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
var events = rows("character.saved_inspection_resolved");
assertEquals(2, events.size());
assertEquals(1, fixture.actor.books.size());
if (holder != fixture.actor) assertTrue(holder.books.isEmpty(), "an old account nonce must not authorize a replacement native holder");
var v = events.getLast().getAsJsonObject("data");
assertEquals(failure.equals("missing") ? "not_found" : failure.equals("open_failed") ? "failed" : "refused",
v.get("outcome").getAsString());
assertEquals("book_navigation", v.get("entry_point").getAsString());
assertFalse(v.has("subject_character_id") || v.has("saved_revision") || v.has("exposed_sections"));
if (failure.equals("permission") || failure.equals("forged") || failure.equals("reconnect"))
assertFalse(v.has("requested_character_id"));
assertCorrelated(events.getLast());
}
}
@Test void failedNativeSearchPresentationResolvesWithoutEscapingOrInventingExposedResults() throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f;
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
storage(f).renameCharacter(fixture.actor.core.record.id(), "Controlled Search").get(5, TimeUnit.SECONDS);
org.mockito.Mockito.doThrow(new IllegalStateException("controlled native search response failure"))
.when(fixture.actor.player).sendMessage(org.mockito.ArgumentMatchers.anyString());
entry.map.dispatch(entry.sender, "fstickadmin character find Controlled Search");
assertDoesNotThrow(() -> reads(f), "an asynchronous native response failure must resolve, not escape the owner");
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
var events = rows("character.saved_search_resolved");
assertEquals(1, events.size());
var v = events.getFirst().getAsJsonObject("data");
assertEquals("failed", v.get("outcome").getAsString());
assertEquals("response_failed", v.get("reason").getAsString());
assertFalse(v.has("results") || v.has("catalog_complete"));
assertCorrelated(events.getFirst());
}
}
@Test void restartKeepsSavedOutcomesUnchangedAndNewExplicitReadsUseNewIdentities() throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f;
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
UUID id = fixture.actor.core.record.id();
var path = data.resolve("characters").resolve(id + ".yml");
var before = Files.readAllBytes(path);
entry.map.dispatch(entry.sender, "fstickadmin character show " + id);
reads(f); FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
var original = rows("character.saved_inspection_resolved").getFirst().deepCopy();
f.plugin.setEnabled(false); WitchAuditTestSupport.drain(f, "adminActionDrain");
f.plugin.setEnabled(true); FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
assertEquals(List.of(original), rows("character.saved_inspection_resolved"), "startup must not replay a user read or re-render a book");
entry.map.dispatch(entry.sender, "fstickadmin character show " + id);
reads(f); FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
var events = rows("character.saved_inspection_resolved");
assertEquals(2, events.size());
assertEquals(original, events.stream().filter(row -> row.get("id").equals(original.get("id"))).findFirst().orElseThrow());
var fresh = events.stream().filter(row -> !row.get("id").equals(original.get("id"))).findFirst().orElseThrow();
assertNotEquals(original.getAsJsonObject("data").get("action_id"), fresh.getAsJsonObject("data").get("action_id"));
assertEquals("opened", fresh.getAsJsonObject("data").get("outcome").getAsString());
assertArrayEquals(before, Files.readAllBytes(path));
assertCorrelated(fresh);
}
}
@Test void activeAdministratorReadAndAutocompleteCreateNoGenericOrRenderingSuccess() throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f;
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
f.activate(fixture.actor);
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
var baseline = ReskillEventsNativeTest.records(data).stream().map(row -> row.get("id")).collect(java.util.stream.Collectors.toSet());
var before = fixture.actor.core.snapshot();
entry.map.dispatch(entry.sender, "fstickadmin character show " + fixture.actor.core.record.id());
reads(f); FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
var events = ReskillEventsNativeTest.records(data).stream().filter(row -> !baseline.contains(row.get("id"))).toList();
assertEquals(5, events.size(), "one inspection plus request, authorization, async pending and terminal admin action only");
var central = events.stream().filter(row -> row.get("type").getAsString().endsWith(".admin.action"))
.map(row -> row.getAsJsonObject("data")).toList();
assertEquals(List.of("request", "authorization", "result", "result"),
central.stream().map(row -> row.get("stage").getAsString()).toList());
assertEquals(List.of("pending", "completed"), central.stream().filter(row -> row.has("outcome"))
.map(row -> row.get("outcome").getAsString()).toList());
assertEquals(java.util.Set.of("games.dmg.minecraft.fantasystick.character.saved_inspection_resolved",
"games.dmg.minecraft.fantasystick.admin.action"),
events.stream().map(row -> row.get("type").getAsString()).collect(java.util.stream.Collectors.toSet()));
var ids = ReskillEventsNativeTest.records(data).stream().map(row -> row.get("id")).collect(java.util.stream.Collectors.toSet());
entry.map.tabComplete(entry.sender, "fstickadmin character show ");
entry.map.tabComplete(entry.sender, "fstickadmin character find ");
reads(f); FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
assertEquals(ids, ReskillEventsNativeTest.records(data).stream().map(row -> row.get("id")).collect(java.util.stream.Collectors.toSet()));
assertArrayEquals(before, fixture.actor.core.snapshot());
}
}
private void assertCorrelated(JsonObject event) throws Exception {
var v = event.getAsJsonObject("data");
var terminal = AdminActionEntryNativeTest.rows(data).stream().filter(row -> row.getAsJsonObject("data").has("outcome")
&& !row.getAsJsonObject("data").get("outcome").getAsString().equals("pending")
&& row.getAsJsonObject("data").get("action_id").equals(v.get("action_id"))).toList();
assertEquals(1, terminal.size());
var central = terminal.getFirst().getAsJsonObject("data");
if (v.get("authorization").getAsString().equals("denied")) assertEquals("refused", central.get("outcome").getAsString());
if (central.get("authorization").getAsString().equals("denied")) {
assertFalse(central.has("domain_event_id") || central.has("subject_id"), "entry-denied facts must remain redacted");
} else if (v.has("resolution_id")) assertEquals(v.get("resolution_id"), central.get("domain_event_id"));
else assertEquals(event.get("id"), central.get("domain_event_id"));
}
private static games.dmg.fantasystick.persistence.CharacterRecord withStats(
games.dmg.fantasystick.persistence.CharacterRecord r, java.util.Map<games.dmg.fantasystick.progression.Stat, java.math.BigInteger> stats) {
return new games.dmg.fantasystick.persistence.CharacterRecord(r.id(), r.name(), r.revision().add(java.math.BigInteger.ONE),
stats, r.level(), r.experience(), r.unspentPoints(), r.health(), r.learned(), r.loadout(), r.passives(), r.cooldowns(),
r.restrictedKills(), r.dead(), r.deathId(), r.diedAt(), r.coins(), r.coinsInitialized(), r.rewardReceipts(), r.pvpCoinEscrow(), r.gear(), r.sheetGeneration());
}
private static net.minecraft.network.chat.ClickEvent.Custom next(FantasyStickLifecycleNativeTest.Actor actor) {
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
assertNotNull(content);
for (var page : content.pages()) { var link = PartyBookNativeTest.find(page.raw(), "[Next pages]"); if (link != null) return link; }
return null;
}
private List<JsonObject> rows(String suffix) throws Exception {
return ReskillEventsNativeTest.records(data).stream()
.filter(row -> row.get("type").getAsString().equals("games.dmg.minecraft.fantasystick." + suffix)).toList();
}
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture f) {
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
}
private static void reads(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
if (f.plugin.isEnabled()) storage(f).inspect(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
Runnable callback;
while ((callback = f.tasks.poll()) != null) callback.run();
}
}
@@ -70,6 +70,32 @@ final class FishingDirectoryTest {
assertEquals(BigInteger.ONE,committed.stockBefore());
assertEquals(BigInteger.ZERO,committed.stockAfter());
}
var captured=new YamlFishingRepository(data).load().submissions().getFirst();
assertNotNull(java.time.Instant.parse(captured.audit().get("committed_at")));
try(var restarted=new FishingDirectory(new YamlFishingRepository(data))){
var later=new FishingState.Catch(UUID.randomUUID(),character,quest,site,item,"Scale",BigInteger.ZERO,true);
restarted.prepareCatch(later).get(5,TimeUnit.SECONDS);restarted.completeCatch(later.id()).get(5,TimeUnit.SECONDS);
assertEquals(BigInteger.ONE,restarted.quantity(character,item));
var duplicate=restarted.commitSubmissionWithEvidence(submission).get(5,TimeUnit.SECONDS);
assertEquals(FishingDirectory.CommitStatus.DUPLICATE,duplicate.status());
assertEquals(BigInteger.ONE,duplicate.stockBefore());assertEquals(BigInteger.ZERO,duplicate.stockAfter());
assertEquals(captured.audit(),restarted.pendingSubmissions().getFirst().audit(),"later stock cannot rewrite committed quantities/time");
}
}
@Test void fullLegacyMetadataEnvelopeDoesNotBlockAnOtherwiseValidSubmission()throws Exception{
var character=UUID.randomUUID();var quest=UUID.randomUUID();var site=UUID.randomUUID();var item=UUID.randomUUID();
var audit=new LinkedHashMap<String,String>();for(int index=0;index<16;index++)audit.put("unknown_"+index,"retained");
try(var directory=new FishingDirectory(new YamlFishingRepository(data))){
var catchReceipt=new FishingState.Catch(UUID.randomUUID(),character,quest,site,item,"Scale",BigInteger.ZERO,true);
directory.prepareCatch(catchReceipt).get(5,TimeUnit.SECONDS);directory.completeCatch(catchReceipt.id()).get(5,TimeUnit.SECONDS);
var plan=new FishingState.Submission(UUID.randomUUID(),quest,UUID.randomUUID(),site,character,item,BigInteger.ONE,
Map.of(character,new FishingState.Reward(BigInteger.ONE,BigInteger.ONE)),FishingState.SubmissionPhase.PREPARED,audit);
directory.prepareSubmission(plan).get(5,TimeUnit.SECONDS);
assertEquals(FishingDirectory.CommitStatus.APPLIED,directory.commitSubmission(plan.id()).get(5,TimeUnit.SECONDS));
assertEquals(audit,directory.pendingSubmissions().getFirst().audit());
assertEquals(BigInteger.ZERO,directory.quantity(character,item));assertEquals(BigInteger.ONE,directory.unclaimed(character).xp());
}
}
@Test void preparedSubmissionRetainsCapturedUnitAndZeroShareRosterAfterRestart() throws Exception {