feat(quests): publish acknowledged paper turn-in events #3

Merged
dmg merged 1 commits from us-070-turn-in-paper-adventure-quests into main 2026-10-07 03:18:33 +00:00
9 changed files with 695 additions and 54 deletions

No files matched your search

@@ -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));
});}
@@ -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) {
@@ -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");
}
@@ -130,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();
@@ -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");}
}
@@ -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 {