From 3b9cb407f01c3fa9da36336c55dbc98c7aef5660 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Tue, 6 Oct 2026 23:15:59 -0400 Subject: [PATCH] feat(quests): publish acknowledged paper turn-in events --- .../adventure/AdventureQuestController.java | 56 +++- .../adventure/FishingController.java | 148 +++++++--- .../adventure/FishingDirectory.java | 12 +- .../events/FishingEventBridge.java | 112 +++++++- ...nActionConfigurationCatalogNativeTest.java | 69 ++++- ...CharacterBossMetadataSafetyNativeTest.java | 51 +++- .../fantasystick/FishingQuestNativeTest.java | 9 + .../QuestTurnInEventsNativeTest.java | 266 ++++++++++++++++++ .../adventure/FishingDirectoryTest.java | 26 ++ 9 files changed, 695 insertions(+), 54 deletions(-) create mode 100644 src/nativeTest/java/games/dmg/fantasystick/QuestTurnInEventsNativeTest.java diff --git a/src/main/java/games/dmg/fantasystick/adventure/AdventureQuestController.java b/src/main/java/games/dmg/fantasystick/adventure/AdventureQuestController.java index ca684b7..67811c5 100644 --- a/src/main/java/games/dmg/fantasystick/adventure/AdventureQuestController.java +++ b/src/main/java/games/dmg/fantasystick/adventure/AdventureQuestController.java @@ -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();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+11?49:size-1,turnIn);}player.openInventory(inventory);}));} + 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();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+11?49:size-1,turnIn);}player.openInventory(inventory);}));} 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(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 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&¤t.active.equals(context.active)&¤t.character.equals(context.character)&&(type==AdventureQuestState.UnitType.SOLO||current.leader&&Objects.equals(current.partyId,context.partyId)&¤t.members.equals(context.members));if(!same){directory.abandon(quest.id(),quest.unit()).whenComplete((ignored,rollbackFailure)->schedule(()->player.sendMessage(rollbackFailure==null?"Quest acceptance refused: the character or party changed.":"Quest acceptance is pending safe cancellation; retry after recovery.")));return;}var location=directory.findSite(site).orElseThrow();if(campAudit!=null)campAudit.questTransition("adventure.quest.accepted",operation,campActor,quest,result.capturedSite().orElse(null),type==AdventureQuestState.UnitType.PARTY?context.character:null,"accepted");if(championAudit!=null)championAudit.questTransition("adventure.quest.accepted",operation,championActor,quest,result.capturedSite().orElse(null),type==AdventureQuestState.UnitType.PARTY?context.character:null,"accepted");if(dungeonAudit!=null)try{dungeonAudit.questTransition("adventure.quest.accepted",operation,championActor,quest,result.capturedSite().orElse(null),originalDungeon,"accepted");}catch(RuntimeException ignored){}if(quest.fishing()!=null&&fishingEvents!=null)fishingEvents.accepted(quest.id(),fishingEvents.actor(player),quest);refreshTracking(player);player.sendMessage("Accepted "+location.displayName()+"; expires "+quest.expiresAt()+". Follow the Quest Compass.");});});} - 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&¤t.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&¤t.active.equals(context.active)&¤t.character.equals(context.character)&&(type==AdventureQuestState.UnitType.SOLO||current.leader&&Objects.equals(current.partyId,context.partyId)&¤t.members.equals(context.members));if(!same){directory.abandon(quest.id(),quest.unit()).whenComplete((ignored,rollbackFailure)->schedule(()->player.sendMessage(rollbackFailure==null?"Quest acceptance refused: the character or party changed.":"Quest acceptance is pending safe cancellation; retry after recovery.")));return;}var location=directory.findSite(site).orElseThrow();if(campAudit!=null)campAudit.questTransition("adventure.quest.accepted",operation,campActor,quest,result.capturedSite().orElse(null),type==AdventureQuestState.UnitType.PARTY?context.character:null,"accepted");if(championAudit!=null)championAudit.questTransition("adventure.quest.accepted",operation,championActor,quest,result.capturedSite().orElse(null),type==AdventureQuestState.UnitType.PARTY?context.character:null,"accepted");if(dungeonAudit!=null)try{dungeonAudit.questTransition("adventure.quest.accepted",operation,championActor,quest,result.capturedSite().orElse(null),originalDungeon,"accepted");}catch(RuntimeException ignored){}if(quest.fishing()!=null&&fishingEvents!=null)fishingEvents.accepted(quest.id(),fishingEvents.actor(player),quest);refreshTracking(player);player.sendMessage("Accepted "+location.displayName()+"; expires "+quest.expiresAt()+". Follow the Quest Compass.");});});} + 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&¤t.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); diff --git a/src/main/java/games/dmg/fantasystick/adventure/FishingController.java b/src/main/java/games/dmg/fantasystick/adventure/FishingController.java index 60da05d..8f7c1c9 100644 --- a/src/main/java/games/dmg/fantasystick/adventure/FishingController.java +++ b/src/main/java/games/dmg/fantasystick/adventure/FishingController.java @@ -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();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;index0)inventory.setItem(45,item(Material.ARROW,"Previous","page:"+(page-1),List.of()));if(page+1(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();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(); + 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(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();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(); 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();data.put("receipt_id",captured.id().toString()); + var data=new LinkedHashMap();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(); + 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()));}}); }); }); } diff --git a/src/main/java/games/dmg/fantasystick/adventure/FishingDirectory.java b/src/main/java/games/dmg/fantasystick/adventure/FishingDirectory.java index 74d375b..f3fe89e 100644 --- a/src/main/java/games/dmg/fantasystick/adventure/FishingDirectory.java +++ b/src/main/java/games/dmg/fantasystick/adventure/FishingDirectory.java @@ -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)); });} diff --git a/src/main/java/games/dmg/fantasystick/events/FishingEventBridge.java b/src/main/java/games/dmg/fantasystick/events/FishingEventBridge.java index c3ec937..726416c 100644 --- a/src/main/java/games/dmg/fantasystick/events/FishingEventBridge.java +++ b/src/main/java/games/dmg/fantasystick/events/FishingEventBridge.java @@ -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 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(); + 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(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(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 allocations, TurnInAction request) { + var saved = award.record().orElseThrow(); + var data = new LinkedHashMap(); + 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 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 data) { + durableAt(type,operation,actor,data,clock.instant()); + } + + public void durableAt(String type, UUID operation, Actor actor, Map 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 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 data, int part) { + private EventInput input(String type, UUID operation, Actor actor, Map data, int part, java.time.Instant occurredAt) { var values = new LinkedHashMap(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) { diff --git a/src/nativeTest/java/games/dmg/fantasystick/AdminActionConfigurationCatalogNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/AdminActionConfigurationCatalogNativeTest.java index 98fff39..cab3382 100644 --- a/src/nativeTest/java/games/dmg/fantasystick/AdminActionConfigurationCatalogNativeTest.java +++ b/src/nativeTest/java/games/dmg/fantasystick/AdminActionConfigurationCatalogNativeTest.java @@ -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(); + 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 observer = null; + int messages = entry.messages().size(); + try { + port.set(admin, (java.util.function.Consumer) 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)) { diff --git a/src/nativeTest/java/games/dmg/fantasystick/CharacterBossMetadataSafetyNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/CharacterBossMetadataSafetyNativeTest.java index 39ee2a4..509e4af 100644 --- a/src/nativeTest/java/games/dmg/fantasystick/CharacterBossMetadataSafetyNativeTest.java +++ b/src/nativeTest/java/games/dmg/fantasystick/CharacterBossMetadataSafetyNativeTest.java @@ -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 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"); } diff --git a/src/nativeTest/java/games/dmg/fantasystick/FishingQuestNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/FishingQuestNativeTest.java index fb5c8e3..13d7028 100644 --- a/src/nativeTest/java/games/dmg/fantasystick/FishingQuestNativeTest.java +++ b/src/nativeTest/java/games/dmg/fantasystick/FishingQuestNativeTest.java @@ -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(); diff --git a/src/nativeTest/java/games/dmg/fantasystick/QuestTurnInEventsNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/QuestTurnInEventsNativeTest.java new file mode 100644 index 0000000..2372f7a --- /dev/null +++ b/src/nativeTest/java/games/dmg/fantasystick/QuestTurnInEventsNativeTest.java @@ -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 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()();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 { -- 2.54.0