Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbd0f70a8b | ||
|
|
7b588e08d2 | ||
|
|
86f248d80c |
No files matched your search
@@ -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;
|
||||
@@ -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,7 +11,7 @@ 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 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()));}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 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();}
|
||||
@@ -27,6 +27,6 @@ public final class AdventureQuestCommands {
|
||||
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> [<radius>|<x-blocks> <z-blocks>] [name...]|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>");}
|
||||
}
|
||||
@@ -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)); }
|
||||
|
||||
@@ -34,7 +34,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));}
|
||||
@@ -60,5 +60,5 @@ 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 void usage(CommandSender sender){sender.sendMessage("Usage: /fstickadmin dungeon <create|list|remove|edit|entrance|room|boss|enable|disable|open|close|teleport> ...");}
|
||||
}
|
||||
@@ -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"))));}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user