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