diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b198b1e..535a507 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,10 +32,19 @@ jobs: - name: Bump patch version in pom.xml id: version + env: + GH_TOKEN: ${{ github.token }} run: | CURRENT="$(mvn -B -ntp help:evaluate -Dexpression=project.version -q -DforceStdout)" IFS='.' read -r major minor patch <<< "${CURRENT//-SNAPSHOT/}" NEW="${major}.${minor}.$((patch + 1))" + + # pom.xml can lag behind tags when release bump PRs merge on main only + while gh release view "v${NEW}" >/dev/null 2>&1; do + IFS='.' read -r major minor patch <<< "${NEW}" + NEW="${major}.${minor}.$((patch + 1))" + done + echo "current=${CURRENT}" >> "$GITHUB_OUTPUT" echo "new=${NEW}" >> "$GITHUB_OUTPUT" mvn -B -ntp org.codehaus.mojo:versions-maven-plugin:2.17.1:set \ diff --git a/.gitignore b/.gitignore index 1ba39a8..285f688 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,8 @@ log/ target/ /bin/ -dependency-reduced-pom.xml \ No newline at end of file +dependency-reduced-pom.xml + +# Local dev (portable JDK, downloaded release JARs) +.jdk/ +dev/ \ No newline at end of file diff --git a/pom.xml b/pom.xml index 0150c6e..d160608 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ 4.0.0 edu.whimc WHIMC-QRF-Agent - 3.3.16 + 3.3.20 WHIMC QRF Agent Defines overworld agent traits diff --git a/scripts/build-local.sh b/scripts/build-local.sh new file mode 100644 index 0000000..c092b92 --- /dev/null +++ b/scripts/build-local.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +JDK_HOME="${ROOT}/.jdk/jdk-21.0.11+10/Contents/Home" + +if [[ ! -x "${JDK_HOME}/bin/java" ]]; then + echo "Portable JDK 21 not found at ${JDK_HOME}" + echo "Download Temurin 21 for macOS aarch64 into ${ROOT}/.jdk/ or install Java 21 and set JAVA_HOME." + exit 1 +fi + +export JAVA_HOME="${JDK_HOME}" +cd "${ROOT}" +chmod +x mvnw +./mvnw -B -ntp -DskipTests package + +echo +echo "Built: ${ROOT}/target/WHIMC-QRF-Agent-$(./mvnw -q -DforceStdout help:evaluate -Dexpression=project.version).jar" +echo "Copy that JAR into your Paper server's plugins/ folder and restart." diff --git a/src/main/java/edu/whimc/overworld_agent/Listeners.java b/src/main/java/edu/whimc/overworld_agent/Listeners.java index 6534f1d..81ddfc7 100644 --- a/src/main/java/edu/whimc/overworld_agent/Listeners.java +++ b/src/main/java/edu/whimc/overworld_agent/Listeners.java @@ -80,7 +80,7 @@ public void onPlayerJoin(PlayerJoinEvent event){ plugin.relinkOwnedAgent(player); Map agents = plugin.getAgents(); NPC npc = agents.get(player.getName()); - if(npc != null) { + if (npc != null) { npc.getOrAddTrait(AgentPermanentFlyingTrait.class); npc.getOrAddTrait(AgentFollowCatchUpTrait.class); npc.spawn(player.getLocation()); diff --git a/src/main/java/edu/whimc/overworld_agent/OverworldAgent.java b/src/main/java/edu/whimc/overworld_agent/OverworldAgent.java index 6fdfbbb..34c9f38 100644 --- a/src/main/java/edu/whimc/overworld_agent/OverworldAgent.java +++ b/src/main/java/edu/whimc/overworld_agent/OverworldAgent.java @@ -15,6 +15,7 @@ import edu.whimc.overworld_agent.dialoguetemplate.models.llm.LlmRagContextBuilder; import edu.whimc.overworld_agent.dialoguetemplate.models.llm.WorldLlmPromptRegistry; import edu.whimc.overworld_agent.dialoguetemplate.models.BuildTemplate; +import edu.whimc.overworld_agent.utils.AgentEntityTypes; import edu.whimc.overworld_agent.utils.sql.Queryer; import org.bukkit.Bukkit; @@ -78,6 +79,7 @@ public class OverworldAgent extends JavaPlugin { @Override public void onEnable() { saveDefaultConfig(); + AgentEntityTypes.load(this); //receiver = (SpeechReceiver) Bukkit.getServer().getPluginManager().getPlugin("SpeechReceiver"); sessions = new HashMap<>(); buildTemplates = new HashMap<>(); @@ -119,6 +121,8 @@ public void onEnable() { getCommand("agent").setExecutor(agentCommand); getCommand("agent").setTabCompleter(agentCommand); + edu.whimc.overworld_agent.utils.AgentPermissions.register(); + HabitatAssessCommand assessCommand = new HabitatAssessCommand(this); getCommand("assess-habitat").setExecutor(assessCommand); getCommand("assess-habitat").setTabCompleter(assessCommand); @@ -212,6 +216,11 @@ public void onDisable(){ public Map getAgents(){return agents;} + /** When false, hides builder dialogue options and blocks {@code /agent rebuilderspawn}. */ + public boolean isBuilderEnabled() { + return getConfig().getBoolean("builder.enabled", false); + } + public void removeAgents(){ agents = new HashMap<>(); } @@ -219,6 +228,80 @@ public void removeAgents(){ public void removeAgent(String playerName){ agents.remove(playerName); } + + /** + * Permanently removes every Citizens NPC assigned to {@code playerName} and drops them from {@link #agents}. + * Destroy must clear Citizens persistence, not just the in-memory map, or {@link #relinkOwnedAgent} will + * resurrect the agent on the next login. + */ + public void destroyOwnedAgent(String playerName) { + if (playerName == null || playerName.isBlank()) { + return; + } + agents.remove(playerName); + List snapshot = new ArrayList<>(); + for (NPC npc : CitizensAPI.getNPCRegistry()) { + snapshot.add(npc); + } + for (NPC npc : snapshot) { + if (!playerName.equalsIgnoreCase(resolveNpcOwnerName(npc))) { + continue; + } + destroyCitizensNpc(npc); + } + } + + /** Removes all player-owned agent NPCs from Citizens and clears {@link #agents}. */ + public void destroyAllOwnedAgents() { + for (String owner : new ArrayList<>(agents.keySet())) { + destroyOwnedAgent(owner); + } + List snapshot = new ArrayList<>(); + for (NPC npc : CitizensAPI.getNPCRegistry()) { + snapshot.add(npc); + } + for (NPC npc : snapshot) { + if (resolveNpcOwnerName(npc) != null) { + destroyCitizensNpc(npc); + } + } + agents.clear(); + } + + private static void destroyCitizensNpc(NPC npc) { + if (npc == null) { + return; + } + try { + if (npc.isSpawned()) { + npc.despawn(); + } + npc.destroy(); + } catch (Exception ex) { + JavaPlugin.getPlugin(OverworldAgent.class).getLogger().warning( + "Failed to destroy agent NPC " + npc.getId() + ": " + ex.getMessage()); + } + } + + /** @return owning player name for guide/builder agent NPCs, or null */ + public static String resolveNpcOwnerName(NPC npc) { + if (npc == null) { + return null; + } + if (npc.hasTrait(SpawnExpertTrait.class)) { + return npc.getOrAddTrait(SpawnExpertTrait.class).getAssignedPlayerName(); + } + if (npc.hasTrait(SpawnNoviceTrait.class)) { + SpawnNoviceTrait trait = npc.getTrait(SpawnNoviceTrait.class); + return trait != null ? trait.getAssignedPlayerName() : null; + } + if (npc.hasTrait(RebuilderTrait.class)) { + RebuilderTrait trait = npc.getTrait(RebuilderTrait.class); + return trait != null ? trait.getTargetPlayerName() : null; + } + return null; + } + public SignMenuFactory getSignMenuFactory(){return signMenuFactory; } public ChatTextInputFactory getChatTextInputFactory() { @@ -302,26 +385,10 @@ public void relinkOwnedAgent(Player player) { return; } for (NPC npc : CitizensAPI.getNPCRegistry()) { - if (npc.hasTrait(SpawnExpertTrait.class)) { - SpawnExpertTrait t = npc.getOrAddTrait(SpawnExpertTrait.class); - if (name.equals(t.getAssignedPlayerName())) { - agents.put(name, npc); - return; - } - } - if (npc.hasTrait(SpawnNoviceTrait.class)) { - SpawnNoviceTrait t = npc.getTrait(SpawnNoviceTrait.class); - if (t != null && name.equals(t.getAssignedPlayerName())) { - agents.put(name, npc); - return; - } - } - if (npc.hasTrait(RebuilderTrait.class)) { - RebuilderTrait t = npc.getTrait(RebuilderTrait.class); - if (t != null && name.equals(t.getTargetPlayerName())) { - agents.put(name, npc); - return; - } + String owner = resolveNpcOwnerName(npc); + if (owner != null && name.equalsIgnoreCase(owner)) { + agents.put(name, npc); + return; } } } diff --git a/src/main/java/edu/whimc/overworld_agent/commands/AbstractSubCommand.java b/src/main/java/edu/whimc/overworld_agent/commands/AbstractSubCommand.java index 724e16c..5cf203a 100644 --- a/src/main/java/edu/whimc/overworld_agent/commands/AbstractSubCommand.java +++ b/src/main/java/edu/whimc/overworld_agent/commands/AbstractSubCommand.java @@ -1,6 +1,7 @@ package edu.whimc.overworld_agent.commands; import edu.whimc.overworld_agent.OverworldAgent; +import edu.whimc.overworld_agent.utils.AgentPermissions; import edu.whimc.overworld_agent.utils.Utils; import org.bukkit.Bukkit; import org.bukkit.ChatColor; @@ -89,7 +90,8 @@ protected List onTabComplete(CommandSender sender, String[] args) { } public List executeOnTabComplete(CommandSender sender, String[] args) { - if (!sender.hasPermission(getPermission()) || args.length > this.maxArgs) { + if ((!AgentPermissions.bypassesRestrictions(sender) && !sender.hasPermission(getPermission())) + || args.length > this.maxArgs) { return Arrays.asList(); } return onTabComplete(sender, args); @@ -133,7 +135,7 @@ public Permission getPermission() { protected abstract boolean onCommand(CommandSender sender, String[] args); public boolean executeSubCommand(CommandSender sender, String[] args) { - if (!sender.hasPermission(getPermission())) { + if (!AgentPermissions.bypassesRestrictions(sender) && !sender.hasPermission(getPermission())) { Utils.msg(sender, "&cYou do not have the required permission!", " &f&o" + getPermission().getName()); diff --git a/src/main/java/edu/whimc/overworld_agent/commands/AgentCommand.java b/src/main/java/edu/whimc/overworld_agent/commands/AgentCommand.java index 899a4ea..a8e711a 100644 --- a/src/main/java/edu/whimc/overworld_agent/commands/AgentCommand.java +++ b/src/main/java/edu/whimc/overworld_agent/commands/AgentCommand.java @@ -20,9 +20,11 @@ */ public class AgentCommand implements CommandExecutor, TabCompleter { + private final OverworldAgent plugin; private final Map subCommands = new HashMap<>(); public AgentCommand(OverworldAgent plugin) { + this.plugin = plugin; String base = "agent"; subCommands.put("chat", new ChatCommand(plugin, base, "chat")); subCommands.put("spawn", plugin.getExpertSpawnCommand()); @@ -57,13 +59,17 @@ public boolean onCommand(CommandSender sender, Command cmd, String commandLabel, @Override public List onTabComplete(CommandSender sender, Command command, String alias, String[] args) { if (args.length == 0) { - return subCommands.keySet().stream().sorted().collect(Collectors.toList()); + return subCommands.keySet().stream() + .filter(v -> !v.equals("rebuilderspawn") || plugin.isBuilderEnabled()) + .sorted() + .collect(Collectors.toList()); } if (args.length == 1) { return subCommands.keySet() .stream() .filter(v -> v.startsWith(args[0].toLowerCase())) + .filter(v -> !v.equals("rebuilderspawn") || plugin.isBuilderEnabled()) .sorted() .collect(Collectors.toList()); } diff --git a/src/main/java/edu/whimc/overworld_agent/commands/subcommands/DespawnAgentsCommand.java b/src/main/java/edu/whimc/overworld_agent/commands/subcommands/DespawnAgentsCommand.java index b8c42a5..2a32877 100644 --- a/src/main/java/edu/whimc/overworld_agent/commands/subcommands/DespawnAgentsCommand.java +++ b/src/main/java/edu/whimc/overworld_agent/commands/subcommands/DespawnAgentsCommand.java @@ -2,73 +2,127 @@ import edu.whimc.overworld_agent.OverworldAgent; import edu.whimc.overworld_agent.commands.AbstractSubCommand; +import edu.whimc.overworld_agent.utils.AgentPermissions; import net.citizensnpcs.api.npc.NPC; import org.bukkit.Bukkit; -import org.bukkit.ChatColor; -import org.bukkit.command.Command; -import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandSender; -import org.bukkit.command.TabCompleter; -import org.bukkit.configuration.ConfigurationSection; import org.bukkit.entity.Player; +import org.bukkit.permissions.PermissionDefault; -import java.util.*; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; /** - * Class to define command for despawning agents - * @author sam + * Despawns agents with LuckPerms-scoped targets: own, other players, or all. */ public class DespawnAgentsCommand extends AbstractSubCommand { private static final String ALL = "all"; - public DespawnAgentsCommand(OverworldAgent plugin, String baseCommand, String subCommand){ - super(plugin, baseCommand, subCommand); - super.description("Despawns specified player's agent (all for everyone's)"); - super.arguments("playerName"); + public DespawnAgentsCommand(OverworldAgent plugin, String baseCommand, String subCommand) { + super(plugin, baseCommand, subCommand, PermissionDefault.FALSE); + super.description("Despawn an agent (self, another player, or all)"); + super.arguments("[playerName|all]"); } @Override - protected boolean onCommand(CommandSender sender, String[] args) { + public boolean executeSubCommand(CommandSender sender, String[] args) { + if (!AgentPermissions.canUseDespawn(sender)) { + AgentPermissions.deny(sender, AgentPermissions.DESPAWN_SELF); + return true; + } + return super.executeSubCommand(sender, args); + } + @Override + protected boolean onCommand(CommandSender sender, String[] args) { if (args.length < 1) { - sender.sendMessage("No player name was given"); + if (sender instanceof Player player) { + return despawnOne(sender, player.getName()); + } + sender.sendMessage("Usage: /agent despawn "); return true; } + return despawnOne(sender, args[0]); + } - String playerName = args[0]; - Map npcs = plugin.getAgents(); - if(playerName.equalsIgnoreCase(ALL)){ - for (Map.Entry entry : npcs.entrySet()){ + private boolean despawnOne(CommandSender sender, String targetName) { + if (targetName.equalsIgnoreCase(ALL)) { + if (!AgentPermissions.canDespawnAll(sender)) { + AgentPermissions.deny(sender, AgentPermissions.DESPAWN_ALL); + return true; + } + Map npcs = plugin.getAgents(); + int count = 0; + for (Map.Entry entry : npcs.entrySet()) { NPC npc = entry.getValue(); - String currentName = entry.getKey(); - if(Bukkit.getPlayer(currentName) != null){ - npc.despawn(); + if (npc == null) { + continue; } - } - sender.sendMessage("All agents were despawned"); - } else { - if(Bukkit.getPlayer(playerName) != null){ - NPC npc = npcs.get(playerName); - if(npc != null) { + if (Bukkit.getPlayer(entry.getKey()) != null) { npc.despawn(); - sender.sendMessage(npc.getName() + " was despawned"); - } else { - sender.sendMessage("Player does not have an agent"); + count++; } } + sender.sendMessage("Despawned " + count + " agent(s)."); + return true; + } + + if (sender instanceof Player player && targetName.equalsIgnoreCase(player.getName())) { + if (!AgentPermissions.canDespawnSelf(sender)) { + AgentPermissions.deny(sender, AgentPermissions.DESPAWN_SELF); + return true; + } + } else if (!AgentPermissions.canDespawnOther(sender)) { + AgentPermissions.deny(sender, AgentPermissions.DESPAWN_OTHER); + return true; } + + Player online = Bukkit.getPlayerExact(targetName); + if (online == null) { + sender.sendMessage("Player must be online to despawn their agent."); + return true; + } + + NPC npc = plugin.getAgents().get(online.getName()); + if (npc == null) { + sender.sendMessage("Player does not have an agent."); + return true; + } + + npc.despawn(); + sender.sendMessage(npc.getName() + " was despawned."); return true; } @Override - protected List onTabComplete(CommandSender sender, java.lang.String[] args) { - List list = new ArrayList(); - for (Player p : Bukkit.getOnlinePlayers()) { - list.add(p.getName()); + protected List onTabComplete(CommandSender sender, String[] args) { + if (args.length != 1 || !AgentPermissions.canUseDespawn(sender)) { + return List.of(); } - list.add("all"); - return list; - } + String prefix = args[0].toLowerCase(Locale.ROOT); + List options = new ArrayList<>(); + if (AgentPermissions.canDespawnSelf(sender) && sender instanceof Player player) { + options.add(player.getName()); + } + if (AgentPermissions.canDespawnOther(sender)) { + for (Player online : Bukkit.getOnlinePlayers()) { + if (sender instanceof Player self && online.equals(self)) { + continue; + } + options.add(online.getName()); + } + } + if (AgentPermissions.canDespawnAll(sender) && ALL.startsWith(prefix)) { + options.add(ALL); + } + + return options.stream() + .filter(name -> name.toLowerCase(Locale.ROOT).startsWith(prefix)) + .sorted(String.CASE_INSENSITIVE_ORDER) + .toList(); + } } diff --git a/src/main/java/edu/whimc/overworld_agent/commands/subcommands/DestroyAgentsCommand.java b/src/main/java/edu/whimc/overworld_agent/commands/subcommands/DestroyAgentsCommand.java index 51f9504..596bd37 100644 --- a/src/main/java/edu/whimc/overworld_agent/commands/subcommands/DestroyAgentsCommand.java +++ b/src/main/java/edu/whimc/overworld_agent/commands/subcommands/DestroyAgentsCommand.java @@ -2,81 +2,133 @@ import edu.whimc.overworld_agent.OverworldAgent; import edu.whimc.overworld_agent.commands.AbstractSubCommand; +import edu.whimc.overworld_agent.utils.AgentPermissions; import net.citizensnpcs.api.npc.NPC; -import org.bukkit.Bukkit; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import org.bukkit.permissions.PermissionDefault; -import java.util.*; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; /** - * Class to define command for despawning agents - * @author sam + * Destroys agents with LuckPerms-scoped targets: own, other players, or all. */ public class DestroyAgentsCommand extends AbstractSubCommand { + private static final String ALL = "all"; - public DestroyAgentsCommand(OverworldAgent plugin, String baseCommand, String subCommand){ - super(plugin, baseCommand, subCommand); - super.description("Removes specified player's agent from the serve (all for everyones')"); - super.arguments("playerName"); + public DestroyAgentsCommand(OverworldAgent plugin, String baseCommand, String subCommand) { + super(plugin, baseCommand, subCommand, PermissionDefault.FALSE); + super.description("Remove an agent from the server (self, another player, or all)"); + super.arguments("[playerName|all]"); } @Override - protected boolean onCommand(CommandSender sender, String[] args) { + public boolean executeSubCommand(CommandSender sender, String[] args) { + if (!AgentPermissions.canUseDestroy(sender)) { + AgentPermissions.deny(sender, AgentPermissions.DESTROY_SELF); + return true; + } + return super.executeSubCommand(sender, args); + } - Map npcs = plugin.getAgents(); + @Override + protected boolean onCommand(CommandSender sender, String[] args) { if (args.length < 1) { - sender.sendMessage("No player name was given"); + if (sender instanceof Player player) { + return destroyOne(sender, player.getName()); + } + sender.sendMessage("Usage: /agent destroy "); return true; } - String playerName = args[0]; - if (playerName.equalsIgnoreCase(ALL)) { - if (npcs.isEmpty()) { - sender.sendMessage("There are no agents to destroy."); + return destroyOne(sender, args[0]); + } + + private boolean destroyOne(CommandSender sender, String targetName) { + if (targetName.equalsIgnoreCase(ALL)) { + if (!AgentPermissions.canDestroyAll(sender)) { + AgentPermissions.deny(sender, AgentPermissions.DESTROY_ALL); return true; } - - for (NPC npc : new ArrayList<>(npcs.values())) { - if (npc == null) continue; - - if (npc.isSpawned()) { - npc.despawn(); - } - npc.destroy(); + if (plugin.getAgents().isEmpty() && !anyOwnedAgentNpcExists()) { + sender.sendMessage("There are no agents to destroy."); + return true; } - - plugin.removeAgents(); + plugin.destroyAllOwnedAgents(); sender.sendMessage("All agents were destroyed."); return true; } - // Single player destroy - we do NOT require the player to be online - NPC npc = npcs.get(playerName); - - if (npc == null) { - sender.sendMessage("Player " + playerName + " does not have an agent."); + if (sender instanceof Player player && targetName.equalsIgnoreCase(player.getName())) { + if (!AgentPermissions.canDestroySelf(sender)) { + AgentPermissions.deny(sender, AgentPermissions.DESTROY_SELF); + return true; + } + } else if (!AgentPermissions.canDestroyOther(sender)) { + AgentPermissions.deny(sender, AgentPermissions.DESTROY_OTHER); return true; } - if (npc.isSpawned()) { - npc.despawn(); + if (!plugin.getAgents().containsKey(targetName) && !ownedAgentExists(targetName)) { + sender.sendMessage("Player " + targetName + " does not have an agent."); + return true; } - npc.destroy(); - plugin.removeAgent(playerName); - - sender.sendMessage(npc.getName() + " was destroyed."); + String agentLabel = plugin.getAgents().containsKey(targetName) + ? plugin.getAgents().get(targetName).getName() + : targetName + "'s agent"; + plugin.destroyOwnedAgent(targetName); + sender.sendMessage(agentLabel + " was destroyed."); return true; } + private static boolean ownedAgentExists(String playerName) { + for (NPC npc : net.citizensnpcs.api.CitizensAPI.getNPCRegistry()) { + String owner = OverworldAgent.resolveNpcOwnerName(npc); + if (owner != null && owner.equalsIgnoreCase(playerName)) { + return true; + } + } + return false; + } + + private static boolean anyOwnedAgentNpcExists() { + for (NPC npc : net.citizensnpcs.api.CitizensAPI.getNPCRegistry()) { + if (OverworldAgent.resolveNpcOwnerName(npc) != null) { + return true; + } + } + return false; + } + @Override - protected List onTabComplete(CommandSender sender, java.lang.String[] args) { - List list = new ArrayList(); - for (Player p : Bukkit.getOnlinePlayers()) { - list.add(p.getName()); + protected List onTabComplete(CommandSender sender, String[] args) { + if (args.length != 1 || !AgentPermissions.canUseDestroy(sender)) { + return List.of(); } - list.add("all"); - return list; + + String prefix = args[0].toLowerCase(Locale.ROOT); + List options = new ArrayList<>(); + if (AgentPermissions.canDestroySelf(sender) && sender instanceof Player player) { + options.add(player.getName()); + } + if (AgentPermissions.canDestroyOther(sender)) { + for (String ownerName : plugin.getAgents().keySet()) { + if (sender instanceof Player self && ownerName.equalsIgnoreCase(self.getName())) { + continue; + } + options.add(ownerName); + } + } + if (AgentPermissions.canDestroyAll(sender) && ALL.startsWith(prefix)) { + options.add(ALL); + } + + return options.stream() + .filter(name -> name.toLowerCase(Locale.ROOT).startsWith(prefix)) + .sorted(String.CASE_INSENSITIVE_ORDER) + .toList(); } -} \ No newline at end of file +} diff --git a/src/main/java/edu/whimc/overworld_agent/commands/subcommands/ExpertSpawnCommand.java b/src/main/java/edu/whimc/overworld_agent/commands/subcommands/ExpertSpawnCommand.java index 4044b37..697d597 100644 --- a/src/main/java/edu/whimc/overworld_agent/commands/subcommands/ExpertSpawnCommand.java +++ b/src/main/java/edu/whimc/overworld_agent/commands/subcommands/ExpertSpawnCommand.java @@ -3,9 +3,11 @@ import edu.whimc.overworld_agent.OverworldAgent; import edu.whimc.overworld_agent.commands.AbstractSubCommand; import edu.whimc.overworld_agent.utils.AgentEntityTypes; +import edu.whimc.overworld_agent.utils.AgentPermissions; import edu.whimc.overworld_agent.utils.CitizensSkinUrls; -import edu.whimc.overworld_agent.traits.AgentFollowCatchUpTrait; +import edu.whimc.overworld_agent.traits.AgentFollowCatchUp; import edu.whimc.overworld_agent.traits.AgentFollowTuning; +import edu.whimc.overworld_agent.traits.AgentFollowCatchUpTrait; import edu.whimc.overworld_agent.traits.AgentPermanentFlyingTrait; import edu.whimc.overworld_agent.traits.SpawnExpertTrait; import net.citizensnpcs.api.CitizensAPI; @@ -13,10 +15,12 @@ import net.citizensnpcs.api.npc.NPCRegistry; import net.citizensnpcs.trait.LookClose; import net.citizensnpcs.trait.SkinTrait; +import org.bukkit.Location; import org.bukkit.command.CommandSender; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.entity.EntityType; import org.bukkit.entity.Player; +import org.bukkit.permissions.PermissionDefault; import java.util.ArrayList; import java.util.Arrays; @@ -31,12 +35,12 @@ public class ExpertSpawnCommand extends AbstractSubCommand { private static final String COMMAND = "expert"; - private static final List ANIMAL_ENTITY_NAMES = AgentEntityTypes.animalNamesLowercaseSorted(); public ExpertSpawnCommand(OverworldAgent plugin, String baseCommand, String subCommand) { - super(plugin, baseCommand, subCommand); + super(plugin, baseCommand, subCommand, PermissionDefault.TRUE); super.description("Spawns an agent to follow sender with specified entity type/skin and name"); super.arguments("[entityType] [skinName | --url ] agentName"); + super.requiresPlayer(); } @Override @@ -56,6 +60,15 @@ protected boolean onCommand(CommandSender sender, String[] args) { return true; } + if (!AgentPermissions.canSpawnEntityType(player, request.entityType())) { + if (request.entityType() == EntityType.PLAYER) { + AgentPermissions.deny(player, AgentPermissions.SPAWN_PLAYER); + } else { + AgentPermissions.deny(player, AgentPermissions.SPAWN_ANIMAL); + } + return true; + } + NPC npc = createGuideNpc(player, request.entityType(), request.npcName()); if (npc == null) { return true; @@ -251,7 +264,10 @@ private void applySkinAndFinishSpawn( private void finishSpawn(Player player, NPC npc, String npcName, EntityType entityType, String agentSkinOrType) { String storedAppearance = entityType == EntityType.PLAYER ? agentSkinOrType : entityType.name(); plugin.getQueryer().storeNewAgent(player, COMMAND, npcName, storedAppearance, id -> { - npc.spawn(player.getLocation()); + Location spawnAt = entityType == EntityType.PLAYER + ? player.getLocation() + : AgentFollowCatchUp.mobSpawnLocation(plugin, player); + npc.spawn(spawnAt); AgentFollowTuning.scheduleFollowAndApplyTraits(plugin, npc, player); plugin.getAgents().put(player.getName(), npc); }); @@ -314,8 +330,12 @@ protected List onTabComplete(CommandSender sender, String[] args) { if (args.length == 1) { String prefix = args[0].toLowerCase(Locale.ROOT); List entityOpts = new ArrayList<>(); - entityOpts.add("player"); - entityOpts.addAll(ANIMAL_ENTITY_NAMES); + if (AgentPermissions.canSpawnPlayer(sender)) { + entityOpts.add("player"); + } + if (AgentPermissions.canSpawnAnimal(sender)) { + entityOpts.addAll(AgentEntityTypes.animalNamesLowercaseSorted()); + } return entityOpts.stream() .filter(v -> v.startsWith(prefix)) .collect(Collectors.toList()); diff --git a/src/main/java/edu/whimc/overworld_agent/commands/subcommands/RebuilderSpawnCommand.java b/src/main/java/edu/whimc/overworld_agent/commands/subcommands/RebuilderSpawnCommand.java index e3baca2..c5edc86 100644 --- a/src/main/java/edu/whimc/overworld_agent/commands/subcommands/RebuilderSpawnCommand.java +++ b/src/main/java/edu/whimc/overworld_agent/commands/subcommands/RebuilderSpawnCommand.java @@ -46,6 +46,10 @@ public RebuilderSpawnCommand(OverworldAgent plugin, String baseCommand, String s */ @Override protected boolean onCommand(CommandSender sender, String[] args) { + if (!plugin.isBuilderEnabled()) { + sender.sendMessage("Builder agent features are disabled on this server."); + return true; + } Player player; if (!(sender instanceof Player)) { sender.sendMessage("You must be a player"); diff --git a/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/BuilderDialogue.java b/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/BuilderDialogue.java index 8d13a43..aa28bc8 100644 --- a/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/BuilderDialogue.java +++ b/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/BuilderDialogue.java @@ -51,6 +51,9 @@ public void setGoBack(Runnable goBack) { } public void doDialogue(){ + if (!plugin.isBuilderEnabled()) { + return; + } this.spigotCallback.clearCallbacks(player); HashMap> templates = plugin.getBuildTemplates(); Utils.msgNoPrefix(player, "&lWhat do you want to do?", ""); diff --git a/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/Dialogue.java b/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/Dialogue.java index 1db8967..a4d209d 100644 --- a/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/Dialogue.java +++ b/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/Dialogue.java @@ -13,6 +13,7 @@ import edu.whimc.overworld_agent.llm.research.AgentChatResearchTurn; import edu.whimc.overworld_agent.utils.AgentEntityTypes; +import edu.whimc.overworld_agent.utils.AgentPermissions; import edu.whimc.overworld_agent.utils.Utils; import edu.whimc.sciencetools.models.sciencetool.ScienceTool; import edu.whimc.sciencetools.models.sciencetool.ScienceToolMeasureEvent; @@ -71,6 +72,8 @@ public class Dialogue implements Listener { private String discussionConversationId; private String discussionSessionId; private int discussionTurnIndex; + private DialoguePrompt builtInUnknownPrompt; + public Dialogue(OverworldAgent plugin, Player player, boolean text, boolean embodied) { this.spigotCallback = plugin.getSpigotCallback(); this.plugin = plugin; @@ -115,8 +118,13 @@ private void dispatchJourneyCommand(Player player, String rawDestination) { Object manager = journeyPublicWaypointManager(); Object waypoint = manager == null ? null : invokeGetWaypoint(manager, nameId); String displayName = waypoint == null ? null : extractWaypointName(waypoint); + if (displayName == null && manager != null && waypoint != null) { + displayName = lookupPublicWaypointDisplayName(manager, nameId); + } Integer waypointDomain = waypoint == null ? null : waypointCellDomain(waypoint); Integer playerDomain = journeyDomainForWorldSafe(player.getWorld()); + boolean preferApi = !"command".equalsIgnoreCase(StringUtils.trimToEmpty( + cfg.getString("journey.dispatch-command", "auto"))); String journeyRoot = cfg.getString("journey.journey-command-root", "journey"); String journeytoRoot = StringUtils.trimToEmpty(cfg.getString("journey.journeyto-command-root", "jt")); @@ -147,20 +155,22 @@ private void dispatchJourneyCommand(Player player, String rawDestination) { + player.getWorld().getName()); } - plugin.getLogger().info( - "[OverworldAgent][Journey] dispatch as player " - + player.getName() - + ": /" - + primaryCmd - + (commands.size() > 1 ? " (fallbacks: " + commands.subList(1, commands.size()) + ")" : "") - + " (nameId=" - + nameId - + ", raw=" - + rawDestination - + ", dispatch-command=" - + cfg.getString("journey.dispatch-command", "auto") - + ")"); - Runnable runDispatch = () -> { + if (!(preferApi && waypoint != null)) { + plugin.getLogger().info( + "[OverworldAgent][Journey] dispatch as player " + + player.getName() + + ": /" + + primaryCmd + + (commands.size() > 1 ? " (fallbacks: " + commands.subList(1, commands.size()) + ")" : "") + + " (nameId=" + + nameId + + ", raw=" + + rawDestination + + ", dispatch-command=" + + cfg.getString("journey.dispatch-command", "auto") + + ")"); + } + Runnable runCommandDispatch = () -> { Throwable lastError = null; for (int attempt = 0; attempt < commands.size(); attempt++) { String cmd = commands.get(attempt); @@ -211,10 +221,15 @@ private void dispatchJourneyCommand(Player player, String rawDestination) { ChatColor.RED + "Journey did not run that command. Try /" + primaryCmd + " manually."); } }; + if (preferApi && waypoint != null) { + boolean debug = cfg.getBoolean("journey.debug-log", false); + JourneyNavigationHelper.scheduleNavigation(plugin, player, nameId, plugin.getLogger(), debug, runCommandDispatch); + return; + } if (Bukkit.isPrimaryThread()) { - runDispatch.run(); + runCommandDispatch.run(); } else { - Bukkit.getScheduler().runTask(plugin, runDispatch); + Bukkit.getScheduler().runTask(plugin, runCommandDispatch); } } @@ -385,6 +400,10 @@ private static Integer waypointCellDomain(Object waypoint) { if (waypoint == null) { return null; } + Integer direct = cellDomainIndex(waypoint); + if (direct != null) { + return direct; + } Object cell = null; for (String accessor : new String[] {"location", "cell", "getLocation", "getCell"}) { try { @@ -607,10 +626,35 @@ private static Object invokeGetWaypoint(Object publicWaypointManager, String nam } } + private static String lookupPublicWaypointDisplayName(Object publicWaypointManager, String nameId) { + if (publicWaypointManager == null || StringUtils.isBlank(nameId)) { + return null; + } + try { + Object all = publicWaypointManager.getClass().getMethod("getAll").invoke(publicWaypointManager); + if (all == null) { + return null; + } + String needle = nameId.toLowerCase(Locale.ROOT); + for (Object item : flattenWaypointContainer(all)) { + String label = extractWaypointName(item); + String key = resolveNameIdForWaypoint(publicWaypointManager, item, label); + if (needle.equals(key)) { + return StringUtils.isNotBlank(label) ? label : nameId; + } + } + } catch (ReflectiveOperationException ignored) { + } + return null; + } + private static Object extractWaypointCell(Object waypoint) { if (waypoint == null) { return null; } + if (cellCoords(waypoint) != null) { + return waypoint; + } for (String accessor : new String[] {"location", "cell", "getLocation", "getCell"}) { try { Method m = waypoint.getClass().getMethod(accessor); @@ -1073,20 +1117,26 @@ private void sendDialogueMenuTail(FileConfiguration cfg, String scoreResponse, S }); //Agent Build option (templates + base feedback; merged from the old chat_type Builder menu) - String buildResponse = cfg.getString("template-gui.text.build-response", - "&f&nI want to build something!"); - sendComponent( - player, - "&8" + BULLET + buildResponse, - "&aClick here for build templates and base feedback!", - p -> openBuilderMenu() - ); + if (plugin.isBuilderEnabled()) { + String buildResponse = cfg.getString("template-gui.text.build-response", + "&f&nI want to build something!"); + sendComponent( + player, + "&8" + BULLET + buildResponse, + "&aClick here for build templates and base feedback!", + p -> openBuilderMenu() + ); + } Map edits = plugin.getAgentEdits().get(player); int skinChange = edits.get("Skin"); int nameChange = edits.get("Name"); int typeChange = edits.getOrDefault("Type", 0); - if((skinChange < AGENT_EDIT_NUM || nameChange < AGENT_EDIT_NUM || typeChange < AGENT_EDIT_NUM) && embodied){ + boolean canEditName = AgentPermissions.canEditName(player) && nameChange < AGENT_EDIT_NUM; + boolean canEditSkin = AgentPermissions.canEditSkin(player) && skinChange < AGENT_EDIT_NUM; + boolean canEditType = AgentPermissions.canEditTypeMenu(player) && typeChange < AGENT_EDIT_NUM; + if (embodied && AgentPermissions.canOpenEditMenu(player) + && (canEditName || canEditSkin || canEditType)) { //Agent edit Option sendComponent(player, "&8" + BULLET + agentEdit, "&aClick here to change me!", p -> openEditMenu()); } @@ -1107,6 +1157,9 @@ private void sendDialogueMenuTail(FileConfiguration cfg, String scoreResponse, S * reusing an in-progress builder session when one exists so template state is kept. */ private void openBuilderMenu() { + if (!plugin.isBuilderEnabled()) { + return; + } BuilderDialogue bd = plugin.getInProgressTemplates().get(player); if (bd == null) { bd = new BuilderDialogue(plugin, player, embodied); @@ -1136,13 +1189,15 @@ private void openEditMenu() { int nameChange = edits.get("Name"); int typeChange = edits.getOrDefault("Type", 0); NPC ownedForEdit = plugin.getAgents().get(player.getName()); - boolean canEditSkin = ownedForEdit != null && ownedForEdit.isSpawned() && ownedForEdit.getEntity() != null + boolean canEditSkin = AgentPermissions.canEditSkin(player) + && skinChange < AGENT_EDIT_NUM + && ownedForEdit != null && ownedForEdit.isSpawned() && ownedForEdit.getEntity() != null && ownedForEdit.getEntity().getType() == EntityType.PLAYER; this.spigotCallback.clearCallbacks(player); Utils.msgNoPrefix(player, "&lClick what you want to change:", ""); - if(skinChange < AGENT_EDIT_NUM && canEditSkin) { + if(canEditSkin) { sendComponent( player, "&8" + BULLET + " &rSkin", @@ -1186,7 +1241,7 @@ private void openEditMenu() { sendBackOption(this::openEditMenu); }); } - if(nameChange < AGENT_EDIT_NUM){ + if (AgentPermissions.canEditName(player) && nameChange < AGENT_EDIT_NUM) { sendComponent( player, "&8" + BULLET + " &rName", @@ -1234,7 +1289,7 @@ private void openEditMenu() { }) .open(player) );} - if (typeChange < AGENT_EDIT_NUM) { + if (AgentPermissions.canEditTypeMenu(player) && typeChange < AGENT_EDIT_NUM) { sendComponent( player, "&8" + BULLET + " &rEntity Type", @@ -1242,13 +1297,25 @@ private void openEditMenu() { l -> { this.spigotCallback.clearCallbacks(player); Utils.msgNoPrefix(player, "&lClick what type you want me to be:", ""); + boolean offeredType = false; for (EntityType type : AgentEntityTypes.selectableAgentTypes()) { + if (!AgentPermissions.canEditEntityType(player, type)) { + continue; + } + offeredType = true; String label = StringUtils.capitalize(type.name().toLowerCase()); sendComponent( player, "&8" + BULLET + " &r" + label, "&aClick here to become \"&r" + label + "&a\"", m -> this.plugin.getQueryer().storeNewInteraction(new Interaction(plugin, player, "Edit"), id -> { + if (!AgentPermissions.canEditEntityType(player, type)) { + AgentPermissions.deny(player, type == EntityType.PLAYER + ? AgentPermissions.EDIT_TYPE + : AgentPermissions.EDIT_TYPE_ANIMAL); + this.spigotCallback.clearCallbacks(player); + return; + } NPC npc = plugin.getAgents().get(player.getName()); if (npc == null) { player.sendMessage("You need to have an AI friend first. Please try again"); @@ -1289,6 +1356,9 @@ private void openEditMenu() { }) ); } + if (!offeredType) { + Utils.msgNoPrefix(player, "&cYou do not have permission to change entity type."); + } sendBackOption(this::openEditMenu); } ); @@ -1296,6 +1366,41 @@ private void openEditMenu() { sendBackOption(this::doDialogue); } + /** + * Resolves a PMML label to a configured prompt, then {@link #UNKNOWN_LABEL}, then a built-in fallback. + * Live servers with truncated {@code prompts} lists (missing label -2) used to NPE here during discussion. + */ + private DialoguePrompt resolvePrompt(int label) { + DialoguePrompt configured = prompts.get(label); + if (configured != null) { + return configured; + } + if (label != UNKNOWN_LABEL) { + plugin.getLogger().warning( + "[OverworldAgent] Missing prompts entry for label " + label + " in config.yml; using unknown."); + } + DialoguePrompt unknown = prompts.get(UNKNOWN_LABEL); + if (unknown != null) { + return unknown; + } + plugin.getLogger().warning( + "[OverworldAgent] Missing prompts label -2 (unknown) in config.yml; using built-in fallback."); + return builtInUnknownPrompt(); + } + + private DialoguePrompt builtInUnknownPrompt() { + if (builtInUnknownPrompt == null) { + Map entry = new HashMap<>(); + entry.put("prompt", "unknown"); + entry.put("tool", null); + entry.put( + "feedback", + "Sorry I am not sure about this. Try talking to me about something else or maybe ask an instructor about this feature." + ); + builtInUnknownPrompt = new DialoguePrompt(entry); + } + return builtInUnknownPrompt; + } private void doResponse() { DialoguePrompt prompt = null; @@ -1305,12 +1410,10 @@ private void doResponse() { int predictedClass = (int) prediction[0]; double certainty = prediction[1]; if (certainty > THRESHOLD) { - prompt = prompts.get(predictedClass); - if (prompt == null) { - prompt = prompts.get(UNKNOWN_LABEL); - feedback = prompt.getFeedback(); - } else { - feedback = prompt.getFeedback(); + DialoguePrompt matched = prompts.get(predictedClass); + prompt = matched != null ? matched : resolvePrompt(UNKNOWN_LABEL); + feedback = prompt.getFeedback(); + if (matched != null) { this.fillIn(); } if (prompt.getPrompt().equalsIgnoreCase("quest")) { @@ -1353,7 +1456,7 @@ private void doResponse() { }); } } else { - prompt = prompts.get(UNKNOWN_LABEL); + prompt = resolvePrompt(UNKNOWN_LABEL); feedback = prompt.getFeedback(); } diff --git a/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/JourneyNavigationHelper.java b/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/JourneyNavigationHelper.java new file mode 100644 index 0000000..221dee2 --- /dev/null +++ b/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/JourneyNavigationHelper.java @@ -0,0 +1,238 @@ +package edu.whimc.overworld_agent.dialoguetemplate; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.plugin.java.JavaPlugin; + +import java.lang.reflect.Array; +import java.lang.reflect.Method; +import java.util.Locale; +import java.util.UUID; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Starts Journey navigation the same way {@code /journey server waypoint} does internally. + * Uses {@code SearchManager.launchIngameSearch} with player flag preferences so trails render. + */ +public final class JourneyNavigationHelper { + + private JourneyNavigationHelper() {} + + /** + * Starts navigation on the main thread using the resolved public waypoint cell. + * + * @return true when Journey accepted the navigation request + */ + public static boolean startNavigationOnMainThread(Player player, String nameId, Logger logger, boolean debugLog) { + Object manager = journeyPublicWaypointManager(); + if (manager == null) { + return false; + } + Object destinationCell = invokeGetWaypoint(manager, nameId); + if (destinationCell == null) { + return false; + } + if (startViaLaunchIngameSearch(player, destinationCell, logger, debugLog)) { + logger.info("[OverworldAgent][Journey] navigation started via launchIngameSearch for " + + player.getName() + + " -> " + + nameId); + return true; + } + if (startViaSearchApi(player, destinationCell, logger, debugLog)) { + logger.info("[OverworldAgent][Journey] navigation started via Journey API for " + + player.getName() + + " -> " + + nameId); + return true; + } + return false; + } + + /** + * Schedules {@link #startNavigationOnMainThread} on the server main thread when needed. + */ + public static void scheduleNavigation(JavaPlugin plugin, Player player, String nameId, Logger logger, + boolean debugLog, Runnable onUnavailable) { + Runnable start = () -> { + if (!startNavigationOnMainThread(player, nameId, logger, debugLog)) { + onUnavailable.run(); + } + }; + if (Bukkit.isPrimaryThread()) { + start.run(); + } else { + Bukkit.getScheduler().runTask(plugin, start); + } + } + + /** + * Mirrors {@code JourneyExecutor.destinationSearch}: player caller, flag preferences, ingame launch. + */ + private static boolean startViaLaunchIngameSearch(Player player, Object destinationCell, Logger logger, + boolean debugLog) { + try { + Class journeyClass = Class.forName("net.whimxiqal.journey.Journey"); + Object journey = journeyClass.getMethod("get").invoke(null); + Object proxy = journey.getClass().getMethod("proxy").invoke(journey); + Object platform = proxy.getClass().getMethod("platform").invoke(proxy); + + Object journeyPlayerOpt = platform.getClass() + .getMethod("onlinePlayer", UUID.class) + .invoke(platform, player.getUniqueId()); + if (!optionalPresent(journeyPlayerOpt)) { + return false; + } + Object journeyPlayer = optionalValue(journeyPlayerOpt); + + Object startOpt = platform.getClass() + .getMethod("entityCellLocation", UUID.class) + .invoke(platform, player.getUniqueId()); + if (!optionalPresent(startOpt)) { + logger.warning("[OverworldAgent][Journey] entityCellLocation empty for " + player.getName()); + return false; + } + Object startCell = optionalValue(startOpt); + + Class journeyPlayerClass = Class.forName("net.whimxiqal.journey.JourneyPlayer"); + Class cellClass = Class.forName("net.whimxiqal.journey.Cell"); + Class sessionClass = Class.forName("net.whimxiqal.journey.search.DestinationGoalSearchSession"); + Object session = sessionClass.getConstructor( + journeyPlayerClass, cellClass, cellClass, boolean.class, boolean.class) + .newInstance(journeyPlayer, startCell, destinationCell, false, true); + + Object searchManager = journey.getClass().getMethod("searchManager").invoke(journey); + Class flagSetClass = Class.forName("net.whimxiqal.journey.search.flag.FlagSet"); + Object flagPrefs = searchManager.getClass() + .getMethod("getFlagPreferences", UUID.class, boolean.class) + .invoke(searchManager, player.getUniqueId(), false); + sessionClass.getMethod("addFlags", flagSetClass).invoke(session, flagPrefs); + + Class searchSessionClass = Class.forName("net.whimxiqal.journey.search.SearchSession"); + Object future = searchManager.getClass() + .getMethod("launchIngameSearch", searchSessionClass) + .invoke(searchManager, session); + logSearchCompletion(future, player, logger, debugLog); + return true; + } catch (Exception ex) { + if (debugLog) { + logger.log(Level.FINE, "[OverworldAgent][Journey] launchIngameSearch unavailable", ex); + } + return false; + } + } + + /** Fallback when launchIngameSearch reflection fails. */ + private static boolean startViaSearchApi(Player player, Object destinationCell, Logger logger, boolean debugLog) { + try { + Class provider = Class.forName("net.whimxiqal.journey.JourneyApiProvider"); + Object api = provider.getMethod("get").invoke(null); + Object searchApi = api.getClass().getMethod("searching").invoke(api); + Class searchFlagClass = Class.forName("net.whimxiqal.journey.search.SearchFlag"); + Object emptyFlags = Array.newInstance(searchFlagClass, 0); + Method run = null; + for (Method candidate : searchApi.getClass().getMethods()) { + if (!"runPlayerDestinationSearch".equals(candidate.getName()) || candidate.getParameterCount() != 4) { + continue; + } + Class[] params = candidate.getParameterTypes(); + if (params[0] == UUID.class && params[2] == boolean.class && params[3].isArray()) { + run = candidate; + break; + } + } + if (run == null) { + return false; + } + Object future = run.invoke(searchApi, player.getUniqueId(), destinationCell, true, emptyFlags); + logSearchCompletion(future, player, logger, debugLog); + return true; + } catch (Exception ex) { + if (debugLog) { + logger.fine("[OverworldAgent][Journey] Journey API navigation unavailable: " + ex.getMessage()); + } + return false; + } + } + + private static void logSearchCompletion(Object future, Player player, Logger logger, boolean debugLog) { + if (!(future instanceof java.util.concurrent.CompletionStage stage)) { + return; + } + stage.whenComplete((result, error) -> { + if (error != null) { + logger.log(Level.WARNING, + "[OverworldAgent][Journey] navigation error for " + player.getName(), + error); + return; + } + if (result == null) { + logger.warning("[OverworldAgent][Journey] navigation returned null for " + player.getName()); + return; + } + try { + Object state = result.getClass().getMethod("state").invoke(result); + String stateName = String.valueOf(state); + if (stateName.contains("SUCCESSFUL")) { + if (debugLog) { + logger.info("[OverworldAgent][Journey] navigation succeeded for " + player.getName()); + } + } else { + logger.warning("[OverworldAgent][Journey] navigation finished without trail for " + + player.getName() + + ": " + + stateName); + } + } catch (ReflectiveOperationException ignored) { + } + }); + } + + private static boolean optionalPresent(Object optional) { + if (optional instanceof java.util.Optional opt) { + return opt.isPresent(); + } + try { + return (boolean) optional.getClass().getMethod("isPresent").invoke(optional); + } catch (ReflectiveOperationException ex) { + return false; + } + } + + private static Object optionalValue(Object optional) { + if (optional instanceof java.util.Optional opt) { + return opt.orElse(null); + } + try { + return optional.getClass().getMethod("get").invoke(optional); + } catch (ReflectiveOperationException ex) { + return null; + } + } + + private static Object journeyPublicWaypointManager() { + try { + Class journeyClass = Class.forName("net.whimxiqal.journey.Journey"); + Object journey = journeyClass.getMethod("get").invoke(null); + if (journey == null) { + return null; + } + Object proxy = journey.getClass().getMethod("proxy").invoke(journey); + Object dataManager = proxy.getClass().getMethod("dataManager").invoke(proxy); + return dataManager.getClass().getMethod("publicWaypointManager").invoke(dataManager); + } catch (ReflectiveOperationException ignored) { + return null; + } + } + + private static Object invokeGetWaypoint(Object publicWaypointManager, String nameId) { + try { + return publicWaypointManager.getClass() + .getMethod("getWaypoint", String.class) + .invoke(publicWaypointManager, nameId.toLowerCase(Locale.ROOT)); + } catch (ReflectiveOperationException ignored) { + return null; + } + } +} diff --git a/src/main/java/edu/whimc/overworld_agent/traits/AgentFollowCatchUp.java b/src/main/java/edu/whimc/overworld_agent/traits/AgentFollowCatchUp.java index 4011d07..4dbb664 100644 --- a/src/main/java/edu/whimc/overworld_agent/traits/AgentFollowCatchUp.java +++ b/src/main/java/edu/whimc/overworld_agent/traits/AgentFollowCatchUp.java @@ -29,23 +29,118 @@ public static double besideOffset(OverworldAgent plugin) { } /** - * @return the player this agent is following, or null + * @return the player this agent is assigned to / following, or null */ public static Player followedPlayer(NPC npc) { - if (npc == null || !npc.hasTrait(FollowTrait.class)) { + if (npc == null) { return null; } - FollowTrait follow = npc.getTrait(FollowTrait.class); - if (!follow.isEnabled()) { - return null; + if (npc.hasTrait(FollowTrait.class)) { + FollowTrait follow = npc.getTrait(FollowTrait.class); + if (follow.isEnabled()) { + Entity entity = follow.getFollowing(); + if (entity instanceof Player player && player.isOnline()) { + return player; + } + } } - Entity entity = follow.getFollowing(); - if (entity instanceof Player player && player.isOnline()) { - return player; + if (npc.hasTrait(edu.whimc.overworld_agent.traits.SpawnExpertTrait.class)) { + String name = npc.getOrAddTrait(edu.whimc.overworld_agent.traits.SpawnExpertTrait.class) + .getAssignedPlayerName(); + if (name != null) { + Player player = org.bukkit.Bukkit.getPlayerExact(name); + if (player != null && player.isOnline()) { + return player; + } + } } return null; } + /** + * Spawn location for hovering mob agents: same settle point as {@link #mobFollowTarget} (front-side when idle). + */ + public static Location mobSpawnLocation(OverworldAgent plugin, Player player) { + Location spot = mobFollowTarget(plugin, player, 0.6); + return spot != null ? spot : player.getLocation(); + } + + /** Sets Y to {@code surface + hover} for a mob agent at this X/Z. */ + public static Location withMobHoverHeight(OverworldAgent plugin, Location location, double entityHeight) { + if (location == null || location.getWorld() == null) { + return location; + } + double hover = plugin.getConfig().getDouble("agent-non-player-hover-height", 2.0); + if (hover <= 0) { + return location; + } + double surfaceY = surfaceYBelow(location.getWorld(), location, entityHeight); + location.setY(surfaceY + hover); + return location; + } + + /** + * Horizontal follow point for hovering mob agents. When the owner is idle, the mob settles in + * front of their view (slightly to the side) so it stays clickable; while moving, it trails behind. + */ + public static Location mobFollowTarget(OverworldAgent plugin, Player player, double entityHeight) { + if (player == null || !player.isOnline()) { + return null; + } + double followDistance = plugin.getConfig().getDouble("agent-mob-follow-distance", 3.5); + double sideOffset = plugin.getConfig().getDouble("agent-mob-idle-side-offset", 1.5); + double idleSpeed = plugin.getConfig().getDouble("agent-mob-idle-settle-speed", 0.08); + double hover = plugin.getConfig().getDouble("agent-non-player-hover-height", 2.0); + + Location base = player.getLocation(); + Vector forward = base.getDirection(); + forward.setY(0); + if (forward.lengthSquared() < 1.0E-4) { + forward = new Vector(0, 0, 1); + } + forward.normalize(); + Vector right = new Vector(-forward.getZ(), 0, forward.getX()).normalize(); + + Location spot; + if (isPlayerHorizontallyIdle(player, idleSpeed)) { + spot = base.clone() + .add(forward.clone().multiply(followDistance)) + .add(right.clone().multiply(sideOffset)); + } else { + spot = base.clone().subtract(forward.multiply(followDistance)); + } + if (hover <= 0) { + return spot; + } + return withMobHoverHeight(plugin, spot, entityHeight); + } + + /** True when the owner is not moving horizontally (standing still, looking around). */ + private static boolean isPlayerHorizontallyIdle(Player player, double maxHorizontalSpeed) { + if (player.isGliding() || player.isRiptiding() || player.isFlying()) { + return false; + } + if (player.getVehicle() != null) { + return false; + } + Vector velocity = player.getVelocity(); + double horizontal = Math.hypot(velocity.getX(), velocity.getZ()); + return horizontal <= maxHorizontalSpeed; + } + + private static double surfaceYBelow(World world, Location feet, double entityHeight) { + double clearance = Math.max(1.0, entityHeight * 0.95); + double startY = Math.min(feet.getY() + clearance, world.getMaxHeight() - 1.0); + Location start = new Location(world, feet.getX(), startY, feet.getZ()); + double maxLen = Math.max(2.0, startY - world.getMinHeight() + 4.0); + org.bukkit.util.RayTraceResult hit = world.rayTraceBlocks( + start, new Vector(0, -1, 0), maxLen, org.bukkit.FluidCollisionMode.NEVER, true); + if (hit != null && hit.getHitBlock() != null) { + return hit.getHitBlock().getY() + 1.0; + } + return world.getHighestBlockYAt(feet) + 1.0; + } + /** * Teleport catch-up when horizontal distance exceeds the configured threshold, or when the agent * is stacked on the player (Citizens cross-world / stuck recovery). @@ -62,11 +157,27 @@ public static void applyIfNeeded(OverworldAgent plugin, NPC npc, Player player) double catchUp = catchUpDistance(plugin); double horizontal = horizontalDistance(agentLoc, playerLoc); + if (horizontal < 0.75) { + nudgeOffPlayer(plugin, npc, player); + return; + } if (horizontal > catchUp) { teleportBeside(plugin, npc, player); } } + /** Reposition mob agents that Citizens stacked on the owner's head. */ + private static void nudgeOffPlayer(OverworldAgent plugin, NPC npc, Player player) { + if (npc == null || player == null || !npc.isSpawned() || npc.getEntity() == null) { + return; + } + if (npc.getEntity().getType() == org.bukkit.entity.EntityType.PLAYER) { + return; + } + Location dest = mobSpawnLocation(plugin, player); + npc.teleport(dest, org.bukkit.event.player.PlayerTeleportEvent.TeleportCause.PLUGIN); + } + public static void teleportBeside(OverworldAgent plugin, NPC npc, Player player) { if (npc == null || player == null || !player.isOnline()) { return; @@ -75,6 +186,10 @@ public static void teleportBeside(OverworldAgent plugin, NPC npc, Player player) if (dest == null) { return; } + if (npc.isSpawned() && npc.getEntity() != null + && npc.getEntity().getType() != org.bukkit.entity.EntityType.PLAYER) { + dest = withMobHoverHeight(plugin, dest, npc.getEntity().getHeight()); + } if (!npc.isSpawned()) { npc.spawn(dest); return; diff --git a/src/main/java/edu/whimc/overworld_agent/traits/AgentFollowCatchUpTrait.java b/src/main/java/edu/whimc/overworld_agent/traits/AgentFollowCatchUpTrait.java index 3ccd1df..a4fb7f1 100644 --- a/src/main/java/edu/whimc/overworld_agent/traits/AgentFollowCatchUpTrait.java +++ b/src/main/java/edu/whimc/overworld_agent/traits/AgentFollowCatchUpTrait.java @@ -1,7 +1,6 @@ package edu.whimc.overworld_agent.traits; import edu.whimc.overworld_agent.OverworldAgent; -import net.citizensnpcs.trait.FollowTrait; import org.bukkit.entity.Player; import org.bukkit.plugin.java.JavaPlugin; @@ -29,9 +28,6 @@ public void run() { return; } tickCounter = 0; - if (!npc.hasTrait(FollowTrait.class) || !npc.getTrait(FollowTrait.class).isEnabled()) { - return; - } Player player = AgentFollowCatchUp.followedPlayer(npc); if (player == null) { return; diff --git a/src/main/java/edu/whimc/overworld_agent/traits/AgentFollowTuning.java b/src/main/java/edu/whimc/overworld_agent/traits/AgentFollowTuning.java index 26298ec..109f3a0 100644 --- a/src/main/java/edu/whimc/overworld_agent/traits/AgentFollowTuning.java +++ b/src/main/java/edu/whimc/overworld_agent/traits/AgentFollowTuning.java @@ -21,6 +21,7 @@ public final class AgentFollowTuning { private static final String CFG_MOB_MARGIN = "agent-mob-follow-margin"; private static final AgentFollowStuckAction PLAYER_STUCK_ACTION = new AgentFollowStuckAction(); + private static final AgentFollowStuckAction MOB_STUCK_ACTION = new AgentFollowStuckAction(); private AgentFollowTuning() {} @@ -36,9 +37,18 @@ public static void scheduleFollowAndApplyTraits(OverworldAgent plugin, NPC npc, if (!npc.isSpawned() || !player.isOnline()) { return; } - npc.getOrAddTrait(FollowTrait.class).follow(player); - npc.getOrAddTrait(AgentFollowCatchUpTrait.class); - npc.getOrAddTrait(AgentPermanentFlyingTrait.class).applyFlyingForCurrentEntity(); + if (npc.getEntity().getType() == EntityType.PLAYER) { + npc.getOrAddTrait(FollowTrait.class).follow(player); + npc.getOrAddTrait(AgentFollowCatchUpTrait.class); + npc.getOrAddTrait(AgentPermanentFlyingTrait.class).applyFlyingForCurrentEntity(); + } else { + // Mob agents: custom hover follow in AgentPermanentFlyingTrait (Citizens pathing fights flight). + FollowTrait follow = npc.getOrAddTrait(FollowTrait.class); + follow.follow(null); + npc.getOrAddTrait(AgentFollowCatchUpTrait.class); + npc.getOrAddTrait(AgentPermanentFlyingTrait.class).applyFlyingForCurrentEntity(); + applyForCurrentEntity(plugin, npc); + } }); } @@ -71,13 +81,16 @@ public static void applyForPlannedType(OverworldAgent plugin, NPC npc, EntityTyp npc.getNavigator().getLocalParameters().stuckAction(PLAYER_STUCK_ACTION); ft.setFollowingMargin(margin); } else { - float range = (float) plugin.getConfig().getDouble(CFG_MOB_RANGE, 5); - double margin = plugin.getConfig().getDouble(CFG_MOB_MARGIN, 1.25); + float range = (float) plugin.getConfig().getDouble(CFG_MOB_RANGE, 24); + double margin = plugin.getConfig().getDouble(CFG_MOB_MARGIN, 3.0); npc.getNavigator().getDefaultParameters().range(range); npc.getNavigator().getLocalParameters().range(range); - // Hovering mobs: switch to direct steering sooner so pathfinding does not leave them sliding in XZ at the wrong Y. - npc.getNavigator().getDefaultParameters().straightLineTargetingDistance(Math.min(range, 6.0f)); - npc.getNavigator().getLocalParameters().straightLineTargetingDistance(Math.min(range, 6.0f)); + npc.getNavigator().getDefaultParameters().destinationTeleportMargin(-1); + npc.getNavigator().getLocalParameters().destinationTeleportMargin(-1); + npc.getNavigator().getDefaultParameters().stuckAction(MOB_STUCK_ACTION); + npc.getNavigator().getLocalParameters().stuckAction(MOB_STUCK_ACTION); + npc.getNavigator().getDefaultParameters().straightLineTargetingDistance(0); + npc.getNavigator().getLocalParameters().straightLineTargetingDistance(0); ft.setFollowingMargin(margin); } } diff --git a/src/main/java/edu/whimc/overworld_agent/traits/AgentPermanentFlyingTrait.java b/src/main/java/edu/whimc/overworld_agent/traits/AgentPermanentFlyingTrait.java index 42dc430..63dc2ca 100644 --- a/src/main/java/edu/whimc/overworld_agent/traits/AgentPermanentFlyingTrait.java +++ b/src/main/java/edu/whimc/overworld_agent/traits/AgentPermanentFlyingTrait.java @@ -4,26 +4,23 @@ import net.citizensnpcs.api.trait.Trait; import net.citizensnpcs.trait.FollowTrait; import net.citizensnpcs.trait.Gravity; -import org.bukkit.FluidCollisionMode; import org.bukkit.Location; -import org.bukkit.World; import org.bukkit.entity.Entity; import org.bukkit.entity.EntityType; import org.bukkit.entity.Player; import org.bukkit.plugin.java.JavaPlugin; -import org.bukkit.util.RayTraceResult; import org.bukkit.util.Vector; /** - * Non-{@link EntityType#PLAYER} agents use no gravity and are kept a configurable height above - * the surface under them. Player-shaped agents use normal gravity and walking. + * Non-{@link EntityType#PLAYER} agents use no gravity and glide toward a hover point near their + * owner (in front when idle, behind while moving). Player-shaped agents use normal gravity and walking (unchanged). */ public class AgentPermanentFlyingTrait extends Trait { private static final String CONFIG_HOVER_KEY = "agent-non-player-hover-height"; private static final String CONFIG_SPEED_MULT_KEY = "agent-non-player-navigator-speed-modifier"; - private static final double ADJUST_EPSILON = 0.04; - private static final double MAX_VERTICAL_STEP_PER_TICK = 0.45; + private static final String CONFIG_MOB_FOLLOW_SPEED = "agent-mob-follow-speed"; + private static final String CONFIG_MOB_STOP_DISTANCE = "agent-mob-follow-stop-distance"; private final OverworldAgent plugin; @@ -57,16 +54,11 @@ public void applyFlyingForCurrentEntity() { if (npc.hasTrait(Gravity.class)) { npc.removeTrait(Gravity.class); } - // Removing the Gravity trait does not necessarily restore entity gravity (e.g. after a mob form). npc.getEntity().setGravity(true); if (npc.getEntity() instanceof Player player) { player.setFlying(false); player.setAllowFlight(false); } - // speedModifier() is a percentage (1.0 = normal walk speed). The old code passed speed() - // (absolute blocks/tick, ~0.2 for players), which made player agents crawl-glide at ~20% - // speed instead of walking. Set on default params too: locals are recreated from defaults - // every time FollowTrait re-targets, so local-only values are lost between path updates. npc.getNavigator().getDefaultParameters().speedModifier(1.0F); npc.getNavigator().getLocalParameters().speedModifier(1.0F); if (npc.hasTrait(FollowTrait.class)) { @@ -77,8 +69,8 @@ public void applyFlyingForCurrentEntity() { Gravity gravity = npc.getOrAddTrait(Gravity.class); gravity.setHasGravity(false); - npc.setFlyable(true); + npc.getEntity().setGravity(false); double mult = plugin.getConfig().getDouble(CONFIG_SPEED_MULT_KEY, 1.65); float modifier = mult > 0 ? (float) mult : 1.0F; @@ -86,8 +78,9 @@ public void applyFlyingForCurrentEntity() { npc.getNavigator().getLocalParameters().speedModifier(modifier); if (npc.hasTrait(FollowTrait.class)) { - AgentFollowTuning.applyForCurrentEntity(plugin, npc); + npc.getTrait(FollowTrait.class).follow(null); } + AgentFollowTuning.applyForCurrentEntity(plugin, npc); } @Override @@ -99,49 +92,41 @@ public void run() { if (entity.getType() == EntityType.PLAYER) { return; } - double hover = plugin.getConfig().getDouble(CONFIG_HOVER_KEY, 2.0); - if (hover <= 0) { - return; - } - Location cur = entity.getLocation(); - World world = cur.getWorld(); - if (world == null) { + Player owner = AgentFollowCatchUp.followedPlayer(npc); + if (owner == null || !owner.isOnline()) { + entity.setVelocity(new Vector(0, 0, 0)); return; } - - double surfaceY = surfaceYBelow(world, cur, entity.getHeight()); - double targetY = surfaceY + hover; - if (Math.abs(cur.getY() - targetY) < ADJUST_EPSILON) { + if (!owner.getWorld().equals(entity.getWorld())) { return; } - double dy = targetY - cur.getY(); - // While Citizens is actively pathfinding, do not teleport — hover fights horizontal navigation. - // FollowTrait can be "active" between path updates; allow vertical correction then so hover height catches up. if (npc.getNavigator().isNavigating()) { + npc.getNavigator().cancelNavigation(); + } + + double hover = plugin.getConfig().getDouble(CONFIG_HOVER_KEY, 2.0); + if (hover <= 0) { return; } - double newY = cur.getY() + Math.signum(dy) * Math.min(MAX_VERTICAL_STEP_PER_TICK, Math.abs(dy)); - Location to = cur.clone(); - to.setY(newY); - entity.teleport(to); - } + Location target = AgentFollowCatchUp.mobFollowTarget(plugin, owner, entity.getHeight()); + if (target == null) { + return; + } - /** - * Top Y of the walkable surface below the agent (feet height), using a short downward ray - * from above the mob so caves and porches resolve to local floor, not the skylight map. - */ - private static double surfaceYBelow(World world, Location feet, double entityHeight) { - double clearance = Math.max(1.0, entityHeight * 0.95); - double startY = Math.min(feet.getY() + clearance, world.getMaxHeight() - 1.0); - Location start = new Location(world, feet.getX(), startY, feet.getZ()); - double maxLen = Math.max(2.0, startY - world.getMinHeight() + 4.0); - RayTraceResult hit = world.rayTraceBlocks(start, new Vector(0, -1, 0), maxLen, FluidCollisionMode.NEVER, true); - if (hit != null && hit.getHitBlock() != null) { - return hit.getHitBlock().getY() + 1.0; + Location current = entity.getLocation(); + Vector delta = target.toVector().subtract(current.toVector()); + double distance = delta.length(); + double stopDistance = plugin.getConfig().getDouble(CONFIG_MOB_STOP_DISTANCE, 0.35); + if (distance <= stopDistance) { + entity.setVelocity(new Vector(0, 0, 0)); + return; } - return world.getHighestBlockYAt(feet) + 1.0; + + double speed = plugin.getConfig().getDouble(CONFIG_MOB_FOLLOW_SPEED, 0.32); + Vector velocity = delta.normalize().multiply(Math.min(speed, distance)); + entity.setVelocity(velocity); } } diff --git a/src/main/java/edu/whimc/overworld_agent/traits/RebuilderTrait.java b/src/main/java/edu/whimc/overworld_agent/traits/RebuilderTrait.java index 7e47275..38a787f 100644 --- a/src/main/java/edu/whimc/overworld_agent/traits/RebuilderTrait.java +++ b/src/main/java/edu/whimc/overworld_agent/traits/RebuilderTrait.java @@ -73,6 +73,11 @@ public void click(net.citizensnpcs.api.event.NPCRightClickEvent event) { Player sender = event.getClicker(); if(sender == Bukkit.getPlayer(target)) { if (event.getNPC() == this.getNPC()) { + if (!plugin.isBuilderEnabled()) { + Dialogue dialogue = new Dialogue(plugin, sender, true, true); + dialogue.doDialogue(); + return; + } if (plugin.getInProgressTemplates().containsKey(sender)) { BuilderDialogue bd = plugin.getInProgressTemplates().get(sender); bd.doDialogue(); diff --git a/src/main/java/edu/whimc/overworld_agent/utils/AgentEntityTypes.java b/src/main/java/edu/whimc/overworld_agent/utils/AgentEntityTypes.java index ec5818a..3d18c11 100644 --- a/src/main/java/edu/whimc/overworld_agent/utils/AgentEntityTypes.java +++ b/src/main/java/edu/whimc/overworld_agent/utils/AgentEntityTypes.java @@ -1,25 +1,23 @@ package edu.whimc.overworld_agent.utils; +import org.bukkit.configuration.file.FileConfiguration; import org.bukkit.entity.EntityType; +import org.bukkit.plugin.java.JavaPlugin; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Locale; +import java.util.logging.Logger; import java.util.stream.Collectors; /** - * Allowed expert-agent mob types: {@link EntityType#PLAYER} plus a fixed whitelist (see - * {@link #ALLOWED_MOB_IDS}). Types are resolved with {@link EntityType#valueOf(String)} so builds against an - * older {@code paper-api} still compile; at runtime, constants missing on that server version are skipped. + * Allowed expert-agent mob types: {@link EntityType#PLAYER} plus entries from {@code agent-mob-types} in config.yml. + * Types are resolved with {@link EntityType#valueOf(String)}; constants missing on the runtime server are skipped. */ public final class AgentEntityTypes { - /** - * Lowercase {@link EntityType} names in menu / tab order. Resolved on first use; - * entries not present on the runtime server are omitted. - */ - public static final String[] ALLOWED_MOB_IDS = { + private static final List DEFAULT_MOB_IDS = List.of( "AXOLOTL", "OCELOT", "TURTLE", @@ -30,51 +28,73 @@ public final class AgentEntityTypes { "NAUTILUS", "HAPPY_GHAST", "BEE", - "PARROT", - }; + "PARROT" + ); - private static List cachedAllowedMobs; + private static List allowedMobs = List.of(); private AgentEntityTypes() {} - private static List allowedMobs() { - if (cachedAllowedMobs == null) { - synchronized (AgentEntityTypes.class) { - if (cachedAllowedMobs == null) { - List list = new ArrayList<>(); - for (String id : ALLOWED_MOB_IDS) { - try { - list.add(EntityType.valueOf(id)); - } catch (IllegalArgumentException ignored) { - // Not present in this server's EntityType enum (older game/API). - } - } - cachedAllowedMobs = Collections.unmodifiableList(list); + /** Reads {@code agent-mob-types} from the plugin config. Call on enable (and after config reload). */ + public static void load(JavaPlugin plugin) { + FileConfiguration config = plugin.getConfig(); + List raw = config.getStringList("agent-mob-types"); + if (raw == null || raw.isEmpty()) { + raw = DEFAULT_MOB_IDS; + } + allowedMobs = Collections.unmodifiableList(resolveMobTypes(raw, plugin.getLogger(), true)); + } + + private static List resolveMobTypes(List raw, Logger logger, boolean allowFallback) { + List list = new ArrayList<>(); + for (String id : raw) { + if (id == null || id.isBlank()) { + continue; + } + String normalized = id.trim().toUpperCase(Locale.ROOT); + if ("PLAYER".equals(normalized)) { + logger.warning("agent-mob-types: PLAYER is always available and should not be listed; skipping."); + continue; + } + try { + EntityType type = EntityType.valueOf(normalized); + if (type.getEntityClass() == null || !type.isAlive()) { + logger.warning("agent-mob-types: " + id + " is not a living mob, skipping."); + continue; } + if (!list.contains(type)) { + list.add(type); + } + } catch (IllegalArgumentException ex) { + logger.warning("agent-mob-types: unknown EntityType '" + id + "', skipping."); } } - return cachedAllowedMobs; + if (list.isEmpty() && allowFallback) { + logger.warning("agent-mob-types: no valid mob types configured; using built-in defaults."); + return resolveMobTypes(DEFAULT_MOB_IDS, logger, false); + } + return list; } /** - * Player first, then allowed mob types in {@link #ALLOWED_MOB_IDS} order (minus any absent at runtime). + * Player first, then allowed mob types in config order (minus any absent at runtime). */ public static List selectableAgentTypes() { List out = new ArrayList<>(); out.add(EntityType.PLAYER); - out.addAll(allowedMobs()); + out.addAll(allowedMobs); return out; } - /** Lowercase {@link EntityType} names for tab-complete, in {@link #ALLOWED_MOB_IDS} order (minus absent-at-runtime types). */ + /** Lowercase {@link EntityType} names for tab-complete, in config order. */ public static List animalNamesLowercaseSorted() { - return allowedMobs().stream() + return allowedMobs.stream() .map(t -> t.name().toLowerCase(Locale.ROOT)) .collect(Collectors.toList()); } /** Whether {@code type} is a non-player mob allowed as an embodied agent (spawn + edit menu). */ public static boolean isAllowedNonPlayerAgent(EntityType type) { - return type != null && type != EntityType.PLAYER && allowedMobs().contains(type); + return type != null && type != EntityType.PLAYER && allowedMobs.contains(type); } } diff --git a/src/main/java/edu/whimc/overworld_agent/utils/AgentPermissions.java b/src/main/java/edu/whimc/overworld_agent/utils/AgentPermissions.java new file mode 100644 index 0000000..a49e795 --- /dev/null +++ b/src/main/java/edu/whimc/overworld_agent/utils/AgentPermissions.java @@ -0,0 +1,197 @@ +package edu.whimc.overworld_agent.utils; + +import edu.whimc.overworld_agent.OverworldAgent; +import net.citizensnpcs.api.npc.NPC; +import org.bukkit.Bukkit; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.Player; +import org.bukkit.permissions.Permission; +import org.bukkit.permissions.PermissionDefault; + +/** + * LuckPerms-friendly permission nodes for agent spawn, edit (dialogue menu), despawn, and destroy. + * Register once on plugin enable via {@link #register()}. + */ +public final class AgentPermissions { + + public static final String SPAWN = node("spawn"); + public static final String SPAWN_PLAYER = node("spawn.player"); + public static final String SPAWN_ANIMAL = node("spawn.animal"); + + public static final String DESPAWN = node("despawn"); + public static final String DESPAWN_SELF = node("despawn.self"); + public static final String DESPAWN_OTHER = node("despawn.other"); + public static final String DESPAWN_ALL = node("despawn.all"); + + public static final String DESTROY = node("destroy"); + public static final String DESTROY_SELF = node("destroy.self"); + public static final String DESTROY_OTHER = node("destroy.other"); + public static final String DESTROY_ALL = node("destroy.all"); + + public static final String EDIT = node("edit"); + public static final String EDIT_NAME = node("edit.name"); + public static final String EDIT_SKIN = node("edit.skin"); + public static final String EDIT_TYPE = node("edit.type"); + public static final String EDIT_TYPE_ANIMAL = node("edit.type.animal"); + + private AgentPermissions() {} + + private static String node(String suffix) { + return OverworldAgent.PERM_PREFIX + ".agent." + suffix; + } + + public static void register() { + Permission agentWildcard = ensurePermission( + OverworldAgent.PERM_PREFIX + ".agent.*", + "All /agent subcommand permissions", + PermissionDefault.FALSE, + OverworldAgent.PERM_PREFIX + ".*" + ); + + childOnly("spawn.player", "Spawn player-model agents", PermissionDefault.TRUE, SPAWN); + childOnly("spawn.animal", "Spawn animal mob agents", PermissionDefault.FALSE, SPAWN); + + childOnly("despawn.self", "Despawn your own agent", PermissionDefault.FALSE, DESPAWN); + childOnly("despawn.other", "Despawn another player's agent", PermissionDefault.FALSE, DESPAWN); + childOnly("despawn.all", "Despawn every online player's agent", PermissionDefault.FALSE, DESPAWN); + + childOnly("destroy.self", "Destroy your own agent", PermissionDefault.FALSE, DESTROY); + childOnly("destroy.other", "Destroy another player's agent", PermissionDefault.FALSE, DESTROY); + childOnly("destroy.all", "Destroy every agent", PermissionDefault.FALSE, DESTROY); + + ensurePermission(EDIT, "Agent edit menu (right-click dialogue)", PermissionDefault.FALSE, agentWildcard.getName()); + childOnly("edit.name", "Change your agent's name", PermissionDefault.FALSE, EDIT); + childOnly("edit.skin", "Change your agent's skin", PermissionDefault.FALSE, EDIT); + childOnly("edit.type", "Change your agent's entity type", PermissionDefault.FALSE, EDIT); + childOnly("edit.type.animal", "Change your agent to an animal mob type", PermissionDefault.FALSE, EDIT_TYPE); + } + + private static void childOnly(String suffix, String description, PermissionDefault def, String parentName) { + ensurePermission(node(suffix), description, def, parentName); + } + + private static Permission ensurePermission( + String name, + String description, + PermissionDefault def, + String parentName + ) { + Permission existing = Bukkit.getPluginManager().getPermission(name); + if (existing != null) { + return existing; + } + Permission perm = new Permission(name, description, def); + perm.addParent(parentName, true); + Bukkit.getPluginManager().addPermission(perm); + return perm; + } + + /** Server operators bypass all granular LuckPerms checks for agent commands. */ + public static boolean bypassesRestrictions(CommandSender sender) { + return sender != null && sender.isOp(); + } + + private static boolean has(CommandSender sender, String permission) { + return bypassesRestrictions(sender) || sender.hasPermission(permission); + } + + public static boolean canSpawnPlayer(CommandSender sender) { + return has(sender, SPAWN_PLAYER); + } + + public static boolean canSpawnAnimal(CommandSender sender) { + return has(sender, SPAWN_ANIMAL); + } + + public static boolean canSpawnEntityType(CommandSender sender, EntityType type) { + if (type == null || type == EntityType.PLAYER) { + return canSpawnPlayer(sender); + } + return canSpawnAnimal(sender); + } + + public static boolean canDespawnSelf(CommandSender sender) { + return has(sender, DESPAWN_SELF); + } + + public static boolean canDespawnOther(CommandSender sender) { + return has(sender, DESPAWN_OTHER); + } + + public static boolean canDespawnAll(CommandSender sender) { + return has(sender, DESPAWN_ALL); + } + + public static boolean canUseDespawn(CommandSender sender) { + return canDespawnSelf(sender) || canDespawnOther(sender) || canDespawnAll(sender); + } + + public static boolean canDestroySelf(CommandSender sender) { + return has(sender, DESTROY_SELF); + } + + public static boolean canDestroyOther(CommandSender sender) { + return has(sender, DESTROY_OTHER); + } + + public static boolean canDestroyAll(CommandSender sender) { + return has(sender, DESTROY_ALL); + } + + public static boolean canUseDestroy(CommandSender sender) { + return canDestroySelf(sender) || canDestroyOther(sender) || canDestroyAll(sender); + } + + public static boolean canEditName(Player player) { + return has(player, EDIT_NAME); + } + + public static boolean canEditSkin(Player player) { + return has(player, EDIT_SKIN); + } + + public static boolean canEditTypeMenu(Player player) { + return has(player, EDIT_TYPE); + } + + public static boolean canEditEntityType(Player player, EntityType targetType) { + if (!canEditTypeMenu(player)) { + return false; + } + if (targetType != null && targetType != EntityType.PLAYER) { + return has(player, EDIT_TYPE_ANIMAL); + } + return true; + } + + public static boolean canOpenEditMenu(Player player) { + return bypassesRestrictions(player) + || canEditName(player) + || canEditSkin(player) + || canEditTypeMenu(player); + } + + public static boolean canManageAgent(CommandSender sender, String ownerName, boolean allTargets) { + if (allTargets) { + return false; + } + if (sender instanceof Player player && player.getName().equalsIgnoreCase(ownerName)) { + return true; + } + return false; + } + + public static String ownerName(NPC npc) { + if (npc == null || !npc.hasTrait(edu.whimc.overworld_agent.traits.SpawnExpertTrait.class)) { + return null; + } + return npc.getOrAddTrait(edu.whimc.overworld_agent.traits.SpawnExpertTrait.class).getAssignedPlayerName(); + } + + public static void deny(CommandSender sender, String permission) { + Utils.msg(sender, + "&cYou do not have the required permission!", + " &f&o" + permission); + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 172f498..749fbc7 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -9,7 +9,8 @@ journey: journey-command-root: journey # Alias for /journeyto (default jt). Used when dispatch-command is journeyto_* or auto for npc-/poi- waypoints. journeyto-command-root: jt - # auto = /journey server waypoint (avoids /jt scope merge). Do NOT use journeyto_* if Journey logs Duplicate key. + # auto = launchIngameSearch via Journey internals (same as /journey server waypoint); falls back to API then command. + # command = always use /journey server waypoint (requires QRF-Journey with '_' in identifier grammar). dispatch-command: auto # When true, logs Journey domain / public waypoint counts when opening the guidance menu (console). debug-log: false @@ -26,9 +27,27 @@ journey: worldguard-table-prefix: rg_ agent_type: scientist_casual +# Builder templates, rebuild NPC, and "I want to build something!" dialogue option. +builder: + enabled: false + # Player spawn: /agent spawn player --url (uses Citizens/Mineskin, like /npc skin --url) agent-spawn: allow-url-skins: true +# Non-player entity types allowed for mob agents (spawn + edit menu). PLAYER is always available and must not be listed here. +# Use Bukkit EntityType enum names (e.g. AXOLOTL, PIG). Unknown or invalid entries are skipped at load with a warning. +agent-mob-types: + - AXOLOTL + - OCELOT + - TURTLE + - SHEEP + - PIG + - STRIDER + - SNIFFER + - NAUTILUS + - HAPPY_GHAST + - BEE + - PARROT # Blocks above ground for non-player agent mobs (no gravity + height lock). Player-shaped agents ignore this. Use 0 to disable vertical tracking (only no-gravity). agent-non-player-hover-height: 2.0 # Multiplier applied to Citizens navigator speed for non-player agents (1.0 = default mob follow speed). Higher = faster when hovering/following. @@ -45,9 +64,19 @@ agent-player-nav-stationary-ticks: 1200 agent-follow-catch-up-distance: 16.0 # Blocks to the side of the player when catch-up teleporting. agent-follow-catch-up-offset: 1.5 -# Mob agents (hovering): tighter path range and margin so they keep up while floating. -agent-mob-follow-path-range: 5 -agent-mob-follow-margin: 1.25 +# Mob agents (hovering): preferred horizontal distance behind the player while moving. +agent-mob-follow-distance: 3.5 +# When the owner is stopped, mob agents settle this far in front of their view (plus side offset). +agent-mob-idle-side-offset: 1.5 +# Max horizontal velocity (blocks/tick) to treat the owner as idle for mob settle positioning. +agent-mob-idle-settle-speed: 0.08 +# Blocks per tick for mob glide speed (Paper velocity cap applies). +agent-mob-follow-speed: 0.32 +# Stop gliding when within this distance of the follow target. +agent-mob-follow-stop-distance: 0.35 +# Mob agents (hovering): navigator safety settings (movement is handled by AgentPermanentFlyingTrait). +agent-mob-follow-path-range: 24 +agent-mob-follow-margin: 3.0 # LLM chat: provider + API key (or api-key-env). Requires outbound HTTPS for cloud providers; local uses llm.base-url. llm: use-for-reply: false