diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..31dffcf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [animal-ai, llm-agent] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + cache: maven + + - name: Build plugin JAR + run: mvn -B -ntp package diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..28de177 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,32 @@ +name: CodeQL + +on: + pull_request: + branches: [main] + push: + branches: [main] + +permissions: + contents: read + security-events: write + +jobs: + analyze: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + cache: maven + + - uses: github/codeql-action/init@v3 + with: + languages: java + + - name: Build for CodeQL + run: mvn -B -ntp package + + - uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/dependency-audit.yml b/.github/workflows/dependency-audit.yml new file mode 100644 index 0000000..348a306 --- /dev/null +++ b/.github/workflows/dependency-audit.yml @@ -0,0 +1,26 @@ +name: Dependency Audit + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + dependency-audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + cache: maven + + - name: Verify dependency resolution + run: mvn -B -ntp dependency:tree -Dverbose=false + + - name: Check for undeclared or unused dependencies + run: mvn -B -ntp dependency:analyze-only -DignoreNonCompile=true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b198b1e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,74 @@ +# Publishes a downloadable JAR to GitHub Releases only. +# Minecraft server install is manual — no deploy steps or secrets. +name: Release + +on: + push: + branches: [main, animal-ai] + +concurrency: + group: release-${{ github.ref_name }} + cancel-in-progress: false + +permissions: + contents: write + pull-requests: write + +jobs: + release: + # Skip version-bump commits and merges of version-bump PRs to avoid release loops. + if: ${{ !contains(github.event.head_commit.message, 'chore(release):') && !contains(github.event.head_commit.message, 'release/bump-') }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + cache: maven + + - name: Bump patch version in pom.xml + id: version + run: | + CURRENT="$(mvn -B -ntp help:evaluate -Dexpression=project.version -q -DforceStdout)" + IFS='.' read -r major minor patch <<< "${CURRENT//-SNAPSHOT/}" + NEW="${major}.${minor}.$((patch + 1))" + echo "current=${CURRENT}" >> "$GITHUB_OUTPUT" + echo "new=${NEW}" >> "$GITHUB_OUTPUT" + mvn -B -ntp org.codehaus.mojo:versions-maven-plugin:2.17.1:set \ + -DnewVersion="${NEW}" \ + -DgenerateBackupPom=false + + - name: Build release JAR + run: mvn -B -ntp package + + - name: Publish GitHub Release + env: + NEW_VERSION: ${{ steps.version.outputs.new }} + GH_TOKEN: ${{ github.token }} + run: | + JAR="target/WHIMC-QRF-Agent-${NEW_VERSION}.jar" + test -f "${JAR}" + + gh release create "v${NEW_VERSION}" \ + "${JAR}" \ + --title "v${NEW_VERSION}" \ + --target "${GITHUB_SHA}" \ + --generate-notes + + - name: Open version bump pull request + uses: peter-evans/create-pull-request@v7 + with: + branch: release/bump-${{ steps.version.outputs.new }} + delete-branch: true + title: "chore(release): bump version to ${{ steps.version.outputs.new }}" + commit-message: "chore(release): bump version to ${{ steps.version.outputs.new }}" + body: | + Automated `pom.xml` version bump after publishing [v${{ steps.version.outputs.new }}](https://github.com/${{ github.repository }}/releases/tag/v${{ steps.version.outputs.new }}). + + Download the JAR from the GitHub Release and install it on the Minecraft server manually. + labels: release + add-paths: pom.xml diff --git a/.github/workflows/trufflehog.yml b/.github/workflows/trufflehog.yml new file mode 100644 index 0000000..3582a1b --- /dev/null +++ b/.github/workflows/trufflehog.yml @@ -0,0 +1,20 @@ +name: TruffleHog + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + trufflehog: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: trufflesecurity/trufflehog@main + with: + extra_args: --only-verified diff --git a/README.md b/README.md index 14b0c0d..941a257 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,21 @@ -# 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. 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 (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. -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 **`/agent rebuilderspawn`** and interacted with like a guide agent. _**Requires Java 21+**_ @@ -56,12 +57,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 +72,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. @@ -95,15 +96,61 @@ 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-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`). | +| `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. See [docs/llm-rag.md](docs/llm-rag.md) for behavior details. + +#### 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 (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`** 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`). + +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`). | -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. +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`) @@ -163,19 +210,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,28 +234,28 @@ 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). **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 | |-------|---------| @@ -236,56 +283,52 @@ 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`**. - -### 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`). | -| **`/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. | +### `/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_type`** | `whimc-agent.agents.chat_type` | Set disembodied dialogue mode: **`Guide`** or **`Builder`** (matches `DialogueType` enum; case-insensitive). | +| **`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. | -### `/agent` subcommands +*(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.)* -| Subcommand | Permission node | Description | -|------------|-----------------|-------------| -| **`chat`** | `whimc-agent.agent.chat` | Disembodied menu (`Guide`/`Builder`), or **`chat test`** / **`chat end`** for interactive LLM chat (see above). | -| **`spawn`** | `whimc-agent.agents.spawn` | Same as **`/agents spawn`** (uses the shared `ExpertSpawnCommand`). | +### Other commands (`plugin.yml`) + +| 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) 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. +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,17 +343,24 @@ 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 | |-----------------|-------------| -| 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). | +| 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. | | 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; the top-level menu ends with **"That's all for now"** as the last option. + +### 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 +372,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/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 c287cb9..1bf92be 100644 --- a/example-config.yml +++ b/example-config.yml @@ -1,15 +1,25 @@ -mysql: +mysql: host: localhost port: 3306 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 -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 +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. @@ -22,6 +32,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,11 +48,14 @@ 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." - # 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 @@ -48,252 +65,16 @@ llm: include-extensions: - 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 + - doc + - docx + npc-context: 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?" + radius: 25.0 + max-items: 3 + journey-actions: + enabled: true + max-destinations: 60 + prompts: - label: 0 prompt: agent @@ -424,7 +205,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..0150c6e 100644 --- a/pom.xml +++ b/pom.xml @@ -4,9 +4,9 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 edu.whimc - WHIMC-OverworldAgent - 3.3.10 - WHIMC Overworld Agent + WHIMC-QRF-Agent + 3.3.16 + WHIMC QRF Agent Defines overworld agent traits @@ -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/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..6fdfbbb 100644 --- a/src/main/java/edu/whimc/overworld_agent/OverworldAgent.java +++ b/src/main/java/edu/whimc/overworld_agent/OverworldAgent.java @@ -8,16 +8,17 @@ 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.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.dialoguetemplate.models.DialogueType; 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; @@ -53,7 +54,6 @@ */ public class OverworldAgent extends JavaPlugin { private Map agents; - private DialogueType agentType; private Queryer queryer; private List profanity; private SignMenuFactory signMenuFactory; @@ -61,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; @@ -78,11 +79,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 +93,6 @@ public void onEnable() { }); - Tag.startExpiredObservationScanningTask(this); - //check if Citizens is present and enabled. agents = new HashMap<>(); agentEdits = new HashMap<>(); @@ -114,21 +111,14 @@ 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"); + 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); - - 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); @@ -143,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); } @@ -175,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 @@ -263,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()); } } @@ -347,7 +362,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/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 3c07809..0000000 --- a/src/main/java/edu/whimc/overworld_agent/commands/AgentsCommand.java +++ /dev/null @@ -1,71 +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("chat_type", new ChangeAgentTypeCommand(plugin, "agents", "chat_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/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..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 @@ -2,14 +2,14 @@ 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; 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; @@ -18,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; @@ -71,19 +68,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 +195,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"); @@ -339,27 +329,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: " + @@ -406,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..."); @@ -716,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 + ) ); } @@ -758,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( @@ -779,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 ); } @@ -818,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); } @@ -882,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/commands/subcommands/ExpertSpawnCommand.java b/src/main/java/edu/whimc/overworld_agent/commands/subcommands/ExpertSpawnCommand.java index 89828c0..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,13 +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.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; @@ -25,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; + } + + 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; + } - String path = "skins."+plugin.getSkinType(); + SpawnRequest request = parseSpawnRequest(player, args); + if (request == null) { + return true; + } - if (!(sender instanceof Player)) { - sender.sendMessage("You must be a player"); + NPC npc = createGuideNpc(player, request.entityType(), request.npcName()); + if (npc == 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"); + } + + 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) { @@ -81,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; @@ -90,71 +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; + } + + 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(); + } - 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()); - - 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 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]); + } + 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 int indexOfFlag(String[] args, String flag) { + for (int i = 0; i < args.length; i++) { + if (flag.equalsIgnoreCase(args[i])) { + return i; + } } - player.sendMessage("You already have an AI friend. You can change their name by right clicking on them."); - return true; + 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) { @@ -162,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"); @@ -180,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/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/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..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; @@ -56,11 +59,18 @@ 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 static final String DIALOGUE_DISCUSSION_COMMAND = "dialogue_discussion"; 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<>(); + 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; @@ -100,53 +110,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); keep keys stable for getWaypoint(name). - String nameId = destination.toLowerCase(Locale.ROOT); - 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) { @@ -159,7 +265,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; @@ -167,6 +273,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) { @@ -202,7 +318,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 +484,271 @@ 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() || 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; } - String label = extractWaypointName(waypoint); + key = key.toLowerCase(Locale.ROOT); 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 (invokeGetWaypoint(publicWaypointManager, candidate) != null) { + return candidate; + } + } + return null; + } + + /** 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 fullSlug = trimmed.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", ""); + if (!fullSlug.isBlank()) { + 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) { + 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; + } + concat.append(w); + if (hyphen.length() > 0) { + hyphen.append('-'); + } + 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); + } + } + private void openJourneyDestinationTextInput() { List instruct = Arrays.asList( Utils.color("&0&lJourney"), @@ -408,45 +775,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 +816,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,14 +847,14 @@ 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); - if (c.jtKey != null && !c.jtKey.isBlank()) { + JourneyWaypointChoice c = choiceFromWaypointObject(item, publicWaypointManager); + if (c != null && c.jtKey != null && !c.jtKey.isBlank()) { choices.add(c); } } @@ -523,6 +866,135 @@ 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 -> { + dispatchJourneyCommand(player, wp.jtKey); + this.plugin.getQueryer().storeNewInteraction( + new Interaction(plugin, player, "Guidance"), id -> { }); + } + ); + } + sendBackOption(this::doDialogue); + } + ); + } + public void doDialogue() { plugin.relinkOwnedAgent(player); plugin.ensureAgentEdits(player); @@ -540,111 +1012,12 @@ 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", "&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. - 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() - ); - } - } - // 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, - "&8" + BULLET + scoreResponse, - "&aClick here to see your scores!", - p -> { - this.plugin.getQueryer().storeNewInteraction(new Interaction(plugin, player, "Progress"), id -> { - plugin.ensureStudentFeedbackSession(player); - Bukkit.dispatchCommand(player, "progress"); - }); - }); - - //Agent Dialogue option (free text → PMML / future LLM via chat, not sign) + // Discussion first (free text → PMML / LLM via chat) if (text) { sendComponent( player, @@ -662,32 +1035,102 @@ public void doDialogue() { doResponse(); }); } -/* - //Agent Reflection Option (disabled) + + // 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()) { + showGuidanceDestinationMenu(player, guidanceResponse, guidanceChoices); + } else { + sendComponent( + player, + "&8" + BULLET + guidanceResponse, + "&aClick here to enter a Journey destination (chat)", + p -> openJourneyDestinationTextInput() + ); + } + sendMenuTail.run(); + }); + } else { + sendMenuTail.run(); + } + } + + private void sendDialogueMenuTail(FileConfiguration cfg, String scoreResponse, String agentEdit) { + //Agent Score option sendComponent( player, - "&8" + BULLET + seeDialogue, - "&aClick here to see our conversation so far!", + "&8" + BULLET + scoreResponse, + "&aClick here to see your scores!", 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 -> { + this.plugin.getQueryer().storeNewInteraction(new Interaction(plugin, player, "Progress"), id -> { + plugin.ensureStudentFeedbackSession(player); + Bukkit.dispatchCommand(player, "progress"); }); }); -*/ + + //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() + ); + + Map edits = plugin.getAgentEdits().get(player); + int skinChange = edits.get("Skin"); + int nameChange = edits.get("Name"); + int typeChange = edits.getOrDefault("Type", 0); + if((skinChange < AGENT_EDIT_NUM || nameChange < AGENT_EDIT_NUM || typeChange < AGENT_EDIT_NUM) && embodied){ + //Agent edit Option + sendComponent(player, "&8" + BULLET + agentEdit, "&aClick here to change me!", p -> openEditMenu()); + } + + //Close option — always last + 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"); @@ -695,11 +1138,9 @@ public void doDialogue() { 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 +1148,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 +1183,7 @@ public void doDialogue() { }); }); } + sendBackOption(this::openEditMenu); }); } if(nameChange < AGENT_EDIT_NUM){ @@ -789,7 +1232,7 @@ public void doDialogue() { }); return true; }) - .open(p) + .open(player) );} if (typeChange < AGENT_EDIT_NUM) { sendComponent( @@ -797,6 +1240,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 +1289,11 @@ public void doDialogue() { }) ); } + sendBackOption(this::openEditMenu); } ); } - });} + sendBackOption(this::doDialogue); } @@ -917,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")) { @@ -932,28 +1389,289 @@ 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.")); - CompletableFuture.supplyAsync(() -> { - try { - return chatbot.generateLlmReply(plugin.getLlmProvider(), systemPrompt); - } catch (Exception ex) { - plugin.getLogger().warning("LLM reply failed: " + ex.getMessage()); - return null; + 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); } - }).thenAccept(llmText -> Bukkit.getScheduler().runTask(plugin, () -> { - if (llmText != null && !llmText.isBlank()) { - feedbackOut[0] = llmText; - } - storeAndSend.run(); - })); + final String llmSystemPrompt = systemPrompt; + 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); + } catch (Exception ex) { + plugin.getLogger().warning("LLM reply failed: " + ex.getMessage()); + 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); + } + if (journeyTarget != null && journeyActions) { + plugin.getLogger().info( + "[OverworldAgent][Journey] LLM-triggered navigation for " + + player.getName() + + ": " + + 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(); + })); + }; + + if (journeyActions) { + loadGuidanceDestinations(player, startLlm); + } else { + startLlm.accept(List.of()); + } } 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 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/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/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/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/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/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 +) { +} 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/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/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..172f498 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -7,10 +7,28 @@ 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 -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 + +# 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. @@ -23,6 +41,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 @@ -39,7 +61,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 @@ -49,21 +74,17 @@ llm: include-extensions: - txt - md + - doc + - docx npc-context: enabled: true radius: 25.0 max-items: 3 -tags: - num_tags: - NoMoon: 25 - feedback: - holo_visible: false + # 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 - 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?" + max-destinations: 60 prompts: - label: 0 prompt: agent @@ -193,9 +214,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..8ca4f25 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -7,17 +7,12 @@ 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 ' - admintags: - description: Manage tags - usage: '/admintags' assess-habitat: description: command to assess habitat usage: '/assess-habitat' \ No newline at end of file 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