Compare commits

...
5 Commits
Author SHA1 Message Date
dmg 421e44d86f feat(adventure): add site participation modes
Release / release (push) Successful in 11m36s
2026-09-23 15:23:18 -04:00
dmg 871258070b test(dungeon): await asynchronous creation
Release / release (push) Successful in 6m41s
2026-09-23 15:17:41 -04:00
dmg cbd0f70a8b feat(admin): recover saved character sheets
Release / release (push) Failing after 11m7s
2026-09-23 14:47:25 -04:00
dmg 7b588e08d2 feat(admin): inspect saved characters
Release / release (push) Failing after 6m1s
2026-09-23 12:50:00 -04:00
dmg 86f248d80c feat(commands): split player and admin roots
Release / release (push) Failing after 5m54s
2026-09-23 12:20:39 -04:00
47 changed files with 2657 additions and 221 deletions

No files matched your search

+7 -5
View File
@@ -136,10 +136,12 @@ Optional `character-swap-cooldown-seconds` defaults to `900`; zero bypasses the
Administration (permission `fantasystick.admin`, default operators):
- `/fantasystick character view <online-player>` — opens the exact online account's current active character as a complete read-only virtual book for an in-game administrator.
- `/fantasystick inspect <player-uuid>` — asynchronous receipt/native-session inspection.
- `/fantasystick recover <player-uuid>` — validated online equipment/game-mode restoration only; no forced overwrite, progression grant or ambiguous replay.
- `/fantasystick config adventure-mode [true|false]` — show or persist the policy for new sessions; no change to active sessions.
- `/fstickadmin character find <name...>` and `character show <character-uuid>` — asynchronously locate authoritative saved characters and inspect one exact UUID read-only.
- `/fstickadmin character view <online-player>` — opens the exact online account's current active character as a complete read-only virtual book for an in-game administrator.
- `/fstickadmin character recover <character-uuid> <online-player>` — previews and explicitly confirms a generation-revoking reissue of the same saved character; dead characters use a distinct no-Tail revival action, and unresolved native delivery remains fail-closed.
- `/fstickadmin inspect <player-uuid>` — asynchronous receipt/native-session inspection.
- `/fstickadmin recover <player-uuid>` — validated online equipment/game-mode restoration only; no forced overwrite, progression grant or ambiguous replay.
- `/fstickadmin config adventure-mode [true|false]` — show or persist the policy for new sessions; no change to active sessions.
Tests use actual native items, inventories, serialization, Paper event dispatch and menu-close behavior, with explicit scheduler/player/world ports. Drag continuation ordering includes a model based on pinned bytecode. The packaged lifecycle fixture isolates the entrypoint but delegates helpers parent-first. Live clients, production PluginClassLoader execution and arbitrary-plugin/crash atomicity are not claimed verified. No deployment or existing-data migration is authorized by story completion.
@@ -167,7 +169,7 @@ A same-process retry can acknowledge gear already verified restored for that exa
## Native fantasy health, exit and defeat
Offhand entry applies actual native maximum health `min(10 + CON, native health limit)`, resumes saved remaining character health, and supplies the recurring blue active-character aura to native viewers who opt in with `/fantasyparticles players on`. The account-owned aura preference defaults to off; `/fantasyparticles players` reports it and `/fantasyparticles players off` hides only this recurring aura. The independent, default-on `/fantasyparticles all [on|off]` preference durably controls every explicit Fantasy Stick particle for that account without changing nearby players' delivery, sounds or gameplay. Turning `all` off temporarily takes precedence without overwriting the saved `players` choice. A fresh character's saved health starts full. CON has no gameplay cap; high values do not reject entry. Native damage/healing rules remain in effect, with the existing party/mixed-mode protection. The book reports current native health rather than waiting for an exit save.
Offhand entry applies actual native maximum health `min(10 + CON, native health limit)`, resumes saved remaining character health, and supplies the recurring blue active-character aura to native viewers who opt in with `/fstick particles players on`. The account-owned aura preference defaults to off; `/fstick particles players` reports it and `/fstick particles players off` hides only this recurring aura. The independent, default-on `/fstick particles all [on|off]` preference durably controls every explicit Fantasy Stick particle for that account without changing nearby players' delivery, sounds or gameplay. Turning `all` off temporarily takes precedence without overwriting the saved `players` choice. A fresh character's saved health starts full. CON has no gameplay cap; high values do not reject entry. Native damage/healing rules remain in effect, with the existing party/mixed-mode protection. The book reports current native health rather than waiting for an exit save.
`NativeMaximumHealth` owns only the effective-value cache/dirty callback of that player's existing native maximum-health instance. Base values, modifiers and native potion expiry remain owned by their original sources; nothing is copied back from an old modifier snapshot. The native player health methods and client attribute snapshot are tested. This adapter uses **pinned Purpur 2618 internals**, including private fields; a detached startup probe exercises installation, recalculation and removal before gameplay is registered. Runtime upgrades require renewed native compatibility verification, not a guessed fallback or global attribute-limit changes. Matching native health scaling displays the real fantasy maximum, not a separate virtual health pool; a later unrelated display-scale change is preserved on exit.
@@ -53,6 +53,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
private org.bukkit.scheduler.BukkitTask armorMenuPulse;
private games.dmg.fantasystick.party.PartyCombat partyCombat;
private EquipmentAdminCommand admin;
private games.dmg.fantasystick.session.AdminCharacterRecovery characterRecovery;
private games.dmg.fantasystick.persistence.PlayerFeedbackPreferences feedbackPreferences;
private games.dmg.fantasystick.session.FantasyFeedbackCommand feedbackCommand;
private games.dmg.fantasystick.session.FantasyParticlesCommand particlesCommand;
@@ -567,7 +568,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
characterParties.attachUnavailableCharacter(character->custodyDirectory.custody(character).isPresent());
dungeonCommands = new games.dmg.fantasystick.dungeon.DungeonCommands(this,dungeons,dungeonController,adventureQuests,nextTick,characters::find,character->sessions.activeCharacter(character).isPresent(),character->characterParties.partyOf(character).isPresent());
adventureQuests.registerDungeonRewards(site->dungeons.find(site.id()).map(value->new games.dmg.fantasystick.adventure.AdventureQuestDirectory.Reward(value.maximumXp(),value.maximumCoins())).orElse(new games.dmg.fantasystick.adventure.AdventureQuestDirectory.Reward(java.math.BigInteger.ZERO,java.math.BigInteger.ZERO)));
adventureQuests.registerDungeonSites(()->dungeons.snapshot().dungeons().stream().filter(value->dungeons.eligibleForFutureOffer(value.id())).map(value->new games.dmg.fantasystick.adventure.AdventureQuestState.Site(value.id(),games.dmg.fantasystick.adventure.AdventureQuestState.SiteType.DUNGEON,value.name(),value.world(),value.entrance().x(),value.entrance().y(),value.entrance().z())).toList());
adventureQuests.registerDungeonSites(()->dungeons.snapshot().dungeons().stream().filter(value->dungeons.eligibleForFutureOffer(value.id())).map(value->new games.dmg.fantasystick.adventure.AdventureQuestState.Site(value.id(),games.dmg.fantasystick.adventure.AdventureQuestState.SiteType.DUNGEON,value.name(),value.world(),value.entrance().x(),value.entrance().y(),value.entrance().z(),null,value.participationMode())).toList());
adventureQuests.registerDungeonAvailability(site->{var definition=dungeons.find(site.id());if(definition.isEmpty()||!definition.orElseThrow().enabled())return false;var catalog=adventureQuests.snapshot().catalog();return catalog==null||dungeons.runView(site.id(),catalog.revision()).map(run->!run.defeated()).orElse(true);});
adventureQuestController.registerResetParticipant(dungeonController);
adventureQuestController.registerDungeonRoomPresence((dungeon,location)->location.getWorld()!=null&&dungeons.find(dungeon).filter(value->value.world().equals(location.getWorld().getUID())).isPresent()&&dungeons.roomAt(dungeon,location.getBlockX(),location.getBlockY(),location.getBlockZ()).isPresent());
@@ -618,22 +619,28 @@ public final class FantasyStickPlugin extends JavaPlugin {
() -> factory.create(rules), nextTick, problem -> getLogger().log(Level.WARNING,
"Admin character issue " + problem.operation() + " for player " + problem.player()
+ "; character " + problem.character(), problem.cause())));
admin.attachCharacterBooks(books);
var command = java.util.Objects.requireNonNull(getCommand("fantasystick"), "Missing packaged fantasystick command");
command.setExecutor(admin);
command.setTabCompleter(admin);
var playerFeedbackCommand = java.util.Objects.requireNonNull(getCommand("fantasyfeedback"),
"Missing packaged fantasyfeedback command");
playerFeedbackCommand.setExecutor(feedbackCommand);
playerFeedbackCommand.setTabCompleter(feedbackCommand);
var playerParticlesCommand = java.util.Objects.requireNonNull(getCommand("fantasyparticles"),
"Missing packaged fantasyparticles command");
playerParticlesCommand.setExecutor(particlesCommand);
playerParticlesCommand.setTabCompleter(particlesCommand);
admin.attachCharacterBooks(books, characters);
characterRecovery = new games.dmg.fantasystick.session.AdminCharacterRecovery(
characters, receipts, revivals, sessions, getServer(), nextTick,
problem -> getLogger().log(Level.WARNING,
"Saved character recovery " + problem.operation() + " for character "
+ problem.character() + " and recipient " + problem.recipient(), problem.cause()));
admin.attachCharacterRecovery(characterRecovery);
var playerRoot = new games.dmg.fantasystick.session.FantasyPlayerCommand(
admin, feedbackCommand, particlesCommand);
var playerCommand = java.util.Objects.requireNonNull(getCommand("fstick"),
"Missing packaged fstick command");
playerCommand.setExecutor(playerRoot);
playerCommand.setTabCompleter(playerRoot);
var adminCommand = java.util.Objects.requireNonNull(getCommand("fstickadmin"),
"Missing packaged fstickadmin command");
adminCommand.setExecutor(admin);
adminCommand.setTabCompleter(admin);
getServer().getPluginManager().registerEvents(crafting, this);
getServer().getPluginManager().registerEvents(guard, this);
getServer().getPluginManager().registerEvents(anvilNames, this);
getServer().getPluginManager().registerEvents(sessionEvents, this);
getServer().getPluginManager().registerEvents(characterRecovery, this);
getServer().getPluginManager().registerEvents(chatNames, this);
getServer().getPluginManager().registerEvents(partyEvents, this);
getServer().getPluginManager().registerEvents(partyCombat, this);
@@ -711,6 +718,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingProtocol = protocolPresentation; protocolPresentation = null;
var closingChat = chatNames; chatNames = null;
var closingAdmin = admin; admin = null;
var closingCharacterRecovery = characterRecovery; characterRecovery = null;
var closingFeedbackCommand = feedbackCommand; feedbackCommand = null;
var closingParticlesCommand = particlesCommand; particlesCommand = null;
var closingFeedbackPreferences = feedbackPreferences; feedbackPreferences = null;
@@ -1017,6 +1025,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
unregister(closingGreatSword);
unregister(closingRevive);
unregister(closingAdminRevival);
unregister(closingCharacterRecovery);
unregister(closingBasicAttack);
unregister(closingNutrition);
unregister(closingOutOfCombatRecovery);
@@ -270,7 +270,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
try {
var provisional = new CharacterRecord(dead.id(), dead.name(), dead.revision(), dead.stats(), dead.level(), dead.experience(),
dead.unspentPoints(), dead.maximumHealth(), dead.learned(), dead.loadout(), dead.passives(), dead.cooldowns(), dead.restrictedKills(),
false, dead.deathId(), dead.diedAt());
false, dead.deathId(), dead.diedAt(), dead.sheetGeneration());
ItemStack sheet = sheets.create(provisional);
ItemStack source = view.target.getInventory().getItem(reservation.sourceSlot());
if (reservation.sourceAmount() == 1) view.target.getInventory().setItem(reservation.sourceSlot(), sheet);
@@ -11,22 +11,23 @@ public final class AdventureQuestCommands {
private static final String PERMISSION="fantasystick.admin";
private final Plugin plugin;private final AdventureQuestDirectory directory;private final AdventureQuestController controller;private final Consumer<Runnable> nextTick;private final AdventureLocationPresentation locations;private final AdventureAdminTeleport teleports;
public AdventureQuestCommands(Plugin plugin,AdventureQuestDirectory directory,AdventureQuestController controller,Consumer<Runnable> nextTick){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.controller=Objects.requireNonNull(controller);this.nextTick=Objects.requireNonNull(nextTick);locations=new AdventureLocationPresentation(plugin.getServer());teleports=new AdventureAdminTeleport(plugin,nextTick);}
public void adventure(CommandSender sender,String[] args){if(args.length==1){if(sender instanceof Player player)controller.inspect(player);else sender.sendMessage("Usage: /fantasystick adventure");return;}if(args.length==2&&args[1].equals("tracking")){if(sender instanceof Player player)controller.tracking(player,null);else sender.sendMessage("Quest tracking requires a player.");return;}if(args.length==3&&args[1].equals("tracking")&&(args[2].equals("on")||args[2].equals("off"))){if(sender instanceof Player player)controller.tracking(player,args[2].equals("on"));else sender.sendMessage("Quest tracking requires a player.");return;}if(args.length==2&&args[1].equals("reroll")){if(!authorized(sender))return;sender.sendMessage("Reroll expires every current adventure quest, encounter and reward claim. Run /fantasystick adventure reroll confirm to reset the day.");return;}if(args.length!=3||!args[1].equals("reroll")||!args[2].equals("confirm")){sender.sendMessage("Usage: /fantasystick adventure [tracking [on|off]|reroll confirm]");return;}if(!authorized(sender))return;var current=directory.snapshot().catalog();UUID expected=current==null?null:current.revision();directory.rerollToday(expected).whenComplete((result,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Adventure reset failed; the current generation is unchanged.");return;}switch(result.status()){case STALE->sender.sendMessage("Adventure reset refused: the generation changed or is still being published; inspect it and try again.");case APPLIED->{var catalog=result.catalog().orElseThrow();controller.publishPendingReset().whenComplete((published,publishFailure)->respond(sender,()->{if(publishFailure!=null||!published){sender.sendMessage("Adventure reset is saved but pending recovery; the new generation is not yet available.");return;}sender.sendMessage("Reset daily adventures: generation="+catalog.revision()+", offers="+catalog.sites().size()+", expires="+catalog.expiresAt()+".");}));}}}));}
public void site(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usageSite(sender);return;}switch(args[1]){case"list"->{if(args.length!=2){usageSite(sender);return;}if(directory.snapshot().sites().stream().noneMatch(v->v.type()!=AdventureQuestState.SiteType.DUNGEON))sender.sendMessage("No adventure sites registered.");else directory.snapshot().sites().stream().filter(v->v.type()!=AdventureQuestState.SiteType.DUNGEON).sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.type().name().toLowerCase(Locale.ROOT)+" "+v.displayName()+" at "+locations.format(v.world(),v.x(),v.y(),v.z())+"; "+controller.arena(v).display()));}case"place"->{if(args.length<3||!(args[2].equals("camp")||args[2].equals("champion"))||!(sender instanceof Player player)){usageSite(sender);return;}var placement=placement(args);if(placement==null){usageSite(sender);return;}var location=player.getLocation();var type=args[2].equals("camp")?AdventureQuestState.SiteType.CAMP:AdventureQuestState.SiteType.CHAMPION;directory.placeSite(type,placement.name(),Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),placement.arena()).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Placed "+value.type().name().toLowerCase(Locale.ROOT)+" "+value.id()+" ("+value.displayName()+") at "+locations.format(value.world(),value.x(),value.y(),value.z())+"; "+controller.arena(value).display()+".":"Adventure site save failed; desired state is unchanged.")));}case"remove"->{if(args.length!=3){usageSite(sender);return;}UUID id=parse(args[2]);if(id==null){usageSite(sender);return;}directory.removeSite(id).whenComplete((removed,failure)->respond(sender,()->{if(failure!=null)sender.sendMessage("Adventure site removal failed; desired state is unchanged.");else if(removed.isEmpty())sender.sendMessage("Adventure site not found.");else{controller.removeSiteLoaded(id);sender.sendMessage("Removed adventure site "+id+"; its quests expired.");}}));}case"teleport"->{if(args.length!=3){usageSite(sender);return;}if(!(sender instanceof Player)){sender.sendMessage("Adventure-site teleport requires a player.");return;}UUID id=parse(args[2]);if(id==null){usageSite(sender);return;}var site=site(id).orElse(null);if(site==null){sender.sendMessage("Adventure site not found.");return;}teleports.teleport(sender,"Adventure-site",target(site),()->site(id).map(AdventureQuestCommands::target));}default->usageSite(sender);}}
public void adventure(CommandSender sender,String[] args){if(args.length==1){if(sender instanceof Player player)controller.inspect(player);else sender.sendMessage("Usage: /fstick adventure");return;}if(args.length==2&&args[1].equals("tracking")){if(sender instanceof Player player)controller.tracking(player,null);else sender.sendMessage("Quest tracking requires a player.");return;}if(args.length==3&&args[1].equals("tracking")&&(args[2].equals("on")||args[2].equals("off"))){if(sender instanceof Player player)controller.tracking(player,args[2].equals("on"));else sender.sendMessage("Quest tracking requires a player.");return;}if(args.length==2&&args[1].equals("reroll")){if(!authorized(sender))return;sender.sendMessage("Reroll expires every current adventure quest, encounter and reward claim. Run /fstickadmin adventure reroll confirm to reset the day.");return;}if(args.length!=3||!args[1].equals("reroll")||!args[2].equals("confirm")){sender.sendMessage("Usage: /fstick adventure [tracking [on|off]] or /fstickadmin adventure reroll [confirm]");return;}if(!authorized(sender))return;var current=directory.snapshot().catalog();UUID expected=current==null?null:current.revision();directory.rerollToday(expected).whenComplete((result,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Adventure reset failed; the current generation is unchanged.");return;}switch(result.status()){case STALE->sender.sendMessage("Adventure reset refused: the generation changed or is still being published; inspect it and try again.");case APPLIED->{var catalog=result.catalog().orElseThrow();controller.publishPendingReset().whenComplete((published,publishFailure)->respond(sender,()->{if(publishFailure!=null||!published){sender.sendMessage("Adventure reset is saved but pending recovery; the new generation is not yet available.");return;}sender.sendMessage("Reset daily adventures: generation="+catalog.revision()+", offers="+catalog.sites().size()+", expires="+catalog.expiresAt()+".");}));}}}));}
public void site(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usageSite(sender);return;}switch(args[1]){case"list"->{if(args.length!=2){usageSite(sender);return;}if(directory.snapshot().sites().stream().noneMatch(v->v.type()!=AdventureQuestState.SiteType.DUNGEON))sender.sendMessage("No adventure sites registered.");else directory.snapshot().sites().stream().filter(v->v.type()!=AdventureQuestState.SiteType.DUNGEON).sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.type().name().toLowerCase(Locale.ROOT)+" "+v.displayName()+" at "+locations.format(v.world(),v.x(),v.y(),v.z())+"; "+controller.arena(v).display()+"; mode="+v.participationMode().value()));}case"place"->{if(args.length<3||!(args[2].equals("camp")||args[2].equals("champion"))||!(sender instanceof Player player)){usageSite(sender);return;}int start=3;var mode=AdventureQuestState.ParticipationMode.PARTY_AVAILABLE;if(args.length>3&&args[3].equals("--mode")){if(args.length<5){usageSite(sender);return;}try{mode=AdventureQuestState.ParticipationMode.parse(args[4]);}catch(IllegalArgumentException invalid){usageSite(sender);return;}start=5;}var placement=placement(args,start);if(placement==null){usageSite(sender);return;}var location=player.getLocation();var type=args[2].equals("camp")?AdventureQuestState.SiteType.CAMP:AdventureQuestState.SiteType.CHAMPION;directory.placeSite(type,placement.name(),Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),placement.arena(),mode).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Placed "+value.type().name().toLowerCase(Locale.ROOT)+" "+value.id()+" ("+value.displayName()+") at "+locations.format(value.world(),value.x(),value.y(),value.z())+"; "+controller.arena(value).display()+"; mode="+value.participationMode().value()+".":"Adventure site save failed; desired state is unchanged.")));}case"mode"->{if(args.length<3||args.length>4){usageSite(sender);return;}UUID id=parse(args[2]);var current=id==null?null:site(id).orElse(null);if(current==null){sender.sendMessage("Adventure site not found.");return;}if(args.length==3){sender.sendMessage("Adventure site "+id+" mode="+current.participationMode().value()+".");return;}AdventureQuestState.ParticipationMode requested;try{requested=AdventureQuestState.ParticipationMode.parse(args[3]);}catch(IllegalArgumentException invalid){usageSite(sender);return;}directory.setSiteMode(id,current.participationMode(),requested).whenComplete((result,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Adventure site mode save failed; the acknowledged mode is unchanged.");return;}sender.sendMessage(switch(result.status()){case APPLIED->"Updated adventure site "+id+" mode="+result.site().orElseThrow().participationMode().value()+" for future acceptances.";case UNCHANGED->"Adventure site "+id+" mode is already "+requested.value()+".";case STALE->"Adventure site mode update refused: the acknowledged mode changed; inspect and retry.";case NOT_FOUND->"Adventure site not found.";});}));}case"remove"->{if(args.length!=3){usageSite(sender);return;}UUID id=parse(args[2]);if(id==null){usageSite(sender);return;}directory.removeSite(id).whenComplete((removed,failure)->respond(sender,()->{if(failure!=null)sender.sendMessage("Adventure site removal failed; desired state is unchanged.");else if(removed.isEmpty())sender.sendMessage("Adventure site not found.");else{controller.removeSiteLoaded(id);sender.sendMessage("Removed adventure site "+id+"; its quests expired.");}}));}case"teleport"->{if(args.length!=3){usageSite(sender);return;}if(!(sender instanceof Player)){sender.sendMessage("Adventure-site teleport requires a player.");return;}UUID id=parse(args[2]);if(id==null){usageSite(sender);return;}var site=site(id).orElse(null);if(site==null){sender.sendMessage("Adventure site not found.");return;}teleports.teleport(sender,"Adventure-site",target(site),()->site(id).map(AdventureQuestCommands::target));}default->usageSite(sender);}}
public void giver(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usageGiver(sender);return;}switch(args[1]){case"list"->{if(args.length!=2){usageGiver(sender);return;}if(directory.snapshot().givers().isEmpty())sender.sendMessage("No Quest Givers registered.");else directory.snapshot().givers().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.displayName()+" at "+locations.format(v.world(),v.x(),v.y(),v.z())));}case"place"->{if(!(sender instanceof Player player)){usageGiver(sender);return;}String name=join(args,2);var location=player.getLocation();directory.placeGiver(name,Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Quest Giver save failed; desired state is unchanged.");return;}var world=plugin.getServer().getWorld(value.world());if(world!=null&&world.isChunkLoaded(section(value.x()),section(value.z())))controller.reconcile(world.getChunkAt(section(value.x()),section(value.z())));sender.sendMessage("Placed Quest Giver "+value.id()+" ("+value.displayName()+") at "+locations.format(value.world(),value.x(),value.y(),value.z())+".");}));}case"remove"->{if(args.length!=3){usageGiver(sender);return;}UUID id=parse(args[2]);if(id==null){usageGiver(sender);return;}directory.removeGiver(id).whenComplete((removed,failure)->respond(sender,()->{if(failure!=null)sender.sendMessage("Quest Giver removal failed; desired state is unchanged.");else if(removed.isEmpty())sender.sendMessage("Quest Giver not found.");else{controller.removeGiverLoaded(id);sender.sendMessage("Removed Quest Giver "+id+".");}}));}default->usageGiver(sender);}}
public List<String> complete(CommandSender sender,String[] args){if(args.length==1){var values=new ArrayList<String>();values.add("adventure");if(sender.hasPermission(PERMISSION)){values.add("adventure-site");values.add("quest-giver");}return matching(values,args[0]);}if(args.length==2&&args[0].equals("adventure")){var values=new ArrayList<String>();if(sender instanceof Player player&&controller.trackingAvailable(player))values.add("tracking");if(sender.hasPermission(PERMISSION))values.add("reroll");return matching(values,args[1]);}if(args.length==3&&args[0].equals("adventure")&&args[1].equals("tracking")&&sender instanceof Player player&&controller.trackingAvailable(player))return matching(List.of("on","off"),args[2]);if(!sender.hasPermission(PERMISSION))return List.of();if(args.length==3&&args[0].equals("adventure")&&args[1].equals("reroll"))return matching(List.of("confirm"),args[2]);if(args.length==2&&args[0].equals("adventure-site"))return matching(sender instanceof Player?List.of("list","place","remove","teleport"):List.of("list","remove"),args[1]);if(args.length==3&&args[0].equals("adventure-site")&&args[1].equals("place"))return matching(List.of("camp","champion"),args[2]);if(args.length==4&&args[0].equals("adventure-site")&&args[1].equals("place")&&(args[2].equals("camp")||args[2].equals("champion")))return matching(List.of("4","16","64"),args[3]);if(args.length==5&&args[0].equals("adventure-site")&&args[1].equals("place")&&whole(args[3])!=null)return matching(List.of("8","16","128"),args[4]);if(args.length==3&&args[0].equals("adventure-site")&&Set.of("remove","teleport").contains(args[1]))return matching(directory.snapshot().sites().stream().filter(v->v.type()!=AdventureQuestState.SiteType.DUNGEON).map(v->v.id().toString()).toList(),args[2]);if(args.length==2&&args[0].equals("quest-giver"))return matching(sender instanceof Player?List.of("list","place","remove"):List.of("list","remove"),args[1]);if(args.length==3&&args[0].equals("quest-giver")&&args[1].equals("remove"))return matching(directory.snapshot().givers().stream().map(v->v.id().toString()).toList(),args[2]);return List.of();}
public List<String> complete(CommandSender sender,String[] args){if(args.length==1){var values=new ArrayList<String>();values.add("adventure");if(sender.hasPermission(PERMISSION)){values.add("adventure-site");values.add("quest-giver");}return matching(values,args[0]);}if(args.length==2&&args[0].equals("adventure")){var values=new ArrayList<String>();if(sender instanceof Player player&&controller.trackingAvailable(player))values.add("tracking");if(sender.hasPermission(PERMISSION))values.add("reroll");return matching(values,args[1]);}if(args.length==3&&args[0].equals("adventure")&&args[1].equals("tracking")&&sender instanceof Player player&&controller.trackingAvailable(player))return matching(List.of("on","off"),args[2]);if(!sender.hasPermission(PERMISSION))return List.of();if(args.length==3&&args[0].equals("adventure")&&args[1].equals("reroll"))return matching(List.of("confirm"),args[2]);if(args.length==2&&args[0].equals("adventure-site"))return matching(sender instanceof Player?List.of("list","mode","place","remove","teleport"):List.of("list","mode","remove"),args[1]);if(args.length==3&&args[0].equals("adventure-site")&&args[1].equals("place"))return matching(List.of("camp","champion"),args[2]);if(args.length==4&&args[0].equals("adventure-site")&&args[1].equals("place")&&(args[2].equals("camp")||args[2].equals("champion"))){var values=new ArrayList<>(List.of("--mode","4","16","64"));return matching(values,args[3]);}if(args.length==5&&args[0].equals("adventure-site")&&args[1].equals("place")&&args[3].equals("--mode"))return matching(modeValues(),args[4]);if(args.length==6&&args[0].equals("adventure-site")&&args[1].equals("place")&&args[3].equals("--mode"))return matching(List.of("4","16","64"),args[5]);if(args.length==5&&args[0].equals("adventure-site")&&args[1].equals("place")&&whole(args[3])!=null)return matching(List.of("8","16","128"),args[4]);if(args.length==7&&args[0].equals("adventure-site")&&args[1].equals("place")&&args[3].equals("--mode")&&whole(args[5])!=null)return matching(List.of("8","16","128"),args[6]);if(args.length==3&&args[0].equals("adventure-site")&&Set.of("mode","remove","teleport").contains(args[1]))return matching(directory.snapshot().sites().stream().filter(v->v.type()!=AdventureQuestState.SiteType.DUNGEON).map(v->v.id().toString()).toList(),args[2]);if(args.length==4&&args[0].equals("adventure-site")&&args[1].equals("mode"))return matching(modeValues(),args[3]);if(args.length==2&&args[0].equals("quest-giver"))return matching(sender instanceof Player?List.of("list","place","remove"):List.of("list","remove"),args[1]);if(args.length==3&&args[0].equals("quest-giver")&&args[1].equals("remove"))return matching(directory.snapshot().givers().stream().map(v->v.id().toString()).toList(),args[2]);return List.of();}
private Optional<AdventureQuestState.Site> site(UUID id){return directory.findSite(id).filter(value->value.type()!=AdventureQuestState.SiteType.DUNGEON);}
private static AdventureAdminTeleport.Target target(AdventureQuestState.Site site){return new AdventureAdminTeleport.Target(site.id(),site.displayName(),site.type()==AdventureQuestState.SiteType.CAMP?"monster camp":"champion",site.world(),site.x(),site.y(),site.z(),0,0,true);}
private boolean authorized(CommandSender sender){if(sender.hasPermission(PERMISSION))return true;sender.sendMessage("Permission denied: you need fantasystick.admin to administer Fantasy Stick.");return false;}
private void respond(CommandSender sender,Runnable response){try{nextTick.accept(()->{if(sender.hasPermission(PERMISSION)&&(!(sender instanceof Player player)||player.isOnline()))response.run();});}catch(RuntimeException ignored){}}
private record Placement(String name,AdventureQuestState.Arena arena){}
private static Placement placement(String[] args){if(args.length==3)return new Placement("",null);Integer first=whole(args[3]);if(first==null)return looksNumeric(args[3])?null:new Placement(join(args,3),null);if(args.length>4){Integer second=whole(args[4]);if(second!=null)return first>=8&&first<=128&&second>=8&&second<=128?new Placement(join(args,5),AdventureQuestState.Arena.rectangle(first,second)):null;if(looksNumeric(args[4]))return null;}return first>=4&&first<=64?new Placement(join(args,4),AdventureQuestState.Arena.circle(first)):null;}
private static Placement placement(String[] args,int start){if(args.length==start)return new Placement("",null);if(Arrays.stream(args,start,args.length).anyMatch(value->value.startsWith("--")))return null;Integer first=whole(args[start]);if(first==null)return looksNumeric(args[start])||args[start].startsWith("--")?null:new Placement(join(args,start),null);if(args.length>start+1){Integer second=whole(args[start+1]);if(second!=null)return first>=8&&first<=128&&second>=8&&second<=128?new Placement(join(args,start+2),AdventureQuestState.Arena.rectangle(first,second)):null;if(looksNumeric(args[start+1]))return null;}return first>=4&&first<=64?new Placement(join(args,start+1),AdventureQuestState.Arena.circle(first)):null;}
private static List<String> modeValues(){return Arrays.stream(AdventureQuestState.ParticipationMode.values()).map(AdventureQuestState.ParticipationMode::value).toList();}
private static Integer whole(String value){try{return Integer.valueOf(value);}catch(NumberFormatException invalid){return null;}}
private static boolean looksNumeric(String value){return value.matches("[+-]?(?:\\d.*|\\.\\d+)");}
private static String join(String[] args,int start){return start>=args.length?"":String.join(" ",Arrays.copyOfRange(args,start,args.length));}
private static UUID parse(String value){try{var id=UUID.fromString(value);return id.toString().equals(value)?id:null;}catch(IllegalArgumentException invalid){return null;}}
private static int section(double coordinate){return((int)Math.floor(coordinate))>>4;}
private static List<String> matching(Collection<String> values,String prefix){return values.stream().filter(v->v.startsWith(prefix)).distinct().sorted().toList();}
private static void usageSite(CommandSender sender){sender.sendMessage("Usage: /fantasystick adventure-site <place <camp|champion> [<radius>|<x-blocks> <z-blocks>] [name...]|remove <site-uuid>|teleport <site-uuid>|list>");}
private static void usageGiver(CommandSender sender){sender.sendMessage("Usage: /fantasystick quest-giver <place [name...]|remove <giver-uuid>|list>");}
private static void usageSite(CommandSender sender){sender.sendMessage("Usage: /fstickadmin adventure-site <place <camp|champion> [--mode <mode>] [<radius>|<x-blocks> <z-blocks>] [name...]|mode <site-uuid> [<mode>]|remove <site-uuid>|teleport <site-uuid>|list>");}
private static void usageGiver(CommandSender sender){sender.sendMessage("Usage: /fstickadmin quest-giver <place [name...]|remove <giver-uuid>|list>");}
}
@@ -59,12 +59,13 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
private void spawnGiver(AdventureQuestState.Giver value){var world=plugin.getServer().getWorld(value.world());if(world==null||!world.isChunkLoaded(section(value.x()),section(value.z())))return;world.spawn(location(value),Villager.class,villager->configure(villager,value));}
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){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.max(9,((count+8)/9)*9);var inventory=plugin.getServer().createInventory(new MenuHolder(catalog.revision(),page),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 (maximum)";};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?"Maximum reward: ":"Reward: ";meta.lore(List.of(net.kyori.adventure.text.Component.text(kind),net.kyori.adventure.text.Component.text(rewardLabel+rewardXp+" XP, "+rewardCoins+" coins"),net.kyori.adventure.text.Component.text("Expires: "+catalog.expiresAt()),net.kyori.adventure.text.Component.text(state)));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"));}player.openInventory(inventory);}));}
private void open(Player player,int requestedPage){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.max(9,((count+8)/9)*9);var inventory=plugin.getServer().createInventory(new MenuHolder(catalog.revision(),page),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 (maximum)";};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?"Maximum reward: ":"Reward: ";var lore=new ArrayList<net.kyori.adventure.text.Component>();lore.add(net.kyori.adventure.text.Component.text(kind));lore.add(net.kyori.adventure.text.Component.text(rewardLabel+rewardXp+" XP, "+rewardCoins+" coins"));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"));}player.openInventory(inventory);}));}
private ItemStack pageItem(int page,String name){var item=new ItemStack(Material.ARROW);var meta=item.getItemMeta();meta.displayName(net.kyori.adventure.text.Component.text(name));meta.getPersistentDataContainer().set(menuPageKey,PersistentDataType.INTEGER,page);item.setItemMeta(meta);return item;}
private static void acceptanceLore(List<net.kyori.adventure.text.Component> lore,AdventureQuestState.ParticipationMode mode,Context context){if(mode!=AdventureQuestState.ParticipationMode.PARTY_REQUIRED)lore.add(net.kyori.adventure.text.Component.text("Left click: Accept individually"));if(mode!=AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY){if(context.partyId!=null&&context.leader)lore.add(net.kyori.adventure.text.Component.text("Right click: Accept for party"));else lore.add(net.kyori.adventure.text.Component.text("Party acceptance requires the active leader"));}}
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).isPresent())open(event.getPlayer());}
@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);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;}directory.abandon(active.id(),active.unit()).whenComplete((changed,failure)->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;}if(context.partyId!=null&&!context.leader){player.sendMessage("Only the active party leader may accept a party quest.");return;}var type=context.partyId==null?AdventureQuestState.UnitType.SOLO:AdventureQuestState.UnitType.PARTY;var unit=context.partyId==null?context.character:context.partyId;directory.accept(catalog,site,type,unit,context.members).whenComplete((result,failure)->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 location=directory.findSite(site).orElseThrow();refreshTracking(player);player.sendMessage("Accepted "+location.displayName()+"; expires "+result.quest().orElseThrow().expiresAt()+". Follow the Quest Compass.");}));}
@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);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;}directory.abandon(active.id(),active.unit()).whenComplete((changed,failure)->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;directory.accept(catalog,site,type,unit,members,offered.participationMode()).whenComplete((result,failure)->schedule(()->{if(failure!=null){player.sendMessage("Quest acceptance failed; retained state is unchanged.");return;}if(result.status()!=AdventureQuestDirectory.Acceptance.APPLIED){player.sendMessage("Quest acceptance refused: "+result.status().name().toLowerCase(Locale.ROOT)+".");return;}var quest=result.quest().orElseThrow();var current=context(player);boolean same=current!=null&&current.active.equals(context.active)&&current.character.equals(context.character)&&(type==AdventureQuestState.UnitType.SOLO||current.leader&&Objects.equals(current.partyId,context.partyId)&&current.members.equals(context.members));if(!same){directory.abandon(quest.id(),quest.unit()).whenComplete((ignored,rollbackFailure)->schedule(()->player.sendMessage(rollbackFailure==null?"Quest acceptance refused: the character or party changed.":"Quest acceptance is pending safe cancellation; retry after recovery.")));return;}var location=directory.findSite(site).orElseThrow();refreshTracking(player);player.sendMessage("Accepted "+location.displayName()+"; expires "+quest.expiresAt()+". Follow the Quest Compass.");}));}
private void claim(Player player,Context expected,UUID catalog,UUID site){directory.beginClaim(catalog,site,expected.character).whenComplete((operation,failure)->schedule(()->{if(failure!=null||operation.isEmpty()){player.sendMessage("Reward claim failed; retained rewards are unchanged.");return;}var current=context(player);if(current==null||!current.active.equals(expected.active)||!current.character.equals(operation.orElseThrow().character())){player.sendMessage("Reward claim paused: actively play the exact living character and try again.");return;}var record=sessions.currentRecord(current.active).orElse(null);if(record==null){player.sendMessage("Reward claim paused: the current character changed.");return;}attemptClaim(player,current.active,operation.orElseThrow(),record);}));}
private void attemptClaim(Player player,EquipmentSessions.Active active,AdventureQuestState.RewardClaim operation,games.dmg.fantasystick.persistence.CharacterRecord record){characters.awardCombatReward(record.id(),record.revision(),operation.id(),operation.xp(),operation.coins(),progression,games.dmg.fantasystick.progression.CombatRewardRules.defaults(),null).whenComplete((award,failure)->{if(failure!=null){schedule(()->player.sendMessage("Reward claim failed; retained rewards are unchanged."));return;}if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){attemptClaim(player,active,operation,award.record().orElseThrow());return;}if(award.record().isEmpty()){schedule(()->player.sendMessage("Reward claim failed; retained rewards are unchanged."));return;}var saved=award.record().orElseThrow();directory.finishClaim(operation.id()).whenComplete((finished,finishFailure)->schedule(()->{if(finishFailure!=null||!finished){player.sendMessage("Reward claim is pending recovery; retry at a Quest Giver.");return;}var holder=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);if(award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE&&holder!=null)sessions.acknowledgeProgress(holder,saved.revision().subtract(BigInteger.ONE),saved);var currentPlayer=holder==null?null:plugin.getServer().getPlayer(holder.player());var current=currentPlayer==null?null:context(currentPlayer);if(current!=null&&current.character.equals(operation.character())){currentPlayer.sendMessage("Claimed "+operation.xp()+" fantasy XP and "+operation.coins()+" character coins.");open(currentPlayer);}}));});}
public boolean trackingAvailable(Player player){var context=context(player);return context!=null&&directory.activeFor(context.character,context.partyId).filter(quest->quest.snapshot().contains(context.character)).isPresent();}
@@ -12,10 +12,12 @@ import org.bukkit.entity.EntityType;
/** Serialized durable daily catalog, acceptance, roster and completion authority. */
public final class AdventureQuestDirectory implements AutoCloseable {
public enum Acceptance { APPLIED, STALE, UNAVAILABLE, CONFLICT, CLAIMED }
public enum Acceptance { APPLIED, STALE, UNAVAILABLE, POLICY, CONFLICT, CLAIMED }
public enum ModeStatus { APPLIED, UNCHANGED, STALE, NOT_FOUND }
public enum RerollStatus { APPLIED, STALE }
public enum TrackingStatus { APPLIED, UNCHANGED, INELIGIBLE }
public record AcceptResult(Acceptance status,Optional<AdventureQuestState.Quest> quest) { }
public record ModeResult(ModeStatus status,Optional<AdventureQuestState.Site> site) { }
public record RerollResult(RerollStatus status,Optional<AdventureQuestState.Catalog> catalog) { }
public record Reward(java.math.BigInteger xp,java.math.BigInteger coins) {public Reward{Objects.requireNonNull(xp);Objects.requireNonNull(coins);if(xp.signum()<0||coins.signum()<0)throw new IllegalArgumentException("Invalid reward");}}
private final YamlAdventureQuestRepository repository;private final Clock clock;private final Supplier<AdventureQuestSettings.Rules> settings;private final java.util.random.RandomGenerator random;
@@ -32,8 +34,10 @@ public final class AdventureQuestDirectory implements AutoCloseable {
public Optional<AdventureQuestState.Site> findSite(UUID id){return site(snapshot,id);}
public Optional<AdventureQuestState.Giver> findGiver(UUID id){return snapshot.givers().stream().filter(v->v.id().equals(id)).findFirst();}
public Optional<AdventureQuestState.Quest> findQuest(UUID id){return snapshot.quests().stream().filter(v->v.id().equals(id)).findFirst();}
public CompletableFuture<AdventureQuestState.Site> placeSite(AdventureQuestState.SiteType type,String name,UUID world,double x,double y,double z){return placeSite(type,name,world,x,y,z,null);}
public CompletableFuture<AdventureQuestState.Site> placeSite(AdventureQuestState.SiteType type,String name,UUID world,double x,double y,double z,AdventureQuestState.Arena arena){var value=new AdventureQuestState.Site(UUID.randomUUID(),type,name,world,x,y,z,arena);return mutate(state->{var sites=new ArrayList<>(state.sites());sites.add(value);return result(copy(state,List.copyOf(sites),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),value);});}
public CompletableFuture<AdventureQuestState.Site> placeSite(AdventureQuestState.SiteType type,String name,UUID world,double x,double y,double z){return placeSite(type,name,world,x,y,z,null,AdventureQuestState.ParticipationMode.PARTY_AVAILABLE);}
public CompletableFuture<AdventureQuestState.Site> placeSite(AdventureQuestState.SiteType type,String name,UUID world,double x,double y,double z,AdventureQuestState.Arena arena){return placeSite(type,name,world,x,y,z,arena,AdventureQuestState.ParticipationMode.PARTY_AVAILABLE);}
public CompletableFuture<AdventureQuestState.Site> placeSite(AdventureQuestState.SiteType type,String name,UUID world,double x,double y,double z,AdventureQuestState.Arena arena,AdventureQuestState.ParticipationMode mode){var value=new AdventureQuestState.Site(UUID.randomUUID(),type,name,world,x,y,z,arena,mode);return mutate(state->{var sites=new ArrayList<>(state.sites());sites.add(value);return result(copy(state,List.copyOf(sites),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),value);});}
public CompletableFuture<ModeResult> setSiteMode(UUID id,AdventureQuestState.ParticipationMode expected,AdventureQuestState.ParticipationMode requested){Objects.requireNonNull(expected);Objects.requireNonNull(requested);return mutate(state->{var found=state.sites().stream().filter(value->value.id().equals(id)&&value.type()!=AdventureQuestState.SiteType.DUNGEON).findFirst();if(found.isEmpty())return result(state,new ModeResult(ModeStatus.NOT_FOUND,Optional.empty()));var current=found.orElseThrow();if(current.participationMode()!=expected)return result(state,new ModeResult(ModeStatus.STALE,Optional.of(current)));if(current.participationMode()==requested)return result(state,new ModeResult(ModeStatus.UNCHANGED,Optional.of(current)));var changed=new AdventureQuestState.Site(current.id(),current.type(),current.name(),current.world(),current.x(),current.y(),current.z(),current.arena(),requested);var sites=state.sites().stream().map(value->value.id().equals(id)?changed:value).toList();return result(copy(state,sites,state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),new ModeResult(ModeStatus.APPLIED,Optional.of(changed)));});}
public CompletableFuture<Optional<AdventureQuestState.Site>> removeSite(UUID id){return mutate(state->{var found=state.sites().stream().filter(v->v.id().equals(id)&&v.type()!=AdventureQuestState.SiteType.DUNGEON).findFirst();if(found.isEmpty())return result(state,Optional.empty());var sites=state.sites().stream().filter(v->!v.id().equals(id)).toList();var quests=state.quests().stream().filter(v->!v.site().equals(id)).toList();var rosters=new HashMap<>(state.rosters());rosters.remove(id);return result(copy(state,sites,state.givers(),state.catalog(),quests,state.unitClaims(),state.characterClaims(),rosters),found);});}
public CompletableFuture<Optional<AdventureQuestState.Site>> removeDungeonSite(UUID id){return mutate(state->{var found=state.sites().stream().filter(v->v.id().equals(id)&&v.type()==AdventureQuestState.SiteType.DUNGEON).findFirst();if(found.isEmpty())return result(state,Optional.empty());var sites=state.sites().stream().filter(v->!v.id().equals(id)).toList();return result(copy(state,sites,state.givers(),state.catalog(),state.quests().stream().filter(v->!v.site().equals(id)).toList(),state.unitClaims(),state.characterClaims(),state.rosters()),found);});}
public CompletableFuture<AdventureQuestState.Giver> placeGiver(String name,UUID world,double x,double y,double z,float yaw,float pitch){var value=new AdventureQuestState.Giver(UUID.randomUUID(),name,world,x,y,z,yaw,pitch);return mutate(state->{var values=new ArrayList<>(state.givers());values.add(value);return result(copy(state,state.sites(),List.copyOf(values),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),value);});}
@@ -44,7 +48,8 @@ public final class AdventureQuestDirectory implements AutoCloseable {
public CompletableFuture<RerollResult> rerollToday(){var current=snapshot.catalog();return rerollToday(current==null?null:current.revision());}
public CompletableFuture<RerollResult> rerollToday(UUID expectedRevision){return mutate(original->{var state=expire(original);var current=state.catalog();UUID actual=current==null?null:current.revision();if(state.reset()!=null||!Objects.equals(expectedRevision,actual))return result(state,new RerollResult(RerollStatus.STALE,Optional.ofNullable(current)));var today=LocalDate.now(clock.withZone(ZoneOffset.UTC));boolean sameDay=current!=null&&current.day().equals(today);var catalog=roll(state,today,sameDay?Set.copyOf(current.sites()):Set.of());var reset=new AdventureQuestState.Reset(UUID.randomUUID(),catalog.revision(),clock.instant());var next=copyReset(state,state.sites(),state.givers(),catalog,List.of(),Set.of(),Set.of(),Map.of(),reset);return result(next,new RerollResult(RerollStatus.APPLIED,Optional.of(catalog)));});}
public CompletableFuture<Boolean> completeReset(UUID operation){return mutate(state->{if(state.reset()==null||!state.reset().operation().equals(operation))return result(state,false);return result(copyReset(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters(),null),true);});}
public CompletableFuture<AcceptResult> accept(UUID catalog,UUID site,AdventureQuestState.UnitType type,UUID unit,Set<UUID> members){return mutate(original->{var state=expire(original);var current=state.catalog();if(state.reset()!=null||current==null||!current.day().equals(LocalDate.now(clock.withZone(ZoneOffset.UTC)))||!current.revision().equals(catalog))return result(state,new AcceptResult(Acceptance.STALE,Optional.empty()));if(!current.sites().contains(site)||current.closed().contains(site)||site(state,site).filter(this::available).isEmpty())return result(state,new AcceptResult(Acceptance.UNAVAILABLE,Optional.empty()));boolean unitClaimed=state.unitClaims().stream().anyMatch(claim->claim.site().equals(site)&&claim.type()==type&&claim.unit().equals(unit));boolean characterClaimed=members.stream().anyMatch(member->state.characterClaims().stream().anyMatch(claim->claim.site().equals(site)&&claim.character().equals(member)));if(unitClaimed||characterClaimed)return result(state,new AcceptResult(Acceptance.CLAIMED,Optional.empty()));boolean conflict=state.quests().stream().anyMatch(q->q.unit().equals(unit)||type==AdventureQuestState.UnitType.PARTY&&q.unitType()==AdventureQuestState.UnitType.SOLO&&members.contains(q.unit()));if(conflict)return result(state,new AcceptResult(Acceptance.CONFLICT,Optional.empty()));var offered=site(state,site).orElseThrow();var reward=reward(offered);var quest=new AdventureQuestState.Quest(UUID.randomUUID(),catalog,site,type,unit,members,current.expiresAt(),reward.xp(),reward.coins());var quests=new ArrayList<>(state.quests());quests.add(quest);var tracking=new HashSet<>(state.tracking());for(UUID character:members)tracking.add(new AdventureQuestState.Tracking(quest.id(),character,true));return result(copyTracking(state,state.sites(),state.givers(),current,List.copyOf(quests),state.unitClaims(),state.characterClaims(),state.rosters(),tracking),new AcceptResult(Acceptance.APPLIED,Optional.of(quest)));});}
public CompletableFuture<AcceptResult> accept(UUID catalog,UUID site,AdventureQuestState.UnitType type,UUID unit,Set<UUID> members){var offered=findSite(site).orElse(null);return offered==null?CompletableFuture.completedFuture(new AcceptResult(Acceptance.UNAVAILABLE,Optional.empty())):accept(catalog,site,type,unit,members,offered.participationMode());}
public CompletableFuture<AcceptResult> accept(UUID catalog,UUID site,AdventureQuestState.UnitType type,UUID unit,Set<UUID> members,AdventureQuestState.ParticipationMode expectedMode){Objects.requireNonNull(expectedMode);var requestedMembers=Set.copyOf(members);return mutate(original->{var state=expire(original);var current=state.catalog();if(state.reset()!=null||current==null||!current.day().equals(LocalDate.now(clock.withZone(ZoneOffset.UTC)))||!current.revision().equals(catalog))return result(state,new AcceptResult(Acceptance.STALE,Optional.empty()));var offered=site(state,site).filter(this::available).orElse(null);if(!current.sites().contains(site)||current.closed().contains(site)||offered==null)return result(state,new AcceptResult(Acceptance.UNAVAILABLE,Optional.empty()));if(offered.participationMode()!=expectedMode)return result(state,new AcceptResult(Acceptance.STALE,Optional.empty()));if(!offered.participationMode().permits(type))return result(state,new AcceptResult(Acceptance.POLICY,Optional.empty()));boolean unitClaimed=state.unitClaims().stream().anyMatch(claim->claim.site().equals(site)&&claim.type()==type&&claim.unit().equals(unit));boolean characterClaimed=requestedMembers.stream().anyMatch(member->state.characterClaims().stream().anyMatch(claim->claim.site().equals(site)&&claim.character().equals(member)));if(unitClaimed||characterClaimed)return result(state,new AcceptResult(Acceptance.CLAIMED,Optional.empty()));boolean conflict=state.quests().stream().anyMatch(q->q.unit().equals(unit)||!Collections.disjoint(q.snapshot(),requestedMembers));if(conflict)return result(state,new AcceptResult(Acceptance.CONFLICT,Optional.empty()));var reward=reward(offered);var quest=new AdventureQuestState.Quest(UUID.randomUUID(),catalog,site,type,unit,requestedMembers,current.expiresAt(),reward.xp(),reward.coins(),offered.participationMode());var quests=new ArrayList<>(state.quests());quests.add(quest);var tracking=new HashSet<>(state.tracking());for(UUID character:requestedMembers)tracking.add(new AdventureQuestState.Tracking(quest.id(),character,true));return result(copyTracking(state,state.sites(),state.givers(),current,List.copyOf(quests),state.unitClaims(),state.characterClaims(),state.rosters(),tracking),new AcceptResult(Acceptance.APPLIED,Optional.of(quest)));});}
public CompletableFuture<Integer> expireOfferQuests(UUID site){return mutate(state->{var quests=state.quests().stream().filter(value->!value.site().equals(site)).toList();int removed=state.quests().size()-quests.size();return removed==0?result(state,0):result(copy(state,state.sites(),state.givers(),state.catalog(),quests,state.unitClaims(),state.characterClaims(),state.rosters()),removed);});}
public CompletableFuture<Boolean> abandon(UUID quest,UUID unit){return mutate(state->{var found=state.quests().stream().filter(q->q.id().equals(quest)&&q.unit().equals(unit)).findFirst();if(found.isEmpty())return result(state,false);return result(copy(state,state.sites(),state.givers(),state.catalog(),state.quests().stream().filter(q->!q.id().equals(quest)).toList(),state.unitClaims(),state.characterClaims(),state.rosters()),true);});}
public CompletableFuture<List<EntityType>> roster(UUID site){return mutate(state->{state=expire(state);if(state.reset()!=null||state.catalog()==null||!state.catalog().day().equals(LocalDate.now(clock.withZone(ZoneOffset.UTC)))||!state.catalog().sites().contains(site)||find(state.sites(),site).filter(v->v.type()==AdventureQuestState.SiteType.CAMP).isEmpty())throw new IllegalArgumentException("Unavailable camp");var existing=state.rosters().get(site);if(existing!=null)return result(state,existing);var rolled=CampRoster.roll(settings.get().campEnemies(),random);var rosters=new HashMap<>(state.rosters());rosters.put(site,rolled);return result(copy(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),rosters),rolled);});}
@@ -12,6 +12,14 @@ public record AdventureQuestState(List<Site> sites,List<Giver> givers,Catalog ca
Map<UUID,List<EntityType>> rosters,Set<Tracking> tracking,RewardLedger rewards,Reset reset) {
public enum SiteType { CAMP, CHAMPION, DUNGEON }
public enum UnitType { SOLO, PARTY }
public enum ParticipationMode {
INDIVIDUAL_ONLY("individual-only"), PARTY_AVAILABLE("party-available"), PARTY_REQUIRED("party-required");
private final String value;
ParticipationMode(String value){this.value=value;}
public String value(){return value;}
public static ParticipationMode parse(String value){return Arrays.stream(values()).filter(mode->mode.value.equals(value)).findFirst().orElseThrow(()->new IllegalArgumentException("Invalid participation mode"));}
public boolean permits(UnitType type){return switch(this){case INDIVIDUAL_ONLY->type==UnitType.SOLO;case PARTY_AVAILABLE->true;case PARTY_REQUIRED->type==UnitType.PARTY;};}
}
public enum ArenaShape { CIRCLE, RECTANGLE }
public record Arena(ArenaShape shape,int xBlocks,int zBlocks) {
public Arena {
@@ -26,9 +34,10 @@ public record AdventureQuestState(List<Site> sites,List<Giver> givers,Catalog ca
public boolean contains(double xOffset,double zOffset){return shape==ArenaShape.CIRCLE?xOffset*xOffset+zOffset*zOffset<=xBlocks*(double)xBlocks:Math.abs(xOffset)<=xBlocks/2d&&Math.abs(zOffset)<=zBlocks/2d;}
public String display(){return shape==ArenaShape.CIRCLE?"circle radius="+xBlocks:"rectangle x="+xBlocks+" z="+zBlocks;}
}
public record Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z,Arena arena) {
public Site { Objects.requireNonNull(id);Objects.requireNonNull(type);name=validName(name);Objects.requireNonNull(world);finite(x,y,z); }
public Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z){this(id,type,name,world,x,y,z,null);}
public record Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z,Arena arena,ParticipationMode participationMode) {
public Site { Objects.requireNonNull(id);Objects.requireNonNull(type);name=validName(name);Objects.requireNonNull(world);finite(x,y,z);Objects.requireNonNull(participationMode); }
public Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z,Arena arena){this(id,type,name,world,x,y,z,arena,ParticipationMode.PARTY_AVAILABLE);}
public Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z){this(id,type,name,world,x,y,z,null,ParticipationMode.PARTY_AVAILABLE);}
public String displayName(){return name.isBlank()?switch(type){case CAMP->"Monster Camp";case CHAMPION->"Champion Site";case DUNGEON->"Dungeon";}:name;}
public Arena arenaOrDefault(int radius){return arena==null?Arena.circle(radius):arena;}
}
@@ -42,8 +51,9 @@ public record AdventureQuestState(List<Site> sites,List<Giver> givers,Catalog ca
public Instant expiresAt(){return day.plusDays(1).atStartOfDay(java.time.ZoneOffset.UTC).toInstant();}
}
public record Quest(UUID id,UUID catalog,UUID site,UnitType unitType,UUID unit,Set<UUID> snapshot,
Instant expiresAt,BigInteger rewardXp,BigInteger rewardCoins) {
public Quest { Objects.requireNonNull(id);Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(unitType);Objects.requireNonNull(unit);snapshot=Set.copyOf(snapshot);Objects.requireNonNull(expiresAt);Objects.requireNonNull(rewardXp);Objects.requireNonNull(rewardCoins);if(snapshot.isEmpty()||rewardXp.signum()<0||rewardCoins.signum()<0||unitType==UnitType.SOLO&&(!snapshot.equals(Set.of(unit))))throw new IllegalArgumentException("Invalid quest"); }
Instant expiresAt,BigInteger rewardXp,BigInteger rewardCoins,ParticipationMode participationMode) {
public Quest { Objects.requireNonNull(id);Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(unitType);Objects.requireNonNull(unit);snapshot=Set.copyOf(snapshot);Objects.requireNonNull(expiresAt);Objects.requireNonNull(rewardXp);Objects.requireNonNull(rewardCoins);Objects.requireNonNull(participationMode);if(snapshot.isEmpty()||rewardXp.signum()<0||rewardCoins.signum()<0||unitType==UnitType.SOLO&&(!snapshot.equals(Set.of(unit)))||!participationMode.permits(unitType))throw new IllegalArgumentException("Invalid quest"); }
public Quest(UUID id,UUID catalog,UUID site,UnitType unitType,UUID unit,Set<UUID> snapshot,Instant expiresAt,BigInteger rewardXp,BigInteger rewardCoins){this(id,catalog,site,unitType,unit,snapshot,expiresAt,rewardXp,rewardCoins,ParticipationMode.PARTY_AVAILABLE);}
}
public record UnitClaim(UUID catalog,UUID site,UnitType type,UUID unit) {
public UnitClaim {Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(type);Objects.requireNonNull(unit);}
@@ -33,10 +33,12 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
private final Map<UUID, Object> pending = new ConcurrentHashMap<>();
private final Map<UUID, Object> adminPending = new ConcurrentHashMap<>();
private final Map<UUID, AdminView> adminViews = new ConcurrentHashMap<>();
private final Map<UUID, SavedView> savedViews = new ConcurrentHashMap<>();
private volatile boolean closed;
private static final int VOLUME_SIZE = 30; // Reserve room for linked ability details, navigation and party pages.
private static final net.kyori.adventure.key.Key PAGES = net.kyori.adventure.key.Key.key("fantasystick", "character_pages");
private static final net.kyori.adventure.key.Key ADMIN_PAGES = net.kyori.adventure.key.Key.key("fantasystick", "admin_character_pages");
private static final net.kyori.adventure.key.Key SAVED_PAGES = net.kyori.adventure.key.Key.key("fantasystick", "saved_character_pages");
private static final net.kyori.adventure.key.Key STATS = net.kyori.adventure.key.Key.key("fantasystick", "character_stat");
private static final net.kyori.adventure.key.Key ABILITY_UNLOCK = net.kyori.adventure.key.Key.key("fantasystick", "ability_unlock");
private static final net.kyori.adventure.key.Key ABILITY_UNLEARN = net.kyori.adventure.key.Key.key("fantasystick", "ability_unlearn");
@@ -49,6 +51,7 @@ 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 StatView(String nonce, ControlInput input, java.math.BigInteger revision) { }
private record AbilityView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids) { }
private record PassiveView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids) { }
@@ -123,6 +126,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
UUID targetId = target.getUniqueId();
Object ticket = new Object();
adminViews.remove(administratorId);
savedViews.remove(administratorId);
adminPending.put(administratorId, ticket);
characters.find(active.character()).whenComplete((record, failure) -> {
if (closed || adminPending.get(administratorId) != ticket) return;
@@ -164,6 +168,55 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
});
}
public void openSavedReadOnly(org.bukkit.entity.Player administrator, UUID character) {
Objects.requireNonNull(administrator); Objects.requireNonNull(character);
if (closed) return;
if (!administrator.isOnline() || !administrator.hasPermission("fantasystick.admin")) {
if (administrator.isOnline()) administrator.sendMessage("Saved character view refused: administrator permission is required.");
return;
}
loadSavedReadOnly(administrator, character, 0);
}
private void loadSavedReadOnly(org.bukkit.entity.Player administrator, UUID character, int offset) {
if (closed) return;
UUID administratorId = administrator.getUniqueId();
Object ticket = new Object();
adminViews.remove(administratorId);
savedViews.remove(administratorId);
adminPending.put(administratorId, ticket);
characters.inspect(character).whenComplete((record, failure) -> {
if (closed || adminPending.get(administratorId) != ticket) return;
try {
nextTick.accept(() -> {
if (closed || !adminPending.remove(administratorId, ticket)) return;
if (!administrator.isOnline() || !administrator.hasPermission("fantasystick.admin")) {
if (administrator.isOnline()) administrator.sendMessage("Saved character view refused: administrator permission changed.");
return;
}
if (failure != null) {
report(new Failure(administratorId, character, failure));
administrator.sendMessage("Saved character view could not be loaded; no state was changed.");
return;
}
if (record.isEmpty()) {
administrator.sendMessage("Saved character view refused: character not found.");
return;
}
try { administrator.openBook(renderSavedReadOnly(record.orElseThrow(), administrator, offset)); }
catch (RuntimeException error) {
savedViews.remove(administratorId);
report(new Failure(administratorId, character, error));
administrator.sendMessage("Saved character view could not be opened; no state was changed.");
}
});
} catch (RuntimeException rejected) {
adminPending.remove(administratorId, ticket);
report(new Failure(administratorId, character, rejected));
}
});
}
private void load(ControlInput input, int offset) {
if (closed || input.kind() != ControlInput.Kind.BOOK || input.gesture() != ControlInput.Gesture.USE
|| !input.stillCurrent()) return;
@@ -200,19 +253,24 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
}
private ItemStack render(CharacterRecord record, ControlInput input, int offset) {
return renderBook(record, input.player(), input, offset, null, null);
return renderBook(record, input.player(), input, offset, null, null, false);
}
private ItemStack renderReadOnly(CharacterRecord record, org.bukkit.entity.Player administrator,
org.bukkit.entity.Player target,
games.dmg.fantasystick.session.EquipmentSessions.Active active,
int offset) {
return renderBook(record, target, null, offset, administrator, active);
return renderBook(record, target, null, offset, administrator, active, false);
}
private ItemStack renderSavedReadOnly(CharacterRecord record, org.bukkit.entity.Player administrator, int offset) {
return renderBook(record, administrator, null, offset, administrator, null, true);
}
private ItemStack renderBook(CharacterRecord record, org.bukkit.entity.Player subject,
ControlInput input, int offset, org.bukkit.entity.Player administrator,
games.dmg.fantasystick.session.EquipmentSessions.Active adminActive) {
games.dmg.fantasystick.session.EquipmentSessions.Active adminActive,
boolean savedState) {
boolean interactive = input != null;
var item = new ItemStack(Material.WRITTEN_BOOK);
var meta = (BookMeta) item.getItemMeta();
@@ -220,10 +278,12 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
meta.setAuthor("Fantasy Stick");
var statView = interactive ? new StatView(UUID.randomUUID().toString(), input, record.revision()) : null;
var allOverview = overviewPages(record, subject, statView);
var allOverview = overviewPages(record, subject, statView, savedState);
int start = Math.min(offset, (allOverview.size() - 1) / VOLUME_SIZE * VOLUME_SIZE);
int end = Math.min(start + VOLUME_SIZE, allOverview.size());
var pages = new java.util.ArrayList<Component>(allOverview.subList(start, end));
var pages = new java.util.ArrayList<Component>();
if (savedState && start == 0) pages.addAll(savedSummaryPages(record));
pages.addAll(allOverview.subList(start, end));
if(end==allOverview.size())pages.add(gearPage(record));
if (interactive && record.unspentPoints().signum() > 0 && start == 0)
statViews.put(input.session().player(), statView);
@@ -304,6 +364,14 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
.append(pageButton("Previous pages", view, 0));
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(),
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())
.append(savedPageButton("Previous pages", view, 0));
if (view.next() >= 0) navigation = navigation.append(Component.newline())
.append(savedPageButton("Next pages", view, 1));
} else {
var view = new AdminView(UUID.randomUUID().toString(), administrator.getUniqueId(),
subject, adminActive, start > 0 ? start - VOLUME_SIZE : -1,
@@ -317,7 +385,9 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
pages.add(navigation);
}
if (end == allOverview.size()) pages.addAll(interactive
? partyControls.pages(input) : partyControls.readOnlyPages(subject.getUniqueId()));
? partyControls.pages(input) : savedState
? partyControls.readOnlyCharacterPages(record.id())
: partyControls.readOnlyPages(subject.getUniqueId()));
meta.pages(pages);
item.setItemMeta(meta);
return item;
@@ -326,8 +396,22 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
private Component gearPage(CharacterRecord record){return Component.text("Gear\n\nHead: Cosmetic\nChestplate: "+tier(record.gear().chestplate())+"\nLeggings: "+tier(record.gear().leggings())+"\nBoots: "+tier(record.gear().boots()));}
private static String tier(games.dmg.fantasystick.persistence.GearTier tier){String value=tier.name().toLowerCase(java.util.Locale.ROOT);return Character.toUpperCase(value.charAt(0))+value.substring(1);}
private java.util.List<Component> savedSummaryPages(CharacterRecord record) {
var builder = new PageBuilder();
boolean active = sessions.activeCharacter(record.id()).filter(sessions::current).isPresent();
builder.text("Saved state");
builder.blank();
builder.text("Character UUID:");
builder.text(record.id().toString());
builder.text(active ? "Active" : "Inactive");
builder.text(record.dead() ? "Dead" : "Alive");
if (record.deathId() != null) builder.text("Death: " + record.deathId());
if (record.diedAt() != null) builder.text("Died: " + record.diedAt());
return builder.pages();
}
private java.util.List<Component> overviewPages(CharacterRecord record, org.bukkit.entity.Player subject,
StatView view) {
StatView view, boolean savedState) {
var builder = new PageBuilder();
builder.text(record.name());
builder.blank();
@@ -343,8 +427,16 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
builder.text("XP: " + xp);
builder.text("Unspent points: " + record.unspentPoints());
builder.text("Coins: " + record.coins());
builder.text("Health: " + java.math.BigDecimal.valueOf(subject.getHealth()).min(maximum).stripTrailingZeros().toPlainString()
var health = savedState ? record.health() : java.math.BigDecimal.valueOf(subject.getHealth()).min(maximum);
builder.text((savedState ? "Saved health: " : "Health: ") + health.stripTrailingZeros().toPlainString()
+ "/" + maximum.stripTrailingZeros().toPlainString());
if (savedState) {
builder.blank();
builder.text("Saved cooldowns");
if (record.cooldowns().isEmpty()) builder.text("None");
else record.cooldowns().entrySet().stream().sorted(java.util.Map.Entry.comparingByKey())
.forEach(entry -> builder.text(entry.getKey() + ": " + entry.getValue()));
}
return builder.pages();
}
@@ -585,6 +677,28 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\"" + view.nonce() + ":" + direction + "\"")));
}
private static Component savedPageButton(String label, SavedView view, int direction) {
return Component.text("[" + label + "]", net.kyori.adventure.text.format.NamedTextColor.DARK_BLUE)
.clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(SAVED_PAGES,
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\"" + view.nonce() + ":" + direction + "\"")));
}
@org.bukkit.event.EventHandler
public void savedPageAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
if (closed || !SAVED_PAGES.equals(event.getIdentifier())
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)) return;
var administrator = connection.getPlayer();
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);
}
@org.bukkit.event.EventHandler
public void adminPageAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
if (closed || !ADMIN_PAGES.equals(event.getIdentifier())
@@ -784,6 +898,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
pending.remove(owner);
adminPending.remove(owner);
adminViews.remove(owner);
savedViews.remove(owner);
views.remove(owner);
statViews.remove(owner);
abilityViews.remove(owner);
@@ -792,6 +907,6 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
}
@Override public void close() {
closed = true; pending.clear(); adminPending.clear(); adminViews.clear(); views.clear(); statViews.clear(); abilityViews.clear(); passiveViews.clear(); partyControls.close();
closed = true; pending.clear(); adminPending.clear(); adminViews.clear(); savedViews.clear(); views.clear(); statViews.clear(); abilityViews.clear(); passiveViews.clear(); partyControls.close();
}
}
@@ -60,6 +60,11 @@ public final class PartyBookControls implements AutoCloseable {
return rootPages(Objects.requireNonNull(account));
}
public List<Component> readOnlyCharacterPages(UUID character) {
if (closed) return List.of();
return rootPages(parties.partyOfCharacter(Objects.requireNonNull(character)));
}
private List<Component> render(ControlInput input, Menu menu, int offset) {
if (closed || !input.stillCurrent()) return List.of();
var build = new Pages(input);
@@ -104,8 +109,11 @@ public final class PartyBookControls implements AutoCloseable {
}
private List<Component> rootPages(UUID owner) {
return rootPages(parties.partyOf(owner));
}
private List<Component> rootPages(java.util.Optional<PartyRoster.Party> party) {
var pages = new ArrayList<Component>();
var party = parties.partyOf(owner);
Component summary = Component.text("Party\n\n");
if (party.isEmpty()) {
pages.add(summary.append(Component.text("No party.\n\nVisit a Party\nRegistrar.\nCreate, join, or\nmanage one.")));
@@ -36,15 +36,15 @@ public final class CharacterAnvilNames implements Listener {
public void onTaken(AnvilTakeResultEvent event) {
var sheets = new CharacterSheets();
var result = event.getResult();
var id = sheets.identify(result);
if (id.isEmpty()) return;
var identity = sheets.identity(result);
if (identity.isEmpty()) return;
var input = event.getInventory().getItem(0);
if (!id.equals(sheets.identify(input))) return;
if (!identity.equals(sheets.identity(input))) return;
String name = name(result);
if (name.equals(name(input))) return;
var player = event.getPlayer().getUniqueId();
var character = id.orElseThrow();
storage.renameCharacter(character, name).whenComplete((renamed, failure) -> {
var character = identity.orElseThrow().character();
storage.renameCharacter(character, name, identity.orElseThrow().generation()).whenComplete((renamed, failure) -> {
if (failure != null || renamed.isEmpty()) { failures.accept(new Failure(player, character, failure)); return; }
try { acknowledged.accept(renamed.orElseThrow()); }
catch (RuntimeException | LinkageError error) { failures.accept(new Failure(player, character, error)); }
@@ -20,9 +20,10 @@ public final class DungeonCommands {
private final Plugin plugin;private final DungeonDirectory directory;private final DungeonController controller;private final games.dmg.fantasystick.adventure.AdventureQuestDirectory adventures;private final Consumer<Runnable> nextTick;private final games.dmg.fantasystick.adventure.AdventureLocationPresentation locations;private final Function<UUID,CompletableFuture<Optional<CharacterRecord>>> characters;private final Predicate<UUID> activeCharacter;private final Predicate<UUID> partyCharacter;private final CharacterSheets sheets=new CharacterSheets();private final AdventureAdminTeleport teleports;
public DungeonCommands(Plugin plugin,DungeonDirectory directory,DungeonController controller,games.dmg.fantasystick.adventure.AdventureQuestDirectory adventures,Consumer<Runnable> nextTick){this(plugin,directory,controller,adventures,nextTick,id->CompletableFuture.completedFuture(Optional.empty()),id->false,id->false);}
public DungeonCommands(Plugin plugin,DungeonDirectory directory,DungeonController controller,games.dmg.fantasystick.adventure.AdventureQuestDirectory adventures,Consumer<Runnable> nextTick,Function<UUID,CompletableFuture<Optional<CharacterRecord>>> characters,Predicate<UUID> activeCharacter,Predicate<UUID> partyCharacter){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.controller=Objects.requireNonNull(controller);this.adventures=Objects.requireNonNull(adventures);this.nextTick=Objects.requireNonNull(nextTick);this.characters=Objects.requireNonNull(characters);this.activeCharacter=Objects.requireNonNull(activeCharacter);this.partyCharacter=Objects.requireNonNull(partyCharacter);locations=new games.dmg.fantasystick.adventure.AdventureLocationPresentation(plugin.getServer());teleports=new AdventureAdminTeleport(plugin,nextTick);}
public void execute(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usage(sender);return;}try{switch(args[1]){case"create"->create(sender,args);case"list"->list(sender,args);case"remove"->remove(sender,args);case"edit"->edit(sender,args);case"entrance"->entrance(sender,args);case"room"->room(sender,args);case"boss"->bossCommand(sender,args);case"teleport"->teleport(sender,args);case"enable"->enable(sender,args);case"disable"->disable(sender,args);case"open"->open(sender,args);case"close"->close(sender,args);default->usage(sender);}}catch(IllegalArgumentException invalid){sender.sendMessage("Dungeon command refused: "+invalid.getMessage()+".");}}
private void create(CommandSender sender,String[] args){if(args.length<3||!(sender instanceof Player player)){usage(sender);return;}var location=player.getLocation();String name=join(args,2);if(name.isBlank()){usage(sender);return;}directory.create(name,Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Created disabled dungeon "+value.id()+" ("+value.name()+").":"Dungeon creation failed; desired state is unchanged.")));}
private void list(CommandSender sender,String[] args){if(args.length!=2){usage(sender);return;}if(directory.snapshot().dungeons().isEmpty()){sender.sendMessage("No dungeons registered.");return;}directory.snapshot().dungeons().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(value->sender.sendMessage(value.id()+" "+value.name()+" at "+locations.format(value.world(),value.entrance().x(),value.entrance().y(),value.entrance().z())+"; "+(value.enabled()?"enabled":"disabled")+"; rooms="+value.rooms().size()+"; maximum="+value.maximumXp()+" XP, "+value.maximumCoins()+" coins."));}
public void execute(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usage(sender);return;}try{switch(args[1]){case"create"->create(sender,args);case"list"->list(sender,args);case"mode"->mode(sender,args);case"remove"->remove(sender,args);case"edit"->edit(sender,args);case"entrance"->entrance(sender,args);case"room"->room(sender,args);case"boss"->bossCommand(sender,args);case"teleport"->teleport(sender,args);case"enable"->enable(sender,args);case"disable"->disable(sender,args);case"open"->open(sender,args);case"close"->close(sender,args);default->usage(sender);}}catch(IllegalArgumentException invalid){sender.sendMessage("Dungeon command refused: "+invalid.getMessage()+".");}}
private void create(CommandSender sender,String[] args){if(args.length<3||!(sender instanceof Player player)){usage(sender);return;}int nameStart=2;var mode=games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.PARTY_AVAILABLE;if(args[2].equals("--mode")){if(args.length<5){usage(sender);return;}try{mode=games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.parse(args[3]);}catch(IllegalArgumentException invalid){usage(sender);return;}nameStart=4;}else if(args[2].startsWith("--")){usage(sender);return;}String name=join(args,nameStart);if(name.isBlank()){usage(sender);return;}var location=player.getLocation();directory.create(name,Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch(),mode).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Created disabled dungeon "+value.id()+" ("+value.name()+"); mode="+value.participationMode().value()+".":"Dungeon creation failed; desired state is unchanged.")));}
private void list(CommandSender sender,String[] args){if(args.length!=2){usage(sender);return;}if(directory.snapshot().dungeons().isEmpty()){sender.sendMessage("No dungeons registered.");return;}directory.snapshot().dungeons().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(value->sender.sendMessage(value.id()+" "+value.name()+" at "+locations.format(value.world(),value.entrance().x(),value.entrance().y(),value.entrance().z())+"; "+(value.enabled()?"enabled":"disabled")+"; mode="+value.participationMode().value()+"; rooms="+value.rooms().size()+"; maximum="+value.maximumXp()+" XP, "+value.maximumCoins()+" coins."));}
private void mode(CommandSender sender,String[] args){if(args.length<3||args.length>4){usage(sender);return;}UUID id=id(args[2]);var current=id==null?null:directory.find(id).orElse(null);if(current==null){sender.sendMessage("Dungeon not found.");return;}if(args.length==3){sender.sendMessage("Dungeon "+id+" mode="+current.participationMode().value()+".");return;}games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode requested;try{requested=games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.parse(args[3]);}catch(IllegalArgumentException invalid){usage(sender);return;}directory.setMode(id,current.revision(),requested).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Updated dungeon "+id+" mode="+value.participationMode().value()+" for future acceptances.":"Dungeon mode update refused; the acknowledged definition is unchanged or stale.")));}
private void remove(CommandSender sender,String[] args){if(args.length!=4||!args[3].equals("confirm")){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}if(directory.custodyForDungeon(id).isPresent()){sender.sendMessage("Recover character boss custody before removing this dungeon.");return;}controller.closeOffer(id).thenCompose(ignored->adventures.removeDungeonSite(id)).thenCompose(ignored->directory.remove(id)).whenComplete((removed,failure)->respond(sender,()->sender.sendMessage(failure!=null?"Dungeon removal failed; desired state is retained.":removed.isPresent()?"Removed dungeon "+id+".":"Dungeon not found.")));}
private void edit(CommandSender sender,String[] args){if(!(sender instanceof Player player)){usage(sender);return;}if(args.length==3&&args[2].equals("done")){sender.sendMessage(controller.done(player.getUniqueId())?"Dungeon editing ended; the dungeon remains disabled.":"No dungeon is being edited.");return;}if(args.length<4||args.length>5||!args[2].equals("start")||args.length==5&&!args[4].equals("confirm")){usage(sender);return;}UUID id=id(args[3]);if(id==null){usage(sender);return;}controller.start(player,id,args.length==5).whenComplete((status,failure)->respond(sender,()->{if(failure!=null){plugin.getLogger().log(java.util.logging.Level.WARNING,"Dungeon edit transition failed",failure);sender.sendMessage("Dungeon edit transition failed; retained state is authoritative.");return;}sender.sendMessage(switch(status){case STARTED->"Editing dungeon "+id+"; it is disabled.";case CONFIRM_REQUIRED->"Dungeon has offered, accepted or progressed work; repeat with exact confirm to close and expire it.";case BUSY->"Dungeon editing refused: the editor or dungeon already has an editing lease.";case NOT_FOUND->"Dungeon not found.";});}));}
private void entrance(CommandSender sender,String[] args){if(args.length!=3||!args[2].equals("set")||!(sender instanceof Player player)){usage(sender);return;}UUID dungeon=editing(player);if(dungeon==null)return;var location=player.getLocation();if(location.getWorld()==null||!directory.find(dungeon).orElseThrow().world().equals(location.getWorld().getUID())){sender.sendMessage("Dungeon entrance must remain in its world.");return;}directory.setEntrance(dungeon,location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->saved(sender,failure,"Updated dungeon entrance."));}
@@ -34,7 +35,7 @@ public final class DungeonCommands {
private void effect(CommandSender sender,Player player,UUID dungeon,String[] args){var room=standing(sender,player);if(room==null)return;if(args.length>=7&&args[3].equals("add")){DungeonState.EffectTarget target;try{target=DungeonState.EffectTarget.valueOf(args[4].toUpperCase(Locale.ROOT));}catch(IllegalArgumentException invalid){usage(sender);return;}String key=args[5].contains(":")?args[5]:"minecraft:"+args[5];var namespaced=NamespacedKey.fromString(key);Integer level=integer(args[6]);boolean particles=args.length<8||Boolean.parseBoolean(args[7]);if(namespaced==null||PotionEffectType.getByKey(namespaced)==null||level==null||args.length>8||args.length==8&&!Set.of("true","false").contains(args[7])){usage(sender);return;}directory.addEffect(dungeon,room.id(),target,key,level,particles).whenComplete((value,failure)->saved(sender,failure,"Added room effect "+(value==null?"":value.id())+"."));return;}if(args.length==5&&args[3].equals("remove")){UUID id=id(args[4]);if(id==null){usage(sender);return;}directory.removeEffect(dungeon,room.id(),id).whenComplete((value,failure)->saved(sender,failure,value!=null&&value.isPresent()?"Removed room effect "+id+".":"Room effect not found."));return;}usage(sender);}
private void reward(CommandSender sender,Player player,UUID dungeon,String[] args){if(args.length!=6||!args[3].equals("set")){usage(sender);return;}var room=standing(sender,player);if(room==null)return;BigInteger xp=whole(args[4]),coins=whole(args[5]);if(xp==null||coins==null){usage(sender);return;}directory.setReward(dungeon,room.id(),xp,coins).whenComplete((value,failure)->saved(sender,failure,"Set room reward to "+xp+" XP and "+coins+" coins."));}
private void boss(CommandSender sender,Player player,UUID dungeon,String[] args){var room=standing(sender,player);if(room==null)return;if(args.length==5&&args[3].equals("assign-sheet")){assignCharacterBoss(sender,player,dungeon,room,args[4]);return;}if(args.length>=5&&args[3].equals("mob")){var type=entity(args[4]);if(!ordinary(type)){sender.sendMessage("Unsupported living boss type.");return;}var location=player.getLocation();directory.setBoss(dungeon,room.id(),type,join(args,5),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->saved(sender,failure,"Configured native boss "+type.name().toLowerCase(Locale.ROOT)+"."));return;}if(args.length==4&&args[3].equals("clear")){directory.clearBoss(dungeon,room.id()).whenComplete((value,failure)->saved(sender,failure,"Cleared the room boss."));return;}usage(sender);}
private void assignCharacterBoss(CommandSender sender,Player player,UUID dungeon,DungeonState.Room room,String rawMultiplier){BigDecimal multiplier;try{if(!rawMultiplier.matches("(?:0|[1-9][0-9]*)(?:\\.[0-9]{1,3})?"))throw new NumberFormatException();multiplier=new BigDecimal(rawMultiplier);}catch(NumberFormatException invalid){usage(sender);return;}var held=player.getInventory().getItemInMainHand();var character=sheets.identify(held).orElse(null);if(character==null||held.getAmount()!=1){sender.sendMessage("Hold exactly one authentic living character sheet in your main hand.");return;}if(physicalCopies(character)!=1){sender.sendMessage("Character boss assignment requires one unambiguous physical sheet.");return;}if(activeCharacter.test(character)){sender.sendMessage("That character has an active equipment session.");return;}if(partyCharacter.test(character)){sender.sendMessage("That character must leave its party before boss assignment.");return;}characters.apply(character).whenComplete((loaded,failure)->{if(failure!=null||loaded==null||loaded.isEmpty()||loaded.orElseThrow().dead()||loaded.orElseThrow().health().signum()<=0){respond(sender,()->sender.sendMessage("Character boss assignment refused: the saved living character could not be verified."));return;}var expected=loaded.orElseThrow();directory.assignCharacterBoss(dungeon,room.id(),expected,multiplier).whenComplete((custody,saveFailure)->{if(saveFailure!=null){respond(sender,()->sender.sendMessage("Character boss assignment failed before sheet custody changed."));return;}characters.apply(character).whenComplete((fresh,readFailure)->respond(sender,()->{if(readFailure!=null||fresh==null||fresh.isEmpty()||!fresh.orElseThrow().equals(expected)||activeCharacter.test(character)||partyCharacter.test(character)){directory.cancelAssignment(dungeon,custody.operation()).whenComplete((ignored,rollbackFailure)->respond(sender,()->sender.sendMessage(rollbackFailure==null?"Character boss assignment cancelled because the saved character changed.":"Character boss custody was saved but assignment rollback could not be confirmed; recover it before editing.")));return;}var current=player.getInventory().getItemInMainHand();if(!player.isOnline()||physicalCopies(character)!=1||current.getAmount()!=1||sheets.identify(current).filter(character::equals).isEmpty()){directory.cancelAssignment(dungeon,custody.operation()).whenComplete((ignored,rollbackFailure)->respond(sender,()->sender.sendMessage(rollbackFailure==null?"Character boss assignment cancelled because the held sheet changed.":"Character boss custody was saved but sheet consumption could not be confirmed; recover it before editing.")));return;}player.getInventory().setItemInMainHand(null);sender.sendMessage("Assigned character "+custody.character().name()+" as the dungeon boss at "+custody.multiplier().toPlainString()+"x stats; its sheet is now in durable custody.");}));});});}
private void assignCharacterBoss(CommandSender sender,Player player,UUID dungeon,DungeonState.Room room,String rawMultiplier){BigDecimal multiplier;try{if(!rawMultiplier.matches("(?:0|[1-9][0-9]*)(?:\\.[0-9]{1,3})?"))throw new NumberFormatException();multiplier=new BigDecimal(rawMultiplier);}catch(NumberFormatException invalid){usage(sender);return;}var held=player.getInventory().getItemInMainHand();var sheetIdentity=sheets.identity(held).orElse(null);var character=sheetIdentity==null?null:sheetIdentity.character();if(character==null||held.getAmount()!=1){sender.sendMessage("Hold exactly one authentic living character sheet in your main hand.");return;}if(physicalCopies(character)!=1){sender.sendMessage("Character boss assignment requires one unambiguous physical sheet.");return;}if(activeCharacter.test(character)){sender.sendMessage("That character has an active equipment session.");return;}if(partyCharacter.test(character)){sender.sendMessage("That character must leave its party before boss assignment.");return;}characters.apply(character).whenComplete((loaded,failure)->{if(failure!=null||loaded==null||loaded.isEmpty()||loaded.orElseThrow().dead()||loaded.orElseThrow().health().signum()<=0){respond(sender,()->sender.sendMessage("Character boss assignment refused: the saved living character could not be verified."));return;}var expected=loaded.orElseThrow();if(!sheetIdentity.generation().equals(expected.sheetGeneration())||(sheetIdentity.legacy()&&expected.sheetGeneration().signum()!=0)){respond(sender,()->sender.sendMessage("Character boss assignment refused: that sheet generation is no longer authorized."));return;}directory.assignCharacterBoss(dungeon,room.id(),expected,multiplier).whenComplete((custody,saveFailure)->{if(saveFailure!=null){respond(sender,()->sender.sendMessage("Character boss assignment failed before sheet custody changed."));return;}characters.apply(character).whenComplete((fresh,readFailure)->respond(sender,()->{if(readFailure!=null||fresh==null||fresh.isEmpty()||!fresh.orElseThrow().equals(expected)||activeCharacter.test(character)||partyCharacter.test(character)){directory.cancelAssignment(dungeon,custody.operation()).whenComplete((ignored,rollbackFailure)->respond(sender,()->sender.sendMessage(rollbackFailure==null?"Character boss assignment cancelled because the saved character changed.":"Character boss custody was saved but assignment rollback could not be confirmed; recover it before editing.")));return;}var current=player.getInventory().getItemInMainHand();if(!player.isOnline()||physicalCopies(character)!=1||current.getAmount()!=1||!sheets.authorizes(current,expected)){directory.cancelAssignment(dungeon,custody.operation()).whenComplete((ignored,rollbackFailure)->respond(sender,()->sender.sendMessage(rollbackFailure==null?"Character boss assignment cancelled because the held sheet changed.":"Character boss custody was saved but sheet consumption could not be confirmed; recover it before editing.")));return;}player.getInventory().setItemInMainHand(null);sender.sendMessage("Assigned character "+custody.character().name()+" as the dungeon boss at "+custody.multiplier().toPlainString()+"x stats; its sheet is now in durable custody.");}));});});}
private void bossCommand(CommandSender sender,String[] args){if(args.length!=4||!args[2].equals("recover-sheet")||!(sender instanceof Player player)){usage(sender);return;}UUID dungeon=id(args[3]);if(dungeon==null){usage(sender);return;}recoverCharacterBoss(sender,player,dungeon);}
private void recoverCharacterBoss(CommandSender sender,Player player,UUID dungeon){var definition=directory.find(dungeon).orElse(null);var custody=directory.custodyForDungeon(dungeon).orElse(null);if(definition==null||custody==null){sender.sendMessage("Dungeon character boss custody was not found.");return;}var catalog=adventures.snapshot().catalog();boolean offered=catalog!=null&&adventures.offerOpen(dungeon);boolean accepted=adventures.active().stream().anyMatch(quest->quest.site().equals(dungeon));if(definition.enabled()||offered||accepted||controller.beingEdited(dungeon)||controller.editing(player.getUniqueId()).isPresent()||controller.occupied(dungeon)){sender.sendMessage("Recovery requires a disabled, closed, unedited and unoccupied dungeon with no accepted quests.");return;}var physical=plugin.getServer().getOnlinePlayers().stream().flatMap(holder->Arrays.stream(holder.getInventory().getContents()).filter(Objects::nonNull).filter(item->sheets.identify(item).filter(custody.character().id()::equals).isPresent()).map(item->Map.entry(holder,item))).toList();if(physical.size()>1||physical.size()==1&&physical.getFirst().getKey()!=player){sender.sendMessage("Recovery refused because existing physical-sheet custody is ambiguous.");return;}boolean delivered=!physical.isEmpty()||custody.recovery()!=null&&Arrays.stream(player.getInventory().getContents()).anyMatch(item->sheets.hasRecovery(item,custody.recovery().operation()));if(!delivered&&player.getInventory().firstEmpty()<0){sender.sendMessage("One empty inventory slot is required before recovery.");return;}directory.prepareRecovery(dungeon,player.getUniqueId()).whenComplete((prepared,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Character boss recovery could not be prepared; custody is retained.");return;}var operation=prepared.recovery().operation();org.bukkit.inventory.ItemStack sheet=null;for(var item:player.getInventory().getContents())if(sheets.hasRecovery(item,operation)||sheets.identify(item).filter(prepared.character().id()::equals).isPresent()){sheet=item;break;}if(sheet==null){int slot=player.getInventory().firstEmpty();if(slot<0){sender.sendMessage("Character boss recovery is prepared, but one empty inventory slot is required.");return;}sheet=sheets.create(prepared.character());sheets.markRecovery(sheet,operation);player.getInventory().setItem(slot,sheet);}var item=sheet;directory.completeRecovery(dungeon,operation).whenComplete((completed,completeFailure)->respond(sender,()->{if(completeFailure!=null||!completed){sender.sendMessage("Recovery sheet delivery is retained, but durable custody completion must be retried.");return;}sheets.clearRecovery(item);sender.sendMessage("Recovered character sheet for "+prepared.character().name()+" from dungeon "+dungeon+"; configure another boss before enabling it.");}));}));}
private void teleport(CommandSender sender,String[] args){if(args.length!=3){usage(sender);return;}if(!(sender instanceof Player)){sender.sendMessage("Dungeon teleport requires a player.");return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}var dungeon=directory.find(id).orElse(null);if(dungeon==null){sender.sendMessage("Dungeon not found.");return;}teleports.teleport(sender,"Dungeon",target(dungeon),()->directory.find(id).map(DungeonCommands::target));}
@@ -43,7 +44,7 @@ public final class DungeonCommands {
private void disable(CommandSender sender,String[] args){if(args.length!=4||!args[3].equals("confirm")){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}controller.disable(id).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure!=null?"Dungeon disable failed; retained state is authoritative.":value?"Disabled and closed dungeon "+id+".":"Dungeon not found.")));}
private void open(CommandSender sender,String[] args){if(args.length!=3){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}controller.open(id).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Opened dungeon "+id+" in generation "+value.revision()+".":"Dungeon open refused: it is invalid, disabled, defeated or the offer limit was reached.")));}
private void close(CommandSender sender,String[] args){if(args.length!=4||!args[3].equals("confirm")){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}controller.closeOffer(id).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure!=null?"Dungeon close failed; retained state is authoritative.":value?"Closed dungeon "+id+" for this generation.":"Dungeon was not open.")));}
public List<String> complete(CommandSender sender,String[] args){if(!sender.hasPermission(PERMISSION))return List.of();if(args.length==1)return matching(List.of("dungeon"),args[0]);if(args.length==2&&args[0].equals("dungeon"))return matching(sender instanceof Player?List.of("boss","close","create","disable","edit","enable","entrance","list","open","remove","room","teleport"):List.of("close","disable","enable","list","open","remove"),args[1]);if(!args[0].equals("dungeon"))return List.of();if(args.length==3&&args[1].equals("boss"))return matching(List.of("recover-sheet"),args[2]);if(args.length==4&&args[1].equals("boss")&&args[2].equals("recover-sheet"))return matching(directory.snapshot().dungeons().stream().filter(value->recoverable(value.id())).map(value->value.id().toString()).toList(),args[3]);if(args.length==3&&Set.of("close","disable","enable","open","remove","teleport").contains(args[1]))return matching(directory.snapshot().dungeons().stream().map(v->v.id().toString()).toList(),args[2]);if(args.length==4&&Set.of("close","disable","remove").contains(args[1]))return matching(List.of("confirm"),args[3]);if(args.length==3&&args[1].equals("edit"))return matching(List.of("done","start"),args[2]);if(args.length==4&&args[1].equals("edit")&&args[2].equals("start"))return matching(directory.snapshot().dungeons().stream().map(v->v.id().toString()).toList(),args[3]);if(args.length==5&&args[1].equals("edit")&&args[2].equals("start"))return matching(List.of("confirm"),args[4]);if(args.length==3&&args[1].equals("entrance"))return matching(List.of("set"),args[2]);if(args.length==3&&args[1].equals("room"))return matching(List.of("boss","create","effect","list","mob","remove","reward"),args[2]);if(args.length==4&&args[1].equals("room")&&args[2].equals("create"))return matching(List.of("corner","radius"),args[3]);if(args.length>=5&&args.length<=7&&args[1].equals("room")&&args[2].equals("create"))return matching(List.of("1","3","8","128","256"),args[args.length-1]);if(args.length==4&&args[1].equals("room")&&args[2].equals("remove")&&sender instanceof Player player){var id=controller.editing(player.getUniqueId()).orElse(null);return id==null?List.of():matching(directory.find(id).map(DungeonState.Dungeon::rooms).orElse(List.of()).stream().map(v->v.id().toString()).toList(),args[3]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("mob"))return matching(List.of("add","list","remove"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("mob")&&args[3].equals("add"))return matching(entityNames(),args[4]);if(args.length==5&&args[1].equals("room")&&args[2].equals("mob")&&args[3].equals("remove")&&sender instanceof Player player){var room=controller.standingRoom(player).orElse(null);return room==null?List.of():matching(room.mobs().stream().map(v->v.id().toString()).toList(),args[4]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("effect"))return matching(List.of("add","remove"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("both","mobs","players"),args[4]);if(args.length==6&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(effectNames(),args[5]);if(args.length==7&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("1","2","64","256"),args[6]);if(args.length==8&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("false","true"),args[7]);if(args.length==5&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("remove")&&sender instanceof Player player){var room=controller.standingRoom(player).orElse(null);return room==null?List.of():matching(room.effects().stream().map(v->v.id().toString()).toList(),args[4]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("reward"))return matching(List.of("set"),args[3]);if((args.length==5||args.length==6)&&args[1].equals("room")&&args[2].equals("reward")&&args[3].equals("set"))return matching(List.of("0","10","100","2147483647"),args[args.length-1]);if(args.length==4&&args[1].equals("room")&&args[2].equals("boss"))return matching(List.of("assign-sheet","clear","mob"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("boss")&&args[3].equals("mob"))return matching(entityNames(),args[4]);if(args.length==5&&args[1].equals("room")&&args[2].equals("boss")&&args[3].equals("assign-sheet"))return matching(List.of("0.1","1","1.5","10","100"),args[4]);return List.of();}
public List<String> complete(CommandSender sender,String[] args){if(!sender.hasPermission(PERMISSION))return List.of();if(args.length==1)return matching(List.of("dungeon"),args[0]);if(args.length==2&&args[0].equals("dungeon"))return matching(sender instanceof Player?List.of("boss","close","create","disable","edit","enable","entrance","list","mode","open","remove","room","teleport"):List.of("close","disable","enable","list","mode","open","remove"),args[1]);if(!args[0].equals("dungeon"))return List.of();if(args.length==3&&args[1].equals("boss"))return matching(List.of("recover-sheet"),args[2]);if(args.length==4&&args[1].equals("boss")&&args[2].equals("recover-sheet"))return matching(directory.snapshot().dungeons().stream().filter(value->recoverable(value.id())).map(value->value.id().toString()).toList(),args[3]);if(args.length==3&&args[1].equals("create"))return matching(List.of("--mode"),args[2]);if(args.length==4&&args[1].equals("create")&&args[2].equals("--mode"))return matching(modeValues(),args[3]);if(args.length==3&&Set.of("close","disable","enable","mode","open","remove","teleport").contains(args[1]))return matching(directory.snapshot().dungeons().stream().map(v->v.id().toString()).toList(),args[2]);if(args.length==4&&args[1].equals("mode"))return matching(modeValues(),args[3]);if(args.length==4&&Set.of("close","disable","remove").contains(args[1]))return matching(List.of("confirm"),args[3]);if(args.length==3&&args[1].equals("edit"))return matching(List.of("done","start"),args[2]);if(args.length==4&&args[1].equals("edit")&&args[2].equals("start"))return matching(directory.snapshot().dungeons().stream().map(v->v.id().toString()).toList(),args[3]);if(args.length==5&&args[1].equals("edit")&&args[2].equals("start"))return matching(List.of("confirm"),args[4]);if(args.length==3&&args[1].equals("entrance"))return matching(List.of("set"),args[2]);if(args.length==3&&args[1].equals("room"))return matching(List.of("boss","create","effect","list","mob","remove","reward"),args[2]);if(args.length==4&&args[1].equals("room")&&args[2].equals("create"))return matching(List.of("corner","radius"),args[3]);if(args.length>=5&&args.length<=7&&args[1].equals("room")&&args[2].equals("create"))return matching(List.of("1","3","8","128","256"),args[args.length-1]);if(args.length==4&&args[1].equals("room")&&args[2].equals("remove")&&sender instanceof Player player){var id=controller.editing(player.getUniqueId()).orElse(null);return id==null?List.of():matching(directory.find(id).map(DungeonState.Dungeon::rooms).orElse(List.of()).stream().map(v->v.id().toString()).toList(),args[3]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("mob"))return matching(List.of("add","list","remove"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("mob")&&args[3].equals("add"))return matching(entityNames(),args[4]);if(args.length==5&&args[1].equals("room")&&args[2].equals("mob")&&args[3].equals("remove")&&sender instanceof Player player){var room=controller.standingRoom(player).orElse(null);return room==null?List.of():matching(room.mobs().stream().map(v->v.id().toString()).toList(),args[4]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("effect"))return matching(List.of("add","remove"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("both","mobs","players"),args[4]);if(args.length==6&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(effectNames(),args[5]);if(args.length==7&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("1","2","64","256"),args[6]);if(args.length==8&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("false","true"),args[7]);if(args.length==5&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("remove")&&sender instanceof Player player){var room=controller.standingRoom(player).orElse(null);return room==null?List.of():matching(room.effects().stream().map(v->v.id().toString()).toList(),args[4]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("reward"))return matching(List.of("set"),args[3]);if((args.length==5||args.length==6)&&args[1].equals("room")&&args[2].equals("reward")&&args[3].equals("set"))return matching(List.of("0","10","100","2147483647"),args[args.length-1]);if(args.length==4&&args[1].equals("room")&&args[2].equals("boss"))return matching(List.of("assign-sheet","clear","mob"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("boss")&&args[3].equals("mob"))return matching(entityNames(),args[4]);if(args.length==5&&args[1].equals("room")&&args[2].equals("boss")&&args[3].equals("assign-sheet"))return matching(List.of("0.1","1","1.5","10","100"),args[4]);return List.of();}
private long physicalCopies(UUID character){return plugin.getServer().getOnlinePlayers().stream().flatMap(holder->Arrays.stream(holder.getInventory().getContents())).filter(Objects::nonNull).filter(item->sheets.identify(item).filter(character::equals).isPresent()).mapToLong(org.bukkit.inventory.ItemStack::getAmount).sum();}
private boolean recoverable(UUID dungeon){var definition=directory.find(dungeon).orElse(null);var catalog=adventures.snapshot().catalog();return definition!=null&&!definition.enabled()&&directory.custodyForDungeon(dungeon).isPresent()&&!controller.beingEdited(dungeon)&&!controller.occupied(dungeon)&&(catalog==null||!adventures.offerOpen(dungeon))&&adventures.active().stream().noneMatch(quest->quest.site().equals(dungeon));}
private UUID editing(Player player){var result=controller.editing(player.getUniqueId()).orElse(null);if(result==null)player.sendMessage("Start editing one dungeon first.");return result;}
@@ -60,5 +61,6 @@ public final class DungeonCommands {
private static BigInteger whole(String value){try{return new BigInteger(value);}catch(NumberFormatException invalid){return null;}}
private static String join(String[] args,int from){return from>=args.length?"":String.join(" ",Arrays.copyOfRange(args,from,args.length));}
private static List<String> matching(Collection<String> values,String prefix){return values.stream().filter(value->value.startsWith(prefix)).distinct().sorted().toList();}
private static void usage(CommandSender sender){sender.sendMessage("Usage: /fantasystick dungeon <create|list|remove|edit|entrance|room|boss|enable|disable|open|close|teleport> ...");}
private static List<String> modeValues(){return Arrays.stream(games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.values()).map(games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode::value).toList();}
private static void usage(CommandSender sender){sender.sendMessage("Usage: /fstickadmin dungeon <create [--mode <mode>] <name...>|mode <dungeon-uuid> [<mode>]|list|remove|edit|entrance|room|boss|enable|disable|open|close|teleport> ...");}
}
@@ -23,7 +23,9 @@ public final class DungeonDirectory implements AutoCloseable,AdventureResetParti
public Optional<DungeonState.CharacterBoss> custodyForDungeon(UUID dungeon){return find(dungeon).stream().flatMap(value->value.rooms().stream()).map(DungeonState.Room::boss).filter(Objects::nonNull).map(DungeonState.Boss::character).filter(Objects::nonNull).findFirst();}
public Optional<DungeonState.Room> room(UUID dungeon,UUID room){return find(dungeon).flatMap(value->value.rooms().stream().filter(v->v.id().equals(room)).findFirst());}
public Optional<DungeonState.Room> roomAt(UUID dungeon,int x,int y,int z){return find(dungeon).flatMap(value->{var matches=value.rooms().stream().filter(room->room.bounds().contains(x,y,z)).toList();return matches.size()==1?Optional.of(matches.getFirst()):Optional.empty();});}
public CompletableFuture<DungeonState.Dungeon> create(String name,UUID world,double x,double y,double z,float yaw,float pitch){var value=new DungeonState.Dungeon(UUID.randomUUID(),name,world,new DungeonState.Position(x,y,z,yaw,pitch),false,UUID.randomUUID(),List.of());return mutate(state->{var values=new ArrayList<>(state.dungeons());values.add(value);return result(new DungeonState(values,state.runs()),value);});}
public CompletableFuture<DungeonState.Dungeon> create(String name,UUID world,double x,double y,double z,float yaw,float pitch){return create(name,world,x,y,z,yaw,pitch,AdventureQuestState.ParticipationMode.PARTY_AVAILABLE);}
public CompletableFuture<DungeonState.Dungeon> create(String name,UUID world,double x,double y,double z,float yaw,float pitch,AdventureQuestState.ParticipationMode mode){var value=new DungeonState.Dungeon(UUID.randomUUID(),name,world,new DungeonState.Position(x,y,z,yaw,pitch),false,UUID.randomUUID(),List.of(),mode);return mutate(state->{var values=new ArrayList<>(state.dungeons());values.add(value);return result(new DungeonState(values,state.runs()),value);});}
public CompletableFuture<DungeonState.Dungeon> setMode(UUID id,UUID expectedRevision,AdventureQuestState.ParticipationMode mode){Objects.requireNonNull(expectedRevision);Objects.requireNonNull(mode);return mutate(state->{var current=find(state,id).orElseThrow(()->new IllegalArgumentException("Dungeon not found"));if(!current.revision().equals(expectedRevision))throw new IllegalArgumentException("Dungeon changed");if(current.participationMode()==mode)return result(state,current);var changed=new DungeonState.Dungeon(current.id(),current.name(),current.world(),current.entrance(),current.enabled(),UUID.randomUUID(),current.rooms(),mode);return result(replace(state,changed),changed);});}
public CompletableFuture<Optional<DungeonState.Dungeon>> remove(UUID id){return mutate(state->{var found=find(state,id);if(found.isEmpty())return result(state,Optional.empty());if(found.orElseThrow().rooms().stream().map(DungeonState.Room::boss).filter(Objects::nonNull).anyMatch(boss->boss.character()!=null))throw new IllegalArgumentException("Recover character boss custody first");return result(new DungeonState(state.dungeons().stream().filter(v->!v.id().equals(id)).toList(),state.runs().stream().filter(v->!v.dungeon().equals(id)).toList()),found);});}
public CompletableFuture<DungeonState.Dungeon> setEntrance(UUID id,double x,double y,double z,float yaw,float pitch){return change(id,dungeon->definition(dungeon,dungeon.rooms(),new DungeonState.Position(x,y,z,yaw,pitch),false));}
public CompletableFuture<DungeonState.Room> addCornerRoom(UUID id,String name,int x,int y,int z,int xSize,int zSize,int height){if(xSize==0||Math.abs((long)xSize)>256||zSize==0||Math.abs((long)zSize)>256||height<1||height>128)return CompletableFuture.failedFuture(new IllegalArgumentException("Room dimensions out of range"));int maxX=x+(xSize>0?xSize-1:0),minX=x+(xSize<0?xSize+1:0),maxZ=z+(zSize>0?zSize-1:0),minZ=z+(zSize<0?zSize+1:0);return addRoom(id,name,new DungeonState.Bounds(Math.min(x,minX),y,Math.min(z,minZ),Math.max(x,maxX),y+height-1,Math.max(z,maxZ)));}
@@ -57,7 +59,7 @@ public final class DungeonDirectory implements AutoCloseable,AdventureResetParti
private static DungeonState.Dungeon editable(DungeonState state,UUID id){var value=find(state,id).orElseThrow(()->new IllegalArgumentException("Dungeon not found"));if(value.enabled())throw new IllegalArgumentException("Dungeon must be disabled");return value;}
private static Optional<DungeonState.Dungeon> find(DungeonState state,UUID id){return state.dungeons().stream().filter(v->v.id().equals(id)).findFirst();}
private static DungeonState replace(DungeonState state,DungeonState.Dungeon dungeon){var values=state.dungeons().stream().map(v->v.id().equals(dungeon.id())?dungeon:v).toList();return new DungeonState(values,state.runs());}
private static DungeonState.Dungeon definition(DungeonState.Dungeon original,List<DungeonState.Room> rooms,DungeonState.Position entrance,boolean enabled){return new DungeonState.Dungeon(original.id(),original.name(),original.world(),entrance,enabled,UUID.randomUUID(),rooms);}
private static DungeonState.Dungeon definition(DungeonState.Dungeon original,List<DungeonState.Room> rooms,DungeonState.Position entrance,boolean enabled){return new DungeonState.Dungeon(original.id(),original.name(),original.world(),entrance,enabled,UUID.randomUUID(),rooms,original.participationMode());}
public boolean eligibleForFutureOffer(UUID id){var dungeon=find(id).orElse(null);if(dungeon==null||!dungeon.enabled())return false;try{validate(dungeon);return true;}catch(IllegalArgumentException invalid){return false;}}
private void validate(DungeonState.Dungeon dungeon){if(overlapping(dungeon.rooms()))throw new IllegalArgumentException("Dungeon rooms overlap");if(dungeon.rooms().stream().map(DungeonState.Room::boss).filter(Objects::nonNull).map(DungeonState.Boss::character).filter(Objects::nonNull).anyMatch(value->value.recovery()!=null))throw new IllegalArgumentException("Character recovery is pending");if(dungeon.rooms().isEmpty()||!connected(dungeon.rooms(),linkDistance()))throw new IllegalArgumentException("Dungeon rooms are incomplete");if(dungeon.rooms().stream().filter(v->v.boss()!=null).count()!=1)throw new IllegalArgumentException("Dungeon needs exactly one boss");}
private int linkDistance(){int value=roomLinkDistance.getAsInt();if(value<1||value>256)throw new IllegalArgumentException("Invalid dungeon room link distance");return value;}
@@ -1,5 +1,6 @@
package games.dmg.fantasystick.dungeon;
import games.dmg.fantasystick.adventure.AdventureQuestState;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.progression.Stat;
import java.math.*;
@@ -45,8 +46,9 @@ public record DungeonState(List<Dungeon> dungeons,List<Run> runs) {
public Room {Objects.requireNonNull(id);name=validName(name);Objects.requireNonNull(bounds);mobs=List.copyOf(mobs);effects=List.copyOf(effects);Objects.requireNonNull(reward);unique(mobs.stream().map(Spawn::id).toList());unique(effects.stream().map(Effect::id).toList());if(mobs.size()>256||effects.size()>32)throw new IllegalArgumentException("Room limit exceeded");}
public String displayName(){return name.isBlank()?"Room":name;}
}
public record Dungeon(UUID id,String name,UUID world,Position entrance,boolean enabled,UUID revision,List<Room> rooms) {
public Dungeon {Objects.requireNonNull(id);name=validName(name);if(name.isBlank())throw new IllegalArgumentException("Dungeon name required");Objects.requireNonNull(world);Objects.requireNonNull(entrance);Objects.requireNonNull(revision);rooms=List.copyOf(rooms);unique(rooms.stream().map(Room::id).toList());if(rooms.size()>128)throw new IllegalArgumentException("Dungeon room limit exceeded");}
public record Dungeon(UUID id,String name,UUID world,Position entrance,boolean enabled,UUID revision,List<Room> rooms,AdventureQuestState.ParticipationMode participationMode) {
public Dungeon {Objects.requireNonNull(id);name=validName(name);if(name.isBlank())throw new IllegalArgumentException("Dungeon name required");Objects.requireNonNull(world);Objects.requireNonNull(entrance);Objects.requireNonNull(revision);rooms=List.copyOf(rooms);Objects.requireNonNull(participationMode);unique(rooms.stream().map(Room::id).toList());if(rooms.size()>128)throw new IllegalArgumentException("Dungeon room limit exceeded");}
public Dungeon(UUID id,String name,UUID world,Position entrance,boolean enabled,UUID revision,List<Room> rooms){this(id,name,world,entrance,enabled,revision,rooms,AdventureQuestState.ParticipationMode.PARTY_AVAILABLE);}
public BigInteger maximumXp(){return rooms.stream().map(v->v.reward().xp()).reduce(BigInteger.ZERO,BigInteger::add);}
public BigInteger maximumCoins(){return rooms.stream().map(v->v.reward().coins()).reduce(BigInteger.ZERO,BigInteger::add);}
}
@@ -1,6 +1,7 @@
package games.dmg.fantasystick.items;
import games.dmg.fantasystick.persistence.CharacterRecord;
import java.math.BigInteger;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@@ -17,6 +18,7 @@ public final class CharacterSheets {
public static final NamespacedKey MODEL = new NamespacedKey("fantasystick", "character_sheet");
private static final NamespacedKey VERSION = new NamespacedKey("fantasystick", "sheet_version");
private static final NamespacedKey CHARACTER = new NamespacedKey("fantasystick", "character_id");
private static final NamespacedKey GENERATION = new NamespacedKey("fantasystick", "sheet_generation");
private static final NamespacedKey RECOVERY = new NamespacedKey("fantasystick", "boss_recovery_operation");
private static final List<Component> INSTRUCTION = List.of(Component.text("Character sheet:"),
Component.text("Place in off hand"), Component.text("to enter fantasy world."));
@@ -28,6 +30,7 @@ public final class CharacterSheets {
var data = meta.getPersistentDataContainer();
data.set(VERSION, PersistentDataType.INTEGER, 1);
data.set(CHARACTER, PersistentDataType.STRING, character.id().toString());
data.set(GENERATION, PersistentDataType.STRING, character.sheetGeneration().toString());
meta.setItemModel(MODEL);
item.setItemMeta(meta);
return item;
@@ -65,6 +68,27 @@ public final class CharacterSheets {
return true;
}
public record Identity(UUID character, BigInteger generation, boolean legacy) { }
public Optional<Identity> identity(ItemStack item) {
var character = identify(item);
if (character.isEmpty()) return Optional.empty();
var data = item.getItemMeta().getPersistentDataContainer();
if (!data.has(GENERATION, PersistentDataType.STRING))
return Optional.of(new Identity(character.orElseThrow(), BigInteger.ZERO, true));
try {
var generation = new BigInteger(data.get(GENERATION, PersistentDataType.STRING));
if (generation.signum() < 0) return Optional.empty();
return Optional.of(new Identity(character.orElseThrow(), generation, false));
} catch (RuntimeException invalid) { return Optional.empty(); }
}
public boolean authorizes(ItemStack item, CharacterRecord record) {
return identity(item).filter(value -> value.character().equals(record.id())
&& value.generation().equals(record.sheetGeneration())
&& (!value.legacy() || record.sheetGeneration().signum() == 0)).isPresent();
}
/** A reference, not authorization to activate: the caller must load saved state and claim the session. */
public Optional<UUID> identify(ItemStack item) {
if (item == null || item.getType() != Material.PAPER || !item.hasItemMeta()) return Optional.empty();
@@ -20,6 +20,7 @@ public final class PartyRoster implements AutoCloseable {
public int maximumSize(){int value=maximumSize.getAsInt();if(value<1)throw new IllegalStateException("Party maximum must be positive");return value;}
public Optional<Party> create(UUID player){if(closed)return Optional.empty();if(durable!=null){if(!durable.ready())return Optional.empty();var character=character(player);if(character==null)return Optional.empty();var result=durable.create(character,"Party "+character.toString().substring(0,8)).join();return result.party().flatMap(this::project);}if(closed||!connected(player)||membership.containsKey(player))return Optional.empty();var party=new Party(UUID.randomUUID(),player,List.of(player));membership.put(player,party);return Optional.of(party);}
public Optional<Party> partyOf(UUID player){if(durable!=null){if(!durable.ready())return Optional.empty();var character=character(player);return character==null?Optional.empty():durable.partyOf(character).flatMap(this::project);}return Optional.ofNullable(membership.get(player));}
public Optional<Party> partyOfCharacter(UUID character){if(durable!=null)return durable.ready()?durable.partyOf(character).flatMap(this::project):Optional.empty();return Optional.ofNullable(membership.get(character));}
public Optional<RewardMembership> rewardMembership(UUID player){if(durable!=null){if(!durable.ready())return Optional.empty();var character=character(player);if(character==null)return Optional.empty();return durable.partyOf(character).map(party->new RewardMembership(party.id(),activePlayers(party)));}var party=membership.get(player);return party==null?Optional.empty():Optional.of(new RewardMembership(party.id(),party.members().stream().filter(this::connected).toList()));}
public List<Invitation> invitationsFor(UUID player){if(durable!=null){if(!durable.ready())return List.of();var character=character(player);if(character==null)return List.of();return durable.invitationsFor(character).stream().map(value->new Invitation(value.id(),value.party(),new UUID(0,0),player,Instant.MAX)).toList();}expire();return invitations.values().stream().filter(i->i.invitee().equals(player)).sorted(Comparator.comparing(Invitation::expires).thenComparing(Invitation::id)).toList();}
public Optional<Invitation> invite(UUID leader,UUID party,UUID invitee){if(closed)return Optional.empty();if(durable!=null){if(!durable.ready())return Optional.empty();var source=character(leader);var target=character(invitee);if(source==null||target==null)return Optional.empty();var result=durable.invite(source,party,target).join();return result.invitation().map(value->new Invitation(value.id(),value.party(),leader,invitee,Instant.MAX));}expire();var group=managedBy(leader,party);if(group==null||group.members().size()>=maximumSize()||!connected(invitee)||membership.containsKey(invitee))return Optional.empty();var existing=invitations.values().stream().filter(i->i.party().equals(party)&&i.invitee().equals(invitee)).findFirst();if(existing.isPresent())return existing;var invitation=new Invitation(UUID.randomUUID(),party,leader,invitee,clock.instant().plusSeconds(60));invitations.put(invitation.id(),invitation);return Optional.of(invitation);}
@@ -19,7 +19,9 @@ public record CharacterRecord(
Set<String> learned, List<String> loadout, List<String> passives, Map<String, Instant> cooldowns,
Map<UUID, CombatRewardRules.KillPosition> restrictedKills,
boolean dead, UUID deathId, Instant diedAt,
BigInteger coins, boolean coinsInitialized, Set<UUID> rewardReceipts, BigInteger pvpCoinEscrow,CharacterGear gear) {
BigInteger coins, boolean coinsInitialized, Set<UUID> rewardReceipts, BigInteger pvpCoinEscrow,
CharacterGear gear, BigInteger sheetGeneration) {
public CharacterRecord(UUID id,String name,BigInteger revision,Map<Stat,BigInteger> stats,int level,BigInteger experience,BigInteger unspentPoints,BigDecimal health,Set<String> learned,List<String> loadout,List<String> passives,Map<String,Instant> cooldowns,Map<UUID,CombatRewardRules.KillPosition> restrictedKills,boolean dead,UUID deathId,Instant diedAt,BigInteger coins,boolean coinsInitialized,Set<UUID> rewardReceipts,BigInteger pvpCoinEscrow,CharacterGear gear){this(id,name,revision,stats,level,experience,unspentPoints,health,learned,loadout,passives,cooldowns,restrictedKills,dead,deathId,diedAt,coins,coinsInitialized,rewardReceipts,pvpCoinEscrow,gear,BigInteger.ZERO);}
public CharacterRecord(UUID id,String name,BigInteger revision,Map<Stat,BigInteger> stats,int level,BigInteger experience,BigInteger unspentPoints,BigDecimal health,Set<String> learned,List<String> loadout,List<String> passives,Map<String,Instant> cooldowns,Map<UUID,CombatRewardRules.KillPosition> restrictedKills,boolean dead,UUID deathId,Instant diedAt,BigInteger coins,boolean coinsInitialized,Set<UUID> rewardReceipts,BigInteger pvpCoinEscrow){this(id,name,revision,stats,level,experience,unspentPoints,health,learned,loadout,passives,cooldowns,restrictedKills,dead,deathId,diedAt,coins,coinsInitialized,rewardReceipts,pvpCoinEscrow,CharacterGear.none());}
public CharacterRecord(UUID id, String name, BigInteger revision, Map<Stat, BigInteger> stats,
int level, BigInteger experience, BigInteger unspentPoints, BigDecimal health,
@@ -55,6 +57,16 @@ public record CharacterRecord(
cooldowns, restrictedKills, dead, deathId, diedAt, BigInteger.ZERO, true, Set.of());
}
public CharacterRecord(UUID id, String name, BigInteger revision, Map<Stat, BigInteger> stats,
int level, BigInteger experience, BigInteger unspentPoints, BigDecimal health,
Set<String> learned, List<String> loadout, List<String> passives, Map<String, Instant> cooldowns,
Map<UUID, CombatRewardRules.KillPosition> restrictedKills,
boolean dead, UUID deathId, Instant diedAt, BigInteger sheetGeneration) {
this(id, name, revision, stats, level, experience, unspentPoints, health, learned, loadout, passives,
cooldowns, restrictedKills, dead, deathId, diedAt, BigInteger.ZERO, true, Set.of(), BigInteger.ZERO,
CharacterGear.none(), sheetGeneration);
}
public CharacterRecord {
Objects.requireNonNull(id);
Objects.requireNonNull(name);
@@ -65,6 +77,7 @@ public record CharacterRecord(
Objects.requireNonNull(coins);
Objects.requireNonNull(pvpCoinEscrow);
Objects.requireNonNull(gear);
Objects.requireNonNull(sheetGeneration);
stats = Map.copyOf(stats);
learned = Set.copyOf(learned);
loadout = List.copyOf(loadout);
@@ -85,7 +98,8 @@ public record CharacterRecord(
|| cooldowns.keySet().stream().anyMatch(String::isBlank)
|| restrictedKills.size() > 1024
|| restrictedKills.entrySet().stream().anyMatch(entry -> !entry.getKey().equals(entry.getValue().world()))
|| coins.signum() < 0 || pvpCoinEscrow.signum() < 0 || !coinsInitialized && coins.signum() != 0
|| coins.signum() < 0 || pvpCoinEscrow.signum() < 0 || sheetGeneration.signum() < 0
|| !coinsInitialized && coins.signum() != 0
|| rewardReceipts.size() > 4096 || !dead && pvpCoinEscrow.signum() != 0
|| (deathId == null) != (diedAt == null) || dead && deathId == null) {
throw new IllegalArgumentException("Invalid character record");
@@ -0,0 +1,167 @@
package games.dmg.fantasystick.persistence;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.math.BigInteger;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
/** Durable fail-closed authority for one explicitly confirmed saved-sheet recovery. */
final class CharacterRecoveryStore {
private static final int MAX_FILE_BYTES = 16_384;
enum Phase { PREPARED, COMMITTED, DELIVERED }
record Operation(UUID operation, UUID character, UUID administrator, UUID recipient,
BigInteger expectedRevision, BigInteger expectedGeneration,
BigInteger replacementGeneration, boolean revive, UUID death, Phase phase) {
Operation {
Objects.requireNonNull(operation); Objects.requireNonNull(character);
Objects.requireNonNull(administrator); Objects.requireNonNull(recipient);
Objects.requireNonNull(expectedRevision); Objects.requireNonNull(expectedGeneration);
Objects.requireNonNull(replacementGeneration); Objects.requireNonNull(phase);
if (expectedRevision.signum() < 0 || expectedGeneration.signum() < 0
|| !replacementGeneration.equals(expectedGeneration.add(BigInteger.ONE))
|| revive != (death != null)) throw new IllegalArgumentException("Invalid recovery operation");
}
Operation phase(Phase value) {
return new Operation(operation, character, administrator, recipient, expectedRevision,
expectedGeneration, replacementGeneration, revive, death, value);
}
}
private final Path directory;
CharacterRecoveryStore(Path pluginDataDirectory) {
directory = Objects.requireNonNull(pluginDataDirectory).resolve("character-recoveries").toAbsolutePath();
}
Optional<Operation> find(UUID character) throws IOException {
if (Files.notExists(directory, LinkOption.NOFOLLOW_LINKS)) return Optional.empty();
safeDirectory();
Path path = file(character);
if (Files.notExists(path, LinkOption.NOFOLLOW_LINKS)) return Optional.empty();
if (Files.isSymbolicLink(path) || !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)
|| Files.size(path) > MAX_FILE_BYTES) throw new IOException("Recovery record is unsafe");
return Optional.of(decode(Files.readString(path, StandardCharsets.UTF_8)));
}
/** Mutation predicate: corrupt or unreadable proof is unresolved and therefore blocks. */
boolean blocks(UUID character) {
try { return find(character).map(operation -> operation.phase() != Phase.DELIVERED).orElse(false); }
catch (IOException unsafe) { return true; }
}
void prepare(Operation operation) throws IOException {
withLock(() -> {
var existing = find(operation.character());
if (existing.isPresent() && existing.orElseThrow().phase() != Phase.DELIVERED)
throw new IOException("Character recovery is unresolved");
write(operation);
});
}
void advance(UUID character, UUID operation, Phase expected, Phase replacement) throws IOException {
withLock(() -> {
var current = find(character).orElseThrow(() -> new IOException("Recovery operation missing"));
if (!current.operation().equals(operation) || current.phase() != expected)
throw new IOException("Recovery operation changed");
write(current.phase(replacement));
});
}
void cancelPrepared(UUID character, UUID operation) throws IOException {
withLock(() -> {
var current = find(character);
if (current.isEmpty()) return;
if (!current.orElseThrow().operation().equals(operation)
|| current.orElseThrow().phase() != Phase.PREPARED)
throw new IOException("Recovery operation cannot be cancelled");
Files.delete(file(character));
DurableFiles.forceDirectory(directory);
});
}
private void write(Operation operation) throws IOException {
safeDirectory();
byte[] bytes = encode(operation).getBytes(StandardCharsets.UTF_8);
Path temporary = Files.createTempFile(directory, ".recovery-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE)) {
var buffer = ByteBuffer.wrap(bytes);
while (buffer.hasRemaining()) output.write(buffer);
output.force(true);
}
Files.move(temporary, file(operation.character()), StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING);
DurableFiles.forceDirectory(directory);
} finally { Files.deleteIfExists(temporary); }
}
private void withLock(IoAction action) throws IOException {
safeDirectory();
Path lockPath = directory.resolve(".recoveries.lock");
try (var channel = FileChannel.open(lockPath, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Recovery store busy");
action.run();
} catch (OverlappingFileLockException failure) { throw new IOException("Recovery store busy", failure); }
}
private void safeDirectory() throws IOException {
if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) {
if (Files.isSymbolicLink(directory) || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS))
throw new IOException("Recovery directory is unsafe");
} else Files.createDirectories(directory);
}
private Path file(UUID character) { return directory.resolve(Objects.requireNonNull(character) + ".yml"); }
private static String encode(Operation value) {
return "schema-version: 1\n"
+ "operation: " + value.operation() + "\n"
+ "character: " + value.character() + "\n"
+ "administrator: " + value.administrator() + "\n"
+ "recipient: " + value.recipient() + "\n"
+ "expected-revision: " + value.expectedRevision() + "\n"
+ "expected-generation: " + value.expectedGeneration() + "\n"
+ "replacement-generation: " + value.replacementGeneration() + "\n"
+ "revive: " + value.revive() + "\n"
+ "death: " + (value.death() == null ? "" : value.death()) + "\n"
+ "phase: " + value.phase() + "\n";
}
private static Operation decode(String text) throws IOException {
var values = new LinkedHashMap<String, String>();
for (String line : text.split("\\n", -1)) {
if (line.isEmpty()) continue;
int split = line.indexOf(": ");
if (split < 1 || values.putIfAbsent(line.substring(0, split), line.substring(split + 2)) != null)
throw new IOException("Invalid recovery record");
}
try {
if (!values.keySet().equals(java.util.Set.of("schema-version", "operation", "character",
"administrator", "recipient", "expected-revision", "expected-generation",
"replacement-generation", "revive", "death", "phase"))
|| !values.get("schema-version").equals("1")) throw new IllegalArgumentException();
boolean revive = switch (values.get("revive")) { case "true" -> true; case "false" -> false;
default -> throw new IllegalArgumentException(); };
String death = values.get("death");
return new Operation(UUID.fromString(values.get("operation")), UUID.fromString(values.get("character")),
UUID.fromString(values.get("administrator")), UUID.fromString(values.get("recipient")),
new BigInteger(values.get("expected-revision")), new BigInteger(values.get("expected-generation")),
new BigInteger(values.get("replacement-generation")), revive,
death.isEmpty() ? null : UUID.fromString(death), Phase.valueOf(values.get("phase")));
} catch (RuntimeException invalid) { throw new IOException("Invalid recovery record"); }
}
@FunctionalInterface private interface IoAction { void run() throws IOException; }
}
@@ -26,6 +26,7 @@ public final class CharacterStorage implements AutoCloseable {
private final Clock clock;
private final java.util.function.Supplier<games.dmg.fantasystick.progression.CoinRules> coinRules;
private final java.util.random.RandomGenerator coinRandom;
private final CharacterRecoveryStore recoveries;
private final ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-character-persistence"));
@@ -41,6 +42,8 @@ public final class CharacterStorage implements AutoCloseable {
this.clock = Objects.requireNonNull(clock);
this.coinRules = Objects.requireNonNull(coinRules);
this.coinRandom = Objects.requireNonNull(coinRandom);
this.recoveries = new CharacterRecoveryStore(repository.pluginDataDirectory());
repository.attachMutationUnavailable(recoveries::blocks);
}
public void attachUnavailableCharacter(java.util.function.Predicate<UUID> predicate){repository.attachUnavailable(predicate);}
@@ -205,35 +208,174 @@ public final class CharacterStorage implements AutoCloseable {
return new CraftCleanup(removed, retained);
}
public record Catalog(List<CharacterRecord> records, int unreadable) {
public Catalog { records = List.copyOf(records); }
public boolean complete() { return unreadable == 0; }
}
public CompletableFuture<Catalog> catalog() {
return submit(() -> {
var catalog = repository.catalog();
return new Catalog(catalog.records(), catalog.unreadable());
});
}
/** Pure authoritative read for administration; unlike gameplay loading, this never performs legacy migration. */
public CompletableFuture<Optional<CharacterRecord>> inspect(UUID id) {
return submit(() -> repository.load(Objects.requireNonNull(id)));
}
public CompletableFuture<Optional<CharacterRecord>> find(UUID id) {
return submit(() -> load(id));
}
public enum RecoveryStatus { APPLIED, UNAVAILABLE, STALE, BLOCKED }
public record RecoveryRequest(UUID operation, UUID character, UUID administrator, UUID recipient,
BigInteger expectedRevision, BigInteger expectedGeneration,
boolean revive, UUID death, java.math.BigDecimal nativeLimit) {
public RecoveryRequest {
Objects.requireNonNull(operation); Objects.requireNonNull(character);
Objects.requireNonNull(administrator); Objects.requireNonNull(recipient);
Objects.requireNonNull(expectedRevision); Objects.requireNonNull(expectedGeneration);
Objects.requireNonNull(nativeLimit);
if (expectedRevision.signum() < 0 || expectedGeneration.signum() < 0
|| nativeLimit.signum() <= 0 || revive != (death != null))
throw new IllegalArgumentException("Invalid character recovery request");
}
}
public record RecoveryCommit(RecoveryStatus status, Optional<CharacterRecord> record, UUID operation) { }
/** Durable generation/revival commit; native delivery happens only after APPLIED acknowledgment. */
public CompletableFuture<RecoveryCommit> beginRecovery(RecoveryRequest request) {
Objects.requireNonNull(request);
return submit(() -> {
var found = repository.load(request.character());
if (found.isEmpty()) return new RecoveryCommit(RecoveryStatus.UNAVAILABLE, Optional.empty(), request.operation());
var old = found.orElseThrow();
reconcileRecovery(old);
if (!old.revision().equals(request.expectedRevision())
|| !old.sheetGeneration().equals(request.expectedGeneration()))
return new RecoveryCommit(RecoveryStatus.STALE, Optional.of(old), request.operation());
if (request.revive()) {
if (!old.dead() || !Objects.equals(old.deathId(), request.death()))
return new RecoveryCommit(RecoveryStatus.STALE, Optional.of(old), request.operation());
} else if (old.dead()) return new RecoveryCommit(RecoveryStatus.STALE, Optional.of(old), request.operation());
var existing = recoveries.find(old.id());
if (existing.isPresent() && existing.orElseThrow().phase() != CharacterRecoveryStore.Phase.DELIVERED)
return new RecoveryCommit(RecoveryStatus.BLOCKED, Optional.of(old), request.operation());
BigInteger nextGeneration = old.sheetGeneration().add(BigInteger.ONE);
var operation = new CharacterRecoveryStore.Operation(request.operation(), old.id(),
request.administrator(), request.recipient(), old.revision(), old.sheetGeneration(),
nextGeneration, request.revive(), request.death(), CharacterRecoveryStore.Phase.PREPARED);
recoveries.prepare(operation);
var health = request.revive() ? old.maximumHealth().min(request.nativeLimit()) : old.health();
var replacement = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
old.level(), old.experience(), old.unspentPoints(), health, old.learned(), old.loadout(), old.passives(),
old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(),
old.coinsInitialized(), old.rewardReceipts(), request.revive() ? BigInteger.ZERO : old.pvpCoinEscrow(),
old.gear(), nextGeneration);
try { repository.replaceForRecovery(replacement, old.revision()); }
catch (IOException failure) {
try { recoveries.cancelPrepared(old.id(), request.operation()); }
catch (IOException retained) { failure.addSuppressed(retained); }
throw failure;
}
recoveries.advance(old.id(), request.operation(), CharacterRecoveryStore.Phase.PREPARED,
CharacterRecoveryStore.Phase.COMMITTED);
return new RecoveryCommit(RecoveryStatus.APPLIED, Optional.of(replacement), request.operation());
});
}
public CompletableFuture<Boolean> completeRecovery(UUID character, UUID operation) {
Objects.requireNonNull(character); Objects.requireNonNull(operation);
return submit(() -> {
var record = repository.load(character).orElseThrow(() -> new IOException("Recovered character missing"));
var proof = recoveries.find(character).orElseThrow(() -> new IOException("Recovery operation missing"));
if (!proof.operation().equals(operation) || proof.phase() != CharacterRecoveryStore.Phase.COMMITTED
|| !replacementMatches(record, proof))
throw new IOException("Recovery completion proof conflicts with saved character");
recoveries.advance(character, operation, CharacterRecoveryStore.Phase.COMMITTED,
CharacterRecoveryStore.Phase.DELIVERED);
return true;
});
}
/** Persist a legacy character's one-time configured migration roll before exposing it. */
private Optional<CharacterRecord> load(UUID id) throws IOException {
var found = repository.load(id);
if (found.isEmpty() || found.orElseThrow().coinsInitialized()) return found;
if (found.isEmpty()) {
if (recoveries.find(id).filter(operation -> operation.phase() != CharacterRecoveryStore.Phase.DELIVERED).isPresent())
throw new IOException("Recovery proof references a missing character");
return found;
}
reconcileRecovery(found.orElseThrow());
if (found.orElseThrow().coinsInitialized()) return found;
var old = found.orElseThrow();
var migrated = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
old.level(), old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(),
old.cooldowns(), old.restrictedKills(), old.dead(), old.deathId(), old.diedAt(),
coinRules.get().starting(coinRandom), true, old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
coinRules.get().starting(coinRandom), true, old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
repository.replace(migrated, old.revision());
return Optional.of(migrated);
}
private void reconcileRecovery(CharacterRecord record) throws IOException {
var found = recoveries.find(record.id());
if (found.isEmpty()) return;
var operation = found.orElseThrow();
if (operation.phase() == CharacterRecoveryStore.Phase.PREPARED) {
if (expectedMatches(record, operation)) {
recoveries.cancelPrepared(record.id(), operation.operation());
return;
}
if (replacementMatches(record, operation)) {
recoveries.advance(record.id(), operation.operation(), CharacterRecoveryStore.Phase.PREPARED,
CharacterRecoveryStore.Phase.COMMITTED);
throw new IOException("Recovery committed before acknowledgment; custody is unresolved");
}
throw new IOException("Prepared recovery proof conflicts with saved character");
}
if (operation.phase() == CharacterRecoveryStore.Phase.COMMITTED) {
if (!replacementMatches(record, operation))
throw new IOException("Committed recovery proof conflicts with saved character");
throw new IOException("Character has unresolved committed recovery custody");
}
if (!record.sheetGeneration().equals(operation.replacementGeneration())
|| record.revision().compareTo(operation.expectedRevision().add(BigInteger.ONE)) < 0)
throw new IOException("Delivered recovery proof conflicts with saved character");
}
private static boolean expectedMatches(CharacterRecord record, CharacterRecoveryStore.Operation operation) {
return record.revision().equals(operation.expectedRevision())
&& record.sheetGeneration().equals(operation.expectedGeneration())
&& (operation.revive() ? record.dead() && Objects.equals(record.deathId(), operation.death()) : !record.dead());
}
private static boolean replacementMatches(CharacterRecord record, CharacterRecoveryStore.Operation operation) {
return record.revision().equals(operation.expectedRevision().add(BigInteger.ONE))
&& record.sheetGeneration().equals(operation.replacementGeneration())
&& !record.dead() && (!operation.revive() || Objects.equals(record.deathId(), operation.death()));
}
/** Persist an accepted native rename against the latest live record; never create a character from item metadata. */
public CompletableFuture<Optional<CharacterRecord>> renameCharacter(UUID characterId, String name) {
return renameCharacter(characterId, name, null);
}
public CompletableFuture<Optional<CharacterRecord>> renameCharacter(
UUID characterId, String name, BigInteger expectedSheetGeneration) {
Objects.requireNonNull(characterId);
Objects.requireNonNull(name);
return submit(() -> {
var found = repository.load(characterId);
if (found.isEmpty() || found.orElseThrow().dead()) return Optional.empty();
var old = found.orElseThrow();
if (expectedSheetGeneration != null && !old.sheetGeneration().equals(expectedSheetGeneration))
return Optional.empty();
if (old.name().equals(name)) return Optional.of(old);
var renamed = new CharacterRecord(old.id(), name, old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(), old.cooldowns(),
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
repository.replace(renamed, old.revision());
return Optional.of(renamed);
});
@@ -273,7 +415,7 @@ public final class CharacterStorage implements AutoCloseable {
int gained = Math.max(0, level - old.level());
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
level, experience, old.unspentPoints().add(BigInteger.valueOf(gained)), old.health(), old.learned(),
old.loadout(), old.passives(), old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
old.loadout(), old.passives(), old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
repository.replace(saved, old.revision());
return new ExperienceAward(ExperienceAwardStatus.APPLIED, Optional.of(saved), gained);
});
@@ -309,7 +451,7 @@ public final class CharacterStorage implements AutoCloseable {
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
old.level(), old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(),
old.passives(), old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(),
old.coins().subtract(amount), true, receipts,old.pvpCoinEscrow(),old.gear());
old.coins().subtract(amount), true, receipts,old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
repository.replace(saved, old.revision());
return new CoinSpend(CoinSpendStatus.APPLIED, Optional.of(saved));
});
@@ -318,12 +460,12 @@ public final class CharacterStorage implements AutoCloseable {
public enum CoinRefundStatus { APPLIED, DUPLICATE, UNAVAILABLE }
public record CoinRefund(CoinRefundStatus status,Optional<CharacterRecord> record){}
/** Compensate a persisted debit exactly once while retaining both audit receipts. */
public CompletableFuture<CoinRefund> refundCoins(UUID characterId,UUID debitId,UUID refundId,BigInteger amount){Objects.requireNonNull(characterId);Objects.requireNonNull(debitId);Objects.requireNonNull(refundId);Objects.requireNonNull(amount);if(amount.signum()<0)throw new IllegalArgumentException("Coin refund must not be negative");return submit(()->{var found=repository.load(characterId);if(found.isEmpty())return new CoinRefund(CoinRefundStatus.UNAVAILABLE,Optional.empty());var old=found.orElseThrow();if(old.rewardReceipts().contains(refundId))return new CoinRefund(CoinRefundStatus.DUPLICATE,Optional.of(old));if(!old.rewardReceipts().contains(debitId))return new CoinRefund(CoinRefundStatus.UNAVAILABLE,Optional.of(old));var receipts=new java.util.HashSet<>(old.rewardReceipts());if(receipts.size()>=4096)receipts.remove(receipts.stream().filter(id->!id.equals(debitId)).min(UUID::compareTo).orElseThrow());receipts.add(refundId);var saved=new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints(),old.health(),old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),old.dead(),old.deathId(),old.diedAt(),old.coins().add(amount),old.coinsInitialized(),receipts,old.pvpCoinEscrow(),old.gear());repository.replace(saved,old.revision());return new CoinRefund(CoinRefundStatus.APPLIED,Optional.of(saved));});}
public CompletableFuture<CoinRefund> refundCoins(UUID characterId,UUID debitId,UUID refundId,BigInteger amount){Objects.requireNonNull(characterId);Objects.requireNonNull(debitId);Objects.requireNonNull(refundId);Objects.requireNonNull(amount);if(amount.signum()<0)throw new IllegalArgumentException("Coin refund must not be negative");return submit(()->{var found=repository.load(characterId);if(found.isEmpty())return new CoinRefund(CoinRefundStatus.UNAVAILABLE,Optional.empty());var old=found.orElseThrow();if(old.rewardReceipts().contains(refundId))return new CoinRefund(CoinRefundStatus.DUPLICATE,Optional.of(old));if(!old.rewardReceipts().contains(debitId))return new CoinRefund(CoinRefundStatus.UNAVAILABLE,Optional.of(old));var receipts=new java.util.HashSet<>(old.rewardReceipts());if(receipts.size()>=4096)receipts.remove(receipts.stream().filter(id->!id.equals(debitId)).min(UUID::compareTo).orElseThrow());receipts.add(refundId);var saved=new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints(),old.health(),old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),old.dead(),old.deathId(),old.diedAt(),old.coins().add(amount),old.coinsInitialized(),receipts,old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());repository.replace(saved,old.revision());return new CoinRefund(CoinRefundStatus.APPLIED,Optional.of(saved));});}
public enum GearPurchaseStatus { APPLIED,DUPLICATE,INSUFFICIENT,NOT_UPGRADE,STALE,UNAVAILABLE }
public record GearPurchase(GearPurchaseStatus status,Optional<CharacterRecord> record){}
/** One character-file transaction owns both the exact coin debit and strict gear replacement. */
public CompletableFuture<GearPurchase> purchaseGear(UUID characterId,BigInteger expectedRevision,UUID operationId,GearSlot slot,GearTier target,BigInteger price){Objects.requireNonNull(characterId);Objects.requireNonNull(expectedRevision);Objects.requireNonNull(operationId);Objects.requireNonNull(slot);Objects.requireNonNull(target);Objects.requireNonNull(price);if(target==GearTier.NONE||price.signum()<0)throw new IllegalArgumentException("Invalid gear purchase");return submit(()->{var found=repository.load(characterId);if(found.isEmpty()||found.orElseThrow().dead())return new GearPurchase(GearPurchaseStatus.UNAVAILABLE,Optional.empty());var old=found.orElseThrow();if(old.rewardReceipts().contains(operationId))return new GearPurchase(GearPurchaseStatus.DUPLICATE,Optional.of(old));if(!old.revision().equals(expectedRevision))return new GearPurchase(GearPurchaseStatus.STALE,Optional.of(old));if(!target.higherThan(old.gear().tier(slot)))return new GearPurchase(GearPurchaseStatus.NOT_UPGRADE,Optional.of(old));if(old.coins().compareTo(price)<0)return new GearPurchase(GearPurchaseStatus.INSUFFICIENT,Optional.of(old));var receipts=new java.util.HashSet<>(old.rewardReceipts());if(receipts.size()>=4096)receipts.remove(receipts.stream().min(UUID::compareTo).orElseThrow());receipts.add(operationId);var saved=new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints(),old.health(),old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),false,old.deathId(),old.diedAt(),old.coins().subtract(price),true,receipts,old.pvpCoinEscrow(),old.gear().with(slot,target));repository.replace(saved,old.revision());return new GearPurchase(GearPurchaseStatus.APPLIED,Optional.of(saved));});}
public CompletableFuture<GearPurchase> purchaseGear(UUID characterId,BigInteger expectedRevision,UUID operationId,GearSlot slot,GearTier target,BigInteger price){Objects.requireNonNull(characterId);Objects.requireNonNull(expectedRevision);Objects.requireNonNull(operationId);Objects.requireNonNull(slot);Objects.requireNonNull(target);Objects.requireNonNull(price);if(target==GearTier.NONE||price.signum()<0)throw new IllegalArgumentException("Invalid gear purchase");return submit(()->{var found=repository.load(characterId);if(found.isEmpty()||found.orElseThrow().dead())return new GearPurchase(GearPurchaseStatus.UNAVAILABLE,Optional.empty());var old=found.orElseThrow();if(old.rewardReceipts().contains(operationId))return new GearPurchase(GearPurchaseStatus.DUPLICATE,Optional.of(old));if(!old.revision().equals(expectedRevision))return new GearPurchase(GearPurchaseStatus.STALE,Optional.of(old));if(!target.higherThan(old.gear().tier(slot)))return new GearPurchase(GearPurchaseStatus.NOT_UPGRADE,Optional.of(old));if(old.coins().compareTo(price)<0)return new GearPurchase(GearPurchaseStatus.INSUFFICIENT,Optional.of(old));var receipts=new java.util.HashSet<>(old.rewardReceipts());if(receipts.size()>=4096)receipts.remove(receipts.stream().min(UUID::compareTo).orElseThrow());receipts.add(operationId);var saved=new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints(),old.health(),old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),false,old.deathId(),old.diedAt(),old.coins().subtract(price),true,receipts,old.pvpCoinEscrow(),old.gear().with(slot,target),old.sheetGeneration());repository.replace(saved,old.revision());return new GearPurchase(GearPurchaseStatus.APPLIED,Optional.of(saved));});}
public enum CombatAwardStatus { APPLIED, SPACING_BLOCKED, AT_CAP, UNCHANGED, DUPLICATE, STALE, UNAVAILABLE }
public record CombatAward(CombatAwardStatus status, Optional<CharacterRecord> record, int levels) { }
@@ -378,7 +520,7 @@ public final class CharacterStorage implements AutoCloseable {
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
level, experience, old.unspentPoints().add(BigInteger.valueOf(gained)), old.health(), old.learned(),
old.loadout(), old.passives(), old.cooldowns(), history, false, old.deathId(), old.diedAt(),
old.coins().add(eligible ? coinReward : BigInteger.ZERO), true, receipts,old.pvpCoinEscrow(),old.gear());
old.coins().add(eligible ? coinReward : BigInteger.ZERO), true, receipts,old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
repository.replace(saved, old.revision());
return new CombatAward(status, Optional.of(saved), gained);
});
@@ -418,7 +560,7 @@ public final class CharacterStorage implements AutoCloseable {
var passives = new java.util.ArrayList<>(old.passives()); passives.set(slot, passive);
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints().subtract(cost), old.health(), old.learned(), old.loadout(), passives,
old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
repository.replace(saved, old.revision());
return new PassiveMutation(PassiveMutationStatus.APPLIED, Optional.of(saved));
});
@@ -446,7 +588,7 @@ public final class CharacterStorage implements AutoCloseable {
var passives = old.passives().stream().map(id -> id.equals(passive) ? "" : id).toList();
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints().add(BigInteger.ONE), old.health(), old.learned(), old.loadout(), passives,
old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
repository.replace(saved, old.revision());
return new PassiveMutation(PassiveMutationStatus.APPLIED, Optional.of(saved));
});
@@ -481,7 +623,7 @@ public final class CharacterStorage implements AutoCloseable {
var loadout = new java.util.ArrayList<>(old.loadout()); loadout.set(slot, ability);
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints().subtract(cost), old.health(), learned, loadout, old.passives(), old.cooldowns(),
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
repository.replace(saved, old.revision());
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
});
@@ -501,7 +643,7 @@ public final class CharacterStorage implements AutoCloseable {
var loadout = old.loadout().stream().map(assigned -> assigned.equals(ability) ? "" : assigned).toList();
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints().add(BigInteger.ONE), old.health(), learned, loadout, old.passives(), old.cooldowns(),
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
repository.replace(saved, old.revision());
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
});
@@ -524,7 +666,7 @@ public final class CharacterStorage implements AutoCloseable {
if (loadout.equals(old.loadout())) return new AbilityMutation(AbilityMutationStatus.UNCHANGED, Optional.of(old));
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints(), old.health(), old.learned(), loadout, old.passives(), old.cooldowns(),
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
repository.replace(saved, old.revision());
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
});
@@ -548,7 +690,7 @@ public final class CharacterStorage implements AutoCloseable {
var cooldowns = new java.util.HashMap<>(old.cooldowns()); cooldowns.put(ability, readyAt);
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(), cooldowns,
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
repository.replace(saved, old.revision());
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
});
@@ -593,7 +735,7 @@ public final class CharacterStorage implements AutoCloseable {
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
old.level(), old.experience(), old.unspentPoints().subtract(BigInteger.ONE),
currentHealth.add(gain), old.learned(), old.loadout(), old.passives(), old.cooldowns(), old.restrictedKills(), false,
old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
repository.replace(saved, old.revision());
return new StatPurchase(StatPurchaseStatus.APPLIED, Optional.of(saved));
});
@@ -619,7 +761,7 @@ public final class CharacterStorage implements AutoCloseable {
if (old.health().compareTo(bounded) == 0) return Optional.of(old);
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints(), bounded, old.learned(), old.loadout(), old.passives(), old.cooldowns(),
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
repository.replace(saved, old.revision());
return Optional.of(saved);
});
@@ -656,7 +798,7 @@ public final class CharacterStorage implements AutoCloseable {
old.level(), old.experience(), old.unspentPoints(), java.math.BigDecimal.ZERO, old.learned(),
old.loadout(), old.passives(), old.cooldowns(), old.restrictedKills(), true, deathId, diedAt,
loseCoins ? BigInteger.ZERO : old.coins(), old.coinsInitialized(), old.rewardReceipts(),
loseCoins ? old.coins() : BigInteger.ZERO,old.gear());
loseCoins ? old.coins() : BigInteger.ZERO,old.gear(),old.sheetGeneration());
repository.replace(dead, old.revision());
return Optional.of(dead);
});
@@ -693,7 +835,7 @@ public final class CharacterStorage implements AutoCloseable {
var health = nativeLimit == null ? old.maximumHealth() : old.maximumHealth().min(nativeLimit);
var restored = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
old.level(), old.experience(), old.unspentPoints(), health, old.learned(), old.loadout(), old.passives(), old.cooldowns(),
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),BigInteger.ZERO,old.gear());
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),BigInteger.ZERO,old.gear(),old.sheetGeneration());
repository.replace(restored, old.revision());
return Optional.of(restored);
});
@@ -33,6 +33,7 @@ public final class CharacterYaml {
root.put("character-id", record.id().toString());
root.put("name", record.name());
root.put("revision", record.revision().toString());
root.put("sheet-generation", record.sheetGeneration().toString());
Map<String, String> stats = new LinkedHashMap<>();
for (Stat stat : Stat.values()) stats.put(stat.name(), record.stats().get(stat).toString());
root.put("stats", stats);
@@ -108,7 +109,8 @@ public final class CharacterYaml {
root.containsKey("reward-receipts") ? strings(root.get("reward-receipts")).stream()
.map(UUID::fromString).collect(java.util.stream.Collectors.toUnmodifiableSet()) : java.util.Set.of(),
root.containsKey("pvp-coin-escrow") ? number(root.get("pvp-coin-escrow")) : BigInteger.ZERO,
root.containsKey("gear")?gear(root.get("gear")):CharacterGear.none());
root.containsKey("gear")?gear(root.get("gear")):CharacterGear.none(),
root.containsKey("sheet-generation") ? number(root.get("sheet-generation")) : BigInteger.ZERO);
}
private static CharacterGear gear(Object value){var gear=object(value);return new CharacterGear(GearTier.valueOf(text(gear.get("chestplate"))),GearTier.valueOf(text(gear.get("leggings"))),GearTier.valueOf(text(gear.get("boots"))));}
@@ -21,10 +21,10 @@ public final class YamlAdventureQuestRepository {
if(Files.notExists(file))return AdventureQuestState.empty();
try{
Object decoded=yaml().load(Files.readString(file,StandardCharsets.UTF_8));var root=map(decoded);if(number(root.get("schema-version"))!=1)throw new IllegalArgumentException();
var sites=new ArrayList<AdventureQuestState.Site>();for(Object item:list(root.get("sites"))){var r=map(item);AdventureQuestState.Arena arena=null;if(r.containsKey("arena-shape")){var shape=AdventureQuestState.ArenaShape.valueOf(text(r.get("arena-shape")));int x=number(r.get("arena-x")),z=number(r.get("arena-z"));arena=new AdventureQuestState.Arena(shape,x,z);}sites.add(new AdventureQuestState.Site(uuid(r,"id"),AdventureQuestState.SiteType.valueOf(text(r.get("type"))),text(r.get("name")),uuid(r,"world"),decimal(r,"x"),decimal(r,"y"),decimal(r,"z"),arena));}
var sites=new ArrayList<AdventureQuestState.Site>();for(Object item:list(root.get("sites"))){var r=map(item);AdventureQuestState.Arena arena=null;if(r.containsKey("arena-shape")){var shape=AdventureQuestState.ArenaShape.valueOf(text(r.get("arena-shape")));int x=number(r.get("arena-x")),z=number(r.get("arena-z"));arena=new AdventureQuestState.Arena(shape,x,z);}sites.add(new AdventureQuestState.Site(uuid(r,"id"),AdventureQuestState.SiteType.valueOf(text(r.get("type"))),text(r.get("name")),uuid(r,"world"),decimal(r,"x"),decimal(r,"y"),decimal(r,"z"),arena,r.containsKey("participation-mode")?AdventureQuestState.ParticipationMode.parse(text(r.get("participation-mode"))):AdventureQuestState.ParticipationMode.PARTY_AVAILABLE));}
var givers=new ArrayList<AdventureQuestState.Giver>();for(Object item:list(root.get("givers"))){var r=map(item);givers.add(new AdventureQuestState.Giver(uuid(r,"id"),text(r.get("name")),uuid(r,"world"),decimal(r,"x"),decimal(r,"y"),decimal(r,"z"),(float)decimal(r,"yaw"),(float)decimal(r,"pitch")));}
AdventureQuestState.Catalog catalog=null;if(root.get("catalog")!=null){var r=map(root.get("catalog"));catalog=new AdventureQuestState.Catalog(LocalDate.parse(text(r.get("day"))),uuid(r,"revision"),Instant.parse(text(r.get("created-at"))),list(r.get("sites")).stream().map(value->UUID.fromString(text(value))).toList(),r.containsKey("closed")?list(r.get("closed")).stream().map(value->UUID.fromString(text(value))).collect(java.util.stream.Collectors.toSet()):Set.of());}
var quests=new ArrayList<AdventureQuestState.Quest>();for(Object item:list(root.get("quests"))){var r=map(item);quests.add(new AdventureQuestState.Quest(uuid(r,"id"),uuid(r,"catalog"),uuid(r,"site"),AdventureQuestState.UnitType.valueOf(text(r.get("unit-type"))),uuid(r,"unit"),list(r.get("snapshot")).stream().map(value->UUID.fromString(text(value))).collect(java.util.stream.Collectors.toSet()),Instant.parse(text(r.get("expires-at"))),new BigInteger(text(r.get("reward-xp"))),new BigInteger(text(r.get("reward-coins")))));}
var quests=new ArrayList<AdventureQuestState.Quest>();for(Object item:list(root.get("quests"))){var r=map(item);quests.add(new AdventureQuestState.Quest(uuid(r,"id"),uuid(r,"catalog"),uuid(r,"site"),AdventureQuestState.UnitType.valueOf(text(r.get("unit-type"))),uuid(r,"unit"),list(r.get("snapshot")).stream().map(value->UUID.fromString(text(value))).collect(java.util.stream.Collectors.toSet()),Instant.parse(text(r.get("expires-at"))),new BigInteger(text(r.get("reward-xp"))),new BigInteger(text(r.get("reward-coins"))),r.containsKey("participation-mode")?AdventureQuestState.ParticipationMode.parse(text(r.get("participation-mode"))):AdventureQuestState.ParticipationMode.PARTY_AVAILABLE));}
var unitClaims=new HashSet<AdventureQuestState.UnitClaim>();for(Object item:list(root.get("unit-claims"))){var r=map(item);unitClaims.add(new AdventureQuestState.UnitClaim(uuid(r,"catalog"),uuid(r,"site"),AdventureQuestState.UnitType.valueOf(text(r.get("unit-type"))),uuid(r,"unit")));}
var characterClaims=new HashSet<AdventureQuestState.CharacterClaim>();for(Object item:list(root.get("character-claims"))){var r=map(item);characterClaims.add(new AdventureQuestState.CharacterClaim(uuid(r,"catalog"),uuid(r,"site"),uuid(r,"character")));}
var rosters=new LinkedHashMap<UUID,List<EntityType>>();for(Object item:list(root.get("rosters"))){var r=map(item);rosters.put(uuid(r,"site"),list(r.get("entities")).stream().map(value->EntityType.valueOf(text(value))).toList());}
@@ -49,7 +49,7 @@ public final class YamlAdventureQuestRepository {
root.put("sites",state.sites().stream().sorted(Comparator.comparing(AdventureQuestState.Site::id)).map(YamlAdventureQuestRepository::siteRow).toList());
root.put("givers",state.givers().stream().sorted(Comparator.comparing(AdventureQuestState.Giver::id)).map(v->row("id",v.id(),"name",v.name(),"world",v.world(),"x",v.x(),"y",v.y(),"z",v.z(),"yaw",v.yaw(),"pitch",v.pitch())).toList());
if(state.catalog()!=null)root.put("catalog",row("day",state.catalog().day(),"revision",state.catalog().revision(),"created-at",state.catalog().createdAt(),"sites",state.catalog().sites(),"closed",state.catalog().closed().stream().sorted().toList()));else root.put("catalog",null);
root.put("quests",state.quests().stream().sorted(Comparator.comparing(AdventureQuestState.Quest::id)).map(v->row("id",v.id(),"catalog",v.catalog(),"site",v.site(),"unit-type",v.unitType(),"unit",v.unit(),"snapshot",v.snapshot().stream().sorted().toList(),"expires-at",v.expiresAt(),"reward-xp",v.rewardXp(),"reward-coins",v.rewardCoins())).toList());
root.put("quests",state.quests().stream().sorted(Comparator.comparing(AdventureQuestState.Quest::id)).map(v->row("id",v.id(),"catalog",v.catalog(),"site",v.site(),"unit-type",v.unitType(),"unit",v.unit(),"snapshot",v.snapshot().stream().sorted().toList(),"expires-at",v.expiresAt(),"reward-xp",v.rewardXp(),"reward-coins",v.rewardCoins(),"participation-mode",v.participationMode().value())).toList());
root.put("unit-claims",state.unitClaims().stream().sorted(Comparator.comparing(v->v.catalog().toString()+v.site()+v.unit())).map(v->row("catalog",v.catalog(),"site",v.site(),"unit-type",v.type(),"unit",v.unit())).toList());
root.put("character-claims",state.characterClaims().stream().sorted(Comparator.comparing(v->v.catalog().toString()+v.site()+v.character())).map(v->row("catalog",v.catalog(),"site",v.site(),"character",v.character())).toList());
root.put("rosters",state.rosters().entrySet().stream().sorted(Map.Entry.comparingByKey()).map(e->row("site",e.getKey(),"entities",e.getValue())).toList());
@@ -58,7 +58,7 @@ public final class YamlAdventureQuestRepository {
root.put("reset",state.reset()==null?null:row("operation",state.reset().operation(),"generation",state.reset().generation(),"started-at",state.reset().startedAt()));
Files.createDirectories(file.getParent());Path temporary=Files.createTempFile(file.getParent(),"adventure-quests-",".tmp");try{byte[] bytes=yaml().dump(root).getBytes(StandardCharsets.UTF_8);try(var output=java.nio.channels.FileChannel.open(temporary,StandardOpenOption.WRITE)){var buffer=java.nio.ByteBuffer.wrap(bytes);while(buffer.hasRemaining())output.write(buffer);output.force(true);}try{Files.move(temporary,file,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);}catch(AtomicMoveNotSupportedException unsupported){throw new IOException("Atomic adventure persistence unavailable",unsupported);}DurableFiles.forceDirectory(file.getParent());}finally{Files.deleteIfExists(temporary);}
}
private static Map<String,Object> siteRow(AdventureQuestState.Site site){var result=row("id",site.id(),"type",site.type(),"name",site.name(),"world",site.world(),"x",site.x(),"y",site.y(),"z",site.z());if(site.arena()!=null){result.put("arena-shape",encode(site.arena().shape()));result.put("arena-x",site.arena().xBlocks());result.put("arena-z",site.arena().zBlocks());}return result;}
private static Map<String,Object> siteRow(AdventureQuestState.Site site){var result=row("id",site.id(),"type",site.type(),"name",site.name(),"world",site.world(),"x",site.x(),"y",site.y(),"z",site.z(),"participation-mode",site.participationMode().value());if(site.arena()!=null){result.put("arena-shape",encode(site.arena().shape()));result.put("arena-x",site.arena().xBlocks());result.put("arena-z",site.arena().zBlocks());}return result;}
private static Map<String,Object> row(Object...pairs){var row=new LinkedHashMap<String,Object>();for(int i=0;i<pairs.length;i+=2)row.put((String)pairs[i],encode(pairs[i+1]));return row;}
private static Object encode(Object value){if(value instanceof UUID||value instanceof Enum<?>||value instanceof Instant||value instanceof LocalDate||value instanceof BigInteger||value instanceof Double||value instanceof Float)return value.toString();if(value instanceof Collection<?> values)return values.stream().map(YamlAdventureQuestRepository::encode).toList();return value;}
private static Yaml yaml(){var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(64);options.setCodePointLimit(4_194_304);return new Yaml(new SafeConstructor(options));}
@@ -12,7 +12,9 @@ import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
@@ -23,6 +25,7 @@ public final class YamlCharacterRepository {
private final Path directory;
private final Replacement replacement;
private volatile java.util.function.Predicate<UUID> unavailable = ignored -> false;
private volatile java.util.function.Predicate<UUID> mutationUnavailable = ignored -> false;
@FunctionalInterface
interface Replacement { void move(Path source, Path target) throws IOException; }
@@ -30,6 +33,10 @@ public final class YamlCharacterRepository {
private interface Operation { void run() throws IOException; }
public void attachUnavailable(java.util.function.Predicate<UUID> predicate){unavailable=Objects.requireNonNull(predicate);}
void attachMutationUnavailable(java.util.function.Predicate<UUID> predicate) {
mutationUnavailable = Objects.requireNonNull(predicate);
}
Path pluginDataDirectory() { return directory.getParent(); }
public YamlCharacterRepository(Path pluginDataDirectory) {
this(pluginDataDirectory, (source, target) -> Files.move(source, target,
@@ -71,12 +78,26 @@ public final class YamlCharacterRepository {
}
public void replace(CharacterRecord character, BigInteger expectedRevision) throws IOException {
replace(character, expectedRevision, true);
}
/** Recovery owns its separately persisted reservation but still honors dungeon custody. */
void replaceForRecovery(CharacterRecord character, BigInteger expectedRevision) throws IOException {
replace(character, expectedRevision, false);
}
private void replace(CharacterRecord character, BigInteger expectedRevision,
boolean enforceMutationAvailability) throws IOException {
if (unavailable.test(character.id())) throw new IOException("Character is in dungeon boss custody");
if (enforceMutationAvailability && mutationUnavailable.test(character.id()))
throw new IOException("Character has unresolved recovery custody");
if (!character.revision().equals(expectedRevision.add(BigInteger.ONE))) {
throw new IOException("Character update must advance exactly one revision");
}
withLock(() -> {
if (unavailable.test(character.id())) throw new IOException("Character is in dungeon boss custody");
if (enforceMutationAvailability && mutationUnavailable.test(character.id()))
throw new IOException("Character has unresolved recovery custody");
CharacterRecord current = load(character.id()).orElseThrow(() -> new IOException("Character not found"));
if (!current.revision().equals(expectedRevision)) throw new IOException("Stale character revision");
write(character);
@@ -108,6 +129,41 @@ public final class YamlCharacterRepository {
return removed[0];
}
public record Catalog(List<CharacterRecord> records, int unreadable) {
public Catalog { records = List.copyOf(records); }
}
/** Enumerate only canonical record files without following directory-entry symlinks. */
public Catalog catalog() throws IOException {
if (Files.notExists(directory, LinkOption.NOFOLLOW_LINKS)) return new Catalog(List.of(), 0);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS))
throw new IOException("Character directory is unavailable");
var records = new ArrayList<CharacterRecord>();
int unreadable = 0;
try (var entries = Files.newDirectoryStream(directory, "*.yml")) {
for (var entry : entries) {
String filename = entry.getFileName().toString();
if (!filename.endsWith(".yml")) continue;
UUID id;
try {
String stem = filename.substring(0, filename.length() - 4);
id = UUID.fromString(stem);
if (!id.toString().equals(stem)) continue;
} catch (IllegalArgumentException invalid) { continue; }
if (!Files.isRegularFile(entry, LinkOption.NOFOLLOW_LINKS)) {
unreadable++;
continue;
}
try {
var record = load(id);
if (record.isPresent()) records.add(record.orElseThrow());
else unreadable++;
} catch (IOException failure) { unreadable++; }
}
}
return new Catalog(records, unreadable);
}
public Optional<CharacterRecord> load(UUID id) throws IOException {
Path file = file(id);
if (Files.notExists(file, LinkOption.NOFOLLOW_LINKS)) return Optional.empty();
@@ -17,13 +17,13 @@ public final class YamlDungeonRepository {
public YamlDungeonRepository(Path data){file=data.toAbsolutePath().resolve("dungeons.yml");}
public DungeonState load()throws IOException{
if(Files.notExists(file))return DungeonState.empty();
try{var root=map(yaml().load(Files.readString(file,StandardCharsets.UTF_8)));if(integer(root.get("schema-version"))!=1)throw new IllegalArgumentException();var dungeons=new ArrayList<DungeonState.Dungeon>();for(var item:list(root.get("dungeons"))){var row=map(item);var rooms=new ArrayList<DungeonState.Room>();for(var roomItem:list(row.get("rooms"))){var room=map(roomItem);var mobs=new ArrayList<DungeonState.Spawn>();for(var mobItem:list(room.get("mobs"))){var mob=map(mobItem);mobs.add(new DungeonState.Spawn(uuid(mob,"id"),EntityType.valueOf(text(mob.get("type"))),text(mob.get("name")),position(map(mob.get("position")))));}var effects=new ArrayList<DungeonState.Effect>();for(var effectItem:list(room.get("effects"))){var effect=map(effectItem);effects.add(new DungeonState.Effect(uuid(effect,"id"),DungeonState.EffectTarget.valueOf(text(effect.get("target"))),text(effect.get("effect")),integer(effect.get("level")),bool(effect.get("particles"))));}DungeonState.Boss boss=null;if(room.get("boss")!=null){var value=map(room.get("boss"));DungeonState.CharacterBoss character=null;if(value.get("character")!=null){var custody=map(value.get("character"));DungeonState.Recovery recovery=null;if(custody.get("recovery")!=null){var pending=map(custody.get("recovery"));recovery=new DungeonState.Recovery(uuid(pending,"operation"),uuid(pending,"administrator"));}character=new DungeonState.CharacterBoss(uuid(custody,"operation"),CharacterYaml.decode(text(custody.get("record"))),new java.math.BigDecimal(text(custody.get("multiplier"))),custody.containsKey("prior-type")?EntityType.valueOf(text(custody.get("prior-type"))):EntityType.HUSK,custody.containsKey("prior-name")?text(custody.get("prior-name")):"",recovery);}boss=new DungeonState.Boss(EntityType.valueOf(text(value.get("type"))),text(value.get("name")),position(map(value.get("position"))),character);}rooms.add(new DungeonState.Room(uuid(room,"id"),text(room.get("name")),bounds(map(room.get("bounds"))),mobs,effects,new DungeonState.Reward(new BigInteger(text(room.get("reward-xp"))),new BigInteger(text(room.get("reward-coins")))),boss));}dungeons.add(new DungeonState.Dungeon(uuid(row,"id"),text(row.get("name")),uuid(row,"world"),position(map(row.get("entrance"))),bool(row.get("enabled")),uuid(row,"revision"),rooms));}
try{var root=map(yaml().load(Files.readString(file,StandardCharsets.UTF_8)));if(integer(root.get("schema-version"))!=1)throw new IllegalArgumentException();var dungeons=new ArrayList<DungeonState.Dungeon>();for(var item:list(root.get("dungeons"))){var row=map(item);var rooms=new ArrayList<DungeonState.Room>();for(var roomItem:list(row.get("rooms"))){var room=map(roomItem);var mobs=new ArrayList<DungeonState.Spawn>();for(var mobItem:list(room.get("mobs"))){var mob=map(mobItem);mobs.add(new DungeonState.Spawn(uuid(mob,"id"),EntityType.valueOf(text(mob.get("type"))),text(mob.get("name")),position(map(mob.get("position")))));}var effects=new ArrayList<DungeonState.Effect>();for(var effectItem:list(room.get("effects"))){var effect=map(effectItem);effects.add(new DungeonState.Effect(uuid(effect,"id"),DungeonState.EffectTarget.valueOf(text(effect.get("target"))),text(effect.get("effect")),integer(effect.get("level")),bool(effect.get("particles"))));}DungeonState.Boss boss=null;if(room.get("boss")!=null){var value=map(room.get("boss"));DungeonState.CharacterBoss character=null;if(value.get("character")!=null){var custody=map(value.get("character"));DungeonState.Recovery recovery=null;if(custody.get("recovery")!=null){var pending=map(custody.get("recovery"));recovery=new DungeonState.Recovery(uuid(pending,"operation"),uuid(pending,"administrator"));}character=new DungeonState.CharacterBoss(uuid(custody,"operation"),CharacterYaml.decode(text(custody.get("record"))),new java.math.BigDecimal(text(custody.get("multiplier"))),custody.containsKey("prior-type")?EntityType.valueOf(text(custody.get("prior-type"))):EntityType.HUSK,custody.containsKey("prior-name")?text(custody.get("prior-name")):"",recovery);}boss=new DungeonState.Boss(EntityType.valueOf(text(value.get("type"))),text(value.get("name")),position(map(value.get("position"))),character);}rooms.add(new DungeonState.Room(uuid(room,"id"),text(room.get("name")),bounds(map(room.get("bounds"))),mobs,effects,new DungeonState.Reward(new BigInteger(text(room.get("reward-xp"))),new BigInteger(text(room.get("reward-coins")))),boss));}dungeons.add(new DungeonState.Dungeon(uuid(row,"id"),text(row.get("name")),uuid(row,"world"),position(map(row.get("entrance"))),bool(row.get("enabled")),uuid(row,"revision"),rooms,row.containsKey("participation-mode")?games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.parse(text(row.get("participation-mode"))):games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.PARTY_AVAILABLE));}
var runs=new ArrayList<DungeonState.Run>();for(var item:list(root.get("runs"))){var row=map(item);var unitClaims=new HashSet<DungeonState.UnitClaim>();for(var value:list(row.get("unit-claims"))){var claim=map(value);unitClaims.add(new DungeonState.UnitClaim(uuid(claim,"room"),uuid(claim,"quest")));}var characterClaims=new HashSet<DungeonState.CharacterClaim>();for(var value:list(row.get("character-claims"))){var claim=map(value);characterClaims.add(new DungeonState.CharacterClaim(uuid(claim,"room"),uuid(claim,"character")));}runs.add(new DungeonState.Run(uuid(row,"dungeon"),uuid(row,"generation"),uuids(row.get("defeated-slots")),uuids(row.get("cleared-rooms")),unitClaims,characterClaims,bool(row.get("defeated"))));}
return new DungeonState(dungeons,runs);
}catch(RuntimeException invalid){throw new IOException("Invalid dungeon file",invalid);}
}
public void save(DungeonState state)throws IOException{Objects.requireNonNull(state);var root=new LinkedHashMap<String,Object>();root.put("schema-version",1);root.put("dungeons",state.dungeons().stream().sorted(Comparator.comparing(DungeonState.Dungeon::id)).map(YamlDungeonRepository::dungeon).toList());root.put("runs",state.runs().stream().sorted(Comparator.comparing(v->v.dungeon().toString()+v.generation())).map(YamlDungeonRepository::run).toList());Files.createDirectories(file.getParent());Path temporary=Files.createTempFile(file.getParent(),"dungeons-",".tmp");try{byte[] bytes=yaml().dump(root).getBytes(StandardCharsets.UTF_8);try(var output=java.nio.channels.FileChannel.open(temporary,StandardOpenOption.WRITE)){var buffer=java.nio.ByteBuffer.wrap(bytes);while(buffer.hasRemaining())output.write(buffer);output.force(true);}try{Files.move(temporary,file,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);}catch(AtomicMoveNotSupportedException unsupported){throw new IOException("Atomic dungeon persistence unavailable",unsupported);}DurableFiles.forceDirectory(file.getParent());}finally{Files.deleteIfExists(temporary);}}
private static Map<String,Object> dungeon(DungeonState.Dungeon value){return row("id",value.id(),"name",value.name(),"world",value.world(),"entrance",position(value.entrance()),"enabled",value.enabled(),"revision",value.revision(),"rooms",value.rooms().stream().sorted(Comparator.comparing(DungeonState.Room::id)).map(YamlDungeonRepository::room).toList());}
private static Map<String,Object> dungeon(DungeonState.Dungeon value){return row("id",value.id(),"name",value.name(),"world",value.world(),"entrance",position(value.entrance()),"enabled",value.enabled(),"revision",value.revision(),"participation-mode",value.participationMode().value(),"rooms",value.rooms().stream().sorted(Comparator.comparing(DungeonState.Room::id)).map(YamlDungeonRepository::room).toList());}
private static Map<String,Object> room(DungeonState.Room value){return row("id",value.id(),"name",value.name(),"bounds",bounds(value.bounds()),"mobs",value.mobs().stream().sorted(Comparator.comparing(DungeonState.Spawn::id)).map(v->row("id",v.id(),"type",v.type(),"name",v.name(),"position",position(v.position()))).toList(),"effects",value.effects().stream().sorted(Comparator.comparing(DungeonState.Effect::id)).map(v->row("id",v.id(),"target",v.target(),"effect",v.effect(),"level",v.level(),"particles",v.particles())).toList(),"reward-xp",value.reward().xp(),"reward-coins",value.reward().coins(),"boss",value.boss()==null?null:row("type",value.boss().type(),"name",value.boss().name(),"position",position(value.boss().position()),"character",value.boss().character()==null?null:character(value.boss().character())));}
private static Map<String,Object> character(DungeonState.CharacterBoss value){return row("operation",value.operation(),"record",CharacterYaml.encode(value.character()),"multiplier",value.multiplier().toPlainString(),"prior-type",value.priorType(),"prior-name",value.priorName(),"recovery",value.recovery()==null?null:row("operation",value.recovery().operation(),"administrator",value.recovery().administrator()));}
private static Map<String,Object> run(DungeonState.Run value){return row("dungeon",value.dungeon(),"generation",value.generation(),"defeated-slots",value.defeatedSlots().stream().sorted().toList(),"cleared-rooms",value.clearedRooms().stream().sorted().toList(),"unit-claims",value.unitClaims().stream().sorted(Comparator.comparing(v->v.room().toString()+v.quest())).map(v->row("room",v.room(),"quest",v.quest())).toList(),"character-claims",value.characterClaims().stream().sorted(Comparator.comparing(v->v.room().toString()+v.character())).map(v->row("room",v.room(),"character",v.character())).toList(),"defeated",value.defeated());}
@@ -0,0 +1,301 @@
package games.dmg.fantasystick.session;
import games.dmg.fantasystick.items.CharacterSheets;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.persistence.EquipmentSessionStore;
import games.dmg.fantasystick.persistence.RevivalStore;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import net.kyori.adventure.text.Component;
import org.bukkit.Material;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
/** Explicit one-use preview and fail-closed delivery for saved-character sheet recovery. */
public final class AdminCharacterRecovery implements Listener, AutoCloseable {
public record Failure(UUID character, UUID recipient, String operation, Throwable cause) { }
private record View(UUID nonce, Player administrator, Player recipient, CharacterRecord record,
int destination, Inventory inventory) { }
private record Prerequisites(java.util.Optional<CharacterRecord> record,
java.util.Optional<EquipmentSessionStore.State> receipt,
java.util.Optional<RevivalStore.Reservation> revival) { }
private final CharacterStorage characters;
private final EquipmentSessionStore receipts;
private final RevivalStore revivals;
private final EquipmentSessions sessions;
private final Server server;
private final Consumer<Runnable> nextTick;
private final Consumer<Failure> failures;
private final Map<UUID, Object> pending = new ConcurrentHashMap<>();
private final Map<UUID, View> views = new ConcurrentHashMap<>();
private final CharacterSheets sheets = new CharacterSheets();
private volatile boolean closed;
public AdminCharacterRecovery(CharacterStorage characters, EquipmentSessionStore receipts,
RevivalStore revivals, EquipmentSessions sessions, Server server, Consumer<Runnable> nextTick,
Consumer<Failure> failures) {
this.characters = Objects.requireNonNull(characters);
this.receipts = Objects.requireNonNull(receipts);
this.revivals = Objects.requireNonNull(revivals);
this.sessions = Objects.requireNonNull(sessions);
this.server = Objects.requireNonNull(server);
this.nextTick = Objects.requireNonNull(nextTick);
this.failures = Objects.requireNonNull(failures);
}
public void open(Player administrator, UUID character, Player recipient) {
Objects.requireNonNull(administrator); Objects.requireNonNull(character); Objects.requireNonNull(recipient);
if (closed || !current(administrator) || !recipient.isOnline()) {
administrator.sendMessage("Character recovery refused: administrator or recipient is unavailable.");
return;
}
UUID administratorId = administrator.getUniqueId();
UUID recipientId = recipient.getUniqueId();
Object ticket = new Object();
views.remove(administratorId);
pending.put(administratorId, ticket);
characters.inspect(character).whenComplete((record, recordFailure) -> {
if (recordFailure != null) {
schedule(character, recipientId, () -> finishPreview(administratorId, ticket,
administrator, recipient, character, null, recordFailure));
return;
}
try {
var receiptStage = receipts.find(recipientId);
receiptStage.whenComplete((receipt, receiptFailure) -> {
if (receiptFailure != null) {
schedule(character, recipientId, () -> finishPreview(administratorId, ticket,
administrator, recipient, character, null, receiptFailure));
return;
}
revivals.find(character).whenComplete((revival, revivalFailure) ->
schedule(character, recipientId, () -> finishPreview(administratorId, ticket,
administrator, recipient, character,
revivalFailure == null ? new Prerequisites(record, receipt, revival) : null,
revivalFailure)));
});
} catch (RuntimeException rejected) {
schedule(character, recipientId, () -> finishPreview(administratorId, ticket,
administrator, recipient, character, null, rejected));
}
});
}
private void finishPreview(UUID administratorId, Object ticket, Player administrator, Player recipient,
UUID character, Prerequisites loaded, Throwable failure) {
if (closed || !pending.remove(administratorId, ticket)) return;
Player currentRecipient = server.getPlayer(recipient.getUniqueId());
if (!current(administrator) || currentRecipient != recipient || !recipient.isOnline()) {
administrator.sendMessage("Character recovery refused: administrator or recipient changed.");
return;
}
if (failure != null) {
report(character, recipient.getUniqueId(), "preview", failure);
administrator.sendMessage("Character recovery preview could not be loaded; no state changed.");
return;
}
var record = loaded.record().orElse(null);
if (record == null) {
administrator.sendMessage("Character recovery refused: saved character not found.");
return;
}
if (loaded.receipt().map(EquipmentSessionStore.State::pending).orElse(null) != null
|| loaded.revival().isPresent()
|| !sessions.availableForCharacterRecovery(character, recipient.getUniqueId())) {
administrator.sendMessage("Character recovery refused: active or unresolved custody must be resolved first.");
return;
}
int destination = recipient.getInventory().firstEmpty();
if (destination < 0 || destination >= 36) {
administrator.sendMessage("Character recovery refused: recipient needs one empty main-inventory slot.");
return;
}
show(administrator, recipient, record, destination);
}
private void show(Player administrator, Player recipient, CharacterRecord record, int destination) {
var inventory = server.createInventory(null, 9, Component.text("Recover saved character"));
inventory.setItem(2, action(record.dead() ? Material.BARRIER : Material.PAPER,
record.dead() ? "Living recovery unavailable" : "Recover living character sheet", record, recipient));
inventory.setItem(6, action(record.dead() ? Material.TOTEM_OF_UNDYING : Material.BARRIER,
record.dead() ? "Revive without Tail and recover sheet" : "No-Tail revival unavailable", record, recipient));
var view = new View(UUID.randomUUID(), administrator, recipient, record, destination, inventory);
views.put(administrator.getUniqueId(), view);
administrator.openInventory(inventory);
}
private static ItemStack action(Material material, String label, CharacterRecord record, Player recipient) {
var item = new ItemStack(material);
var meta = item.getItemMeta();
meta.displayName(Component.text(label));
var lines = new java.util.ArrayList<Component>();
lines.add(Component.text(record.name()));
lines.add(Component.text(record.id().toString()));
lines.add(Component.text("Level " + record.level() + " — " + (record.dead() ? "dead" : "living")));
if (record.deathId() != null) lines.add(Component.text("Death " + record.deathId()));
if (record.diedAt() != null) lines.add(Component.text("Died " + record.diedAt()));
lines.add(Component.text("Recipient " + recipient.getName() + " — " + recipient.getUniqueId()));
lines.add(Component.text("Confirmation invalidates every older sheet, including copies held by anyone."));
meta.lore(lines); item.setItemMeta(meta); return item;
}
@EventHandler public void close(org.bukkit.event.inventory.InventoryCloseEvent event) {
if (!(event.getPlayer() instanceof Player administrator)) return;
var view = views.get(administrator.getUniqueId());
if (view != null && event.getInventory() == view.inventory()) views.remove(administrator.getUniqueId(), view);
}
@EventHandler public void click(InventoryClickEvent event) {
if (closed || !(event.getWhoClicked() instanceof Player administrator)) return;
var view = views.get(administrator.getUniqueId());
if (view == null || event.getInventory() != view.inventory()) return;
event.setCancelled(true);
int slot = event.getRawSlot();
boolean revive = slot == 6;
if (!(slot == 2 || slot == 6) || revive != view.record().dead()) return;
if (!views.remove(administrator.getUniqueId(), view)) return;
administrator.closeInventory();
confirm(view, revive);
}
private void confirm(View view, boolean revive) {
Player administrator = view.administrator(); Player recipient = view.recipient();
UUID recipientId = recipient.getUniqueId();
if (!current(administrator) || server.getPlayer(recipient.getUniqueId()) != recipient
|| !recipient.isOnline() || !empty(recipient, view.destination())) {
administrator.sendMessage("Character recovery confirmation became stale; no state changed.");
return;
}
var custody = sessions.claimCharacterRecovery(view.record().id(), recipientId);
if (custody.isEmpty()) {
administrator.sendMessage("Character recovery confirmation became stale; no state changed.");
return;
}
var claim = custody.orElseThrow();
java.util.concurrent.CompletionStage<java.util.Optional<EquipmentSessionStore.State>> receiptRead;
try { receiptRead = receipts.find(recipientId); }
catch (RuntimeException rejected) {
claim.close();
report(view.record().id(), recipientId, "receipt", rejected);
administrator.sendMessage("Character recovery confirmation became stale; no state changed.");
return;
}
receiptRead.whenComplete((receipt, failure) -> schedule(
view.record().id(), recipientId, () -> {
if (failure != null || receipt.map(EquipmentSessionStore.State::pending).orElse(null) != null
|| !current(administrator) || server.getPlayer(recipient.getUniqueId()) != recipient
|| !recipient.isOnline() || !empty(recipient, view.destination())) {
claim.close();
if (failure != null) report(view.record().id(), recipient.getUniqueId(), "receipt", failure);
administrator.sendMessage("Character recovery confirmation became stale; no state changed.");
return;
}
revivals.find(view.record().id()).whenComplete((revival, revivalFailure) -> schedule(
view.record().id(), recipientId, () -> {
if (revivalFailure != null || revival.isPresent() || !current(administrator)
|| server.getPlayer(recipientId) != recipient || !recipient.isOnline()
|| !empty(recipient, view.destination())) {
claim.close();
if (revivalFailure != null) report(view.record().id(), recipientId, "revival", revivalFailure);
administrator.sendMessage("Character recovery confirmation became stale; no state changed.");
return;
}
UUID operation = UUID.randomUUID();
var request = new CharacterStorage.RecoveryRequest(operation, view.record().id(),
administrator.getUniqueId(), recipientId, view.record().revision(),
view.record().sheetGeneration(), revive, revive ? view.record().deathId() : null,
NativeHealthLimit.read());
characters.beginRecovery(request).whenComplete((commit, commitFailure) -> schedule(
view.record().id(), recipientId, () -> deliver(view, claim, commit, commitFailure)));
}));
}));
}
private void deliver(View view, EquipmentSessions.CharacterRecoveryClaim claim,
CharacterStorage.RecoveryCommit commit, Throwable failure) {
if (failure != null) {
report(view.record().id(), view.recipient().getUniqueId(), "commit", failure);
view.administrator().sendMessage("Character recovery failed or is retained for inspection; no retry was performed.");
return;
}
if (commit.status() != CharacterStorage.RecoveryStatus.APPLIED || commit.record().isEmpty()) {
claim.close();
view.administrator().sendMessage("Character recovery refused: saved state or custody changed ("
+ commit.status().name().toLowerCase(java.util.Locale.ROOT) + ").");
return;
}
var record = commit.record().orElseThrow();
Player recipient = view.recipient();
if (!current(view.administrator()) || server.getPlayer(recipient.getUniqueId()) != recipient || !recipient.isOnline()
|| !empty(recipient, view.destination())) {
view.administrator().sendMessage("Character recovery committed, but native delivery is uncertain; do not retry blindly.");
return;
}
try {
recipient.getInventory().setItem(view.destination(), sheets.create(record));
if (!sheets.authorizes(recipient.getInventory().getItem(view.destination()), record))
throw new IllegalStateException("Native inventory did not retain the recovered sheet");
} catch (RuntimeException uncertain) {
report(record.id(), recipient.getUniqueId(), "native-delivery", uncertain);
view.administrator().sendMessage("Character recovery committed, but native delivery is uncertain; do not retry blindly.");
return;
}
try { recipient.updateInventory(); }
catch (RuntimeException syncFailure) { report(record.id(), recipient.getUniqueId(), "inventory-sync", syncFailure); }
UUID recipientId = recipient.getUniqueId();
characters.completeRecovery(record.id(), commit.operation()).whenComplete((completed, completionFailure) ->
schedule(record.id(), recipientId, () -> {
if (completionFailure != null || !completed) {
if (completionFailure != null) report(record.id(), recipient.getUniqueId(), "completion", completionFailure);
view.administrator().sendMessage("Recovery sheet was delivered, but durable completion is retained for inspection.");
return;
}
claim.close();
if (current(view.administrator())) view.administrator().sendMessage(
(view.record().dead() ? "Revived without a Tail and recovered " : "Recovered ")
+ record.name() + " for " + recipient.getName() + ". Older sheets are revoked.");
if (recipient.isOnline()) recipient.sendMessage("Recovered character sheet: " + record.name() + ".");
}));
}
private static boolean empty(Player player, int slot) {
if (slot < 0 || slot >= 36) return false;
var item = player.getInventory().getItem(slot);
return item == null || item.isEmpty();
}
private static boolean authorized(Player player) {
return player.isOnline() && player.hasPermission(EquipmentAdminCommand.PERMISSION);
}
private boolean current(Player player) {
return authorized(player) && server.getPlayer(player.getUniqueId()) == player;
}
private void schedule(UUID character, UUID recipient, Runnable task) {
if (closed) return;
try { nextTick.accept(() -> { if (!closed) task.run(); }); }
catch (RuntimeException rejected) { report(character, recipient, "schedule", rejected); }
}
private void report(UUID character, UUID recipient, String operation, Throwable cause) {
try { failures.accept(new Failure(character, recipient, operation, cause)); }
catch (RuntimeException ignored) { }
}
@Override public void close() {
closed = true; pending.clear();
for (var view : views.values()) view.inventory().clear();
views.clear();
}
}
@@ -60,7 +60,7 @@ public final class AdminRevival implements Listener, AutoCloseable {
private void confirm(View view,Choice choice){if(!view.current()||!unchanged(view.target,choice.candidate)){view.close();return;}characters.find(choice.record.id()).whenComplete((found,failure)->schedule(()->{if(failure!=null){report(failure);view.admin.sendMessage("Admin revival could not recheck that Tail; no item changed.");view.close();return;}if(!view.current()||found.isEmpty()||!eligible(found.orElseThrow(),choice.candidate.identity)||!unchanged(view.target,choice.candidate)){view.admin.sendMessage("That Tail selection is stale; no item changed.");view.close();return;}reserve(view,choice.candidate,found.orElseThrow());}));}
private void reserve(View view,Candidate candidate,CharacterRecord dead){var source=view.target.getInventory().getItem(candidate.slot);int destination=source.getAmount()==1?-1:emptySlot(view.target,candidate.slot);if(source.getAmount()>1&&destination<0){view.admin.sendMessage("Admin revival needs one empty direct inventory slot to preserve copied Tails.");view.close();return;}var reservation=new RevivalStore.Reservation(UUID.randomUUID(),view.admin.getUniqueId(),view.target.getUniqueId(),dead.id(),dead.deathId(),clock.instant().truncatedTo(ChronoUnit.SECONDS),candidate.slot,source.getAmount(),destination,true);reservations.reserve(reservation).whenComplete((saved,failure)->schedule(()->{if(failure!=null){report(failure);view.admin.sendMessage("Admin revival reservation failed; no item changed.");view.close();return;}if(saved.isEmpty()||!view.current()||!unchanged(view.target,candidate)){view.admin.sendMessage("Admin revival became stale after reservation; no item changed.");view.close();return;}deliver(view,dead,reservation);}));}
private static int emptySlot(Player target,int source){for(int slot=0;slot<=40;slot++){if(slot>35&&slot!=40||slot==source)continue;var item=target.getInventory().getItem(slot);if(item==null||item.isEmpty())return slot;}return-1;}
private void deliver(View view,CharacterRecord dead,RevivalStore.Reservation reservation){try{var provisional=new CharacterRecord(dead.id(),dead.name(),dead.revision(),dead.stats(),dead.level(),dead.experience(),dead.unspentPoints(),dead.maximumHealth(),dead.learned(),dead.loadout(),dead.passives(),dead.cooldowns(),dead.restrictedKills(),false,dead.deathId(),dead.diedAt());var sheet=sheets.create(provisional);var source=view.target.getInventory().getItem(reservation.sourceSlot());if(reservation.sourceAmount()==1)view.target.getInventory().setItem(reservation.sourceSlot(),sheet);else{view.target.getInventory().setItem(reservation.destinationSlot(),sheet);var remaining=source.clone();remaining.setAmount(reservation.sourceAmount()-1);view.target.getInventory().setItem(reservation.sourceSlot(),remaining);}view.target.updateInventory();}catch(RuntimeException uncertain){report(uncertain);view.admin.sendMessage("Admin revival delivery is uncertain; retained proof requires recovery.");view.close();return;}characters.restoreDeathRecordAdministratively(reservation.character(),reservation.death(),NativeHealthLimit.read()).whenComplete((restored,failure)->schedule(()->{if(failure!=null||restored.isEmpty()){if(failure!=null)report(failure);view.admin.sendMessage("The Tail changed, but restoration is pending proof-based recovery.");view.close();return;}if(installFinal(view.target,restored.orElseThrow())){reservations.clear(reservation.character(),reservation.id()).exceptionally(error->{report(error);return false;});view.admin.sendMessage("Administratively restored "+restored.orElseThrow().name()+" for "+view.target.getName()+".");view.target.sendMessage("An administrator restored character sheet: "+restored.orElseThrow().name()+".");}else view.admin.sendMessage("Character restoration was saved, but sheet custody is ambiguous; retained proof requires recovery.");view.close();}));}
private void deliver(View view,CharacterRecord dead,RevivalStore.Reservation reservation){try{var provisional=new CharacterRecord(dead.id(),dead.name(),dead.revision(),dead.stats(),dead.level(),dead.experience(),dead.unspentPoints(),dead.maximumHealth(),dead.learned(),dead.loadout(),dead.passives(),dead.cooldowns(),dead.restrictedKills(),false,dead.deathId(),dead.diedAt(),dead.sheetGeneration());var sheet=sheets.create(provisional);var source=view.target.getInventory().getItem(reservation.sourceSlot());if(reservation.sourceAmount()==1)view.target.getInventory().setItem(reservation.sourceSlot(),sheet);else{view.target.getInventory().setItem(reservation.destinationSlot(),sheet);var remaining=source.clone();remaining.setAmount(reservation.sourceAmount()-1);view.target.getInventory().setItem(reservation.sourceSlot(),remaining);}view.target.updateInventory();}catch(RuntimeException uncertain){report(uncertain);view.admin.sendMessage("Admin revival delivery is uncertain; retained proof requires recovery.");view.close();return;}characters.restoreDeathRecordAdministratively(reservation.character(),reservation.death(),NativeHealthLimit.read()).whenComplete((restored,failure)->schedule(()->{if(failure!=null||restored.isEmpty()){if(failure!=null)report(failure);view.admin.sendMessage("The Tail changed, but restoration is pending proof-based recovery.");view.close();return;}if(installFinal(view.target,restored.orElseThrow())){reservations.clear(reservation.character(),reservation.id()).exceptionally(error->{report(error);return false;});view.admin.sendMessage("Administratively restored "+restored.orElseThrow().name()+" for "+view.target.getName()+".");view.target.sendMessage("An administrator restored character sheet: "+restored.orElseThrow().name()+".");}else view.admin.sendMessage("Character restoration was saved, but sheet custody is ambiguous; retained proof requires recovery.");view.close();}));}
private boolean installFinal(Player target,CharacterRecord record){int found=-1;for(int slot=0;slot<=40;slot++){if(slot>35&&slot!=40)continue;if(sheets.identify(target.getInventory().getItem(slot)).filter(record.id()::equals).isEmpty())continue;if(found>=0)return false;found=slot;}if(found<0)return false;target.getInventory().setItem(found,sheets.create(record));target.updateInventory();return true;}
private void schedule(Runnable action){try{nextTick.accept(action);}catch(RuntimeException rejected){report(rejected);}}
private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}
@@ -20,6 +20,10 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private final Server server;
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 static final int SAVED_CATALOG_COMPLETION_LIMIT = 256;
private static final int SAVED_SEARCH_PAGE_SIZE = 8;
private volatile boolean closed;
private games.dmg.fantasystick.persistence.AdventureSettings settings;
private games.dmg.fantasystick.persistence.FireballSettings fireballSettings;
@@ -93,6 +97,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private games.dmg.fantasystick.shop.FantasyArmorerController armorerController;
private games.dmg.fantasystick.shop.ArmorMarket armorMarket;
private games.dmg.fantasystick.book.CharacterBookController characterBooks;
private games.dmg.fantasystick.persistence.CharacterStorage characters;
private AdminCharacterRecovery characterRecovery;
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics) {
@@ -380,9 +386,16 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
characterIssuer = Objects.requireNonNull(issuer);
}
public void attachCharacterBooks(games.dmg.fantasystick.book.CharacterBookController books) {
public void attachCharacterBooks(games.dmg.fantasystick.book.CharacterBookController books,
games.dmg.fantasystick.persistence.CharacterStorage storage) {
if (closed || characterBooks != null) throw new IllegalStateException("Character books already attached or command closed");
characterBooks = Objects.requireNonNull(books);
characters = Objects.requireNonNull(storage);
}
public void attachCharacterRecovery(AdminCharacterRecovery recovery) {
if (closed || characterRecovery != null) throw new IllegalStateException("Character recovery already attached or command closed");
characterRecovery = Objects.requireNonNull(recovery);
}
public void attachPartyRegistrars(games.dmg.fantasystick.party.PartyRegistrarDirectory directory,
@@ -408,13 +421,19 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (closed) return true;
if (adventureQuestCommands != null && args.length > 0 && args[0].equals("adventure")) {
adventureQuestCommands.adventure(sender, args); return true;
}
if (!sender.hasPermission(PERMISSION)) {
sender.sendMessage("Permission denied: you need fantasystick.admin to administer Fantasy Stick.");
return true;
}
if (args.length == 0) {
adminUsage(sender);
return true;
}
if (adventureQuestCommands != null && args[0].equals("adventure")) {
if (args.length >= 2 && args[1].equals("reroll")) adventureQuestCommands.adventure(sender, args);
else sender.sendMessage("Usage: /fstickadmin adventure reroll [confirm]");
return true;
}
if (adventureQuestCommands != null && args.length > 0 && args[0].equals("adventure-site")) {
adventureQuestCommands.site(sender, args); return true;
}
@@ -422,7 +441,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
adventureQuestCommands.giver(sender, args); return true;
}
if (args.length > 0 && args[0].equals("character")) {
characterView(sender, args); return true;
characterCommand(sender, args); return true;
}
if(dungeonCommands!=null&&args.length>0&&args[0].equals("dungeon")){dungeonCommands.execute(sender,args);return true;}
if(args.length>0&&args[0].equals("shop")){shop(sender,args);return true;}
@@ -454,7 +473,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
owner = UUID.fromString(args[1]);
if (!owner.toString().equalsIgnoreCase(args[1])) throw new IllegalArgumentException("Noncanonical UUID shape");
} catch (IllegalArgumentException invalid) {
sender.sendMessage("Usage: /fantasystick <inspect|recover> <player-uuid> (full UUID required)");
sender.sendMessage("Usage: /fstickadmin <inspect|recover> <player-uuid> (full UUID required)");
return true;
}
if (args[0].equals("recover")) {
@@ -506,10 +525,50 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
return true;
}
private void characterView(CommandSender sender, String[] args) {
public boolean playerAdventure(CommandSender sender, String[] args) {
if (closed) return true;
if (adventureQuestCommands == null || args.length == 0 || !args[0].equals("adventure")
|| args.length >= 2 && args[1].equals("reroll")) {
sender.sendMessage("Usage: /fstick adventure [tracking [on|off]]");
return true;
}
adventureQuestCommands.adventure(sender, args);
return true;
}
public List<String> completePlayerAdventure(CommandSender sender, String[] args) {
if (closed || adventureQuestCommands == null || args.length == 0 || !args[0].equals("adventure"))
return List.of();
return adventureQuestCommands.complete(sender, args).stream()
.filter(value -> !value.equals("reroll"))
.toList();
}
private static void adminUsage(CommandSender sender) {
sender.sendMessage("Usage: /fstickadmin <adventure reroll [confirm]|adventure-site|character view|config|dungeon|experience|give|inspect|party-registrar|quest-giver|recover|revive|shop|training-dummy> ...");
}
private void characterCommand(CommandSender sender, String[] args) {
if (args.length >= 2 && args[1].equals("find")) {
characterFind(sender, args);
return;
}
if (args.length >= 2 && args[1].equals("show")) {
characterShow(sender, args);
return;
}
if (args.length >= 2 && args[1].equals("recover")) {
characterRecover(sender, args);
return;
}
if (args.length == 3 && args[1].equals("view")
&& !(sender instanceof org.bukkit.entity.Player)) {
sender.sendMessage("Character view requires an in-game administrator.");
return;
}
if (characterBooks == null || !(sender instanceof org.bukkit.entity.Player administrator)
|| args.length != 3 || !args[1].equals("view") || args[2].isBlank()) {
sender.sendMessage("Usage: /fantasystick character view <online-player> (in-game administrator only)");
sender.sendMessage("Usage: /fstickadmin character <find <name...>|show <character-uuid>|recover <character-uuid> <online-player>|view <online-player>>");
return;
}
var matches = server.getOnlinePlayers().stream()
@@ -523,9 +582,119 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
characterBooks.openReadOnly(administrator, matches.getFirst());
}
private void characterFind(CommandSender sender, String[] args) {
if (args.length < 3 || String.join(" ", java.util.Arrays.copyOfRange(args, 2, args.length)).isBlank()) {
sender.sendMessage("Usage: /fstickadmin character find <name...>");
return;
}
String query = String.join(" ", java.util.Arrays.copyOfRange(args, 2, args.length));
String folded = query.toLowerCase(java.util.Locale.ROOT);
Object ticket = new Object();
Object requestKey = sender instanceof org.bukkit.entity.Player player
? player.getUniqueId() : EquipmentAdminCommand.class;
savedSearches.put(requestKey, ticket);
characters.catalog().whenComplete((catalog, failure) -> {
if (closed || savedSearches.get(requestKey) != ticket) return;
try { nextTick.accept(() -> {
if (closed || !savedSearches.remove(requestKey, ticket) || !sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
if (failure != null) {
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.");
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);
}
}
}); } catch (RuntimeException rejected) {
savedSearches.remove(requestKey, ticket);
report(null, "character-find-response", rejected);
}
});
}
private void characterRecover(CommandSender sender, String[] args) {
if (characterRecovery == null || !(sender instanceof org.bukkit.entity.Player administrator)) {
sender.sendMessage("Saved character recovery requires an in-game administrator.");
return;
}
UUID character;
try {
if (args.length != 4) throw new IllegalArgumentException("Invalid shape");
character = UUID.fromString(args[2]);
if (!character.toString().equals(args[2])) throw new IllegalArgumentException("Noncanonical UUID");
} catch (IllegalArgumentException invalid) {
sender.sendMessage("Usage: /fstickadmin character recover <full-character-uuid> <online-player>");
return;
}
var matches = server.getOnlinePlayers().stream().filter(org.bukkit.entity.Player::isOnline)
.filter(player -> player.getName().equals(args[3])).toList();
if (matches.size() != 1) {
sender.sendMessage("Character recovery refused: recipient must exactly name one online player.");
return;
}
characterRecovery.open(administrator, character, matches.getFirst());
}
private void characterShow(CommandSender sender, String[] args) {
if (characterBooks == null || !(sender instanceof org.bukkit.entity.Player administrator)) {
sender.sendMessage("Saved character books require an in-game administrator.");
return;
}
UUID character;
try {
if (args.length != 3) throw new IllegalArgumentException("Invalid shape");
character = UUID.fromString(args[2]);
if (!character.toString().equals(args[2])) throw new IllegalArgumentException("Noncanonical UUID");
} catch (IllegalArgumentException invalid) {
sender.sendMessage("Usage: /fstickadmin character show <full-character-uuid>");
return;
}
characterBooks.openSavedReadOnly(administrator, character);
}
private synchronized void rememberSavedCharacters(
java.util.Collection<games.dmg.fantasystick.persistence.CharacterRecord> records) {
for (var record : records.stream().sorted(java.util.Comparator.comparing(value -> value.id().toString())).toList()) {
savedCharacterCatalog.put(record.id(), record.name());
while (savedCharacterCatalog.size() > SAVED_CATALOG_COMPLETION_LIMIT)
savedCharacterCatalog.remove(savedCharacterCatalog.keySet().iterator().next());
}
}
private synchronized java.util.Map<UUID, String> savedCharacterSuggestions() {
return java.util.Map.copyOf(savedCharacterCatalog);
}
private void experience(CommandSender sender, String[] args) {
if (experienceGrant == null || args.length < 4 || !args[1].equals("give")) {
sender.sendMessage("Usage: /fantasystick experience give <active-character-name...> <positive-amount>"); return;
sender.sendMessage("Usage: /fstickadmin experience give <active-character-name...> <positive-amount>"); return;
}
BigInteger amount;
try { amount = new BigInteger(args[args.length - 1]); }
@@ -537,7 +706,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
}
String name = String.join(" ", java.util.Arrays.copyOfRange(args, 2, args.length - 1));
if (name.isBlank()) {
sender.sendMessage("Usage: /fantasystick experience give <active-character-name...> <positive-amount>"); return;
sender.sendMessage("Usage: /fstickadmin experience give <active-character-name...> <positive-amount>"); return;
}
var matches = sessions.activeCharacters().stream().filter(active -> active.record().name().equals(name)).toList();
if (matches.isEmpty()) {
@@ -571,7 +740,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (!accepted) sender.sendMessage("Experience grant refused: the captured character session changed before persistence. No fantasy XP changed.");
}
private void adminRevive(CommandSender sender,String[] args){if(args.length!=2||args[1].isBlank()||adminRevival==null||!(sender instanceof org.bukkit.entity.Player admin)){sender.sendMessage("Usage: /fantasystick revive <online-player> (an in-game administrator must select the Tail)");return;}var matches=server.getOnlinePlayers().stream().filter(player->player.isOnline()&&player.getName().equalsIgnoreCase(args[1])).toList();if(matches.size()!=1){sender.sendMessage("Admin revival refused: target must name one online player. No item changed.");return;}adminRevival.open(admin,matches.getFirst(),adminReviveSettings.timeout());}
private void adminRevive(CommandSender sender,String[] args){if(args.length!=2||args[1].isBlank()||adminRevival==null||!(sender instanceof org.bukkit.entity.Player admin)){sender.sendMessage("Usage: /fstickadmin revive <online-player> (an in-game administrator must select the Tail)");return;}var matches=server.getOnlinePlayers().stream().filter(player->player.isOnline()&&player.getName().equalsIgnoreCase(args[1])).toList();if(matches.size()!=1){sender.sendMessage("Admin revival refused: target must name one online player. No item changed.");return;}adminRevival.open(admin,matches.getFirst(),adminReviveSettings.timeout());}
private void shop(CommandSender sender,String[] args){
if(armorers==null||armorerController==null||args.length<2){shopUsage(sender);return;}
@@ -586,7 +755,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if(args[1].equals("remove")&&args.length==3){UUID id;try{id=UUID.fromString(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Fantasy Armorer UUID is invalid.");return;}armorers.remove(id).whenComplete((removed,failure)->removeShopResponse(sender,id,removed,failure));return;}
shopUsage(sender);
}
private static void shopUsage(CommandSender sender){sender.sendMessage("Usage: /fantasystick shop <place [name...]|remove <shop-uuid>|list|restock>");}
private static void shopUsage(CommandSender sender){sender.sendMessage("Usage: /fstickadmin shop <place [name...]|remove <shop-uuid>|list|restock>");}
private void placeShopResponse(org.bukkit.entity.Player player,org.bukkit.Location location,games.dmg.fantasystick.shop.FantasyArmorer saved,Throwable failure){if(failure!=null)report(player.getUniqueId(),"shop-place",failure);if(closed)return;nextTick.accept(()->{if(closed)return;if(failure!=null){if(player.isOnline())player.sendMessage("Fantasy Armorer placement failed; no registration or entity changed.");return;}try{int x=location.getBlockX()>>4,z=location.getBlockZ()>>4;if(location.getWorld().isChunkLoaded(x,z))armorerController.reconcile(location.getWorld().getChunkAt(x,z));if(player.isOnline())player.sendMessage("Placed Fantasy Armorer "+saved.id()+".");}catch(RuntimeException entityFailure){report(player.getUniqueId(),"shop-entity",entityFailure);armorers.remove(saved.id()).whenComplete((ignored,rollback)->nextTick.accept(()->{armorerController.removeLoaded(saved.id());if(player.isOnline())player.sendMessage("Fantasy Armorer placement failed; no registration or entity changed.");}));}});}
private void removeShopResponse(CommandSender sender,UUID id,java.util.Optional<games.dmg.fantasystick.shop.FantasyArmorer> removed,Throwable failure){if(failure!=null)report(null,"shop-remove",failure);if(closed)return;nextTick.accept(()->{if(failure!=null)sender.sendMessage("Fantasy Armorer removal failed; registration and entity are unchanged.");else if(removed.isEmpty())sender.sendMessage("Fantasy Armorer not found.");else try{armorerController.removeLoaded(id);sender.sendMessage("Removed Fantasy Armorer "+id+".");}catch(RuntimeException entityFailure){var value=removed.orElseThrow();report(null,"shop-entity-remove",entityFailure);armorers.restore(value).whenComplete((restored,rollback)->{if(rollback!=null)report(null,"shop-remove-rollback",rollback);if(closed)return;nextTick.accept(()->{if(rollback==null){var world=server.getWorld(value.world());if(world!=null&&world.isChunkLoaded(((int)Math.floor(value.x()))>>4,((int)Math.floor(value.z()))>>4))armorerController.reconcile(world.getChunkAt(((int)Math.floor(value.x()))>>4,((int)Math.floor(value.z()))>>4));sender.sendMessage("Fantasy Armorer removal failed; registration and entity are unchanged.");}else sender.sendMessage("Fantasy Armorer removal rollback failed; inspect diagnostics.");});});}});}
@@ -622,7 +791,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
}
private static void trainingDummyUsage(CommandSender sender) {
sender.sendMessage("Usage: /fantasystick training-dummy <place [name...]|remove <dummy-uuid>|list>");
sender.sendMessage("Usage: /fstickadmin training-dummy <place [name...]|remove <dummy-uuid>|list>");
}
private void placeDummyResponse(org.bukkit.entity.Player player, org.bukkit.Location location,
@@ -703,16 +872,16 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void placeRegistrarResponse(org.bukkit.entity.Player player,org.bukkit.Location location,games.dmg.fantasystick.party.PartyRegistrar saved,Throwable failure){if(failure!=null)report(player.getUniqueId(),"party-registrar-place",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure!=null){if(player.isOnline())player.sendMessage("Party Registrar placement failed; no registration or entity changed.");return;}try{int chunkX=location.getBlockX()>>4,chunkZ=location.getBlockZ()>>4;if(location.getWorld().isChunkLoaded(chunkX,chunkZ))partyRegistrarController.reconcile(location.getWorld().getChunkAt(chunkX,chunkZ));if(player.isOnline())player.sendMessage("Placed Party Registrar "+saved.id()+".");}catch(RuntimeException entityFailure){report(player.getUniqueId(),"party-registrar-entity",entityFailure);partyRegistrars.remove(saved.id()).whenComplete((ignored,rollbackFailure)->{if(rollbackFailure!=null)report(player.getUniqueId(),"party-registrar-rollback",rollbackFailure);if(closed)return;nextTick.accept(()->{partyRegistrarController.removeLoaded(saved.id());if(player.isOnline())player.sendMessage("Party Registrar placement failed; no registration or entity changed.");});});}});}catch(RuntimeException rejected){report(player.getUniqueId(),"party-registrar-response",rejected);}}
private void removeRegistrarResponse(CommandSender sender,UUID id,java.util.Optional<games.dmg.fantasystick.party.PartyRegistrar> removed,Throwable failure){if(failure!=null)report(null,"party-registrar-remove",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure!=null)sender.sendMessage("Party Registrar removal failed; registration and entity are unchanged.");else if(removed.isEmpty())sender.sendMessage("Party Registrar not found.");else try{partyRegistrarController.removeLoaded(id);sender.sendMessage("Removed Party Registrar "+id+".");}catch(RuntimeException entityFailure){report(null,"party-registrar-entity",entityFailure);partyRegistrars.restore(removed.orElseThrow()).whenComplete((restored,rollbackFailure)->{if(rollbackFailure!=null)report(null,"party-registrar-rollback",rollbackFailure);if(closed)return;nextTick.accept(()->{if(rollbackFailure==null){var world=server.getWorld(restored.world());int chunkX=((int)Math.floor(restored.x()))>>4,chunkZ=((int)Math.floor(restored.z()))>>4;if(world!=null&&world.isChunkLoaded(chunkX,chunkZ))partyRegistrarController.reconcile(world.getChunkAt(chunkX,chunkZ));}sender.sendMessage("Party Registrar removal failed; registration and entity are unchanged.");});});}});}catch(RuntimeException rejected){report(null,"party-registrar-response",rejected);}}
private void partyRegistrar(CommandSender sender,String[] args){
if(partyRegistrars==null||partyRegistrarController==null||args.length<2){sender.sendMessage("Usage: /fantasystick party-registrar <place [name...]|remove <registrar-uuid>|list>");return;}
if(partyRegistrars==null||partyRegistrarController==null||args.length<2){sender.sendMessage("Usage: /fstickadmin party-registrar <place [name...]|remove <registrar-uuid>|list>");return;}
if(args[1].equals("list")&&args.length==2){var values=partyRegistrars.all();if(values.isEmpty())sender.sendMessage("No Party Registrars are registered.");else for(var value:values)sender.sendMessage(value.id()+" "+(value.name().isBlank()?"Party Registrar":value.name())+" @ "+value.world()+" "+value.x()+" "+value.y()+" "+value.z());return;}
if(args[1].equals("place")&&sender instanceof org.bukkit.entity.Player player){String name=args.length<3?"":String.join(" ",java.util.Arrays.copyOfRange(args,2,args.length));var location=player.getLocation().clone();java.util.concurrent.CompletableFuture<games.dmg.fantasystick.party.PartyRegistrar> operation;try{operation=partyRegistrars.place(name,location.getWorld().getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch());}catch(IllegalArgumentException invalid){sender.sendMessage("Party Registrar name is invalid.");return;}operation.whenComplete((saved,failure)->placeRegistrarResponse(player,location,saved,failure));return;}
if(args[1].equals("remove")&&args.length==3){UUID id;try{id=UUID.fromString(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Party Registrar UUID is invalid.");return;}partyRegistrars.remove(id).whenComplete((removed,failure)->removeRegistrarResponse(sender,id,removed,failure));return;}
sender.sendMessage("Usage: /fantasystick party-registrar <place [name...]|remove <registrar-uuid>|list>");
sender.sendMessage("Usage: /fstickadmin party-registrar <place [name...]|remove <registrar-uuid>|list>");
}
private void give(CommandSender sender, String[] args) {
if (args.length != 2 || args[1].isBlank() || characterIssuer == null) {
sender.sendMessage("Usage: /fantasystick give <online-player>"); return;
sender.sendMessage("Usage: /fstickadmin give <online-player>"); return;
}
var matches = server.getOnlinePlayers().stream().filter(player -> player.isOnline()
&& player.getName().equalsIgnoreCase(args[1])).toList();
@@ -1413,7 +1582,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void partyConfiguration(CommandSender sender,String[] args){if(partySettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.PartySettings.KEY+"="+partySettings.value()+" (existing parties are retained)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.PartySettings.Update> operation;try{operation=partySettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Party maximum is invalid; enter a positive whole number.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-party",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=false;if(failure==null)applied=partySettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Party configuration save failed; the effective limit and rosters are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+games.dmg.fantasystick.persistence.PartySettings.KEY+"="+partySettings.value()+".");else sender.sendMessage("Saved "+games.dmg.fantasystick.persistence.PartySettings.KEY+"="+saved.value()+"; existing members are retained.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void roleplayConfiguration(CommandSender sender,String[] args){if(roleplaySettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+roleplaySettings.value());return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.RoleplaySettings.Update> operation;try{operation=roleplaySettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Usage: /fantasystick config roleplay-disabled [true|false]");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-roleplay",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=false;if(failure==null){applied=roleplaySettings.publish(saved);if(applied&&saved.disabled())sessions.disableRoleplay();}if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Roleplay configuration save failed; policy and sessions are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+roleplaySettings.value()+".");else sender.sendMessage("Saved "+games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+saved.disabled()+(saved.disabled()?"; ordinary exits were requested for active sessions.":"; normal sheet activation is available."));});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void roleplayConfiguration(CommandSender sender,String[] args){if(roleplaySettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+roleplaySettings.value());return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.RoleplaySettings.Update> operation;try{operation=roleplaySettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Usage: /fstickadmin config roleplay-disabled [true|false]");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-roleplay",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=false;if(failure==null){applied=roleplaySettings.publish(saved);if(applied&&saved.disabled())sessions.disableRoleplay();}if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Roleplay configuration save failed; policy and sessions are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+roleplaySettings.value()+".");else sender.sendMessage("Saved "+games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+saved.disabled()+(saved.disabled()?"; ordinary exits were requested for active sessions.":"; normal sheet activation is available."));});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void switchDelayConfiguration(CommandSender sender,String[] args){if(switchDelaySettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY+"="+switchDelaySettings.value()+" (new exits use this delay; existing deadlines are unchanged)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SwitchDelaySettings.Update> operation;try{operation=switchDelaySettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Character switch delay is invalid; the effective value is unchanged.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-switch-delay",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure==null)switchDelaySettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;sender.sendMessage(failure==null?"Saved "+games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY+"="+saved.value()+"; new exits use it and existing deadlines are unchanged.":"Character switch delay save failed; the effective value is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
@@ -1421,7 +1590,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void adventureConfiguration(CommandSender sender, String[] args) {
if (args.length == 3 && !args[2].equals("true") && !args[2].equals("false")) {
sender.sendMessage("Usage: /fantasystick config adventure-mode [true|false]"); return;
sender.sendMessage("Usage: /fstickadmin config adventure-mode [true|false]"); return;
}
if (settings == null) { sender.sendMessage("Adventure configuration is unavailable."); return; }
if (args.length == 2) {
@@ -1443,7 +1612,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
}
private static void configurationUsage(CommandSender sender) {
sender.sendMessage("Usage: /fantasystick config <setting> [value]");
sender.sendMessage("Usage: /fstickadmin config <setting> [value]");
}
private void report(UUID owner, String operation, Throwable cause) {
@@ -1454,20 +1623,37 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
// Return only explicit implemented routes; never fall back to Bukkit's broad default suggestions.
@Override public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
if (closed) return List.of();
if (adventureQuestCommands != null) {
if (args.length == 1 && !sender.hasPermission(PERMISSION)) return matching(List.of("adventure"), args[0]);
if (args.length > 1) {
var adventure = adventureQuestCommands.complete(sender,args); if(!adventure.isEmpty()) return adventure;
if(args[0].equals("adventure")||args[0].equals("adventure-site")||args[0].equals("quest-giver"))return List.of();
}
}
if (!sender.hasPermission(PERMISSION)) return List.of();
if (adventureQuestCommands != null && args.length > 1) {
if (args[0].equals("adventure")) {
if (args.length == 2) return matching(List.of("reroll"), args[1]);
if (args.length == 3 && args[1].equals("reroll")) return matching(List.of("confirm"), args[2]);
return List.of();
}
var adventure = adventureQuestCommands.complete(sender,args); if(!adventure.isEmpty()) return adventure;
if(args[0].equals("adventure-site")||args[0].equals("quest-giver"))return List.of();
}
if(dungeonCommands!=null&&args.length>1){var dungeon=dungeonCommands.complete(sender,args);if(!dungeon.isEmpty())return dungeon;if(args[0].equals("dungeon"))return List.of();}
if (args.length == 1) return matching(sender instanceof org.bukkit.entity.Player
? List.of("adventure", "adventure-site", "character", "config", "dungeon", "experience", "give", "inspect", "party-registrar", "quest-giver", "recover", "revive", "shop", "training-dummy")
: List.of("adventure-site", "config", "dungeon", "experience", "give", "inspect", "party-registrar", "quest-giver", "recover", "shop", "training-dummy"), args[0]);
if (args.length == 2 && args[0].equals("character") && sender instanceof org.bukkit.entity.Player)
return matching(List.of("view"), args[1]);
if (args.length == 2 && args[0].equals("character")) {
var routes = new java.util.ArrayList<>(List.of("find"));
if (sender instanceof org.bukkit.entity.Player) routes.addAll(List.of("recover", "show", "view"));
return matching(routes, args[1]);
}
if (args.length >= 3 && args[0].equals("character") && args[1].equals("find")) {
String prefix = String.join(" ", java.util.Arrays.copyOfRange(args, 2, args.length));
return matchingIgnoreCase(savedCharacterSuggestions().values(), prefix);
}
if (args.length == 3 && args[0].equals("character")
&& (args[1].equals("show") || args[1].equals("recover"))
&& sender instanceof org.bukkit.entity.Player)
return matching(savedCharacterSuggestions().keySet().stream().map(UUID::toString).toList(), args[2]);
if (args.length == 4 && args[0].equals("character") && args[1].equals("recover")
&& sender instanceof org.bukkit.entity.Player)
return matching(server.getOnlinePlayers().stream().filter(org.bukkit.entity.Player::isOnline)
.map(org.bukkit.entity.Player::getName).toList(), args[3]);
if (args.length == 3 && args[0].equals("character") && args[1].equals("view")
&& sender instanceof org.bukkit.entity.Player)
return matching(server.getOnlinePlayers().stream().filter(org.bukkit.entity.Player::isOnline)
@@ -1666,5 +1852,12 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private static List<String> matching(java.util.Collection<String> values, String prefix) {
return values.stream().filter(value -> value.startsWith(prefix)).distinct().sorted().toList();
}
@Override public void close() { closed = true; if (characterIssuer != null) characterIssuer.close(); if(adminRevival!=null)adminRevival.close(); if(experienceGrant!=null)experienceGrant.close(); }
private static List<String> matchingIgnoreCase(java.util.Collection<String> values, String prefix) {
String folded = prefix.toLowerCase(java.util.Locale.ROOT);
return values.stream().filter(value -> value.toLowerCase(java.util.Locale.ROOT).startsWith(folded))
.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(); }
}
@@ -60,6 +60,8 @@ public final class EquipmentSessions implements AutoCloseable {
private final Map<UUID, Attempt> pending = new HashMap<>();
private final Map<UUID, Attempt> owned = new HashMap<>();
private final Map<UUID, Attempt> active = new HashMap<>();
private final Map<UUID, UUID> characterRecoveries = new HashMap<>();
private final java.util.Set<UUID> recoveryRecipients = new java.util.HashSet<>();
private final java.util.ArrayDeque<UUID> cooldownRefreshOrder = new java.util.ArrayDeque<>();
private final java.util.Set<UUID> cooldownRefreshQueued = new java.util.HashSet<>();
// Covers reentrant inventory setters and client sync, not just asynchronous work.
@@ -78,6 +80,7 @@ public final class EquipmentSessions implements AutoCloseable {
final Player player;
final UUID owner;
UUID character;
CharacterSheets.Identity sheetIdentity;
CharacterRecord record;
SessionClaims.Claim claim;
UUID session;
@@ -250,12 +253,55 @@ public final class EquipmentSessions implements AutoCloseable {
return attempt == null ? Optional.empty() : Optional.of(new Active(player, attempt.session, attempt.character));
}
/** Server-thread recovery gate; persistence still revalidates revision/generation/death atomically. */
public synchronized boolean availableForCharacterRecovery(UUID character, UUID recipient) {
Objects.requireNonNull(character); Objects.requireNonNull(recipient);
return availableForCharacterRecoveryNow(character, recipient)
&& !characterRecoveries.containsKey(character) && !recoveryRecipients.contains(recipient);
}
/** Process-lifetime custody closes the entry race while durable recovery is prepared and delivered. */
public synchronized Optional<CharacterRecoveryClaim> claimCharacterRecovery(UUID character, UUID recipient) {
Objects.requireNonNull(character); Objects.requireNonNull(recipient);
if (!availableForCharacterRecovery(character, recipient)) return Optional.empty();
characterRecoveries.put(character, recipient);
recoveryRecipients.add(recipient);
return Optional.of(new CharacterRecoveryClaim(character, recipient));
}
private boolean availableForCharacterRecoveryNow(UUID character, UUID recipient) {
if (closed || unavailableCharacter.test(character) || active.containsKey(recipient)
|| pending.containsKey(recipient) || transitioning.containsKey(recipient)
|| owned.containsKey(recipient)) return false;
return java.util.stream.Stream.of(active.values(), pending.values(), transitioning.values(), owned.values())
.flatMap(java.util.Collection::stream)
.noneMatch(attempt -> character.equals(attempt.character));
}
public final class CharacterRecoveryClaim implements AutoCloseable {
private final UUID character;
private final UUID recipient;
private boolean released;
private CharacterRecoveryClaim(UUID character, UUID recipient) {
this.character = character;
this.recipient = recipient;
}
@Override public void close() {
synchronized (EquipmentSessions.this) {
if (released) return;
released = true;
characterRecoveries.remove(character, recipient);
recoveryRecipients.remove(recipient);
}
}
}
/** Server-thread effect authorization: registry identity alone is not live custody. */
public synchronized boolean current(Active session) {
var attempt = active.get(session.player());
return !closed && attempt != null && attempt.session.equals(session.session())
&& attempt.character.equals(session.character()) && attempt.player.isOnline()
&& sheets.identify(attempt.player.getInventory().getItemInOffHand()).filter(attempt.character::equals).isPresent();
&& attempt.character.equals(session.character()) && attempt.player.isOnline() && attempt.record != null
&& sheets.authorizes(attempt.player.getInventory().getItemInOffHand(), attempt.record);
}
/** Side-effect-free server-thread snapshots for exact active-character command resolution/autocomplete. */
@@ -403,7 +449,7 @@ public final class EquipmentSessions implements AutoCloseable {
public synchronized Optional<SelectedControl> selectedControl(Player player, ItemStack reported) {
var attempt = active.get(player.getUniqueId());
if (closed || attempt == null || attempt.player != player || !player.isOnline() || attempt.record == null
|| !sheets.identify(player.getInventory().getItemInOffHand()).filter(attempt.character::equals).isPresent()) {
|| !sheets.authorizes(player.getInventory().getItemInOffHand(), attempt.record)) {
return Optional.empty();
}
int slot = player.getInventory().getHeldItemSlot();
@@ -422,6 +468,10 @@ public final class EquipmentSessions implements AutoCloseable {
public synchronized void enter(Player player) {
UUID owner = player.getUniqueId();
if (closed) return;
if (recoveryRecipients.contains(owner)) {
notice(owner, "CHARACTER_RECOVERY", "Administrative character recovery is still completing.");
return;
}
if (roleplayDisabled.getAsBoolean()) {
notice(owner, "ROLEPLAY_DISABLED", "Fantasy roleplaying is disabled; the character sheet was not activated.");
return;
@@ -440,16 +490,22 @@ public final class EquipmentSessions implements AutoCloseable {
notice(owner, "BUSY", "Equipment persistence or recovery is pending; retry after it completes.");
return;
}
var character = sheets.identify(player.getInventory().getItemInOffHand());
if (!player.isOnline() || character.isEmpty()) {
var identity = sheets.identity(player.getInventory().getItemInOffHand());
if (!player.isOnline() || identity.isEmpty()) {
notice(owner, "INVALID_SHEET", "Hold a valid live character sheet in your off hand.");
return;
}
if (unavailableCharacter.test(character.orElseThrow())) {
var character = identity.orElseThrow().character();
if (characterRecoveries.containsKey(character)) {
notice(owner, "CHARACTER_RECOVERY", "That character has unresolved administrative recovery custody.");
return;
}
if (unavailableCharacter.test(character)) {
notice(owner, "CHARACTER_IN_CUSTODY", "That character sheet is assigned to a dungeon boss and cannot be activated.");
return;
}
var attempt = new Attempt(player, owner, character.orElseThrow());
var attempt = new Attempt(player, owner, character);
attempt.sheetIdentity = identity.orElseThrow();
if (adventureMode.getAsBoolean() && player.getGameMode() != org.bukkit.GameMode.ADVENTURE) {
attempt.previousGameMode = player.getGameMode().name();
}
@@ -506,6 +562,11 @@ public final class EquipmentSessions implements AutoCloseable {
if (record == null || record.dead() || record.health().signum() <= 0) {
refuse(attempt, "UNAVAILABLE_CHARACTER", "The saved character is missing, dead or has no health."); return;
}
if (!sheets.authorizes(attempt.player.getInventory().getItemInOffHand(), record)
|| attempt.sheetIdentity == null || !attempt.sheetIdentity.equals(
sheets.identity(attempt.player.getInventory().getItemInOffHand()).orElse(null))) {
refuse(attempt, "STALE_SHEET", "That character sheet generation is no longer authorized."); return;
}
if (loaded.state().isPresent()) {
var state = loaded.state().orElseThrow();
if (state.pending() != null) {
@@ -824,10 +885,11 @@ public final class EquipmentSessions implements AutoCloseable {
private boolean tailCustody(Attempt attempt) {
var current = attempt.player.getInventory().getItemInOffHand();
var authority = attempt.deathRecord == null ? attempt.record : attempt.deathRecord;
return attempt.deathReference != null && attempt.deathNativeReference != null
&& Objects.equals(attempt.deathReference, copy(current))
&& nativeIdentity(current) == attempt.deathNativeReference
&& sheets.identify(attempt.deathReference).filter(attempt.character::equals).isPresent()
&& authority != null && sheets.authorizes(attempt.deathReference, authority)
&& attempt.deathReference.getAmount() >= 1
&& attempt.deathReference.getAmount() <= attempt.deathReference.getMaxStackSize();
}
@@ -1042,7 +1104,8 @@ public final class EquipmentSessions implements AutoCloseable {
|| !Objects.equals(attempt.entryMarker, EquipmentSessionMarker.read(attempt.player).orElse(null)))
|| !Arrays.equals(attempt.snapshot, snapshot(attempt.player))
|| !Objects.equals(attempt.cursor, copy(attempt.player.getItemOnCursor()))
|| !sheets.identify(attempt.player.getInventory().getItemInOffHand()).filter(attempt.character::equals).isPresent()) {
|| attempt.sheetIdentity != null && !attempt.sheetIdentity.equals(
sheets.identity(attempt.player.getInventory().getItemInOffHand()).orElse(null))) {
refuse(attempt, "STALE_ENTRY", "Entry changed before preparation completed; retry with your current equipment.");
return false;
}
@@ -1210,6 +1273,8 @@ public final class EquipmentSessions implements AutoCloseable {
}
for (var owner : java.util.List.copyOf(pending.keySet())) cancelEntry(owner);
for (var attempt : java.util.List.copyOf(active.values())) exit(attempt.player);
characterRecoveries.clear();
recoveryRecipients.clear();
claims.close();
} finally {
closeSweepFinished = true;
@@ -0,0 +1,56 @@
package games.dmg.fantasystick.session;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabExecutor;
import org.bukkit.entity.Player;
/** Strict ordinary-player command root for personal adventure and presentation controls. */
public final class FantasyPlayerCommand implements TabExecutor {
private final EquipmentAdminCommand adventures;
private final FantasyFeedbackCommand feedback;
private final FantasyParticlesCommand particles;
public FantasyPlayerCommand(EquipmentAdminCommand adventures, FantasyFeedbackCommand feedback,
FantasyParticlesCommand particles) {
this.adventures = Objects.requireNonNull(adventures);
this.feedback = Objects.requireNonNull(feedback);
this.particles = Objects.requireNonNull(particles);
}
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (args.length == 0) {
if (!(sender instanceof Player)) sender.sendMessage("Fantasy Stick player controls require an in-game player.");
return false;
}
return switch (args[0]) {
case "adventure" -> adventures.playerAdventure(sender, args);
case "feedback" -> feedback.onCommand(sender, command, label, tail(args));
case "particles" -> particles.onCommand(sender, command, label, tail(args));
default -> false;
};
}
@Override public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
if (!(sender instanceof Player)) return List.of();
if (args.length == 1) return matching(List.of("adventure", "feedback", "particles"), args[0]);
return switch (args[0]) {
case "adventure" -> adventures.completePlayerAdventure(sender, args);
case "feedback" -> feedback.onTabComplete(sender, command, alias, tail(args));
case "particles" -> particles.onTabComplete(sender, command, alias, tail(args));
default -> List.of();
};
}
private static String[] tail(String[] args) {
return java.util.Arrays.copyOfRange(args, 1, args.length);
}
private static List<String> matching(List<String> values, String rawPrefix) {
String prefix = rawPrefix.toLowerCase(Locale.ROOT);
return values.stream().filter(value -> value.startsWith(prefix)).toList();
}
}
+20 -20
View File
@@ -1,5 +1,5 @@
# Captured when a new fantasy session starts; changing this does not alter active sessions.
# Admin command: /fantasystick config adventure-mode [true|false]
# Admin command: /fstickadmin config adventure-mode [true|false]
adventure-mode: true
admin-revive-selection-timeout-seconds: 60
@@ -32,7 +32,7 @@ sheet-resource-pack-url: ''
sheet-resource-pack-sha1: ''
# Active-session recovery after actual accepted damage has stopped.
# Admin command: /fantasystick config <key> [value]
# Admin command: /fstickadmin config <key> [value]
out-of-combat-healing-delay-seconds: 10
out-of-combat-healing-interval-seconds: 10
out-of-combat-healing-amount: 1
@@ -75,7 +75,7 @@ adventure-champion-reward-xp: 150
adventure-champion-reward-coins: 40
# Fireball defaults from the reviewed starter catalogue.
# Admin command: /fantasystick config <key> [value]
# Admin command: /fstickadmin config <key> [value]
fireball-base-damage: 6
fireball-cooldown-seconds: 1.2
fireball-range-blocks: 32
@@ -87,7 +87,7 @@ great-sword-base-damage: 7
great-sword-recovery-seconds: 0.75
# Frost Shard defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
# Admin command: /fstickadmin config <key> [value]
frost-shard-base-damage: 4
frost-shard-cooldown-seconds: 5
frost-shard-range-blocks: 32
@@ -97,7 +97,7 @@ frost-shard-slowness-amplifier: 0
frost-shard-slowness-duration-seconds: 2
# Longbow defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
# Admin command: /fstickadmin config <key> [value]
longbow-base-damage: 5
longbow-recovery-seconds: 1
longbow-minimum-draw-ticks: 3
@@ -107,24 +107,24 @@ longbow-lifetime-seconds: 10
longbow-full-projectile-speed-blocks-per-tick: 3
# Quickstep defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
# Admin command: /fstickadmin config <key> [value]
quickstep-cooldown-seconds: 5
quickstep-distance-blocks: 4
quickstep-speed-blocks-per-tick: 1
# Second Wind defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
# Admin command: /fstickadmin config <key> [value]
second-wind-base-healing: 6
second-wind-cooldown-seconds: 15
# Stone Skin defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
# Admin command: /fstickadmin config <key> [value]
stone-skin-cooldown-seconds: 14
stone-skin-duration-seconds: 4
stone-skin-damage-reduction-percent: 25
# Shield Bash defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
# Admin command: /fstickadmin config <key> [value]
shield-bash-base-damage: 4
shield-bash-cooldown-seconds: 3
shield-bash-range-blocks: 3
@@ -132,39 +132,39 @@ shield-bash-horizontal-knockback: 0.8
shield-bash-minimum-vertical-knockback: 0.35
# Hex defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
# Admin command: /fstickadmin config <key> [value]
hex-cooldown-seconds: 10
hex-range-blocks: 12
hex-duration-seconds: 4
hex-damage-reduction-percent: 20
# Inspire defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
# Admin command: /fstickadmin config <key> [value]
inspire-base-temporary-health: 4
inspire-cooldown-seconds: 15
inspire-range-blocks: 12
inspire-duration-seconds: 6
# Mending Light defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
# Admin command: /fstickadmin config <key> [value]
mending-light-base-healing: 6
mending-light-cooldown-seconds: 8
mending-light-range-blocks: 12
# Purify defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
# Admin command: /fstickadmin config <key> [value]
purify-cooldown-seconds: 15
purify-range-blocks: 12
# Guardian Bond defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
# Admin command: /fstickadmin config <key> [value]
guardian-bond-cooldown-seconds: 20
guardian-bond-range-blocks: 10
guardian-bond-duration-seconds: 6
guardian-bond-redirect-percent: 30
# Rallying Cry defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
# Admin command: /fstickadmin config <key> [value]
rallying-cry-cooldown-seconds: 25
rallying-cry-radius-blocks: 8
rallying-cry-duration-seconds: 6
@@ -172,7 +172,7 @@ rallying-cry-speed-amplifier: 0
rallying-cry-damage-bonus-percent: 20
# Frost Nova defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
# Admin command: /fstickadmin config <key> [value]
frost-nova-base-damage: 5
frost-nova-cooldown-seconds: 12
frost-nova-radius-blocks: 5
@@ -180,7 +180,7 @@ frost-nova-slowness-amplifier: 1
frost-nova-slowness-duration-seconds: 3
# Chain Lightning defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
# Admin command: /fstickadmin config <key> [value]
chain-lightning-base-damage: 8
chain-lightning-cooldown-seconds: 18
chain-lightning-initial-range-blocks: 16
@@ -189,7 +189,7 @@ chain-lightning-additional-targets: 3
chain-lightning-jump-multiplier: 0.75
# Sanctuary defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
# Admin command: /fstickadmin config <key> [value]
sanctuary-cooldown-seconds: 45
sanctuary-radius-blocks: 6
sanctuary-vertical-range-blocks: 3
@@ -199,7 +199,7 @@ sanctuary-healing-per-pulse: 2
sanctuary-damage-reduction-percent: 20
# War Banner defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
# Admin command: /fstickadmin config <key> [value]
war-banner-cooldown-seconds: 45
war-banner-placement-range-blocks: 12
war-banner-radius-blocks: 8
@@ -209,7 +209,7 @@ war-banner-damage-bonus-percent: 25
war-banner-knockback-resistance-percent: 50
# Meteor defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
# Admin command: /fstickadmin config <key> [value]
meteor-base-damage: 14
meteor-cooldown-seconds: 45
meteor-target-range-blocks: 24
+7 -10
View File
@@ -6,16 +6,13 @@ depend: [ProtocolLib]
description: Transferable characters, native fantasy health, recoverable equipment, character books and parties.
author: dmg.games
commands:
fantasystick:
description: Inspect daily adventures or administer Fantasy Stick.
usage: /fantasystick adventure [reroll confirm] | character view <online-player> | adventure-site <place <camp|champion> [name...]|remove <site-uuid>|teleport <site-uuid>|list> | dungeon <teleport <dungeon-uuid>|...> | quest-giver <place [name...]|remove <giver-uuid>|list> | <admin route>
fantasyfeedback:
description: Read or change routine Fantasy Stick combat and ability feedback for your player account.
usage: /fantasyfeedback [on|off]
fantasyparticles:
description: Read or change Fantasy Stick particle visibility for your player account.
usage: /fantasyparticles <all|players> [on|off]
fstick:
description: Inspect personal adventures and manage Fantasy Stick player preferences.
usage: /fstick adventure [tracking [on|off]] | feedback [on|off] | particles <all|players> [on|off]
fstickadmin:
description: Administer Fantasy Stick.
usage: /fstickadmin adventure reroll [confirm] | character view <online-player> | adventure-site <place <camp|champion> [name...]|remove <site-uuid>|teleport <site-uuid>|list> | dungeon <teleport <dungeon-uuid>|...> | quest-giver <place [name...]|remove <giver-uuid>|list> | <admin route>
permission: fantasystick.admin
permissions:
fantasystick.admin:
description: Issue/revive sheets, grant fantasy experience, recover sessions and configure fantasy mode.
@@ -275,8 +275,8 @@ class AdminCharacterBookNativeTest {
assertEquals(java.util.List.of("character", "config"),
fixture.command.tabComplete(admin.player, "fantasystick", new String[]{"c"}));
assertEquals(java.util.List.of("view"),
fixture.command.tabComplete(admin.player, "fantasystick", new String[]{"character", ""}));
assertEquals(java.util.List.of("find", "recover", "show", "view"),
fixture.command.tabComplete(admin.player, "fstickadmin", new String[]{"character", ""}));
assertEquals(java.util.List.of("ActiveTarget"),
fixture.command.tabComplete(admin.player, "fantasystick", new String[]{"character", "view", ""}));
assertEquals(java.util.List.of("ActiveTarget"),
@@ -293,8 +293,8 @@ class AdminCharacterBookNativeTest {
admin.allowed = true;
var console = new EquipmentAdminCommandNativeTest.Sender();
assertTrue(fixture.command.tabComplete(console.port, "fantasystick",
new String[]{"character", ""}).isEmpty());
assertEquals(java.util.List.of("find"), fixture.command.tabComplete(console.port, "fstickadmin",
new String[]{"character", ""}));
fixture.command.execute(console.port, "fantasystick",
new String[]{"character", "view", "ActiveTarget"});
assertTrue(console.messages.getLast().contains("in-game administrator"));
@@ -0,0 +1,365 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.nio.file.Path;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import org.bukkit.Material;
import org.bukkit.event.inventory.InventoryAction;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryType;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Registered confirmed reissue/revival through durable generation authority and native delivery. */
class AdminCharacterRecoveryNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void confirmedLivingRecoveryRevokesOldSheetPreservesCharacterAndActivatesReplacement() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var administrator = actor(data, "Admin");
var recipient = actor(data, "Recipient");
var source = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(administrator.core.id, administrator.player);
fixture.players.put(recipient.core.id, recipient.player);
var storage = characters(fixture);
var original = storage.renameCharacter(source.core.record.id(), "Recovered Hero")
.get(5, TimeUnit.SECONDS).orElseThrow();
var sheets = new games.dmg.fantasystick.items.CharacterSheets();
var issued = sheets.create(original);
var legacy = issued.clone();
var legacyMeta = legacy.getItemMeta();
legacyMeta.getPersistentDataContainer().remove(
new org.bukkit.NamespacedKey("fantasystick", "sheet_generation"));
legacy.setItemMeta(legacyMeta);
assertTrue(sheets.authorizes(legacy, original));
var malformed = issued.clone();
var malformedMeta = malformed.getItemMeta();
malformedMeta.getPersistentDataContainer().set(
new org.bukkit.NamespacedKey("fantasystick", "sheet_generation"),
org.bukkit.persistence.PersistentDataType.STRING, "not-a-generation");
malformed.setItemMeta(malformedMeta);
assertFalse(sheets.authorizes(malformed, original));
var oldSheet = legacy;
int destination = recipient.core.inventory.firstEmpty();
assertTrue(fixture.adminCommand.execute(administrator.player, "fstickadmin",
new String[]{"character", "find", "Recovered"}));
drain(fixture, recipient.core.id);
assertEquals(java.util.List.of(original.id().toString()), fixture.adminCommand.tabComplete(
administrator.player, "fstickadmin", new String[]{"character", "recover", original.id().toString().substring(0, 8)}));
assertEquals(java.util.List.of("Recipient"), fixture.adminCommand.tabComplete(
administrator.player, "fstickadmin", new String[]{"character", "recover", original.id().toString(), "Rec"}));
administrator.allowed = false;
assertTrue(fixture.adminCommand.tabComplete(administrator.player, "fstickadmin",
new String[]{"character", "recover", ""}).isEmpty());
administrator.allowed = true;
assertTrue(fixture.adminCommand.execute(administrator.player, "fstickadmin",
new String[]{"character", "recover", original.id().toString(), "Recipient"}));
var recovery = FantasyStickLifecycleNativeTest.Fixture.field(
fixture.adminCommand.getExecutor(), "characterRecovery");
assertEquals(1, ((java.util.Map<?, ?>) FantasyStickLifecycleNativeTest.Fixture.field(
recovery, "pending")).size());
drain(fixture, recipient.core.id);
assertEquals(1, administrator.inventories.size(), () -> administrator.messages + " pending="
+ ((java.util.Map<?, ?>) FantasyStickLifecycleNativeTest.Fixture.field(recovery, "pending")).size()
+ " tasks=" + fixture.tasks.size());
var preview = administrator.inventories.getLast();
assertEquals(Material.PAPER, preview.getItem(2).getType());
assertEquals(Material.BARRIER, preview.getItem(6).getType());
String previewText = String.valueOf(preview.getItem(2).getItemMeta().lore());
assertTrue(previewText.contains(original.id().toString()));
assertTrue(previewText.contains(recipient.core.id.toString()));
click(fixture, administrator, preview, 2);
recipient.core.inventory.setItemInOffHand(oldSheet);
sessions(fixture).enter(recipient.player);
assertTrue(sessions(fixture).active(recipient.core.id).isEmpty());
assertTrue(recipient.messages.stream().anyMatch(message -> message.contains("recovery is still completing")));
recipient.core.inventory.setItemInOffHand(null);
drain(fixture, recipient.core.id);
var recovered = storage.inspect(original.id()).get(5, TimeUnit.SECONDS).orElseThrow();
assertEquals(original.id(), recovered.id());
assertEquals(original.name(), recovered.name());
assertEquals(original.stats(), recovered.stats());
assertEquals(original.level(), recovered.level());
assertEquals(original.experience(), recovered.experience());
assertEquals(original.unspentPoints(), recovered.unspentPoints());
assertEquals(original.health(), recovered.health());
assertEquals(original.learned(), recovered.learned());
assertEquals(original.loadout(), recovered.loadout());
assertEquals(original.passives(), recovered.passives());
assertEquals(original.cooldowns(), recovered.cooldowns());
assertEquals(original.coins(), recovered.coins());
assertEquals(original.gear(), recovered.gear());
assertEquals(original.sheetGeneration().add(java.math.BigInteger.ONE), recovered.sheetGeneration());
var replacement = recipient.core.inventory.getItem(destination);
assertTrue(sheets.authorizes(replacement, recovered), () -> administrator.messages
+ " item=" + replacement);
assertFalse(sheets.authorizes(oldSheet, recovered));
assertTrue(administrator.messages.stream().anyMatch(message -> message.contains("Older sheets are revoked")));
var ordinary = recipient.core.inventory.getItemInOffHand();
recipient.core.inventory.setItemInOffHand(oldSheet);
sessions(fixture).enter(recipient.player);
drain(fixture, recipient.core.id);
assertTrue(sessions(fixture).active(recipient.core.id).isEmpty());
fixture.plugin.setEnabled(false);
fixture.plugin.setEnabled(true);
recipient.core.inventory.setItem(destination, null);
recipient.core.inventory.setItemInOffHand(replacement);
sessions(fixture).enter(recipient.player);
drain(fixture, recipient.core.id);
var active = sessions(fixture).active(recipient.core.id);
assertTrue(active.isPresent(), () -> "messages=" + recipient.messages + " contents="
+ java.util.Arrays.toString(recipient.core.inventory.getContents()));
assertEquals(recovered.id(), active.orElseThrow().character());
recipient.core.inventory.setItemInOffHand(ordinary);
assertEquals(recovered.sheetGeneration(), new games.dmg.fantasystick.persistence.YamlCharacterRepository(data)
.load(recovered.id()).orElseThrow().sheetGeneration());
}
}
@Test void malformedConsoleFullInventoryClosedPreviewAndPermissionLossMutateNothing() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var administrator = actor(data, "Admin");
var recipient = actor(data, "Recipient");
var source = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(administrator.core.id, administrator.player);
fixture.players.put(recipient.core.id, recipient.player);
var storage = characters(fixture);
var original = storage.inspect(source.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
for (String[] arguments : java.util.List.of(
new String[]{"character", "recover", "not-a-uuid", "Recipient"},
new String[]{"character", "recover", original.id().toString()},
new String[]{"character", "recover", original.id().toString(), "Recipient", "extra"}))
assertTrue(fixture.adminCommand.execute(administrator.player, "fstickadmin", arguments));
var console = mock(org.bukkit.command.CommandSender.class);
when(console.hasPermission(games.dmg.fantasystick.session.EquipmentAdminCommand.PERMISSION)).thenReturn(true);
assertTrue(fixture.adminCommand.execute(console, "fstickadmin",
new String[]{"character", "recover", original.id().toString(), "Recipient"}));
verify(console).sendMessage(contains("in-game administrator"));
var reservation = new games.dmg.fantasystick.persistence.RevivalStore.Reservation(
UUID.randomUUID(), UUID.randomUUID(), recipient.core.id, original.id(), UUID.randomUUID(),
java.time.Instant.parse("2026-09-23T00:00:00Z"), 0, 1, -1, true);
assertTrue(revivals(fixture).reserve(reservation).get(5, TimeUnit.SECONDS).isPresent());
fixture.adminCommand.execute(administrator.player, "fstickadmin",
new String[]{"character", "recover", original.id().toString(), "Recipient"});
drain(fixture, recipient.core.id);
assertTrue(administrator.messages.stream().anyMatch(message -> message.contains("custody must be resolved")));
assertTrue(revivals(fixture).clear(original.id(), reservation.id()).get(5, TimeUnit.SECONDS));
for (int slot = 0; slot < 36; slot++)
recipient.core.inventory.setItem(slot, new org.bukkit.inventory.ItemStack(Material.STONE));
fixture.adminCommand.execute(administrator.player, "fstickadmin",
new String[]{"character", "recover", original.id().toString(), "Recipient"});
drain(fixture, recipient.core.id);
assertTrue(administrator.inventories.isEmpty());
assertTrue(administrator.messages.stream().anyMatch(message -> message.contains("empty main-inventory slot")));
assertEquals(original, storage.inspect(original.id()).get(5, TimeUnit.SECONDS).orElseThrow());
recipient.core.inventory.setItem(1, null);
fixture.adminCommand.execute(administrator.player, "fstickadmin",
new String[]{"character", "recover", original.id().toString(), "Recipient"});
drain(fixture, recipient.core.id);
var preview = administrator.inventories.getLast();
close(fixture, administrator, preview);
var ignored = clickEvent(administrator, preview, 2);
fixture.dispatcher.accept(ignored);
assertFalse(ignored.isCancelled());
assertEquals(original, storage.inspect(original.id()).get(5, TimeUnit.SECONDS).orElseThrow());
fixture.adminCommand.execute(administrator.player, "fstickadmin",
new String[]{"character", "recover", original.id().toString(), "Recipient"});
drain(fixture, recipient.core.id);
administrator.allowed = false;
click(fixture, administrator, administrator.inventories.getLast(), 2);
drain(fixture, recipient.core.id);
assertEquals(original, storage.inspect(original.id()).get(5, TimeUnit.SECONDS).orElseThrow());
assertNull(recipient.core.inventory.getItem(1));
}
}
@Test void activeCharacterCustodyRefusesRecoveryBeforePreview() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var administrator = actor(data, "Admin");
var recipient = actor(data, "Recipient");
var holder = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(administrator.core.id, administrator.player);
fixture.players.put(recipient.core.id, recipient.player);
fixture.players.put(holder.core.id, holder.player);
fixture.activate(holder);
fixture.adminCommand.execute(administrator.player, "fstickadmin",
new String[]{"character", "recover", holder.core.record.id().toString(), "Recipient"});
drain(fixture, recipient.core.id);
assertTrue(administrator.inventories.isEmpty());
assertTrue(administrator.messages.stream().anyMatch(message -> message.contains("custody must be resolved")));
assertEquals(java.math.BigInteger.ZERO, characters(fixture).inspect(holder.core.record.id())
.get(5, TimeUnit.SECONDS).orElseThrow().sheetGeneration());
}
}
@Test void changedNativeDestinationAfterCommitRetainsUncertainCustodyWithoutBlindReplay() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var administrator = actor(data, "Admin");
var recipient = actor(data, "Recipient");
var source = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(administrator.core.id, administrator.player);
fixture.players.put(recipient.core.id, recipient.player);
var storage = characters(fixture);
var original = storage.inspect(source.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
int destination = recipient.core.inventory.firstEmpty();
fixture.adminCommand.execute(administrator.player, "fstickadmin",
new String[]{"character", "recover", original.id().toString(), "Recipient"});
drain(fixture, recipient.core.id);
click(fixture, administrator, administrator.inventories.getLast(), 2);
awaitTask(fixture, recipient.core.id).run();
awaitTask(fixture, recipient.core.id).run();
characters(fixture).inspect(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
recipient.core.inventory.setItem(destination, new org.bukkit.inventory.ItemStack(Material.DIAMOND));
awaitTask(fixture, recipient.core.id).run();
var committed = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data)
.load(original.id()).orElseThrow();
assertEquals(original.sheetGeneration().add(java.math.BigInteger.ONE), committed.sheetGeneration());
assertEquals(Material.DIAMOND, recipient.core.inventory.getItem(destination).getType());
assertThrows(java.util.concurrent.CompletionException.class, () -> storage.find(original.id()).join());
assertTrue(administrator.messages.stream().anyMatch(message -> message.contains("delivery is uncertain")));
fixture.adminCommand.execute(administrator.player, "fstickadmin",
new String[]{"character", "recover", original.id().toString(), "Recipient"});
drain(fixture, recipient.core.id);
assertTrue(administrator.messages.stream().anyMatch(message -> message.contains("custody must be resolved")));
}
}
@Test void explicitNoTailRecoveryRevivesSameDeathAtFullHealthWithoutReplayingConsequences() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var administrator = actor(data, "Admin");
var recipient = actor(data, "Recipient");
var source = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(administrator.core.id, administrator.player);
fixture.players.put(recipient.core.id, recipient.player);
var storage = characters(fixture);
var dead = storage.recordDeath(source.core.record.id(), true).get(5, TimeUnit.SECONDS).orElseThrow();
var oldTail = new games.dmg.fantasystick.items.TailBooks().create(dead);
int destination = recipient.core.inventory.firstEmpty();
fixture.adminCommand.execute(administrator.player, "fstickadmin",
new String[]{"character", "recover", dead.id().toString(), "Recipient"});
drain(fixture, recipient.core.id);
assertFalse(administrator.inventories.isEmpty(), administrator.messages::toString);
var preview = administrator.inventories.getLast();
assertEquals(Material.BARRIER, preview.getItem(2).getType());
assertEquals(Material.TOTEM_OF_UNDYING, preview.getItem(6).getType());
click(fixture, administrator, preview, 6);
drain(fixture, recipient.core.id);
var revived = storage.inspect(dead.id()).get(5, TimeUnit.SECONDS).orElseThrow();
assertFalse(revived.dead());
assertEquals(revived.maximumHealth(), revived.health());
assertEquals(dead.id(), revived.id());
assertEquals(dead.stats(), revived.stats());
assertEquals(dead.coins(), revived.coins());
assertEquals(java.math.BigInteger.ZERO, revived.pvpCoinEscrow());
assertEquals(dead.deathId(), revived.deathId());
assertTrue(new games.dmg.fantasystick.items.CharacterSheets()
.authorizes(recipient.core.inventory.getItem(destination), revived));
assertEquals(dead.deathId(), new games.dmg.fantasystick.items.TailBooks().identify(oldTail).orElseThrow().deathId());
assertTrue(storage.restoreDeathRecordAdministratively(dead.id(), dead.deathId(), revived.maximumHealth())
.get(5, TimeUnit.SECONDS).isEmpty(), "The old Tail death authority was consumed by the living state");
var laterDeath = storage.recordDeath(revived.id(), false).get(5, TimeUnit.SECONDS).orElseThrow();
assertNotEquals(dead.deathId(), laterDeath.deathId());
assertTrue(storage.restoreDeathRecordAdministratively(dead.id(), dead.deathId(), revived.maximumHealth())
.get(5, TimeUnit.SECONDS).isEmpty(), "An old Tail cannot revive a different later death");
assertEquals(laterDeath.deathId(), storage.inspect(dead.id()).get(5, TimeUnit.SECONDS)
.orElseThrow().deathId());
}
}
private static FantasyStickLifecycleNativeTest.Actor actor(Path data, String name) throws Exception {
var actor = new FantasyStickLifecycleNativeTest.Actor(data); actor.accountName = name; return actor;
}
private static void click(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor administrator, org.bukkit.inventory.Inventory inventory, int slot) {
var click = clickEvent(administrator, inventory, slot);
fixture.dispatcher.accept(click);
assertTrue(click.isCancelled());
}
private static InventoryClickEvent clickEvent(FantasyStickLifecycleNativeTest.Actor administrator,
org.bukkit.inventory.Inventory inventory, int slot) {
var view = view(administrator, inventory);
return new InventoryClickEvent(view, InventoryType.SlotType.CONTAINER, slot,
org.bukkit.event.inventory.ClickType.LEFT, InventoryAction.PICKUP_ALL);
}
private static void close(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor administrator, org.bukkit.inventory.Inventory inventory) {
fixture.dispatcher.accept(new org.bukkit.event.inventory.InventoryCloseEvent(view(administrator, inventory)));
}
private static org.bukkit.inventory.InventoryView view(
FantasyStickLifecycleNativeTest.Actor administrator, org.bukkit.inventory.Inventory inventory) {
var view = mock(org.bukkit.inventory.InventoryView.class);
when(view.getTopInventory()).thenReturn(inventory);
when(view.getBottomInventory()).thenReturn(administrator.core.inventory);
when(view.getPlayer()).thenReturn(administrator.player);
return view;
}
private static Runnable awaitTask(FantasyStickLifecycleNativeTest.Fixture fixture, UUID recipient) throws Exception {
for (int pass = 0; pass < 100; pass++) {
characters(fixture).inspect(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
fixture.receipts().find(recipient).toCompletableFuture().get(5, TimeUnit.SECONDS);
revivals(fixture).find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
var callback = fixture.tasks.poll();
if (callback != null) return callback;
Thread.sleep(5);
}
throw new AssertionError("Expected a deferred recovery callback");
}
private static void drain(FantasyStickLifecycleNativeTest.Fixture fixture, UUID recipient) throws Exception {
for (int pass = 0; pass < 40; pass++) {
characters(fixture).inspect(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
fixture.receipts().find(recipient).toCompletableFuture().get(5, TimeUnit.SECONDS);
revivals(fixture).find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
Runnable callback;
boolean ran = false;
while ((callback = fixture.tasks.poll()) != null) { callback.run(); ran = true; }
if (!ran) Thread.sleep(5);
}
}
private static games.dmg.fantasystick.persistence.CharacterStorage characters(
FantasyStickLifecycleNativeTest.Fixture fixture) {
return (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
}
private static games.dmg.fantasystick.persistence.RevivalStore revivals(
FantasyStickLifecycleNativeTest.Fixture fixture) {
return (games.dmg.fantasystick.persistence.RevivalStore)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "revivals");
}
private static games.dmg.fantasystick.session.EquipmentSessions sessions(
FantasyStickLifecycleNativeTest.Fixture fixture) {
return (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
}
}
@@ -0,0 +1,241 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import games.dmg.fantasystick.adventure.AdventureQuestController;
import games.dmg.fantasystick.adventure.AdventureQuestDirectory;
import games.dmg.fantasystick.adventure.AdventureQuestState;
import games.dmg.fantasystick.dungeon.DungeonDirectory;
import java.nio.file.Path;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
final class AdventureParticipationModeNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void registeredSiteAndDungeonCommandsPersistReportUpdateAndCompleteModes() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "adventure-daily-site-count: 0\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(admin.core.id, admin.player);
doReturn(true).when(admin.player).hasPermission("fantasystick.admin");
when(admin.world.getUID()).thenReturn(UUID.randomUUID());
assertEquals(List.of("--mode"), f.command.tabComplete(admin.player, "fantasystick",
new String[]{"adventure-site", "place", "camp", "--"}));
assertEquals(List.of("individual-only"), f.command.tabComplete(admin.player, "fantasystick",
new String[]{"adventure-site", "place", "camp", "--mode", "i"}));
f.command.execute(admin.player, "fantasystick", new String[]{"adventure-site", "place", "camp",
"--mode", "individual-only", "10", "Solo Camp"});
drain(f);
var adventures = (AdventureQuestDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,
"adventureQuests");
var site = adventures.snapshot().sites().getFirst();
assertEquals(AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY, site.participationMode());
assertTrue(admin.messages.getLast().contains("mode=individual-only"));
f.command.execute(admin.player, "fantasystick", new String[]{"adventure-site", "list"});
assertTrue(admin.messages.getLast().contains("mode=individual-only"));
assertEquals(List.of(site.id().toString()), f.command.tabComplete(admin.player, "fantasystick",
new String[]{"adventure-site", "mode", ""}));
assertEquals(List.of("party-available", "party-required"), f.command.tabComplete(admin.player,
"fantasystick", new String[]{"adventure-site", "mode", site.id().toString(), "party-"}));
f.command.execute(admin.player, "fantasystick", new String[]{"adventure-site", "mode",
site.id().toString()});
assertTrue(admin.messages.getLast().contains("individual-only"));
f.command.execute(admin.player, "fantasystick", new String[]{"adventure-site", "mode",
site.id().toString(), "party-required"});
drain(f);
assertEquals(AdventureQuestState.ParticipationMode.PARTY_REQUIRED,
adventures.findSite(site.id()).orElseThrow().participationMode());
int count = adventures.snapshot().sites().size();
f.command.execute(admin.player, "fantasystick", new String[]{"adventure-site", "place", "camp",
"--mode", "invalid", "Broken"});
assertEquals(count, adventures.snapshot().sites().size());
assertEquals(List.of("--mode"), f.command.tabComplete(admin.player, "fantasystick",
new String[]{"dungeon", "create", "--"}));
f.command.execute(admin.player, "fantasystick", new String[]{"dungeon", "create", "--mode",
"party-required", "Mode Vault"});
drain(f);
var dungeons = (DungeonDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "dungeons");
var dungeon = dungeons.snapshot().dungeons().getFirst();
assertEquals(AdventureQuestState.ParticipationMode.PARTY_REQUIRED, dungeon.participationMode());
assertEquals(List.of(dungeon.id().toString()), f.command.tabComplete(admin.player, "fantasystick",
new String[]{"dungeon", "mode", ""}));
f.command.execute(admin.player, "fantasystick", new String[]{"dungeon", "mode",
dungeon.id().toString(), "party-available"});
drain(f);
assertEquals(AdventureQuestState.ParticipationMode.PARTY_AVAILABLE,
dungeons.find(dungeon.id()).orElseThrow().participationMode());
f.command.execute(admin.player, "fantasystick", new String[]{"dungeon", "mode",
dungeon.id().toString(), "party-required"}); drain(f);
var room = dungeons.addRadiusRoom(dungeon.id(), "Boss", 0, 64, 0, 2, 3)
.get(5, TimeUnit.SECONDS);
dungeons.setBoss(dungeon.id(), room.id(), org.bukkit.entity.EntityType.HUSK, "Keeper",
0, 65, 0, 0, 0).get(5, TimeUnit.SECONDS);
f.command.execute(admin.player, "fantasystick", new String[]{"dungeon", "enable",
dungeon.id().toString()}); drain(f);
f.command.execute(admin.player, "fantasystick", new String[]{"dungeon", "open",
dungeon.id().toString()}); drain(f);
assertEquals(AdventureQuestState.ParticipationMode.PARTY_REQUIRED,
adventures.findSite(dungeon.id()).orElseThrow().participationMode());
}
}
@Test void questGiverLabelsAndExplicitlyAcceptsPersonalOrPartyForm() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "adventure-daily-site-count: 3\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var leader = new FantasyStickLifecycleNativeTest.Actor(data);
var member = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(leader.core.id, leader.player); f.players.put(member.core.id, member.player);
UUID world = UUID.randomUUID(); when(leader.world.getUID()).thenReturn(world);
member.core.location.setWorld(leader.world);
f.activate(leader); f.activate(member);
var parties = PartyLifecycleNativeTest.parties(f);
var party = parties.create(leader.core.id).orElseThrow();
assertTrue(parties.accept(member.core.id,
parties.invite(leader.core.id, party.id(), member.core.id).orElseThrow().id()));
var adventures = (AdventureQuestDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,
"adventureQuests");
var personal = adventures.placeSite(AdventureQuestState.SiteType.CAMP, "Flexible Camp", world,
0, 64, 0, null, AdventureQuestState.ParticipationMode.PARTY_AVAILABLE).get(5, TimeUnit.SECONDS);
var required = adventures.placeSite(AdventureQuestState.SiteType.CHAMPION, "Party Summit", world,
20, 64, 0, null, AdventureQuestState.ParticipationMode.PARTY_REQUIRED).get(5, TimeUnit.SECONDS);
var individual = adventures.placeSite(AdventureQuestState.SiteType.CAMP, "Solo Camp", world,
40, 64, 0, null, AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY).get(5, TimeUnit.SECONDS);
adventures.ensureToday().get(5, TimeUnit.SECONDS);
var controller = (AdventureQuestController) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,
"adventureQuestController");
controller.open(member.player); drain(f);
var memberMenu = member.inventories.getLast();
var personalItem = java.util.stream.IntStream.range(0, memberMenu.getSize())
.mapToObj(memberMenu::getItem).filter(java.util.Objects::nonNull)
.filter(item -> PlainTextComponentSerializer.plainText().serialize(item.getItemMeta().displayName())
.equals("Flexible Camp")).findFirst().orElseThrow();
var lore = personalItem.getItemMeta().lore().stream()
.map(PlainTextComponentSerializer.plainText()::serialize).toList();
assertTrue(lore.contains("Mode: party-available"));
assertTrue(lore.stream().anyMatch(line -> line.contains("Left click: Accept individually")));
var personalClick = mock(InventoryClickEvent.class);
when(personalClick.getWhoClicked()).thenReturn(member.player);
when(personalClick.getInventory()).thenReturn(memberMenu);
when(personalClick.getCurrentItem()).thenReturn(personalItem);
controller.click(personalClick); drain(f);
var personalQuest = adventures.activeFor(member.core.record.id(), party.id()).orElseThrow();
assertEquals(AdventureQuestState.UnitType.SOLO, personalQuest.unitType());
assertEquals(Set.of(member.core.record.id()), personalQuest.snapshot());
assertTrue(adventures.abandon(personalQuest.id(), personalQuest.unit()).get(5, TimeUnit.SECONDS));
controller.open(member.player); drain(f);
var memberIndividualMenu = member.inventories.getLast();
var individualItem = offer(memberIndividualMenu, "Solo Camp");
click(controller, member, memberIndividualMenu, individualItem, false); drain(f);
var memberIndividual = adventures.activeFor(member.core.record.id(), party.id()).orElseThrow();
assertEquals(individual.id(), memberIndividual.site());
assertEquals(AdventureQuestState.UnitType.SOLO, memberIndividual.unitType());
assertTrue(adventures.abandon(memberIndividual.id(), memberIndividual.unit()).get(5, TimeUnit.SECONDS));
click(controller, member, memberIndividualMenu, individualItem, true);
assertTrue(member.messages.getLast().contains("individual-only"));
var memberRequiredItem = offer(memberIndividualMenu, "Party Summit");
click(controller, member, memberIndividualMenu, memberRequiredItem, false);
assertTrue(member.messages.getLast().contains("party-required"));
click(controller, member, memberIndividualMenu, memberRequiredItem, true);
assertTrue(member.messages.getLast().contains("Only the active party leader"));
var partyless = new FantasyStickLifecycleNativeTest.Actor(data);
partyless.core.location.setWorld(leader.world); f.players.put(partyless.core.id, partyless.player);
f.activate(partyless); controller.open(partyless.player); drain(f);
var partylessMenu = partyless.inventories.getLast();
click(controller, partyless, partylessMenu, offer(partylessMenu, "Solo Camp"), false); drain(f);
var partylessQuest = adventures.activeFor(partyless.core.record.id(), null).orElseThrow();
assertEquals(AdventureQuestState.UnitType.SOLO, partylessQuest.unitType());
assertTrue(adventures.abandon(partylessQuest.id(), partylessQuest.unit()).get(5, TimeUnit.SECONDS));
var partylessRequired = offer(partylessMenu, "Party Summit");
click(controller, partyless, partylessMenu, partylessRequired, false);
assertTrue(partyless.messages.getLast().contains("party-required"));
click(controller, partyless, partylessMenu, partylessRequired, true);
assertTrue(partyless.messages.getLast().contains("Only the active party leader"));
controller.open(leader.player); drain(f);
var leaderIndividualMenu = leader.inventories.getLast();
var leaderIndividualItem = offer(leaderIndividualMenu, "Solo Camp");
click(controller, leader, leaderIndividualMenu, leaderIndividualItem, false); drain(f);
var leaderIndividual = adventures.activeFor(leader.core.record.id(), party.id()).orElseThrow();
assertEquals(AdventureQuestState.UnitType.SOLO, leaderIndividual.unitType());
assertTrue(adventures.abandon(leaderIndividual.id(), leaderIndividual.unit()).get(5, TimeUnit.SECONDS));
click(controller, leader, leaderIndividualMenu, leaderIndividualItem, true);
assertTrue(leader.messages.getLast().contains("individual-only"));
controller.open(leader.player); drain(f);
var leaderMenu = leader.inventories.getLast();
var requiredItem = java.util.stream.IntStream.range(0, leaderMenu.getSize())
.mapToObj(leaderMenu::getItem).filter(java.util.Objects::nonNull)
.filter(item -> PlainTextComponentSerializer.plainText().serialize(item.getItemMeta().displayName())
.equals("Party Summit")).findFirst().orElseThrow();
var requiredLore = requiredItem.getItemMeta().lore().stream()
.map(PlainTextComponentSerializer.plainText()::serialize).toList();
assertTrue(requiredLore.contains("Mode: party-required"));
assertTrue(requiredLore.stream().anyMatch(line -> line.contains("Right click: Accept for party")));
var partyClick = mock(InventoryClickEvent.class);
when(partyClick.getWhoClicked()).thenReturn(leader.player);
when(partyClick.getInventory()).thenReturn(leaderMenu);
when(partyClick.getCurrentItem()).thenReturn(requiredItem);
when(partyClick.isRightClick()).thenReturn(true);
controller.click(partyClick);
assertTrue(parties.disband(leader.core.id, party.id()), "party changes before acceptance publication");
await(f, () -> leader.messages.stream().anyMatch(message -> message.contains("character or party changed")));
assertTrue(adventures.active().isEmpty());
party = parties.create(leader.core.id).orElseThrow();
assertTrue(parties.accept(member.core.id,
parties.invite(leader.core.id, party.id(), member.core.id).orElseThrow().id()));
controller.click(partyClick); drain(f);
var partyQuest = adventures.activeFor(leader.core.record.id(), party.id()).orElseThrow();
assertEquals(AdventureQuestState.UnitType.PARTY, partyQuest.unitType());
assertEquals(Set.of(leader.core.record.id(), member.core.record.id()), partyQuest.snapshot());
assertEquals(AdventureQuestState.ParticipationMode.PARTY_REQUIRED,
partyQuest.participationMode());
}
}
private static org.bukkit.inventory.ItemStack offer(org.bukkit.inventory.Inventory menu, String name) {
return java.util.stream.IntStream.range(0, menu.getSize()).mapToObj(menu::getItem)
.filter(java.util.Objects::nonNull)
.filter(item -> PlainTextComponentSerializer.plainText().serialize(item.getItemMeta().displayName())
.equals(name)).findFirst().orElseThrow();
}
private static void click(AdventureQuestController controller, FantasyStickLifecycleNativeTest.Actor actor,
org.bukkit.inventory.Inventory menu, org.bukkit.inventory.ItemStack item,
boolean right) {
var event = mock(InventoryClickEvent.class);
when(event.getWhoClicked()).thenReturn(actor.player); when(event.getInventory()).thenReturn(menu);
when(event.getCurrentItem()).thenReturn(item); when(event.isRightClick()).thenReturn(right);
controller.click(event);
}
private static void drain(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
for (int attempt = 0; attempt < 20; 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 < 50 && !condition.getAsBoolean(); attempt++) {
drain(f); Thread.sleep(5);
}
assertTrue(condition.getAsBoolean());
}
}
@@ -135,7 +135,7 @@ class AdventureQuestNativeTest {
assertTrue(actor.messages.stream().anyMatch(message -> message.equals(
"Accepted Dawn Camp; expires " + directory.active().getFirst().expiresAt()
+ ". Follow the Quest Compass.")));
f.command.execute(actor.player,"fantasystick",new String[]{"adventure"});
f.playerCommand.execute(actor.player,"fstick",new String[]{"adventure"});
assertEquals("Dawn Camp; state=waiting; expires "+directory.active().getFirst().expiresAt()
+"; Quest Compass guidance=on.",actor.messages.getLast());
assertFalse(actor.messages.getLast().contains("(12, 70, -8)"));assertFalse(actor.messages.getLast().contains(worldId.toString()));
@@ -394,13 +394,13 @@ class AdventureQuestNativeTest {
quest.unit().toString(), actor.core.record.id().toString())) {
assertFalse(accepted.contains(hidden));
}
f.command.execute(actor.player, "fantasystick", new String[]{"adventure"});
f.playerCommand.execute(actor.player, "fstick", new String[]{"adventure"});
String inspected = actor.messages.getLast();
assertEquals("Monster Camp; state=waiting; expires "+quest.expiresAt()+"; Quest Compass guidance=on.",inspected);
for(String hidden:List.of("(12, -1, -9)",worldId.toString(),site.id().toString(),quest.id().toString()))assertFalse(inspected.contains(hidden));
when(actor.world.getUID()).thenReturn(UUID.randomUUID());
f.command.execute(actor.player, "fantasystick", new String[]{"adventure"});
f.playerCommand.execute(actor.player, "fstick", new String[]{"adventure"});
assertEquals("Monster Camp; state=waiting; expires "+quest.expiresAt()+"; Quest Compass guidance=on.",actor.messages.getLast());
assertFalse(actor.messages.getLast().contains(worldId.toString()));
f.command.execute(actor.player, "fantasystick", new String[]{"adventure-site", "list"});
@@ -461,15 +461,15 @@ class AdventureQuestNativeTest {
characters.find(UUID.randomUUID()).get(5,java.util.concurrent.TimeUnit.SECONDS);for(int retry=0;retry<10&&actor.books.isEmpty();retry++)f.tick();assertEquals(1,actor.books.size());
doReturn(false).when(actor.player).hasPermission("fantasystick.admin");
assertEquals(List.of("tracking"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure","t"}));
assertEquals(List.of("off","on"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure","tracking",""}));
assertTrue(f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure","tracking","on","extra"}).isEmpty());
f.command.execute(actor.player,"fantasystick",new String[]{"adventure","tracking"});
assertEquals(List.of("tracking"),f.playerCommand.tabComplete(actor.player,"fstick",new String[]{"adventure","t"}));
assertEquals(List.of("off","on"),f.playerCommand.tabComplete(actor.player,"fstick",new String[]{"adventure","tracking",""}));
assertTrue(f.playerCommand.tabComplete(actor.player,"fstick",new String[]{"adventure","tracking","on","extra"}).isEmpty());
f.playerCommand.execute(actor.player,"fstick",new String[]{"adventure","tracking"});
assertTrue(actor.messages.getLast().contains("on"));
f.command.execute(actor.player,"fantasystick",new String[]{"adventure","tracking","off"});for(int retry=0;retry<10&&actor.core.inventory.getItem(0).getType()!=Material.BOOK;retry++)drainOne(f);
f.playerCommand.execute(actor.player,"fstick",new String[]{"adventure","tracking","off"});for(int retry=0;retry<10&&actor.core.inventory.getItem(0).getType()!=Material.BOOK;retry++)drainOne(f);
assertEquals(Material.BOOK,actor.core.inventory.getItem(0).getType());
assertFalse(directory.trackingEnabled(quest.id(),actor.core.record.id()));
f.command.execute(actor.player,"fantasystick",new String[]{"adventure","tracking","on"});for(int retry=0;retry<10&&actor.core.inventory.getItem(0).getType()!=Material.COMPASS;retry++)drainOne(f);
f.playerCommand.execute(actor.player,"fstick",new String[]{"adventure","tracking","on"});for(int retry=0;retry<10&&actor.core.inventory.getItem(0).getType()!=Material.COMPASS;retry++)drainOne(f);
assertEquals(Material.COMPASS,actor.core.inventory.getItem(0).getType());
actor.core.location.setX(site.x());actor.core.location.setY(site.y());actor.core.location.setZ(site.z());
@@ -38,7 +38,7 @@ class ChampionQuestNativeTest {
f.command.execute(actor.player,"fantasystick",new String[]{"config","adventure-champion-damage","9"});drainOne(f);assertEquals(List.of("8","9"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"config","adventure-champion-damage",""}));
f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","place","champion","20","60","Summit"});drainOne(f);f.command.execute(actor.player,"fantasystick",new String[]{"quest-giver","place","Guide"});drainOne(f);
var directory=(games.dmg.fantasystick.adventure.AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");var current=directory.snapshot();var snapshotField=directory.getClass().getDeclaredField("snapshot");snapshotField.setAccessible(true);snapshotField.set(directory,new games.dmg.fantasystick.adventure.AdventureQuestState(current.sites(),current.givers(),null,current.quests(),current.unitClaims(),current.characterClaims(),current.rosters()));directory.ensureToday().get(5,java.util.concurrent.TimeUnit.SECONDS);
var controller=(games.dmg.fantasystick.adventure.AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");controller.reconcile(chunk);var giver=spawned.stream().filter(Villager.class::isInstance).map(Villager.class::cast).findFirst().orElseThrow();f.activate(actor);var member=new FantasyStickLifecycleNativeTest.Actor(data);member.core.location.setWorld(actor.world);f.players.put(member.core.id,member.player);f.activate(member);var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(actor.core.id).orElseThrow();assertTrue(parties.accept(member.core.id,parties.invite(actor.core.id,party.id(),member.core.id).orElseThrow().id()));f.dispatcher.accept(new PlayerInteractEntityEvent(actor.player,giver,EquipmentSlot.HAND));drainOne(f);var menu=actor.inventories.getLast();assertEquals(Material.PAPER,menu.getItem(0).getType());var lore=menu.getItem(0).getItemMeta().lore().toString();assertTrue(lore.contains("Champion"));assertTrue(lore.contains("150 XP, 40 coins"));var click=mock(InventoryClickEvent.class);when(click.getWhoClicked()).thenReturn(actor.player);when(click.getInventory()).thenReturn(menu);when(click.getCurrentItem()).thenReturn(menu.getItem(0));controller.click(click);drainOne(f);
var controller=(games.dmg.fantasystick.adventure.AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");controller.reconcile(chunk);var giver=spawned.stream().filter(Villager.class::isInstance).map(Villager.class::cast).findFirst().orElseThrow();f.activate(actor);var member=new FantasyStickLifecycleNativeTest.Actor(data);member.core.location.setWorld(actor.world);f.players.put(member.core.id,member.player);f.activate(member);var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(actor.core.id).orElseThrow();assertTrue(parties.accept(member.core.id,parties.invite(actor.core.id,party.id(),member.core.id).orElseThrow().id()));f.dispatcher.accept(new PlayerInteractEntityEvent(actor.player,giver,EquipmentSlot.HAND));drainOne(f);var menu=actor.inventories.getLast();assertEquals(Material.PAPER,menu.getItem(0).getType());var lore=menu.getItem(0).getItemMeta().lore().toString();assertTrue(lore.contains("Champion"));assertTrue(lore.contains("150 XP, 40 coins"));var click=mock(InventoryClickEvent.class);when(click.getWhoClicked()).thenReturn(actor.player);when(click.getInventory()).thenReturn(menu);when(click.getCurrentItem()).thenReturn(menu.getItem(0));when(click.isRightClick()).thenReturn(true);controller.click(click);drainOne(f);
var solo=new FantasyStickLifecycleNativeTest.Actor(data);solo.core.location.setWorld(actor.world);f.players.put(solo.core.id,solo.player);f.activate(solo);var catalog=directory.snapshot().catalog();var site=catalog.sites().getFirst();assertEquals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.Acceptance.APPLIED,directory.accept(catalog.revision(),site,games.dmg.fantasystick.adventure.AdventureQuestState.UnitType.SOLO,solo.core.record.id(),Set.of(solo.core.record.id())).get(5,java.util.concurrent.TimeUnit.SECONDS).status());var siteRecord=directory.findSite(site).orElseThrow();assertEquals(games.dmg.fantasystick.adventure.AdventureQuestState.Arena.rectangle(20,60),siteRecord.arena());actor.core.location.setZ(siteRecord.z()+25);member.core.location.setZ(siteRecord.z()+25);solo.core.location.setZ(siteRecord.z()+25);var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var initial=storage.find(actor.core.record.id()).get().orElseThrow();var memberInitial=storage.find(member.core.record.id()).get().orElseThrow();var soloInitial=storage.find(solo.core.record.id()).get().orElseThrow();for(Runnable pulse:List.copyOf(f.pulses))pulse.run();var champion=spawned.stream().filter(Vindicator.class::isInstance).map(Vindicator.class::cast).findFirst().orElseThrow();verify(maxHealth).setBaseValue(100);verify(attack).setBaseValue(9);verify(champion).setHealth(100);verify(champion).customName(argThat(component->component.toString().contains("Daybreak Champion")));assertEquals(1,f.bossBars.size());verify(f.bossBars.getFirst()).addPlayer(actor.player);verify(f.bossBars.getFirst()).addPlayer(member.player);verify(f.bossBars.getFirst()).addPlayer(solo.player);assertFalse(actor.particleCalls.isEmpty());assertFalse(member.particleCalls.isEmpty());assertFalse(solo.particleCalls.isEmpty());int memberParticles=member.particleCalls.size();member.core.location.setX(100);for(Runnable pulse:List.copyOf(f.pulses))pulse.run();verify(f.bossBars.getFirst()).removePlayer(member.player);assertEquals(memberParticles,member.particleCalls.size());member.core.location.setX(siteRecord.x());for(Runnable pulse:List.copyOf(f.pulses))pulse.run();verify(f.bossBars.getFirst(),atLeast(2)).addPlayer(member.player);f.command.execute(actor.player,"fantasystick",new String[]{"config","adventure-champion-health","120"});drainOne(f);f.command.execute(actor.player,"fantasystick",new String[]{"config","adventure-champion-damage","10"});drainOne(f);var liveSettings=(games.dmg.fantasystick.persistence.AdventureQuestSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestSettings");for(int retry=0;retry<10&&liveSettings.rules().championDamage()!=10;retry++)drainOne(f);verify(maxHealth,times(1)).setBaseValue(100);verify(attack,times(1)).setBaseValue(9);
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);var cancelled=new org.bukkit.event.entity.EntityDamageByEntityEvent(actor.player,champion,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);cancelled.setCancelled(true);f.dispatcher.accept(cancelled);verify(f.bossBars.getFirst(),never()).setProgress(0.99);var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(actor.player,champion,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);f.dispatcher.accept(hit);assertFalse(hit.isCancelled());verify(f.bossBars.getFirst()).setProgress(0.99);var death=new org.bukkit.event.entity.EntityDeathEvent(champion,source,new ArrayList<>(),9);f.dispatcher.accept(death);assertEquals(0,death.getDroppedExp());assertTrue(death.getDrops().isEmpty());for(int i=0;i<50&&(directory.active().size()!=1||directory.unclaimed(catalog.revision(),site,actor.core.record.id()).xp().add(directory.unclaimed(catalog.revision(),site,member.core.record.id()).xp()).signum()==0);i++){var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(10);}var saved=storage.find(actor.core.record.id()).get().orElseThrow();var savedMember=storage.find(member.core.record.id()).get().orElseThrow();assertEquals(initial.experience(),saved.experience());assertEquals(memberInitial.experience(),savedMember.experience());var partyRewardA=directory.unclaimed(catalog.revision(),site,actor.core.record.id());var partyRewardB=directory.unclaimed(catalog.revision(),site,member.core.record.id());assertEquals(java.math.BigInteger.valueOf(150),partyRewardA.xp().add(partyRewardB.xp()));assertEquals(java.math.BigInteger.valueOf(40),partyRewardA.coins().add(partyRewardB.coins()));assertEquals(1,directory.active().size(),"Unrepresented solo quest remains");verify(f.bossBars.getFirst(),atLeastOnce()).removeAll();int memberParticlesAfterCompletion=member.particleCalls.size();
for(Runnable pulse:List.copyOf(f.pulses))pulse.run();assertEquals(memberParticlesAfterCompletion,member.particleCalls.size(),"completed party members stop receiving the shared boundary");var second=spawned.stream().filter(Vindicator.class::isInstance).map(Vindicator.class::cast).skip(1).findFirst().orElseThrow();assertEquals(2,f.bossBars.size());verify(maxHealth).setBaseValue(120);verify(attack).setBaseValue(10);verify(second).setHealth(120);var soloSource=mock(org.bukkit.damage.DamageSource.class);when(soloSource.getCausingEntity()).thenReturn(solo.player);when(soloSource.getDirectEntity()).thenReturn(solo.player);f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(solo.player,second,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,soloSource,1));f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(second,soloSource,new ArrayList<>(),0));for(int i=0;i<50&&!directory.active().isEmpty();i++){var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(10);}var savedSolo=storage.find(solo.core.record.id()).get().orElseThrow();assertEquals(soloInitial.experience(),savedSolo.experience());assertEquals(soloInitial.coins(),savedSolo.coins());assertEquals(new games.dmg.fantasystick.adventure.AdventureQuestDirectory.Reward(java.math.BigInteger.valueOf(150),java.math.BigInteger.valueOf(40)),directory.unclaimed(catalog.revision(),site,solo.core.record.id()));verify(f.bossBars.get(1),atLeastOnce()).removeAll();
@@ -225,6 +225,29 @@ class CharacterAnvilNamesNativeTest {
}
}
@Test
void staleGenerationCannotRenameOrLaunderARecoveredCharacterSheet() throws Exception {
var original = new CharacterFactory().create(ProgressionRules.defaults());
var staleSheet = new CharacterSheets().create(original);
var fixture = new AnvilFixture();
fixture.grid.setItem(0, staleSheet);
var output = named(staleSheet, Component.text("Laundered name"));
var problems = new CopyOnWriteArrayList<CharacterAnvilNames.Failure>();
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
storage.create(original).join();
var operation = java.util.UUID.randomUUID();
var commit = storage.beginRecovery(new CharacterStorage.RecoveryRequest(operation, original.id(),
java.util.UUID.randomUUID(), java.util.UUID.randomUUID(), original.revision(),
original.sheetGeneration(), false, null, java.math.BigDecimal.valueOf(20))).join();
storage.completeRecovery(original.id(), operation).join();
listener(storage, problems).onTaken(new AnvilTakeResultEvent(fixture.actor.player, fixture.view, output));
var current = storage.find(original.id()).join().orElseThrow();
assertEquals(original.name(), current.name());
assertEquals(java.math.BigInteger.ONE, current.sheetGeneration());
assertEquals(1, problems.size());
}
}
@Test
void clearingACustomNameRespectsAnExplicitNativeItemNameComponent() throws Exception {
var original = new CharacterFactory().create(ProgressionRules.defaults());
@@ -115,12 +115,16 @@ class CharacterSheetsNativeTest {
assertEquals(List.of(Component.text("Character sheet:"), Component.text("Place in off hand"),
Component.text("to enter fantasy world.")), sheet.getItemMeta().lore());
assertEquals(record.id(), sheets.identify(sheet).orElseThrow());
assertEquals(Set.of(new NamespacedKey("fantasystick", "sheet_version"), new NamespacedKey("fantasystick", "character_id")),
sheet.getItemMeta().getPersistentDataContainer().getKeys(), "No player owner, equipment or progress snapshot belongs on the sheet");
assertEquals(Set.of(new NamespacedKey("fantasystick", "sheet_version"),
new NamespacedKey("fantasystick", "character_id"),
new NamespacedKey("fantasystick", "sheet_generation")),
sheet.getItemMeta().getPersistentDataContainer().getKeys(),
"Only character identity and revocable issuance generation belong on the sheet");
var ops = RegistryOps.create(NbtOps.INSTANCE, CraftRegistry.getMinecraftRegistry());
var encoded = net.minecraft.world.item.ItemStack.CODEC.encodeStart(ops, CraftItemStack.asNMSCopy(sheet)).getOrThrow();
var reloaded = CraftItemStack.asBukkitCopy(net.minecraft.world.item.ItemStack.CODEC.parse(ops, encoded).getOrThrow());
assertEquals(record.id(), new CharacterSheets().identify(reloaded).orElseThrow());
assertEquals(record.sheetGeneration(), new CharacterSheets().identity(reloaded).orElseThrow().generation());
assertEquals(sheet, reloaded);
}
}
@@ -34,6 +34,7 @@ class DefeatHealthDeliveryNativeTest {
if (a.nativePlayer.queuedHealthUpdatePacket != null) sent.add(a.nativePlayer.queuedHealthUpdatePacket);
a.nativePlayer.queueHealthUpdatePacket = false;
PartyLifecycleNativeTest.directory(f).defeat(java.util.UUID.randomUUID()).join();
f.awaitCharacterAndReceipt(a);
PartyBookNativeTest.drain(f);
assertTrue(death.isCancelled());
assertEquals(a.maximumHealth.getValue(), a.player.getHealth());
@@ -91,7 +91,7 @@ class DungeonNativeTest {
java.nio.file.Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\ndungeon-room-link-distance: 10\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);doReturn(true).when(actor.player).hasPermission("fantasystick.admin");when(actor.world.getUID()).thenReturn(UUID.randomUUID());
command(f,actor,"dungeon","create","Feedback");var dungeons=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var dungeon=dungeons.snapshot().dungeons().getFirst();command(f,actor,"dungeon","edit","start",dungeon.id().toString());var controller=(games.dmg.fantasystick.dungeon.DungeonController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeonController");await(f,()->controller.editing(actor.core.id).isPresent(),actor.messages);
command(f,actor,"dungeon","create","Feedback");var dungeons=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");await(f,()->!dungeons.snapshot().dungeons().isEmpty(),actor.messages);var dungeon=dungeons.snapshot().dungeons().getFirst();command(f,actor,"dungeon","edit","start",dungeon.id().toString());var controller=(games.dmg.fantasystick.dungeon.DungeonController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeonController");await(f,()->controller.editing(actor.core.id).isPresent(),actor.messages);
command(f,actor,"dungeon","room","create","corner","1","1","1","First");await(f,()->dungeons.find(dungeon.id()).orElseThrow().rooms().size()==1,actor.messages);var first=dungeons.find(dungeon.id()).orElseThrow().rooms().getFirst();
command(f,actor,"dungeon","room","create","corner","1","1","1","Overlap");await(f,()->actor.messages.stream().anyMatch(message->message.contains("overlaps an existing room")),actor.messages);
actor.core.location.setX(first.bounds().maxX()+11.1);actor.core.location.setY(first.bounds().minY()+1);actor.core.location.setZ(first.bounds().minZ()+.5);command(f,actor,"dungeon","room","create","corner","1","1","1","Too Far");await(f,()->actor.messages.stream().anyMatch(message->message.contains("configured 10-block link distance")),actor.messages);
@@ -17,6 +17,34 @@ class EquipmentAdminCommandNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void registeredRootsKeepPlayerAndAdministratorGuidanceSeparated() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var administrator = new Sender();
f.adminCommand.execute(administrator.port, "fstickadmin", new String[]{});
assertTrue(administrator.messages.getLast().startsWith("Usage: /fstickadmin"));
assertTrue(administrator.messages.getLast().contains("adventure-site"));
assertTrue(administrator.messages.getLast().contains("character view"));
administrator.messages.clear();
f.adminCommand.execute(administrator.port, "fstickadmin", new String[]{"inspect"});
assertTrue(administrator.messages.getLast().startsWith("Usage: /fstickadmin"));
var player = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(player.core.id, player.player);
assertEquals(List.of("adventure", "feedback", "particles"),
f.playerCommand.tabComplete(player.player, "fstick", new String[]{""}));
assertEquals(List.of("reroll"),
f.adminCommand.tabComplete(administrator.port, "fstickadmin", new String[]{"adventure", ""}));
assertFalse(f.adminCommand.tabComplete(administrator.port, "fstickadmin", new String[]{""})
.stream().anyMatch(route -> route.equals("feedback") || route.equals("particles")));
assertTrue(f.playerCommand.tabComplete(player.player, "fstick", new String[]{"adventure", "r"}).isEmpty());
assertTrue(f.playerCommand.tabComplete(player.player, "fstick", new String[]{"config", ""}).isEmpty());
assertTrue(f.playerCommand.execute(player.player, "fstick", new String[]{"adventure", "reroll", "confirm"}));
assertTrue(player.messages.getLast().startsWith("Usage: /fstick adventure"));
assertFalse(String.join("\n", player.messages).contains("fstickadmin"));
}
}
@Test void registeredGivePersistsOneFreshSheetThenDeliversActivatesAndReloads() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
@@ -109,7 +137,7 @@ class EquipmentAdminCommandNativeTest {
assertEquals(List.of("true"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "adventure-mode", "t"}));
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "adventure-mode", "true", ""}).isEmpty());
sender.allowed = false;
assertEquals(List.of("adventure"), f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
assertTrue(f.command.tabComplete(sender.port, "fstickadmin", new String[]{""}).isEmpty());
for (String[] args : List.of(new String[]{"inspect", ""}, new String[]{"config", ""},
new String[]{"config", "adventure-mode", ""})) {
assertTrue(f.command.tabComplete(sender.port, "fantasystick", args).isEmpty());
@@ -49,10 +49,10 @@ class FantasyFeedbackCommandNativeTest {
assertTrue(fixture.feedbackCommand.tabComplete(console.port, "fantasyfeedback", new String[]{""}).isEmpty());
var player = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(player.core.id, player.player);
assertFalse(fixture.feedbackCommand.getExecutor().onCommand(player.player, fixture.feedbackCommand,
"fantasyfeedback", new String[]{"OFF"}));
assertFalse(fixture.feedbackCommand.getExecutor().onCommand(player.player, fixture.feedbackCommand,
"fantasyfeedback", new String[]{"off", "extra"}));
assertFalse(fixture.playerCommand.getExecutor().onCommand(player.player, fixture.playerCommand,
"fstick", new String[]{"feedback", "OFF"}));
assertFalse(fixture.playerCommand.getExecutor().onCommand(player.player, fixture.playerCommand,
"fstick", new String[]{"feedback", "off", "extra"}));
assertTrue(((games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences"))
.enabled(player.core.id));
@@ -65,8 +65,10 @@ class FantasyParticlesNativeTest {
for (String[] invalid : List.of(new String[]{}, new String[]{"unknown"},
new String[]{"players", "ON"}, new String[]{"all", "ON"},
new String[]{"players", "on", "extra"}, new String[]{"all", "off", "extra"})) {
assertFalse(fixture.particlesCommand.getExecutor().onCommand(player.player,
fixture.particlesCommand, "fantasyparticles", invalid));
var routed = java.util.stream.Stream.concat(java.util.stream.Stream.of("particles"),
java.util.Arrays.stream(invalid)).toArray(String[]::new);
assertFalse(fixture.playerCommand.getExecutor().onCommand(player.player,
fixture.playerCommand, "fstick", routed));
}
fixture.particlesCommand.execute(player.player, "fantasyparticles",
@@ -78,8 +78,13 @@ class FantasyStickLifecycleNativeTest {
assertTrue(fixture.order.indexOf("OutOfCombatRecovery") < recipe);
assertTrue(fixture.order.indexOf("RecipeDiscovery") > recipe,
"Discovery must not become active before successful recipe registration");
assertNotNull(fixture.command);
assertEquals("EquipmentAdminCommand", fixture.command.getExecutor().getClass().getSimpleName());
assertNotNull(fixture.playerCommand);
assertNotNull(fixture.adminCommand);
assertEquals("FantasyPlayerCommand", fixture.playerCommand.getExecutor().getClass().getSimpleName());
assertEquals("EquipmentAdminCommand", fixture.adminCommand.getExecutor().getClass().getSimpleName());
assertNull(fixture.server.getPluginCommand("fantasystick"));
assertNull(fixture.server.getPluginCommand("fantasyfeedback"));
assertNull(fixture.server.getPluginCommand("fantasyparticles"));
assertTrue(fixture.tasks.isEmpty());
}
}
@@ -1144,8 +1149,10 @@ class FantasyStickLifecycleNativeTest {
final Thread main = Thread.currentThread();
final Map<java.util.UUID, org.bukkit.entity.Player> players = new HashMap<>();
org.bukkit.command.PluginCommand command;
org.bukkit.command.PluginCommand feedbackCommand;
org.bukkit.command.PluginCommand particlesCommand;
org.bukkit.command.PluginCommand playerCommand;
org.bukkit.command.PluginCommand adminCommand;
RoutedPlayerCommand feedbackCommand;
RoutedPlayerCommand particlesCommand;
int playerLookups;
String failRegistrationAt;
boolean failRemoval;
@@ -1229,9 +1236,9 @@ class FantasyStickLifecycleNativeTest {
.filter(world -> java.util.Objects.equals(world.getUID(), args[0])).findFirst().orElse(null);
case "getPlayer" -> { assertSame(main, Thread.currentThread()); playerLookups++; yield players.get(args[0]); }
case "getPluginCommand" -> switch ((String) args[0]) {
case "fantasyfeedback" -> feedbackCommand;
case "fantasyparticles" -> particlesCommand;
default -> command;
case "fstick" -> playerCommand;
case "fstickadmin" -> adminCommand;
default -> null;
};
case "createInventory" -> args[1] instanceof Integer size
? new org.bukkit.craftbukkit.inventory.CraftInventoryCustom((org.bukkit.inventory.InventoryHolder) args[0], size, (net.kyori.adventure.text.Component) args[2])
@@ -1256,28 +1263,44 @@ class FantasyStickLifecycleNativeTest {
plugin.getLogger().addHandler(logHandler);
var constructor = org.bukkit.command.PluginCommand.class.getDeclaredConstructor(String.class, org.bukkit.plugin.Plugin.class);
constructor.setAccessible(true);
var definition = plugin.getDescription().getCommands().get("fantasystick");
if (definition != null) {
// Bukkit normally constructs this from the packaged descriptor during plugin loading.
command = constructor.newInstance("fantasystick", plugin);
command.setPermission((String) definition.get("permission"));
// Explicit server-message port: native permission dispatch otherwise reads global Bukkit.server.
command.permissionMessage(net.kyori.adventure.text.Component.text("Permission denied"));
command.setUsage((String) definition.get("usage"));
var playerDefinition = plugin.getDescription().getCommands().get("fstick");
if (playerDefinition != null) {
// Bukkit normally constructs these from the packaged descriptor during plugin loading.
playerCommand = constructor.newInstance("fstick", plugin);
playerCommand.setPermission((String) playerDefinition.get("permission"));
playerCommand.permissionMessage(net.kyori.adventure.text.Component.text("Permission denied"));
playerCommand.setUsage((String) playerDefinition.get("usage"));
}
var feedbackDefinition = plugin.getDescription().getCommands().get("fantasyfeedback");
if (feedbackDefinition != null) {
feedbackCommand = constructor.newInstance("fantasyfeedback", plugin);
feedbackCommand.setPermission((String) feedbackDefinition.get("permission"));
feedbackCommand.permissionMessage(net.kyori.adventure.text.Component.text("Permission denied"));
feedbackCommand.setUsage((String) feedbackDefinition.get("usage"));
var adminDefinition = plugin.getDescription().getCommands().get("fstickadmin");
if (adminDefinition != null) {
adminCommand = constructor.newInstance("fstickadmin", plugin);
adminCommand.setPermission((String) adminDefinition.get("permission"));
adminCommand.permissionMessage(net.kyori.adventure.text.Component.text("Permission denied"));
adminCommand.setUsage((String) adminDefinition.get("usage"));
}
var particlesDefinition = plugin.getDescription().getCommands().get("fantasyparticles");
if (particlesDefinition != null) {
particlesCommand = constructor.newInstance("fantasyparticles", plugin);
particlesCommand.setPermission((String) particlesDefinition.get("permission"));
particlesCommand.permissionMessage(net.kyori.adventure.text.Component.text("Permission denied"));
particlesCommand.setUsage((String) particlesDefinition.get("usage"));
command = adminCommand;
feedbackCommand = new RoutedPlayerCommand(playerCommand, "feedback");
particlesCommand = new RoutedPlayerCommand(playerCommand, "particles");
}
static final class RoutedPlayerCommand {
private final org.bukkit.command.PluginCommand command;
private final String route;
RoutedPlayerCommand(org.bukkit.command.PluginCommand command, String route) {
this.command = command;
this.route = route;
}
boolean execute(org.bukkit.command.CommandSender sender, String label, String[] args) {
return command.execute(sender, "fstick", prefixed(args));
}
java.util.List<String> tabComplete(org.bukkit.command.CommandSender sender, String label, String[] args) {
return command.tabComplete(sender, "fstick", prefixed(args));
}
private String[] prefixed(String[] args) {
var routed = new String[args.length + 1];
routed[0] = route;
System.arraycopy(args, 0, routed, 1, args.length);
return routed;
}
}
@@ -0,0 +1,208 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Path;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import net.minecraft.network.chat.Component;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Registered saved-character lookup and exact-UUID read-only inspection. */
class SavedCharacterAdminNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void findsInactiveSavedCharactersThenShowsExactSavedStateWithoutMutation() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var administrator = new FantasyStickLifecycleNativeTest.Actor(data);
administrator.accountName = "Admin";
fixture.players.put(administrator.core.id, administrator.player);
var first = new FantasyStickLifecycleNativeTest.Actor(data);
var second = new FantasyStickLifecycleNativeTest.Actor(data);
var storage = characters(fixture);
var renamedFirst = storage.renameCharacter(first.core.record.id(), "Écho Scout").get(5, TimeUnit.SECONDS).orElseThrow();
storage.renameCharacter(second.core.record.id(), "Echo Keeper").get(5, TimeUnit.SECONDS).orElseThrow();
byte[] before = java.nio.file.Files.readAllBytes(data.resolve("characters").resolve(renamedFirst.id() + ".yml"));
assertTrue(fixture.adminCommand.execute(administrator.player, "fstickadmin",
new String[]{"character", "find", "echo"}));
drain(fixture);
assertTrue(administrator.messages.stream().anyMatch(message -> message.contains("Echo Keeper")));
assertFalse(administrator.messages.stream().anyMatch(message -> message.contains("Écho Scout")),
"Literal case-insensitive matching does not strip accents");
assertTrue(administrator.messages.stream().anyMatch(message -> message.contains(second.core.record.id().toString())));
assertEquals(java.util.List.of("Echo Keeper"), fixture.adminCommand.tabComplete(administrator.player,
"fstickadmin", new String[]{"character", "find", "ech"}));
assertEquals(java.util.List.of(second.core.record.id().toString()), fixture.adminCommand.tabComplete(
administrator.player, "fstickadmin", new String[]{"character", "show",
second.core.record.id().toString().substring(0, 8)}));
administrator.messages.clear();
fixture.adminCommand.execute(administrator.player, "fstickadmin",
new String[]{"character", "find", "Écho"});
fixture.adminCommand.execute(administrator.player, "fstickadmin",
new String[]{"character", "find", "Keeper"});
drain(fixture);
assertFalse(administrator.messages.stream().anyMatch(message -> message.contains("Écho Scout")),
"A superseded asynchronous search must not publish stale results");
assertTrue(administrator.messages.stream().anyMatch(message -> message.contains("Echo Keeper")));
administrator.messages.clear();
assertTrue(fixture.adminCommand.execute(administrator.player, "fstickadmin",
new String[]{"character", "show", renamedFirst.id().toString()}));
drain(fixture);
assertEquals(1, administrator.books.size());
String text = pages(administrator).stream().map(Component::getString)
.collect(java.util.stream.Collectors.joining("\n"));
assertTrue(text.contains("Saved state"), text);
assertTrue(text.contains("Inactive"), text);
String unwrapped = text.replace("\n", "");
assertTrue(unwrapped.contains("Saved health: 10/10"), text);
assertTrue(unwrapped.contains(renamedFirst.id().toString()), text);
assertTrue(text.contains("Écho Scout"), text);
assertArrayEquals(before, java.nio.file.Files.readAllBytes(
data.resolve("characters").resolve(renamedFirst.id() + ".yml")));
}
}
@Test void savedViewUsesSavedRatherThanLiveHealthAndRevalidatesPermissionAndLatestRequest() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var administrator = new FantasyStickLifecycleNativeTest.Actor(data);
var first = new FantasyStickLifecycleNativeTest.Actor(data);
var second = new FantasyStickLifecycleNativeTest.Actor(data);
administrator.accountName = "Admin";
fixture.players.put(administrator.core.id, administrator.player);
fixture.players.put(first.core.id, first.player);
fixture.players.put(second.core.id, second.player);
fixture.activate(first);
first.core.health = 3;
fixture.adminCommand.execute(administrator.player, "fstickadmin",
new String[]{"character", "show", first.core.record.id().toString()});
fixture.adminCommand.execute(administrator.player, "fstickadmin",
new String[]{"character", "show", second.core.record.id().toString()});
drain(fixture);
assertEquals(1, administrator.books.size(), "Only the latest saved-state request may open");
String latest = pages(administrator).stream().map(Component::getString)
.collect(java.util.stream.Collectors.joining("\n")).replace("\n", "");
assertTrue(latest.contains(second.core.record.id().toString()));
assertTrue(latest.contains("Inactive"));
fixture.adminCommand.execute(administrator.player, "fstickadmin",
new String[]{"character", "show", first.core.record.id().toString()});
characters(fixture).find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
var response = fixture.tasks.poll();
assertNotNull(response);
administrator.allowed = false;
response.run();
assertEquals(1, administrator.books.size());
assertTrue(administrator.messages.getLast().contains("permission changed"));
administrator.allowed = true;
fixture.adminCommand.execute(administrator.player, "fstickadmin",
new String[]{"character", "show", first.core.record.id().toString()});
drain(fixture);
String active = pages(administrator).stream().map(Component::getString)
.collect(java.util.stream.Collectors.joining("\n")).replace("\n", "");
assertTrue(active.contains("Active"));
assertTrue(active.contains("Saved health: 10/10"), active);
assertFalse(active.contains("Saved health: 3/10"), active);
}
}
@Test void searchPaginatesDuplicateNamesReportsUnreadableRecordsAndSupportsConsole() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var administrator = new FantasyStickLifecycleNativeTest.Actor(data);
administrator.accountName = "Admin";
fixture.players.put(administrator.core.id, administrator.player);
var storage = characters(fixture);
var records = new java.util.ArrayList<games.dmg.fantasystick.persistence.CharacterRecord>();
for (int index = 0; index < 10; index++) {
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
records.add(storage.renameCharacter(actor.core.record.id(), "Shared Name")
.get(5, TimeUnit.SECONDS).orElseThrow());
if (index == 0) {
actor.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets()
.create(records.getLast()));
fixture.players.put(actor.core.id, actor.player);
fixture.activate(actor);
}
}
var dead = storage.recordDeath(records.get(1).id()).get(5, TimeUnit.SECONDS).orElseThrow();
UUID corrupt = UUID.randomUUID();
java.nio.file.Files.writeString(data.resolve("characters").resolve(corrupt + ".yml"),
"schema-version: malformed\n");
fixture.adminCommand.execute(administrator.player, "fstickadmin",
new String[]{"character", "find", "shared name"});
drain(fixture);
assertTrue(administrator.messages.contains("Saved character results 1/2 (incomplete: 1 unreadable)"));
assertTrue(administrator.messages.contains("Saved character results 2/2 (incomplete: 1 unreadable)"));
assertEquals(10, administrator.messages.stream().filter(message -> message.contains("Shared Name — ")).count());
assertTrue(administrator.messages.stream().anyMatch(message -> message.contains(" — active")));
assertTrue(administrator.messages.stream().anyMatch(message -> message.contains(dead.id().toString())
&& message.contains(" — dead — inactive")));
assertEquals(10, administrator.messageComponents.stream()
.filter(component -> component.clickEvent() != null && component.clickEvent().action()
== net.kyori.adventure.text.event.ClickEvent.Action.SUGGEST_COMMAND)
.count());
var console = new EquipmentAdminCommandNativeTest.Sender();
fixture.adminCommand.execute(console.port, "fstickadmin",
new String[]{"character", "find", "Shared"});
drain(fixture);
assertTrue(console.messages.stream().anyMatch(message -> message.contains("Saved character results 1/2")));
fixture.adminCommand.execute(console.port, "fstickadmin",
new String[]{"character", "show", records.getFirst().id().toString()});
assertTrue(console.messages.getLast().contains("in-game administrator"));
int opened = administrator.books.size();
fixture.adminCommand.execute(administrator.player, "fstickadmin",
new String[]{"character", "show", dead.id().toString()});
drain(fixture);
assertEquals(opened + 1, administrator.books.size());
String deadText = pages(administrator).stream().map(Component::getString)
.collect(java.util.stream.Collectors.joining("\n")).replace("\n", "");
assertTrue(deadText.contains("Dead"));
assertTrue(deadText.contains(dead.deathId().toString()));
assertTrue(deadText.contains(dead.diedAt().toString()));
fixture.adminCommand.execute(administrator.player, "fstickadmin",
new String[]{"character", "show", corrupt.toString()});
drain(fixture);
assertEquals(opened + 1, administrator.books.size());
assertTrue(administrator.messages.getLast().contains("could not be loaded"));
fixture.adminCommand.execute(administrator.player, "fstickadmin",
new String[]{"character", "show", UUID.randomUUID().toString()});
drain(fixture);
assertEquals(opened + 1, administrator.books.size());
assertTrue(administrator.messages.getLast().contains("not found"));
}
}
private static games.dmg.fantasystick.persistence.CharacterStorage characters(
FantasyStickLifecycleNativeTest.Fixture fixture) {
return (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
}
private static void drain(FantasyStickLifecycleNativeTest.Fixture fixture) throws Exception {
characters(fixture).find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
Runnable callback;
while ((callback = fixture.tasks.poll()) != null) callback.run();
}
private static java.util.List<Component> pages(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);
return content.pages().stream().map(page -> page.raw()).toList();
}
}
@@ -29,23 +29,31 @@ class DistributionTest {
assertEquals("games.dmg.fantasystick.FantasyStickPlugin", metadata.getMain());
assertNotNull(metadata.getAPIVersion(), "Declare the native API compatibility contract");
assertEquals(java.util.List.of("ProtocolLib"), metadata.getDepend());
assertEquals(java.util.Set.of("fantasystick", "fantasyfeedback", "fantasyparticles"),
metadata.getCommands().keySet());
assertNull(metadata.getCommands().get("fantasystick").get("permission"),
"Player adventure inspection must reach command-level authorization");
assertNull(metadata.getCommands().get("fantasyfeedback").get("permission"));
assertNull(metadata.getCommands().get("fantasyparticles").get("permission"));
assertEquals("/fantasyfeedback [on|off]", metadata.getCommands().get("fantasyfeedback").get("usage"));
assertEquals("/fantasyparticles <all|players> [on|off]",
metadata.getCommands().get("fantasyparticles").get("usage"));
assertTrue(metadata.getCommands().get("fantasystick").get("usage").toString().contains("adventure-site"));
assertTrue(metadata.getCommands().get("fantasystick").get("usage").toString().contains("quest-giver"));
assertTrue(metadata.getCommands().get("fantasystick").get("usage").toString()
assertEquals(java.util.Set.of("fstick", "fstickadmin"), metadata.getCommands().keySet());
assertNull(metadata.getCommands().get("fstick").get("permission"),
"Personal player controls perform their own sender/context checks");
assertEquals("fantasystick.admin", metadata.getCommands().get("fstickadmin").get("permission"));
assertTrue(metadata.getCommands().get("fstick").get("usage").toString().contains("feedback [on|off]"));
assertTrue(metadata.getCommands().get("fstick").get("usage").toString()
.contains("particles <all|players> [on|off]"));
assertTrue(metadata.getCommands().get("fstickadmin").get("usage").toString().contains("adventure-site"));
assertTrue(metadata.getCommands().get("fstickadmin").get("usage").toString().contains("quest-giver"));
assertTrue(metadata.getCommands().get("fstickadmin").get("usage").toString()
.contains("character view <online-player>"));
assertEquals(1, metadata.getPermissions().size());
assertEquals("fantasystick.admin", metadata.getPermissions().getFirst().getName());
assertEquals(org.bukkit.permissions.PermissionDefault.OP, metadata.getPermissions().getFirst().getDefault());
var directParticleEmitters = jar.stream().filter(entry -> entry.getName().endsWith(".class"))
var staleCommandReferences = jar.stream().filter(entry -> entry.getName().endsWith(".class"))
.filter(entry -> {
try (var input = jar.getInputStream(entry)) {
String bytes = new String(input.readAllBytes(), java.nio.charset.StandardCharsets.ISO_8859_1);
return bytes.contains("/fantasystick ") || bytes.contains("/fantasyfeedback")
|| bytes.contains("/fantasyparticles");
} catch (java.io.IOException failure) { throw new java.io.UncheckedIOException(failure); }
}).map(java.util.zip.ZipEntry::getName).collect(java.util.stream.Collectors.toSet());
assertTrue(staleCommandReferences.isEmpty(),
"Packaged runtime guidance must not advertise removed command roots: " + staleCommandReferences);
var directParticleEmitters = jar.stream().filter(entry -> entry.getName().endsWith(".class")).filter(entry -> entry.getName().endsWith(".class"))
.filter(entry -> {
try (var input = jar.getInputStream(entry)) {
return new String(input.readAllBytes(), java.nio.charset.StandardCharsets.ISO_8859_1)
@@ -0,0 +1,178 @@
package games.dmg.fantasystick.adventure;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.dungeon.DungeonDirectory;
import games.dmg.fantasystick.persistence.AdventureQuestSettings;
import games.dmg.fantasystick.persistence.YamlAdventureQuestRepository;
import games.dmg.fantasystick.persistence.YamlDungeonRepository;
import java.math.BigInteger;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Random;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
final class AdventureParticipationModeTest {
@TempDir Path data;
@Test void sitesDefaultPersistUpdateAndCaptureParticipationMode() throws Exception {
var rules = new AdventureQuestSettings.Rules(3, 16, 30, 8, BigInteger.valueOf(100),
BigInteger.valueOf(20), 100, 8, BigInteger.valueOf(150), BigInteger.valueOf(40));
var clock = Clock.fixed(Instant.parse("2026-09-23T19:00:00Z"), ZoneOffset.UTC);
UUID siteId;
try (var directory = new AdventureQuestDirectory(new YamlAdventureQuestRepository(data), clock,
() -> rules, new Random(1))) {
var site = directory.placeSite(AdventureQuestState.SiteType.CAMP, "Mode Camp", UUID.randomUUID(),
0, 64, 0).get(5, TimeUnit.SECONDS);
siteId = site.id();
assertEquals(AdventureQuestState.ParticipationMode.PARTY_AVAILABLE, site.participationMode());
var changed = directory.setSiteMode(site.id(), site.participationMode(),
AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY).get(5, TimeUnit.SECONDS);
assertEquals(AdventureQuestDirectory.ModeStatus.APPLIED, changed.status());
assertEquals(AdventureQuestDirectory.ModeStatus.STALE, directory.setSiteMode(site.id(),
AdventureQuestState.ParticipationMode.PARTY_AVAILABLE,
AdventureQuestState.ParticipationMode.PARTY_REQUIRED).get(5, TimeUnit.SECONDS).status());
var catalog = directory.ensureToday().get(5, TimeUnit.SECONDS);
UUID character = UUID.randomUUID(), party = UUID.randomUUID();
assertEquals(AdventureQuestDirectory.Acceptance.POLICY, directory.accept(catalog.revision(), site.id(),
AdventureQuestState.UnitType.PARTY, party, Set.of(character),
AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY).get(5, TimeUnit.SECONDS).status());
var quest = directory.accept(catalog.revision(), site.id(), AdventureQuestState.UnitType.SOLO,
character, Set.of(character), AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY)
.get(5, TimeUnit.SECONDS).quest().orElseThrow();
assertEquals(AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY, quest.participationMode());
}
try (var restarted = new AdventureQuestDirectory(new YamlAdventureQuestRepository(data), clock,
() -> rules, new Random(2))) {
assertEquals(AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY,
restarted.findSite(siteId).orElseThrow().participationMode());
assertEquals(AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY,
restarted.active().getFirst().participationMode());
}
}
@Test void compatibleSiteAndDungeonSavesDefaultToPartyAvailable() throws Exception {
Files.writeString(data.resolve("adventure-quests.yml"), """
schema-version: 1
sites:
- id: '00000000-0000-0000-0000-000000000001'
type: CAMP
name: Legacy
world: '00000000-0000-0000-0000-000000000002'
x: '0.0'
y: '64.0'
z: '0.0'
givers: []
catalog: null
quests: []
unit-claims: []
character-claims: []
rosters: []
tracking: []
rewards: {allocations: [], operations: [], receipts: []}
reset: null
""");
assertEquals(AdventureQuestState.ParticipationMode.PARTY_AVAILABLE,
new YamlAdventureQuestRepository(data).load().sites().getFirst().participationMode());
UUID dungeonId;
try (var dungeons = new DungeonDirectory(new YamlDungeonRepository(data))) {
var dungeon = dungeons.create("Modes", UUID.randomUUID(), 0, 64, 0, 0, 0,
AdventureQuestState.ParticipationMode.PARTY_REQUIRED).get(5, TimeUnit.SECONDS);
dungeonId = dungeon.id();
assertEquals(AdventureQuestState.ParticipationMode.PARTY_REQUIRED, dungeon.participationMode());
var updated = dungeons.setMode(dungeon.id(), dungeon.revision(),
AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY).get(5, TimeUnit.SECONDS);
assertEquals(AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY, updated.participationMode());
assertThrows(java.util.concurrent.ExecutionException.class, () -> dungeons.setMode(dungeon.id(),
dungeon.revision(), AdventureQuestState.ParticipationMode.PARTY_AVAILABLE).get(5, TimeUnit.SECONDS));
}
var dungeonFile = data.resolve("dungeons.yml");
Files.writeString(dungeonFile, Files.readString(dungeonFile)
.replaceFirst("(?m)^ participation-mode: individual-only\\R", ""));
assertEquals(AdventureQuestState.ParticipationMode.PARTY_AVAILABLE,
new YamlDungeonRepository(data).load().dungeons().stream()
.filter(value -> value.id().equals(dungeonId)).findFirst().orElseThrow().participationMode());
}
@Test void failedModeWritesRetainLastAcknowledgedSiteAndDungeonState() throws Exception {
var rules = new AdventureQuestSettings.Rules(1, 16, 30, 8, BigInteger.valueOf(100),
BigInteger.valueOf(20), 100, 8, BigInteger.valueOf(150), BigInteger.valueOf(40));
try (var directory = new AdventureQuestDirectory(new YamlAdventureQuestRepository(data), Clock.systemUTC(),
() -> rules, new Random(1))) {
var site = directory.placeSite(AdventureQuestState.SiteType.CAMP, "Retained", UUID.randomUUID(),
0, 64, 0).get(5, TimeUnit.SECONDS);
var file = data.resolve("adventure-quests.yml");
Files.delete(file); Files.createDirectory(file);
assertThrows(java.util.concurrent.ExecutionException.class, () -> directory.setSiteMode(site.id(),
AdventureQuestState.ParticipationMode.PARTY_AVAILABLE,
AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY).get(5, TimeUnit.SECONDS));
assertEquals(AdventureQuestState.ParticipationMode.PARTY_AVAILABLE,
directory.findSite(site.id()).orElseThrow().participationMode());
}
var dungeonRoot = data.resolve("dungeon-failure");
try (var dungeons = new DungeonDirectory(new YamlDungeonRepository(dungeonRoot))) {
var dungeon = dungeons.create("Retained", UUID.randomUUID(), 0, 64, 0, 0, 0)
.get(5, TimeUnit.SECONDS);
var file = dungeonRoot.resolve("dungeons.yml");
Files.delete(file); Files.createDirectory(file);
assertThrows(java.util.concurrent.ExecutionException.class, () -> dungeons.setMode(dungeon.id(),
dungeon.revision(), AdventureQuestState.ParticipationMode.PARTY_REQUIRED)
.get(5, TimeUnit.SECONDS));
assertEquals(AdventureQuestState.ParticipationMode.PARTY_AVAILABLE,
dungeons.find(dungeon.id()).orElseThrow().participationMode());
}
}
@Test void liveModeChangesGovernNewAcceptanceWithoutReinterpretingExistingQuest() throws Exception {
var rules = new AdventureQuestSettings.Rules(1, 16, 30, 8, BigInteger.valueOf(100),
BigInteger.valueOf(20), 100, 8, BigInteger.valueOf(150), BigInteger.valueOf(40));
var clock = Clock.fixed(Instant.parse("2026-09-23T19:00:00Z"), ZoneOffset.UTC);
try (var directory = new AdventureQuestDirectory(new YamlAdventureQuestRepository(data), clock,
() -> rules, new Random(1))) {
var site = directory.placeSite(AdventureQuestState.SiteType.CHAMPION, "Changing", UUID.randomUUID(),
0, 64, 0).get(5, TimeUnit.SECONDS);
var catalog = directory.ensureToday().get(5, TimeUnit.SECONDS);
UUID first = UUID.randomUUID(), second = UUID.randomUUID(), party = UUID.randomUUID();
var existing = directory.accept(catalog.revision(), site.id(), AdventureQuestState.UnitType.SOLO,
first, Set.of(first), AdventureQuestState.ParticipationMode.PARTY_AVAILABLE)
.get(5, TimeUnit.SECONDS).quest().orElseThrow();
directory.setSiteMode(site.id(), AdventureQuestState.ParticipationMode.PARTY_AVAILABLE,
AdventureQuestState.ParticipationMode.PARTY_REQUIRED).get(5, TimeUnit.SECONDS);
assertEquals(AdventureQuestState.ParticipationMode.PARTY_AVAILABLE, existing.participationMode());
assertEquals(AdventureQuestDirectory.Acceptance.STALE, directory.accept(catalog.revision(), site.id(),
AdventureQuestState.UnitType.PARTY, party, Set.of(second),
AdventureQuestState.ParticipationMode.PARTY_AVAILABLE).get(5, TimeUnit.SECONDS).status());
var replacement = directory.accept(catalog.revision(), site.id(), AdventureQuestState.UnitType.PARTY,
party, Set.of(second), AdventureQuestState.ParticipationMode.PARTY_REQUIRED)
.get(5, TimeUnit.SECONDS).quest().orElseThrow();
assertEquals(AdventureQuestState.ParticipationMode.PARTY_REQUIRED, replacement.participationMode());
}
}
@Test void personalAndPartyCommitmentsCannotOverlapCharacterSnapshots() throws Exception {
var rules = new AdventureQuestSettings.Rules(2, 16, 30, 8, BigInteger.valueOf(100),
BigInteger.valueOf(20), 100, 8, BigInteger.valueOf(150), BigInteger.valueOf(40));
var clock = Clock.fixed(Instant.parse("2026-09-23T19:00:00Z"), ZoneOffset.UTC);
try (var directory = new AdventureQuestDirectory(new YamlAdventureQuestRepository(data), clock,
() -> rules, new Random(1))) {
var first = directory.placeSite(AdventureQuestState.SiteType.CAMP, "First", UUID.randomUUID(),
0, 64, 0).get(5, TimeUnit.SECONDS);
var second = directory.placeSite(AdventureQuestState.SiteType.CHAMPION, "Second", UUID.randomUUID(),
20, 64, 0).get(5, TimeUnit.SECONDS);
var catalog = directory.ensureToday().get(5, TimeUnit.SECONDS);
UUID leader = UUID.randomUUID(), member = UUID.randomUUID(), party = UUID.randomUUID();
assertEquals(AdventureQuestDirectory.Acceptance.APPLIED, directory.accept(catalog.revision(), first.id(),
AdventureQuestState.UnitType.PARTY, party, Set.of(leader, member)).get(5, TimeUnit.SECONDS).status());
assertEquals(AdventureQuestDirectory.Acceptance.CONFLICT, directory.accept(catalog.revision(), second.id(),
AdventureQuestState.UnitType.SOLO, member, Set.of(member)).get(5, TimeUnit.SECONDS).status());
}
}
}
@@ -699,6 +699,164 @@ class CharacterStorageTest {
}
}
@Test
void ordinaryMutationsPreserveAnAcknowledgedSheetGeneration() throws Exception {
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var generated = new CharacterRecord(base.id(), base.name(), base.revision(), base.stats(), base.level(),
base.experience(), base.unspentPoints(), base.health(), base.learned(), base.loadout(), base.passives(),
base.cooldowns(), base.restrictedKills(), false, null, null, BigInteger.valueOf(100), true,
base.rewardReceipts(), BigInteger.ZERO, base.gear(), BigInteger.valueOf(7));
var repository = new YamlCharacterRepository(data);
repository.create(generated);
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
var renamed = storage.renameCharacter(generated.id(), "Generation keeper").join().orElseThrow();
assertEquals(BigInteger.valueOf(7), renamed.sheetGeneration());
var purchased = storage.purchaseGear(generated.id(), renamed.revision(), UUID.randomUUID(),
GearSlot.CHESTPLATE, GearTier.LEATHER, BigInteger.ONE).join().record().orElseThrow();
assertEquals(BigInteger.valueOf(7), purchased.sheetGeneration());
var dead = storage.recordDeath(generated.id(), false).join().orElseThrow();
assertEquals(BigInteger.valueOf(7), dead.sheetGeneration());
var restored = storage.restoreDeathRecordAdministratively(
generated.id(), dead.deathId(), dead.maximumHealth()).join().orElseThrow();
assertEquals(BigInteger.valueOf(7), restored.sheetGeneration());
assertEquals(BigInteger.valueOf(7), new YamlCharacterRepository(data).load(generated.id())
.orElseThrow().sheetGeneration());
}
}
@Test
void preparedRecoveryWithUnchangedCharacterReconcilesWithoutRevocationAndAllowsNewConfirmation() throws Exception {
var repository = new YamlCharacterRepository(data);
var character = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
repository.create(character);
var operation = recovery(character, UUID.randomUUID());
var store = new CharacterRecoveryStore(data);
store.prepare(new CharacterRecoveryStore.Operation(operation.operation(), character.id(),
operation.administrator(), operation.recipient(), character.revision(), character.sheetGeneration(),
character.sheetGeneration().add(BigInteger.ONE), false, null, CharacterRecoveryStore.Phase.PREPARED));
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
assertEquals(character, storage.find(character.id()).join().orElseThrow());
var replacement = storage.beginRecovery(recovery(character, UUID.randomUUID())).join();
assertEquals(CharacterStorage.RecoveryStatus.APPLIED, replacement.status());
}
}
@Test
void recoveryConfirmationCannotChangeMeaningAfterThePreviewedLifeOrDeathStateChanges() throws Exception {
var repository = new YamlCharacterRepository(data);
var living = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
repository.create(living);
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
var livingRequest = recovery(living, UUID.randomUUID());
var dead = storage.recordDeath(living.id(), false).join().orElseThrow();
assertEquals(CharacterStorage.RecoveryStatus.STALE, storage.beginRecovery(livingRequest).join().status());
var reviveRequest = new CharacterStorage.RecoveryRequest(UUID.randomUUID(), dead.id(), UUID.randomUUID(),
UUID.randomUUID(), dead.revision(), dead.sheetGeneration(), true, dead.deathId(), dead.maximumHealth());
storage.restoreDeathRecordAdministratively(dead.id(), dead.deathId(), dead.maximumHealth()).join().orElseThrow();
assertEquals(CharacterStorage.RecoveryStatus.STALE, storage.beginRecovery(reviveRequest).join().status());
}
}
@Test
void failedCharacterCommitCancelsPreparedRecoveryWithoutRevokingTheCurrentGeneration() throws Exception {
var moves = new java.util.concurrent.atomic.AtomicInteger();
var repository = new YamlCharacterRepository(data, (source, target) -> {
if (moves.incrementAndGet() == 2) throw new IOException("synthetic recovery commit failure");
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
});
var character = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
repository.create(character);
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
assertThrows(CompletionException.class,
() -> storage.beginRecovery(recovery(character, UUID.randomUUID())).join());
}
assertEquals(character, new YamlCharacterRepository(data).load(character.id()).orElseThrow());
try (var retry = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
assertEquals(CharacterStorage.RecoveryStatus.APPLIED,
retry.beginRecovery(recovery(character, UUID.randomUUID())).join().status());
}
}
@Test
void restartPromotesProvedPreparedCommitToUnresolvedWithoutReissuing() throws Exception {
var repository = new YamlCharacterRepository(data);
var character = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
repository.create(character);
var request = recovery(character, UUID.randomUUID());
var store = new CharacterRecoveryStore(data);
var operation = new CharacterRecoveryStore.Operation(request.operation(), character.id(),
request.administrator(), request.recipient(), character.revision(), character.sheetGeneration(),
character.sheetGeneration().add(BigInteger.ONE), false, null, CharacterRecoveryStore.Phase.PREPARED);
store.prepare(operation);
var replacement = new CharacterRecord(character.id(), character.name(), character.revision().add(BigInteger.ONE),
character.stats(), character.level(), character.experience(), character.unspentPoints(), character.health(),
character.learned(), character.loadout(), character.passives(), character.cooldowns(), character.restrictedKills(),
false, character.deathId(), character.diedAt(), character.coins(), character.coinsInitialized(),
character.rewardReceipts(), character.pvpCoinEscrow(), character.gear(), BigInteger.ONE);
repository.replaceForRecovery(replacement, character.revision());
try (var reloaded = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
assertThrows(CompletionException.class, () -> reloaded.find(character.id()).join());
assertEquals(CharacterRecoveryStore.Phase.COMMITTED,
store.find(character.id()).orElseThrow().phase());
assertThrows(CompletionException.class,
() -> reloaded.beginRecovery(recovery(replacement, UUID.randomUUID())).join());
}
}
@Test
void committedRecoveryBlocksLoadingAndMutationAcrossRestartUntilExactCompletion() throws Exception {
var repository = new YamlCharacterRepository(data);
var character = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
repository.create(character);
var request = recovery(character, UUID.randomUUID());
CharacterRecord committed;
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
committed = storage.beginRecovery(request).join().record().orElseThrow();
assertThrows(CompletionException.class, () -> storage.find(character.id()).join());
assertThrows(CompletionException.class, () -> storage.renameCharacter(character.id(), "Unsafe rename").join());
}
try (var reloaded = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
assertThrows(CompletionException.class, () -> reloaded.find(character.id()).join());
assertTrue(reloaded.completeRecovery(character.id(), request.operation()).join());
assertEquals(committed, reloaded.find(character.id()).join().orElseThrow());
assertThrows(CompletionException.class,
() -> reloaded.completeRecovery(character.id(), request.operation()).join());
assertEquals(CharacterStorage.RecoveryStatus.STALE,
reloaded.beginRecovery(request).join().status());
assertEquals(committed, reloaded.find(character.id()).join().orElseThrow());
}
}
@Test
void committedRecoveryProofRejectsARecordDowngradedToLegacyGeneration() throws Exception {
var repository = new YamlCharacterRepository(data);
var character = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
repository.create(character);
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
storage.beginRecovery(recovery(character, UUID.randomUUID())).join();
}
Path path = data.resolve("characters").resolve(character.id() + ".yml");
Files.writeString(path, Files.readString(path).replaceFirst("(?m)^sheet-generation:.*\\n", ""));
try (var reloaded = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
assertThrows(CompletionException.class, () -> reloaded.find(character.id()).join());
}
}
private static CharacterStorage.RecoveryRequest recovery(CharacterRecord record, UUID operation) {
return new CharacterStorage.RecoveryRequest(operation, record.id(), UUID.randomUUID(), UUID.randomUUID(),
record.revision(), record.sheetGeneration(), false, null, java.math.BigDecimal.valueOf(20));
}
@Test
void restoresOnlyFromTheSavedDeadCharacterAndPreservesProgressAndCooldowns() throws Exception {
var saved = YamlCharacterRepositoryTest.sample(UUID.randomUUID());
@@ -98,6 +98,28 @@ class YamlCharacterRepositoryTest {
assertEquals(Map.of(), CharacterYaml.decode(legacy).restrictedKills());
}
@Test
void catalogReadsOnlyCanonicalRegularRecordsAndReportsUnreadableEntries() throws Exception {
var repository = new YamlCharacterRepository(data);
var first = sample(UUID.randomUUID());
var second = sample(UUID.randomUUID());
repository.create(first); repository.create(second);
var directory = data.resolve("characters");
Files.writeString(directory.resolve(UUID.randomUUID() + ".yml"), "schema-version: malformed\n");
Files.writeString(directory.resolve("not-a-uuid.yml"), "ignored");
var outside = data.resolve("outside-character.yml");
Files.writeString(outside, CharacterYaml.encode(sample(UUID.randomUUID())));
var link = directory.resolve(UUID.randomUUID() + ".yml");
Files.createSymbolicLink(link, outside);
var catalog = repository.catalog();
assertEquals(Set.of(first.id(), second.id()), catalog.records().stream()
.map(CharacterRecord::id).collect(java.util.stream.Collectors.toSet()));
assertEquals(2, catalog.unreadable());
assertTrue(Files.isSymbolicLink(link));
assertEquals(CharacterYaml.encode(CharacterYaml.decode(Files.readString(outside))), Files.readString(outside));
}
@Test
void repeatedCreationAndAbandonmentDoNotAccumulatePerCharacterCoordinationFiles() throws Exception {
var repository = new YamlCharacterRepository(data);