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