Compare commits

...
5 Commits
Author SHA1 Message Date
dmg e3f2f2481b feat(particles): add viewer-specific active auras
Release / release (push) Successful in 11m38s
2026-09-23 06:28:58 -04:00
dmg 6ec97569d3 feat(admin): add read-only active character view
Release / release (push) Successful in 11m51s
2026-09-23 00:02:07 -04:00
dmg fd5961ccd8 fix(sessions): simplify switch cooldown feedback
Release / release (push) Successful in 11m8s
2026-09-22 23:26:29 -04:00
dmg c2d84458c7 feat(adventure): add admin site teleports
Release / release (push) Successful in 10m59s
2026-09-22 18:25:14 -04:00
dmg f2a6043d08 feat(adventure): hide quest coordinates
Release / release (push) Successful in 11m29s
2026-09-22 18:09:30 -04:00
24 changed files with 1331 additions and 124 deletions

No files matched your search

+2 -1
View File
@@ -136,6 +136,7 @@ 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.
@@ -166,7 +167,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 shows public blue particles. 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 `/fantasyparticles players on`. The account-owned aura preference defaults to off; `/fantasyparticles players` reports it and `/fantasyparticles players off` hides only this recurring aura. Defeat, progression and ability particles remain unchanged. 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.
@@ -55,6 +55,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
private EquipmentAdminCommand admin;
private games.dmg.fantasystick.persistence.PlayerFeedbackPreferences feedbackPreferences;
private games.dmg.fantasystick.session.FantasyFeedbackCommand feedbackCommand;
private games.dmg.fantasystick.session.FantasyParticlesCommand particlesCommand;
private games.dmg.fantasystick.presentation.CombatFeedback combatFeedback;
private volatile Object lifetime;
private CharacterPresentation presentation;
@@ -274,6 +275,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
}
feedbackCommand = new games.dmg.fantasystick.session.FantasyFeedbackCommand(feedbackPreferences, nextTick,
failure -> getLogger().log(Level.WARNING, "Player feedback preference failed", failure));
particlesCommand = new games.dmg.fantasystick.session.FantasyParticlesCommand(feedbackPreferences, nextTick,
failure -> getLogger().log(Level.WARNING, "Player particle preference failed", failure));
java.util.function.Consumer<Throwable> presentationFailures = problem ->
getLogger().log(Level.WARNING, "Character presentation failed", problem);
// This supplier belongs to one enable, never the mutable plugin field after re-enable.
@@ -608,6 +611,7 @@ 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);
@@ -615,6 +619,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
"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);
getServer().getPluginManager().registerEvents(crafting, this);
getServer().getPluginManager().registerEvents(guard, this);
getServer().getPluginManager().registerEvents(anvilNames, this);
@@ -671,7 +679,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
getServer().getPluginManager().registerEvents(books, this);
getServer().getPluginManager().registerEvents(gameModes, this);
getServer().getPluginManager().registerEvents(sheetResourcePack, this);
sessionVisuals = new games.dmg.fantasystick.session.SessionVisuals(sessions,
sessionVisuals = new games.dmg.fantasystick.session.SessionVisuals(sessions, feedbackPreferences,
failure -> getLogger().log(Level.WARNING, "Fantasy session particle delivery failed", failure));
sessionVisuals.install(this);
recipeRegistered = getServer().addRecipe(new CharacterSheets().recipe());
@@ -697,6 +705,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingChat = chatNames; chatNames = null;
var closingAdmin = admin; admin = null;
var closingFeedbackCommand = feedbackCommand; feedbackCommand = null;
var closingParticlesCommand = particlesCommand; particlesCommand = null;
var closingFeedbackPreferences = feedbackPreferences; feedbackPreferences = null;
combatFeedback = null;
var closingEvents = sessionEvents; sessionEvents = null;
@@ -805,6 +814,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (closingProtocol != null) cleanup("protocol presentation", closingProtocol::close);
if (closingAdmin != null) cleanup("admin command", closingAdmin::close);
if (closingFeedbackCommand != null) cleanup("feedback command", closingFeedbackCommand::close);
if (closingParticlesCommand != null) cleanup("particles command", closingParticlesCommand::close);
if (closingFeedbackPreferences != null) cleanup("feedback preferences", closingFeedbackPreferences::close);
if (closingSettings != null) cleanup("configuration", closingSettings::close);
if (closingFireballSettings != null) cleanup("Fireball configuration", closingFireballSettings::close);
@@ -0,0 +1,66 @@
package games.dmg.fantasystick.adventure;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
import java.util.function.Supplier;
import org.bukkit.Location;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.bukkit.plugin.Plugin;
/** Shared asynchronous, revalidated administrator teleport for durable adventure locations. */
public final class AdventureAdminTeleport {
private static final String PERMISSION="fantasystick.admin";
private final Server server;
private final Consumer<Runnable> nextTick;
private final AdventureLocationPresentation locations;
public AdventureAdminTeleport(Plugin plugin,Consumer<Runnable> nextTick){
this.server=Objects.requireNonNull(plugin).getServer();
this.nextTick=Objects.requireNonNull(nextTick);
this.locations=new AdventureLocationPresentation(server);
}
public record Target(UUID id,String name,String type,UUID world,double x,double y,double z,float yaw,float pitch,boolean preserveFacing){
public Target{Objects.requireNonNull(id);Objects.requireNonNull(name);Objects.requireNonNull(type);Objects.requireNonNull(world);}
}
public void teleport(CommandSender sender,String route,Target expected,Supplier<Optional<Target>> current){
Objects.requireNonNull(sender);Objects.requireNonNull(route);Objects.requireNonNull(expected);Objects.requireNonNull(current);
if(!(sender instanceof Player player)){sender.sendMessage(route+" teleport requires a player.");return;}
World world=server.getWorld(expected.world());
if(world==null){sender.sendMessage(route+" teleport refused: destination world is unavailable.");return;}
CompletableFuture<?> loading;
try{loading=world.getChunkAtAsync(section(expected.x()),section(expected.z()));}
catch(RuntimeException failure){sender.sendMessage(route+" teleport failed; your location is unchanged.");return;}
loading.whenComplete((chunk,loadFailure)->schedule(()->{
if(loadFailure!=null){player.sendMessage(route+" teleport failed; your location is unchanged.");return;}
if(!currentPlayer(player)){player.sendMessage(route+" teleport refused: permission or player session changed.");return;}
var fresh=current.get();
if(fresh.isEmpty()||!fresh.orElseThrow().equals(expected)){
player.sendMessage(route+" teleport refused: destination changed or is no longer registered.");return;
}
World currentWorld=server.getWorld(expected.world());
if(currentWorld==null||currentWorld!=world){player.sendMessage(route+" teleport refused: destination world is unavailable.");return;}
var facing=player.getLocation();
var destination=new Location(world,expected.x(),expected.y(),expected.z(),
expected.preserveFacing()?facing.getYaw():expected.yaw(),expected.preserveFacing()?facing.getPitch():expected.pitch());
CompletableFuture<Boolean> moving;
try{moving=player.teleportAsync(destination);}
catch(RuntimeException failure){player.sendMessage(route+" teleport failed; your location is unchanged.");return;}
moving.whenComplete((moved,moveFailure)->schedule(()->{
if(moveFailure!=null||!Boolean.TRUE.equals(moved)){player.sendMessage(route+" teleport failed; your location is unchanged.");return;}
if(player.isOnline())player.sendMessage("Teleported to "+expected.name()+" ("+expected.type()+") at "+locations.format(expected.world(),expected.x(),expected.y(),expected.z())+".");
}));
}));
}
private boolean currentPlayer(Player player){return player.isOnline()&&player.hasPermission(PERMISSION)&&server.getPlayer(player.getUniqueId())==player;}
private void schedule(Runnable task){try{nextTick.accept(task);}catch(RuntimeException ignored){}}
private static int section(double coordinate){return((int)Math.floor(coordinate))>>4;}
}
@@ -9,12 +9,14 @@ import org.bukkit.plugin.Plugin;
/** Registered command adapter for player inspection and acknowledged adventure administration. */
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;
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());}
private final Plugin plugin;private final AdventureQuestDirectory directory;private final AdventureQuestController controller;private final Consumer<Runnable> nextTick;private final AdventureLocationPresentation locations;private final AdventureAdminTeleport teleports;
public AdventureQuestCommands(Plugin plugin,AdventureQuestDirectory directory,AdventureQuestController controller,Consumer<Runnable> nextTick){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.controller=Objects.requireNonNull(controller);this.nextTick=Objects.requireNonNull(nextTick);locations=new AdventureLocationPresentation(plugin.getServer());teleports=new AdventureAdminTeleport(plugin,nextTick);}
public void adventure(CommandSender sender,String[] args){if(args.length==1){if(sender instanceof Player player)controller.inspect(player);else sender.sendMessage("Usage: /fantasystick adventure");return;}if(args.length==2&&args[1].equals("tracking")){if(sender instanceof Player player)controller.tracking(player,null);else sender.sendMessage("Quest tracking requires a player.");return;}if(args.length==3&&args[1].equals("tracking")&&(args[2].equals("on")||args[2].equals("off"))){if(sender instanceof Player player)controller.tracking(player,args[2].equals("on"));else sender.sendMessage("Quest tracking requires a player.");return;}if(args.length==2&&args[1].equals("reroll")){if(!authorized(sender))return;sender.sendMessage("Reroll expires every current adventure quest, encounter and reward claim. Run /fantasystick adventure reroll confirm to reset the day.");return;}if(args.length!=3||!args[1].equals("reroll")||!args[2].equals("confirm")){sender.sendMessage("Usage: /fantasystick adventure [tracking [on|off]|reroll confirm]");return;}if(!authorized(sender))return;var current=directory.snapshot().catalog();UUID expected=current==null?null:current.revision();directory.rerollToday(expected).whenComplete((result,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Adventure reset failed; the current generation is unchanged.");return;}switch(result.status()){case STALE->sender.sendMessage("Adventure reset refused: the generation changed or is still being published; inspect it and try again.");case APPLIED->{var catalog=result.catalog().orElseThrow();controller.publishPendingReset().whenComplete((published,publishFailure)->respond(sender,()->{if(publishFailure!=null||!published){sender.sendMessage("Adventure reset is saved but pending recovery; the new generation is not yet available.");return;}sender.sendMessage("Reset daily adventures: generation="+catalog.revision()+", offers="+catalog.sites().size()+", expires="+catalog.expiresAt()+".");}));}}}));}
public void site(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usageSite(sender);return;}switch(args[1]){case"list"->{if(args.length!=2){usageSite(sender);return;}if(directory.snapshot().sites().stream().noneMatch(v->v.type()!=AdventureQuestState.SiteType.DUNGEON))sender.sendMessage("No adventure sites registered.");else directory.snapshot().sites().stream().filter(v->v.type()!=AdventureQuestState.SiteType.DUNGEON).sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.type().name().toLowerCase(Locale.ROOT)+" "+v.displayName()+" at "+locations.format(v.world(),v.x(),v.y(),v.z())+"; "+controller.arena(v).display()));}case"place"->{if(args.length<3||!(args[2].equals("camp")||args[2].equals("champion"))||!(sender instanceof Player player)){usageSite(sender);return;}var placement=placement(args);if(placement==null){usageSite(sender);return;}var location=player.getLocation();var type=args[2].equals("camp")?AdventureQuestState.SiteType.CAMP:AdventureQuestState.SiteType.CHAMPION;directory.placeSite(type,placement.name(),Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),placement.arena()).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Placed "+value.type().name().toLowerCase(Locale.ROOT)+" "+value.id()+" ("+value.displayName()+") at "+locations.format(value.world(),value.x(),value.y(),value.z())+"; "+controller.arena(value).display()+".":"Adventure site save failed; desired state is unchanged.")));}case"remove"->{if(args.length!=3){usageSite(sender);return;}UUID id=parse(args[2]);if(id==null){usageSite(sender);return;}directory.removeSite(id).whenComplete((removed,failure)->respond(sender,()->{if(failure!=null)sender.sendMessage("Adventure site removal failed; desired state is unchanged.");else if(removed.isEmpty())sender.sendMessage("Adventure site not found.");else{controller.removeSiteLoaded(id);sender.sendMessage("Removed adventure site "+id+"; its quests expired.");}}));}default->usageSite(sender);}}
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"):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")&&args[1].equals("remove"))return matching(directory.snapshot().sites().stream().filter(v->v.type()!=AdventureQuestState.SiteType.DUNGEON).map(v->v.id().toString()).toList(),args[2]);if(args.length==2&&args[0].equals("quest-giver"))return matching(sender instanceof Player?List.of("list","place","remove"):List.of("list","remove"),args[1]);if(args.length==3&&args[0].equals("quest-giver")&&args[1].equals("remove"))return matching(directory.snapshot().givers().stream().map(v->v.id().toString()).toList(),args[2]);return List.of();}
public List<String> complete(CommandSender sender,String[] args){if(args.length==1){var values=new ArrayList<String>();values.add("adventure");if(sender.hasPermission(PERMISSION)){values.add("adventure-site");values.add("quest-giver");}return matching(values,args[0]);}if(args.length==2&&args[0].equals("adventure")){var values=new ArrayList<String>();if(sender instanceof Player player&&controller.trackingAvailable(player))values.add("tracking");if(sender.hasPermission(PERMISSION))values.add("reroll");return matching(values,args[1]);}if(args.length==3&&args[0].equals("adventure")&&args[1].equals("tracking")&&sender instanceof Player player&&controller.trackingAvailable(player))return matching(List.of("on","off"),args[2]);if(!sender.hasPermission(PERMISSION))return List.of();if(args.length==3&&args[0].equals("adventure")&&args[1].equals("reroll"))return matching(List.of("confirm"),args[2]);if(args.length==2&&args[0].equals("adventure-site"))return matching(sender instanceof Player?List.of("list","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();}
private Optional<AdventureQuestState.Site> site(UUID id){return directory.findSite(id).filter(value->value.type()!=AdventureQuestState.SiteType.DUNGEON);}
private static AdventureAdminTeleport.Target target(AdventureQuestState.Site site){return new AdventureAdminTeleport.Target(site.id(),site.displayName(),site.type()==AdventureQuestState.SiteType.CAMP?"monster camp":"champion",site.world(),site.x(),site.y(),site.z(),0,0,true);}
private boolean authorized(CommandSender sender){if(sender.hasPermission(PERMISSION))return true;sender.sendMessage("Permission denied: you need fantasystick.admin to administer Fantasy Stick.");return false;}
private void respond(CommandSender sender,Runnable response){try{nextTick.accept(()->{if(sender.hasPermission(PERMISSION)&&(!(sender instanceof Player player)||player.isOnline()))response.run();});}catch(RuntimeException ignored){}}
private record Placement(String name,AdventureQuestState.Arena arena){}
@@ -25,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>|list>");}
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>");}
}
@@ -31,7 +31,7 @@ import org.bukkit.plugin.Plugin;
public final class AdventureQuestController implements Listener,AutoCloseable {
private static final String GIVER="quest_giver",ENEMY="adventure_enemy",PROJECTILE="adventure_projectile";
private final Plugin plugin;private final AdventureQuestDirectory directory;private final AdventureQuestSettings settings;
private final AdventureLocationPresentation locations;private final AdventureQuestNavigation navigation;private final EquipmentSessions sessions;private final PartyRoster parties;private final CharacterStorage characters;private final ProgressionRules progression;
private final AdventureQuestNavigation navigation;private final EquipmentSessions sessions;private final PartyRoster parties;private final CharacterStorage characters;private final ProgressionRules progression;
private final Consumer<Runnable> nextTick;private final Clock clock;private final java.util.random.RandomGenerator random;
private final NamespacedKey typeKey,idKey,siteKey,catalogKey,menuSiteKey,menuCatalogKey,menuPageKey;
private final Set<Entity> relocating=Collections.newSetFromMap(new IdentityHashMap<>());
@@ -41,7 +41,7 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
private org.bukkit.scheduler.BukkitTask pulseTask;private boolean closed;
private static final class MenuHolder implements InventoryHolder {final UUID catalog;final int page;MenuHolder(UUID catalog,int page){this.catalog=catalog;this.page=page;}@Override public Inventory getInventory(){return null;}}
private static final class Attempt {final UUID id=UUID.randomUUID(),site,catalog;final AdventureQuestState.SiteType type;final Set<UUID> entities=new HashSet<>(),represented=new HashSet<>();final AdventureQuestState.Arena arena;final int resetSeconds;BossBar bossBar;LivingEntity champion;Instant absentSince;Attempt(UUID site,UUID catalog,AdventureQuestState.SiteType type,AdventureQuestState.Arena arena,int resetSeconds){this.site=site;this.catalog=catalog;this.type=type;this.arena=arena;this.resetSeconds=resetSeconds;}}
public AdventureQuestController(Plugin plugin,AdventureQuestDirectory directory,AdventureQuestSettings settings,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,Clock clock,java.util.random.RandomGenerator random){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.settings=Objects.requireNonNull(settings);locations=new AdventureLocationPresentation(plugin.getServer());navigation=new AdventureQuestNavigation(plugin.getServer());this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.characters=Objects.requireNonNull(characters);this.progression=Objects.requireNonNull(progression);this.nextTick=Objects.requireNonNull(nextTick);this.clock=Objects.requireNonNull(clock);this.random=Objects.requireNonNull(random);typeKey=new NamespacedKey(plugin,"managed_npc_type");idKey=new NamespacedKey(plugin,"quest_giver_id");siteKey=new NamespacedKey(plugin,"adventure_site_id");catalogKey=new NamespacedKey(plugin,"adventure_catalog_id");menuSiteKey=new NamespacedKey(plugin,"quest_offer_site");menuCatalogKey=new NamespacedKey(plugin,"quest_offer_catalog");menuPageKey=new NamespacedKey(plugin,"quest_offer_page");}
public AdventureQuestController(Plugin plugin,AdventureQuestDirectory directory,AdventureQuestSettings settings,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,Clock clock,java.util.random.RandomGenerator random){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.settings=Objects.requireNonNull(settings);navigation=new AdventureQuestNavigation(plugin.getServer());this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.characters=Objects.requireNonNull(characters);this.progression=Objects.requireNonNull(progression);this.nextTick=Objects.requireNonNull(nextTick);this.clock=Objects.requireNonNull(clock);this.random=Objects.requireNonNull(random);typeKey=new NamespacedKey(plugin,"managed_npc_type");idKey=new NamespacedKey(plugin,"quest_giver_id");siteKey=new NamespacedKey(plugin,"adventure_site_id");catalogKey=new NamespacedKey(plugin,"adventure_catalog_id");menuSiteKey=new NamespacedKey(plugin,"quest_offer_site");menuCatalogKey=new NamespacedKey(plugin,"quest_offer_catalog");menuPageKey=new NamespacedKey(plugin,"quest_offer_page");}
public void install(){for(var world:plugin.getServer().getWorlds()){var chunks=world.getLoadedChunks();if(chunks!=null)for(var chunk:chunks)reconcile(chunk);}pulseTask=plugin.getServer().getScheduler().runTaskTimer(plugin,this::pulse,20,20);publishPendingReset();}
public boolean giver(Entity entity){var data=entity.getPersistentDataContainer();return data!=null&&GIVER.equals(data.get(typeKey,PersistentDataType.STRING));}
public boolean enemy(Entity entity){var data=entity.getPersistentDataContainer();return data!=null&&ENEMY.equals(data.get(typeKey,PersistentDataType.STRING));}
@@ -62,12 +62,12 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
private record Context(EquipmentSessions.Active active,UUID character,UUID partyId,boolean leader,Set<UUID> members){}
private Context context(Player player){var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return null;var record=sessions.currentRecord(active).orElse(null);if(record==null||record.dead()||player.isDead())return null;var party=parties.partyOf(player.getUniqueId()).orElse(null);if(party==null)return new Context(active,record.id(),null,true,Set.of(record.id()));return new Context(active,record.id(),party.id(),party.leaderCharacter().equals(record.id()),Set.copyOf(party.characters()));}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void interact(PlayerInteractEntityEvent event){if(!giver(event.getRightClicked()))return;event.setCancelled(true);if(event.getHand()!=EquipmentSlot.HAND)return;UUID id=id(event.getRightClicked(),idKey);if(id!=null&&directory.findGiver(id).isPresent())open(event.getPlayer());}
@EventHandler(priority=EventPriority.HIGHEST)public void click(InventoryClickEvent event){if(!(event.getInventory().getHolder() instanceof MenuHolder holder))return;event.setCancelled(true);if(!(event.getWhoClicked() instanceof Player player)||event.getCurrentItem()==null)return;var data=event.getCurrentItem().getPersistentDataContainer();Integer page=data.get(menuPageKey,PersistentDataType.INTEGER);if(page!=null){open(player,page);return;}UUID site=parse(data.get(menuSiteKey,PersistentDataType.STRING)),catalog=parse(data.get(menuCatalogKey,PersistentDataType.STRING));if(site==null||catalog==null||!holder.catalog.equals(catalog))return;var context=context(player);if(context==null){player.sendMessage("Quest action refused: stale fantasy session.");return;}var active=directory.activeFor(context.character,context.partyId).orElse(null);var unclaimed=directory.unclaimed(catalog,site,context.character);if(unclaimed.xp().signum()>0||unclaimed.coins().signum()>0){claim(player,context,catalog,site);return;}if(active!=null){if(!active.site().equals(site)){player.sendMessage("Quest action refused: another quest is active.");return;}if(!event.isRightClick()){player.sendMessage("This quest is active; right-click its Paper to abandon it.");return;}directory.abandon(active.id(),active.unit()).whenComplete((changed,failure)->schedule(()->{refreshTracking(player);player.sendMessage(failure==null&&changed?"Adventure quest abandoned.":"Quest abandonment failed; retained state is unchanged.");open(player);}));return;}if(directory.characterClaimed(catalog,site,context.character)){player.sendMessage(directory.rewardClaimed(catalog,site,context.character)?"Adventure rewards were already claimed.":"Adventure complete with no reward owed.");return;}if(!directory.offerOpen(site)||directory.findSite(site).filter(directory::available).isEmpty()){player.sendMessage("Quest action refused: this offer is unavailable.");return;}if(context.partyId!=null&&!context.leader){player.sendMessage("Only the active party leader may accept a party quest.");return;}var type=context.partyId==null?AdventureQuestState.UnitType.SOLO:AdventureQuestState.UnitType.PARTY;var unit=context.partyId==null?context.character:context.partyId;directory.accept(catalog,site,type,unit,context.members).whenComplete((result,failure)->schedule(()->{if(failure!=null){player.sendMessage("Quest acceptance failed; retained state is unchanged.");return;}if(result.status()!=AdventureQuestDirectory.Acceptance.APPLIED){player.sendMessage("Quest acceptance refused: "+result.status().name().toLowerCase(Locale.ROOT)+".");return;}var location=directory.findSite(site).orElseThrow();refreshTracking(player);player.sendMessage("Accepted "+location.displayName()+" at "+locations.format(location.world(),location.x(),location.y(),location.z())+"; expires "+result.quest().orElseThrow().expiresAt()+".");}));}
@EventHandler(priority=EventPriority.HIGHEST)public void click(InventoryClickEvent event){if(!(event.getInventory().getHolder() instanceof MenuHolder holder))return;event.setCancelled(true);if(!(event.getWhoClicked() instanceof Player player)||event.getCurrentItem()==null)return;var data=event.getCurrentItem().getPersistentDataContainer();Integer page=data.get(menuPageKey,PersistentDataType.INTEGER);if(page!=null){open(player,page);return;}UUID site=parse(data.get(menuSiteKey,PersistentDataType.STRING)),catalog=parse(data.get(menuCatalogKey,PersistentDataType.STRING));if(site==null||catalog==null||!holder.catalog.equals(catalog))return;var context=context(player);if(context==null){player.sendMessage("Quest action refused: stale fantasy session.");return;}var active=directory.activeFor(context.character,context.partyId).orElse(null);var unclaimed=directory.unclaimed(catalog,site,context.character);if(unclaimed.xp().signum()>0||unclaimed.coins().signum()>0){claim(player,context,catalog,site);return;}if(active!=null){if(!active.site().equals(site)){player.sendMessage("Quest action refused: another quest is active.");return;}if(!event.isRightClick()){player.sendMessage("This quest is active; right-click its Paper to abandon it.");return;}directory.abandon(active.id(),active.unit()).whenComplete((changed,failure)->schedule(()->{refreshTracking(player);player.sendMessage(failure==null&&changed?"Adventure quest abandoned.":"Quest abandonment failed; retained state is unchanged.");open(player);}));return;}if(directory.characterClaimed(catalog,site,context.character)){player.sendMessage(directory.rewardClaimed(catalog,site,context.character)?"Adventure rewards were already claimed.":"Adventure complete with no reward owed.");return;}if(!directory.offerOpen(site)||directory.findSite(site).filter(directory::available).isEmpty()){player.sendMessage("Quest action refused: this offer is unavailable.");return;}if(context.partyId!=null&&!context.leader){player.sendMessage("Only the active party leader may accept a party quest.");return;}var type=context.partyId==null?AdventureQuestState.UnitType.SOLO:AdventureQuestState.UnitType.PARTY;var unit=context.partyId==null?context.character:context.partyId;directory.accept(catalog,site,type,unit,context.members).whenComplete((result,failure)->schedule(()->{if(failure!=null){player.sendMessage("Quest acceptance failed; retained state is unchanged.");return;}if(result.status()!=AdventureQuestDirectory.Acceptance.APPLIED){player.sendMessage("Quest acceptance refused: "+result.status().name().toLowerCase(Locale.ROOT)+".");return;}var location=directory.findSite(site).orElseThrow();refreshTracking(player);player.sendMessage("Accepted "+location.displayName()+"; expires "+result.quest().orElseThrow().expiresAt()+". Follow the Quest Compass.");}));}
private void claim(Player player,Context expected,UUID catalog,UUID site){directory.beginClaim(catalog,site,expected.character).whenComplete((operation,failure)->schedule(()->{if(failure!=null||operation.isEmpty()){player.sendMessage("Reward claim failed; retained rewards are unchanged.");return;}var current=context(player);if(current==null||!current.active.equals(expected.active)||!current.character.equals(operation.orElseThrow().character())){player.sendMessage("Reward claim paused: actively play the exact living character and try again.");return;}var record=sessions.currentRecord(current.active).orElse(null);if(record==null){player.sendMessage("Reward claim paused: the current character changed.");return;}attemptClaim(player,current.active,operation.orElseThrow(),record);}));}
private void attemptClaim(Player player,EquipmentSessions.Active active,AdventureQuestState.RewardClaim operation,games.dmg.fantasystick.persistence.CharacterRecord record){characters.awardCombatReward(record.id(),record.revision(),operation.id(),operation.xp(),operation.coins(),progression,games.dmg.fantasystick.progression.CombatRewardRules.defaults(),null).whenComplete((award,failure)->{if(failure!=null){schedule(()->player.sendMessage("Reward claim failed; retained rewards are unchanged."));return;}if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){attemptClaim(player,active,operation,award.record().orElseThrow());return;}if(award.record().isEmpty()){schedule(()->player.sendMessage("Reward claim failed; retained rewards are unchanged."));return;}var saved=award.record().orElseThrow();directory.finishClaim(operation.id()).whenComplete((finished,finishFailure)->schedule(()->{if(finishFailure!=null||!finished){player.sendMessage("Reward claim is pending recovery; retry at a Quest Giver.");return;}var holder=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);if(award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE&&holder!=null)sessions.acknowledgeProgress(holder,saved.revision().subtract(BigInteger.ONE),saved);var currentPlayer=holder==null?null:plugin.getServer().getPlayer(holder.player());var current=currentPlayer==null?null:context(currentPlayer);if(current!=null&&current.character.equals(operation.character())){currentPlayer.sendMessage("Claimed "+operation.xp()+" fantasy XP and "+operation.coins()+" character coins.");open(currentPlayer);}}));});}
public boolean trackingAvailable(Player player){var context=context(player);return context!=null&&directory.activeFor(context.character,context.partyId).filter(quest->quest.snapshot().contains(context.character)).isPresent();}
public void tracking(Player player,Boolean enabled){var context=context(player);if(context==null){player.sendMessage("Quest tracking refused: actively play a living fantasy character first.");return;}var quest=directory.activeFor(context.character,context.partyId).filter(value->value.snapshot().contains(context.character)).orElse(null);if(quest==null){player.sendMessage("Quest tracking refused: this character has no eligible active quest.");refreshTracking(player);return;}if(enabled==null){player.sendMessage("Quest tracking is "+(directory.trackingEnabled(quest.id(),context.character)?"on":"off")+" for the current adventure quest.");return;}directory.setTracking(quest.id(),context.character,enabled).whenComplete((status,failure)->schedule(()->{if(failure!=null||status==AdventureQuestDirectory.TrackingStatus.INELIGIBLE){player.sendMessage("Quest tracking change failed; retained state is unchanged.");return;}refreshTracking(player);player.sendMessage("Quest tracking is now "+(enabled?"on.":"off."));}));}
public void inspect(Player player){var context=context(player);if(context==null){player.sendMessage("No active fantasy character.");return;}var quest=directory.activeFor(context.character,context.partyId).orElse(null);if(quest==null){player.sendMessage("No active adventure quest.");return;}var site=directory.findSite(quest.site()).orElse(null);if(site==null){player.sendMessage("Active quest site is unavailable.");return;}player.sendMessage(site.displayName()+" at "+locations.format(site.world(),site.x(),site.y(),site.z())+"; state="+(attempts.containsKey(site.id())?"active":"waiting")+"; expires "+quest.expiresAt()+".");}
public void inspect(Player player){var context=context(player);if(context==null){player.sendMessage("No active fantasy character.");return;}var quest=directory.activeFor(context.character,context.partyId).orElse(null);if(quest==null){player.sendMessage("No active adventure quest.");return;}var site=directory.findSite(quest.site()).orElse(null);if(site==null){player.sendMessage("Active quest site is unavailable.");return;}player.sendMessage(site.displayName()+"; state="+(attempts.containsKey(site.id())?"active":"waiting")+"; expires "+quest.expiresAt()+"; Quest Compass guidance="+(directory.trackingEnabled(quest.id(),context.character)?"on":"off")+".");}
private void pulse(){if(closed)return;directory.ensureToday().whenComplete((catalog,failure)->{if(failure==null)schedule(()->{if(directory.snapshot().reset()!=null)publishPendingReset();});});if(directory.snapshot().reset()!=null){publishPendingReset();return;}directory.expireParties(parties::exists);refreshTrackingPlayers(true);showArenaBoundaries();for(var attempt:new ArrayList<>(attempts.values())){var catalog=directory.snapshot().catalog();if(catalog==null||!catalog.revision().equals(attempt.catalog)||!clock.instant().isBefore(catalog.expiresAt())){cleanup(attempt);attempts.remove(attempt.site);readyAt.remove(attempt.site);continue;}contain(attempt);syncBossBar(attempt);boolean present=eligibleInside(attempt.site,attempt.arena).findAny().isPresent();if(present)attempt.absentSince=null;else if(attempt.absentSince==null)attempt.absentSince=clock.instant();else if(!clock.instant().isBefore(attempt.absentSince.plusSeconds(attempt.resetSeconds))){cleanup(attempt);attempts.remove(attempt.site);readyAt.put(attempt.site,clock.instant());}}for(var quest:directory.active()){if(directory.findSite(quest.site()).map(AdventureQuestState.Site::type).orElse(null)==AdventureQuestState.SiteType.DUNGEON)continue;if(attempts.containsKey(quest.site())||starting.contains(quest.site()))continue;var ready=readyAt.get(quest.site());if(ready!=null&&clock.instant().isBefore(ready))continue;if(eligibleInside(quest.site()).findAny().isPresent())start(quest.site(),quest.catalog());}}
private void showArenaBoundaries(){for(UUID siteId:directory.active().stream().map(AdventureQuestState.Quest::site).distinct().toList()){var site=directory.findSite(siteId).orElse(null);if(site==null||site.type()==AdventureQuestState.SiteType.DUNGEON)continue;var world=plugin.getServer().getWorld(site.world());if(world==null)continue;var center=new Location(world,site.x(),site.y(),site.z());var points=perimeter(arena(site));for(var player:plugin.getServer().getOnlinePlayers()){if(eligible(player,siteId)==null)continue;var viewer=player.getLocation();if(viewer.getWorld()==null||!viewer.getWorld().getUID().equals(site.world())||viewer.distanceSquared(center)>64d*64d)continue;for(var point:points)player.spawnParticle(Particle.END_ROD,new Location(world,site.x()+point[0],site.y()+0.2,site.z()+point[1]),1,0,0,0,0);}}}
private static List<double[]> perimeter(AdventureQuestState.Arena arena){var result=new ArrayList<double[]>();if(arena.shape()==AdventureQuestState.ArenaShape.CIRCLE){int count=Math.min(256,Math.max(16,(int)Math.ceil(Math.PI*arena.xBlocks())));for(int index=0;index<count;index++){double angle=2*Math.PI*index/count;result.add(new double[]{Math.cos(angle)*arena.xBlocks(),Math.sin(angle)*arena.xBlocks()});}}else{double halfX=arena.xBlocks()/2d,halfZ=arena.zBlocks()/2d;edge(result,-halfX,halfX,-halfZ,true);edge(result,-halfX,halfX,halfZ,true);edge(result,-halfZ,halfZ,-halfX,false);edge(result,-halfZ,halfZ,halfX,false);}return result.size()<=256?List.copyOf(result):List.copyOf(result.subList(0,256));}
@@ -16,9 +16,8 @@ import org.bukkit.inventory.meta.CompassMeta;
public final class AdventureQuestNavigation {
private static final String[] BEARINGS={"N","NE","E","SE","S","SW","W","NW"};
private final Server server;
private final AdventureLocationPresentation locations;
public AdventureQuestNavigation(Server server){this.server=Objects.requireNonNull(server);locations=new AdventureLocationPresentation(server);}
public AdventureQuestNavigation(Server server){this.server=Objects.requireNonNull(server);}
public ItemStack compass(ItemStack ordinaryBook,AdventureQuestState.Quest quest,AdventureQuestState.Site site,Location viewer){
Objects.requireNonNull(ordinaryBook);Objects.requireNonNull(quest);Objects.requireNonNull(site);Objects.requireNonNull(viewer);
@@ -29,8 +28,8 @@ public final class AdventureQuestNavigation {
var lore=new java.util.ArrayList<Component>();
lore.add(Component.text(site.displayName()));
lore.add(Component.text(type(site.type())));
lore.add(Component.text(locations.format(site.world(),site.x(),site.y(),site.z())));
World target=server.getWorld(site.world());
lore.add(Component.text("World: "+worldName(target)));
if(target!=null&&viewer.getWorld()!=null&&viewer.getWorld().getUID().equals(site.world()))lore.add(Component.text("Distance: "+distance(viewer,site)+" blocks"));
lore.add(Component.text("Expires: "+quest.expiresAt()));
meta.lore(List.copyOf(lore));
@@ -47,10 +46,9 @@ public final class AdventureQuestNavigation {
Objects.requireNonNull(player);Objects.requireNonNull(site);
if(arrived)return Optional.empty();
World target=server.getWorld(site.world());
String place=locations.format(site.world(),site.x(),site.y(),site.z());
var viewer=player.getLocation();
if(target==null)return Optional.of(Component.text("Travel to "+place));
if(viewer.getWorld()==null||!viewer.getWorld().getUID().equals(site.world()))return Optional.of(Component.text("Travel to "+place));
if(target==null)return Optional.of(Component.text("Travel to unavailable world"));
if(viewer.getWorld()==null||!viewer.getWorld().getUID().equals(site.world()))return Optional.of(Component.text("Travel to "+worldName(target)));
long vertical=Math.round(site.y()-viewer.getY());
String height=vertical>0?"↑ +"+vertical:vertical<0?"↓ "+vertical:"↔ 0";
return Optional.of(Component.text(site.displayName()+" — "+distance(viewer,site)+" blocks "+bearing(viewer,site)+" · "+height));
@@ -59,4 +57,5 @@ public final class AdventureQuestNavigation {
static long distance(Location viewer,AdventureQuestState.Site site){return Math.round(Math.hypot(site.x()-viewer.getX(),site.z()-viewer.getZ()));}
static String bearing(Location viewer,AdventureQuestState.Site site){double angle=Math.atan2(site.x()-viewer.getX(),-(site.z()-viewer.getZ()));int index=(int)Math.round(angle/(Math.PI/4));return BEARINGS[Math.floorMod(index,8)];}
private static String type(AdventureQuestState.SiteType type){return switch(type){case CAMP->"Monster camp";case CHAMPION->"Champion";case DUNGEON->"Dungeon entrance";};}
private static String worldName(World world){return world==null||world.getName()==null||world.getName().isBlank()?"unavailable world":world.getName();}
}
@@ -29,10 +29,14 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
private final games.dmg.fantasystick.ability.AbilityEngine abilityEngine;
private final games.dmg.fantasystick.passive.PassiveRegistry passives;
private final games.dmg.fantasystick.passive.PassiveEngine passiveEngine;
private final org.bukkit.Server server;
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 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 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");
@@ -42,6 +46,9 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
private static final java.util.regex.Pattern STAT_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):(STR|DEX|CON|INT|WIS|CHR)\"");
private static final java.util.regex.Pattern ABILITY_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([a-z][a-z0-9_]{0,63})\"");
private record View(String nonce, ControlInput input, int previous, int next) { }
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 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) { }
@@ -87,11 +94,76 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
this.abilityEngine = abilityEngine;
this.passives = Objects.requireNonNull(passives);
this.passiveEngine = passiveEngine;
this.server = Objects.requireNonNull(server);
this.partyControls = new PartyBookControls(parties, server, names, this::accept, this::report);
}
@Override public void accept(ControlInput input) { load(input, 0); }
public void openReadOnly(org.bukkit.entity.Player administrator, org.bukkit.entity.Player target) {
Objects.requireNonNull(administrator); Objects.requireNonNull(target);
if (closed) return;
if (!administrator.isOnline() || !administrator.hasPermission("fantasystick.admin")) {
if (administrator.isOnline()) administrator.sendMessage("Character view refused: administrator permission is required.");
return;
}
UUID targetId = target.getUniqueId();
var active = sessions.active(targetId).filter(sessions::current).orElse(null);
if (active == null) {
administrator.sendMessage("Character view refused: the target has no current active character.");
return;
}
loadReadOnly(administrator, target, active, 0);
}
private void loadReadOnly(org.bukkit.entity.Player administrator, org.bukkit.entity.Player target,
games.dmg.fantasystick.session.EquipmentSessions.Active active, int offset) {
if (closed) return;
UUID administratorId = administrator.getUniqueId();
UUID targetId = target.getUniqueId();
Object ticket = new Object();
adminViews.remove(administratorId);
adminPending.put(administratorId, ticket);
characters.find(active.character()).whenComplete((record, failure) -> {
if (closed || adminPending.get(administratorId) != ticket) return;
try {
nextTick.accept(() -> {
if (closed || !adminPending.remove(administratorId, ticket)) return;
var currentTarget = server.getPlayer(targetId);
var current = currentTarget == null ? null : sessions.active(targetId)
.filter(sessions::current).orElse(null);
if (!administrator.isOnline() || !administrator.hasPermission("fantasystick.admin")) {
if (administrator.isOnline()) administrator.sendMessage("Character view refused: administrator permission changed.");
return;
}
if (currentTarget != target || !target.isOnline() || !active.equals(current)) {
administrator.sendMessage("Character view refused: the target character session changed.");
return;
}
if (failure != null) {
report(new Failure(administratorId, active.character(), failure));
administrator.sendMessage("Character view could not be loaded; no state was changed.");
return;
}
if (record.isEmpty() || record.orElseThrow().dead()) {
administrator.sendMessage("Character view refused: the target character is unavailable.");
return;
}
try { administrator.openBook(renderReadOnly(record.orElseThrow(), administrator,
currentTarget, active, offset)); }
catch (RuntimeException error) {
adminViews.remove(administratorId);
report(new Failure(administratorId, active.character(), error));
administrator.sendMessage("Character view could not be opened; no state was changed.");
}
});
} catch (RuntimeException rejected) {
adminPending.remove(administratorId, ticket);
report(new Failure(administratorId, active.character(), rejected));
}
});
}
private void load(ControlInput input, int offset) {
if (closed || input.kind() != ControlInput.Kind.BOOK || input.gesture() != ControlInput.Gesture.USE
|| !input.stillCurrent()) return;
@@ -128,18 +200,33 @@ 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);
}
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);
}
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) {
boolean interactive = input != null;
var item = new ItemStack(Material.WRITTEN_BOOK);
var meta = (BookMeta) item.getItemMeta();
meta.setTitle("Character");
meta.setAuthor("Fantasy Stick");
var statView = new StatView(UUID.randomUUID().toString(), input, record.revision());
var allOverview = overviewPages(record, input, statView);
var statView = interactive ? new StatView(UUID.randomUUID().toString(), input, record.revision()) : null;
var allOverview = overviewPages(record, subject, statView);
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));
if(end==allOverview.size())pages.add(gearPage(record));
if (record.unspentPoints().signum() > 0 && start == 0) statViews.put(input.session().player(), statView);
if (interactive && record.unspentPoints().signum() > 0 && start == 0)
statViews.put(input.session().player(), statView);
var definitions = abilities.definitions().stream()
.sorted(java.util.Comparator.comparing(games.dmg.fantasystick.ability.AbilityDefinition::category)
@@ -151,14 +238,14 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
.thenComparing(games.dmg.fantasystick.passive.PassiveDefinition::id))
.toList();
if ((!definitions.isEmpty() || !passiveDefinitions.isEmpty()) && end == allOverview.size()) {
var abilityView = new AbilityView(UUID.randomUUID().toString(), input, record.revision(),
var abilityView = interactive ? new AbilityView(UUID.randomUUID().toString(), input, record.revision(),
definitions.stream().map(games.dmg.fantasystick.ability.AbilityDefinition::id)
.collect(java.util.stream.Collectors.toUnmodifiableSet()));
if (!definitions.isEmpty()) abilityViews.put(input.session().player(), abilityView);
var passiveView = new PassiveView(UUID.randomUUID().toString(), input, record.revision(),
.collect(java.util.stream.Collectors.toUnmodifiableSet())) : null;
if (interactive && !definitions.isEmpty()) abilityViews.put(input.session().player(), abilityView);
var passiveView = interactive ? new PassiveView(UUID.randomUUID().toString(), input, record.revision(),
passiveDefinitions.stream().map(games.dmg.fantasystick.passive.PassiveDefinition::id)
.collect(java.util.stream.Collectors.toUnmodifiableSet()));
if (!passiveDefinitions.isEmpty()) passiveViews.put(input.session().player(), passiveView);
.collect(java.util.stream.Collectors.toUnmodifiableSet())) : null;
if (interactive && !passiveDefinitions.isEmpty()) passiveViews.put(input.session().player(), passiveView);
var passiveNames = passiveDefinitions.stream().collect(java.util.stream.Collectors.toMap(
games.dmg.fantasystick.passive.PassiveDefinition::id,
games.dmg.fantasystick.passive.PassiveDefinition::name));
@@ -208,15 +295,29 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
passiveDetails.forEach(pages::addAll);
}
if (allOverview.size() > VOLUME_SIZE) {
var view = new View(UUID.randomUUID().toString(), input, start > 0 ? start - VOLUME_SIZE : -1,
end < allOverview.size() ? end : -1);
views.put(input.session().player(), view);
Component navigation = Component.text("Character pages\n\n" + (start + 1) + "–" + end + "/" + allOverview.size());
if (view.previous() >= 0) navigation = navigation.append(Component.newline()).append(pageButton("Previous pages", view, 0));
if (view.next() >= 0) navigation = navigation.append(Component.newline()).append(pageButton("Next pages", view, 1));
if (interactive) {
var view = new View(UUID.randomUUID().toString(), input, start > 0 ? start - VOLUME_SIZE : -1,
end < allOverview.size() ? end : -1);
views.put(input.session().player(), view);
if (view.previous() >= 0) navigation = navigation.append(Component.newline())
.append(pageButton("Previous pages", view, 0));
if (view.next() >= 0) navigation = navigation.append(Component.newline())
.append(pageButton("Next pages", view, 1));
} else {
var view = new AdminView(UUID.randomUUID().toString(), administrator.getUniqueId(),
subject, adminActive, start > 0 ? start - VOLUME_SIZE : -1,
end < allOverview.size() ? end : -1);
adminViews.put(administrator.getUniqueId(), view);
if (view.previous() >= 0) navigation = navigation.append(Component.newline())
.append(adminPageButton("Previous pages", view, 0));
if (view.next() >= 0) navigation = navigation.append(Component.newline())
.append(adminPageButton("Next pages", view, 1));
}
pages.add(navigation);
}
if (end == allOverview.size()) pages.addAll(partyControls.pages(input));
if (end == allOverview.size()) pages.addAll(interactive
? partyControls.pages(input) : partyControls.readOnlyPages(subject.getUniqueId()));
meta.pages(pages);
item.setItemMeta(meta);
return item;
@@ -225,12 +326,13 @@ 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> overviewPages(CharacterRecord record, ControlInput input, StatView view) {
private java.util.List<Component> overviewPages(CharacterRecord record, org.bukkit.entity.Player subject,
StatView view) {
var builder = new PageBuilder();
builder.text(record.name());
builder.blank();
for (var stat : Stat.values()) {
Component action = record.unspentPoints().signum() > 0
Component action = view != null && record.unspentPoints().signum() > 0
? statButton(stat, view) : null;
builder.inline(stat + ": " + record.stats().get(stat), action);
}
@@ -241,7 +343,7 @@ 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(input.player().getHealth()).min(maximum).stripTrailingZeros().toPlainString()
builder.text("Health: " + java.math.BigDecimal.valueOf(subject.getHealth()).min(maximum).stripTrailingZeros().toPlainString()
+ "/" + maximum.stripTrailingZeros().toPlainString());
return builder.pages();
}
@@ -362,7 +464,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
String state = learned ? "Learned" : !prerequisites || !stats ? "Unavailable: requirements"
: !points ? "Unavailable: points" : !slot ? "Unavailable: passive slots" : "Available";
var builder = new PageBuilder(); builder.text(definition.name()); builder.text(requirements);
if (learned) builder.component(passiveButton("Unlearn", PASSIVE_UNLEARN, view, definition.id()));
if (view == null) builder.text(state);
else if (learned) builder.component(passiveButton("Unlearn", PASSIVE_UNLEARN, view, definition.id()));
else if (prerequisites && stats && points && slot) builder.component(passiveButton("Learn", PASSIVE_UNLOCK, view, definition.id()));
else builder.text(state);
builder.blank(); definition.description().forEach(builder::text); builder.blank(); builder.text(state);
@@ -387,7 +490,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
builder.text(definition.name());
builder.text(definition.category().name());
builder.text(requirements);
if (learned) builder.component(abilityButton("Unlearn", ABILITY_UNLEARN, view, definition.id()));
if (view == null) builder.text(state);
else if (learned) builder.component(abilityButton("Unlearn", ABILITY_UNLEARN, view, definition.id()));
else if (stats && points && slot) builder.component(abilityButton("Learn", ABILITY_UNLOCK, view, definition.id()));
else builder.text(state);
builder.blank();
@@ -475,6 +579,28 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\"" + view.nonce() + ":" + direction + "\"")));
}
private static Component adminPageButton(String label, AdminView view, int direction) {
return Component.text("[" + label + "]", net.kyori.adventure.text.format.NamedTextColor.DARK_BLUE)
.clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(ADMIN_PAGES,
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\"" + view.nonce() + ":" + direction + "\"")));
}
@org.bukkit.event.EventHandler
public void adminPageAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
if (closed || !ADMIN_PAGES.equals(event.getIdentifier())
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)) return;
var administrator = connection.getPlayer();
var view = adminViews.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 || !adminViews.remove(administrator.getUniqueId(), view)) return;
loadReadOnly(administrator, view.target(), view.active(), offset);
}
@org.bukkit.event.EventHandler
public void pageAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
if (closed || !PAGES.equals(event.getIdentifier())
@@ -493,7 +619,11 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
}
private void report(ControlInput input, Throwable failure) {
try { failures.accept(new Failure(input.session().player(), input.session().character(), failure)); }
report(new Failure(input.session().player(), input.session().character(), failure));
}
private void report(Failure failure) {
try { failures.accept(failure); }
catch (RuntimeException ignored) { /* A diagnostic failure must not replay a book request. */ }
}
@@ -652,6 +782,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
public void quit(org.bukkit.event.player.PlayerQuitEvent event) {
var owner = event.getPlayer().getUniqueId();
pending.remove(owner);
adminPending.remove(owner);
adminViews.remove(owner);
views.remove(owner);
statViews.remove(owner);
abilityViews.remove(owner);
@@ -660,6 +792,6 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
}
@Override public void close() {
closed = true; pending.clear(); views.clear(); statViews.clear(); abilityViews.clear(); passiveViews.clear(); partyControls.close();
closed = true; pending.clear(); adminPending.clear(); adminViews.clear(); views.clear(); statViews.clear(); abilityViews.clear(); passiveViews.clear(); partyControls.close();
}
}
@@ -54,6 +54,12 @@ public final class PartyBookControls implements AutoCloseable {
public List<Component> pages(ControlInput input) { return render(input, Menu.ROOT, 0); }
/** Informational party pages with no registered action or retained viewer state. */
public List<Component> readOnlyPages(UUID account) {
if (closed) return List.of();
return rootPages(Objects.requireNonNull(account));
}
private List<Component> render(ControlInput input, Menu menu, int offset) {
if (closed || !input.stillCurrent()) return List.of();
var build = new Pages(input);
@@ -61,18 +67,7 @@ public final class PartyBookControls implements AutoCloseable {
var party = parties.partyOf(owner);
if (menu == Menu.PLAYERS && (party.isEmpty() || !party.orElseThrow().leader().equals(owner))) menu = Menu.ROOT;
if (menu == Menu.ROOT) {
Component summary = Component.text("Party\n\n");
if (party.isEmpty()) {
build.pages.add(summary.append(Component.text("No party.\n\nVisit a Party\nRegistrar.\nCreate, join, or\nmanage one.")));
} else {
var group = party.orElseThrow();
summary = summary.append(Component.text(group.characters().size() + " members\n" + group.members().size() + " active\n\nManage this party\nonly at a Party\nRegistrar."));
build.pages.add(summary);
for (var member : group.characters()) {
Component label=parties.activePlayer(member).map(names).orElse(Component.text("Character\n"+member.toString().substring(0,8)));
build.pages.add(Component.text(member.equals(group.leaderCharacter()) ? "Leader\n\n" : "Member\n\n").append(label));
}
}
build.pages.addAll(rootPages(owner));
} else if (menu == Menu.PLAYERS) {
var group = party.orElseThrow();
var candidates = server.getOnlinePlayers().stream().filter(player -> player.isOnline())
@@ -108,6 +103,26 @@ public final class PartyBookControls implements AutoCloseable {
return List.copyOf(build.pages);
}
private List<Component> rootPages(UUID owner) {
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.")));
} else {
var group = party.orElseThrow();
summary = summary.append(Component.text(group.characters().size() + " members\n" + group.members().size()
+ " active\n\nManage this party\nonly at a Party\nRegistrar."));
pages.add(summary);
for (var member : group.characters()) {
Component label = parties.activePlayer(member).map(names)
.orElse(Component.text("Character\n" + member.toString().substring(0, 8)));
pages.add(Component.text(member.equals(group.leaderCharacter()) ? "Leader\n\n" : "Member\n\n").append(label));
}
}
return List.copyOf(pages);
}
private static int start(int requested, int size) {
return Math.min(Math.max(0, requested), Math.max(0, (size - 1) / PAGE_SIZE * PAGE_SIZE));
}
@@ -5,6 +5,7 @@ import java.math.BigInteger;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.function.*;
import games.dmg.fantasystick.adventure.AdventureAdminTeleport;
import games.dmg.fantasystick.items.CharacterSheets;
import games.dmg.fantasystick.persistence.CharacterRecord;
import org.bukkit.NamespacedKey;
@@ -16,10 +17,10 @@ import org.bukkit.potion.PotionEffectType;
/** Registered command adapter for durable dungeon authoring and catalog administration. */
public final class DungeonCommands {
private static final String PERMISSION="fantasystick.admin";
private final Plugin plugin;private final DungeonDirectory directory;private final DungeonController controller;private final games.dmg.fantasystick.adventure.AdventureQuestDirectory adventures;private final Consumer<Runnable> nextTick;private final games.dmg.fantasystick.adventure.AdventureLocationPresentation locations;private final Function<UUID,CompletableFuture<Optional<CharacterRecord>>> characters;private final Predicate<UUID> activeCharacter;private final Predicate<UUID> partyCharacter;private final CharacterSheets sheets=new CharacterSheets();
private final Plugin plugin;private final DungeonDirectory directory;private final DungeonController controller;private final games.dmg.fantasystick.adventure.AdventureQuestDirectory adventures;private final Consumer<Runnable> nextTick;private final games.dmg.fantasystick.adventure.AdventureLocationPresentation locations;private final Function<UUID,CompletableFuture<Optional<CharacterRecord>>> characters;private final Predicate<UUID> activeCharacter;private final Predicate<UUID> partyCharacter;private final CharacterSheets sheets=new CharacterSheets();private final AdventureAdminTeleport teleports;
public DungeonCommands(Plugin plugin,DungeonDirectory directory,DungeonController controller,games.dmg.fantasystick.adventure.AdventureQuestDirectory adventures,Consumer<Runnable> nextTick){this(plugin,directory,controller,adventures,nextTick,id->CompletableFuture.completedFuture(Optional.empty()),id->false,id->false);}
public DungeonCommands(Plugin plugin,DungeonDirectory directory,DungeonController controller,games.dmg.fantasystick.adventure.AdventureQuestDirectory adventures,Consumer<Runnable> nextTick,Function<UUID,CompletableFuture<Optional<CharacterRecord>>> characters,Predicate<UUID> activeCharacter,Predicate<UUID> partyCharacter){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.controller=Objects.requireNonNull(controller);this.adventures=Objects.requireNonNull(adventures);this.nextTick=Objects.requireNonNull(nextTick);this.characters=Objects.requireNonNull(characters);this.activeCharacter=Objects.requireNonNull(activeCharacter);this.partyCharacter=Objects.requireNonNull(partyCharacter);locations=new games.dmg.fantasystick.adventure.AdventureLocationPresentation(plugin.getServer());}
public void execute(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usage(sender);return;}try{switch(args[1]){case"create"->create(sender,args);case"list"->list(sender,args);case"remove"->remove(sender,args);case"edit"->edit(sender,args);case"entrance"->entrance(sender,args);case"room"->room(sender,args);case"boss"->bossCommand(sender,args);case"enable"->enable(sender,args);case"disable"->disable(sender,args);case"open"->open(sender,args);case"close"->close(sender,args);default->usage(sender);}}catch(IllegalArgumentException invalid){sender.sendMessage("Dungeon command refused: "+invalid.getMessage()+".");}}
public DungeonCommands(Plugin plugin,DungeonDirectory directory,DungeonController controller,games.dmg.fantasystick.adventure.AdventureQuestDirectory adventures,Consumer<Runnable> nextTick,Function<UUID,CompletableFuture<Optional<CharacterRecord>>> characters,Predicate<UUID> activeCharacter,Predicate<UUID> partyCharacter){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.controller=Objects.requireNonNull(controller);this.adventures=Objects.requireNonNull(adventures);this.nextTick=Objects.requireNonNull(nextTick);this.characters=Objects.requireNonNull(characters);this.activeCharacter=Objects.requireNonNull(activeCharacter);this.partyCharacter=Objects.requireNonNull(partyCharacter);locations=new games.dmg.fantasystick.adventure.AdventureLocationPresentation(plugin.getServer());teleports=new AdventureAdminTeleport(plugin,nextTick);}
public void execute(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usage(sender);return;}try{switch(args[1]){case"create"->create(sender,args);case"list"->list(sender,args);case"remove"->remove(sender,args);case"edit"->edit(sender,args);case"entrance"->entrance(sender,args);case"room"->room(sender,args);case"boss"->bossCommand(sender,args);case"teleport"->teleport(sender,args);case"enable"->enable(sender,args);case"disable"->disable(sender,args);case"open"->open(sender,args);case"close"->close(sender,args);default->usage(sender);}}catch(IllegalArgumentException invalid){sender.sendMessage("Dungeon command refused: "+invalid.getMessage()+".");}}
private void create(CommandSender sender,String[] args){if(args.length<3||!(sender instanceof Player player)){usage(sender);return;}var location=player.getLocation();String name=join(args,2);if(name.isBlank()){usage(sender);return;}directory.create(name,Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Created disabled dungeon "+value.id()+" ("+value.name()+").":"Dungeon creation failed; desired state is unchanged.")));}
private void list(CommandSender sender,String[] args){if(args.length!=2){usage(sender);return;}if(directory.snapshot().dungeons().isEmpty()){sender.sendMessage("No dungeons registered.");return;}directory.snapshot().dungeons().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(value->sender.sendMessage(value.id()+" "+value.name()+" at "+locations.format(value.world(),value.entrance().x(),value.entrance().y(),value.entrance().z())+"; "+(value.enabled()?"enabled":"disabled")+"; rooms="+value.rooms().size()+"; maximum="+value.maximumXp()+" XP, "+value.maximumCoins()+" coins."));}
private void remove(CommandSender sender,String[] args){if(args.length!=4||!args[3].equals("confirm")){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}if(directory.custodyForDungeon(id).isPresent()){sender.sendMessage("Recover character boss custody before removing this dungeon.");return;}controller.closeOffer(id).thenCompose(ignored->adventures.removeDungeonSite(id)).thenCompose(ignored->directory.remove(id)).whenComplete((removed,failure)->respond(sender,()->sender.sendMessage(failure!=null?"Dungeon removal failed; desired state is retained.":removed.isPresent()?"Removed dungeon "+id+".":"Dungeon not found.")));}
@@ -36,11 +37,13 @@ public final class DungeonCommands {
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 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));}
private static AdventureAdminTeleport.Target target(DungeonState.Dungeon dungeon){var entrance=dungeon.entrance();return new AdventureAdminTeleport.Target(dungeon.id(),dungeon.name(),"dungeon entrance",dungeon.world(),entrance.x(),entrance.y(),entrance.z(),entrance.yaw(),entrance.pitch(),false);}
private void enable(CommandSender sender,String[] args){if(args.length!=3){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}controller.enable(id).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Enabled dungeon "+id+" for later adventure catalogs.":"Dungeon enable refused: validate rooms, boss, overlap and persistence.")));}
private void disable(CommandSender sender,String[] args){if(args.length!=4||!args[3].equals("confirm")){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}controller.disable(id).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure!=null?"Dungeon disable failed; retained state is authoritative.":value?"Disabled and closed dungeon "+id+".":"Dungeon not found.")));}
private void open(CommandSender sender,String[] args){if(args.length!=3){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}controller.open(id).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Opened dungeon "+id+" in generation "+value.revision()+".":"Dungeon open refused: it is invalid, disabled, defeated or the offer limit was reached.")));}
private void close(CommandSender sender,String[] args){if(args.length!=4||!args[3].equals("confirm")){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}controller.closeOffer(id).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure!=null?"Dungeon close failed; retained state is authoritative.":value?"Closed dungeon "+id+" for this generation.":"Dungeon was not open.")));}
public List<String> complete(CommandSender sender,String[] args){if(!sender.hasPermission(PERMISSION))return List.of();if(args.length==1)return matching(List.of("dungeon"),args[0]);if(args.length==2&&args[0].equals("dungeon"))return matching(sender instanceof Player?List.of("boss","close","create","disable","edit","enable","entrance","list","open","remove","room"):List.of("close","disable","enable","list","open","remove"),args[1]);if(!args[0].equals("dungeon"))return List.of();if(args.length==3&&args[1].equals("boss"))return matching(List.of("recover-sheet"),args[2]);if(args.length==4&&args[1].equals("boss")&&args[2].equals("recover-sheet"))return matching(directory.snapshot().dungeons().stream().filter(value->recoverable(value.id())).map(value->value.id().toString()).toList(),args[3]);if(args.length==3&&Set.of("close","disable","enable","open","remove").contains(args[1]))return matching(directory.snapshot().dungeons().stream().map(v->v.id().toString()).toList(),args[2]);if(args.length==4&&Set.of("close","disable","remove").contains(args[1]))return matching(List.of("confirm"),args[3]);if(args.length==3&&args[1].equals("edit"))return matching(List.of("done","start"),args[2]);if(args.length==4&&args[1].equals("edit")&&args[2].equals("start"))return matching(directory.snapshot().dungeons().stream().map(v->v.id().toString()).toList(),args[3]);if(args.length==5&&args[1].equals("edit")&&args[2].equals("start"))return matching(List.of("confirm"),args[4]);if(args.length==3&&args[1].equals("entrance"))return matching(List.of("set"),args[2]);if(args.length==3&&args[1].equals("room"))return matching(List.of("boss","create","effect","list","mob","remove","reward"),args[2]);if(args.length==4&&args[1].equals("room")&&args[2].equals("create"))return matching(List.of("corner","radius"),args[3]);if(args.length>=5&&args.length<=7&&args[1].equals("room")&&args[2].equals("create"))return matching(List.of("1","3","8","128","256"),args[args.length-1]);if(args.length==4&&args[1].equals("room")&&args[2].equals("remove")&&sender instanceof Player player){var id=controller.editing(player.getUniqueId()).orElse(null);return id==null?List.of():matching(directory.find(id).map(DungeonState.Dungeon::rooms).orElse(List.of()).stream().map(v->v.id().toString()).toList(),args[3]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("mob"))return matching(List.of("add","list","remove"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("mob")&&args[3].equals("add"))return matching(entityNames(),args[4]);if(args.length==5&&args[1].equals("room")&&args[2].equals("mob")&&args[3].equals("remove")&&sender instanceof Player player){var room=controller.standingRoom(player).orElse(null);return room==null?List.of():matching(room.mobs().stream().map(v->v.id().toString()).toList(),args[4]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("effect"))return matching(List.of("add","remove"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("both","mobs","players"),args[4]);if(args.length==6&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(effectNames(),args[5]);if(args.length==7&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("1","2","64","256"),args[6]);if(args.length==8&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("false","true"),args[7]);if(args.length==5&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("remove")&&sender instanceof Player player){var room=controller.standingRoom(player).orElse(null);return room==null?List.of():matching(room.effects().stream().map(v->v.id().toString()).toList(),args[4]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("reward"))return matching(List.of("set"),args[3]);if((args.length==5||args.length==6)&&args[1].equals("room")&&args[2].equals("reward")&&args[3].equals("set"))return matching(List.of("0","10","100","2147483647"),args[args.length-1]);if(args.length==4&&args[1].equals("room")&&args[2].equals("boss"))return matching(List.of("assign-sheet","clear","mob"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("boss")&&args[3].equals("mob"))return matching(entityNames(),args[4]);if(args.length==5&&args[1].equals("room")&&args[2].equals("boss")&&args[3].equals("assign-sheet"))return matching(List.of("0.1","1","1.5","10","100"),args[4]);return List.of();}
public List<String> complete(CommandSender sender,String[] args){if(!sender.hasPermission(PERMISSION))return List.of();if(args.length==1)return matching(List.of("dungeon"),args[0]);if(args.length==2&&args[0].equals("dungeon"))return matching(sender instanceof Player?List.of("boss","close","create","disable","edit","enable","entrance","list","open","remove","room","teleport"):List.of("close","disable","enable","list","open","remove"),args[1]);if(!args[0].equals("dungeon"))return List.of();if(args.length==3&&args[1].equals("boss"))return matching(List.of("recover-sheet"),args[2]);if(args.length==4&&args[1].equals("boss")&&args[2].equals("recover-sheet"))return matching(directory.snapshot().dungeons().stream().filter(value->recoverable(value.id())).map(value->value.id().toString()).toList(),args[3]);if(args.length==3&&Set.of("close","disable","enable","open","remove","teleport").contains(args[1]))return matching(directory.snapshot().dungeons().stream().map(v->v.id().toString()).toList(),args[2]);if(args.length==4&&Set.of("close","disable","remove").contains(args[1]))return matching(List.of("confirm"),args[3]);if(args.length==3&&args[1].equals("edit"))return matching(List.of("done","start"),args[2]);if(args.length==4&&args[1].equals("edit")&&args[2].equals("start"))return matching(directory.snapshot().dungeons().stream().map(v->v.id().toString()).toList(),args[3]);if(args.length==5&&args[1].equals("edit")&&args[2].equals("start"))return matching(List.of("confirm"),args[4]);if(args.length==3&&args[1].equals("entrance"))return matching(List.of("set"),args[2]);if(args.length==3&&args[1].equals("room"))return matching(List.of("boss","create","effect","list","mob","remove","reward"),args[2]);if(args.length==4&&args[1].equals("room")&&args[2].equals("create"))return matching(List.of("corner","radius"),args[3]);if(args.length>=5&&args.length<=7&&args[1].equals("room")&&args[2].equals("create"))return matching(List.of("1","3","8","128","256"),args[args.length-1]);if(args.length==4&&args[1].equals("room")&&args[2].equals("remove")&&sender instanceof Player player){var id=controller.editing(player.getUniqueId()).orElse(null);return id==null?List.of():matching(directory.find(id).map(DungeonState.Dungeon::rooms).orElse(List.of()).stream().map(v->v.id().toString()).toList(),args[3]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("mob"))return matching(List.of("add","list","remove"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("mob")&&args[3].equals("add"))return matching(entityNames(),args[4]);if(args.length==5&&args[1].equals("room")&&args[2].equals("mob")&&args[3].equals("remove")&&sender instanceof Player player){var room=controller.standingRoom(player).orElse(null);return room==null?List.of():matching(room.mobs().stream().map(v->v.id().toString()).toList(),args[4]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("effect"))return matching(List.of("add","remove"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("both","mobs","players"),args[4]);if(args.length==6&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(effectNames(),args[5]);if(args.length==7&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("1","2","64","256"),args[6]);if(args.length==8&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("false","true"),args[7]);if(args.length==5&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("remove")&&sender instanceof Player player){var room=controller.standingRoom(player).orElse(null);return room==null?List.of():matching(room.effects().stream().map(v->v.id().toString()).toList(),args[4]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("reward"))return matching(List.of("set"),args[3]);if((args.length==5||args.length==6)&&args[1].equals("room")&&args[2].equals("reward")&&args[3].equals("set"))return matching(List.of("0","10","100","2147483647"),args[args.length-1]);if(args.length==4&&args[1].equals("room")&&args[2].equals("boss"))return matching(List.of("assign-sheet","clear","mob"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("boss")&&args[3].equals("mob"))return matching(entityNames(),args[4]);if(args.length==5&&args[1].equals("room")&&args[2].equals("boss")&&args[3].equals("assign-sheet"))return matching(List.of("0.1","1","1.5","10","100"),args[4]);return List.of();}
private long physicalCopies(UUID character){return plugin.getServer().getOnlinePlayers().stream().flatMap(holder->Arrays.stream(holder.getInventory().getContents())).filter(Objects::nonNull).filter(item->sheets.identify(item).filter(character::equals).isPresent()).mapToLong(org.bukkit.inventory.ItemStack::getAmount).sum();}
private boolean recoverable(UUID dungeon){var definition=directory.find(dungeon).orElse(null);var catalog=adventures.snapshot().catalog();return definition!=null&&!definition.enabled()&&directory.custodyForDungeon(dungeon).isPresent()&&!controller.beingEdited(dungeon)&&!controller.occupied(dungeon)&&(catalog==null||!adventures.offerOpen(dungeon))&&adventures.active().stream().noneMatch(quest->quest.site().equals(dungeon));}
private UUID editing(Player player){var result=controller.editing(player.getUniqueId()).orElse(null);if(result==null)player.sendMessage("Start editing one dungeon first.");return result;}
@@ -57,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> ...");}
private static void usage(CommandSender sender){sender.sendMessage("Usage: /fantasystick dungeon <create|list|remove|edit|entrance|room|boss|enable|disable|open|close|teleport> ...");}
}
@@ -22,22 +22,35 @@ import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Durable account-owned routine combat/effect feedback preference. */
/** Durable account-owned presentation preferences, serialized through one save-before-publish worker. */
public final class PlayerFeedbackPreferences implements AutoCloseable {
private static final String FILE = "player-preferences.yml";
private static final String PLAYERS = "players";
private static final String KEY = "combat-feedback";
private static final int MAX_BYTES = 1_048_576;
public record Update(UUID account, boolean enabled, long sequence) {
public Update { Objects.requireNonNull(account); }
public enum Preference {
COMBAT_FEEDBACK("combat-feedback", true),
PLAYER_AURAS("player-aura-particles", false),
ALL_PARTICLES("all-particles", true);
private final String key;
private final boolean defaultValue;
Preference(String key, boolean defaultValue) { this.key = key; this.defaultValue = defaultValue; }
}
private record AccountPreference(UUID account, Preference preference) {
private AccountPreference { Objects.requireNonNull(account); Objects.requireNonNull(preference); }
}
public record Update(UUID account, Preference preference, boolean enabled, long sequence) {
public Update { Objects.requireNonNull(account); Objects.requireNonNull(preference); }
}
private final Path directory;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-player-feedback-preferences"));
private final Map<UUID, Boolean> effective = new java.util.HashMap<>();
private final Map<UUID, Long> published = new java.util.HashMap<>();
private final Map<AccountPreference, Boolean> effective = new java.util.HashMap<>();
private final Map<AccountPreference, Long> published = new java.util.HashMap<>();
private long persistedSequence;
private boolean closed;
private int pending;
@@ -48,13 +61,34 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
effective.putAll(loadEnabled());
}
public synchronized boolean enabled(UUID account) {
/** Existing routine combat/effect feedback preference. */
public synchronized boolean enabled(UUID account) { return value(account, Preference.COMBAT_FEEDBACK); }
public synchronized boolean playerAurasEnabled(UUID account) { return value(account, Preference.PLAYER_AURAS); }
public synchronized boolean allParticlesEnabled(UUID account) { return value(account, Preference.ALL_PARTICLES); }
public synchronized boolean auraParticlesEnabled(UUID account) {
return value(account, Preference.ALL_PARTICLES) && value(account, Preference.PLAYER_AURAS);
}
private boolean value(UUID account, Preference preference) {
Objects.requireNonNull(account);
return effective.getOrDefault(account, true);
return effective.getOrDefault(new AccountPreference(account, preference), preference.defaultValue);
}
public CompletionStage<Update> set(UUID account, boolean enabled) {
Objects.requireNonNull(account);
return set(account, Preference.COMBAT_FEEDBACK, enabled);
}
public CompletionStage<Update> setPlayerAuras(UUID account, boolean enabled) {
return set(account, Preference.PLAYER_AURAS, enabled);
}
/** Shared persisted precedence for the separately delivered all-particles command. */
public CompletionStage<Update> setAllParticles(UUID account, boolean enabled) {
return set(account, Preference.ALL_PARTICLES, enabled);
}
private CompletionStage<Update> set(UUID account, Preference preference, boolean enabled) {
Objects.requireNonNull(account); Objects.requireNonNull(preference);
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Player preference store is closed"));
pending++;
@@ -62,11 +96,11 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
try {
return CompletableFuture.supplyAsync(() -> {
try {
save(account, enabled);
return new Update(account, enabled, ++persistedSequence);
save(account, preference, enabled);
return new Update(account, preference, enabled, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(new IOException(
"Combat feedback preference save refused or failed; effective setting unchanged", failure));
"Player preference save refused or failed; effective setting unchanged", failure));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
@@ -78,34 +112,37 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
public synchronized boolean publish(Update update) {
Objects.requireNonNull(update);
if (closed) return false;
long previous = published.getOrDefault(update.account(), 0L);
var key = new AccountPreference(update.account(), update.preference());
long previous = published.getOrDefault(key, 0L);
if (update.sequence() <= previous) return false;
effective.put(update.account(), update.enabled());
published.put(update.account(), update.sequence());
effective.put(key, update.enabled());
published.put(key, update.sequence());
return true;
}
private Map<UUID, Boolean> loadEnabled() throws IOException {
private Map<AccountPreference, Boolean> loadEnabled() throws IOException {
Map<String, Object> root = readRoot();
Object rawPlayers = root.get(PLAYERS);
if (rawPlayers == null) return Map.of();
if (!(rawPlayers instanceof Map<?, ?> players)) throw new IOException("Invalid player preferences");
var result = new LinkedHashMap<UUID, Boolean>();
var result = new LinkedHashMap<AccountPreference, Boolean>();
for (var entry : players.entrySet()) {
if (!(entry.getKey() instanceof String rawId)) throw new IOException("Invalid player preference identity");
UUID account;
try { account = UUID.fromString(rawId); }
catch (IllegalArgumentException invalid) { throw new IOException("Invalid player preference identity", invalid); }
if (!(entry.getValue() instanceof Map<?, ?> values)) throw new IOException("Invalid player preference entry");
Object raw = values.get(KEY);
if (raw == null) continue;
if (!(raw instanceof Boolean value)) throw new IOException("Invalid combat feedback preference");
result.put(account, value);
for (var preference : Preference.values()) {
Object raw = values.get(preference.key);
if (raw == null) continue;
if (!(raw instanceof Boolean value)) throw new IOException("Invalid " + preference.key + " preference");
result.put(new AccountPreference(account, preference), value);
}
}
return result;
}
private void save(UUID account, boolean enabled) throws IOException {
private void save(UUID account, Preference preference, boolean enabled) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe data directory");
Path lockFile = directory.resolve(".player-preferences.lock");
@@ -118,7 +155,7 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
Map<String, Object> root = readRoot();
Map<String, Object> players = stringMap(root.get(PLAYERS), "player preferences");
Map<String, Object> values = stringMap(players.get(account.toString()), "player preference entry");
values.put(KEY, enabled);
values.put(preference.key, enabled);
players.put(account.toString(), values);
root.put(PLAYERS, players);
byte[] bytes = yaml().dump(root).getBytes(StandardCharsets.UTF_8);
@@ -92,6 +92,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private games.dmg.fantasystick.shop.FantasyArmorerDirectory armorers;
private games.dmg.fantasystick.shop.FantasyArmorerController armorerController;
private games.dmg.fantasystick.shop.ArmorMarket armorMarket;
private games.dmg.fantasystick.book.CharacterBookController characterBooks;
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics) {
@@ -379,6 +380,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
characterIssuer = Objects.requireNonNull(issuer);
}
public void attachCharacterBooks(games.dmg.fantasystick.book.CharacterBookController books) {
if (closed || characterBooks != null) throw new IllegalStateException("Character books already attached or command closed");
characterBooks = Objects.requireNonNull(books);
}
public void attachPartyRegistrars(games.dmg.fantasystick.party.PartyRegistrarDirectory directory,
games.dmg.fantasystick.party.PartyRegistrarController controller) {
if (closed || partyRegistrars != null) throw new IllegalStateException("Party Registrars already attached or command closed");
@@ -415,6 +421,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (adventureQuestCommands != null && args.length > 0 && args[0].equals("quest-giver")) {
adventureQuestCommands.giver(sender, args); return true;
}
if (args.length > 0 && args[0].equals("character")) {
characterView(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;}
if (args.length > 0 && args[0].equals("training-dummy")) {
@@ -497,6 +506,23 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
return true;
}
private void characterView(CommandSender sender, String[] args) {
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)");
return;
}
var matches = server.getOnlinePlayers().stream()
.filter(org.bukkit.entity.Player::isOnline)
.filter(player -> player.getName().equals(args[2]))
.toList();
if (matches.size() != 1) {
sender.sendMessage("Character view refused: target must exactly name one online player.");
return;
}
characterBooks.openReadOnly(administrator, matches.getFirst());
}
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;
@@ -1438,8 +1464,15 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (!sender.hasPermission(PERMISSION)) 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", "config", "dungeon", "experience", "give", "inspect", "party-registrar", "quest-giver", "recover", "revive", "shop", "training-dummy")
? 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 == 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)
.filter(player -> sessions.active(player.getUniqueId()).filter(sessions::current).isPresent())
.map(org.bukkit.entity.Player::getName).toList(), args[2]);
if(args.length==2&&args[0].equals("shop"))return matching(sender instanceof org.bukkit.entity.Player?List.of("list","place","remove","restock"):List.of("list","remove","restock"),args[1]);
if(args.length==3&&args[0].equals("shop")&&args[1].equals("remove")&&armorers!=null)return matching(armorers.all().stream().map(value->value.id().toString()).toList(),args[2]);
if (args.length == 2 && args[0].equals("training-dummy")) return matching(
@@ -505,8 +505,8 @@ public final class EquipmentSessions implements AutoCloseable {
if (!state.canEnter(attempt.character, switchDelay.get(), now)) {
Duration remaining = Duration.between(now, state.lastExit().deadline());
long seconds = remaining.getSeconds() + (remaining.getNano() == 0 ? 0 : 1);
refuse(attempt, "COOLDOWN", "Wait " + seconds + " seconds to switch characters (until "
+ state.lastExit().deadline() + "); the same character may be resumed now."); return;
refuse(attempt, "COOLDOWN", "Wait " + seconds
+ " seconds to switch characters; the same character may be resumed now."); return;
}
}
if (!entryNativeSafe(attempt, loaded.state().orElse(null))) {
@@ -0,0 +1,89 @@
package games.dmg.fantasystick.session;
import games.dmg.fantasystick.persistence.PlayerFeedbackPreferences;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabExecutor;
import org.bukkit.entity.Player;
/** Ordinary-player command for durable account-owned Fantasy Stick particle preferences. */
public final class FantasyParticlesCommand implements TabExecutor, AutoCloseable {
private final PlayerFeedbackPreferences preferences;
private final Consumer<Runnable> nextTick;
private final Consumer<Throwable> failures;
private boolean closed;
public FantasyParticlesCommand(PlayerFeedbackPreferences preferences, Consumer<Runnable> nextTick,
Consumer<Throwable> failures) {
this.preferences = Objects.requireNonNull(preferences);
this.nextTick = Objects.requireNonNull(nextTick);
this.failures = Objects.requireNonNull(failures);
}
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (!(sender instanceof Player player)) {
sender.sendMessage("Fantasy particles are a player-account preference.");
return true;
}
if (args.length == 1 && args[0].equals("players")) {
player.sendMessage(status(player.getUniqueId()));
return true;
}
if (args.length != 2 || !args[0].equals("players")
|| !(args[1].equals("on") || args[1].equals("off"))) return false;
boolean enabled = args[1].equals("on");
UUID account = player.getUniqueId();
preferences.setPlayerAuras(account, enabled).whenComplete((saved, failure) -> {
if (failure != null) report(failure);
try {
nextTick.accept(() -> {
if (closed) return;
if (failure != null) {
if (player.isOnline()) player.sendMessage(
"Active-character aura preference could not be saved; " + status(account));
return;
}
boolean published = preferences.publish(saved);
if (player.isOnline()) player.sendMessage(acknowledgement(account, published));
});
} catch (RuntimeException rejected) { report(rejected); }
});
return true;
}
@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("players"), args[0]);
if (args.length == 2 && args[0].equals("players")) return matching(List.of("off", "on"), args[1]);
return List.of();
}
private String status(UUID account) {
boolean saved = preferences.playerAurasEnabled(account);
if (saved && !preferences.allParticlesEnabled(account))
return "Active-character aura particles are on (effectively off while all particles are off).";
return "Active-character aura particles are " + state(saved) + ".";
}
private String acknowledgement(UUID account, boolean published) {
boolean saved = preferences.playerAurasEnabled(account);
String prefix = "Active-character aura particles are " + (published ? "now " : "already ") + state(saved);
if (saved && !preferences.allParticlesEnabled(account))
return prefix + " (effectively off while all particles are off).";
return prefix + ".";
}
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();
}
private static String state(boolean enabled) { return enabled ? "on" : "off"; }
private void report(Throwable failure) { try { failures.accept(failure); } catch (RuntimeException ignored) { } }
@Override public void close() { closed = true; }
}
@@ -11,21 +11,37 @@ import org.bukkit.scheduler.BukkitTask;
/** Public particles only; never grants gameplay state, protection, health or persistence authority. */
public final class SessionVisuals implements AutoCloseable {
private final EquipmentSessions sessions;
private final games.dmg.fantasystick.persistence.PlayerFeedbackPreferences preferences;
private final Consumer<Throwable> failures;
private BukkitTask task;
private boolean closed;
public SessionVisuals(EquipmentSessions sessions, Consumer<Throwable> failures) {
this.sessions = Objects.requireNonNull(sessions); this.failures = Objects.requireNonNull(failures);
public SessionVisuals(EquipmentSessions sessions,
games.dmg.fantasystick.persistence.PlayerFeedbackPreferences preferences,
Consumer<Throwable> failures) {
this.sessions = Objects.requireNonNull(sessions);
this.preferences = Objects.requireNonNull(preferences);
this.failures = Objects.requireNonNull(failures);
}
public void install(Plugin plugin) {
if (closed || task != null) throw new IllegalStateException("Visual lifetime already used");
task = plugin.getServer().getScheduler().runTaskTimer(plugin, () -> {
if (closed) return;
for (var player : plugin.getServer().getOnlinePlayers()) {
var online = java.util.List.copyOf(plugin.getServer().getOnlinePlayers());
for (var source : online) {
try {
var active = sessions.active(player.getUniqueId());
if (active.isPresent() && sessions.current(active.orElseThrow())) burst(player, Color.BLUE, 3);
var active = sessions.active(source.getUniqueId());
if (active.isEmpty() || !sessions.current(active.orElseThrow())) continue;
var location = source.getLocation().add(0, 1, 0);
var dust = new Particle.DustOptions(Color.BLUE, 1);
var tracked = source.getTrackedBy();
for (var viewer : online) {
if (!viewer.isOnline() || viewer.getWorld() != source.getWorld()
|| (!viewer.getUniqueId().equals(source.getUniqueId()) && !tracked.contains(viewer))
|| !viewer.canSee(source)
|| !preferences.auraParticlesEnabled(viewer.getUniqueId())) continue;
viewer.spawnParticle(Particle.DUST, location, 3, 0.3, 0.4, 0.3, 0, dust);
}
} catch (RuntimeException failure) { failures.accept(failure); }
}
}, 1L, 10L);
+4 -1
View File
@@ -8,11 +8,14 @@ author: dmg.games
commands:
fantasystick:
description: Inspect daily adventures or administer Fantasy Stick.
usage: /fantasystick adventure [reroll confirm] | adventure-site <place <camp|champion> [name...]|remove <site-uuid>|list> | quest-giver <place [name...]|remove <giver-uuid>|list> | <admin route>
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 players [on|off]
permissions:
fantasystick.admin:
description: Issue/revive sheets, grant fantasy experience, recover sessions and configure fantasy mode.
@@ -0,0 +1,366 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import net.minecraft.network.chat.ClickEvent;
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;
/** Actual registered admin command through authoritative storage and native virtual-book output. */
class AdminCharacterBookNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void registeredCommandOpensTheTargetsAuthoritativeReadOnlyBook() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(data);
var target = new FantasyStickLifecycleNativeTest.Actor(data);
admin.accountName = "Admin";
target.accountName = "Target";
fixture.players.put(admin.core.id, admin.player);
fixture.players.put(target.core.id, target.player);
fixture.activate(target);
target.core.health = 7.0;
org.bukkit.inventory.ItemStack[] adminInventory = admin.core.snapshot();
org.bukkit.inventory.ItemStack[] targetInventory = target.core.snapshot();
Path character = data.resolve("characters").resolve(target.core.record.id() + ".yml");
byte[] savedCharacter = Files.readAllBytes(character);
assertTrue(fixture.command.execute(admin.player, "fantasystick",
new String[]{"character", "view", "Target"}));
assertTrue(admin.books.isEmpty(), "Authoritative storage must complete before opening");
assertTrue(admin.messages.isEmpty(), admin.messages::toString);
var controller = FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "books");
assertEquals(1, ((java.util.Map<?, ?>) FantasyStickLifecycleNativeTest.Fixture.field(controller,
"adminPending")).size());
characters(fixture).find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
fixture.tick();
assertEquals(1, admin.books.size());
assertTrue(target.books.isEmpty(), "Inspection must not interrupt the target");
var pages = nativePages(admin);
String text = pages.stream().map(Component::getString)
.collect(java.util.stream.Collectors.joining("\n"));
assertTrue(text.replace("\n", "").contains(target.core.record.name()));
assertTrue(text.contains("Health: 7/10"), text);
assertTrue(text.contains("Active abilities"));
assertTrue(text.contains("Passive abilities"));
assertTrue(text.contains("Party"));
assertFalse(text.contains("[+1") || text.contains("[Learn]") || text.contains("[Unlearn]"));
assertTrue(pages.stream().allMatch(AdminCharacterBookNativeTest::onlyNativePageLinks));
dispatchCustom(fixture, admin, "fantasystick:character_stat",
"\"" + UUID.randomUUID() + ":STR\"");
dispatchCustom(fixture, admin, "fantasystick:ability_unlock",
"\"" + UUID.randomUUID() + ":fireball\"");
assertArrayEquals(adminInventory, admin.core.snapshot());
assertArrayEquals(targetInventory, target.core.snapshot());
assertArrayEquals(savedCharacter, Files.readAllBytes(character));
}
}
@Test void largeAuthoritativeRecordsRemainCompleteThroughBoundedReadOnlyNavigation() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(data); admin.accountName = "Admin";
var target = new FantasyStickLifecycleNativeTest.Actor(data); target.accountName = "Target";
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(target.core.record.id()).orElseThrow();
var stats = new java.util.EnumMap<>(old.stats());
String digits = "1234567890".repeat(4_000);
stats.put(games.dmg.fantasystick.progression.Stat.STR, new java.math.BigInteger(digits));
var cooldowns = new java.util.HashMap<>(old.cooldowns());
cooldowns.put("unknown_saved", java.time.Instant.parse("2030-01-02T03:04:05Z"));
var large = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(),
old.revision().add(java.math.BigInteger.ONE), stats, old.level(), old.experience(),
old.unspentPoints(), old.health(), java.util.Set.of("unknown_saved"),
java.util.List.of("unknown_saved", "", "", "", "", "", ""), cooldowns,
old.restrictedKills(), false, null, null);
repository.replace(large, old.revision());
fixture.players.put(admin.core.id, admin.player);
fixture.players.put(target.core.id, target.player);
fixture.activate(target);
Path character = data.resolve("characters").resolve(target.core.record.id() + ".yml");
byte[] savedCharacter = Files.readAllBytes(character);
org.bukkit.inventory.ItemStack[] adminInventory = admin.core.snapshot();
org.bukkit.inventory.ItemStack[] targetInventory = target.core.snapshot();
fixture.command.execute(admin.player, "fantasystick",
new String[]{"character", "view", "Target"});
drainReads(fixture); runQueued(fixture);
var all = new StringBuilder();
var content = new StringBuilder();
int volumes = 0;
while (true) {
volumes++;
var pages = nativePages(admin);
assertTrue(pages.size() <= 100, "Every administrator view must stay bounded");
pages.forEach(page -> {
String text = page.getString();
all.append(text);
if (!text.startsWith("Character pages\n\n")) content.append(text);
});
var next = customLinkOrNull(pages, "[Next pages]");
if (next == null) break;
int before = admin.books.size();
PartyBookNativeTest.click(fixture, admin, next);
if (volumes == 1) PartyBookNativeTest.click(fixture, admin, next);
drainReads(fixture); runQueued(fixture);
assertEquals(before + 1, admin.books.size(), "Repeated navigation payloads are single-use");
}
assertTrue(volumes > 1);
assertTrue(content.toString().replace("\n", "").contains("STR: " + digits + "DEX:"),
"Every persisted stat digit must remain reachable in order across bounded volumes");
assertTrue(all.toString().contains("Gear"));
assertTrue(all.toString().contains("Active abilities"));
assertTrue(all.toString().contains("unknown_saved"));
assertTrue(all.toString().contains("Party"));
assertArrayEquals(adminInventory, admin.core.snapshot());
assertArrayEquals(targetInventory, target.core.snapshot());
assertArrayEquals(savedCharacter, Files.readAllBytes(character));
}
}
@Test void accountNameSelectionSupportsSelfAndDuplicateFantasyNames() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(data); admin.accountName = "Admin";
var other = new FantasyStickLifecycleNativeTest.Actor(data); other.accountName = "Other";
fixture.players.put(admin.core.id, admin.player);
fixture.players.put(other.core.id, other.player);
var storage = characters(fixture);
var renamedAdmin = storage.renameCharacter(admin.core.record.id(), "Duplicate Fantasy Name")
.get(5, TimeUnit.SECONDS).orElseThrow();
var renamedOther = storage.renameCharacter(other.core.record.id(), "Duplicate Fantasy Name")
.get(5, TimeUnit.SECONDS).orElseThrow();
admin.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(renamedAdmin));
other.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(renamedOther));
fixture.activate(admin); fixture.activate(other);
admin.core.health = 6.0; other.core.health = 8.0;
fixture.command.execute(admin.player, "fantasystick", new String[]{"character", "view", "Admin"});
drainReads(fixture); runQueued(fixture);
assertTrue(text(admin).contains("Health: 6/10"));
fixture.command.execute(admin.player, "fantasystick", new String[]{"character", "view", "Other"});
drainReads(fixture); runQueued(fixture);
assertTrue(text(admin).contains("Health: 8/10"));
assertEquals(2, admin.books.size());
}
}
@Test void stalePermissionSessionAndSupersededReadsNeverOpenCharacterData() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(data); admin.accountName = "Admin";
var first = new FantasyStickLifecycleNativeTest.Actor(data); first.accountName = "First";
var second = new FantasyStickLifecycleNativeTest.Actor(data); second.accountName = "Second";
fixture.players.put(admin.core.id, admin.player);
fixture.players.put(first.core.id, first.player);
fixture.players.put(second.core.id, second.player);
fixture.activate(first); fixture.activate(second);
fixture.command.execute(admin.player, "fantasystick", new String[]{"character", "view", "First"});
fixture.command.execute(admin.player, "fantasystick", new String[]{"character", "view", "Second"});
drainReads(fixture); runQueued(fixture);
assertEquals(1, admin.books.size(), "Only the latest authoritative request may open");
assertTrue(nativePages(admin).stream().map(Component::getString)
.collect(java.util.stream.Collectors.joining()).contains(second.core.record.name()));
int opened = admin.books.size();
fixture.command.execute(admin.player, "fantasystick", new String[]{"character", "view", "First"});
drainReads(fixture);
var permissionResponse = fixture.tasks.poll(); assertNotNull(permissionResponse);
admin.allowed = false; permissionResponse.run();
assertEquals(opened, admin.books.size());
assertTrue(admin.messages.getLast().contains("permission changed"));
admin.allowed = true;
fixture.command.execute(admin.player, "fantasystick", new String[]{"character", "view", "First"});
drainReads(fixture);
var sessionResponse = fixture.tasks.poll(); assertNotNull(sessionResponse);
((games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions")).exit(first.player);
sessionResponse.run();
assertEquals(opened, admin.books.size());
assertTrue(admin.messages.getLast().contains("session changed"));
fixture.command.execute(admin.player, "fantasystick", new String[]{"character", "view", "Second"});
drainReads(fixture);
var retiredLifetimeResponse = fixture.tasks.poll(); assertNotNull(retiredLifetimeResponse);
fixture.plugin.setEnabled(false);
retiredLifetimeResponse.run();
assertEquals(opened, admin.books.size(), "A retired plugin lifetime cannot open character data");
}
}
@Test void malformedAuthoritativeReadReportsFailureAndAllowsAValidRetry() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(data); admin.accountName = "Admin";
var target = new FantasyStickLifecycleNativeTest.Actor(data); target.accountName = "Target";
fixture.players.put(admin.core.id, admin.player);
fixture.players.put(target.core.id, target.player);
fixture.activate(target);
Path character = data.resolve("characters").resolve(target.core.record.id() + ".yml");
byte[] valid = Files.readAllBytes(character);
Files.writeString(character, "schema-version: malformed\n");
fixture.command.execute(admin.player, "fantasystick", new String[]{"character", "view", "Target"});
drainReads(fixture); runQueued(fixture);
assertTrue(admin.books.isEmpty());
assertTrue(admin.messages.getLast().contains("could not be loaded"));
assertEquals("schema-version: malformed\n", Files.readString(character));
Files.write(character, valid);
fixture.command.execute(admin.player, "fantasystick", new String[]{"character", "view", "Target"});
drainReads(fixture); runQueued(fixture);
assertEquals(1, admin.books.size());
assertArrayEquals(valid, Files.readAllBytes(character));
}
}
@Test void deadAndMissingAuthoritativeCharactersAreRefusedWithoutOpening() throws Exception {
for (String state : java.util.List.of("dead", "missing")) {
Path dir = data.resolve(state);
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(dir)) {
fixture.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(dir); admin.accountName = "Admin";
var target = new FantasyStickLifecycleNativeTest.Actor(dir); target.accountName = "Target";
fixture.players.put(admin.core.id, admin.player);
fixture.players.put(target.core.id, target.player);
fixture.activate(target);
Path character = dir.resolve("characters").resolve(target.core.record.id() + ".yml");
if (state.equals("missing")) {
Files.delete(character);
} else {
var old = target.core.record;
var dead = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(),
old.revision().add(java.math.BigInteger.ONE), old.stats(), old.level(), old.experience(),
old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.cooldowns(),
old.restrictedKills(), true, UUID.randomUUID(), java.time.Instant.parse("2026-09-23T00:00:00Z"));
new games.dmg.fantasystick.persistence.YamlCharacterRepository(dir)
.replace(dead, old.revision());
}
fixture.command.execute(admin.player, "fantasystick",
new String[]{"character", "view", "Target"});
drainReads(fixture); runQueued(fixture);
assertTrue(admin.books.isEmpty());
assertTrue(admin.messages.getLast().contains("unavailable"));
}
}
}
@Test void commandAndCompletionRequireAnAuthorizedOnlinePlayerAndExactActiveAccount() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(data); admin.accountName = "Admin";
var active = new FantasyStickLifecycleNativeTest.Actor(data); active.accountName = "ActiveTarget";
var inactive = new FantasyStickLifecycleNativeTest.Actor(data); inactive.accountName = "InactiveTarget";
var offline = new FantasyStickLifecycleNativeTest.Actor(data); offline.accountName = "OfflineTarget";
offline.core.online = false;
fixture.players.put(admin.core.id, admin.player);
fixture.players.put(active.core.id, active.player);
fixture.players.put(inactive.core.id, inactive.player);
fixture.players.put(offline.core.id, offline.player);
fixture.activate(active);
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("ActiveTarget"),
fixture.command.tabComplete(admin.player, "fantasystick", new String[]{"character", "view", ""}));
assertEquals(java.util.List.of("ActiveTarget"),
fixture.command.tabComplete(admin.player, "fantasystick", new String[]{"character", "view", "Act"}));
assertTrue(fixture.command.tabComplete(admin.player, "fantasystick",
new String[]{"character", "view", "ActiveTarget", ""}).isEmpty());
admin.allowed = false;
assertTrue(fixture.command.tabComplete(admin.player, "fantasystick",
new String[]{"character", ""}).isEmpty());
fixture.command.getExecutor().onCommand(admin.player, fixture.command, "fantasystick",
new String[]{"character", "view", "ActiveTarget"});
assertTrue(admin.books.isEmpty()); assertTrue(fixture.tasks.isEmpty());
admin.allowed = true;
var console = new EquipmentAdminCommandNativeTest.Sender();
assertTrue(fixture.command.tabComplete(console.port, "fantasystick",
new String[]{"character", ""}).isEmpty());
fixture.command.execute(console.port, "fantasystick",
new String[]{"character", "view", "ActiveTarget"});
assertTrue(console.messages.getLast().contains("in-game administrator"));
for (String[] args : java.util.List.of(
new String[]{"character"},
new String[]{"character", "view"},
new String[]{"character", "view", "activetarget"},
new String[]{"character", "view", "InactiveTarget"},
new String[]{"character", "view", "OfflineTarget"},
new String[]{"character", "view", "ActiveTarget", "extra"})) {
fixture.command.execute(admin.player, "fantasystick", args);
assertTrue(admin.books.isEmpty()); assertTrue(fixture.tasks.isEmpty());
}
}
}
private static void dispatchCustom(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor,
String key, String payload) {
var connection = org.mockito.Mockito.mock(io.papermc.paper.connection.PlayerGameConnection.class);
org.mockito.Mockito.when(connection.getPlayer()).thenReturn(actor.player);
fixture.dispatcher.accept(new io.papermc.paper.event.player.PaperPlayerCustomClickEvent(
net.kyori.adventure.key.Key.key(key), connection,
net.minecraft.nbt.StringTag.valueOf(payload)));
}
private static net.minecraft.network.chat.ClickEvent.Custom customLinkOrNull(
java.util.List<Component> pages, String label) {
for (var page : pages) {
var click = PartyBookNativeTest.find(page, label);
if (click != null) return click;
}
return null;
}
private static void drainReads(FantasyStickLifecycleNativeTest.Fixture fixture) throws Exception {
characters(fixture).find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
}
private static void runQueued(FantasyStickLifecycleNativeTest.Fixture fixture) {
Runnable task;
while ((task = fixture.tasks.poll()) != null) task.run();
}
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 String text(FantasyStickLifecycleNativeTest.Actor actor) {
return nativePages(actor).stream().map(Component::getString)
.collect(java.util.stream.Collectors.joining("\n"));
}
private static java.util.List<Component> nativePages(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();
}
private static boolean onlyNativePageLinks(Component component) {
var click = component.getStyle().getClickEvent();
if (click != null && !(click instanceof ClickEvent.ChangePage)) return false;
return component.getSiblings().stream().allMatch(AdminCharacterBookNativeTest::onlyNativePageLinks);
}
}
@@ -21,6 +21,86 @@ class AdventureQuestNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void registeredAdventureSiteTeleportIsAsyncAuthorizedRevalidatedAndReadOnly() throws Exception {
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);
var admin=new FantasyStickLifecycleNativeTest.Actor(data);var destination=new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(admin.core.id,admin.player);f.players.put(destination.core.id,destination.player);
doReturn(true).when(admin.player).hasPermission("fantasystick.admin");
UUID startWorld=UUID.randomUUID(),targetWorld=UUID.randomUUID();
when(admin.world.getUID()).thenReturn(startWorld);when(admin.world.getName()).thenReturn("Start Realm");
when(destination.world.getUID()).thenReturn(targetWorld);when(destination.world.getName()).thenReturn("Maintenance Realm");
admin.core.location.setYaw(37.5f);admin.core.location.setPitch(-12.25f);f.activate(admin);
var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");
var activeSession=sessions.active(admin.core.id).orElseThrow();
var directory=(games.dmg.fantasystick.adventure.AdventureQuestDirectory)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");
var camp=directory.placeSite(games.dmg.fantasystick.adventure.AdventureQuestState.SiteType.CAMP,
"Negative Camp",targetWorld,-32.75,70.5,48.125).get(5,java.util.concurrent.TimeUnit.SECONDS);
var before=directory.snapshot();var inventory=admin.core.snapshot();var cursor=admin.core.cursor.clone();
var chunkFuture=new java.util.concurrent.CompletableFuture<Chunk>();
doReturn(chunkFuture).when(destination.world).getChunkAtAsync(-3,3);
var teleports=new ArrayList<Location>();
doAnswer(call->{teleports.add(((Location)call.getArgument(0)).clone());return java.util.concurrent.CompletableFuture.completedFuture(true);})
.when(admin.player).teleportAsync(any(Location.class));
assertTrue(f.command.tabComplete(admin.player,"fantasystick",new String[]{"adventure-site",""}).contains("teleport"));
assertEquals(List.of(camp.id().toString()),f.command.tabComplete(admin.player,"fantasystick",new String[]{"adventure-site","teleport",""}));
f.command.execute(admin.player,"fantasystick",new String[]{"adventure-site","teleport",camp.id().toString()});
assertTrue(teleports.isEmpty(),"teleport waits for asynchronous chunk loading");
chunkFuture.complete(mock(Chunk.class));drainOne(f);assertEquals(1,teleports.size());drainOne(f);
var target=teleports.getFirst();assertSame(destination.world,target.getWorld());
assertEquals(-32.75,target.getX());assertEquals(70.5,target.getY());assertEquals(48.125,target.getZ());
assertEquals(37.5f,target.getYaw());assertEquals(-12.25f,target.getPitch());
assertEquals("Teleported to Negative Camp (monster camp) at Maintenance Realm (-33, 70, 48).",admin.messages.getLast());
assertEquals(before,directory.snapshot());assertArrayEquals(inventory,admin.core.snapshot());assertEquals(cursor,admin.core.cursor);
assertEquals(activeSession,sessions.active(admin.core.id).orElseThrow());
var champion=directory.placeSite(games.dmg.fantasystick.adventure.AdventureQuestState.SiteType.CHAMPION,
"Summit",targetWorld,9,80,-7).get(5,java.util.concurrent.TimeUnit.SECONDS);
doReturn(java.util.concurrent.CompletableFuture.completedFuture(mock(Chunk.class))).when(destination.world).getChunkAtAsync(0,-1);
f.command.execute(admin.player,"fantasystick",new String[]{"adventure-site","teleport",champion.id().toString()});drainOne(f);drainOne(f);
assertTrue(admin.messages.getLast().contains("Summit (champion)"));
var local=directory.placeSite(games.dmg.fantasystick.adventure.AdventureQuestState.SiteType.CAMP,
"Local Camp",startWorld,4,65,4).get(5,java.util.concurrent.TimeUnit.SECONDS);
doReturn(java.util.concurrent.CompletableFuture.completedFuture(mock(Chunk.class))).when(admin.world).getChunkAtAsync(0,0);
f.command.execute(admin.player,"fantasystick",new String[]{"adventure-site","teleport",local.id().toString()});drainOne(f);drainOne(f);
assertSame(admin.world,teleports.getLast().getWorld());
var unavailable=directory.placeSite(games.dmg.fantasystick.adventure.AdventureQuestState.SiteType.CAMP,
"Lost",UUID.randomUUID(),1,2,3).get(5,java.util.concurrent.TimeUnit.SECONDS);
f.command.execute(admin.player,"fantasystick",new String[]{"adventure-site","teleport",unavailable.id().toString()});
assertEquals("Adventure-site teleport refused: destination world is unavailable.",admin.messages.getLast());
assertEquals(3,teleports.size());
var staleFuture=new java.util.concurrent.CompletableFuture<Chunk>();doReturn(staleFuture).when(destination.world).getChunkAtAsync(0,0);
var stale=directory.placeSite(games.dmg.fantasystick.adventure.AdventureQuestState.SiteType.CAMP,
"Stale",targetWorld,1,64,1).get(5,java.util.concurrent.TimeUnit.SECONDS);
f.command.execute(admin.player,"fantasystick",new String[]{"adventure-site","teleport",stale.id().toString()});
directory.removeSite(stale.id()).get(5,java.util.concurrent.TimeUnit.SECONDS);staleFuture.complete(mock(Chunk.class));drainOne(f);
assertEquals("Adventure-site teleport refused: destination changed or is no longer registered.",admin.messages.getLast());assertEquals(3,teleports.size());
var revokedFuture=new java.util.concurrent.CompletableFuture<Chunk>();doReturn(revokedFuture).when(destination.world).getChunkAtAsync(-3,3);
f.command.execute(admin.player,"fantasystick",new String[]{"adventure-site","teleport",camp.id().toString()});
doReturn(false).when(admin.player).hasPermission("fantasystick.admin");revokedFuture.complete(mock(Chunk.class));drainOne(f);
assertEquals("Adventure-site teleport refused: permission or player session changed.",admin.messages.getLast());assertEquals(3,teleports.size());
assertTrue(f.command.tabComplete(admin.player,"fantasystick",new String[]{"adventure-site","teleport",""}).isEmpty());
doReturn(true).when(admin.player).hasPermission("fantasystick.admin");
doReturn(java.util.concurrent.CompletableFuture.completedFuture(false)).when(admin.player).teleportAsync(any(Location.class));
var originalLocation=admin.core.location.clone();
f.command.execute(admin.player,"fantasystick",new String[]{"adventure-site","teleport",camp.id().toString()});drainOne(f);drainOne(f);
assertEquals("Adventure-site teleport failed; your location is unchanged.",admin.messages.getLast());
assertEquals(originalLocation,admin.core.location);
var console=new EquipmentAdminCommandNativeTest.Sender();
f.command.execute(console.port,"fantasystick",new String[]{"adventure-site","teleport",camp.id().toString()});
assertEquals("Adventure-site teleport requires a player.",console.messages.getLast());
assertFalse(f.command.tabComplete(console.port,"fantasystick",new String[]{"adventure-site",""}).contains("teleport"));
}
}
@SuppressWarnings({"unchecked", "rawtypes"})
@Test void registeredCommandsGiverMenuAcceptanceCampCombatAndExactRewardUseConnectedPath() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
@@ -53,10 +133,12 @@ class AdventureQuestNativeTest {
var stale=mock(InventoryClickEvent.class);when(stale.getWhoClicked()).thenReturn(actor.player);when(stale.getInventory()).thenReturn(menu);when(stale.getCurrentItem()).thenReturn(menu.getItem(0));controller.click(stale);drainOne(f);assertTrue(actor.messages.getLast().contains("stale"));
f.dispatcher.accept(new PlayerInteractEntityEvent(actor.player,giver,EquipmentSlot.HAND));drainOne(f);menu=actor.inventories.getLast();var click=mock(InventoryClickEvent.class);when(click.getWhoClicked()).thenReturn(actor.player);when(click.getInventory()).thenReturn(menu);when(click.getCurrentItem()).thenReturn(menu.getItem(0));controller.click(click);drainOne(f);
assertTrue(actor.messages.stream().anyMatch(message -> message.equals(
"Accepted Dawn Camp at Adventure Realm (12, 70, -8); expires "
+ directory.active().getFirst().expiresAt() + ".")));
"Accepted Dawn Camp; expires " + directory.active().getFirst().expiresAt()
+ ". Follow the Quest Compass.")));
f.command.execute(actor.player,"fantasystick",new String[]{"adventure"});
assertTrue(actor.messages.getLast().startsWith("Dawn Camp at Adventure Realm (12, 70, -8); state="));
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()));
var siteRecord=directory.snapshot().sites().getFirst();assertEquals(games.dmg.fantasystick.adventure.AdventureQuestState.Arena.rectangle(20,60),siteRecord.arena());actor.core.location.setZ(siteRecord.z()+25);
var partyA=new FantasyStickLifecycleNativeTest.Actor(data);var partyB=new FantasyStickLifecycleNativeTest.Actor(data);partyA.core.location.setWorld(actor.world);partyB.core.location.setWorld(actor.world);f.players.put(partyA.core.id,partyA.player);f.players.put(partyB.core.id,partyB.player);f.activate(partyA);f.activate(partyB);var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(partyA.core.id).orElseThrow();assertTrue(parties.accept(partyB.core.id,parties.invite(partyA.core.id,party.id(),partyB.core.id).orElseThrow().id()));var catalog=directory.snapshot().catalog();var siteId=catalog.sites().getFirst();assertEquals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.Acceptance.APPLIED,directory.accept(catalog.revision(),siteId,games.dmg.fantasystick.adventure.AdventureQuestState.UnitType.PARTY,party.id(),Set.of(partyA.core.record.id(),partyB.core.record.id())).get(5,java.util.concurrent.TimeUnit.SECONDS).status());
var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var actorCoins=storage.find(actor.core.record.id()).get().orElseThrow().coins();var partyACoins=storage.find(partyA.core.record.id()).get().orElseThrow().coins();var partyBCoins=storage.find(partyB.core.record.id()).get().orElseThrow().coins();
@@ -297,21 +379,20 @@ class AdventureQuestNativeTest {
drainOne(f);
var quest = directory.active().getFirst();
String accepted = actor.messages.getLast();
assertTrue(accepted.startsWith("Accepted Monster Camp at Adventure Realm (12, -1, -9); expires "));
for (UUID internal : List.of(worldId, site.id(), directory.snapshot().catalog().revision(),
quest.id(), quest.unit(), actor.core.record.id())) {
assertFalse(accepted.contains(internal.toString()));
assertEquals("Accepted Monster Camp; expires "+quest.expiresAt()+". Follow the Quest Compass.",accepted);
for (String hidden : List.of("(12, -1, -9)", worldId.toString(), site.id().toString(),
directory.snapshot().catalog().revision().toString(), quest.id().toString(),
quest.unit().toString(), actor.core.record.id().toString())) {
assertFalse(accepted.contains(hidden));
}
f.command.execute(actor.player, "fantasystick", new String[]{"adventure"});
String inspected = actor.messages.getLast();
assertTrue(inspected.startsWith("Monster Camp at Adventure Realm (12, -1, -9); state="));
assertFalse(inspected.contains(worldId.toString()));
assertFalse(inspected.contains(site.id().toString()));
assertFalse(inspected.contains(quest.id().toString()));
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"});
assertTrue(actor.messages.getLast().startsWith("Monster Camp at unavailable world (12, -1, -9); state="));
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"});
assertTrue(actor.messages.getLast().contains(site.id().toString()));
@@ -359,9 +440,9 @@ class AdventureQuestNativeTest {
verify(actor.player,never()).setCompassTarget(any(Location.class));
String lore=compass.getItemMeta().lore().stream().map(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText()::serialize).reduce("",(a,b)->a+"\n"+b);
assertTrue(lore.contains("Compass Camp"));assertTrue(lore.contains("Monster camp"));
assertTrue(lore.contains("Adventure Realm (10, 69, -11)"));assertTrue(lore.contains("Distance: 15 blocks"));
assertTrue(lore.contains("World: Adventure Realm"));assertTrue(lore.contains("Distance: 15 blocks"));
assertTrue(lore.contains("Expires: "+quest.expiresAt()));
assertFalse(lore.contains(site.id().toString()));assertFalse(lore.contains(quest.id().toString()));
for(String hidden:List.of("(10, 69, -11)",targetWorld.toString(),site.id().toString(),quest.id().toString()))assertFalse(lore.contains(hidden));
assertEquals(List.of("Compass Camp — 15 blocks NE · ↑ +6"),actor.actionBars);
var use=new org.bukkit.event.player.PlayerInteractEvent(actor.player,org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
@@ -392,10 +473,10 @@ class AdventureQuestNativeTest {
var other=mock(World.class);when(other.getUID()).thenReturn(otherWorld);actor.core.location.setWorld(other);
f.players.put(targetWorldActor.core.id,targetWorldActor.player);
for(Runnable pulse:List.copyOf(f.pulses))pulse.run();
assertEquals("Travel to Adventure Realm (10, 69, -11)",actor.actionBars.getLast());
assertEquals("Travel to Adventure Realm",actor.actionBars.getLast());
f.players.remove(targetWorldActor.core.id);
for(Runnable pulse:List.copyOf(f.pulses))pulse.run();
assertEquals("Travel to unavailable world (10, 69, -11)",actor.actionBars.getLast());
assertEquals("Travel to unavailable world",actor.actionBars.getLast());
assertTrue(directory.abandon(quest.id(),quest.unit()).get(5,java.util.concurrent.TimeUnit.SECONDS));
for(Runnable pulse:List.copyOf(f.pulses))pulse.run();
@@ -433,8 +514,8 @@ class AdventureQuestNativeTest {
for(Runnable pulse:List.copyOf(f.pulses))pulse.run();
assertEquals(Material.COMPASS,actor.core.inventory.getItem(0).getType());assertTrue(actor.actionBars.isEmpty());
String visible=actor.core.inventory.getItem(0).getItemMeta().lore().stream().map(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText()::serialize).reduce("",String::concat);
assertTrue(visible.contains("Dungeon entrance"));assertTrue(visible.contains("Dungeon Realm (40, 70, -20)"));
for(UUID hidden:List.of(entrance.id(),quest.id(),quest.catalog(),quest.unit(),actor.core.record.id()))assertFalse(visible.contains(hidden.toString()));
assertTrue(visible.contains("Dungeon entrance"));assertTrue(visible.contains("World: Dungeon Realm"));
for(String hidden:List.of("(40, 70, -20)",world.toString(),entrance.id().toString(),quest.id().toString(),quest.catalog().toString(),quest.unit().toString(),actor.core.record.id().toString()))assertFalse(visible.contains(hidden));
inside.set(false);for(Runnable pulse:List.copyOf(f.pulses))pulse.run();
assertEquals(List.of("Deep Keep — 45 blocks NE · ↑ +10"),actor.actionBars);
assertTrue(directory.closeOffer(entrance.id()).get(5,java.util.concurrent.TimeUnit.SECONDS));
@@ -26,6 +26,39 @@ class DungeonNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void registeredDungeonTeleportUsesExactEntranceForDisabledAndEnabledDefinitions() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\n");
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var admin=new FantasyStickLifecycleNativeTest.Actor(data);var destination=new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(admin.core.id,admin.player);f.players.put(destination.core.id,destination.player);
doReturn(true).when(admin.player).hasPermission("fantasystick.admin");
UUID startWorld=UUID.randomUUID(),targetWorld=UUID.randomUUID();when(admin.world.getUID()).thenReturn(startWorld);
when(destination.world.getUID()).thenReturn(targetWorld);when(destination.world.getName()).thenReturn("Dungeon Realm");
var directory=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");
var disabled=directory.create("Disabled Vault",targetWorld,34.75,66.5,18.25,91.5f,-7.25f).get(5,TimeUnit.SECONDS);
var enabled=directory.create("Enabled Depths",targetWorld,-20.5,55.25,-33.75,-135f,22f).get(5,TimeUnit.SECONDS);
var room=directory.addRadiusRoom(enabled.id(),"Boss",-20,54,-34,2,3).get(5,TimeUnit.SECONDS);
directory.setBoss(enabled.id(),room.id(),EntityType.HUSK,"Keeper",-20,55,-34,0,0).get(5,TimeUnit.SECONDS);
enabled=directory.enable(enabled.id(),List.of()).get(5,TimeUnit.SECONDS);
var before=directory.snapshot();var teleports=new ArrayList<Location>();
doReturn(java.util.concurrent.CompletableFuture.completedFuture(mock(Chunk.class))).when(destination.world).getChunkAtAsync(anyInt(),anyInt());
doAnswer(call->{teleports.add(((Location)call.getArgument(0)).clone());return java.util.concurrent.CompletableFuture.completedFuture(true);})
.when(admin.player).teleportAsync(any(Location.class));
assertTrue(f.command.tabComplete(admin.player,"fantasystick",new String[]{"dungeon",""}).contains("teleport"));
assertEquals(List.of(disabled.id().toString(),enabled.id().toString()).stream().sorted().toList(),
f.command.tabComplete(admin.player,"fantasystick",new String[]{"dungeon","teleport",""}));
f.command.execute(admin.player,"fantasystick",new String[]{"dungeon","teleport",disabled.id().toString()});drain(f);drain(f);
var first=teleports.getFirst();assertSame(destination.world,first.getWorld());assertEquals(34.75,first.getX());assertEquals(66.5,first.getY());assertEquals(18.25,first.getZ());assertEquals(91.5f,first.getYaw());assertEquals(-7.25f,first.getPitch());
assertEquals("Teleported to Disabled Vault (dungeon entrance) at Dungeon Realm (34, 66, 18).",admin.messages.getLast());
f.command.execute(admin.player,"fantasystick",new String[]{"dungeon","teleport",enabled.id().toString()});drain(f);drain(f);
var second=teleports.getLast();assertEquals(-20.5,second.getX());assertEquals(55.25,second.getY());assertEquals(-33.75,second.getZ());assertEquals(-135f,second.getYaw());assertEquals(22f,second.getPitch());
assertTrue(admin.messages.getLast().contains("Enabled Depths (dungeon entrance) at Dungeon Realm (-21, 55, -34)"));
assertEquals(before,directory.snapshot());
verify(destination.world).getChunkAtAsync(2,1);verify(destination.world).getChunkAtAsync(-2,-3);
}
}
@SuppressWarnings({"unchecked","rawtypes"})
@Test void registeredAuthoringQuestAcceptanceRoomRewardAndDirectBossClearAreConnected() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\n");
@@ -1292,8 +1292,9 @@ class EquipmentSessionsNativeTest {
}
}
@Test void cooldownExplainsRemainingSecondsAndSameCharacterBeforeEquipmentPreflight() throws Exception {
try (var f = new Fixture()) {
@Test void cooldownShowsOnlyFreshRoundedRemainingSecondsBeforeEquipmentPreflight() throws Exception {
var clock = new MutableClock();
try (var f = new Fixture(Duration.ofSeconds(13), clock)) {
var actor = f.actor(); f.activate(actor);
f.sessions.exit(actor.player);
var exited = f.state(actor);
@@ -1304,14 +1305,18 @@ class EquipmentSessionsNativeTest {
actor.inventory.setItem(0, new ItemStack(Material.SHULKER_BOX));
actor.cursor = new ItemStack(Material.EMERALD);
var before = actor.snapshot(); var cursor = actor.cursor.clone(); var marker = actor.marker();
for (int retry = 0; retry < 2; retry++) {
clock.advance(Duration.ofMillis(900));
for (String expected : java.util.List.of(
"Wait 13 seconds to switch characters; the same character may be resumed now.",
"Wait 12 seconds to switch characters; the same character may be resumed now.")) {
f.sessions.enter(actor.player); f.tick();
var notice = f.notices.getLast();
assertEquals("COOLDOWN", notice.code());
assertTrue(notice.message().matches("(?s).*Wait [1-9][0-9]* seconds.*"), notice::message);
assertTrue(notice.message().contains("same character may be resumed now"), notice::message);
assertEquals(expected, notice.message());
assertFalse(notice.message().contains(exited.lastExit().deadline().toString()));
assertArrayEquals(before, actor.snapshot()); assertEquals(cursor, actor.cursor);
assertEquals(marker, actor.marker()); assertEquals(exited, f.state(actor));
clock.advance(Duration.ofMillis(100));
}
}
}
@@ -64,16 +64,21 @@ class FantasyHealthNativeTest {
}
}
@Test void activeBlueMistAndOneRedDefeatBurstUseWorldLocationsAndStopWithTheSession() throws Exception {
@Test void activeBlueMistDefaultsOffWhileOptInAndOneRedDefeatBurstRespectTheirOwnLifetimes() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var ordinary = new FantasyStickLifecycleNativeTest.Actor(data); ordinary.core.inventory.setItemInOffHand(null);
f.players.put(a.core.id, a.player); f.players.put(ordinary.core.id, ordinary.player);
var entry = a.player.getLocation(); f.activate(a);
f.pulses.forEach(Runnable::run);
assertTrue(dust(a, org.bukkit.Color.BLUE) > 0, "Active fantasy mode must have visible public blue particles");
assertEquals(0, dust(ordinary, org.bukkit.Color.BLUE));
f.pulses.getLast().run();
assertEquals(0, receivedDust(a, org.bukkit.Color.BLUE), "Active-character auras default off per viewer");
assertEquals(0, receivedDust(ordinary, org.bukkit.Color.BLUE));
f.particlesCommand.execute(a.player, "fantasyparticles", new String[]{"players", "on"}); f.tick();
f.pulses.getLast().run();
assertTrue(receivedDust(a, org.bukkit.Color.BLUE) > 0, "An opted-in active viewer sees the current blue aura");
assertEquals(0, receivedDust(ordinary, org.bukkit.Color.BLUE));
f.particlesCommand.execute(a.player, "fantasyparticles", new String[]{"players", "off"}); f.tick();
a.core.location.add(20, 0, 20);
var defeatLocation = a.player.getLocation().add(0, 1, 0);
var source = new org.bukkit.craftbukkit.damage.CraftDamageSource(new net.minecraft.world.damagesource.DamageSource(
@@ -82,19 +87,19 @@ class FantasyHealthNativeTest {
net.kyori.adventure.text.Component.empty(), true);
f.dispatcher.accept(death); assertTrue(death.isCancelled());
PartyBookNativeTest.drain(f);
assertEquals(1, dust(a, org.bukkit.Color.RED));
assertEquals(1, worldDust(a, org.bukkit.Color.RED), "Aura opt-out must not hide the defeat burst");
assertTrue(org.mockito.Mockito.mockingDetails(a.world).getInvocations().stream().anyMatch(call ->
call.getMethod().getName().equals("spawnParticle") && java.util.Arrays.asList(call.getArguments()).contains(defeatLocation)));
assertEquals(entry, a.player.getLocation()); assertEquals(28, a.player.getHealth());
var tail = a.core.inventory.getItemInOffHand().clone();
assertEquals(org.bukkit.Material.WRITTEN_BOOK, tail.getType()); assertEquals(1, tail.getAmount());
long blue = dust(a, org.bukkit.Color.BLUE);
long blue = receivedDust(a, org.bukkit.Color.BLUE);
f.pulses.forEach(Runnable::run); f.dispatcher.accept(death);
assertEquals(blue, dust(a, org.bukkit.Color.BLUE)); assertEquals(1, dust(a, org.bukkit.Color.RED));
assertEquals(blue, receivedDust(a, org.bukkit.Color.BLUE)); assertEquals(1, worldDust(a, org.bukkit.Color.RED));
assertEquals(tail, a.core.inventory.getItemInOffHand());
f.plugin.setEnabled(false); f.awaitWorkers();
f.pulses.forEach(Runnable::run);
assertEquals(blue, dust(a, org.bukkit.Color.BLUE), "Retired timers cannot emit fantasy effects");
assertEquals(blue, receivedDust(a, org.bukkit.Color.BLUE), "Retired timers cannot emit fantasy effects");
}
}
@@ -347,12 +352,18 @@ class FantasyHealthNativeTest {
}
}
private static long dust(FantasyStickLifecycleNativeTest.Actor a, org.bukkit.Color color) {
private static long worldDust(FantasyStickLifecycleNativeTest.Actor a, org.bukkit.Color color) {
return org.mockito.Mockito.mockingDetails(a.world).getInvocations().stream().filter(call ->
call.getMethod().getName().equals("spawnParticle") && java.util.Arrays.stream(call.getArguments())
.anyMatch(argument -> argument instanceof org.bukkit.Particle.DustOptions dust && dust.getColor().equals(color))).count();
}
private static long receivedDust(FantasyStickLifecycleNativeTest.Actor a, org.bukkit.Color color) {
return a.particleCalls.stream().filter(call -> java.util.Arrays.stream(call)
.anyMatch(argument -> argument instanceof org.bukkit.Particle.DustOptions dust
&& dust.getColor().equals(color))).count();
}
private static net.minecraft.world.entity.ai.attributes.AttributeModifier modifier(String name, double amount,
net.minecraft.world.entity.ai.attributes.AttributeModifier.Operation operation) {
return new net.minecraft.world.entity.ai.attributes.AttributeModifier(net.minecraft.resources.Identifier.fromNamespaceAndPath("test", name), amount, operation);
@@ -0,0 +1,228 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Path;
import java.util.List;
import org.bukkit.Color;
import org.bukkit.Particle;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Registered account command through the real recurring active-session aura pulse. */
class FantasyParticlesNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void registeredPlayersRouteDefaultsOffPersistsAndRejectsInvalidSendersAndArguments() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
assertNotNull(fixture.particlesCommand);
var player = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(player.core.id, player.player);
assertTrue(fixture.particlesCommand.execute(player.player, "fantasyparticles",
new String[]{"players"}));
assertEquals("Active-character aura particles are off.", player.messages.getLast());
assertEquals(List.of("players"), fixture.particlesCommand.tabComplete(
player.player, "fantasyparticles", new String[]{""}));
assertEquals(List.of("off", "on"), fixture.particlesCommand.tabComplete(
player.player, "fantasyparticles", new String[]{"players", ""}));
assertEquals(List.of("off"), fixture.particlesCommand.tabComplete(
player.player, "fantasyparticles", new String[]{"players", "of"}));
assertEquals(List.of("on"), fixture.particlesCommand.tabComplete(
player.player, "fantasyparticles", new String[]{"players", "on"}));
int before = player.messages.size();
assertTrue(fixture.particlesCommand.execute(player.player, "fantasyparticles",
new String[]{"players", "on"}));
assertEquals(before, player.messages.size(), "Persistence must complete before acknowledgement");
fixture.tick();
assertEquals("Active-character aura particles are now on.", player.messages.getLast());
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
var allOff = preferences.setAllParticles(player.core.id, false).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS);
assertTrue(preferences.publish(allOff));
fixture.particlesCommand.execute(player.player, "fantasyparticles", new String[]{"players"});
assertEquals("Active-character aura particles are on (effectively off while all particles are off).",
player.messages.getLast());
var allOn = preferences.setAllParticles(player.core.id, true).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS);
assertTrue(preferences.publish(allOn));
var console = new EquipmentAdminCommandNativeTest.Sender();
assertTrue(fixture.particlesCommand.execute(console.port, "fantasyparticles",
new String[]{"players", "off"}));
assertTrue(console.messages.getLast().contains("player-account"));
assertTrue(fixture.particlesCommand.tabComplete(console.port, "fantasyparticles",
new String[]{""}).isEmpty());
for (String[] invalid : List.of(new String[]{}, new String[]{"all"},
new String[]{"players", "ON"}, new String[]{"players", "on", "extra"})) {
assertFalse(fixture.particlesCommand.getExecutor().onCommand(player.player,
fixture.particlesCommand, "fantasyparticles", invalid));
}
var other = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(other.core.id, other.player);
var playerSheet = player.core.inventory.getItemInOffHand();
player.core.inventory.setItemInOffHand(other.core.inventory.getItemInOffHand());
other.core.inventory.setItemInOffHand(playerSheet);
fixture.particlesCommand.execute(player.player, "fantasyparticles", new String[]{"players"});
assertEquals("Active-character aura particles are on.", player.messages.getLast(),
"The preference stays with the account after character-sheet switching");
fixture.particlesCommand.execute(other.player, "fantasyparticles", new String[]{"players"});
assertEquals("Active-character aura particles are off.", other.messages.getLast(),
"A transferred character does not transfer its prior holder's preference");
fixture.plugin.setEnabled(false);
fixture.plugin.setEnabled(true);
fixture.particlesCommand.execute(player.player, "fantasyparticles", new String[]{"players"});
assertEquals("Active-character aura particles are on.", player.messages.getLast());
}
}
@Test void reversedCompetingCommandCallbacksCannotPublishStaleAuraState() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var player = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(player.core.id, player.player);
fixture.particlesCommand.execute(player.player, "fantasyparticles",
new String[]{"players", "on"});
fixture.particlesCommand.execute(player.player, "fantasyparticles",
new String[]{"players", "off"});
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
preferences.setPlayerAuras(java.util.UUID.randomUUID(), false).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS);
var callbacks = new java.util.ArrayList<Runnable>();
fixture.tasks.drainTo(callbacks);
assertEquals(2, callbacks.size());
java.util.Collections.reverse(callbacks); callbacks.forEach(Runnable::run);
assertFalse(preferences.playerAurasEnabled(player.core.id));
fixture.particlesCommand.execute(player.player, "fantasyparticles", new String[]{"players"});
assertEquals("Active-character aura particles are off.", player.messages.getLast());
}
}
@Test void failedAuraPreferenceSaveKeepsPriorEffectiveStateAndReportsRefusal() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var player = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(player.core.id, player.player);
java.nio.file.Files.createDirectory(data.resolve("player-preferences.yml"));
fixture.particlesCommand.execute(player.player, "fantasyparticles",
new String[]{"players", "on"});
fixture.tick();
assertTrue(player.messages.getLast().contains("could not be saved"));
fixture.particlesCommand.execute(player.player, "fantasyparticles", new String[]{"players"});
assertEquals("Active-character aura particles are off.", player.messages.getLast());
}
}
@Test void recurringAuraIsDefaultOffAndDeliveredOnlyToOptedInNativeViewers() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var first = new FantasyStickLifecycleNativeTest.Actor(data); first.accountName = "First";
var second = new FantasyStickLifecycleNativeTest.Actor(data); second.accountName = "Second";
var enabled = new FantasyStickLifecycleNativeTest.Actor(data); enabled.accountName = "Enabled";
var disabled = new FantasyStickLifecycleNativeTest.Actor(data); disabled.accountName = "Disabled";
var untracked = new FantasyStickLifecycleNativeTest.Actor(data); untracked.accountName = "Untracked";
var hidden = new FantasyStickLifecycleNativeTest.Actor(data); hidden.accountName = "Hidden"; hidden.visible = false;
var crossWorld = new FantasyStickLifecycleNativeTest.Actor(data); crossWorld.accountName = "CrossWorld";
second.core.location.setWorld(first.world); second.core.location.add(5, 0, 0);
enabled.core.location.setWorld(first.world);
disabled.core.location.setWorld(first.world);
untracked.core.location.setWorld(first.world);
hidden.core.location.setWorld(first.world);
enabled.core.inventory.setItemInOffHand(null);
disabled.core.inventory.setItemInOffHand(null);
untracked.core.inventory.setItemInOffHand(null);
hidden.core.inventory.setItemInOffHand(null);
crossWorld.core.inventory.setItemInOffHand(null);
for (var actor : List.of(first, second, enabled, disabled, untracked, hidden, crossWorld))
fixture.players.put(actor.core.id, actor.player);
first.tracked.add(enabled.player); first.tracked.add(disabled.player); first.tracked.add(hidden.player);
first.tracked.add(crossWorld.player);
second.tracked.add(enabled.player); second.tracked.add(disabled.player); second.tracked.add(hidden.player);
second.tracked.add(first.player);
fixture.activate(first); fixture.activate(second);
var firstInventory = first.core.snapshot(); var secondInventory = second.core.snapshot();
String firstMarker = first.core.marker(); String secondMarker = second.core.marker();
clear(first, second, enabled, disabled, untracked, hidden, crossWorld);
auraPulse(fixture);
assertTrue(first.particleCalls.isEmpty());
assertTrue(second.particleCalls.isEmpty());
assertTrue(enabled.particleCalls.isEmpty());
assertTrue(disabled.particleCalls.isEmpty());
assertTrue(hidden.particleCalls.isEmpty());
org.mockito.Mockito.verify(first.world, org.mockito.Mockito.never()).spawnParticle(
org.mockito.ArgumentMatchers.eq(Particle.DUST), org.mockito.ArgumentMatchers.any(org.bukkit.Location.class),
org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.anyDouble(),
org.mockito.ArgumentMatchers.anyDouble(), org.mockito.ArgumentMatchers.anyDouble(),
org.mockito.ArgumentMatchers.anyDouble(), org.mockito.ArgumentMatchers.any(Particle.DustOptions.class));
optIn(fixture, enabled);
optIn(fixture, first);
optIn(fixture, untracked);
optIn(fixture, hidden);
optIn(fixture, crossWorld);
clear(first, second, enabled, disabled, untracked, hidden, crossWorld);
auraPulse(fixture);
assertEquals(2, blueAuraCalls(enabled).size(), "An ordinary opted-in viewer sees both tracked sources");
assertEquals(2, blueAuraCalls(first).size(), "An opted-in active viewer sees self and a tracked peer");
assertTrue(disabled.particleCalls.isEmpty(), "Default-off viewers receive no substitute packet");
assertTrue(untracked.particleCalls.isEmpty(), "Opt-in does not expand native tracking");
assertTrue(hidden.particleCalls.isEmpty(), "Opt-in preserves native player visibility");
assertTrue(crossWorld.particleCalls.isEmpty(), "Opt-in does not cross world boundaries");
assertEquals(java.util.Set.of(first.player.getLocation().add(0, 1, 0),
second.player.getLocation().add(0, 1, 0)), blueAuraCalls(enabled).stream()
.map(call -> (org.bukkit.Location) call[1]).collect(java.util.stream.Collectors.toSet()));
for (Object[] call : blueAuraCalls(enabled)) {
assertEquals(3, call[2]);
assertEquals(0.3, (Double) call[3]); assertEquals(0.4, (Double) call[4]);
assertEquals(0.3, (Double) call[5]); assertEquals(0.0, (Double) call[6]);
}
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
var allOff = preferences.setAllParticles(enabled.core.id, false).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS);
assertTrue(preferences.publish(allOff));
clear(enabled); auraPulse(fixture);
assertTrue(enabled.particleCalls.isEmpty(), "All-particle off takes precedence over saved aura opt-in");
assertTrue(preferences.playerAurasEnabled(enabled.core.id), "Precedence does not overwrite aura preference");
var allOn = preferences.setAllParticles(enabled.core.id, true).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS);
assertTrue(preferences.publish(allOn));
clear(enabled); auraPulse(fixture);
assertEquals(2, blueAuraCalls(enabled).size(), "Re-enabling all restores the saved aura preference");
assertArrayEquals(firstInventory, first.core.snapshot()); assertArrayEquals(secondInventory, second.core.snapshot());
assertEquals(firstMarker, first.core.marker()); assertEquals(secondMarker, second.core.marker());
}
}
private static void optIn(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
fixture.particlesCommand.execute(actor.player, "fantasyparticles", new String[]{"players", "on"});
fixture.tick();
}
private static void auraPulse(FantasyStickLifecycleNativeTest.Fixture fixture) {
fixture.pulses.getLast().run();
}
private static List<Object[]> blueAuraCalls(FantasyStickLifecycleNativeTest.Actor actor) {
return actor.particleCalls.stream().filter(call -> call[0] == Particle.DUST && call[2].equals(3)
&& java.util.Arrays.stream(call).anyMatch(value -> value instanceof Particle.DustOptions dust
&& dust.getColor().equals(Color.BLUE))).toList();
}
private static void clear(FantasyStickLifecycleNativeTest.Actor... actors) {
for (var actor : actors) { actor.particles.clear(); actor.particleCalls.clear(); }
}
}
@@ -1129,6 +1129,7 @@ class FantasyStickLifecycleNativeTest {
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;
int playerLookups;
String failRegistrationAt;
boolean failRemoval;
@@ -1211,7 +1212,11 @@ class FantasyStickLifecycleNativeTest {
case "getWorld" -> players.values().stream().map(org.bukkit.entity.Player::getWorld)
.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" -> "fantasyfeedback".equals(args[0]) ? feedbackCommand : command;
case "getPluginCommand" -> switch ((String) args[0]) {
case "fantasyfeedback" -> feedbackCommand;
case "fantasyparticles" -> particlesCommand;
default -> command;
};
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])
: new org.bukkit.craftbukkit.inventory.CraftInventoryCustom((org.bukkit.inventory.InventoryHolder) args[0], (org.bukkit.event.inventory.InventoryType) args[1], (net.kyori.adventure.text.Component) args[2]);
@@ -1251,6 +1256,13 @@ class FantasyStickLifecycleNativeTest {
feedbackCommand.permissionMessage(net.kyori.adventure.text.Component.text("Permission denied"));
feedbackCommand.setUsage((String) feedbackDefinition.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"));
}
}
<T> List<T> packets(org.bukkit.entity.Player viewer, Class<T> type) {
@@ -1352,6 +1364,9 @@ class FantasyStickLifecycleNativeTest {
final net.minecraft.world.food.FoodData food = new net.minecraft.world.food.FoodData();
boolean healthScaled;
boolean sprinting;
boolean allowed = true;
boolean visible = true;
String accountName = "Account";
double healthScale = 20;
double absorption;
Runnable scaleMutation = () -> {};
@@ -1381,11 +1396,12 @@ class FantasyStickLifecycleNativeTest {
case "equals" -> call.getMock() == args[0];
case "getHandle" -> nativePlayer;
case "getEntityId" -> 42;
case "getName" -> "Account";
case "getName" -> accountName;
case "hasPermission" -> allowed;
case "getWorld" -> core.location.getWorld();
case "getHeight" -> 1.8;
case "getTrackedBy" -> tracked;
case "canSee" -> true;
case "canSee" -> visible;
case "getAttribute" -> {
assertEquals(org.bukkit.attribute.Attribute.MAX_HEALTH, args[0]);
yield new org.bukkit.craftbukkit.attribute.CraftAttributeInstance(maximumHealth, org.bukkit.attribute.Attribute.MAX_HEALTH);
@@ -29,13 +29,19 @@ 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"), metadata.getCommands().keySet());
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 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()
.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());
@@ -46,14 +46,69 @@ class PlayerFeedbackPreferencesTest {
try (var reopened = new PlayerFeedbackPreferences(data)) { assertTrue(reopened.enabled(account)); }
}
@Test void auraDefaultsOffAndRemainsIndependentUnderAllParticlePrecedenceAcrossRestart() throws Exception {
UUID account = UUID.randomUUID();
java.nio.file.Files.writeString(data.resolve("player-preferences.yml"),
"unrelated: keep\nplayers:\n " + account + ":\n combat-feedback: false\n other: value\n");
try (var preferences = new PlayerFeedbackPreferences(data)) {
assertFalse(preferences.playerAurasEnabled(account));
assertTrue(preferences.allParticlesEnabled(account));
assertFalse(preferences.auraParticlesEnabled(account));
var auraOn = preferences.setPlayerAuras(account, true).toCompletableFuture()
.get(5, TimeUnit.SECONDS);
assertFalse(preferences.playerAurasEnabled(account), "Durability precedes effective publication");
assertTrue(preferences.publish(auraOn));
assertTrue(preferences.auraParticlesEnabled(account));
var allOff = preferences.setAllParticles(account, false).toCompletableFuture()
.get(5, TimeUnit.SECONDS);
assertTrue(preferences.playerAurasEnabled(account), "Changing all preserves the saved aura value");
assertTrue(preferences.publish(allOff));
assertFalse(preferences.auraParticlesEnabled(account));
}
String saved = java.nio.file.Files.readString(data.resolve("player-preferences.yml"));
assertTrue(saved.contains("unrelated: keep")); assertTrue(saved.contains("other: value"));
try (var reopened = new PlayerFeedbackPreferences(data)) {
assertTrue(reopened.playerAurasEnabled(account));
assertFalse(reopened.allParticlesEnabled(account));
assertFalse(reopened.auraParticlesEnabled(account));
assertFalse(reopened.enabled(account), "Combat feedback remains independent");
var allOn = reopened.setAllParticles(account, true).toCompletableFuture().get(5, TimeUnit.SECONDS);
assertTrue(reopened.publish(allOn));
assertTrue(reopened.auraParticlesEnabled(account));
}
}
@Test void competingAuraWritesRejectStalePublicationAndAcceptedShutdownWorkDrains() throws Exception {
UUID account = UUID.randomUUID();
var preferences = new PlayerFeedbackPreferences(data);
var enabled = preferences.setPlayerAuras(account, true).toCompletableFuture().get(5, TimeUnit.SECONDS);
var disabled = preferences.setPlayerAuras(account, false).toCompletableFuture().get(5, TimeUnit.SECONDS);
assertTrue(preferences.publish(disabled));
assertFalse(preferences.publish(enabled));
assertFalse(preferences.playerAurasEnabled(account));
var accepted = preferences.setPlayerAuras(account, true).toCompletableFuture();
preferences.close();
preferences.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
assertNotNull(accepted.get(5, TimeUnit.SECONDS));
assertFalse(preferences.playerAurasEnabled(account), "A closed lifetime does not publish its callback");
try (var reopened = new PlayerFeedbackPreferences(data)) {
assertTrue(reopened.playerAurasEnabled(account), "Accepted shutdown work is durable on restart");
}
}
@Test void failedOrClosedWritesRetainTheEffectivePreference() throws Exception {
UUID account = UUID.randomUUID();
var preferences = new PlayerFeedbackPreferences(data);
java.nio.file.Files.createDirectory(data.resolve("player-preferences.yml"));
assertThrows(Exception.class, () -> preferences.set(account, false).toCompletableFuture().get(5, TimeUnit.SECONDS));
assertThrows(Exception.class, () -> preferences.setPlayerAuras(account, true).toCompletableFuture().get(5, TimeUnit.SECONDS));
assertTrue(preferences.enabled(account));
assertFalse(preferences.playerAurasEnabled(account));
preferences.close();
assertThrows(Exception.class, () -> preferences.set(account, false).toCompletableFuture().get(5, TimeUnit.SECONDS));
assertThrows(Exception.class, () -> preferences.setPlayerAuras(account, true).toCompletableFuture().get(5, TimeUnit.SECONDS));
assertTrue(preferences.enabled(account));
assertFalse(preferences.playerAurasEnabled(account));
}
}