diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..31dffcf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [animal-ai, llm-agent] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + cache: maven + + - name: Build plugin JAR + run: mvn -B -ntp package diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..28de177 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,32 @@ +name: CodeQL + +on: + pull_request: + branches: [main] + push: + branches: [main] + +permissions: + contents: read + security-events: write + +jobs: + analyze: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + cache: maven + + - uses: github/codeql-action/init@v3 + with: + languages: java + + - name: Build for CodeQL + run: mvn -B -ntp package + + - uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/dependency-audit.yml b/.github/workflows/dependency-audit.yml new file mode 100644 index 0000000..348a306 --- /dev/null +++ b/.github/workflows/dependency-audit.yml @@ -0,0 +1,26 @@ +name: Dependency Audit + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + dependency-audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + cache: maven + + - name: Verify dependency resolution + run: mvn -B -ntp dependency:tree -Dverbose=false + + - name: Check for undeclared or unused dependencies + run: mvn -B -ntp dependency:analyze-only -DignoreNonCompile=true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b198b1e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,74 @@ +# Publishes a downloadable JAR to GitHub Releases only. +# Minecraft server install is manual — no deploy steps or secrets. +name: Release + +on: + push: + branches: [main, animal-ai] + +concurrency: + group: release-${{ github.ref_name }} + cancel-in-progress: false + +permissions: + contents: write + pull-requests: write + +jobs: + release: + # Skip version-bump commits and merges of version-bump PRs to avoid release loops. + if: ${{ !contains(github.event.head_commit.message, 'chore(release):') && !contains(github.event.head_commit.message, 'release/bump-') }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + cache: maven + + - name: Bump patch version in pom.xml + id: version + 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))" + echo "current=${CURRENT}" >> "$GITHUB_OUTPUT" + echo "new=${NEW}" >> "$GITHUB_OUTPUT" + mvn -B -ntp org.codehaus.mojo:versions-maven-plugin:2.17.1:set \ + -DnewVersion="${NEW}" \ + -DgenerateBackupPom=false + + - name: Build release JAR + run: mvn -B -ntp package + + - name: Publish GitHub Release + env: + NEW_VERSION: ${{ steps.version.outputs.new }} + GH_TOKEN: ${{ github.token }} + run: | + JAR="target/WHIMC-QRF-Agent-${NEW_VERSION}.jar" + test -f "${JAR}" + + gh release create "v${NEW_VERSION}" \ + "${JAR}" \ + --title "v${NEW_VERSION}" \ + --target "${GITHUB_SHA}" \ + --generate-notes + + - name: Open version bump pull request + uses: peter-evans/create-pull-request@v7 + with: + branch: release/bump-${{ steps.version.outputs.new }} + delete-branch: true + title: "chore(release): bump version to ${{ steps.version.outputs.new }}" + commit-message: "chore(release): bump version to ${{ steps.version.outputs.new }}" + body: | + Automated `pom.xml` version bump after publishing [v${{ steps.version.outputs.new }}](https://github.com/${{ github.repository }}/releases/tag/v${{ steps.version.outputs.new }}). + + Download the JAR from the GitHub Release and install it on the Minecraft server manually. + labels: release + add-paths: pom.xml diff --git a/.github/workflows/trufflehog.yml b/.github/workflows/trufflehog.yml new file mode 100644 index 0000000..3582a1b --- /dev/null +++ b/.github/workflows/trufflehog.yml @@ -0,0 +1,20 @@ +name: TruffleHog + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + trufflehog: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: trufflesecurity/trufflehog@main + with: + extra_args: --only-verified diff --git a/README.md b/README.md index 14b0c0d..85da51d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# QRF-Agent +# QRF-Agent QRF-Agent is a Minecraft plugin to create and define agent behavior (forked from [Overworld-Agent](https://github.com/whimc/Overworld-Agent) `animal-ai`). To teleport to the agent room for the guide agent for exploration use `/destination teleport AIchoice`. To select the agent right click on the desired agent and to start a conversation with your agent also right click on them. @@ -8,13 +8,13 @@ Alternatively you can spawn a guide agent with **`/agents spawn`** or **`/agent **Spawn syntax** -- **Player agent:** `/agents spawn player ` — first tab-completion token is `player`, second is a skin key from `skins.` in `config.yml`, then the display name (spaces allowed in the name). -- **Animal agent:** `/agents spawn ` — `` is one of the **fixed** mob IDs allowed by `AgentEntityTypes` (see that class / tab-complete: e.g. `axolotl`, `ocelot`, `turtle`, `sheep`, `pig`, `strider`, `sniffer`, `nautilus`, `happy_ghast`, `bee`, `parrot`; types not present on your game version are omitted at runtime). No skin argument. -- **Legacy:** `/agents spawn ` — if the first token is not a valid entity type, it is treated as a **player** skin key (same as omitting `player`). +- **Player agent:** `/agents spawn player ` — first tab-completion token is `player`, second is a skin key from `skins.` in `config.yml`, then the display name (spaces allowed in the name). +- **Animal agent:** `/agents spawn ` — `` is one of the **fixed** mob IDs allowed by `AgentEntityTypes` (see that class / tab-complete: e.g. `axolotl`, `ocelot`, `turtle`, `sheep`, `pig`, `strider`, `sniffer`, `nautilus`, `happy_ghast`, `bee`, `parrot`; types not present on your game version are omitted at runtime). No skin argument. +- **Legacy:** `/agents spawn ` — if the first token is not a valid entity type, it is treated as a **player** skin key (same as omitting `player`). Tab-complete the first argument to see every allowed value on your server version. -To spawn a builder agent use **`/agents rebuilderspawn`** and interact with it like a guide agent. +Builder functions (build templates, demo builds, base feedback) no longer require a separate mode: they live in **every agent's dialogue menu** under **"I want to build something!"**. A dedicated builder NPC can still be spawned with **`/agents rebuilderspawn`** and interacted with like a guide agent. _**Requires Java 21+**_ @@ -56,12 +56,12 @@ Free-text player lines (e.g. **Discuss something** / chat input) are **not** han |--------|------| | **`src/main/resources/model.pmml`** | Shipped inside the plugin JAR. A **PMML** model evaluated at runtime with **PMML4s** (`Chatbot#classifyDialogueIntent()`). | | **Output** | An integer **label** (class index) plus a **confidence** score in \([0, 1]\). | -| **Decision** | `Dialogue#doResponse()` compares confidence to an internal threshold (**0.5**). Above threshold → use that label’s row from **`prompts`** in `config.yml`; otherwise → **unknown** prompt (`label: -2`). | +| **Decision** | `Dialogue#doResponse()` compares confidence to an internal threshold (**0.5**). Above threshold → use that label’s row from **`prompts`** in `config.yml`; otherwise → **unknown** prompt (`label: -2`). | | **Reply text** | Each prompt row supplies **`feedback`** strings. Placeholders such as `{NAME}`, `{PLANET}`, `{AGENT}` are filled from Bukkit/Citizens (`Dialogue#fillIn()`). | -So answers like “what’s your name?” work because the model maps the sentence to a label (e.g. **agent** / `label: 0`) whose feedback template includes something like `My name is {AGENT}.` That is **intent routing + templates**, not open-ended generation. +So answers like “what’s your name?” work because the model maps the sentence to a label (e.g. **agent** / `label: 0`) whose feedback template includes something like `My name is {AGENT}.` That is **intent routing + templates**, not open-ended generation. -Replacing or retraining the classifier means supplying a new **`model.pmml`** (and keeping **`prompts` labels** aligned with the model’s output classes). The PMML file is large and is treated as an **opaque artifact** in this repo. +Replacing or retraining the classifier means supplying a new **`model.pmml`** (and keeping **`prompts` labels** aligned with the model’s output classes). The PMML file is large and is treated as an **opaque artifact** in this repo. ### LLM chatbot (optional) @@ -71,12 +71,12 @@ Player dialogue can be answered by an **internet-hosted** model (**OpenAI** or * | Value | Use case | Credentials | Default model if `llm.model` empty | |-------|------------|-------------|-------------------------------------| -| `none` | Disable built-in HTTP LLM | — | — | +| `none` | Disable built-in HTTP LLM | — | — | | `openai` | [OpenAI Chat Completions](https://platform.openai.com/docs/api-reference/chat) | **Required:** API key | `gpt-4o-mini` | | `gemini` | [Google AI Gemini](https://ai.google.dev/) generateContent | **Required:** API key | `gemini-1.5-flash` | | `openai_compatible` | Local or self-hosted `/v1/chat/completions` | Optional API key (many local servers use none) | `llama3.2` | -For **local** inference, set `provider: openai_compatible` and `base-url` to your server’s OpenAI-compatible root (must end up posting to `…/v1/chat/completions` — the plugin normalizes a base such as `http://127.0.0.1:11434/v1`). Example: **Ollama** default `http://127.0.0.1:11434/v1`. +For **local** inference, set `provider: openai_compatible` and `base-url` to your server’s OpenAI-compatible root (must end up posting to `…/v1/chat/completions` — the plugin normalizes a base such as `http://127.0.0.1:11434/v1`). Example: **Ollama** default `http://127.0.0.1:11434/v1`. **Networking:** Cloud providers need outbound **HTTPS** from the Paper host. Local providers only need **localhost** (or your LAN URL) reachable from the JVM running the server. @@ -97,13 +97,13 @@ RAG (retrieval-augmented generation) here means: **optional** inclusion of plain | Key | Description | |-----|-------------| -| `llm.context-directory` | Subfolder name under the plugin **data folder** (default `llm-context`). Created on enable when possible. Full path: `plugins/WHIMC-OverworldAgent/llm-context` (artifact id may differ). | +| `llm.context-directory` | Subfolder name under the plugin **data folder** (default `llm-context`). Created on enable when possible. Full path: `plugins/WHIMC-QRF-Agent/llm-context`. | | `llm.rag.enabled` | When `true`, scans that directory and appends bounded excerpts to the system prompt before each completion. | | `llm.rag.max-total-chars` / `max-file-chars` | Cap total and per-file bytes so prompts stay reasonable. | | `llm.rag.max-directory-depth` | How deep to walk subfolders. | | `llm.rag.include-extensions` | File extensions to read (default `txt`, `md`). | -Put glossaries, world lore, or lesson snippets as `.md`/`.txt` files there. This is **not** a vector database or hybrid search—only a simple file concat for small corpora; you can replace the flow later with a custom `LlmProvider` that does real retrieval. +Put glossaries, world lore, or lesson snippets as `.md`/`.txt` files there. This is **not** a vector database or hybrid search—only a simple file concat for small corpora; you can replace the flow later with a custom `LlmProvider` that does real retrieval. #### Nearby NPC context (`llm.npc-context`) @@ -163,19 +163,19 @@ llm: #### `LlmProvider` interface -- **`boolean isConfigured()`** — Built-in providers return `true` only when required fields (e.g. API key + model) are set. -- **`String complete(String systemPrompt, String userMessage)`** — Plain-text reply; runs off the main thread. +- **`boolean isConfigured()`** — Built-in providers return `true` only when required fields (e.g. API key + model) are set. +- **`String complete(String systemPrompt, String userMessage)`** — Plain-text reply; runs off the main thread. You can still **override** the auto-selected provider after load: ```java -OverworldAgent oa = (OverworldAgent) Bukkit.getPluginManager().getPlugin("WHIMC-OverworldAgent"); +OverworldAgent oa = (OverworldAgent) Bukkit.getPluginManager().getPlugin("WHIMC-QRF-Agent"); if (oa != null) { oa.setLlmProvider(new YourLlmProvider(/* ... */)); } ``` -Use `depend` / `softdepend` / load order so your code runs after `WHIMC-OverworldAgent` enables. +Use `depend` / `softdepend` / load order so your code runs after `WHIMC-QRF-Agent` enables. #### Behavior summary @@ -187,21 +187,21 @@ Use `depend` / `softdepend` / load order so your code runs after `WHIMC-Overworl ### Interactive LLM chat (`/agent chat test`) -Separate from embodied right-click dialogue and from `llm.use-for-reply` on the **Discuss something** flow. This mode starts a **multi-turn chat session** that listens to the player’s **public chat** (`T`), calls the configured `LlmProvider`, and logs research data to MySQL. +Separate from embodied right-click dialogue and from `llm.use-for-reply` on the **Discuss something** flow. This mode starts a **multi-turn chat session** that listens to the player’s **public chat** (`T`), calls the configured `LlmProvider`, and logs research data to MySQL. | Command | Permission | Description | |---------|------------|-------------| | `/agent chat test` | `whimc-agent.agent.chat` | Start interactive LLM chat (requires a configured provider; independent of `llm.use-for-reply`). | | `/agent chat end` | `whimc-agent.agent.chat` | End the session. | -| `/agent chat` | `whimc-agent.agent.chat` | Opens the classic **disembodied** Guide/Builder menu (PMML + optional `use-for-reply`). | +| `/agent chat` | `whimc-agent.agent.chat` | Opens the **disembodied dialogue menu** (guidance, scores, discussion, build, edit; PMML + optional `use-for-reply`). | **In-session behavior** 1. Player runs `/agent chat test`. -2. Each chat line is intercepted (public chat is cancelled; the player sees a private `You: …` echo). +2. Each chat line is intercepted (public chat is cancelled; the player sees a private `You: …` echo). 3. The plugin builds a system prompt from `llm.system-prompt`, optional **RAG** (`llm.rag`), and optional **nearby NPC context** (`llm.npc-context`). 4. Up to **10** prior user/assistant lines in the session are prepended to the user message for short-term memory. -5. The LLM runs **async**; the player sees `Thinking…` then the assistant reply. +5. The LLM runs **async**; the player sees `Thinking…` then the assistant reply. 6. Type **`exit`**, **`quit`**, **`stop`**, or run `/agent chat end` to leave the mode. **Requirements:** MySQL configured and reachable (schema migration **8** creates chat research tables). Provider must be configured (`llm.provider` + key/model or `base-url` for local). @@ -236,22 +236,21 @@ llm: max-items: 3 ``` -Then in-game: `/agent chat test` → type messages in chat → `/agent chat end` when finished. +Then in-game: `/agent chat test` → type messages in chat → `/agent chat end` when finished. --- ## Commands -Permissions follow **`whimc-agent..`** (each `/agents …` subcommand registers its own node). The shared **guide spawn** handler is registered as **`whimc-agent.agents.spawn`** even when invoked as **`/agent spawn`**. +Permissions follow **`whimc-agent..`** (each `/agents …` subcommand registers its own node). The shared **guide spawn** handler is registered as **`whimc-agent.agents.spawn`** even when invoked as **`/agent spawn`**. ### Root commands (`plugin.yml`) | Command | Typical use | |---------|--------------------------------------------------------------------------------------------------| -| **`/agents`** | Admin / spawn / builder — requires a subcommand (see below). | +| **`/agents`** | Admin / spawn / builder — requires a subcommand (see below). | | **`/agent`** | Player **`chat`** or **`spawn`** (same spawn behavior as `/agents spawn`). | -| **`/admintags`** | Manage dialogue tags (`TagAdminCommand`). | | **`/assess-habitat`** | Habitat assessment command. Only works with ML-API and routing pythong script on server running. | -| **`/oacallback`** | **Internal** — clickable chat UI callbacks; not for players to run manually. | +| **`/oacallback`** | **Internal** — clickable chat UI callbacks; not for players to run manually. | ### `/agents` subcommands (current code) @@ -261,22 +260,23 @@ Permissions follow **`whimc-agent..`** (each `/agents …` sub | **`despawn`** | `whimc-agent.agents.despawn` | Despawn agent(s) for a player or **`all`**. | | **`destroy`** | `whimc-agent.agents.destroy` | Destroy agent NPC(s) for a player or **`all`**. | | **`reactivate`** | `whimc-agent.agents.reactivate` | Respawn agent(s) for a player or **`all`**. | -| **`rebuilderspawn`** | `whimc-agent.agents.rebuilderspawn` | Spawn a **builder** NPC (player model, fixed “Builder” setup) at your location. | +| **`rebuilderspawn`** | `whimc-agent.agents.rebuilderspawn` | Spawn a **builder** NPC (player model, fixed “Builder” setup) at your location. | | **`skin_type`** | `whimc-agent.agents.skin_type` | Set global skin pack: argument must be a **top-level key** under `skins:` in `config.yml` (bundled: **`scientist_casual`**, **`scientist_stereotype`**). | -| **`chat_type`** | `whimc-agent.agents.chat_type` | Set disembodied dialogue mode: **`Guide`** or **`Builder`** (matches `DialogueType` enum; case-insensitive). | + +*(The old `chat_type` subcommand was removed: guide and builder menus are merged into one — builder options live under "I want to build something!".)* ### `/agent` subcommands | Subcommand | Permission node | Description | |------------|-----------------|-------------| -| **`chat`** | `whimc-agent.agent.chat` | Disembodied menu (`Guide`/`Builder`), or **`chat test`** / **`chat end`** for interactive LLM chat (see above). | +| **`chat`** | `whimc-agent.agent.chat` | Disembodied dialogue menu (guide + builder options merged), or **`chat test`** / **`chat end`** for interactive LLM chat (see above). | | **`spawn`** | `whimc-agent.agents.spawn` | Same as **`/agents spawn`** (uses the shared `ExpertSpawnCommand`). | ### Guide agent entity types (reference) The spawn command accepts: -1. **`player`** — then a **skin key** under `skins.` (see `agent_type` in `config.yml`, usually **`scientist_casual`** or **`scientist_stereotype`**). +1. **`player`** — then a **skin key** under `skins.` (see `agent_type` in `config.yml`, usually **`scientist_casual`** or **`scientist_stereotype`**). 2. Any other token that is in the **configured whitelist** in `AgentEntityTypes` (`player` + fixed mob enum names). Other `EntityType` IDs are rejected even if they are valid mobs on the server. Use **tab completion** on the first argument of `/agents spawn` / `/agent spawn` for the list (`player` plus allowed mobs in whitelist order). On older servers, mobs whose `EntityType` constant does not exist yet (e.g. `HAPPY_GHAST`) are skipped automatically. @@ -303,14 +303,18 @@ Use these as **``** after **`player`**; names are **lowercase** and must m ### Guide | Dialogue option | Description | |-----------------|-------------| -| Guidance (“something cool”) | If **Journey** is present: shows a **random subset** (3–5 when available) of **server public** waypoints (world-scoped when Journey domain mapping works, otherwise all public). Each choice runs **`/ server waypoint `** as the player (default root `journey` from `config.yml`; must match how Journey registers its command, e.g. `jo`). Requires the player to **have permission** for that command. **Console:** each dispatch is logged at `INFO`; if `dispatchCommand` returns `false`, a **`WARNING`** explains common causes. Set **`journey.debug-log: true`** for extra logs when opening the menu (domain id, waypoint counts, fallback). If there are no public waypoints, falls back to **chat** entry for a destination. | -| Free discussion | **Chat input** (not a sign): type what you want to say; routed through **`doResponse()`** (PMML intent today; **`llm.use-for-reply`** when an `LlmProvider` is registered). | +| Guidance ("something cool") | If **Journey** is present: shows a **random subset** (3–5 when available) of **server public** waypoints and **`poi-*` regions** from **portal-linked worlds** (same name prefix, e.g. `ColderCold` / `ColderHot` / `ColderStrip` share `Colder`; override with `journey.linked-world-prefix`). POI regions come from WorldGuard and/or `rg_region` in MySQL (`journey.poi-source`: `worldguard`, `database`, or `both`). Each choice runs **`/ server waypoint `** as the player. Set **`journey.debug-log: true`** for linked-world and source counts in console. Falls back to all public waypoints, then **chat** entry, if nothing matches. | +| Free discussion | **Ongoing AI chat mode**: clicking the option toggles chat mode on (the player is notified) and every chat message they send is routed to the agent through **`doResponse()`** (PMML intent today; **`llm.use-for-reply`** when an `LlmProvider` is registered, with short-term conversation history). Type **`stop`** or **`exit`** in chat to end the session. | | Scores | Runs **`/progress`** (e.g. **WHIMC-StudentFeedback**); session is ensured when possible. | +| Build ("I want to build something!") | Opens the **builder menu** (templates, demo builds, base feedback — see Builder table below); no mode switch needed. | | Edit | **Embodied** agents only: change **name**, **entity type** (`player` vs Animals list), and **skin** when the NPC is a **player** model (up to configured edit limits). | -*(Planet **tagging** chat from the dialogue menu is commented out in `Dialogue#doDialogue`; `/admintags` and `Tag.java` remain for operators who still manage tag data.)* +Every menu and submenu ends with a **Go back** entry (or "That's all for now" at the top level) so players can always navigate backwards. *(Planet tagging was removed from the plugin.)* + +### Builder ("I want to build something!") + +Opened from the main dialogue menu (or by right-clicking a `rebuilderspawn` NPC). State for an in-progress template is kept while navigating menus. -### Builder | Dialogue Option | Description | |-----------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | Demo | Only available to admins. Enables admin agents to demo build using the rowid of the template. | @@ -322,5 +326,25 @@ Use these as **``** after **`player`**; names are **lowercase** and must m | Feedback | Gives feedback to player using AI about their team's base. Teams are designated by defining members of the world guard region students are working on. Socket server and API must be running for this to work. | | Stay | Only available to embodied builders and not agents using the chat function. Makes agent wait in place until commanded to follow again. | | Follow | Only available to embodied builders and not agents using the chat function. Makes agent follow the player until commanded to stay. | +| Go back | Returns to the main dialogue menu. | + +## Agent movement & following + +Agents follow their assigned player using Citizens **`FollowTrait`** + navigator pathfinding, tuned per entity type by `AgentFollowTuning`: + +- **Player-shaped agents** use normal gravity and **A\* pathfinding, so they WALK** after the player (straight-line steering is disabled — it made them glide over terrain instead of walking). They re-attach follow on respawn, world change, and player rejoin. +- **Animal/mob agents** hover at a configurable height above the ground (no gravity) and steer more directly so they keep up while floating. + +| Config key | Default | Description | +|------------|---------|-------------| +| `agent-player-follow-path-range` | `48` | Max pathfinding range (blocks) for player-shaped agents. Too low makes Citizens give up on paths. | +| `agent-player-follow-margin` | `2.5` | Distance at which the follower counts as "close enough". | +| `agent-player-nav-destination-teleport-margin` | `-1` | When `>= 0`, allows snap-teleporting near the final waypoint; `-1` disables (prefer walking). | +| `agent-player-nav-stationary-ticks` | `1200` | Ticks standing still before navigation cancels as stuck. | +| `agent-follow-catch-up-distance` | `16.0` | Catch-up teleport only when horizontal distance to the owner exceeds this (blocks). | +| `agent-follow-catch-up-offset` | `1.5` | How far beside the player catch-up teleports land (blocks). | +| `agent-non-player-hover-height` | `2.0` | Blocks above ground that mob agents hover; `0` disables vertical tracking. | +| `agent-non-player-navigator-speed-modifier` | `1.65` | Speed multiplier for hovering mob agents. | +| `agent-mob-follow-path-range` / `agent-mob-follow-margin` | `5` / `1.25` | Tighter follow tuning for mob agents. | diff --git a/example-config.yml b/example-config.yml index c287cb9..db710f0 100644 --- a/example-config.yml +++ b/example-config.yml @@ -1,4 +1,4 @@ -mysql: +mysql: host: localhost port: 3306 database: "" @@ -8,7 +8,12 @@ mysql: journey: journey-command-root: journey debug-log: false -expiration-days: 7 + linked-world-prefix: "" + linked-world-min-siblings: 2 + include-poi-regions: true + poi-region-prefix: "poi-" + poi-source: both + worldguard-table-prefix: rg_ agent_type: scientist_casual # 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 @@ -22,6 +27,10 @@ agent-player-follow-margin: 2.5 agent-player-nav-destination-teleport-margin: -1 # Ticks the NPC can stand still before navigation is cancelled as STUCK; higher avoids premature cancel on slow paths. agent-player-nav-stationary-ticks: 1200 +# When horizontal distance to the owner exceeds this, the agent catch-up teleports beside them (not on top). +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 @@ -34,7 +43,7 @@ llm: api-key: "" api-key-env: "" model: "" - # openai_compatible only — Ollama default http://127.0.0.1:11434/v1 , LM Studio http://127.0.0.1:1234/v1 + # openai_compatible only — Ollama default http://127.0.0.1:11434/v1 , LM Studio http://127.0.0.1:1234/v1 base-url: "" request-timeout-seconds: 60 system-prompt: "You are a friendly in-game science education assistant. Answer clearly and briefly; keep content appropriate for students." @@ -49,251 +58,6 @@ llm: - txt - md -tags: - num_tags: - NoMoon: 25 - ColderStrip: 25 - ColderCold: 25 - ColderHot: 25 - TiltedFrozen: 25 - TiltedWarm: 25 - TiltedMelting: 25 - MynoaClose: 25 - MynoaHalf: 25 - MynoaFar: 25 - TwoMoons: 25 - TwoMoonsLluna: 25 - TwoMoonsLow: 25 - feedback: - holo_visible: false - enabled: true - default: "Great observation! I'll take note of your discovery. Please continue thinking about the science concepts unique to this world. Feel free to show me something else or ask me about anything!" - NoMoon: - - tag: - aliases: tree - feedback: "The trees look kinda funny don't they? This is a really neat effect of the extreme wind as a result of having no moon. Why do you think they look this way?" - - tag: - aliases: dirt, grass - feedback: "Isn't it so different over here than on the other side of the map. Natural barriers block the high speed winds. Why do you think there is an area with grass and the other with dirt?" - - tag: - aliases: windmill - feedback: "Good catch! Without the gravity of the moon the Earth rotates faster making it windier. Why do you think there is a giant windmill here?" - - tag: - aliases: night, sky, moon - feedback: "You are definitely right the sky looks a little different than what we are used to. On this hypothetical world there is no moon. How do you think this changes things on Earth?" - - tag: - aliases: greenhouse, house, lab - feedback: "Great observation! The scientists are using a greenhouse to grow crops. Why do you think they are doing this? Remember the wind is a lot stronger here than what we are used to." - - tag: - aliases: hill - feedback: "This is very important! This hill acts as a natural barrier to wind. How do you think this changes the landscape?" - - tag: - aliases: water, spring - feedback: "This is very cool! Did you check the water in the pond? What is different? Try checking science tools in there and see what is different." - - tag: - aliases: wind, sign - feedback: "This is one of the most important differences on this planet. There is high winds due to having no moon. How might this change how we get out energy?" - - tag: - aliases: npc, carl, tanya, kwali - feedback: "You talked to my fellow scientist! Why do you think what they said is important?" - - tag: - aliases: vegetation, crop - feedback: "There is vegetation on Earth with no moon. What other aspects are unique here?" - - tag: - aliases: cave - feedback: "Caves are natural forming on Earth. What other aspects are unique here?" - - tag: - aliases: animal - feedback: "Animals can live on Earth with no moon. What other aspects are unique here?" - ColderStrip: - - tag: - aliases: sun - feedback: "Great observation! The Earth here is tidally locked so parts of Earth will always face the sun, parts will never face the sun, and parts will always get a little sun. How might this effect the weather and life based on the location of this area?" - - tag: - aliases: jungle, forrest - feedback: "Nice job! The Earth here is tidally locked so parts of Earth will always face the sun, parts will never face the sun, and parts will always get a little sun. Why is there a jungle here and not in the other areas on this planet?" - - tag: - aliases: bridge - feedback: "There is a really large bridge on this map. What other aspects of this world are unique here?" - - tag: - aliases: npc, jorge - feedback: "You talked to my fellow scientist! Why do you think what they said is important?" - ColderCold: - - tag: - aliases: snow, tundra - feedback: "Nice observation! The Earth here is tidally locked so parts of Earth will always face the sun, parts will never face the sun, and parts will always get a little sun. Why is it snowing here but not in the other areas on this planet?" - - tag: - aliases: cave - feedback: "Great job noticing the cave, it is actually very important for life here! Why do you think underground facilities are so important for life on this area of the planet?" - - tag: - aliases: moon - feedback: "Great observation! The Earth here is tidally locked so parts of Earth will always face the sun, parts will never face the sun, and parts will always get a little sun. How might this effect the weather and life based on the location of this area?" - - tag: - aliases: npc, jack, vera, engineer, hydro, power, controller - feedback: "You talked to my fellow scientist! Why do you think what they said is important?" - - tag: - aliases: plant, crop - feedback: "You noticed the plants! How are they growing here when the outside weather is so cold? Think about the importance of underground facilities and try checking science tools here." - ColderHot: - - tag: - aliases: sun - feedback: "Great observation! The Earth here is tidally locked so parts of Earth will always face the sun, parts will never face the sun, and parts will always get a little sun. How might this effect the weather and life based on the location of this area?" - - tag: - aliases: desert - feedback: "Nice observation! The Earth here is tidally locked so parts of Earth will always face the sun, parts will never face the sun, and parts will always get a little sun. Why is it so hot here but not in the other areas on this planet?" - - tag: - aliases: cave - feedback: "You noticed the cave in the desert, this is actually essential for life here! Why are caves and underground areas important on this area of the planet?" - - tag: - aliases: solar, panel - feedback: "Great observation, these solar panels are really important for energy production. Why do you think solar is a good option for energy here?" - - tag: - aliases: npc, damien, josephina, anita, pierre - feedback: "You talked to my fellow scientist! Why do you think what they said is important?" - TiltedFrozen: - - tag: - aliases: dark - feedback: "It is really dark isn't it! Earth's 23.5 degree tilt create the seasons that we normally experience, so being tilted 90 degrees can make the seasons more extreme. Why do you think this season is so dark and cold?" - - tag: - aliases: helicopter - feedback: "You found the helicopter! Make sure you enter it and swim below the buoy." - - tag: - aliases: buoy, dingy, raft - feedback: "You found the buoy! Make sure you swim to the bottom of the ocean to see what life is down there." - - tag: - aliases: tundra, winter - feedback: "You noticed the tundra and winter-like environment! Earth's tilt create the seasons that we normally experience, so being tilted 90 degrees can make the seasons more extreme. How does the extra tilt of this Earth contribute to this extreme weather?" - - tag: - aliases: animal, bear, rabbit, bunny, axolotl, salamander, fish, bird - feedback: "You found the animals! The extreme seasons will greatly effect how animals will migrate and adapt to survive. Why do you think they moved here to survive the extreme cold?" - - tag: - aliases: water, ice - feedback: "Nice job noticing the ice! Earth's 23.5 degree tilt create the seasons that we normally experience, so being tilted 90 degrees can make the seasons more extreme. Why is there so much ice and how do you expect the environment to change when we time travel one more time?" - - tag: - aliases: tower - feedback: "Great job noticing the tower! Were you able to talk to the scientists?" - - tag: - aliases: vent - feedback: "Great observation you noticed the undersea vent! Earth's 23.5 degree tilt create the seasons that we normally experience, so being tilted 90 degrees can make the seasons more extreme. What does this sea vent provide for sea life?" - - tag: - aliases: coral - feedback: "You noticed the coral! Earth's tilt has made the oceans much cooler, which has an impact on where sea life can thrive. How is the coral surviving in this harsh environment?" - TiltedWarm: - - tag: - aliases: npc, clara, astrochemist, mechanic, scientist, ornithologist, herpetologist - feedback: "You talked to my fellow scientist! Why do you think what they said is important?" - - tag: - aliases: animal, bear, rabbit, bunny, axolotl, bird, salamander, fish - feedback: "You found the animals! Earth's 23.5 degree tilt create the seasons that we normally experience, so being tilted 90 degrees can make the seasons more extreme. How do you think their locations will change based on the season?" - - tag: - aliases: helicopter - feedback: "You found the helicopter! Make sure you enter it and swim below the buoy." - - tag: - aliases: buoy, dingy, raft - feedback: "You found the buoy! Make sure you swim to the bottom of the ocean to see what life is down there." - - tag: - aliases: water - feedback: "Great observation about the water! Earth's 23.5 degree tilt create the seasons that we normally experience, so being tilted 90 degrees can make the seasons more extreme. What do you think will happen to it after you time travel and how does this relate to being tilted?" - - tag: - aliases: tower - feedback: "Great job noticing the tower! Were you able to talk to the scientists?" - TiltedMelting: - - tag: - aliases: npc, scientist, ornithologist, herpetologist - feedback: "You talked to my fellow scientist! Why do you think what they said is important?" - - tag: - aliases: spring, plants, vegetation - feedback: "Nice observation about the change to a Spring-like environment! Earth's 23.5 degree tilt create the seasons that we normally experience, so being tilted 90 degrees can make the seasons more extreme. How does the change in season change life here for plants?" - - tag: - aliases: animal, bear, rabbit, bunny, axolotl, bird, salamander, fish - feedback: "You found the animals! Earth's 23.5 degree tilt create the seasons that we normally experience, so being tilted 90 degrees can make the seasons more extreme. Why do you think they have moved here when the ice has begun to melt?" - - tag: - aliases: water, ice, melt - feedback: "Great observation about the ice melting! The extreme winter-like conditions are ending and this will cause the environment to drastically change for life. Why do you think the ice is melting and how will this change life here?" - - tag: - aliases: tower - feedback: "Great job noticing the tower! Were you able to talk to the scientists?" - - tag: - aliases: helicopter - feedback: "You found the helicopter! Make sure you enter it and swim below the buoy." - - tag: - aliases: buoy, dingy, raft - feedback: "You found the buoy! Make sure you swim to the bottom of the ocean to see what life is down there." - MynoaClose: - - tag: - aliases: planet - feedback: "Isn't it hard to miss the giant planet Tyran in the sky! How do you think this would effect life on this side of Mynoa? Try checking science tools over here." - - tag: - aliases: water, tide - feedback: "Nice observation about the water! The giant planet Tyran in the sky can have massive effects on the gravity. How do you think the gravity has effected the tides here?" - - tag: - aliases: eclipse, blocking - feedback: "You noticed the eclipse! Based on where we are in relation to the giant planet Tyran and the sun it is possible for it to be pitch black. Do you think it will always be dark on this side?" - - tag: - aliases: creature, animal, npc - feedback: "You noticed the Mynoan creature! Living beings might look different here based on the effects of having Tyran nearby. How do they need to adapt on this side of Mynoa to survive and how might this make them look the way they do?" - MynoaHalf: - - tag: - aliases: npc, mynoa, person - feedback: "You talked to the Mynoan! Why do you think what they said is important?" - - tag: - aliases: temperature, hot, lava, volcan, terrain - feedback: "Great observation, it is dangerous on this side! Tyran can massively effect gravity and other science tools that would have major impacts on the surroundings. Why do you think this side so hot and full of lava?" - - tag: - aliases: tectonic - feedback: "You noticed the tectonic differences! Tyran can change things like gravity that can cause tectonic plates to move. How might this shape the landscape?" - - tag: - aliases: magnet, gravity - feedback: "Great job measuring these science tools! Why do you think it's like this and how is it different from Earth? Think about the size and closeness of Tyran and how that can effect it." - MynoaFar: - - tag: - aliases: npc, worker, citizen - feedback: "You talked to my fellow npcs! Why do you think what they said is important?" - - tag: - aliases: museum - feedback: "Nice job finding the museum! Did you learn anything from the workers or models?" - - tag: - aliases: life, people, plants - feedback: "You noticed the life here. Being further from Tyran makes the surroundings much different! What is different here that makes this area special compared to the other areas?" - TwoMoons: - - tag: - aliases: npc, tobias, kristina - feedback: "You talked to my fellow npcs! Why do you think what they said is important?" - - tag: - aliases: moon - feedback: "Nice observation about the moons! Having two moons will change the gravity on this Earth. How will this effect the tides?" - - tag: - aliases: buoy, tides - feedback: "You noticed the tides! The two moons will pull on the Earth and will cause gravity to change. How does this make the tides higher?" - - tag: - aliases: museum - feedback: "You found the museum! Did you learn anything from the npcs or models?" - - tag: - aliases: time, rotation, day - feedback: "Great job measuring the science variables! The two moons will pull on the Earth and change the speed at which it rotates. How does having two moons contribute to the speed of Earth's rotation here?" - TwoMoonsLluna: - - tag: - aliases: npc, astronaut - feedback: "You talked to the astronaut! Why do you think what they said is important?" - - tag: - aliases: volcano, moon, surface - feedback: "Nice observation about the surface of the moon! Having the Earth and other moon pulling on this moon can cause things like gravity to change. How do you think this contributes to the environment here?" - TwoMoonsLow: - - tag: - aliases: npc, worker, kristina - feedback: "You talked to my fellow npcs! Why do you think what they said is important?" - - tag: - aliases: moon - feedback: "Nice observation about the moons! Having two moons will change the gravity on this Earth. How will this effect the tides?" - - tag: - aliases: buoy, tides - feedback: "You noticed the tides! The two moons will pull on the Earth and will cause gravity to change. How does this make the tides lower?" - - tag: - aliases: museum - feedback: "You found the museum! Did you learn anything from the npcs or models?" - - tag: - aliases: time, rotation, day - feedback: "Great job measuring the science variables! The two moons will pull on the Earth and change the speed at which it rotates. How does having two moons contribute to the speed of Earth's rotation here?" prompts: - label: 0 prompt: agent @@ -424,7 +188,7 @@ template-gui: guidance-response: "&f&nCan you show me something cool?" show-response: "&f&nI want to show you/ask about something unique to this environment!" score-response: "&f&nI want to see my scores" - tag-score-response: "&f&nI want to see my tag scores" + build-response: "&f&nI want to build something!" agent-edit: "&f&nI want to edit my agent" filler-item: white_stained_glass_pane inventory-name: "&lTopics" diff --git a/pom.xml b/pom.xml index 2ee0370..3d5e254 100644 --- a/pom.xml +++ b/pom.xml @@ -4,9 +4,9 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 edu.whimc - WHIMC-OverworldAgent - 3.3.10 - WHIMC Overworld Agent + WHIMC-QRF-Agent + 3.3.11 + WHIMC QRF Agent Defines overworld agent traits diff --git a/src/main/java/edu/whimc/overworld_agent/Listeners.java b/src/main/java/edu/whimc/overworld_agent/Listeners.java index 6461f5a..6534f1d 100644 --- a/src/main/java/edu/whimc/overworld_agent/Listeners.java +++ b/src/main/java/edu/whimc/overworld_agent/Listeners.java @@ -1,5 +1,6 @@ package edu.whimc.overworld_agent; +import edu.whimc.overworld_agent.traits.AgentFollowCatchUpTrait; import edu.whimc.overworld_agent.traits.AgentFollowTuning; import edu.whimc.overworld_agent.traits.AgentPermanentFlyingTrait; import net.citizensnpcs.api.CitizensAPI; @@ -81,6 +82,7 @@ public void onPlayerJoin(PlayerJoinEvent event){ NPC npc = agents.get(player.getName()); if(npc != null) { npc.getOrAddTrait(AgentPermanentFlyingTrait.class); + npc.getOrAddTrait(AgentFollowCatchUpTrait.class); npc.spawn(player.getLocation()); AgentFollowTuning.scheduleFollowAndApplyTraits(plugin, npc, player); } diff --git a/src/main/java/edu/whimc/overworld_agent/OverworldAgent.java b/src/main/java/edu/whimc/overworld_agent/OverworldAgent.java index 61ca502..410455f 100644 --- a/src/main/java/edu/whimc/overworld_agent/OverworldAgent.java +++ b/src/main/java/edu/whimc/overworld_agent/OverworldAgent.java @@ -8,13 +8,11 @@ import edu.whimc.overworld_agent.dialoguetemplate.ChatTextInputFactory; import edu.whimc.overworld_agent.dialoguetemplate.SpigotCallback; import edu.whimc.overworld_agent.dialoguetemplate.SignMenuFactory; -import edu.whimc.overworld_agent.dialoguetemplate.Tag; import edu.whimc.overworld_agent.dialoguetemplate.models.LlmProvider; import edu.whimc.overworld_agent.dialoguetemplate.models.NoOpLlmProvider; import edu.whimc.overworld_agent.dialoguetemplate.models.llm.LlmProviderFactory; import edu.whimc.overworld_agent.dialoguetemplate.models.llm.LlmRagContextBuilder; import edu.whimc.overworld_agent.dialoguetemplate.models.BuildTemplate; -import edu.whimc.overworld_agent.dialoguetemplate.models.DialogueType; import edu.whimc.overworld_agent.utils.sql.Queryer; import org.bukkit.Bukkit; @@ -53,7 +51,6 @@ */ public class OverworldAgent extends JavaPlugin { private Map agents; - private DialogueType agentType; private Queryer queryer; private List profanity; private SignMenuFactory signMenuFactory; @@ -78,11 +75,9 @@ public class OverworldAgent extends JavaPlugin { public void onEnable() { saveDefaultConfig(); //receiver = (SpeechReceiver) Bukkit.getServer().getPluginManager().getPlugin("SpeechReceiver"); - agentType = DialogueType.GUIDE; sessions = new HashMap<>(); buildTemplates = new HashMap<>(); inProgressTemplates = new HashMap<>(); - Tag.instantiate(this); this.queryer = new Queryer(this, q -> { // If we couldn't connect to the database disable the plugin @@ -94,8 +89,6 @@ public void onEnable() { }); - Tag.startExpiredObservationScanningTask(this); - //check if Citizens is present and enabled. agents = new HashMap<>(); agentEdits = new HashMap<>(); @@ -114,6 +107,7 @@ public void onEnable() { net.citizensnpcs.api.CitizensAPI.getTraitFactory().registerTrait(net.citizensnpcs.api.trait.TraitInfo.create(SpawnNoviceTrait.class).withName("noviceagentspawn")); net.citizensnpcs.api.CitizensAPI.getTraitFactory().registerTrait(net.citizensnpcs.api.trait.TraitInfo.create(SpawnExpertTrait.class).withName("expertagentspawn")); net.citizensnpcs.api.CitizensAPI.getTraitFactory().registerTrait(net.citizensnpcs.api.trait.TraitInfo.create(AgentPermanentFlyingTrait.class).withName("agentpermanentflying")); + net.citizensnpcs.api.CitizensAPI.getTraitFactory().registerTrait(net.citizensnpcs.api.trait.TraitInfo.create(AgentFollowCatchUpTrait.class).withName("agentfollowcatchup")); expertSpawnCommand = new ExpertSpawnCommand(this, "agents", "spawn"); @@ -125,10 +119,6 @@ public void onEnable() { getCommand("agents").setExecutor(agentsCommand); getCommand("agents").setTabCompleter(agentsCommand); - TagAdminCommand tagCommand = new TagAdminCommand(this); - getCommand("admintags").setExecutor(tagCommand); - getCommand("admintags").setTabCompleter(tagCommand); - HabitatAssessCommand assessCommand = new HabitatAssessCommand(this); getCommand("assess-habitat").setExecutor(assessCommand); getCommand("assess-habitat").setTabCompleter(assessCommand); @@ -347,7 +337,5 @@ public String getSkinType(){ public void setSkinType(String skinType){ this.skinType = skinType; } - public void setAgentType(DialogueType type){this.agentType = type;} - public DialogueType getAgentType(){return agentType;} } diff --git a/src/main/java/edu/whimc/overworld_agent/commands/AgentsCommand.java b/src/main/java/edu/whimc/overworld_agent/commands/AgentsCommand.java index 3c07809..c537017 100644 --- a/src/main/java/edu/whimc/overworld_agent/commands/AgentsCommand.java +++ b/src/main/java/edu/whimc/overworld_agent/commands/AgentsCommand.java @@ -25,7 +25,6 @@ public AgentsCommand(OverworldAgent plugin) { subCommands.put("rebuilderspawn", new RebuilderSpawnCommand(plugin, "agents", "rebuilderspawn")); subCommands.put("reactivate", new SpawnAgentsCommand(plugin, "agents", "reactivate")); subCommands.put("skin_type", new SkinTypeCommand(plugin, "agents", "skin_type")); - subCommands.put("chat_type", new ChangeAgentTypeCommand(plugin, "agents", "chat_type")); subCommands.put("about", new AboutAgentsCommand(plugin, "agents", "about")); } diff --git a/src/main/java/edu/whimc/overworld_agent/commands/TagAdminCommand.java b/src/main/java/edu/whimc/overworld_agent/commands/TagAdminCommand.java deleted file mode 100644 index f408c9a..0000000 --- a/src/main/java/edu/whimc/overworld_agent/commands/TagAdminCommand.java +++ /dev/null @@ -1,63 +0,0 @@ -package edu.whimc.overworld_agent.commands; - -import edu.whimc.overworld_agent.OverworldAgent; -import edu.whimc.overworld_agent.commands.subcommands.*; -import org.bukkit.command.Command; -import org.bukkit.command.CommandExecutor; -import org.bukkit.command.CommandSender; -import org.bukkit.command.TabCompleter; - -import java.util.Arrays; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -public class TagAdminCommand implements CommandExecutor, TabCompleter { - - private final Map subCommands = new HashMap<>(); - - public TagAdminCommand(OverworldAgent plugin) { - subCommands.put("remove", new TagsRemoveCommand(plugin, "admintags", "remove")); - } - - @Override - public boolean onCommand(CommandSender sender, Command cmd, String commandLabel, String[] args) { - if (args.length == 0) { - sender.sendMessage("You need to add another argument. Please try again"); - return true; - } - - AbstractSubCommand subCmd = subCommands.getOrDefault(args[0].toLowerCase(), null); - if (subCmd == null) { - sender.sendMessage("You need to add another argument. Please try again"); - return true; - } - - return subCmd.executeSubCommand(sender, args); - } - - @Override - public List onTabComplete(CommandSender sender, Command command, String alias, String[] args) { - if (args.length == 0) { - return subCommands.keySet().stream().sorted().collect(Collectors.toList()); - } - - if (args.length == 1) { - return subCommands.keySet() - .stream() - .filter(v -> v.startsWith(args[0].toLowerCase())) - .sorted() - .collect(Collectors.toList()); - } - - AbstractSubCommand subCmd = subCommands.getOrDefault(args[0].toLowerCase(), null); - if (subCmd == null) { - return null; - } - - return subCmd.executeOnTabComplete(sender, Arrays.copyOfRange(args, 1, args.length)); - } - - -} diff --git a/src/main/java/edu/whimc/overworld_agent/commands/subcommands/ChangeAgentTypeCommand.java b/src/main/java/edu/whimc/overworld_agent/commands/subcommands/ChangeAgentTypeCommand.java deleted file mode 100644 index ce3bcf8..0000000 --- a/src/main/java/edu/whimc/overworld_agent/commands/subcommands/ChangeAgentTypeCommand.java +++ /dev/null @@ -1,62 +0,0 @@ -package edu.whimc.overworld_agent.commands.subcommands; - -import edu.whimc.overworld_agent.OverworldAgent; -import edu.whimc.overworld_agent.commands.AbstractSubCommand; - -import edu.whimc.overworld_agent.dialoguetemplate.models.DialogueType; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -public class ChangeAgentTypeCommand extends AbstractSubCommand { - private final String COMMAND = "agent_type"; - - public ChangeAgentTypeCommand(OverworldAgent plugin, String baseCommand, String subCommand){ - super(plugin, baseCommand, subCommand); - super.description("Changes agent type for dialogue type"); - super.arguments("type"); - } - /** - * Creates a dialogue menu to chat with the agent - * @param sender - Source of the command - * @param args - Passed command arguments - * @return if the command was successfully executed - */ - @Override - protected boolean onCommand(CommandSender sender, String[] args) { - Player player; - boolean text = true; - if (!(sender instanceof Player)) { - sender.sendMessage("You must be a player"); - return true; - } else { - player = (Player) sender; - } - if (args.length < 1) { - sender.sendMessage("No agent type was given"); - return true; - } - String agentType = args[0]; - for (DialogueType type : DialogueType.class.getEnumConstants()) { - if(type.toString().equalsIgnoreCase(agentType)) { - plugin.setAgentType(type); - sender.sendMessage("Agent type set to " + agentType); - return true; - } - } - sender.sendMessage("Agent type not valid"); - return true; - } - - @Override - protected List onTabComplete(CommandSender sender, java.lang.String[] args) { - List list = new ArrayList(); - for (DialogueType type : Arrays.asList(DialogueType.class.getEnumConstants())) { - list.add(type.toString()); - } - return list; - } -} diff --git a/src/main/java/edu/whimc/overworld_agent/commands/subcommands/ChatCommand.java b/src/main/java/edu/whimc/overworld_agent/commands/subcommands/ChatCommand.java index 9a3d0c0..2da8344 100644 --- a/src/main/java/edu/whimc/overworld_agent/commands/subcommands/ChatCommand.java +++ b/src/main/java/edu/whimc/overworld_agent/commands/subcommands/ChatCommand.java @@ -2,10 +2,8 @@ import edu.whimc.overworld_agent.OverworldAgent; import edu.whimc.overworld_agent.commands.AbstractSubCommand; -import edu.whimc.overworld_agent.dialoguetemplate.BuilderDialogue; import edu.whimc.overworld_agent.dialoguetemplate.Dialogue; import edu.whimc.overworld_agent.dialoguetemplate.models.Chatbot; -import edu.whimc.overworld_agent.dialoguetemplate.models.DialogueType; import edu.whimc.overworld_agent.dialoguetemplate.models.LlmProvider; import edu.whimc.overworld_agent.llm.context.AgentChatContextItem; import edu.whimc.overworld_agent.llm.context.AgentChatEvent; @@ -71,19 +69,11 @@ protected boolean onCommand(CommandSender sender, String[] args) { player = (Player) sender; } - if (plugin.getAgentType().equals(DialogueType.GUIDE)) { - plugin.ensureAgentEdits(player); - Dialogue dialogue = new Dialogue(plugin, player, text, embodied); - dialogue.doDialogue(); - } else { - if (plugin.getInProgressTemplates().containsKey(player)) { - BuilderDialogue bd = plugin.getInProgressTemplates().get(player); - bd.doDialogue(); - } else { - BuilderDialogue bd = new BuilderDialogue(plugin, player, embodied); - bd.doDialogue(); - } - } + // Single merged menu: guide options (guidance, scores, discussion, edit) plus the + // builder submenu (templates, base feedback) — no /agents chat_type switch needed. + plugin.ensureAgentEdits(player); + Dialogue dialogue = new Dialogue(plugin, player, text, embodied); + dialogue.doDialogue(); return true; } @@ -206,7 +196,8 @@ private void runLlmChatTurn(Player player, ActiveChatSession session, String use String playerResearchId = playerUuid; // Replace later with a de-identified research ID if needed. String sessionId = session.sessionId(); String worldName = player.getWorld().getName(); - String agentType = plugin.getAgentType().name(); + // Research-log label; the old Guide/Builder mode switch was removed (menus are merged). + String agentType = "GUIDE"; String agentName = "interactive-chat-agent"; String providerName = plugin.getConfig().getString("llm.provider", "unknown"); 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 89828c0..168e089 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 @@ -4,6 +4,7 @@ import edu.whimc.overworld_agent.commands.AbstractSubCommand; import edu.whimc.overworld_agent.utils.AgentEntityTypes; 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; @@ -132,6 +133,7 @@ protected boolean onCommand(CommandSender sender, String[] args) { trait.setInputType(true); npc.addTrait(trait); npc.addTrait(new AgentPermanentFlyingTrait()); + npc.addTrait(new AgentFollowCatchUpTrait()); if (entityType == EntityType.PLAYER) { //Set NPC skin by grabbing values from config diff --git a/src/main/java/edu/whimc/overworld_agent/commands/subcommands/TagsRemoveCommand.java b/src/main/java/edu/whimc/overworld_agent/commands/subcommands/TagsRemoveCommand.java deleted file mode 100644 index 7603ef6..0000000 --- a/src/main/java/edu/whimc/overworld_agent/commands/subcommands/TagsRemoveCommand.java +++ /dev/null @@ -1,35 +0,0 @@ -package edu.whimc.overworld_agent.commands.subcommands; - -import edu.whimc.overworld_agent.OverworldAgent; -import edu.whimc.overworld_agent.commands.AbstractSubCommand; -import edu.whimc.overworld_agent.utils.Utils; -import edu.whimc.overworld_agent.dialoguetemplate.Tag; -import org.bukkit.command.CommandSender; - -import java.util.List; - -public class TagsRemoveCommand extends AbstractSubCommand { - - public TagsRemoveCommand(OverworldAgent plugin, String baseCommand, String subCommand) { - super(plugin, baseCommand, subCommand); - super.description("Removes an tag"); - super.arguments("id"); - } - - @Override - protected boolean onCommand(CommandSender sender, String[] args) { - Tag tag = Utils.getTagWithError(sender, args[0]); - if (tag == null) return true; - - tag.deleteAndSetInactive(() -> { - Utils.msg(sender, "&aTag \"&2" + tag.getId() + "&a\" removed!"); - }); - - return true; - } - - @Override - protected List onTabComplete(CommandSender sender, String[] args) { - return Tag.getTagsTabComplete(args[0]); - } -} 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 507aa45..8d13a43 100644 --- a/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/BuilderDialogue.java +++ b/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/BuilderDialogue.java @@ -34,6 +34,8 @@ public class BuilderDialogue { private boolean embodied; private int id; private Logger log; + /** Reopens the parent menu (main agent dialogue) when the player clicks "Go back". */ + private Runnable goBack; public BuilderDialogue(OverworldAgent plugin, Player player, boolean embodied){ this.plugin = plugin; this.player = player; @@ -44,7 +46,12 @@ public BuilderDialogue(OverworldAgent plugin, Player player, boolean embodied){ id = -1; } + public void setGoBack(Runnable goBack) { + this.goBack = goBack; + } + public void doDialogue(){ + this.spigotCallback.clearCallbacks(player); HashMap> templates = plugin.getBuildTemplates(); Utils.msgNoPrefix(player, "&lWhat do you want to do?", ""); if(player.isOp()){ @@ -226,6 +233,11 @@ public void doDialogue(){ }); }); } + sendComponent( + player, + "&8" + BULLET + " &7&nGo back", + "&aClick here to go back", + l -> doDialogue()); }); /** sendComponent( @@ -290,6 +302,20 @@ public void doDialogue(){ }); } } + + sendComponent( + player, + "&8" + BULLET + " &7&nGo back", + "&aClick here to go back", + p -> { + this.spigotCallback.clearCallbacks(player); + if (goBack != null) { + goBack.run(); + } else { + // Opened directly (builder NPC right click): go back to the main agent menu. + new Dialogue(plugin, player, true, embodied).doDialogue(); + } + }); } public Player getPlayer(){return player;} diff --git a/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/ChatTextInputFactory.java b/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/ChatTextInputFactory.java index 088d498..d434df5 100644 --- a/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/ChatTextInputFactory.java +++ b/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/ChatTextInputFactory.java @@ -18,13 +18,18 @@ import java.util.function.Consumer; /** - * Collects a line of text from chat (more capacity than a sign). Paper 1.21.11+ requires + * Collects text from chat (more capacity than a sign). Paper 1.21.11+ requires * {@code written_book_content} for {@link Player#openBook}, so writable-book UIs no longer work for this use case. + * Supports one-shot prompts ({@link #open}) and ongoing chat sessions ({@link #openSession}) where every + * chat line is routed to the callback until the player types {@code stop} or {@code exit}. */ public final class ChatTextInputFactory implements Listener { + private record PendingInput(Consumer callback, boolean session) { + } + private final Plugin plugin; - private final Map> pending = new ConcurrentHashMap<>(); + private final Map pending = new ConcurrentHashMap<>(); public ChatTextInputFactory(Plugin plugin) { this.plugin = plugin; @@ -40,7 +45,7 @@ public void open(Player player, List instructionLines, int contentStartP if (!player.isOnline()) { return; } - pending.put(player.getUniqueId(), onSubmit); + pending.put(player.getUniqueId(), new PendingInput(onSubmit, false)); for (String line : instructionLines) { player.sendMessage(line); } @@ -48,15 +53,51 @@ public void open(Player player, List instructionLines, int contentStartP player.sendMessage(ChatColor.DARK_GRAY + "Type " + ChatColor.WHITE + "cancel" + ChatColor.DARK_GRAY + " to abort."); } + /** + * Starts an ongoing chat session: every chat line the player sends is passed to {@code onMessage} + * until they type {@code stop} or {@code exit} (which ends the session and notifies them). + */ + public void openSession(Player player, List instructionLines, Consumer onMessage) { + if (!player.isOnline()) { + return; + } + pending.put(player.getUniqueId(), new PendingInput(onMessage, true)); + for (String line : instructionLines) { + player.sendMessage(line); + } + player.sendMessage(ChatColor.GRAY + "AI chat mode is on. Anything you type in chat is sent to your agent."); + player.sendMessage(ChatColor.DARK_GRAY + "Type " + ChatColor.WHITE + "stop" + ChatColor.DARK_GRAY + " or " + + ChatColor.WHITE + "exit" + ChatColor.DARK_GRAY + " to end the chat."); + } + @EventHandler(priority = EventPriority.LOWEST) public void onAsyncChat(AsyncChatEvent event) { Player player = event.getPlayer(); - Consumer callback = pending.get(player.getUniqueId()); - if (callback == null) { + PendingInput input = pending.get(player.getUniqueId()); + if (input == null) { return; } event.setCancelled(true); String plain = PlainTextComponentSerializer.plainText().serialize(event.message()).trim(); + + if (input.session()) { + if (isSessionExit(plain)) { + pending.remove(player.getUniqueId()); + Bukkit.getScheduler().runTask(plugin, () -> { + if (player.isOnline()) { + player.sendMessage(ChatColor.YELLOW + "AI chat mode ended."); + } + }); + return; + } + Bukkit.getScheduler().runTask(plugin, () -> { + if (player.isOnline()) { + input.callback().accept(plain); + } + }); + return; + } + pending.remove(player.getUniqueId()); Bukkit.getScheduler().runTask(plugin, () -> { if (!player.isOnline()) { @@ -66,10 +107,14 @@ public void onAsyncChat(AsyncChatEvent event) { player.sendMessage(ChatColor.RED + "Cancelled."); return; } - callback.accept(plain); + input.callback().accept(plain); }); } + private static boolean isSessionExit(String message) { + return "stop".equalsIgnoreCase(message) || "exit".equalsIgnoreCase(message); + } + @EventHandler public void onQuit(PlayerQuitEvent event) { pending.remove(event.getPlayer().getUniqueId()); 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 5bbe5f0..654fb01 100644 --- a/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/Dialogue.java +++ b/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/Dialogue.java @@ -56,11 +56,14 @@ public class Dialogue implements Listener { private final int UNKNOWN_LABEL = -2; private final double THRESHOLD = .5; private final int AGENT_EDIT_NUM = 5; + private static final int MAX_DISCUSSION_HISTORY = 10; private String feedback; private String response; private boolean text; private boolean embodied; private Map prompts; + /** Short-term memory for the ongoing free-discussion chat; only sent to the LLM path. */ + private final List discussionHistory = new ArrayList<>(); public Dialogue(OverworldAgent plugin, Player player, boolean text, boolean embodied) { this.spigotCallback = plugin.getSpigotCallback(); this.plugin = plugin; @@ -100,8 +103,8 @@ private void dispatchJourneyCommand(Player player, String rawDestination) { plugin.getLogger().fine("[OverworldAgent][Journey] dispatch skipped: empty destination for " + player.getName()); return; } - // Public waypoint lookups use name_id (lowercase in SQL); keep keys stable for getWaypoint(name). - String nameId = destination.toLowerCase(Locale.ROOT); + // Public waypoint lookups use name_id (lowercase in SQL); resolve display names to name_id when needed. + String nameId = resolveJourneyPublicNameId(rawDestination); String journeyRoot = plugin.getConfig().getString("journey.journey-command-root", "journey"); String cmd = journeyRoot + " server waypoint " + quoteIfNeeded(nameId); plugin.getLogger().info( @@ -202,7 +205,8 @@ private static List randomGuidanceWaypointSample(List flattenWaypointContainer(Object all) { List out = new ArrayList<>(); if (all instanceof Map map) { - for (Object v : map.values()) { + for (Map.Entry entry : map.entrySet()) { + Object v = entry.getValue(); if (v != null) { out.add(v); } @@ -367,21 +371,257 @@ private static String extractWaypointName(Object waypoint) { } } - private static JourneyWaypointChoice choiceFromWaypointObject(Object waypoint) { + private static JourneyWaypointChoice choiceFromWaypointObject(Object waypoint, Object publicWaypointManager) { + String label = extractWaypointName(waypoint); String key = extractWaypointJtKey(waypoint); - if (key == null || key.isBlank()) { - key = extractWaypointName(waypoint); + if (key == null || key.isBlank() || !nameIdMatchesWaypoint(publicWaypointManager, key, waypoint)) { + key = resolveNameIdForWaypoint(publicWaypointManager, waypoint, label); } if (key != null && !key.isBlank()) { key = key.toLowerCase(Locale.ROOT); } - String label = extractWaypointName(waypoint); if (label == null || label.isBlank()) { label = key; } return new JourneyWaypointChoice(key, label); } + /** + * Journey stores {@code name_id} (e.g. {@code npc-jorgeperezgallego}) separately from the friendly + * {@code name} column ({@code Dr. Jorge Perez Gallego}). {@code getWaypoint} only accepts name_id. + */ + private static String resolveNameIdForWaypoint(Object publicWaypointManager, Object waypoint, String displayName) { + if (publicWaypointManager == null) { + return displayName == null ? null : displayName.toLowerCase(Locale.ROOT); + } + String fromRecord = resolveNameIdFromWaypointRecord(publicWaypointManager, waypoint); + if (fromRecord != null) { + return fromRecord; + } + for (String candidate : buildNameIdCandidates(displayName)) { + if (nameIdMatchesWaypoint(publicWaypointManager, candidate, waypoint)) { + return candidate; + } + } + for (String candidate : buildNameIdCandidates(displayName)) { + if (invokeGetWaypoint(publicWaypointManager, candidate) != null) { + return candidate; + } + } + return displayName == null ? null : displayName.toLowerCase(Locale.ROOT); + } + + /** Journey SQL stores name_id separately from display name; it is not always exposed on {@code Waypoint}. */ + private static String resolveNameIdFromWaypointRecord(Object publicWaypointManager, Object waypoint) { + if (waypoint == null || publicWaypointManager == null) { + return null; + } + try { + java.lang.reflect.RecordComponent[] components = waypoint.getClass().getRecordComponents(); + if (components == null) { + return null; + } + for (java.lang.reflect.RecordComponent rc : components) { + String rcName = rc.getName(); + if ("nameId".equals(rcName) || "name_id".equals(rcName) || "id".equals(rcName)) { + Object v = rc.getAccessor().invoke(waypoint); + if (v != null) { + String s = String.valueOf(v).trim(); + if (!s.isBlank() && invokeGetWaypoint(publicWaypointManager, s) != null) { + return s.toLowerCase(Locale.ROOT); + } + } + } + } + } catch (ReflectiveOperationException ignored) { + } + return null; + } + + private String resolveJourneyPublicNameId(String rawInput) { + Object manager = journeyPublicWaypointManager(); + if (manager == null || StringUtils.isBlank(rawInput)) { + return StringUtils.trimToEmpty(rawInput).toLowerCase(Locale.ROOT); + } + for (String candidate : buildNameIdCandidates(rawInput)) { + if (invokeGetWaypoint(manager, candidate) != null) { + return candidate; + } + } + String trimmed = rawInput.trim(); + if (trimmed.matches("(?i)(npc|poi)-[a-z0-9-]+")) { + return trimmed.toLowerCase(Locale.ROOT); + } + return trimmed.toLowerCase(Locale.ROOT); + } + + private Object journeyPublicWaypointManager() { + try { + Class journeyClass = Class.forName("net.whimxiqal.journey.Journey"); + Object journey = journeyClass.getMethod("get").invoke(null); + if (journey == null) { + return null; + } + Object dataManager = journeyDataManager(journey); + if (dataManager == null) { + return null; + } + return dataManager.getClass().getMethod("publicWaypointManager").invoke(dataManager); + } catch (ReflectiveOperationException ignored) { + return null; + } + } + + private static boolean nameIdMatchesWaypoint(Object publicWaypointManager, String nameId, Object waypoint) { + if (publicWaypointManager == null || nameId == null || nameId.isBlank()) { + return false; + } + Object found = invokeGetWaypoint(publicWaypointManager, nameId); + if (found == null) { + return false; + } + if (waypoint == null) { + return true; + } + Object expected = extractWaypointCell(waypoint); + if (expected == null) { + return true; + } + return cellsMatch(found, expected); + } + + private static Object invokeGetWaypoint(Object publicWaypointManager, String nameId) { + try { + return publicWaypointManager.getClass().getMethod("getWaypoint", String.class).invoke(publicWaypointManager, nameId); + } catch (ReflectiveOperationException ignored) { + return null; + } + } + + private static Object extractWaypointCell(Object waypoint) { + if (waypoint == null) { + return null; + } + for (String accessor : new String[] {"location", "cell", "getLocation", "getCell"}) { + try { + Method m = waypoint.getClass().getMethod(accessor); + if (m.getParameterCount() != 0) { + continue; + } + Object cell = m.invoke(waypoint); + if (cell != null) { + return cell; + } + } catch (ReflectiveOperationException ignored) { + } + } + return null; + } + + private static boolean cellsMatch(Object cellA, Object cellB) { + if (cellA == null || cellB == null) { + return false; + } + Integer[] a = cellCoords(cellA); + Integer[] b = cellCoords(cellB); + if (a == null || b == null) { + return false; + } + if (!a[0].equals(b[0]) || !a[1].equals(b[1]) || !a[2].equals(b[2])) { + return false; + } + Integer domainA = cellDomainIndex(cellA); + Integer domainB = cellDomainIndex(cellB); + return domainA == null || domainB == null || domainA.equals(domainB); + } + + private static Integer[] cellCoords(Object cell) { + if (cell == null) { + return null; + } + for (String[] accessors : new String[][] { + {"blockX", "blockY", "blockZ"}, + {"x", "y", "z"}, + {"getBlockX", "getBlockY", "getBlockZ"}, + {"getX", "getY", "getZ"}, + }) { + try { + int x = ((Number) cell.getClass().getMethod(accessors[0]).invoke(cell)).intValue(); + int y = ((Number) cell.getClass().getMethod(accessors[1]).invoke(cell)).intValue(); + int z = ((Number) cell.getClass().getMethod(accessors[2]).invoke(cell)).intValue(); + return new Integer[] {x, y, z}; + } catch (ReflectiveOperationException ignored) { + } + } + return null; + } + + private static Integer cellDomainIndex(Object cell) { + if (cell == null) { + return null; + } + try { + Method domain = cell.getClass().getMethod("domain"); + Object d = domain.invoke(cell); + if (d instanceof Integer) { + return (Integer) d; + } + if (d instanceof Number) { + return ((Number) d).intValue(); + } + } catch (ReflectiveOperationException ignored) { + } + return null; + } + + /** Candidate {@code name_id} values for Journey SQL lookups (WHIMC uses npc-/poi- prefixes). */ + private static List buildNameIdCandidates(String displayName) { + LinkedHashSet out = new LinkedHashSet<>(); + if (displayName == null || displayName.isBlank()) { + return List.of(); + } + String trimmed = displayName.trim(); + String lower = trimmed.toLowerCase(Locale.ROOT); + out.add(lower); + String fullSlug = lower.replaceAll("[^a-z0-9]", ""); + if (!fullSlug.isBlank()) { + out.add(fullSlug); + out.add("npc-" + fullSlug); + out.add("poi-" + fullSlug); + } + String[] words = trimmed.split("\\s+"); + int start = 0; + if (words.length > 1 && words[0].matches("(?i)(dr|mr|mrs|ms|prof)\\.?")) { + start = 1; + } + if (start > 0) { + StringBuilder stripped = new StringBuilder(); + for (int i = start; i < words.length; i++) { + stripped.append(words[i].toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", "")); + } + String slug = stripped.toString(); + if (!slug.isBlank()) { + out.add(slug); + out.add("npc-" + slug); + out.add("poi-" + slug); + } + StringBuilder hyphenated = new StringBuilder(); + for (int i = start; i < words.length; i++) { + if (hyphenated.length() > 0) { + hyphenated.append('-'); + } + hyphenated.append(words[i].toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", "")); + } + String hyphenSlug = hyphenated.toString(); + if (!hyphenSlug.isBlank() && !hyphenSlug.equals(slug)) { + out.add(hyphenSlug); + out.add("npc-" + hyphenSlug); + out.add("poi-" + hyphenSlug); + } + } + return new ArrayList<>(out); + } + private void openJourneyDestinationTextInput() { List instruct = Arrays.asList( Utils.color("&0&lJourney"), @@ -408,45 +648,21 @@ private void openFreeDiscussionChatInput() { List instruct = Arrays.asList( Utils.color("&0&lDiscuss"), "", - Utils.color("&7Type what you want to say to your agent in chat."), + Utils.color("&aAI chat mode started! Type what you want to say to your agent in chat."), + Utils.color("&7You can keep going back and forth as an ongoing chat."), Utils.color("&7(When the LLM is enabled, this text will be sent there.)")); - plugin.getChatTextInputFactory().open(player, instruct, text -> { + plugin.getChatTextInputFactory().openSession(player, instruct, text -> { if (StringUtils.isBlank(text)) { - Utils.msgNoPrefix(player, ChatColor.RED + "Enter a message in chat, or type cancel."); - openFreeDiscussionChatInput(); + Utils.msgNoPrefix(player, ChatColor.RED + "Enter a message in chat, or type stop to end the chat."); return; } response = StringUtils.trimToEmpty(text); + // Echo privately because the public chat event is cancelled while in chat mode. + Utils.msgNoPrefix(player, "&7You: &f" + response); doResponse(); }); } - /* - private void openPlanetTagTextInput() { - List instruct = Arrays.asList( - Utils.color("&0&lObservation"), - "", - Utils.color("&7What do you want to show or ask about on this planet?"), - Utils.color("&7Send it as your next chat message.")); - openPlanetTagTextWithRetry(instruct); - } - - private void openPlanetTagTextWithRetry(List instruct) { - plugin.getChatTextInputFactory().open(player, instruct, text -> { - String normalized = StringUtils.trimToEmpty(text).toLowerCase(Locale.ROOT).replaceAll("\\s+", " "); - if (normalized.isEmpty()) { - Utils.msgNoPrefix(player, ChatColor.RED + "Please enter something in chat."); - openPlanetTagTextWithRetry(instruct); - return; - } - plugin.getQueryer().storeNewInteraction(new Interaction(plugin, player, "Tag"), id -> { - Tag tag = new Tag(plugin, player, normalized); - tag.sendFeedback(); - }); - }); - } - */ - /** * Journey exposes {@link net.whimxiqal.journey.data.DataManager} at {@code Journey.get().proxy().dataManager()}, * not as {@code Journey.dataManager()} (older code assumed it lived on {@code Journey} directly). @@ -473,10 +689,10 @@ private static Object journeyDataManager(Object journey) { } /** - * Public Journey destinations for optional {@code domainFilter} (Journey world id). - * {@code jtKey} prefers {@code name_id}-style accessors when present; otherwise the waypoint display name. + * Public Journey destinations for optional {@code domainFilter} (Journey world ids). + * {@code null} = all domains. {@code jtKey} prefers {@code name_id}-style accessors when present. */ - private List collectJourneyPublicWaypoints(Integer domainFilter) { + private List collectJourneyPublicWaypoints(Set domainFilter) { try { Class journeyClass = Class.forName("net.whimxiqal.journey.Journey"); Method getMethod = journeyClass.getMethod("get"); @@ -504,13 +720,13 @@ private List collectJourneyPublicWaypoints(Integer domain List choices = new ArrayList<>(); for (Object item : flattenWaypointContainer(all)) { - if (domainFilter != null) { + if (domainFilter != null && !domainFilter.isEmpty()) { Integer dom = waypointCellDomain(item); - if (dom == null || !dom.equals(domainFilter)) { + if (dom == null || !domainFilter.contains(dom)) { continue; } } - JourneyWaypointChoice c = choiceFromWaypointObject(item); + JourneyWaypointChoice c = choiceFromWaypointObject(item, publicWaypointManager); if (c.jtKey != null && !c.jtKey.isBlank()) { choices.add(c); } @@ -523,6 +739,133 @@ private List collectJourneyPublicWaypoints(Integer domain } } + private void loadGuidanceDestinations(Player player, Consumer> callback) { + FileConfiguration cfg = plugin.getConfig(); + List linkedWorlds = JourneyGuidanceCatalog.linkedWorlds(player.getWorld(), cfg); + Set linkedDomains = JourneyGuidanceCatalog.journeyDomains(linkedWorlds); + String linkedPrefix = JourneyGuidanceCatalog.linkedPrefixFor(player.getWorld(), cfg); + + List choices = new ArrayList<>(collectJourneyPublicWaypoints(linkedDomains)); + int journeyInLinked = choices.size(); + + String poiSource = cfg.getString("journey.poi-source", "both"); + boolean useWorldGuard = "worldguard".equalsIgnoreCase(poiSource) || "both".equalsIgnoreCase(poiSource); + boolean useDatabase = "database".equalsIgnoreCase(poiSource) || "both".equalsIgnoreCase(poiSource); + int poiFromWorldGuard = 0; + if (cfg.getBoolean("journey.include-poi-regions", true) && useWorldGuard) { + for (JourneyGuidanceCatalog.Destination poi : JourneyGuidanceCatalog.poiRegionsFromWorldGuard(linkedWorlds, cfg)) { + choices.add(new JourneyWaypointChoice(poi.jtKey(), poi.label())); + poiFromWorldGuard++; + } + } + List mergedChoices = sortUniqueChoices(choices); + final int journeyInLinkedFinal = journeyInLinked; + final int poiFromWorldGuardFinal = poiFromWorldGuard; + + Runnable finish = () -> { + List finalChoices = mergedChoices; + if (finalChoices.isEmpty()) { + finalChoices = collectJourneyPublicWaypoints(null); + } + if (cfg.getBoolean("journey.debug-log", false)) { + plugin.getLogger().info( + "[OverworldAgent][Journey] guidance sources (" + + player.getName() + + " world=" + + player.getWorld().getName() + + " linkedPrefix=" + + linkedPrefix + + " linkedWorlds=" + + linkedWorlds.stream().map(World::getName).toList() + + " journeyDomains=" + + linkedDomains + + " journeyWaypointsInLinked=" + + journeyInLinkedFinal + + " poiFromWorldGuard=" + + poiFromWorldGuardFinal + + " finalChoiceCount=" + + finalChoices.size() + + ")"); + } + callback.accept(finalChoices); + }; + + if (cfg.getBoolean("journey.include-poi-regions", true) && useDatabase && plugin.getQueryer() != null) { + String tablePrefix = cfg.getString("journey.worldguard-table-prefix", "rg_"); + String poiPrefix = cfg.getString("journey.poi-region-prefix", "poi-"); + final List baseChoices = mergedChoices; + plugin.getQueryer().listPoiRegions(linkedPrefix, poiPrefix, tablePrefix, dbPoi -> { + List withDb = baseChoices; + if (dbPoi != null && !dbPoi.isEmpty()) { + List merged = new ArrayList<>(baseChoices); + for (JourneyGuidanceCatalog.Destination poi : dbPoi) { + merged.add(new JourneyWaypointChoice(poi.jtKey(), poi.label())); + } + withDb = sortUniqueChoices(merged); + } + List finalChoices = withDb; + if (finalChoices.isEmpty()) { + finalChoices = collectJourneyPublicWaypoints(null); + } + if (cfg.getBoolean("journey.debug-log", false)) { + plugin.getLogger().info( + "[OverworldAgent][Journey] guidance sources (" + + player.getName() + + " world=" + + player.getWorld().getName() + + " linkedPrefix=" + + linkedPrefix + + " linkedWorlds=" + + linkedWorlds.stream().map(World::getName).toList() + + " journeyDomains=" + + linkedDomains + + " journeyWaypointsInLinked=" + + journeyInLinkedFinal + + " poiFromWorldGuard=" + + poiFromWorldGuardFinal + + " finalChoiceCount=" + + finalChoices.size() + + ")"); + } + callback.accept(finalChoices); + }); + return; + } + finish.run(); + } + + private void showGuidanceDestinationMenu(Player player, String guidanceResponse, List guidanceChoices) { + FileConfiguration cfg = plugin.getConfig(); + List linkedWorlds = JourneyGuidanceCatalog.linkedWorlds(player.getWorld(), cfg); + String linkedPrefix = JourneyGuidanceCatalog.linkedPrefixFor(player.getWorld(), cfg); + final boolean linkedCluster = linkedWorlds.size() > 1; + final List guidanceDisplay = randomGuidanceWaypointSample(guidanceChoices); + String hoverPick = linkedCluster + ? "&aRandom places in linked worlds (" + linkedPrefix + "*) — click to journey" + : "&aA few random places in this world — click to journey"; + sendComponent( + player, + "&8" + BULLET + guidanceResponse, + hoverPick, + p -> { + this.spigotCallback.clearCallbacks(player); + Utils.msgNoPrefix(player, "&lPick a destination:", ""); + + for (JourneyWaypointChoice wp : guidanceDisplay) { + sendComponent( + player, + "&8" + BULLET + " &r" + wp.label, + "&aClick here to select \"&r" + wp.label + "&a\"", + l -> this.plugin.getQueryer().storeNewInteraction(new Interaction(plugin, player, "Guidance"), id -> { + dispatchJourneyCommand(player, wp.jtKey); + }) + ); + } + sendBackOption(this::doDialogue); + } + ); + } + public void doDialogue() { plugin.relinkOwnedAgent(player); plugin.ensureAgentEdits(player); @@ -540,11 +883,6 @@ public void doDialogue() { "&f&nYour response"); String guidanceResponse = cfg.getString("template-gui.text.guidance-response", "&f&nCan you show me something cool?"); - // Tagging (planet observation) menu — disabled; see git history / Tag.java to restore. - // String showResponse = cfg.getString("template-gui.text.show-response", - // "&f&nI want to show you something unique to this environment!"); - // String tagScoreResponse = cfg.getString("template-gui.text.tag-score-response", - // "&f&nI want to see my tag scores"); String scoreResponse = cfg.getString("template-gui.text.score-response", "&f&nI want to see my scores"); String agentEdit = cfg.getString("template-gui.text.agent-edit", @@ -554,83 +892,19 @@ public void doDialogue() { // We avoid that by enumerating public waypoints (if available) and dispatching `jt ` // which does not require opening the GUI. if (Bukkit.getPluginManager().getPlugin("Journey") != null) { - Integer worldDomain = journeyDomainForWorldSafe(player.getWorld()); - // Prefer waypoints in the player's current Journey domain (same Bukkit world). - List guidanceChoices = (worldDomain != null) - ? collectJourneyPublicWaypoints(worldDomain) - : Collections.emptyList(); - int domainFilteredCount = guidanceChoices.size(); - // If domain resolution fails or nothing matches (API/layout changes), fall back to all public - // waypoints so players still get a random short list instead of only manual chat entry. - final boolean sameWorldOnly = !guidanceChoices.isEmpty(); - if (guidanceChoices.isEmpty()) { - guidanceChoices = collectJourneyPublicWaypoints(null); - } - if (plugin.getConfig().getBoolean("journey.debug-log", false)) { - plugin.getLogger().info( - "[OverworldAgent][Journey] guidance menu (" - + player.getName() - + " world=" - + player.getWorld().getName() - + "): journeyDomainId=" - + worldDomain - + " publicWaypointsInDomain=" - + domainFilteredCount - + " usedAllDomainsFallback=" - + (!sameWorldOnly && !guidanceChoices.isEmpty()) - + " finalPublicWaypointCount=" - + guidanceChoices.size()); - } - if (!guidanceChoices.isEmpty()) { - final List guidanceDisplay = randomGuidanceWaypointSample(guidanceChoices); - String hoverPick = sameWorldOnly - ? "&aA few random places in this world — click to journey" - : "&aA few random public waypoints — click to journey (not limited to this world)"; - sendComponent( - player, - "&8" + BULLET + guidanceResponse, - hoverPick, - p -> { - Utils.msgNoPrefix(player, "&lPick a destination:", ""); - - for (JourneyWaypointChoice wp : guidanceDisplay) { - sendComponent( - player, - "&8" + BULLET + " &r" + wp.label, - "&aClick here to select \"&r" + wp.label + "&a\"", - l -> this.plugin.getQueryer().storeNewInteraction(new Interaction(plugin, player, "Guidance"), id -> { - dispatchJourneyCommand(player, wp.jtKey); - }) - ); - } - } - ); - } else { - // Fallback: avoid bare `/jt` GUI; use chat for longer input than a sign (Paper 1.21.11+ book API requires written_book only). - sendComponent( - player, - "&8" + BULLET + guidanceResponse, - "&aClick here to enter a Journey destination (chat)", - p -> openJourneyDestinationTextInput() - ); - } + loadGuidanceDestinations(player, guidanceChoices -> { + if (!guidanceChoices.isEmpty()) { + showGuidanceDestinationMenu(player, guidanceResponse, guidanceChoices); + } else { + sendComponent( + player, + "&8" + BULLET + guidanceResponse, + "&aClick here to enter a Journey destination (chat)", + p -> openJourneyDestinationTextInput() + ); + } + }); } - // Agent Tag option (disabled) - // Map> playerTags = Tag.getPlayerTags(); - // int numTags = 0; - // if (playerTags.get(player) != null && playerTags.get(player).get(player.getWorld()) != null) { - // numTags = playerTags.get(player).get(player.getWorld()); - // } - // if (Tag.maxTags(player.getWorld()) != null && numTags < Tag.maxTags(player.getWorld()) - // && Tag.getDialogueTags().get(player.getWorld()) != null) { - // sendComponent( - // player, - // "&8" + BULLET + showResponse, - // "&aClick here to show or ask about something unique (chat)", - // p -> openPlanetTagTextInput() - // ); - // } - //Agent Score option sendComponent( player, @@ -644,6 +918,16 @@ public void doDialogue() { }); }); + //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() + ); + //Agent Dialogue option (free text → PMML / future LLM via chat, not sign) if (text) { sendComponent( @@ -692,14 +976,61 @@ public void doDialogue() { 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){ + //Agent edit Option + sendComponent(player, "&8" + BULLET + agentEdit, "&aClick here to change me!", p -> openEditMenu()); + } + + //Close option so every menu has a way out + sendComponent( + player, + "&8" + BULLET + " &7&nThat's all for now", + "&aClick here to close this menu", + p -> { + this.spigotCallback.clearCallbacks(player); + Utils.msgNoPrefix(player, "&7Talk to you later!"); + }); + } + + /** + * Opens the builder menu (templates, demo builds, base feedback) for this player, + * reusing an in-progress builder session when one exists so template state is kept. + */ + private void openBuilderMenu() { + BuilderDialogue bd = plugin.getInProgressTemplates().get(player); + if (bd == null) { + bd = new BuilderDialogue(plugin, player, embodied); + } + bd.setGoBack(this::doDialogue); + bd.doDialogue(); + } + + /** Renders a "Go back" entry that clears this menu's callbacks and reopens the parent menu. */ + private void sendBackOption(Runnable onBack) { + sendComponent( + player, + "&8" + BULLET + " &7&nGo back", + "&aClick here to go back", + p -> { + this.spigotCallback.clearCallbacks(player); + onBack.run(); + }); + } + + private void openEditMenu() { + FileConfiguration cfg = plugin.getConfig(); + String signHeader = cfg.getString("template-gui.text.custom-response-sign-header", + "&f&nYour response"); + Map edits = plugin.getAgentEdits().get(player); + int skinChange = edits.get("Skin"); + 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 && ownedForEdit.getEntity().getType() == EntityType.PLAYER; - if((skinChange < AGENT_EDIT_NUM || nameChange < AGENT_EDIT_NUM || typeChange < AGENT_EDIT_NUM) && embodied){ - //Agent edit Option - sendComponent(player, "&8" + BULLET + agentEdit, "&aClick here to change me!", p -> { - this.spigotCallback.clearCallbacks(player); - Utils.msgNoPrefix(player, "&lClick what you want to change:", ""); + + this.spigotCallback.clearCallbacks(player); + Utils.msgNoPrefix(player, "&lClick what you want to change:", ""); if(skinChange < AGENT_EDIT_NUM && canEditSkin) { sendComponent( @@ -707,6 +1038,7 @@ public void doDialogue() { "&8" + BULLET + " &rSkin", "&aClick here to select \"&rskin change", l -> { + this.spigotCallback.clearCallbacks(player); Utils.msgNoPrefix(player, "&lClick what skin you want me to have:", ""); FileConfiguration config = plugin.getConfig(); String path = "skins."+plugin.getSkinType(); @@ -741,6 +1073,7 @@ public void doDialogue() { }); }); } + sendBackOption(this::openEditMenu); }); } if(nameChange < AGENT_EDIT_NUM){ @@ -789,7 +1122,7 @@ public void doDialogue() { }); return true; }) - .open(p) + .open(player) );} if (typeChange < AGENT_EDIT_NUM) { sendComponent( @@ -797,6 +1130,7 @@ public void doDialogue() { "&8" + BULLET + " &rEntity Type", "&aClick here to change what I am", l -> { + this.spigotCallback.clearCallbacks(player); Utils.msgNoPrefix(player, "&lClick what type you want me to be:", ""); for (EntityType type : AgentEntityTypes.selectableAgentTypes()) { String label = StringUtils.capitalize(type.name().toLowerCase()); @@ -845,10 +1179,11 @@ public void doDialogue() { }) ); } + sendBackOption(this::openEditMenu); } ); } - });} + sendBackOption(this::doDialogue); } @@ -935,9 +1270,10 @@ private void doResponse() { String systemPrompt = plugin.augmentLlmSystemPrompt(plugin.getConfig().getString("llm.system-prompt", "You are a friendly in-game science education assistant. " + "Answer clearly and briefly; keep content appropriate for students.")); + Chatbot llmChatbot = new Chatbot(buildLlmMessageWithHistory(finalResponse)); CompletableFuture.supplyAsync(() -> { try { - return chatbot.generateLlmReply(plugin.getLlmProvider(), systemPrompt); + return llmChatbot.generateLlmReply(plugin.getLlmProvider(), systemPrompt); } catch (Exception ex) { plugin.getLogger().warning("LLM reply failed: " + ex.getMessage()); return null; @@ -946,14 +1282,36 @@ private void doResponse() { if (llmText != null && !llmText.isBlank()) { feedbackOut[0] = llmText; } + recordDiscussionTurn(finalResponse, feedbackOut[0]); storeAndSend.run(); })); } else { + recordDiscussionTurn(finalResponse, feedbackOut[0]); storeAndSend.run(); } } + private String buildLlmMessageWithHistory(String currentMessage) { + if (discussionHistory.isEmpty()) { + return currentMessage; + } + StringBuilder builder = new StringBuilder("Recent conversation history:\n"); + for (String line : discussionHistory) { + builder.append(line).append('\n'); + } + builder.append("\nCurrent player message:\n").append(currentMessage); + return builder.toString(); + } + + private void recordDiscussionTurn(String userMessage, String agentReply) { + discussionHistory.add("User: " + userMessage); + discussionHistory.add("Assistant: " + agentReply); + while (discussionHistory.size() > MAX_DISCUSSION_HISTORY) { + discussionHistory.remove(0); + } + } + private void sendComponent(Player player, String text, String hoverText, Consumer onClick) { player.spigot().sendMessage(createComponent(text, hoverText, onClick)); } diff --git a/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/JourneyGuidanceCatalog.java b/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/JourneyGuidanceCatalog.java new file mode 100644 index 0000000..448d4d6 --- /dev/null +++ b/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/JourneyGuidanceCatalog.java @@ -0,0 +1,217 @@ +package edu.whimc.overworld_agent.dialoguetemplate; + +import com.sk89q.worldedit.bukkit.BukkitAdapter; +import com.sk89q.worldguard.WorldGuard; +import com.sk89q.worldguard.protection.managers.RegionManager; +import com.sk89q.worldguard.protection.regions.ProtectedRegion; +import com.sk89q.worldguard.protection.regions.RegionContainer; +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.configuration.file.FileConfiguration; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * Resolves portal-linked worlds (shared name prefix) and POI destinations for the guidance menu. + */ +public final class JourneyGuidanceCatalog { + + public record Destination(String jtKey, String label) {} + + private JourneyGuidanceCatalog() {} + + /** + * Worlds that share a name prefix with {@code current} (e.g. ColderCold, ColderHot, ColderStrip). + */ + public static List linkedWorlds(World current, FileConfiguration cfg) { + if (current == null) { + return List.of(); + } + String configured = cfg.getString("journey.linked-world-prefix", ""); + if (configured != null && !configured.isBlank()) { + return worldsWithPrefix(configured.trim()); + } + String prefix = detectLinkedPrefix(current.getName(), cfg.getInt("journey.linked-world-min-siblings", 2)); + if (prefix == null || prefix.isBlank()) { + return List.of(current); + } + List linked = worldsWithPrefix(prefix); + return linked.isEmpty() ? List.of(current) : linked; + } + + public static String linkedPrefixFor(World current, FileConfiguration cfg) { + if (current == null) { + return ""; + } + String configured = cfg.getString("journey.linked-world-prefix", ""); + if (configured != null && !configured.isBlank()) { + return configured.trim(); + } + String detected = detectLinkedPrefix(current.getName(), cfg.getInt("journey.linked-world-min-siblings", 2)); + return detected == null ? current.getName() : detected; + } + + public static Set journeyDomains(Collection worlds) { + Set out = new LinkedHashSet<>(); + if (worlds == null) { + return out; + } + for (World world : worlds) { + Integer domain = journeyDomainForWorldSafe(world); + if (domain != null) { + out.add(domain); + } + } + return out; + } + + public static List poiRegionsFromWorldGuard(Collection worlds, FileConfiguration cfg) { + if (worlds == null || worlds.isEmpty() || !cfg.getBoolean("journey.include-poi-regions", true)) { + return List.of(); + } + if (Bukkit.getPluginManager().getPlugin("WorldGuard") == null) { + return List.of(); + } + String poiPrefix = cfg.getString("journey.poi-region-prefix", "poi-"); + if (poiPrefix == null) { + poiPrefix = "poi-"; + } + String prefixLower = poiPrefix.toLowerCase(Locale.ROOT); + Map byKey = new LinkedHashMap<>(); + RegionContainer container = WorldGuard.getInstance().getPlatform().getRegionContainer(); + for (World world : worlds) { + if (world == null) { + continue; + } + RegionManager manager = container.get(BukkitAdapter.adapt(world)); + if (manager == null) { + continue; + } + for (Map.Entry entry : manager.getRegions().entrySet()) { + String regionId = entry.getKey(); + if (regionId == null || !regionId.toLowerCase(Locale.ROOT).startsWith(prefixLower)) { + continue; + } + String key = regionId.toLowerCase(Locale.ROOT); + byKey.putIfAbsent(key, new Destination(key, formatPoiLabel(regionId, poiPrefix))); + } + } + return new ArrayList<>(byKey.values()); + } + + public static List mergeDestinations(Collection... groups) { + Map byKey = new LinkedHashMap<>(); + if (groups == null) { + return List.of(); + } + for (Collection group : groups) { + if (group == null) { + continue; + } + for (Destination d : group) { + if (d != null && d.jtKey() != null && !d.jtKey().isBlank()) { + byKey.putIfAbsent(d.jtKey().toLowerCase(Locale.ROOT), d); + } + } + } + return new ArrayList<>(byKey.values()); + } + + public static String formatPoiLabel(String regionId, String poiPrefix) { + if (regionId == null || regionId.isBlank()) { + return regionId; + } + String body = regionId; + if (poiPrefix != null && !poiPrefix.isBlank() + && regionId.toLowerCase(Locale.ROOT).startsWith(poiPrefix.toLowerCase(Locale.ROOT))) { + body = regionId.substring(poiPrefix.length()); + } + body = body.replace('-', ' ').replace('_', ' ').trim(); + if (body.isBlank()) { + return regionId; + } + StringBuilder out = new StringBuilder(); + for (String word : body.split("\\s+")) { + if (word.isBlank()) { + continue; + } + if (out.length() > 0) { + out.append(' '); + } + out.append(Character.toUpperCase(word.charAt(0))); + if (word.length() > 1) { + out.append(word.substring(1).toLowerCase(Locale.ROOT)); + } + } + return out.toString(); + } + + private static List worldsWithPrefix(String prefix) { + List out = new ArrayList<>(); + for (World world : Bukkit.getWorlds()) { + if (world != null && world.getName().startsWith(prefix)) { + out.add(world); + } + } + return out; + } + + /** + * Longest CamelCase prefix shared by at least {@code minSiblings} worlds (e.g. {@code Colder} from ColderStrip). + */ + static String detectLinkedPrefix(String worldName, int minSiblings) { + if (worldName == null || worldName.length() < 2 || minSiblings < 2) { + return null; + } + String best = null; + for (int i = 1; i < worldName.length(); i++) { + if (!Character.isUpperCase(worldName.charAt(i))) { + continue; + } + String prefix = worldName.substring(0, i); + int siblings = 0; + for (World world : Bukkit.getWorlds()) { + String name = world.getName(); + if (name.startsWith(prefix) && name.length() > prefix.length()) { + siblings++; + } + } + if (siblings >= minSiblings) { + best = prefix; + } + } + return best; + } + + private static Integer journeyDomainForWorldSafe(World world) { + if (world == null) { + return null; + } + try { + Class providerCl = Class.forName("net.whimxiqal.journey.bukkit.JourneyBukkitApiProvider"); + Method get = providerCl.getMethod("get"); + Object api = get.invoke(null); + if (api == null) { + return null; + } + Method toDomain = api.getClass().getMethod("toDomain", World.class); + Object id = toDomain.invoke(api, world); + if (id instanceof Integer) { + return (Integer) id; + } + if (id instanceof Number) { + return ((Number) id).intValue(); + } + } catch (Throwable ignored) { + } + return null; + } +} diff --git a/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/Tag.java b/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/Tag.java deleted file mode 100644 index c6a0cd3..0000000 --- a/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/Tag.java +++ /dev/null @@ -1,218 +0,0 @@ -package edu.whimc.overworld_agent.dialoguetemplate; - -import com.gmail.filoghost.holographicdisplays.api.Hologram; -import com.gmail.filoghost.holographicdisplays.api.HologramsAPI; -import edu.whimc.overworld_agent.OverworldAgent; -import edu.whimc.overworld_agent.dialoguetemplate.models.DialogueTag; -import edu.whimc.overworld_agent.utils.Utils; -import org.bukkit.*; -import org.bukkit.configuration.ConfigurationSection; -import org.bukkit.configuration.file.FileConfiguration; -import org.bukkit.entity.Player; -import org.bukkit.inventory.ItemStack; - -import java.sql.Timestamp; -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.*; -import java.util.stream.Collectors; - -public class Tag { - private static final List tags = new ArrayList<>(); - private static final Map> playerTags = new HashMap<>(); - private Player player; - private String feedback; - private Timestamp tagTime; - private Timestamp tagExpiration; - private Hologram hologram; - private Location viewLocation; - private Location holoLocation; - private Material hologramItem = Material.NAME_TAG; - private OverworldAgent plugin; - private String tagText; - private boolean active; - private int id; - private static boolean display; - private static boolean tagFeedbackEnabled; - private static Map> dialogueTags; - private static String defaultTagFeedback; - private static Map numTagsAllowed; - public Tag(OverworldAgent plugin, Player player, String text){ - this.plugin = plugin; - this.player = player; - feedback = ""; - viewLocation = player.getLocation(); - holoLocation = viewLocation.clone().add(0, 3, 0).add(viewLocation.getDirection().multiply(2)); - this.tagText = text; - int days = plugin.getConfig().getInt("expiration-days"); - tagExpiration = Timestamp.from(Instant.now().plus(days, ChronoUnit.DAYS)); - tagTime = new Timestamp(System.currentTimeMillis()); - active = true; - - if(!playerTags.containsKey(player)){ - playerTags.putIfAbsent(player,new HashMap<>()); - playerTags.get(player).putIfAbsent(player.getWorld(),1); - } else if (!playerTags.get(player).containsKey(player.getWorld())){ - playerTags.get(player).putIfAbsent(player.getWorld(),1); - } else { - int numTags = playerTags.get(player).get(player.getWorld())+1; - playerTags.get(player).put(player.getWorld(), numTags); - } - String[] words = tagText.split(" "); - boolean tagSeen = false; - if (tagFeedbackEnabled) { - if(dialogueTags.containsKey(player.getWorld())) { - for (DialogueTag tag : dialogueTags.get(player.getWorld())) { - for (String alias : tag.getAliases()) { - for (String word : words) { - word = word.toLowerCase(); - if (word.contains(alias)) { - feedback = tag.getFeedback(); - tagSeen = true; - break; - } - } - if(tagSeen){ - break; - } - } - if(tagSeen){ - break; - } - } - if (!tagSeen) { - feedback = defaultTagFeedback; - } - } else { - feedback = "Sorry, feedback is not currently implemented on this world"; - } - } - } - - public static void instantiate(OverworldAgent plugin){ - dialogueTags = new HashMap<>(); - numTagsAllowed = new HashMap<>(); - String path = "tags"; - FileConfiguration config = plugin.getConfig(); - for (String key : config.getConfigurationSection(path).getKeys(false)) { - ConfigurationSection section = config.getConfigurationSection(path + "." + key); - if (key.equalsIgnoreCase("feedback")) { - defaultTagFeedback = section.getString("default"); - tagFeedbackEnabled = section.getBoolean("enabled"); - display = section.getBoolean("holo_visible"); - } else if (key.equalsIgnoreCase("num_tags")) { - for (String world : config.getConfigurationSection(path + "." + key).getKeys(false)) { - numTagsAllowed.put(Bukkit.getWorld(world), config.getConfigurationSection(path + "." + key).getInt(world)); - } - } else { - List> tagEntries = plugin.getConfig().getMapList(path + "." + key); - for (Map tagEntry : tagEntries) { - dialogueTags.putIfAbsent(Bukkit.getWorld(key), new ArrayList<>()); - dialogueTags.get(Bukkit.getWorld(key)).add(new DialogueTag(tagEntry)); - } - } - } - } - public void sendFeedback(){ - - this.plugin.getQueryer().storeNewTag(this, id -> { - if(display) { - createHologram(); - Utils.msg(player, - "&7Your tag has been placed:", - " &8\"&f&l" + tagText + "&8\""); - this.id = id; - tags.add(this); - } - int maxTagsAllowedOnWorld = numTagsAllowed.get(player.getWorld()); - int numTags = playerTags.get(player).get(player.getWorld()); - int numTagsLeft = maxTagsAllowedOnWorld-numTags; - player.sendMessage(feedback); - player.sendMessage("You have " + numTagsLeft + " tags left!"); - }); - } - private void createHologram() { - - Hologram holo = HologramsAPI.createHologram(this.plugin, holoLocation); - - holo.appendItemLine(new ItemStack(hologramItem)); - holo.appendTextLine(Utils.color(tagText)); - holo.appendTextLine(ChatColor.GRAY + player.getName() + " - " + Utils.getDate(tagTime)); - holo.getVisibilityManager().setVisibleByDefault(false); - holo.getVisibilityManager().showTo(player); - if (this.tagExpiration != null) { - holo.appendTextLine(ChatColor.GRAY + "Expires " + Utils.getDate(this.tagExpiration)); - } - this.hologram = holo; - } - public static void startExpiredObservationScanningTask(OverworldAgent plugin) { - Bukkit.getScheduler().scheduleSyncRepeatingTask(plugin, () -> { - Utils.debug("Scanning for expired tags..."); - List toRemove = tags.stream() - .filter(Tag::hasExpired) - .collect(Collectors.toList()); - - int count = toRemove.size(); - toRemove.forEach(tag -> tag.deleteTag()); - - if (count > 0) { - plugin.getQueryer().makeExpiredTagsInactive(dbCount -> { - Utils.debug("Removed " + count + " expired observation(s). (" + dbCount + " from the database)"); - }); - } - }, 20 * 60, 20 * 60); - } - public static List getTagsTabComplete(String hint) { - return tags.stream() - .filter(v -> Integer.toString(v.getId()).startsWith(hint)) - .sorted(Comparator.comparing(Tag::getId)) - .map(v -> Integer.toString(v.getId())) - .collect(Collectors.toList()); - } - public static Tag getTagByID(int id) { - for (Tag tag : tags) { - if (tag.getId() == id) return tag; - } - - return null; - } - public void deleteAndSetInactive(Runnable callback) { - this.plugin.getQueryer().makeSingleTagInactive(this.id, callback); - deleteTag(); - } - public boolean hasExpired() { - return this.tagExpiration != null && Instant.now().isAfter(tagExpiration.toInstant()); - } - public void deleteHologramOnly() { - if (this.hologram != null) { - this.hologram.delete(); - this.hologram = null; - } - } - - public String getTag(){return tagText;} - public void deleteTag() { - deleteHologramOnly(); - active = false; - tags.remove(this); - } - public Location getHoloLocation(){return holoLocation;} - public Player getPlayer(){return player;} - public Timestamp getTagTime(){return tagTime;} - public Timestamp getExpiration(){return tagExpiration;} - public boolean getActive(){return active;} - public int getId(){return id;} - public static Map> getPlayerTags(){ - return playerTags; - } - public static Integer maxTags(World world){ - return numTagsAllowed.get(world); - } - public static Map> getDialogueTags(){ - return dialogueTags; - } - public String getFeedback(){ - return feedback; - } - -} diff --git a/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/models/DialogueTag.java b/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/models/DialogueTag.java deleted file mode 100644 index db62ac9..0000000 --- a/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/models/DialogueTag.java +++ /dev/null @@ -1,33 +0,0 @@ -package edu.whimc.overworld_agent.dialoguetemplate.models; - -import org.bukkit.Bukkit; -import org.bukkit.World; - -import java.util.*; - -/** - * Class to hold entries from config for dialogues - */ -public class DialogueTag { - - private String[] aliases; - - private String feedback; - - @SuppressWarnings("unchecked") - public DialogueTag(Map entry) { - String aliases = (String) entry.get("aliases"); - aliases = aliases.toLowerCase(); - this.aliases = aliases.split(", "); - this.feedback = (String) entry.get("feedback"); - } - - public String[] getAliases() { - return this.aliases; - } - - public String getFeedback() { - return this.feedback; - } - -} \ No newline at end of file diff --git a/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/models/DialogueType.java b/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/models/DialogueType.java deleted file mode 100644 index 9b00b63..0000000 --- a/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/models/DialogueType.java +++ /dev/null @@ -1,16 +0,0 @@ -package edu.whimc.overworld_agent.dialoguetemplate.models; - -import org.apache.commons.lang3.StringUtils; - -public enum DialogueType { - - GUIDE, - - BUILDER - ; - - @Override - public String toString() { - return StringUtils.capitalize(super.toString().toLowerCase()); - } -} diff --git a/src/main/java/edu/whimc/overworld_agent/traits/AgentFollowCatchUp.java b/src/main/java/edu/whimc/overworld_agent/traits/AgentFollowCatchUp.java new file mode 100644 index 0000000..4011d07 --- /dev/null +++ b/src/main/java/edu/whimc/overworld_agent/traits/AgentFollowCatchUp.java @@ -0,0 +1,114 @@ +package edu.whimc.overworld_agent.traits; + +import edu.whimc.overworld_agent.OverworldAgent; +import net.citizensnpcs.api.npc.NPC; +import net.citizensnpcs.trait.FollowTrait; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import org.bukkit.util.Vector; + +/** + * Teleports an agent beside its followed player when they are too far behind, and nudges them off + * the player's position when Citizens {@link FollowTrait} or stuck recovery snaps them on top. + */ +public final class AgentFollowCatchUp { + + private static final String CFG_CATCH_UP_DISTANCE = "agent-follow-catch-up-distance"; + private static final String CFG_CATCH_UP_OFFSET = "agent-follow-catch-up-offset"; + + private AgentFollowCatchUp() {} + + public static double catchUpDistance(OverworldAgent plugin) { + return plugin.getConfig().getDouble(CFG_CATCH_UP_DISTANCE, 16.0); + } + + public static double besideOffset(OverworldAgent plugin) { + return plugin.getConfig().getDouble(CFG_CATCH_UP_OFFSET, 1.5); + } + + /** + * @return the player this agent is following, or null + */ + public static Player followedPlayer(NPC npc) { + if (npc == null || !npc.hasTrait(FollowTrait.class)) { + return null; + } + FollowTrait follow = npc.getTrait(FollowTrait.class); + if (!follow.isEnabled()) { + return null; + } + Entity entity = follow.getFollowing(); + if (entity instanceof Player player && player.isOnline()) { + return player; + } + return null; + } + + /** + * Teleport catch-up when horizontal distance exceeds the configured threshold, or when the agent + * is stacked on the player (Citizens cross-world / stuck recovery). + */ + public static void applyIfNeeded(OverworldAgent plugin, NPC npc, Player player) { + if (plugin == null || npc == null || player == null || !npc.isSpawned() || npc.getEntity() == null) { + return; + } + Location agentLoc = npc.getEntity().getLocation(); + Location playerLoc = player.getLocation(); + if (!agentLoc.getWorld().equals(playerLoc.getWorld())) { + return; + } + + double catchUp = catchUpDistance(plugin); + double horizontal = horizontalDistance(agentLoc, playerLoc); + if (horizontal > catchUp) { + teleportBeside(plugin, npc, player); + } + } + + public static void teleportBeside(OverworldAgent plugin, NPC npc, Player player) { + if (npc == null || player == null || !player.isOnline()) { + return; + } + Location dest = besidePlayer(player, besideOffset(plugin)); + if (dest == null) { + return; + } + if (!npc.isSpawned()) { + npc.spawn(dest); + return; + } + npc.teleport(dest, org.bukkit.event.player.PlayerTeleportEvent.TeleportCause.PLUGIN); + AgentFollowTuning.applyForCurrentEntity(plugin, npc); + } + + /** Spawn / respawn location: beside the player, same world, feet on ground when possible. */ + public static Location besidePlayer(Player player, double offset) { + if (player == null || !player.isOnline()) { + return null; + } + Location base = player.getLocation(); + World world = base.getWorld(); + Vector forward = base.getDirection(); + forward.setY(0); + if (forward.lengthSquared() < 1.0E-4) { + forward = new Vector(0, 0, 1); + } + forward.normalize(); + // Perpendicular "to the right" of where the player is facing. + Vector right = new Vector(-forward.getZ(), 0, forward.getX()).normalize().multiply(offset); + Location dest = base.clone().add(right); + dest.setPitch(base.getPitch()); + dest.setYaw(base.getYaw()); + int groundY = world.getHighestBlockYAt(dest); + dest.setY(groundY + 1.0); + return dest; + } + + private static double horizontalDistance(Location a, Location b) { + double dx = a.getX() - b.getX(); + double dz = a.getZ() - b.getZ(); + return Math.sqrt(dx * dx + dz * dz); + } +} diff --git a/src/main/java/edu/whimc/overworld_agent/traits/AgentFollowCatchUpTrait.java b/src/main/java/edu/whimc/overworld_agent/traits/AgentFollowCatchUpTrait.java new file mode 100644 index 0000000..3ccd1df --- /dev/null +++ b/src/main/java/edu/whimc/overworld_agent/traits/AgentFollowCatchUpTrait.java @@ -0,0 +1,41 @@ +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; + +/** + * Periodic follow catch-up: teleports beside the owner when far behind, and nudges off the player's + * block when Citizens snaps the agent on top (cross-world follow or stuck recovery). + */ +public class AgentFollowCatchUpTrait extends net.citizensnpcs.api.trait.Trait { + + private static final int CHECK_INTERVAL_TICKS = 10; + private final OverworldAgent plugin; + private int tickCounter; + + public AgentFollowCatchUpTrait() { + super("agentfollowcatchup"); + plugin = JavaPlugin.getPlugin(OverworldAgent.class); + } + + @Override + public void run() { + if (!npc.isSpawned() || npc.getEntity() == null) { + return; + } + if (++tickCounter < CHECK_INTERVAL_TICKS) { + return; + } + tickCounter = 0; + if (!npc.hasTrait(FollowTrait.class) || !npc.getTrait(FollowTrait.class).isEnabled()) { + return; + } + Player player = AgentFollowCatchUp.followedPlayer(npc); + if (player == null) { + return; + } + AgentFollowCatchUp.applyIfNeeded(plugin, npc, player); + } +} diff --git a/src/main/java/edu/whimc/overworld_agent/traits/AgentFollowStuckAction.java b/src/main/java/edu/whimc/overworld_agent/traits/AgentFollowStuckAction.java new file mode 100644 index 0000000..358ead8 --- /dev/null +++ b/src/main/java/edu/whimc/overworld_agent/traits/AgentFollowStuckAction.java @@ -0,0 +1,46 @@ +package edu.whimc.overworld_agent.traits; + +import edu.whimc.overworld_agent.OverworldAgent; +import net.citizensnpcs.api.ai.Navigator; +import net.citizensnpcs.api.ai.StuckAction; +import net.citizensnpcs.api.npc.NPC; +import org.bukkit.entity.Player; +import org.bukkit.plugin.java.JavaPlugin; + +/** + * Replaces Citizens default {@code TeleportStuckAction}, which snaps the NPC onto the follow target. + * Only catch-up teleports when the followed player is beyond {@link AgentFollowCatchUp#catchUpDistance}. + */ +public final class AgentFollowStuckAction implements StuckAction { + + private final OverworldAgent plugin; + + public AgentFollowStuckAction() { + plugin = JavaPlugin.getPlugin(OverworldAgent.class); + } + + @Override + public boolean run(NPC npc, Navigator navigator) { + if (npc == null || !npc.isSpawned() || npc.getEntity() == null || navigator == null) { + return false; + } + Player player = AgentFollowCatchUp.followedPlayer(npc); + if (player == null) { + return false; + } + double horizontal = horizontalDistance(npc.getEntity().getLocation(), player.getLocation()); + if (horizontal <= AgentFollowCatchUp.catchUpDistance(plugin)) { + navigator.setTarget(player, false); + return true; + } + AgentFollowCatchUp.teleportBeside(plugin, npc, player); + navigator.setTarget(player, false); + return true; + } + + private static double horizontalDistance(org.bukkit.Location a, org.bukkit.Location b) { + double dx = a.getX() - b.getX(); + double dz = a.getZ() - b.getZ(); + return Math.sqrt(dx * dx + dz * dz); + } +} 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 4b9284f..26298ec 100644 --- a/src/main/java/edu/whimc/overworld_agent/traits/AgentFollowTuning.java +++ b/src/main/java/edu/whimc/overworld_agent/traits/AgentFollowTuning.java @@ -20,6 +20,8 @@ public final class AgentFollowTuning { private static final String CFG_MOB_RANGE = "agent-mob-follow-path-range"; private static final String CFG_MOB_MARGIN = "agent-mob-follow-margin"; + private static final AgentFollowStuckAction PLAYER_STUCK_ACTION = new AgentFollowStuckAction(); + private AgentFollowTuning() {} /** @@ -35,6 +37,7 @@ public static void scheduleFollowAndApplyTraits(OverworldAgent plugin, NPC npc, return; } npc.getOrAddTrait(FollowTrait.class).follow(player); + npc.getOrAddTrait(AgentFollowCatchUpTrait.class); npc.getOrAddTrait(AgentPermanentFlyingTrait.class).applyFlyingForCurrentEntity(); }); } @@ -58,10 +61,14 @@ public static void applyForPlannedType(OverworldAgent plugin, NPC npc, EntityTyp npc.getNavigator().getLocalParameters().destinationTeleportMargin(destTele); npc.getNavigator().getDefaultParameters().stationaryTicks(stationaryTicks); npc.getNavigator().getLocalParameters().stationaryTicks(stationaryTicks); - // After a mob agent, default params may still carry low straight-line thresholds — ground NPCs need pathfinding. - float straight = Math.max(range, 32.0f); - npc.getNavigator().getDefaultParameters().straightLineTargetingDistance(straight); - npc.getNavigator().getLocalParameters().straightLineTargetingDistance(straight); + // Walk like the original guide agents: straight-line targeting makes Citizens steer + // directly at the player (gliding over terrain) instead of A* pathfinding + walking. + // 0 disables it so player-shaped agents always pathfind and WALK while following + // (Citizens wiki "Making an NPC Move": navigator.setTarget -> pathfind). + npc.getNavigator().getDefaultParameters().straightLineTargetingDistance(0); + npc.getNavigator().getLocalParameters().straightLineTargetingDistance(0); + npc.getNavigator().getDefaultParameters().stuckAction(PLAYER_STUCK_ACTION); + npc.getNavigator().getLocalParameters().stuckAction(PLAYER_STUCK_ACTION); ft.setFollowingMargin(margin); } else { float range = (float) plugin.getConfig().getDouble(CFG_MOB_RANGE, 5); 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 9a5a736..42dc430 100644 --- a/src/main/java/edu/whimc/overworld_agent/traits/AgentPermanentFlyingTrait.java +++ b/src/main/java/edu/whimc/overworld_agent/traits/AgentPermanentFlyingTrait.java @@ -57,12 +57,18 @@ 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); } - float baseline = npc.getNavigator().getDefaultParameters().speed(); - npc.getNavigator().getLocalParameters().speedModifier(baseline); + // 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)) { AgentFollowTuning.applyForCurrentEntity(plugin, npc); } @@ -72,15 +78,12 @@ public void applyFlyingForCurrentEntity() { Gravity gravity = npc.getOrAddTrait(Gravity.class); gravity.setHasGravity(false); - float baseline = npc.getNavigator().getDefaultParameters().speed(); npc.setFlyable(true); double mult = plugin.getConfig().getDouble(CONFIG_SPEED_MULT_KEY, 1.65); - if (mult > 0) { - npc.getNavigator().getLocalParameters().speedModifier(baseline * (float) mult); - } else { - npc.getNavigator().getLocalParameters().speedModifier(baseline); - } + float modifier = mult > 0 ? (float) mult : 1.0F; + npc.getNavigator().getDefaultParameters().speedModifier(modifier); + npc.getNavigator().getLocalParameters().speedModifier(modifier); if (npc.hasTrait(FollowTrait.class)) { AgentFollowTuning.applyForCurrentEntity(plugin, npc); 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 8df39c0..7e47275 100644 --- a/src/main/java/edu/whimc/overworld_agent/traits/RebuilderTrait.java +++ b/src/main/java/edu/whimc/overworld_agent/traits/RebuilderTrait.java @@ -90,8 +90,11 @@ public void run() { if(npc.isSpawned() && target != null && Bukkit.getPlayer(target) != null){ if (!npc.getEntity().getWorld().equals(Bukkit.getPlayer(target).getWorld())) { if (Settings.Setting.FOLLOW_ACROSS_WORLDS.asBoolean()) { + Player follower = Bukkit.getPlayer(target); npc.despawn(); - npc.spawn(Bukkit.getPlayer(target).getLocation()); + npc.spawn(AgentFollowCatchUp.besidePlayer(follower, AgentFollowCatchUp.besideOffset(plugin))); + AgentFollowTuning.applyForCurrentEntity(plugin, npc); + AgentFollowTuning.scheduleFollowAndApplyTraits(plugin, npc, follower); } return; } diff --git a/src/main/java/edu/whimc/overworld_agent/traits/SpawnExpertTrait.java b/src/main/java/edu/whimc/overworld_agent/traits/SpawnExpertTrait.java index c986b8b..7da5c9f 100644 --- a/src/main/java/edu/whimc/overworld_agent/traits/SpawnExpertTrait.java +++ b/src/main/java/edu/whimc/overworld_agent/traits/SpawnExpertTrait.java @@ -92,7 +92,7 @@ public void run() { if (Settings.Setting.FOLLOW_ACROSS_WORLDS.asBoolean()) { Player follower = Bukkit.getPlayer(player); npc.despawn(); - npc.spawn(follower.getLocation()); + npc.spawn(AgentFollowCatchUp.besidePlayer(follower, AgentFollowCatchUp.besideOffset(plugin))); AgentFollowTuning.applyForCurrentEntity(plugin, npc); AgentFollowTuning.scheduleFollowAndApplyTraits(plugin, npc, follower); } diff --git a/src/main/java/edu/whimc/overworld_agent/utils/Utils.java b/src/main/java/edu/whimc/overworld_agent/utils/Utils.java index c38664c..b55bd96 100644 --- a/src/main/java/edu/whimc/overworld_agent/utils/Utils.java +++ b/src/main/java/edu/whimc/overworld_agent/utils/Utils.java @@ -14,7 +14,6 @@ import java.util.List; import java.util.stream.Collectors; import java.util.stream.Stream; -import edu.whimc.overworld_agent.dialoguetemplate.Tag; public class Utils { @@ -160,19 +159,4 @@ public static List getWorldsTabComplete(String hint) { .collect(Collectors.toList()); } - public static Tag getTagWithError(CommandSender sender, String strId) { - Integer id = parseIntWithError(sender, strId); - if (id == null) { - return null; - } - Tag tag = Tag.getTagByID(id); - - if (tag == null) { - Utils.msg(sender, "&c\"&4" + id + "&c\" is not a valid tag id!"); - return null; - } - - return tag; - } - } \ No newline at end of file diff --git a/src/main/java/edu/whimc/overworld_agent/utils/sql/Queryer.java b/src/main/java/edu/whimc/overworld_agent/utils/sql/Queryer.java index fc3f297..0cf2337 100644 --- a/src/main/java/edu/whimc/overworld_agent/utils/sql/Queryer.java +++ b/src/main/java/edu/whimc/overworld_agent/utils/sql/Queryer.java @@ -3,7 +3,7 @@ import edu.whimc.overworld_agent.OverworldAgent; import edu.whimc.overworld_agent.dialoguetemplate.Interaction; -import edu.whimc.overworld_agent.dialoguetemplate.Tag; +import edu.whimc.overworld_agent.dialoguetemplate.JourneyGuidanceCatalog; import edu.whimc.overworld_agent.dialoguetemplate.models.BuildTemplate; import edu.whimc.overworld_agent.llm.context.AgentChatContextItem; import edu.whimc.overworld_agent.llm.context.AgentChatEvent; @@ -19,7 +19,10 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; +import java.util.Map; import java.util.function.Consumer; /** @@ -46,13 +49,6 @@ public class Queryer { /** * Query for inserting a progress entry into the database. */ - private static final String QUERY_SAVE_TAG = - "INSERT INTO whimc_tags " + - "(uuid, username, world, x, y, z, time, tag, active, expiration) " + - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; - /** - * Query for inserting a progress entry into the database. - */ private static final String QUERY_SAVE_INTERACTION = "INSERT INTO whimc_dialogue_interaction" + "(uuid, username, world, time, interaction, x, y, z) " + @@ -82,18 +78,6 @@ public class Queryer { private static final String QUERY_GET_SESSION_CONVERSATION = "SELECT * FROM whimc_dialog_science "+ "WHERE uuid=? AND time > ? "; - private static final String QUERY_MAKE_EXPIRED_INACTIVE = - "UPDATE whimc_tags " + - "SET active=0 " + - "WHERE ? > expiration"; - /** - * Query for making an observation inactive. - */ - private static final String QUERY_MAKE_TAG_INACTIVE = - "UPDATE whimc_tags " + - "SET active=0 " + - "WHERE rowid=? AND active=1"; - /** * Query for saving an agent chat conversation. */ @@ -133,6 +117,11 @@ public class Queryer { "(turn_id, time, event_type, event_payload) " + "VALUES (?, ?, ?, ?)"; + private static final String QUERY_LIST_POI_REGIONS = + "SELECT r.id, w.name AS world_name FROM %sregion r " + + "INNER JOIN %sworld w ON r.world_id = w.id " + + "WHERE r.id LIKE ? AND w.name LIKE ?"; + private final OverworldAgent plugin; private final MySQLConnection sqlConnection; @@ -246,53 +235,6 @@ public void storeNewScienceInquiry(Player player, String inquiry, String respons }); } - /** - * Generated a PreparedStatement for saving a new progress session. - * @param connection MySQL Connection - * @param tag player tag put into overworld - * @return PreparedStatement - * @throws SQLException - */ - private PreparedStatement insertTag(Connection connection, Tag tag) throws SQLException { - PreparedStatement statement = connection.prepareStatement(QUERY_SAVE_TAG, Statement.RETURN_GENERATED_KEYS); - statement.setString(1, tag.getPlayer().getUniqueId().toString()); - statement.setString(2, tag.getPlayer().getName()); - statement.setString(3, tag.getPlayer().getWorld().getName()); - statement.setDouble(4, tag.getHoloLocation().getX()); - statement.setDouble(5, tag.getHoloLocation().getY()); - statement.setDouble(6, tag.getHoloLocation().getZ()); - statement.setLong(7, tag.getTagTime().getTime()); - statement.setString(8, tag.getTag()); - statement.setBoolean(9, tag.getActive()); - statement.setLong(10, tag.getExpiration().getTime()); - return statement; - } - - /** - * Stores a progress command into the database and returns the obervation's ID - * @param tag tag player placed - * @param callback Function to call once the observation has been saved - */ - public void storeNewTag(Tag tag, Consumer callback) { - async(() -> { - - try (Connection connection = this.sqlConnection.getConnection()) { - try (PreparedStatement statement = insertTag(connection, tag)) { - statement.executeUpdate(); - - try (ResultSet idRes = statement.getGeneratedKeys()) { - idRes.next(); - int id = idRes.getInt(1); - - sync(callback, id); - } - } - } catch (SQLException e) { - e.printStackTrace(); - } - }); - } - /** * Generated a PreparedStatement for saving a new progress session. * @param connection MySQL Connection @@ -487,38 +429,6 @@ public void getBuildTemplate(int buildID, Player sender, Consumer callback){ }); } - /** - * Makes an observation inactive in the database. - * - * @param id Id of the observation - */ - public void makeSingleTagInactive(int id, Runnable callback) { - async(() -> { - try (Connection connection = this.sqlConnection.getConnection()) { - try (PreparedStatement statement = connection.prepareStatement(QUERY_MAKE_TAG_INACTIVE)) { - statement.setInt(1, id); - statement.executeUpdate(); - sync(callback); - } - } catch (SQLException exc) { - exc.printStackTrace(); - } - }); - } - - public void makeExpiredTagsInactive(Consumer callback) { - async(() -> { - try (Connection connection = this.sqlConnection.getConnection()) { - try (PreparedStatement statement = connection.prepareStatement(QUERY_MAKE_EXPIRED_INACTIVE)) { - statement.setLong(1, System.currentTimeMillis()); - sync(callback, statement.executeUpdate()); - } - } catch (SQLException e) { - e.printStackTrace(); - } - }); - } - public void storeAgentChatResearchTurn( String conversationId, String turnId, @@ -950,5 +860,49 @@ private void async(Runnable runnable) { Bukkit.getScheduler().runTaskAsynchronously(this.plugin, runnable); } + /** + * WorldGuard MySQL POI regions ({@code poi-*} ids in {@code rg_region}) for worlds sharing a name prefix. + */ + public void listPoiRegions(String worldNamePrefix, String poiRegionPrefix, String worldguardTablePrefix, + Consumer> callback) { + if (callback == null) { + return; + } + String worldPrefix = worldNamePrefix == null ? "" : worldNamePrefix.trim(); + String poiPrefix = poiRegionPrefix == null ? "poi-" : poiRegionPrefix; + String tablePrefix = worldguardTablePrefix == null ? "rg_" : worldguardTablePrefix; + async(() -> { + List out = new ArrayList<>(); + try (Connection connection = this.sqlConnection.getConnection()) { + if (connection == null) { + sync(callback, out); + return; + } + String sql = String.format(QUERY_LIST_POI_REGIONS, tablePrefix, tablePrefix); + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, poiPrefix + "%"); + statement.setString(2, worldPrefix + "%"); + try (ResultSet results = statement.executeQuery()) { + Map byKey = new LinkedHashMap<>(); + while (results.next()) { + String id = results.getString("id"); + if (id == null || id.isBlank()) { + continue; + } + String key = id.toLowerCase(Locale.ROOT); + byKey.putIfAbsent(key, new JourneyGuidanceCatalog.Destination( + key, + JourneyGuidanceCatalog.formatPoiLabel(id, poiPrefix))); + } + out.addAll(byKey.values()); + } + } + } catch (SQLException exc) { + plugin.getLogger().warning("Failed to load POI regions from WorldGuard tables: " + exc.getMessage()); + } + sync(callback, out); + }); + } + } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 31abb93..cfd4a77 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -9,7 +9,17 @@ journey: journey-command-root: journey # When true, logs Journey domain / public waypoint counts when opening the guidance menu (console). debug-log: false -expiration-days: 7 + # Optional override for portal-linked world clusters (e.g. Colder). When unset, auto-detects from CamelCase names. + linked-world-prefix: "" + # Minimum number of worlds sharing a prefix before treating them as linked (auto-detect only). + linked-world-min-siblings: 2 + # Include WorldGuard / DB regions whose id starts with poi- (also used as Journey name_id when registered). + include-poi-regions: true + poi-region-prefix: "poi-" + # worldguard = in-memory regions; database = rg_region SQL; both = merge (recommended). + poi-source: both + # Table prefix for WorldGuard MySQL storage (rg_region, rg_world). + worldguard-table-prefix: rg_ agent_type: scientist_casual # 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 @@ -23,6 +33,10 @@ agent-player-follow-margin: 2.5 agent-player-nav-destination-teleport-margin: -1 # Ticks the NPC can stand still before navigation is cancelled as STUCK; higher avoids premature cancel on slow paths. agent-player-nav-stationary-ticks: 1200 +# When horizontal distance to the owner exceeds this, the agent catch-up teleports beside them (not on top). +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 @@ -53,17 +67,6 @@ llm: enabled: true radius: 25.0 max-items: 3 -tags: - num_tags: - NoMoon: 25 - feedback: - holo_visible: false - enabled: true - default: "Great observation! I'll take note of your discovery. Please continue thinking about the science concepts unique to this world. Feel free to show me something else or ask me about anything!" - NoMoon: - - tag: - aliases: tree - feedback: "The trees look kinda funny don't they? This is a really neat effect of the extreme wind as a result of having no moon. Why do you think they look this way?" prompts: - label: 0 prompt: agent @@ -193,9 +196,8 @@ template-gui: custom-response-sign-header: "&f&nYour response" end-your-own-response-speech: "&f&nClick here to stop query" guidance-response: "&f&nCan you show me something cool?" - show-response: "&f&nI want to show you something unique to this environment!" score-response: "&f&nI want to see my scores" - tag-score-response: "&f&nI want to see my tag scores" + build-response: "&f&nI want to build something!" agent-edit: "&f&nI want to edit my agent" filler-item: white_stained_glass_pane inventory-name: "&lTopics" diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 56fb312..68f1991 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -15,9 +15,6 @@ commands: oacallback: description: Internal callback command for chat UI usage: '/oacallback ' - admintags: - description: Manage tags - usage: '/admintags' assess-habitat: description: command to assess habitat usage: '/assess-habitat' \ No newline at end of file