From b5541ce99966a6c398701910b4c63646e15b1883 Mon Sep 17 00:00:00 2001 From: geph Date: Fri, 5 Jun 2026 16:00:15 -0600 Subject: [PATCH 01/15] Add GitHub Actions CI and automated release workflows. Build and security-check pull requests to main, publish a Maven-built JAR on merge, and open a follow-up PR to bump the patch version in pom.xml. Co-authored-by: Cursor --- .github/workflows/ci.yml | 25 ++++++++++ .github/workflows/codeql.yml | 23 +++++++++ .github/workflows/dependency-review.yml | 17 +++++++ .github/workflows/release.yml | 64 +++++++++++++++++++++++++ .github/workflows/trufflehog.yml | 20 ++++++++ 5 files changed, 149 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/dependency-review.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/trufflehog.yml 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..5e526e6 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,23 @@ +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: github/codeql-action/init@v3 + with: + languages: java + + - uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..82e9101 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,17 @@ +name: Dependency Review + +on: + pull_request: + branches: [main] + +permissions: + contents: read + pull-requests: write + +jobs: + dependency-review: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/dependency-review-action@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ab120d5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,64 @@ +name: Release + +on: + push: + branches: [main] + +concurrency: + group: release-main + cancel-in-progress: false + +permissions: + contents: write + +jobs: + release: + if: ${{ !startsWith(github.event.head_commit.message, 'chore(release):') }} + 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: Commit version bump, tag, and publish release + env: + NEW_VERSION: ${{ steps.version.outputs.new }} + GH_TOKEN: ${{ github.token }} + run: | + JAR="target/WHIMC-OverworldAgent-${NEW_VERSION}.jar" + test -f "${JAR}" + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add pom.xml + git commit -m "chore(release): bump version to ${NEW_VERSION}" + git tag "v${NEW_VERSION}" + + git push origin main + git push origin "v${NEW_VERSION}" + + gh release create "v${NEW_VERSION}" \ + "${JAR}" \ + --title "v${NEW_VERSION}" \ + --generate-notes 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 From 1688885877dab84c20b0359a499473e31f14f9b3 Mon Sep 17 00:00:00 2001 From: geph Date: Fri, 5 Jun 2026 16:04:10 -0600 Subject: [PATCH 02/15] Fix CI checks for Maven project and disabled dependency graph. Run Maven build before CodeQL analysis and replace GitHub dependency review with a Maven dependency audit job. Co-authored-by: Cursor --- .github/workflows/codeql.yml | 9 +++++++++ .github/workflows/dependency-audit.yml | 26 +++++++++++++++++++++++++ .github/workflows/dependency-review.yml | 17 ---------------- .github/workflows/release.yml | 25 ++++++++++++++---------- 4 files changed, 50 insertions(+), 27 deletions(-) create mode 100644 .github/workflows/dependency-audit.yml delete mode 100644 .github/workflows/dependency-review.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 5e526e6..28de177 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -16,8 +16,17 @@ jobs: 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..e6c6341 --- /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: Resolve Maven dependencies + run: mvn -B -ntp dependency:go-offline + + - name: Analyze dependency tree + run: mvn -B -ntp dependency:analyze-only -DignoreNonCompile=true diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml deleted file mode 100644 index 82e9101..0000000 --- a/.github/workflows/dependency-review.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: Dependency Review - -on: - pull_request: - branches: [main] - -permissions: - contents: read - pull-requests: write - -jobs: - dependency-review: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/dependency-review-action@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ab120d5..3a604b3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,7 @@ concurrency: permissions: contents: write + pull-requests: write jobs: release: @@ -41,7 +42,7 @@ jobs: - name: Build release JAR run: mvn -B -ntp package - - name: Commit version bump, tag, and publish release + - name: Publish GitHub Release env: NEW_VERSION: ${{ steps.version.outputs.new }} GH_TOKEN: ${{ github.token }} @@ -49,16 +50,20 @@ jobs: JAR="target/WHIMC-OverworldAgent-${NEW_VERSION}.jar" test -f "${JAR}" - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add pom.xml - git commit -m "chore(release): bump version to ${NEW_VERSION}" - git tag "v${NEW_VERSION}" - - git push origin main - git push origin "v${NEW_VERSION}" - 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 }}). + labels: release + add-paths: pom.xml From c9e0342e2cf9ddbe1f9471db12ab432f294eb0f8 Mon Sep 17 00:00:00 2001 From: geph Date: Fri, 5 Jun 2026 16:17:57 -0600 Subject: [PATCH 03/15] Fix dependency-audit for system-scoped lib JARs. Replace dependency:go-offline (which tried to fetch local system dependencies from Maven repos) with dependency:tree and analyze-only. Clarify release workflow is GitHub Releases only, not server deploy. Co-authored-by: Cursor --- .github/workflows/dependency-audit.yml | 6 +++--- .github/workflows/release.yml | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/dependency-audit.yml b/.github/workflows/dependency-audit.yml index e6c6341..348a306 100644 --- a/.github/workflows/dependency-audit.yml +++ b/.github/workflows/dependency-audit.yml @@ -19,8 +19,8 @@ jobs: java-version: "21" cache: maven - - name: Resolve Maven dependencies - run: mvn -B -ntp dependency:go-offline + - name: Verify dependency resolution + run: mvn -B -ntp dependency:tree -Dverbose=false - - name: Analyze dependency tree + - 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 index 3a604b3..2c1ee42 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,3 +1,5 @@ +# Publishes a downloadable JAR to GitHub Releases only. +# Minecraft server install is manual — no deploy steps or secrets. name: Release on: @@ -65,5 +67,7 @@ jobs: 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 From 320b94680327aae4860823f5bb93d3db7d547904 Mon Sep 17 00:00:00 2001 From: Geph Date: Wed, 10 Jun 2026 09:30:41 -0600 Subject: [PATCH 04/15] Enable automated releases on animal-ai pushes and prevent release loops. Co-authored-by: Cursor --- .github/workflows/release.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2c1ee42..1c2bd0f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,10 +4,10 @@ name: Release on: push: - branches: [main] + branches: [main, animal-ai] concurrency: - group: release-main + group: release-${{ github.ref_name }} cancel-in-progress: false permissions: @@ -16,7 +16,8 @@ permissions: jobs: release: - if: ${{ !startsWith(github.event.head_commit.message, 'chore(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 From 9446957ad1d23f89f96e71dfc05274ef890fcc7c Mon Sep 17 00:00:00 2001 From: Geph Date: Wed, 10 Jun 2026 09:32:22 -0600 Subject: [PATCH 05/15] Quote release PR title and commit message to fix YAML syntax. Co-authored-by: Cursor --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1c2bd0f..1e7635d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -64,8 +64,8 @@ jobs: 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 }} + 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 }}). From 816a7cae33d21bf2e7ed3d56e2e19b2d755553c1 Mon Sep 17 00:00:00 2001 From: Geph Date: Wed, 10 Jun 2026 17:26:22 -0600 Subject: [PATCH 06/15] Improve QRF agent UX, movement, and Journey guidance across linked worlds. Rename build to WHIMC-QRF-Agent, remove tagging, merge builder into the main dialogue with go-back navigation, fix player-shaped agents to walk-follow, add catch-up teleport beside the owner when far behind, and expand Journey guidance with name_id resolution, linked-world clusters, and poi-* region discovery. Co-authored-by: Cursor --- .github/workflows/release.yml | 2 +- README.md | 90 ++- example-config.yml | 262 +------- pom.xml | 4 +- .../edu/whimc/overworld_agent/Listeners.java | 2 + .../whimc/overworld_agent/OverworldAgent.java | 14 +- .../commands/AgentsCommand.java | 1 - .../commands/TagAdminCommand.java | 63 -- .../subcommands/ChangeAgentTypeCommand.java | 62 -- .../commands/subcommands/ChatCommand.java | 23 +- .../subcommands/ExpertSpawnCommand.java | 2 + .../subcommands/TagsRemoveCommand.java | 35 - .../dialoguetemplate/BuilderDialogue.java | 26 + .../ChatTextInputFactory.java | 57 +- .../dialoguetemplate/Dialogue.java | 622 ++++++++++++++---- .../JourneyGuidanceCatalog.java | 217 ++++++ .../overworld_agent/dialoguetemplate/Tag.java | 218 ------ .../dialoguetemplate/models/DialogueTag.java | 33 - .../dialoguetemplate/models/DialogueType.java | 16 - .../traits/AgentFollowCatchUp.java | 114 ++++ .../traits/AgentFollowCatchUpTrait.java | 41 ++ .../traits/AgentFollowStuckAction.java | 46 ++ .../traits/AgentFollowTuning.java | 15 +- .../traits/AgentPermanentFlyingTrait.java | 19 +- .../traits/RebuilderTrait.java | 5 +- .../traits/SpawnExpertTrait.java | 2 +- .../whimc/overworld_agent/utils/Utils.java | 16 - .../overworld_agent/utils/sql/Queryer.java | 152 ++--- src/main/resources/config.yml | 30 +- src/main/resources/plugin.yml | 3 - 30 files changed, 1166 insertions(+), 1026 deletions(-) delete mode 100644 src/main/java/edu/whimc/overworld_agent/commands/TagAdminCommand.java delete mode 100644 src/main/java/edu/whimc/overworld_agent/commands/subcommands/ChangeAgentTypeCommand.java delete mode 100644 src/main/java/edu/whimc/overworld_agent/commands/subcommands/TagsRemoveCommand.java create mode 100644 src/main/java/edu/whimc/overworld_agent/dialoguetemplate/JourneyGuidanceCatalog.java delete mode 100644 src/main/java/edu/whimc/overworld_agent/dialoguetemplate/Tag.java delete mode 100644 src/main/java/edu/whimc/overworld_agent/dialoguetemplate/models/DialogueTag.java delete mode 100644 src/main/java/edu/whimc/overworld_agent/dialoguetemplate/models/DialogueType.java create mode 100644 src/main/java/edu/whimc/overworld_agent/traits/AgentFollowCatchUp.java create mode 100644 src/main/java/edu/whimc/overworld_agent/traits/AgentFollowCatchUpTrait.java create mode 100644 src/main/java/edu/whimc/overworld_agent/traits/AgentFollowStuckAction.java diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1e7635d..b198b1e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,7 +50,7 @@ jobs: NEW_VERSION: ${{ steps.version.outputs.new }} GH_TOKEN: ${{ github.token }} run: | - JAR="target/WHIMC-OverworldAgent-${NEW_VERSION}.jar" + JAR="target/WHIMC-QRF-Agent-${NEW_VERSION}.jar" test -f "${JAR}" gh release create "v${NEW_VERSION}" \ 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..8b566dd 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 + WHIMC-QRF-Agent 3.3.10 - WHIMC Overworld Agent + 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 From 01d06fa8eda79a339be19fbf1a876b0d3c911b96 Mon Sep 17 00:00:00 2001 From: Geph Date: Wed, 10 Jun 2026 17:30:11 -0600 Subject: [PATCH 07/15] chore(release): sync pom version to 3.3.11 so CI publishes v3.3.12 v3.3.11 tag already exists from a prior release run; bumping pom avoids duplicate-tag failures on deploy. Co-authored-by: Cursor --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 8b566dd..3d5e254 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ 4.0.0 edu.whimc WHIMC-QRF-Agent - 3.3.10 + 3.3.11 WHIMC QRF Agent Defines overworld agent traits From 0335ce764f1db901b795a851e0735f706e1e5264 Mon Sep 17 00:00:00 2001 From: Geph Date: Wed, 10 Jun 2026 17:30:30 -0600 Subject: [PATCH 08/15] Trigger GitHub release for latest animal-ai changes Co-authored-by: Cursor From 350b2301804a6e87ba36ba841e0bd3bbd23e27a4 Mon Sep 17 00:00:00 2001 From: Geph Date: Wed, 24 Jun 2026 02:08:43 -0600 Subject: [PATCH 09/15] Unify /agent command, add world LLM prompts, RAG docs, and Journey LLM bridge. Merge /agents into /agent, per-world prompts with reload, doc/docx RAG, journey-actions for chat navigation, and safer Journey dispatch defaults. Co-authored-by: Cursor --- README.md | 111 +++-- example-config.yml | 19 +- pom.xml | 10 + .../whimc/overworld_agent/OverworldAgent.java | 47 +- .../commands/AgentCommand.java | 25 +- .../commands/AgentsCommand.java | 70 --- .../commands/subcommands/ChatCommand.java | 16 +- .../subcommands/ReloadLlmPromptCommand.java | 92 ++++ .../dialoguetemplate/Dialogue.java | 414 ++++++++++++------ .../dialoguetemplate/JourneyLlmBridge.java | 154 +++++++ .../models/llm/LlmConfigAnnouncer.java | 99 +++++ .../models/llm/LlmRagContextBuilder.java | 58 ++- .../models/llm/RagDocumentReader.java | 52 +++ .../models/llm/WorldLlmPrompt.java | 42 ++ .../models/llm/WorldLlmPromptRegistry.java | 248 +++++++++++ src/main/resources/config.yml | 16 +- src/main/resources/plugin.yml | 8 +- src/main/resources/world-prompts/colder.yml | 9 + src/main/resources/world-prompts/default.yml | 9 + 19 files changed, 1213 insertions(+), 286 deletions(-) delete mode 100644 src/main/java/edu/whimc/overworld_agent/commands/AgentsCommand.java create mode 100644 src/main/java/edu/whimc/overworld_agent/commands/subcommands/ReloadLlmPromptCommand.java create mode 100644 src/main/java/edu/whimc/overworld_agent/dialoguetemplate/JourneyLlmBridge.java create mode 100644 src/main/java/edu/whimc/overworld_agent/dialoguetemplate/models/llm/LlmConfigAnnouncer.java create mode 100644 src/main/java/edu/whimc/overworld_agent/dialoguetemplate/models/llm/RagDocumentReader.java create mode 100644 src/main/java/edu/whimc/overworld_agent/dialoguetemplate/models/llm/WorldLlmPrompt.java create mode 100644 src/main/java/edu/whimc/overworld_agent/dialoguetemplate/models/llm/WorldLlmPromptRegistry.java create mode 100644 src/main/resources/world-prompts/colder.yml create mode 100644 src/main/resources/world-prompts/default.yml diff --git a/README.md b/README.md index 85da51d..1a2fb86 100644 --- a/README.md +++ b/README.md @@ -4,17 +4,17 @@ QRF-Agent is a Minecraft plugin to create and define agent behavior (forked from To select the agent right click on the desired agent and to start a conversation with your agent also right click on them. To select a dialogue option must click enter then left click on the desired option. -Alternatively you can spawn a guide agent with **`/agents spawn`** or **`/agent spawn`** (same implementation; permission is always **`whimc-agent.agents.spawn`** because the handler is registered under the `agents` permission namespace). +Alternatively you can spawn a guide agent with **`/agent spawn`** (`/agents` is an alias). **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:** `/agent 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:** `/agent 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:** `/agent 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. -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. +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 **`/agent rebuilderspawn`** and interacted with like a guide agent. _**Requires Java 21+**_ @@ -101,9 +101,53 @@ RAG (retrieval-augmented generation) here means: **optional** inclusion of plain | `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`). | +| `llm.rag.include-extensions` | File extensions to read (default `txt`, `md`, `doc`, `docx`). Word files are converted to plain text via Apache POI. | -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`, `.doc`, or `.docx` 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. + +#### Per-world prompts (`world-prompts/`) + +Long or world-specific system prompts live in **`plugins/WHIMC-QRF-Agent/world-prompts/*.yml`** instead of the main `config.yml`. Each file can set: + +| Key | Description | +|-----|-------------| +| `world` | Single Bukkit world name this prompt applies to. | +| `worlds` | List of world names sharing one prompt (e.g. `ColderStrip`, `ColderHot`, `ColderCold`). | +| `prompt` | Multi-line system prompt text for those worlds. | +| `rag-directory` | Optional folder under the plugin data directory for world-specific RAG (overrides global `llm.context-directory` for that world). | + +If no file matches the player's world, **`world-prompts/default.yml`** is used; if that is missing, the plugin falls back to **`llm.system-prompt`** in `config.yml`. + +**Per-world `rag-directory`** (in a world-prompt YAML) is appended whenever that world’s prompt is built — it does **not** require `llm.rag.enabled: true` in the main config. Global `llm.rag` only applies to the fallback `llm.system-prompt` path. + +Reload without restart: **`/agent reload_llm_prompt`** (optional world name). In-game output shows prompt size; **`(RAG appended)`** means files from `rag-directory` were included. For a per-file list, check the server console **`[OverworldAgent][LLM]`** block on startup/reload (or set `llm.debug-log: true`). + +On **startup** and after **`reload_llm_prompt`**, the console logs an **`[OverworldAgent][LLM]`** summary: provider, model, `use-for-reply`, global RAG directory and document list, each loaded world-prompt file with bound worlds, and per-world RAG directories with their document filenames. + +#### LLM Journey actions (`llm.journey-actions`) + +When **Journey** is installed and the player uses **free discussion** with `llm.use-for-reply: true`, the plugin can start navigation from chat: + +| Key | Description | +|-----|-------------| +| `llm.journey-actions.enabled` | When `true`, appends the Journey destination catalog to the system prompt and may run `/journey server waypoint …` after the LLM reply. | +| `llm.journey-actions.max-destinations` | Max destinations listed in the prompt (default `60`). | + +The model may end its reply with a line `JOURNEY:` (stripped before the player sees it). The plugin also fuzzy-matches the player’s message against known `name_id` / labels. + +#### Journey guidance dispatch (`journey.*`) + +Guidance menu clicks and PMML **guidance** intents dispatch navigation as the player. Prefer **`dispatch-command: auto`** (default): runs **`/journey server waypoint `**, which avoids `/jt` merging all Journey scopes. + +| Key | Default | Description | +|-----|---------|-------------| +| `journey.journey-command-root` | `journey` | Root for `/journey server waypoint …` | +| `journey.journeyto-command-root` | `jt` | Root for `/jt` / journeyto (used only if you set `dispatch-command` to `journeyto_*`) | +| `journey.dispatch-command` | `auto` | `auto` → `server waypoint`; also `server_waypoint`, `journeyto_scoped`, `journeyto_plain`, `server_waypoint_display` | + +**Do not use `journeyto_*` on servers where Journey throws `Duplicate key …` when merging scopes** (e.g. two destinations with the same display name like “Mission Control”). Fix duplicate names in `journey_waypoints` / NPC data, or stay on `auto` / `server_waypoint`. + +Other Journey keys: `debug-log`, `linked-world-prefix`, `include-poi-regions`, `poi-source`, etc. — see [`config.yml`](src/main/resources/config.yml). #### Nearby NPC context (`llm.npc-context`) @@ -241,36 +285,32 @@ Then in-game: `/agent chat test` → type messages in chat → `/agent --- ## 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`**. - -### Root commands (`plugin.yml`) +Permissions follow **`whimc-agent..`** (each subcommand registers its own node under the unified **`agent`** command). -| Command | Typical use | -|---------|--------------------------------------------------------------------------------------------------| -| **`/agents`** | Admin / spawn / builder — requires a subcommand (see below). | -| **`/agent`** | Player **`chat`** or **`spawn`** (same spawn behavior as `/agents spawn`). | -| **`/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. | +### `/agent` (alias: `/agents`) -### `/agents` subcommands (current code) +Running **`/agent`** with no arguments opens the **disembodied dialogue menu** (same as **`/agent chat`**). Subcommands: | Subcommand | Permission node | Description | |------------|-----------------|-------------| -| **`spawn`** | `whimc-agent.agents.spawn` | Spawn or replace your **guide** agent (`player` + skin + name, or `Animals` mob type + name). | -| **`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. | -| **`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`** | `whimc-agent.agent.chat` | Dialogue menu, or **`chat test`** / **`chat end`** for interactive LLM chat (see above). | +| **`spawn`** | `whimc-agent.agent.spawn` | Spawn or replace your **guide** agent (`player` + skin + name, or mob type + name). | +| **`despawn`** | `whimc-agent.agent.despawn` | Despawn agent(s) for a player or **`all`**. | +| **`destroy`** | `whimc-agent.agent.destroy` | Destroy agent NPC(s) for a player or **`all`**. | +| **`reactivate`** | `whimc-agent.agent.reactivate` | Respawn agent(s) for a player or **`all`**. | +| **`rebuilderspawn`** | `whimc-agent.agent.rebuilderspawn` | Spawn a **builder** NPC at your location. | +| **`skin_type`** | `whimc-agent.agent.skin_type` | Set global skin pack (`scientist_casual`, `scientist_stereotype`, etc.). | +| **`about`** | `whimc-agent.agent.about` | List subcommands and permissions. | +| **`reload_llm_prompt`** | `whimc-agent.agent.reload_llm_prompt` | Reload `world-prompts/` from disk; re-logs LLM config to console. | -*(The old `chat_type` subcommand was removed: guide and builder menus are merged into one — builder options live under "I want to build something!".)* +*(The old separate `/agents` root command was merged into `/agent`. Permission nodes changed from `whimc-agent.agents.*` to `whimc-agent.agent.*` — update LuckPerms or similar grants.)* -### `/agent` subcommands +### Other commands (`plugin.yml`) -| Subcommand | Permission node | Description | -|------------|-----------------|-------------| -| **`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`). | +| Command | Typical use | +|---------|---------------| +| **`/assess-habitat`** | Habitat assessment (requires ML-API and routing script on server). | +| **`/oacallback`** | **Internal** — clickable chat UI callbacks; not for players. | ### Guide agent entity types (reference) @@ -279,13 +319,13 @@ The spawn command accepts: 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. +Use **tab completion** on the first argument of `/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. **In-game entity type change:** embodied players can switch the agent between **`player`** and the same allowed mob list (`AgentEntityTypes.selectableAgentTypes()`). ### Skin keys (under each `skins` section) -Use these as **``** after **`player`**; names are **lowercase** and must match `config.yml`. They are grouped under **`scientist_casual`** and **`scientist_stereotype`** (switch pack with **`/agents skin_type `**). +Use these as **``** after **`player`**; names are **lowercase** and must match `config.yml`. They are grouped under **`scientist_casual`** and **`scientist_stereotype`** (switch pack with **`/agent skin_type `**). | Skin key | Typical label | |----------|----------------| @@ -300,16 +340,19 @@ Use these as **``** after **`player`**; names are **lowercase** and must m | `hfscientist` | Hispanic female scientist | ## Player dialogue options + +The main menu lists **free discussion first** ("I want to discuss something"), then guidance, scores, build, edit, and close. + ### Guide | Dialogue option | Description | |-----------------|-------------| +| Free discussion | **First option** in the menu. **Ongoing AI chat mode**: clicking toggles chat mode on and every chat message is routed to the agent through **`doResponse()`** (PMML intent by default; **`llm.use-for-reply`** when an `LlmProvider` is configured, with short-term conversation history). Type **`stop`** or **`exit`** in chat to end the session. | | 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. | +| 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). | -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.)* +Every menu and submenu ends with a **Go back** entry; the top-level menu ends with **"That's all for now"** as the last option. ### Builder ("I want to build something!") diff --git a/example-config.yml b/example-config.yml index db710f0..516664c 100644 --- a/example-config.yml +++ b/example-config.yml @@ -4,9 +4,12 @@ database: "" username: "" password: "" -# Guidance uses Journey public waypoints: command root must match Journey's primary label (default "journey"; alias "jo" if you use that). +# Guidance uses Journey public waypoints. dispatch-command: auto avoids /jt scope-merge crashes (Duplicate key). journey: journey-command-root: journey + journeyto-command-root: jt + # auto | server_waypoint | journeyto_scoped | journeyto_plain | server_waypoint_display + dispatch-command: auto debug-log: false linked-world-prefix: "" linked-world-min-siblings: 2 @@ -47,7 +50,10 @@ llm: 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." - # Folder under the plugin data directory for RAG source files (txt/md). Safe to add lore, scripts, glossaries. + # Per-world prompts: plugins/WHIMC-QRF-Agent/world-prompts/*.yml + # Use worlds: for shared prompts. Reload: /agent reload_llm_prompt + # Global context-directory used when llm.rag.enabled; per-world rag-directory in world-prompts/*.yml is separate. + debug-log: false context-directory: llm-context rag: enabled: false @@ -57,6 +63,15 @@ llm: include-extensions: - txt - md + - doc + - docx + npc-context: + enabled: true + radius: 25.0 + max-items: 3 + journey-actions: + enabled: true + max-destinations: 60 prompts: - label: 0 diff --git a/pom.xml b/pom.xml index 3d5e254..1914ef5 100644 --- a/pom.xml +++ b/pom.xml @@ -167,6 +167,16 @@ + + org.apache.poi + poi-ooxml + 5.2.5 + + + org.apache.poi + poi-scratchpad + 5.2.5 + com.sk89q.worldguard worldguard-bukkit diff --git a/src/main/java/edu/whimc/overworld_agent/OverworldAgent.java b/src/main/java/edu/whimc/overworld_agent/OverworldAgent.java index 410455f..6fdfbbb 100644 --- a/src/main/java/edu/whimc/overworld_agent/OverworldAgent.java +++ b/src/main/java/edu/whimc/overworld_agent/OverworldAgent.java @@ -10,12 +10,15 @@ import edu.whimc.overworld_agent.dialoguetemplate.SignMenuFactory; import edu.whimc.overworld_agent.dialoguetemplate.models.LlmProvider; import edu.whimc.overworld_agent.dialoguetemplate.models.NoOpLlmProvider; +import edu.whimc.overworld_agent.dialoguetemplate.models.llm.LlmConfigAnnouncer; 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.llm.WorldLlmPromptRegistry; import edu.whimc.overworld_agent.dialoguetemplate.models.BuildTemplate; import edu.whimc.overworld_agent.utils.sql.Queryer; import org.bukkit.Bukkit; +import org.bukkit.World; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.player.PlayerJoinEvent; @@ -58,6 +61,7 @@ public class OverworldAgent extends JavaPlugin { /** Single instance; {@link edu.whimc.overworld_agent.dialoguetemplate.Dialogue} registers clicks here (see /oacallback). */ private SpigotCallback spigotCallback; private LlmProvider llmProvider = new NoOpLlmProvider(); + private final WorldLlmPromptRegistry worldLlmPromptRegistry = new WorldLlmPromptRegistry(); private ExpertSpawnCommand expertSpawnCommand; private HashMap sessions; //private SpeechReceiver receiver; @@ -109,16 +113,12 @@ public void onEnable() { 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"); + expertSpawnCommand = new ExpertSpawnCommand(this, "agent", "spawn"); AgentCommand agentCommand = new AgentCommand(this); getCommand("agent").setExecutor(agentCommand); getCommand("agent").setTabCompleter(agentCommand); - AgentsCommand agentsCommand = new AgentsCommand(this); - getCommand("agents").setExecutor(agentsCommand); - getCommand("agents").setTabCompleter(agentsCommand); - HabitatAssessCommand assessCommand = new HabitatAssessCommand(this); getCommand("assess-habitat").setExecutor(assessCommand); getCommand("assess-habitat").setTabCompleter(assessCommand); @@ -133,10 +133,14 @@ public void onEnable() { chatTextInputFactory = new ChatTextInputFactory(this); try { Files.createDirectories(LlmRagContextBuilder.resolveContextRoot(this)); + saveResource(WorldLlmPromptRegistry.PROMPTS_FOLDER + "/" + WorldLlmPromptRegistry.DEFAULT_FILE, false); + saveResource(WorldLlmPromptRegistry.PROMPTS_FOLDER + "/colder.yml", false); + worldLlmPromptRegistry.reload(this); } catch (IOException e) { - getLogger().log(Level.FINE, "LLM context directory not created yet: " + e.getMessage()); + getLogger().log(Level.WARNING, "Could not initialize world LLM prompts: " + e.getMessage()); } setupLlmFromConfig(); + LlmConfigAnnouncer.announce(this); getServer().getPluginManager().registerEvents(new Listeners(this), this); } @@ -165,6 +169,26 @@ public String augmentLlmSystemPrompt(String baseSystemPrompt) { return LlmRagContextBuilder.appendIfEnabled(this, baseSystemPrompt); } + public WorldLlmPromptRegistry getWorldLlmPromptRegistry() { + return worldLlmPromptRegistry; + } + + /** + * Resolves the LLM system prompt for a world (per-world YAML, then default.yml, then config.yml). + */ + public String buildLlmSystemPrompt(World world) { + if (world == null) { + return worldLlmPromptRegistry.buildSystemPrompt(this, null); + } + return worldLlmPromptRegistry.buildSystemPrompt(this, world.getName()); + } + + public String buildLlmSystemPrompt(Player player) { + if (player == null) { + return buildLlmSystemPrompt((World) null); + } + return buildLlmSystemPrompt(player.getWorld()); + } /** * Method when server is stopped @@ -253,18 +277,19 @@ public void ensureStudentFeedbackSession(Player player) { try { Class cl = Class.forName("edu.whimc.feedback.StudentFeedback"); Method getInstance = cl.getMethod("getInstance"); - Object plugin = getInstance.invoke(null); - if (plugin == null) { + Object feedback = getInstance.invoke(null); + if (feedback == null) { return; } Method getSessions = cl.getMethod("getPlayerSessions"); - Object sessions = getSessions.invoke(plugin); + Object sessions = getSessions.invoke(feedback); if (sessions instanceof Map map) { @SuppressWarnings("unchecked") - Map typed = (Map) map; + Map typed = (Map) map; typed.putIfAbsent(player, System.currentTimeMillis()); } - } catch (Throwable ignored) { + } catch (Throwable ex) { + getLogger().fine("Could not ensure StudentFeedback session for " + player.getName() + ": " + ex.getMessage()); } } diff --git a/src/main/java/edu/whimc/overworld_agent/commands/AgentCommand.java b/src/main/java/edu/whimc/overworld_agent/commands/AgentCommand.java index 434f51d..899a4ea 100644 --- a/src/main/java/edu/whimc/overworld_agent/commands/AgentCommand.java +++ b/src/main/java/edu/whimc/overworld_agent/commands/AgentCommand.java @@ -6,6 +6,7 @@ import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandSender; import org.bukkit.command.TabCompleter; +import org.bukkit.entity.Player; import java.util.Arrays; import java.util.HashMap; @@ -13,25 +14,40 @@ import java.util.Map; import java.util.stream.Collectors; +/** + * Unified {@code /agent} command ({@code /agents} is a Bukkit alias). Subcommands use permission + * nodes {@code whimc-agent.agent.}. + */ public class AgentCommand implements CommandExecutor, TabCompleter { private final Map subCommands = new HashMap<>(); public AgentCommand(OverworldAgent plugin) { - subCommands.put("chat", new ChatCommand(plugin, "agent", "chat")); + String base = "agent"; + subCommands.put("chat", new ChatCommand(plugin, base, "chat")); subCommands.put("spawn", plugin.getExpertSpawnCommand()); + subCommands.put("despawn", new DespawnAgentsCommand(plugin, base, "despawn")); + subCommands.put("destroy", new DestroyAgentsCommand(plugin, base, "destroy")); + subCommands.put("rebuilderspawn", new RebuilderSpawnCommand(plugin, base, "rebuilderspawn")); + subCommands.put("reactivate", new SpawnAgentsCommand(plugin, base, "reactivate")); + subCommands.put("skin_type", new SkinTypeCommand(plugin, base, "skin_type")); + subCommands.put("about", new AboutAgentsCommand(plugin, base, "about")); + subCommands.put("reload_llm_prompt", new ReloadLlmPromptCommand(plugin, base, "reload_llm_prompt")); } @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"); + if (sender instanceof Player) { + return subCommands.get("chat").executeSubCommand(sender, new String[0]); + } + sender.sendMessage("Usage: /" + commandLabel + " — try /" + commandLabel + " about"); return true; } AbstractSubCommand subCmd = subCommands.getOrDefault(args[0].toLowerCase(), null); if (subCmd == null) { - sender.sendMessage("You need to add another argument. Please try again"); + sender.sendMessage("Unknown subcommand. Use /" + commandLabel + " about"); return true; } @@ -59,7 +75,4 @@ public List onTabComplete(CommandSender sender, Command command, String return subCmd.executeOnTabComplete(sender, Arrays.copyOfRange(args, 1, args.length)); } - - } - diff --git a/src/main/java/edu/whimc/overworld_agent/commands/AgentsCommand.java b/src/main/java/edu/whimc/overworld_agent/commands/AgentsCommand.java deleted file mode 100644 index c537017..0000000 --- a/src/main/java/edu/whimc/overworld_agent/commands/AgentsCommand.java +++ /dev/null @@ -1,70 +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 AgentsCommand implements CommandExecutor, TabCompleter { - - private final Map subCommands = new HashMap<>(); - - public AgentsCommand(OverworldAgent plugin) { - subCommands.put("despawn", new DespawnAgentsCommand(plugin, "agents", "despawn")); - subCommands.put("destroy", new DestroyAgentsCommand(plugin, "agents", "destroy")); - subCommands.put("spawn", plugin.getExpertSpawnCommand()); - //subCommands.put("speechspawn", new SpeechSpawnCommand(plugin, "agent", "speechspawn")); - 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("about", new AboutAgentsCommand(plugin, "agents", "about")); - } - - @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)); - } - - -} \ No newline at end of file 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 2da8344..0b2f04b 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 @@ -330,27 +330,21 @@ private void runLlmChatTurn(Player player, ActiveChatSession session, String use logStage(traceId, "NPC_CONTEXT", "NPC context is disabled."); } - String baseSystemPrompt = plugin.getConfig().getString( - "llm.system-prompt", - "You are a friendly in-game science education assistant. Answer clearly and briefly; keep content appropriate for students." - ); - String systemPrompt; try { - logStage(traceId, "PROMPT_BASE", - "Base system prompt length: " + baseSystemPrompt.length() + " characters"); + systemPrompt = plugin.buildLlmSystemPrompt(player); - logStage(traceId, "RAG_CONTEXT", "Starting system prompt augmentation."); - systemPrompt = plugin.augmentLlmSystemPrompt(baseSystemPrompt); + logStage(traceId, "PROMPT_BASE", + "System prompt length: " + systemPrompt.length() + " characters (world=" + + worldName + ")"); if (!npcPromptContext.isBlank()) { systemPrompt = systemPrompt + npcPromptContext; } logStage(traceId, "RAG_CONTEXT", - "Finished system prompt augmentation. Final system prompt length: " + - systemPrompt.length() + " characters"); + "Final system prompt length: " + systemPrompt.length() + " characters"); } catch (Exception e) { String error = "Failed while preparing system prompt/context: " + diff --git a/src/main/java/edu/whimc/overworld_agent/commands/subcommands/ReloadLlmPromptCommand.java b/src/main/java/edu/whimc/overworld_agent/commands/subcommands/ReloadLlmPromptCommand.java new file mode 100644 index 0000000..612d252 --- /dev/null +++ b/src/main/java/edu/whimc/overworld_agent/commands/subcommands/ReloadLlmPromptCommand.java @@ -0,0 +1,92 @@ +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.llm.LlmConfigAnnouncer; +import edu.whimc.overworld_agent.dialoguetemplate.models.llm.WorldLlmPrompt; +import edu.whimc.overworld_agent.dialoguetemplate.models.llm.WorldLlmPromptRegistry; +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Reloads per-world LLM prompts from {@code world-prompts/*.yml} without restarting the server. + */ +public class ReloadLlmPromptCommand extends AbstractSubCommand { + + public ReloadLlmPromptCommand(OverworldAgent plugin, String baseCommand, String subCommand) { + super(plugin, baseCommand, subCommand); + super.description("Reload per-world LLM prompts from world-prompts/"); + super.arguments("[world]"); + } + + @Override + protected boolean onCommand(CommandSender sender, String[] args) { + WorldLlmPromptRegistry registry = plugin.getWorldLlmPromptRegistry(); + int loaded; + try { + loaded = registry.reload(plugin); + } catch (Exception ex) { + sender.sendMessage("Failed to reload world LLM prompts: " + ex.getMessage()); + plugin.getLogger().warning("world-prompts reload failed: " + ex.getMessage()); + return true; + } + + sender.sendMessage("Reloaded " + loaded + " prompt file(s), " + + registry.allWorldPrompts().size() + " world binding(s) from " + + WorldLlmPromptRegistry.promptsDirectory(plugin)); + + LlmConfigAnnouncer.announce(plugin); + + if (args.length >= 1) { + String worldName = args[0]; + sender.sendMessage(registry.describeBuiltPrompt(plugin, worldName)); + return true; + } + + if (sender instanceof Player player) { + sender.sendMessage(registry.describeBuiltPrompt(plugin, player.getWorld().getName())); + } + + for (Map.Entry> entry : registry.worldNamesBySourceFile().entrySet()) { + WorldLlmPrompt sample = registry.getForWorld(entry.getValue().get(0)).orElse(null); + if (sample == null) { + continue; + } + String rag = sample.hasRagDirectory() ? ", RAG: " + sample.getRagDirectory() : ""; + sender.sendMessage(" " + entry.getKey() + " -> " + String.join(", ", entry.getValue()) + + " (" + sample.getPrompt().length() + " chars" + rag + ")"); + } + registry.getDefaultPrompt().ifPresent(wp -> + sender.sendMessage(" default <- " + wp.getSourceFile() + + " (" + wp.getPrompt().length() + " chars)")); + + return true; + } + + @Override + protected List onTabComplete(CommandSender sender, String[] args) { + if (args.length != 1) { + return List.of(); + } + String prefix = args[0].toLowerCase(); + List worlds = new ArrayList<>(); + for (World world : Bukkit.getWorlds()) { + worlds.add(world.getName()); + } + for (WorldLlmPrompt wp : plugin.getWorldLlmPromptRegistry().allWorldPrompts()) { + worlds.add(wp.getWorldName()); + } + return worlds.stream() + .distinct() + .filter(name -> name.toLowerCase().startsWith(prefix)) + .sorted() + .collect(Collectors.toList()); + } +} 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 654fb01..9a3055a 100644 --- a/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/Dialogue.java +++ b/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/Dialogue.java @@ -103,53 +103,149 @@ 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); 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); + FileConfiguration cfg = plugin.getConfig(); + String nameId = resolveJourneyPublicNameId(destination); + Object manager = journeyPublicWaypointManager(); + Object waypoint = manager == null ? null : invokeGetWaypoint(manager, nameId); + String displayName = waypoint == null ? null : extractWaypointName(waypoint); + Integer waypointDomain = waypoint == null ? null : waypointCellDomain(waypoint); + Integer playerDomain = journeyDomainForWorldSafe(player.getWorld()); + + String journeyRoot = cfg.getString("journey.journey-command-root", "journey"); + String journeytoRoot = StringUtils.trimToEmpty(cfg.getString("journey.journeyto-command-root", "jt")); + if (journeytoRoot.isBlank()) { + journeytoRoot = "jt"; + } + List commands = buildJourneyDispatchCommands(cfg, journeyRoot, journeytoRoot, nameId, displayName); + String primaryCmd = commands.get(0); + + if (waypoint == null) { + plugin.getLogger().warning( + "[OverworldAgent][Journey] PublicWaypointManager.getWaypoint('" + + nameId + + "') returned null before dispatch for " + + player.getName() + + ". The id may not be in Journey's server-public scope even if it appears in listwaypoints."); + } else if (cfg.getBoolean("journey.debug-log", false)) { + plugin.getLogger().info( + "[OverworldAgent][Journey] resolved waypoint nameId=" + + nameId + + " label=" + + displayName + + " waypointDomain=" + + waypointDomain + + " playerDomain=" + + playerDomain + + " playerWorld=" + + player.getWorld().getName()); + } + plugin.getLogger().info( "[OverworldAgent][Journey] dispatch as player " + player.getName() + ": /" - + cmd + + primaryCmd + + (commands.size() > 1 ? " (fallbacks: " + commands.subList(1, commands.size()) + ")" : "") + " (nameId=" + nameId + ", raw=" + rawDestination - + ", journey-command-root=config:" - + journeyRoot + + ", dispatch-command=" + + cfg.getString("journey.dispatch-command", "auto") + ")"); - try { - if (!Bukkit.dispatchCommand(player, cmd)) { - plugin.getLogger().warning( - "[OverworldAgent][Journey] Bukkit.dispatchCommand returned **false** for " - + player.getName() - + ". Command line: /" - + cmd - + ". Common causes: (1) player lacks permission for that Journey command or for `" - + journeyRoot - + "`, (2) wrong `journey.journey-command-root` (must match the label players use, e.g. journey vs jo), " - + "(3) the command is not registered / Journey disabled. " - + "If dispatch is true but navigation still fails, Journey may have run but rejected the waypoint id—check in-game Journey messages."); - Utils.msgNoPrefix(player, ChatColor.RED + "Journey did not run that command. Check permissions and the destination name."); - } else { - plugin.getLogger().info( - "[OverworldAgent][Journey] dispatchCommand returned true for " - + player.getName() - + " (Journey should handle the rest; if nothing happens, verify waypoint `" - + nameId - + "` exists for server public scope)."); + Runnable runDispatch = () -> { + Throwable lastError = null; + for (int attempt = 0; attempt < commands.size(); attempt++) { + String cmd = commands.get(attempt); + try { + if (player.performCommand(cmd)) { + plugin.getLogger().info( + "[OverworldAgent][Journey] performCommand returned true for " + + player.getName() + + " (/" + + cmd + + "). If no trail appears, Journey rejected the destination or could not pathfind—check in-game Journey messages."); + return; + } + plugin.getLogger().warning( + "[OverworldAgent][Journey] performCommand returned false for " + + player.getName() + + " (/" + + cmd + + ")"); + lastError = null; + } catch (Throwable ex) { + lastError = ex; + String dupLabel = extractDuplicateKeyLabel(ex); + if (dupLabel != null && attempt + 1 < commands.size()) { + plugin.getLogger().warning( + "[OverworldAgent][Journey] /" + + cmd + + " failed: duplicate Journey scope name \"" + + dupLabel + + "\". Trying fallback /" + + commands.get(attempt + 1)); + continue; + } + plugin.getLogger().log(Level.WARNING, "Journey navigation failed for " + player.getName() + ": " + cmd, ex); + if (dupLabel != null) { + Utils.msgNoPrefix(player, + ChatColor.RED + "Journey could not start navigation: duplicate destination name \"" + + dupLabel + + "\" in Journey scopes. An admin should rename or remove the duplicate in journey_waypoints / NPC data."); + } else { + Utils.msgNoPrefix(player, ChatColor.RED + "Journey failed to start navigation. See the server log for details."); + } + return; + } } - } catch (Throwable ex) { - plugin.getLogger().log(Level.WARNING, "Journey navigation failed for " + player.getName() + ": " + cmd, ex); - if (throwableChainMessageContains(ex, "Duplicate key")) { + if (lastError == null) { Utils.msgNoPrefix(player, - ChatColor.RED + "Journey failed: duplicate destination keys in Journey scopes or data (see server log). " - + "An administrator should dedupe `journey_waypoints` / NPC scopes."); - } else { - Utils.msgNoPrefix(player, ChatColor.RED + "Journey failed to start navigation. See the server log for details."); + ChatColor.RED + "Journey did not run that command. Try /" + primaryCmd + " manually."); + } + }; + if (Bukkit.isPrimaryThread()) { + runDispatch.run(); + } else { + Bukkit.getScheduler().runTask(plugin, runDispatch); + } + } + + private static List buildJourneyDispatchCommands(FileConfiguration cfg, String journeyRoot, String journeytoRoot, + String nameId, String displayName) { + String mode = StringUtils.trimToEmpty(cfg.getString("journey.dispatch-command", "auto")); + if ("auto".equalsIgnoreCase(mode)) { + return List.of(journeyRoot + " server waypoint " + quoteIfNeeded(nameId)); + } + String primary = switch (mode.toLowerCase(Locale.ROOT)) { + case "journeyto_plain" -> journeytoRoot + " " + quoteIfNeeded(nameId); + case "journeyto_scoped" -> journeytoRoot + " server:" + nameId; + case "server_waypoint_display" -> journeyRoot + " server waypoint " + + quoteIfNeeded(StringUtils.isNotBlank(displayName) ? displayName : nameId); + default -> journeyRoot + " server waypoint " + quoteIfNeeded(nameId); + }; + String fallback = journeyRoot + " server waypoint " + quoteIfNeeded(nameId); + if (primary.equals(fallback)) { + return List.of(primary); + } + return List.of(primary, fallback); + } + + private static String extractDuplicateKeyLabel(Throwable ex) { + for (Throwable t = ex; t != null; t = t.getCause()) { + String m = t.getMessage(); + if (m == null || !m.contains("Duplicate key")) { + continue; + } + int start = m.indexOf("Duplicate key "); + if (start < 0) { + return m; } + start += "Duplicate key ".length(); + int end = m.indexOf(" (", start); + return end > start ? m.substring(start, end) : m.substring(start); } + return null; } private static boolean throwableChainMessageContains(Throwable ex, String fragment) { @@ -162,7 +258,7 @@ private static boolean throwableChainMessageContains(Throwable ex, String fragme return false; } - private static final class JourneyWaypointChoice { + private static final class JourneyWaypointChoice implements JourneyLlmBridge.JourneyWaypointChoiceView { final String jtKey; final String label; @@ -170,6 +266,16 @@ private static final class JourneyWaypointChoice { this.jtKey = jtKey; this.label = (label != null && !label.isBlank()) ? label : jtKey; } + + @Override + public String jtKey() { + return jtKey; + } + + @Override + public String label() { + return label; + } } private static List sortUniqueChoices(Collection choices) { @@ -374,12 +480,13 @@ private static String extractWaypointName(Object waypoint) { private static JourneyWaypointChoice choiceFromWaypointObject(Object waypoint, Object publicWaypointManager) { String label = extractWaypointName(waypoint); String key = extractWaypointJtKey(waypoint); - if (key == null || key.isBlank() || !nameIdMatchesWaypoint(publicWaypointManager, key, waypoint)) { + if (key == null || key.isBlank() || invokeGetWaypoint(publicWaypointManager, key) == null) { key = resolveNameIdForWaypoint(publicWaypointManager, waypoint, label); } - if (key != null && !key.isBlank()) { - key = key.toLowerCase(Locale.ROOT); + if (key == null || key.isBlank() || invokeGetWaypoint(publicWaypointManager, key) == null) { + return null; } + key = key.toLowerCase(Locale.ROOT); if (label == null || label.isBlank()) { label = key; } @@ -398,17 +505,12 @@ private static String resolveNameIdForWaypoint(Object publicWaypointManager, Obj 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); + return null; } /** Journey SQL stores name_id separately from display name; it is not always exposed on {@code Waypoint}. */ @@ -449,7 +551,7 @@ private String resolveJourneyPublicNameId(String rawInput) { } } String trimmed = rawInput.trim(); - if (trimmed.matches("(?i)(npc|poi)-[a-z0-9-]+")) { + if (trimmed.matches("(?i)(npc|poi)-[a-z0-9_-]+")) { return trimmed.toLowerCase(Locale.ROOT); } return trimmed.toLowerCase(Locale.ROOT); @@ -581,45 +683,63 @@ private static List buildNameIdCandidates(String displayName) { return List.of(); } String trimmed = displayName.trim(); - String lower = trimmed.toLowerCase(Locale.ROOT); - out.add(lower); - String fullSlug = lower.replaceAll("[^a-z0-9]", ""); + String fullSlug = trimmed.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", ""); if (!fullSlug.isBlank()) { - out.add(fullSlug); - out.add("npc-" + fullSlug); - out.add("poi-" + fullSlug); + addSlugVariants(out, fullSlug); } String[] words = trimmed.split("\\s+"); int start = 0; if (words.length > 1 && words[0].matches("(?i)(dr|mr|mrs|ms|prof)\\.?")) { start = 1; } + addWordJoinSlugs(out, words, start); 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); + addWordJoinSlugs(out, words, 0); + } + return new ArrayList<>(out); + } + + private static void addSlugVariants(LinkedHashSet out, String slug) { + if (slug == null || slug.isBlank()) { + return; + } + String s = slug.toLowerCase(Locale.ROOT); + out.add(s); + out.add("npc-" + s); + out.add("poi-" + s); + } + + /** Builds concatenated, hyphen-, and underscore-joined slugs (e.g. {@code solar_panel_power}). */ + private static void addWordJoinSlugs(LinkedHashSet out, String[] words, int start) { + StringBuilder concat = new StringBuilder(); + StringBuilder hyphen = new StringBuilder(); + StringBuilder underscore = new StringBuilder(); + for (int i = start; i < words.length; i++) { + String w = words[i].toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", ""); + if (w.isBlank()) { + continue; } - 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]", "")); + concat.append(w); + if (hyphen.length() > 0) { + hyphen.append('-'); } - String hyphenSlug = hyphenated.toString(); - if (!hyphenSlug.isBlank() && !hyphenSlug.equals(slug)) { - out.add(hyphenSlug); - out.add("npc-" + hyphenSlug); - out.add("poi-" + hyphenSlug); + hyphen.append(w); + if (underscore.length() > 0) { + underscore.append('_'); } + underscore.append(w); + } + addSlugVariants(out, concat.toString()); + String hyphenSlug = hyphen.toString(); + String underscoreSlug = underscore.toString(); + if (!hyphenSlug.isBlank() && !hyphenSlug.contentEquals(concat)) { + addSlugVariants(out, hyphenSlug); + } + if (!underscoreSlug.isBlank() + && !underscoreSlug.contentEquals(concat) + && !underscoreSlug.contentEquals(hyphenSlug)) { + addSlugVariants(out, underscoreSlug); } - return new ArrayList<>(out); } private void openJourneyDestinationTextInput() { @@ -727,7 +847,7 @@ private List collectJourneyPublicWaypoints(Set d } } JourneyWaypointChoice c = choiceFromWaypointObject(item, publicWaypointManager); - if (c.jtKey != null && !c.jtKey.isBlank()) { + if (c != null && c.jtKey != null && !c.jtKey.isBlank()) { choices.add(c); } } @@ -856,9 +976,11 @@ private void showGuidanceDestinationMenu(Player player, String guidanceResponse, 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 -> { + l -> { dispatchJourneyCommand(player, wp.jtKey); - }) + this.plugin.getQueryer().storeNewInteraction( + new Interaction(plugin, player, "Guidance"), id -> { }); + } ); } sendBackOption(this::doDialogue); @@ -887,10 +1009,29 @@ public void doDialogue() { "&f&nI want to see my scores"); String agentEdit = cfg.getString("template-gui.text.agent-edit", "&f&nI want to edit my agent"); - // Agent Guidance Option - // NOTE: Journey 1.3.x may throw when opening its GUI via plain `/jt` on some servers. - // We avoid that by enumerating public waypoints (if available) and dispatching `jt ` - // which does not require opening the GUI. + + // Discussion first (free text → PMML / LLM via chat) + if (text) { + sendComponent( + player, + "&8" + BULLET + customResponse, + "&aClick here, then type your message in chat", + p -> openFreeDiscussionChatInput() + ); + } else { + sendComponent( + player, + "&8" + BULLET + endResponse, + "&aClick here to see my response!", + p -> { + player.sendMessage(response); + doResponse(); + }); + } + + // Agent Guidance Option (async — remaining menu items are sent after this completes) + Runnable sendMenuTail = () -> sendDialogueMenuTail(cfg, scoreResponse, agentEdit); + if (Bukkit.getPluginManager().getPlugin("Journey") != null) { loadGuidanceDestinations(player, guidanceChoices -> { if (!guidanceChoices.isEmpty()) { @@ -903,8 +1044,14 @@ public void doDialogue() { p -> openJourneyDestinationTextInput() ); } + sendMenuTail.run(); }); + } else { + sendMenuTail.run(); } + } + + private void sendDialogueMenuTail(FileConfiguration cfg, String scoreResponse, String agentEdit) { //Agent Score option sendComponent( player, @@ -928,50 +1075,6 @@ public void doDialogue() { p -> openBuilderMenu() ); - //Agent Dialogue option (free text → PMML / future LLM via chat, not sign) - if (text) { - sendComponent( - player, - "&8" + BULLET + customResponse, - "&aClick here, then type your message in chat", - p -> openFreeDiscussionChatInput() - ); - } else { - sendComponent( - player, - "&8" + BULLET + endResponse, - "&aClick here to see my response!", - p -> { - player.sendMessage(response); - doResponse(); - }); - } -/* - //Agent Reflection Option (disabled) - sendComponent( - player, - "&8" + BULLET + seeDialogue, - "&aClick here to see our conversation so far!", - p -> { - plugin.getQueryer().getSessionConversation(player, plugin.getPlayerSessions().get(player), conversation -> { - HashMap> dialogue = (HashMap>) conversation; - for (Map.Entry> entry : dialogue.entrySet()) { - String world = entry.getKey(); - List discussion = entry.getValue(); - for (int k = 0; k < discussion.size(); k++) { - if (k % 2 == 0) { - player.sendMessage(world + ": " + player.getName() + ": " + discussion.get(k)); - } else { - player.sendMessage(world + ": " + plugin.getAgents().get(player.getName()).getName() + ": " + discussion.get(k)); - } - } - } - }); - this.plugin.getQueryer().storeNewInteraction(new Interaction(plugin, player, "Reflection"), id -> { - - }); - }); -*/ Map edits = plugin.getAgentEdits().get(player); int skinChange = edits.get("Skin"); int nameChange = edits.get("Name"); @@ -981,7 +1084,7 @@ public void doDialogue() { sendComponent(player, "&8" + BULLET + agentEdit, "&aClick here to change me!", p -> openEditMenu()); } - //Close option so every menu has a way out + //Close option — always last sendComponent( player, "&8" + BULLET + " &7&nThat's all for now", @@ -1267,24 +1370,53 @@ private void doResponse() { && plugin.getLlmProvider() != null && plugin.getLlmProvider().isConfigured()) { plugin.getLogger().fine("[OverworldAgent][Journey] LLM path started"); - 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 llmChatbot.generateLlmReply(plugin.getLlmProvider(), systemPrompt); - } catch (Exception ex) { - plugin.getLogger().warning("LLM reply failed: " + ex.getMessage()); - return null; - } - }).thenAccept(llmText -> Bukkit.getScheduler().runTask(plugin, () -> { - if (llmText != null && !llmText.isBlank()) { - feedbackOut[0] = llmText; + boolean journeyActions = plugin.getConfig().getBoolean("llm.journey-actions.enabled", true) + && Bukkit.getPluginManager().getPlugin("Journey") != null; + + java.util.function.Consumer> startLlm = guidanceChoices -> { + List destinations = + JourneyLlmBridge.fromWaypointChoices(guidanceChoices); + String systemPrompt = plugin.buildLlmSystemPrompt(player); + if (journeyActions && !destinations.isEmpty()) { + int max = plugin.getConfig().getInt("llm.journey-actions.max-destinations", 60); + systemPrompt = JourneyLlmBridge.appendDestinationContext(systemPrompt, destinations, max); } - recordDiscussionTurn(finalResponse, feedbackOut[0]); - storeAndSend.run(); - })); + final String llmSystemPrompt = systemPrompt; + Chatbot llmChatbot = new Chatbot(buildLlmMessageWithHistory(finalResponse)); + CompletableFuture.supplyAsync(() -> { + try { + return llmChatbot.generateLlmReply(plugin.getLlmProvider(), llmSystemPrompt); + } catch (Exception ex) { + plugin.getLogger().warning("LLM reply failed: " + ex.getMessage()); + return null; + } + }).thenAccept(llmText -> Bukkit.getScheduler().runTask(plugin, () -> { + if (llmText != null && !llmText.isBlank()) { + JourneyLlmBridge.ParsedReply parsed = JourneyLlmBridge.parseLlmReply(llmText); + feedbackOut[0] = parsed.displayText().isBlank() ? llmText : parsed.displayText(); + String journeyTarget = parsed.journeyNameId(); + if (journeyTarget == null && journeyActions) { + journeyTarget = JourneyLlmBridge.matchDestination(finalResponse, destinations).orElse(null); + } + if (journeyTarget != null && journeyActions) { + plugin.getLogger().info( + "[OverworldAgent][Journey] LLM-triggered navigation for " + + player.getName() + + ": " + + journeyTarget); + dispatchJourneyCommand(player, journeyTarget); + } + } + recordDiscussionTurn(finalResponse, feedbackOut[0]); + storeAndSend.run(); + })); + }; + + if (journeyActions) { + loadGuidanceDestinations(player, startLlm); + } else { + startLlm.accept(List.of()); + } } else { recordDiscussionTurn(finalResponse, feedbackOut[0]); storeAndSend.run(); diff --git a/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/JourneyLlmBridge.java b/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/JourneyLlmBridge.java new file mode 100644 index 0000000..585f0dc --- /dev/null +++ b/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/JourneyLlmBridge.java @@ -0,0 +1,154 @@ +package edu.whimc.overworld_agent.dialoguetemplate; + +import org.apache.commons.lang3.StringUtils; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Lets the optional LLM trigger Journey navigation via a structured {@code JOURNEY:name_id} line + * and fuzzy matching against the same destination catalog used by the guidance menu. + */ +public final class JourneyLlmBridge { + + private static final Pattern JOURNEY_DIRECTIVE = Pattern.compile( + "(?im)^\\s*JOURNEY\\s*:\\s*(\\S+)\\s*$"); + + public record ParsedReply(String displayText, String journeyNameId) {} + + private JourneyLlmBridge() {} + + public static String appendDestinationContext(String systemPrompt, + List destinations, int maxDestinations) { + if (destinations == null || destinations.isEmpty()) { + return systemPrompt; + } + int cap = maxDestinations <= 0 ? 60 : maxDestinations; + List sorted = new ArrayList<>(destinations); + sorted.sort(Comparator.comparing(JourneyGuidanceCatalog.Destination::label, String.CASE_INSENSITIVE_ORDER)); + if (sorted.size() > cap) { + sorted = sorted.subList(0, cap); + } + + StringBuilder block = new StringBuilder(); + block.append("\n\n## Navigation (Journey plugin)\n"); + block.append("When the player asks to go somewhere, visit a place, meet a character, or explore a region, "); + block.append("and you can identify a destination from the list below, end your reply with a line exactly:\n"); + block.append("JOURNEY:\n"); + block.append("Use the name_id from the list (not the display label). "); + block.append("The JOURNEY line is for the server only — do not explain it to the player.\n"); + block.append("Available destinations (name_id — label):\n"); + for (JourneyGuidanceCatalog.Destination d : sorted) { + block.append("- ").append(d.jtKey()).append(" — ").append(d.label()).append('\n'); + } + return (systemPrompt == null ? "" : systemPrompt) + block; + } + + public static ParsedReply parseLlmReply(String llmText) { + if (llmText == null || llmText.isBlank()) { + return new ParsedReply("", null); + } + Matcher matcher = JOURNEY_DIRECTIVE.matcher(llmText); + String nameId = null; + String last = null; + while (matcher.find()) { + last = matcher.group(1).toLowerCase(Locale.ROOT); + } + if (last != null) { + nameId = last; + } + String display = JOURNEY_DIRECTIVE.matcher(llmText).replaceAll("").trim(); + return new ParsedReply(display, nameId); + } + + /** + * Best-effort match of free text (player message or destination phrase) to a catalog entry. + */ + public static Optional matchDestination(String text, List destinations) { + if (StringUtils.isBlank(text) || destinations == null || destinations.isEmpty()) { + return Optional.empty(); + } + String norm = normalize(text); + if (norm.isBlank()) { + return Optional.empty(); + } + + JourneyGuidanceCatalog.Destination best = null; + int bestScore = 0; + for (JourneyGuidanceCatalog.Destination d : destinations) { + int score = scoreMatch(norm, d); + if (score > bestScore) { + bestScore = score; + best = d; + } + } + if (best != null && bestScore >= 80) { + return Optional.of(best.jtKey()); + } + return Optional.empty(); + } + + private static int scoreMatch(String norm, JourneyGuidanceCatalog.Destination d) { + String labelNorm = normalize(d.label()); + String keyNorm = normalize(stripScopePrefix(d.jtKey())); + if (norm.equals(labelNorm) || norm.equals(keyNorm)) { + return 100; + } + if (labelNorm.contains(norm) || norm.contains(labelNorm)) { + return 90; + } + if (keyNorm.contains(norm) || norm.contains(keyNorm)) { + return 85; + } + if (labelNorm.length() >= 4 && norm.contains(labelNorm)) { + return 88; + } + if (keyNorm.length() >= 4 && norm.contains(keyNorm)) { + return 86; + } + return 0; + } + + private static String stripScopePrefix(String jtKey) { + if (jtKey == null) { + return ""; + } + String k = jtKey.toLowerCase(Locale.ROOT); + if (k.startsWith("poi-") || k.startsWith("npc-")) { + return k.substring(4); + } + return k; + } + + private static String normalize(String text) { + if (text == null) { + return ""; + } + return text.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]+", ""); + } + + public static List fromWaypointChoices( + Iterable choices) { + List out = new ArrayList<>(); + if (choices == null) { + return out; + } + for (JourneyWaypointChoiceView c : choices) { + if (c != null && c.jtKey() != null && !c.jtKey().isBlank()) { + out.add(new JourneyGuidanceCatalog.Destination(c.jtKey(), c.label())); + } + } + return out; + } + + /** Minimal view so {@link Dialogue}'s private waypoint type can be passed without exposing it. */ + public interface JourneyWaypointChoiceView { + String jtKey(); + String label(); + } +} diff --git a/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/models/llm/LlmConfigAnnouncer.java b/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/models/llm/LlmConfigAnnouncer.java new file mode 100644 index 0000000..238b5d8 --- /dev/null +++ b/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/models/llm/LlmConfigAnnouncer.java @@ -0,0 +1,99 @@ +package edu.whimc.overworld_agent.dialoguetemplate.models.llm; + +import edu.whimc.overworld_agent.OverworldAgent; +import org.apache.commons.lang3.StringUtils; +import org.bukkit.configuration.file.FileConfiguration; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.logging.Logger; +import java.util.stream.Collectors; + +/** + * Logs active LLM provider settings, per-world prompts, and RAG document bindings to the server console. + */ +public final class LlmConfigAnnouncer { + + private LlmConfigAnnouncer() {} + + public static void announce(OverworldAgent plugin) { + Logger log = plugin.getLogger(); + FileConfiguration cfg = plugin.getConfig(); + String provider = cfg.getString("llm.provider", "none"); + String model = cfg.getString("llm.model", ""); + String baseUrl = StringUtils.trimToEmpty(cfg.getString("llm.base-url")); + boolean useForReply = cfg.getBoolean("llm.use-for-reply", false); + boolean ragEnabled = cfg.getBoolean("llm.rag.enabled", false); + String apiKeyEnv = StringUtils.trimToEmpty(cfg.getString("llm.api-key-env")); + boolean inlineKey = StringUtils.isNotBlank(cfg.getString("llm.api-key")); + boolean configured = plugin.getLlmProvider() != null && plugin.getLlmProvider().isConfigured(); + + log.info("[OverworldAgent][LLM] ---- active configuration ----"); + log.info("[OverworldAgent][LLM] provider=" + provider + + " model=" + (model.isBlank() ? "(default)" : model) + + " use-for-reply=" + useForReply + + " configured=" + configured); + if (!baseUrl.isBlank()) { + log.info("[OverworldAgent][LLM] base-url=" + baseUrl); + } + if (!apiKeyEnv.isBlank()) { + log.info("[OverworldAgent][LLM] api-key-env=" + apiKeyEnv); + } else if (inlineKey) { + log.info("[OverworldAgent][LLM] api-key=(set inline in config.yml)"); + } + log.info("[OverworldAgent][LLM] global RAG enabled=" + ragEnabled + + " context-directory=" + cfg.getString("llm.context-directory", "llm-context")); + if (ragEnabled) { + announceRagDirectory(log, "global", LlmRagContextBuilder.resolveContextRoot(plugin), plugin); + } + + WorldLlmPromptRegistry registry = plugin.getWorldLlmPromptRegistry(); + for (Map.Entry> entry : registry.worldNamesBySourceFile().entrySet()) { + Optional sample = registry.getForWorld(entry.getValue().get(0)); + if (sample.isEmpty()) { + continue; + } + WorldLlmPrompt wp = sample.get(); + log.info("[OverworldAgent][LLM] world-prompts/" + entry.getKey() + + " -> worlds=[" + String.join(", ", entry.getValue()) + "]" + + " promptChars=" + wp.getPrompt().length()); + if (wp.hasRagDirectory()) { + Path ragRoot = WorldLlmPromptRegistry.resolveRagPath(plugin, wp.getRagDirectory()); + log.info("[OverworldAgent][LLM] rag-directory=" + wp.getRagDirectory() + + " resolved=" + ragRoot); + announceRagDirectory(log, wp.getRagDirectory(), ragRoot, plugin); + } else { + log.info("[OverworldAgent][LLM] rag-directory=(none)"); + } + } + registry.getDefaultPrompt().ifPresent(wp -> { + log.info("[OverworldAgent][LLM] world-prompts/default.yml (fallback)" + + " promptChars=" + wp.getPrompt().length()); + if (wp.hasRagDirectory()) { + Path ragRoot = WorldLlmPromptRegistry.resolveRagPath(plugin, wp.getRagDirectory()); + log.info("[OverworldAgent][LLM] rag-directory=" + wp.getRagDirectory() + + " resolved=" + ragRoot); + announceRagDirectory(log, wp.getRagDirectory(), ragRoot, plugin); + } + }); + if (registry.allWorldPrompts().isEmpty() && registry.getDefaultPrompt().isEmpty()) { + String fallback = cfg.getString("llm.system-prompt", ""); + log.info("[OverworldAgent][LLM] no world-prompts loaded; fallback config llm.system-prompt chars=" + + (fallback == null ? 0 : fallback.length())); + } + log.info("[OverworldAgent][LLM] ------------------------------"); + } + + private static void announceRagDirectory(Logger log, String label, Path root, OverworldAgent plugin) { + List docs = LlmRagContextBuilder.listDocumentPaths(root, plugin); + if (docs.isEmpty()) { + log.info("[OverworldAgent][LLM] RAG documents for " + label + ": (none — missing dir or no matching files)"); + return; + } + log.info("[OverworldAgent][LLM] RAG documents for " + label + " (" + docs.size() + "): " + + docs.stream().limit(12).collect(Collectors.joining(", ")) + + (docs.size() > 12 ? " …" : "")); + } +} diff --git a/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/models/llm/LlmRagContextBuilder.java b/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/models/llm/LlmRagContextBuilder.java index 18988ff..ed876eb 100644 --- a/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/models/llm/LlmRagContextBuilder.java +++ b/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/models/llm/LlmRagContextBuilder.java @@ -4,7 +4,6 @@ import org.bukkit.plugin.java.JavaPlugin; import java.io.IOException; -import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; @@ -27,17 +26,24 @@ public static String appendIfEnabled(JavaPlugin plugin, String baseSystemPrompt) if (!cfg.getBoolean("llm.rag.enabled", false)) { return baseSystemPrompt == null ? "" : baseSystemPrompt; } - Path root = resolveContextRoot(plugin); - if (!Files.isDirectory(root)) { + return appendFromDirectory(plugin, baseSystemPrompt, resolveContextRoot(plugin)); + } + + /** + * Appends bounded excerpts from {@code root} using limits from {@code llm.rag.*} in the main config. + */ + public static String appendFromDirectory(JavaPlugin plugin, String baseSystemPrompt, Path root) { + if (root == null || !Files.isDirectory(root)) { return baseSystemPrompt == null ? "" : baseSystemPrompt; } + FileConfiguration cfg = plugin.getConfig(); int maxTotal = cfg.getInt("llm.rag.max-total-chars", 12_000); int maxFile = cfg.getInt("llm.rag.max-file-chars", 4_000); int maxDepth = cfg.getInt("llm.rag.max-directory-depth", 6); List extensions = cfg.getStringList("llm.rag.include-extensions"); if (extensions == null || extensions.isEmpty()) { - extensions = List.of("txt", "md"); + extensions = List.of("txt", "md", "doc", "docx"); } List normalizedExt = extensions.stream() .map(e -> e.toLowerCase(Locale.ROOT).replace(".", "")) @@ -70,7 +76,7 @@ public static String appendIfEnabled(JavaPlugin plugin, String baseSystemPrompt) } try { String rel = root.relativize(file).toString().replace('\\', '/'); - String content = Files.readString(file, StandardCharsets.UTF_8); + String content = RagDocumentReader.readText(file); if (content.length() > maxFile) { content = content.substring(0, maxFile) + "\n...[truncated]\n"; } @@ -86,9 +92,17 @@ public static String appendIfEnabled(JavaPlugin plugin, String baseSystemPrompt) } if (rag.isEmpty()) { + if (plugin.getConfig().getBoolean("llm.debug-log", false)) { + plugin.getLogger().info("[OverworldAgent][LLM RAG] No files in " + root); + } return baseSystemPrompt == null ? "" : baseSystemPrompt; } + if (plugin.getConfig().getBoolean("llm.debug-log", false)) { + plugin.getLogger().info("[OverworldAgent][LLM RAG] Appended " + rag.length() + + " chars from " + files.size() + " file(s) under " + root); + } + String header = "\n\n## Reference material (from server context files — use only if relevant)\n\n"; String sep = baseSystemPrompt == null || baseSystemPrompt.isBlank() ? "" : "\n\n"; return (baseSystemPrompt == null ? "" : baseSystemPrompt) + sep + header + rag; @@ -107,4 +121,38 @@ public static Path resolveContextRoot(JavaPlugin plugin) { } return p.normalize(); } + + /** Relative paths of RAG-eligible files under {@code root} (for console diagnostics). */ + public static List listDocumentPaths(Path root, JavaPlugin plugin) { + if (root == null || !Files.isDirectory(root)) { + return List.of(); + } + FileConfiguration cfg = plugin.getConfig(); + int maxDepth = cfg.getInt("llm.rag.max-directory-depth", 6); + List extensions = cfg.getStringList("llm.rag.include-extensions"); + if (extensions == null || extensions.isEmpty()) { + extensions = List.of("txt", "md", "doc", "docx"); + } + List normalizedExt = extensions.stream() + .map(e -> e.toLowerCase(Locale.ROOT).replace(".", "")) + .collect(Collectors.toList()); + List out = new ArrayList<>(); + try (Stream walk = Files.walk(root, maxDepth)) { + walk.filter(Files::isRegularFile) + .filter(p -> { + String name = p.getFileName().toString(); + int dot = name.lastIndexOf('.'); + if (dot < 0 || dot == name.length() - 1) { + return false; + } + String ext = name.substring(dot + 1).toLowerCase(Locale.ROOT); + return normalizedExt.contains(ext); + }) + .sorted(Comparator.comparing(Path::toString)) + .forEach(p -> out.add(root.relativize(p).toString().replace('\\', '/'))); + } catch (IOException ex) { + plugin.getLogger().warning("LLM RAG: could not list " + root + ": " + ex.getMessage()); + } + return out; + } } diff --git a/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/models/llm/RagDocumentReader.java b/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/models/llm/RagDocumentReader.java new file mode 100644 index 0000000..52fd453 --- /dev/null +++ b/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/models/llm/RagDocumentReader.java @@ -0,0 +1,52 @@ +package edu.whimc.overworld_agent.dialoguetemplate.models.llm; + +import org.apache.poi.hwpf.HWPFDocument; +import org.apache.poi.hwpf.extractor.WordExtractor; +import org.apache.poi.xwpf.extractor.XWPFWordExtractor; +import org.apache.poi.xwpf.usermodel.XWPFDocument; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; + +/** + * Extracts plain text from RAG source files ({@code .txt}, {@code .md}, {@code .doc}, {@code .docx}). + */ +public final class RagDocumentReader { + + private RagDocumentReader() {} + + public static String readText(Path file) throws IOException { + String name = file.getFileName().toString(); + int dot = name.lastIndexOf('.'); + if (dot < 0 || dot == name.length() - 1) { + throw new IOException("No file extension: " + name); + } + String ext = name.substring(dot + 1).toLowerCase(Locale.ROOT); + return switch (ext) { + case "txt", "md" -> Files.readString(file, StandardCharsets.UTF_8); + case "docx" -> readDocx(file); + case "doc" -> readDoc(file); + default -> throw new IOException("Unsupported extension: " + ext); + }; + } + + private static String readDocx(Path file) throws IOException { + try (InputStream in = Files.newInputStream(file); + XWPFDocument document = new XWPFDocument(in); + XWPFWordExtractor extractor = new XWPFWordExtractor(document)) { + return extractor.getText(); + } + } + + private static String readDoc(Path file) throws IOException { + try (InputStream in = Files.newInputStream(file); + HWPFDocument document = new HWPFDocument(in); + WordExtractor extractor = new WordExtractor(document)) { + return extractor.getText(); + } + } +} diff --git a/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/models/llm/WorldLlmPrompt.java b/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/models/llm/WorldLlmPrompt.java new file mode 100644 index 0000000..b9d779a --- /dev/null +++ b/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/models/llm/WorldLlmPrompt.java @@ -0,0 +1,42 @@ +package edu.whimc.overworld_agent.dialoguetemplate.models.llm; + +import org.apache.commons.lang3.StringUtils; + +/** + * Per-world LLM system prompt binding (loaded from {@code world-prompts/*.yml}). + * Several worlds may share one file via a {@code worlds:} list. + */ +public final class WorldLlmPrompt { + + private final String worldName; + private final String prompt; + private final String ragDirectory; + private final String sourceFile; + + public WorldLlmPrompt(String worldName, String prompt, String ragDirectory, String sourceFile) { + this.worldName = worldName; + this.prompt = prompt == null ? "" : prompt; + this.ragDirectory = StringUtils.trimToNull(ragDirectory); + this.sourceFile = sourceFile; + } + + public String getWorldName() { + return worldName; + } + + public String getPrompt() { + return prompt; + } + + public boolean hasRagDirectory() { + return ragDirectory != null; + } + + public String getRagDirectory() { + return ragDirectory; + } + + public String getSourceFile() { + return sourceFile; + } +} diff --git a/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/models/llm/WorldLlmPromptRegistry.java b/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/models/llm/WorldLlmPromptRegistry.java new file mode 100644 index 0000000..0f7ab9c --- /dev/null +++ b/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/models/llm/WorldLlmPromptRegistry.java @@ -0,0 +1,248 @@ +package edu.whimc.overworld_agent.dialoguetemplate.models.llm; + +import edu.whimc.overworld_agent.OverworldAgent; +import org.apache.commons.lang3.StringUtils; +import org.bukkit.configuration.file.YamlConfiguration; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * Loads per-world LLM prompts from {@code plugins/WHIMC-QRF-Agent/world-prompts/*.yml}. + */ +public final class WorldLlmPromptRegistry { + + public static final String PROMPTS_FOLDER = "world-prompts"; + public static final String DEFAULT_FILE = "default.yml"; + + private static final String DEFAULT_PROMPT = + "You are a friendly in-game science education assistant. " + + "Answer clearly and briefly; keep content appropriate for students."; + + private final Map byWorldKey = new LinkedHashMap<>(); + private WorldLlmPrompt defaultPrompt; + + public static Path promptsDirectory(OverworldAgent plugin) { + return Path.of(plugin.getDataFolder().getAbsolutePath(), PROMPTS_FOLDER); + } + + public int reload(OverworldAgent plugin) throws IOException { + Path dir = promptsDirectory(plugin); + Files.createDirectories(dir); + + Map next = new LinkedHashMap<>(); + WorldLlmPrompt nextDefault = null; + int filesLoaded = 0; + + File[] files = dir.toFile().listFiles((d, name) -> name.endsWith(".yml") || name.endsWith(".yaml")); + if (files != null) { + for (File file : files) { + String fileName = file.getName(); + boolean isDefaultFile = DEFAULT_FILE.equalsIgnoreCase(fileName); + String stem = fileName; + int dot = stem.lastIndexOf('.'); + if (dot > 0) { + stem = stem.substring(0, dot); + } + Optional parsed = loadFile(plugin, file, isDefaultFile ? null : stem); + if (parsed.isEmpty()) { + continue; + } + filesLoaded++; + PromptFile bundle = parsed.get(); + if (bundle.worldNames.isEmpty()) { + if (!isDefaultFile) { + plugin.getLogger().warning("world-prompts/" + fileName + + ": no worlds listed (set worlds:, world:, or use .yml)"); + continue; + } + nextDefault = bundle.asPrompt("default"); + continue; + } + for (String worldName : bundle.worldNames) { + String key = worldKey(worldName); + if (next.containsKey(key)) { + plugin.getLogger().warning("world-prompts/" + fileName + ": overwriting prompt for world '" + + worldName + "' (was " + next.get(key).getSourceFile() + ")"); + } + next.put(key, bundle.asPrompt(worldName)); + } + } + } + + byWorldKey.clear(); + byWorldKey.putAll(next); + defaultPrompt = nextDefault; + return filesLoaded; + } + + private static Optional loadFile(OverworldAgent plugin, File file, String fallbackWorldName) { + YamlConfiguration yaml = YamlConfiguration.loadConfiguration(file); + List worldNames = resolveWorldNames(yaml, fallbackWorldName); + String prompt = yaml.getString("prompt"); + if (StringUtils.isBlank(prompt)) { + plugin.getLogger().warning("world-prompts/" + file.getName() + ": missing or empty prompt"); + return Optional.empty(); + } + String ragDirectory = yaml.getString("rag-directory"); + return Optional.of(new PromptFile(worldNames, prompt, ragDirectory, file.getName())); + } + + /** + * {@code worlds:} applies one prompt to many worlds. {@code world:} is a single-world shorthand. + * Filename stem is used when neither is set (e.g. {@code ColderStrip.yml} -> ColderStrip). + */ + private static List resolveWorldNames(YamlConfiguration yaml, String fallbackWorldName) { + Set names = new LinkedHashSet<>(); + for (String entry : yaml.getStringList("worlds")) { + String trimmed = StringUtils.trimToNull(entry); + if (trimmed != null) { + names.add(trimmed); + } + } + String single = StringUtils.trimToNull(yaml.getString("world")); + if (single != null) { + names.add(single); + } + if (names.isEmpty() && fallbackWorldName != null) { + names.add(fallbackWorldName); + } + return List.copyOf(names); + } + + private static final class PromptFile { + private final List worldNames; + private final String prompt; + private final String ragDirectory; + private final String sourceFile; + + private PromptFile(List worldNames, String prompt, String ragDirectory, String sourceFile) { + this.worldNames = worldNames; + this.prompt = prompt; + this.ragDirectory = ragDirectory; + this.sourceFile = sourceFile; + } + + private WorldLlmPrompt asPrompt(String worldName) { + return new WorldLlmPrompt(worldName, prompt, ragDirectory, sourceFile); + } + } + + public List allWorldPrompts() { + return Collections.unmodifiableList(new ArrayList<>(byWorldKey.values())); + } + + /** Groups loaded world bindings by source file (for admin listings). */ + public Map> worldNamesBySourceFile() { + Map> grouped = new LinkedHashMap<>(); + for (WorldLlmPrompt wp : byWorldKey.values()) { + grouped.computeIfAbsent(wp.getSourceFile(), k -> new ArrayList<>()).add(wp.getWorldName()); + } + return grouped; + } + + public Optional getDefaultPrompt() { + return Optional.ofNullable(defaultPrompt); + } + + public Optional getForWorld(String worldName) { + if (worldName == null) { + return Optional.empty(); + } + return Optional.ofNullable(byWorldKey.get(worldKey(worldName))); + } + + /** + * Resolves the effective prompt definition for a world (specific file, then default.yml, then absent). + */ + public Optional resolveDefinition(String worldName) { + Optional specific = getForWorld(worldName); + if (specific.isPresent()) { + return specific; + } + return getDefaultPrompt(); + } + + public String buildSystemPrompt(OverworldAgent plugin, String worldName) { + Optional definition = resolveDefinition(worldName); + String built; + if (definition.isPresent()) { + WorldLlmPrompt wp = definition.get(); + String base = wp.getPrompt(); + if (wp.hasRagDirectory()) { + Path ragRoot = resolveRagPath(plugin, wp.getRagDirectory()); + built = LlmRagContextBuilder.appendFromDirectory(plugin, base, ragRoot); + } else { + built = base; + } + } else { + String base = plugin.getConfig().getString("llm.system-prompt", DEFAULT_PROMPT); + built = LlmRagContextBuilder.appendIfEnabled(plugin, base); + } + if (plugin.getConfig().getBoolean("llm.debug-log", false) && worldName != null) { + boolean hasRag = built.contains("## Reference material"); + plugin.getLogger().info("[OverworldAgent][LLM] system prompt for world " + + worldName + ": " + built.length() + " chars" + + (hasRag ? " (includes RAG)" : " (no RAG block)")); + } + return built; + } + + /** Summary for admin commands: prompt source, size, and whether RAG was appended. */ + public String describeBuiltPrompt(OverworldAgent plugin, String worldName) { + String built = buildSystemPrompt(plugin, worldName); + boolean hasRag = built.contains("## Reference material"); + StringBuilder sb = new StringBuilder(describeForWorld(worldName)); + sb.append(" -> ").append(built.length()).append(" chars sent to LLM"); + if (hasRag) { + sb.append(" (RAG appended)"); + } else { + sb.append(" (no RAG block — check rag-directory path and .txt/.md files)"); + } + return sb.toString(); + } + + public static Path resolveRagPath(OverworldAgent plugin, String ragDirectory) { + String sub = StringUtils.trimToEmpty(ragDirectory); + Path p = Path.of(sub); + if (p.isAbsolute()) { + return p.normalize(); + } + return Path.of(plugin.getDataFolder().getAbsolutePath()).resolve(sub).normalize(); + } + + public static String worldKey(String worldName) { + return worldName.toLowerCase(Locale.ROOT); + } + + /** + * Human-readable summary after reload (for admin command output). + */ + public String describeForWorld(String worldName) { + Optional definition = resolveDefinition(worldName); + if (definition.isEmpty()) { + return "world '" + worldName + "' uses llm.system-prompt from config.yml" + + (byWorldKey.isEmpty() && defaultPrompt == null ? " (no world-prompts files loaded)" : ""); + } + WorldLlmPrompt wp = definition.get(); + StringBuilder sb = new StringBuilder(); + sb.append("world '").append(worldName).append("' -> ").append(wp.getSourceFile()); + sb.append(" (").append(wp.getPrompt().length()).append(" chars"); + if (wp.hasRagDirectory()) { + sb.append(", RAG: ").append(wp.getRagDirectory()); + } + sb.append(")"); + return sb.toString(); + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index cfd4a77..ef4ef95 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -7,6 +7,10 @@ mysql: # Guidance uses Journey public waypoints: command root must match Journey's primary label (default "journey"; alias "jo" if you use that). journey: journey-command-root: journey + # Alias for /journeyto (default jt). Used when dispatch-command is journeyto_* or auto for npc-/poi- waypoints. + journeyto-command-root: jt + # auto = /journey server waypoint (avoids /jt scope merge). Do NOT use journeyto_* if Journey logs Duplicate key. + dispatch-command: auto # When true, logs Journey domain / public waypoint counts when opening the guidance menu (console). debug-log: false # Optional override for portal-linked world clusters (e.g. Colder). When unset, auto-detects from CamelCase names. @@ -53,7 +57,10 @@ llm: 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." - # Folder under the plugin data directory for RAG source files (txt/md). Safe to add lore, scripts, glossaries. + # Per-world prompts: world-prompts/*.yml (worlds: list or .yml) — /agent reload_llm_prompt + # When true, logs system-prompt size and RAG file counts to console on each LLM call. + debug-log: false + # Folder under the plugin data directory for global RAG (txt/md/doc/docx) when llm.rag.enabled is true. context-directory: llm-context rag: enabled: false @@ -63,10 +70,17 @@ llm: include-extensions: - txt - md + - doc + - docx npc-context: enabled: true radius: 25.0 max-items: 3 + # When Journey is installed, append destinations to the LLM system prompt and run navigation + # when the model emits JOURNEY: or the player message matches a known destination. + journey-actions: + enabled: true + max-destinations: 60 prompts: - label: 0 prompt: agent diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 68f1991..8ca4f25 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -7,11 +7,9 @@ main: edu.whimc.overworld_agent.OverworldAgent softdepend: [Citizens,ProtocolLib,WHIMC-ScienceTools,Journey,SpeechReceiver,HolographicDisplays,CoreProtect,WHIMC-StudentFeedback] commands: agent: - description: agent command for dialogue - usage: '/agent' - agents: - description: Root agent admin command - usage: '/agents' + description: Agent dialogue and admin commands + usage: '/agent [subcommand]' + aliases: [agents] oacallback: description: Internal callback command for chat UI usage: '/oacallback ' diff --git a/src/main/resources/world-prompts/colder.yml b/src/main/resources/world-prompts/colder.yml new file mode 100644 index 0000000..dea3b8f --- /dev/null +++ b/src/main/resources/world-prompts/colder.yml @@ -0,0 +1,9 @@ +# One prompt shared across portal-linked Colder worlds. +worlds: + - ColderStrip + - ColderHot + - ColderCold +prompt: | + You are a WHIMC science mentor helping students explore the Colder Sun scenario. + Answer briefly and guide exploration without giving away conclusions. +# rag-directory: llm-context/colder diff --git a/src/main/resources/world-prompts/default.yml b/src/main/resources/world-prompts/default.yml new file mode 100644 index 0000000..f4236f2 --- /dev/null +++ b/src/main/resources/world-prompts/default.yml @@ -0,0 +1,9 @@ +# Fallback when no world-prompts entry matches the player's world. +# For one prompt on several worlds, use worlds: (see colder.yml). +# +# After editing, run: /agent reload_llm_prompt +prompt: | + You are a friendly in-game science education assistant. + Answer clearly and briefly; keep content appropriate for students. +# Optional path under the plugin data folder for RAG (txt/md/doc/docx files). +# rag-directory: llm-context From f009fa2f1d06ddd2e63891b8ecac60e9f9702e9b Mon Sep 17 00:00:00 2001 From: Geph Date: Wed, 24 Jun 2026 02:16:59 -0600 Subject: [PATCH 10/15] Sync pom to 3.3.13 after existing v3.3.12 release; retry GitHub deploy. Reconciles version with tags published on 2026-06-10 so CI can publish the next release. Co-authored-by: Cursor --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 1914ef5..c7167e8 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ 4.0.0 edu.whimc WHIMC-QRF-Agent - 3.3.11 + 3.3.13 WHIMC QRF Agent Defines overworld agent traits From 5f8878c41de0c2a2644320112b21681ea345b900 Mon Sep 17 00:00:00 2001 From: Geph <5846359+Geph@users.noreply.github.com> Date: Wed, 24 Jun 2026 08:19:30 +0000 Subject: [PATCH 11/15] chore(release): bump version to 3.3.14 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index c7167e8..3fe491e 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ 4.0.0 edu.whimc WHIMC-QRF-Agent - 3.3.13 + 3.3.14 WHIMC QRF Agent Defines overworld agent traits From 13ce743714601f8b1a46ed6df3eebdb2757da03d Mon Sep 17 00:00:00 2001 From: Geph Date: Wed, 24 Jun 2026 09:37:41 -0600 Subject: [PATCH 12/15] Log dialogue discussion turns to whimc_agent_chat research tables. Share AgentChatResearchLogger between interactive chat and Discuss-something flows so PMML and LLM dialogue turns persist with conversation ids. Co-authored-by: Cursor --- README.md | 2 +- .../commands/subcommands/ChatCommand.java | 174 ++++--------- .../dialoguetemplate/Dialogue.java | 230 +++++++++++++++++- .../llm/research/AgentChatResearchLogger.java | 187 ++++++++++++++ .../llm/research/AgentChatResearchTurn.java | 35 +++ 5 files changed, 497 insertions(+), 131 deletions(-) create mode 100644 src/main/java/edu/whimc/overworld_agent/llm/research/AgentChatResearchLogger.java create mode 100644 src/main/java/edu/whimc/overworld_agent/llm/research/AgentChatResearchTurn.java diff --git a/README.md b/README.md index 1a2fb86..96fc260 100644 --- a/README.md +++ b/README.md @@ -252,7 +252,7 @@ Separate from embodied right-click dialogue and from `llm.use-for-reply` on the **Research logging (MySQL)** -Each turn is stored for analysis (conversation id, turn index, provider/model, latency, status, errors). Related tables: +Each turn from **`/agent chat test`** and from **dialogue free discussion** (Discuss something → chat input, `command = dialogue_discussion`) is stored for analysis (conversation id, turn index, provider/model, latency, status, errors). PMML-only dialogue turns log with `provider = pmml`; LLM dialogue turns include full request/response payloads. Related tables: | Table | Purpose | |-------|---------| 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 0b2f04b..153bf20 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 @@ -8,6 +8,8 @@ import edu.whimc.overworld_agent.llm.context.AgentChatContextItem; import edu.whimc.overworld_agent.llm.context.AgentChatEvent; import edu.whimc.overworld_agent.llm.context.NpcContextProvider; +import edu.whimc.overworld_agent.llm.research.AgentChatResearchLogger; +import edu.whimc.overworld_agent.llm.research.AgentChatResearchTurn; import org.bukkit.Bukkit; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; @@ -16,11 +18,8 @@ import org.bukkit.event.Listener; import org.bukkit.event.player.AsyncPlayerChatEvent; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; import java.util.ArrayList; import java.util.Arrays; -import java.util.HexFormat; import java.util.List; import java.util.Map; import java.util.UUID; @@ -391,7 +390,7 @@ private void runLlmChatTurn(Player player, ActiveChatSession session, String use final List finalContextItems = contextItems; final String finalSystemPrompt = systemPrompt; final String finalLlmUserMessage = llmUserMessage; - final String systemPromptHash = sha256OrNull(systemPrompt); + final String systemPromptHash = AgentChatResearchLogger.sha256OrNull(systemPrompt); player.sendMessage("Thinking..."); @@ -701,39 +700,35 @@ private void storeResearchTurnWithContextItems( List contextItems, List events ) { - if (plugin.getQueryer() == null) { - plugin.getLogger().warning("[OverworldAgent][ResearchDB] Queryer is null; cannot store chat research turn."); - return; - } - - plugin.getQueryer().storeAgentChatResearchTurnWithContextItems( - conversationId, - turnId, - turnIndex, - time, - playerUuid, - username, - playerResearchId, - sessionId, - worldName, - agentType, - agentName, - command, - userMessage, - assistantResponse, - providerName, - modelName, - "default", - systemPromptHash, - ragEnabled, - userMessage, - requestStartedAt, - responseReceivedAt, - latencyMs, - status, - errorMessage, - contextItems == null ? List.of() : contextItems, - events == null ? List.of() : events + AgentChatResearchLogger.storeTurn( + plugin, + new AgentChatResearchTurn( + conversationId, + turnId, + turnIndex, + time, + playerUuid, + username, + playerResearchId, + sessionId, + worldName, + agentType, + agentName, + command, + userMessage, + assistantResponse, + providerName, + modelName, + systemPromptHash, + ragEnabled, + requestStartedAt, + responseReceivedAt, + latencyMs, + status, + errorMessage, + contextItems, + events + ) ); } @@ -743,13 +738,7 @@ private AgentChatEvent buildSimpleEvent( String eventType, String message ) { - String payload = - "{" + - "\"event_type\":\"" + jsonEscape(eventType) + "\"," + - "\"message\":\"" + jsonEscape(message) + "\"" + - "}"; - - return new AgentChatEvent(turnId, time, eventType, payload); + return AgentChatResearchLogger.simpleEvent(turnId, time, eventType, message); } private AgentChatEvent buildLlmRequestPayloadEvent( @@ -764,33 +753,18 @@ private AgentChatEvent buildLlmRequestPayloadEvent( boolean ragEnabled, List contextItems ) { - String payload = - "{" + - "\"trace_id\":\"" + jsonEscape(traceId) + "\"," + - "\"conversation_id\":\"" + jsonEscape(conversationId) + "\"," + - "\"turn_id\":\"" + jsonEscape(turnId) + "\"," + - "\"command\":\"chat_message\"," + - "\"provider\":\"" + jsonEscape(providerName) + "\"," + - "\"model\":\"" + jsonEscape(modelName) + "\"," + - "\"rag_enabled\":" + ragEnabled + "," + - "\"context_item_count\":" + (contextItems == null ? 0 : contextItems.size()) + "," + - "\"messages\":[" + - "{" + - "\"role\":\"system\"," + - "\"content\":\"" + jsonEscape(systemPrompt) + "\"" + - "}," + - "{" + - "\"role\":\"user\"," + - "\"content\":\"" + jsonEscape(userMessage) + "\"" + - "}" + - "]" + - "}"; - - return new AgentChatEvent( + return AgentChatResearchLogger.llmRequestPayloadEvent( turnId, time, - "llm_request_payload", - payload + conversationId, + traceId, + "chat_message", + providerName, + modelName, + systemPrompt, + userMessage, + ragEnabled, + contextItems ); } @@ -803,54 +777,11 @@ private AgentChatEvent buildLlmResponsePayloadEvent( Integer latencyMs, String errorMessage ) { - String payload = - "{" + - "\"trace_id\":\"" + jsonEscape(traceId) + "\"," + - "\"status\":\"" + jsonEscape(status) + "\"," + - "\"latency_ms\":" + (latencyMs == null ? "null" : latencyMs) + "," + - "\"response\":\"" + jsonEscape(response) + "\"," + - "\"error_message\":\"" + jsonEscape(errorMessage) + "\"" + - "}"; - - return new AgentChatEvent( - turnId, - time, - "llm_response_payload", - payload + return AgentChatResearchLogger.llmResponsePayloadEvent( + turnId, time, traceId, response, status, latencyMs, errorMessage ); } - private String jsonEscape(String value) { - if (value == null) { - return ""; - } - - StringBuilder escaped = new StringBuilder(); - - for (int i = 0; i < value.length(); i++) { - char c = value.charAt(i); - - switch (c) { - case '"' -> escaped.append("\\\""); - case '\\' -> escaped.append("\\\\"); - case '\b' -> escaped.append("\\b"); - case '\f' -> escaped.append("\\f"); - case '\n' -> escaped.append("\\n"); - case '\r' -> escaped.append("\\r"); - case '\t' -> escaped.append("\\t"); - default -> { - if (c < 0x20) { - escaped.append(String.format("\\u%04x", (int) c)); - } else { - escaped.append(c); - } - } - } - } - - return escaped.toString(); - } - private void logStage(String traceId, String stage, String message) { plugin.getLogger().info("[OverworldAgent][LLM chat " + traceId + "][" + stage + "] " + message); } @@ -867,21 +798,6 @@ private Throwable unwrapCompletionException(Throwable throwable) { return throwable; } - private String sha256OrNull(String value) { - if (value == null) { - return null; - } - - try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8)); - return HexFormat.of().formatHex(hash); - } catch (Exception e) { - plugin.getLogger().warning("[OverworldAgent][LLM chat] Could not hash system prompt: " + e.getMessage()); - return null; - } - } - @Override protected List onTabComplete(CommandSender sender, String[] args) { if (args.length == 1) { 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 9a3055a..1db8967 100644 --- a/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/Dialogue.java +++ b/src/main/java/edu/whimc/overworld_agent/dialoguetemplate/Dialogue.java @@ -8,6 +8,9 @@ import edu.whimc.overworld_agent.traits.AgentPermanentFlyingTrait; import edu.whimc.overworld_agent.dialoguetemplate.models.Chatbot; import edu.whimc.overworld_agent.dialoguetemplate.models.DialoguePrompt; +import edu.whimc.overworld_agent.llm.context.AgentChatEvent; +import edu.whimc.overworld_agent.llm.research.AgentChatResearchLogger; +import edu.whimc.overworld_agent.llm.research.AgentChatResearchTurn; import edu.whimc.overworld_agent.utils.AgentEntityTypes; import edu.whimc.overworld_agent.utils.Utils; @@ -57,6 +60,7 @@ public class Dialogue implements Listener { private final double THRESHOLD = .5; private final int AGENT_EDIT_NUM = 5; private static final int MAX_DISCUSSION_HISTORY = 10; + private static final String DIALOGUE_DISCUSSION_COMMAND = "dialogue_discussion"; private String feedback; private String response; private boolean text; @@ -64,6 +68,9 @@ public class Dialogue implements Listener { private Map prompts; /** Short-term memory for the ongoing free-discussion chat; only sent to the LLM path. */ private final List discussionHistory = new ArrayList<>(); + private String discussionConversationId; + private String discussionSessionId; + private int discussionTurnIndex; public Dialogue(OverworldAgent plugin, Player player, boolean text, boolean embodied) { this.spigotCallback = plugin.getSpigotCallback(); this.plugin = plugin; @@ -1355,8 +1362,20 @@ private void doResponse() { //Janky but waits until event is done before stores in db DialoguePrompt finalPrompt1 = prompt; final String[] feedbackOut = {feedback}; + final int finalPredictedClass = predictedClass; + final double finalCertainty = certainty; + final boolean[] dialogueResearchLogged = {false}; Runnable storeAndSend = () -> Bukkit.getScheduler().runTaskLater(plugin, () -> { + if (!dialogueResearchLogged[0]) { + logDialogueDiscussionPmmlTurn( + finalResponse, + feedbackOut[0], + finalPrompt1, + finalPredictedClass, + finalCertainty + ); + } this.plugin.getQueryer().storeNewScienceInquiry(player, finalResponse, feedbackOut[0], id -> { this.plugin.getQueryer().storeNewInteraction(new Interaction(plugin, player, "Dialogue"), id2 -> { if (finalPrompt1 != null && !finalPrompt1.getPrompt().equalsIgnoreCase("science_tool")) { @@ -1382,7 +1401,17 @@ private void doResponse() { systemPrompt = JourneyLlmBridge.appendDestinationContext(systemPrompt, destinations, max); } final String llmSystemPrompt = systemPrompt; - Chatbot llmChatbot = new Chatbot(buildLlmMessageWithHistory(finalResponse)); + final String llmUserMessage = buildLlmMessageWithHistory(finalResponse); + final long requestStartedAt = System.currentTimeMillis(); + final String turnId = newDiscussionTurnId(); + final int turnIndex = nextDiscussionTurnIndex(); + final String traceId = UUID.randomUUID().toString().substring(0, 8); + final String providerName = plugin.getConfig().getString("llm.provider", "unknown"); + final String modelName = plugin.getConfig().getString("llm.model", "unknown"); + final boolean ragEnabled = plugin.getConfig().getBoolean("llm.rag.enabled", false); + final String systemPromptHash = AgentChatResearchLogger.sha256OrNull(llmSystemPrompt); + + Chatbot llmChatbot = new Chatbot(llmUserMessage); CompletableFuture.supplyAsync(() -> { try { return llmChatbot.generateLlmReply(plugin.getLlmProvider(), llmSystemPrompt); @@ -1391,9 +1420,16 @@ private void doResponse() { return null; } }).thenAccept(llmText -> Bukkit.getScheduler().runTask(plugin, () -> { + long responseReceivedAt = System.currentTimeMillis(); + int latencyMs = (int) (responseReceivedAt - requestStartedAt); + String status; + String errorMessage = null; + String assistantForLog = feedbackOut[0]; + if (llmText != null && !llmText.isBlank()) { JourneyLlmBridge.ParsedReply parsed = JourneyLlmBridge.parseLlmReply(llmText); feedbackOut[0] = parsed.displayText().isBlank() ? llmText : parsed.displayText(); + assistantForLog = feedbackOut[0]; String journeyTarget = parsed.journeyNameId(); if (journeyTarget == null && journeyActions) { journeyTarget = JourneyLlmBridge.matchDestination(finalResponse, destinations).orElse(null); @@ -1406,7 +1442,34 @@ private void doResponse() { + journeyTarget); dispatchJourneyCommand(player, journeyTarget); } + status = "SUCCESS"; + } else { + status = "FALLBACK_PMML"; + errorMessage = "LLM returned no response; PMML/template reply shown."; } + + dialogueResearchLogged[0] = true; + logDialogueDiscussionLlmTurn( + turnId, + turnIndex, + requestStartedAt, + responseReceivedAt, + latencyMs, + finalResponse, + assistantForLog, + providerName, + modelName, + llmSystemPrompt, + llmUserMessage, + systemPromptHash, + ragEnabled, + status, + errorMessage, + traceId, + finalPrompt1, + finalPredictedClass, + finalCertainty + ); recordDiscussionTurn(finalResponse, feedbackOut[0]); storeAndSend.run(); })); @@ -1444,6 +1507,171 @@ private void recordDiscussionTurn(String userMessage, String agentReply) { } } + private void ensureDiscussionConversation() { + if (discussionConversationId == null) { + discussionConversationId = UUID.randomUUID().toString(); + discussionSessionId = player.getUniqueId().toString() + "-" + System.currentTimeMillis(); + discussionTurnIndex = 0; + } + } + + private String newDiscussionTurnId() { + ensureDiscussionConversation(); + return UUID.randomUUID().toString(); + } + + private int nextDiscussionTurnIndex() { + ensureDiscussionConversation(); + discussionTurnIndex++; + return discussionTurnIndex; + } + + private String dialogueAgentType() { + return embodied ? "EMBODIED_GUIDE" : "GUIDE"; + } + + private String dialogueAgentName() { + NPC npc = plugin.getAgents().get(player.getName()); + if (npc != null && npc.getName() != null && !npc.getName().isBlank()) { + return npc.getName(); + } + return embodied ? "embodied-dialogue-agent" : "dialogue-agent"; + } + + private String dialogueIntentLabel(DialoguePrompt prompt) { + if (prompt == null || prompt.getPrompt() == null) { + return "unknown"; + } + return prompt.getPrompt(); + } + + private void logDialogueDiscussionPmmlTurn( + String userMessage, + String assistantResponse, + DialoguePrompt prompt, + int predictedClass, + double certainty + ) { + long time = System.currentTimeMillis(); + String turnId = newDiscussionTurnId(); + int turnIndex = nextDiscussionTurnIndex(); + String intentLabel = dialogueIntentLabel(prompt); + + List events = List.of( + AgentChatResearchLogger.pmmlIntentEvent(turnId, time, predictedClass, certainty, intentLabel) + ); + + AgentChatResearchLogger.storeTurn( + plugin, + new AgentChatResearchTurn( + discussionConversationId, + turnId, + turnIndex, + time, + player.getUniqueId().toString(), + player.getName(), + player.getUniqueId().toString(), + discussionSessionId, + player.getWorld().getName(), + dialogueAgentType(), + dialogueAgentName(), + DIALOGUE_DISCUSSION_COMMAND, + userMessage, + assistantResponse, + "pmml", + "dialogue-intent", + null, + false, + time, + time, + 0, + "SUCCESS", + null, + List.of(), + events + ) + ); + } + + private void logDialogueDiscussionLlmTurn( + String turnId, + int turnIndex, + long requestStartedAt, + long responseReceivedAt, + int latencyMs, + String userMessage, + String assistantResponse, + String providerName, + String modelName, + String systemPrompt, + String llmUserMessage, + String systemPromptHash, + boolean ragEnabled, + String status, + String errorMessage, + String traceId, + DialoguePrompt prompt, + int predictedClass, + double certainty + ) { + String intentLabel = dialogueIntentLabel(prompt); + List events = List.of( + AgentChatResearchLogger.pmmlIntentEvent( + turnId, requestStartedAt, predictedClass, certainty, intentLabel), + AgentChatResearchLogger.llmRequestPayloadEvent( + turnId, + requestStartedAt, + discussionConversationId, + traceId, + DIALOGUE_DISCUSSION_COMMAND, + providerName, + modelName, + systemPrompt, + llmUserMessage, + ragEnabled, + List.of()), + AgentChatResearchLogger.llmResponsePayloadEvent( + turnId, + responseReceivedAt, + traceId, + assistantResponse, + status, + latencyMs, + errorMessage) + ); + + AgentChatResearchLogger.storeTurn( + plugin, + new AgentChatResearchTurn( + discussionConversationId, + turnId, + turnIndex, + requestStartedAt, + player.getUniqueId().toString(), + player.getName(), + player.getUniqueId().toString(), + discussionSessionId, + player.getWorld().getName(), + dialogueAgentType(), + dialogueAgentName(), + DIALOGUE_DISCUSSION_COMMAND, + userMessage, + assistantResponse, + providerName, + modelName, + systemPromptHash, + ragEnabled, + requestStartedAt, + responseReceivedAt, + latencyMs, + status, + errorMessage, + List.of(), + events + ) + ); + } + 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/llm/research/AgentChatResearchLogger.java b/src/main/java/edu/whimc/overworld_agent/llm/research/AgentChatResearchLogger.java new file mode 100644 index 0000000..9050134 --- /dev/null +++ b/src/main/java/edu/whimc/overworld_agent/llm/research/AgentChatResearchLogger.java @@ -0,0 +1,187 @@ +package edu.whimc.overworld_agent.llm.research; + +import edu.whimc.overworld_agent.OverworldAgent; +import edu.whimc.overworld_agent.llm.context.AgentChatContextItem; +import edu.whimc.overworld_agent.llm.context.AgentChatEvent; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.List; + +/** + * Persists turns to {@code whimc_agent_chat_*} for interactive chat and dialogue discussion. + */ +public final class AgentChatResearchLogger { + + private AgentChatResearchLogger() { + } + + public static void storeTurn(OverworldAgent plugin, AgentChatResearchTurn turn) { + if (plugin.getQueryer() == null) { + plugin.getLogger().warning("[OverworldAgent][ResearchDB] Queryer is null; cannot store chat research turn."); + return; + } + + plugin.getQueryer().storeAgentChatResearchTurnWithContextItems( + turn.conversationId(), + turn.turnId(), + turn.turnIndex(), + turn.time(), + turn.playerUuid(), + turn.username(), + turn.playerResearchId(), + turn.sessionId(), + turn.worldName(), + turn.agentType(), + turn.agentName(), + turn.command(), + turn.userMessage(), + turn.assistantResponse(), + turn.providerName(), + turn.modelName(), + "default", + turn.systemPromptHash(), + turn.ragEnabled(), + turn.userMessage(), + turn.requestStartedAt(), + turn.responseReceivedAt(), + turn.latencyMs(), + turn.status(), + turn.errorMessage(), + turn.contextItems() == null ? List.of() : turn.contextItems(), + turn.events() == null ? List.of() : turn.events() + ); + } + + public static AgentChatEvent simpleEvent(String turnId, long time, String eventType, String message) { + String payload = + "{" + + "\"event_type\":\"" + jsonEscape(eventType) + "\"," + + "\"message\":\"" + jsonEscape(message) + "\"" + + "}"; + + return new AgentChatEvent(turnId, time, eventType, payload); + } + + public static AgentChatEvent llmRequestPayloadEvent( + String turnId, + long time, + String conversationId, + String traceId, + String command, + String providerName, + String modelName, + String systemPrompt, + String userMessage, + boolean ragEnabled, + List contextItems + ) { + String payload = + "{" + + "\"trace_id\":\"" + jsonEscape(traceId) + "\"," + + "\"conversation_id\":\"" + jsonEscape(conversationId) + "\"," + + "\"turn_id\":\"" + jsonEscape(turnId) + "\"," + + "\"command\":\"" + jsonEscape(command) + "\"," + + "\"provider\":\"" + jsonEscape(providerName) + "\"," + + "\"model\":\"" + jsonEscape(modelName) + "\"," + + "\"rag_enabled\":" + ragEnabled + "," + + "\"context_item_count\":" + (contextItems == null ? 0 : contextItems.size()) + "," + + "\"messages\":[" + + "{" + + "\"role\":\"system\"," + + "\"content\":\"" + jsonEscape(systemPrompt) + "\"" + + "}," + + "{" + + "\"role\":\"user\"," + + "\"content\":\"" + jsonEscape(userMessage) + "\"" + + "}" + + "]" + + "}"; + + return new AgentChatEvent(turnId, time, "llm_request_payload", payload); + } + + public static AgentChatEvent llmResponsePayloadEvent( + String turnId, + long time, + String traceId, + String response, + String status, + Integer latencyMs, + String errorMessage + ) { + String payload = + "{" + + "\"trace_id\":\"" + jsonEscape(traceId) + "\"," + + "\"status\":\"" + jsonEscape(status) + "\"," + + "\"latency_ms\":" + (latencyMs == null ? "null" : latencyMs) + "," + + "\"response\":\"" + jsonEscape(response) + "\"," + + "\"error_message\":\"" + jsonEscape(errorMessage) + "\"" + + "}"; + + return new AgentChatEvent(turnId, time, "llm_response_payload", payload); + } + + public static AgentChatEvent pmmlIntentEvent( + String turnId, + long time, + int predictedClass, + double certainty, + String intentLabel + ) { + String payload = + "{" + + "\"predicted_class\":" + predictedClass + "," + + "\"certainty\":" + certainty + "," + + "\"intent_label\":\"" + jsonEscape(intentLabel) + "\"" + + "}"; + + return new AgentChatEvent(turnId, time, "pmml_intent", payload); + } + + public static String sha256OrNull(String value) { + if (value == null) { + return null; + } + + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hash); + } catch (Exception e) { + return null; + } + } + + public static String jsonEscape(String value) { + if (value == null) { + return ""; + } + + StringBuilder escaped = new StringBuilder(); + + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + + switch (c) { + case '"' -> escaped.append("\\\""); + case '\\' -> escaped.append("\\\\"); + case '\b' -> escaped.append("\\b"); + case '\f' -> escaped.append("\\f"); + case '\n' -> escaped.append("\\n"); + case '\r' -> escaped.append("\\r"); + case '\t' -> escaped.append("\\t"); + default -> { + if (c < 0x20) { + escaped.append(String.format("\\u%04x", (int) c)); + } else { + escaped.append(c); + } + } + } + } + + return escaped.toString(); + } +} diff --git a/src/main/java/edu/whimc/overworld_agent/llm/research/AgentChatResearchTurn.java b/src/main/java/edu/whimc/overworld_agent/llm/research/AgentChatResearchTurn.java new file mode 100644 index 0000000..73c7341 --- /dev/null +++ b/src/main/java/edu/whimc/overworld_agent/llm/research/AgentChatResearchTurn.java @@ -0,0 +1,35 @@ +package edu.whimc.overworld_agent.llm.research; + +import edu.whimc.overworld_agent.llm.context.AgentChatContextItem; +import edu.whimc.overworld_agent.llm.context.AgentChatEvent; + +import java.util.List; + +public record AgentChatResearchTurn( + String conversationId, + String turnId, + int turnIndex, + long time, + String playerUuid, + String username, + String playerResearchId, + String sessionId, + String worldName, + String agentType, + String agentName, + String command, + String userMessage, + String assistantResponse, + String providerName, + String modelName, + String systemPromptHash, + boolean ragEnabled, + long requestStartedAt, + Long responseReceivedAt, + Integer latencyMs, + String status, + String errorMessage, + List contextItems, + List events +) { +} From 0558fae87b043e88f16bf48ed6ee24d811ef2cb1 Mon Sep 17 00:00:00 2001 From: Geph <5846359+Geph@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:38:33 +0000 Subject: [PATCH 13/15] chore(release): bump version to 3.3.15 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 3fe491e..8b50982 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ 4.0.0 edu.whimc WHIMC-QRF-Agent - 3.3.14 + 3.3.15 WHIMC QRF Agent Defines overworld agent traits From bf554512eb1234a9438f4f79d7b5d496e506e420 Mon Sep 17 00:00:00 2001 From: Geph Date: Wed, 24 Jun 2026 12:19:11 -0600 Subject: [PATCH 14/15] Add custom URL skins on spawn and document LLM reference material (RAG). /agent spawn player --url uses Citizens/Mineskin; docs/llm-rag.md explains file-based context stuffing and README links to it. Co-authored-by: Cursor --- README.md | 15 +- docs/llm-rag.md | 170 +++++++++ example-config.yml | 2 + .../subcommands/ExpertSpawnCommand.java | 356 +++++++++++++----- .../utils/CitizensSkinUrls.java | 62 +++ src/main/resources/config.yml | 4 + 6 files changed, 503 insertions(+), 106 deletions(-) create mode 100644 docs/llm-rag.md create mode 100644 src/main/java/edu/whimc/overworld_agent/utils/CitizensSkinUrls.java diff --git a/README.md b/README.md index 96fc260..941a257 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,10 @@ Alternatively you can spawn a guide agent with **`/agent spawn`** (`/agents` is **Spawn syntax** -- **Player agent:** `/agent 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:** `/agent 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:** `/agent 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 (config skin):** `/agent spawn player ` — skin key from `skins.` in `config.yml`. +- **Player agent (custom URL):** `/agent spawn player --url ` — direct **https** link to a **.png** skin (same Mineskin flow as Citizens `/npc skin --url`). Optional `--slim` for slim arms. Set `agent-spawn.allow-url-skins: false` to disable. +- **Animal agent:** `/agent spawn ` — mob ID from `AgentEntityTypes` (tab-complete). No skin argument. +- **Legacy:** `/agent spawn ` — first token treated as a **player** skin key if it is not a valid entity type. Tab-complete the first argument to see every allowed value on your server version. @@ -95,6 +96,8 @@ Other useful keys: `llm.model`, `llm.base-url` (for `openai_compatible` only), ` RAG (retrieval-augmented generation) here means: **optional** inclusion of plain-text files from a designated folder into the **system** prompt so the model can ground answers in your own notes. +**Full guide:** [How LLM reference material (RAG) works](docs/llm-rag.md) — directory layout, per-world vs global paths, file parsing, limits, and what this plugin does *not* do (no vector DB or query-time retrieval). + | Key | Description | |-----|-------------| | `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`. | @@ -103,7 +106,7 @@ RAG (retrieval-augmented generation) here means: **optional** inclusion of plain | `llm.rag.max-directory-depth` | How deep to walk subfolders. | | `llm.rag.include-extensions` | File extensions to read (default `txt`, `md`, `doc`, `docx`). Word files are converted to plain text via Apache POI. | -Put glossaries, world lore, or lesson snippets as `.md`, `.txt`, `.doc`, or `.docx` 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`, `.doc`, or `.docx` files there. See [docs/llm-rag.md](docs/llm-rag.md) for behavior details. #### Per-world prompts (`world-prompts/`) @@ -114,11 +117,11 @@ Long or world-specific system prompts live in **`plugins/WHIMC-QRF-Agent/world-p | `world` | Single Bukkit world name this prompt applies to. | | `worlds` | List of world names sharing one prompt (e.g. `ColderStrip`, `ColderHot`, `ColderCold`). | | `prompt` | Multi-line system prompt text for those worlds. | -| `rag-directory` | Optional folder under the plugin data directory for world-specific RAG (overrides global `llm.context-directory` for that world). | +| `rag-directory` | Optional folder under the plugin data directory for world-specific RAG (see [docs/llm-rag.md](docs/llm-rag.md)). | If no file matches the player's world, **`world-prompts/default.yml`** is used; if that is missing, the plugin falls back to **`llm.system-prompt`** in `config.yml`. -**Per-world `rag-directory`** (in a world-prompt YAML) is appended whenever that world’s prompt is built — it does **not** require `llm.rag.enabled: true` in the main config. Global `llm.rag` only applies to the fallback `llm.system-prompt` path. +**Per-world `rag-directory`** is documented in [docs/llm-rag.md](docs/llm-rag.md#two-ways-reference-files-get-attached). In short: it does **not** require `llm.rag.enabled: true` in the main config. Reload without restart: **`/agent reload_llm_prompt`** (optional world name). In-game output shows prompt size; **`(RAG appended)`** means files from `rag-directory` were included. For a per-file list, check the server console **`[OverworldAgent][LLM]`** block on startup/reload (or set `llm.debug-log: true`). diff --git a/docs/llm-rag.md b/docs/llm-rag.md new file mode 100644 index 0000000..80e87cf --- /dev/null +++ b/docs/llm-rag.md @@ -0,0 +1,170 @@ +# LLM reference material (RAG) + +This document describes how WHIMC QRF Agent loads files from `llm-context` (and per-world folders) and attaches them to LLM calls. + +## What “RAG” means here + +In this plugin, **RAG is not vector search or semantic retrieval**. The server: + +1. Reads files from disk on the server. +2. Truncates them to configured size limits. +3. Pastes excerpts into the **system prompt** before each LLM call. + +The model is instructed to use that block only when relevant. There is **no embedding index**, **no similarity search**, and **no per-question chunk selection**. Every eligible file in the configured folder is considered, in sorted path order, until character limits are reached. + +Implementation: `LlmRagContextBuilder` and `RagDocumentReader` in the plugin source. + +## Directory layout + +Typical layout under the plugin data folder (`plugins/WHIMC-QRF-Agent/`): + +``` +plugins/WHIMC-QRF-Agent/ +├── world-prompts/ # Per-world system prompts (+ optional rag-directory) +│ ├── default.yml +│ └── colder.yml +└── llm-context/ # Global RAG folder (when llm.rag.enabled) + └── colder/ # Example per-world subfolder + ├── notes.txt + └── lesson.docx +``` + +On each LLM call (dialogue **Discuss something** with `llm.use-for-reply: true`, or `/agent chat test`): + +1. Resolve the **base system prompt** for the player’s world. +2. Optionally **append file contents** under a `## Reference material` header. +3. Send the combined string as the **system** message to the provider. +4. Send the student’s message as the **user** message. + +## Two ways reference files get attached + +These paths are **independent**: + +| Path | When it runs | Configuration | +|------|----------------|---------------| +| **Per-world RAG** | Player is in a world matched by `world-prompts/*.yml` **and** that file sets `rag-directory` | e.g. `rag-directory: llm-context/colder` in `world-prompts/colder.yml` | +| **Global RAG** | No world prompt with `rag-directory`, and fallback uses `llm.system-prompt` **and** `llm.rag.enabled: true` | `llm.context-directory: llm-context` in `config.yml` | + +**Important:** Per-world `rag-directory` works **without** `llm.rag.enabled: true`. Global `llm.rag` only applies on the fallback `llm.system-prompt` path. + +### Prompt resolution order + +`WorldLlmPromptRegistry.buildSystemPrompt` resolves in this order: + +1. World-specific YAML (e.g. `ColderStrip` → `colder.yml`) +2. Else `world-prompts/default.yml` +3. Else `llm.system-prompt` in `config.yml` + +If the chosen world prompt defines `rag-directory`, files from that folder are appended. Otherwise, if global `llm.rag.enabled` is on, files from `llm.context-directory` are appended. + +## How files are scanned and parsed + +### 1. Directory scan + +- **Root:** `plugins/WHIMC-QRF-Agent/` (relative to the plugin data folder), or an absolute path if set in YAML. +- **Recursion:** Subfolders up to `llm.rag.max-directory-depth` (default **6**). +- **Extensions:** Only types listed in `llm.rag.include-extensions` (default `txt`, `md`, `doc`, `docx`). +- **Order:** Files are processed in **sorted path order** (stable and predictable). + +### 2. Text extraction + +| Extension | Method | +|-----------|--------| +| `.txt`, `.md` | UTF-8 plain text | +| `.docx` | Apache POI `XWPFWordExtractor` | +| `.doc` | Apache POI `WordExtractor` | + +Unsupported extensions are skipped. + +### 3. Truncation limits + +From `config.yml` → `llm.rag`: + +| Setting | Default | Effect | +|---------|---------|--------| +| `max-file-chars` | 4000 | Each file is cut off with `...[truncated]` if longer | +| `max-total-chars` | 12000 | Stops adding files once total RAG size reaches this | +| `max-directory-depth` | 6 | Maximum subdirectory depth when walking the tree | + +### 4. Prompt assembly + +Each file becomes a block like: + +```text +--- file: colder/notes.txt --- +(contents here) + +``` + +All blocks are appended after this header: + +```text +## Reference material (from server context files — use only if relevant) +``` + +The LLM therefore sees: **mentor instructions + pasted reference docs + (optionally) Journey destinations + (in `/agent chat test` only) nearby NPC summaries**. + +## What does *not* happen + +- **No query-time retrieval** — the student’s question does not select which files or chunks to load. +- **No chunking or embeddings** — whole files (truncated) are included; the `whimc_agent_chat_retrieved_chunks` table is reserved for future use and is **not populated** today. +- **No separate RAG reload** — files are read when the system prompt is built for that LLM call (reload world prompts with `/agent reload_llm_prompt`; file contents are re-read on each call). +- **PMML-only dialogue** — if `llm.use-for-reply: false`, discussion does not call the LLM, so reference files are not used. + +## Configuration reference + +**Global** (`config.yml`): + +```yaml +llm: + context-directory: llm-context + rag: + enabled: false + max-total-chars: 12000 + max-file-chars: 4000 + max-directory-depth: 6 + include-extensions: + - txt + - md + - doc + - docx + debug-log: false # log RAG char counts and file lists to console +``` + +**Per-world** (`world-prompts/colder.yml` example): + +```yaml +worlds: + - ColderStrip + - ColderHot + - ColderCold +prompt: | + You are a WHIMC science mentor... +rag-directory: llm-context/colder +``` + +## Verifying that RAG is working + +1. Add files under e.g. `plugins/WHIMC-QRF-Agent/llm-context/colder/`. +2. Set `rag-directory: llm-context/colder` in the matching world-prompt YAML. +3. Run `/agent reload_llm_prompt ColderStrip` (or your world name). +4. Check the server console for `[OverworldAgent][LLM]` — file list and `(RAG appended)` in reload output when material was included. +5. Set `llm.debug-log: true` for per-call prompt size and RAG diagnostics. + +## Practical tips + +- **File order matters** — earlier paths (alphabetically) are included first if you hit `max-total-chars`. +- **Prefer several smaller files** over one huge document so more topics fit under the cap. +- **Use `.txt` / `.md` when possible** — Word support is for convenience; complex formatting may be lost. +- **Per-world folders** keep scenario-specific lore separate (e.g. Colder vs other worlds). + +## Related features (not RAG) + +These also extend the system prompt but are separate from `llm-context`: + +| Feature | Config | Purpose | +|---------|--------|---------| +| Journey actions | `llm.journey-actions` | Appends destination catalog for navigation from chat | +| NPC context | `llm.npc-context` | Nearby Citizens NPC summaries (`/agent chat test` only) | + +See the main [README](../README.md) for LLM provider setup, world prompts, and dialogue behavior. diff --git a/example-config.yml b/example-config.yml index 516664c..1bf92be 100644 --- a/example-config.yml +++ b/example-config.yml @@ -18,6 +18,8 @@ journey: poi-source: both worldguard-table-prefix: rg_ agent_type: scientist_casual +agent-spawn: + allow-url-skins: true # Blocks above ground for non-player agent mobs (no gravity + height lock). Player-shaped agents ignore this. Use 0 to disable vertical tracking (only no-gravity). agent-non-player-hover-height: 2.0 # Multiplier applied to Citizens navigator speed for non-player agents (1.0 = default mob follow speed). Higher = faster when hovering/following. 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 168e089..4044b37 100644 --- a/src/main/java/edu/whimc/overworld_agent/commands/subcommands/ExpertSpawnCommand.java +++ b/src/main/java/edu/whimc/overworld_agent/commands/subcommands/ExpertSpawnCommand.java @@ -3,14 +3,14 @@ import edu.whimc.overworld_agent.OverworldAgent; import edu.whimc.overworld_agent.commands.AbstractSubCommand; import edu.whimc.overworld_agent.utils.AgentEntityTypes; -import edu.whimc.overworld_agent.traits.AgentFollowTuning; +import edu.whimc.overworld_agent.utils.CitizensSkinUrls; import edu.whimc.overworld_agent.traits.AgentFollowCatchUpTrait; +import edu.whimc.overworld_agent.traits.AgentFollowTuning; import edu.whimc.overworld_agent.traits.AgentPermanentFlyingTrait; import edu.whimc.overworld_agent.traits.SpawnExpertTrait; import net.citizensnpcs.api.CitizensAPI; import net.citizensnpcs.api.npc.NPC; import net.citizensnpcs.api.npc.NPCRegistry; -import net.citizensnpcs.trait.FollowTrait; import net.citizensnpcs.trait.LookClose; import net.citizensnpcs.trait.SkinTrait; import org.bukkit.command.CommandSender; @@ -26,50 +26,137 @@ import java.util.stream.Collectors; /** - * Class to define command for spawning an expert agent - * @author sam + * Spawns a guide agent for the executing player. */ public class ExpertSpawnCommand extends AbstractSubCommand { - - private final String COMMAND = "expert"; + private static final String COMMAND = "expert"; private static final List ANIMAL_ENTITY_NAMES = AgentEntityTypes.animalNamesLowercaseSorted(); - public ExpertSpawnCommand(OverworldAgent plugin, String baseCommand, String subCommand){ + public ExpertSpawnCommand(OverworldAgent plugin, String baseCommand, String subCommand) { super(plugin, baseCommand, subCommand); super.description("Spawns an agent to follow sender with specified entity type/skin and name"); - super.arguments("[entityType] skinName agentName"); + super.arguments("[entityType] [skinName | --url ] agentName"); } - /** - * Creates a new expert agent and adds the entity to the world with the appropriate traits - * @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) { - String npcName = ""; - String playerName = ""; - Player player; + if (!(sender instanceof Player player)) { + sender.sendMessage("You must be a player"); + return true; + } - String path = "skins."+plugin.getSkinType(); + if (plugin.getAgents().containsKey(player.getName())) { + player.sendMessage("You already have an AI friend. You can change their name by right clicking on them."); + return true; + } - if (!(sender instanceof Player)) { - sender.sendMessage("You must be a player"); + SpawnRequest request = parseSpawnRequest(player, args); + if (request == null) { return true; - } else { - player = (Player) sender; - playerName = player.getName(); - if(args.length < 2){ - player.sendMessage("Make sure to give your AI friend an appearance and a name! Please try again"); + } + + NPC npc = createGuideNpc(player, request.entityType(), request.npcName()); + if (npc == null) { + return true; + } + + if (request.skinUrl() != null) { + if (!plugin.getConfig().getBoolean("agent-spawn.allow-url-skins", true)) { + npc.destroy(); + player.sendMessage("Custom URL skins are disabled on this server."); return true; } + + player.sendMessage("Fetching skin from URL..."); + CitizensSkinUrls.fetchFromUrl( + plugin, + request.skinUrl(), + request.slim(), + skinData -> applySkinAndFinishSpawn(player, npc, request.npcName(), request.entityType(), skinData), + error -> { + npc.destroy(); + player.sendMessage( + "Could not load skin from that URL. Use a direct https:// link to a .png file. " + + "(" + error + ")" + ); + } + ); + return true; + } + + if (request.entityType() == EntityType.PLAYER) { + String path = "skins." + plugin.getSkinType(); + String signature = plugin.getConfig().getString(path + "." + request.configSkinName() + ".signature"); + String data = plugin.getConfig().getString(path + "." + request.configSkinName() + ".data"); + SkinTrait skinTrait = npc.getOrAddTrait(SkinTrait.class); + skinTrait.setSkinPersistent(request.configSkinName(), signature, data); + } + + finishSpawn(player, npc, request.npcName(), request.entityType(), request.configSkinName()); + return true; + } + + private SpawnRequest parseSpawnRequest(Player player, String[] args) { + int urlIndex = indexOfFlag(args, "--url"); + if (urlIndex >= 0) { + return parseUrlSpawnRequest(player, args, urlIndex); + } + return parseConfigSkinSpawnRequest(player, args); + } + + private SpawnRequest parseUrlSpawnRequest(Player player, String[] args, int urlIndex) { + if (urlIndex + 1 >= args.length) { + player.sendMessage("Provide a URL after --url (direct https link to a .png skin image)."); + return null; + } + + String skinUrl = args[urlIndex + 1]; + if (!CitizensSkinUrls.isHttpsUrl(skinUrl)) { + player.sendMessage("Skin URL must start with https:// and point to a direct image link."); + return null; + } + + boolean slim = indexOfFlag(args, "--slim") >= 0; + EntityType entityType = EntityType.PLAYER; + + if (urlIndex > 0) { + String first = args[0]; + if (!first.startsWith("--")) { + EntityType parsed = parseEntityTypeOrNull(first); + if (parsed != null) { + entityType = parsed; + } else if (!first.equalsIgnoreCase("player")) { + player.sendMessage("For custom URL skins use: /agent spawn player --url "); + return null; + } + } + } + + if (entityType != EntityType.PLAYER) { + player.sendMessage("Custom URL skins are only supported for player agents."); + return null; + } + + String npcName = joinTokensSkippingFlags(args, urlIndex + 2); + if (npcName.isBlank()) { + player.sendMessage("Provide a name for your agent after the skin URL."); + return null; + } + + return new SpawnRequest(entityType, null, skinUrl, slim, truncateNpcName(npcName)); + } + + private SpawnRequest parseConfigSkinSpawnRequest(Player player, String[] args) { + if (args.length < 2) { + player.sendMessage( + "Give your AI friend an appearance and a name. " + + "Example: /agent spawn player wmscientist Alex " + + "or /agent spawn player --url https://example.com/skin.png Alex" + ); + return null; } - // New (preferred) syntax: - // /agents spawn [skinName] - // Backwards compatible syntax: - // /agents spawn (entityType defaults to PLAYER) EntityType entityType = parseEntityTypeOrNull(args[0]); boolean explicitEntityType = entityType != null; if (!explicitEntityType) { @@ -82,7 +169,7 @@ protected boolean onCommand(CommandSender sender, String[] args) { if (explicitEntityType) { if (args.length < 3) { player.sendMessage("For player agents, provide an entity type, a skin, and a name."); - return true; + return null; } skinName = args[1]; nameStartIndex = 2; @@ -91,72 +178,130 @@ protected boolean onCommand(CommandSender sender, String[] args) { nameStartIndex = 1; } } else { - // For non-player entity types, skin is not used. nameStartIndex = 1; } - for(int k = nameStartIndex; k < args.length; k++){ - npcName += args[k] + " "; - } - npcName = npcName.substring(0,npcName.length()-1); - if(npcName.length() > 25){ - npcName = npcName.substring(0,25); + String npcName = joinTokens(args, nameStartIndex); + if (npcName.isBlank()) { + player.sendMessage("Provide a name for your agent."); + return null; } - if(!plugin.getAgents().containsKey(playerName)) { + npcName = truncateNpcName(npcName); + + if (entityType == EntityType.PLAYER) { + String path = "skins." + plugin.getSkinType(); ConfigurationSection sec = plugin.getConfig().getConfigurationSection(path); Set keys = sec != null ? sec.getKeys(false) : Set.of(); - List skins = new ArrayList<>(keys); - if (entityType == EntityType.PLAYER && (skinName == null || !skins.contains(skinName))) { - player.sendMessage("Make sure to give your AI friend a valid skin name, you can press tab to complete one of the options! Please try again"); - return true; + if (skinName == null || !keys.contains(skinName)) { + player.sendMessage( + "Use a valid skin name from tab completion, or spawn with " + + "/agent spawn player --url for a custom skin." + ); + return null; } - NPCRegistry registry = CitizensAPI.getNPCRegistry(); + } - // Validate entity type if non-player (fixed whitelist in AgentEntityTypes; not all Animals in the game) - if (entityType != EntityType.PLAYER) { - if (!AgentEntityTypes.isAllowedNonPlayerAgent(entityType)) { - player.sendMessage("That entity type cannot be used as an animal agent."); - return true; - } - Class entityClass = entityType.getEntityClass(); - if (entityClass == null || !entityType.isAlive()) { - player.sendMessage("That entity type cannot be used as an animal agent."); - return true; - } + return new SpawnRequest(entityType, skinName, null, false, npcName); + } + + private NPC createGuideNpc(Player player, EntityType entityType, String npcName) { + if (entityType != EntityType.PLAYER) { + if (!AgentEntityTypes.isAllowedNonPlayerAgent(entityType)) { + player.sendMessage("That entity type cannot be used as an animal agent."); + return null; + } + Class entityClass = entityType.getEntityClass(); + if (entityClass == null || !entityType.isAlive()) { + player.sendMessage("That entity type cannot be used as an animal agent."); + return null; } + } + + NPCRegistry registry = CitizensAPI.getNPCRegistry(); + NPC npc = registry.createNPC(entityType, npcName); + npc.getOrAddTrait(LookClose.class).setDisableWhileNavigating(true); + AgentFollowTuning.applyForPlannedType(plugin, npc, entityType); + SpawnExpertTrait trait = new SpawnExpertTrait(); + trait.setPlayer(player); + trait.setInputType(true); + npc.addTrait(trait); + npc.addTrait(new AgentPermanentFlyingTrait()); + npc.addTrait(new AgentFollowCatchUpTrait()); + return npc; + } + + private void applySkinAndFinishSpawn( + Player player, + NPC npc, + String npcName, + EntityType entityType, + CitizensSkinUrls.SkinData skinData + ) { + if (plugin.getAgents().containsKey(player.getName())) { + npc.destroy(); + player.sendMessage("You already have an AI friend."); + return; + } + + SkinTrait skinTrait = npc.getOrAddTrait(SkinTrait.class); + skinTrait.setSkinPersistent(skinData.cacheId(), skinData.signature(), skinData.texture()); + finishSpawn(player, npc, npcName, entityType, skinData.cacheId()); + } + + private void finishSpawn(Player player, NPC npc, String npcName, EntityType entityType, String agentSkinOrType) { + String storedAppearance = entityType == EntityType.PLAYER ? agentSkinOrType : entityType.name(); + plugin.getQueryer().storeNewAgent(player, COMMAND, npcName, storedAppearance, id -> { + npc.spawn(player.getLocation()); + AgentFollowTuning.scheduleFollowAndApplyTraits(plugin, npc, player); + plugin.getAgents().put(player.getName(), npc); + }); + } + + private static String truncateNpcName(String npcName) { + if (npcName.length() > 25) { + return npcName.substring(0, 25); + } + return npcName; + } - NPC npc = registry.createNPC(entityType, npcName); - npc.getOrAddTrait(LookClose.class).setDisableWhileNavigating(true); - AgentFollowTuning.applyForPlannedType(plugin, npc, entityType); - SpawnExpertTrait trait = new SpawnExpertTrait(); - trait.setPlayer(player); - 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 - String signature = plugin.getConfig().getString(path + "." + skinName + ".signature"); - String data = plugin.getConfig().getString(path + "." + skinName + ".data"); - SkinTrait skinTrait = npc.getOrAddTrait(SkinTrait.class); - skinTrait.setSkinPersistent(skinName, signature, data); + private static String joinTokens(String[] args, int startIndex) { + StringBuilder builder = new StringBuilder(); + for (int i = startIndex; i < args.length; i++) { + if (builder.length() > 0) { + builder.append(' '); } + builder.append(args[i]); + } + return builder.toString().trim(); + } - String agentSkinOrType = entityType == EntityType.PLAYER ? skinName : entityType.name(); - plugin.getQueryer().storeNewAgent(player, COMMAND, npcName, agentSkinOrType, id -> { - npc.spawn(player.getLocation()); - AgentFollowTuning.scheduleFollowAndApplyTraits(plugin, npc, player); - plugin.getAgents().put(player.getName(), npc); - }); - return true; + private static String joinTokensSkippingFlags(String[] args, int startIndex) { + StringBuilder builder = new StringBuilder(); + for (int i = startIndex; i < args.length; i++) { + if (args[i].startsWith("--")) { + continue; + } + if (builder.length() > 0) { + builder.append(' '); + } + builder.append(args[i]); } - player.sendMessage("You already have an AI friend. You can change their name by right clicking on them."); - return true; + return builder.toString().trim(); + } + + private static int indexOfFlag(String[] args, String flag) { + for (int i = 0; i < args.length; i++) { + if (flag.equalsIgnoreCase(args[i])) { + return i; + } + } + return -1; } private static EntityType parseEntityTypeOrNull(String raw) { - if (raw == null) return null; + if (raw == null) { + return null; + } try { return EntityType.valueOf(raw.trim().toUpperCase(Locale.ROOT)); } catch (IllegalArgumentException ex) { @@ -164,16 +309,9 @@ private static EntityType parseEntityTypeOrNull(String raw) { } } - /** - * Allows tab completion of command - * @param sender - Source of the command - * @param args - Passed command arguments - * @return list of tab completions (currently empty) - */ @Override - protected List onTabComplete(CommandSender sender, java.lang.String[] args) { + protected List onTabComplete(CommandSender sender, String[] args) { if (args.length == 1) { - // First token is always entity type: `player` or an animal enum name (skins are the *second* token after `player`). String prefix = args[0].toLowerCase(Locale.ROOT); List entityOpts = new ArrayList<>(); entityOpts.add("player"); @@ -182,21 +320,39 @@ protected List onTabComplete(CommandSender sender, java.lang.S .filter(v -> v.startsWith(prefix)) .collect(Collectors.toList()); } + if (args.length == 2 && args[0].equalsIgnoreCase("player")) { - String path = "skins"; - String type = plugin.getSkinType(); - path = path + "." + type; + String prefix = args[1].toLowerCase(Locale.ROOT); + List options = new ArrayList<>(); + if ("--url".startsWith(prefix)) { + options.add("--url"); + } + + String path = "skins." + plugin.getSkinType(); ConfigurationSection sec = plugin.getConfig().getConfigurationSection(path); - if (sec == null) { - return Arrays.asList(); + if (sec != null) { + options.addAll( + sec.getKeys(false).stream() + .filter(v -> v.toLowerCase(Locale.ROOT).startsWith(prefix)) + .collect(Collectors.toList()) + ); } - Set keys = sec.getKeys(false); - List skins = new ArrayList<>(keys); - String prefix = args[1].toLowerCase(Locale.ROOT); - return skins.stream() - .filter(v -> v.toLowerCase(Locale.ROOT).startsWith(prefix)) - .collect(Collectors.toList()); + return options; } - return Arrays.asList(); + + if (args.length == 3 && args[0].equalsIgnoreCase("player") && args[1].equalsIgnoreCase("--url")) { + return List.of(); + } + + return List.of(); + } + + private record SpawnRequest( + EntityType entityType, + String configSkinName, + String skinUrl, + boolean slim, + String npcName + ) { } } diff --git a/src/main/java/edu/whimc/overworld_agent/utils/CitizensSkinUrls.java b/src/main/java/edu/whimc/overworld_agent/utils/CitizensSkinUrls.java new file mode 100644 index 0000000..b679869 --- /dev/null +++ b/src/main/java/edu/whimc/overworld_agent/utils/CitizensSkinUrls.java @@ -0,0 +1,62 @@ +package edu.whimc.overworld_agent.utils; + +import net.citizensnpcs.util.MojangSkinGenerator; +import org.bukkit.Bukkit; +import org.bukkit.plugin.Plugin; +import org.json.simple.JSONObject; + +import java.util.function.Consumer; + +/** + * Fetches player skin texture data from a public image URL via Citizens' Mineskin integration + * (same backend as {@code /npc skin --url}). + */ +public final class CitizensSkinUrls { + + public record SkinData(String cacheId, String signature, String texture) { + } + + private CitizensSkinUrls() { + } + + public static boolean isHttpsUrl(String url) { + if (url == null || url.isBlank()) { + return false; + } + String trimmed = url.trim(); + return trimmed.regionMatches(true, 0, "https://", 0, 8); + } + + public static void fetchFromUrl( + Plugin plugin, + String url, + boolean slim, + Consumer onSuccess, + Consumer onFailure + ) { + String trimmedUrl = url.trim(); + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { + try { + JSONObject data = MojangSkinGenerator.generateFromURL(trimmedUrl, slim); + String cacheId = (String) data.get("uuid"); + JSONObject texture = (JSONObject) data.get("texture"); + String textureEncoded = (String) texture.get("value"); + String signature = (String) texture.get("signature"); + + if (cacheId == null || textureEncoded == null || signature == null) { + throw new IllegalStateException("Mineskin returned incomplete skin data."); + } + + SkinData skinData = new SkinData(cacheId, signature, textureEncoded); + Bukkit.getScheduler().runTask(plugin, () -> onSuccess.accept(skinData)); + } catch (Throwable t) { + String message = t.getMessage(); + if (message == null || message.isBlank()) { + message = t.getClass().getSimpleName(); + } + String finalMessage = message; + Bukkit.getScheduler().runTask(plugin, () -> onFailure.accept(finalMessage)); + } + }); + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index ef4ef95..172f498 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -25,6 +25,10 @@ journey: # Table prefix for WorldGuard MySQL storage (rg_region, rg_world). worldguard-table-prefix: rg_ agent_type: scientist_casual + +# Player spawn: /agent spawn player --url (uses Citizens/Mineskin, like /npc skin --url) +agent-spawn: + allow-url-skins: true # Blocks above ground for non-player agent mobs (no gravity + height lock). Player-shaped agents ignore this. Use 0 to disable vertical tracking (only no-gravity). agent-non-player-hover-height: 2.0 # Multiplier applied to Citizens navigator speed for non-player agents (1.0 = default mob follow speed). Higher = faster when hovering/following. From 153c7805f658249caf2bfd95550fff70ac9df905 Mon Sep 17 00:00:00 2001 From: Geph <5846359+Geph@users.noreply.github.com> Date: Wed, 24 Jun 2026 18:20:00 +0000 Subject: [PATCH 15/15] chore(release): bump version to 3.3.16 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 8b50982..0150c6e 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ 4.0.0 edu.whimc WHIMC-QRF-Agent - 3.3.15 + 3.3.16 WHIMC QRF Agent Defines overworld agent traits